diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..1c94a8e7 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +.gradle +.idea +**/build/** +/qbittorrent/ +!/server/build/install/ +!/client-web/build/distributions/ +!/client-web-old/build/distributions/ \ No newline at end of file diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..fe71611e --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +* text=auto eol=lf +Dockerfile eol=lf +*.sh eol=lf diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 00000000..97a1fd05 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +github: [ drewcarlson ] \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..b5d07bae --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +.gradle/ +/.idea +!/.idea/codeStyles +out/ +build/ +.env +*.iml +*.ipr +*.iws +.DS_Store +qbittorrent/ +local.properties diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..f058d119 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,15 @@ +FROM openjdk:13-alpine + +COPY . /build-project +WORKDIR /build-project +RUN ./gradlew installShadowDist browserProductionWebpack --no-daemon + +FROM openjdk:13-alpine +RUN apk add --update \ + bash \ + ffmpeg \ + && rm -rf /var/cache/apk/* +WORKDIR /app +COPY --from=0 /build-project/server/build/install ./install +COPY --from=0 /build-project/client-web/build/distributions ./client-web +ENTRYPOINT ["./install/server-shadow/bin/server"] \ No newline at end of file diff --git a/Dockerfile-local b/Dockerfile-local new file mode 100644 index 00000000..05b84448 --- /dev/null +++ b/Dockerfile-local @@ -0,0 +1,9 @@ +FROM openjdk:13-alpine +RUN apk add --update \ + bash \ + ffmpeg \ + && rm -rf /var/cache/apk/* +WORKDIR /app +COPY server/build/install ./install +COPY client-web/build/distributions ./client-web +ENTRYPOINT ["./install/server-shadow/bin/server"] \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..be3f7b28 --- /dev/null +++ b/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 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 Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are 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. + + 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. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + 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 Affero 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. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + 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 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 work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero 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 Affero 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 Affero 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 Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero 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 Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + 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 AGPL, see +. diff --git a/README.md b/README.md new file mode 100644 index 00000000..b49311d0 --- /dev/null +++ b/README.md @@ -0,0 +1,91 @@ +AnyStream +=== + +![License](https://img.shields.io/github/license/drewcarlson/anystream) +![](https://img.shields.io/static/v1?label=status&message=wip&color=red) + +A self-hosted streaming service for your media collection. + +### Features + +- Track and organize your existing media library +- Stream to all your favorite devices +- Share your library with fine-grained permissions + +
+Screenshots + +![](media/screenshot-android-home.png) +![](media/screenshot-web-home.png) + +
+ +### Structure + +AnyStream consists of a self-hosted server instance and various client applications that connect to it. + +- [server](server) - Web server for managing and streaming media built with [Ktor](https://github.com/ktorio/ktor) +- [client](client) - Multiplatform infrastructure for AnyStream client applications built with [Mobius.kt](https://github.com/DrewCarlson/mobius.kt) +- [client-android](client-android) - Android client implementation built with [Jetpack Compose](https://developer.android.com/jetpack/compose) +- [client-web](client-web) - Web client implementation built with [Jetbrains Compose](https://github.com/JetBrains/compose-jb/) +- [data-models](data-models) - Data models shared between the server and clients +- [api-client](api-client) - Multiplatform API client for interacting with the server built with [Ktor-client](https://github.com/ktorio/ktor) + +### Development + +*Note: Because of the use of Jetpack Compose, only [Android Studio 2020.3.1 Arctic Fox](https://developer.android.com/studio/preview) is supported!* + +- Install [Intellij IDEA](https://www.jetbrains.com/idea/) (preferred) or [Android Studio](https://developer.android.com/studio/) +- Clone this repo `git clone https://github.com/DrewCarlson/AnyStream.git` +- Open the `AnyStream` folder in your IDE + +### Run locally + +1. Build server `./gradlew installShadowDist` + +2. Build client-web `./gradlew jsBrowserReleaseExecutableDistribution` + +3. Start docker stack `docker-compose up -d` + +The following services will be running: + +- anystream (app): http://localhost:8888 +- mongo: localhost:27017 +- mongo-express: http://localhost:8081 +- docker-qbittorrentvpn: http://localhost:9090 + + +### Web Client Development + +1. _(Follow Run Locally)_ + +2. Run the client-web webpack dev server: `./gradlew -t jsBrowserRun` + +The web client with live-reload is served from http://localhost:3000. +Webpack's dev server proxies API requests to `localhost:8888`. + + +### Server Development + +1. _(Follow Run Locally)_ + +2. Stop the AnyStream container: `docker-compose stop app` + +3. Run the server: `./gradlew -t run` + + +### Other useful tasks + +Build server bundle into `server/build/install/server-shadow`: +```bash +./gradlew installShadowDist +``` + +Build production client-web source into `client-web/build/distributions`: +```bash +./gradlew jsBrowserDistribution +``` + +### License + +This project is licensed under AGPL-3.0, found in [LICENSE](LICENSE). diff --git a/api-client/build.gradle.kts b/api-client/build.gradle.kts new file mode 100644 index 00000000..b0c43dc0 --- /dev/null +++ b/api-client/build.gradle.kts @@ -0,0 +1,76 @@ +plugins { + kotlin("multiplatform") + kotlin("plugin.serialization") +} + +kotlin { + jvm() + js(IR) { + browser { + testTask { + useKarma { + useChromeHeadless() + } + } + } + } + + sourceSets { + all { + languageSettings.apply { + useExperimentalAnnotation("kotlinx.coroutines.ExperimentalCoroutinesApi") + useExperimentalAnnotation("kotlinx.coroutines.FlowPreview") + } + } + val commonMain by getting { + kotlin.srcDirs("src") + dependencies { + implementation(projects.dataModels) + implementation(libs.coroutines.core) + implementation(libs.serialization.core) + implementation(libs.serialization.json) + + implementation(libs.ktor.client.core) + implementation(libs.ktor.client.json) + implementation(libs.ktor.client.serialization) + implementation(libs.korio) + } + } + val commonTest by getting { + kotlin.srcDirs("test") + dependencies { + implementation(kotlin("test-common")) + implementation(kotlin("test-annotations-common")) + } + } + + val jvmMain by getting { + dependencies { + implementation(project(":data-models")) + implementation(libs.ktor.client.okhttp) + } + } + + val jvmTest by getting { + kotlin.srcDirs("testJvm") + dependencies { + implementation(kotlin("test")) + implementation(kotlin("test-junit")) + } + } + + val jsMain by getting { + dependencies { + implementation(project(":data-models")) + implementation(libs.ktor.client.js) + } + } + + val jsTest by getting { + kotlin.srcDirs("testJs") + dependencies { + implementation(kotlin("test-js")) + } + } + } +} diff --git a/api-client/src/AnyStreamClient.kt b/api-client/src/AnyStreamClient.kt new file mode 100644 index 00000000..5f94fdd9 --- /dev/null +++ b/api-client/src/AnyStreamClient.kt @@ -0,0 +1,376 @@ +/** + * AnyStream + * Copyright (C) 2021 Drew Carlson + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package anystream.client + +import anystream.models.* +import anystream.models.api.* +import anystream.torrent.search.TorrentDescription2 +import com.soywiz.korio.net.ws.WebSocketClient +import drewcarlson.qbittorrent.models.GlobalTransferInfo +import drewcarlson.qbittorrent.models.Torrent +import drewcarlson.qbittorrent.models.TorrentFile +import io.ktor.client.HttpClient +import io.ktor.client.features.* +import io.ktor.client.features.cookies.* +import io.ktor.client.features.json.* +import io.ktor.client.features.json.serializer.KotlinxSerializer +import io.ktor.client.features.websocket.* +import io.ktor.client.request.* +import io.ktor.client.statement.* +import io.ktor.http.* +import io.ktor.http.HttpStatusCode.Companion.NotFound +import io.ktor.http.HttpStatusCode.Companion.Unauthorized +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.map +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + + +private const val PAGE = "page" +private const val QUERY = "query" + +private val json = Json { + isLenient = true + encodeDefaults = true + ignoreUnknownKeys = true + classDiscriminator = "__type" + allowStructuredMapKeys = true + useAlternativeNames = false +} + +private const val SESSION_HEADER = "as_user_session" + +class AnyStreamClient( + /** The AnyStream server URL, ex. `http://localhost:3000`. */ + private val serverUrl: String, + http: HttpClient = HttpClient(), + private val sessionManager: SessionManager = SessionManager(SessionDataStore) +) { + val authenticated: Flow = sessionManager.tokenFlow.map { it != null } + val permissions: Flow?> = sessionManager.permissionsFlow + val user: Flow = sessionManager.userFlow + + private val wsProto = if (serverUrl.startsWith("https")) "wss" else "ws" + private val wsServerUrl = "$wsProto://${serverUrl.substringAfter("://")}" + private val http = http.config { + install(HttpCookies) { + storage = AcceptAllCookiesStorage() + } + WebSockets { } + Json { + serializer = KotlinxSerializer(json) + } + defaultRequest { + url { + host = serverUrl.substringAfter("://").substringBefore(':') + port = serverUrl.substringAfterLast(':').takeWhile { it != '/' }.toIntOrNull() ?: 0 + protocol = URLProtocol.createOrDefault(serverUrl.substringBefore("://")) + } + } + install("TokenHandler") { + requestPipeline.intercept(HttpRequestPipeline.Before) { + sessionManager.fetchToken()?.let { token -> + context.header(SESSION_HEADER, token) + } + } + responsePipeline.intercept(HttpResponsePipeline.Receive) { + context.response.headers[SESSION_HEADER]?.let { token -> + if (token != sessionManager.fetchToken()) { + sessionManager.writeToken(token) + } + } + val sentToken = context.request.headers[SESSION_HEADER] + if (context.response.status == Unauthorized && sentToken != null) { + sessionManager.clear() + } + } + } + } + + fun isAuthenticated(): Boolean { + return sessionManager.fetchToken() != null + } + + fun userPermissions(): Set { + return sessionManager.fetchPermissions() ?: emptySet() + } + + fun authedUser(): User? { + return sessionManager.fetchUser() + } + + fun torrentListChanges(): Flow> = callbackFlow { + val client = wsClient("/api/ws/torrents/observe") + client.onStringMessage { msg -> + if (msg.isNotBlank()) { + trySend(msg.split(",")) + } + } + client.onError { close(it) } + client.onClose { close() } + awaitClose { client.close() } + } + + fun globalInfoChanges(): Flow = callbackFlow { + val client = wsClient("/api/ws/torrents/global") + client.onStringMessage { msg -> + if (msg.isNotBlank()) { + trySend(json.decodeFromString(msg)) + } + } + client.onError { close(it) } + client.onClose { close() } + awaitClose { client.close() } + } + + suspend fun getHomeData() = http.get("/api/home") + + suspend fun getMovies(page: Int = 1) = + http.get("/api/movies") { + pageParam(page) + } + + suspend fun getMovieSources(id: String) = + http.get>("/api/media/movies/$id/sources") + + suspend fun getTvShowSources(id: String) = + http.get>("/api/media/tv/$id/sources") + + suspend fun getTvShows(page: Int = 1) = + http.get>("/api/tv") { + pageParam(page) + } + + suspend fun getMovie(id: String) = + http.get("/api/movies/$id") + + suspend fun deleteMovie(id: String) = + http.delete("/api/movies/$id") + + suspend fun importMedia(importMedia: ImportMedia, importAll: Boolean) { + http.post("/api/media/import") { + contentType(ContentType.Application.Json) + parameter("importAll", importAll) + body = importMedia + } + } + + suspend fun unmappedMedia(importMedia: ImportMedia): List { + return http.post("/api/media/unmapped") { + contentType(ContentType.Application.Json) + body = importMedia + } + } + + suspend fun getTvShow(id: String) = + http.get("/api/tv/$id") + + suspend fun searchTmdbMovies(query: String, page: Int = 1) = + http.get("/api/movies/tmdb/search") { + parameter(QUERY, query) + pageParam(page) + } + + suspend fun getTmdbSources(tmdbId: Int) = + http.get>("/api/media/tmdb/$tmdbId/sources") + + suspend fun searchTmbdTvShows(query: String, page: Int = 1) = + http.get("/api/tv/tmdb/search") { + parameter(QUERY, query) + pageParam(page) + } + + suspend fun getTmdbPopularMovies(page: Int = 1) = + http.get("/api/movies/tmdb/popular") { + pageParam(page) + } + + suspend fun getTmdbPopularTvShows(page: Int = 1) = + http.get("/api/tv/tmdb/popular") { + pageParam(page) + } + + suspend fun addMovieFromTmdb(tmdbId: Int) = + http.get("/api/movies/tmdb/$tmdbId/add") + + suspend fun getGlobalTransferInfo() = + http.get("/api/torrents/global") + + suspend fun getTorrents() = + http.get>("/api/torrents") + + suspend fun getTorrentFiles(hash: String) = + http.get>("/api/torrents/$hash/files") + + suspend fun resumeTorrent(hash: String) = + http.get("/api/torrents/$hash/resume") + + suspend fun pauseTorrent(hash: String) = + http.get("/api/torrents/$hash/pause") + + suspend fun deleteTorrent(hash: String, deleteFiles: Boolean = false) = + http.delete("/api/torrents/$hash") { + parameter("deleteFiles", deleteFiles) + } + + suspend fun downloadTorrent(description: TorrentDescription2, movieId: String?) { + http.post("/api/torrents") { + contentType(ContentType.Application.Json) + body = description + + movieId?.let { parameter("movieId", it) } + } + } + + suspend fun playbackSession( + mediaRefId: String, + init: (state: PlaybackState) -> Unit + ): suspend (progress: Long) -> Unit { + var currentState: PlaybackState? = null + var open = false + val client = wsClient("/api/ws/stream/$mediaRefId/state") + client.send(sessionManager.fetchUser()!!.id) + client.onStringMessage { msg -> + currentState = json.decodeFromString(msg) + init(currentState!!) + open = true + } + client.onError { open = false } + client.onClose { open = false } + return { progress -> + if (open) { + currentState = currentState!!.copy( + position = progress + ) + client.send(json.encodeToString(currentState!!)) + } + } + } + + suspend fun createUser( + username: String, + password: String, + inviteCode: String?, + rememberUser: Boolean = true, + ) = http.post("/api/users") { + contentType(ContentType.Application.Json) + parameter("createSession", rememberUser) + body = CreateUserBody(username, password, inviteCode) + }.also { (success, _) -> + if (rememberUser && success != null) { + sessionManager.writeUser(success.user) + sessionManager.writePermissions(success.permissions) + } + } + + suspend fun getUser(id: String) = + http.get("/api/users/$id") + + suspend fun updateUser( + userId: String, + displayName: String, + password: String?, + currentPassword: String? + ) = http.put("/api/users/${userId}") { + contentType(ContentType.Application.Json) + body = UpdateUserBody( + displayName = displayName, + password = password, + currentPassword = currentPassword + ) + } + + suspend fun deleteUser(id: String) = + http.delete("/api/users/$id") + + suspend fun login(username: String, password: String, pairing: Boolean = false): CreateSessionResponse { + return http.post("/api/users/session") { + contentType(ContentType.Application.Json) + body = CreateSessionBody(username, password) + }.also { (success, _) -> + if (!pairing && success != null) { + sessionManager.writeUser(success.user) + sessionManager.writePermissions(success.permissions) + } + } + } + + suspend fun logout() { + val token = sessionManager.fetchToken() ?: return + sessionManager.clear() + http.delete("/api/users/session") { + header(SESSION_HEADER, token) + } + } + + suspend fun getInvites(): List = + http.get("/api/users/invite") + + suspend fun createInvite(permissions: Set): InviteCode { + return http.post("/api/users/invite") { + contentType(ContentType.Application.Json) + body = permissions + } + } + + suspend fun deleteInvite(id: String): Boolean { + return try { + http.delete("/api/users/invite/$id") + true + } catch (e: ClientRequestException) { + if (e.response.status == NotFound) false else throw e + } + } + + suspend fun createPairedSession(pairingCode: String, secret: String): CreateSessionResponse { + val response = http.post("/api/users/session/paired") { + parameter("pairingCode", pairingCode) + parameter("secret", secret) + } + + response.success?.run { + sessionManager.writeUser(user) + sessionManager.writePermissions(permissions) + } + + return response + } + + suspend fun createPairingSession(): Flow = callbackFlow { + val client = wsClient("/api/ws/users/pair") + client.onStringMessage { msg -> + if (msg.isNotBlank()) { + trySend(json.decodeFromString(msg)) + } + } + client.onError { close(it) } + client.onClose { close() } + awaitClose { client.close() } + } + + private suspend fun wsClient(path: String): WebSocketClient { + return WebSocketClient(url = "$wsServerUrl$path") + } +} + +fun HttpRequestBuilder.pageParam(page: Int) { + parameter(PAGE, page) +} diff --git a/api-client/src/SessionManager.kt b/api-client/src/SessionManager.kt new file mode 100644 index 00000000..7188f917 --- /dev/null +++ b/api-client/src/SessionManager.kt @@ -0,0 +1,120 @@ +/** + * AnyStream + * Copyright (C) 2021 Drew Carlson + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package anystream.client + +import anystream.models.User +import kotlinx.coroutines.channels.BufferOverflow.DROP_OLDEST +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlin.native.concurrent.SharedImmutable + +private const val STORAGE_KEY = "SESSION_TOKEN" +private const val PERMISSIONS_KEY = "PERMISSIONS_KEY" +private const val USER_KEY = "USER_KEY" + +@SharedImmutable +private val json = Json { + isLenient = true + encodeDefaults = true + ignoreUnknownKeys = true + useAlternativeNames = false +} + +interface SessionDataStore { + fun write(key: String, value: String) + fun read(key: String): String? + fun remove(key: String) + + companion object : SessionDataStore { + private val data = mutableMapOf() + + override fun read(key: String): String? = data[key] + + override fun write(key: String, value: String) { + data[key] = value + } + + override fun remove(key: String) { + data.remove(key) + } + } +} + +class SessionManager(private val dataStore: SessionDataStore) { + private val user = MutableSharedFlow(1, 0, DROP_OLDEST) + private val token = MutableSharedFlow(1, 0, DROP_OLDEST) + private val permissions = MutableSharedFlow?>(1, 0, DROP_OLDEST) + + val userFlow: Flow = user + val tokenFlow: Flow = token + val permissionsFlow: Flow?> = permissions + + init { + fetchUser() + fetchToken() + fetchPermissions() + } + + fun writeUser(user: User) { + dataStore.write(USER_KEY, json.encodeToString(user)) + this.user.tryEmit(user) + } + + fun writeToken(token: String) { + dataStore.write(STORAGE_KEY, token) + this.token.tryEmit(token) + } + + fun writePermissions(permissions: Set) { + dataStore.write(PERMISSIONS_KEY, permissions.joinToString(",")) + this.permissions.tryEmit(permissions) + } + + fun fetchUser(): User? { + return user.replayCache.singleOrNull() + ?: dataStore.read(USER_KEY)?.let { data -> + json.decodeFromString(data) + .also(user::tryEmit) + } + } + + fun fetchToken(): String? { + return token.replayCache.singleOrNull() + ?: dataStore.read(STORAGE_KEY).also(token::tryEmit) + } + + fun fetchPermissions(): Set? { + return permissions.replayCache.singleOrNull() + ?: dataStore.read(PERMISSIONS_KEY) + ?.split(",") + ?.toSet() + ?.also(permissions::tryEmit) + } + + fun clear() { + dataStore.remove(USER_KEY) + dataStore.remove(STORAGE_KEY) + dataStore.remove(PERMISSIONS_KEY) + user.tryEmit(null) + token.tryEmit(null) + permissions.tryEmit(null) + } +} diff --git a/api-client/test/AnyStreamClientTests.kt b/api-client/test/AnyStreamClientTests.kt new file mode 100644 index 00000000..d26ebc70 --- /dev/null +++ b/api-client/test/AnyStreamClientTests.kt @@ -0,0 +1,40 @@ +/** + * AnyStream + * Copyright (C) 2021 Drew Carlson + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package anystream.client + +import kotlin.test.Test +import kotlin.test.assertTrue + +class AnyStreamClientTests { + + @Test + fun testReturnsTmdbPopularMovies() = runTest { + val client = AnyStreamClient("") + + val movies = client.getTmdbPopularMovies() + assertTrue(movies.items.count() > 0) + } + + @Test + fun testReturnsTmdbPopularTvShows() = runTest { + val client = AnyStreamClient("") + + val shows = client.getTmdbPopularTvShows() + assertTrue(shows.items.count() > 0) + } +} diff --git a/api-client/test/runTest.kt b/api-client/test/runTest.kt new file mode 100644 index 00000000..814a0606 --- /dev/null +++ b/api-client/test/runTest.kt @@ -0,0 +1,20 @@ +/** + * AnyStream + * Copyright (C) 2021 Drew Carlson + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package anystream.client + +expect fun runTest(body: suspend () -> Unit) diff --git a/api-client/testJs/runTest.kt b/api-client/testJs/runTest.kt new file mode 100644 index 00000000..02cb0d85 --- /dev/null +++ b/api-client/testJs/runTest.kt @@ -0,0 +1,24 @@ +/** + * AnyStream + * Copyright (C) 2021 Drew Carlson + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package anystream.client + +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.promise + +actual fun runTest(body: suspend () -> Unit): dynamic = + GlobalScope.promise { body() } diff --git a/api-client/testJvm/runTest.kt b/api-client/testJvm/runTest.kt new file mode 100644 index 00000000..2ebc5947 --- /dev/null +++ b/api-client/testJvm/runTest.kt @@ -0,0 +1,23 @@ +/** + * AnyStream + * Copyright (C) 2021 Drew Carlson + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package anystream.client + +import kotlinx.coroutines.runBlocking + +actual fun runTest(body: suspend () -> Unit) = + runBlocking { body() } diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 00000000..39fddf68 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,28 @@ +plugins { + kotlin("multiplatform") version "1.5.10" apply false + kotlin("jvm") version "1.5.10" apply false + kotlin("plugin.serialization") version "1.5.10" apply false + id("org.jetbrains.compose") version "0.5.0-build225" apply false +} + +buildscript { + repositories { + gradlePluginPortal() + mavenCentral() + google() + } + dependencies { + classpath("com.android.tools.build:gradle:7.0.0-beta04") + } +} + +allprojects { + repositories { + mavenCentral() + jcenter() + google() + maven("https://maven.pkg.jetbrains.space/public/p/compose/dev") + maven("https://maven.pkg.jetbrains.space/kotlin/p/kotlin/kotlin-js-wrappers/") + } +} + diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts new file mode 100644 index 00000000..876c922b --- /dev/null +++ b/buildSrc/build.gradle.kts @@ -0,0 +1,7 @@ +plugins { + `kotlin-dsl` +} + +repositories { + mavenCentral() +} diff --git a/buildSrc/src/main/kotlin/hasAndroidSdk.kt b/buildSrc/src/main/kotlin/hasAndroidSdk.kt new file mode 100644 index 00000000..ed02bcad --- /dev/null +++ b/buildSrc/src/main/kotlin/hasAndroidSdk.kt @@ -0,0 +1,50 @@ +/** + * AnyStream + * Copyright (C) 2021 Drew Carlson + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import java.io.File +import org.gradle.api.Project + +private var sdkFound: Boolean? = null + +val Project.hasAndroidSdk: Boolean + get() { + if (sdkFound == null) { + val localProps = File(rootDir, "local.properties") + if (localProps.exists() && localProps.readText().contains("sdk.dir")) { + sdkFound = true + } else { + val trySdkDir = sequenceOf( + System.getProperty("user.home") + "/Library", + System.getenv("LOCALAPPDATA") + ).mapNotNull { File("$it/Android/sdk") } + .filter { it.exists() } + .firstOrNull() + sdkFound = if (trySdkDir?.exists() == true) { + if (!localProps.exists()) { + val pathString = trySdkDir.absolutePath + .replace("\\", "\\\\") + .replace(":", "\\:") + localProps.writeText("sdk.dir=$pathString") + } + true + } else { + false + } + } + } + return sdkFound!! + } diff --git a/client-android/DevSigningKey b/client-android/DevSigningKey new file mode 100644 index 00000000..37831a9d Binary files /dev/null and b/client-android/DevSigningKey differ diff --git a/client-android/build.gradle.kts b/client-android/build.gradle.kts new file mode 100644 index 00000000..27d0c993 --- /dev/null +++ b/client-android/build.gradle.kts @@ -0,0 +1,52 @@ +plugins { + id("com.android.application") + kotlin("android") +} + +android { + compileSdk = 30 + defaultConfig { + minSdk = 23 + targetSdk = 30 + } + buildFeatures { + compose = true + } + sourceSets { + named("main") { + java.srcDir("src/main/kotlin") + } + } + composeOptions { + kotlinCompilerExtensionVersion = libs.versions.compose.get() + } + signingConfigs { + named("debug") { + storeFile = file("DevSigningKey") + storePassword = "password" + keyAlias = "key0" + keyPassword = "password" + } + } + buildTypes { + named("debug") { + applicationIdSuffix = ".debug" + signingConfig = signingConfigs.findByName("debug") + } + } +} + +dependencies { + implementation(projects.client) + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.appcompat.core) + implementation(libs.androidx.leanback.core) + implementation(libs.androidx.activity.ktx) + implementation(libs.androidx.activity.compose) + implementation(libs.bundles.compose) + implementation(libs.accompanist.coil) + implementation(libs.bundles.exoplayer) + implementation(libs.zxing.core) + implementation(libs.quickie.bundled) + implementation(libs.anrWatchdog) +} diff --git a/client-android/src/main/AndroidManifest.xml b/client-android/src/main/AndroidManifest.xml new file mode 100644 index 00000000..e5d94a45 --- /dev/null +++ b/client-android/src/main/AndroidManifest.xml @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client-android/src/main/kotlin/App.kt b/client-android/src/main/kotlin/App.kt new file mode 100644 index 00000000..78adcd8c --- /dev/null +++ b/client-android/src/main/kotlin/App.kt @@ -0,0 +1,30 @@ +/** + * AnyStream + * Copyright (C) 2021 Drew Carlson + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package anystream.android + +import android.app.Application +import com.github.anrwatchdog.ANRWatchDog + + +class App : Application() { + + override fun onCreate() { + super.onCreate() + ANRWatchDog().start() + } +} diff --git a/client-android/src/main/kotlin/AppTheme.kt b/client-android/src/main/kotlin/AppTheme.kt new file mode 100644 index 00000000..71154e11 --- /dev/null +++ b/client-android/src/main/kotlin/AppTheme.kt @@ -0,0 +1,42 @@ +/** + * AnyStream + * Copyright (C) 2021 Drew Carlson + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package anystream.android + +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Typography +import androidx.compose.material.darkColors +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color + +@Composable +fun AppTheme(content: @Composable () -> Unit) { + MaterialTheme( + colors = AppColors, + content = content, + typography = AppTypography + ) +} + +val AppColors = darkColors( + primary = Color.Red, + primaryVariant = Color.Red +) + +val AppTypography = Typography( + +) \ No newline at end of file diff --git a/client-android/src/main/kotlin/MainActivity.kt b/client-android/src/main/kotlin/MainActivity.kt new file mode 100644 index 00000000..4621877d --- /dev/null +++ b/client-android/src/main/kotlin/MainActivity.kt @@ -0,0 +1,192 @@ +/** + * AnyStream + * Copyright (C) 2021 Drew Carlson + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package anystream.android + +import android.content.pm.PackageManager +import android.os.Bundle +import androidx.activity.compose.setContent +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.* +import androidx.compose.material.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ExitToApp +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.unit.dp +import androidx.core.content.edit +import anystream.android.router.* +import anystream.android.ui.* +import anystream.client.AnyStreamClient +import anystream.client.SessionManager +import anystream.core.AndroidSessionDataStore +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch + +private const val PREFS_NAME = "prefs" +private const val PREF_SERVER_URL = "serverUrl" + +class LeanbackActivity : MainActivity() +open class MainActivity : AppCompatActivity() { + private val backPressHandler = BackPressHandler() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val prefs = applicationContext.getSharedPreferences(PREFS_NAME, MODE_PRIVATE) + val sessionManager = SessionManager(AndroidSessionDataStore(prefs)) + setContent { + val scope = rememberCoroutineScope() + var client = remember { + if (prefs.contains(PREF_SERVER_URL)) { + val serverUrl = prefs.getString(PREF_SERVER_URL, null)!! + AnyStreamClient(serverUrl, sessionManager = sessionManager) + } else null + } + CompositionLocalProvider(LocalBackPressHandler provides backPressHandler) { + AppTheme { + BundleScope(savedInstanceState) { + val currentClient = client + val defaultRoute = when { + currentClient == null || !currentClient.isAuthenticated() -> + Routes.Login + else -> Routes.Home + } + Router(defaultRouting = defaultRoute) { stack -> + client?.authenticated + ?.onEach { authed -> + val isLoginRoute = stack.last() == Routes.Login + if (authed && isLoginRoute) { + stack.replace(Routes.Home) + } else if (!authed && !isLoginRoute) { + stack.replace(Routes.Login) + } + } + ?.launchIn(scope) + when (val route = stack.last()) { + Routes.Login -> LoginScreen( + sessionManager = sessionManager, + onLoginCompleted = { newClient, serverUrl -> + client = newClient + prefs.edit { putString(PREF_SERVER_URL, serverUrl) } + stack.replace(Routes.Home) + } + ) + Routes.Home -> HomeScreen( + client = client!!, + backStack = stack, + onMediaClick = { mediaRefId -> + if (mediaRefId != null) { + stack.push(Routes.Player(mediaRefId)) + } + }, + onViewMoviesClicked = { + stack.push(Routes.Movies) + } + ) + Routes.Movies -> MoviesScreen( + client = client!!, + onMediaClick = { mediaRefId -> + if (mediaRefId != null) { + stack.replace(Routes.Player(mediaRefId)) + } + }, + backStack = stack + ) + Routes.PairingScanner -> PairingScanner( + client = client!!, + backStack = stack + ) + is Routes.Player -> PlayerScreen( + client = client!!, + mediaRefId = route.mediaRefId + ) + } + } + } + } + } + } + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + outState.saveLocal() + } + + override fun onBackPressed() { + if (!backPressHandler.handle()) { + super.onBackPressed() + } + } +} + +@Composable +fun AppTopBar(client: AnyStreamClient?, backStack: BackStack?) { + TopAppBar { + val scope = rememberCoroutineScope() + Image( + imageVector = ImageVector.vectorResource(id = R.drawable.as_logo), + modifier = Modifier + .padding(all = 8.dp) + .size(width = 150.dp, height = 50.dp), + contentDescription = null + ) + + if (client != null) { + val authed by client.authenticated.collectAsState(initial = client.isAuthenticated()) + if (authed) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.End, + modifier = Modifier.fillMaxSize() + ) { + val packageManager = LocalContext.current.packageManager + val hasCamera = remember { + packageManager.hasSystemFeature(PackageManager.FEATURE_CAMERA_ANY) + } + if (hasCamera) { + IconButton( + onClick = { backStack?.push(Routes.PairingScanner) } + ) { + Icon( + imageVector = ImageVector.vectorResource(R.drawable.ic_qr_code_scanner), + contentDescription = "Pair a device." + ) + } + } + + IconButton(onClick = { + scope.launch { + client.logout() + } + }) { + Icon( + imageVector = Icons.Filled.ExitToApp, + contentDescription = "Sign out" + ) + } + } + } + } + } +} diff --git a/client-android/src/main/kotlin/Routes.kt b/client-android/src/main/kotlin/Routes.kt new file mode 100644 index 00000000..ce475ebb --- /dev/null +++ b/client-android/src/main/kotlin/Routes.kt @@ -0,0 +1,28 @@ +/** + * AnyStream + * Copyright (C) 2021 Drew Carlson + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package anystream.android + +sealed class Routes { + object Login : Routes() + object Home : Routes() + object PairingScanner : Routes() + object Movies : Routes() + data class Player( + val mediaRefId: String + ) : Routes() +} \ No newline at end of file diff --git a/client-android/src/main/kotlin/router/BackPressHandler.kt b/client-android/src/main/kotlin/router/BackPressHandler.kt new file mode 100644 index 00000000..898ef812 --- /dev/null +++ b/client-android/src/main/kotlin/router/BackPressHandler.kt @@ -0,0 +1,33 @@ +/** + * AnyStream + * Copyright (C) 2021 Drew Carlson + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package anystream.android.router + +import androidx.compose.runtime.ProvidableCompositionLocal +import androidx.compose.runtime.compositionLocalOf + +val LocalBackPressHandler: ProvidableCompositionLocal = + compositionLocalOf { throw IllegalStateException("backPressHandler is not initialized") } + +class BackPressHandler( + val id: String = "Root" +) { + var children = mutableListOf<() -> Boolean>() + + fun handle(): Boolean = + children.reversed().any { it() } +} \ No newline at end of file diff --git a/client-android/src/main/kotlin/router/BackStack.kt b/client-android/src/main/kotlin/router/BackStack.kt new file mode 100644 index 00000000..689bf41b --- /dev/null +++ b/client-android/src/main/kotlin/router/BackStack.kt @@ -0,0 +1,73 @@ +/** + * AnyStream + * Copyright (C) 2021 Drew Carlson + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package anystream.android.router + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue + + +class BackStack internal constructor( + initialElement: T, + private var onElementRemoved: ((Int) -> Unit) +) { + var elements by mutableStateOf(listOf(initialElement)) + private set + + val lastIndex: Int + get() = elements.lastIndex + + val size: Int + get() = elements.size + + fun last(): T = + elements.last() + + fun push(element: T) { + elements = elements.plus(element) + } + + fun pushAndDropNested(element: T) { + onElementRemoved.invoke(lastIndex) + push(element) + } + + fun pop(): Boolean = + // we won’t let the last item to be popped + if (size <= 1) false else { + onElementRemoved.invoke(lastIndex) + elements = ArrayList( + elements.subList(0, lastIndex) // exclusive + ) + true + } + + fun replace(element: T) { + onElementRemoved.invoke(lastIndex) + elements = elements + .subList(0, elements.lastIndex) // exclusive + .plus(element) + } + + fun newRoot(element: T) { + elements.indices.reversed().forEach { index -> + onElementRemoved.invoke(index) + } + elements = arrayListOf(element) + } +} \ No newline at end of file diff --git a/client-android/src/main/kotlin/router/Router.kt b/client-android/src/main/kotlin/router/Router.kt new file mode 100644 index 00000000..54cb0668 --- /dev/null +++ b/client-android/src/main/kotlin/router/Router.kt @@ -0,0 +1,96 @@ +/** + * AnyStream + * Copyright (C) 2021 Drew Carlson + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package anystream.android.router + +import androidx.compose.runtime.* + +private fun key(backStackIndex: Int) = + "K$backStackIndex" + +private val backStackMap: MutableMap> = + mutableMapOf() + +val LocalRouting: ProvidableCompositionLocal> = compositionLocalOf { + listOf() +} + +@Composable +inline fun Router( + defaultRouting: T, + noinline children: @Composable (BackStack) -> Unit +) { + Router(T::class.java.name, defaultRouting, children) +} + +@Composable +fun Router( + contextId: String, + defaultRouting: T, + children: @Composable (BackStack) -> Unit +) { + val route = LocalRouting.current + val routingFromAmbient = route.firstOrNull() as? T + val downStreamRoute = if (route.size > 1) route.takeLast(route.size - 1) else emptyList() + + val upstreamHandler = LocalBackPressHandler.current + val localHandler = remember { BackPressHandler("${upstreamHandler.id}.$contextId") } + val backStack = fetchBackStack(localHandler.id, defaultRouting, routingFromAmbient) + val handleBackPressHere: () -> Boolean = { localHandler.handle() || backStack.pop() } + + SideEffect { + upstreamHandler.children.add(handleBackPressHere) + } + DisposableEffect(Unit) { + onDispose { + upstreamHandler.children.remove(handleBackPressHere) + } + } + + @Composable + fun Observe(body: @Composable () -> Unit) = body() + + Observe { + // Not recomposing router on backstack operation + BundleScope(key(backStack.lastIndex), autoDispose = false) { + CompositionLocalProvider( + LocalBackPressHandler provides localHandler, + LocalRouting provides downStreamRoute + ) { + children(backStack) + } + } + } +} + +@Composable +private fun fetchBackStack(key: String, defaultElement: T, override: T?): BackStack { + val upstreamBundle = LocalSavedInstanceState.current + val onElementRemoved: (Int) -> Unit = { upstreamBundle.remove(key(it)) } + + @Suppress("UNCHECKED_CAST") + val existing = backStackMap[key] as BackStack? + @Suppress("UNCHECKED_CAST") + return when { + override != null -> BackStack(override as T, onElementRemoved) + existing != null -> existing + else -> BackStack(defaultElement, onElementRemoved) + }.also { + backStackMap[key] = it + } +} + diff --git a/client-android/src/main/kotlin/router/SavedInstanceState.kt b/client-android/src/main/kotlin/router/SavedInstanceState.kt new file mode 100644 index 00000000..19b34108 --- /dev/null +++ b/client-android/src/main/kotlin/router/SavedInstanceState.kt @@ -0,0 +1,91 @@ +/** + * AnyStream + * Copyright (C) 2021 Drew Carlson + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package anystream.android.router + +import android.os.Bundle +import androidx.compose.runtime.* + +private val rootSavedInstanceState = Bundle() + +val LocalSavedInstanceState: ProvidableCompositionLocal = compositionLocalOf { rootSavedInstanceState } + +internal const val BUNDLE_KEY = "LocalSavedInstanceState" + +fun Bundle.saveLocal() { + putBundle(BUNDLE_KEY, rootSavedInstanceState) +} + +@Composable +fun persistentInt(key: String, defaultValue: Int = 0): MutableState { + val bundle = LocalSavedInstanceState.current + + val state: MutableState = remember { + mutableStateOf( + bundle.getInt(key, defaultValue) + ) + } + + saveInt(key, state.value) + + return state +} + +@Composable +private fun saveInt(key: String, value: Int) { + val bundle = LocalSavedInstanceState.current + bundle.putInt(key, value) +} + + +@Composable +fun BundleScope( + savedInstanceState: Bundle?, + children: @Composable (bundle: Bundle) -> Unit +) { + BundleScope(BUNDLE_KEY, savedInstanceState ?: Bundle(), true, children) +} + +@Composable +fun BundleScope( + key: String, + children: @Composable (bundle: Bundle) -> Unit +) { + BundleScope(key, Bundle(), true, children) +} + +@Composable +fun BundleScope( + key: String, + defaultBundle: Bundle = Bundle(), + autoDispose: Boolean = true, + children: @Composable (Bundle) -> Unit +) { + val upstream = LocalSavedInstanceState.current + val downstream = upstream.getBundle(key) ?: defaultBundle + + SideEffect { + upstream.putBundle(key, downstream) + } + if (autoDispose) { + DisposableEffect(Unit) { onDispose { upstream.remove(key) } } + } + + CompositionLocalProvider(LocalSavedInstanceState provides downstream) { + children(downstream) + } +} \ No newline at end of file diff --git a/client-android/src/main/kotlin/ui/HomeScreen.kt b/client-android/src/main/kotlin/ui/HomeScreen.kt new file mode 100644 index 00000000..806d4b23 --- /dev/null +++ b/client-android/src/main/kotlin/ui/HomeScreen.kt @@ -0,0 +1,268 @@ +/** + * AnyStream + * Copyright (C) 2021 Drew Carlson + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package anystream.android.ui + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import anystream.android.AppTopBar +import anystream.android.AppTypography +import anystream.android.Routes +import anystream.android.router.BackStack +import anystream.client.AnyStreamClient +import anystream.models.* +import anystream.models.api.HomeResponse +import anystream.models.tmdb.PartialMovie +import com.google.accompanist.coil.rememberCoilPainter +import com.google.accompanist.imageloading.ImageLoadState + +private val CARD_SPACING = 12.dp + +@Composable +private fun RowSpace() = Spacer(modifier = Modifier.size(8.dp)) + +@Composable +fun HomeScreen( + client: AnyStreamClient, + backStack: BackStack, + onMediaClick: (mediaRefId: String?) -> Unit, + onViewMoviesClicked: () -> Unit +) { + Scaffold( + topBar = { AppTopBar(client = client, backStack = backStack) } + ) { + LazyColumn( + modifier = Modifier + .padding(horizontal = 8.dp) + ) { + item { + val homeData = produceState(null) { + value = client.getHomeData() + } + + homeData.value?.run { + Spacer(modifier = Modifier.size(4.dp)) + if (currentlyWatching.isNotEmpty()) { + RowTitle(text = "Continue Watching") + ContinueWatchingRow(currentlyWatching, onClick = onMediaClick) + RowSpace() + } + + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + RowTitle(text = "Recently Added Movies") + TextButton(onClick = onViewMoviesClicked) { + Text(text = "All Movies") + } + } + MovieRow(movies = recentlyAdded, onClick = onMediaClick) + RowSpace() + + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + RowTitle(text = "Recently Added TV") + TextButton(onClick = onViewMoviesClicked) { + Text(text = "All Shows") + } + } + TvRow(shows = recentlyAddedTv, onClick = onMediaClick) + RowSpace() + + RowTitle(text = "Popular Movies") + PartialMovieRow(movies = popularMovies, onClick = onMediaClick) + RowSpace() + } + } + } + } +} + +@Composable +private fun ContinueWatchingRow( + currentlyWatching: Map, + onClick: (mediaRefId: String) -> Unit, + modifier: Modifier = Modifier, +) { + LazyRow( + horizontalArrangement = Arrangement.spacedBy(CARD_SPACING), + modifier = modifier, + content = { + items(currentlyWatching.toList()) { (movie, playbackState) -> + WatchingCard(movie, playbackState, onClick) + } + } + ) +} + +@Composable +private fun WatchingCard( + movie: Movie, + playbackState: PlaybackState, + onClick: (mediaRefId: String) -> Unit, +) { + Card( + shape = RoundedCornerShape(2.dp), + modifier = Modifier + .width(256.dp) + .clickable(onClick = { onClick(playbackState.mediaReferenceId) }), + ) { + Column(modifier = Modifier.fillMaxSize()) { + val painter = rememberCoilPainter( + request = "https://image.tmdb.org/t/p/w300${movie.backdropPath}", + fadeIn = true, + ) + Box( + modifier = Modifier + .height(144.dp) + .fillMaxWidth() + ) { + Image( + painter = painter, + contentDescription = null, + modifier = Modifier.fillMaxSize() + ) + + when (painter.loadState) { + is ImageLoadState.Loading -> { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.DarkGray) + ) + } + is ImageLoadState.Error -> { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .fillMaxSize() + .background(Color.DarkGray) + ) { + Text("No Backdrop") + } + } + } + } + + LinearProgressIndicator( + progress = playbackState.position / (movie.runtime * 60f), + modifier = Modifier + .fillMaxWidth() + ) + + Box( + modifier = Modifier + .padding(all = 4.dp) + .fillMaxWidth() + ) { + Text( + text = movie.title, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + } +} + +@Composable +private fun MovieRow( + movies: Map, + onClick: (mediaRefId: String?) -> Unit +) { + LazyRow( + horizontalArrangement = Arrangement.spacedBy(CARD_SPACING), + content = { + items(movies.toList()) { (movie, mediaRef) -> + PosterCard( + title = movie.title, + imagePath = movie.posterPath, + onClick = { mediaRef?.run { onClick(id) } }, + ) + } + } + ) +} + +@Composable +private fun TvRow( + shows: List, + onClick: (mediaRefId: String?) -> Unit +) { + LazyRow( + horizontalArrangement = Arrangement.spacedBy(CARD_SPACING), + content = { + items(shows) { show -> + PosterCard( + title = show.name, + imagePath = show.posterPath, + onClick = { }, + ) + } + } + ) +} + +@Composable +private fun PartialMovieRow( + movies: Map, + onClick: (mediaRefId: String?) -> Unit +) { + LazyRow( + horizontalArrangement = Arrangement.spacedBy(CARD_SPACING), + content = { + items(movies.toList()) { (movie, mediaRef) -> + PosterCard( + title = movie.title, + imagePath = movie.posterPath, + onClick = { + mediaRef?.run { onClick(id) } + }, + ) + } + } + ) +} + +@Composable +private fun RowTitle(text: String) { + Text( + text = text, + fontSize = 24.sp, + style = AppTypography.h3, + modifier = Modifier.padding(vertical = 8.dp), + ) +} \ No newline at end of file diff --git a/client-android/src/main/kotlin/ui/LoginScreen.kt b/client-android/src/main/kotlin/ui/LoginScreen.kt new file mode 100644 index 00000000..b3d3148d --- /dev/null +++ b/client-android/src/main/kotlin/ui/LoginScreen.kt @@ -0,0 +1,251 @@ +/** + * AnyStream + * Copyright (C) 2021 Drew Carlson + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package anystream.android.ui + +import android.graphics.Bitmap +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.material.Button +import androidx.compose.material.OutlinedTextField +import androidx.compose.material.Scaffold +import androidx.compose.material.Text +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.unit.dp +import anystream.android.AppTopBar +import anystream.android.AppTypography +import anystream.client.AnyStreamClient +import anystream.client.SessionManager +import anystream.models.api.PairingMessage +import com.google.zxing.BarcodeFormat +import com.google.zxing.qrcode.QRCodeWriter +import io.ktor.client.features.* +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch + + +@Composable +fun LoginScreen( + sessionManager: SessionManager, + onLoginCompleted: (client: AnyStreamClient, serverUrl: String) -> Unit, + modifier: Modifier = Modifier +) { + Scaffold( + topBar = { AppTopBar(client = null, backStack = null) }, + modifier = modifier + ) { padding -> + FormBody(sessionManager, onLoginCompleted, padding) + } +} + + +@Composable +private fun FormBody( + sessionManager: SessionManager, + onLoginCompleted: (client: AnyStreamClient, serverUrl: String) -> Unit, + paddingValues: PaddingValues +) { + val scope = rememberCoroutineScope() + var serverUrl by remember { mutableStateOf(TextFieldValue("https://anystream.dev")) } + var username by remember { mutableStateOf(TextFieldValue()) } + var password by remember { mutableStateOf(TextFieldValue()) } + var errorMessage by rememberSaveable{ mutableStateOf(null) } + val showStacked = LocalConfiguration.current.screenWidthDp < 800 + StackedOrSideBySide(stacked = showStacked) { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterVertically), + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + ) { + OutlinedTextField( + value = serverUrl, + placeholder = { Text(text = "Server Url") }, + onValueChange = { serverUrl = it }, + singleLine = true, + ) + OutlinedTextField( + value = username, + placeholder = { Text(text = "Username") }, + onValueChange = { username = it }, + singleLine = true, + ) + OutlinedTextField( + value = password, + placeholder = { Text(text = "Password") }, + visualTransformation = PasswordVisualTransformation(), + onValueChange = { password = it }, + singleLine = true, + ) + + errorMessage?.let { error -> + Text(text = error) + } + + Button(onClick = { + scope.launch { + try { + val client = AnyStreamClient( + serverUrl = serverUrl.text, + sessionManager = sessionManager + ) + client.login(username.text, password.text) + onLoginCompleted(client, serverUrl.text) + } catch (e: ClientRequestException) { + e.printStackTrace() + errorMessage = "${e.response.status}" + } + } + }) { + Text(text = "Submit") + } + } + + if (serverUrl.text.run { contains("://") && contains(".") }) { + DisplayPairingCode(serverUrl.text, sessionManager, onLoginCompleted) + } + } +} + +@Composable +private fun StackedOrSideBySide( + stacked: Boolean, + modifier: Modifier = Modifier, + body: @Composable () -> Unit +) { + if (stacked) { + LazyColumn( + verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterVertically), + horizontalAlignment = Alignment.CenterHorizontally, + modifier = modifier + .fillMaxSize() + ) { + item { body() } + } + } else { + LazyRow( + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically, + modifier = modifier + .fillMaxSize() + ) { + item { body() } + } + } +} + +@Composable +private fun DisplayPairingCode( + serverUrl: String, + sessionManager: SessionManager, + onLoginCompleted: (client: AnyStreamClient, serverUrl: String) -> Unit, +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + val client = AnyStreamClient( + serverUrl = serverUrl, + sessionManager = sessionManager + ) + val pairingCode by produceState(null) { + while (value == null) { + client.createPairingSession().collect { message -> + when (message) { + is PairingMessage.Started -> { + value = message.pairingCode + } + is PairingMessage.Authorized -> { + try { + client.createPairedSession(value!!, message.secret) + onLoginCompleted( + AnyStreamClient( + serverUrl, + sessionManager = sessionManager + ), + serverUrl + ) + } catch (e: ClientRequestException) { + e.printStackTrace() + } + } + is PairingMessage.Failed -> { + value = null + } + } + } + } + } + + if (pairingCode != null) { + Text( + text = "Pairing Code", + style = AppTypography.subtitle1 + ) + Text( + text = "Scan with another device to login.", + style = AppTypography.subtitle2 + ) + QrImage(content = pairingCode!!) + } + } +} + +@Composable +fun QrImage( + content: String +) { + val bitmap by produceState(null) { + val size = 500 + val bitMatrix = QRCodeWriter() + .encode(content, BarcodeFormat.QR_CODE, size, size) + val w: Int = bitMatrix.width + val h: Int = bitMatrix.height + val pixels = IntArray(w * h) + for (y in 0 until h) { + val offset = y * w + for (x in 0 until w) { + pixels[offset + x] = if (bitMatrix.get(x, y)) { + 0xFF000000 + } else { + 0xFFFFFFFF + }.toInt() + } + } + value = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888).apply { + setPixels(pixels, 0, size, 0, 0, w, h) + } + } + + bitmap?.run { + Image( + bitmap = asImageBitmap(), + modifier = Modifier.size(250.dp), + contentDescription = null + ) + } +} \ No newline at end of file diff --git a/client-android/src/main/kotlin/ui/MoviesScreen.kt b/client-android/src/main/kotlin/ui/MoviesScreen.kt new file mode 100644 index 00000000..52836f59 --- /dev/null +++ b/client-android/src/main/kotlin/ui/MoviesScreen.kt @@ -0,0 +1,107 @@ +/** + * AnyStream + * Copyright (C) 2021 Drew Carlson + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package anystream.android.ui + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.* +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.unit.dp +import anystream.android.AppTopBar +import anystream.android.Routes +import anystream.android.router.BackStack +import anystream.client.AnyStreamClient +import anystream.models.MediaReference +import anystream.models.Movie +import anystream.models.api.MoviesResponse + +@Composable +fun MoviesScreen( + client: AnyStreamClient, + onMediaClick: (mediaRefId: String?) -> Unit, + backStack: BackStack +) { + Scaffold( + topBar = { AppTopBar(client = client, backStack = backStack) } + ) { padding -> + val response = produceState(null) { + value = client.getMovies() + } + if (response.value == null) { + LoadingScreen(padding) + } else { + MovieGrid( + movies = response.value!!.movies, + mediaReferences = response.value!!.mediaReferences, + onMediaClick = onMediaClick, + paddingValues = padding + ) + } + } +} + +@Composable +private fun LoadingScreen(paddingValues: PaddingValues) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .padding(paddingValues) + .fillMaxSize() + ) { + CircularProgressIndicator() + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun MovieGrid( + movies: List, + mediaReferences: List, + onMediaClick: (mediaRefId: String?) -> Unit, + paddingValues: PaddingValues, +) { + val cardWidth = (LocalConfiguration.current.screenWidthDp / 3).coerceAtMost(130).dp + LazyVerticalGrid( + cells = GridCells.Adaptive(cardWidth), + modifier = Modifier + .padding(paddingValues) + .fillMaxSize(), + contentPadding = PaddingValues(all = 8.dp) + ) { + items(movies) { movie -> + val mediaRef = remember { + mediaReferences.find { it.contentId == movie.id } + } + PosterCard( + title = movie.title, + imagePath = movie.posterPath, + onClick = { onMediaClick(mediaRef?.id) }, + preferredWidth = cardWidth, + modifier = Modifier + .padding(all = 8.dp) + ) + } + } +} \ No newline at end of file diff --git a/client-android/src/main/kotlin/ui/PairingScanner.kt b/client-android/src/main/kotlin/ui/PairingScanner.kt new file mode 100644 index 00000000..54f87ced --- /dev/null +++ b/client-android/src/main/kotlin/ui/PairingScanner.kt @@ -0,0 +1,78 @@ +/** + * AnyStream + * Copyright (C) 2021 Drew Carlson + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package anystream.android.ui + +import android.Manifest +import android.content.pm.PackageManager +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts.RequestPermission +import androidx.compose.runtime.* +import androidx.compose.ui.platform.LocalContext +import anystream.android.Routes +import anystream.android.router.BackStack +import anystream.client.AnyStreamClient +import io.github.g00fy2.quickie.QRResult +import io.github.g00fy2.quickie.ScanQRCode +import io.ktor.client.features.* +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch + +@Composable +fun PairingScanner( + client: AnyStreamClient, + backStack: BackStack, +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + var launched = remember { false } + val hasPermission = remember { + mutableStateOf(context.checkSelfPermission(Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) + } + val scanRequest = rememberLauncherForActivityResult(ScanQRCode()) { result -> + scope.launch(Dispatchers.Default) { + if (result is QRResult.QRSuccess) { + val user = client.user.filterNotNull().first() + try { + client.login(user.username, result.content.rawValue, pairing = true) + } catch (e: ClientRequestException) { + e.printStackTrace() + } + } + backStack.pop() + } + } + val permissionLauncher = rememberLauncherForActivityResult(RequestPermission()) { granted -> + hasPermission.value = granted + if (!granted) { + backStack.pop() + } + } + + LaunchedEffect(hasPermission.value) { + if (!launched) { + if (hasPermission.value) { + scanRequest.launch(null) + } else { + permissionLauncher.launch(Manifest.permission.CAMERA) + } + launched = true + } + } +} \ No newline at end of file diff --git a/client-android/src/main/kotlin/ui/PlayerScreen.kt b/client-android/src/main/kotlin/ui/PlayerScreen.kt new file mode 100644 index 00000000..528fc021 --- /dev/null +++ b/client-android/src/main/kotlin/ui/PlayerScreen.kt @@ -0,0 +1,154 @@ +/** + * AnyStream + * Copyright (C) 2021 Drew Carlson + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package anystream.android.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material.Scaffold +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.viewinterop.AndroidView +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleObserver +import androidx.lifecycle.OnLifecycleEvent +import anystream.client.AnyStreamClient +import anystream.models.MediaReference +import anystream.models.Movie +import com.google.android.exoplayer2.MediaItem +import com.google.android.exoplayer2.Player +import com.google.android.exoplayer2.SimpleExoPlayer +import com.google.android.exoplayer2.ui.PlayerView +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + + +private const val PLAYER_STATE_UPDATE_INTERVAL = 250L +private const val PLAYER_STATE_REMOTE_UPDATE_INTERVAL = 5_000L + +@Composable +fun PlayerScreen( + client: AnyStreamClient, + mediaRefId: String, + modifier: Modifier = Modifier, + mediaReference: MediaReference? = null, + movie: Movie? = null, +) { + val scope = rememberCoroutineScope() + val context = LocalContext.current + val lifecycle = LocalLifecycleOwner.current.lifecycle + + var autoPlay by rememberSaveable { mutableStateOf(true) } + var window by rememberSaveable { mutableStateOf(0) } + var position by rememberSaveable { mutableStateOf(0L) } + + val player = remember { + SimpleExoPlayer.Builder(context).build().apply { + var updateStateJob: Job? = null + addListener(object : Player.EventListener { + override fun onPlaybackStateChanged(state: Int) { + updateStateJob?.cancel() + updateStateJob = when (state) { + Player.STATE_READY -> { + scope.launch { + while (true) { + window = currentWindowIndex + position = contentPosition.coerceAtLeast(0L) + delay(PLAYER_STATE_UPDATE_INTERVAL) + } + } + } + else -> null + } + } + }) + playWhenReady = autoPlay + setMediaItem(MediaItem.fromUri("https://anystream.dev/api/stream/$mediaRefId/direct")) + prepare() + seekTo(window, position) + } + } + + fun updateState() { + autoPlay = player.playWhenReady + window = player.currentWindowIndex + position = player.contentPosition.coerceAtLeast(0L) + } + + val playerView = remember { + PlayerView(context).apply { + lifecycle.addObserver(object : LifecycleObserver { + @OnLifecycleEvent(Lifecycle.Event.ON_START) + fun onStart() { + onResume() + player.playWhenReady = autoPlay + } + + @OnLifecycleEvent(Lifecycle.Event.ON_STOP) + fun onStop() { + updateState() + onPause() + player.playWhenReady = false + } + }) + } + } + + DisposableEffect(Unit) { + onDispose { + updateState() + player.release() + } + } + + LaunchedEffect(mediaRefId) { + val updateProgress = client.playbackSession(mediaRefId) { initialState -> + player.seekTo(initialState.position * 1000) + } + + while (true) { + if (player.playWhenReady && player.playbackState == Player.STATE_READY) { + updateProgress((player.currentPosition / 1000).coerceAtLeast(0L)) + } + delay(PLAYER_STATE_REMOTE_UPDATE_INTERVAL) + } + } + + Scaffold { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .background(Color.Black) + .fillMaxSize() + ) { + AndroidView( + factory = { playerView }, + modifier = Modifier + .fillMaxSize() + ) { + playerView.player = player + } + } + } +} \ No newline at end of file diff --git a/client-android/src/main/kotlin/ui/PosterCard.kt b/client-android/src/main/kotlin/ui/PosterCard.kt new file mode 100644 index 00000000..ae17d678 --- /dev/null +++ b/client-android/src/main/kotlin/ui/PosterCard.kt @@ -0,0 +1,124 @@ +/** + * AnyStream + * Copyright (C) 2021 Drew Carlson + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package anystream.android.ui + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Card +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.google.accompanist.coil.rememberCoilPainter +import com.google.accompanist.imageloading.ImageLoadState + +@Composable +fun PosterCard( + title: String, + imagePath: String?, + onClick: () -> Unit, + modifier: Modifier = Modifier, + preferredWidth: Dp = 130.dp +) { + Card( + shape = RoundedCornerShape(size = 2.dp), + modifier = modifier + .clickable(onClick = onClick) + ) { + Column( + verticalArrangement = Arrangement.spacedBy(2.dp), + modifier = Modifier + .width(preferredWidth) + ) { + Surface( + shape = RoundedCornerShape( + bottomStart = 2.dp, + bottomEnd = 2.dp + ), + modifier = Modifier + .aspectRatio(ratio = 0.69f) + ) { + val painter = rememberCoilPainter( + request = "https://image.tmdb.org/t/p/w200$imagePath", + fadeIn = true, + ) + Box( + modifier = Modifier.fillMaxSize() + ) { + Image( + painter = painter, + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop, + alignment = Alignment.Center, + ) + + when (painter.loadState) { + is ImageLoadState.Loading -> { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.DarkGray) + ) + } + is ImageLoadState.Error -> { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .fillMaxSize() + .background(Color.DarkGray) + ) { + Text("No Backdrop") + } + } + } + } + } + + Box(modifier = Modifier.padding(all = 4.dp)) { + Text( + text = title, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .fillMaxWidth() + ) + } + } + } +} + +@Preview(name = "Movie Card") +@Composable +private fun MovieCardPreview() { + PosterCard( + title = "Turbo: A Power Rangers Movie", + imagePath = "/sHgjdRPYduUCaw3Te2CXaWLpBkm.jpg", + onClick = { } + ) +} \ No newline at end of file diff --git a/client-android/src/main/res/drawable/as_logo.xml b/client-android/src/main/res/drawable/as_logo.xml new file mode 100644 index 00000000..414f0804 --- /dev/null +++ b/client-android/src/main/res/drawable/as_logo.xml @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/client-android/src/main/res/drawable/ic_qr_code_scanner.xml b/client-android/src/main/res/drawable/ic_qr_code_scanner.xml new file mode 100644 index 00000000..597e8d7b --- /dev/null +++ b/client-android/src/main/res/drawable/ic_qr_code_scanner.xml @@ -0,0 +1,10 @@ + + + diff --git a/client-web-old/build.gradle.kts b/client-web-old/build.gradle.kts new file mode 100644 index 00000000..b9eb22a6 --- /dev/null +++ b/client-web-old/build.gradle.kts @@ -0,0 +1,72 @@ +import org.jetbrains.kotlin.gradle.targets.js.webpack.KotlinWebpackConfig + +plugins { + kotlin("js") + kotlin("plugin.serialization") +} + +kotlin { + js(IR) { + browser { + binaries.executable() + + runTask { + outputFileName = "main.bundle.js" + devtool = "eval-cheap-module-source-map" + devServer = KotlinWebpackConfig.DevServer( + open = false, + port = 3000, + proxy = mutableMapOf( + "/api/*" to mapOf( + "target" to "http://localhost:8888", + "ws" to true + ) + ), + static = mutableListOf("$buildDir/processedResources/js/main") + ) + } + webpackTask { + outputFileName = "main.bundle.js" + devtool = "eval-cheap-module-source-map" + } + + testTask { + useKarma { + useChromeHeadless() + } + } + } + binaries.executable() + } + + sourceSets["main"].apply { + dependencies { + implementation(projects.client) + implementation(libs.coroutines.core) + implementation(libs.serialization.json) + implementation(libs.ktor.client.js) + + implementation(libs.kvision.core) + implementation(libs.kvision.routing.navigo) + implementation(libs.kvision.bootstrap.core) + //implementation("io.kvision:kvision-bootstrap-css:$KVISION_VERSION") + //implementation("io.kvision:kvision-bootstrap-datetime:$KVISION_VERSION") + implementation(libs.kvision.bootstrap.select) + //implementation("io.kvision:kvision-bootstrap-spinner:$KVISION_VERSION") + //implementation("io.kvision:kvision-bootstrap-upload:$KVISION_VERSION") + //implementation("io.kvision:kvision-bootstrap-dialog:$KVISION_VERSION") + implementation(libs.kvision.fontawesome) + //implementation("io.kvision:kvision-richtext:$KVISION_VERSION") + //implementation("io.kvision:kvision-handlebars:$KVISION_VERSION") + //implementation("io.kvision:kvision-tabulator:$KVISION_VERSION") + //implementation("io.kvision:kvision-pace:$KVISION_VERSION") + //implementation("io.kvision:kvision-moment:$KVISION_VERSION") + implementation(libs.kvision.datacontainer) + implementation(libs.kvision.toast) + implementation(libs.kvision.eventFlow) + implementation(npm("jstree", "3.3.10")) + implementation(devNpm("file-loader", "6.2.0")) + implementation(devNpm("url-loader", "4.1.1")) + } + } +} diff --git a/client-web-old/src/main/kotlin/DownloadsPage.kt b/client-web-old/src/main/kotlin/DownloadsPage.kt new file mode 100644 index 00000000..99464034 --- /dev/null +++ b/client-web-old/src/main/kotlin/DownloadsPage.kt @@ -0,0 +1,226 @@ +/** + * AnyStream + * Copyright (C) 2021 Drew Carlson + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package anystream.frontend + +import anystream.client.AnyStreamClient +import drewcarlson.qbittorrent.models.Torrent +import drewcarlson.qbittorrent.models.Torrent.State.* +import drewcarlson.qbittorrent.models.ConnectionStatus +import drewcarlson.qbittorrent.models.GlobalTransferInfo +import kotlinx.coroutines.* +import kotlinx.coroutines.Dispatchers.Default +import kotlinx.coroutines.flow.* +import io.kvision.core.AlignItems +import io.kvision.core.Container +import io.kvision.core.Widget +import io.kvision.data.dataContainer +import io.kvision.dropdown.cmLink +import io.kvision.dropdown.contextMenu +import io.kvision.form.check.CheckBox +import io.kvision.html.* +import io.kvision.modal.Modal +import io.kvision.panel.* +import io.kvision.state.observableListOf +import io.kvision.state.observableState +import io.kvision.utils.px +import kotlin.math.roundToInt + + +class DownloadsPage( + private val client: AnyStreamClient +) : VPanel(), CoroutineScope { + + override val coroutineContext = Default + SupervisorJob() + private val scope: CoroutineScope = this + private val torrentsObservable = observableListOf() + private val info = client.globalInfoChanges() + .transform { emit(it as GlobalTransferInfo?) } + .onStart { + emit(null) + emit(client.getGlobalTransferInfo()) + } + .shareIn(scope, SharingStarted.WhileSubscribed()) + + init { + observeTorrents() + addGlobalStats() + dataContainer(torrentsObservable, { torrent, _, _ -> + createTorrentRow(torrent) + }) + } + + private fun observeTorrents() { + client.torrentListChanges() + .debounce(2000) + .onStart { emit(emptyList()) } + .mapLatest { client.getTorrents() } + .onEach { torrents -> + torrentsObservable.clear() + torrentsObservable.addAll(torrents) + } + .launchIn(scope) + } + + private fun Container.addGlobalStats(): HPanel { + return hPanel( + info.observableState, + spacing = 8 + ) { state -> + if (state == null) { + div(classes = setOf("spinner-grow", "text-primary")) { + span(className = "visually-hidden") + } + } else { + icon( + when (state.connectionStatus) { + ConnectionStatus.CONNECTED -> "fas fa-wifi" + ConnectionStatus.FIREWALLED -> "fas fa-shield-alt" + ConnectionStatus.DISCONNECTED -> "fas fa-plug" + } + ) { + title = state.connectionStatus.name + } + label("Upload: ${state.upInfoSpeed}") + label("Download: ${state.dlInfoSpeed}") + } + } + } + + private fun Container.createTorrentRow(torrent: Torrent): HPanel { + return hPanel( + spacing = 8, + alignItems = AlignItems.CENTER + ) { + createTorrentContextMenu(this, torrent) + button("", "fas fa-trash", style = ButtonStyle.DANGER) { + size = ButtonSize.SMALL + onClick { showDeleteTorrent(torrent) } + } + button("", "fas fa-pause", style = ButtonStyle.SECONDARY) { + size = ButtonSize.SMALL + onClick { + scope.launch { + client.pauseTorrent(torrent.hash) + } + } + } + button("", "fas fa-play", style = ButtonStyle.PRIMARY) { + size = ButtonSize.SMALL + onClick { + scope.launch { + client.resumeTorrent(torrent.hash) + } + } + } + button("", "fas fa-tv", style = ButtonStyle.INFO) { + size = ButtonSize.SMALL + onClick { + //Router.navigate("/play/${torrent.hash}") + } + } + label("${(torrent.progress * 100).roundToInt()}%") + label(torrent.name) { + icon(stateIcon(torrent)) { + title = torrent.state.name + padding = 4.px + } + } + } + } + + private fun createTorrentContextMenu(widget: Widget, torrent: Torrent) { + widget.contextMenu { + header(align = Align.LEFT) { + marginLeft = 8.px + marginRight = 8.px + content = if (torrent.name.length <= 20) { + torrent.name + } else { + "${torrent.name.take(20)}..." + } + } + cmLink( + label = "Files", + icon = "fas fa-folder-open" + ) { + onClick { showTorrentFiles(torrent) } + } + } + } + + private fun showTorrentFiles(torrent: Torrent) { + scope.launch { + val files = client.getTorrentFiles(torrent.hash) + val modal = Modal("Files for ${torrent.name}") + modal.add(VPanel() { + ul { + files.forEach { file -> + li { + label("Name: ${file.name}") + label("Size: ${file.size}") + label("Progress: ${file.progress}") + } + } + } + }) + modal.show() + } + } + + private fun showDeleteTorrent(torrent: Torrent) { + val deleteFilesBox = CheckBox(false, label = "Delete Files") + val modal = Modal("Confirm Delete") + modal.add(Label("Are you sure you would like to delete \"${torrent.name}\"?")) + modal.add(deleteFilesBox) + modal.addButton(Button("Cancel", style = ButtonStyle.SECONDARY).apply { + onClick { modal.hide() } + }) + modal.addButton(Button("Confirm", style = ButtonStyle.DANGER).apply { + onClick { + modal.hide() + scope.launch { + client.deleteTorrent(torrent.hash, deleteFiles = deleteFilesBox.value) + } + } + }) + modal.show() + } + + private fun stateIcon(torrent: Torrent): String { + return when (torrent.state) { + PAUSED_DL, + QUEUED_UP -> "fas fa-pause" + STALLED_DL -> "fas fa-binoculars" + CHECKING_UP, + STALLED_UP, + FORCED_UP, + ALLOCATING, + CHECKING_DL, + META_DL, + FORCED_DL, + CHECKING_RESUME_DATA, + DOWNLOADING -> "fas fa-play" + UPLOADING -> "fas fa-upload" + MOVING -> "fas fa-file-import" + MISSING_FILES, + ERROR, + UNKNOWN -> "fas fa-exclamation-triangle" + PAUSED_UP -> "fas fa-check" + } + } +} diff --git a/client-web-old/src/main/kotlin/LoginPage.kt b/client-web-old/src/main/kotlin/LoginPage.kt new file mode 100644 index 00000000..3858b34f --- /dev/null +++ b/client-web-old/src/main/kotlin/LoginPage.kt @@ -0,0 +1,108 @@ +/** + * AnyStream + * Copyright (C) 2021 Drew Carlson + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package anystream.frontend + +import anystream.client.AnyStreamClient +import anystream.models.api.CreateSessionError.* +import io.ktor.client.features.* +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers.Default +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import io.kvision.core.AlignItems +import io.kvision.core.JustifyContent +import io.kvision.form.text.TextInputType +import io.kvision.form.text.textInput +import io.kvision.html.* +import io.kvision.panel.VPanel +import io.kvision.routing.routing +import io.kvision.toast.Toast + +class LoginPage( + private val client: AnyStreamClient +) : VPanel( + justify = JustifyContent.CENTER, + alignItems = AlignItems.CENTER, +), CoroutineScope { + + override val coroutineContext = Default + SupervisorJob() + + private val authMutex = Mutex() + + init { + h3("Login") + + val username = textInput { + placeholder = "Username" + } + + val password = textInput(type = TextInputType.PASSWORD) { + placeholder = "Password" + } + + val error = label() + + button("Confirm") { + onClick { + val user = username.value ?: "" + val pass = password.value ?: "" + attemptLogin(this, error, user, pass) + } + } + + link("Go to Signup") { + setStyle("cursor", "pointer") + onClick { + routing.navigate("/signup") + } + } + } + + private fun attemptLogin(button: Button, errorLabel: Label, username: String, password: String) { + if (authMutex.isLocked) return + button.disabled = true + fun unlockWithError(error: String = "Login failed!") { + Toast.error(error) + button.disabled = false + } + launch { + authMutex.withLock { + try { + val (success, error) = client.login(username, password) + when { + success != null -> routing.navigate("/") + error != null -> { + errorLabel.content = when (error) { + USERNAME_NOT_FOUND -> "Unknown username" + USERNAME_INVALID -> "Not a valid username" + PASSWORD_INCORRECT -> "Incorrect password" + PASSWORD_INVALID -> "Not a valid password" + } + unlockWithError() + } + } + } catch (e: ClientRequestException) { + e.printStackTrace() + unlockWithError(e.message ?: "") + } + } + } + } +} diff --git a/client-web-old/src/main/kotlin/MovieCard.kt b/client-web-old/src/main/kotlin/MovieCard.kt new file mode 100644 index 00000000..95fd276f --- /dev/null +++ b/client-web-old/src/main/kotlin/MovieCard.kt @@ -0,0 +1,145 @@ +/** + * AnyStream + * Copyright (C) 2021 Drew Carlson + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package anystream.frontend + +import io.kvision.core.* +import io.kvision.html.* +import io.kvision.panel.* +import io.kvision.utils.auto +import io.kvision.utils.pt +import io.kvision.utils.px + +class MovieCard( + title: String, + posterPath: String?, + overview: String, + releaseDate: String?, + isAdded: Boolean, + onPlayClicked: () -> Unit, + onBodyClicked: () -> Unit +) : VPanel(classes = setOf("p-3")) { + + init { + div(classes = setOf("card", "movie-card")) { + onClick { onBodyClicked() } + val overlayDiv = div( + classes = setOf("rounded", "h-100", "w-100") + ) { + setStyle("cursor", "pointer") + position = Position.ABSOLUTE + zIndex = 1 + visible = false + + flexPanel( + justify = JustifyContent.SPACEBETWEEN, + alignItems = AlignItems.CENTER, + direction = FlexDirection.COLUMN, + classes = setOf("rounded", "border", "h-100", "w-100", "p-3") + ) { + zIndex = 3 + position = Position.ABSOLUTE + addCssClass("border-white") + + options(alignSelf = AlignItems.FLEXEND) { + icon("fas fa-ellipsis-v") { + color = Color.name(Col.WHITE) + } + } + + div { + icon("fas fa-play-circle") { + margin = auto + fontSize = 36.px + color = Color.name(Col.WHITE) + } + onClick { onPlayClicked() } + } + + options(alignSelf = AlignItems.FLEXEND) { + icon(if (isAdded) "fas fa-check" else "fas fa-plus") { + color = Color.name(Col.WHITE) + } + } + } + + div(classes = setOf("rounded", "h-100", "w-100")) { + background = Background(Color.name(Col.BLACK)) + position = Position.ABSOLUTE + zIndex = 2 + opacity = .45 + } + } + image( + src = "https://image.tmdb.org/t/p/w200${posterPath}", + classes = setOf("rounded") + ) { + setAttribute("loading", "lazy") + height = 300.px + width = 200.px + } + onEvent { + mouseenter = { _ -> overlayDiv.fadeIn(150) } + mouseleave = { _ -> overlayDiv.fadeOut(150) } + } + } + + vPanel(noWrappers = true, classes = setOf("py-2")) { + width = 200.px + link(label = title, url = "#") { + fontSize = 12.pt + color = Color.name(Col.WHITE) + whiteSpace = WhiteSpace.NOWRAP + overflow = Overflow.HIDDEN + textOverflow = TextOverflow.ELLIPSIS + } + span( + content = releaseDate?.split("-")?.firstOrNull() ?: "", + classes = setOf("text-muted") + ) { + whiteSpace = WhiteSpace.NOWRAP + overflow = Overflow.HIDDEN + textOverflow = TextOverflow.ELLIPSIS + } + } + /*div(className = "col-md-8") { + vPanel( + classes = setOf("card-body", "h-100"), + noWrappers = true + ) { + h5(title, className = "card-title") + p(overview, className = "card-text") { + overflow = Overflow.HIDDEN + setStyle("display", "-webkit-box") + setStyle("-webkit-line-clamp", "6") + setStyle("-webkit-box-orient", "vertical") + } + hPanel(justify = JustifyContent.SPACEBETWEEN) { + marginTop = auto + label( + content = if (releaseDate.isNullOrBlank()) "" else "Released $releaseDate", + className = "text-muted" + ) + + hPanel { + initActions() + } + } + } + }*/ + } +} diff --git a/client-web-old/src/main/kotlin/MoviesTab.kt b/client-web-old/src/main/kotlin/MoviesTab.kt new file mode 100644 index 00000000..e3801250 --- /dev/null +++ b/client-web-old/src/main/kotlin/MoviesTab.kt @@ -0,0 +1,256 @@ +/** + * AnyStream + * Copyright (C) 2021 Drew Carlson + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package anystream.frontend + +import anystream.client.AnyStreamClient +import anystream.models.MediaReference +import anystream.models.MediaKind +import anystream.models.Movie +import anystream.models.api.ImportMedia +import io.ktor.client.features.* +import io.ktor.http.* +import kotlinx.coroutines.* +import kotlinx.coroutines.Dispatchers.Default +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import io.kvision.core.* +import io.kvision.core.FlexWrap +import io.kvision.form.check.CheckBox +import io.kvision.form.check.checkBox +import io.kvision.form.check.radioGroup +import io.kvision.form.text.textArea +import io.kvision.form.text.textInput +import io.kvision.html.* +import io.kvision.modal.Modal +import io.kvision.panel.* +import io.kvision.routing.routing +import io.kvision.state.observableListOf +import io.kvision.state.stateFlow +import io.kvision.toast.Toast + +class MoviesTab( + private val client: AnyStreamClient +) : VPanel(), CoroutineScope { + + + override val coroutineContext = Default + SupervisorJob() + private val scope: CoroutineScope = this + private val movies = observableListOf() + private var downloads = emptyList() + + init { + hPanel( + className = "tmdb-menu-bar", + spacing = 4, + alignItems = AlignItems.CENTER + ) { + button("", icon = "fas fa-file-import") { + title = "Import" + onClick { importMovie() } + } + button("", icon = "fas fa-exclamation-triangle") { + title = "Find Unmapped" + onClick { findUnmappedFiles() } + } + } + + flexPanel( + movies, + wrap = FlexWrap.WRAP, + direction = FlexDirection.ROW, + justify = JustifyContent.FLEXSTART, + alignItems = AlignItems.STRETCH, + className = "container-fluid" + ) { movies -> + movies.forEach { movie -> + val download = downloads.find { it.contentId == movie.id } + add(MovieCard( + title = movie.title, + posterPath = movie.posterPath, + overview = movie.overview, + releaseDate = movie.releaseDate, + isAdded = true, + onPlayClicked = { + download?.id?.let { + routing.navigate("/play/$it") + } + }, + onBodyClicked = { + download?.id?.let { + routing.navigate("/play/$it") + } + } + )) + } + } + updateMovies() + } + + private fun updateMovies() { + scope.launch { + val response = client.getMovies() + downloads = response.mediaReferences + movies.clear() + movies.addAll(response.movies) + } + } + + private fun Container.addMovieActions(movie: Movie, download: MediaReference?) { + button("", "fas fa-trash", style = ButtonStyle.OUTLINEDANGER) { + size = ButtonSize.SMALL + onClick { showDeleteMovie(movie) } + } + button("", "fas fa-search", style = ButtonStyle.OUTLINESECONDARY) { + size = ButtonSize.SMALL + onClick { searchForTorrents(movie) } + } + button( + "", + icon = "fas fa-play", + style = ButtonStyle.INFO + ) { + size = ButtonSize.SMALL + onClick { + download?.id?.let { + routing.navigate("/play/$it") + } + } + } + } + + private fun showDeleteMovie(movie: Movie) { + val deleteFilesBox = CheckBox(false, label = "Delete Files") + val modal = Modal("Confirm Delete") + modal.add(Label("Are you sure you would like to delete \"${movie.title}\"?")) + modal.add(deleteFilesBox) + modal.addButton(Button("Cancel", style = ButtonStyle.SECONDARY).apply { + onClick { modal.hide() } + }) + modal.addButton(Button("Confirm", style = ButtonStyle.DANGER).apply { + onClick { + modal.hide() + scope.launch { + try { + client.deleteMovie(movie.id) + movies.remove(movie) + Toast.success("Movie deleted") + } catch (e: ClientRequestException) { + e.printStackTrace() + Toast.error("Failed to delete movie") + } + } + } + }) + modal.show() + } + + private fun importMovie() { + val modal = Modal("Import") + modal.add(Label("Please select the root folder of the media.")) + modal.addButton(Button("Cancel", style = ButtonStyle.SECONDARY).apply { + onClick { modal.hide() } + }) + val pathInput = modal.textInput { + placeholder = "Content Path" + } + val importAll = modal.checkBox(false) { + label = "Import all in directory" + } + val type = modal.radioGroup( + listOf("MOVIE" to "MOVIE", "TV" to "TV"), + label = "Media type" + ) + modal.addButton(Button("Confirm", style = ButtonStyle.PRIMARY).apply { + disabled = true + pathInput.stateFlow + .onEach { disabled = it.isNullOrBlank() } + .launchIn(scope) + onClick { + val path = checkNotNull(pathInput.value) + scope.launch { + try { + val request = ImportMedia( + contentPath = path, + mediaKind = MediaKind.valueOf(type.value ?: "MOVIE") + ) + client.importMedia(request, importAll.value) + modal.hide() + Toast.success("Media imported") + } catch (e: ClientRequestException) { + if (e.response.status == HttpStatusCode.NotFound) { + Toast.warning("The content path does not exist") + } else { + e.printStackTrace() + Toast.error("Failed to import media") + } + } + } + } + }) + modal.show() + } + + private fun findUnmappedFiles() { + val modal = Modal("Find Unmapped Movies") + modal.add(Label("Please select a folder to scan.")) + modal.addButton(Button("Cancel", style = ButtonStyle.SECONDARY).apply { + onClick { modal.hide() } + }) + val pathInput = modal.textInput { + placeholder = "Content Path" + } + modal.addButton(Button("Confirm", style = ButtonStyle.PRIMARY).apply { + disabled = true + pathInput.stateFlow + .onEach { disabled = it.isNullOrBlank() } + .launchIn(scope) + onClick { + val path = checkNotNull(pathInput.value) + scope.launch { + try { + val request = ImportMedia( + contentPath = path, + mediaKind = MediaKind.MOVIE + ) + val unmapped = client.unmappedMedia(request) + modal.hide() + val outputModal = Modal("Unmapped Movies") + outputModal.textArea { + value = unmapped.joinToString("\n") + } + outputModal.show() + } catch (e: ClientRequestException) { + if (e.response.status == HttpStatusCode.NotFound) { + Toast.warning("The content path does not exist") + } else { + e.printStackTrace() + Toast.error("Failed searching for unmapped files") + } + } + } + } + }) + modal.show() + } + + private fun searchForTorrents(movie: Movie) { + TorrentSearchResultsModal(client, movie.id, movie.title) { + client.getMovieSources(movie.id) + } + } +} diff --git a/client-web-old/src/main/kotlin/PlayerPanel.kt b/client-web-old/src/main/kotlin/PlayerPanel.kt new file mode 100644 index 00000000..27f973e2 --- /dev/null +++ b/client-web-old/src/main/kotlin/PlayerPanel.kt @@ -0,0 +1,106 @@ +/** + * AnyStream + * Copyright (C) 2021 Drew Carlson + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package anystream.frontend + +import anystream.client.AnyStreamClient +import kotlinx.browser.window +import kotlinx.coroutines.* +import kotlinx.coroutines.Dispatchers.Default +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.flow.SharingStarted.Companion.WhileSubscribed +import org.w3c.dom.HTMLVideoElement +import io.kvision.core.Position +import io.kvision.core.onEvent +import io.kvision.event.eventFlow +import io.kvision.html.customTag +import io.kvision.navbar.NavbarType +import io.kvision.navbar.nav +import io.kvision.navbar.navLink +import io.kvision.navbar.navbar +import io.kvision.panel.SimplePanel +import io.kvision.utils.perc + +private const val MOUSE_IDLE_DELAY_MS = 800L + +class PlayerPanel( + private val mediaRefId: String, + private val client: AnyStreamClient +) : SimplePanel(), CoroutineScope { + + override val coroutineContext = Default + SupervisorJob() + + private val controlsVisibleState = + eventFlow("mousemove") + .transformLatest { + emit(true) + delay(MOUSE_IDLE_DELAY_MS) + emit(false) + } + .stateIn(this, WhileSubscribed(), true) + + init { + val videoTag = customTag( + elementName = "video", + attributes = mapOf( + "autoplay" to "", + "controls" to "", + ) + ) { + position = Position.ABSOLUTE + width = 100.perc + height = 100.perc + } + navbar(type = NavbarType.FIXEDTOP) { + visible = controlsVisibleState.value + controlsVisibleState + .onEach { controlsVisible -> + if (controlsVisible) fadeIn() else fadeOut() + } + .launchIn(this@PlayerPanel) + + nav(rightAlign = true) { + navLink(label = "", icon = "fas fa-times") { + onClick { window.history.back() } + } + } + } + + launch { + val updateProgress = client.playbackSession(mediaRefId) { state -> + videoTag.onEvent { + loadedmetadata = { + (videoTag.getElement() as HTMLVideoElement).currentTime = state.position.toDouble() + } + } + videoTag.setAttribute("src", "${window.location.protocol}//${window.location.host}/api/stream/$mediaRefId/direct") + launch { + // TODO: Cache movie data so the poster can be set immediately + val movie = client.getMovie(state.mediaId) + videoTag.setAttribute("poster", "https://image.tmdb.org/t/p/w1920_and_h800_multi_faces${movie.backdropPath}") + } + } + merge( + videoTag.eventFlow("seeked").debounce(1_000L), + videoTag.eventFlow("timeupdate").sample(8_000L) + ).onEach { (_, _) -> + val currentTime = (videoTag.getElement() as HTMLVideoElement).currentTime + updateProgress(currentTime.toLong()) + }.launchIn(this) + } + } +} diff --git a/client-web-old/src/main/kotlin/SignupPage.kt b/client-web-old/src/main/kotlin/SignupPage.kt new file mode 100644 index 00000000..37e89074 --- /dev/null +++ b/client-web-old/src/main/kotlin/SignupPage.kt @@ -0,0 +1,153 @@ +/** + * AnyStream + * Copyright (C) 2021 Drew Carlson + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package anystream.frontend + +import anystream.client.AnyStreamClient +import anystream.models.* +import anystream.models.api.CreateUserError.PasswordError +import anystream.models.api.CreateUserError.UsernameError +import io.ktor.client.features.* +import io.ktor.http.HttpStatusCode.Companion.Forbidden +import kotlinx.browser.window +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers.Default +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.w3c.dom.url.URLSearchParams +import io.kvision.core.AlignItems +import io.kvision.core.JustifyContent +import io.kvision.form.text.TextInputType +import io.kvision.form.text.textInput +import io.kvision.html.* +import io.kvision.panel.VPanel +import io.kvision.routing.routing +import io.kvision.toast.Toast + +class SignupPage( + private val client: AnyStreamClient +) : VPanel( + justify = JustifyContent.CENTER, + alignItems = AlignItems.CENTER, +), CoroutineScope { + + override val coroutineContext = Default + SupervisorJob() + + private val authMutex = Mutex() + + init { + h3("Signup") + val username = textInput { + placeholder = "Username" + } + + val password = textInput(type = TextInputType.PASSWORD) { + placeholder = "Password" + } + + val confirmPassword = textInput(type = TextInputType.PASSWORD) { + placeholder = "Confirm Password" + } + + val inviteCodeInput = textInput { + placeholder = "Invite Code" + value = URLSearchParams(window.location.search).get("inviteCode") + disabled = value != null + } + + val error = label() + + button("Confirm") { + onClick { + val user = username.value ?: "" + val pass = password.value ?: "" + val inviteCode = inviteCodeInput.value + val passConf = confirmPassword.value ?: "" + if (pass == passConf) { + error.content = null + attemptSignup(this, error, user, pass, inviteCode) + } else { + error.content = "Passwords do not match!" + } + } + } + + link("Go to Login") { + setStyle("cursor", "pointer") + onClick { + routing.navigate("/login") + } + } + } + + private fun attemptSignup( + button: Button, + errorLabel: Label, + username: String, + password: String, + inviteCode: String? + ) { + if (authMutex.isLocked) return + button.disabled = true + fun unlockWithError(error: String = "Signup failed!") { + button.disabled = false + Toast.error(error) + } + launch { + authMutex.withLock { + try { + val (success, error) = client.createUser(username, password, inviteCode) + when { + success != null -> routing.navigate("/") + error != null -> { + errorLabel.content = error.passwordError?.message + ?: error.usernameError?.message + unlockWithError() + } + } + } catch (e: ClientRequestException) { + if (e.response.status == Forbidden) { + errorLabel.content = "Check your invite code" + unlockWithError("A valid invite code is required.") + } else { + e.printStackTrace() + unlockWithError(e.message ?: "") + } + } + } + } + } + + private val PasswordError?.message: String? + get() = when (this) { + PasswordError.BLANK -> "Password cannot be blank" + PasswordError.TOO_SHORT -> "Password must be at least $PASSWORD_LENGTH_MIN characters." + PasswordError.TOO_LONG -> "Password must be $PASSWORD_LENGTH_MAX or fewer characters." + null -> null + } + + private val UsernameError?.message: String? + get() = when (this) { + UsernameError.BLANK -> "Username cannot be blank" + UsernameError.TOO_SHORT -> "Username must be at least $USERNAME_LENGTH_MIN characters." + UsernameError.TOO_LONG -> "Username must be $USERNAME_LENGTH_MAX or fewer characters." + UsernameError.ALREADY_EXISTS -> "Username already exists." + null -> null + } +} diff --git a/client-web-old/src/main/kotlin/TmdbTab.kt b/client-web-old/src/main/kotlin/TmdbTab.kt new file mode 100644 index 00000000..f7f8f7c9 --- /dev/null +++ b/client-web-old/src/main/kotlin/TmdbTab.kt @@ -0,0 +1,189 @@ +/** + * AnyStream + * Copyright (C) 2021 Drew Carlson + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package anystream.frontend + +import anystream.client.AnyStreamClient +import anystream.models.tmdb.PartialMovie +import io.ktor.client.features.* +import io.ktor.http.* +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.* +import io.kvision.core.* +import io.kvision.core.FlexWrap +import io.kvision.form.text.TextInputType +import io.kvision.form.text.textInput +import io.kvision.html.* +import io.kvision.panel.* +import io.kvision.state.observableListOf +import io.kvision.state.observableState +import io.kvision.state.stateFlow +import io.kvision.toast.Toast +import io.kvision.utils.px + +private const val SEARCH_DEBOUNCE_MS = 200L + +class TmdbTab( + private val client: AnyStreamClient +) : VPanel(), CoroutineScope { + + override val coroutineContext = Dispatchers.Default + SupervisorJob() + private val scope: CoroutineScope = this + private val page = MutableStateFlow(1) + private val query = MutableStateFlow("") + private val movies = observableListOf() + + init { + hPanel( + className = "tmdb-menu-bar", + spacing = 4, + alignItems = AlignItems.CENTER + ) { + textInput(TextInputType.TEXT) { + placeholder = "Search" + stateFlow + .map { it ?: "" } + .onEach { + page.value = 1 + query.value = it + } + .launchIn(scope) + } + + button("", "fas fa-chevron-left") { + onClick { + page.value = (page.value - 1).coerceAtLeast(1) + } + } + + textInput(page.observableState) { currentPage -> + width = 60.px + value = currentPage.toString() + stateFlow + .onEach { page.value = it?.toIntOrNull() ?: page.value } + .launchIn(scope) + } + + button("", "fas fa-chevron-right") { + onClick { + page.value += 1 + } + } + } + + flexPanel( + movies, + wrap = FlexWrap.WRAP, + direction = FlexDirection.ROW, + justify = JustifyContent.FLEXSTART, + alignItems = AlignItems.STRETCH, + className = "container-fluid" + ) { movies -> + movies.forEach { movie -> + add(MovieCard( + title = movie.title, + posterPath = movie.posterPath, + overview = movie.overview, + releaseDate = movie.releaseDate, + isAdded = movie.isAdded, + onPlayClicked = {}, + onBodyClicked = {} + )) + } + } + + combineTransform(page, query) { page, query -> emit(page to query) } + .debounce(SEARCH_DEBOUNCE_MS) + .onStart { emit(page.value to query.value) } + .distinctUntilChanged() + .mapLatest { (page, query) -> + try { + if (query.isBlank()) { + client.getTmdbPopularMovies(page = page).items + } else { + client.searchTmdbMovies(query, page).items + } + } catch (e: ClientRequestException) { + e.printStackTrace() + emptyList() + } + } + .onEach { newMovies -> + movies.clear() + movies.addAll(newMovies) + } + .launchIn(scope) + } + + private fun addMovie(movie: PartialMovie, onComplete: (success: Boolean) -> Unit) { + scope.launch { + try { + client.addMovieFromTmdb(movie.tmdbId) + movies[movies.indexOf(movie)] = movie.copy(isAdded = true) + Toast.success("Movie added") + onComplete(true) + } catch (e: ClientRequestException) { + e.printStackTrace() + when (e.response.status) { + HttpStatusCode.Conflict -> { + Toast.warning("Movie already added") + } + else -> { + Toast.error("Failed to add movie!") + } + } + onComplete(false) + } + } + } + + private fun Container.addMovieActions(movie: PartialMovie) { + button("") { + val addedIcon = "fas fa-check-circle" + val unAddedIcon = "fas fa-plus" + size = ButtonSize.SMALL + if (movie.isAdded) { + icon = addedIcon + style = ButtonStyle.OUTLINESUCCESS + } else { + icon = unAddedIcon + style = ButtonStyle.OUTLINEPRIMARY + onClick { + icon = "" + disabled = true + style = ButtonStyle.SUCCESS + val loading = div(classes = setOf("spinner-grow", "spinner-grow-sm")) + addMovie(movie) { success -> + disabled = success + style = if (success) ButtonStyle.SUCCESS else ButtonStyle.PRIMARY + icon = if (success) addedIcon else unAddedIcon + remove(loading) + } + } + } + } + button( + "", + icon = "fas fa-play", + style = ButtonStyle.INFO + ) { + size = ButtonSize.SMALL + onClick { + } + } + } +} diff --git a/client-web-old/src/main/kotlin/TorrentSearchResultsModal.kt b/client-web-old/src/main/kotlin/TorrentSearchResultsModal.kt new file mode 100644 index 00000000..ad1af7ad --- /dev/null +++ b/client-web-old/src/main/kotlin/TorrentSearchResultsModal.kt @@ -0,0 +1,104 @@ +/** + * AnyStream + * Copyright (C) 2021 Drew Carlson + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package anystream.frontend + +import anystream.client.AnyStreamClient +import anystream.torrent.search.TorrentDescription2 +import io.ktor.client.features.* +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import io.kvision.html.ButtonSize +import io.kvision.html.ButtonStyle +import io.kvision.html.button +import io.kvision.modal.Modal +import io.kvision.modal.ModalSize +import io.kvision.state.observableListOf +import io.kvision.table.cell +import io.kvision.table.row +import io.kvision.table.table +import io.kvision.toast.Toast + + +class TorrentSearchResultsModal( + private val client: AnyStreamClient, + private val movieId: String, + title: String, + private val fetchResults: suspend () -> List +) : Modal( + caption = title, + size = ModalSize.XLARGE, + scrollable = true +) { + + private val sources = observableListOf() + + init { + GlobalScope.launch { + try { + sources.addAll(fetchResults()) + if (sources.isEmpty()) { + hide() + Toast.warning("No torrents found for \"$title\"") + } else { + show() + } + } catch (e: ClientRequestException) { + e.printStackTrace() + hide() + Toast.error("Error searching for \"$title\"") + } + } + table( + sources, + classes = setOf("table", "table-hover"), + headerNames = listOf("", "Provider", "Name", "Seeds", "Peers") + ) { state -> + state.onEach { source -> + row { + cell { + button( + text = "", + icon = "fas fa-plus", + style = ButtonStyle.PRIMARY + ) { + size = ButtonSize.SMALL + onClick { addTorrent(source) } + } + } + cell(source.provider) + cell(source.title) + cell(source.seeds.toString()) + cell(source.peers.toString()) + } + } + } + } + + private fun addTorrent(description: TorrentDescription2) { + GlobalScope.launch { + try { + client.downloadTorrent(description, movieId) + hide() + Toast.success("Torrent added") + } catch (e: ClientRequestException) { + e.printStackTrace() + Toast.error("Failed to add torrent") + } + } + } +} diff --git a/client-web-old/src/main/kotlin/TvTab.kt b/client-web-old/src/main/kotlin/TvTab.kt new file mode 100644 index 00000000..533ae6b9 --- /dev/null +++ b/client-web-old/src/main/kotlin/TvTab.kt @@ -0,0 +1,208 @@ +/** + * AnyStream + * Copyright (C) 2021 Drew Carlson + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package anystream.frontend + +import anystream.client.AnyStreamClient +import anystream.models.MediaReference +import anystream.models.MediaKind +import anystream.models.TvShow +import anystream.models.api.ImportMedia +import io.ktor.client.features.* +import io.ktor.http.* +import kotlinx.coroutines.* +import kotlinx.coroutines.Dispatchers.Default +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import io.kvision.core.* +import io.kvision.core.FlexWrap +import io.kvision.form.check.CheckBox +import io.kvision.form.check.checkBox +import io.kvision.form.check.radioGroup +import io.kvision.form.text.textInput +import io.kvision.html.* +import io.kvision.modal.Modal +import io.kvision.panel.* +import io.kvision.routing.routing +import io.kvision.state.observableListOf +import io.kvision.state.stateFlow +import io.kvision.toast.Toast + +class TvTab( + private val client: AnyStreamClient +) : VPanel(), CoroutineScope { + + + override val coroutineContext = Default + SupervisorJob() + private val scope: CoroutineScope = this + private val shows = observableListOf() + private var downloads = emptyList() + + init { + hPanel( + className = "tmdb-menu-bar", + spacing = 4, + alignItems = AlignItems.CENTER + ) { + button("", icon = "fas fa-file-import") { + title = "Import" + onClick { importMovie() } + } + } + + flexPanel( + shows, + wrap = FlexWrap.WRAP, + direction = FlexDirection.ROW, + justify = JustifyContent.FLEXSTART, + alignItems = AlignItems.STRETCH, + className = "container-fluid" + ) { movies -> + movies.forEach { movie -> + val download = downloads.find { it.contentId == movie.id } + add(MovieCard( + title = movie.name, + posterPath = movie.posterPath, + overview = movie.overview, + releaseDate = movie.firstAirDate, + isAdded = true, + onPlayClicked = { + download?.id?.let { + routing.navigate("/play/$it") + } + }, + onBodyClicked = { + download?.id?.let { + routing.navigate("/play/$it") + } + } + )) + } + } + updateMovies() + } + + private fun updateMovies() { + scope.launch { + val response = client.getTvShows() + //downloads = response.mediaReferences + shows.clear() + shows.addAll(response) + } + } + + private fun Container.addMovieActions(show: TvShow, download: MediaReference?) { + button("", "fas fa-trash", style = ButtonStyle.OUTLINEDANGER) { + size = ButtonSize.SMALL + onClick { showDeleteMovie(show) } + } + button("", "fas fa-search", style = ButtonStyle.OUTLINESECONDARY) { + size = ButtonSize.SMALL + onClick { searchForTorrents(show) } + } + button( + "", + icon = "fas fa-play", + style = ButtonStyle.INFO + ) { + size = ButtonSize.SMALL + onClick { + download?.id?.let { + routing.navigate("/play/$it") + } + } + } + } + + private fun showDeleteMovie(movie: TvShow) { + val deleteFilesBox = CheckBox(false, label = "Delete Files") + val modal = Modal("Confirm Delete") + modal.add(Label("Are you sure you would like to delete \"${movie.name}\"?")) + modal.add(deleteFilesBox) + modal.addButton(Button("Cancel", style = ButtonStyle.SECONDARY).apply { + onClick { modal.hide() } + }) + modal.addButton(Button("Confirm", style = ButtonStyle.DANGER).apply { + onClick { + modal.hide() + scope.launch { + try { + //client.deleteMovie(movie.id) + //shows.remove(movie) + Toast.success("Movie deleted") + } catch (e: ClientRequestException) { + e.printStackTrace() + Toast.error("Failed to delete movie") + } + } + } + }) + modal.show() + } + + private fun importMovie() { + val modal = Modal("Import") + modal.add(Label("Please select the root folder of the media.")) + modal.addButton(Button("Cancel", style = ButtonStyle.SECONDARY).apply { + onClick { modal.hide() } + }) + val pathInput = modal.textInput { + placeholder = "Content Path" + } + val importAll = modal.checkBox(false) { + label = "Import all in directory" + } + val type = modal.radioGroup( + listOf("MOVIE" to "MOVIE", "TV" to "TV"), + label = "Media type" + ) + modal.addButton(Button("Confirm", style = ButtonStyle.PRIMARY).apply { + disabled = true + pathInput.stateFlow + .onEach { disabled = it.isNullOrBlank() } + .launchIn(scope) + onClick { + val path = checkNotNull(pathInput.value) + scope.launch { + try { + val request = ImportMedia( + contentPath = path, + mediaKind = MediaKind.valueOf(type.value ?: "TV") + ) + client.importMedia(request, importAll.value) + modal.hide() + Toast.success("Media imported") + } catch (e: ClientRequestException) { + if (e.response.status == HttpStatusCode.NotFound) { + Toast.warning("The content path does not exist") + } else { + e.printStackTrace() + Toast.error("Failed to import media") + } + } + } + } + }) + modal.show() + } + + private fun searchForTorrents(show: TvShow) { + TorrentSearchResultsModal(client, show.id, show.name) { + client.getTvShowSources(show.id) + } + } +} diff --git a/client-web-old/src/main/kotlin/UserManagerPage.kt b/client-web-old/src/main/kotlin/UserManagerPage.kt new file mode 100644 index 00000000..8a35a373 --- /dev/null +++ b/client-web-old/src/main/kotlin/UserManagerPage.kt @@ -0,0 +1,110 @@ +/** + * AnyStream + * Copyright (C) 2021 Drew Carlson + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package anystream.frontend + +import anystream.client.AnyStreamClient +import anystream.models.InviteCode +import anystream.models.Permissions +import kotlinx.browser.document +import kotlinx.browser.window +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers.Default +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import io.kvision.core.onEvent +import io.kvision.data.dataContainer +import io.kvision.form.select.Select +import io.kvision.form.text.textInput +import io.kvision.html.ButtonSize +import io.kvision.html.ButtonStyle +import io.kvision.html.button +import io.kvision.html.label +import io.kvision.panel.VPanel +import io.kvision.panel.hPanel +import io.kvision.state.observableListOf +import io.kvision.toast.Toast + +class UserManagerPage( + private val client: AnyStreamClient +) : VPanel(), CoroutineScope { + + override val coroutineContext = Default + SupervisorJob() + + private val inviteCodes = observableListOf() + + init { + launch { + val codes = client.getInvites() + inviteCodes.clear() + inviteCodes.addAll(codes) + } + hPanel { + val selectedPermissions = Select( + options = Permissions.all.map { it to it }, + value = Permissions.VIEW_COLLECTION, + multiple = true + ) + button("", icon = "fas fa-plus") { + size = ButtonSize.SMALL + onClick { + launch { + val permissions = (selectedPermissions.value ?: "").split(",").toSet() + val inviteCode = client.createInvite(permissions) + inviteCodes.add(inviteCode) + Toast.success("Invite created") + } + } + } + + add(selectedPermissions) + } + dataContainer(inviteCodes, { inviteCode, _, _ -> + hPanel { + button("", icon = "fas fa-trash", style = ButtonStyle.DANGER) { + onClick { + launch { + if (client.deleteInvite(inviteCode.value)) { + Toast.success("Invite deleted") + inviteCodes.remove(inviteCode) + } else { + Toast.error("Failed to delete invite") + } + } + } + } + val inviteLink = textInput { + val baseUrl = window.location.run { "$protocol//$host" } + value = "$baseUrl/signup?inviteCode=${inviteCode.value}" + onEvent { + keypress = { it.preventDefault() } + } + } + + button("", "fas fa-clipboard") { + onClick { + inviteLink.focus() + inviteLink.getElementJQuery()?.select() + document.execCommand("copy") + } + } + + label("Permissions: ${inviteCode.permissions.joinToString()}") + } + }) + } +} diff --git a/client-web-old/src/main/kotlin/WebApp.kt b/client-web-old/src/main/kotlin/WebApp.kt new file mode 100644 index 00000000..2c3696bc --- /dev/null +++ b/client-web-old/src/main/kotlin/WebApp.kt @@ -0,0 +1,236 @@ +/** + * AnyStream + * Copyright (C) 2021 Drew Carlson + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package anystream.frontend + +import anystream.client.AnyStreamClient +import anystream.client.SessionManager +import anystream.models.Permissions +import anystream.models.Permissions.TORRENT_MANAGEMENT +import io.ktor.client.* +import kotlinx.browser.window +import kotlinx.coroutines.* +import kotlinx.coroutines.Dispatchers.Default +import kotlinx.coroutines.flow.* +import io.kvision.Application +import io.kvision.core.* +import io.kvision.html.* +import io.kvision.module +import io.kvision.navbar.* +import io.kvision.navbar.nav +import io.kvision.panel.* +import io.kvision.startApplication +import io.kvision.utils.perc +import io.kvision.navigo.NavigoHooks +import io.kvision.routing.Routing +import io.kvision.routing.routing +import io.kvision.state.observableState +import kotlin.js.RegExp +import kotlin.reflect.KClass + +class App : Application() { + + private val httpClient = HttpClient() + private val client by lazy { + AnyStreamClient( + window.location.run { "$protocol//$host" }, + httpClient, + SessionManager(JsSessionDataStore) + ) + } + + private val scope = CoroutineScope(Default + SupervisorJob()) + private val activePage = MutableStateFlow?>(null) + + override fun start() { + Routing.init("${window.location.protocol}//${window.location.host}", false, "#!") + root("kvapp") { + vPanel(className = "main-panel") { + val mainNavbar = addMainNavbar() + val mainContainer = simplePanel() + fun setPage(component: Widget) { + val oldComp = mainContainer + .getChildren() + .firstOrNull() + + oldComp?.run(mainContainer::remove) + (oldComp as? CoroutineScope)?.cancel() + + activePage.value = component::class + mainContainer.add(component) + if (component is PlayerPanel) { + mainNavbar.slideUp() + } else if (oldComp is PlayerPanel) { + mainNavbar.slideDown() + } + } + + val authedRouteHook = object : NavigoHooks { + val scope = CoroutineScope(SupervisorJob() + Default) + override val before = { done: (Boolean) -> Unit -> + if (client.isAuthenticated()) { + done(true) + } else { + done(false) + routing.navigate("/login", true) + } + } + + override val after: () -> Unit = { + client.authenticated + .filterNot { it } + .take(1) + .onEach { routing.navigate("/login", true) } + .launchIn(scope) + } + + override val leave = { scope.coroutineContext.cancelChildren() } + } + routing.notFound({ _ -> + // TODO: Add not found page + setPage(VPanel( + justify = JustifyContent.CENTER, + alignItems = AlignItems.CENTER + ) { + height = 100.perc + width = 100.perc + h3("Not found") + }) + }) + routing + .on({ _ -> setPage(TmdbTab(client)) }, authedRouteHook) // TODO: Add homepage + .on("/tmdb", { _ -> setPage(TmdbTab(client)) }, authedRouteHook) + .on("/movies", { _ -> setPage(MoviesTab(client)) }, authedRouteHook) + .on("/tv", { _ -> setPage(TvTab(client)) }, authedRouteHook) + .on("/downloads", { _ -> setPage(DownloadsPage(client)) }, authedRouteHook) + .on("/play", { _ -> routing.navigate("/movies") }, authedRouteHook) + .on(RegExp("/play/(.*)"), { mediaRefId -> + setPage(PlayerPanel(mediaRefId.trim('/'), client)) + }, authedRouteHook) + .on("/usermanager", { _ -> setPage(UserManagerPage(client)) }, authedRouteHook) + .on("/login", { _ -> setPage(LoginPage(client)) }) + .on("/signup", { _ -> setPage(SignupPage(client)) }) + .resolve(currentURL = window.location.pathname) + } + } + } + + override fun dispose(): Map { + scope.cancel() + routing.destroy() + httpClient.close() + return super.dispose() + } + + private fun Container.addMainNavbar(): Navbar { + return navbar( + nColor = NavbarColor.DARK, + bgColor = BsBgColor.DARK + ) { + link(label = "", className = "navbar-brand") { + image(src = "/images/as-logo.svg") + } + val links = mutableMapOf, Link>() + nav { + links[TmdbTab::class] = navLink( + label = "Discover", + icon = "fas fa-search" + ) { + onClick { routing.navigate("/tmdb") } + } + links[MoviesTab::class] = navLink( + label = "Movies", + icon = "fas fa-hdd" + ) { + onClick { routing.navigate("/movies") } + } + links[TvTab::class] = navLink( + label = "TV", + icon = "fas fa-hdd" + ) { + onClick { routing.navigate("/tv") } + } + links[DownloadsPage::class] = navLink( + label = "Downloads", + icon = "fas fa-cloud-download-alt" + ) { + onClick { routing.navigate("/downloads") } + client.permissions + .map { permission -> + Permissions.check(TORRENT_MANAGEMENT, permission ?: emptySet()) + } + .onEach { show -> + if (show) fadeIn() else fadeOut() + } + .launchIn(scope) + } + + visible = client.isAuthenticated() + client.authenticated + .drop(1) + .onEach { authed -> + if (authed) fadeIn() else fadeOut() + } + .launchIn(scope) + } + + nav( + client.permissions.observableState, + rightAlign = true + ) { permissions -> + if (permissions.orEmpty().contains(Permissions.GLOBAL)) { + links[UserManagerPage::class] = navLink("", icon = "fas fa-users-cog") { + onClick { routing.navigate("/usermanager") } + } + } + + if (permissions == null) { + links[LoginPage::class] = navLink("", icon = "fas fa-user-circle") { + onClick { routing.navigate("/login") } + } + } else { + links.remove(LoginPage::class) + navLink("", icon = "fas fa-sign-out-alt") { + onClick { + GlobalScope.launch { + client.logout() + routing.navigate("/login") + } + } + } + } + } + + activePage.onEach { activeClass -> + links.forEach { (kind, link) -> + if (activeClass == kind) { + link.addCssClass("active") + link.setAttribute("aria-current", "page") + } else { + link.removeCssClass("active") + link.removeAttribute("aria-current") + } + } + }.launchIn(scope) + + } + } +} + +fun main() { + startApplication(::App, module.hot) +} diff --git a/client-web-old/src/main/resources/css/bootstrap.min.css b/client-web-old/src/main/resources/css/bootstrap.min.css new file mode 100644 index 00000000..2c7900b6 --- /dev/null +++ b/client-web-old/src/main/resources/css/bootstrap.min.css @@ -0,0 +1,12 @@ +/*! + * Bootswatch v4.5.3 + * Homepage: https://bootswatch.com + * Copyright 2012-2020 Thomas Park + * Licensed under MIT + * Based on Bootstrap +*//*! + * Bootstrap v4.5.3 (https://getbootstrap.com/) + * Copyright 2011-2020 The Bootstrap Authors + * Copyright 2011-2020 Twitter, Inc. + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) + */@import url(https://fonts.googleapis.com/css2?family=Roboto:wght@400;700&display=swap);:root{--blue:#2a9fd6;--indigo:#6610f2;--purple:#6f42c1;--pink:#e83e8c;--red:#c00;--orange:#fd7e14;--yellow:#f80;--green:#77b300;--teal:#20c997;--cyan:#93c;--white:#fff;--gray:#555;--gray-dark:#222;--primary:#2a9fd6;--secondary:#555;--success:#77b300;--info:#93c;--warning:#f80;--danger:#c00;--light:#222;--dark:#adafae;--breakpoint-xs:0;--breakpoint-sm:576px;--breakpoint-md:768px;--breakpoint-lg:992px;--breakpoint-xl:1200px;--font-family-sans-serif:Roboto,-apple-system,BlinkMacSystemFont,"Segoe UI","Helvetica Neue",Arial,sans-serif;--font-family-monospace:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace}*,::after,::before{box-sizing:border-box}html{font-family:sans-serif;line-height:1.15;-webkit-text-size-adjust:100%;-webkit-tap-highlight-color:transparent}article,aside,figcaption,figure,footer,header,hgroup,main,nav,section{display:block}body{margin:0;font-family:Roboto,-apple-system,BlinkMacSystemFont,"Segoe UI","Helvetica Neue",Arial,sans-serif;font-size:.875rem;font-weight:400;line-height:1.5;color:#adafae;text-align:left;background-color:#060606}[tabindex="-1"]:focus:not(:focus-visible){outline:0!important}hr{box-sizing:content-box;height:0;overflow:visible}h1,h2,h3,h4,h5,h6{margin-top:0;margin-bottom:.5rem}p{margin-top:0;margin-bottom:1rem}abbr[data-original-title],abbr[title]{text-decoration:underline;-webkit-text-decoration:underline dotted;text-decoration:underline dotted;cursor:help;border-bottom:0;-webkit-text-decoration-skip-ink:none;text-decoration-skip-ink:none}address{margin-bottom:1rem;font-style:normal;line-height:inherit}dl,ol,ul{margin-top:0;margin-bottom:1rem}ol ol,ol ul,ul ol,ul ul{margin-bottom:0}dt{font-weight:700}dd{margin-bottom:.5rem;margin-left:0}blockquote{margin:0 0 1rem}b,strong{font-weight:bolder}small{font-size:80%}sub,sup{position:relative;font-size:75%;line-height:0;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}a{color:#2a9fd6;text-decoration:none;background-color:transparent}a:hover{color:#1d7097;text-decoration:underline}a:not([href]):not([class]){color:inherit;text-decoration:none}a:not([href]):not([class]):hover{color:inherit;text-decoration:none}code,kbd,pre,samp{font-family:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;font-size:1em}pre{margin-top:0;margin-bottom:1rem;overflow:auto;-ms-overflow-style:scrollbar}figure{margin:0 0 1rem}img{vertical-align:middle;border-style:none}svg{overflow:hidden;vertical-align:middle}table{border-collapse:collapse}caption{padding-top:.75rem;padding-bottom:.75rem;color:#555;text-align:left;caption-side:bottom}th{text-align:inherit;text-align:-webkit-match-parent}label{display:inline-block;margin-bottom:.5rem}button{border-radius:0}button:focus{outline:1px dotted;outline:5px auto -webkit-focus-ring-color}button,input,optgroup,select,textarea{margin:0;font-family:inherit;font-size:inherit;line-height:inherit}button,input{overflow:visible}button,select{text-transform:none}[role=button]{cursor:pointer}select{word-wrap:normal}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}[type=button]:not(:disabled),[type=reset]:not(:disabled),[type=submit]:not(:disabled),button:not(:disabled){cursor:pointer}[type=button]::-moz-focus-inner,[type=reset]::-moz-focus-inner,[type=submit]::-moz-focus-inner,button::-moz-focus-inner{padding:0;border-style:none}input[type=checkbox],input[type=radio]{box-sizing:border-box;padding:0}textarea{overflow:auto;resize:vertical}fieldset{min-width:0;padding:0;margin:0;border:0}legend{display:block;width:100%;max-width:100%;padding:0;margin-bottom:.5rem;font-size:1.5rem;line-height:inherit;color:inherit;white-space:normal}progress{vertical-align:baseline}[type=number]::-webkit-inner-spin-button,[type=number]::-webkit-outer-spin-button{height:auto}[type=search]{outline-offset:-2px;-webkit-appearance:none}[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{font:inherit;-webkit-appearance:button}output{display:inline-block}summary{display:list-item;cursor:pointer}template{display:none}[hidden]{display:none!important}.h1,.h2,.h3,.h4,.h5,.h6,h1,h2,h3,h4,h5,h6{margin-bottom:.5rem;font-weight:500;line-height:1.2;color:#fff}.h1,h1{font-size:4rem}.h2,h2{font-size:3rem}.h3,h3{font-size:2.5rem}.h4,h4{font-size:2rem}.h5,h5{font-size:1.5rem}.h6,h6{font-size:.875rem}.lead{font-size:1.09375rem;font-weight:300}.display-1{font-size:6rem;font-weight:300;line-height:1.2}.display-2{font-size:5.5rem;font-weight:300;line-height:1.2}.display-3{font-size:4.5rem;font-weight:300;line-height:1.2}.display-4{font-size:3.5rem;font-weight:300;line-height:1.2}hr{margin-top:1rem;margin-bottom:1rem;border:0;border-top:1px solid rgba(0,0,0,.1)}.small,small{font-size:80%;font-weight:400}.mark,mark{padding:.2em;background-color:#fcf8e3}.list-unstyled{padding-left:0;list-style:none}.list-inline{padding-left:0;list-style:none}.list-inline-item{display:inline-block}.list-inline-item:not(:last-child){margin-right:.5rem}.initialism{font-size:90%;text-transform:uppercase}.blockquote{margin-bottom:1rem;font-size:1.09375rem}.blockquote-footer{display:block;font-size:80%;color:#555}.blockquote-footer::before{content:"\2014\00A0"}.img-fluid{max-width:100%;height:auto}.img-thumbnail{padding:.25rem;background-color:#060606;border:1px solid #dee2e6;border-radius:.25rem;max-width:100%;height:auto}.figure{display:inline-block}.figure-img{margin-bottom:.5rem;line-height:1}.figure-caption{font-size:90%;color:#555}code{font-size:87.5%;color:#e83e8c;word-wrap:break-word}a>code{color:inherit}kbd{padding:.2rem .4rem;font-size:87.5%;color:#fff;background-color:#212529;border-radius:.2rem}kbd kbd{padding:0;font-size:100%;font-weight:700}pre{display:block;font-size:87.5%;color:inherit}pre code{font-size:inherit;color:inherit;word-break:normal}.pre-scrollable{max-height:340px;overflow-y:scroll}.container,.container-fluid,.container-lg,.container-md,.container-sm,.container-xl{width:100%;padding-right:15px;padding-left:15px;margin-right:auto;margin-left:auto}@media (min-width:576px){.container,.container-sm{max-width:540px}}@media (min-width:768px){.container,.container-md,.container-sm{max-width:720px}}@media (min-width:992px){.container,.container-lg,.container-md,.container-sm{max-width:960px}}@media (min-width:1200px){.container,.container-lg,.container-md,.container-sm,.container-xl{max-width:1140px}}.row{display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;margin-right:-15px;margin-left:-15px}.no-gutters{margin-right:0;margin-left:0}.no-gutters>.col,.no-gutters>[class*=col-]{padding-right:0;padding-left:0}.col,.col-1,.col-10,.col-11,.col-12,.col-2,.col-3,.col-4,.col-5,.col-6,.col-7,.col-8,.col-9,.col-auto,.col-lg,.col-lg-1,.col-lg-10,.col-lg-11,.col-lg-12,.col-lg-2,.col-lg-3,.col-lg-4,.col-lg-5,.col-lg-6,.col-lg-7,.col-lg-8,.col-lg-9,.col-lg-auto,.col-md,.col-md-1,.col-md-10,.col-md-11,.col-md-12,.col-md-2,.col-md-3,.col-md-4,.col-md-5,.col-md-6,.col-md-7,.col-md-8,.col-md-9,.col-md-auto,.col-sm,.col-sm-1,.col-sm-10,.col-sm-11,.col-sm-12,.col-sm-2,.col-sm-3,.col-sm-4,.col-sm-5,.col-sm-6,.col-sm-7,.col-sm-8,.col-sm-9,.col-sm-auto,.col-xl,.col-xl-1,.col-xl-10,.col-xl-11,.col-xl-12,.col-xl-2,.col-xl-3,.col-xl-4,.col-xl-5,.col-xl-6,.col-xl-7,.col-xl-8,.col-xl-9,.col-xl-auto{position:relative;width:100%;padding-right:15px;padding-left:15px}.col{-ms-flex-preferred-size:0;flex-basis:0;-ms-flex-positive:1;flex-grow:1;max-width:100%}.row-cols-1>*{-ms-flex:0 0 100%;flex:0 0 100%;max-width:100%}.row-cols-2>*{-ms-flex:0 0 50%;flex:0 0 50%;max-width:50%}.row-cols-3>*{-ms-flex:0 0 33.333333%;flex:0 0 33.333333%;max-width:33.333333%}.row-cols-4>*{-ms-flex:0 0 25%;flex:0 0 25%;max-width:25%}.row-cols-5>*{-ms-flex:0 0 20%;flex:0 0 20%;max-width:20%}.row-cols-6>*{-ms-flex:0 0 16.666667%;flex:0 0 16.666667%;max-width:16.666667%}.col-auto{-ms-flex:0 0 auto;flex:0 0 auto;width:auto;max-width:100%}.col-1{-ms-flex:0 0 8.333333%;flex:0 0 8.333333%;max-width:8.333333%}.col-2{-ms-flex:0 0 16.666667%;flex:0 0 16.666667%;max-width:16.666667%}.col-3{-ms-flex:0 0 25%;flex:0 0 25%;max-width:25%}.col-4{-ms-flex:0 0 33.333333%;flex:0 0 33.333333%;max-width:33.333333%}.col-5{-ms-flex:0 0 41.666667%;flex:0 0 41.666667%;max-width:41.666667%}.col-6{-ms-flex:0 0 50%;flex:0 0 50%;max-width:50%}.col-7{-ms-flex:0 0 58.333333%;flex:0 0 58.333333%;max-width:58.333333%}.col-8{-ms-flex:0 0 66.666667%;flex:0 0 66.666667%;max-width:66.666667%}.col-9{-ms-flex:0 0 75%;flex:0 0 75%;max-width:75%}.col-10{-ms-flex:0 0 83.333333%;flex:0 0 83.333333%;max-width:83.333333%}.col-11{-ms-flex:0 0 91.666667%;flex:0 0 91.666667%;max-width:91.666667%}.col-12{-ms-flex:0 0 100%;flex:0 0 100%;max-width:100%}.order-first{-ms-flex-order:-1;order:-1}.order-last{-ms-flex-order:13;order:13}.order-0{-ms-flex-order:0;order:0}.order-1{-ms-flex-order:1;order:1}.order-2{-ms-flex-order:2;order:2}.order-3{-ms-flex-order:3;order:3}.order-4{-ms-flex-order:4;order:4}.order-5{-ms-flex-order:5;order:5}.order-6{-ms-flex-order:6;order:6}.order-7{-ms-flex-order:7;order:7}.order-8{-ms-flex-order:8;order:8}.order-9{-ms-flex-order:9;order:9}.order-10{-ms-flex-order:10;order:10}.order-11{-ms-flex-order:11;order:11}.order-12{-ms-flex-order:12;order:12}.offset-1{margin-left:8.333333%}.offset-2{margin-left:16.666667%}.offset-3{margin-left:25%}.offset-4{margin-left:33.333333%}.offset-5{margin-left:41.666667%}.offset-6{margin-left:50%}.offset-7{margin-left:58.333333%}.offset-8{margin-left:66.666667%}.offset-9{margin-left:75%}.offset-10{margin-left:83.333333%}.offset-11{margin-left:91.666667%}@media (min-width:576px){.col-sm{-ms-flex-preferred-size:0;flex-basis:0;-ms-flex-positive:1;flex-grow:1;max-width:100%}.row-cols-sm-1>*{-ms-flex:0 0 100%;flex:0 0 100%;max-width:100%}.row-cols-sm-2>*{-ms-flex:0 0 50%;flex:0 0 50%;max-width:50%}.row-cols-sm-3>*{-ms-flex:0 0 33.333333%;flex:0 0 33.333333%;max-width:33.333333%}.row-cols-sm-4>*{-ms-flex:0 0 25%;flex:0 0 25%;max-width:25%}.row-cols-sm-5>*{-ms-flex:0 0 20%;flex:0 0 20%;max-width:20%}.row-cols-sm-6>*{-ms-flex:0 0 16.666667%;flex:0 0 16.666667%;max-width:16.666667%}.col-sm-auto{-ms-flex:0 0 auto;flex:0 0 auto;width:auto;max-width:100%}.col-sm-1{-ms-flex:0 0 8.333333%;flex:0 0 8.333333%;max-width:8.333333%}.col-sm-2{-ms-flex:0 0 16.666667%;flex:0 0 16.666667%;max-width:16.666667%}.col-sm-3{-ms-flex:0 0 25%;flex:0 0 25%;max-width:25%}.col-sm-4{-ms-flex:0 0 33.333333%;flex:0 0 33.333333%;max-width:33.333333%}.col-sm-5{-ms-flex:0 0 41.666667%;flex:0 0 41.666667%;max-width:41.666667%}.col-sm-6{-ms-flex:0 0 50%;flex:0 0 50%;max-width:50%}.col-sm-7{-ms-flex:0 0 58.333333%;flex:0 0 58.333333%;max-width:58.333333%}.col-sm-8{-ms-flex:0 0 66.666667%;flex:0 0 66.666667%;max-width:66.666667%}.col-sm-9{-ms-flex:0 0 75%;flex:0 0 75%;max-width:75%}.col-sm-10{-ms-flex:0 0 83.333333%;flex:0 0 83.333333%;max-width:83.333333%}.col-sm-11{-ms-flex:0 0 91.666667%;flex:0 0 91.666667%;max-width:91.666667%}.col-sm-12{-ms-flex:0 0 100%;flex:0 0 100%;max-width:100%}.order-sm-first{-ms-flex-order:-1;order:-1}.order-sm-last{-ms-flex-order:13;order:13}.order-sm-0{-ms-flex-order:0;order:0}.order-sm-1{-ms-flex-order:1;order:1}.order-sm-2{-ms-flex-order:2;order:2}.order-sm-3{-ms-flex-order:3;order:3}.order-sm-4{-ms-flex-order:4;order:4}.order-sm-5{-ms-flex-order:5;order:5}.order-sm-6{-ms-flex-order:6;order:6}.order-sm-7{-ms-flex-order:7;order:7}.order-sm-8{-ms-flex-order:8;order:8}.order-sm-9{-ms-flex-order:9;order:9}.order-sm-10{-ms-flex-order:10;order:10}.order-sm-11{-ms-flex-order:11;order:11}.order-sm-12{-ms-flex-order:12;order:12}.offset-sm-0{margin-left:0}.offset-sm-1{margin-left:8.333333%}.offset-sm-2{margin-left:16.666667%}.offset-sm-3{margin-left:25%}.offset-sm-4{margin-left:33.333333%}.offset-sm-5{margin-left:41.666667%}.offset-sm-6{margin-left:50%}.offset-sm-7{margin-left:58.333333%}.offset-sm-8{margin-left:66.666667%}.offset-sm-9{margin-left:75%}.offset-sm-10{margin-left:83.333333%}.offset-sm-11{margin-left:91.666667%}}@media (min-width:768px){.col-md{-ms-flex-preferred-size:0;flex-basis:0;-ms-flex-positive:1;flex-grow:1;max-width:100%}.row-cols-md-1>*{-ms-flex:0 0 100%;flex:0 0 100%;max-width:100%}.row-cols-md-2>*{-ms-flex:0 0 50%;flex:0 0 50%;max-width:50%}.row-cols-md-3>*{-ms-flex:0 0 33.333333%;flex:0 0 33.333333%;max-width:33.333333%}.row-cols-md-4>*{-ms-flex:0 0 25%;flex:0 0 25%;max-width:25%}.row-cols-md-5>*{-ms-flex:0 0 20%;flex:0 0 20%;max-width:20%}.row-cols-md-6>*{-ms-flex:0 0 16.666667%;flex:0 0 16.666667%;max-width:16.666667%}.col-md-auto{-ms-flex:0 0 auto;flex:0 0 auto;width:auto;max-width:100%}.col-md-1{-ms-flex:0 0 8.333333%;flex:0 0 8.333333%;max-width:8.333333%}.col-md-2{-ms-flex:0 0 16.666667%;flex:0 0 16.666667%;max-width:16.666667%}.col-md-3{-ms-flex:0 0 25%;flex:0 0 25%;max-width:25%}.col-md-4{-ms-flex:0 0 33.333333%;flex:0 0 33.333333%;max-width:33.333333%}.col-md-5{-ms-flex:0 0 41.666667%;flex:0 0 41.666667%;max-width:41.666667%}.col-md-6{-ms-flex:0 0 50%;flex:0 0 50%;max-width:50%}.col-md-7{-ms-flex:0 0 58.333333%;flex:0 0 58.333333%;max-width:58.333333%}.col-md-8{-ms-flex:0 0 66.666667%;flex:0 0 66.666667%;max-width:66.666667%}.col-md-9{-ms-flex:0 0 75%;flex:0 0 75%;max-width:75%}.col-md-10{-ms-flex:0 0 83.333333%;flex:0 0 83.333333%;max-width:83.333333%}.col-md-11{-ms-flex:0 0 91.666667%;flex:0 0 91.666667%;max-width:91.666667%}.col-md-12{-ms-flex:0 0 100%;flex:0 0 100%;max-width:100%}.order-md-first{-ms-flex-order:-1;order:-1}.order-md-last{-ms-flex-order:13;order:13}.order-md-0{-ms-flex-order:0;order:0}.order-md-1{-ms-flex-order:1;order:1}.order-md-2{-ms-flex-order:2;order:2}.order-md-3{-ms-flex-order:3;order:3}.order-md-4{-ms-flex-order:4;order:4}.order-md-5{-ms-flex-order:5;order:5}.order-md-6{-ms-flex-order:6;order:6}.order-md-7{-ms-flex-order:7;order:7}.order-md-8{-ms-flex-order:8;order:8}.order-md-9{-ms-flex-order:9;order:9}.order-md-10{-ms-flex-order:10;order:10}.order-md-11{-ms-flex-order:11;order:11}.order-md-12{-ms-flex-order:12;order:12}.offset-md-0{margin-left:0}.offset-md-1{margin-left:8.333333%}.offset-md-2{margin-left:16.666667%}.offset-md-3{margin-left:25%}.offset-md-4{margin-left:33.333333%}.offset-md-5{margin-left:41.666667%}.offset-md-6{margin-left:50%}.offset-md-7{margin-left:58.333333%}.offset-md-8{margin-left:66.666667%}.offset-md-9{margin-left:75%}.offset-md-10{margin-left:83.333333%}.offset-md-11{margin-left:91.666667%}}@media (min-width:992px){.col-lg{-ms-flex-preferred-size:0;flex-basis:0;-ms-flex-positive:1;flex-grow:1;max-width:100%}.row-cols-lg-1>*{-ms-flex:0 0 100%;flex:0 0 100%;max-width:100%}.row-cols-lg-2>*{-ms-flex:0 0 50%;flex:0 0 50%;max-width:50%}.row-cols-lg-3>*{-ms-flex:0 0 33.333333%;flex:0 0 33.333333%;max-width:33.333333%}.row-cols-lg-4>*{-ms-flex:0 0 25%;flex:0 0 25%;max-width:25%}.row-cols-lg-5>*{-ms-flex:0 0 20%;flex:0 0 20%;max-width:20%}.row-cols-lg-6>*{-ms-flex:0 0 16.666667%;flex:0 0 16.666667%;max-width:16.666667%}.col-lg-auto{-ms-flex:0 0 auto;flex:0 0 auto;width:auto;max-width:100%}.col-lg-1{-ms-flex:0 0 8.333333%;flex:0 0 8.333333%;max-width:8.333333%}.col-lg-2{-ms-flex:0 0 16.666667%;flex:0 0 16.666667%;max-width:16.666667%}.col-lg-3{-ms-flex:0 0 25%;flex:0 0 25%;max-width:25%}.col-lg-4{-ms-flex:0 0 33.333333%;flex:0 0 33.333333%;max-width:33.333333%}.col-lg-5{-ms-flex:0 0 41.666667%;flex:0 0 41.666667%;max-width:41.666667%}.col-lg-6{-ms-flex:0 0 50%;flex:0 0 50%;max-width:50%}.col-lg-7{-ms-flex:0 0 58.333333%;flex:0 0 58.333333%;max-width:58.333333%}.col-lg-8{-ms-flex:0 0 66.666667%;flex:0 0 66.666667%;max-width:66.666667%}.col-lg-9{-ms-flex:0 0 75%;flex:0 0 75%;max-width:75%}.col-lg-10{-ms-flex:0 0 83.333333%;flex:0 0 83.333333%;max-width:83.333333%}.col-lg-11{-ms-flex:0 0 91.666667%;flex:0 0 91.666667%;max-width:91.666667%}.col-lg-12{-ms-flex:0 0 100%;flex:0 0 100%;max-width:100%}.order-lg-first{-ms-flex-order:-1;order:-1}.order-lg-last{-ms-flex-order:13;order:13}.order-lg-0{-ms-flex-order:0;order:0}.order-lg-1{-ms-flex-order:1;order:1}.order-lg-2{-ms-flex-order:2;order:2}.order-lg-3{-ms-flex-order:3;order:3}.order-lg-4{-ms-flex-order:4;order:4}.order-lg-5{-ms-flex-order:5;order:5}.order-lg-6{-ms-flex-order:6;order:6}.order-lg-7{-ms-flex-order:7;order:7}.order-lg-8{-ms-flex-order:8;order:8}.order-lg-9{-ms-flex-order:9;order:9}.order-lg-10{-ms-flex-order:10;order:10}.order-lg-11{-ms-flex-order:11;order:11}.order-lg-12{-ms-flex-order:12;order:12}.offset-lg-0{margin-left:0}.offset-lg-1{margin-left:8.333333%}.offset-lg-2{margin-left:16.666667%}.offset-lg-3{margin-left:25%}.offset-lg-4{margin-left:33.333333%}.offset-lg-5{margin-left:41.666667%}.offset-lg-6{margin-left:50%}.offset-lg-7{margin-left:58.333333%}.offset-lg-8{margin-left:66.666667%}.offset-lg-9{margin-left:75%}.offset-lg-10{margin-left:83.333333%}.offset-lg-11{margin-left:91.666667%}}@media (min-width:1200px){.col-xl{-ms-flex-preferred-size:0;flex-basis:0;-ms-flex-positive:1;flex-grow:1;max-width:100%}.row-cols-xl-1>*{-ms-flex:0 0 100%;flex:0 0 100%;max-width:100%}.row-cols-xl-2>*{-ms-flex:0 0 50%;flex:0 0 50%;max-width:50%}.row-cols-xl-3>*{-ms-flex:0 0 33.333333%;flex:0 0 33.333333%;max-width:33.333333%}.row-cols-xl-4>*{-ms-flex:0 0 25%;flex:0 0 25%;max-width:25%}.row-cols-xl-5>*{-ms-flex:0 0 20%;flex:0 0 20%;max-width:20%}.row-cols-xl-6>*{-ms-flex:0 0 16.666667%;flex:0 0 16.666667%;max-width:16.666667%}.col-xl-auto{-ms-flex:0 0 auto;flex:0 0 auto;width:auto;max-width:100%}.col-xl-1{-ms-flex:0 0 8.333333%;flex:0 0 8.333333%;max-width:8.333333%}.col-xl-2{-ms-flex:0 0 16.666667%;flex:0 0 16.666667%;max-width:16.666667%}.col-xl-3{-ms-flex:0 0 25%;flex:0 0 25%;max-width:25%}.col-xl-4{-ms-flex:0 0 33.333333%;flex:0 0 33.333333%;max-width:33.333333%}.col-xl-5{-ms-flex:0 0 41.666667%;flex:0 0 41.666667%;max-width:41.666667%}.col-xl-6{-ms-flex:0 0 50%;flex:0 0 50%;max-width:50%}.col-xl-7{-ms-flex:0 0 58.333333%;flex:0 0 58.333333%;max-width:58.333333%}.col-xl-8{-ms-flex:0 0 66.666667%;flex:0 0 66.666667%;max-width:66.666667%}.col-xl-9{-ms-flex:0 0 75%;flex:0 0 75%;max-width:75%}.col-xl-10{-ms-flex:0 0 83.333333%;flex:0 0 83.333333%;max-width:83.333333%}.col-xl-11{-ms-flex:0 0 91.666667%;flex:0 0 91.666667%;max-width:91.666667%}.col-xl-12{-ms-flex:0 0 100%;flex:0 0 100%;max-width:100%}.order-xl-first{-ms-flex-order:-1;order:-1}.order-xl-last{-ms-flex-order:13;order:13}.order-xl-0{-ms-flex-order:0;order:0}.order-xl-1{-ms-flex-order:1;order:1}.order-xl-2{-ms-flex-order:2;order:2}.order-xl-3{-ms-flex-order:3;order:3}.order-xl-4{-ms-flex-order:4;order:4}.order-xl-5{-ms-flex-order:5;order:5}.order-xl-6{-ms-flex-order:6;order:6}.order-xl-7{-ms-flex-order:7;order:7}.order-xl-8{-ms-flex-order:8;order:8}.order-xl-9{-ms-flex-order:9;order:9}.order-xl-10{-ms-flex-order:10;order:10}.order-xl-11{-ms-flex-order:11;order:11}.order-xl-12{-ms-flex-order:12;order:12}.offset-xl-0{margin-left:0}.offset-xl-1{margin-left:8.333333%}.offset-xl-2{margin-left:16.666667%}.offset-xl-3{margin-left:25%}.offset-xl-4{margin-left:33.333333%}.offset-xl-5{margin-left:41.666667%}.offset-xl-6{margin-left:50%}.offset-xl-7{margin-left:58.333333%}.offset-xl-8{margin-left:66.666667%}.offset-xl-9{margin-left:75%}.offset-xl-10{margin-left:83.333333%}.offset-xl-11{margin-left:91.666667%}}.table{width:100%;margin-bottom:1rem;color:#fff}.table td,.table th{padding:.75rem;vertical-align:top;border-top:1px solid #282828}.table thead th{vertical-align:bottom;border-bottom:2px solid #282828}.table tbody+tbody{border-top:2px solid #282828}.table-sm td,.table-sm th{padding:.3rem}.table-bordered{border:1px solid #282828}.table-bordered td,.table-bordered th{border:1px solid #282828}.table-bordered thead td,.table-bordered thead th{border-bottom-width:2px}.table-borderless tbody+tbody,.table-borderless td,.table-borderless th,.table-borderless thead th{border:0}.table-striped tbody tr:nth-of-type(odd){background-color:rgba(255,255,255,.05)}.table-hover tbody tr:hover{color:#fff;background-color:rgba(255,255,255,.075)}.table-primary,.table-primary>td,.table-primary>th{background-color:#c3e4f4}.table-primary tbody+tbody,.table-primary td,.table-primary th,.table-primary thead th{border-color:#90cdea}.table-hover .table-primary:hover{background-color:#addaf0}.table-hover .table-primary:hover>td,.table-hover .table-primary:hover>th{background-color:#addaf0}.table-secondary,.table-secondary>td,.table-secondary>th{background-color:#cfcfcf}.table-secondary tbody+tbody,.table-secondary td,.table-secondary th,.table-secondary thead th{border-color:#a7a7a7}.table-hover .table-secondary:hover{background-color:#c2c2c2}.table-hover .table-secondary:hover>td,.table-hover .table-secondary:hover>th{background-color:#c2c2c2}.table-success,.table-success>td,.table-success>th{background-color:#d9eab8}.table-success tbody+tbody,.table-success td,.table-success th,.table-success thead th{border-color:#b8d77a}.table-hover .table-success:hover{background-color:#cee4a4}.table-hover .table-success:hover>td,.table-hover .table-success:hover>th{background-color:#cee4a4}.table-info,.table-info>td,.table-info>th{background-color:#e2c6f1}.table-info tbody+tbody,.table-info td,.table-info th,.table-info thead th{border-color:#ca95e4}.table-hover .table-info:hover{background-color:#d8b2ec}.table-hover .table-info:hover>td,.table-hover .table-info:hover>th{background-color:#d8b2ec}.table-warning,.table-warning>td,.table-warning>th{background-color:#ffdeb8}.table-warning tbody+tbody,.table-warning td,.table-warning th,.table-warning thead th{border-color:#ffc17a}.table-hover .table-warning:hover{background-color:#ffd29f}.table-hover .table-warning:hover>td,.table-hover .table-warning:hover>th{background-color:#ffd29f}.table-danger,.table-danger>td,.table-danger>th{background-color:#f1b8b8}.table-danger tbody+tbody,.table-danger td,.table-danger th,.table-danger thead th{border-color:#e47a7a}.table-hover .table-danger:hover{background-color:#eda3a3}.table-hover .table-danger:hover>td,.table-hover .table-danger:hover>th{background-color:#eda3a3}.table-light,.table-light>td,.table-light>th{background-color:#c1c1c1}.table-light tbody+tbody,.table-light td,.table-light th,.table-light thead th{border-color:#8c8c8c}.table-hover .table-light:hover{background-color:#b4b4b4}.table-hover .table-light:hover>td,.table-hover .table-light:hover>th{background-color:#b4b4b4}.table-dark,.table-dark>td,.table-dark>th{background-color:#e8e9e8}.table-dark tbody+tbody,.table-dark td,.table-dark th,.table-dark thead th{border-color:#d4d5d5}.table-hover .table-dark:hover{background-color:#dbdddb}.table-hover .table-dark:hover>td,.table-hover .table-dark:hover>th{background-color:#dbdddb}.table-active,.table-active>td,.table-active>th{background-color:rgba(255,255,255,.075)}.table-hover .table-active:hover{background-color:rgba(242,242,242,.075)}.table-hover .table-active:hover>td,.table-hover .table-active:hover>th{background-color:rgba(242,242,242,.075)}.table .thead-dark th{color:#fff;background-color:#888;border-color:#757575}.table .thead-light th{color:#282828;background-color:#e9ecef;border-color:#282828}.table-dark{color:#fff;background-color:#888}.table-dark td,.table-dark th,.table-dark thead th{border-color:#757575}.table-dark.table-bordered{border:0}.table-dark.table-striped tbody tr:nth-of-type(odd){background-color:rgba(255,255,255,.05)}.table-dark.table-hover tbody tr:hover{color:#fff;background-color:rgba(255,255,255,.075)}@media (max-width:575.98px){.table-responsive-sm{display:block;width:100%;overflow-x:auto;-webkit-overflow-scrolling:touch}.table-responsive-sm>.table-bordered{border:0}}@media (max-width:767.98px){.table-responsive-md{display:block;width:100%;overflow-x:auto;-webkit-overflow-scrolling:touch}.table-responsive-md>.table-bordered{border:0}}@media (max-width:991.98px){.table-responsive-lg{display:block;width:100%;overflow-x:auto;-webkit-overflow-scrolling:touch}.table-responsive-lg>.table-bordered{border:0}}@media (max-width:1199.98px){.table-responsive-xl{display:block;width:100%;overflow-x:auto;-webkit-overflow-scrolling:touch}.table-responsive-xl>.table-bordered{border:0}}.table-responsive{display:block;width:100%;overflow-x:auto;-webkit-overflow-scrolling:touch}.table-responsive>.table-bordered{border:0}.form-control{display:block;width:100%;height:calc(1.5em + .75rem + 2px);padding:.375rem 1rem;font-size:.875rem;font-weight:400;line-height:1.5;color:#282828;background-color:#fff;background-clip:padding-box;border:1px solid #fff;border-radius:.25rem;transition:border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-control{transition:none}}.form-control::-ms-expand{background-color:transparent;border:0}.form-control:-moz-focusring{color:transparent;text-shadow:0 0 0 #282828}.form-control:focus{color:#282828;background-color:#fff;border-color:#95cfeb;outline:0;box-shadow:0 0 0 .2rem rgba(42,159,214,.25)}.form-control::-webkit-input-placeholder{color:#555;opacity:1}.form-control::-moz-placeholder{color:#555;opacity:1}.form-control:-ms-input-placeholder{color:#555;opacity:1}.form-control::-ms-input-placeholder{color:#555;opacity:1}.form-control::placeholder{color:#555;opacity:1}.form-control:disabled,.form-control[readonly]{background-color:#adafae;opacity:1}input[type=date].form-control,input[type=datetime-local].form-control,input[type=month].form-control,input[type=time].form-control{-webkit-appearance:none;-moz-appearance:none;appearance:none}select.form-control:focus::-ms-value{color:#282828;background-color:#fff}.form-control-file,.form-control-range{display:block;width:100%}.col-form-label{padding-top:calc(.375rem + 1px);padding-bottom:calc(.375rem + 1px);margin-bottom:0;font-size:inherit;line-height:1.5}.col-form-label-lg{padding-top:calc(.5rem + 1px);padding-bottom:calc(.5rem + 1px);font-size:1.09375rem;line-height:1.5}.col-form-label-sm{padding-top:calc(.25rem + 1px);padding-bottom:calc(.25rem + 1px);font-size:.765625rem;line-height:1.5}.form-control-plaintext{display:block;width:100%;padding:.375rem 0;margin-bottom:0;font-size:.875rem;line-height:1.5;color:#adafae;background-color:transparent;border:solid transparent;border-width:1px 0}.form-control-plaintext.form-control-lg,.form-control-plaintext.form-control-sm{padding-right:0;padding-left:0}.form-control-sm{height:calc(1.5em + .5rem + 2px);padding:.25rem .5rem;font-size:.765625rem;line-height:1.5;border-radius:.2rem}.form-control-lg{height:calc(1.5em + 1rem + 2px);padding:.5rem 1rem;font-size:1.09375rem;line-height:1.5;border-radius:.3rem}select.form-control[multiple],select.form-control[size]{height:auto}textarea.form-control{height:auto}.form-group{margin-bottom:1rem}.form-text{display:block;margin-top:.25rem}.form-row{display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;margin-right:-5px;margin-left:-5px}.form-row>.col,.form-row>[class*=col-]{padding-right:5px;padding-left:5px}.form-check{position:relative;display:block;padding-left:1.25rem}.form-check-input{position:absolute;margin-top:.3rem;margin-left:-1.25rem}.form-check-input:disabled~.form-check-label,.form-check-input[disabled]~.form-check-label{color:#555}.form-check-label{margin-bottom:0}.form-check-inline{display:-ms-inline-flexbox;display:inline-flex;-ms-flex-align:center;align-items:center;padding-left:0;margin-right:.75rem}.form-check-inline .form-check-input{position:static;margin-top:0;margin-right:.3125rem;margin-left:0}.valid-feedback{display:none;width:100%;margin-top:.25rem;font-size:80%;color:#77b300}.valid-tooltip{position:absolute;top:100%;left:0;z-index:5;display:none;max-width:100%;padding:.25rem .5rem;margin-top:.1rem;font-size:.765625rem;line-height:1.5;color:#fff;background-color:#77b300;border-radius:.25rem}.is-valid~.valid-feedback,.is-valid~.valid-tooltip,.was-validated :valid~.valid-feedback,.was-validated :valid~.valid-tooltip{display:block}.form-control.is-valid,.was-validated .form-control:valid{border-color:#77b300;padding-right:calc(1.5em + .75rem);background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8' viewBox='0 0 8 8'%3e%3cpath fill='%2377b300' d='M2.3 6.73L.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3e%3c/svg%3e");background-repeat:no-repeat;background-position:right calc(.375em + .1875rem) center;background-size:calc(.75em + .375rem) calc(.75em + .375rem)}.form-control.is-valid:focus,.was-validated .form-control:valid:focus{border-color:#77b300;box-shadow:0 0 0 .2rem rgba(119,179,0,.25)}.was-validated textarea.form-control:valid,textarea.form-control.is-valid{padding-right:calc(1.5em + .75rem);background-position:top calc(.375em + .1875rem) right calc(.375em + .1875rem)}.custom-select.is-valid,.was-validated .custom-select:valid{border-color:#77b300;padding-right:calc(.75em + 2.5625rem);background:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='4' height='5' viewBox='0 0 4 5'%3e%3cpath fill='%23222' d='M2 0L0 2h4zm0 5L0 3h4z'/%3e%3c/svg%3e") no-repeat right 1rem center/8px 10px,url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8' viewBox='0 0 8 8'%3e%3cpath fill='%2377b300' d='M2.3 6.73L.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3e%3c/svg%3e") #fff no-repeat center right 2rem/calc(.75em + .375rem) calc(.75em + .375rem)}.custom-select.is-valid:focus,.was-validated .custom-select:valid:focus{border-color:#77b300;box-shadow:0 0 0 .2rem rgba(119,179,0,.25)}.form-check-input.is-valid~.form-check-label,.was-validated .form-check-input:valid~.form-check-label{color:#77b300}.form-check-input.is-valid~.valid-feedback,.form-check-input.is-valid~.valid-tooltip,.was-validated .form-check-input:valid~.valid-feedback,.was-validated .form-check-input:valid~.valid-tooltip{display:block}.custom-control-input.is-valid~.custom-control-label,.was-validated .custom-control-input:valid~.custom-control-label{color:#77b300}.custom-control-input.is-valid~.custom-control-label::before,.was-validated .custom-control-input:valid~.custom-control-label::before{border-color:#77b300}.custom-control-input.is-valid:checked~.custom-control-label::before,.was-validated .custom-control-input:valid:checked~.custom-control-label::before{border-color:#99e600;background-color:#99e600}.custom-control-input.is-valid:focus~.custom-control-label::before,.was-validated .custom-control-input:valid:focus~.custom-control-label::before{box-shadow:0 0 0 .2rem rgba(119,179,0,.25)}.custom-control-input.is-valid:focus:not(:checked)~.custom-control-label::before,.was-validated .custom-control-input:valid:focus:not(:checked)~.custom-control-label::before{border-color:#77b300}.custom-file-input.is-valid~.custom-file-label,.was-validated .custom-file-input:valid~.custom-file-label{border-color:#77b300}.custom-file-input.is-valid:focus~.custom-file-label,.was-validated .custom-file-input:valid:focus~.custom-file-label{border-color:#77b300;box-shadow:0 0 0 .2rem rgba(119,179,0,.25)}.invalid-feedback{display:none;width:100%;margin-top:.25rem;font-size:80%;color:#c00}.invalid-tooltip{position:absolute;top:100%;left:0;z-index:5;display:none;max-width:100%;padding:.25rem .5rem;margin-top:.1rem;font-size:.765625rem;line-height:1.5;color:#fff;background-color:#c00;border-radius:.25rem}.is-invalid~.invalid-feedback,.is-invalid~.invalid-tooltip,.was-validated :invalid~.invalid-feedback,.was-validated :invalid~.invalid-tooltip{display:block}.form-control.is-invalid,.was-validated .form-control:invalid{border-color:#c00;padding-right:calc(1.5em + .75rem);background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='none' stroke='%23c00' viewBox='0 0 12 12'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23c00' stroke='none'/%3e%3c/svg%3e");background-repeat:no-repeat;background-position:right calc(.375em + .1875rem) center;background-size:calc(.75em + .375rem) calc(.75em + .375rem)}.form-control.is-invalid:focus,.was-validated .form-control:invalid:focus{border-color:#c00;box-shadow:0 0 0 .2rem rgba(204,0,0,.25)}.was-validated textarea.form-control:invalid,textarea.form-control.is-invalid{padding-right:calc(1.5em + .75rem);background-position:top calc(.375em + .1875rem) right calc(.375em + .1875rem)}.custom-select.is-invalid,.was-validated .custom-select:invalid{border-color:#c00;padding-right:calc(.75em + 2.5625rem);background:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='4' height='5' viewBox='0 0 4 5'%3e%3cpath fill='%23222' d='M2 0L0 2h4zm0 5L0 3h4z'/%3e%3c/svg%3e") no-repeat right 1rem center/8px 10px,url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='none' stroke='%23c00' viewBox='0 0 12 12'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23c00' stroke='none'/%3e%3c/svg%3e") #fff no-repeat center right 2rem/calc(.75em + .375rem) calc(.75em + .375rem)}.custom-select.is-invalid:focus,.was-validated .custom-select:invalid:focus{border-color:#c00;box-shadow:0 0 0 .2rem rgba(204,0,0,.25)}.form-check-input.is-invalid~.form-check-label,.was-validated .form-check-input:invalid~.form-check-label{color:#c00}.form-check-input.is-invalid~.invalid-feedback,.form-check-input.is-invalid~.invalid-tooltip,.was-validated .form-check-input:invalid~.invalid-feedback,.was-validated .form-check-input:invalid~.invalid-tooltip{display:block}.custom-control-input.is-invalid~.custom-control-label,.was-validated .custom-control-input:invalid~.custom-control-label{color:#c00}.custom-control-input.is-invalid~.custom-control-label::before,.was-validated .custom-control-input:invalid~.custom-control-label::before{border-color:#c00}.custom-control-input.is-invalid:checked~.custom-control-label::before,.was-validated .custom-control-input:invalid:checked~.custom-control-label::before{border-color:red;background-color:red}.custom-control-input.is-invalid:focus~.custom-control-label::before,.was-validated .custom-control-input:invalid:focus~.custom-control-label::before{box-shadow:0 0 0 .2rem rgba(204,0,0,.25)}.custom-control-input.is-invalid:focus:not(:checked)~.custom-control-label::before,.was-validated .custom-control-input:invalid:focus:not(:checked)~.custom-control-label::before{border-color:#c00}.custom-file-input.is-invalid~.custom-file-label,.was-validated .custom-file-input:invalid~.custom-file-label{border-color:#c00}.custom-file-input.is-invalid:focus~.custom-file-label,.was-validated .custom-file-input:invalid:focus~.custom-file-label{border-color:#c00;box-shadow:0 0 0 .2rem rgba(204,0,0,.25)}.form-inline{display:-ms-flexbox;display:flex;-ms-flex-flow:row wrap;flex-flow:row wrap;-ms-flex-align:center;align-items:center}.form-inline .form-check{width:100%}@media (min-width:576px){.form-inline label{display:-ms-flexbox;display:flex;-ms-flex-align:center;align-items:center;-ms-flex-pack:center;justify-content:center;margin-bottom:0}.form-inline .form-group{display:-ms-flexbox;display:flex;-ms-flex:0 0 auto;flex:0 0 auto;-ms-flex-flow:row wrap;flex-flow:row wrap;-ms-flex-align:center;align-items:center;margin-bottom:0}.form-inline .form-control{display:inline-block;width:auto;vertical-align:middle}.form-inline .form-control-plaintext{display:inline-block}.form-inline .custom-select,.form-inline .input-group{width:auto}.form-inline .form-check{display:-ms-flexbox;display:flex;-ms-flex-align:center;align-items:center;-ms-flex-pack:center;justify-content:center;width:auto;padding-left:0}.form-inline .form-check-input{position:relative;-ms-flex-negative:0;flex-shrink:0;margin-top:0;margin-right:.25rem;margin-left:0}.form-inline .custom-control{-ms-flex-align:center;align-items:center;-ms-flex-pack:center;justify-content:center}.form-inline .custom-control-label{margin-bottom:0}}.btn{display:inline-block;font-weight:400;color:#adafae;text-align:center;vertical-align:middle;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;background-color:transparent;border:1px solid transparent;padding:.375rem 1rem;font-size:.875rem;line-height:1.5;border-radius:.25rem;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.btn{transition:none}}.btn:hover{color:#adafae;text-decoration:none}.btn.focus,.btn:focus{outline:0;box-shadow:0 0 0 .2rem rgba(42,159,214,.25)}.btn.disabled,.btn:disabled{opacity:.65}.btn:not(:disabled):not(.disabled){cursor:pointer}a.btn.disabled,fieldset:disabled a.btn{pointer-events:none}.btn-primary{color:#fff;background-color:#2a9fd6;border-color:#2a9fd6}.btn-primary:hover{color:#fff;background-color:#2387b7;border-color:#2180ac}.btn-primary.focus,.btn-primary:focus{color:#fff;background-color:#2387b7;border-color:#2180ac;box-shadow:0 0 0 .2rem rgba(74,173,220,.5)}.btn-primary.disabled,.btn-primary:disabled{color:#fff;background-color:#2a9fd6;border-color:#2a9fd6}.btn-primary:not(:disabled):not(.disabled).active,.btn-primary:not(:disabled):not(.disabled):active,.show>.btn-primary.dropdown-toggle{color:#fff;background-color:#2180ac;border-color:#1f78a1}.btn-primary:not(:disabled):not(.disabled).active:focus,.btn-primary:not(:disabled):not(.disabled):active:focus,.show>.btn-primary.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(74,173,220,.5)}.btn-secondary{color:#fff;background-color:#555;border-color:#555}.btn-secondary:hover{color:#fff;background-color:#424242;border-color:#3c3c3c}.btn-secondary.focus,.btn-secondary:focus{color:#fff;background-color:#424242;border-color:#3c3c3c;box-shadow:0 0 0 .2rem rgba(111,111,111,.5)}.btn-secondary.disabled,.btn-secondary:disabled{color:#fff;background-color:#555;border-color:#555}.btn-secondary:not(:disabled):not(.disabled).active,.btn-secondary:not(:disabled):not(.disabled):active,.show>.btn-secondary.dropdown-toggle{color:#fff;background-color:#3c3c3c;border-color:#353535}.btn-secondary:not(:disabled):not(.disabled).active:focus,.btn-secondary:not(:disabled):not(.disabled):active:focus,.show>.btn-secondary.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(111,111,111,.5)}.btn-success{color:#fff;background-color:#77b300;border-color:#77b300}.btn-success:hover{color:#fff;background-color:#5e8d00;border-color:#558000}.btn-success.focus,.btn-success:focus{color:#fff;background-color:#5e8d00;border-color:#558000;box-shadow:0 0 0 .2rem rgba(139,190,38,.5)}.btn-success.disabled,.btn-success:disabled{color:#fff;background-color:#77b300;border-color:#77b300}.btn-success:not(:disabled):not(.disabled).active,.btn-success:not(:disabled):not(.disabled):active,.show>.btn-success.dropdown-toggle{color:#fff;background-color:#558000;border-color:#4d7300}.btn-success:not(:disabled):not(.disabled).active:focus,.btn-success:not(:disabled):not(.disabled):active:focus,.show>.btn-success.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(139,190,38,.5)}.btn-info{color:#fff;background-color:#93c;border-color:#93c}.btn-info:hover{color:#fff;background-color:#822bad;border-color:#7a29a3}.btn-info.focus,.btn-info:focus{color:#fff;background-color:#822bad;border-color:#7a29a3;box-shadow:0 0 0 .2rem rgba(168,82,212,.5)}.btn-info.disabled,.btn-info:disabled{color:#fff;background-color:#93c;border-color:#93c}.btn-info:not(:disabled):not(.disabled).active,.btn-info:not(:disabled):not(.disabled):active,.show>.btn-info.dropdown-toggle{color:#fff;background-color:#7a29a3;border-color:#732699}.btn-info:not(:disabled):not(.disabled).active:focus,.btn-info:not(:disabled):not(.disabled):active:focus,.show>.btn-info.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(168,82,212,.5)}.btn-warning{color:#fff;background-color:#f80;border-color:#f80}.btn-warning:hover{color:#fff;background-color:#d97400;border-color:#cc6d00}.btn-warning.focus,.btn-warning:focus{color:#fff;background-color:#d97400;border-color:#cc6d00;box-shadow:0 0 0 .2rem rgba(255,154,38,.5)}.btn-warning.disabled,.btn-warning:disabled{color:#fff;background-color:#f80;border-color:#f80}.btn-warning:not(:disabled):not(.disabled).active,.btn-warning:not(:disabled):not(.disabled):active,.show>.btn-warning.dropdown-toggle{color:#fff;background-color:#cc6d00;border-color:#bf6600}.btn-warning:not(:disabled):not(.disabled).active:focus,.btn-warning:not(:disabled):not(.disabled):active:focus,.show>.btn-warning.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(255,154,38,.5)}.btn-danger{color:#fff;background-color:#c00;border-color:#c00}.btn-danger:hover{color:#fff;background-color:#a60000;border-color:#900}.btn-danger.focus,.btn-danger:focus{color:#fff;background-color:#a60000;border-color:#900;box-shadow:0 0 0 .2rem rgba(212,38,38,.5)}.btn-danger.disabled,.btn-danger:disabled{color:#fff;background-color:#c00;border-color:#c00}.btn-danger:not(:disabled):not(.disabled).active,.btn-danger:not(:disabled):not(.disabled):active,.show>.btn-danger.dropdown-toggle{color:#fff;background-color:#900;border-color:#8c0000}.btn-danger:not(:disabled):not(.disabled).active:focus,.btn-danger:not(:disabled):not(.disabled):active:focus,.show>.btn-danger.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(212,38,38,.5)}.btn-light{color:#fff;background-color:#222;border-color:#222}.btn-light:hover{color:#fff;background-color:#0f0f0f;border-color:#090909}.btn-light.focus,.btn-light:focus{color:#fff;background-color:#0f0f0f;border-color:#090909;box-shadow:0 0 0 .2rem rgba(67,67,67,.5)}.btn-light.disabled,.btn-light:disabled{color:#fff;background-color:#222;border-color:#222}.btn-light:not(:disabled):not(.disabled).active,.btn-light:not(:disabled):not(.disabled):active,.show>.btn-light.dropdown-toggle{color:#fff;background-color:#090909;border-color:#020202}.btn-light:not(:disabled):not(.disabled).active:focus,.btn-light:not(:disabled):not(.disabled):active:focus,.show>.btn-light.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(67,67,67,.5)}.btn-dark{color:#fff;background-color:#adafae;border-color:#adafae}.btn-dark:hover{color:#fff;background-color:#9a9c9b;border-color:#939695}.btn-dark.focus,.btn-dark:focus{color:#fff;background-color:#9a9c9b;border-color:#939695;box-shadow:0 0 0 .2rem rgba(185,187,186,.5)}.btn-dark.disabled,.btn-dark:disabled{color:#fff;background-color:#adafae;border-color:#adafae}.btn-dark:not(:disabled):not(.disabled).active,.btn-dark:not(:disabled):not(.disabled):active,.show>.btn-dark.dropdown-toggle{color:#fff;background-color:#939695;border-color:#8d908e}.btn-dark:not(:disabled):not(.disabled).active:focus,.btn-dark:not(:disabled):not(.disabled):active:focus,.show>.btn-dark.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(185,187,186,.5)}.btn-outline-primary{color:#2a9fd6;border-color:#2a9fd6}.btn-outline-primary:hover{color:#fff;background-color:#2a9fd6;border-color:#2a9fd6}.btn-outline-primary.focus,.btn-outline-primary:focus{box-shadow:0 0 0 .2rem rgba(42,159,214,.5)}.btn-outline-primary.disabled,.btn-outline-primary:disabled{color:#2a9fd6;background-color:transparent}.btn-outline-primary:not(:disabled):not(.disabled).active,.btn-outline-primary:not(:disabled):not(.disabled):active,.show>.btn-outline-primary.dropdown-toggle{color:#fff;background-color:#2a9fd6;border-color:#2a9fd6}.btn-outline-primary:not(:disabled):not(.disabled).active:focus,.btn-outline-primary:not(:disabled):not(.disabled):active:focus,.show>.btn-outline-primary.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(42,159,214,.5)}.btn-outline-secondary{color:#555;border-color:#555}.btn-outline-secondary:hover{color:#fff;background-color:#555;border-color:#555}.btn-outline-secondary.focus,.btn-outline-secondary:focus{box-shadow:0 0 0 .2rem rgba(85,85,85,.5)}.btn-outline-secondary.disabled,.btn-outline-secondary:disabled{color:#555;background-color:transparent}.btn-outline-secondary:not(:disabled):not(.disabled).active,.btn-outline-secondary:not(:disabled):not(.disabled):active,.show>.btn-outline-secondary.dropdown-toggle{color:#fff;background-color:#555;border-color:#555}.btn-outline-secondary:not(:disabled):not(.disabled).active:focus,.btn-outline-secondary:not(:disabled):not(.disabled):active:focus,.show>.btn-outline-secondary.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(85,85,85,.5)}.btn-outline-success{color:#77b300;border-color:#77b300}.btn-outline-success:hover{color:#fff;background-color:#77b300;border-color:#77b300}.btn-outline-success.focus,.btn-outline-success:focus{box-shadow:0 0 0 .2rem rgba(119,179,0,.5)}.btn-outline-success.disabled,.btn-outline-success:disabled{color:#77b300;background-color:transparent}.btn-outline-success:not(:disabled):not(.disabled).active,.btn-outline-success:not(:disabled):not(.disabled):active,.show>.btn-outline-success.dropdown-toggle{color:#fff;background-color:#77b300;border-color:#77b300}.btn-outline-success:not(:disabled):not(.disabled).active:focus,.btn-outline-success:not(:disabled):not(.disabled):active:focus,.show>.btn-outline-success.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(119,179,0,.5)}.btn-outline-info{color:#93c;border-color:#93c}.btn-outline-info:hover{color:#fff;background-color:#93c;border-color:#93c}.btn-outline-info.focus,.btn-outline-info:focus{box-shadow:0 0 0 .2rem rgba(153,51,204,.5)}.btn-outline-info.disabled,.btn-outline-info:disabled{color:#93c;background-color:transparent}.btn-outline-info:not(:disabled):not(.disabled).active,.btn-outline-info:not(:disabled):not(.disabled):active,.show>.btn-outline-info.dropdown-toggle{color:#fff;background-color:#93c;border-color:#93c}.btn-outline-info:not(:disabled):not(.disabled).active:focus,.btn-outline-info:not(:disabled):not(.disabled):active:focus,.show>.btn-outline-info.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(153,51,204,.5)}.btn-outline-warning{color:#f80;border-color:#f80}.btn-outline-warning:hover{color:#fff;background-color:#f80;border-color:#f80}.btn-outline-warning.focus,.btn-outline-warning:focus{box-shadow:0 0 0 .2rem rgba(255,136,0,.5)}.btn-outline-warning.disabled,.btn-outline-warning:disabled{color:#f80;background-color:transparent}.btn-outline-warning:not(:disabled):not(.disabled).active,.btn-outline-warning:not(:disabled):not(.disabled):active,.show>.btn-outline-warning.dropdown-toggle{color:#fff;background-color:#f80;border-color:#f80}.btn-outline-warning:not(:disabled):not(.disabled).active:focus,.btn-outline-warning:not(:disabled):not(.disabled):active:focus,.show>.btn-outline-warning.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(255,136,0,.5)}.btn-outline-danger{color:#c00;border-color:#c00}.btn-outline-danger:hover{color:#fff;background-color:#c00;border-color:#c00}.btn-outline-danger.focus,.btn-outline-danger:focus{box-shadow:0 0 0 .2rem rgba(204,0,0,.5)}.btn-outline-danger.disabled,.btn-outline-danger:disabled{color:#c00;background-color:transparent}.btn-outline-danger:not(:disabled):not(.disabled).active,.btn-outline-danger:not(:disabled):not(.disabled):active,.show>.btn-outline-danger.dropdown-toggle{color:#fff;background-color:#c00;border-color:#c00}.btn-outline-danger:not(:disabled):not(.disabled).active:focus,.btn-outline-danger:not(:disabled):not(.disabled):active:focus,.show>.btn-outline-danger.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(204,0,0,.5)}.btn-outline-light{color:#222;border-color:#222}.btn-outline-light:hover{color:#fff;background-color:#222;border-color:#222}.btn-outline-light.focus,.btn-outline-light:focus{box-shadow:0 0 0 .2rem rgba(34,34,34,.5)}.btn-outline-light.disabled,.btn-outline-light:disabled{color:#222;background-color:transparent}.btn-outline-light:not(:disabled):not(.disabled).active,.btn-outline-light:not(:disabled):not(.disabled):active,.show>.btn-outline-light.dropdown-toggle{color:#fff;background-color:#222;border-color:#222}.btn-outline-light:not(:disabled):not(.disabled).active:focus,.btn-outline-light:not(:disabled):not(.disabled):active:focus,.show>.btn-outline-light.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(34,34,34,.5)}.btn-outline-dark{color:#adafae;border-color:#adafae}.btn-outline-dark:hover{color:#fff;background-color:#adafae;border-color:#adafae}.btn-outline-dark.focus,.btn-outline-dark:focus{box-shadow:0 0 0 .2rem rgba(173,175,174,.5)}.btn-outline-dark.disabled,.btn-outline-dark:disabled{color:#adafae;background-color:transparent}.btn-outline-dark:not(:disabled):not(.disabled).active,.btn-outline-dark:not(:disabled):not(.disabled):active,.show>.btn-outline-dark.dropdown-toggle{color:#fff;background-color:#adafae;border-color:#adafae}.btn-outline-dark:not(:disabled):not(.disabled).active:focus,.btn-outline-dark:not(:disabled):not(.disabled):active:focus,.show>.btn-outline-dark.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(173,175,174,.5)}.btn-link{font-weight:400;color:#2a9fd6;text-decoration:none}.btn-link:hover{color:#1d7097;text-decoration:underline}.btn-link.focus,.btn-link:focus{text-decoration:underline}.btn-link.disabled,.btn-link:disabled{color:#555;pointer-events:none}.btn-group-lg>.btn,.btn-lg{padding:.5rem 1rem;font-size:1.09375rem;line-height:1.5;border-radius:.3rem}.btn-group-sm>.btn,.btn-sm{padding:.25rem .5rem;font-size:.765625rem;line-height:1.5;border-radius:.2rem}.btn-block{display:block;width:100%}.btn-block+.btn-block{margin-top:.5rem}input[type=button].btn-block,input[type=reset].btn-block,input[type=submit].btn-block{width:100%}.fade{transition:opacity .15s linear}@media (prefers-reduced-motion:reduce){.fade{transition:none}}.fade:not(.show){opacity:0}.collapse:not(.show){display:none}.collapsing{position:relative;height:0;overflow:hidden;transition:height .35s ease}@media (prefers-reduced-motion:reduce){.collapsing{transition:none}}.dropdown,.dropleft,.dropright,.dropup{position:relative}.dropdown-toggle{white-space:nowrap}.dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:"";border-top:.3em solid;border-right:.3em solid transparent;border-bottom:0;border-left:.3em solid transparent}.dropdown-toggle:empty::after{margin-left:0}.dropdown-menu{position:absolute;top:100%;left:0;z-index:1000;display:none;float:left;min-width:10rem;padding:.5rem 0;margin:.125rem 0 0;font-size:.875rem;color:#adafae;text-align:left;list-style:none;background-color:#282828;background-clip:padding-box;border:1px solid rgba(0,0,0,.15);border-radius:.25rem}.dropdown-menu-left{right:auto;left:0}.dropdown-menu-right{right:0;left:auto}@media (min-width:576px){.dropdown-menu-sm-left{right:auto;left:0}.dropdown-menu-sm-right{right:0;left:auto}}@media (min-width:768px){.dropdown-menu-md-left{right:auto;left:0}.dropdown-menu-md-right{right:0;left:auto}}@media (min-width:992px){.dropdown-menu-lg-left{right:auto;left:0}.dropdown-menu-lg-right{right:0;left:auto}}@media (min-width:1200px){.dropdown-menu-xl-left{right:auto;left:0}.dropdown-menu-xl-right{right:0;left:auto}}.dropup .dropdown-menu{top:auto;bottom:100%;margin-top:0;margin-bottom:.125rem}.dropup .dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:"";border-top:0;border-right:.3em solid transparent;border-bottom:.3em solid;border-left:.3em solid transparent}.dropup .dropdown-toggle:empty::after{margin-left:0}.dropright .dropdown-menu{top:0;right:auto;left:100%;margin-top:0;margin-left:.125rem}.dropright .dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:"";border-top:.3em solid transparent;border-right:0;border-bottom:.3em solid transparent;border-left:.3em solid}.dropright .dropdown-toggle:empty::after{margin-left:0}.dropright .dropdown-toggle::after{vertical-align:0}.dropleft .dropdown-menu{top:0;right:100%;left:auto;margin-top:0;margin-right:.125rem}.dropleft .dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:""}.dropleft .dropdown-toggle::after{display:none}.dropleft .dropdown-toggle::before{display:inline-block;margin-right:.255em;vertical-align:.255em;content:"";border-top:.3em solid transparent;border-right:.3em solid;border-bottom:.3em solid transparent}.dropleft .dropdown-toggle:empty::after{margin-left:0}.dropleft .dropdown-toggle::before{vertical-align:0}.dropdown-menu[x-placement^=bottom],.dropdown-menu[x-placement^=left],.dropdown-menu[x-placement^=right],.dropdown-menu[x-placement^=top]{right:auto;bottom:auto}.dropdown-divider{height:0;margin:.5rem 0;overflow:hidden;border-top:1px solid #222}.dropdown-item{display:block;width:100%;padding:.25rem 1.5rem;clear:both;font-weight:400;color:#fff;text-align:inherit;white-space:nowrap;background-color:transparent;border:0}.dropdown-item:focus,.dropdown-item:hover{color:#fff;text-decoration:none;background-color:#2a9fd6}.dropdown-item.active,.dropdown-item:active{color:#fff;text-decoration:none;background-color:#2a9fd6}.dropdown-item.disabled,.dropdown-item:disabled{color:#555;pointer-events:none;background-color:transparent}.dropdown-menu.show{display:block}.dropdown-header{display:block;padding:.5rem 1.5rem;margin-bottom:0;font-size:.765625rem;color:#555;white-space:nowrap}.dropdown-item-text{display:block;padding:.25rem 1.5rem;color:#fff}.btn-group,.btn-group-vertical{position:relative;display:-ms-inline-flexbox;display:inline-flex;vertical-align:middle}.btn-group-vertical>.btn,.btn-group>.btn{position:relative;-ms-flex:1 1 auto;flex:1 1 auto}.btn-group-vertical>.btn:hover,.btn-group>.btn:hover{z-index:1}.btn-group-vertical>.btn.active,.btn-group-vertical>.btn:active,.btn-group-vertical>.btn:focus,.btn-group>.btn.active,.btn-group>.btn:active,.btn-group>.btn:focus{z-index:1}.btn-toolbar{display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;-ms-flex-pack:start;justify-content:flex-start}.btn-toolbar .input-group{width:auto}.btn-group>.btn-group:not(:first-child),.btn-group>.btn:not(:first-child){margin-left:-1px}.btn-group>.btn-group:not(:last-child)>.btn,.btn-group>.btn:not(:last-child):not(.dropdown-toggle){border-top-right-radius:0;border-bottom-right-radius:0}.btn-group>.btn-group:not(:first-child)>.btn,.btn-group>.btn:not(:first-child){border-top-left-radius:0;border-bottom-left-radius:0}.dropdown-toggle-split{padding-right:.75rem;padding-left:.75rem}.dropdown-toggle-split::after,.dropright .dropdown-toggle-split::after,.dropup .dropdown-toggle-split::after{margin-left:0}.dropleft .dropdown-toggle-split::before{margin-right:0}.btn-group-sm>.btn+.dropdown-toggle-split,.btn-sm+.dropdown-toggle-split{padding-right:.375rem;padding-left:.375rem}.btn-group-lg>.btn+.dropdown-toggle-split,.btn-lg+.dropdown-toggle-split{padding-right:.75rem;padding-left:.75rem}.btn-group-vertical{-ms-flex-direction:column;flex-direction:column;-ms-flex-align:start;align-items:flex-start;-ms-flex-pack:center;justify-content:center}.btn-group-vertical>.btn,.btn-group-vertical>.btn-group{width:100%}.btn-group-vertical>.btn-group:not(:first-child),.btn-group-vertical>.btn:not(:first-child){margin-top:-1px}.btn-group-vertical>.btn-group:not(:last-child)>.btn,.btn-group-vertical>.btn:not(:last-child):not(.dropdown-toggle){border-bottom-right-radius:0;border-bottom-left-radius:0}.btn-group-vertical>.btn-group:not(:first-child)>.btn,.btn-group-vertical>.btn:not(:first-child){border-top-left-radius:0;border-top-right-radius:0}.btn-group-toggle>.btn,.btn-group-toggle>.btn-group>.btn{margin-bottom:0}.btn-group-toggle>.btn input[type=checkbox],.btn-group-toggle>.btn input[type=radio],.btn-group-toggle>.btn-group>.btn input[type=checkbox],.btn-group-toggle>.btn-group>.btn input[type=radio]{position:absolute;clip:rect(0,0,0,0);pointer-events:none}.input-group{position:relative;display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;-ms-flex-align:stretch;align-items:stretch;width:100%}.input-group>.custom-file,.input-group>.custom-select,.input-group>.form-control,.input-group>.form-control-plaintext{position:relative;-ms-flex:1 1 auto;flex:1 1 auto;width:1%;min-width:0;margin-bottom:0}.input-group>.custom-file+.custom-file,.input-group>.custom-file+.custom-select,.input-group>.custom-file+.form-control,.input-group>.custom-select+.custom-file,.input-group>.custom-select+.custom-select,.input-group>.custom-select+.form-control,.input-group>.form-control+.custom-file,.input-group>.form-control+.custom-select,.input-group>.form-control+.form-control,.input-group>.form-control-plaintext+.custom-file,.input-group>.form-control-plaintext+.custom-select,.input-group>.form-control-plaintext+.form-control{margin-left:-1px}.input-group>.custom-file .custom-file-input:focus~.custom-file-label,.input-group>.custom-select:focus,.input-group>.form-control:focus{z-index:3}.input-group>.custom-file .custom-file-input:focus{z-index:4}.input-group>.custom-select:not(:last-child),.input-group>.form-control:not(:last-child){border-top-right-radius:0;border-bottom-right-radius:0}.input-group>.custom-select:not(:first-child),.input-group>.form-control:not(:first-child){border-top-left-radius:0;border-bottom-left-radius:0}.input-group>.custom-file{display:-ms-flexbox;display:flex;-ms-flex-align:center;align-items:center}.input-group>.custom-file:not(:last-child) .custom-file-label,.input-group>.custom-file:not(:last-child) .custom-file-label::after{border-top-right-radius:0;border-bottom-right-radius:0}.input-group>.custom-file:not(:first-child) .custom-file-label{border-top-left-radius:0;border-bottom-left-radius:0}.input-group-append,.input-group-prepend{display:-ms-flexbox;display:flex}.input-group-append .btn,.input-group-prepend .btn{position:relative;z-index:2}.input-group-append .btn:focus,.input-group-prepend .btn:focus{z-index:3}.input-group-append .btn+.btn,.input-group-append .btn+.input-group-text,.input-group-append .input-group-text+.btn,.input-group-append .input-group-text+.input-group-text,.input-group-prepend .btn+.btn,.input-group-prepend .btn+.input-group-text,.input-group-prepend .input-group-text+.btn,.input-group-prepend .input-group-text+.input-group-text{margin-left:-1px}.input-group-prepend{margin-right:-1px}.input-group-append{margin-left:-1px}.input-group-text{display:-ms-flexbox;display:flex;-ms-flex-align:center;align-items:center;padding:.375rem 1rem;margin-bottom:0;font-size:.875rem;font-weight:400;line-height:1.5;color:#fff;text-align:center;white-space:nowrap;background-color:#282828;border:1px solid transparent;border-radius:.25rem}.input-group-text input[type=checkbox],.input-group-text input[type=radio]{margin-top:0}.input-group-lg>.custom-select,.input-group-lg>.form-control:not(textarea){height:calc(1.5em + 1rem + 2px)}.input-group-lg>.custom-select,.input-group-lg>.form-control,.input-group-lg>.input-group-append>.btn,.input-group-lg>.input-group-append>.input-group-text,.input-group-lg>.input-group-prepend>.btn,.input-group-lg>.input-group-prepend>.input-group-text{padding:.5rem 1rem;font-size:1.09375rem;line-height:1.5;border-radius:.3rem}.input-group-sm>.custom-select,.input-group-sm>.form-control:not(textarea){height:calc(1.5em + .5rem + 2px)}.input-group-sm>.custom-select,.input-group-sm>.form-control,.input-group-sm>.input-group-append>.btn,.input-group-sm>.input-group-append>.input-group-text,.input-group-sm>.input-group-prepend>.btn,.input-group-sm>.input-group-prepend>.input-group-text{padding:.25rem .5rem;font-size:.765625rem;line-height:1.5;border-radius:.2rem}.input-group-lg>.custom-select,.input-group-sm>.custom-select{padding-right:2rem}.input-group>.input-group-append:last-child>.btn:not(:last-child):not(.dropdown-toggle),.input-group>.input-group-append:last-child>.input-group-text:not(:last-child),.input-group>.input-group-append:not(:last-child)>.btn,.input-group>.input-group-append:not(:last-child)>.input-group-text,.input-group>.input-group-prepend>.btn,.input-group>.input-group-prepend>.input-group-text{border-top-right-radius:0;border-bottom-right-radius:0}.input-group>.input-group-append>.btn,.input-group>.input-group-append>.input-group-text,.input-group>.input-group-prepend:first-child>.btn:not(:first-child),.input-group>.input-group-prepend:first-child>.input-group-text:not(:first-child),.input-group>.input-group-prepend:not(:first-child)>.btn,.input-group>.input-group-prepend:not(:first-child)>.input-group-text{border-top-left-radius:0;border-bottom-left-radius:0}.custom-control{position:relative;z-index:1;display:block;min-height:1.3125rem;padding-left:1.5rem;-webkit-print-color-adjust:exact;color-adjust:exact}.custom-control-inline{display:-ms-inline-flexbox;display:inline-flex;margin-right:1rem}.custom-control-input{position:absolute;left:0;z-index:-1;width:1rem;height:1.15625rem;opacity:0}.custom-control-input:checked~.custom-control-label::before{color:#fff;border-color:#2a9fd6;background-color:#2a9fd6}.custom-control-input:focus~.custom-control-label::before{box-shadow:0 0 0 .2rem rgba(42,159,214,.25)}.custom-control-input:focus:not(:checked)~.custom-control-label::before{border-color:#95cfeb}.custom-control-input:not(:disabled):active~.custom-control-label::before{color:#fff;background-color:#c0e2f3;border-color:#c0e2f3}.custom-control-input:disabled~.custom-control-label,.custom-control-input[disabled]~.custom-control-label{color:#555}.custom-control-input:disabled~.custom-control-label::before,.custom-control-input[disabled]~.custom-control-label::before{background-color:#adafae}.custom-control-label{position:relative;margin-bottom:0;vertical-align:top}.custom-control-label::before{position:absolute;top:.15625rem;left:-1.5rem;display:block;width:1rem;height:1rem;pointer-events:none;content:"";background-color:#fff;border:#888 solid 1px}.custom-control-label::after{position:absolute;top:.15625rem;left:-1.5rem;display:block;width:1rem;height:1rem;content:"";background:no-repeat 50%/50% 50%}.custom-checkbox .custom-control-label::before{border-radius:.25rem}.custom-checkbox .custom-control-input:checked~.custom-control-label::after{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8' viewBox='0 0 8 8'%3e%3cpath fill='%23fff' d='M6.564.75l-3.59 3.612-1.538-1.55L0 4.26l2.974 2.99L8 2.193z'/%3e%3c/svg%3e")}.custom-checkbox .custom-control-input:indeterminate~.custom-control-label::before{border-color:#2a9fd6;background-color:#2a9fd6}.custom-checkbox .custom-control-input:indeterminate~.custom-control-label::after{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='4' height='4' viewBox='0 0 4 4'%3e%3cpath stroke='%23fff' d='M0 2h4'/%3e%3c/svg%3e")}.custom-checkbox .custom-control-input:disabled:checked~.custom-control-label::before{background-color:rgba(42,159,214,.5)}.custom-checkbox .custom-control-input:disabled:indeterminate~.custom-control-label::before{background-color:rgba(42,159,214,.5)}.custom-radio .custom-control-label::before{border-radius:50%}.custom-radio .custom-control-input:checked~.custom-control-label::after{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%23fff'/%3e%3c/svg%3e")}.custom-radio .custom-control-input:disabled:checked~.custom-control-label::before{background-color:rgba(42,159,214,.5)}.custom-switch{padding-left:2.25rem}.custom-switch .custom-control-label::before{left:-2.25rem;width:1.75rem;pointer-events:all;border-radius:.5rem}.custom-switch .custom-control-label::after{top:calc(.15625rem + 2px);left:calc(-2.25rem + 2px);width:calc(1rem - 4px);height:calc(1rem - 4px);background-color:#888;border-radius:.5rem;transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out,-webkit-transform .15s ease-in-out;transition:transform .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:transform .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out,-webkit-transform .15s ease-in-out}@media (prefers-reduced-motion:reduce){.custom-switch .custom-control-label::after{transition:none}}.custom-switch .custom-control-input:checked~.custom-control-label::after{background-color:#fff;-webkit-transform:translateX(.75rem);transform:translateX(.75rem)}.custom-switch .custom-control-input:disabled:checked~.custom-control-label::before{background-color:rgba(42,159,214,.5)}.custom-select{display:inline-block;width:100%;height:calc(1.5em + .75rem + 2px);padding:.375rem 2rem .375rem 1rem;font-size:.875rem;font-weight:400;line-height:1.5;color:#282828;vertical-align:middle;background:#fff url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='4' height='5' viewBox='0 0 4 5'%3e%3cpath fill='%23222' d='M2 0L0 2h4zm0 5L0 3h4z'/%3e%3c/svg%3e") no-repeat right 1rem center/8px 10px;border:1px solid #fff;border-radius:.25rem;-webkit-appearance:none;-moz-appearance:none;appearance:none}.custom-select:focus{border-color:#95cfeb;outline:0;box-shadow:0 0 0 .2rem rgba(42,159,214,.25)}.custom-select:focus::-ms-value{color:#282828;background-color:#fff}.custom-select[multiple],.custom-select[size]:not([size="1"]){height:auto;padding-right:1rem;background-image:none}.custom-select:disabled{color:#555;background-color:#e9ecef}.custom-select::-ms-expand{display:none}.custom-select:-moz-focusring{color:transparent;text-shadow:0 0 0 #282828}.custom-select-sm{height:calc(1.5em + .5rem + 2px);padding-top:.25rem;padding-bottom:.25rem;padding-left:.5rem;font-size:.765625rem}.custom-select-lg{height:calc(1.5em + 1rem + 2px);padding-top:.5rem;padding-bottom:.5rem;padding-left:1rem;font-size:1.09375rem}.custom-file{position:relative;display:inline-block;width:100%;height:calc(1.5em + .75rem + 2px);margin-bottom:0}.custom-file-input{position:relative;z-index:2;width:100%;height:calc(1.5em + .75rem + 2px);margin:0;opacity:0}.custom-file-input:focus~.custom-file-label{border-color:#95cfeb;box-shadow:0 0 0 .2rem rgba(42,159,214,.25)}.custom-file-input:disabled~.custom-file-label,.custom-file-input[disabled]~.custom-file-label{background-color:#adafae}.custom-file-input:lang(en)~.custom-file-label::after{content:"Browse"}.custom-file-input~.custom-file-label[data-browse]::after{content:attr(data-browse)}.custom-file-label{position:absolute;top:0;right:0;left:0;z-index:1;height:calc(1.5em + .75rem + 2px);padding:.375rem 1rem;font-weight:400;line-height:1.5;color:#fff;background-color:#fff;border:1px solid #282828;border-radius:.25rem}.custom-file-label::after{position:absolute;top:0;right:0;bottom:0;z-index:3;display:block;height:calc(1.5em + .75rem);padding:.375rem 1rem;line-height:1.5;color:#fff;content:"Browse";background-color:#282828;border-left:inherit;border-radius:0 .25rem .25rem 0}.custom-range{width:100%;height:1.4rem;padding:0;background-color:transparent;-webkit-appearance:none;-moz-appearance:none;appearance:none}.custom-range:focus{outline:0}.custom-range:focus::-webkit-slider-thumb{box-shadow:0 0 0 1px #060606,0 0 0 .2rem rgba(42,159,214,.25)}.custom-range:focus::-moz-range-thumb{box-shadow:0 0 0 1px #060606,0 0 0 .2rem rgba(42,159,214,.25)}.custom-range:focus::-ms-thumb{box-shadow:0 0 0 1px #060606,0 0 0 .2rem rgba(42,159,214,.25)}.custom-range::-moz-focus-outer{border:0}.custom-range::-webkit-slider-thumb{width:1rem;height:1rem;margin-top:-.25rem;background-color:#2a9fd6;border:0;border-radius:1rem;-webkit-transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;-webkit-appearance:none;appearance:none}@media (prefers-reduced-motion:reduce){.custom-range::-webkit-slider-thumb{-webkit-transition:none;transition:none}}.custom-range::-webkit-slider-thumb:active{background-color:#c0e2f3}.custom-range::-webkit-slider-runnable-track{width:100%;height:.5rem;color:transparent;cursor:pointer;background-color:#dee2e6;border-color:transparent;border-radius:1rem}.custom-range::-moz-range-thumb{width:1rem;height:1rem;background-color:#2a9fd6;border:0;border-radius:1rem;-moz-transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;-moz-appearance:none;appearance:none}@media (prefers-reduced-motion:reduce){.custom-range::-moz-range-thumb{-moz-transition:none;transition:none}}.custom-range::-moz-range-thumb:active{background-color:#c0e2f3}.custom-range::-moz-range-track{width:100%;height:.5rem;color:transparent;cursor:pointer;background-color:#dee2e6;border-color:transparent;border-radius:1rem}.custom-range::-ms-thumb{width:1rem;height:1rem;margin-top:0;margin-right:.2rem;margin-left:.2rem;background-color:#2a9fd6;border:0;border-radius:1rem;-ms-transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;appearance:none}@media (prefers-reduced-motion:reduce){.custom-range::-ms-thumb{-ms-transition:none;transition:none}}.custom-range::-ms-thumb:active{background-color:#c0e2f3}.custom-range::-ms-track{width:100%;height:.5rem;color:transparent;cursor:pointer;background-color:transparent;border-color:transparent;border-width:.5rem}.custom-range::-ms-fill-lower{background-color:#dee2e6;border-radius:1rem}.custom-range::-ms-fill-upper{margin-right:15px;background-color:#dee2e6;border-radius:1rem}.custom-range:disabled::-webkit-slider-thumb{background-color:#888}.custom-range:disabled::-webkit-slider-runnable-track{cursor:default}.custom-range:disabled::-moz-range-thumb{background-color:#888}.custom-range:disabled::-moz-range-track{cursor:default}.custom-range:disabled::-ms-thumb{background-color:#888}.custom-control-label::before,.custom-file-label,.custom-select{transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.custom-control-label::before,.custom-file-label,.custom-select{transition:none}}.nav{display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;padding-left:0;margin-bottom:0;list-style:none}.nav-link{display:block;padding:.5rem 1rem}.nav-link:focus,.nav-link:hover{text-decoration:none}.nav-link.disabled{color:#555;pointer-events:none;cursor:default}.nav-tabs{border-bottom:1px solid #282828}.nav-tabs .nav-item{margin-bottom:-1px}.nav-tabs .nav-link{border:1px solid transparent;border-top-left-radius:.25rem;border-top-right-radius:.25rem}.nav-tabs .nav-link:focus,.nav-tabs .nav-link:hover{border-color:#282828}.nav-tabs .nav-link.disabled{color:#555;background-color:transparent;border-color:transparent}.nav-tabs .nav-item.show .nav-link,.nav-tabs .nav-link.active{color:#fff;background-color:#282828;border-color:#282828}.nav-tabs .dropdown-menu{margin-top:-1px;border-top-left-radius:0;border-top-right-radius:0}.nav-pills .nav-link{border-radius:.25rem}.nav-pills .nav-link.active,.nav-pills .show>.nav-link{color:#fff;background-color:#2a9fd6}.nav-fill .nav-item,.nav-fill>.nav-link{-ms-flex:1 1 auto;flex:1 1 auto;text-align:center}.nav-justified .nav-item,.nav-justified>.nav-link{-ms-flex-preferred-size:0;flex-basis:0;-ms-flex-positive:1;flex-grow:1;text-align:center}.tab-content>.tab-pane{display:none}.tab-content>.active{display:block}.navbar{position:relative;display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;-ms-flex-align:center;align-items:center;-ms-flex-pack:justify;justify-content:space-between;padding:.5rem 1rem}.navbar .container,.navbar .container-fluid,.navbar .container-lg,.navbar .container-md,.navbar .container-sm,.navbar .container-xl{display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;-ms-flex-align:center;align-items:center;-ms-flex-pack:justify;justify-content:space-between}.navbar-brand{display:inline-block;padding-top:.335938rem;padding-bottom:.335938rem;margin-right:1rem;font-size:1.09375rem;line-height:inherit;white-space:nowrap}.navbar-brand:focus,.navbar-brand:hover{text-decoration:none}.navbar-nav{display:-ms-flexbox;display:flex;-ms-flex-direction:column;flex-direction:column;padding-left:0;margin-bottom:0;list-style:none}.navbar-nav .nav-link{padding-right:0;padding-left:0}.navbar-nav .dropdown-menu{position:static;float:none}.navbar-text{display:inline-block;padding-top:.5rem;padding-bottom:.5rem}.navbar-collapse{-ms-flex-preferred-size:100%;flex-basis:100%;-ms-flex-positive:1;flex-grow:1;-ms-flex-align:center;align-items:center}.navbar-toggler{padding:.25rem .75rem;font-size:1.09375rem;line-height:1;background-color:transparent;border:1px solid transparent;border-radius:.25rem}.navbar-toggler:focus,.navbar-toggler:hover{text-decoration:none}.navbar-toggler-icon{display:inline-block;width:1.5em;height:1.5em;vertical-align:middle;content:"";background:no-repeat center center;background-size:100% 100%}@media (max-width:575.98px){.navbar-expand-sm>.container,.navbar-expand-sm>.container-fluid,.navbar-expand-sm>.container-lg,.navbar-expand-sm>.container-md,.navbar-expand-sm>.container-sm,.navbar-expand-sm>.container-xl{padding-right:0;padding-left:0}}@media (min-width:576px){.navbar-expand-sm{-ms-flex-flow:row nowrap;flex-flow:row nowrap;-ms-flex-pack:start;justify-content:flex-start}.navbar-expand-sm .navbar-nav{-ms-flex-direction:row;flex-direction:row}.navbar-expand-sm .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-sm .navbar-nav .nav-link{padding-right:.5rem;padding-left:.5rem}.navbar-expand-sm>.container,.navbar-expand-sm>.container-fluid,.navbar-expand-sm>.container-lg,.navbar-expand-sm>.container-md,.navbar-expand-sm>.container-sm,.navbar-expand-sm>.container-xl{-ms-flex-wrap:nowrap;flex-wrap:nowrap}.navbar-expand-sm .navbar-collapse{display:-ms-flexbox!important;display:flex!important;-ms-flex-preferred-size:auto;flex-basis:auto}.navbar-expand-sm .navbar-toggler{display:none}}@media (max-width:767.98px){.navbar-expand-md>.container,.navbar-expand-md>.container-fluid,.navbar-expand-md>.container-lg,.navbar-expand-md>.container-md,.navbar-expand-md>.container-sm,.navbar-expand-md>.container-xl{padding-right:0;padding-left:0}}@media (min-width:768px){.navbar-expand-md{-ms-flex-flow:row nowrap;flex-flow:row nowrap;-ms-flex-pack:start;justify-content:flex-start}.navbar-expand-md .navbar-nav{-ms-flex-direction:row;flex-direction:row}.navbar-expand-md .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-md .navbar-nav .nav-link{padding-right:.5rem;padding-left:.5rem}.navbar-expand-md>.container,.navbar-expand-md>.container-fluid,.navbar-expand-md>.container-lg,.navbar-expand-md>.container-md,.navbar-expand-md>.container-sm,.navbar-expand-md>.container-xl{-ms-flex-wrap:nowrap;flex-wrap:nowrap}.navbar-expand-md .navbar-collapse{display:-ms-flexbox!important;display:flex!important;-ms-flex-preferred-size:auto;flex-basis:auto}.navbar-expand-md .navbar-toggler{display:none}}@media (max-width:991.98px){.navbar-expand-lg>.container,.navbar-expand-lg>.container-fluid,.navbar-expand-lg>.container-lg,.navbar-expand-lg>.container-md,.navbar-expand-lg>.container-sm,.navbar-expand-lg>.container-xl{padding-right:0;padding-left:0}}@media (min-width:992px){.navbar-expand-lg{-ms-flex-flow:row nowrap;flex-flow:row nowrap;-ms-flex-pack:start;justify-content:flex-start}.navbar-expand-lg .navbar-nav{-ms-flex-direction:row;flex-direction:row}.navbar-expand-lg .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-lg .navbar-nav .nav-link{padding-right:.5rem;padding-left:.5rem}.navbar-expand-lg>.container,.navbar-expand-lg>.container-fluid,.navbar-expand-lg>.container-lg,.navbar-expand-lg>.container-md,.navbar-expand-lg>.container-sm,.navbar-expand-lg>.container-xl{-ms-flex-wrap:nowrap;flex-wrap:nowrap}.navbar-expand-lg .navbar-collapse{display:-ms-flexbox!important;display:flex!important;-ms-flex-preferred-size:auto;flex-basis:auto}.navbar-expand-lg .navbar-toggler{display:none}}@media (max-width:1199.98px){.navbar-expand-xl>.container,.navbar-expand-xl>.container-fluid,.navbar-expand-xl>.container-lg,.navbar-expand-xl>.container-md,.navbar-expand-xl>.container-sm,.navbar-expand-xl>.container-xl{padding-right:0;padding-left:0}}@media (min-width:1200px){.navbar-expand-xl{-ms-flex-flow:row nowrap;flex-flow:row nowrap;-ms-flex-pack:start;justify-content:flex-start}.navbar-expand-xl .navbar-nav{-ms-flex-direction:row;flex-direction:row}.navbar-expand-xl .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-xl .navbar-nav .nav-link{padding-right:.5rem;padding-left:.5rem}.navbar-expand-xl>.container,.navbar-expand-xl>.container-fluid,.navbar-expand-xl>.container-lg,.navbar-expand-xl>.container-md,.navbar-expand-xl>.container-sm,.navbar-expand-xl>.container-xl{-ms-flex-wrap:nowrap;flex-wrap:nowrap}.navbar-expand-xl .navbar-collapse{display:-ms-flexbox!important;display:flex!important;-ms-flex-preferred-size:auto;flex-basis:auto}.navbar-expand-xl .navbar-toggler{display:none}}.navbar-expand{-ms-flex-flow:row nowrap;flex-flow:row nowrap;-ms-flex-pack:start;justify-content:flex-start}.navbar-expand>.container,.navbar-expand>.container-fluid,.navbar-expand>.container-lg,.navbar-expand>.container-md,.navbar-expand>.container-sm,.navbar-expand>.container-xl{padding-right:0;padding-left:0}.navbar-expand .navbar-nav{-ms-flex-direction:row;flex-direction:row}.navbar-expand .navbar-nav .dropdown-menu{position:absolute}.navbar-expand .navbar-nav .nav-link{padding-right:.5rem;padding-left:.5rem}.navbar-expand>.container,.navbar-expand>.container-fluid,.navbar-expand>.container-lg,.navbar-expand>.container-md,.navbar-expand>.container-sm,.navbar-expand>.container-xl{-ms-flex-wrap:nowrap;flex-wrap:nowrap}.navbar-expand .navbar-collapse{display:-ms-flexbox!important;display:flex!important;-ms-flex-preferred-size:auto;flex-basis:auto}.navbar-expand .navbar-toggler{display:none}.navbar-light .navbar-brand{color:rgba(0,0,0,.9)}.navbar-light .navbar-brand:focus,.navbar-light .navbar-brand:hover{color:rgba(0,0,0,.9)}.navbar-light .navbar-nav .nav-link{color:rgba(0,0,0,.5)}.navbar-light .navbar-nav .nav-link:focus,.navbar-light .navbar-nav .nav-link:hover{color:rgba(0,0,0,.7)}.navbar-light .navbar-nav .nav-link.disabled{color:rgba(0,0,0,.3)}.navbar-light .navbar-nav .active>.nav-link,.navbar-light .navbar-nav .nav-link.active,.navbar-light .navbar-nav .nav-link.show,.navbar-light .navbar-nav .show>.nav-link{color:rgba(0,0,0,.9)}.navbar-light .navbar-toggler{color:rgba(0,0,0,.5);border-color:rgba(0,0,0,.1)}.navbar-light .navbar-toggler-icon{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='30' height='30' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%280, 0, 0, 0.5%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e")}.navbar-light .navbar-text{color:rgba(0,0,0,.5)}.navbar-light .navbar-text a{color:rgba(0,0,0,.9)}.navbar-light .navbar-text a:focus,.navbar-light .navbar-text a:hover{color:rgba(0,0,0,.9)}.navbar-dark .navbar-brand{color:#fff}.navbar-dark .navbar-brand:focus,.navbar-dark .navbar-brand:hover{color:#fff}.navbar-dark .navbar-nav .nav-link{color:rgba(255,255,255,.5)}.navbar-dark .navbar-nav .nav-link:focus,.navbar-dark .navbar-nav .nav-link:hover{color:#fff}.navbar-dark .navbar-nav .nav-link.disabled{color:rgba(255,255,255,.25)}.navbar-dark .navbar-nav .active>.nav-link,.navbar-dark .navbar-nav .nav-link.active,.navbar-dark .navbar-nav .nav-link.show,.navbar-dark .navbar-nav .show>.nav-link{color:#fff}.navbar-dark .navbar-toggler{color:rgba(255,255,255,.5);border-color:rgba(255,255,255,.1)}.navbar-dark .navbar-toggler-icon{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='30' height='30' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.5%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e")}.navbar-dark .navbar-text{color:rgba(255,255,255,.5)}.navbar-dark .navbar-text a{color:#fff}.navbar-dark .navbar-text a:focus,.navbar-dark .navbar-text a:hover{color:#fff}.card{position:relative;display:-ms-flexbox;display:flex;-ms-flex-direction:column;flex-direction:column;min-width:0;word-wrap:break-word;background-color:#282828;background-clip:border-box;border:1px solid rgba(0,0,0,.125);border-radius:.25rem}.card>hr{margin-right:0;margin-left:0}.card>.list-group{border-top:inherit;border-bottom:inherit}.card>.list-group:first-child{border-top-width:0;border-top-left-radius:calc(.25rem - 1px);border-top-right-radius:calc(.25rem - 1px)}.card>.list-group:last-child{border-bottom-width:0;border-bottom-right-radius:calc(.25rem - 1px);border-bottom-left-radius:calc(.25rem - 1px)}.card>.card-header+.list-group,.card>.list-group+.card-footer{border-top:0}.card-body{-ms-flex:1 1 auto;flex:1 1 auto;min-height:1px;padding:1.25rem}.card-title{margin-bottom:.75rem}.card-subtitle{margin-top:-.375rem;margin-bottom:0}.card-text:last-child{margin-bottom:0}.card-link:hover{text-decoration:none}.card-link+.card-link{margin-left:1.25rem}.card-header{padding:.75rem 1.25rem;margin-bottom:0;background-color:rgba(0,0,0,.03);border-bottom:1px solid rgba(0,0,0,.125)}.card-header:first-child{border-radius:calc(.25rem - 1px) calc(.25rem - 1px) 0 0}.card-footer{padding:.75rem 1.25rem;background-color:rgba(0,0,0,.03);border-top:1px solid rgba(0,0,0,.125)}.card-footer:last-child{border-radius:0 0 calc(.25rem - 1px) calc(.25rem - 1px)}.card-header-tabs{margin-right:-.625rem;margin-bottom:-.75rem;margin-left:-.625rem;border-bottom:0}.card-header-pills{margin-right:-.625rem;margin-left:-.625rem}.card-img-overlay{position:absolute;top:0;right:0;bottom:0;left:0;padding:1.25rem;border-radius:calc(.25rem - 1px)}.card-img,.card-img-bottom,.card-img-top{-ms-flex-negative:0;flex-shrink:0;width:100%}.card-img,.card-img-top{border-top-left-radius:calc(.25rem - 1px);border-top-right-radius:calc(.25rem - 1px)}.card-img,.card-img-bottom{border-bottom-right-radius:calc(.25rem - 1px);border-bottom-left-radius:calc(.25rem - 1px)}.card-deck .card{margin-bottom:15px}@media (min-width:576px){.card-deck{display:-ms-flexbox;display:flex;-ms-flex-flow:row wrap;flex-flow:row wrap;margin-right:-15px;margin-left:-15px}.card-deck .card{-ms-flex:1 0 0%;flex:1 0 0%;margin-right:15px;margin-bottom:0;margin-left:15px}}.card-group>.card{margin-bottom:15px}@media (min-width:576px){.card-group{display:-ms-flexbox;display:flex;-ms-flex-flow:row wrap;flex-flow:row wrap}.card-group>.card{-ms-flex:1 0 0%;flex:1 0 0%;margin-bottom:0}.card-group>.card+.card{margin-left:0;border-left:0}.card-group>.card:not(:last-child){border-top-right-radius:0;border-bottom-right-radius:0}.card-group>.card:not(:last-child) .card-header,.card-group>.card:not(:last-child) .card-img-top{border-top-right-radius:0}.card-group>.card:not(:last-child) .card-footer,.card-group>.card:not(:last-child) .card-img-bottom{border-bottom-right-radius:0}.card-group>.card:not(:first-child){border-top-left-radius:0;border-bottom-left-radius:0}.card-group>.card:not(:first-child) .card-header,.card-group>.card:not(:first-child) .card-img-top{border-top-left-radius:0}.card-group>.card:not(:first-child) .card-footer,.card-group>.card:not(:first-child) .card-img-bottom{border-bottom-left-radius:0}}.card-columns .card{margin-bottom:.75rem}@media (min-width:576px){.card-columns{-webkit-column-count:3;-moz-column-count:3;column-count:3;-webkit-column-gap:1.25rem;-moz-column-gap:1.25rem;column-gap:1.25rem;orphans:1;widows:1}.card-columns .card{display:inline-block;width:100%}}.accordion{overflow-anchor:none}.accordion>.card{overflow:hidden}.accordion>.card:not(:last-of-type){border-bottom:0;border-bottom-right-radius:0;border-bottom-left-radius:0}.accordion>.card:not(:first-of-type){border-top-left-radius:0;border-top-right-radius:0}.accordion>.card>.card-header{border-radius:0;margin-bottom:-1px}.breadcrumb{display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;padding:.75rem 1rem;margin-bottom:1rem;list-style:none;background-color:#282828;border-radius:.25rem}.breadcrumb-item{display:-ms-flexbox;display:flex}.breadcrumb-item+.breadcrumb-item{padding-left:.5rem}.breadcrumb-item+.breadcrumb-item::before{display:inline-block;padding-right:.5rem;color:#555;content:"/"}.breadcrumb-item+.breadcrumb-item:hover::before{text-decoration:underline}.breadcrumb-item+.breadcrumb-item:hover::before{text-decoration:none}.breadcrumb-item.active{color:#555}.pagination{display:-ms-flexbox;display:flex;padding-left:0;list-style:none;border-radius:.25rem}.page-link{position:relative;display:block;padding:.5rem .75rem;margin-left:-1px;line-height:1.25;color:#fff;background-color:#282828;border:1px solid transparent}.page-link:hover{z-index:2;color:#fff;text-decoration:none;background-color:#2a9fd6;border-color:transparent}.page-link:focus{z-index:3;outline:0;box-shadow:0 0 0 .2rem rgba(42,159,214,.25)}.page-item:first-child .page-link{margin-left:0;border-top-left-radius:.25rem;border-bottom-left-radius:.25rem}.page-item:last-child .page-link{border-top-right-radius:.25rem;border-bottom-right-radius:.25rem}.page-item.active .page-link{z-index:3;color:#fff;background-color:#2a9fd6;border-color:#2a9fd6}.page-item.disabled .page-link{color:#555;pointer-events:none;cursor:auto;background-color:#282828;border-color:transparent}.pagination-lg .page-link{padding:.75rem 1.5rem;font-size:1.09375rem;line-height:1.5}.pagination-lg .page-item:first-child .page-link{border-top-left-radius:.3rem;border-bottom-left-radius:.3rem}.pagination-lg .page-item:last-child .page-link{border-top-right-radius:.3rem;border-bottom-right-radius:.3rem}.pagination-sm .page-link{padding:.25rem .5rem;font-size:.765625rem;line-height:1.5}.pagination-sm .page-item:first-child .page-link{border-top-left-radius:.2rem;border-bottom-left-radius:.2rem}.pagination-sm .page-item:last-child .page-link{border-top-right-radius:.2rem;border-bottom-right-radius:.2rem}.badge{display:inline-block;padding:.25em .4em;font-size:75%;font-weight:700;line-height:1;text-align:center;white-space:nowrap;vertical-align:baseline;border-radius:.25rem;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.badge{transition:none}}a.badge:focus,a.badge:hover{text-decoration:none}.badge:empty{display:none}.btn .badge{position:relative;top:-1px}.badge-pill{padding-right:.6em;padding-left:.6em;border-radius:10rem}.badge-primary{color:#fff;background-color:#2a9fd6}a.badge-primary:focus,a.badge-primary:hover{color:#fff;background-color:#2180ac}a.badge-primary.focus,a.badge-primary:focus{outline:0;box-shadow:0 0 0 .2rem rgba(42,159,214,.5)}.badge-secondary{color:#fff;background-color:#555}a.badge-secondary:focus,a.badge-secondary:hover{color:#fff;background-color:#3c3c3c}a.badge-secondary.focus,a.badge-secondary:focus{outline:0;box-shadow:0 0 0 .2rem rgba(85,85,85,.5)}.badge-success{color:#fff;background-color:#77b300}a.badge-success:focus,a.badge-success:hover{color:#fff;background-color:#558000}a.badge-success.focus,a.badge-success:focus{outline:0;box-shadow:0 0 0 .2rem rgba(119,179,0,.5)}.badge-info{color:#fff;background-color:#93c}a.badge-info:focus,a.badge-info:hover{color:#fff;background-color:#7a29a3}a.badge-info.focus,a.badge-info:focus{outline:0;box-shadow:0 0 0 .2rem rgba(153,51,204,.5)}.badge-warning{color:#fff;background-color:#f80}a.badge-warning:focus,a.badge-warning:hover{color:#fff;background-color:#cc6d00}a.badge-warning.focus,a.badge-warning:focus{outline:0;box-shadow:0 0 0 .2rem rgba(255,136,0,.5)}.badge-danger{color:#fff;background-color:#c00}a.badge-danger:focus,a.badge-danger:hover{color:#fff;background-color:#900}a.badge-danger.focus,a.badge-danger:focus{outline:0;box-shadow:0 0 0 .2rem rgba(204,0,0,.5)}.badge-light{color:#fff;background-color:#222}a.badge-light:focus,a.badge-light:hover{color:#fff;background-color:#090909}a.badge-light.focus,a.badge-light:focus{outline:0;box-shadow:0 0 0 .2rem rgba(34,34,34,.5)}.badge-dark{color:#fff;background-color:#adafae}a.badge-dark:focus,a.badge-dark:hover{color:#fff;background-color:#939695}a.badge-dark.focus,a.badge-dark:focus{outline:0;box-shadow:0 0 0 .2rem rgba(173,175,174,.5)}.jumbotron{padding:2rem 1rem;margin-bottom:2rem;background-color:#282828;border-radius:.3rem}@media (min-width:576px){.jumbotron{padding:4rem 2rem}}.jumbotron-fluid{padding-right:0;padding-left:0;border-radius:0}.alert{position:relative;padding:.75rem 1.25rem;margin-bottom:1rem;border:1px solid transparent;border-radius:.25rem}.alert-heading{color:inherit}.alert-link{font-weight:700}.alert-dismissible{padding-right:3.8125rem}.alert-dismissible .close{position:absolute;top:0;right:0;z-index:2;padding:.75rem 1.25rem;color:inherit}.alert-primary{color:#16536f;background-color:#d4ecf7;border-color:#c3e4f4}.alert-primary hr{border-top-color:#addaf0}.alert-primary .alert-link{color:#0e3344}.alert-secondary{color:#2c2c2c;background-color:#ddd;border-color:#cfcfcf}.alert-secondary hr{border-top-color:#c2c2c2}.alert-secondary .alert-link{color:#131313}.alert-success{color:#3e5d00;background-color:#e4f0cc;border-color:#d9eab8}.alert-success hr{border-top-color:#cee4a4}.alert-success .alert-link{color:#1c2a00}.alert-info{color:#501b6a;background-color:#ebd6f5;border-color:#e2c6f1}.alert-info hr{border-top-color:#d8b2ec}.alert-info .alert-link{color:#311141}.alert-warning{color:#854700;background-color:#ffe7cc;border-color:#ffdeb8}.alert-warning hr{border-top-color:#ffd29f}.alert-warning .alert-link{color:#522c00}.alert-danger{color:#6a0000;background-color:#f5cccc;border-color:#f1b8b8}.alert-danger hr{border-top-color:#eda3a3}.alert-danger .alert-link{color:#370000}.alert-light{color:#121212;background-color:#d3d3d3;border-color:#c1c1c1}.alert-light hr{border-top-color:#b4b4b4}.alert-light .alert-link{color:#000}.alert-dark{color:#5a5b5a;background-color:#efefef;border-color:#e8e9e8}.alert-dark hr{border-top-color:#dbdddb}.alert-dark .alert-link{color:#414141}@-webkit-keyframes progress-bar-stripes{from{background-position:1rem 0}to{background-position:0 0}}@keyframes progress-bar-stripes{from{background-position:1rem 0}to{background-position:0 0}}.progress{display:-ms-flexbox;display:flex;height:1rem;overflow:hidden;line-height:0;font-size:.65625rem;background-color:#282828;border-radius:.25rem}.progress-bar{display:-ms-flexbox;display:flex;-ms-flex-direction:column;flex-direction:column;-ms-flex-pack:center;justify-content:center;overflow:hidden;color:#fff;text-align:center;white-space:nowrap;background-color:#2a9fd6;transition:width .6s ease}@media (prefers-reduced-motion:reduce){.progress-bar{transition:none}}.progress-bar-striped{background-image:linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-size:1rem 1rem}.progress-bar-animated{-webkit-animation:progress-bar-stripes 1s linear infinite;animation:progress-bar-stripes 1s linear infinite}@media (prefers-reduced-motion:reduce){.progress-bar-animated{-webkit-animation:none;animation:none}}.media{display:-ms-flexbox;display:flex;-ms-flex-align:start;align-items:flex-start}.media-body{-ms-flex:1;flex:1}.list-group{display:-ms-flexbox;display:flex;-ms-flex-direction:column;flex-direction:column;padding-left:0;margin-bottom:0;border-radius:.25rem}.list-group-item-action{width:100%;color:#282828;text-align:inherit}.list-group-item-action:focus,.list-group-item-action:hover{z-index:1;color:#282828;text-decoration:none;background-color:#2a9fd6}.list-group-item-action:active{color:#adafae;background-color:#2a9fd6}.list-group-item{position:relative;display:block;padding:.75rem 1.25rem;background-color:#222;border:1px solid #282828}.list-group-item:first-child{border-top-left-radius:inherit;border-top-right-radius:inherit}.list-group-item:last-child{border-bottom-right-radius:inherit;border-bottom-left-radius:inherit}.list-group-item.disabled,.list-group-item:disabled{color:#555;pointer-events:none;background-color:#282828}.list-group-item.active{z-index:2;color:#fff;background-color:#2a9fd6;border-color:#2a9fd6}.list-group-item+.list-group-item{border-top-width:0}.list-group-item+.list-group-item.active{margin-top:-1px;border-top-width:1px}.list-group-horizontal{-ms-flex-direction:row;flex-direction:row}.list-group-horizontal>.list-group-item:first-child{border-bottom-left-radius:.25rem;border-top-right-radius:0}.list-group-horizontal>.list-group-item:last-child{border-top-right-radius:.25rem;border-bottom-left-radius:0}.list-group-horizontal>.list-group-item.active{margin-top:0}.list-group-horizontal>.list-group-item+.list-group-item{border-top-width:1px;border-left-width:0}.list-group-horizontal>.list-group-item+.list-group-item.active{margin-left:-1px;border-left-width:1px}@media (min-width:576px){.list-group-horizontal-sm{-ms-flex-direction:row;flex-direction:row}.list-group-horizontal-sm>.list-group-item:first-child{border-bottom-left-radius:.25rem;border-top-right-radius:0}.list-group-horizontal-sm>.list-group-item:last-child{border-top-right-radius:.25rem;border-bottom-left-radius:0}.list-group-horizontal-sm>.list-group-item.active{margin-top:0}.list-group-horizontal-sm>.list-group-item+.list-group-item{border-top-width:1px;border-left-width:0}.list-group-horizontal-sm>.list-group-item+.list-group-item.active{margin-left:-1px;border-left-width:1px}}@media (min-width:768px){.list-group-horizontal-md{-ms-flex-direction:row;flex-direction:row}.list-group-horizontal-md>.list-group-item:first-child{border-bottom-left-radius:.25rem;border-top-right-radius:0}.list-group-horizontal-md>.list-group-item:last-child{border-top-right-radius:.25rem;border-bottom-left-radius:0}.list-group-horizontal-md>.list-group-item.active{margin-top:0}.list-group-horizontal-md>.list-group-item+.list-group-item{border-top-width:1px;border-left-width:0}.list-group-horizontal-md>.list-group-item+.list-group-item.active{margin-left:-1px;border-left-width:1px}}@media (min-width:992px){.list-group-horizontal-lg{-ms-flex-direction:row;flex-direction:row}.list-group-horizontal-lg>.list-group-item:first-child{border-bottom-left-radius:.25rem;border-top-right-radius:0}.list-group-horizontal-lg>.list-group-item:last-child{border-top-right-radius:.25rem;border-bottom-left-radius:0}.list-group-horizontal-lg>.list-group-item.active{margin-top:0}.list-group-horizontal-lg>.list-group-item+.list-group-item{border-top-width:1px;border-left-width:0}.list-group-horizontal-lg>.list-group-item+.list-group-item.active{margin-left:-1px;border-left-width:1px}}@media (min-width:1200px){.list-group-horizontal-xl{-ms-flex-direction:row;flex-direction:row}.list-group-horizontal-xl>.list-group-item:first-child{border-bottom-left-radius:.25rem;border-top-right-radius:0}.list-group-horizontal-xl>.list-group-item:last-child{border-top-right-radius:.25rem;border-bottom-left-radius:0}.list-group-horizontal-xl>.list-group-item.active{margin-top:0}.list-group-horizontal-xl>.list-group-item+.list-group-item{border-top-width:1px;border-left-width:0}.list-group-horizontal-xl>.list-group-item+.list-group-item.active{margin-left:-1px;border-left-width:1px}}.list-group-flush{border-radius:0}.list-group-flush>.list-group-item{border-width:0 0 1px}.list-group-flush>.list-group-item:last-child{border-bottom-width:0}.list-group-item-primary{color:#16536f;background-color:#c3e4f4}.list-group-item-primary.list-group-item-action:focus,.list-group-item-primary.list-group-item-action:hover{color:#16536f;background-color:#addaf0}.list-group-item-primary.list-group-item-action.active{color:#fff;background-color:#16536f;border-color:#16536f}.list-group-item-secondary{color:#2c2c2c;background-color:#cfcfcf}.list-group-item-secondary.list-group-item-action:focus,.list-group-item-secondary.list-group-item-action:hover{color:#2c2c2c;background-color:#c2c2c2}.list-group-item-secondary.list-group-item-action.active{color:#fff;background-color:#2c2c2c;border-color:#2c2c2c}.list-group-item-success{color:#3e5d00;background-color:#d9eab8}.list-group-item-success.list-group-item-action:focus,.list-group-item-success.list-group-item-action:hover{color:#3e5d00;background-color:#cee4a4}.list-group-item-success.list-group-item-action.active{color:#fff;background-color:#3e5d00;border-color:#3e5d00}.list-group-item-info{color:#501b6a;background-color:#e2c6f1}.list-group-item-info.list-group-item-action:focus,.list-group-item-info.list-group-item-action:hover{color:#501b6a;background-color:#d8b2ec}.list-group-item-info.list-group-item-action.active{color:#fff;background-color:#501b6a;border-color:#501b6a}.list-group-item-warning{color:#854700;background-color:#ffdeb8}.list-group-item-warning.list-group-item-action:focus,.list-group-item-warning.list-group-item-action:hover{color:#854700;background-color:#ffd29f}.list-group-item-warning.list-group-item-action.active{color:#fff;background-color:#854700;border-color:#854700}.list-group-item-danger{color:#6a0000;background-color:#f1b8b8}.list-group-item-danger.list-group-item-action:focus,.list-group-item-danger.list-group-item-action:hover{color:#6a0000;background-color:#eda3a3}.list-group-item-danger.list-group-item-action.active{color:#fff;background-color:#6a0000;border-color:#6a0000}.list-group-item-light{color:#121212;background-color:#c1c1c1}.list-group-item-light.list-group-item-action:focus,.list-group-item-light.list-group-item-action:hover{color:#121212;background-color:#b4b4b4}.list-group-item-light.list-group-item-action.active{color:#fff;background-color:#121212;border-color:#121212}.list-group-item-dark{color:#5a5b5a;background-color:#e8e9e8}.list-group-item-dark.list-group-item-action:focus,.list-group-item-dark.list-group-item-action:hover{color:#5a5b5a;background-color:#dbdddb}.list-group-item-dark.list-group-item-action.active{color:#fff;background-color:#5a5b5a;border-color:#5a5b5a}.close{float:right;font-size:1.3125rem;font-weight:700;line-height:1;color:#fff;text-shadow:none;opacity:.5}.close:hover{color:#fff;text-decoration:none}.close:not(:disabled):not(.disabled):focus,.close:not(:disabled):not(.disabled):hover{opacity:.75}button.close{padding:0;background-color:transparent;border:0}a.close.disabled{pointer-events:none}.toast{-ms-flex-preferred-size:350px;flex-basis:350px;max-width:350px;font-size:.875rem;color:#fff;background-color:#222;background-clip:padding-box;border:1px solid #282828;box-shadow:0 .25rem .75rem rgba(0,0,0,.1);opacity:0;border-radius:.25rem}.toast:not(:last-child){margin-bottom:.75rem}.toast.showing{opacity:1}.toast.show{display:block;opacity:1}.toast.hide{display:none}.toast-header{display:-ms-flexbox;display:flex;-ms-flex-align:center;align-items:center;padding:.25rem .75rem;color:#adafae;background-color:#222;background-clip:padding-box;border-bottom:1px solid #282828;border-top-left-radius:calc(.25rem - 1px);border-top-right-radius:calc(.25rem - 1px)}.toast-body{padding:.75rem}.modal-open{overflow:hidden}.modal-open .modal{overflow-x:hidden;overflow-y:auto}.modal{position:fixed;top:0;left:0;z-index:1050;display:none;width:100%;height:100%;overflow:hidden;outline:0}.modal-dialog{position:relative;width:auto;margin:.5rem;pointer-events:none}.modal.fade .modal-dialog{transition:-webkit-transform .3s ease-out;transition:transform .3s ease-out;transition:transform .3s ease-out,-webkit-transform .3s ease-out;-webkit-transform:translate(0,-50px);transform:translate(0,-50px)}@media (prefers-reduced-motion:reduce){.modal.fade .modal-dialog{transition:none}}.modal.show .modal-dialog{-webkit-transform:none;transform:none}.modal.modal-static .modal-dialog{-webkit-transform:scale(1.02);transform:scale(1.02)}.modal-dialog-scrollable{display:-ms-flexbox;display:flex;max-height:calc(100% - 1rem)}.modal-dialog-scrollable .modal-content{max-height:calc(100vh - 1rem);overflow:hidden}.modal-dialog-scrollable .modal-footer,.modal-dialog-scrollable .modal-header{-ms-flex-negative:0;flex-shrink:0}.modal-dialog-scrollable .modal-body{overflow-y:auto}.modal-dialog-centered{display:-ms-flexbox;display:flex;-ms-flex-align:center;align-items:center;min-height:calc(100% - 1rem)}.modal-dialog-centered::before{display:block;height:calc(100vh - 1rem);height:-webkit-min-content;height:-moz-min-content;height:min-content;content:""}.modal-dialog-centered.modal-dialog-scrollable{-ms-flex-direction:column;flex-direction:column;-ms-flex-pack:center;justify-content:center;height:100%}.modal-dialog-centered.modal-dialog-scrollable .modal-content{max-height:none}.modal-dialog-centered.modal-dialog-scrollable::before{content:none}.modal-content{position:relative;display:-ms-flexbox;display:flex;-ms-flex-direction:column;flex-direction:column;width:100%;pointer-events:auto;background-color:#222;background-clip:padding-box;border:1px solid rgba(0,0,0,.2);border-radius:.3rem;outline:0}.modal-backdrop{position:fixed;top:0;left:0;z-index:1040;width:100vw;height:100vh;background-color:#000}.modal-backdrop.fade{opacity:0}.modal-backdrop.show{opacity:.5}.modal-header{display:-ms-flexbox;display:flex;-ms-flex-align:start;align-items:flex-start;-ms-flex-pack:justify;justify-content:space-between;padding:1rem 1rem;border-bottom:1px solid #282828;border-top-left-radius:calc(.3rem - 1px);border-top-right-radius:calc(.3rem - 1px)}.modal-header .close{padding:1rem 1rem;margin:-1rem -1rem -1rem auto}.modal-title{margin-bottom:0;line-height:1.5}.modal-body{position:relative;-ms-flex:1 1 auto;flex:1 1 auto;padding:1rem}.modal-footer{display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;-ms-flex-align:center;align-items:center;-ms-flex-pack:end;justify-content:flex-end;padding:.75rem;border-top:1px solid #282828;border-bottom-right-radius:calc(.3rem - 1px);border-bottom-left-radius:calc(.3rem - 1px)}.modal-footer>*{margin:.25rem}.modal-scrollbar-measure{position:absolute;top:-9999px;width:50px;height:50px;overflow:scroll}@media (min-width:576px){.modal-dialog{max-width:500px;margin:1.75rem auto}.modal-dialog-scrollable{max-height:calc(100% - 3.5rem)}.modal-dialog-scrollable .modal-content{max-height:calc(100vh - 3.5rem)}.modal-dialog-centered{min-height:calc(100% - 3.5rem)}.modal-dialog-centered::before{height:calc(100vh - 3.5rem);height:-webkit-min-content;height:-moz-min-content;height:min-content}.modal-sm{max-width:300px}}@media (min-width:992px){.modal-lg,.modal-xl{max-width:800px}}@media (min-width:1200px){.modal-xl{max-width:1140px}}.tooltip{position:absolute;z-index:1070;display:block;margin:0;font-family:Roboto,-apple-system,BlinkMacSystemFont,"Segoe UI","Helvetica Neue",Arial,sans-serif;font-style:normal;font-weight:400;line-height:1.5;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;letter-spacing:normal;word-break:normal;word-spacing:normal;white-space:normal;line-break:auto;font-size:.765625rem;word-wrap:break-word;opacity:0}.tooltip.show{opacity:1}.tooltip .arrow{position:absolute;display:block;width:.8rem;height:.4rem}.tooltip .arrow::before{position:absolute;content:"";border-color:transparent;border-style:solid}.bs-tooltip-auto[x-placement^=top],.bs-tooltip-top{padding:.4rem 0}.bs-tooltip-auto[x-placement^=top] .arrow,.bs-tooltip-top .arrow{bottom:0}.bs-tooltip-auto[x-placement^=top] .arrow::before,.bs-tooltip-top .arrow::before{top:0;border-width:.4rem .4rem 0;border-top-color:#282828}.bs-tooltip-auto[x-placement^=right],.bs-tooltip-right{padding:0 .4rem}.bs-tooltip-auto[x-placement^=right] .arrow,.bs-tooltip-right .arrow{left:0;width:.4rem;height:.8rem}.bs-tooltip-auto[x-placement^=right] .arrow::before,.bs-tooltip-right .arrow::before{right:0;border-width:.4rem .4rem .4rem 0;border-right-color:#282828}.bs-tooltip-auto[x-placement^=bottom],.bs-tooltip-bottom{padding:.4rem 0}.bs-tooltip-auto[x-placement^=bottom] .arrow,.bs-tooltip-bottom .arrow{top:0}.bs-tooltip-auto[x-placement^=bottom] .arrow::before,.bs-tooltip-bottom .arrow::before{bottom:0;border-width:0 .4rem .4rem;border-bottom-color:#282828}.bs-tooltip-auto[x-placement^=left],.bs-tooltip-left{padding:0 .4rem}.bs-tooltip-auto[x-placement^=left] .arrow,.bs-tooltip-left .arrow{right:0;width:.4rem;height:.8rem}.bs-tooltip-auto[x-placement^=left] .arrow::before,.bs-tooltip-left .arrow::before{left:0;border-width:.4rem 0 .4rem .4rem;border-left-color:#282828}.tooltip-inner{max-width:200px;padding:.25rem .5rem;color:#fff;text-align:center;background-color:#282828;border-radius:.25rem}.popover{position:absolute;top:0;left:0;z-index:1060;display:block;max-width:276px;font-family:Roboto,-apple-system,BlinkMacSystemFont,"Segoe UI","Helvetica Neue",Arial,sans-serif;font-style:normal;font-weight:400;line-height:1.5;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;letter-spacing:normal;word-break:normal;word-spacing:normal;white-space:normal;line-break:auto;font-size:.765625rem;word-wrap:break-word;background-color:#282828;background-clip:padding-box;border:1px solid rgba(0,0,0,.2);border-radius:.3rem}.popover .arrow{position:absolute;display:block;width:1rem;height:.5rem;margin:0 .3rem}.popover .arrow::after,.popover .arrow::before{position:absolute;display:block;content:"";border-color:transparent;border-style:solid}.bs-popover-auto[x-placement^=top],.bs-popover-top{margin-bottom:.5rem}.bs-popover-auto[x-placement^=top]>.arrow,.bs-popover-top>.arrow{bottom:calc(-.5rem - 1px)}.bs-popover-auto[x-placement^=top]>.arrow::before,.bs-popover-top>.arrow::before{bottom:0;border-width:.5rem .5rem 0;border-top-color:rgba(0,0,0,.25)}.bs-popover-auto[x-placement^=top]>.arrow::after,.bs-popover-top>.arrow::after{bottom:1px;border-width:.5rem .5rem 0;border-top-color:#282828}.bs-popover-auto[x-placement^=right],.bs-popover-right{margin-left:.5rem}.bs-popover-auto[x-placement^=right]>.arrow,.bs-popover-right>.arrow{left:calc(-.5rem - 1px);width:.5rem;height:1rem;margin:.3rem 0}.bs-popover-auto[x-placement^=right]>.arrow::before,.bs-popover-right>.arrow::before{left:0;border-width:.5rem .5rem .5rem 0;border-right-color:rgba(0,0,0,.25)}.bs-popover-auto[x-placement^=right]>.arrow::after,.bs-popover-right>.arrow::after{left:1px;border-width:.5rem .5rem .5rem 0;border-right-color:#282828}.bs-popover-auto[x-placement^=bottom],.bs-popover-bottom{margin-top:.5rem}.bs-popover-auto[x-placement^=bottom]>.arrow,.bs-popover-bottom>.arrow{top:calc(-.5rem - 1px)}.bs-popover-auto[x-placement^=bottom]>.arrow::before,.bs-popover-bottom>.arrow::before{top:0;border-width:0 .5rem .5rem .5rem;border-bottom-color:rgba(0,0,0,.25)}.bs-popover-auto[x-placement^=bottom]>.arrow::after,.bs-popover-bottom>.arrow::after{top:1px;border-width:0 .5rem .5rem .5rem;border-bottom-color:#282828}.bs-popover-auto[x-placement^=bottom] .popover-header::before,.bs-popover-bottom .popover-header::before{position:absolute;top:0;left:50%;display:block;width:1rem;margin-left:-.5rem;content:"";border-bottom:1px solid #202020}.bs-popover-auto[x-placement^=left],.bs-popover-left{margin-right:.5rem}.bs-popover-auto[x-placement^=left]>.arrow,.bs-popover-left>.arrow{right:calc(-.5rem - 1px);width:.5rem;height:1rem;margin:.3rem 0}.bs-popover-auto[x-placement^=left]>.arrow::before,.bs-popover-left>.arrow::before{right:0;border-width:.5rem 0 .5rem .5rem;border-left-color:rgba(0,0,0,.25)}.bs-popover-auto[x-placement^=left]>.arrow::after,.bs-popover-left>.arrow::after{right:1px;border-width:.5rem 0 .5rem .5rem;border-left-color:#282828}.popover-header{padding:.5rem .75rem;margin-bottom:0;font-size:.875rem;color:#fff;background-color:#202020;border-bottom:1px solid #141414;border-top-left-radius:calc(.3rem - 1px);border-top-right-radius:calc(.3rem - 1px)}.popover-header:empty{display:none}.popover-body{padding:.5rem .75rem;color:#adafae}.carousel{position:relative}.carousel.pointer-event{-ms-touch-action:pan-y;touch-action:pan-y}.carousel-inner{position:relative;width:100%;overflow:hidden}.carousel-inner::after{display:block;clear:both;content:""}.carousel-item{position:relative;display:none;float:left;width:100%;margin-right:-100%;-webkit-backface-visibility:hidden;backface-visibility:hidden;transition:-webkit-transform .6s ease-in-out;transition:transform .6s ease-in-out;transition:transform .6s ease-in-out,-webkit-transform .6s ease-in-out}@media (prefers-reduced-motion:reduce){.carousel-item{transition:none}}.carousel-item-next,.carousel-item-prev,.carousel-item.active{display:block}.active.carousel-item-right,.carousel-item-next:not(.carousel-item-left){-webkit-transform:translateX(100%);transform:translateX(100%)}.active.carousel-item-left,.carousel-item-prev:not(.carousel-item-right){-webkit-transform:translateX(-100%);transform:translateX(-100%)}.carousel-fade .carousel-item{opacity:0;transition-property:opacity;-webkit-transform:none;transform:none}.carousel-fade .carousel-item-next.carousel-item-left,.carousel-fade .carousel-item-prev.carousel-item-right,.carousel-fade .carousel-item.active{z-index:1;opacity:1}.carousel-fade .active.carousel-item-left,.carousel-fade .active.carousel-item-right{z-index:0;opacity:0;transition:opacity 0s .6s}@media (prefers-reduced-motion:reduce){.carousel-fade .active.carousel-item-left,.carousel-fade .active.carousel-item-right{transition:none}}.carousel-control-next,.carousel-control-prev{position:absolute;top:0;bottom:0;z-index:1;display:-ms-flexbox;display:flex;-ms-flex-align:center;align-items:center;-ms-flex-pack:center;justify-content:center;width:15%;color:#fff;text-align:center;opacity:.5;transition:opacity .15s ease}@media (prefers-reduced-motion:reduce){.carousel-control-next,.carousel-control-prev{transition:none}}.carousel-control-next:focus,.carousel-control-next:hover,.carousel-control-prev:focus,.carousel-control-prev:hover{color:#fff;text-decoration:none;outline:0;opacity:.9}.carousel-control-prev{left:0}.carousel-control-next{right:0}.carousel-control-next-icon,.carousel-control-prev-icon{display:inline-block;width:20px;height:20px;background:no-repeat 50%/100% 100%}.carousel-control-prev-icon{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' width='8' height='8' viewBox='0 0 8 8'%3e%3cpath d='M5.25 0l-4 4 4 4 1.5-1.5L4.25 4l2.5-2.5L5.25 0z'/%3e%3c/svg%3e")}.carousel-control-next-icon{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' width='8' height='8' viewBox='0 0 8 8'%3e%3cpath d='M2.75 0l-1.5 1.5L3.75 4l-2.5 2.5L2.75 8l4-4-4-4z'/%3e%3c/svg%3e")}.carousel-indicators{position:absolute;right:0;bottom:0;left:0;z-index:15;display:-ms-flexbox;display:flex;-ms-flex-pack:center;justify-content:center;padding-left:0;margin-right:15%;margin-left:15%;list-style:none}.carousel-indicators li{box-sizing:content-box;-ms-flex:0 1 auto;flex:0 1 auto;width:30px;height:3px;margin-right:3px;margin-left:3px;text-indent:-999px;cursor:pointer;background-color:#fff;background-clip:padding-box;border-top:10px solid transparent;border-bottom:10px solid transparent;opacity:.5;transition:opacity .6s ease}@media (prefers-reduced-motion:reduce){.carousel-indicators li{transition:none}}.carousel-indicators .active{opacity:1}.carousel-caption{position:absolute;right:15%;bottom:20px;left:15%;z-index:10;padding-top:20px;padding-bottom:20px;color:#fff;text-align:center}@-webkit-keyframes spinner-border{to{-webkit-transform:rotate(360deg);transform:rotate(360deg)}}@keyframes spinner-border{to{-webkit-transform:rotate(360deg);transform:rotate(360deg)}}.spinner-border{display:inline-block;width:2rem;height:2rem;vertical-align:text-bottom;border:.25em solid currentColor;border-right-color:transparent;border-radius:50%;-webkit-animation:spinner-border .75s linear infinite;animation:spinner-border .75s linear infinite}.spinner-border-sm{width:1rem;height:1rem;border-width:.2em}@-webkit-keyframes spinner-grow{0%{-webkit-transform:scale(0);transform:scale(0)}50%{opacity:1;-webkit-transform:none;transform:none}}@keyframes spinner-grow{0%{-webkit-transform:scale(0);transform:scale(0)}50%{opacity:1;-webkit-transform:none;transform:none}}.spinner-grow{display:inline-block;width:2rem;height:2rem;vertical-align:text-bottom;background-color:currentColor;border-radius:50%;opacity:0;-webkit-animation:spinner-grow .75s linear infinite;animation:spinner-grow .75s linear infinite}.spinner-grow-sm{width:1rem;height:1rem}.align-baseline{vertical-align:baseline!important}.align-top{vertical-align:top!important}.align-middle{vertical-align:middle!important}.align-bottom{vertical-align:bottom!important}.align-text-bottom{vertical-align:text-bottom!important}.align-text-top{vertical-align:text-top!important}.bg-primary{background-color:#2a9fd6!important}a.bg-primary:focus,a.bg-primary:hover,button.bg-primary:focus,button.bg-primary:hover{background-color:#2180ac!important}.bg-secondary{background-color:#555!important}a.bg-secondary:focus,a.bg-secondary:hover,button.bg-secondary:focus,button.bg-secondary:hover{background-color:#3c3c3c!important}.bg-success{background-color:#77b300!important}a.bg-success:focus,a.bg-success:hover,button.bg-success:focus,button.bg-success:hover{background-color:#558000!important}.bg-info{background-color:#93c!important}a.bg-info:focus,a.bg-info:hover,button.bg-info:focus,button.bg-info:hover{background-color:#7a29a3!important}.bg-warning{background-color:#f80!important}a.bg-warning:focus,a.bg-warning:hover,button.bg-warning:focus,button.bg-warning:hover{background-color:#cc6d00!important}.bg-danger{background-color:#c00!important}a.bg-danger:focus,a.bg-danger:hover,button.bg-danger:focus,button.bg-danger:hover{background-color:#900!important}.bg-light{background-color:#222!important}a.bg-light:focus,a.bg-light:hover,button.bg-light:focus,button.bg-light:hover{background-color:#090909!important}.bg-dark{background-color:#adafae!important}a.bg-dark:focus,a.bg-dark:hover,button.bg-dark:focus,button.bg-dark:hover{background-color:#939695!important}.bg-white{background-color:#fff!important}.bg-transparent{background-color:transparent!important}.border{border:1px solid #dee2e6!important}.border-top{border-top:1px solid #dee2e6!important}.border-right{border-right:1px solid #dee2e6!important}.border-bottom{border-bottom:1px solid #dee2e6!important}.border-left{border-left:1px solid #dee2e6!important}.border-0{border:0!important}.border-top-0{border-top:0!important}.border-right-0{border-right:0!important}.border-bottom-0{border-bottom:0!important}.border-left-0{border-left:0!important}.border-primary{border-color:#2a9fd6!important}.border-secondary{border-color:#555!important}.border-success{border-color:#77b300!important}.border-info{border-color:#93c!important}.border-warning{border-color:#f80!important}.border-danger{border-color:#c00!important}.border-light{border-color:#222!important}.border-dark{border-color:#adafae!important}.border-white{border-color:#fff!important}.rounded-sm{border-radius:.2rem!important}.rounded{border-radius:.25rem!important}.rounded-top{border-top-left-radius:.25rem!important;border-top-right-radius:.25rem!important}.rounded-right{border-top-right-radius:.25rem!important;border-bottom-right-radius:.25rem!important}.rounded-bottom{border-bottom-right-radius:.25rem!important;border-bottom-left-radius:.25rem!important}.rounded-left{border-top-left-radius:.25rem!important;border-bottom-left-radius:.25rem!important}.rounded-lg{border-radius:.3rem!important}.rounded-circle{border-radius:50%!important}.rounded-pill{border-radius:50rem!important}.rounded-0{border-radius:0!important}.clearfix::after{display:block;clear:both;content:""}.d-none{display:none!important}.d-inline{display:inline!important}.d-inline-block{display:inline-block!important}.d-block{display:block!important}.d-table{display:table!important}.d-table-row{display:table-row!important}.d-table-cell{display:table-cell!important}.d-flex{display:-ms-flexbox!important;display:flex!important}.d-inline-flex{display:-ms-inline-flexbox!important;display:inline-flex!important}@media (min-width:576px){.d-sm-none{display:none!important}.d-sm-inline{display:inline!important}.d-sm-inline-block{display:inline-block!important}.d-sm-block{display:block!important}.d-sm-table{display:table!important}.d-sm-table-row{display:table-row!important}.d-sm-table-cell{display:table-cell!important}.d-sm-flex{display:-ms-flexbox!important;display:flex!important}.d-sm-inline-flex{display:-ms-inline-flexbox!important;display:inline-flex!important}}@media (min-width:768px){.d-md-none{display:none!important}.d-md-inline{display:inline!important}.d-md-inline-block{display:inline-block!important}.d-md-block{display:block!important}.d-md-table{display:table!important}.d-md-table-row{display:table-row!important}.d-md-table-cell{display:table-cell!important}.d-md-flex{display:-ms-flexbox!important;display:flex!important}.d-md-inline-flex{display:-ms-inline-flexbox!important;display:inline-flex!important}}@media (min-width:992px){.d-lg-none{display:none!important}.d-lg-inline{display:inline!important}.d-lg-inline-block{display:inline-block!important}.d-lg-block{display:block!important}.d-lg-table{display:table!important}.d-lg-table-row{display:table-row!important}.d-lg-table-cell{display:table-cell!important}.d-lg-flex{display:-ms-flexbox!important;display:flex!important}.d-lg-inline-flex{display:-ms-inline-flexbox!important;display:inline-flex!important}}@media (min-width:1200px){.d-xl-none{display:none!important}.d-xl-inline{display:inline!important}.d-xl-inline-block{display:inline-block!important}.d-xl-block{display:block!important}.d-xl-table{display:table!important}.d-xl-table-row{display:table-row!important}.d-xl-table-cell{display:table-cell!important}.d-xl-flex{display:-ms-flexbox!important;display:flex!important}.d-xl-inline-flex{display:-ms-inline-flexbox!important;display:inline-flex!important}}@media print{.d-print-none{display:none!important}.d-print-inline{display:inline!important}.d-print-inline-block{display:inline-block!important}.d-print-block{display:block!important}.d-print-table{display:table!important}.d-print-table-row{display:table-row!important}.d-print-table-cell{display:table-cell!important}.d-print-flex{display:-ms-flexbox!important;display:flex!important}.d-print-inline-flex{display:-ms-inline-flexbox!important;display:inline-flex!important}}.embed-responsive{position:relative;display:block;width:100%;padding:0;overflow:hidden}.embed-responsive::before{display:block;content:""}.embed-responsive .embed-responsive-item,.embed-responsive embed,.embed-responsive iframe,.embed-responsive object,.embed-responsive video{position:absolute;top:0;bottom:0;left:0;width:100%;height:100%;border:0}.embed-responsive-21by9::before{padding-top:42.857143%}.embed-responsive-16by9::before{padding-top:56.25%}.embed-responsive-4by3::before{padding-top:75%}.embed-responsive-1by1::before{padding-top:100%}.flex-row{-ms-flex-direction:row!important;flex-direction:row!important}.flex-column{-ms-flex-direction:column!important;flex-direction:column!important}.flex-row-reverse{-ms-flex-direction:row-reverse!important;flex-direction:row-reverse!important}.flex-column-reverse{-ms-flex-direction:column-reverse!important;flex-direction:column-reverse!important}.flex-wrap{-ms-flex-wrap:wrap!important;flex-wrap:wrap!important}.flex-nowrap{-ms-flex-wrap:nowrap!important;flex-wrap:nowrap!important}.flex-wrap-reverse{-ms-flex-wrap:wrap-reverse!important;flex-wrap:wrap-reverse!important}.flex-fill{-ms-flex:1 1 auto!important;flex:1 1 auto!important}.flex-grow-0{-ms-flex-positive:0!important;flex-grow:0!important}.flex-grow-1{-ms-flex-positive:1!important;flex-grow:1!important}.flex-shrink-0{-ms-flex-negative:0!important;flex-shrink:0!important}.flex-shrink-1{-ms-flex-negative:1!important;flex-shrink:1!important}.justify-content-start{-ms-flex-pack:start!important;justify-content:flex-start!important}.justify-content-end{-ms-flex-pack:end!important;justify-content:flex-end!important}.justify-content-center{-ms-flex-pack:center!important;justify-content:center!important}.justify-content-between{-ms-flex-pack:justify!important;justify-content:space-between!important}.justify-content-around{-ms-flex-pack:distribute!important;justify-content:space-around!important}.align-items-start{-ms-flex-align:start!important;align-items:flex-start!important}.align-items-end{-ms-flex-align:end!important;align-items:flex-end!important}.align-items-center{-ms-flex-align:center!important;align-items:center!important}.align-items-baseline{-ms-flex-align:baseline!important;align-items:baseline!important}.align-items-stretch{-ms-flex-align:stretch!important;align-items:stretch!important}.align-content-start{-ms-flex-line-pack:start!important;align-content:flex-start!important}.align-content-end{-ms-flex-line-pack:end!important;align-content:flex-end!important}.align-content-center{-ms-flex-line-pack:center!important;align-content:center!important}.align-content-between{-ms-flex-line-pack:justify!important;align-content:space-between!important}.align-content-around{-ms-flex-line-pack:distribute!important;align-content:space-around!important}.align-content-stretch{-ms-flex-line-pack:stretch!important;align-content:stretch!important}.align-self-auto{-ms-flex-item-align:auto!important;align-self:auto!important}.align-self-start{-ms-flex-item-align:start!important;align-self:flex-start!important}.align-self-end{-ms-flex-item-align:end!important;align-self:flex-end!important}.align-self-center{-ms-flex-item-align:center!important;align-self:center!important}.align-self-baseline{-ms-flex-item-align:baseline!important;align-self:baseline!important}.align-self-stretch{-ms-flex-item-align:stretch!important;align-self:stretch!important}@media (min-width:576px){.flex-sm-row{-ms-flex-direction:row!important;flex-direction:row!important}.flex-sm-column{-ms-flex-direction:column!important;flex-direction:column!important}.flex-sm-row-reverse{-ms-flex-direction:row-reverse!important;flex-direction:row-reverse!important}.flex-sm-column-reverse{-ms-flex-direction:column-reverse!important;flex-direction:column-reverse!important}.flex-sm-wrap{-ms-flex-wrap:wrap!important;flex-wrap:wrap!important}.flex-sm-nowrap{-ms-flex-wrap:nowrap!important;flex-wrap:nowrap!important}.flex-sm-wrap-reverse{-ms-flex-wrap:wrap-reverse!important;flex-wrap:wrap-reverse!important}.flex-sm-fill{-ms-flex:1 1 auto!important;flex:1 1 auto!important}.flex-sm-grow-0{-ms-flex-positive:0!important;flex-grow:0!important}.flex-sm-grow-1{-ms-flex-positive:1!important;flex-grow:1!important}.flex-sm-shrink-0{-ms-flex-negative:0!important;flex-shrink:0!important}.flex-sm-shrink-1{-ms-flex-negative:1!important;flex-shrink:1!important}.justify-content-sm-start{-ms-flex-pack:start!important;justify-content:flex-start!important}.justify-content-sm-end{-ms-flex-pack:end!important;justify-content:flex-end!important}.justify-content-sm-center{-ms-flex-pack:center!important;justify-content:center!important}.justify-content-sm-between{-ms-flex-pack:justify!important;justify-content:space-between!important}.justify-content-sm-around{-ms-flex-pack:distribute!important;justify-content:space-around!important}.align-items-sm-start{-ms-flex-align:start!important;align-items:flex-start!important}.align-items-sm-end{-ms-flex-align:end!important;align-items:flex-end!important}.align-items-sm-center{-ms-flex-align:center!important;align-items:center!important}.align-items-sm-baseline{-ms-flex-align:baseline!important;align-items:baseline!important}.align-items-sm-stretch{-ms-flex-align:stretch!important;align-items:stretch!important}.align-content-sm-start{-ms-flex-line-pack:start!important;align-content:flex-start!important}.align-content-sm-end{-ms-flex-line-pack:end!important;align-content:flex-end!important}.align-content-sm-center{-ms-flex-line-pack:center!important;align-content:center!important}.align-content-sm-between{-ms-flex-line-pack:justify!important;align-content:space-between!important}.align-content-sm-around{-ms-flex-line-pack:distribute!important;align-content:space-around!important}.align-content-sm-stretch{-ms-flex-line-pack:stretch!important;align-content:stretch!important}.align-self-sm-auto{-ms-flex-item-align:auto!important;align-self:auto!important}.align-self-sm-start{-ms-flex-item-align:start!important;align-self:flex-start!important}.align-self-sm-end{-ms-flex-item-align:end!important;align-self:flex-end!important}.align-self-sm-center{-ms-flex-item-align:center!important;align-self:center!important}.align-self-sm-baseline{-ms-flex-item-align:baseline!important;align-self:baseline!important}.align-self-sm-stretch{-ms-flex-item-align:stretch!important;align-self:stretch!important}}@media (min-width:768px){.flex-md-row{-ms-flex-direction:row!important;flex-direction:row!important}.flex-md-column{-ms-flex-direction:column!important;flex-direction:column!important}.flex-md-row-reverse{-ms-flex-direction:row-reverse!important;flex-direction:row-reverse!important}.flex-md-column-reverse{-ms-flex-direction:column-reverse!important;flex-direction:column-reverse!important}.flex-md-wrap{-ms-flex-wrap:wrap!important;flex-wrap:wrap!important}.flex-md-nowrap{-ms-flex-wrap:nowrap!important;flex-wrap:nowrap!important}.flex-md-wrap-reverse{-ms-flex-wrap:wrap-reverse!important;flex-wrap:wrap-reverse!important}.flex-md-fill{-ms-flex:1 1 auto!important;flex:1 1 auto!important}.flex-md-grow-0{-ms-flex-positive:0!important;flex-grow:0!important}.flex-md-grow-1{-ms-flex-positive:1!important;flex-grow:1!important}.flex-md-shrink-0{-ms-flex-negative:0!important;flex-shrink:0!important}.flex-md-shrink-1{-ms-flex-negative:1!important;flex-shrink:1!important}.justify-content-md-start{-ms-flex-pack:start!important;justify-content:flex-start!important}.justify-content-md-end{-ms-flex-pack:end!important;justify-content:flex-end!important}.justify-content-md-center{-ms-flex-pack:center!important;justify-content:center!important}.justify-content-md-between{-ms-flex-pack:justify!important;justify-content:space-between!important}.justify-content-md-around{-ms-flex-pack:distribute!important;justify-content:space-around!important}.align-items-md-start{-ms-flex-align:start!important;align-items:flex-start!important}.align-items-md-end{-ms-flex-align:end!important;align-items:flex-end!important}.align-items-md-center{-ms-flex-align:center!important;align-items:center!important}.align-items-md-baseline{-ms-flex-align:baseline!important;align-items:baseline!important}.align-items-md-stretch{-ms-flex-align:stretch!important;align-items:stretch!important}.align-content-md-start{-ms-flex-line-pack:start!important;align-content:flex-start!important}.align-content-md-end{-ms-flex-line-pack:end!important;align-content:flex-end!important}.align-content-md-center{-ms-flex-line-pack:center!important;align-content:center!important}.align-content-md-between{-ms-flex-line-pack:justify!important;align-content:space-between!important}.align-content-md-around{-ms-flex-line-pack:distribute!important;align-content:space-around!important}.align-content-md-stretch{-ms-flex-line-pack:stretch!important;align-content:stretch!important}.align-self-md-auto{-ms-flex-item-align:auto!important;align-self:auto!important}.align-self-md-start{-ms-flex-item-align:start!important;align-self:flex-start!important}.align-self-md-end{-ms-flex-item-align:end!important;align-self:flex-end!important}.align-self-md-center{-ms-flex-item-align:center!important;align-self:center!important}.align-self-md-baseline{-ms-flex-item-align:baseline!important;align-self:baseline!important}.align-self-md-stretch{-ms-flex-item-align:stretch!important;align-self:stretch!important}}@media (min-width:992px){.flex-lg-row{-ms-flex-direction:row!important;flex-direction:row!important}.flex-lg-column{-ms-flex-direction:column!important;flex-direction:column!important}.flex-lg-row-reverse{-ms-flex-direction:row-reverse!important;flex-direction:row-reverse!important}.flex-lg-column-reverse{-ms-flex-direction:column-reverse!important;flex-direction:column-reverse!important}.flex-lg-wrap{-ms-flex-wrap:wrap!important;flex-wrap:wrap!important}.flex-lg-nowrap{-ms-flex-wrap:nowrap!important;flex-wrap:nowrap!important}.flex-lg-wrap-reverse{-ms-flex-wrap:wrap-reverse!important;flex-wrap:wrap-reverse!important}.flex-lg-fill{-ms-flex:1 1 auto!important;flex:1 1 auto!important}.flex-lg-grow-0{-ms-flex-positive:0!important;flex-grow:0!important}.flex-lg-grow-1{-ms-flex-positive:1!important;flex-grow:1!important}.flex-lg-shrink-0{-ms-flex-negative:0!important;flex-shrink:0!important}.flex-lg-shrink-1{-ms-flex-negative:1!important;flex-shrink:1!important}.justify-content-lg-start{-ms-flex-pack:start!important;justify-content:flex-start!important}.justify-content-lg-end{-ms-flex-pack:end!important;justify-content:flex-end!important}.justify-content-lg-center{-ms-flex-pack:center!important;justify-content:center!important}.justify-content-lg-between{-ms-flex-pack:justify!important;justify-content:space-between!important}.justify-content-lg-around{-ms-flex-pack:distribute!important;justify-content:space-around!important}.align-items-lg-start{-ms-flex-align:start!important;align-items:flex-start!important}.align-items-lg-end{-ms-flex-align:end!important;align-items:flex-end!important}.align-items-lg-center{-ms-flex-align:center!important;align-items:center!important}.align-items-lg-baseline{-ms-flex-align:baseline!important;align-items:baseline!important}.align-items-lg-stretch{-ms-flex-align:stretch!important;align-items:stretch!important}.align-content-lg-start{-ms-flex-line-pack:start!important;align-content:flex-start!important}.align-content-lg-end{-ms-flex-line-pack:end!important;align-content:flex-end!important}.align-content-lg-center{-ms-flex-line-pack:center!important;align-content:center!important}.align-content-lg-between{-ms-flex-line-pack:justify!important;align-content:space-between!important}.align-content-lg-around{-ms-flex-line-pack:distribute!important;align-content:space-around!important}.align-content-lg-stretch{-ms-flex-line-pack:stretch!important;align-content:stretch!important}.align-self-lg-auto{-ms-flex-item-align:auto!important;align-self:auto!important}.align-self-lg-start{-ms-flex-item-align:start!important;align-self:flex-start!important}.align-self-lg-end{-ms-flex-item-align:end!important;align-self:flex-end!important}.align-self-lg-center{-ms-flex-item-align:center!important;align-self:center!important}.align-self-lg-baseline{-ms-flex-item-align:baseline!important;align-self:baseline!important}.align-self-lg-stretch{-ms-flex-item-align:stretch!important;align-self:stretch!important}}@media (min-width:1200px){.flex-xl-row{-ms-flex-direction:row!important;flex-direction:row!important}.flex-xl-column{-ms-flex-direction:column!important;flex-direction:column!important}.flex-xl-row-reverse{-ms-flex-direction:row-reverse!important;flex-direction:row-reverse!important}.flex-xl-column-reverse{-ms-flex-direction:column-reverse!important;flex-direction:column-reverse!important}.flex-xl-wrap{-ms-flex-wrap:wrap!important;flex-wrap:wrap!important}.flex-xl-nowrap{-ms-flex-wrap:nowrap!important;flex-wrap:nowrap!important}.flex-xl-wrap-reverse{-ms-flex-wrap:wrap-reverse!important;flex-wrap:wrap-reverse!important}.flex-xl-fill{-ms-flex:1 1 auto!important;flex:1 1 auto!important}.flex-xl-grow-0{-ms-flex-positive:0!important;flex-grow:0!important}.flex-xl-grow-1{-ms-flex-positive:1!important;flex-grow:1!important}.flex-xl-shrink-0{-ms-flex-negative:0!important;flex-shrink:0!important}.flex-xl-shrink-1{-ms-flex-negative:1!important;flex-shrink:1!important}.justify-content-xl-start{-ms-flex-pack:start!important;justify-content:flex-start!important}.justify-content-xl-end{-ms-flex-pack:end!important;justify-content:flex-end!important}.justify-content-xl-center{-ms-flex-pack:center!important;justify-content:center!important}.justify-content-xl-between{-ms-flex-pack:justify!important;justify-content:space-between!important}.justify-content-xl-around{-ms-flex-pack:distribute!important;justify-content:space-around!important}.align-items-xl-start{-ms-flex-align:start!important;align-items:flex-start!important}.align-items-xl-end{-ms-flex-align:end!important;align-items:flex-end!important}.align-items-xl-center{-ms-flex-align:center!important;align-items:center!important}.align-items-xl-baseline{-ms-flex-align:baseline!important;align-items:baseline!important}.align-items-xl-stretch{-ms-flex-align:stretch!important;align-items:stretch!important}.align-content-xl-start{-ms-flex-line-pack:start!important;align-content:flex-start!important}.align-content-xl-end{-ms-flex-line-pack:end!important;align-content:flex-end!important}.align-content-xl-center{-ms-flex-line-pack:center!important;align-content:center!important}.align-content-xl-between{-ms-flex-line-pack:justify!important;align-content:space-between!important}.align-content-xl-around{-ms-flex-line-pack:distribute!important;align-content:space-around!important}.align-content-xl-stretch{-ms-flex-line-pack:stretch!important;align-content:stretch!important}.align-self-xl-auto{-ms-flex-item-align:auto!important;align-self:auto!important}.align-self-xl-start{-ms-flex-item-align:start!important;align-self:flex-start!important}.align-self-xl-end{-ms-flex-item-align:end!important;align-self:flex-end!important}.align-self-xl-center{-ms-flex-item-align:center!important;align-self:center!important}.align-self-xl-baseline{-ms-flex-item-align:baseline!important;align-self:baseline!important}.align-self-xl-stretch{-ms-flex-item-align:stretch!important;align-self:stretch!important}}.float-left{float:left!important}.float-right{float:right!important}.float-none{float:none!important}@media (min-width:576px){.float-sm-left{float:left!important}.float-sm-right{float:right!important}.float-sm-none{float:none!important}}@media (min-width:768px){.float-md-left{float:left!important}.float-md-right{float:right!important}.float-md-none{float:none!important}}@media (min-width:992px){.float-lg-left{float:left!important}.float-lg-right{float:right!important}.float-lg-none{float:none!important}}@media (min-width:1200px){.float-xl-left{float:left!important}.float-xl-right{float:right!important}.float-xl-none{float:none!important}}.user-select-all{-webkit-user-select:all!important;-moz-user-select:all!important;-ms-user-select:all!important;user-select:all!important}.user-select-auto{-webkit-user-select:auto!important;-moz-user-select:auto!important;-ms-user-select:auto!important;user-select:auto!important}.user-select-none{-webkit-user-select:none!important;-moz-user-select:none!important;-ms-user-select:none!important;user-select:none!important}.overflow-auto{overflow:auto!important}.overflow-hidden{overflow:hidden!important}.position-static{position:static!important}.position-relative{position:relative!important}.position-absolute{position:absolute!important}.position-fixed{position:fixed!important}.position-sticky{position:-webkit-sticky!important;position:sticky!important}.fixed-top{position:fixed;top:0;right:0;left:0;z-index:1030}.fixed-bottom{position:fixed;right:0;bottom:0;left:0;z-index:1030}@supports ((position:-webkit-sticky) or (position:sticky)){.sticky-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}.sr-only-focusable:active,.sr-only-focusable:focus{position:static;width:auto;height:auto;overflow:visible;clip:auto;white-space:normal}.shadow-sm{box-shadow:0 .125rem .25rem rgba(0,0,0,.075)!important}.shadow{box-shadow:0 .5rem 1rem rgba(0,0,0,.15)!important}.shadow-lg{box-shadow:0 1rem 3rem rgba(0,0,0,.175)!important}.shadow-none{box-shadow:none!important}.w-25{width:25%!important}.w-50{width:50%!important}.w-75{width:75%!important}.w-100{width:100%!important}.w-auto{width:auto!important}.h-25{height:25%!important}.h-50{height:50%!important}.h-75{height:75%!important}.h-100{height:100%!important}.h-auto{height:auto!important}.mw-100{max-width:100%!important}.mh-100{max-height:100%!important}.min-vw-100{min-width:100vw!important}.min-vh-100{min-height:100vh!important}.vw-100{width:100vw!important}.vh-100{height:100vh!important}.m-0{margin:0!important}.mt-0,.my-0{margin-top:0!important}.mr-0,.mx-0{margin-right:0!important}.mb-0,.my-0{margin-bottom:0!important}.ml-0,.mx-0{margin-left:0!important}.m-1{margin:.25rem!important}.mt-1,.my-1{margin-top:.25rem!important}.mr-1,.mx-1{margin-right:.25rem!important}.mb-1,.my-1{margin-bottom:.25rem!important}.ml-1,.mx-1{margin-left:.25rem!important}.m-2{margin:.5rem!important}.mt-2,.my-2{margin-top:.5rem!important}.mr-2,.mx-2{margin-right:.5rem!important}.mb-2,.my-2{margin-bottom:.5rem!important}.ml-2,.mx-2{margin-left:.5rem!important}.m-3{margin:1rem!important}.mt-3,.my-3{margin-top:1rem!important}.mr-3,.mx-3{margin-right:1rem!important}.mb-3,.my-3{margin-bottom:1rem!important}.ml-3,.mx-3{margin-left:1rem!important}.m-4{margin:1.5rem!important}.mt-4,.my-4{margin-top:1.5rem!important}.mr-4,.mx-4{margin-right:1.5rem!important}.mb-4,.my-4{margin-bottom:1.5rem!important}.ml-4,.mx-4{margin-left:1.5rem!important}.m-5{margin:3rem!important}.mt-5,.my-5{margin-top:3rem!important}.mr-5,.mx-5{margin-right:3rem!important}.mb-5,.my-5{margin-bottom:3rem!important}.ml-5,.mx-5{margin-left:3rem!important}.p-0{padding:0!important}.pt-0,.py-0{padding-top:0!important}.pr-0,.px-0{padding-right:0!important}.pb-0,.py-0{padding-bottom:0!important}.pl-0,.px-0{padding-left:0!important}.p-1{padding:.25rem!important}.pt-1,.py-1{padding-top:.25rem!important}.pr-1,.px-1{padding-right:.25rem!important}.pb-1,.py-1{padding-bottom:.25rem!important}.pl-1,.px-1{padding-left:.25rem!important}.p-2{padding:.5rem!important}.pt-2,.py-2{padding-top:.5rem!important}.pr-2,.px-2{padding-right:.5rem!important}.pb-2,.py-2{padding-bottom:.5rem!important}.pl-2,.px-2{padding-left:.5rem!important}.p-3{padding:1rem!important}.pt-3,.py-3{padding-top:1rem!important}.pr-3,.px-3{padding-right:1rem!important}.pb-3,.py-3{padding-bottom:1rem!important}.pl-3,.px-3{padding-left:1rem!important}.p-4{padding:1.5rem!important}.pt-4,.py-4{padding-top:1.5rem!important}.pr-4,.px-4{padding-right:1.5rem!important}.pb-4,.py-4{padding-bottom:1.5rem!important}.pl-4,.px-4{padding-left:1.5rem!important}.p-5{padding:3rem!important}.pt-5,.py-5{padding-top:3rem!important}.pr-5,.px-5{padding-right:3rem!important}.pb-5,.py-5{padding-bottom:3rem!important}.pl-5,.px-5{padding-left:3rem!important}.m-n1{margin:-.25rem!important}.mt-n1,.my-n1{margin-top:-.25rem!important}.mr-n1,.mx-n1{margin-right:-.25rem!important}.mb-n1,.my-n1{margin-bottom:-.25rem!important}.ml-n1,.mx-n1{margin-left:-.25rem!important}.m-n2{margin:-.5rem!important}.mt-n2,.my-n2{margin-top:-.5rem!important}.mr-n2,.mx-n2{margin-right:-.5rem!important}.mb-n2,.my-n2{margin-bottom:-.5rem!important}.ml-n2,.mx-n2{margin-left:-.5rem!important}.m-n3{margin:-1rem!important}.mt-n3,.my-n3{margin-top:-1rem!important}.mr-n3,.mx-n3{margin-right:-1rem!important}.mb-n3,.my-n3{margin-bottom:-1rem!important}.ml-n3,.mx-n3{margin-left:-1rem!important}.m-n4{margin:-1.5rem!important}.mt-n4,.my-n4{margin-top:-1.5rem!important}.mr-n4,.mx-n4{margin-right:-1.5rem!important}.mb-n4,.my-n4{margin-bottom:-1.5rem!important}.ml-n4,.mx-n4{margin-left:-1.5rem!important}.m-n5{margin:-3rem!important}.mt-n5,.my-n5{margin-top:-3rem!important}.mr-n5,.mx-n5{margin-right:-3rem!important}.mb-n5,.my-n5{margin-bottom:-3rem!important}.ml-n5,.mx-n5{margin-left:-3rem!important}.m-auto{margin:auto!important}.mt-auto,.my-auto{margin-top:auto!important}.mr-auto,.mx-auto{margin-right:auto!important}.mb-auto,.my-auto{margin-bottom:auto!important}.ml-auto,.mx-auto{margin-left:auto!important}@media (min-width:576px){.m-sm-0{margin:0!important}.mt-sm-0,.my-sm-0{margin-top:0!important}.mr-sm-0,.mx-sm-0{margin-right:0!important}.mb-sm-0,.my-sm-0{margin-bottom:0!important}.ml-sm-0,.mx-sm-0{margin-left:0!important}.m-sm-1{margin:.25rem!important}.mt-sm-1,.my-sm-1{margin-top:.25rem!important}.mr-sm-1,.mx-sm-1{margin-right:.25rem!important}.mb-sm-1,.my-sm-1{margin-bottom:.25rem!important}.ml-sm-1,.mx-sm-1{margin-left:.25rem!important}.m-sm-2{margin:.5rem!important}.mt-sm-2,.my-sm-2{margin-top:.5rem!important}.mr-sm-2,.mx-sm-2{margin-right:.5rem!important}.mb-sm-2,.my-sm-2{margin-bottom:.5rem!important}.ml-sm-2,.mx-sm-2{margin-left:.5rem!important}.m-sm-3{margin:1rem!important}.mt-sm-3,.my-sm-3{margin-top:1rem!important}.mr-sm-3,.mx-sm-3{margin-right:1rem!important}.mb-sm-3,.my-sm-3{margin-bottom:1rem!important}.ml-sm-3,.mx-sm-3{margin-left:1rem!important}.m-sm-4{margin:1.5rem!important}.mt-sm-4,.my-sm-4{margin-top:1.5rem!important}.mr-sm-4,.mx-sm-4{margin-right:1.5rem!important}.mb-sm-4,.my-sm-4{margin-bottom:1.5rem!important}.ml-sm-4,.mx-sm-4{margin-left:1.5rem!important}.m-sm-5{margin:3rem!important}.mt-sm-5,.my-sm-5{margin-top:3rem!important}.mr-sm-5,.mx-sm-5{margin-right:3rem!important}.mb-sm-5,.my-sm-5{margin-bottom:3rem!important}.ml-sm-5,.mx-sm-5{margin-left:3rem!important}.p-sm-0{padding:0!important}.pt-sm-0,.py-sm-0{padding-top:0!important}.pr-sm-0,.px-sm-0{padding-right:0!important}.pb-sm-0,.py-sm-0{padding-bottom:0!important}.pl-sm-0,.px-sm-0{padding-left:0!important}.p-sm-1{padding:.25rem!important}.pt-sm-1,.py-sm-1{padding-top:.25rem!important}.pr-sm-1,.px-sm-1{padding-right:.25rem!important}.pb-sm-1,.py-sm-1{padding-bottom:.25rem!important}.pl-sm-1,.px-sm-1{padding-left:.25rem!important}.p-sm-2{padding:.5rem!important}.pt-sm-2,.py-sm-2{padding-top:.5rem!important}.pr-sm-2,.px-sm-2{padding-right:.5rem!important}.pb-sm-2,.py-sm-2{padding-bottom:.5rem!important}.pl-sm-2,.px-sm-2{padding-left:.5rem!important}.p-sm-3{padding:1rem!important}.pt-sm-3,.py-sm-3{padding-top:1rem!important}.pr-sm-3,.px-sm-3{padding-right:1rem!important}.pb-sm-3,.py-sm-3{padding-bottom:1rem!important}.pl-sm-3,.px-sm-3{padding-left:1rem!important}.p-sm-4{padding:1.5rem!important}.pt-sm-4,.py-sm-4{padding-top:1.5rem!important}.pr-sm-4,.px-sm-4{padding-right:1.5rem!important}.pb-sm-4,.py-sm-4{padding-bottom:1.5rem!important}.pl-sm-4,.px-sm-4{padding-left:1.5rem!important}.p-sm-5{padding:3rem!important}.pt-sm-5,.py-sm-5{padding-top:3rem!important}.pr-sm-5,.px-sm-5{padding-right:3rem!important}.pb-sm-5,.py-sm-5{padding-bottom:3rem!important}.pl-sm-5,.px-sm-5{padding-left:3rem!important}.m-sm-n1{margin:-.25rem!important}.mt-sm-n1,.my-sm-n1{margin-top:-.25rem!important}.mr-sm-n1,.mx-sm-n1{margin-right:-.25rem!important}.mb-sm-n1,.my-sm-n1{margin-bottom:-.25rem!important}.ml-sm-n1,.mx-sm-n1{margin-left:-.25rem!important}.m-sm-n2{margin:-.5rem!important}.mt-sm-n2,.my-sm-n2{margin-top:-.5rem!important}.mr-sm-n2,.mx-sm-n2{margin-right:-.5rem!important}.mb-sm-n2,.my-sm-n2{margin-bottom:-.5rem!important}.ml-sm-n2,.mx-sm-n2{margin-left:-.5rem!important}.m-sm-n3{margin:-1rem!important}.mt-sm-n3,.my-sm-n3{margin-top:-1rem!important}.mr-sm-n3,.mx-sm-n3{margin-right:-1rem!important}.mb-sm-n3,.my-sm-n3{margin-bottom:-1rem!important}.ml-sm-n3,.mx-sm-n3{margin-left:-1rem!important}.m-sm-n4{margin:-1.5rem!important}.mt-sm-n4,.my-sm-n4{margin-top:-1.5rem!important}.mr-sm-n4,.mx-sm-n4{margin-right:-1.5rem!important}.mb-sm-n4,.my-sm-n4{margin-bottom:-1.5rem!important}.ml-sm-n4,.mx-sm-n4{margin-left:-1.5rem!important}.m-sm-n5{margin:-3rem!important}.mt-sm-n5,.my-sm-n5{margin-top:-3rem!important}.mr-sm-n5,.mx-sm-n5{margin-right:-3rem!important}.mb-sm-n5,.my-sm-n5{margin-bottom:-3rem!important}.ml-sm-n5,.mx-sm-n5{margin-left:-3rem!important}.m-sm-auto{margin:auto!important}.mt-sm-auto,.my-sm-auto{margin-top:auto!important}.mr-sm-auto,.mx-sm-auto{margin-right:auto!important}.mb-sm-auto,.my-sm-auto{margin-bottom:auto!important}.ml-sm-auto,.mx-sm-auto{margin-left:auto!important}}@media (min-width:768px){.m-md-0{margin:0!important}.mt-md-0,.my-md-0{margin-top:0!important}.mr-md-0,.mx-md-0{margin-right:0!important}.mb-md-0,.my-md-0{margin-bottom:0!important}.ml-md-0,.mx-md-0{margin-left:0!important}.m-md-1{margin:.25rem!important}.mt-md-1,.my-md-1{margin-top:.25rem!important}.mr-md-1,.mx-md-1{margin-right:.25rem!important}.mb-md-1,.my-md-1{margin-bottom:.25rem!important}.ml-md-1,.mx-md-1{margin-left:.25rem!important}.m-md-2{margin:.5rem!important}.mt-md-2,.my-md-2{margin-top:.5rem!important}.mr-md-2,.mx-md-2{margin-right:.5rem!important}.mb-md-2,.my-md-2{margin-bottom:.5rem!important}.ml-md-2,.mx-md-2{margin-left:.5rem!important}.m-md-3{margin:1rem!important}.mt-md-3,.my-md-3{margin-top:1rem!important}.mr-md-3,.mx-md-3{margin-right:1rem!important}.mb-md-3,.my-md-3{margin-bottom:1rem!important}.ml-md-3,.mx-md-3{margin-left:1rem!important}.m-md-4{margin:1.5rem!important}.mt-md-4,.my-md-4{margin-top:1.5rem!important}.mr-md-4,.mx-md-4{margin-right:1.5rem!important}.mb-md-4,.my-md-4{margin-bottom:1.5rem!important}.ml-md-4,.mx-md-4{margin-left:1.5rem!important}.m-md-5{margin:3rem!important}.mt-md-5,.my-md-5{margin-top:3rem!important}.mr-md-5,.mx-md-5{margin-right:3rem!important}.mb-md-5,.my-md-5{margin-bottom:3rem!important}.ml-md-5,.mx-md-5{margin-left:3rem!important}.p-md-0{padding:0!important}.pt-md-0,.py-md-0{padding-top:0!important}.pr-md-0,.px-md-0{padding-right:0!important}.pb-md-0,.py-md-0{padding-bottom:0!important}.pl-md-0,.px-md-0{padding-left:0!important}.p-md-1{padding:.25rem!important}.pt-md-1,.py-md-1{padding-top:.25rem!important}.pr-md-1,.px-md-1{padding-right:.25rem!important}.pb-md-1,.py-md-1{padding-bottom:.25rem!important}.pl-md-1,.px-md-1{padding-left:.25rem!important}.p-md-2{padding:.5rem!important}.pt-md-2,.py-md-2{padding-top:.5rem!important}.pr-md-2,.px-md-2{padding-right:.5rem!important}.pb-md-2,.py-md-2{padding-bottom:.5rem!important}.pl-md-2,.px-md-2{padding-left:.5rem!important}.p-md-3{padding:1rem!important}.pt-md-3,.py-md-3{padding-top:1rem!important}.pr-md-3,.px-md-3{padding-right:1rem!important}.pb-md-3,.py-md-3{padding-bottom:1rem!important}.pl-md-3,.px-md-3{padding-left:1rem!important}.p-md-4{padding:1.5rem!important}.pt-md-4,.py-md-4{padding-top:1.5rem!important}.pr-md-4,.px-md-4{padding-right:1.5rem!important}.pb-md-4,.py-md-4{padding-bottom:1.5rem!important}.pl-md-4,.px-md-4{padding-left:1.5rem!important}.p-md-5{padding:3rem!important}.pt-md-5,.py-md-5{padding-top:3rem!important}.pr-md-5,.px-md-5{padding-right:3rem!important}.pb-md-5,.py-md-5{padding-bottom:3rem!important}.pl-md-5,.px-md-5{padding-left:3rem!important}.m-md-n1{margin:-.25rem!important}.mt-md-n1,.my-md-n1{margin-top:-.25rem!important}.mr-md-n1,.mx-md-n1{margin-right:-.25rem!important}.mb-md-n1,.my-md-n1{margin-bottom:-.25rem!important}.ml-md-n1,.mx-md-n1{margin-left:-.25rem!important}.m-md-n2{margin:-.5rem!important}.mt-md-n2,.my-md-n2{margin-top:-.5rem!important}.mr-md-n2,.mx-md-n2{margin-right:-.5rem!important}.mb-md-n2,.my-md-n2{margin-bottom:-.5rem!important}.ml-md-n2,.mx-md-n2{margin-left:-.5rem!important}.m-md-n3{margin:-1rem!important}.mt-md-n3,.my-md-n3{margin-top:-1rem!important}.mr-md-n3,.mx-md-n3{margin-right:-1rem!important}.mb-md-n3,.my-md-n3{margin-bottom:-1rem!important}.ml-md-n3,.mx-md-n3{margin-left:-1rem!important}.m-md-n4{margin:-1.5rem!important}.mt-md-n4,.my-md-n4{margin-top:-1.5rem!important}.mr-md-n4,.mx-md-n4{margin-right:-1.5rem!important}.mb-md-n4,.my-md-n4{margin-bottom:-1.5rem!important}.ml-md-n4,.mx-md-n4{margin-left:-1.5rem!important}.m-md-n5{margin:-3rem!important}.mt-md-n5,.my-md-n5{margin-top:-3rem!important}.mr-md-n5,.mx-md-n5{margin-right:-3rem!important}.mb-md-n5,.my-md-n5{margin-bottom:-3rem!important}.ml-md-n5,.mx-md-n5{margin-left:-3rem!important}.m-md-auto{margin:auto!important}.mt-md-auto,.my-md-auto{margin-top:auto!important}.mr-md-auto,.mx-md-auto{margin-right:auto!important}.mb-md-auto,.my-md-auto{margin-bottom:auto!important}.ml-md-auto,.mx-md-auto{margin-left:auto!important}}@media (min-width:992px){.m-lg-0{margin:0!important}.mt-lg-0,.my-lg-0{margin-top:0!important}.mr-lg-0,.mx-lg-0{margin-right:0!important}.mb-lg-0,.my-lg-0{margin-bottom:0!important}.ml-lg-0,.mx-lg-0{margin-left:0!important}.m-lg-1{margin:.25rem!important}.mt-lg-1,.my-lg-1{margin-top:.25rem!important}.mr-lg-1,.mx-lg-1{margin-right:.25rem!important}.mb-lg-1,.my-lg-1{margin-bottom:.25rem!important}.ml-lg-1,.mx-lg-1{margin-left:.25rem!important}.m-lg-2{margin:.5rem!important}.mt-lg-2,.my-lg-2{margin-top:.5rem!important}.mr-lg-2,.mx-lg-2{margin-right:.5rem!important}.mb-lg-2,.my-lg-2{margin-bottom:.5rem!important}.ml-lg-2,.mx-lg-2{margin-left:.5rem!important}.m-lg-3{margin:1rem!important}.mt-lg-3,.my-lg-3{margin-top:1rem!important}.mr-lg-3,.mx-lg-3{margin-right:1rem!important}.mb-lg-3,.my-lg-3{margin-bottom:1rem!important}.ml-lg-3,.mx-lg-3{margin-left:1rem!important}.m-lg-4{margin:1.5rem!important}.mt-lg-4,.my-lg-4{margin-top:1.5rem!important}.mr-lg-4,.mx-lg-4{margin-right:1.5rem!important}.mb-lg-4,.my-lg-4{margin-bottom:1.5rem!important}.ml-lg-4,.mx-lg-4{margin-left:1.5rem!important}.m-lg-5{margin:3rem!important}.mt-lg-5,.my-lg-5{margin-top:3rem!important}.mr-lg-5,.mx-lg-5{margin-right:3rem!important}.mb-lg-5,.my-lg-5{margin-bottom:3rem!important}.ml-lg-5,.mx-lg-5{margin-left:3rem!important}.p-lg-0{padding:0!important}.pt-lg-0,.py-lg-0{padding-top:0!important}.pr-lg-0,.px-lg-0{padding-right:0!important}.pb-lg-0,.py-lg-0{padding-bottom:0!important}.pl-lg-0,.px-lg-0{padding-left:0!important}.p-lg-1{padding:.25rem!important}.pt-lg-1,.py-lg-1{padding-top:.25rem!important}.pr-lg-1,.px-lg-1{padding-right:.25rem!important}.pb-lg-1,.py-lg-1{padding-bottom:.25rem!important}.pl-lg-1,.px-lg-1{padding-left:.25rem!important}.p-lg-2{padding:.5rem!important}.pt-lg-2,.py-lg-2{padding-top:.5rem!important}.pr-lg-2,.px-lg-2{padding-right:.5rem!important}.pb-lg-2,.py-lg-2{padding-bottom:.5rem!important}.pl-lg-2,.px-lg-2{padding-left:.5rem!important}.p-lg-3{padding:1rem!important}.pt-lg-3,.py-lg-3{padding-top:1rem!important}.pr-lg-3,.px-lg-3{padding-right:1rem!important}.pb-lg-3,.py-lg-3{padding-bottom:1rem!important}.pl-lg-3,.px-lg-3{padding-left:1rem!important}.p-lg-4{padding:1.5rem!important}.pt-lg-4,.py-lg-4{padding-top:1.5rem!important}.pr-lg-4,.px-lg-4{padding-right:1.5rem!important}.pb-lg-4,.py-lg-4{padding-bottom:1.5rem!important}.pl-lg-4,.px-lg-4{padding-left:1.5rem!important}.p-lg-5{padding:3rem!important}.pt-lg-5,.py-lg-5{padding-top:3rem!important}.pr-lg-5,.px-lg-5{padding-right:3rem!important}.pb-lg-5,.py-lg-5{padding-bottom:3rem!important}.pl-lg-5,.px-lg-5{padding-left:3rem!important}.m-lg-n1{margin:-.25rem!important}.mt-lg-n1,.my-lg-n1{margin-top:-.25rem!important}.mr-lg-n1,.mx-lg-n1{margin-right:-.25rem!important}.mb-lg-n1,.my-lg-n1{margin-bottom:-.25rem!important}.ml-lg-n1,.mx-lg-n1{margin-left:-.25rem!important}.m-lg-n2{margin:-.5rem!important}.mt-lg-n2,.my-lg-n2{margin-top:-.5rem!important}.mr-lg-n2,.mx-lg-n2{margin-right:-.5rem!important}.mb-lg-n2,.my-lg-n2{margin-bottom:-.5rem!important}.ml-lg-n2,.mx-lg-n2{margin-left:-.5rem!important}.m-lg-n3{margin:-1rem!important}.mt-lg-n3,.my-lg-n3{margin-top:-1rem!important}.mr-lg-n3,.mx-lg-n3{margin-right:-1rem!important}.mb-lg-n3,.my-lg-n3{margin-bottom:-1rem!important}.ml-lg-n3,.mx-lg-n3{margin-left:-1rem!important}.m-lg-n4{margin:-1.5rem!important}.mt-lg-n4,.my-lg-n4{margin-top:-1.5rem!important}.mr-lg-n4,.mx-lg-n4{margin-right:-1.5rem!important}.mb-lg-n4,.my-lg-n4{margin-bottom:-1.5rem!important}.ml-lg-n4,.mx-lg-n4{margin-left:-1.5rem!important}.m-lg-n5{margin:-3rem!important}.mt-lg-n5,.my-lg-n5{margin-top:-3rem!important}.mr-lg-n5,.mx-lg-n5{margin-right:-3rem!important}.mb-lg-n5,.my-lg-n5{margin-bottom:-3rem!important}.ml-lg-n5,.mx-lg-n5{margin-left:-3rem!important}.m-lg-auto{margin:auto!important}.mt-lg-auto,.my-lg-auto{margin-top:auto!important}.mr-lg-auto,.mx-lg-auto{margin-right:auto!important}.mb-lg-auto,.my-lg-auto{margin-bottom:auto!important}.ml-lg-auto,.mx-lg-auto{margin-left:auto!important}}@media (min-width:1200px){.m-xl-0{margin:0!important}.mt-xl-0,.my-xl-0{margin-top:0!important}.mr-xl-0,.mx-xl-0{margin-right:0!important}.mb-xl-0,.my-xl-0{margin-bottom:0!important}.ml-xl-0,.mx-xl-0{margin-left:0!important}.m-xl-1{margin:.25rem!important}.mt-xl-1,.my-xl-1{margin-top:.25rem!important}.mr-xl-1,.mx-xl-1{margin-right:.25rem!important}.mb-xl-1,.my-xl-1{margin-bottom:.25rem!important}.ml-xl-1,.mx-xl-1{margin-left:.25rem!important}.m-xl-2{margin:.5rem!important}.mt-xl-2,.my-xl-2{margin-top:.5rem!important}.mr-xl-2,.mx-xl-2{margin-right:.5rem!important}.mb-xl-2,.my-xl-2{margin-bottom:.5rem!important}.ml-xl-2,.mx-xl-2{margin-left:.5rem!important}.m-xl-3{margin:1rem!important}.mt-xl-3,.my-xl-3{margin-top:1rem!important}.mr-xl-3,.mx-xl-3{margin-right:1rem!important}.mb-xl-3,.my-xl-3{margin-bottom:1rem!important}.ml-xl-3,.mx-xl-3{margin-left:1rem!important}.m-xl-4{margin:1.5rem!important}.mt-xl-4,.my-xl-4{margin-top:1.5rem!important}.mr-xl-4,.mx-xl-4{margin-right:1.5rem!important}.mb-xl-4,.my-xl-4{margin-bottom:1.5rem!important}.ml-xl-4,.mx-xl-4{margin-left:1.5rem!important}.m-xl-5{margin:3rem!important}.mt-xl-5,.my-xl-5{margin-top:3rem!important}.mr-xl-5,.mx-xl-5{margin-right:3rem!important}.mb-xl-5,.my-xl-5{margin-bottom:3rem!important}.ml-xl-5,.mx-xl-5{margin-left:3rem!important}.p-xl-0{padding:0!important}.pt-xl-0,.py-xl-0{padding-top:0!important}.pr-xl-0,.px-xl-0{padding-right:0!important}.pb-xl-0,.py-xl-0{padding-bottom:0!important}.pl-xl-0,.px-xl-0{padding-left:0!important}.p-xl-1{padding:.25rem!important}.pt-xl-1,.py-xl-1{padding-top:.25rem!important}.pr-xl-1,.px-xl-1{padding-right:.25rem!important}.pb-xl-1,.py-xl-1{padding-bottom:.25rem!important}.pl-xl-1,.px-xl-1{padding-left:.25rem!important}.p-xl-2{padding:.5rem!important}.pt-xl-2,.py-xl-2{padding-top:.5rem!important}.pr-xl-2,.px-xl-2{padding-right:.5rem!important}.pb-xl-2,.py-xl-2{padding-bottom:.5rem!important}.pl-xl-2,.px-xl-2{padding-left:.5rem!important}.p-xl-3{padding:1rem!important}.pt-xl-3,.py-xl-3{padding-top:1rem!important}.pr-xl-3,.px-xl-3{padding-right:1rem!important}.pb-xl-3,.py-xl-3{padding-bottom:1rem!important}.pl-xl-3,.px-xl-3{padding-left:1rem!important}.p-xl-4{padding:1.5rem!important}.pt-xl-4,.py-xl-4{padding-top:1.5rem!important}.pr-xl-4,.px-xl-4{padding-right:1.5rem!important}.pb-xl-4,.py-xl-4{padding-bottom:1.5rem!important}.pl-xl-4,.px-xl-4{padding-left:1.5rem!important}.p-xl-5{padding:3rem!important}.pt-xl-5,.py-xl-5{padding-top:3rem!important}.pr-xl-5,.px-xl-5{padding-right:3rem!important}.pb-xl-5,.py-xl-5{padding-bottom:3rem!important}.pl-xl-5,.px-xl-5{padding-left:3rem!important}.m-xl-n1{margin:-.25rem!important}.mt-xl-n1,.my-xl-n1{margin-top:-.25rem!important}.mr-xl-n1,.mx-xl-n1{margin-right:-.25rem!important}.mb-xl-n1,.my-xl-n1{margin-bottom:-.25rem!important}.ml-xl-n1,.mx-xl-n1{margin-left:-.25rem!important}.m-xl-n2{margin:-.5rem!important}.mt-xl-n2,.my-xl-n2{margin-top:-.5rem!important}.mr-xl-n2,.mx-xl-n2{margin-right:-.5rem!important}.mb-xl-n2,.my-xl-n2{margin-bottom:-.5rem!important}.ml-xl-n2,.mx-xl-n2{margin-left:-.5rem!important}.m-xl-n3{margin:-1rem!important}.mt-xl-n3,.my-xl-n3{margin-top:-1rem!important}.mr-xl-n3,.mx-xl-n3{margin-right:-1rem!important}.mb-xl-n3,.my-xl-n3{margin-bottom:-1rem!important}.ml-xl-n3,.mx-xl-n3{margin-left:-1rem!important}.m-xl-n4{margin:-1.5rem!important}.mt-xl-n4,.my-xl-n4{margin-top:-1.5rem!important}.mr-xl-n4,.mx-xl-n4{margin-right:-1.5rem!important}.mb-xl-n4,.my-xl-n4{margin-bottom:-1.5rem!important}.ml-xl-n4,.mx-xl-n4{margin-left:-1.5rem!important}.m-xl-n5{margin:-3rem!important}.mt-xl-n5,.my-xl-n5{margin-top:-3rem!important}.mr-xl-n5,.mx-xl-n5{margin-right:-3rem!important}.mb-xl-n5,.my-xl-n5{margin-bottom:-3rem!important}.ml-xl-n5,.mx-xl-n5{margin-left:-3rem!important}.m-xl-auto{margin:auto!important}.mt-xl-auto,.my-xl-auto{margin-top:auto!important}.mr-xl-auto,.mx-xl-auto{margin-right:auto!important}.mb-xl-auto,.my-xl-auto{margin-bottom:auto!important}.ml-xl-auto,.mx-xl-auto{margin-left:auto!important}}.stretched-link::after{position:absolute;top:0;right:0;bottom:0;left:0;z-index:1;pointer-events:auto;content:"";background-color:rgba(0,0,0,0)}.text-monospace{font-family:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace!important}.text-justify{text-align:justify!important}.text-wrap{white-space:normal!important}.text-nowrap{white-space:nowrap!important}.text-truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.text-left{text-align:left!important}.text-right{text-align:right!important}.text-center{text-align:center!important}@media (min-width:576px){.text-sm-left{text-align:left!important}.text-sm-right{text-align:right!important}.text-sm-center{text-align:center!important}}@media (min-width:768px){.text-md-left{text-align:left!important}.text-md-right{text-align:right!important}.text-md-center{text-align:center!important}}@media (min-width:992px){.text-lg-left{text-align:left!important}.text-lg-right{text-align:right!important}.text-lg-center{text-align:center!important}}@media (min-width:1200px){.text-xl-left{text-align:left!important}.text-xl-right{text-align:right!important}.text-xl-center{text-align:center!important}}.text-lowercase{text-transform:lowercase!important}.text-uppercase{text-transform:uppercase!important}.text-capitalize{text-transform:capitalize!important}.font-weight-light{font-weight:300!important}.font-weight-lighter{font-weight:lighter!important}.font-weight-normal{font-weight:400!important}.font-weight-bold{font-weight:700!important}.font-weight-bolder{font-weight:bolder!important}.font-italic{font-style:italic!important}.text-white{color:#fff!important}.text-primary{color:#2a9fd6!important}a.text-primary:focus,a.text-primary:hover{color:#1d7097!important}.text-secondary{color:#555!important}a.text-secondary:focus,a.text-secondary:hover{color:#2f2f2f!important}.text-success{color:#77b300!important}a.text-success:focus,a.text-success:hover{color:#446700!important}.text-info{color:#93c!important}a.text-info:focus,a.text-info:hover{color:#6b248f!important}.text-warning{color:#f80!important}a.text-warning:focus,a.text-warning:hover{color:#b35f00!important}.text-danger{color:#c00!important}a.text-danger:focus,a.text-danger:hover{color:maroon!important}.text-light{color:#222!important}a.text-light:focus,a.text-light:hover{color:#000!important}.text-dark{color:#adafae!important}a.text-dark:focus,a.text-dark:hover{color:#868988!important}.text-body{color:#adafae!important}.text-muted{color:#555!important}.text-black-50{color:rgba(0,0,0,.5)!important}.text-white-50{color:rgba(255,255,255,.5)!important}.text-hide{font:0/0 a;color:transparent;text-shadow:none;background-color:transparent;border:0}.text-decoration-none{text-decoration:none!important}.text-break{word-break:break-word!important;word-wrap:break-word!important}.text-reset{color:inherit!important}.visible{visibility:visible!important}.invisible{visibility:hidden!important}@media print{*,::after,::before{text-shadow:none!important;box-shadow:none!important}a:not(.btn){text-decoration:underline}abbr[title]::after{content:" (" attr(title) ")"}pre{white-space:pre-wrap!important}blockquote,pre{border:1px solid #888;page-break-inside:avoid}thead{display:table-header-group}img,tr{page-break-inside:avoid}h2,h3,p{orphans:3;widows:3}h2,h3{page-break-after:avoid}@page{size:a3}body{min-width:992px!important}.container{min-width:992px!important}.navbar{display:none}.badge{border:1px solid #000}.table{border-collapse:collapse!important}.table td,.table th{background-color:#fff!important}.table-bordered td,.table-bordered th{border:1px solid #dee2e6!important}.table-dark{color:inherit}.table-dark tbody+tbody,.table-dark td,.table-dark th,.table-dark thead th{border-color:#282828}.table .thead-dark th{color:inherit;border-color:#282828}}.navbar.bg-primary{border:1px solid #282828}.navbar.bg-dark{background-color:#060606!important;border:1px solid #282828}.navbar.bg-light{background-color:#888!important}.navbar.fixed-top{border-width:0 0 1px}.navbar.fixed-bottom{border-width:1px 0 0}.btn-primary{background-color:#2a9fd6}.btn-secondary{background-color:#555}.btn-success{background-color:#77b300}.btn-info{background-color:#93c}.btn-warning{background-color:#f80}.btn-danger{background-color:#c00}.btn-light{background-color:#222}.btn-dark{background-color:#adafae}table{color:#fff}.table-primary,.table-primary>td,.table-primary>th{background-color:#2a9fd6}.table-secondary,.table-secondary>td,.table-secondary>th{background-color:#555}.table-light,.table-light>td,.table-light>th{background-color:#222}.table-dark,.table-dark>td,.table-dark>th{background-color:#adafae}.table-success,.table-success>td,.table-success>th{background-color:#77b300}.table-info,.table-info>td,.table-info>th{background-color:#93c}.table-danger,.table-danger>td,.table-danger>th{background-color:#c00}.table-warning,.table-warning>td,.table-warning>th{background-color:#f80}.table-active,.table-active>td,.table-active>th{background-color:rgba(255,255,255,.075)}.table-hover .table-primary:hover,.table-hover .table-primary:hover>td,.table-hover .table-primary:hover>th{background-color:#258fc1}.table-hover .table-secondary:hover,.table-hover .table-secondary:hover>td,.table-hover .table-secondary:hover>th{background-color:#484848}.table-hover .table-light:hover,.table-hover .table-light:hover>td,.table-hover .table-light:hover>th{background-color:#151515}.table-hover .table-dark:hover,.table-hover .table-dark:hover>td,.table-hover .table-dark:hover>th{background-color:#a0a2a1}.table-hover .table-success:hover,.table-hover .table-success:hover>td,.table-hover .table-success:hover>th{background-color:#669a00}.table-hover .table-info:hover,.table-hover .table-info:hover>td,.table-hover .table-info:hover>th{background-color:#8a2eb8}.table-hover .table-danger:hover,.table-hover .table-danger:hover>td,.table-hover .table-danger:hover>th{background-color:#b30000}.table-hover .table-warning:hover,.table-hover .table-warning:hover>td,.table-hover .table-warning:hover>th{background-color:#e67a00}.table-hover .table-active:hover,.table-hover .table-active:hover>td,.table-hover .table-active:hover>th{background-color:rgba(255,255,255,.075)}legend{color:#fff}.form-control{background-clip:border-box}.form-control:disabled,.form-control[readonly]{border-color:transparent}.nav-pills .nav-link,.nav-tabs .nav-link{color:#fff}.nav-pills .nav-link:hover,.nav-tabs .nav-link:hover{background-color:#282828}.nav-pills .nav-link.disabled,.nav-pills .nav-link.disabled:hover,.nav-tabs .nav-link.disabled,.nav-tabs .nav-link.disabled:hover{background-color:transparent;color:#555}.nav-pills .nav-link.active,.nav-tabs .nav-link.active{background-color:#2a9fd6}.breadcrumb a{color:#fff}.pagination a:hover{text-decoration:none}.alert{border:none;color:#fff}.alert .alert-link,.alert a{color:#fff;text-decoration:underline}.alert-primary{background-color:#2a9fd6}.alert-secondary{background-color:#555}.alert-success{background-color:#77b300}.alert-info{background-color:#93c}.alert-warning{background-color:#f80}.alert-danger{background-color:#c00}.alert-light{background-color:#222}.alert-dark{background-color:#adafae}.badge-warning{color:#fff}.close{opacity:.6}.close:hover{opacity:1}.list-group-item:hover{background-color:#282828;color:#fff}.list-group-item-action{color:#888}.list-group-item-action .list-group-item-heading{color:#888}.list-group-item:hover .list-group-item-heading{color:#fff}.card h1,.card h2,.card h3,.card h4,.card h5,.card h6,.list-group-item h1,.list-group-item h2,.list-group-item h3,.list-group-item h4,.list-group-item h5,.list-group-item h6{color:inherit}.popover-title{border-bottom:none} diff --git a/client-web-old/src/main/resources/css/main.css b/client-web-old/src/main/resources/css/main.css new file mode 100644 index 00000000..fa0bcec4 --- /dev/null +++ b/client-web-old/src/main/resources/css/main.css @@ -0,0 +1,34 @@ +body, #kvapp, .main-panel { + width: 100%; + height: 100%; +} + +.tmdb-menu-bar { + padding: 4px; +} + +.nav-link { + cursor: pointer; + font-size: 20pt; +} + +.navbar.bg-dark { + border: none; +} + +.navbar-nav { + margin-top: auto; + /* TODO: Fix the logo so the emblem and + * text align logically with nav buttons, + * making the padding unnecessary. + */ + margin-bottom: 2px; +} + +.navbar-brand img { + height: 65px; +} + +video { + background-color: black; +} diff --git a/client-web-old/src/main/resources/favicon.ico b/client-web-old/src/main/resources/favicon.ico new file mode 100644 index 00000000..6c15f7c1 Binary files /dev/null and b/client-web-old/src/main/resources/favicon.ico differ diff --git a/client-web-old/src/main/resources/images/as-logo.svg b/client-web-old/src/main/resources/images/as-logo.svg new file mode 100644 index 00000000..0e75a398 --- /dev/null +++ b/client-web-old/src/main/resources/images/as-logo.svg @@ -0,0 +1,74 @@ + + + diff --git a/client-web-old/src/main/resources/index.html b/client-web-old/src/main/resources/index.html new file mode 100644 index 00000000..66c2ce8c --- /dev/null +++ b/client-web-old/src/main/resources/index.html @@ -0,0 +1,33 @@ + + + + + + + + AnyStream + + + + + + +
+ + diff --git a/client-web-old/webpack.config.d/bootstrap.js b/client-web-old/webpack.config.d/bootstrap.js new file mode 100644 index 00000000..c4d7c82e --- /dev/null +++ b/client-web-old/webpack.config.d/bootstrap.js @@ -0,0 +1,17 @@ +config.module.rules.push({ + test: /\.(woff|woff2)(\?v=\d+\.\d+\.\d+)?$/, + loader: 'url-loader', + options: { + limit: 10000, + mimetype: 'application/font-woff' + } +}); +config.module.rules.push({ + test: /\.ttf(\?v=\d+\.\d+\.\d+)?$/, + loader: 'url-loader', + options: { + limit: 10000, + mimetype: 'application/octet-stream' + } +}); +config.module.rules.push({test: /\.eot(\?v=\d+\.\d+\.\d+)?$/, loader: 'file-loader'}); diff --git a/client-web-old/webpack.config.d/css.js b/client-web-old/webpack.config.d/css.js new file mode 100644 index 00000000..76dbc709 --- /dev/null +++ b/client-web-old/webpack.config.d/css.js @@ -0,0 +1 @@ +config.module.rules.push({ test: /\.css$/, use:["style-loader","css-loader"] }); diff --git a/client-web-old/webpack.config.d/file.js b/client-web-old/webpack.config.d/file.js new file mode 100644 index 00000000..e7c6c47b --- /dev/null +++ b/client-web-old/webpack.config.d/file.js @@ -0,0 +1,9 @@ +config.module.rules.push( + { + test: /\.(jpe?g|png|gif|svg)$/i, + loader: 'file-loader', + options: { + esModule: false, + }, + } +); diff --git a/client-web-old/webpack.config.d/jquery.js b/client-web-old/webpack.config.d/jquery.js new file mode 100644 index 00000000..06d85e31 --- /dev/null +++ b/client-web-old/webpack.config.d/jquery.js @@ -0,0 +1,9 @@ +;(function() { + const webpack = require('webpack') + + config.plugins.push(new webpack.ProvidePlugin({ + $: "jquery", + jQuery: "jquery", + "window.jQuery": "jquery" + })); +})(); diff --git a/client-web-old/webpack.config.d/moment.js b/client-web-old/webpack.config.d/moment.js new file mode 100644 index 00000000..9deca1ae --- /dev/null +++ b/client-web-old/webpack.config.d/moment.js @@ -0,0 +1,12 @@ +;(function() { + const webpack = require('webpack') + try { + const moment = require("moment"); + + config.plugins.push(new webpack.ProvidePlugin({ + moment: moment, + "window.moment": moment + })); + } catch (e) { + } +})(); diff --git a/client-web-old/webpack.config.d/webpack.js b/client-web-old/webpack.config.d/webpack.js new file mode 100644 index 00000000..0c4c992c --- /dev/null +++ b/client-web-old/webpack.config.d/webpack.js @@ -0,0 +1,7 @@ +config.resolve.modules.push("../../processedResources/js/main"); + +if (config.devServer) { + config.devServer.historyApiFallback = true; + config.devServer.hot = true; + config.output.publicPath = '/'; +} diff --git a/client-web/build.gradle.kts b/client-web/build.gradle.kts new file mode 100644 index 00000000..78599253 --- /dev/null +++ b/client-web/build.gradle.kts @@ -0,0 +1,56 @@ +import org.jetbrains.kotlin.gradle.targets.js.webpack.KotlinWebpackConfig.* + +plugins { + kotlin("multiplatform") + id("org.jetbrains.compose") +} + +kotlin { + js(IR) { + browser { + binaries.executable() + commonWebpackConfig { + cssSupport.enabled = true + } + runTask { + outputFileName = "main.bundle.js" + devtool = "eval-cheap-module-source-map" + devServer = DevServer( + open = false, + port = 3000, + proxy = mutableMapOf( + "/api/*" to mapOf( + "target" to "http://localhost:8888", + "ws" to true + ) + ), + static = mutableListOf("$buildDir/processedResources/js/main") + ) + } + webpackTask { + outputFileName = "main.bundle.js" + //devtool = "cheap-module-eval-source-map" + } + } + } + + sourceSets { + named("jsMain") { + dependencies { + implementation(projects.client) + implementation(libs.coroutines.core) + implementation(libs.ktor.client.js) + + implementation(compose.web.core) + implementation(compose.web.widgets) + implementation(compose.runtime) + + implementation(libs.kotlinjs.extensions) + implementation(npm("bootstrap", "5.0.1")) + implementation(npm("bootstrap-icons", "1.5.0")) + implementation(devNpm("file-loader", "6.2.0")) + implementation(devNpm("webpack-bundle-analyzer", "4.4.2")) + } + } + } +} diff --git a/client-web/src/jsMain/kotlin/Navbar.kt b/client-web/src/jsMain/kotlin/Navbar.kt new file mode 100644 index 00000000..aa394582 --- /dev/null +++ b/client-web/src/jsMain/kotlin/Navbar.kt @@ -0,0 +1,111 @@ +/** + * AnyStream + * Copyright (C) 2021 Drew Carlson + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package anystream.frontend + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.rememberCoroutineScope +import anystream.client.AnyStreamClient +import com.soywiz.korio.async.launch +import org.jetbrains.compose.web.attributes.AttrsBuilder +import org.jetbrains.compose.web.css.StyleBuilder +import org.jetbrains.compose.web.css.margin +import org.jetbrains.compose.web.css.px +import org.jetbrains.compose.web.dom.A +import org.jetbrains.compose.web.dom.Div +import org.jetbrains.compose.web.dom.I +import org.jetbrains.compose.web.dom.Img +import org.jetbrains.compose.web.dom.Nav +import org.jetbrains.compose.web.dom.Text +import org.w3c.dom.HTMLElement + +@Composable +fun Navbar(client: AnyStreamClient) { + val isAuthenticated = client.authenticated.collectAsState(client.isAuthenticated()) + Nav(attrs = { classes("navbar", "navbar-expand-lg", "navbar-dark", "bg-dark") }) { + Div(attrs = { classes("container-fluid") }) { + A(attrs = { classes("navbar-brand") }) { + Img(src = "/images/as-logo.svg") + } + Div(attrs = { classes("collapse", "navbar-collapse") }) { + if (isAuthenticated.value) { + MainMenu(client) + SecondaryMenu(client) + } + } + } + } +} + +@Composable +private fun MainMenu(client: AnyStreamClient) { + Div(attrs = { classes("navbar-nav") }) { + A(attrs = { classes("nav-link", "active") }) { + ButtonIcon("bi-search") + Text("Discover") + } + A(attrs = { classes("nav-link") }) { + ButtonIcon("bi-film") + Text("Movies") + } + A(attrs = { classes("nav-link") }) { + ButtonIcon("bi-tv") + Text("TV") + } + A(attrs = { classes("nav-link") }) { + ButtonIcon("bi-cloud-arrow-down") + Text("Downloads") + } + } +} + +@Composable +private fun SecondaryMenu(client: AnyStreamClient) { + val scope = rememberCoroutineScope() + Div(attrs = { classes("navbar-nav", "ms-auto") }) { + A(attrs = { classes("nav-link") }) { + I(attrs = { classes("bi-people") }) { } + } + A(attrs = { + onClick { + scope.launch { client.logout() } + } + classes("nav-link") + }) { + I(attrs = { classes("bi-box-arrow-right") }) { } + } + } +} + +@Composable +private fun ButtonIcon( + icon: String, + attrs: AttrsBuilder.() -> Unit = {}, + style: (StyleBuilder.() -> Unit) = {}, +) { + I({ + classes(icon) + attrs() + style(style) + style { + margin(5.px) + } + }, { + + }) +} \ No newline at end of file diff --git a/client-web/src/jsMain/kotlin/WebApp.kt b/client-web/src/jsMain/kotlin/WebApp.kt new file mode 100644 index 00000000..5cbdb0ee --- /dev/null +++ b/client-web/src/jsMain/kotlin/WebApp.kt @@ -0,0 +1,70 @@ +/** + * AnyStream + * Copyright (C) 2021 Drew Carlson + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package anystream.frontend + +import androidx.compose.runtime.* +import anystream.client.AnyStreamClient +import anystream.client.SessionManager +import anystream.frontend.screens.HomeScreen +import anystream.frontend.screens.LoginScreen +import kotlinx.browser.window +import org.jetbrains.compose.web.css.* +import org.jetbrains.compose.web.dom.* +import org.jetbrains.compose.web.renderComposable + +fun webApp() = renderComposable(rootElementId = "root") { + val client = AnyStreamClient( + serverUrl = window.location.run { "$protocol//$host" }, + sessionManager = SessionManager(JsSessionDataStore) + ) + Div( + attrs = { + id("main-panel") + classes("h-100", "w-100") + style { + display(DisplayStyle.Flex) + flexDirection(FlexDirection.Column) + } + }, + ) { + Div { Navbar(client) } + ContentContainer(client) + } +} + +@Composable +private fun ContentContainer(client: AnyStreamClient) { + Div( + attrs = { + classes("container-fluid") + style { + flexGrow(1) + flexShrink(1) + property("flex-basis", "auto") + property("overflow-y", "auto") + } + } + ) { + val isAuthenticated = client.authenticated.collectAsState(client.isAuthenticated()) + if (isAuthenticated.value) { + HomeScreen(client) + } else { + LoginScreen(client) + } + } +} diff --git a/client-web/src/jsMain/kotlin/components/MovieCard.kt b/client-web/src/jsMain/kotlin/components/MovieCard.kt new file mode 100644 index 00000000..965e4c7a --- /dev/null +++ b/client-web/src/jsMain/kotlin/components/MovieCard.kt @@ -0,0 +1,164 @@ +/** + * AnyStream + * Copyright (C) 2021 Drew Carlson + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package anystream.frontend.components + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import com.soywiz.kmem.toInt +import org.jetbrains.compose.web.css.* +import org.jetbrains.compose.web.dom.Div +import org.jetbrains.compose.web.dom.I +import org.jetbrains.compose.web.dom.Img + + +@Composable +fun MovieCard( + title: String, + posterPath: String?, + overview: String, + releaseDate: String?, + isAdded: Boolean, + //onPlayClicked: () -> Unit, + //onBodyClicked: () -> Unit +) { + val isOverlayVisible = remember { mutableStateOf(false) } + Div( + attrs = { + classes("p-3") + style { + display(DisplayStyle.Flex) + flexDirection(FlexDirection.Column) + } + } + ) { + Div( + attrs = { + classes("card", "movie-card") + onMouseEnter { isOverlayVisible.value = true } + onMouseLeave { isOverlayVisible.value = false } + } + ) { + + CardOverlay(isAdded, isOverlayVisible.value) + + Img( + src = "https://image.tmdb.org/t/p/w200${posterPath}", + attrs = { + classes("rounded") + attr("loading", "lazy") + style { + height(300.px) + width(200.px) + } + } + ) + } + } +} + +@Composable +private fun CardOverlay( + isAdded: Boolean, + isOverlayVisible: Boolean, +) { + Div( + attrs = { + classes("rounded", "h-100", "w-100") + style { + position(Position.Absolute) + property("cursor", "pointer") + property("z-index", 1) + opacity(isOverlayVisible.toInt()) + property("transition", "opacity 0.15s ease-in-out") + } + } + ) { + Div( + attrs = { + classes("rounded", "border", "h-100", "w-100", "p-3", "border-white") + style { + display(DisplayStyle.Flex) + justifyContent(JustifyContent.SpaceBetween) + alignItems(AlignItems.Center) + flexDirection(FlexDirection.Column) + property("z-index", 3) + position(Position.Absolute) + } + } + ) { + I( + attrs = { + classes("bi-three-dots-vertical") + style { + display(DisplayStyle.Flex) + alignSelf(AlignSelf.FlexEnd) + color(Color.RGB(255, 255, 255)) + } + } + ) + Div( + attrs = { + style { + display(DisplayStyle.Flex) + } + } + ) { + I( + attrs = { + classes("bi-play-circle-fill") + style { + property("margin", auto) + fontSize(36.px) + color(Color.RGB(255, 255, 255)) + } + } + ) + } + Div( + attrs = { + style { + display(DisplayStyle.Flex) + alignSelf(AlignSelf.FlexEnd) + } + } + ) { + I( + attrs = { + classes(if (isAdded) "bi-check-lg" else "bi-plus-lg") + style { + color(Color.RGB(255, 255, 255)) + } + } + ) + } + } + + Div( + attrs = { + classes("rounded", "h-100", "w-100") + style { + backgroundColor(Color.RGB(0, 0, 0)) + position(Position.Absolute) + property("z-index", 2) + opacity(.7) + } + } + ) + } +} \ No newline at end of file diff --git a/client-web/src/jsMain/kotlin/main.kt b/client-web/src/jsMain/kotlin/main.kt new file mode 100644 index 00000000..1b6ff35a --- /dev/null +++ b/client-web/src/jsMain/kotlin/main.kt @@ -0,0 +1,24 @@ +/** + * AnyStream + * Copyright (C) 2021 Drew Carlson + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package anystream.frontend + +fun main() { + kotlinext.js.require("bootstrap/dist/css/bootstrap.min.css") + kotlinext.js.require("bootstrap-icons/font/bootstrap-icons.css") + webApp() +} \ No newline at end of file diff --git a/client-web/src/jsMain/kotlin/screens/HomeScreen.kt b/client-web/src/jsMain/kotlin/screens/HomeScreen.kt new file mode 100644 index 00000000..7f17afd7 --- /dev/null +++ b/client-web/src/jsMain/kotlin/screens/HomeScreen.kt @@ -0,0 +1,125 @@ +/** + * AnyStream + * Copyright (C) 2021 Drew Carlson + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package anystream.frontend.screens + +import androidx.compose.runtime.* +import anystream.client.AnyStreamClient +import anystream.frontend.components.MovieCard +import anystream.models.api.HomeResponse +import com.soywiz.kmem.toInt +import org.jetbrains.compose.web.css.* +import org.jetbrains.compose.web.dom.* + +@Composable +fun HomeScreen(client: AnyStreamClient) { + val homeResponse by produceState(null) { + value = client.getHomeData() + } + + homeResponse?.run { + if (currentlyWatching.isNotEmpty()) { + MovieRow( + title = { Text("Continue Watching") } + ) { + currentlyWatching.forEach { (movie, state) -> + MovieCard( + title = movie.title, + posterPath = movie.posterPath, + overview = movie.overview, + releaseDate = movie.releaseDate, + isAdded = true, + ) + } + } + } + + if (popularMovies.isNotEmpty()) { + MovieRow( + title = { Text("Popular") } + ) { + popularMovies.forEach { (movie, ref) -> + MovieCard( + title = movie.title, + posterPath = movie.posterPath, + overview = movie.overview, + releaseDate = movie.releaseDate, + isAdded = ref != null, + ) + } + } + } + + if (recentlyAdded.isNotEmpty()) { + MovieRow( + title = { Text("Recently Added Movies") } + ) { + recentlyAdded.forEach { (movie, ref) -> + MovieCard( + title = movie.title, + posterPath = movie.posterPath, + overview = movie.overview, + releaseDate = movie.releaseDate, + isAdded = true, + ) + } + } + } + + if (recentlyAddedTv.isNotEmpty()) { + MovieRow( + title = { Text("Recently Added Movies") } + ) { + recentlyAddedTv.forEach { show -> + MovieCard( + title = show.name, + posterPath = show.posterPath, + overview = show.overview, + releaseDate = show.firstAirDate, + isAdded = true, + ) + } + } + } + } +} + +@Composable +private fun MovieRow( + title: @Composable () -> Unit, + buildItems: @Composable () -> Unit, +) { + Div { + H3({ + classes("p-3") + }) { + title() + } + } + Div( + attrs = { + style { + display(DisplayStyle.Flex) + flexDirection(FlexDirection.Row) + property("overflow-x", "auto") + property("scrollbar-width", "none") + } + } + ) { + buildItems() + } +} diff --git a/client-web/src/jsMain/kotlin/screens/LoginScreen.kt b/client-web/src/jsMain/kotlin/screens/LoginScreen.kt new file mode 100644 index 00000000..5a5c0cb4 --- /dev/null +++ b/client-web/src/jsMain/kotlin/screens/LoginScreen.kt @@ -0,0 +1,106 @@ +/** + * AnyStream + * Copyright (C) 2021 Drew Carlson + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package anystream.frontend.screens + +import androidx.compose.runtime.* +import anystream.client.AnyStreamClient +import anystream.models.api.CreateSessionError +import com.soywiz.korio.async.launch +import org.jetbrains.compose.web.attributes.ButtonType +import org.jetbrains.compose.web.attributes.InputType +import org.jetbrains.compose.web.attributes.placeholder +import org.jetbrains.compose.web.attributes.type +import org.jetbrains.compose.web.css.* +import org.jetbrains.compose.web.dom.* + +@Composable +fun LoginScreen(client: AnyStreamClient) { + val scope = rememberCoroutineScope() + var username by remember { mutableStateOf("") } + var password by remember { mutableStateOf("") } + var error by remember { mutableStateOf(null) } + Div( + attrs = { + style { + display(DisplayStyle.Flex) + flexDirection(FlexDirection.Column) + justifyContent(JustifyContent.Center) + alignItems(AlignItems.Center) + } + } + ) { + Div { H3 { Text("Login") } } + Div { + Input( + attrs = { + onTextInput { username = it.inputValue } + classes("form-control") + placeholder("Username") + type(InputType.Text) + } + ) + } + Div { + Input( + attrs = { + onTextInput { password = it.inputValue } + classes("form-control") + placeholder("Password") + type(InputType.Password) + } + ) + } + Div { + error?.run { Text(this) } + } + Div { + Button( + attrs = { + classes("btn", "btn-primary") + type(ButtonType.Button) + onClick { + scope.launch { + error = null + val response = client.login(username, password) + error = when (response.error) { + CreateSessionError.USERNAME_INVALID -> "Invalid username" + CreateSessionError.USERNAME_NOT_FOUND -> "Username not found" + CreateSessionError.PASSWORD_INVALID -> "Invalid password" + CreateSessionError.PASSWORD_INCORRECT -> "Incorrect password" + null -> null + } + } + } + } + ) { + Text("Confirm") + } + } + Div { + A( + attrs = { + style { + property("cursor", "pointer") + } + } + ) { + Text("Go to Signup") + } + } + } +} \ No newline at end of file diff --git a/client-web/src/jsMain/resources/css/main.css b/client-web/src/jsMain/resources/css/main.css new file mode 100644 index 00000000..2c78524e --- /dev/null +++ b/client-web/src/jsMain/resources/css/main.css @@ -0,0 +1,40 @@ +html, body, #root, #main-panel { + width: 100%; + height: 100%; + background-color: black; + color: white; +} + +:root { + scrollbar-color: #403f3e transparent; +} + +.tmdb-menu-bar { + padding: 4px; +} + +.nav-link { + cursor: pointer; + font-size: 20pt; +} + +.navbar.bg-dark { + border: none; +} + +.navbar-nav { + margin-top: auto; + /* TODO: Fix the logo so the emblem and + * text align logically with nav buttons, + * making the padding unnecessary. + */ + margin-bottom: 2px; +} + +.navbar-brand img { + height: 50px; +} + +video { + background-color: black; +} diff --git a/client-web/src/jsMain/resources/favicon.ico b/client-web/src/jsMain/resources/favicon.ico new file mode 100644 index 00000000..6c15f7c1 Binary files /dev/null and b/client-web/src/jsMain/resources/favicon.ico differ diff --git a/client-web/src/jsMain/resources/images/as-logo.svg b/client-web/src/jsMain/resources/images/as-logo.svg new file mode 100644 index 00000000..0e75a398 --- /dev/null +++ b/client-web/src/jsMain/resources/images/as-logo.svg @@ -0,0 +1,74 @@ + + + diff --git a/client-web/src/jsMain/resources/index.html b/client-web/src/jsMain/resources/index.html new file mode 100644 index 00000000..9d76bfc1 --- /dev/null +++ b/client-web/src/jsMain/resources/index.html @@ -0,0 +1,32 @@ + + + + + + + + AnyStream + + + + +
+ + + \ No newline at end of file diff --git a/client-web/webpack.config.d/bootstrap-icons.js b/client-web/webpack.config.d/bootstrap-icons.js new file mode 100644 index 00000000..02976f1a --- /dev/null +++ b/client-web/webpack.config.d/bootstrap-icons.js @@ -0,0 +1 @@ +config.module.rules.push({test: /\.woff(2)?(\?\S+)?$/, loader: 'file-loader'}); \ No newline at end of file diff --git a/client-web/webpack.config.d/file.js b/client-web/webpack.config.d/file.js new file mode 100644 index 00000000..e7c6c47b --- /dev/null +++ b/client-web/webpack.config.d/file.js @@ -0,0 +1,9 @@ +config.module.rules.push( + { + test: /\.(jpe?g|png|gif|svg)$/i, + loader: 'file-loader', + options: { + esModule: false, + }, + } +); diff --git a/client-web/webpack.config.d/jquery.js b/client-web/webpack.config.d/jquery.js new file mode 100644 index 00000000..06d85e31 --- /dev/null +++ b/client-web/webpack.config.d/jquery.js @@ -0,0 +1,9 @@ +;(function() { + const webpack = require('webpack') + + config.plugins.push(new webpack.ProvidePlugin({ + $: "jquery", + jQuery: "jquery", + "window.jQuery": "jquery" + })); +})(); diff --git a/client-web/webpack.config.d/webpack.js b/client-web/webpack.config.d/webpack.js new file mode 100644 index 00000000..0c2bb447 --- /dev/null +++ b/client-web/webpack.config.d/webpack.js @@ -0,0 +1,12 @@ +config.resolve.modules.push("../../processedResources/js/main"); +const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin; + +if (config.devServer) { + config.plugins = config.plugins || [] + config.plugins.push(new BundleAnalyzerPlugin({ + analyzerMode: 'disabled' + })) + config.devServer.historyApiFallback = true; + config.devServer.hot = true; + config.output.publicPath = '/'; +} diff --git a/client/build.gradle.kts b/client/build.gradle.kts new file mode 100644 index 00000000..4b5240dd --- /dev/null +++ b/client/build.gradle.kts @@ -0,0 +1,87 @@ +import com.android.build.gradle.LibraryExtension + +plugins { + kotlin("multiplatform") + kotlin("plugin.serialization") +} + +if (hasAndroidSdk) { + apply(plugin = "com.android.library") + configure { + compileSdk = 29 + defaultConfig { + minSdk = 23 + targetSdk = 29 + } + sourceSets { + named("main") { + manifest.srcFile("src/androidMain/AndroidManifest.xml") + } + } + } +} + +kotlin { + js(IR) { + browser { + testTask { + useKarma { + useChromeHeadless() + } + } + } + } + if (hasAndroidSdk) { + android() + } + + sourceSets { + all { + languageSettings.apply { + useExperimentalAnnotation("kotlinx.coroutines.ExperimentalCoroutinesApi") + useExperimentalAnnotation("kotlinx.coroutines.FlowPreview") + } + } + val commonMain by getting { + dependencies { + api(projects.dataModels) + api(projects.apiClient) + api(libs.coroutines.core) + api(libs.mobiuskt.core) + api(libs.mobiuskt.extras) + implementation(libs.serialization.core) + implementation(libs.serialization.json) + + api(libs.ktor.client.core) + implementation(libs.ktor.client.json) + implementation(libs.ktor.client.serialization) + } + } + val commonTest by getting { + dependencies { + implementation(kotlin("test-common")) + implementation(kotlin("test-annotations-common")) + } + } + + if (hasAndroidSdk) { + val androidMain by getting { + dependencies { + implementation("androidx.core:core-ktx:1.6.0-beta02") + } + } + } + + val jsMain by getting { + dependencies { + implementation(libs.ktor.client.js) + } + } + + val jsTest by getting { + dependencies { + implementation(kotlin("test-js")) + } + } + } +} diff --git a/client/src/androidMain/AndroidManifest.xml b/client/src/androidMain/AndroidManifest.xml new file mode 100644 index 00000000..fdb0c2c4 --- /dev/null +++ b/client/src/androidMain/AndroidManifest.xml @@ -0,0 +1,19 @@ + + + diff --git a/client/src/androidMain/kotlin/AndroidSessionDataStore.kt b/client/src/androidMain/kotlin/AndroidSessionDataStore.kt new file mode 100644 index 00000000..f8a18021 --- /dev/null +++ b/client/src/androidMain/kotlin/AndroidSessionDataStore.kt @@ -0,0 +1,39 @@ +/** + * AnyStream + * Copyright (C) 2021 Drew Carlson + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package anystream.core + +import android.content.SharedPreferences +import androidx.core.content.edit +import anystream.client.SessionDataStore + +class AndroidSessionDataStore( + private val prefs: SharedPreferences +) : SessionDataStore { + + override fun write(key: String, value: String) { + prefs.edit { putString(key, value) } + } + + override fun read(key: String): String? { + return prefs.getString(key, null) + } + + override fun remove(key: String) { + prefs.edit { remove(key) } + } +} diff --git a/client/src/jsMain/kotlin/JsSessionDataStore.kt b/client/src/jsMain/kotlin/JsSessionDataStore.kt new file mode 100644 index 00000000..1225eb3f --- /dev/null +++ b/client/src/jsMain/kotlin/JsSessionDataStore.kt @@ -0,0 +1,35 @@ +/** + * AnyStream + * Copyright (C) 2021 Drew Carlson + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package anystream.frontend + +import anystream.client.SessionDataStore +import kotlinx.browser.localStorage + +object JsSessionDataStore : SessionDataStore { + override fun write(key: String, value: String) { + localStorage.setItem(key, value) + } + + override fun read(key: String): String? { + return localStorage.getItem(key) + } + + override fun remove(key: String) { + localStorage.removeItem(key) + } +} diff --git a/data-models/build.gradle.kts b/data-models/build.gradle.kts new file mode 100644 index 00000000..f0dc9998 --- /dev/null +++ b/data-models/build.gradle.kts @@ -0,0 +1,25 @@ +plugins { + kotlin("multiplatform") + kotlin("plugin.serialization") +} + +kotlin { + jvm() + js(IR) { + browser() + } + + sourceSets { + val commonMain by getting { + kotlin.srcDirs("src") + dependencies { + implementation(libs.serialization.core) + implementation(libs.serialization.json) + api(libs.qbittorrent.models) + } + } + + val jvmMain by getting { + } + } +} diff --git a/data-models/src/Episode.kt b/data-models/src/Episode.kt new file mode 100644 index 00000000..b7dfeb45 --- /dev/null +++ b/data-models/src/Episode.kt @@ -0,0 +1,35 @@ +/** + * AnyStream + * Copyright (C) 2021 Drew Carlson + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package anystream.models + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class Episode( + @SerialName("_id") + val id: String, + val showId: String, + val name: String, + val tmdbId: Int, + val overview: String, + val airDate: String, + val number: Int, + val seasonNumber: Int, + val stillPath: String, +) diff --git a/data-models/src/InviteCode.kt b/data-models/src/InviteCode.kt new file mode 100644 index 00000000..61b41610 --- /dev/null +++ b/data-models/src/InviteCode.kt @@ -0,0 +1,29 @@ +/** + * AnyStream + * Copyright (C) 2021 Drew Carlson + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package anystream.models + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class InviteCode( + @SerialName("_id") + val value: String, + val permissions: Set, + val createdByUserId: String, +) diff --git a/data-models/src/MediaFile.kt b/data-models/src/MediaFile.kt new file mode 100644 index 00000000..049c8d30 --- /dev/null +++ b/data-models/src/MediaFile.kt @@ -0,0 +1,29 @@ +/** + * AnyStream + * Copyright (C) 2021 Drew Carlson + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package anystream.models + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class MediaFile( + @SerialName("_id") + val id: String, + val contentId: String, + val filePath: String +) diff --git a/data-models/src/MediaKind.kt b/data-models/src/MediaKind.kt new file mode 100644 index 00000000..b99386f1 --- /dev/null +++ b/data-models/src/MediaKind.kt @@ -0,0 +1,33 @@ +/** + * AnyStream + * Copyright (C) 2021 Drew Carlson + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package anystream.models + +import kotlinx.serialization.Serializable + +@Serializable +enum class MediaKind { + MOVIE, + TV, + BOOK, + AUDIOBOOK, + COMIC, + GAME, + PHOTO, + MUSIC, + PROGRAM, +} \ No newline at end of file diff --git a/data-models/src/MediaReference.kt b/data-models/src/MediaReference.kt new file mode 100644 index 00000000..c4bdc3c2 --- /dev/null +++ b/data-models/src/MediaReference.kt @@ -0,0 +1,60 @@ +/** + * AnyStream + * Copyright (C) 2021 Drew Carlson + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package anystream.models + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + + +@Serializable +sealed class MediaReference { + @SerialName("_id") + abstract val id: String + abstract val contentId: String + abstract val rootContentId: String? + abstract val added: Long + abstract val addedByUserId: String + abstract val mediaKind: MediaKind +} + +@Serializable +data class LocalMediaReference( + @SerialName("_id") + override val id: String, + override val contentId: String, + override val rootContentId: String? = null, + override val added: Long, + override val addedByUserId: String, + override val mediaKind: MediaKind, + val filePath: String, + val directory: Boolean, +) : MediaReference() + +@Serializable +data class DownloadMediaReference( + @SerialName("_id") + override val id: String, + override val contentId: String, + override val rootContentId: String? = null, + override val added: Long, + override val addedByUserId: String, + override val mediaKind: MediaKind, + val hash: String, + val fileIndex: Int?, + val filePath: String? +) : MediaReference() diff --git a/data-models/src/Movie.kt b/data-models/src/Movie.kt new file mode 100644 index 00000000..003b0f62 --- /dev/null +++ b/data-models/src/Movie.kt @@ -0,0 +1,44 @@ +/** + * AnyStream + * Copyright (C) 2021 Drew Carlson + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package anystream.models + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class Movie( + @SerialName("_id") + val id: String, + val title: String, + val overview: String, + val tmdbId: Int, + val imdbId: String?, + val runtime: Int, + val posters: List, + val posterPath: String?, + val backdropPath: String?, + val releaseDate: String?, + val added: Long, + val addedByUserId: String, +) + +@Serializable +data class Image( + val filePath: String, + val language: String +) diff --git a/data-models/src/PlaybackState.kt b/data-models/src/PlaybackState.kt new file mode 100644 index 00000000..ce3a8837 --- /dev/null +++ b/data-models/src/PlaybackState.kt @@ -0,0 +1,33 @@ +/** + * AnyStream + * Copyright (C) 2021 Drew Carlson + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package anystream.models + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + + +@Serializable +data class PlaybackState( + @SerialName("_id") + val id: String, + val mediaReferenceId: String, + val mediaId: String, + val userId: String, + val position: Long, + val updatedAt: Long = 0L, +) diff --git a/data-models/src/TvSeason.kt b/data-models/src/TvSeason.kt new file mode 100644 index 00000000..44a4c03a --- /dev/null +++ b/data-models/src/TvSeason.kt @@ -0,0 +1,33 @@ +/** + * AnyStream + * Copyright (C) 2021 Drew Carlson + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package anystream.models + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class TvSeason( + @SerialName("_id") + val id: String, + val name: String, + val overview: String, + val seasonNumber: Int, + val airDate: String, + val tmdbId: Int, + val posterPath: String?, +) diff --git a/data-models/src/TvShow.kt b/data-models/src/TvShow.kt new file mode 100644 index 00000000..5df320b8 --- /dev/null +++ b/data-models/src/TvShow.kt @@ -0,0 +1,36 @@ +/** + * AnyStream + * Copyright (C) 2021 Drew Carlson + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package anystream.models + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class TvShow( + @SerialName("_id") + val id: String, + val name: String, + val tmdbId: Int, + val overview: String, + val firstAirDate: String, + val numberOfSeasons: Int, + val numberOfEpisodes: Int, + val seasons: List, + val posterPath: String, + val added: Long, +) diff --git a/data-models/src/User.kt b/data-models/src/User.kt new file mode 100644 index 00000000..528c3aac --- /dev/null +++ b/data-models/src/User.kt @@ -0,0 +1,63 @@ +/** + * AnyStream + * Copyright (C) 2021 Drew Carlson + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package anystream.models + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +const val USERNAME_LENGTH_MIN = 4 +const val USERNAME_LENGTH_MAX = 12 +const val PASSWORD_LENGTH_MIN = 6 +const val PASSWORD_LENGTH_MAX = 64 + +@Serializable +data class User( + @SerialName("_id") + val id: String, + val username: String, + val displayName: String, +) + +@Serializable +data class UserCredentials( + @SerialName("_id") + val id: String, + val password: String, + val salt: String, + val permissions: Set +) + +object Permissions { + const val GLOBAL = "*" + const val VIEW_COLLECTION = "view_collection" + const val MANAGE_COLLECTION = "manage_collection" + const val TORRENT_MANAGEMENT = "torrent_management" + + val all = listOf(GLOBAL, VIEW_COLLECTION, MANAGE_COLLECTION, TORRENT_MANAGEMENT) + + fun check(permission: String, permissions: Set): Boolean { + return permissions.contains(permission) || permissions.contains(GLOBAL) + } +} + +@Serializable +data class UpdateUserBody( + val displayName: String, + val password: String?, + val currentPassword: String?, +) diff --git a/data-models/src/api/CreateSessionModels.kt b/data-models/src/api/CreateSessionModels.kt new file mode 100644 index 00000000..fa7e51c8 --- /dev/null +++ b/data-models/src/api/CreateSessionModels.kt @@ -0,0 +1,54 @@ +/** + * AnyStream + * Copyright (C) 2021 Drew Carlson + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package anystream.models.api + +import anystream.models.User +import kotlinx.serialization.Serializable + + +@Serializable +data class CreateSessionBody( + val username: String, + val password: String +) + +@Serializable +data class CreateSessionResponse( + val success: CreateSessionSuccess? = null, + val error: CreateSessionError? = null +) { + companion object { + fun success(user: User, permissions: Set) = + CreateSessionResponse(CreateSessionSuccess(user, permissions)) + fun error(error: CreateSessionError) = + CreateSessionResponse(error = error) + } +} + +@Serializable +data class CreateSessionSuccess( + val user: User, + val permissions: Set, +) + +enum class CreateSessionError { + USERNAME_INVALID, + USERNAME_NOT_FOUND, + PASSWORD_INVALID, + PASSWORD_INCORRECT +} diff --git a/data-models/src/api/CreateUserModels.kt b/data-models/src/api/CreateUserModels.kt new file mode 100644 index 00000000..2adde77a --- /dev/null +++ b/data-models/src/api/CreateUserModels.kt @@ -0,0 +1,66 @@ +/** + * AnyStream + * Copyright (C) 2021 Drew Carlson + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package anystream.models.api + +import anystream.models.User +import kotlinx.serialization.Serializable + + +@Serializable +data class CreateUserBody( + val username: String, + val password: String, + val inviteCode: String?, +) + + +@Serializable +data class CreateUserResponse( + val success: CreateUserSuccess? = null, + val error: CreateUserError? = null +) { + companion object { + fun success(user: User, permissions: Set) = + CreateUserResponse(success = CreateUserSuccess(user, permissions)) + + fun error( + usernameError: CreateUserError.UsernameError?, + passwordError: CreateUserError.PasswordError? + ) = CreateUserResponse(error = CreateUserError(usernameError, passwordError)) + } +} + +@Serializable +data class CreateUserSuccess( + val user: User, + val permissions: Set, +) + +@Serializable +data class CreateUserError( + val usernameError: UsernameError?, + val passwordError: PasswordError? +) { + enum class PasswordError { + TOO_SHORT, TOO_LONG, BLANK + } + + enum class UsernameError { + TOO_SHORT, TOO_LONG, BLANK, ALREADY_EXISTS + } +} diff --git a/data-models/src/api/HomeResponse.kt b/data-models/src/api/HomeResponse.kt new file mode 100644 index 00000000..2ca46af2 --- /dev/null +++ b/data-models/src/api/HomeResponse.kt @@ -0,0 +1,30 @@ +/** + * AnyStream + * Copyright (C) 2021 Drew Carlson + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package anystream.models.api + +import anystream.models.* +import anystream.models.tmdb.PartialMovie +import kotlinx.serialization.Serializable + +@Serializable +data class HomeResponse( + val currentlyWatching: Map, + val recentlyAdded: Map, + val popularMovies: Map, + val recentlyAddedTv: List, +) diff --git a/data-models/src/api/ImportMedia.kt b/data-models/src/api/ImportMedia.kt new file mode 100644 index 00000000..de751ede --- /dev/null +++ b/data-models/src/api/ImportMedia.kt @@ -0,0 +1,66 @@ +/** + * AnyStream + * Copyright (C) 2021 Drew Carlson + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package anystream.models.api + +import anystream.models.MediaKind +import kotlinx.serialization.Serializable + + +@Serializable +data class ImportMedia( + val contentPath: String, + val mediaKind: MediaKind, +) + +@Serializable +sealed class ImportMediaResult { + + @Serializable + data class Success( + val mediaId: String, + val mediaRefId: String, + val subresults: List = emptyList(), + ) : ImportMediaResult() + + @Serializable + object ErrorNothingToImport : ImportMediaResult() + @Serializable + object ErrorFileNotFound : ImportMediaResult() + @Serializable + data class ErrorMediaMatchNotFound( + val contentPath: String, + val query: String, + ) : ImportMediaResult() + @Serializable + object ErrorMediaRefNotFound : ImportMediaResult() + + @Serializable + data class ErrorMediaRefAlreadyExists( + val existingRefId: String + ) : ImportMediaResult() + + @Serializable + data class ErrorDatabaseException( + val stacktrace: String, + ) : ImportMediaResult() + + @Serializable + data class ErrorDataProviderException( + val stacktrace: String, + ) : ImportMediaResult() +} diff --git a/data-models/src/api/MoviesResponse.kt b/data-models/src/api/MoviesResponse.kt new file mode 100644 index 00000000..8f8cd906 --- /dev/null +++ b/data-models/src/api/MoviesResponse.kt @@ -0,0 +1,28 @@ +/** + * AnyStream + * Copyright (C) 2021 Drew Carlson + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package anystream.models.api + +import anystream.models.MediaReference +import anystream.models.Movie +import kotlinx.serialization.Serializable + +@Serializable +data class MoviesResponse( + val movies: List, + val mediaReferences: List +) diff --git a/data-models/src/api/PagedResponse.kt b/data-models/src/api/PagedResponse.kt new file mode 100644 index 00000000..72eb6b37 --- /dev/null +++ b/data-models/src/api/PagedResponse.kt @@ -0,0 +1,25 @@ +/** + * AnyStream + * Copyright (C) 2021 Drew Carlson + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package anystream.models.api + +interface PagedResponse { + val items: List + val page: Int + val pageTotal: Int + val itemTotal: Int +} diff --git a/data-models/src/api/PairingMessage.kt b/data-models/src/api/PairingMessage.kt new file mode 100644 index 00000000..21500f39 --- /dev/null +++ b/data-models/src/api/PairingMessage.kt @@ -0,0 +1,39 @@ +/** + * AnyStream + * Copyright (C) 2021 Drew Carlson + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package anystream.models.api + +import kotlinx.serialization.Serializable + +@Serializable +sealed class PairingMessage { + + @Serializable + object Idle : PairingMessage() + + @Serializable + data class Started(val pairingCode: String) : PairingMessage() + + @Serializable + data class Authorized( + val secret: String, + val userId: String, + ) : PairingMessage() + + @Serializable + object Failed : PairingMessage() +} \ No newline at end of file diff --git a/data-models/src/api/TmdbMoviesResponse.kt b/data-models/src/api/TmdbMoviesResponse.kt new file mode 100644 index 00000000..1d73a94b --- /dev/null +++ b/data-models/src/api/TmdbMoviesResponse.kt @@ -0,0 +1,29 @@ +/** + * AnyStream + * Copyright (C) 2021 Drew Carlson + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package anystream.models.api + +import anystream.models.tmdb.PartialMovie +import kotlinx.serialization.Serializable + +@Serializable +data class TmdbMoviesResponse( + override val items: List = emptyList(), + override val itemTotal: Int = 0, + override val page: Int = 1, + override val pageTotal: Int = 1 +) : PagedResponse diff --git a/data-models/src/api/TmdbTvShowResponse.kt b/data-models/src/api/TmdbTvShowResponse.kt new file mode 100644 index 00000000..bd32247c --- /dev/null +++ b/data-models/src/api/TmdbTvShowResponse.kt @@ -0,0 +1,29 @@ +/** + * AnyStream + * Copyright (C) 2021 Drew Carlson + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package anystream.models.api + +import anystream.models.tmdb.PartialTvSeries +import kotlinx.serialization.Serializable + +@Serializable +data class TmdbTvShowResponse( + override val items: List = emptyList(), + override val itemTotal: Int = 0, + override val page: Int = 1, + override val pageTotal: Int = 1 +) : PagedResponse diff --git a/data-models/src/tmdb/CompleteTvSeries.kt b/data-models/src/tmdb/CompleteTvSeries.kt new file mode 100644 index 00000000..4534dc67 --- /dev/null +++ b/data-models/src/tmdb/CompleteTvSeries.kt @@ -0,0 +1,30 @@ +/** + * AnyStream + * Copyright (C) 2021 Drew Carlson + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package anystream.models.tmdb + +import kotlinx.serialization.Serializable + +@Serializable +data class CompleteTvSeries( + val tmdbId: Int, + val name: String, + val overview: String, + val firstAirDate: String?, + val lastAirDate: String?, + val keywords: List +) diff --git a/data-models/src/tmdb/PartialMovie.kt b/data-models/src/tmdb/PartialMovie.kt new file mode 100644 index 00000000..f479b745 --- /dev/null +++ b/data-models/src/tmdb/PartialMovie.kt @@ -0,0 +1,31 @@ +/** + * AnyStream + * Copyright (C) 2021 Drew Carlson + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package anystream.models.tmdb + +import kotlinx.serialization.Serializable + +@Serializable +data class PartialMovie( + val tmdbId: Int, + val title: String, + val overview: String, + val releaseDate: String?, + val posterPath: String?, + val backdropPath: String?, + val isAdded: Boolean +) diff --git a/data-models/src/tmdb/PartialTvSeries.kt b/data-models/src/tmdb/PartialTvSeries.kt new file mode 100644 index 00000000..afafbcfb --- /dev/null +++ b/data-models/src/tmdb/PartialTvSeries.kt @@ -0,0 +1,29 @@ +/** + * AnyStream + * Copyright (C) 2021 Drew Carlson + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package anystream.models.tmdb + +import kotlinx.serialization.Serializable + +@Serializable +data class PartialTvSeries( + val tmdbId: Int, + val name: String, + val overview: String, + val firstAirDate: String?, + val lastAirDate: String? +) diff --git a/data-models/src/torrent/TorrentDescription2.kt b/data-models/src/torrent/TorrentDescription2.kt new file mode 100644 index 00000000..2e2d0b9b --- /dev/null +++ b/data-models/src/torrent/TorrentDescription2.kt @@ -0,0 +1,36 @@ +/** + * AnyStream + * Copyright (C) 2021 Drew Carlson + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package anystream.torrent.search + +import kotlinx.serialization.Serializable +import kotlinx.serialization.Transient + +@Serializable +data class TorrentDescription2( + val provider: String, + val magnetUrl: String, + val title: String, + val size: Long, + val seeds: Int, + val peers: Int +) { + @Transient + val hash: String = magnetUrl + .substringAfter("xt=urn:btih:") + .substringBefore("&") +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..d97a44b5 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,71 @@ +version: '3.1' + +services: + + app: + container_name: app + image: index.docker.io/drewcarlson/anystream:latest + restart: unless-stopped + command: > + ./install/server-shadow/bin/server + -P:app.ffmpegPath=/usr/bin + -P:app.frontEndPath=/app/client-web + -P:app.mongoUrl=mongodb://root:password@mongo + -P:app.qbittorrentUrl=http://qbittorrent:9090 + -port=8888 + ports: + - 8888:8888 + volumes: + - ./qbittorrent/content:/content + - ./qbittorrent/downloads:/downloads + links: + - mongo + - qbittorrent + depends_on: + - mongo + - qbittorrent + + mongo: + container_name: mongo + image: mongo + restart: unless-stopped + environment: + MONGO_INITDB_ROOT_USERNAME: root + MONGO_INITDB_ROOT_PASSWORD: password + ports: + - 27017:27017 + + qbittorrent: + image: drewcarlson/docker-qbittorrentvpn + container_name: qbittorrent + restart: unless-stopped + cap_add: + - NET_ADMIN + sysctls: + - net.ipv6.conf.all.disable_ipv6=0 + privileged: true + environment: + - VPN_ENABLED=yes + - LAN_NETWORK=192.168.1.0/24 + - NAME_SERVERS=8.8.8.8,8.8.4.4 + - WEBUI_PORT=9090 + - INCOMING_PORT=8148 + - PUID=1000 + - PGID=1000 + - TZ=America/Los_Angeles + - UMASK_SET=022 + ports: + - 9090:9090 + volumes: + - ./qbittorrent/config:/config + - ./qbittorrent/downloads:/downloads + - ./qbittorrent/content:/content + + mongo-express: + container_name: mongo-express + image: mongo-express + environment: + ME_CONFIG_MONGODB_ADMINUSERNAME: root + ME_CONFIG_MONGODB_ADMINPASSWORD: password + ports: + - 8081:8081 diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 00000000..c0538538 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,11 @@ +org.gradle.parallel=true +org.gradle.jvmargs=-Xmx3g -XX:MaxMetaspaceSize=512m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 +kotlin.code.style=official +kotlin.mpp.stability.nowarn=true +kotlin.mpp.enableGranularSourceSetsMetadata=true +kotlin.native.ignoreDisabledTargets=true +android.useAndroidX=true +android.enableJetifier=true +# Publishing +group=anystream +version=0.0.1-SNAPSHOT diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 00000000..613194a3 --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,114 @@ +[versions] +kotlin = "1.5.10" +ktorio = "1.6.0" +korlibs_korio = "2.1.1" +logback = "1.2.1" +bouncy_castle = "1.67" +tmdb = "1.10" +coroutines = "1.5.0-native-mt" +serialization = "1.2.1" +kmongo = "4.2.7" +kvision = "4.8.1" +jaffree = "2021-05-07" +qbittorrent = "0.2.0" +torrent_search = "0.0.3" +ktor_perm = "0.1.0" +compose = "1.0.0-beta08" +exoplayer = "2.14.0" +zxing = "3.3.0" +anr_watchdog = "1.4.0" +mobiuskt = "0.1.9" +kotlinjs_ext = "1.0.1-pre.201-kotlin-1.5.0" + +[libraries] +coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } +coroutines-jdk8 = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-jdk8", version.ref = "coroutines" } + +serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "serialization" } +serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization" } + +kotlinjs-extensions = { module = "org.jetbrains.kotlin-wrappers:kotlin-extensions", version.ref = "kotlinjs_ext" } + +ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktorio" } +ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktorio" } +ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktorio" } +ktor-client-js = { module = "io.ktor:ktor-client-js", version.ref = "ktorio" } +ktor-client-json = { module = "io.ktor:ktor-client-json", version.ref = "ktorio" } +ktor-client-serialization = { module = "io.ktor:ktor-client-serialization", version.ref = "ktorio" } + +ktor-server-core = { module = "io.ktor:ktor-server-core", version.ref = "ktorio" } +ktor-server-netty = { module = "io.ktor:ktor-server-netty", version.ref = "ktorio" } +ktor-server-sessions = { module = "io.ktor:ktor-server-sessions", version.ref = "ktorio" } +ktor-server-tests = { module = "io.ktor:ktor-server-tests", version.ref = "ktorio" } +ktor-server-metrics = { module = "io.ktor:ktor-metrics", version.ref = "ktorio" } +ktor-server-auth = { module = "io.ktor:ktor-auth", version.ref = "ktorio" } +ktor-server-authJwt = { module = "io.ktor:ktor-auth-jwt", version.ref = "ktorio" } +ktor-server-serialization = { module = "io.ktor:ktor-serialization", version.ref = "ktorio" } +ktor-server-websockets = { module = "io.ktor:ktor-websockets", version.ref = "ktorio" } +ktor-server-permissions = { module = "drewcarlson.ktor:ktor-permissions", version.ref = "ktor_perm" } # TODO: { module = "org.drewcarlson:ktor-permissions", version.ref = "ktor_perm" } + +korio = { module = "com.soywiz.korlibs.korio:korio", version.ref = "korlibs_korio" } + +qbittorrent-models = { module = "org.drewcarlson:qbittorrent-models", version.ref = "qbittorrent" } +qbittorrent-client = { module = "org.drewcarlson:qbittorrent-client", version.ref = "qbittorrent" } + +anrWatchdog = { module = "com.github.anrwatchdog:anrwatchdog", version.ref = "anr_watchdog" } +zxing-core = { module = "com.google.zxing:core", version.ref = "zxing" } + +exoplayer-core = { module = "com.google.android.exoplayer:exoplayer-core", version.ref = "exoplayer" } +exoplayer-ui = { module = "com.google.android.exoplayer:exoplayer-ui", version.ref = "exoplayer" } +exoplayer-hls = { module = "com.google.android.exoplayer:exoplayer-hls", version.ref = "exoplayer" } + +torrentSearch = { module = "org.drewcarlson:torrentsearch", version.ref = "torrent_search" } + +compose-ui-ui = { module = "androidx.compose.ui:ui", version.ref = "compose" } +compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling", version.ref = "compose" } +compose-foundation = { module = "androidx.compose.foundation:foundation", version.ref = "compose" } +compose-material = { module = "androidx.compose.material:material", version.ref = "compose" } + +jaffree = { module = "com.github.kokorin.jaffree:jaffree", version.ref = "jaffree" } + +bouncyCastle = { module = "org.bouncycastle:bcprov-jdk15on", version.ref = "bouncy_castle" } + +logback = { module = "ch.qos.logback:logback-classic", version.ref = "logback" } + +tmdbapi = { module = "info.movito:themoviedbapi", version.ref = "tmdb" } + +kmongo-coroutine-serialization = { module = "org.litote.kmongo:kmongo-coroutine-serialization", version.ref = "kmongo" } + +kvision-core = { module = "io.kvision:kvision", version.ref = "kvision" } +kvision-routing-navigo = { module = "io.kvision:kvision-routing-navigo", version.ref = "kvision" } +kvision-bootstrap-core = { module = "io.kvision:kvision-bootstrap", version.ref = "kvision" } +kvision-bootstrap-css = { module = "io.kvision:kvision-bootstrap-css", version.ref = "kvision" } +kvision-bootstrap-datetime = { module = "io.kvision:kvision-bootstrap-datetime", version.ref = "kvision" } +kvision-bootstrap-select = { module = "io.kvision:kvision-bootstrap-select", version.ref = "kvision" } +kvision-bootstrap-spinner = { module = "io.kvision:kvision-bootstrap-spinner", version.ref = "kvision" } +kvision-bootstrap-upload = { module = "io.kvision:kvision-bootstrap-upload", version.ref = "kvision" } +kvision-bootstrap-dialog = { module = "io.kvision:kvision-bootstrap-dialog", version.ref = "kvision" } +kvision-fontawesome = { module = "io.kvision:kvision-fontawesome", version.ref = "kvision" } +kvision-richtext = { module = "io.kvision:kvision-richtext", version.ref = "kvision" } +kvision-handlebars = { module = "io.kvision:kvision-handlebars", version.ref = "kvision" } +kvision-tabulator = { module = "io.kvision:kvision-tabulator", version.ref = "kvision" } +kvision-pace = { module = "io.kvision:kvision-pace", version.ref = "kvision" } +kvision-moment = { module = "io.kvision:kvision-moment", version.ref = "kvision" } +kvision-datacontainer = { module = "io.kvision:kvision-datacontainer", version.ref = "kvision" } +kvision-toast = { module = "io.kvision:kvision-toast", version.ref = "kvision" } +kvision-eventFlow = { module = "io.kvision:kvision-event-flow", version.ref = "kvision" } + +androidx-core-ktx = { module = "androidx.core:core-ktx", version = "1.5.0-rc01" } +androidx-appcompat-core = { module = "androidx.appcompat:appcompat", version = "1.3.0-rc01" } +androidx-leanback-core = { module = "androidx.leanback:leanback", version = "1.1.0-rc01" } +androidx-activity-ktx = { module = "androidx.activity:activity-ktx", version = "1.3.0-alpha07" } +androidx-activity-compose = { module = "androidx.activity:activity-compose", version = "1.3.0-alpha07" } + +accompanist-coil = { module = "com.google.accompanist:accompanist-coil", version = "0.10.0" } + +quickie-bundled = { module = "io.github.g00fy2.quickie:quickie-bundled", version = "0.7.2" } + +mobiuskt-core = { module = "org.drewcarlson:mobiuskt-core", version.ref = "mobiuskt" } +mobiuskt-extras = { module = "org.drewcarlson:mobiuskt-extras", version.ref = "mobiuskt" } +mobiuskt-android = { module = "org.drewcarlson:mobiuskt-android", version.ref = "mobiuskt" } + +[bundles] +exoplayer = [ "exoplayer-core", "exoplayer-ui", "exoplayer-hls" ] +compose = [ "compose-ui-ui", "compose-ui-tooling", "compose-foundation", "compose-material" ] diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 00000000..28861d27 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..9954e687 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https://services.gradle.org/distributions/gradle-7.1-all.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 00000000..cccdd3d5 --- /dev/null +++ b/gradlew @@ -0,0 +1,172 @@ +#!/usr/bin/env sh + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS="" + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin, switch paths to Windows format before running java +if $cygwin ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=$((i+1)) + done + case $i in + (0) set -- ;; + (1) set -- "$args0" ;; + (2) set -- "$args0" "$args1" ;; + (3) set -- "$args0" "$args1" "$args2" ;; + (4) set -- "$args0" "$args1" "$args2" "$args3" ;; + (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=$(save "$@") + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong +if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then + cd "$(dirname "$0")" +fi + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 00000000..e95643d6 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,84 @@ +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS= + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto init + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto init + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:init +@rem Get command-line arguments, handling Windows variants + +if not "%OS%" == "Windows_NT" goto win9xME_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/ktor-permissions/build.gradle.kts b/ktor-permissions/build.gradle.kts new file mode 100644 index 00000000..a5fe1977 --- /dev/null +++ b/ktor-permissions/build.gradle.kts @@ -0,0 +1,18 @@ +plugins { + kotlin("jvm") + kotlin("plugin.serialization") +} + + +dependencies { + implementation(kotlin("stdlib")) + implementation(libs.ktor.server.core) + implementation(libs.ktor.server.sessions) + implementation(libs.ktor.server.auth) + + testImplementation(kotlin("test")) + testImplementation(kotlin("test-junit")) + testImplementation(libs.ktor.server.tests) + testImplementation(libs.ktor.server.serialization) + testImplementation(libs.serialization.json) +} diff --git a/ktor-permissions/src/main/kotlin/AuthorizedRouteSelector.kt b/ktor-permissions/src/main/kotlin/AuthorizedRouteSelector.kt new file mode 100644 index 00000000..bbb53ab7 --- /dev/null +++ b/ktor-permissions/src/main/kotlin/AuthorizedRouteSelector.kt @@ -0,0 +1,31 @@ +/** + * AnyStream + * Copyright (C) 2021 Drew Carlson + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package drewcarlson.ktor.permissions + +import io.ktor.routing.RouteSelector +import io.ktor.routing.RouteSelectorEvaluation +import io.ktor.routing.RoutingResolveContext + +class AuthorizedRouteSelector(private val description: String) : + RouteSelector(RouteSelectorEvaluation.qualityConstant) { + + override fun evaluate(context: RoutingResolveContext, segmentIndex: Int) = + RouteSelectorEvaluation.Constant + + override fun toString(): String = "(authorize ${description})" +} diff --git a/ktor-permissions/src/main/kotlin/PermissionAuthorization.kt b/ktor-permissions/src/main/kotlin/PermissionAuthorization.kt new file mode 100644 index 00000000..06d33d41 --- /dev/null +++ b/ktor-permissions/src/main/kotlin/PermissionAuthorization.kt @@ -0,0 +1,124 @@ +/** + * AnyStream + * Copyright (C) 2021 Drew Carlson + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package drewcarlson.ktor.permissions + +import io.ktor.application.* +import io.ktor.auth.* +import io.ktor.http.HttpStatusCode.Companion.Forbidden +import io.ktor.request.* +import io.ktor.response.* +import io.ktor.routing.* +import io.ktor.util.* +import io.ktor.util.pipeline.* + +private const val PRINCIPAL_OBJECT_MISSING = "Principal missing, is the route wrapped in `authenticate { }`?" +private const val PRINCIPAL_PERMISSIONS_MISSING_ALL = "Principal '%s' is missing required permission(s) %s" +private const val PRINCIPAL_PERMISSIONS_MISSING_ANY = "Principal '%s' is missing all possible permission(s) %s" +private const val PRINCIPAL_PERMISSIONS_MATCHED_EXCLUDE = "Principal '%s' has excluded permission(s) %s" +private const val EXTRACT_PERMISSIONS_NOT_DEFINED = "Principal permission extractor must be defined, ex: `extract { (it as Session).permissions }`." + +class PermissionAuthorization internal constructor( + private val configuration: Configuration +) { + + class Configuration internal constructor() { + internal var globalPermission: Any? = null + internal var extractPermissions: (Principal) -> Set = + { throw NotImplementedError(EXTRACT_PERMISSIONS_NOT_DEFINED) } + + /** + * Define the Global permission to ignore route specific + * permission requirements when attached to the [Principal]. + */ + fun

global(permission: P) { + globalPermission = permission + } + + /** + * Define how to extract the user's permission sent from + * the [Principal] instance. + * + * Note: This should be a fast value mapping function, + * do not read from an expensive data source. + */ + fun

extract(body: (Principal) -> Set

) { + extractPermissions = body + } + } + + fun

interceptPipeline( + pipeline: ApplicationCallPipeline, + any: Set

? = null, + all: Set

? = null, + none: Set

? = null + ) { + pipeline.insertPhaseAfter(ApplicationCallPipeline.Features, Authentication.ChallengePhase) + pipeline.insertPhaseAfter(Authentication.ChallengePhase, AuthorizationPhase) + + pipeline.intercept(AuthorizationPhase) { + val principal = checkNotNull(call.authentication.principal()) { + PRINCIPAL_OBJECT_MISSING + } + val activePermissions = configuration.extractPermissions(principal) + configuration.globalPermission?.let { + if (activePermissions.contains(it)) { + return@intercept + } + } + val denyReasons = mutableListOf() + all?.let { + val missing = all - activePermissions + if (missing.isNotEmpty()) { + denyReasons += PRINCIPAL_PERMISSIONS_MISSING_ALL.format(principal, missing.joinToString(" and ")) + } + } + any?.let { + if (any.none { it in activePermissions }) { + denyReasons += PRINCIPAL_PERMISSIONS_MISSING_ANY.format(principal, any.joinToString(" or ")) + } + } + none?.let { + if (none.any { it in activePermissions }) { + val permissions = none.intersect(activePermissions).joinToString(" and ") + denyReasons += PRINCIPAL_PERMISSIONS_MATCHED_EXCLUDE.format(principal, permissions) + } + } + if (denyReasons.isNotEmpty()) { + val message = denyReasons.joinToString(". ") + call.application.log.warn("Authorization failed for ${call.request.path()}. $message") + call.respond(Forbidden) + finish() + } + } + } + + + companion object Feature : + ApplicationFeature { + override val key = AttributeKey("PermissionAuthorization") + + val AuthorizationPhase = PipelinePhase("PermissionAuthorization") + + override fun install( + pipeline: ApplicationCallPipeline, + configure: Configuration.() -> Unit + ): PermissionAuthorization { + return PermissionAuthorization(Configuration().also(configure)) + } + } +} diff --git a/ktor-permissions/src/main/kotlin/RoutePermissionApi.kt b/ktor-permissions/src/main/kotlin/RoutePermissionApi.kt new file mode 100644 index 00000000..ea4e6692 --- /dev/null +++ b/ktor-permissions/src/main/kotlin/RoutePermissionApi.kt @@ -0,0 +1,80 @@ +/** + * AnyStream + * Copyright (C) 2021 Drew Carlson + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package drewcarlson.ktor.permissions + +import io.ktor.application.* +import io.ktor.routing.* + +/** + * Routes defined in [build] will only be invoked when the + * [io.ktor.auth.Principal] contains [permission]. + * + * If a global permission is defined, [io.ktor.auth.Principal]s with + * that permission will ignore the requirement. + */ +fun

Route.withPermission(permission: P, build: Route.() -> Unit) = + authorizedRoute(all = setOf(permission), build = build) + +/** + * Routes defined in [build] will only be invoked when the + * [io.ktor.auth.Principal] contains all of [permissions]. + * + * If a global permission is defined, [io.ktor.auth.Principal]s with + * that permission will ignore the requirement. + */ +fun

Route.withAllPermissions(vararg permissions: P, build: Route.() -> Unit) = + authorizedRoute(all = permissions.toSet(), build = build) + +/** + * Routes defined in [build] will only be invoked when the + * [io.ktor.auth.Principal] contains any of [permissions]. + * + * If a global permission is defined, [io.ktor.auth.Principal]s with + * that permission will ignore the requirement. + */ +fun

Route.withAnyPermission(vararg permissions: P, build: Route.() -> Unit) = + authorizedRoute(any = permissions.toSet(), build = build) + +/** + * Routes defined in [build] will only be invoked when the + * [io.ktor.auth.Principal] does not contain any of [permissions]. + * + * If a global permission is defined, [io.ktor.auth.Principal]s with + * that permission will ignore the requirement. + */ +fun

Route.withoutPermissions(vararg permissions: P, build: Route.() -> Unit) = + authorizedRoute(none = permissions.toSet(), build = build) + +private fun

Route.authorizedRoute( + any: Set

? = null, + all: Set

? = null, + none: Set

? = null, + build: Route.() -> Unit +): Route { + val description = listOfNotNull( + any?.let { "anyOf (${any.joinToString(" ")})" }, + all?.let { "allOf (${all.joinToString(" ")})" }, + none?.let { "noneOf (${none.joinToString(" ")})" } + ).joinToString(",") + return createChild(AuthorizedRouteSelector(description)).also { route -> + application + .feature(PermissionAuthorization) + .interceptPipeline(route, any, all, none) + route.build() + } +} diff --git a/ktor-permissions/src/test/kotlin/PermissionTests.kt b/ktor-permissions/src/test/kotlin/PermissionTests.kt new file mode 100644 index 00000000..dcedb915 --- /dev/null +++ b/ktor-permissions/src/test/kotlin/PermissionTests.kt @@ -0,0 +1,218 @@ +/** + * AnyStream + * Copyright (C) 2021 Drew Carlson + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package drewcarlson.ktor.permissions + +import drewcarlson.ktor.permissions.Permission.A +import drewcarlson.ktor.permissions.Permission.B +import drewcarlson.ktor.permissions.Permission.C +import drewcarlson.ktor.permissions.Permission.Z +import io.ktor.auth.Principal +import io.ktor.http.HttpStatusCode.Companion.Forbidden +import io.ktor.http.HttpStatusCode.Companion.OK +import kotlinx.serialization.Serializable +import kotlin.test.Test +import kotlin.test.assertEquals + +enum class Permission { + A, B, C, Z +} + +@Serializable +data class UserSession( + val userId: String, + val permissions: Set, +) : Principal + +class PermissionTests { + + @Test + fun testUndefinedGlobalHasNoRestrictedAccess() { + runPermissionTest(setGlobal = false) { + val token = tokenWith(Z) + + Permission.values() + .filter { it != Z } + .fold(listOf()) { prev, perm -> + val set = (prev + perm).toSet().joinToString("") { it.name } + assertEquals(Forbidden, statusFor("/all/$set", token)) + assertEquals(Forbidden, statusFor("/any/$set", token)) + assertEquals(Forbidden, statusFor("/$perm", token)) + prev + perm + } + } + } + + @Test + fun testGlobalAllowsBypassesAllRules() { + runPermissionTest(setGlobal = true) { + val token = tokenWith(Z) + + Permission.values() + .fold(listOf()) { prev, perm -> + val set = (prev + perm).toSet().joinToString("") { it.name } + assertEquals(OK, statusFor("/all/$set", token)) + assertEquals(OK, statusFor("/any/$set", token)) + assertEquals(OK, statusFor("/$perm", token)) + assertEquals(OK, statusFor("/without/$perm", token)) + prev + perm + } + } + } + + @Test + fun testWithPermission() { + runPermissionTest(setGlobal = true) { + val tokenA = tokenWith(A) + + assertEquals(OK, statusFor("/A", tokenA)) + assertEquals(Forbidden, statusFor("/B", tokenA)) + assertEquals(Forbidden, statusFor("/C", tokenA)) + assertEquals(Forbidden, statusFor("/Z", tokenA)) + + val tokenB = tokenWith(B) + + assertEquals(OK, statusFor("/B", tokenB)) + assertEquals(Forbidden, statusFor("/A", tokenB)) + assertEquals(Forbidden, statusFor("/C", tokenB)) + assertEquals(Forbidden, statusFor("/Z", tokenB)) + + val tokenC = tokenWith(C) + + assertEquals(OK, statusFor("/C", tokenC)) + assertEquals(Forbidden, statusFor("/A", tokenC)) + assertEquals(Forbidden, statusFor("/B", tokenC)) + assertEquals(Forbidden, statusFor("/Z", tokenC)) + } + } + + @Test + fun testWithAllPermission() { + runPermissionTest(setGlobal = true) { + val tokenA = tokenWith(A) + + assertEquals(OK, statusFor("/all/A", tokenA)) + assertEquals(Forbidden, statusFor("/all/AB", tokenA)) + assertEquals(Forbidden, statusFor("/all/ABC", tokenA)) + assertEquals(Forbidden, statusFor("/all/ABCZ", tokenA)) + + val tokenB = tokenWith(B) + + assertEquals(OK, statusFor("/all/B", tokenB)) + assertEquals(Forbidden, statusFor("/all/A", tokenB)) + assertEquals(Forbidden, statusFor("/all/AB", tokenB)) + assertEquals(Forbidden, statusFor("/all/ABC", tokenB)) + assertEquals(Forbidden, statusFor("/all/ABCZ", tokenB)) + + val tokenC = tokenWith(C) + + assertEquals(OK, statusFor("/all/C", tokenC)) + assertEquals(Forbidden, statusFor("/all/A", tokenC)) + assertEquals(Forbidden, statusFor("/all/AB", tokenC)) + assertEquals(Forbidden, statusFor("/all/ABC", tokenC)) + assertEquals(Forbidden, statusFor("/all/ABCZ", tokenC)) + + val tokenAB = tokenWith(A, B) + + assertEquals(OK, statusFor("/all/A", tokenAB)) + assertEquals(OK, statusFor("/all/B", tokenAB)) + assertEquals(OK, statusFor("/all/AB", tokenAB)) + assertEquals(Forbidden, statusFor("/all/C", tokenAB)) + assertEquals(Forbidden, statusFor("/all/ABC", tokenAB)) + assertEquals(Forbidden, statusFor("/all/ABCZ", tokenAB)) + } + } + + @Test + fun testWithAnyPermission() { + runPermissionTest(setGlobal = true) { + val tokenA = tokenWith(A) + + assertEquals(OK, statusFor("/any/A", tokenA)) + assertEquals(OK, statusFor("/any/AB", tokenA)) + assertEquals(OK, statusFor("/any/ABC", tokenA)) + assertEquals(Forbidden, statusFor("/any/B", tokenA)) + assertEquals(Forbidden, statusFor("/any/C", tokenA)) + assertEquals(Forbidden, statusFor("/any/Z", tokenA)) + + val tokenB = tokenWith(B) + + assertEquals(OK, statusFor("/any/ABC", tokenB)) + assertEquals(OK, statusFor("/any/AB", tokenB)) + assertEquals(OK, statusFor("/any/B", tokenB)) + assertEquals(Forbidden, statusFor("/any/C", tokenB)) + assertEquals(Forbidden, statusFor("/any/A", tokenB)) + assertEquals(Forbidden, statusFor("/any/Z", tokenB)) + + val tokenC = tokenWith(C) + + assertEquals(OK, statusFor("/any/C", tokenC)) + assertEquals(OK, statusFor("/any/ABC", tokenC)) + assertEquals(Forbidden, statusFor("/any/A", tokenC)) + assertEquals(Forbidden, statusFor("/any/AB", tokenC)) + assertEquals(Forbidden, statusFor("/any/B", tokenC)) + assertEquals(Forbidden, statusFor("/any/Z", tokenC)) + + val tokenBC = tokenWith(B, C) + + assertEquals(OK, statusFor("/any/C", tokenBC)) + assertEquals(OK, statusFor("/any/ABC", tokenBC)) + assertEquals(Forbidden, statusFor("/any/A", tokenBC)) + assertEquals(OK, statusFor("/any/AB", tokenBC)) + assertEquals(OK, statusFor("/any/B", tokenBC)) + assertEquals(Forbidden, statusFor("/any/Z", tokenC)) + } + } + + @Test + fun testWithoutPermission() { + runPermissionTest(setGlobal = true) { + val tokenA = tokenWith(A) + + assertEquals(Forbidden, statusFor("/without/A", tokenA)) + assertEquals(Forbidden, statusFor("/without/AB", tokenA)) + assertEquals(Forbidden, statusFor("/without/ABC", tokenA)) + assertEquals(OK, statusFor("/without/B", tokenA)) + assertEquals(OK, statusFor("/without/C", tokenA)) + assertEquals(OK, statusFor("/without/Z", tokenA)) + + val tokenB = tokenWith(B) + + assertEquals(Forbidden, statusFor("/without/ABC", tokenB)) + assertEquals(Forbidden, statusFor("/without/AB", tokenB)) + assertEquals(Forbidden, statusFor("/without/B", tokenB)) + assertEquals(OK, statusFor("/without/C", tokenB)) + assertEquals(OK, statusFor("/without/A", tokenB)) + + val tokenC = tokenWith(C) + + assertEquals(Forbidden, statusFor("/without/C", tokenC)) + assertEquals(Forbidden, statusFor("/without/ABC", tokenC)) + assertEquals(OK, statusFor("/without/A", tokenC)) + assertEquals(OK, statusFor("/without/AB", tokenC)) + assertEquals(OK, statusFor("/without/B", tokenC)) + + val tokenAB = tokenWith(A,B) + + assertEquals(OK, statusFor("/without/C", tokenAB)) + assertEquals(Forbidden, statusFor("/without/ABC", tokenAB)) + assertEquals(Forbidden, statusFor("/without/A", tokenAB)) + assertEquals(Forbidden, statusFor("/without/AB", tokenAB)) + assertEquals(Forbidden, statusFor("/without/B", tokenAB)) + } + } +} diff --git a/ktor-permissions/src/test/kotlin/runPermissionTest.kt b/ktor-permissions/src/test/kotlin/runPermissionTest.kt new file mode 100644 index 00000000..694d0526 --- /dev/null +++ b/ktor-permissions/src/test/kotlin/runPermissionTest.kt @@ -0,0 +1,148 @@ +/** + * AnyStream + * Copyright (C) 2021 Drew Carlson + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package drewcarlson.ktor.permissions + +import io.ktor.application.call +import io.ktor.application.install +import io.ktor.auth.Authentication +import io.ktor.auth.authenticate +import io.ktor.auth.session +import io.ktor.features.ContentNegotiation +import io.ktor.http.ContentType +import io.ktor.http.HttpMethod +import io.ktor.http.HttpStatusCode +import io.ktor.request.receiveOrNull +import io.ktor.response.respond +import io.ktor.routing.* +import io.ktor.serialization.DefaultJson +import io.ktor.serialization.json +import io.ktor.server.testing.* +import io.ktor.sessions.SessionStorageMemory +import io.ktor.sessions.Sessions +import io.ktor.sessions.getOrSet +import io.ktor.sessions.header +import io.ktor.sessions.sessions +import kotlinx.serialization.encodeToString +import java.util.Base64 +import kotlin.random.Random + +private const val TOKEN = "TOKEN" + +fun TestApplicationEngine.tokenWith(vararg permissions: Permission): String { + return handleRequest(HttpMethod.Post, "/token") { + addHeader("Content-Type", ContentType.Application.Json.toString()) + setBody(DefaultJson.encodeToString(permissions)) + }.response.headers[TOKEN]!! +} + +fun TestApplicationEngine.statusFor( + uri: String, + token: String, +): HttpStatusCode { + return handleRequest(HttpMethod.Get, uri) { + addHeader(TOKEN, token) + }.response.status()!! +} + +fun runPermissionTest( + setGlobal: Boolean, + test: TestApplicationEngine.() -> Unit, +) { + withTestApplication({ + install(Authentication) { + session { + challenge { context.respond(HttpStatusCode.Unauthorized) } + validate { it } + } + } + + install(Sessions) { + header(TOKEN, SessionStorageMemory()) { + identity { Base64.getEncoder().encodeToString(Random.nextBytes(12)) } + } + } + + install(PermissionAuthorization) { + if (setGlobal) { + global(Permission.Z) + } + extract { (it as UserSession).permissions } + } + + install(ContentNegotiation) { + json() + } + + routing { + post("/token") { + val permissions = call.receiveOrNull>()?.toSet() + call.sessions.getOrSet { + UserSession("test", permissions ?: emptySet()) + } + call.respond(HttpStatusCode.OK) + } + authenticate { + val perms = Permission.values().toList() + perms.forEach { permission -> + withPermission(permission) { + get("/${permission.name}") { + call.respond(HttpStatusCode.OK) + } + } + } + perms.fold(emptyList()) { acc, permission -> + val set = (acc + permission).toSet() + withAllPermissions(*set.toTypedArray()) { + get("/all/${set.joinToString("") { it.name }}") { + call.respond(HttpStatusCode.OK) + } + } + withoutPermissions(*set.toTypedArray()) { + get("/without/${set.joinToString("") { it.name }}") { + call.respond(HttpStatusCode.OK) + } + } + withAnyPermission(*set.toTypedArray()) { + get("/any/${set.joinToString("") { it.name }}") { + call.respond(HttpStatusCode.OK) + } + } + + if (set.size > 1) { + withAllPermissions(permission) { + get("/all/${permission.name}") { + call.respond(HttpStatusCode.OK) + } + } + withoutPermissions(permission) { + get("/without/${permission.name}") { + call.respond(HttpStatusCode.OK) + } + } + withAnyPermission(permission) { + get("/any/${permission.name}") { + call.respond(HttpStatusCode.OK) + } + } + } + acc + permission + } + } + } + }, test = test) +} diff --git a/media/screenshot-android-home.png b/media/screenshot-android-home.png new file mode 100644 index 00000000..620971a1 Binary files /dev/null and b/media/screenshot-android-home.png differ diff --git a/media/screenshot-web-home.png b/media/screenshot-web-home.png new file mode 100644 index 00000000..5f6f0f10 Binary files /dev/null and b/media/screenshot-web-home.png differ diff --git a/preferences/build.gradle.kts b/preferences/build.gradle.kts new file mode 100644 index 00000000..cf75f6fe --- /dev/null +++ b/preferences/build.gradle.kts @@ -0,0 +1,46 @@ +import com.android.build.gradle.LibraryExtension + +plugins { + kotlin("multiplatform") + kotlin("plugin.serialization") +} + +if (hasAndroidSdk) { + apply(plugin = "com.android.library") + configure { + compileSdk = 29 + defaultConfig { + minSdk = 23 + targetSdk = 29 + } + sourceSets { + named("main") { + manifest.srcFile("src/androidMain/AndroidManifest.xml") + } + } + } +} + +kotlin { + if (hasAndroidSdk) { + android() + } + jvm() + ios() + js(IR) { + browser() + } + + sourceSets { + all { + languageSettings.apply { + useExperimentalAnnotation("kotlin.RequiresOptIn") + } + } + named("commonMain") { + dependencies { + implementation(kotlin("stdlib-common")) + } + } + } +} diff --git a/preferences/src/androidMain/AndroidManifest.xml b/preferences/src/androidMain/AndroidManifest.xml new file mode 100644 index 00000000..8196ec74 --- /dev/null +++ b/preferences/src/androidMain/AndroidManifest.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/preferences/src/androidMain/kotlin/AndroidPreferences.kt b/preferences/src/androidMain/kotlin/AndroidPreferences.kt new file mode 100644 index 00000000..090f62d8 --- /dev/null +++ b/preferences/src/androidMain/kotlin/AndroidPreferences.kt @@ -0,0 +1,130 @@ +/** + * AnyStream + * Copyright (C) 2021 Drew Carlson + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package anystream.prefs + +import android.annotation.SuppressLint +import android.content.SharedPreferences + +class AndroidPreferences( + private val prefs: SharedPreferences, +) : Preferences { + + override val keys: Set + get() = prefs.all.keys.toSet() + + override val size: Int + get() = prefs.all.size + + override fun contains(key: String): Boolean { + return prefs.contains(key) + } + + override fun removeAll() { + prefs.edit { clear() } + } + + override fun remove(key: String) { + prefs.edit { remove(key) } + } + + override fun putInt(key: String, value: Int) { + prefs.edit { putInt(key, value) } + } + + override fun getInt(key: String, defaultValue: Int): Int { + return prefs.getInt(key, defaultValue) + } + + override fun getIntOrNull(key: String): Int? { + return if (contains(key)) prefs.getInt(key, 0) else null + } + + override fun putLong(key: String, value: Long) { + prefs.edit { putLong(key, value) } + } + + override fun getLong(key: String, defaultValue: Long): Long { + return prefs.getLong(key, defaultValue) + } + + override fun getLongOrNull(key: String): Long? { + return if (contains(key)) prefs.getLong(key, 0L) else null + } + + override fun putString(key: String, value: String) { + prefs.edit { putString(key, value) } + } + + override fun getString(key: String, defaultValue: String): String { + return prefs.getString(key, defaultValue) ?: defaultValue + } + + override fun getStringOrNull(key: String): String? { + return if (contains(key)) prefs.getString(key, "") else null + } + + override fun putDouble(key: String, value: Double) { + putLong(key, value.toRawBits()) + } + + override fun getDouble(key: String, defaultValue: Double): Double { + return Double.fromBits(prefs.getLong(key, defaultValue.toRawBits())) + } + + override fun getDoubleOrNull(key: String): Double? { + return if (contains(key)) getDouble(key, 0.0) else null + } + + override fun putBoolean(key: String, value: Boolean) { + prefs.edit { putBoolean(key, value) } + } + + override fun getBoolean(key: String, defaultValue: Boolean): Boolean { + return prefs.getBoolean(key, defaultValue) + } + + override fun getBooleanOrNull(key: String): Boolean? { + return if (contains(key)) prefs.getBoolean(key, false) else null + } + + override fun getFloat(key: String, defaultValue: Float): Float { + return prefs.getFloat(key, defaultValue) + } + + override fun putFloat(key: String, value: Float) { + prefs.edit { putFloat(key, value) } + } + + override fun getFloatOrNull(key: String): Float? { + return if (contains(key)) prefs.getFloat(key, 0f) else null + } + + @SuppressLint("ApplySharedPref") + private inline fun SharedPreferences.edit( + commit: Boolean = false, + action: SharedPreferences.Editor.() -> Unit + ) { + val editor = edit() + action(editor) + if (commit) { + editor.commit() + } else { + editor.apply() + } + } +} \ No newline at end of file diff --git a/preferences/src/commonMain/kotlin/Preferences.kt b/preferences/src/commonMain/kotlin/Preferences.kt new file mode 100644 index 00000000..26a8f007 --- /dev/null +++ b/preferences/src/commonMain/kotlin/Preferences.kt @@ -0,0 +1,53 @@ +/** + * AnyStream + * Copyright (C) 2021 Drew Carlson + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package anystream.prefs + +interface Preferences { + + val keys: Set + val size: Int + + fun contains(key: String): Boolean + + fun removeAll() + fun remove(key: String) + + fun putInt(key: String, value: Int) + fun getInt(key: String, defaultValue: Int = 0): Int + fun getIntOrNull(key: String): Int? + + fun putLong(key: String, value: Long) + fun getLong(key: String, defaultValue: Long = 0L): Long + fun getLongOrNull(key: String): Long? + + fun putFloat(key: String, value: Float) + fun getFloat(key: String, defaultValue: Float = 0f): Float + fun getFloatOrNull(key: String): Float? + + fun putDouble(key: String, value: Double) + fun getDouble(key: String, defaultValue: Double = 0.0): Double + fun getDoubleOrNull(key: String): Double? + + fun putBoolean(key: String, value: Boolean) + fun getBoolean(key: String, defaultValue: Boolean = false): Boolean + fun getBooleanOrNull(key: String): Boolean? + + fun putString(key: String, value: String) + fun getString(key: String, defaultValue: String = ""): String + fun getStringOrNull(key: String): String? +} \ No newline at end of file diff --git a/preferences/src/iosMain/kotlin/IosPreferences.kt b/preferences/src/iosMain/kotlin/IosPreferences.kt new file mode 100644 index 00000000..187608e2 --- /dev/null +++ b/preferences/src/iosMain/kotlin/IosPreferences.kt @@ -0,0 +1,121 @@ +/** + * AnyStream + * Copyright (C) 2021 Drew Carlson + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package anystream.prefs + +import platform.Foundation.NSUserDefaults +import kotlin.native.concurrent.freeze + +class IosPreferences( + private val prefs: NSUserDefaults +) : Preferences { + + init { + freeze() + } + + @Suppress("UNCHECKED_CAST") + override val keys: Set + get() = prefs.dictionaryRepresentation().keys as Set + + override val size: Int + get() = prefs.dictionaryRepresentation().size + + override fun contains(key: String): Boolean { + return prefs.objectForKey(key) != null + } + + override fun removeAll() { + keys.forEach(prefs::removeObjectForKey) + } + + override fun remove(key: String) { + prefs.removeObjectForKey(key) + } + + override fun putInt(key: String, value: Int) { + prefs.setInteger(value.toLong(), key) + } + + override fun getInt(key: String, defaultValue: Int): Int { + return if (contains(key)) prefs.integerForKey(key).toInt() else defaultValue + } + + override fun getIntOrNull(key: String): Int? { + return if (contains(key)) prefs.integerForKey(key).toInt() else null + } + + override fun putLong(key: String, value: Long) { + prefs.setInteger(value, key) + } + + override fun getLong(key: String, defaultValue: Long): Long { + return if (contains(key)) prefs.integerForKey(key) else defaultValue + } + + override fun getLongOrNull(key: String): Long? { + return if (contains(key)) prefs.integerForKey(key) else null + } + + override fun putString(key: String, value: String) { + prefs.setObject(value, key) + } + + override fun getString(key: String, defaultValue: String): String { + return if (contains(key)) prefs.stringForKey(key) ?: defaultValue else defaultValue + } + + override fun getStringOrNull(key: String): String? { + return if (contains(key)) prefs.stringForKey(key) else null + } + + override fun putDouble(key: String, value: Double) { + prefs.setDouble(value, key) + } + + override fun getDouble(key: String, defaultValue: Double): Double { + return if (contains(key)) prefs.doubleForKey(key) else defaultValue + } + + override fun getDoubleOrNull(key: String): Double? { + return if (contains(key)) prefs.doubleForKey(key) else null + } + + override fun putBoolean(key: String, value: Boolean) { + prefs.setBool(value, key) + } + + override fun getBoolean(key: String, defaultValue: Boolean): Boolean { + return if (contains(key)) prefs.boolForKey(key) else defaultValue + } + + override fun getBooleanOrNull(key: String): Boolean? { + return if (contains(key)) prefs.boolForKey(key) else null + } + + override fun putFloat(key: String, value: Float) { + prefs.setFloat(value, key) + } + + override fun getFloat(key: String, defaultValue: Float): Float { + return if (contains(key)) prefs.floatForKey(key) else defaultValue + } + + override fun getFloatOrNull(key: String): Float? { + return if (contains(key)) prefs.floatForKey(key) else null + } +} \ No newline at end of file diff --git a/preferences/src/jsMain/kotlin/JsPreferences.kt b/preferences/src/jsMain/kotlin/JsPreferences.kt new file mode 100644 index 00000000..4a3c06aa --- /dev/null +++ b/preferences/src/jsMain/kotlin/JsPreferences.kt @@ -0,0 +1,115 @@ +/** + * AnyStream + * Copyright (C) 2021 Drew Carlson + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package anystream.prefs + +import org.w3c.dom.Storage + +class JsPreferences( + private val storage: Storage +) : Preferences { + + override val keys: Set + get() = List(storage.length, storage::key).filterNotNull().toSet() + + override val size: Int + get() = storage.length + + override fun contains(key: String): Boolean { + return storage.getItem(key) != null + } + + override fun removeAll() { + storage.clear() + } + + override fun remove(key: String) { + storage.removeItem(key) + } + + override fun putInt(key: String, value: Int) { + storage.setItem(key, value.toString()) + } + + override fun getInt(key: String, defaultValue: Int): Int { + return if (contains(key)) checkNotNull(storage.getItem(key)).toInt() else defaultValue + } + + override fun getIntOrNull(key: String): Int? { + return if (contains(key)) checkNotNull(storage.getItem(key)).toInt() else null + } + + override fun putLong(key: String, value: Long) { + storage.setItem(key, value.toString()) + } + + override fun getLong(key: String, defaultValue: Long): Long { + return if (contains(key)) checkNotNull(storage.getItem(key)).toLong() else defaultValue + } + + override fun getLongOrNull(key: String): Long? { + return if (contains(key)) checkNotNull(storage.getItem(key)).toLong() else null + } + + override fun putString(key: String, value: String) { + storage.setItem(key, value) + } + + override fun getString(key: String, defaultValue: String): String { + return if (contains(key)) checkNotNull(storage.getItem(key)) else defaultValue + } + + override fun getStringOrNull(key: String): String? { + return if (contains(key)) checkNotNull(storage.getItem(key)) else null + } + + override fun putDouble(key: String, value: Double) { + storage.setItem(key, value.toString()) + } + + override fun getDouble(key: String, defaultValue: Double): Double { + return if (contains(key)) checkNotNull(storage.getItem(key)).toDouble() else defaultValue + } + + override fun getDoubleOrNull(key: String): Double? { + return if (contains(key)) checkNotNull(storage.getItem(key)).toDouble() else null + } + + override fun putBoolean(key: String, value: Boolean) { + storage.setItem(key, value.toString()) + } + + override fun getBoolean(key: String, defaultValue: Boolean): Boolean { + return if (contains(key)) checkNotNull(storage.getItem(key)).toBoolean() else defaultValue + } + + override fun getBooleanOrNull(key: String): Boolean? { + return if (contains(key)) checkNotNull(storage.getItem(key)).toBoolean() else null + } + + override fun putFloat(key: String, value: Float) { + storage.setItem(key, value.toString()) + } + + override fun getFloat(key: String, defaultValue: Float): Float { + return if (contains(key)) checkNotNull(storage.getItem(key)).toFloat() else defaultValue + } + + override fun getFloatOrNull(key: String): Float? { + return if (contains(key)) checkNotNull(storage.getItem(key)).toFloat() else null + } +} \ No newline at end of file diff --git a/server/build.gradle.kts b/server/build.gradle.kts new file mode 100644 index 00000000..1682d424 --- /dev/null +++ b/server/build.gradle.kts @@ -0,0 +1,85 @@ +import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +plugins { + application + kotlin("jvm") + kotlin("plugin.serialization") + id("com.github.johnrengelman.shadow") version "7.0.0" +} + +application { + mainClass.set("io.ktor.server.netty.EngineMain") +} + +kotlin { + sourceSets { + main { + dependencies { + implementation(project(":data-models")) + + implementation(libs.serialization.json) + implementation(libs.coroutines.core) + implementation(libs.coroutines.jdk8) + + implementation(libs.ktor.server.core) + implementation(libs.ktor.server.netty) + implementation(libs.ktor.server.sessions) + implementation(libs.ktor.server.metrics) + implementation(libs.ktor.server.auth) + implementation(libs.ktor.server.authJwt) + implementation(libs.ktor.server.serialization) + implementation(libs.ktor.server.websockets) + + implementation(libs.ktor.client.core) + implementation(libs.ktor.client.okhttp) + implementation(libs.ktor.client.logging) + implementation(libs.ktor.client.json) + implementation(libs.ktor.client.serialization) + + implementation(libs.bouncyCastle) + + implementation(libs.logback) + + implementation(libs.kmongo.coroutine.serialization) + + implementation(libs.jaffree) + + implementation(libs.tmdbapi) + + implementation(libs.qbittorrent.client) + implementation(projects.torrentSearch) + implementation(projects.ktorPermissions) + } + } + + test { + dependencies { + implementation(libs.ktor.server.tests) + } + } + } +} + +tasks.withType { + kotlinOptions { + freeCompilerArgs += listOf( + "-XXLanguage:+InlineClasses", + "-Xopt-in=kotlinx.coroutines.ExperimentalCoroutinesApi", + "-Xopt-in=kotlinx.coroutines.FlowPreview", + "-Xopt-in=kotlin.time.ExperimentalTime", + "-Xopt-in=kotlin.RequiresOptIn" + ) + } +} + +tasks.withType { + manifest { + archiveFileName.set("server.jar") + attributes( + mapOf( + "Main-Class" to application.mainClass.get() + ) + ) + } +} diff --git a/server/src/main/kotlin/Application.kt b/server/src/main/kotlin/Application.kt new file mode 100644 index 00000000..9989d498 --- /dev/null +++ b/server/src/main/kotlin/Application.kt @@ -0,0 +1,129 @@ +/** + * AnyStream + * Copyright (C) 2021 Drew Carlson + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package anystream + +import anystream.data.UserSession +import anystream.routes.installRouting +import anystream.util.MongoSessionStorage +import anystream.util.PermissionAuthorization +import com.mongodb.ConnectionString +import io.ktor.application.* +import io.ktor.auth.* +import io.ktor.features.* +import io.ktor.http.* +import io.ktor.http.HttpStatusCode.Companion.Unauthorized +import io.ktor.http.cio.websocket.* +import io.ktor.http.content.CachingOptions +import io.ktor.response.* +import io.ktor.serialization.* +import io.ktor.sessions.* +import io.ktor.util.date.GMTDate +import io.ktor.websocket.* +import kotlinx.serialization.json.Json +import org.bouncycastle.util.encoders.Hex +import org.litote.kmongo.coroutine.coroutine +import org.litote.kmongo.reactivestreams.KMongo +import org.slf4j.event.Level +import java.time.Duration +import kotlin.random.Random + +fun main(args: Array): Unit = io.ktor.server.netty.EngineMain.main(args) + +val json = Json { + isLenient = true + ignoreUnknownKeys = true + encodeDefaults = true + classDiscriminator = "__type" + allowStructuredMapKeys = true +} + +@Suppress("unused") // Referenced in application.conf +@kotlin.jvm.JvmOverloads +fun Application.module(testing: Boolean = false) { + + val mongoUrl = environment.config.property("app.mongoUrl").getString() + val databaseName = environment.config.propertyOrNull("app.databaseName") + ?.getString() ?: "anystream" + + val kmongo = KMongo.createClient(ConnectionString(mongoUrl)) + val mongodb = kmongo.getDatabase(databaseName).coroutine + + install(DefaultHeaders) {} + install(ContentNegotiation) { json(json) } + install(AutoHeadResponse) + install(ConditionalHeaders) + install(PartialContent) + //install(ForwardedHeaderSupport) WARNING: for security, do not include this if not behind a reverse proxy + //install(XForwardedHeaderSupport) WARNING: for security, do not include this if not behind a reverse proxy + install(Compression) { + gzip { + priority = 1.0 + } + deflate { + priority = 10.0 + minimumSize(1024) // condition + } + excludeContentType(ContentType.Video.Any) + } + + install(CORS) { + methods.addAll(HttpMethod.DefaultMethods) + allowCredentials = true + allowNonSimpleContentTypes = true + allowHeaders { true } + exposeHeader(UserSession.KEY) + anyHost() + } + + install(CallLogging) { + level = Level.TRACE + } + + install(CachingHeaders) { + options { outgoingContent -> + when (outgoingContent.contentType?.withoutParameters()) { + ContentType.Text.CSS -> CachingOptions( + CacheControl.MaxAge(maxAgeSeconds = 24 * 60 * 60), + expires = null as? GMTDate? + ) + else -> null + } + } + } + + install(WebSockets) { + pingPeriod = Duration.ofSeconds(60) + timeout = Duration.ofSeconds(15) + } + + install(Authentication) { + session { + challenge { context.respond(Unauthorized) } + validate { it } + } + } + install(Sessions) { + header(UserSession.KEY, MongoSessionStorage(mongodb, log)) { + identity { Hex.toHexString(Random.nextBytes(48)) } + } + } + install(PermissionAuthorization) { + extract { (it as UserSession).permissions } + } + installRouting(mongodb) +} diff --git a/server/src/main/kotlin/data/ModelExtensions.kt b/server/src/main/kotlin/data/ModelExtensions.kt new file mode 100644 index 00000000..04adce53 --- /dev/null +++ b/server/src/main/kotlin/data/ModelExtensions.kt @@ -0,0 +1,115 @@ +/** + * AnyStream + * Copyright (C) 2021 Drew Carlson + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package anystream.data + +import anystream.models.Image +import anystream.models.Movie +import anystream.models.api.TmdbMoviesResponse +import anystream.models.api.TmdbTvShowResponse +import anystream.models.tmdb.CompleteTvSeries +import anystream.models.tmdb.PartialMovie +import anystream.models.tmdb.PartialTvSeries +import info.movito.themoviedbapi.TvResultsPage +import info.movito.themoviedbapi.model.ArtworkType +import info.movito.themoviedbapi.model.MovieDb +import info.movito.themoviedbapi.model.core.MovieResultsPage +import info.movito.themoviedbapi.model.keywords.Keyword +import info.movito.themoviedbapi.model.tv.TvSeries +import java.time.Instant + +private const val MAX_CACHED_POSTERS = 5 + +fun MovieResultsPage.asApiResponse(existingRecordIds: List): TmdbMoviesResponse { + val ids = existingRecordIds.toMutableList() + return when (results) { + null -> TmdbMoviesResponse() + else -> TmdbMoviesResponse( + items = results.map { it.asPartialMovie(ids) }, + itemTotal = totalResults, + page = page, + pageTotal = totalPages + ) + } +} + +fun MovieDb.asPartialMovie( + existingRecordIds: MutableList? = null +) = PartialMovie( + tmdbId = id, + title = title, + overview = overview, + posterPath = posterPath, + releaseDate = releaseDate, + backdropPath = backdropPath, + isAdded = existingRecordIds?.remove(id) ?: false +) + +fun MovieDb.asMovie( + id: String, + userId: String +) = Movie( + id = id, + tmdbId = this.id, + title = title, + overview = overview, + posterPath = posterPath, + releaseDate = releaseDate, + backdropPath = backdropPath, + imdbId = imdbID, + runtime = runtime, + posters = getImages(ArtworkType.POSTER) + .filter { "en".equals(it.language, true) } + .take(MAX_CACHED_POSTERS) + .map { img -> + Image( + filePath = img.filePath, + language = img.language ?: "" + ) + }, + added = Instant.now().toEpochMilli(), + addedByUserId = userId +) + +fun TvResultsPage.asApiResponse() = + when (results) { + null -> TmdbTvShowResponse() + else -> TmdbTvShowResponse( + items = results.map { it.asPartialTvSeries() }, + itemTotal = totalResults, + page = page, + pageTotal = totalPages + ) + } + +fun TvSeries.asPartialTvSeries() = PartialTvSeries( + tmdbId = id, + name = name, + overview = overview, + firstAirDate = firstAirDate, + lastAirDate = lastAirDate +) + + +fun TvSeries.asCompleteTvSeries() = CompleteTvSeries( + tmdbId = id, + name = name, + overview = overview, + firstAirDate = firstAirDate, + lastAirDate = lastAirDate, + keywords = keywords.map(Keyword::getName) +) diff --git a/server/src/main/kotlin/data/UserSession.kt b/server/src/main/kotlin/data/UserSession.kt new file mode 100644 index 00000000..525bbc9f --- /dev/null +++ b/server/src/main/kotlin/data/UserSession.kt @@ -0,0 +1,34 @@ +/** + * AnyStream + * Copyright (C) 2021 Drew Carlson + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package anystream.data + +import anystream.util.Permission +import io.ktor.auth.* +import kotlinx.serialization.Serializable +import java.time.Instant + +@Serializable +data class UserSession( + val userId: String, + val permissions: Set, + val sessionStarted: Long = Instant.now().toEpochMilli() +) : Principal { + companion object { + const val KEY = "as_user_session" + } +} diff --git a/server/src/main/kotlin/media/MediaImportProcessor.kt b/server/src/main/kotlin/media/MediaImportProcessor.kt new file mode 100644 index 00000000..aa20986d --- /dev/null +++ b/server/src/main/kotlin/media/MediaImportProcessor.kt @@ -0,0 +1,31 @@ +/** + * AnyStream + * Copyright (C) 2021 Drew Carlson + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package anystream.media + +import anystream.models.MediaKind +import anystream.models.api.ImportMediaResult +import org.slf4j.Marker +import java.io.File + + +interface MediaImportProcessor { + + val mediaKinds: List + + suspend fun process(contentFile: File, userId: String, marker: Marker): ImportMediaResult +} \ No newline at end of file diff --git a/server/src/main/kotlin/media/MediaImporter.kt b/server/src/main/kotlin/media/MediaImporter.kt new file mode 100644 index 00000000..ef7a43c6 --- /dev/null +++ b/server/src/main/kotlin/media/MediaImporter.kt @@ -0,0 +1,136 @@ +/** + * AnyStream + * Copyright (C) 2021 Drew Carlson + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package anystream.media + +import anystream.models.LocalMediaReference +import anystream.models.MediaReference +import anystream.models.api.ImportMedia +import anystream.models.api.ImportMediaResult +import anystream.routes.concurrentMap +import info.movito.themoviedbapi.TmdbApi +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.flow.* +import org.litote.kmongo.coroutine.CoroutineCollection +import org.litote.kmongo.exists +import org.slf4j.Logger +import org.slf4j.Marker +import org.slf4j.MarkerFactory +import java.io.File +import java.util.UUID + +class MediaImporter( + tmdb: TmdbApi, + private val processors: List, + private val mediaRefs: CoroutineCollection, + private val logger: Logger, +) { + private val classMarker = MarkerFactory.getMarker(this::class.simpleName) + + // Within a specified content directory, find all content unknown to anystream + suspend fun findUnmappedFiles(userId: String, request: ImportMedia): List { + val contentFile = File(request.contentPath) + if (!contentFile.exists()) { + return emptyList() + } + + val mediaRefPaths = mediaRefs + .withDocumentClass() + .find(LocalMediaReference::filePath.exists()) + .toList() + .map(LocalMediaReference::filePath) + + return contentFile.listFiles() + ?.toList() + .orEmpty() + .filter { file -> + mediaRefPaths.none { ref -> + ref.startsWith(file.absolutePath) + } + } + .map(File::getAbsolutePath) + } + + suspend fun importAll(userId: String, request: ImportMedia): Flow { + val marker = marker() + logger.debug(marker, "Recursive import requested by '$userId': $request") + val contentFile = File(request.contentPath) + if (!contentFile.exists()) { + logger.debug(marker, "Root content directory not found: ${contentFile.absolutePath}") + return flowOf(ImportMediaResult.ErrorFileNotFound) + } + + return contentFile.listFiles() + ?.toList() + .orEmpty() + .asFlow() + .concurrentMap(GlobalScope, 10) { file -> + internalImport( + userId, + request.copy(contentPath = file.absolutePath), + marker, + ) + } + .onCompletion { error -> + if (error == null) { + logger.debug(marker, "Recursive import completed") + } else { + logger.debug(marker, "Recursive import interrupted", error) + } + } + } + + suspend fun import(userId: String, request: ImportMedia): ImportMediaResult { + val marker = marker() + logger.debug(marker, "Import requested by '$userId': $request") + + val contentFile = File(request.contentPath) + if (!contentFile.exists()) { + logger.debug(marker, "Content file not found: ${contentFile.absolutePath}") + return ImportMediaResult.ErrorFileNotFound + } + + return internalImport(userId, request, marker) + } + + // Process a single media file and attempt to import missing data and references + private suspend fun internalImport( + userId: String, + request: ImportMedia, + marker: Marker, + ): ImportMediaResult { + val contentFile = File(request.contentPath) + logger.debug(marker, "Importing content file: ${contentFile.absolutePath}") + if (!contentFile.exists()) { + logger.debug(marker, "Content file not found") + return ImportMediaResult.ErrorFileNotFound + } + + return processors + .mapNotNull { processor -> + if (processor.mediaKinds.contains(request.mediaKind)) { + processor.process(contentFile, userId, marker) + } else null + } + .firstOrNull() + ?: ImportMediaResult.ErrorNothingToImport + } + + // Create a unique nested marker to identify import requests + private fun marker() = MarkerFactory.getMarker(UUID.randomUUID().toString()) + .apply { add(classMarker) } +} diff --git a/server/src/main/kotlin/media/processor/MovieImportProcessor.kt b/server/src/main/kotlin/media/processor/MovieImportProcessor.kt new file mode 100644 index 00000000..b633e261 --- /dev/null +++ b/server/src/main/kotlin/media/processor/MovieImportProcessor.kt @@ -0,0 +1,176 @@ +/** + * AnyStream + * Copyright (C) 2021 Drew Carlson + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package anystream.media.processor + +import anystream.data.asMovie +import anystream.media.MediaImportProcessor +import anystream.models.LocalMediaReference +import anystream.models.MediaReference +import anystream.models.MediaKind +import anystream.models.Movie +import anystream.models.api.ImportMediaResult +import com.mongodb.MongoException +import info.movito.themoviedbapi.TmdbApi +import info.movito.themoviedbapi.TmdbMovies +import org.bson.types.ObjectId +import org.litote.kmongo.coroutine.CoroutineDatabase +import org.litote.kmongo.eq +import org.slf4j.Logger +import org.slf4j.Marker +import java.io.File +import java.time.Instant + +class MovieImportProcessor( + private val tmdb: TmdbApi, + mongodb: CoroutineDatabase, + private val logger: Logger, +) : MediaImportProcessor { + + private val moviesDb = mongodb.getCollection() + private val mediaRefs = mongodb.getCollection() + private val yearRegex = "\\((\\d\\d\\d\\d)\\)".toRegex() + + override val mediaKinds: List = listOf(MediaKind.MOVIE) + + override suspend fun process(contentFile: File, userId: String, marker: Marker): ImportMediaResult { + val movieFile = if (contentFile.isFile) { + logger.debug(marker, "Detected single content file") + contentFile + } else { + logger.debug(marker, "Detected content directory") + contentFile.listFiles() + ?.toList() + .orEmpty() + .maxByOrNull(File::length) + ?.also { result -> + logger.debug(marker, "Largest content file found: ${result.absolutePath}") + } + } + + if (movieFile == null) { + logger.debug(marker, "Content file not found") + return ImportMediaResult.ErrorMediaRefNotFound + } + + val existingRef = try { + mediaRefs.findOne(LocalMediaReference::filePath eq movieFile.absolutePath) + } catch (e: MongoException) { + return ImportMediaResult.ErrorDatabaseException(e.stackTraceToString()) + } + if (existingRef != null) { + logger.debug(marker, "Content file reference already exists") + return ImportMediaResult.ErrorMediaRefAlreadyExists(existingRef.id) + } + + val match = yearRegex.find(movieFile.nameWithoutExtension) + val year = match?.value?.trim('(', ')')?.toInt() ?: 0 + + logger.debug(marker, "Found content year: $year") + + // TODO: Improve query capabilities + val query = movieFile.nameWithoutExtension + .replace(yearRegex, "") + .trim() + val response = try { + logger.debug(marker, "Querying provider for '$query'") + tmdb.search.searchMovie(query, year, null, false, 0) + } catch (e: Throwable) { + logger.debug(marker, "Provider lookup error", e) + return ImportMediaResult.ErrorDataProviderException(e.stackTraceToString()) + } + logger.debug(marker, "Provider returned ${response.totalResults} results") + if (response.results.isEmpty()) { + return ImportMediaResult.ErrorMediaMatchNotFound(contentFile.absolutePath, query) + } + + val tmdbMovie = response.results.first().apply { + logger.debug(marker, "Detected media as ${id}:'${title}' (${releaseDate})") + } + + val existingRecord = moviesDb.findOne(Movie::tmdbId eq tmdbMovie.id) + return if (existingRecord == null) { + logger.debug(marker, "Movie data import required") + movieFile.importMovie(userId, tmdbMovie.id, marker) + } else { + logger.debug(marker, "Movie data exists at ${existingRecord.id}, creating media ref") + val mediaRefId = ObjectId.get().toString() + try { + mediaRefs.insertOne( + LocalMediaReference( + id = mediaRefId, + contentId = existingRecord.id, + added = Instant.now().toEpochMilli(), + addedByUserId = userId, + filePath = movieFile.absolutePath, + mediaKind = MediaKind.MOVIE, + directory = false, + ) + ) + ImportMediaResult.Success(existingRecord.id, mediaRefId) + } catch (e: MongoException) { + logger.debug(marker, "Failed to create media reference", e) + ImportMediaResult.ErrorDatabaseException(e.stackTraceToString()) + } + } + } + + // Import movie data and create media ref + private suspend fun File.importMovie( + userId: String, + tmdbId: Int, + marker: Marker + ): ImportMediaResult { + val movie = try { + tmdb.movies.getMovie( + tmdbId, + null, + TmdbMovies.MovieMethod.images, + TmdbMovies.MovieMethod.release_dates, + TmdbMovies.MovieMethod.alternative_titles, + TmdbMovies.MovieMethod.keywords + ) + } catch (e: Throwable) { + logger.debug(marker, "Extended provider data query failed", e) + return ImportMediaResult.ErrorDataProviderException(e.stackTraceToString()) + } + val movieId = ObjectId.get().toString() + val mediaRefId = ObjectId.get().toString() + try { + moviesDb.insertOne(movie.asMovie(movieId, userId)) + mediaRefs.insertOne( + LocalMediaReference( + id = mediaRefId, + contentId = movieId, + added = Instant.now().toEpochMilli(), + addedByUserId = userId, + filePath = absolutePath, + mediaKind = MediaKind.MOVIE, + directory = false, + ) + ) + logger.debug( + marker, + "Movie and media ref created movieId=$movieId, mediaRefId=$mediaRefId" + ) + return ImportMediaResult.Success(movieId, mediaRefId) + } catch (e: MongoException) { + logger.debug(marker, "Movie or media ref creation failed", e) + return ImportMediaResult.ErrorDatabaseException(e.stackTraceToString()) + } + } +} \ No newline at end of file diff --git a/server/src/main/kotlin/media/processor/TvImportProcessor.kt b/server/src/main/kotlin/media/processor/TvImportProcessor.kt new file mode 100644 index 00000000..e083cceb --- /dev/null +++ b/server/src/main/kotlin/media/processor/TvImportProcessor.kt @@ -0,0 +1,291 @@ +/** + * AnyStream + * Copyright (C) 2021 Drew Carlson + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package anystream.media.processor + +import anystream.media.MediaImportProcessor +import anystream.models.* +import anystream.models.api.ImportMediaResult +import anystream.routes.concurrentMap +import com.mongodb.MongoException +import com.mongodb.MongoQueryException +import info.movito.themoviedbapi.TmdbApi +import info.movito.themoviedbapi.TmdbTV +import info.movito.themoviedbapi.TmdbTvSeasons +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.flow.asFlow +import kotlinx.coroutines.flow.toList +import org.bson.types.ObjectId +import org.litote.kmongo.coroutine.CoroutineDatabase +import org.litote.kmongo.eq +import org.slf4j.Logger +import org.slf4j.Marker +import java.io.File +import java.time.Instant + +class TvImportProcessor( + private val tmdb: TmdbApi, + mongodb: CoroutineDatabase, + private val logger: Logger, +) : MediaImportProcessor { + + private val tvShowDb = mongodb.getCollection() + private val episodeDb = mongodb.getCollection() + private val mediaRefDb = mongodb.getCollection() + + override val mediaKinds: List = listOf(MediaKind.TV) + + private val episodeRegex = "(.*) - S([0-9]{1,2})E([0-9]{1,2}) - (.*)".toRegex() + + override suspend fun process( + contentFile: File, + userId: String, + marker: Marker, + ): ImportMediaResult { + if (contentFile.isFile) { + logger.debug(marker, "Detected single content file, nothing to import") + // TODO: Identify single files as episodes or supplemental content + return ImportMediaResult.ErrorNothingToImport + } else if (contentFile.listFiles().isNullOrEmpty()) { + logger.debug(marker, "Content folder is empty.") + return ImportMediaResult.ErrorNothingToImport + } + + val existingRef = try { + mediaRefDb.findOne(LocalMediaReference::filePath eq contentFile.absolutePath) + } catch (e: MongoQueryException) { + return ImportMediaResult.ErrorDatabaseException(e.stackTraceToString()) + } + if (existingRef != null) { + logger.debug(marker, "Content file reference already exists") + return ImportMediaResult.ErrorMediaRefAlreadyExists(existingRef.id) + } + + // TODO: Improve query capabilities + val query = contentFile.name + val response = try { + logger.debug(marker, "Querying provider for '$query'") + tmdb.search.searchTv(query, "en", 1) + } catch (e: Throwable) { + logger.debug(marker, "Provider lookup error", e) + return ImportMediaResult.ErrorDataProviderException(e.stackTraceToString()) + } + logger.debug(marker, "Provider returned ${response.totalResults} results") + if (response.results.isEmpty()) { + return ImportMediaResult.ErrorMediaMatchNotFound(contentFile.path, query) + } + + val tmdbShow = response.results.first().apply { + logger.debug(marker, "Detected media as ${id}:'${name}' (${firstAirDate})") + } + + val existingRecord = tvShowDb.findOne(TvShow::tmdbId eq tmdbShow.id) + val (show, episodes) = existingRecord?.let { show -> + show to episodeDb.find(Episode::showId eq show.id).toList() + } ?: try { + logger.debug(marker, "Show data import required") + importShow(tmdbShow.id) + } catch (e: MongoException) { + logger.debug(marker, "Failed to insert new show", e) + return ImportMediaResult.ErrorDatabaseException(e.stackTraceToString()) + } catch (e: Throwable) { + logger.debug(marker, "Data provider query failed", e) + return ImportMediaResult.ErrorDataProviderException(e.stackTraceToString()) + } + + val mediaRefId = ObjectId.get().toString() + try { + mediaRefDb.insertOne( + LocalMediaReference( + id = mediaRefId, + contentId = show.id, + added = Instant.now().toEpochMilli(), + addedByUserId = userId, + filePath = contentFile.absolutePath, + mediaKind = MediaKind.TV, + directory = true, + ) + ) + } catch (e: MongoException) { + logger.debug(marker, "Failed to create media reference", e) + return ImportMediaResult.ErrorDatabaseException(e.stackTraceToString()) + } + + val subFolders = contentFile.listFiles()?.toList().orEmpty() + val seasonDirectories = subFolders + .filter { it.isDirectory && it.name.startsWith("season", true) } + .mapNotNull { file -> + file.name + .split(" ") + .lastOrNull() + ?.toIntOrNull() + ?.let { num -> show.seasons.firstOrNull { it.seasonNumber == num } } + ?.let { it to file } + } + + val seasonResults = seasonDirectories.asFlow() + .concurrentMap(GlobalScope, 5) { (season, folder) -> + folder.importSeason(userId, season, episodes, marker) + } + .toList() + + return ImportMediaResult.Success( + mediaId = show.id, + mediaRefId = mediaRefId, + subresults = seasonResults, + ) + } + + // Import show data + private suspend fun importShow(tmdbId: Int): Pair> { + val showId = ObjectId.get().toString() + val tmdbShow = tmdb.tvSeries.getSeries( + tmdbId, + "en", + TmdbTV.TvMethod.keywords, + TmdbTV.TvMethod.external_ids, + TmdbTV.TvMethod.images, + TmdbTV.TvMethod.content_ratings, + TmdbTV.TvMethod.credits, + ) + val tmdbSeasons = tmdbShow.seasons + .map { season -> + tmdb.tvSeasons.getSeason( + tmdbId, + season.seasonNumber, + "en", + TmdbTvSeasons.SeasonMethod.images, + ) + } + val episodes = tmdbSeasons.flatMap { season -> + season.episodes.map { episode -> + Episode( + id = ObjectId.get().toString(), + tmdbId = episode.id, + name = episode.name, + overview = episode.overview, + airDate = episode.airDate ?: "", + number = episode.episodeNumber, + seasonNumber = episode.seasonNumber, + showId = showId, + stillPath = episode.stillPath ?: "" + ) + } + } + val show = TvShow( + id = showId, + name = tmdbShow.name, + tmdbId = tmdbShow.id, + overview = tmdbShow.overview, + firstAirDate = tmdbShow.firstAirDate ?: "", + numberOfSeasons = tmdbShow.numberOfSeasons, + numberOfEpisodes = tmdbShow.numberOfEpisodes, + posterPath = tmdbShow.posterPath ?: "", + added = Instant.now().toEpochMilli(), + seasons = tmdbSeasons.map { season -> + TvSeason( + id = ObjectId.get().toString(), + tmdbId = season.id, + name = season.name, + overview = season.overview, + seasonNumber = season.seasonNumber, + airDate = season.airDate ?: "", + posterPath = season.posterPath ?: "", + ) + } + ) + + tvShowDb.insertOne(show) + episodeDb.insertMany(episodes) + return show to episodes + } + + private suspend fun File.importSeason( + userId: String, + season: TvSeason, + episodes: List, + marker: Marker, + ): ImportMediaResult { + val seasonRefId = ObjectId.get().toString() + try { + mediaRefDb.insertOne( + LocalMediaReference( + id = seasonRefId, + contentId = season.id, + added = Instant.now().toEpochMilli(), + addedByUserId = userId, + filePath = absolutePath, + mediaKind = MediaKind.TV, + directory = true, + ) + ) + } catch (e: MongoException) { + logger.debug(marker, "Failed to create season media ref", e) + return ImportMediaResult.ErrorDatabaseException(e.stackTraceToString()) + } + + val episodeFiles = listFiles()?.toList().orEmpty() + .sortedByDescending(File::length) + .filter { it.isFile && it.nameWithoutExtension.matches(episodeRegex) } + + val episodeFileMatches = episodeFiles.map { episodeFile -> + val nameParts = episodeRegex.find(episodeFile.nameWithoutExtension)!! + val (_, seasonNumber, episodeNumber, _) = nameParts.destructured + + episodeFile to episodes.find { episode -> + episode.seasonNumber == seasonNumber.toIntOrNull() && + episode.number == episodeNumber.toIntOrNull() + } + } + + val episodeRefs = episodeFileMatches + .mapNotNull { (file, episode) -> + episode?.let { + LocalMediaReference( + id = ObjectId.get().toString(), + contentId = episode.id, + added = Instant.now().toEpochMilli(), + addedByUserId = userId, + filePath = file.absolutePath, + mediaKind = MediaKind.TV, + directory = false, + rootContentId = episode.showId, + ) + } + } + val results = try { + if (episodeRefs.isNotEmpty()) { + mediaRefDb.insertMany(episodeRefs) + episodeRefs.map { ref -> + ImportMediaResult.Success( + mediaId = ref.contentId, + mediaRefId = ref.id, + ) + } + } else emptyList() + } catch (e: MongoException) { + logger.debug(marker, "Error creating episode references", e) + listOf(ImportMediaResult.ErrorDatabaseException(e.stackTraceToString())) + } + + return ImportMediaResult.Success( + mediaId = season.id, + mediaRefId = seasonRefId, + subresults = results, + ) + } +} \ No newline at end of file diff --git a/server/src/main/kotlin/modules/StatusPageModule.kt b/server/src/main/kotlin/modules/StatusPageModule.kt new file mode 100644 index 00000000..347fc789 --- /dev/null +++ b/server/src/main/kotlin/modules/StatusPageModule.kt @@ -0,0 +1,41 @@ +/** + * AnyStream + * Copyright (C) 2021 Drew Carlson + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package anystream.modules + +import io.ktor.application.Application +import io.ktor.application.call +import io.ktor.application.install +import io.ktor.features.StatusPages +import io.ktor.http.ContentType +import io.ktor.http.HttpStatusCode +import io.ktor.response.respondText + + +@Suppress("unused") // Referenced in application.conf +fun Application.module() { + install(StatusPages) { + // TODO: Enable only in development mode + exception { error -> + call.respondText( + status = HttpStatusCode.InternalServerError, + contentType = ContentType.Text.Plain, + text = error.stackTraceToString() + ) + } + } +} diff --git a/server/src/main/kotlin/routes/Home.kt b/server/src/main/kotlin/routes/Home.kt new file mode 100644 index 00000000..f7b6b9e4 --- /dev/null +++ b/server/src/main/kotlin/routes/Home.kt @@ -0,0 +1,111 @@ +/** + * AnyStream + * Copyright (C) 2021 Drew Carlson + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package anystream.routes + +import anystream.data.UserSession +import anystream.data.asApiResponse +import anystream.models.* +import anystream.models.api.HomeResponse +import info.movito.themoviedbapi.TmdbApi +import info.movito.themoviedbapi.model.MovieDb +import io.ktor.application.* +import io.ktor.auth.* +import io.ktor.response.* +import io.ktor.routing.* +import org.litote.kmongo.* +import org.litote.kmongo.coroutine.CoroutineDatabase + + +fun Route.addHomeRoutes(tmdb: TmdbApi, mongodb: CoroutineDatabase) { + val playbackStatesDb = mongodb.getCollection() + val moviesDb = mongodb.getCollection() + val tvShowDb = mongodb.getCollection() + val episodeDb = mongodb.getCollection() + val mediaRefsDb = mongodb.getCollection() + route("/home") { + get { + val session = call.principal()!! + + // Currently watching + val playbackStates = playbackStatesDb + .find(PlaybackState::userId eq session.userId) + .sort(descending(PlaybackState::updatedAt)) + .limit(10) + .toList() + + val playbackStateMovies = moviesDb + .find(Movie::id `in` playbackStates.map(PlaybackState::mediaId)) + .toList() + + val playbackStateItems = playbackStates.associateBy { state -> + playbackStateMovies.first { it.id == state.mediaId } + } + + // Recently Added Movies + val recentlyAddedMovies = moviesDb + .find() + .sort(descending(Movie::added)) + .limit(20) + .toList() + val recentlyAddedRefs = mediaRefsDb + .find(MediaReference::contentId `in` recentlyAddedMovies.map(Movie::id)) + .toList() + val recentlyAdded = recentlyAddedMovies.associateWith { movie -> + recentlyAddedRefs.find { it.contentId == movie.id } + } + + val tvShows = tvShowDb + .find() + .sort(descending(TvShow::added)) + .limit(20) + .toList() + + // Popular movies + val tmdbPopular = tmdb.movies.getPopularMovies("en", 1) + val ids = tmdbPopular.map(MovieDb::getId) + val existingIds = moviesDb + .find(Movie::tmdbId `in` ids) + .toList() + .map(Movie::tmdbId) + val popularMovies = tmdbPopular.asApiResponse(existingIds).items + val localPopularMovies = moviesDb + .find(Movie::tmdbId `in` existingIds) + .toList() + val popularMediaRefs = mediaRefsDb + .find(MediaReference::contentId `in` localPopularMovies.map(Movie::id)) + .toList() + val popularMoviesMap = popularMovies.associateWith { m -> + val contentId = localPopularMovies.find { it.tmdbId == m.tmdbId }?.id + if (contentId == null) { + null + } else { + popularMediaRefs.find { it.contentId == contentId } + } + } + + call.respond( + HomeResponse( + currentlyWatching = playbackStateItems, + recentlyAdded = recentlyAdded, + popularMovies = popularMoviesMap, + recentlyAddedTv = tvShows + ) + ) + } + } +} diff --git a/server/src/main/kotlin/routes/Media.kt b/server/src/main/kotlin/routes/Media.kt new file mode 100644 index 00000000..6bb7b1df --- /dev/null +++ b/server/src/main/kotlin/routes/Media.kt @@ -0,0 +1,132 @@ +/** + * AnyStream + * Copyright (C) 2021 Drew Carlson + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package anystream.routes + +import anystream.data.UserSession +import anystream.models.MediaReference +import anystream.models.Movie +import anystream.models.Permissions.GLOBAL +import anystream.models.Permissions.MANAGE_COLLECTION +import anystream.models.api.ImportMedia +import anystream.media.MediaImporter +import anystream.util.logger +import anystream.util.withAnyPermission +import drewcarlson.torrentsearch.Category +import drewcarlson.torrentsearch.TorrentSearch +import info.movito.themoviedbapi.TmdbApi +import io.ktor.application.call +import io.ktor.auth.* +import io.ktor.http.* +import io.ktor.http.HttpStatusCode.Companion.InternalServerError +import io.ktor.http.HttpStatusCode.Companion.NotFound +import io.ktor.http.HttpStatusCode.Companion.OK +import io.ktor.http.HttpStatusCode.Companion.UnprocessableEntity +import io.ktor.http.cio.websocket.* +import io.ktor.request.* +import io.ktor.response.respond +import io.ktor.routing.* +import io.ktor.websocket.* +import kotlinx.coroutines.flow.* +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.encodeToJsonElement +import org.litote.kmongo.* +import org.litote.kmongo.coroutine.CoroutineDatabase + +fun Route.addMediaRoutes( + tmdb: TmdbApi, + mongodb: CoroutineDatabase, + torrentSearch: TorrentSearch, + importer: MediaImporter, +) { + val moviesDb = mongodb.getCollection() + val mediaRefs = mongodb.getCollection() + withAnyPermission(GLOBAL, MANAGE_COLLECTION) { + route("/media") { + post("/import") { + val session = call.principal()!! + val import = call.receiveOrNull() + ?: return@post call.respond(UnprocessableEntity) + val importAll = call.parameters["importAll"]?.toBoolean() ?: false + + call.respond( + if (importAll) { + val list = importer.importAll(session.userId, import).toList() + // TODO: List with different types cannot be serialized yet. + buildJsonArray { + list.forEach { + add(anystream.json.encodeToJsonElement(it)) + } + } + } else { + importer.import(session.userId, import) + } + ) + } + + post("/unmapped") { + val session = call.principal()!! + val import = call.receiveOrNull() + ?: return@post call.respond(UnprocessableEntity) + + call.respond(importer.findUnmappedFiles(session.userId, import)) + } + + route("/tmdb") { + route("/{tmdb_id}") { + get("/sources") { + val tmdbId = call.parameters["tmdb_id"]?.toIntOrNull() + + if (tmdbId == null) { + call.respond(NotFound) + } else { + runCatching { + tmdb.movies.getMovie(tmdbId, null) + }.onSuccess { tmdbMovie -> + call.respond( + torrentSearch.search(tmdbMovie.title, Category.MOVIES, 100) + // TODO: API or client sort+filter + .sortedByDescending { it.seeds } + ) + }.onFailure { e -> + logger.error("Error fetching movie from TMDB - tmdbId=$tmdbId", e) + call.respond(InternalServerError) + } + } + } + } + } + + route("/movie/{movie_id}") { + get("/sources") { + val movieId = call.parameters["movie_id"] ?: "" + + val movie = moviesDb.findOneById(movieId) + if (movie == null) { + call.respond(NotFound) + } else { + call.respond( + torrentSearch.search(movie.title, Category.MOVIES, 100) + // TODO: API or client sort+filter + .sortedByDescending { it.seeds } + ) + } + } + } + } + } +} diff --git a/server/src/main/kotlin/routes/Movies.kt b/server/src/main/kotlin/routes/Movies.kt new file mode 100644 index 00000000..4afbbf5b --- /dev/null +++ b/server/src/main/kotlin/routes/Movies.kt @@ -0,0 +1,225 @@ +/** + * AnyStream + * Copyright (C) 2021 Drew Carlson + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package anystream.routes + +import anystream.data.UserSession +import anystream.data.asApiResponse +import anystream.data.asMovie +import anystream.data.asPartialMovie +import anystream.models.MediaReference +import anystream.models.Movie +import anystream.models.Permissions.GLOBAL +import anystream.models.Permissions.MANAGE_COLLECTION +import anystream.models.api.ImportMedia +import anystream.models.api.MoviesResponse +import anystream.models.api.TmdbMoviesResponse +import anystream.media.MediaImporter +import anystream.util.logger +import anystream.util.withAnyPermission +import drewcarlson.torrentsearch.Category +import drewcarlson.torrentsearch.TorrentSearch +import info.movito.themoviedbapi.TmdbApi +import info.movito.themoviedbapi.TmdbMovies.MovieMethod +import info.movito.themoviedbapi.model.MovieDb +import io.ktor.application.call +import io.ktor.application.log +import io.ktor.auth.* +import io.ktor.http.* +import io.ktor.http.HttpStatusCode.Companion.InternalServerError +import io.ktor.http.HttpStatusCode.Companion.NotFound +import io.ktor.http.HttpStatusCode.Companion.OK +import io.ktor.http.HttpStatusCode.Companion.UnprocessableEntity +import io.ktor.http.cio.websocket.* +import io.ktor.request.* +import io.ktor.response.respond +import io.ktor.routing.* +import io.ktor.websocket.* +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.* +import org.bson.types.ObjectId +import org.litote.kmongo.* +import org.litote.kmongo.coroutine.CoroutineDatabase + +fun Route.addMovieRoutes( + tmdb: TmdbApi, + mongodb: CoroutineDatabase, +) { + val moviesDb = mongodb.getCollection() + val mediaRefs = mongodb.getCollection() + route("/movies") { + get { + call.respond( + MoviesResponse( + movies = moviesDb.find().toList(), + mediaReferences = mediaRefs.find().toList() + ) + ) + } + + route("/tmdb") { + get("/popular") { + val page = call.request.queryParameters["page"]?.toIntOrNull() ?: 1 + runCatching { + tmdb.movies.getPopularMovies("en", page) + }.onSuccess { tmdbMovies -> + val ids = tmdbMovies.map(MovieDb::getId) + val existingIds = moviesDb + .find(Movie::tmdbId `in` ids) + .toList() + .map(Movie::tmdbId) + + call.respond(tmdbMovies.asApiResponse(existingIds)) + }.onFailure { e -> + // TODO: Decompose this exception and retry where possible + logger.error("Error fetching popular movies from TMDB - page=$page", e) + call.respond(InternalServerError) + } + } + + get("/search") { + val query = call.request.queryParameters["query"] + val page = call.request.queryParameters["page"]?.toIntOrNull() ?: 1 + + if (query.isNullOrBlank()) { + call.respond(TmdbMoviesResponse()) + } else { + runCatching { + tmdb.search.searchMovie( + query.encodeURLQueryComponent(), + 0, + null, + false, + page + ) + }.onSuccess { tmdbMovies -> + val ids = tmdbMovies.map(MovieDb::getId) + val existingIds = moviesDb + .find(Movie::tmdbId `in` ids) + .toList() + .map(Movie::tmdbId) + call.respond(tmdbMovies.asApiResponse(existingIds)) + }.onFailure { e -> + // TODO: Decompose this exception and retry where possible + logger.error("Error searching TMDB - page=$page, query='$query'", e) + call.respond(InternalServerError) + } + } + } + + route("/{tmdb_id}") { + get { + val tmdbId = call.parameters["tmdb_id"]?.toIntOrNull() + + if (tmdbId == null) { + call.respond(NotFound) + } else { + runCatching { + tmdb.movies.getMovie( + tmdbId, + null, + MovieMethod.keywords, + MovieMethod.images, + MovieMethod.alternative_titles + ) + }.onSuccess { tmdbMovie -> + call.respond(tmdbMovie.asPartialMovie()) + }.onFailure { e -> + // TODO: Decompose this exception and retry where possible + logger.error("Error fetching movie from TMDB - tmdb=$tmdbId", e) + call.respond(InternalServerError) + } + } + } + + withAnyPermission(GLOBAL, MANAGE_COLLECTION) { + get("/add") { + val session = call.principal()!! + val tmdbId = call.parameters["tmdb_id"]?.toIntOrNull() + + when { + tmdbId == null -> { + call.respond(NotFound) + } + moviesDb.findOne(Movie::tmdbId eq tmdbId) != null -> { + call.respond(HttpStatusCode.Conflict) + } + else -> { + runCatching { + tmdb.movies.getMovie( + tmdbId, + null, + MovieMethod.images, + MovieMethod.release_dates, + MovieMethod.alternative_titles, + MovieMethod.keywords + ) + }.onSuccess { tmdbMovie -> + val id = ObjectId.get().toString() + moviesDb.insertOne(tmdbMovie.asMovie(id, session.userId)) + call.respond(OK) + }.onFailure { e -> + logger.error( + "Error fetching movie from TMDB - tmdbId=$tmdbId", + e + ) + call.respond(InternalServerError) + } + } + } + } + } + } + } + + route("/{movie_id}") { + get { + val movieId = call.parameters["movie_id"] ?: "" + + val movie = moviesDb.findOneById(movieId) + if (movie == null) { + call.respond(NotFound) + } else { + call.respond(movie) + } + } + + withAnyPermission(GLOBAL, MANAGE_COLLECTION) { + delete { + val movieId = call.parameters["movie_id"] ?: "" + val result = moviesDb.deleteOneById(movieId) + if (result.deletedCount == 0L) { + call.respond(NotFound) + } else { + mediaRefs.deleteMany(MediaReference::contentId eq movieId) + call.respond(OK) + } + } + } + } + } +} + +fun Flow.concurrentMap( + scope: CoroutineScope, + concurrencyLevel: Int, + transform: suspend (T) -> R +): Flow = this + .map { scope.async { transform(it) } } + .buffer(concurrencyLevel) + .map { it.await() } diff --git a/server/src/main/kotlin/routes/Routing.kt b/server/src/main/kotlin/routes/Routing.kt new file mode 100644 index 00000000..1803f1b5 --- /dev/null +++ b/server/src/main/kotlin/routes/Routing.kt @@ -0,0 +1,93 @@ +/** + * AnyStream + * Copyright (C) 2021 Drew Carlson + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package anystream.routes + +import anystream.media.MediaImporter +import anystream.media.processor.MovieImportProcessor +import anystream.media.processor.TvImportProcessor +import anystream.models.* +import anystream.torrent.search.KMongoTorrentProviderCache +import anystream.util.SinglePageApp +import anystream.util.withAnyPermission +import com.github.kokorin.jaffree.ffmpeg.FFmpeg +import drewcarlson.qbittorrent.QBittorrentClient +import drewcarlson.torrentsearch.TorrentSearch +import info.movito.themoviedbapi.TmdbApi +import io.ktor.application.* +import io.ktor.auth.* +import io.ktor.routing.* +import org.litote.kmongo.coroutine.CoroutineDatabase +import java.nio.file.Path + +fun Application.installRouting(mongodb: CoroutineDatabase) { + val frontEndPath = environment.config.property("app.frontEndPath").getString() + val ffmpegPath = environment.config.property("app.ffmpegPath").getString() + val tmdbApiKey = environment.config.property("app.tmdbApiKey").getString() + val qbittorrentUrl = environment.config.property("app.qbittorrentUrl").getString() + val qbittorrentUser = environment.config.property("app.qbittorrentUser").getString() + val qbittorrentPass = environment.config.property("app.qbittorrentPassword").getString() + + val tmdb by lazy { TmdbApi(tmdbApiKey) } + + val torrentSearch = TorrentSearch(KMongoTorrentProviderCache(mongodb)) + + val qbClient = QBittorrentClient( + baseUrl = qbittorrentUrl, + username = qbittorrentUser, + password = qbittorrentPass, + ) + val ffmpeg = FFmpeg.atPath(Path.of(ffmpegPath)) + + val mediaRefs = mongodb.getCollection() + + val processors = listOf( + MovieImportProcessor(tmdb, mongodb, log), + TvImportProcessor(tmdb, mongodb, log), + ) + val importer = MediaImporter(tmdb, processors, mediaRefs, log) + + routing { + route("/api") { + addUserRoutes(mongodb) + authenticate { + addHomeRoutes(tmdb, mongodb) + withAnyPermission(Permissions.GLOBAL, Permissions.VIEW_COLLECTION) { + addTvShowRoutes(tmdb, mongodb) + addMovieRoutes(tmdb, mongodb) + } + withAnyPermission(Permissions.GLOBAL, Permissions.TORRENT_MANAGEMENT) { + addTorrentRoutes(qbClient, mongodb) + } + withAnyPermission(Permissions.GLOBAL, Permissions.MANAGE_COLLECTION) { + addMediaRoutes(tmdb, mongodb, torrentSearch, importer) + } + } + + // TODO: WS endpoint Authentication and permissions + addStreamRoutes(qbClient, mongodb, ffmpeg) + addStreamWsRoutes(qbClient, mongodb) + addTorrentWsRoutes(qbClient) + addUserWsRoutes(mongodb) + } + } + + install(SinglePageApp) { + ignoreBasePath = "/api" + staticFilePath = frontEndPath + } +} diff --git a/server/src/main/kotlin/routes/Stream.kt b/server/src/main/kotlin/routes/Stream.kt new file mode 100644 index 00000000..37590ab4 --- /dev/null +++ b/server/src/main/kotlin/routes/Stream.kt @@ -0,0 +1,160 @@ +/** + * AnyStream + * Copyright (C) 2021 Drew Carlson + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package anystream.routes + +import anystream.data.UserSession +import anystream.json +import anystream.models.LocalMediaReference +import anystream.models.DownloadMediaReference +import anystream.models.MediaReference +import anystream.models.PlaybackState +import com.github.kokorin.jaffree.ffmpeg.* +import drewcarlson.qbittorrent.QBittorrentClient +import io.ktor.application.* +import io.ktor.auth.* +import io.ktor.http.HttpStatusCode.Companion.NotFound +import io.ktor.http.HttpStatusCode.Companion.OK +import io.ktor.http.HttpStatusCode.Companion.UnprocessableEntity +import io.ktor.http.cio.websocket.* +import io.ktor.http.cio.websocket.Frame +import io.ktor.http.content.* +import io.ktor.request.* +import io.ktor.response.* +import io.ktor.routing.* +import io.ktor.sessions.* +import io.ktor.websocket.* +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.* +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import org.bson.types.ObjectId +import org.litote.kmongo.coroutine.CoroutineDatabase +import org.litote.kmongo.coroutine.replaceOne +import org.litote.kmongo.eq +import java.io.File +import java.time.Instant +import java.util.concurrent.ConcurrentHashMap +import java.util.* + + +fun Route.addStreamRoutes( + qbClient: QBittorrentClient, + mongodb: CoroutineDatabase, + ffmpeg: FFmpeg, +) { + val transcodePath = application.environment.config.property("app.transcodePath").getString() + val playbackStateDb = mongodb.getCollection() + val mediaRefs = mongodb.getCollection() + route("/stream") { + route("/{media_ref_id}") { + route("/state") { + get { + val session = call.principal()!! + val mediaRefId = call.parameters["media_ref_id"]!! + val state = playbackStateDb.findOne( + PlaybackState::userId eq session.userId, + PlaybackState::mediaReferenceId eq mediaRefId + ) + call.respond(state ?: NotFound) + } + put { + val session = call.principal()!! + val mediaRefId = call.parameters["media_ref_id"]!! + val state = call.receiveOrNull() + ?: return@put call.respond(UnprocessableEntity) + + playbackStateDb.deleteOne( + PlaybackState::userId eq session.userId, + PlaybackState::mediaReferenceId eq mediaRefId + ) + playbackStateDb.insertOne(state) + call.respond(OK) + } + } + + val videoFileCache = ConcurrentHashMap() + @Suppress("BlockingMethodInNonBlockingContext") + get("/direct") { + val mediaRefId = call.parameters["media_ref_id"]!! + val file = if (videoFileCache.containsKey(mediaRefId)) { + videoFileCache[mediaRefId]!! + } else { + val mediaRef = mediaRefs.find(MediaReference::id eq mediaRefId).first() + ?: return@get call.respond(NotFound) + + when (mediaRef) { + is LocalMediaReference -> mediaRef.filePath + is DownloadMediaReference -> mediaRef.filePath + }?.run(::File) + } ?: return@get call.respond(NotFound) + + if (file.name.endsWith(".mp4")) { + videoFileCache.putIfAbsent(mediaRefId, file) + call.respond(LocalFileContent(file)) + } else { + val name = UUID.randomUUID().toString() + val outfile = File(transcodePath, "$name.mp4") + + ffmpeg.addInput(UrlInput.fromPath(file.toPath())) + .addOutput(UrlOutput.toPath(outfile.toPath()).copyAllCodecs()) + .setOverwriteOutput(true) + .execute() + videoFileCache.putIfAbsent(mediaRefId, outfile) + call.respond(LocalFileContent(outfile)) + } + } + } + } +} + +fun Route.addStreamWsRoutes( + qbClient: QBittorrentClient, + mongodb: CoroutineDatabase +) { + val playbackStateDb = mongodb.getCollection() + val mediaRefs = mongodb.getCollection() + + webSocket("/ws/stream/{media_ref_id}/state") { + val userId = (incoming.receive() as Frame.Text).readText() + val mediaRefId = call.parameters["media_ref_id"]!! + val mediaRef = mediaRefs.findOneById(mediaRefId)!! + val state = playbackStateDb.findOne( + PlaybackState::userId eq userId, + PlaybackState::mediaReferenceId eq mediaRefId + ) ?: PlaybackState( + id = ObjectId.get().toString(), + mediaReferenceId = mediaRefId, + position = 0, + userId = userId, + mediaId = mediaRef.contentId, + updatedAt = Instant.now().toEpochMilli() + ).also { + playbackStateDb.insertOne(it) + } + + send(Frame.Text(json.encodeToString(state))) + + incoming.receiveAsFlow() + .takeWhile { !outgoing.isClosedForSend } + .filterIsInstance() + .collect { frame -> + val newState = json.decodeFromString(frame.readText()) + playbackStateDb.replaceOne(newState.copy(updatedAt = Instant.now().toEpochMilli())) + } + } +} diff --git a/server/src/main/kotlin/routes/Torrents.kt b/server/src/main/kotlin/routes/Torrents.kt new file mode 100644 index 00000000..c46e7561 --- /dev/null +++ b/server/src/main/kotlin/routes/Torrents.kt @@ -0,0 +1,193 @@ +/** + * AnyStream + * Copyright (C) 2021 Drew Carlson + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package anystream.routes + +import anystream.data.UserSession +import anystream.json +import anystream.models.DownloadMediaReference +import anystream.models.MediaReference +import anystream.models.MediaKind +import anystream.torrent.search.TorrentDescription2 +import drewcarlson.qbittorrent.QBittorrentClient +import drewcarlson.qbittorrent.models.Torrent +import drewcarlson.qbittorrent.models.TorrentFile +import io.ktor.application.* +import io.ktor.auth.* +import io.ktor.http.HttpStatusCode.Companion.Conflict +import io.ktor.http.HttpStatusCode.Companion.OK +import io.ktor.http.HttpStatusCode.Companion.UnprocessableEntity +import io.ktor.http.cio.websocket.* +import io.ktor.request.* +import io.ktor.response.* +import io.ktor.routing.* +import io.ktor.websocket.* +import kotlinx.coroutines.flow.* +import kotlinx.serialization.encodeToString +import org.bson.types.ObjectId +import org.litote.kmongo.coroutine.CoroutineDatabase +import org.litote.kmongo.eq +import java.time.Instant + +fun Route.addTorrentRoutes(qbClient: QBittorrentClient, mongodb: CoroutineDatabase) { + val mediaRefs = mongodb.getCollection() + route("/torrents") { + get { + call.respond(qbClient.getTorrents()) + } + + post { + val session = call.principal()!! + val description = call.receiveOrNull() + ?: return@post call.respond(UnprocessableEntity) + if (qbClient.getTorrentProperties(description.hash) != null) { + return@post call.respond(Conflict) + } + qbClient.addTorrent { + urls.add(description.magnetUrl) + savePath = "/downloads" + sequentialDownload = true + firstLastPiecePriority = true + } + call.respond(OK) + + val movieId = call.parameters["movieId"] ?: return@post + val downloadId = ObjectId.get().toString() + mediaRefs.insertOne( + DownloadMediaReference( + id = downloadId, + contentId = movieId, + hash = description.hash, + addedByUserId = session.userId, + added = Instant.now().toEpochMilli(), + fileIndex = null, + filePath = null, + mediaKind = MediaKind.MOVIE, + ) + ) + qbClient.torrentFlow(description.hash) + .dropWhile { it.state == Torrent.State.META_DL } + .mapNotNull { torrent -> + qbClient.getTorrentFiles(torrent.hash) + .filter(videoFile) + .maxByOrNull(TorrentFile::size) + ?.run { this to torrent } + } + .take(1) + .onEach { (file, torrent) -> + val download = mediaRefs.findOneById(downloadId) as DownloadMediaReference + mediaRefs.updateOneById( + downloadId, + download.copy( + fileIndex = file.id, + filePath = "${torrent.savePath}/${file.name}" + ) + ) + } + .launchIn(application) + } + + route("/global") { + get { + call.respond(qbClient.getGlobalTransferInfo()) + } + } + + route("/{hash}") { + get("/files") { + val hash = call.parameters["hash"]!! + call.respond(qbClient.getTorrentFiles(hash)) + } + + get("/pause") { + val hash = call.parameters["hash"]!! + qbClient.pauseTorrents(listOf(hash)) + call.respond(OK) + } + + get("/resume") { + val hash = call.parameters["hash"]!! + qbClient.resumeTorrents(listOf(hash)) + call.respond(OK) + } + + delete { + val hash = call.parameters["hash"]!! + val deleteFiles = call.request.queryParameters["deleteFiles"]!!.toBoolean() + qbClient.deleteTorrents(listOf(hash), deleteFiles = deleteFiles) + mediaRefs.deleteOne(DownloadMediaReference::hash eq hash) + call.respond(OK) + } + } + + /*route("/quickstart") { + get("/tmdb/{tmdb_id}") { + val tmdbId = call.parameters["tmdb_id"]!!.toInt() + val movie = tmdb.movies.getMovie(tmdbId, null) + val results = torrentSearch.search(movie.title, Category.MOVIES) + // TODO: Better quickstart behavior, consider file size and transcoding reqs + val selection = results.maxByOrNull { it.seeds }!! + + if (qbClient.getTorrents().any { it.hash == selection.hash }) { + call.respond(selection.hash) + } else { + qbClient.addTorrent { + urls.add(selection.magnetUrl) + savePath = "/downloads" + category = "movies" + rootFolder = true + sequentialDownload = true + firstLastPiecePriority = true + } + call.respond(selection.hash) + } + } + }*/ + } +} + +fun Route.addTorrentWsRoutes(qbClient: QBittorrentClient) { + webSocket("/ws/torrents/observe") { + qbClient.syncMainData() + .takeWhile { !outgoing.isClosedForSend } + .collect { data -> + val changed = data.torrents.keys + val removed = data.torrentsRemoved + if (changed.isNotEmpty() || removed.isNotEmpty()) { + val listText = (changed + removed) + .distinct() + .joinToString(",") + send(Frame.Text(listText)) + } + } + } + webSocket("/ws/torrents/global") { + qbClient.syncMainData() + .takeWhile { !outgoing.isClosedForSend } + .collect { data -> + outgoing.send(Frame.Text(json.encodeToString(data.serverState))) + } + } +} + +private val videoExtensions = listOf(".mp4", ".avi", ".mkv") +private val videoFile = { torrentFile: TorrentFile -> + torrentFile.name.run { + videoExtensions.any { endsWith(it) } && !contains("sample", true) + } +} + diff --git a/server/src/main/kotlin/routes/TvShows.kt b/server/src/main/kotlin/routes/TvShows.kt new file mode 100644 index 00000000..4631d3be --- /dev/null +++ b/server/src/main/kotlin/routes/TvShows.kt @@ -0,0 +1,107 @@ +/** + * AnyStream + * Copyright (C) 2021 Drew Carlson + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package anystream.routes + +import anystream.data.asApiResponse +import anystream.data.asCompleteTvSeries +import anystream.models.Episode +import anystream.models.MediaReference +import anystream.models.TvShow +import anystream.models.api.TmdbTvShowResponse +import anystream.util.logger +import info.movito.themoviedbapi.TmdbApi +import info.movito.themoviedbapi.TmdbTV.TvMethod +import io.ktor.application.call +import io.ktor.http.HttpStatusCode +import io.ktor.response.respond +import io.ktor.routing.* +import org.litote.kmongo.coroutine.CoroutineDatabase + +fun Route.addTvShowRoutes( + tmdb: TmdbApi, + mongodb: CoroutineDatabase, +) { + val tvShowDb = mongodb.getCollection() + val episodeDb = mongodb.getCollection() + val mediaRefs = mongodb.getCollection() + route("/tv") { + get { + call.respond(tvShowDb.find().toList()) + } + + route("/tmdb") { + get("/popular") { + val page = call.request.queryParameters["page"]?.toIntOrNull() ?: 1 + runCatching { + tmdb.tvSeries.getPopular("en", page) + }.onSuccess { tmdbShows -> + call.respond(tmdbShows.asApiResponse()) + }.onFailure { e -> + // TODO: Decompose this exception and retry where possible + logger.error("Error fetching popular series from TMDB - page=$page", e) + call.respond(HttpStatusCode.InternalServerError) + } + } + + get("/{tmdb_id}") { + val tmdbId = call.parameters["tmdb_id"]?.toIntOrNull() + + if (tmdbId == null) { + call.respond(HttpStatusCode.NotFound) + } else { + runCatching { + tmdb.tvSeries.getSeries( + tmdbId, + null, + TvMethod.keywords + ) + }.onSuccess { tmdbSeries -> + call.respond(tmdbSeries.asCompleteTvSeries()) + }.onFailure { e -> + // TODO: Decompose this exception and retry where possible + logger.error("Error fetching series from TMDB - tmdb=$tmdbId", e) + call.respond(HttpStatusCode.InternalServerError) + } + } + } + + get("/search") { + val query = call.request.queryParameters["query"] + val page = call.request.queryParameters["page"]?.toIntOrNull() ?: 1 + + if (query.isNullOrBlank()) { + call.respond(TmdbTvShowResponse()) + } else { + runCatching { + tmdb.search.searchTv(query, null, page) + }.onSuccess { tmdbShows -> + call.respond(tmdbShows.asApiResponse()) + }.onFailure { e -> + // TODO: Decompose this exception and retry where possible + logger.error("Error searching TMDB - page=$page, query='$query'", e) + call.respond(HttpStatusCode.InternalServerError) + } + } + } + } + + get("/{show_id}") { + val showId = call.parameters["show_id"] ?: "" + } + } +} diff --git a/server/src/main/kotlin/routes/Users.kt b/server/src/main/kotlin/routes/Users.kt new file mode 100644 index 00000000..e020a914 --- /dev/null +++ b/server/src/main/kotlin/routes/Users.kt @@ -0,0 +1,376 @@ +/** + * AnyStream + * Copyright (C) 2021 Drew Carlson + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package anystream.routes + +import anystream.data.UserSession +import anystream.json +import anystream.models.* +import anystream.models.api.* +import anystream.models.api.CreateSessionError.* +import anystream.models.api.CreateUserError.PasswordError +import anystream.models.api.CreateUserError.UsernameError +import anystream.util.logger +import anystream.util.withAnyPermission +import com.mongodb.MongoQueryException +import io.ktor.application.call +import io.ktor.auth.* +import io.ktor.http.HttpStatusCode.Companion.BadRequest +import io.ktor.http.HttpStatusCode.Companion.Forbidden +import io.ktor.http.HttpStatusCode.Companion.InternalServerError +import io.ktor.http.HttpStatusCode.Companion.NotFound +import io.ktor.http.HttpStatusCode.Companion.OK +import io.ktor.http.HttpStatusCode.Companion.Unauthorized +import io.ktor.http.HttpStatusCode.Companion.UnprocessableEntity +import io.ktor.http.cio.websocket.* +import io.ktor.request.receiveOrNull +import io.ktor.response.respond +import io.ktor.routing.* +import io.ktor.sessions.* +import io.ktor.websocket.* +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.serialization.encodeToString +import org.bouncycastle.crypto.generators.BCrypt +import org.bouncycastle.util.encoders.Hex +import org.bson.types.ObjectId +import org.litote.kmongo.coroutine.* +import org.litote.kmongo.eq +import java.time.Instant +import java.util.* +import java.util.concurrent.ConcurrentHashMap +import kotlin.random.Random +import kotlin.reflect.jvm.internal.impl.protobuf.Internal +import kotlin.time.seconds + +private const val SALT_BYTES = 128 / 8 +private const val BCRYPT_COST = 10 +private const val INVITE_CODE_BYTES = 32 +private const val PAIRING_SESSION_SECONDS = 60 + +private val pairingCodes = ConcurrentHashMap() + +fun Route.addUserRoutes(mongodb: CoroutineDatabase) { + val users = mongodb.getCollection() + val credentialsDb = mongodb.getCollection() + val inviteCodeDb = mongodb.getCollection() + route("/users") { + + post { + val body = call.receiveOrNull() + ?: return@post call.respond(UnprocessableEntity) + val createSession = call.parameters["createSession"]?.toBoolean() ?: true + + val usernameError = when { + body.username.isBlank() -> UsernameError.BLANK + body.username.length < USERNAME_LENGTH_MIN -> UsernameError.TOO_SHORT + body.username.length > USERNAME_LENGTH_MAX -> UsernameError.TOO_LONG + else -> null + } + val passwordError = when { + body.password.isBlank() -> PasswordError.BLANK + body.password.length < PASSWORD_LENGTH_MIN -> PasswordError.TOO_SHORT + body.password.length > PASSWORD_LENGTH_MAX -> PasswordError.TOO_LONG + else -> null + } + + if (usernameError != null || passwordError != null) { + return@post call.respond(CreateUserResponse.error(usernameError, passwordError)) + } + + val username = body.username.toLowerCase(Locale.ROOT) + if (users.findOne(User::username eq username) != null) { + return@post call.respond( + CreateUserResponse.error( + UsernameError.ALREADY_EXISTS, + null + ) + ) + } + + val id = body.inviteCode + val inviteCode = if (id.isNullOrBlank()) { + null + } else { + inviteCodeDb.findOneById(id) + } + + if (inviteCode == null && users.countDocuments() > 0L) { + return@post call.respond(Forbidden) + } + + val permissions = inviteCode?.permissions ?: setOf(Permissions.GLOBAL) + + val user = User( + id = ObjectId.get().toString(), + username = username, + displayName = body.username + ) + + val salt = Random.nextBytes(SALT_BYTES) + val passwordBytes = body.password.toByteArray() + val hashedPassword = BCrypt.generate(passwordBytes, salt, BCRYPT_COST) + + val credentials = UserCredentials( + id = user.id, + password = hashedPassword.toUtf8Hex(), + salt = salt.toUtf8Hex(), + permissions = permissions + ) + try { + // TODO: Ensure all or clear completed + users.insertOne(user) + credentialsDb.insertOne(credentials) + if (inviteCode != null) { + inviteCodeDb.deleteOneById(inviteCode.value) + } + if (createSession) { + call.sessions.getOrSet { + UserSession(userId = user.id, credentials.permissions) + } + } + + call.respond(CreateUserResponse.success(user, credentials.permissions)) + } catch (e: MongoQueryException) { + logger.error("Failed to insert new user", e) + call.respond(InternalServerError) + } + } + + authenticate { + withAnyPermission(Permissions.GLOBAL) { + route("/invite") { + get { + val session = call.sessions.get()!! + + val codes = if (session.permissions.contains(Permissions.GLOBAL)) { + inviteCodeDb.find().toList() + } else { + inviteCodeDb + .find(InviteCode::createdByUserId eq session.userId) + .toList() + } + call.respond(codes) + } + + post { + val session = call.sessions.get()!! + val permissions = call.receiveOrNull() + ?: setOf(Permissions.VIEW_COLLECTION) + + val inviteCode = InviteCode( + value = Hex.toHexString(Random.nextBytes(INVITE_CODE_BYTES)), + permissions = permissions, + createdByUserId = session.userId + ) + + inviteCodeDb.insertOne(inviteCode) + call.respond(inviteCode) + } + + delete("/{invite_code}") { + val session = call.sessions.get()!! + val inviteCodeId = call.parameters["invite_code"] + ?: return@delete call.respond(BadRequest) + + val result = if (session.permissions.contains(Permissions.GLOBAL)) { + inviteCodeDb.deleteOneById(inviteCodeId) + } else { + inviteCodeDb.deleteOne( + InviteCode::value eq inviteCodeId, + InviteCode::createdByUserId eq session.userId + ) + } + call.respond(if (result.deletedCount == 0L) NotFound else OK) + } + } + } + } + + route("/session") { + authenticate(optional = true) { + post { + val body = call.receiveOrNull() + ?: return@post call.respond(UnprocessableEntity) + + if (body.username.run { isBlank() || length !in USERNAME_LENGTH_MIN..USERNAME_LENGTH_MAX }) { + return@post call.respond(CreateSessionResponse.error(USERNAME_INVALID)) + } + + val username = body.username.toLowerCase(Locale.ROOT) + if (pairingCodes.containsKey(body.password)) { + val session = call.principal() + ?: return@post call.respond(CreateSessionResponse.error(USERNAME_INVALID)) + + val user = users + .findOne(User::username eq username) + ?: return@post call.respond(NotFound) + return@post if (session.userId == user.id) { + pairingCodes[body.password] = PairingMessage.Authorized( + secret = Random.nextBytes(28).toUtf8Hex(), + userId = session.userId + ) + call.respond(CreateSessionResponse.success(user, session.permissions)) + } else { + pairingCodes[body.password] = PairingMessage.Failed + call.respond(CreateSessionResponse.error(PASSWORD_INCORRECT)) + } + } + + if (body.password.run { isBlank() || length !in PASSWORD_LENGTH_MIN..PASSWORD_LENGTH_MAX }) { + return@post call.respond(CreateSessionResponse.error(PASSWORD_INVALID)) + } + + val user = users.findOne(User::username eq username) + ?: return@post call.respond(CreateSessionResponse.error(USERNAME_NOT_FOUND)) + val auth = credentialsDb.findOne(UserCredentials::id eq user.id) + ?: return@post call.respond(InternalServerError) + + val saltBytes = auth.salt.utf8HexToBytes() + val passwordBytes = body.password.toByteArray() + val hashedPassword = + BCrypt.generate(passwordBytes, saltBytes, BCRYPT_COST).toUtf8Hex() + + if (hashedPassword == auth.password) { + call.sessions.set(UserSession(user.id, auth.permissions)) + call.respond(CreateSessionResponse.success(user, auth.permissions)) + } else { + call.respond(CreateSessionResponse.error(PASSWORD_INCORRECT)) + } + } + } + + post("/paired") { + val pairingCode = call.parameters["pairingCode"]!! + val secret = call.parameters["secret"]!! + + val pairingMessage = pairingCodes.remove(pairingCode) + if (pairingMessage == null || pairingMessage !is PairingMessage.Authorized) { + return@post call.respond(NotFound) + } else { + if (pairingMessage.secret == secret) { + val user = users.findOneById(pairingMessage.userId)!! + val userCredentials = credentialsDb.findOneById(pairingMessage.userId)!! + call.sessions.set(UserSession(user.id, userCredentials.permissions)) + call.respond( + CreateSessionResponse.success( + user, + userCredentials.permissions + ) + ) + } else { + call.respond(Unauthorized) + } + } + } + + authenticate { + delete { + call.sessions.clear() + call.respond(OK) + } + } + } + + authenticate { + withAnyPermission(Permissions.GLOBAL) { + get { + call.respond(users.find().toList()) + } + } + + route("/{user_id}") { + withAnyPermission(Permissions.GLOBAL) { + get { + val userId = call.parameters["user_id"]!! + call.respond(users.findOneById(userId) ?: NotFound) + } + } + + put { + val session = call.sessions.get()!! + val userId = call.parameters["user_id"]!! + val body = call.receiveOrNull() + ?: return@put call.respond(UnprocessableEntity) + + if (userId == session.userId) { + val user = users.findOneById(userId) + ?: return@put call.respond(NotFound) + val updatedUser = user.copy( + displayName = body.displayName + ) + users.updateOneById(userId, updatedUser) + // TODO: Update password + call.respond(OK) + } else { + call.respond(InternalServerError) + } + } + + withAnyPermission(Permissions.GLOBAL) { + delete { + val userId = call.parameters["user_id"]!! + + if (ObjectId.isValid(userId)) { + val result = users.deleteOneById(userId) + credentialsDb.deleteOneById(userId) + call.respond(if (result.deletedCount == 0L) NotFound else OK) + } else { + call.respond(BadRequest) + } + } + } + } + } + } +} + +fun Route.addUserWsRoutes( + mongodb: CoroutineDatabase, +) { + webSocket("/ws/users/pair") { + val pairingCode = UUID.randomUUID().toString().toLowerCase(Locale.ROOT) + val startingJson = json.encodeToString(PairingMessage.Started(pairingCode)) + send(Frame.Text(startingJson)) + + pairingCodes[pairingCode] = PairingMessage.Idle + + var tick = 0 + var finalMessage: PairingMessage = PairingMessage.Idle + while (finalMessage == PairingMessage.Idle) { + delay(1.seconds) + tick++ + finalMessage = pairingCodes[pairingCode] ?: return@webSocket close() + println(pairingCodes.toList()) + if (tick >= PAIRING_SESSION_SECONDS) { + send(Frame.Text(json.encodeToString(PairingMessage.Failed))) + return@webSocket close() + } + } + + val finalJson = json.encodeToString(finalMessage) + send(Frame.Text(finalJson)) + close() + } +} + +private fun String.utf8HexToBytes(): ByteArray = + toByteArray().run(Hex::decode) + +private fun ByteArray.toUtf8Hex(): String = + run(Hex::encode).toString(Charsets.UTF_8) + diff --git a/server/src/main/kotlin/torrent/search/KMongoTorrentProviderCache.kt b/server/src/main/kotlin/torrent/search/KMongoTorrentProviderCache.kt new file mode 100644 index 00000000..87a055f5 --- /dev/null +++ b/server/src/main/kotlin/torrent/search/KMongoTorrentProviderCache.kt @@ -0,0 +1,106 @@ +/** + * AnyStream + * Copyright (C) 2021 Drew Carlson + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package anystream.torrent.search + +import com.mongodb.client.model.IndexOptions +import com.mongodb.client.model.UpdateOptions +import drewcarlson.torrentsearch.Category +import drewcarlson.torrentsearch.TorrentDescription +import drewcarlson.torrentsearch.TorrentProvider +import drewcarlson.torrentsearch.TorrentProviderCache +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.Contextual +import kotlinx.serialization.Serializable +import org.apache.commons.codec.binary.Hex +import org.litote.kmongo.coroutine.CoroutineDatabase +import org.litote.kmongo.eq +import java.security.MessageDigest +import java.time.Instant +import java.util.concurrent.TimeUnit + +@Serializable +private data class CacheDoc( + val key: String, + val results: List, + @Contextual + val createdAt: Instant = Instant.now() +) + +private const val TOKEN_COLLECTION = "torrent-token-cache" +private const val RESULT_COLLECTION = "torrent-result-cache" + +@Serializable +data class Token(val value: String) + +class KMongoTorrentProviderCache( + mongo: CoroutineDatabase, + expirationMinutes: Long = 15 +) : TorrentProviderCache { + + private val hash = MessageDigest.getInstance("SHA-256") + private val tokenCollection = mongo.getCollection(TOKEN_COLLECTION) + private val torrentCollection = mongo.getCollection(RESULT_COLLECTION) + + init { + runBlocking { + torrentCollection.ensureIndex( + "{'key':1}", // CacheDoc::key + IndexOptions() + .expireAfter(expirationMinutes, TimeUnit.MINUTES) + ) + } + } + + override fun saveToken(provider: TorrentProvider, token: String) { + runBlocking { + tokenCollection.updateOneById(provider.name, token, UpdateOptions().upsert(true)) + } + } + + override fun loadToken(provider: TorrentProvider): String? { + return runBlocking { + tokenCollection.findOneById(provider.name)?.value + } + } + + override fun saveResults( + provider: TorrentProvider, + query: String, + category: Category, + results: List + ) { + val key = cacheKey(provider, query, category) + runBlocking { + torrentCollection.insertOne(CacheDoc(key, results)) + } + } + + override fun loadResults(provider: TorrentProvider, query: String, category: Category): List? { + val key = cacheKey(provider, query, category) + return runBlocking { + torrentCollection.findOne(CacheDoc::key eq key)?.results + } + } + + + private fun cacheKey(provider: TorrentProvider, query: String, category: Category): String { + val raw = "${provider.name}:$query:${category.name}" + hash.update(raw.toByteArray()) + return Hex.encodeHexString(hash.digest()) + } +} diff --git a/server/src/main/kotlin/util/CallLogger.kt b/server/src/main/kotlin/util/CallLogger.kt new file mode 100644 index 00000000..3eb4c63c --- /dev/null +++ b/server/src/main/kotlin/util/CallLogger.kt @@ -0,0 +1,26 @@ +/** + * AnyStream + * Copyright (C) 2021 Drew Carlson + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package anystream.util + +import io.ktor.application.ApplicationCall +import io.ktor.application.call +import io.ktor.application.log +import io.ktor.util.pipeline.PipelineContext + +val PipelineContext.logger + get() = this.call.application.log diff --git a/server/src/main/kotlin/util/MongoSessionStorage.kt b/server/src/main/kotlin/util/MongoSessionStorage.kt new file mode 100644 index 00000000..6601ec83 --- /dev/null +++ b/server/src/main/kotlin/util/MongoSessionStorage.kt @@ -0,0 +1,100 @@ +/** + * AnyStream + * Copyright (C) 2021 Drew Carlson + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package anystream.util + +import com.mongodb.MongoQueryException +import com.mongodb.client.model.UpdateOptions +import io.ktor.sessions.* +import io.ktor.utils.io.* +import io.ktor.utils.io.core.* +import kotlinx.coroutines.coroutineScope +import kotlinx.serialization.Serializable +import org.litote.kmongo.coroutine.CoroutineDatabase +import org.litote.kmongo.eq +import org.slf4j.Logger +import org.slf4j.MarkerFactory +import java.util.concurrent.ConcurrentHashMap +import kotlin.NoSuchElementException +import kotlin.text.Charsets.UTF_8 + +@Serializable +private class SessionData( + val id: String, + val data: ByteArray, +) + +class MongoSessionStorage( + mongodb: CoroutineDatabase, + private val logger: Logger, +) : SimplifiedSessionStorage() { + + private val marker = MarkerFactory.getMarker(this::class.simpleName) + + private val sessions = ConcurrentHashMap() + private val sessionCollection = mongodb.getCollection() + private val updateOptions = UpdateOptions().upsert(true) + + override suspend fun write(id: String, data: ByteArray?) { + if (data == null) { + logger.trace(marker, "Deleting session $id") + sessions.remove(id) + sessionCollection.deleteOne(SessionData::id eq id) + } else { + logger.debug(marker, "Writing session $id, ${data.toString(UTF_8)}") + sessions[id] = data + try { + sessionCollection.updateOne( + SessionData::id eq id, + SessionData(id, data), + updateOptions + ) + } catch (e: MongoQueryException) { + logger.trace(marker, "Failed to write session data", e) + } + } + } + + override suspend fun read(id: String): ByteArray? { + logger.debug(marker, "Looking for session $id") + return (sessions[id] ?: sessionCollection.findOne(SessionData::id eq id)?.data).also { + logger.debug(marker, "Found session $id") + } + } +} + +abstract class SimplifiedSessionStorage : SessionStorage { + abstract suspend fun read(id: String): ByteArray? + abstract suspend fun write(id: String, data: ByteArray?) + + override suspend fun invalidate(id: String) { + write(id, null) + } + + override suspend fun read(id: String, consumer: suspend (ByteReadChannel) -> R): R { + val data = read(id) ?: throw NoSuchElementException("Session $id not found") + return consumer(ByteReadChannel(data)) + } + + override suspend fun write(id: String, provider: suspend (ByteWriteChannel) -> Unit) { + return coroutineScope { + provider(reader(autoFlush = true) { + write(id, channel.readRemaining().readBytes()) + }.channel) + } + } +} diff --git a/server/src/main/kotlin/util/PermissionAuthorization.kt b/server/src/main/kotlin/util/PermissionAuthorization.kt new file mode 100644 index 00000000..ea890ec1 --- /dev/null +++ b/server/src/main/kotlin/util/PermissionAuthorization.kt @@ -0,0 +1,133 @@ +/** + * AnyStream + * Copyright (C) 2021 Drew Carlson + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package anystream.util + +import io.ktor.application.* +import io.ktor.auth.* +import io.ktor.http.HttpStatusCode.Companion.Forbidden +import io.ktor.request.* +import io.ktor.response.* +import io.ktor.routing.* +import io.ktor.util.* +import io.ktor.util.pipeline.* + +typealias Permission = String + +class AuthorizationException(override val message: String) : Exception(message) + +class PermissionAuthorization { + + private var extractPermissions: (Principal) -> Set = { emptySet() } + + fun extract(body: (Principal) -> Set) { + extractPermissions = body + } + + fun interceptPipeline( + pipeline: ApplicationCallPipeline, + any: Set? = null, + all: Set? = null, + none: Set? = null + ) { + pipeline.insertPhaseAfter(ApplicationCallPipeline.Features, Authentication.ChallengePhase) + pipeline.insertPhaseAfter(Authentication.ChallengePhase, AuthorizationPhase) + + pipeline.intercept(AuthorizationPhase) { + val principal = call.authentication.principal() + ?: throw AuthorizationException("Missing principal") + val activePermissions = extractPermissions(principal) + val denyReasons = mutableListOf() + all?.let { + val missing = all - activePermissions + if (missing.isNotEmpty()) { + denyReasons += "Principal $principal is missing required permission(s) ${missing.joinToString(" and ")}" + } + } + any?.let { + if (any.none { it in activePermissions }) { + denyReasons += "Principal $principal is missing all possible permission(s) ${any.joinToString(" or ")}" + } + } + none?.let { + if (none.any { it in activePermissions }) { + denyReasons += "Principal $principal has excluded permission(s) ${(none.intersect(activePermissions)).joinToString(" and ")}" + } + } + if (denyReasons.isNotEmpty()) { + val message = denyReasons.joinToString(". ") + logger.warn("Authorization failed for ${call.request.path()}. $message") + call.respond(Forbidden) + finish() + } + } + } + + + companion object Feature : + ApplicationFeature { + override val key = AttributeKey("PermissionAuthorization") + + val AuthorizationPhase = PipelinePhase("PermissionAuthorization") + + override fun install( + pipeline: ApplicationCallPipeline, + configure: PermissionAuthorization.() -> Unit + ): PermissionAuthorization { + return PermissionAuthorization().also(configure) + } + } +} + +class AuthorizedRouteSelector(private val description: String) : RouteSelector() { + + override fun evaluate(context: RoutingResolveContext, segmentIndex: Int) = + RouteSelectorEvaluation.Constant + + override fun toString(): String = "(authorize ${description})" +} + +fun Route.withPermission(permission: Permission, build: Route.() -> Unit) = + authorizedRoute(all = setOf(permission), build = build) + +fun Route.withAllPermissions(vararg permissions: Permission, build: Route.() -> Unit) = + authorizedRoute(all = permissions.toSet(), build = build) + +fun Route.withAnyPermission(vararg permissions: Permission, build: Route.() -> Unit) = + authorizedRoute(any = permissions.toSet(), build = build) + +fun Route.withoutPermissions(vararg permissions: Permission, build: Route.() -> Unit) = + authorizedRoute(none = permissions.toSet(), build = build) + +private fun Route.authorizedRoute( + any: Set? = null, + all: Set? = null, + none: Set? = null, + build: Route.() -> Unit +): Route { + val description = listOfNotNull( + any?.let { "anyOf (${any.joinToString(" ")})" }, + all?.let { "allOf (${all.joinToString(" ")})" }, + none?.let { "noneOf (${none.joinToString(" ")})" } + ).joinToString(",") + return createChild(AuthorizedRouteSelector(description)).also { route -> + application + .feature(PermissionAuthorization) + .interceptPipeline(route, any, all, none) + route.build() + } +} diff --git a/server/src/main/kotlin/util/SinglePageApp.kt b/server/src/main/kotlin/util/SinglePageApp.kt new file mode 100644 index 00000000..87c1a899 --- /dev/null +++ b/server/src/main/kotlin/util/SinglePageApp.kt @@ -0,0 +1,81 @@ +/** + * AnyStream + * Copyright (C) 2021 Drew Carlson + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package anystream.util + +import io.ktor.application.* +import io.ktor.http.content.* +import io.ktor.request.* +import io.ktor.response.* +import io.ktor.routing.* +import io.ktor.util.* +import java.io.File +import java.util.concurrent.ConcurrentHashMap + +class SinglePageApp( + var defaultFile: String = "index.html", + var staticFilePath: String = "", + var ignoreBasePath: String = "" +) { + + companion object Feature : ApplicationFeature { + override val key = AttributeKey("SinglePageApp") + + override fun install(pipeline: Application, configure: SinglePageApp.() -> Unit): SinglePageApp { + val configuration = SinglePageApp().apply(configure) + val defaultFile = File(configuration.staticFilePath, configuration.defaultFile) + val staticFileMap = ConcurrentHashMap() + + pipeline.routing { + static("/") { + staticRootFolder = File(configuration.staticFilePath) + files("./") + default(configuration.defaultFile) + } + + get("/*") { + call.respondRedirect("/") + } + } + + pipeline.intercept(ApplicationCallPipeline.Features) { + if (!call.request.uri.startsWith(configuration.ignoreBasePath)) { + val path = call.request.uri.split("/") + if (path.last().contains(".")) { + try { + val file = staticFileMap.getOrPut(path.last()) { + val urlPathString = path.subList(1, path.lastIndex) + .fold(configuration.staticFilePath) { out, part -> "$out/$part" } + File(urlPathString, path.last()) + .apply { check(exists()) } + } + call.respondFile(file) + return@intercept finish() + } catch (e: IllegalStateException) { + // No local resource, fall to other handlers + } + } else { + call.respondFile(defaultFile) + return@intercept finish() + } + } + } + + return configuration + } + } +} diff --git a/server/src/main/resources/application.conf b/server/src/main/resources/application.conf new file mode 100644 index 00000000..94cbea93 --- /dev/null +++ b/server/src/main/resources/application.conf @@ -0,0 +1,31 @@ +ktor { + deployment { + port = 8888 + port = ${?PORT} + watch = [ "server/build" ] + development = true + } + application { + modules = [ + anystream.modules.StatusPageModuleKt.module + anystream.ApplicationKt.module + ] + } +} + +app { + frontEndPath = "/app/client-web" + ffmpegPath = "/usr/bin" + transcodePath = "/tmp" + mongoUrl = "mongodb://root:password@localhost" + tmdbApiKey = "c1e9e8ade306dd9cbc5e17b05ed4badd" + qbittorrentUrl = "http://localhost:9090" + qbittorrentUser = "admin" + qbittorrentPassword = "adminadmin" +} + +media { + rootPath = "/" + movieRootPaths = [] + tvRootPaths = [] +} \ No newline at end of file diff --git a/server/src/main/resources/logback.xml b/server/src/main/resources/logback.xml new file mode 100644 index 00000000..d111b8dd --- /dev/null +++ b/server/src/main/resources/logback.xml @@ -0,0 +1,15 @@ + + + + %d{YYYY-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + + + + diff --git a/server/src/test/kotlin/ApplicationTest.kt b/server/src/test/kotlin/ApplicationTest.kt new file mode 100644 index 00000000..f2255727 --- /dev/null +++ b/server/src/test/kotlin/ApplicationTest.kt @@ -0,0 +1,36 @@ +/** + * AnyStream + * Copyright (C) 2021 Drew Carlson + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package anystream + +import io.ktor.http.HttpMethod +import io.ktor.http.HttpStatusCode +import io.ktor.server.testing.handleRequest +import io.ktor.server.testing.withTestApplication +import kotlin.test.Test +import kotlin.test.assertEquals + +class ApplicationTest { + @Test + fun testRoot() { + withTestApplication({ module(testing = true) }) { + handleRequest(HttpMethod.Get, "/").apply { + assertEquals(HttpStatusCode.OK, response.status()) + } + } + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 00000000..2823450b --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,21 @@ +rootProject.name = "anystream" + +include(":server") +include(":api-client") +include(":data-models") +include(":client") +include(":client-web") +include(":client-android") +include(":preferences") +include(":ktor-permissions") +include(":torrent-search") + +enableFeaturePreview("VERSION_CATALOGS") +enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") + +pluginManagement { + repositories { + gradlePluginPortal() + maven("https://maven.pkg.jetbrains.space/public/p/compose/dev") + } +} diff --git a/torrent-search/build.gradle.kts b/torrent-search/build.gradle.kts new file mode 100644 index 00000000..8b7d3369 --- /dev/null +++ b/torrent-search/build.gradle.kts @@ -0,0 +1,12 @@ +plugins { + kotlin("jvm") + kotlin("plugin.serialization") +} + + +dependencies { + implementation(kotlin("stdlib")) + implementation(libs.ktor.client.core) + implementation(libs.ktor.client.json) + implementation(libs.ktor.client.serialization) +} diff --git a/torrent-search/src/main/kotlin/Category.kt b/torrent-search/src/main/kotlin/Category.kt new file mode 100644 index 00000000..fc59a065 --- /dev/null +++ b/torrent-search/src/main/kotlin/Category.kt @@ -0,0 +1,32 @@ +/** + * AnyStream + * Copyright (C) 2021 Drew Carlson + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package drewcarlson.torrentsearch + +enum class Category { + ALL, + AUDIO, + VIDEO, + OTHER, + MOVIES, + XXX, + GAMES, + TV, + MUSIC, + APPS, + BOOKS +} diff --git a/torrent-search/src/main/kotlin/TorrentDescription.kt b/torrent-search/src/main/kotlin/TorrentDescription.kt new file mode 100644 index 00000000..35c4b873 --- /dev/null +++ b/torrent-search/src/main/kotlin/TorrentDescription.kt @@ -0,0 +1,36 @@ +/** + * AnyStream + * Copyright (C) 2021 Drew Carlson + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package drewcarlson.torrentsearch + +import kotlinx.serialization.Serializable +import kotlinx.serialization.Transient + +@Serializable +data class TorrentDescription( + val provider: String, + val magnetUrl: String, + val title: String, + val size: Long, + val seeds: Int, + val peers: Int +) { + @Transient + val hash: String = magnetUrl + .substringAfter("xt=urn:btih:") + .substringBefore("&") +} diff --git a/torrent-search/src/main/kotlin/TorrentProvider.kt b/torrent-search/src/main/kotlin/TorrentProvider.kt new file mode 100644 index 00000000..7eed6266 --- /dev/null +++ b/torrent-search/src/main/kotlin/TorrentProvider.kt @@ -0,0 +1,53 @@ +/** + * AnyStream + * Copyright (C) 2021 Drew Carlson + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package drewcarlson.torrentsearch + + +interface TorrentProvider { + + /** The Provider's name. */ + val name: String + + /** The Provider's base url. (ex. `https://provider.link`) */ + val baseUrl: String + + /** The Provider's path to acquire a token. */ + val tokenPath: String + + /** The Provider's path to search search data. */ + val searchPath: String + + /** Maps a url safe string of provider categories to a [Category]. */ + val categories: Map + + /** The result limit for search requests. */ + val resultsPerPage: Int get() = 100 + + /** True if the provider is enabled. */ + val isEnabled: Boolean + + /** + * Execute a search for the given [query] in [category], returning + * [TorrentDescription]s for each of the Provider's entries. + */ + suspend fun search(query: String, category: Category, limit: Int): List + + fun enable(username: String? = null, password: String? = null, cookies: List = emptyList()) + + fun disable() +} diff --git a/torrent-search/src/main/kotlin/TorrentProviderCache.kt b/torrent-search/src/main/kotlin/TorrentProviderCache.kt new file mode 100644 index 00000000..61746e5f --- /dev/null +++ b/torrent-search/src/main/kotlin/TorrentProviderCache.kt @@ -0,0 +1,38 @@ +/** + * AnyStream + * Copyright (C) 2021 Drew Carlson + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package drewcarlson.torrentsearch + +interface TorrentProviderCache { + + fun saveToken(provider: TorrentProvider, token: String) + + fun loadToken(provider: TorrentProvider): String? + + fun saveResults( + provider: TorrentProvider, + query: String, + category: Category, + results: List + ) + + fun loadResults( + provider: TorrentProvider, + query: String, + category: Category + ): List? +} diff --git a/torrent-search/src/main/kotlin/TorrentSearch.kt b/torrent-search/src/main/kotlin/TorrentSearch.kt new file mode 100644 index 00000000..46555a5e --- /dev/null +++ b/torrent-search/src/main/kotlin/TorrentSearch.kt @@ -0,0 +1,129 @@ +/** + * AnyStream + * Copyright (C) 2021 Drew Carlson + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package drewcarlson.torrentsearch + +import drewcarlson.torrentsearch.providers.LibreProvider +import drewcarlson.torrentsearch.providers.PirateBayProvider +import drewcarlson.torrentsearch.providers.RarbgProvider +import io.ktor.client.HttpClient +import io.ktor.client.features.* +import io.ktor.client.features.cookies.AcceptAllCookiesStorage +import io.ktor.client.features.cookies.HttpCookies +import io.ktor.client.features.json.JsonFeature +import io.ktor.client.features.json.serializer.KotlinxSerializer +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.merge +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.reduce +import kotlinx.coroutines.flow.take + +class TorrentSearch( + private val providerCache: TorrentProviderCache? = null, + httpClient: HttpClient = HttpClient(), + vararg providers: TorrentProvider +) { + + private val http = httpClient.config { + install(JsonFeature) { + serializer = KotlinxSerializer() + } + + install(HttpCookies) { + storage = AcceptAllCookiesStorage() + } + } + + private val providers = listOf( + RarbgProvider(http, providerCache), + PirateBayProvider(http), + LibreProvider() + ) + providers + + /** + * Search all enabled providers with [query] and [category]. + * + * All results are merged into a single list. [limit] is used + * when possible to limit the result count from each provider. + */ + suspend fun search(query: String, category: Category, limit: Int): List { + return searchFlow(query, category, limit).reduce { acc, next -> acc + next } + } + + /** + * Search all enabled providers with [query] and [category], + * emitting each set of results as the providers respond. + * + * [limit] is used when possible to limit the result count + * from each provider. + */ + fun searchFlow(query: String, category: Category, limit: Int): Flow> { + return providers + .filter(TorrentProvider::isEnabled) + .map { provider -> + println("Searching '${provider.name}' for '$query'") + flow { + try { + emit(provider.search(query, category, limit)) + } catch (e: ClientRequestException) { + println("Search failed for '${provider.name}'") + e.printStackTrace() + } + }.onEach { results -> + if (results.isNotEmpty()) { + providerCache?.saveResults(provider, query, category, results) + } + }.onStart { + val cacheResult = providerCache?.loadResults(provider, query, category) + if (cacheResult != null) { + emit(cacheResult) + } + }.take(1) + } + .merge() + .flowOn(Dispatchers.Default) + } + + /** + * Returns a list of enabled providers. + */ + fun enabledProviders() = providers.filter(TorrentProvider::isEnabled).toList() + + /** + * Returns a list of available providers. + */ + fun availableProviders() = providers.toList() + + /** + * Enable the provider [name] with the included credentials and [cookies]. + */ + fun enableProvider(name: String, username: String?, password: String?, cookies: List) { + providers.singleOrNull { it.name == name } + ?.enable(username, password, cookies) + } + + /** + * Disable the provider [name]. + */ + fun disableProvider(name: String) { + providers.singleOrNull { it.name == name }?.disable() + } +} diff --git a/torrent-search/src/main/kotlin/providers/BaseTorrentProvider.kt b/torrent-search/src/main/kotlin/providers/BaseTorrentProvider.kt new file mode 100644 index 00000000..5202238d --- /dev/null +++ b/torrent-search/src/main/kotlin/providers/BaseTorrentProvider.kt @@ -0,0 +1,48 @@ +/** + * AnyStream + * Copyright (C) 2021 Drew Carlson + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package drewcarlson.torrentsearch.providers + +import drewcarlson.torrentsearch.TorrentProvider +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlin.coroutines.CoroutineContext + +abstract class BaseTorrentProvider( + enabledByDefault: Boolean = true +) : TorrentProvider, CoroutineScope { + + private var enabled = enabledByDefault + + override val coroutineContext: CoroutineContext = + Dispatchers.Default + SupervisorJob() + + final override val isEnabled: Boolean = enabled + + override fun enable( + username: String?, + password: String?, + cookies: List + ) { + enabled = true + } + + override fun disable() { + enabled = false + } +} diff --git a/torrent-search/src/main/kotlin/providers/LibreProvider.kt b/torrent-search/src/main/kotlin/providers/LibreProvider.kt new file mode 100644 index 00000000..5bda061a --- /dev/null +++ b/torrent-search/src/main/kotlin/providers/LibreProvider.kt @@ -0,0 +1,75 @@ +/** + * AnyStream + * Copyright (C) 2021 Drew Carlson + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package drewcarlson.torrentsearch.providers + +import drewcarlson.torrentsearch.Category +import drewcarlson.torrentsearch.TorrentDescription + +class LibreProvider : BaseTorrentProvider() { + override val name: String = "libre" + override val baseUrl: String = "" + override val tokenPath: String = "" + override val searchPath: String = "" + override val categories: Map = emptyMap() + + override suspend fun search(query: String, category: Category, limit: Int): List { + return when (query.toLowerCase()) { + "sintel" -> listOf( + TorrentDescription( + provider = name, + magnetUrl = "magnet:?xt=urn:btih:08ada5a7a6183aae1e09d831df6748d566095a10&dn=Sintel&tr=udp%3A%2F%2Fexplodie.org%3A6969&tr=udp%3A%2F%2Ftracker.coppersurfer.tk%3A6969&tr=udp%3A%2F%2Ftracker.empire-js.us%3A1337&tr=udp%3A%2F%2Ftracker.leechers-paradise.org%3A6969&tr=udp%3A%2F%2Ftracker.opentrackr.org%3A1337&tr=wss%3A%2F%2Ftracker.btorrent.xyz&tr=wss%3A%2F%2Ftracker.fastcast.nz&tr=wss%3A%2F%2Ftracker.openwebtorrent.com&ws=https%3A%2F%2Fwebtorrent.io%2Ftorrents%2F&xs=https%3A%2F%2Fwebtorrent.io%2Ftorrents%2Fsintel.torrent", + title = "Sintel", + size = 0L, + seeds = 0, + peers = 0 + ) + ) + "big buck bunny" -> listOf( + TorrentDescription( + provider = name, + magnetUrl = "magnet:?xt=urn:btih:dd8255ecdc7ca55fb0bbf81323d87062db1f6d1c&dn=Big+Buck+Bunny&tr=udp%3A%2F%2Fexplodie.org%3A6969&tr=udp%3A%2F%2Ftracker.coppersurfer.tk%3A6969&tr=udp%3A%2F%2Ftracker.empire-js.us%3A1337&tr=udp%3A%2F%2Ftracker.leechers-paradise.org%3A6969&tr=udp%3A%2F%2Ftracker.opentrackr.org%3A1337&tr=wss%3A%2F%2Ftracker.btorrent.xyz&tr=wss%3A%2F%2Ftracker.fastcast.nz&tr=wss%3A%2F%2Ftracker.openwebtorrent.com&ws=https%3A%2F%2Fwebtorrent.io%2Ftorrents%2F&xs=https%3A%2F%2Fwebtorrent.io%2Ftorrents%2Fbig-buck-bunny.torrent", + title = "Big Buck Bunny", + size = 0L, + seeds = 0, + peers = 0 + ) + ) + "cosmos laundromat" -> listOf( + TorrentDescription( + provider = name, + magnetUrl = "magnet:?xt=urn:btih:c9e15763f722f23e98a29decdfae341b98d53056&dn=Cosmos+Laundromat&tr=udp%3A%2F%2Fexplodie.org%3A6969&tr=udp%3A%2F%2Ftracker.coppersurfer.tk%3A6969&tr=udp%3A%2F%2Ftracker.empire-js.us%3A1337&tr=udp%3A%2F%2Ftracker.leechers-paradise.org%3A6969&tr=udp%3A%2F%2Ftracker.opentrackr.org%3A1337&tr=wss%3A%2F%2Ftracker.btorrent.xyz&tr=wss%3A%2F%2Ftracker.fastcast.nz&tr=wss%3A%2F%2Ftracker.openwebtorrent.com&ws=https%3A%2F%2Fwebtorrent.io%2Ftorrents%2F&xs=https%3A%2F%2Fwebtorrent.io%2Ftorrents%2Fcosmos-laundromat.torrent", + title = "Cosmos Laundromat", + size = 0L, + seeds = 0, + peers = 0 + ) + ) + "tears of steal" -> listOf( + TorrentDescription( + provider = name, + magnetUrl = "Tears of Steal", + title = "magnet:?xt=urn:btih:209c8226b299b308beaf2b9cd3fb49212dbd13ec&dn=Tears+of+Steel&tr=udp%3A%2F%2Fexplodie.org%3A6969&tr=udp%3A%2F%2Ftracker.coppersurfer.tk%3A6969&tr=udp%3A%2F%2Ftracker.empire-js.us%3A1337&tr=udp%3A%2F%2Ftracker.leechers-paradise.org%3A6969&tr=udp%3A%2F%2Ftracker.opentrackr.org%3A1337&tr=wss%3A%2F%2Ftracker.btorrent.xyz&tr=wss%3A%2F%2Ftracker.fastcast.nz&tr=wss%3A%2F%2Ftracker.openwebtorrent.com&ws=https%3A%2F%2Fwebtorrent.io%2Ftorrents%2F&xs=https%3A%2F%2Fwebtorrent.io%2Ftorrents%2Ftears-of-steel.torrent", + size = 0L, + seeds = 0, + peers = 0 + ) + ) + else -> emptyList() + } + } +} diff --git a/torrent-search/src/main/kotlin/providers/PirateBayProvider.kt b/torrent-search/src/main/kotlin/providers/PirateBayProvider.kt new file mode 100644 index 00000000..713c8bf8 --- /dev/null +++ b/torrent-search/src/main/kotlin/providers/PirateBayProvider.kt @@ -0,0 +1,114 @@ +/** + * AnyStream + * Copyright (C) 2021 Drew Carlson + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package drewcarlson.torrentsearch.providers + +import drewcarlson.torrentsearch.Category +import drewcarlson.torrentsearch.TorrentDescription +import drewcarlson.torrentsearch.TorrentProviderCache +import io.ktor.client.* +import io.ktor.client.call.* +import io.ktor.client.request.* +import io.ktor.client.statement.* +import io.ktor.http.* +import kotlinx.serialization.json.* + +internal class PirateBayProvider( + private val httpClient: HttpClient +) : BaseTorrentProvider() { + + override val name: String = "ThePirateBay" + override val baseUrl: String = "https://apibay.org" + override val tokenPath: String = "" + override val searchPath: String = "/q.php?q={query}&cat={category}" + + override val categories = mapOf( + Category.ALL to "", + Category.AUDIO to "100", + Category.MUSIC to "101", + Category.VIDEO to "200", + Category.MOVIES to "201", + Category.TV to "205", + Category.APPS to "300", + Category.GAMES to "400", + Category.XXX to "500", + Category.OTHER to "600", + ) + + private val trackers = listOf( + "udp://tracker.coppersurfer.tk:6969/announce", + "udp://9.rarbg.to:2920/announce", + "udp://tracker.opentrackr.org:1337", + "udp://tracker.internetwarriors.net:1337/announce", + "udp://tracker.leechers-paradise.org:6969/announce", + "udp://tracker.pirateparty.gr:6969/announce", + "udp://tracker.cyberia.is:6969/announce" + ).map { it.encodeURLQueryComponent() } + + override suspend fun search(query: String, category: Category, limit: Int): List { + val categoryString = categories[category] + + if (query.isBlank() || categoryString.isNullOrBlank()) { + return emptyList() + } + val response = httpClient.get { + url { + takeFrom(baseUrl) + takeFrom( + searchPath + .replace("{query}", query.encodeURLQueryComponent()) + .replace("{category}", categoryString) + ) + } + } + + return if (response.status == HttpStatusCode.OK) { + val torrents = response.call.receive() + val noResults = torrents.singleOrNull() + ?.jsonObject + ?.get("info_hash") + ?.jsonPrimitive + ?.content + ?.all { it == '0' } ?: false + if (noResults) { + emptyList() + } else { + torrents.map { element -> + val torrentName = element.jsonObject["name"]?.jsonPrimitive?.content ?: "" + TorrentDescription( + provider = name, + magnetUrl = formatMagnet( + name = torrentName, + infoHash = checkNotNull(element.jsonObject["info_hash"]).jsonPrimitive.content + ), + title = torrentName, + size = element.jsonObject["size"]?.jsonPrimitive?.long ?: -1, + seeds = element.jsonObject["seeders"]?.jsonPrimitive?.int ?: -1, + peers = element.jsonObject["leechers"]?.jsonPrimitive?.int ?: -1, + ) + } + } + } else { + emptyList() + } + } + + private fun formatMagnet(infoHash: String, name: String): String { + val trackersQueryString = "&tr=${trackers.joinToString("&tr=")}" + return "magnet:?xt=urn:btih:${infoHash}&dn=${name.encodeURLQueryComponent()}${trackersQueryString}" + } +} diff --git a/torrent-search/src/main/kotlin/providers/RarbgProvider.kt b/torrent-search/src/main/kotlin/providers/RarbgProvider.kt new file mode 100644 index 00000000..5627d07a --- /dev/null +++ b/torrent-search/src/main/kotlin/providers/RarbgProvider.kt @@ -0,0 +1,155 @@ +/** + * AnyStream + * Copyright (C) 2021 Drew Carlson + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package drewcarlson.torrentsearch.providers + +import drewcarlson.torrentsearch.Category +import drewcarlson.torrentsearch.TorrentDescription +import drewcarlson.torrentsearch.TorrentProviderCache +import io.ktor.client.HttpClient +import io.ktor.client.request.get +import io.ktor.http.encodeURLParameter +import io.ktor.http.takeFrom +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.serialization.json.* + +private const val API_REQUEST_DELAY = 3000L + +internal class RarbgProvider( + private val httpClient: HttpClient, + private val providerCache: TorrentProviderCache? = null, + prefetchToken: Boolean = true, +) : BaseTorrentProvider() { + override val name = "Rarbg" + override val baseUrl = "https://torrentapi.org" + override val tokenPath = "/pubapi_v2.php?get_token=get_token&app_id=TorrentSearch" + override val searchPath = + "/pubapi_v2.php?app_id=TorrentSearch&search_string={query}&category={category}&mode=search&format=json_extended&sort=seeders&limit={limit}&token={token}" + override val categories = mapOf( + Category.ALL to "1;4;14;15;16;17;21;22;42;18;19;41;27;28;29;30;31;32;40;23;24;25;26;33;34;43;44;45;46;47;48;49;50;51;52", + Category.MOVIES to "14;17;42;44;45;46;47;48;50;51;52", + Category.XXX to "1;4", + Category.GAMES to "1;27;28;29;30;31;32;40", + Category.TV to "1;18;41;49", + Category.MUSIC to "1;23;24;25;26", + Category.APPS to "1;33;34;43", + Category.BOOKS to "35" + ) + + private val mutex = Mutex() + private var token: String? = null + + init { + if (prefetchToken) { + // Prefetch token + launch { readToken() } + } + } + + override suspend fun search(query: String, category: Category, limit: Int): List { + val categoryString = categories[category] + + if (query.isBlank() || categoryString.isNullOrBlank()) { + return emptyList() + } + + val result = fetchSearchResults( + encodedQuery = query.encodeURLParameter(), + categoryString = categoryString, + tokenString = readToken() ?: "", + limit = limit + ) + + launch { + mutex.withLock { delay(API_REQUEST_DELAY) } + } + + val errorCode = result["error_code"]?.jsonPrimitive?.intOrNull + return if (errorCode == null) { + result["torrent_results"]!! + .jsonArray + .map { it.asTorrentDescription() } + } else { + // TODO: Handle error codes + // - 20: No results + emptyList() + } + } + + private suspend fun fetchSearchResults( + encodedQuery: String, + categoryString: String, + tokenString: String, + limit: Int + ): JsonObject = mutex.withLock { + httpClient.get { + url { + takeFrom(baseUrl) + takeFrom( + searchPath + .replace("{query}", encodedQuery) + .replace("{category}", categoryString) + .replace("{token}", tokenString) + .replace("{limit}", limit.toString()) + ) + } + } + } + + private suspend fun fetchToken(): String { + return httpClient.get { + url { + takeFrom(baseUrl) + takeFrom(tokenPath) + } + }["token"]!! + .jsonPrimitive + .content + } + + internal suspend fun readToken(): String? { + if (token == null) { + token = mutex.withLock { + token ?: providerCache?.loadToken(this) ?: try { + fetchToken().also { token -> + providerCache?.saveToken(this, token) + delay(API_REQUEST_DELAY) + } + } catch (e: Exception) { + e.printStackTrace() + null + } + } + } + + return token + } + + private fun JsonElement.asTorrentDescription(): TorrentDescription { + return TorrentDescription( + provider = name, + magnetUrl = jsonObject["download"]!!.jsonPrimitive.content, + title = jsonObject["title"]!!.jsonPrimitive.content, + seeds = jsonObject["seeders"]!!.jsonPrimitive.int, + peers = jsonObject["leechers"]!!.jsonPrimitive.int, + size = jsonObject["size"]!!.jsonPrimitive.long + ) + } +}