pax_global_header00006660000000000000000000000064150745104220014512gustar00rootroot0000000000000052 comment=19592ce92cc662139bdcc69f2638163a352181cd tremc-tremc-19592ce/000077500000000000000000000000001507451042200142775ustar00rootroot00000000000000tremc-tremc-19592ce/COPYING000066400000000000000000001045131507451042200153360ustar00rootroot00000000000000 GNU GENERAL PUBLIC LICENSE Version 3, 29 June 2007 Copyright (C) 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Preamble The GNU General Public License is a free, copyleft license for software and other kinds of works. The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Public License for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others. For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights. Developers that use the GNU GPL protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute and/or modify it. For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software. For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems will not be attributed erroneously to authors of previous versions. Some devices are designed to deny users access to install or run modified versions of the software inside them, although the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those products. If such problems arise substantially in other domains, we stand ready to extend this provision to those domains in future versions of the GPL, as needed to protect the freedom of users. Finally, every program is threatened constantly by software patents. States should not allow patents to restrict development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that patents cannot be used to render the program non-free. The precise terms and conditions for copying, distribution and modification follow. TERMS AND CONDITIONS 0. Definitions. "This License" refers to version 3 of the GNU General Public License. "Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. "The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations. To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work. A "covered work" means either the unmodified Program or a work based on the Program. To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. 1. Source Code. The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means any non-source form of a work. A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A "Major Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work. The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. The Corresponding Source for a work in source code form is that same work. 2. Basic Permissions. All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. 3. Protecting Users' Legal Rights From Anti-Circumvention Law. No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. 4. Conveying Verbatim Copies. You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. 5. Conveying Modified Source Versions. You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: a) The work must carry prominent notices stating that you modified it, and giving a relevant date. b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to "keep intact all notices". c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. 6. Conveying Non-Source Forms. You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge. c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements. e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work. A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, "normally used" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. "Installation Information" for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. 7. Additional Terms. "Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or d) Limiting the use for publicity purposes of names of licensors or authors of the material; or e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors. All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. 8. Termination. You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. 9. Acceptance Not Required for Having Copies. You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. 10. Automatic Licensing of Downstream Recipients. Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. 11. Patents. A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's "contributor version". A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. "Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. 12. No Surrender of Others' Freedom. If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. 13. Use with the GNU Affero General Public License. Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply to the combination as such. 14. Revised Versions of this License. The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU General Public License, you may choose any version ever published by the Free Software Foundation. If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. 15. Disclaimer of Warranty. THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 16. Limitation of Liability. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. 17. Interpretation of Sections 15 and 16. If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee. END OF TERMS AND CONDITIONS How to Apply These Terms to Your New Programs If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. Copyright (C) This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . Also add information on how to contact you by electronic and paper mail. If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode: Copyright (C) This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details. The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, your program's commands might be different; for a GUI interface, you would use an "about box". You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU GPL, see . The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read . tremc-tremc-19592ce/Makefile000066400000000000000000000010631507451042200157370ustar00rootroot00000000000000.PHONY: install install: install -d "${DESTDIR}${PREFIX}/share/man/man1" install -m 644 tremc.1 "${DESTDIR}${PREFIX}/share/man/man1/tremc.1" install -d "${DESTDIR}${PREFIX}/bin" install -m 755 tremc "${DESTDIR}${PREFIX}/bin/tremc" install -d "${DESTDIR}${PREFIX}/share/bash-completion/completions" install -m 644 "completion/bash/tremc.sh" "${DESTDIR}${PREFIX}/share/bash-completion/completions/tremc" install -d "${DESTDIR}${PREFIX}/share/zsh/site-functions/" install -m 644 "completion/zsh/_tremc" "${DESTDIR}${PREFIX}/share/zsh/site-functions/_tremc" tremc-tremc-19592ce/NEWS000066400000000000000000000024131507451042200147760ustar00rootroot000000000000000.9.0 2016-10-09 - Arbitrary version to start new python3 fork - The chunks screen can sometimes cause a segfault 0.9.4 2025-07-00 - Work with RPC version 17 (transmission 4) - Support transmission bandwidth groups - Make color of more elements configurable, and allow configuring attributes, as well as colors - tremc can now save config file, while retaining its structure and comments. Saving can be done manually, or automatically on exit (configurable) - Tab cycles through possible choices in many text input fields - New torrent list filters: bandwidth group, part wanted, error and location including subdirectories - New torrent list sort orders: last activity, time left, time done - New file sort order: priority - Narrow screen mode more useful and configurable - Many bugs fixed - UI improvements 0.9.5 2025-09-07 - Work with RPC version 18 (future transmission 4.1) - Support sequential downloads (requires transmission 4.1) - Prompt for password if it is needed and not supplied in command line or config file - Allow scrolling of torrent list without changing focused torrent - Update bash and zsh completions - Fix crashing bugs in config file creation and debug code - Update documentation tremc-tremc-19592ce/README.md000066400000000000000000000410501507451042200155560ustar00rootroot00000000000000## About A console client for the BitTorrent client [Transmission](http://www.transmissionbt.com/ "Transmission Homepage"). `tremc` is the python3 fork of [transmission-remote-cli](https://github.com/fagga/transmission-remote-cli). ## Requirements Python 3.2 ### Optional Modules - python-GeoIP or python-GeoIP2: Guess which country peers come from. - [python-pyperclip](https://pypi.org/project/pyperclip/): Copy magnet links to the system clipboard. You will also need either xclip on Linux or pbcopy on OS X for this to work. ## Usage ### Connection information Authentication and host/port can be set via command line with one of these patterns: `$ tremc -c homeserver` `$ tremc -c homeserver:1234` `$ tremc -c johndoe:secretbirthday@homeserver` `$ tremc -c johndoe:secretbirthday@homeserver:1234` You can write this (and other) stuff into a configuration file: `$ tremc -c johndoe:secretbirthday@homeserver:1234 --create-config` No configuration file is created automatically. You must create it with the `--create-config` option. If you don't like the default configuration file path ~/.config/tremc/settings.cfg, change it: `$ tremc -f ~/.tremc --create-config` ### Command line options * **`--version`** Show version number and exit * **`-h --help`** Show usage information and a list of options * **`-s --ssl`** Use SSL to connect to the server. Default: don't use SSL * **`--create-config`** Create configuration file with default values. *NOTE:* A config file won't be created unless you provide this option at least once. * **`-f CONFIGFILE --config=CONFIGFILE`** Set path to configuration file. If not creating a config file, and CONFIGFILE does not exist (and contains no slashes), the config directory is also searched for CONFIGFILE or CONFIGFILE.cfg. Default: ~/.config/tremc/settings.cfg * **`-l, --list-actions`** List available actions for key mapping. * **`-k, --list-keys`** List key names for key mapping. * **`-n --netrc`** Get authentication info from ~/.netrc. * **`-X, --skip-version-check, --permissive`** Proceed even if the running transmission daemon seems incompatible, or the terminal is too small. * **`-p PROFILE --profile PROFILE`** Select profile to use. * **`-r --reverse-dns`** Toggle display of reverse DNS of peers addresses. Default: off, but may be set in the config file. * **`-d [LOGFILE] --debug [LOGFILE]`** Enable debugging messages to stderr, or to LOGFILE if provided. ### Main user interface `tremc` has two display modes: torrents list view and torrent details view. In details view there are five tabs: overview, files, peers, trackers and chunks. In list view, as well as in the files and trackers one item may be focused. In list view and the files tab, a set of items may also be selected. `tremc` is controlled by the keyboard. In the following list of key bindings case matters and ^ is used for the Control key. #### Keys that work in both modes: * Vertical movement keys: * Up, k, ^p : move one line up * Down, k, ^n : move one line down * PageUp, ^b : move one screen up * PageDown, ^f : move one screen down * Home, g : move to top * End, G : move to bottom In list view, files tab and trackers tab, the focused item is moved, and the display scrolled to keep the focused item viewable. In the other tabs, only the display is scrolled if there is more than one screen to display. * ?, F1 : Display help window * ^w : Quit `tremc` immediately * X : Send the quit command to the daemon * S : Show session statistics * O : Show `tremc` information and options * M : Copy magnet link to clipboard (if the pyperclip module is available) * B : Set labels (labels require transmission 3.0 or later) + * b : Add label + * ^l : Remove label + * F : Rename focused file (or torrent, if no file is focused) * N : Start torrent now + * p : Pause/unpause torrent + * P : Pause/unpause all torrents * n : Reannounce torrent + * v, y : Verify torrent + * \- : Decrease torrent priority + * \+ : Increase torrent priority + * \* : Toggle torrent's honors session limits flag + * D : Modify torrent download bandwidth limit + * U : Modify torrent upload bandwidth limit + * d : Modify global download bandwidth limit * u : Modify global upload bandwidth limit * L : Set seed ratio of torrent + * m : Move torrent to another directory + * R, Shift+Del : Remove torrent and delete its data * ` : Open menu of actions with not keyboard bindings An action marked with + acts on: * Viewed torrent in details mode * Selected torrents in list mode * Focused torrent in list mode if no torrents are selected #### Torrent details mode keys * Esc : Unfocus file if a file is focused, otherwise return to torrents list mode * q, Backspace : Return to torrents list mode * Enter : View file * | : Enter command to view file * x : Show file information window * J : Move to next directory in file list * K : Move to previous directory in file list * Space : Select/unselect file and move to next file * a : In files tab: select/unselect all files. In trackers tab: add a tracker to torrent * A : Select/unselect all files in directory * V : Visually select files * , : Select files matching text * < : Select files matching regular expression * i : Invert selection * C : Rename torrent directory containing focused file. Will not rename top directory. * r, Del : Remove tracker * Tab : Move to next tab * Shift-Tab : Move to previous tab * Right,l : Increase priority of focused or selected files. Move to next tab if no file is selected or focused. * Left,h : Decrease priority of focused or selected files. Move to previous tab if no file is selected or focused. * o : Move to overview tab * f : Move to files tab * e : Move to peers tab * t : Move to trackers tab * c : Move to chunks tab * / : Search for files matching pattern * . : Search for files matching regular expression * s : File sort menu #### Torrents list mode keys * q : Quit `tremc` * Esc : Unfocus torrent * Enter, Right, l : Enter torrent details mode for current torrent * o : Server options dialog * Space : Select/unselect torrent and move to next torrent * A : Select/unselect all torrents * , : Select torrents matching text * < : Select torrents matching regular expression * i : Invert selection * s : Torrent sort order menu * f : Torrent filter menu * T : Add torrent filter * I : Edit torrent filters * ~ : Invert filters action * t : Toggle turtle mode * J : Move torrent down in queue * K : Move torrent up in queue * r, Del : Remove torrent, keeping data + * ^r : Remove selected torrents, keeping data * e : Profile selection menu * C : Change between 1,2,3 lines per torrent modes * a : Add torrent dialog * ^a : Add torrent (in paused mode) dialog * / : Search for torrents matching pattern * . : Search for torrents matching regular expression ### Dialog windows Various dialog windows can be opened While using the program. At any time, ^w quits the program. In all text input dialogs, edit text with Left, Right, Home, End, Backspace, Del. * Information windows (session statistics, key bindings): * Escape closes the windows * Space shows next page if there is more to display. * Confirmation windows Press y/n to accept or cancel. Left/Right/Tab/Shift-Tab move highlight, Enter accepts highlighted choice. * Options window Select option to modify by pressing the highlighted letter. Esc to close window. * Numeric input Use numeric keys (and period for non integer values). Use Left/h (Right/l) to decrease (increase) the number by 10. Use Down/j (Up/k) to decrease (increase) the number by 100. For setting seed ratios, the small step is 0.1 (instead of 10) and the large step is 1 (instead of 100). * Search window: Enter pattern or regular expression to search. Search happens while typing. If none found, the input line shows this by changing color. Keys: * Esc closes the window. * Tab completes typed pattern by searched items (files/torrents). * Up/Down cycle through history. * Enter moves focus to next match. * ^r moves focus to previous match. * Selection window: Enter pattern or regular expression to search. Then * Esc closes the window without modifying selection. * Tab completes typed pattern by searched items (files/torrents). * Up/Down cycle through history. * Enter selects matching files/torrents. * ^r adds matching files/torrents to selection. * ^t selects matching files/torrents only from currently selected. * Sort menu Select sorting key by pressing the highlighted letter, or moving the selection with Up/Down and pressing Enter. Pressing the capital letter or pressing Backspace after highlighting the desired option select reversed filter (satisfied if the condition is not met). Selecting reverse reverses the current sorting order. Pressing Esc leaves sort order unchanged. * Filter menu Select filter by pressing the highlighted letter, or moving the selection with Up/Down and pressing Enter. Pressing the capital letter or pressing Backspace after highlighting the desired option select reversed filter (satisfied if the condition is not met). Some filters open further window to enter or select the filter parameter. Selecting reverse reverses the current sorting order. Pressing Esc leaves filter unchanged. * Filters lists edit Filtering is done by a list of sublists of filters. For an item to satisfy the list, it has to satisfies all filters in at least one sublist (DNF). Move the cursor by using the arrow keys, d to remove marked filter, f to edit marked filter (including empty one). Enter to accept modified list, Esc to leave window without modifying list of filters. ## Configuration file By default the configuration file is called settings.cfg in the XDG configuration directory for `tremc`. With default XDG configuration, this will be `~/.config/tremc/settings.cfg`. The configuration file is in .ini format (section names in square brackets, key = value, comments start with # or ;). The following sections are read: * [Connection] Keys are username, password, host, port, path, ssl (boolean). * [Sorting] The key 'order' determines torrents sort order. Possible values are: name, addedDate, percentDone, seeders, leechers, sizeWhenDone, status, uploadedEver, rateUpload, rateDownload, uploadRatio, peersConnected, downloadDir, mainTrackerDomain. prefix with `:` for reverse order. * [Filtering] Keys are invert (boolean) and filter with possible values: uploading, downloading, active, paused, seeding, incomplete, verifying, private, isolated, tracker, regex, location, selected, honors, label. The filters tracker, regex, location, label need a parameter. The parameter is provided by using `#=` as a delimiter, see [Profile] section for an example. * [Misc] Keys are * lines_per_torrent, value between 1 and 3. * torrentname_is_progressbar (boolean). * file_viewer, name of program to run for viewing a file. The string %%s is replaced by the file name. * file_open_in_terminal (boolean). * rdns (boolean), the value True enables showing the reverse DNS of connected peers. This requires the `threading` module. * geoip2_database, The location of the Python-GeoIP2 database file. If this key does not exist, or does not point to a file, the database is also searched for in some commonly used locations. * cancel, List of keys that act as cancel key in dialog windows. Printable characters are allowed, but act as cancel key only when not entering text. Default is Escape, Break and q. * x_selection, on Linux, set to `primary` to copy magnet links to S selection instead of the clipboard. Default is `clipboard`. * save_conf when enabled `tremc` save the config file on exit. Default: False. * [Colors] Allows for setting (some of) the interface colors. The format of a color is ` = [,][fg:,][bg:,][a:]`, where - `` is another element that was already defined. Its color is copied to ``, modified the by ``, ``, ``. - Each `` is one of the eight curses colors, or `default`. - `` is a string of characters from 'rbikdu' for reverse, bold, italic, blink, dim, underlind. Each character may be prefixed by `\*` or `-`. Unprefixed sets the attribute, `-` resets it, and `\*` toggles. Allowed elements are 'title_seed', 'title_download', 'title_idle', 'title_verify', 'title_paused', 'title_paused_done', 'title_error', 'title_seed_incomp', 'title_download_incomp', 'title_idle_incomp', 'title_verify_incomp', 'title_paused_incomp', 'title_paused_done_incomp', 'title_error_incomp', 'download_rate', 'upload_rate', 'eta+ratio', 'filter_status', 'sort_status', 'multi_filter_status', 'dialog', 'dialog_important', 'dialog_text', 'dialog_text_important', 'menu_focused', 'file_prio_high', 'file_prio_normal', 'file_prio_low', 'file_prio_off', 'top_line', 'bottom_line', 'chunk_have', 'chunk_dont_have'. Names for elements in torrent list may also be prefixed by `st_` for the attributes of the element when selected. The default is reversed. Names for elements in file list may bu suffixes by _f, _s, or _f_s, for focused, selected, or focused and selected. Note that what the colors mean actually depends on the terminal. In some cases 'white' is darker then the white that the terminal displays, and similarly, 'black' is lighter. Setting 'default' selects the respective background or foreground color of the terminal. The top and bottom status lines (filter_status, multi_filter_status) and the dialog window (dialog, dialog_important) are displayed using inverse mode, so the fg and bg are exchanged. * [Profiles] The key is `profile`. The value is `#=` `` is the name of the torrent sort order, preceded by : for reversed order. `` is ` #& ... #& ` (the separators are space, hash, ampersand, space) Each is of the format `#=#=...#=#=` `` is the name of a torrent filter, preceded by : for inverted. `` is the parameter of the filter if needed, it is ignored otherwise. `` may be empty, but the separators must appear. A torrent satisfies a list of filters if for at least one of the ``, it satisfies each ``. For example: `profilet1 = tracker#=torrent.ubuntu.com#=name` `profilexyz = tracker#=torrent.ubuntu.com#=incomplete#= #& :regex#=ubuntu#=sizeWhenDone` The profile `t1` shows only torrents with tracker torrent.ubuntu.com, sorted by name. The profile `xyz` shows torrents which are either with tracker torrent.ubuntu.com and incomplete, or with a name that does not contain the string ubuntu (case insensitive). The torrents are sorted by size. * [CommonKeys], [DetailsKeys], [ListKeys] Map keys to actions. ListKeys section is for keys pressed in torrent list display, DetailsKeys for torrent details display, and CommonKeys for both. The format is key = action. A list of action is available by running `tremc -l`. Key names are: letters (case sensitive), a_ for ^a, etc. Symbols (comma, semicolon, etc.) are denoted by their name. See list by running `tremc -k`. ## Calling transmission-remote tremc forwards all arguments after '--' to transmission-remote. This is useful if your daemon requires authentication or doesn't listen on the default localhost:9091. tremc reads HOST:PORT and authentication from the config file and forwards them to transmission-remote, along with your arguments. Some examples: `$ tremc -- -l` `$ tremc -- -t 2 -i` `$ tremc -- -as` ### Add torrents If you provide only one command line argument and it doesn't start with '-', it is treated as a torrent file/URL and submitted to the daemon via transmission-remote. This is useful because you can instruct Firefox to open torrent files with tremc. `$ tremc http://link/to/file.torrent` `$ tremc path/to/some/torrent-file` ## Installing `tremc` does not need installation. The only file necessary is the file `tremc` itself. To run the latest git version run something like: ``` $ wget https://github.com/tremc/tremc/raw/refs/heads/master/tremc $ python ./tremc ``` In order to be able to run it by the simple command `tremc`, you may want to copy it to some directory $PATH, and fix the first line to ensure it points to a python3 interpreter. ## Screenshots ![Main window - full](screenshots/screenshot-mainfull-v1.3.png) ![Main window - compact](screenshots/screenshot-maincompact-v1.3.png) ![Details window](screenshots/tremc-details-20171214.png) ## Copyright Released under the GPLv3 license, see [COPYING](COPYING) for details. ## Contact Feel free to request new features or provide bug reports. https://github.com/tremc/tremc tremc-tremc-19592ce/completion/000077500000000000000000000000001507451042200164505ustar00rootroot00000000000000tremc-tremc-19592ce/completion/bash/000077500000000000000000000000001507451042200173655ustar00rootroot00000000000000tremc-tremc-19592ce/completion/bash/tremc.sh000066400000000000000000000015751507451042200210430ustar00rootroot00000000000000# bash completion for tremc(1) -*- shell-script -*- _tremc () { local cur prev opts _get_comp_words_by_ref cur prev opts="-h --help -v --version -c --connect -s --ssl -f --config --create-config -n --netrc -d --debug -k --list-keys -l --list-actions -X --skip-version-check --permissive -p --profile -r --reverse-dns" if [[ ${cur} == -* ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- ${cur}) ) else case "${prev}" in -c|--connect) # no completion, wait for user input ;; -p|--profile) COMPREPLY=($( cat ~/.config/tremc/settings.cfg | grep ^profile"$cur" | cut -d' ' -f 1 | sed s/^profile// )) ;; -f|--config) # dirs and files _filedir ;; *) # dirs and torrents _filedir torrent ;; esac fi } complete -F _tremc tremc # ex: ts=4 sw=4 et filetype=sh tremc-tremc-19592ce/completion/zsh/000077500000000000000000000000001507451042200172545ustar00rootroot00000000000000tremc-tremc-19592ce/completion/zsh/_tremc000066400000000000000000000015651507451042200204570ustar00rootroot00000000000000#compdef tremc _arguments \ {-v,--version}'[Show version number and exit]' \ {-h,--help}'[Show usage information and a list of options]' \ {-c,--connect}'[Point to the server]: :_hosts' \ {-s,--ssl}'[Use SSL to connect to the server]' \ '--create-config[Create default configuration file]' \ {-f,--config}'[Set path to configuration file]: :_files' \ {-n,--netrc}'[Get authentication info from ~/.netrc.]' \ {-l,--list-actions}'[List available actions for key mapping]' \ {-k,--list-keys}'[List available key names for key mapping]' \ {-X,--skip-version-check,--permissive}'[Proceed even if the running transmission daemon seems incompatible, or the terminal is too small]' \ {-p,--profile}'[Select profile to use]' \ {-r,--reverse-dns}'[Toggle reverse DNS peers addresses'] \ {-d,--debug}'[Enable debugging messages]' \ '*::torrent:_files -g "*.torrent"' tremc-tremc-19592ce/screenshots/000077500000000000000000000000001507451042200166375ustar00rootroot00000000000000tremc-tremc-19592ce/screenshots/screenshot-maincompact-v1.3.png000066400000000000000000001643651507451042200245170ustar00rootroot00000000000000PNG  IHDR [IsLbKGD pHYs  tIME 5RIiTXtCommentCreated with GIMPd.e IDATxw\TWg&hbhL1i&)M745h{@DQ@^fys! >'џ {ι)saDDDDDDDtsלDDDDDDDwfe` Ȫ / obp.`RpkH5*I> Z*4"u`P 1`mzb\f nC`EGj!oxV!_oEZ(\^<8WV7_t3meS}? -=}.CɆ:ҖΆXCSހbNwǖ[m-_LH]"Gw?h$ҐոNjunRw_ϝ a0橼xUSwAW'BxaqK>MDĿ,>S_<E\b6#0Jj`''1mװH7ьp-jQ{4\<>{ ?`~[}ݮ-&Mp [7 گN>"|Atf1ߝ9ۗBȾ1]a;>/÷wGq!npwBnv9chG<'D>kKVt_H9iwȭx:᏾xjKx+?:\QlN@,z7 D{sҵ='c."vO-ZWoioĢN?/Xр84BHGPf%N# o?L^^mn<,z۾‰BI_ޮ) 0$)\츰ԾxhC>!g) `! Fq1dt㋟Yu)IGf>m[w@hY*צ 03 0x},KD| 6ti*>gva}<a0qf~OM٦ Ģ|t4xEo̜$|uyxR ? BCxn TYjKSń6䳍as}6`|rHF3H-l\v 3|@Ecs!/"}V/O!߁ /"ۤ<ٶ1&`.-Zm!_IPzbkgmx?Vfkpl2.[MVgr ob/$#5<캊ϕS_x$_LB± X8= I+m6&[9_-C W#qƙEFkUl&$}8t B|> ._L'Cx%ln#/N SZ܃vCȽ:m=^326< o&?6ԅ :YrV6峍ak}9U,^S!R͘\_Dxo(F/y$"Ө2=z Y^vKkq9~4\Oǜ0|خ_ }9'NDAW ǡoװ'>U-ȋߋj cH+Qjgl?۟l~޸]cgFLY~ bS`\Y{z(N>tu\Ql}^M=\ެ+z5k wg4 =#BbPaG l ogtĤWE3qJ.%# z{ V.7nV1;Egk E^"2նٺ]5gӅ(H.f]A8gT6EO d_mW(õ2"ּ={3}+T?6䳭REZ}k//Pb*BƉ՘Jx-Fupwu,e(3[quXRE|f>&~nh_}x#MutU][￶҇axqfdنkm#T_W牨a\uWVၮc]Fb\saQ\ّϊ[g\r;i}1o㑮⎦`;pz jReEx/uTsmꕣ":dkyԗ]uݭC)08 Nrװf09"_lymԍSnFkԗr8}s8Ũ Sw$B|@{C{̽X2{ݐ) '\ѻ[ (+C ;8vҷyGHMI嗪oazp}#uUzlX#ngvuҷ'edxlTnQ/xpi=]y݂b~4ƶvWfq8~y[667ұ[p“a~4ƶžGx'w{L'&ݟ"!9z2ў7hĩ'v[S"Q!{c[qU_[jqu*ϱl?UY`WR䦦݆|%#_;7}_cNE\m :2^2O(t*#'4'-婂ơk(upUw1ۄn~88\Z@TɎW+ Ir5]?9Q!yZ0ّϿ}b]]gp,3u,2^)"UydXqCQ_vruұ;"r?|'k/{ﱾ]cwl Pر߭r8}}s[y!0 |7R^pEs&Тsr./|(6l=͍dGgQط`Huu?䗊]g 0qEUA.u^t&ukm5Ej;'-E[ՇWl> CjM)_VSҺeĶe30ܸ"Dyxkk*^jצĈ-[aZL>зF}1ïPsGްZnWuy [ᰦ;&61$"~Y!vGSX* ,98r@smY9zQQ"vDŽ;Rpv7.tGKڦqK2̀WE=(N:\1)|VU*z|u^΍`,ŵ208Qyԋd WO} =o~J/O]fXx> gO @ڇþOW]XYޞ alY ǫE{bͼH A䈿Phw)Oɶ3^sAXOSSuaAFx'G_֑1zwfG*)0"qߐzl|m,:UZz!=[{Mom ڏ |O;x~iq8yhY1a|Gtռv}xA!?QXzykjJf}~:R£Nbj4BC=m,OI{~<01Kv xΛlg[땍sh/jv5l7//M =:9\i] ]+7C+tᚏKGʹh6rz3wߘj`h̅)|3э+yx(c0C3x:k/DDD Y^ &OKGlC8/x}zϧr 1{,"S i9'6.FDDDDDt7,ǘ1cq#$ """""3)>(=L?Y7<ԩHpV/J`)N ,6I<)>/š ,?Hn))&)%1RP?i\rQ_e sy\Q)loB~ T(_4W^*Է!Q닯s{q;s~"""""BDDDDDX>~/a2)\vVd;s%>ٟn@n).jxC`jD"&L(Y{+-A)0 l0 =;U IAvP90Ym~(~9*|+B` %).Uק/B||8/ ;R'N ;5}reO-\0yzW;%E%'Iqc)<]|X%~lc9="R, K!?URbyFLcK_ݿn@DDDDDDDH@DDDDDDD4 h゘DDDDDT 'T㇪׍Y`1j}@$""""|kD6Q y{X^w\^;U[Jv)v$*gby3RpJqR/W cBx!"""$]WvHI>Qh`X{@@Ftf;8 [/cZ/t0!76-[d &ǢΘ:i2潧Z.[IbkQE#'oM`_C7 6Eɞұ᭙`S#@,̜y[Jڒ YGWaU5~Jc.L6.a)Δwy"CyQ`4lՍY`1c4XG pA J{b 󅈈0$p|!""""""5n65KcnQ,P3p D"""""""*Wp/%Mpm۶kqqqbz℉qs{d ?"{3RWb**~glJ~wt˹* b:n;vP_B!u\GlNO/>{j(+#G4x|Ub)s9~wjF#'9g_)/#z皟78ShѕB|>K1 qwBg&uFU׫$!(ЈCXyj;GO&^y,LGL2:g Q]ȋԈԍÃBa0{2\Q1/Jqc_5k[{]dUնJu,7_E`0 σxcu<-<- W42Oo|d ,y_x}ZBsFpvDDDDDTkŧ`yKwbQ\I eBں^xy]0MU+-h)8w||ax;>美=wVS3^Y:k :3)_wҺiIbIBܯg=*W-btUT7(uܷ{x q5O]'44T+W,Dx$;abjܿT!v7*\C{wqf~c3ţ}2/UxFN>P-BܿS-oFX?F#s7!2JFBrYE q2{ؽ?R\2IyIwBTU{~X{IϺ닯XW.RRox>]*t~ٹ*Bq=ЈBbch󭝷x8O^]<s%W<#sw<ԫkx?TmWźwh R%9K .hIwx|\~[ T#U Z?˞j"v!Uz:wbeLy- }?<s~Ž|Hﯢ#=*$xtt`+ZMYw`pih\ <pe&\*FZ!,"""""c&s}J#L#9GF|fE ȕҍuӮݑ7G|R_ +x;rΣȢH +܈N ef 4 ᘅvÄ \ %"PVWxOXo0`0 h:bWm(pi...aϱȩf~jOߊq]]@_9/54! q@q9iBrM89 jJ'XEqLnY%W3H[.>G= Bo!.Nb'y Eq_p8fUx%W\2{_qɪ3Top8F7"ܮ)LtC%ΑѺe uԈ޽8&q̭bx֙ r8[y1E8'-O*c!Mx?w 'η^5oVAUI[ZckSw.*Ǽ{UZc6mHyo_!>}T<_thg Yw!wQ8ʵoS_1{JלY+~#xBc@sRu@`1\W:~Y33%Bs|%Ym8#5{&~~;B=OU돸A1m:s;uXݚox&-{8'5Feh $S|[KF0TJ[ bvn J^}>qQRTW eVNi]AΈ’2-Ĝeayrh[Wb^ N>}CʻK'UctΆLCJ/>N=Xfm, cԊzԈ2q㙜ODDDDD @u$ `~|ߟ b^"7:5±ZZO C+x׫j ;Y`DDDDD@-ѿ}lM4a)["CE76ӱyw,{v1N׺sTaܱmruW B?N) s=:X,93g.@l1 y!zփ,$#$[쏁]7zB~x E4{Ta_ǀw6tF!L۵)冂 ת gǬ!߶mbytM:S멲o Ej_o٢O|3k,~}OZ-ۉsl;+ quqL_z8X 1JҠB8°y)5g7,]Rbt=")A?S"5Q\&^Dח G~B{\"|@!?|%^Gx=X]<xtNb~1&>5֗`qN+p*A~xtwK1?Ú祉sJF"""""9O@uǜ3aj̅)|Q=RtSW/fGi85> w]Dv`000f̘q$ȋDZtQn+Rw[y|Tޕ>R/R|F[JҺyR^̡TII݌#E)()6+YR ۤxK"[V<CIq#)*Cy!""" jW).Px by SάU C;Uy o~L8:&`X{4 :M϶ϱ'QCSVƄJ i0.\GC|;{i3`:Mϱ@wTz D&u\49ZW # p, q0&Eh#BVgپ]65t@ q/aO| (kP @d@6Mג @]bΊŕ$8&d)ǸKR,!o2) O^1A[c܇YWs/Rez +?KD)>(qRB~{K%Eag5/׉&"""9ъgj_ -ҿG*oϢS@5->܋5胉OȢ2)7z[+J."0c^_7k֢<HOvٓ,uzC j@`cm(U-\ 2NXf|ή&l. K5caE0FV{ (1Y_ŵqXRėj/馒*9.=۷F"""""MtiWMAECAۮaQVmŕ\Nzo+UJ`kz*+I@Q<[/)WZs[!>#q?)˭fj^썙O*?9Yf8@qr<2:aYG?ŵLOvٓqc82~Y|JAߧGImHܴУh#:C,ܔbUzoיNDDDDD`7ڣ Vh3Ny`V`}Rig* *- OV"""""9|.?\Q߫~8Q!7)%g:5FGGS WẃqN"""""n<_`r88f:h5e1ށeSfׄZyC$ڽoMNDDDDD E( %>wG ZhLCήWV^c%Xd 0q8W\@DDDDDD ;ކutwfߛem(X<"j8y. ܍L5)Aؽu7B׼>Ma̛ ąѰ0U&a'8)^Rb+-5Ku)So)((S8UlǛWs9cmޗFJ `C 4,5TP`3VIV`=mM(_3,Nُݫ>jBF""""""L['TL,RDHj* :St5@7Y nZg:7pW7 """""_|ޘiN󩭪80~n 2*,ł3+bNpo4զWYMGnF,eDDDDDD 'wPCA~L[qJW6಩>A_iѬ#FOa;R+ [h_@X/)["12R,)zyLvW9)P|I)*)T)9yODDDDTg9!RH[JA!:k(GAk $l=c&i!Sشl-~>%И S, ri$Ph$ȑb?<7q#AA /* BU6HksFMm#AFnxRҍF6= Å{!53Ao?H"""""788==s&lĒ&"""""""@Uٔ`4RE ?or{vzIqG(<JODDDDDDJ]DDDDD@2 j  8' '9%Iq?,f).G!!R+SX)""""Rx_+I ?g@0Hcѩk ?Qŧ1z7!1/v)j g11:!8_XX IDAT{C x6gV$ODDDDD E: YQ+kCAzh e0pb6[kvvVYJ_dWwg&GB-0xH$z#b`gaąX`Vتj f8,}})K֗tSI4f ۶zQNl%xc.DK!Nv6EBg;Q/m7a]&[Ү]tZEA. 7Im0 rնbB\pk KA"b>~ 1ΟK5H0J1#R[%DR%R}R,CL(RO)vR).`)Δb/Dx+OOPs%>="""">)W&Ǥx{I/0_fX?+ϒOD?ಚ{c&Ⓤ0~@ξPLy>Np‘-@h™#Wk y7 ܂ڡ4Uʛ V"""""""2~Y|JAߧGImHܴУh#:C,ܔX;dEB$lދk0`p!hʞ=Hͺa:@gd슞^:@DDDDDDTo *M^EK1ud{Oӵl]Jkʼnpy7 d!nq5\= _.Y=W˄+ 4unf= oCɞL: Rhq,̜N`ĕbo>W]>csa <9; 7Cf=ƌ cݷURl$ """""""$8 """"RDj'ܓ>R;)x^CnoM *+QC?GV}yRTe}Oy+ V_~o 秣wzzMI .RO'Q|(#"" |' p/s xbcs!?Fx1ŶvT:k7@`mq]޸:qu0^M5+hj?{EcW$ kgxޭ,*-@l1 J\ e/7`Ğd =m5V_pkY,^R_?K)\OsHDw /G Hͽ>+'ʟ8QMbKmpC舁h"9dbB8"|rŪ ӧ Kض%׋۶}GsQr_9q!QC7/~`D}di|?+ zM- 2D kIDNezZ/4dXa7\C]P8j1v1QCk]j,Q@5->܋5胉OHm7z@rbY{x7U{RRzyhB3&^8f-\(ʌǁ n3©QC7ab,MV= pu28i`1]OQ' !hꍈSW8T)AXge4m.?@h;DӣUI! 201v%R,aTKe)n!š 'Og)O?"A 'Lj^b_))|^3Bs,7>P6 oQ|5 icԻ*?yy;Pȯ2Qxow}Q?*kP|Js`KKXjG%1?Rz*|}RHE5nm mXZP^mR@j((Q`;3JLtNЖa맡`*R|Ώ`?_IIGAQ[ϒOD?ಚ{c&Ⓤ!ZHξPLy>Nd ,N6#[P;4w548Y; eDDDDDD +!̗ӟ6$/O4i'2Gԣ)GtYع)Q71~RýY7LCX\1XǠ{/ܴh@yxT?d2d' B !0ز (bPh}}_[mR "(E]=! !}ΐ''纸ȝ9y,s=ϲ5%/C$7PgTY"}e 9Ϸ()O~мS|R~!"}jOHrDIJO|׃5FѬ̬]?XM+GF!f}ttܿn㫉Ѭfn|)5׫9gj7<5wR 4[Yҫ?/9!"j-r59Qz*geKfK`3̋r"A`W_YłXPqֿky`o yG{_Jk~xѭFzC1lc,N؀ g @ϱpEKp&SU-eJ'վ%{i.DDDDDDD&[QJ6W`Pw r'Nl۱݀Z'j4v7 """""""LQ5%rpG8Qrf9ozװ<ݣE|Qr^Q9oql-b9tf: qdzf&bݼuyrlMӜwQK"N_9^Fk6xi_yڠ9?xdXs]oE\9rtS & bo&ofyDDDw=$gE|~Nڡ<4[PsQtLw;ƍD`x'*|+{o?y<% ,zz!Jkk`LgސkQKR`V/G;^OhL}V>W{oGyu Y9hfO.`s@x+8XF{N8Jq8_Wi50@{!nh yI(jdGGj" ~#D䰑JQKk }.DqsZ7D.r| IyRݢZ(oLCncx3aMxoV4MEd[÷Qmuıp"m=>Wr5`p:W MYm#N8EĭDy=KS?GdXlsj1 .>]4'_}hp9wTF$} '_}CD|1hVY^O@0Jɮ/9@[8UE|@S~k1X}Fo2HiuޓO^zKS߾rLw|y}1xi;np_sx\4"+"ο+"%T4kk>1_.hRǍ r.A6ּ1Fb,=*'6Yy(mYeT4"E3`ٮx`'_61-<#}7q-,Չ+}j+`IN(pO{0?@ںx5=g@wKV|YZ XycxK1_ O """""j\`o|`PU5 AtK (G׏|^c81#lN1M|b벣M}nC浚X0yƉJp\8R2E6߅ȷ91@DDDDD̀b9u0 L{q*f/=['A0`b?X?_O)Uq !9"~@t3 2mCZuiIODDDDDx_ {k0)$ `oQw^=goͪI,y/W,YZ7Y_L?vJqw)X5I6;!"""""jjp.Qpno#srd wVo&[QJq5M"}CYEDDͦ_PXV3&N"cgos D"""""""DDDa8n=5I>{YDDDDhiiX tHdј5kӯ ]VqJ\ēƏRbݤr߬Z^oytU-;Tr՟z'w(9$ /`#gk-fZ5|k)O5ޫ\^~ ' """"jxL]ٽ|-ߣįB7[oVz/#Jy]J|0D_Vy X8_ !9oiBg1X,[(_6o=_ 幽pPœ-2 +Yދ:~v.Wسq 2**u1vμ{߅Yӆ!:ЂR|/ץO>6~G>Ƃ?B_/{+˚!r+f[4g=ʃ Gf7,-nFQTI\T9QQ|'bżXm,m"/;T\JE1hciuıpZǮVbEDDDDDͯ4{3ZcDk3޸V&T鍼@ʞp,6»fmw! ¢`.#G?^NlQ|MA ;_`-:by/|y ַۿ @ygwwOle<0qZ⅊Cd1rKc~$ɏ $d:(O=ק[%"p F } vTl*O&;K#Qit9GyPT ' բ wW׏ U6ӿ{#Kh`y.9c[⣭Ȫ'!N:EXcdXvshxfJq/XY ~ Qq5w+W?2Y.w(v)q39_q`ǭ% 3ҮөS էNfgGr ]S4u[}fg f DDRN;<y=g;@33L%{v<'nG~h<,WBGp)茣B=FsFu"QPݢl(`q(L}1*J<]7Yyn灔UK)ӰąQ"""""!kM[.[\z& %ؿ7ڿrFE=_=Rem Ρ[~nJΎGJN9H$ bmyVp?e hHy& lKxkb,~s^ј|Y& """""5w E^USZӊ੟XY.䋘 LvP{ˀOگΪr_{7`]Yާ= F/W S,ݪ @2uS0qI%s] F˳T ,8$'`ŋx o,;s{zNN` P0I7D=[ kbܢԻ"4-r ;AH:V85yJB"˾tEyAO"TMn\NB2º@yx7x`.'& 9 _4!w A^A7'<ߝy MV;|T\ء%CmYJJι.G_zlMݫr|1DDDD&?osv/8_ęyJbيGހ[ݬ3JY%SOsJ|ԻC5w_Tx*q.{Ϊo֚뎡}\e܁ i16lL9E\\Q'_`R>ْnH[ ~ɂ&H8^ۅ@waUGo6lNXoGO5Yqdx'߷5 ݉(6&"""" Ȍo| OM]S06yًX:y~$Z ~B?E~mlDB^R&JnU/NIJ%WCSj2rj_3Z ~xH T[??nigc"C_!N\aAˋG`ø#r"ߺ ͊:C0r=;-U_w h#Q#ժݺu SnW%e*ڌ(ͩ }G}ntg%񨷼c UD'V;vsw_mZQo_yQT%>X z^ݯc#âx0DkVmB;*qjz՟(MތʃөXxjIJn@ zy놼`3(DDDDDtcċOoAaqe+-u/?CBah$A`cO܊- ڣW7 ȵ]I"""""1xOy1~' jZ< RTϢ3ҐO !h7"p QvG3PQ[6!Odb݋S1{: ϯŅ[l{qx1/8+DDDDDtS "0믿A[ 8+_,ĠSŽ$F?1(e ]>ظ(bw"p~Y2 ϰ*H܅ۣpȼU n`IU:\9(qR7@{vLDDDD7xL]ٽ|Xt a%8aĉ-b;V~6DDDDDDDc]?ODtp)شqSN:ƎQb'&dvTS*,%9F7t QiJ5\O)qXD%v>K兇GDeq!& wj1'2rKWMyΫQ:{+=o>SqQ/Hv>|#gnGW ՟G  cw"""""""$]#>U.pvø}b} ޤ [c\Ӯʼ|`HLBBB5h^Z*? &w9gx1/}DDDDDt@gO`Tksy ?ت `l,ٜG=b[k$F7Oo)jfͻ!uQ׬/lӣ:_˥L%.T 4}x<rۥ0O)S8S7uN٬mU☮YJ|)M^lSW~9J\yG%>O. YJڿZ_PJ9\#un%U?Ի=?nToyclݛĹyg%Z?"""_P}콺Dn 312<7vMD_xq%3roqH_>Ok!4yz?CZWjeBm2u|H2r V~7lLj-0lIͳ)5Z@ DDDDDtU?`[X"4|"C_ ‚IqGEXuޛ -=#g߃HYux& FB1Oohk{SZH)؛}:Xy9 xkb+(0bn<*& fP/>=a?Ɖ&*jT#>GxL AHHBB8ܺ y|urI=.Twإĝ?%jEi甸ЦZNfz̖)qtDSܱmJ[V_Ǩä^u[o߫ T8pU뻇Zޅ@3&AQ~:\}w>gO')qXHg%-R~%"ke?OXD^J|mN$ """WEsz_v|}3y<z2NA"dRwWA>eǨ{1nRJZ_.EJx:FبQ>n/\qXfO.X ($Yz٤*X\9aK"""""A`.nQxoO'Xse8Zʺ0썈]$jQ="{K=U`2@lsLэ!'{r6:Op wzb{W0Ė /MgLy![hJp\̝ %LM˂|?1Enzx8Y\޲ )e} ^(Ht&?*naXxH\Zxnv2B4Ɏ=xJ)W׾}:Bcsy%6p)oڧWn%.⭄1ѡk"={}~uP/_T7J|X8P}F!3& SV q18)Gy kYʋ-Z=>_F#"""Wf*v\T⿏]O xkWٹ&%V-G9Jo ~2QI1X}>qB-I}*nB򫸷T>0AO N`=+M4{J13E|}aquP㫭̒QxUD.LlG歚 n$lxt6iL}gt5 \z(N}Yص&oVR.]MzJ9cxD'$$'X۷HKxLy;]h ؂.h~""""""j6W[ ORV3ZRK1 """""""`j$(X"6:Q/8AA")/CGDZđ" ">+",piN5]Exf^wJs5啊x[x~E|LS=D'"q[D-E^reE_4gҜO߉8Zķhui^^O\ķxM_﷊X|NY9&B|~=+yT4x@5DM_mEkwҮ|3V]  ' 8 `MXG?ֽ¬ihAy lb)ܗf-aܒ """""jj'ɄZ4 x,υp~ILwk4%~V>P'wELVt;mQ񻑞 (F[ f+$QK7wg0UbaީOy`ќ3EcxhwלFi*iWs}vzoќ?".L$5CWƘR}OߝXxo 0mQW, _K 4Eyޗ- ZGɇ^}[v5hTϞ(hT3]J.&Tl\{#hhy _& ZG-d'ފ`$9T7Al v : /DDDDDD-M>nVOCuA&Qеwp`^f Zu&>}<˲c`yr"jwլep1<ݲA'bmD,(٧X }e /f{eڦ:3˓ydigWAYr^њoVS^S >$S?N\?qKOotG1Us|đ/nw;kD, x@Sz/C"&!fh/__yBNakEy-0d*\0`_Ĥb @x+m8$'"K>X0y ,zz!JYeK"""""*h adW h%8fd=EW dcwꌻt5}]׉Z~[4>A`W_YłXPqֿkʚ.$QK `S=1Vl@z3U {Xs% X8wnװL*0 ㎧Ѩͽ~]J719Ou>%tw<9S"~q y"+4Z-3tĉ[vm6B!""""""";& $ """"MMA>њO<\pj3Vu|Gt8]=ݧh5erfևno|5t?/Z*9FME\."?UODDDDDDDvKV@:XVX>ao cxwaִa<6_FfVt DcڰhfǶ=(dcH P}ׯ—O!<0Z3zt3׾!'F#?1'1!ܭYaSx|LW`[xPF= uFc()^ICto0))-S _.b9v?qwlSoi>O웓,Pwq}""tsGU[~{4Y?FMuqfk'GIJt7^1i53.c]:xO_ w1 @tT5 +:(ƮH/Lv;o=C9 yl.ؐa#ΏFc(P|qf"opv䃈6TCnue"R@(`2}#X1/p1."sAť$\6} q X¢Ѧ MV?X5|k-$QK#;Jx q%>5ZlM} C<` z7FT" ec?-Q%QP$QU)bOػ: %ωDdbBfUn|T>\ #GŠ/`puFy.vf; m]<ΖּjD,Kc1dqݘ{X {9D^戈:*q35WrL5/s`IV]u"QA訁m(`q(L}1*J<>kUoKq'6䦤GyqC9/ƒbѷirDDDDDD-'8{.`5D^$j]{G@<6x@:VB;C|ulx/r#O|IlO܃h Ptx^w2n@DDDDDҸ ·O E'i2w.y/BbR139P,տ5/gm` vrFVF<>OlM $ """"""rv_=Q7Qi U-یLxؾ B=ll&1e D{]L,X\3Ʌ ӟh ,ş߆rmYG^e~>7X9,b\nCJm4'q[I"lWS5Ay"?Yq>Q=4^2m9A~bx#"""H~DE\ߕc%x،5Hؕ%ߗb{/T\:-ŚXf!Ľ9'_`[N+;cpÒWY[jpK ^+I@DDDDDD-7~O$ bXZV}c$`ܹ/SU-˹r$Q8jI _lp~}4{\8U|&^P'[ l!""""j'.󇻈[56I@DDDDDt+c{Fcw"""""""$UnPr͑">&pGXQn5+➚;X+%bG b9tGkk)_Go{k__M"nr&  IDAT7W8F<y~J֪:XVX>l oCtN` PT2Mw1F2 8y`֬ڄ<0ZJԴ7W'`fVӍȤTs*2OK/M@Py *&1er,.mo,MFf:4 3#:ODDDDDD-MOOg[Wq&c&-M7:#|0(I؄3տ.>x+@z^)leqrz$0"V {& Z)P dVчș~&7+֛4Q`NY9p:(>)5S E>2!">,bǿLħE"1% c[/bjʯԬ}4+h'lnOL3]쬈hE|YW(P`K3Twc 0x0g T5h{s18+~;Ӝ(p{XyH߈)euc\!V^+I@DDDDDD>e'^0 jOR ߊ)yI>_@Yunx 8n2msRoΣjZ*II" @ LDѮʼnU}6%Oىs.‚;G P&lũؾr=aB؈5kDDDDDD-M޽AވA&Q#N>UgAO[9j^B}˩ޭ]k\lg:XVH E,cqe"M6 D.b9oi1zGz[8>M3DDDDDF'/ռ.b5-^.+Yu^NtY~aǸtUOh1}da놤:#.^lT^nuVVA$ 2ũlB<އ&o?fWx fE#qF7f%9$mV#=~7^ayG{_ٍFw 8^\t8`io~KLj w9gX>sLT6=Փ( SG7ۑYrJ'm`R>A떎zK*Gα-M*0 ㎧cJS`PO?9 2q+?{PȎI"""""""1 a{FS{%5%\>"#iQIJmHoܟEN755XZ}9~/qӝ<_orx "_%s^nJ-v4:믩M}}7<{Ο&ͼ}7@~NxM"9Onnܿϋ."> FWe}q/ć]jv7 """"""" """""j8ZaoY }N3@\@|? @iql[1ܓ*ۏ/O p^Lw1F2 8y`֬ڄ5MpkZ """""jy̰wfVgA O1]oᅧǒC3To&s̞Ek4LEپOOWOm(BͯGKTCm}Q\~"'" b}eD,XXv[TħEyr \cfo7MM_f{wBŚ+")5C~"DkM>1%di4Ys|Ϻn5YIJf _MwgW\hPO5'OqלF$K:05"⁚C3Oz\?iOKg痮ڊ,]$ ů*1`Xj-b%s[ hhs,_"b[;+tf:4[y=U&ɬ;Jd/Z!*ⓚh^}TuiC}dTk"׼Us#LIJl;4\~M~F7[?}45ח*<hoNf}en?S&>^5Yk4燎;d|px6z/jOŚ&B?0z|~Tq\+5ǫ۫;?noS^Vh ojof}h5G󠣈~ר"n?*Yk>37p0J^47KC4_(X+ajpM  ?pӷsHrSQ h#-k7ZγzYf>WϏYs*#d>i2%i+bH o9Orf^_'O.ϮǼEim#^"mO7*eMi|'TF7wR~m%gx6z>?:F;tdzOG^ou}c5{]GB?Dsм\zvDrL&ioph}&:IX1C1n6>7S|oD- J@i f ƒR܂m9ͷ %9?/1=5/VbTe,6B!"""" FVExhTO.LlGfeU#e˿-jýNʑs|x|)I"""""""m dDDDDDDD0يR|,ADDDDD|}ɱ>gL8Elfw"""""jaߞ݀0I@DDDDDDDjHWHy=E,My4kWrb9Ek弽~"(InKrgݼr :="g{8Dĩ\y%ͬ)cqi'xci22K<ԤM /YF.m0OcjD9/^ٚ`St[^z{y% X8w\^Zv7 """""jij{g3d56mnr#wr Vv'GSX, a&eg2Kk+RºS;‡CM8S$O(`28"g.ެhXMMSȁ@H)5X@9l?P Cnwt?aofy&(q/ؾ$j^S@ {d"}Z)"N;/LD랚?ft|@c +e!'gt)˗c@zk_S"-֚)ox!""" ;9Woh;ʛ-^.+Yu^Nt_Q.^΂lȯ]p^y PT ǎLu3tŘ>uCk[z"""""*hDA&ֽ8 Z\0XG {n +\0xxHۺe+OcF= ۶!M c @ؖx]׉?{_U}qu`$٫"8*WNhkZ֢Һօd 3 I@q|rr#{='|w3zxZb/Gsw3=3@e>yC_`f5<ˍ|wyd4P~m _KࠚOs- TI """""^˼4QQ]]HUZw5;%ެJ,M|4-A<|' lsԔe|w*T <܀g^ڣϿW#"""r)i8&4NԩSq|jI B0p%g 1w+iDDDDDJpIݏyD%ANTRCDDDDDڇ4%7̅-+y)"""""""`ռyϷ,0|E/+-qrri¼ܿAI}ZZĔ%7YPo7wa]e_PKwg?S.vO>͛-q)8}K\Nkז< Ν%WX_pdKUŖZdٶOd?\ܚ~ĎMoꤋZu~聽%-LΊ\Kyg%jK&0v'ףVKTk~L\i-),qRmU,{׫GYˢy!zC'Z%88W^DDDK'[/Vj3=y:V@tt4 cfֹM*CFN=Kk`?u/㦽@K|V̥sxEM1r k(0v]d\gq|p.-;cK?ڢXI;Qې)_=gsGﻑ=0_lIv90O'""""VE5\ǬeG7`ry ^`o.H'x#k=2C$NJ͊ux%KgO%h,q>梃nX"Xk'$$߷6ݺe%9Ǭgev&o@ʨi+=Ǻks<뀐ե8Ol%k-8nՖa2ݗe?z}ߕ͹}|W.a/6`i"-dem5C_5ogҫ<'n#ǎYb_#Y犵K-,kspK]o7s&Kgۭ̽17Z~]5b5:wh:=Xχ[ÇY˫\jnj3ʆ_7yp|Lkk#q~wkϚ?Yz~Z3kk7eI}233{K^+ں?[ vZL1ٷ]~yI/@ۗpm5wo*~S\On/sC|֥n8yO*ƱjW~íSr 6Xqr?2c=i,D:8B(?(u^KeRy(O>Τ!p_GEBDDDDY5pI%e9+/rdşw9?mSSMXW_wnUWAp2OzN!!44Oox_} 7%sD3 ^)9J9?T籧#8*\@┧ޏ{ȳvSqny6 l8vglwLo\Ýk@*eD8c?W+ |#{ǯ~@0E'?G[""""r>rWSZ$8njjjqY]jVZ |eo|xt=q-2G?`mWA݊ O6S ^2q};ko_ZcM/،I`amiu`Bkuy߯b_;)K<;-%>>>>¥.ٌ4[ю8<,EtdgZ׀!x1 ˶Q=:e4k>ν>U(ȴn1 ]o@GޑcadW!kwg?kGuqtv1e\Wmd]nw%>zU%kcsX);jxvX󯪣uLTWm22zhcׇNUN_h>12) IDATyȨ:\1"b{⬦"w>jsR A<2J糋_ ^oȭJM_aEA[ 8:ǎ'0{aΜ{I9X N&i~oT3ulJ}xMn1i߳n06fwy:=X~K:HˬnuzYm3p `:tȺcǬ}.1}4ߺ@p'K:fÖ(Kv5ZJa`Ķ)CRZU\UfZ5Fmlɿ,-aKw@.-{rKyd,v΅G6`蠾M޿ˬcBIGXwQ&_6}w5 9O 'c15Q.4ynA3~MDDDD#Nb|w|ԋ]7qy+s&O~bNU3ˆ46y8|[\xMyɶٿ//#.""PL7rz {ψ/3F\ \aE@<ߺ=p#'TEM8t;Sd|b8~$+tLͥOdT@yn6.4m70{fcYy寈9i+0^ _%U Ld8XDF {<嚫9-5WN w^Kz>_?jMDI DDDDDDړ78|^ x.8yd{Sy׻8\\<͋DH\Kovа`^DDDDD䜵^鞻ZzKnKL߷BJ}qW5R@3^fN#~Èqw#6}\{,6Vx?#6}P9Cl۬F2w66gO]p#6;l22m6Myd;xb^Y6S.h+2FeYksQS3Zy}&1>:lQ]_&vEDD96wU pϸ}J)ϸT5U<˼'&/<Ә.*ܭ! ᒋ=Ť%},}9GѼ4V/xɱ2\DDDDD)˃`` ՠEamTQC؀xϛ^yQ jWl*}~|+n =y'$iO:633[1%Ss4 0* Z5Yw%(:-i0Z)O?bMzj᝗>N^1z-UI """""ҞtSt$j^5RAp qPo[Tz:lyU ߆lЈys니ȹsh7O~ēP6Ńrzse5k~\oQQL.GBK+=U}.6cBoEznGRj[H9|OIfjrkWz}aF_'^/6QAЖ1 Ց@_?B3ItȮ_iOBOSVxH`Elf4t`}gCלVdtC}>YoR""""""U(̋KU/\Enc-*6w6,s:$w^ܼv,7Xe3Xkf(%Yd9@r/""rfS؏&̰UN.÷-x@DDDDDDDI """""ۋ` Mkg!#q96si7y'f>8֟26bxm;ۯ'ꁴƷ}}96#N2|#4F Jvi9u7~K%]^KѷywtR]q5o- T0DDDDDD}fS_<>w<_%շzzGwaJOӺ.g`8آgxct7#`6zfıFff o /6b9Vk=3=BZ}lӌx&x^Ϯ9oz_oi6iSlʋ,ˆ/22؜wЈ7,_mf^]%OMy9ӂxo0d>W?|~E%vpA*-]G .+^ŕYkI ,z~;e}߮$iOvUyɶZ}bۮWZ: I#d4w+.Ӵ>/Jtn <{* x_tgV^K+ |PNY?ᾧi}^lW""""""퉋SiLVD]D+* | qன/D4w}߮Pnfs(#6ܘ}EFl6V s 3rlcoY07p/2?Vx>Gs٬Ȉqm?lʃ9VIMISVccՌ#m7gO5&|Oxcula畯|ُix85Ѱ(`,=݋5P>V؎#t8S\ nn}߮Z'4:8XO hdEAD"y)p@ၞ/Pt"ok >/J$-0El/J%A9iXQu}r\ޮ=.' .4n!1g{Л5 of[ _}Ylf%&닓l2a9/}Q+חmzQboe~\lxwy~ms_evgbĉ;&"""gJ#fm1zqOjRڦ̻+Ux~<}&݉.2Nr\DDDDD y{iB/Ϟࠫ5+s]y;h}[N"""""""Yv@DDDDDDDZɎݽ)S({q^n? _[3m\^zTh/Oj面Z{?tDl+ DDDDDDeo/bn """""""* DDDDDDD%x4ٜhAFl{Ɉ؜Ӯϵۈ?0kx}9] 59fR#^gĵFm%Fmجb;lKMq#12#>bqL#γ)vVqw)f#"""r)Ϝ/'oF͈!}l`Q]{\i&7,u c[݁yV[Fr&eU٘4ϢC81Sc!n]^D䱤9<m" 'yICm7ff,ݍxSvswݘ fzc\|bJ#6vL,oa3ms>cb>#@/DDDN'B?Jj"tS]JZTJbҰ݀ZWu_(l9;#|=7l iܦZj#Sxz%I;qk| 1j_ zgx#w>&Bw\V%yOkOxZw}e(䷟Q/`gH-;tPgu9߇\ (7n'fmswH> %$}5.\5[Uqt2C\.sAcl?师H;g4]^5ZNtmw;;Ո|UU f}a4hЉEWXo OOeHqa%]mRԛc}cLffy;6k"YFxs3œ~'M~}P+Ofl"g^qMy<ʓ9&]yǦ|>)o68""""VVm0#ks1b?`b}fܿ7_T42@@(M (̥ Pҥ+G,́R_DDDDDUJX )bTz>ae$GW 6_ t~b*\vfi|էZO#īH{<ϴh ijXU>ܷ|C?cT7Ԑ|wfr,'Ofcpm:&sW0n֏Q%y7%Ck~2K~@^b?³=zgLj|/cb,7ض9{N%`>S) Zc~g."""""""$@cH{ ,S2` k6k{hƈG>#6k#N6H4FՈ</M|kk]1}m@ mLMpf~Ŵ׃|-EYi0#Nr}6xC#lSw꿗,QbH; *f'\E[j(b̞tNl;QZ?/jHN¤<:*3Yt.oW}[5y,iN)eHt9I F%NsVʀx g3U%iy !( !m 3SW]=EP @ w}Ì='隶64<=>CML ʻiΈ>fFlIDMl54xۍI`5bsLFhI6mdy2[x;&>UFˈٔ"g+4X#N^f,x^M~UyyN"""7FۈqF*4>?w?{KݽϚ$4<-\_ץ/BؙHvFb22螐~_CR }P砤OorS/Fvܛ7kXxLȡ[vi/'_ hkj( dx gKW'PĨ}y*蝞䊆+kQ$}>83eĉFb37b;lo*s}lƈ72?K"dl6<ӈ쯹6?'wwɿ ٿ6ǻuM#劈H{7ܟ_y25w`< Ҍg̹J"ZĬ|\duYQ)^qA#f3Wsoᡞ?ޮd`TI.9JN""""""HTW9EIspT1y6zBy84F;_WuOy>J0J|_±h`< M6 =H{2xf8p+i %\eP {k\%]Z+ZȾ,p ;W6TI """""Ҟ|ψgb0(54ZN?w񩫹"^G]\}?1.JpYa|;4>ꖬ>6I '4n`gsoSo<üMD`>fb8gi`[?ڈ+8f}q]mflO9v#n%6iġF|ԦԈwڔ@ɏF\j@DDD;W+'ܿS^6kaąFgK/sxqMz}?4bZj)/6oƈˌ3b>C?I~^vlΏj#>fIy%ut=6Wi|"{{Fg'\-J3 (e{\Poے.`~T8N,Igp^1n |޻Ǘ>ĭҋHޚ<4'gqòM$$pH* DDDDDDy]p0x`,x)̯; &g{UӁO.Iˣ0n/@O ikjpf]P}oLJHg}p-@w'TP>!zNz{`:qӁ .CؙHvFb22螐~_sUħabBy3%zIT$DDDDDDګ`!p LDŔgS|/M'yW[FB~%p'I8\J 5&"k32'#o%p0q/#^ocЈ! 0 #6F\euYYkcjsa678heaněڸ&?\eq_g+ج63>fyg}O;8}mn7]y2cs 6ٔ.F<ވmV6l^v/_/Osμ+5bs3s .sL6'7 tv &#—1DTsJi F_2(Jaq9Ⱦ,ǎ1ag[tqH{q֗3*xZd9 9IO"FV]F}t b8|坻\_CyT /4YAPO⓵S~J|vdG3-b,p/,Vթ?u0%m-p A6s'뭡NG)kȒY\_¸X?*F""""""Nc] SҴF'qG聧^+SگAlsUY5eYnmsf(#V9J)|taJ֘ڜso?GvP%@DDDDDڛP`A%h89g{1QFψ1|ڈ8f*؜Ǵ'xy9^qwP+wmWqkiocbD#^g_1&"""rY r}Y6aFˈk-rn """""""@z%%@HEuy4%"?͈#@$oMK"ՇuZz5Nާ<m" 'yWG3iϳ~؈>6;ﰉMFf|nM39&F#4$vL6g2-LL*#elSM3hqH/_[3C98/wo""""gAFۮ÷yyc#q}-uŽJru]tUħabBy3%"G󋨈K($-|v8qɂI7ES '_WiCb~ݙ6{iw|θ#ud3dyHg6' WԵ.MbcOGDDDDDDړں>Ka~4=gz⹧6<5mQt? G^t"}RJ?m3r n,k?4{7 MxߒL0#6D}y8̚D#1b/gof$_#6;8lo*s}lƈ72?K"dlĻ5U3ˏIOo#3U)6w7N#lSZپ]Ex)\ +$E/`Džg.u擡' ]eD |/v33_qt2 w0$K%m`'cZW@'{*!ACN,ϲ1 )iU%H{L3Á[ᕚs3+Ⱦ,p ;r@DDDDD0O9@P FFs++7drkk *9Ą{/{qTO>!lГ (g;O&h8&]dϽ9O990/ڬ2>fb8gN5F\ay6dΓnsvBmߌcmۍx_-ڔ^`6)ρ6wo""""g*#.Y_ܯk>F3Y,p/lV}[׉_ &% udu#LJ»rh2_F7(>1%:q~T)* DDDDDD}ML8tFԩ6 R՗z.G\FcQu7@- DDDDDDZn'sSgev}#m^mfs^~Im)6z[a^.|`St:/On盈JKqD~MҔ^i˝wyV9sΫ$@"""""""Rd%AbGGt+Le&o<6+ؿt6wM6k^㒉&:?g/O[a;L%Օq|p.ܭn4\&~ؕ25+Lاt}#""g?weolg f޼0z磌X5sϭ>՚(sg˝Xז_3oރ|+5*k(dej<7.YRޠ|\Oxx<* .gnN999䤽Ę1VvZrJ}?|( )<ݗb`hk\u66E^ڑ}ͣ)@Q]x[d5O>!mQB٥HÖdf ^,Qr7}M?{T3I]e#nةwJOe?1[P_wb)m6ߜk|^DDDّӕФt\T@qe|f$ Af0bLzXuH6E5ozw.pvtvpIZ=+A*]4~Et.)^V'͕IXjjr\raVWplK,z :b G< yA*5vlaԛO2X[[J7{3\ҝP "^s>?\HGgUwL?:Q+bkYK@?97Ql/?ku x+uYKqF ΥP*DDZYPMIq U]Pk#+rsR:lLUH%Asّ/嘻9mKBbdJj+k*:Z"uqYI/.v&& tȠ{c;[v"bOqײJ} Itt4=OgHc;Pa~R% &Xn{f-.WI'ɞ&/6Q`='+;:D(oG{mk~t&Oflr\2sxtݧ8S5w#3-Lί4C[GNmQS7ˉkWi?'E9(o\콍_vec~Ær/tSw<άMt!\qs6-fdڰޞ\_NTDo]Jڛu۽*LIjbg;*7>'a~Hf,ᇿ?Gˮ-s8Qe98F~i/Ώf_wb|z763hƛ-N{RWΕ)5t*՚ջx!El1W \s;0-0L<ź3M0&`c#뻣ʊr{>P,9[_xi#*N+cC)6Ѣ>gO#H-],ܗkY> NTW?fXB pUxf*%ʘJIIH#s6-x/_GB ~z[PU_{/[_0\ֹm}T F솟_ MTzQ⹤QV5Mq* /tӒ~#ѿ{3I.?:;~A8`N57 wz eRip{"}+va?տlk ̍[^v 5cBXεSHr~;{pM#`w.dh+@#/EB`|tg?7nNo*?7NsꯞGtu. ʨvI>Q_J8SˮHw' "*j)4+ҩjCχ@+}iBOGySN.7Ə&K'wkbvnusKcr!jM<Ҽi2yof.|o<"P OO|m5N\̙ZłcXbO'XBv枿]#;(׏Mw\o^ϣ/2"2W}wY;~sd39x̔1D4\y:+K-oC&*2o7 E4 AU ={{.R"#:#[x_>Ư Mŕxy^S ;Y׬rKacܺ\5 +[3n|󢜺V1g0&)H1ڼzۭϺh.L i>A-̋oy&0!9RSw|˷bտBosk/>ƲDoc_>}:v| nx^^L۸f4hUZp '̽Ihv:Nx.gݿϧE> *l;|4i- `jhQJ#7L`سxOWi`&s fP}2:ʺ\W 9DoOOjɣOн7ˑ#)< o.!1>."63pG_C}D5g >@M> '\cw6l? .P<*79fu4W=!4;#{Ldosm}a-Ѱoc7aŚKڲWxO3_gcי*[}:!&ՇٞSCχ_:i:/-#0J*jTWl&Dzyq_<;axt:+_))*ϭq'ߚ˕~#^[<$uphAsʜfǯ(3-g7Rzzw6ov[ysy c<׾h[l-D51:T.2^\OrЂnmEaϷwts8a4o?|IUъ:'I3yz7^-_~LxޙG5ye"QP\IXB\@qZK(S:WP;tֵj]UJ gU;UVQ #U n{W <}PXPD@*M#ÇE: z+u؈B)SؓhwNHn7=g2wjh0pIJs939waCw%yM,`jx6"éF /&͍GI}-ަ6 6L+h uM5 9ɋzܒ>M) kIDATo%'"&|2ryk4Y fw8-͵n/~I4xMd\ BR 1PRf-NuiH0s(_}&[Z$u&G|^AJ"i֌8gpiDԇ+6F/U}"H6w=XNހw 9u1w~Hh&ב- _;͍ 'e7!QCky=oVKsm2 kU!#d@Y g$^ 4 9#7dQst\aZzu\uĈ=--rJSX*[#^r?"k(v1Ud5~m _YT1G #k$ H%Fl[&q\kT #3YE.~l$1~|Մhޙ`k-tzY{i;2z}S=əh޹Ι1c -a vNQuI%!ތ/p.=]N v\s(g?veaX7oQ/!1;9W3[kܕ:~t eZR?igC]|ځ9~}%|pz6Ȇچ-5^;%N|$KԮΗC\rw]LE$Ys1y3vo(2S|I9M2)6EþNsƮ_nQTV}_÷q >p%R})w&~r2+ s燢=4ּOїi8dYZju~:q]@\bj|#Y1}q?f6N0TU^W ӑ@@4Xܖe`' 1u>8bK"R{l>K WՃM6Je.ĕK(,iK(~齱z~27(+h>$[i,4v@ٯ!\.xr+Gǧ ,I qtXZLjO(cݭx~AJw0Iy£So||ӪZIˤ9ϓ67n~93*Y2nD]ܾ;8o'~K;EJ˞a~dAc|GW;ob6] l*dO !^ȑ2Mr{U3vhDeug!rˋT=2},bS2 ]IbVl, Pk] .C4{#{Qysfl'Dѭe/mgfa@mߟ煢 #slE*Ǚ;@!ub-D+⃇ʃNQlW+J s燍 aWn^}4/ՌǙֲX }:vd֙:盕Kf7JM/Fnf .!,ˡE+x֗2` `jite@ b+@Pݐ\9/eJ/=4N6;pBfIٯx)' ^%puWB7#Ъh}afjJfڞ,}{|,%)/J7dI?#ILEBi\ }D+|BB'BMciwXcLKwDu,K DRH@ S&O\oˮ"2 @c;<=DMXDѫ)SyMo.xXVuAݡOggvJHXCfB@ X8?]Ha߳pK̹Cԋ9h,#!xBӍ)$a]8c4G@ $@434h! !^ : wI @ Y \yIENDB`tremc-tremc-19592ce/screenshots/screenshot-mainfull-v1.3.png000066400000000000000000002012071507451042200240160ustar00rootroot00000000000000PNG  IHDRrbKGD pHYs  tIME 5#DWiTXtCommentCreated with GIMPd.e IDATxw|E%w 5H!&EA@DPT@'Xy" J" *ED;! HҀq I6KL~ܲev;s3huB!B!x0) !B!BB!B@~~eκB1^51__~.G_a:W#YaXRgk$,ȹl`n֝'1S%[W! }_VL>N<9V aBby9*/hLJ/e?O:0m3 Es;}Cd2RUX}W>_C-%,ԗy/I[96UrWX۱ll6cCC KM[$%Fu~~~hl|v},9yOrӑsz72˴䭣xa.jYQMvӎI[yK1!7;y=6Ui1+{6qTt/@yӐ9D9ƦF4;zvǮU0[AޓFGeK<70 q%E䋿T0i~0q6Ht}g7 *He!tGĹ?v,; y]k]gz#i[=MqQ%U4UJftghB |773} N2u>>>>*Nߓ^tnΔExyy r*{^ToO#b{,GE߮:^0eϧ Vk5g o"N.E hԏ 3{qgB%#Q5eHoZ|P˳jwf䴷%O M}WGNǔUҸ"䬁 e}UF&Ԛ jD߯vs#W̚Ҧ& xfdz0,X715}odiTBj42iG2#<Z?J1QCAo¹=tm׆n?mjͶ j'ϖ'KkgW~J_y]!ƽsds|D(_':0Yz)6IV8tg??c͝s8ĽIs%yK??Lp;h٪K(ߺ^u Q`&r8z7j֪f72qצWvmuV봢J{J8zxHǨ매_Ҧ(PZ%=ozU:0el¯?m~\%=)\%#d91$!I ^}sz6mpQ4hfqعw}[we\5x)ad,jFP駙%=`ƛy GpjǗ>!2v7_.Lw~dH"ȐRO\XcwiK#t't\2\S :U7u%=d.JiFDp|{<# O/]8Le~Ũyc3G[pYԖxŨk;aLL8Sb7¹h(Rp JЈ8|4eN_D@kvֿ:SװQ51j+ uig˅zKqWuu$볢"&lBZۥ_mJ\9lK[?^7JAH9s2}ۃx6V.xTA|kzyi$$s9l_ȅ=鑃|ܣ%.]Q+K7dg] y}/P?] -ώ7r9%=_7M企 1WjP20ws&-ZVÈK`ڽ68AT>>W q/U#SZN-w<^lӷ";Τhv yM_JhWkƔ 粭-Fzk B,o扉x2xxR摞؎sq:}QKEqJbјxNÄMb]ԟ8MqqǿƍI/wXtG8]^ ̺WNߞ燐s;)KDǓvU"q&<\@)SHIV\=0%qGٸ>! (V_Owg3& ](3|YcX˅$][˽m^KW̕^ ?/=GԶn3dۣ{]k:zz&RrV㭝{ޗ%bt)-k`<}2>P%$r?S7sR58|+z&D/0OzN9==+\I6.hjQnrc犸PPΛw}.Fʶǿԝ?nt)_r@^ K|{;9 ;Sgڃ}&t@u/w2,10M5>у#?bWTt. &wLN&<\ Ln8'>Qvm?r4 \:*7ܵo٘onr!ΛŜ! /нM%*N5^z7,],OE.ґzEEK (wz `UNא))ZapzB'aL Q$DBZݳJ ʞYG][/øJߢs>Rq8[!nq))߰.]c&) Ұ Rnp䍫!_3/Ki^9s) iJKIIxm I~Ò5qwЛ6+;=7<#\)fu\6L{ _8c-dž ttԮqٽ#\ yu/>x^96%!(GŽ#*fΗ;yPɂ_b>]j'#-m^8޲/Zx7Ǩn i>|#W5: UjN۵qKGٴ:Er>|;mwm<,/.yl=nw.bX(|r˘| ./'/k^r׋kq}BÌ_!+?2ݗwoWGk[0D?;'S"7zåB3Ήka0guƂe;nTYmA-3-r/˚ؿ!GJRelgʕ,Japqd磠_^[pbT sU\HYdMh&8/݂GNhyy/>˛:;YNhՋU jL8͆!mo4m5y\t3R$c[an׎ZZϱT]KMŝƹsԃnq5 hYqjY.'(t9ۉ k/{4(7>l.V!͖bِGtJ̦o6Q Ңk>؍Qb I"6[*ڸ>!  nLۯC\9ܮNӞ5ag1+grlyЪ|-SfœW,׊{VS7ؽpQޘ|rr>ʫ!r"%w$ԟ} nڦapKc;:'}^[٦r{{0S'#%w(bt( WB^AXu+/6ݟP1^=s3,EA8]_q}[^~hwu؅c:npLdLI 9v>c63{>[Cr;)al^Wݮ%n=b[cXr I \:wlnYZyhZ*v^$X>IOc٦[_ˆI+mH]~k$&1sX0vY?{7[ˋɗ`39xkbQ`ͱK'߳;Y=xS o|5s7Ds; 2 T7; Q@5_'&1;7"ٻ+Ɩ[;o[-eٛ?zRpTŠ|W]ir_ERb ,d9|\{]^f{syIqv߾7M;Q3m a/g-/6>W:gs}Vt 2zKok8]_l [?jtΦ[cL!)?ZW?1zL P/'0bH}b}B& 'Y?flʈHv.w ;ouN=-s^ Eaɯ"Bͦ`^#5KqO.q.OQ6Γ: Yvs'Q> M{~F[ | 9gkMo=;tGrH[B]gyù ]NɋHE;[~trs7_̨'D'FX~ :> ;on΍e?q# S Ovdxu~@T_:Aabk3ex)dh. uML4!xL{>O( "Bf1^o6XGa7]ۿ1I.''!.MVNl12`d-e -"3<9y5_ww/79[}x!ByL/";)_4uE5812TfE%+B!YGYo}Ҥ@!Bw;o' C_c9 xy0!B!(LHBCC q\2] B!B!DY"X.ce5x+Mz&iߋIWq>[i}Q.kkVsB^"5i_M:6_v)wyO Yo&]3ϟi_tE2!"4!B!D~ tV^dXx2׎Jcg8֖ ǝ\Eԧ$yfY!@!B!,T ԇR>nrXzzM>J.1} Ω0s2B!Bi%`P9`4HH]+Qm<^eRB9-:..҃ >Khҫ5ZvLvv vӛV__֎9=|&&OԤc[YQM/MZ;漨&t&}XI{k14#t1MZ;ştM`%iҍ5iVK;5M.Y9FV&gx/G6koruL^+#{JyЎ>IѤh 4VMe./?eVaqJ}&SM:ZyY)_Gg!(<95+͓S62y삥B`0s;p%2pcC]V+SWʿm b+{񮶒+ZIפs_*?幘/6?71Vv/<ұBد`Z6ulBj==scR߯Aq<1 Zab4{4!B!S:m2֘L*OodB!BD Ǒ;iܱ [wԿ.eX,C vc*Y.KڠB!"' 0"Wcy ^R0m=FK |ZeLkK l6s1ە6ǴmDP,* m4c6XAlw+1SG _qBי)Br~wk|-2Rѧ6̜9ӏфAKH[.f ìH.<=ޅV}&ǩ\6ch<Wuͼ$Ѝ'-&'V!>[k~)Ť ]ϰW7qc%;$C{ E>ߑR>K/twLT8m/5t$_.Nޤ[E,/܃:>8xKY#Uև&G1{޾ſ7_ح/?{BQRt+`۱J+2 V"N:4[m6c>S^u{-siashӐ9a-?Գ㮍zv=zFJ5_{}b<Ɗr&_׈fN~k{@|m_M)O8%Mk IDATw8T>[pLKe^7aӴ ݁sO yod Ҍ3㟢FPEj=3;2})u9#eNg^j@]6CuE ɫ.]c;S+ПG1RgMBvti,ÊML>q3)(([RO]?=z㟝㸸"v~ӅW;XFgJ$pʼ+T&|Jc MO{Zg1<Je}! b`GCy_ ǻf52o9czu}Oewf_]rF3v 2mǻTu$rjݾSˎ32ݹl/෪WAiM3w)ޮ|S>FP]fLQwWe[2ա תjغ3!JjŪPSgGS)/\Wwڿ{S"3-hڵ5d!¥y;Kڳ-Ut6vS+VͼlR{׃u-sSO}z˽Ww=7u\7;^98v.7sOWTM,˅<=Zm8gVf&^6|ntuNϪQ+O)٬.zwF?=3BOuU3+4ꜺhqT1l1va؀tjP!sK{ : LsŅm|Fٸ[5T\_OlLBvf!92 .E[[_ǒqx\ɭ9߮gB'b| g{t0MY;q-DMx:LjDS98hǞx,>l^|bljiS!6Ηr-Kz26K:W~}1`JvEߝ܁iƿ^`gΫ$禞=eYN^W֛:CwS.7EzM$Hvw ltdx-O/ >)}*" 1!}NB]WHL_,0Ҿ31:$'Se6XP_*-^1޴nbQqG1.n#Cf[NL)֖P<\ xf*?,Ϣ3@=*HgJƓgy\֯/~(!&t;2ns91?3~, {de Fz}-~pUR0O;zgخw4A1Myo=.j\r<|zQ_QS.7 D/*>R=ƁFV1nM;>nNer›p39*.uƲ.펻D F{7s>oJr@q2x!5LFVHkzZc71H8p>{(v]*KJWTtw`I8ˮѲ?mpҜ wD!fJY,Szteܳ.r :v QQl;Vtj2Qu+OmG%w|!&=khW!M)w uH'|_I[Zm qA48)/[岞bya۽֛:#;rX@_HwԿv? ;+{q߆4#i+9h2笽b4Mm7•d)܊ 0VaSIf Jlnq+_2G|cy!SCcx`Y՚j4[]P"hAω Uo"NEbqZ~!,ѝҵ{ɤuv`Id#48>uit÷3|0 ŜS=מ狿8b乤{U1잱ÀioWo[gjan6a ֝#QF7ɸɽ) ɹ('Bڥq7S3|85'g-6ߊbJ<ߩ Ł;,_x?ZxUs|<=,c?o#Fw_ά)wg)xgP{׃u׃Q,ҿ?&k;4A{=7uG B[ᔛ>h[yOĸ1n+^;{7]dq*K@+0'\@ }+pv(YmkNN[]7";eu]$wU<7lijLM:_ [sc2IMkOA4+٬P?M2j72-f2O]LSܝ圼Uկ{*٬Q_{S L6fW֏f9=୪~fܿJ}oZulI'Y|Q֩3M`~5ɒ5rWK>f:52dRA>ƲOTfe6Gw~NRV {cPTuZM~vnNhjفlb|UMVD[ jƪf9WNRinzC7;gعs=TOUP­9YX3)L;?ǑNSՆ\r9acǸkKk'n;^9"2J)~UilX0)yjaK<i)2KP+QgS'VUսSrʐ=KzQ[dL2$Dvzט !E!(KӴ[5Wl₦@hhh8ĕK;hy3ؔϿKFq1Y}{u9&B!BQp%öY i ؽZq'ƯXD$'6Let?;B!B!9b B!BPab B!B!.9>Z!B!B B!B!@ B!B@!B!HB!B!!B!B B!B!@B!B4!B!i B!B!@ B!B@!B!HB!B!!B!B B!B!@B!B4!B!0W2B!B! :Bs,V'SزK48Mp&I&o.M~uk#\wQnIoФie5V*W+gU4۳urI4x3> #Y jפ]Ki'4 MQM&bO[vyct&]JX9^mפOi ^s\6k.g䟻&}NI%:Xf/sy6Y?h,.4jTڸVʓɱsϦ˨&}1X>〕lc;I/i=쿭1Mz&]JhIWFז|JԤ?1V_e%FY)dx%| tN}8O}hC‗RtxoMOImLhMS0Vއ`(r@ B!"'0K`4q?wIQjj#@v6bql |GXKÃw'\܍$'U'B!B!39wI@OO`RFE>X t#kOx!p%o~f(G* B!Bq?)XR}eHEb'A` wXS_4T9e5>}g _(,cshh11ڱ/vLgMZCY^f%ʿ׾r.s\;f#Z75ip+Z^N&XB{/D{`+ӎ)IkǼR^-I6G?leR^~ksVDx}Զ>kwRln>2*geC\_=9\o2[1sʿcUu1>++ﺕ&}JKime\_WB_2`ƺ l5%>iI3E$aj&ng;))-J^͛?<}'\W*AJ!B!Ȏv>ܗ&`[43凅1:moa|!+s!njBy;)7W3[Z{R8WB!SSHN&_,o7HE,=`ߴw.XuAN/yB!B..Xz \ X=("}؀?50\7ce m+D;^;S;&}|}/vv =%bma% kǔjj Xٞמ/7ϧy%ٹ<ۺ?Z޴]˴׎ߡIWѤKXٿ*VWs/+!HާIkV;Ú6柵b-^iWi+o+-V֧-_ć\|N7Y*e-~1hW%wl?w+磼"J6gEQ;+!p8NX&)4=oy9,s*W}Iz3gRl!lj B!B#{W e8@9ž&'u}f, OrED=Ð i B!B0uS?ze7<@]׻ߕVpIQx78eB!B!B`h~AQ[\B!rk&PD!r%Ց,/Xd B!B!7Gy !B!D\@GB!B!!B!B<( Xv|mN2?\03fsv؝CVϓ-bINXP\ JEjsl>l&Ō vN>R WːDZƜgf3fs>-q:f(=l&|bF+IB(,oܨufl|兜_X4*5c̜9 ̙39sܲlMx:s&3˭p7< B%Wq;;T'?-d9jxP{B&68ǝjRѿM-og]wV@?Z!4xB_WڌX-]n|ϫͪPZc^ZO>q^j~~W;oJq*Hܪcބ8Uf!Mમ9BnW}ľ+!qW_Q }]:WŌ9| X\ڹt t܂N%0^4+q cȘ +(] k@ڝu1R^<&4r:˝;ߙQW9k®r+),eեN7;acD͘Ɣjd?=^okK=7G=D4dN'zOq׶)~q\ܷ? !1 nEp*1G7hԛn)_3?p"ED%py4Tyi6>M_!:}ޠWK)83)jU3o.ڗ[3Rt0T ~߅_NMm3dZW$kݐ>3 ~S/ufڴ.qN?);McPV lB`sԖOQb*o%삏c^ ~xחtT+Ӕ[ x*p)f3mlS {nyU/Xԯ>!h"LSepyeF^ܞۧ>!!=wzeN8zO_iT'LԝY_>!`뜸.U&St׋krTyw=.8~JTj:K-R`ہꁁT!*wa{t~UR6i{$qW{_q\ܿq`:Nx_mF[MA@x2NWɶ3ч||iGoLfΜ+9>ZxT۬VsW-g PSKkUUhr`5xn5Wx7D.Svj|wfz[;VV5Jۿ 5h5wn4ە|[2w?6`}-/TJŔ]]}.L#6iׇw8ҬNlB=[0۫wVQFPn/2d vw;,;r^&]_SAFb (\)ī=RMVΑo7rlxZ?mԒT"(\aΨk*:eG3S[ʕbz0"Ԣsnic>5UOo[ꮧ{fkJ^9wܰww=>*ǷH{b'L BCCs鬞u5@uu{Õ9zrj٬Mݧ3,he;R,>f:(l'r1=3kbcm3?ðԠB.4~,7Xefrnx̶SǞ™(vKere\S96]#֜oWb(JqDz5{ nޛI{3g\KZWug"u7$5sDY{,9Ivb-éKO-w;x9eK&4[Nd%+6Η IDATeU[OfW/̳FF ol0͜;04?S%puǙ.rLqQ*/3m8v^%97^4(7mіr]Ogǡ;_zW ]_W=e;= LeZJFq37s6:Ҽ;ѿc?kHNM7 mŽi?b#WS G Ÿ,Ac{ך[NLTֱYs\Qxx֠GL M,vF1'V=G ύlu&;Io~$O'ze?RIJ\qWXZe.xgde Fz}-~pUR0O;zgخw4KLR?&D;nKS^[fz:tԿ9urTKG ;ƫ 8g/M@z#A8VO !&?goJR廼Ǘ]21S1c$b ZAru\}2Mht\&q#|wT~?H$aZ~pERYTdŕ'*ͩ“p]eU/~s+9ArmDG^N.e:`x;?OxҠ7Fɸh-t?3g|t[ܸ㎯3ĤDzNL^>سR^C)섫`;9xM53)Ĝv^O2l>➃i^Ŷ#ތmUGO(ߺ^'v֩ػozچziSÇ+rozZo><7 cy =@bS7E6E*J|B`-~.rТ@bv.ݫA̭æ $:Ȥ/tщ.AqOgOٳq2;hBL]6QZS|Q\F}ТKJD~c/#9q8m2Rğ:_cI4uHV䙚p7SvO>ԝ,~-btH`3|v-Hv}+Hvohg3ަCRx])А^.⋠ ]n{i9a,kJ'>/58~N V6|N^pu'+n(Mz2nr̓#8OY4FwJ|Ƿ[Q\;U;8p' /~G<[Jz'N{ís'}[mKÙ5%l6ſOL g,0`oÙ~~]Oyw=hzZw=򩻩?q*m2]ƾCC{zYqILxl-pv-C4uj\ʨYޤʴޣNl>v2M n 9X[=Q2*57՚0dcY&f3୪~g-r*yAjձ%9dE>ɣw jN󞪺ӽ'޹$OUXlOqtZ4])ۉ`: lNtl'uZ7^Ƙ?^xTuxeP-٩NlT~Z [0lTۡ|b-zT#Բ@諚 6#UḮs?o,F^Oow<-T*}3l63/Qc)SN']^\Ꙥ˦zZoﮂ:n=Ι|w5ŚYWOeP{>UmDr wVOȏ-ϯax8.{K?Sf^Mw'YܽL:hu,ߩzjӡT|ze㣕!9>Z9{!0g FdHS;z1BʋBQ9RC%"@hhh8ĕK;hy3ؔϿKFq1Y}{u9&B!BQp%__66@`/{O_ϱHNl@ˈ~%wB!Bs2@!B!ȡ4@B!B\r| 1B!B!A B!B@!B!HB!B!!B!B B!BwtTE&:HjXE)tQQ"""H!@ ^в!dC}>pf{ܹw~;wVDP@DDDDDDDP@DDDDDDDP@DDDDDDDP@DDDDDDDP@DDDDDDDP@DDDDDDDP@DDDDDDDP@DDDDDDDP@DDDDDDDP@DDDDDDDP@DDDDDDD$DDDD.&S^cǮkڴQ.+VP܃f"""""""z VS6l:"K)-K;R+')uRSHWM5E/EZ,|5-+Him Y3m'+Ӹ' Xb?~i.|rΠؽUB)Ww^\FR\OL-w0_BӦB (cT?ƍRp4CuG%"prkKgB8IQE """"P|%ܺC/Rn#?bu1W&[] |<рMld/đwY pzQ"vq1qpks+5C*pv3G HST[*E:>ܞ"}-E)ґ)ҕHHNIN٣Hޟ"">wGDDDDĂgUOe\h!1 00Gg g)W1P%]\vPmZjeäK0f%j|Y΀Վfnj[/aqx/c ;=r6oϲ oL NHFq'p\Yb~<r״Gšs,$ 8S:;3.~~U}Flj1?]f<84wjܽcJ≍(|^Mt ~]9C#OˬS6Vº9qHm}l{NAL( pYQv_-LGu<=9z͛Db|f#oMe^]1@8Eb/pj5 -곇RPÇo GBƅ"m~,Tl?$P@DDDD䑓eQq:^)@)i/֩/*-Ѫ;=Y6d %qu7[]7%ϽNZ ^gUfd`!G{Sߛy~˦=+g{0;tcpԫzG;+>;s8لr{G^= ISyNTk:s|^BvMٓ45zks<|\9wp۸Aw{q4%b hBGJ<}i/3F8w*[م`itϳ7p* 3w!NІ96N^[N#h?ѹ/Xc,3`Udhy{@O{{/@>N9GÏAq4)\vYxϭ'UpOᆑ+~ Ng)Pr;TksYCaO (G9IYmQ{䙻d>FZb!\))!9""}2E%E;E: ^IN)H"jtR怈X+_zӴ3< ;TcN+>E ]֣ۨBFֻs1Egp |[nb;XP>E;rii!GѽBg\`u+ujGD@b;UH 9B.&&HR|^Ž$ݝʯ^@ݸW |y"K<,<+烓L؃)NjouRrp|=^Ah$Q*` əaؒL<*vRxFQ怃>] 2n{Ӄ*gxN/M$3˾ɞ8sIh͚SNx\EQ;OM~ufoH%K E>(4R7Ǐl$pQRYڏl. H|+SC,&Zl)yT""""vb2U!OjZV.;O~j6GɘxǮkڴQ.+VPb٢ɚA """""(/.ߢry@(@ """""""`\:T""""r;?DǛf"""""""^ X|!WLn=?GV< e&g{w(`_Y' C[dp; '{oRɶג1d a4/r2GX׻:3/X7E iKj5]6C!L&v,dP"^R[}H[X=ֿ'+uAN?ae_>_շۊيդUa SLQCզ.E< i]gLCyG`| |SLos93n3 d=A>>߉mue\G%%Qַhjg7yVjDNj%)[jm'R{~y< 7~Lѕyο]הd(tƒ]7x>sSa[:Ni*N.Ih{&x}SOYY}pmLQE!;/ZE0Qi|(S6E_"ۉnR|WߧkqV0z@O> GQV\uٷ ={߳0P~9ˋٞc:kqhLehZd֡hsHnͷz!#n׉Mr:0:QpF@fs3)vϊ>4jѩZV"nxI"3K$Dqn/ /[,3:Q74OstHX)=m; lFƅp‰BM^'_8jtr/[50vd9u8mdg8[-ʺD}zψ18TԊ>SY8gǒw  "˃ { ,\)rmr;;9~Mnj\ޞCNj}B!@EٶY'DUo_ ꘃݦi!bGUxknnERXk}sn܌/mj/A&& .y9i~w{NFqBM&L{Y5+Sf\,Jk~}sd=Xo><)ۼ<疬U:QuoSn6cprk98sryeBf0M,3W_q*3`/$kgT6_r؞ڿ[Fy?_>ۂ _cfu8nmr}<_ o0|KNz=Z[.ܙ*uiRV#y^Ȃ^YXڵ%RBFF>T)笚mN2n=4ezG 8ğdl.{v3\I1-|J#XS ۹UBpkY+P(_!r8~gs]"HOm=d%=2y퐃le]&2b6xF[{3 SĂN)W*u.^ϋ#K@ΗGl|-NzPT:dlɎÆIu{T͂3Y|xj/և%: ^I?Uƚ)~CqoBЈ^3OP}w*\ǎn4CFd/9Pe[8,J9O#voRhwZ{򔺹ߍD;ws-?ӿ7gœ4M:6S>bf$+ ʒ ' R IDAT@4){CPdC۞c~'3%h-@;GT\$g˧s%7Eiև??gy x5۵@G6Qc7y4W p{h?;B@,1%R 5wfa}gc\J j Lpw\la}iѭ[园7&p!=&ݘ~ԏ~Bϊ1 j'qgp)kqr+@`G@}xX6\n.ےP[ s6Xb)Hϼ&ɉ'1j.K᲍9"/}߽箅V̑X> ~ۅ?MLޟ2߳DEqnR][k|LcA鯻Պ.p/ d 6c&W2s8F֣qt/ ̷^weM{>gq-= ":/c[ s/FO䅓m}aqiѳ;>ɔM\zVf 祧[{=:h}9[u6ηGb߰8ޒ^jvC|Gf=]^ʗʗIA.DGsvNd͏́;_@#Ỷ$]񅓕sB7ߤS0KnB|Z!L\&6x9NE[v峟f^WD.FLm4Rٻ`4|X56] D;{UwoU&,c[@TOH\>r5K+$p`;^ϗTH [ rt[OtTz}JU;fm(}O?!\M z~AS_gJ|šEq=K,<} ,/w%|͏dbOd?\3zRsi *Ca”ݖW2㛺7Lh/և,>(oD]n^bϚ#sSF myJ}#V*yXsf;{V]-g+rHT>Z]] =5;K>e븈ua@70߸ 6Kvwp ADbn 2m4o b=fh]ލp.Oɤ^̘x#](}xhbwhISB,>;1P/4ۤ /!kmhrx31BWRcӰ/LᵽV(Yy5e=ֳ)"{Hca%%xJC(4~ߖ}O'$Y/p-|YsX[,Ty M/g/_0Y^dO/y|+emE^g8ݟP {G֟9<^ov]NfJ(KZg{֟q \Ǥ~cx(뱎Mz%E{>w'Bť{$]e[JQطu:o|a[[`s\~: oڽ}K&]kb̉g&=8_ҩ J>W o#6s:*",7f^?u[#l}f7PCAt(;F1^sGb9?5S(^c5:κ#j9eD#:{Dpq2@j1?kϭ/9Θsr|_[s[ғ_7>R)ύ;9(3w>^Cj nH=Hح\grs M'$YzqD\}.זOtcC|̃*]ʝv✧9#ør6Z9r1>õ87ry9B^Ե L9k(KxFQ.&41_Oe5$fRq2:pw Fh52_ʽOsPNw];~@!Xg˷El?[et]ߢY~<7fܧ|{C{{>_ׯc |5-K~w#Ȥ˟=ka#+Bf{vtj/91f%;pߕۼfX̉$tQVrft#O6 ےY2 |śo]-ݐ.A]o6b`b/,=7°y?W bByoWjw_iv+Jǎu2btEɆ}:բm*L%Qk(Ẇѕ\e[1tT3esLl?FJy҈4V7o%w[izb̜QtmY]0=SCu8ڤI)W nſV۱ ?|Cʍх,+>ŝ:;!,7 يB1wYRYy9ctΊ_Nԙɬ1V/B-(_h7޽cVZF ynZ9>u IƒIAӗdۗ|yc%0w[8+FgjɿsI~BӚbu}XOw^\NّJ~יPLE1x/^NNx;C>g:ڽݯ֖Ǒ,00M}ηvݲT>Xڽ~m7$}y7Y*nF9Y׏ij޽M8qWpK|!f}%𯍜Ǎ /@ ٶˆ.;Iq.<"ٳ @f}:c Mm+/8S;}ʿ6 ,U-~Έt I=1Sq<$g#~^O$.O zWd7;;MdSgև?EO2ᄽ>2Q _vXWZa҃D[ڝNwq"W~KYi -+-rpLԭT=6#l{?-֧DH~U cߠVtK{be,{C>6!c8Dٻn1ԎMwߣڿuyZ4 -g_pAG{%516"z 0A!AY=zޫҪbm}X}Ψ ;js6TH7|a~9G/Z >Ear48b)+^^VqFnK/U~ڻ~m7HX0$7^Pr$&Hؾd,8M|f~v A7c!+8m-!b蘡ºƳ"8؞!1*^@\P;j1q.uǐ}J;}ɺ_¹ ?VziӦOqX79fR.]ϙ'\-Y3ѻYɈChSog#*.^ȑ ۙ4;V:^c|9߳]Ɉslƨ~JDa 0_??v^26)cRj#v amQy\-.1*Lk """""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""L&JBDDDDD BhܘA} ٿbj-{OXۿ]w($$"뛩pm4~·Hv*?y2hHFeɖ4ӫ*Ehշf߷,߉JubL>T'Bl}k4!Z.胏%6O;|̱ehWxЍ|U\LHndžT+~c{Ūɐ!Ħ7t}?s?mE ~8NKI#oxyS f'taKinʹ0C'-UkղHۻ"7;hnHȗ"]P^tPE O""c4"]Fw.ù_}gY~'oK^{ly(f$""""w99ˊ)WBpmDnDl{*D58qH'~S :?*rR&ˮql3!3f%jSQ'|_ȳjd ^F\NFE)d" ۵@.X,D`Qn_OCqW;DgSBͷ(~,1\ }FqF{c6 z\8cN#`.ۘ/6=r|AWM+#@Mnp. _ivk|q_A oaX%3'S YK[G -҅XcUZpI{{Zvi}bjV,BDDDDR㉌wfH21izv>˫ j<뎿!#k{}]?2oM{~V_5v>\vt%#,f%Es-/WT*3k I p'*6 \Jg)NIqw91oNozM Hu~#0{ ӊ4Di;dzk=RU%r2a~ }R%xJ޸9ؘs,1I0Y\E,N(}W+.(덃s1:Mp4&cѵK/y_ -fϣgϴ.՘gF# gsxԄd"p Rgbc>WȾӱj<@.Y&Ns'DOuDI{>FRCwu؏ ԝub͖ϴgL{U,UVH8j怏9"i8Y$ $o,?r)<"w-1XHhTE4۫eܿ=+S,M'""""r7/*v}=S{>˛'s*މ&eV|\7[?ڞk$]cv9:?I*oWP4^./rKWn6ju@ĝ'bי5_68Zy0vCll@XZd0@ """""2}=*>t,.kqlz Nh7/4ӯ-!;c@v`IXbe8ge|pI{ ;2zGc5fCVU#ԣzVDDDDD-*Đf>{EkKjZV.;hIHZHK* QafG* 5m8e&g DDDDDDDDQ }<?'7E1Ln[p3عt|}Ʉ#Oػz?+ڟ7>`Ԣѯ۶V"W#~U}#՟&ttLaʔ |TAyTѕ7%J-IA6f6=J.#>1p̺W|;on3g-D3ϭ|ۺ ;}JH"Vl~XѕËGӿ{wL܊X-um"hi‡o4s=:A4m>|{r7΂Ov_VMP1l"5=5^9[Փ{[t"wE(grzt-f Dst%x5`qp+`2ڳ3e)ܨ?9@Ʉ)x/&wrvGOu|17 9ۖM~Y| 2geJY"^m;{0L_0+*ubêLL'غ` /5ڞϖ8nMgA,Uִfo#\د91$G'Q6vOs|]>ܾkmܵ_=U7XZ]q\5P}+sEPx_'tLqbTsz^,ni=+ҭM.{W &oI˜Km}M| xFyBSa|y4Ԩ)[[M%L6̝lk>98sryš"唋YpB<8@NAǸ]\ӟ xWǂ9 <}_Bf0M,3W_q*3?{k|Į[/XnYR\/vx-ʖQϏ@fqOڿ[Fy?_>ۂ _cfV島<[ә;6:ߜӒ:2;e@M 9\v?/~*_k?;WO[{yɬ Baq 8hUڝۧ2v))$Fy50/9dPuj;|d.lL\-hl{Gx9_k?^̆Xޚd{՚d{{s[Tc.󸪞>|($џ~yFޘq)i䪞f܍̋/4J޿n=oU.쯬_l[6uW%l'e:hm}X/4m IDATvLu^Zg3w_zYޞޫy]]-9{ǼrtsmOY]V7;4μ)&q>kiw}^ڻ߰wbk=|d2,s7nkڴSPde鵻?1*̜y3OXK7_J%.!W5>maW=o"sy_Ys].`ʈA-V)h9͏d"|(zׂĦqlK+s/~מef쯞gdϙf"j)O)}y N&]|Ȅ$ _׭moY;-$]e[JQطu:o|a[[ǥ`v/7;BBۂ퍚1uj<⹜r ۵@.$_b 6w='hDyGA~ެ'e:hu}X51KDp|w1$&/gfB&,h6E/cD;cq^>n:v쯞08zRLTwctP b܊R'K/yϣX ]M{x"X8c:aERe'6õ87ry9BB/I{;YkYǺp [mᯏZ3LPvGb,xOq9#bWl:Őzu(b=<&46ŝIqX>+5[c/k%耋RS3EQ&/kc?imϮ/A|)w+֊f/vooVǑf쀳1{˺q*c~uyykiwڽ~m7_=maH/a)?O/BOVs>1:4d#Yi9nm*Frrԋ[:We薋 2u|۫,8QxhAܮBז|0ÄS)W +.F#nӬ>9Bvܹ!Kcɤ ڌKr2M>b켱ԾqBK/iU.7nF7Thð-sFľ" L}ržLxOE=ϩ{163?*/k;Pv^(ۯZȒygh8+,]-ݐ.d?; ^+YcPLE1x/^NNx;C>g:ڽY]ViZw|7RK^ XU}6#g08`D;ׯ˧ߵk߰WԵ$8ߟ7젻 ]gLCy 89I@vO84ǙS4!fAl3v&ϲg/Vҽ' >j2ve팚1gaNp| >J$.Ӣi8l9C:+Aɶ_n3w<6$<:_D<4vvܰ ` lێ G5B|0{jljt+cc~>2{M@hj?@NГ!0$=౰3fOZ_{u6fS'G k*Ρt{=ۍi#7PdVNv|*|\M%dɴv>}^ZYv?/~ת믽~^S^ c!1*^[j]RʟyL_c.*7*FЪOhڴqM~";y-""""" u  vI>Xׇz1 G{E, ']/giޚI|n꟝xi߿_1 xy?$\O"""""Y AqϑGᅮq0dOToLjY[aIetGO1Y\$'@a'OIl>I̵<3{դн}$uXvՌ|o=ys~y\iG.qr&/2yzs3{h\lf~ժmeL?(Bg'BU Lq#'s46%sLTTO_}^eпIP/&>ƌk y-olZ/KlZ7P";.YgQ8$̬bqU2y*J}F+ ud8t|+MHOuEБ&Z/[N:ܵ"py#;,3~[a:skREt2鹿εHGY0/\ ߝ߻"P29sf^ն-UYSŠGڴ.jb'A """""y,eoxu 1ߵz^I87t*n!lgkl7>dwNwq6_gjfc KeldPᾳ$+**)*nEJP-QP,ɾ 0f 3̜a8s]s}׹еg71 w6iݪpp/5]c~ }r\].XS@ """""Ii$8HO qwG>uCO߽8#M}#hхA;{8̡_y_LE/ck&ŧcatzp萺cJ ͇zv; JdD """""rўq?ToOq 9zJO18 ǃRX,_}ȆyLEڵ"Y~M< P$6_?(쿦! PY۳o, l}8Kt29d]qWtղn,i_ [u,鯿_un5ղl;7KfI?t0K:X֙O֯˒OdլQ,G̒>u,?lCR_K+9W~EDDD$ $$r0) J9IK| S|ߴ?{Gofғ+fzh%.>0k>N-1ߣE7=uC}ܮ8GE6ګK9pl_ϯ@ͻS7K:@VN]B3xw5uAw5H~ o8?L$Tn3l =I1iB?H&Ϲ1n߁ђylCݜԌPJSk{O,Q8l>ܮT(KN 7:ZvC(x;y+Ց?}ΈV{xaITbUhvw%iL@X׮ʒ>b#_!&c+^d;dIt`YҫfYCrCYAez:635s.KQIɄfxxu)fS =ΖؗNHAUyh! BC DDDDDDDD """""""rA """"""xE|||8&hǍǍ;!_! ro7ԄwdK+ }Dtqq.y-^[nAuͥqPN/L<I!~6T "R`$IX7M71>n%zt` :^?D|2.a va1siKZ!^7+{F 5Mt6wUNh,m1<>h&.Q%w Aj y͏/Ob;:'Jע.c;^d %jeSgR.R՞wN/m(LhB.yOn7g|$?]Ɗ%*b4mQL)(5wduYĆwѦJww2Իu$_~&ej?O}yT-t/5;ɲiȺ)YP%jkd73(p=[CRvD*ӘJ@ϋٕbTߗnSAhk^4/C_|kv'vrVpR)#Ѻ:;qҗxzmp[f\sw[miY:Qfڝx|&}cv=+RfF.=BrGvu[ktkVy+P}[/w?v3Wr~!K~Ae طoh 8+ũX#SֽokqEI+y xI)4s8Noe\Ǜ6=6sT*'‡cYt>Ie⇇B.sR\gOۦq|k;Qe k%!VZ`?2$]qw6͇)G=i9p*[x S W[qnROW~{=.^ך7Mm||[JZ_ھ跏a?I)IaMaeR.6c; 5_{lG̭{`t)V >Q׷`]_w^|x8.ncA hgu6}Q'ğ]42p3QGl\GG DDQ˝N.&_Iؾg-^]ߢ]TF@/8xd-T.M{nf>p8FArWsĨ_i$8HO p/`x遴6⚭La>lje;sv}ؑc_^eQo>C+Z{"|/:.k4@DH对NMT.= y|-TF|o_딏=vv9Wy+P}[@㮟eōFc_^e-!ԫҮm, ԋ‘|!'},g=ʹ[D]wT.-nxx8.6.F@P qn~p-kA N[þ$TwI?~9rxU:y9?lp_lxJ~Ⱥ?*X\DZus"gUh$ w딽 D6*9=PHF4vYȧ][qj 8ERJaU ?+q.O<cRʀ  S.InbSTՓRU~Hk<9=kW_$))cǰʺ\l}f,cǑ!܈J=-sӝw|9ޓɣ{ggo|4MM*#/T(s_?2y*ky B1Q{LdvݸvJj~l(AUdG)惛)![M xz]vK1~* ̡͟_{ai?qJ),Z}T3z}}}WrF|tF3˰/9xrkϐɓ+ſ?׃r*#Vԗ?L4n] p3MfLb>c{?a1}is7?SZ_8/p[/㮝xe3?cW`t IDAT2?h:~y?m\Zj}ƑycXW5sf˔(el&~X&/8w$"""""""?G?]iɟE7EQ\)iL`U:"""""""W}b """""""@DDDDDDDPA """""""DDDDDDD5j @ """""""@DDDDDDDPA """""""DDDDDDD5j @ """""""5:""""""u:C|||9gԢ֒.eƒƒɒv^2}[ҍ|Ԓ.R,St%~Ϛ?Ҽ|tg%>H{ږIKz5>wؒ[ռFKϭ%%K|er=}?Zҕ,i%}ܒ.e)l4@DDDDDDD< Q) 9K-\ {:L20hY'}r.zg<+H 4CҲ]LCJX.ʁ^gv"3oC ~+Z;-31K:֒ew%mBjҺՒnbI],,;,m^%ɒgI9/Yw%]֒ne}.[7￳Xͽ*Kz%]ƒe,ikR^x_{9ޖ>Gk+GZocIxoK?ieg |nor^u7-,^9s|Vq}x楼X)Jܓ _{[2ݳxEds=d {p8xsBQ[#ug^1Ռ")H.=y@7d[@Q#!xDs<EkI.ߥ'{ @ꁅLcp""""""+V*x2JOe'qP~/SCe`8'K>zM E """"""@柋'Cz'?71\80de3$g"S^2rcI/cuLwsq}+-i^ΏE:ٶ z9^[1ZA5sκwr<1彜T//Z!\z=X}\uK볎;^K5{c)^ƛ߽,_K}%>7{ϫs $z)e}[;|mR%ID$OCa:0%2a@yr p{˩u&{&jUEh6} `Pu>Ĩ;aɑKwI V*TBݬH5-O" AEoN_|tbm91ch{`:.tՍIʬ"NɃx'hzA׌DwxCN\'"ug-1?dtFwE.T/G#/ZVDsu V{\DӪ}5Bm}o A0y,߼g++N`pXYOt-O16-fҿZ. }ٚݸ\.)bY"]h\27Zn k;gc-fA^̭D˺Yd?gLe%גJپLD|3RsyroGv`/yq_bӻ(K),^_†L~)%$To-^ מ|5i M ڍWdl?׃o6 }g1uڵi4hMPb;6׌nֿvϟȸ2QMB*oyPo w}|p/\(dMX;74qÀH֙V9A>? 5y\e}[S25ti۩w#mT1ߒ]on1wO㕛 圔m1TA~s(͐]uChĨǫldPۙp 'v RDf<Ŝͩ[F}w )b;UBp}S%q+f 6~`y f݄չ ~kTk*zbZ&ԈAq4:,*Ow16΄ݘڭ/C輇iS+dQvEtUeOwNl,:>ˆ]:<{kw9qdXq=U:P@u#yfjj]xo7s׾eZF3^ ϗ|e'̼͌ۢM`^i/g3M n[ڮga`k~Yi_.WZwww%{?5GQMý\||W.r-5#3/*oyҮCFwfa|sS+1\Gg%sdO'Axz3o-=CΜvr&ӏ|d;~ИkS%*q_}%#f{y.[XFxmo9{01yϦR(gX1jçξT&ŏS J_~S@&2pQgoPp_pwvu{ęt%>k?]xZH_^Yuq2Hs=} lԿ(Tr/ +sݨI[fswLˢ\^q7qÏJ/Hk8pֵ)ɇD%%È iE' Lp~a{<\Ջ0ȹo'"ZQ5,s=KʂmU4vF b^<7$۸ՠ a0m6Xz^VQ)'c?Ut17sOy:c=Yu&&҉34nʔgg)xz _]] h2v5!2(Ȋ=IF"{mGǫ:lJ+oHA?w㮝ǸxuK$ _ jI۳5p\>~Ja9L 7L24j߀0~χ$PSq\f  NAlffe\fgvNrn5{2{i=re1{,ٙ HS/ r_|iV'YK+d*v||.r̮g7W0A6&refo\l::Ìlw˕`-| |jpWfȠ {=|wW}pɵ^Cl9;QRi|[f}8qyk e¹f~2;2o3cg7;^iϗIl?׃v&򩞶[*Gl\ƵipWk'2yz{ut`o@9WyvyߟG/xuEK ,k:Mf1f; {5&{}Ww 1A """""""@DDDDDDDPA """""""DDDDDDD5j @ """""""@DDDDDDDPA """""""DDDDDDD5j  B:!>>zAՀ˲D+K%]3p%fIW}jDKz%֒lI;,|~?o݃8~""""""q߂(p癪3ٿX gZro=3xs$aA@DDDDD@K`30XdZv+,0cv:.mF܌H^DDDDDD7E)@ ܕL˾ 4B@o=:.4=˒_a% taIfIcX1K-}ll<5^cUÒ^gIKs//ʒNr<ΟuX,/,/c eGrXKONr7=\$`0ؠ!TF7eݓ]myA """""N`C=_)C~O#@$I\ڸ0wWG@^o0{DSE'g֝5uΣuꂋq[Ee}y<{Oq @S hxPԹVGݙo i5qP({H^dL  la#C.WpσХD~yucFR,dҴK/{k4/͖t~&"#Fp\l]1Wݘ2rg:m"-g)-*N{*ܿ$e/EоWWQi.+fTKa)]=By杯YW+\O)nu弜;C{ɓ'sC~m)r֜%L< y rdAނ`$r(wFd@ "ђkr^9uTI{z%-[ TF) gZg .ۍImn-DP-^)\sXfE$ӄuODjh7|ƾZ!:9KR4!8z5E4 7O^GCHqW\~c:1޶Q1w0bs *uzՂxW l A.8f& GFޯQ41;]E,V "ra&UҼL_R8s =:~4t[vw_]AŶzKer[)c6&Hhx?I<1x2ߌ7!:A"[~?/M9u&v,aq )RB(Syj ZVDsu V{\DӪ}5B}n@f.єclY-( k'cژ7bNsa -os`R IDATREh *߆c潞>[Y1wǞQω)ٽܦLwl]l/I>cln\. b|^,].4`.}n_e˅˵3}YK'Gxy=]7 f} 7|koΐDygUxzk^]8f@[;KqZrwoGv`/yq_bӻN ,Ih{} 2,Jxk\Iib]nw<-u~%Z=%9+Sٷx RqWLS\= hr=Ј69B<> o_3κN[RO=v#bTF5e O18Ź)~/WRAϯnItKū+*\rT?61g`ܟߠU"/MV9Ҿ $ :@ډ9]U UE rwApM|,oGqTLyxv*Ds/~ sǷd[[*{Lxhu9'oA=ckФ߇8J=k3dFW$oݐ"1,م1Tv&ĉ]){vÁ<G1g`sjVQF5%{Ef<;ໂxRL|X){n~廏GsO"_7"Эm7kE类qwjV3Z@W]S6Fl }}1oLqhxWi~z;U!Aw&Ɣnƿ|APiE?R!8V¶_]fyӦV,՛=ȢRK=m3˞HXju| 5ruxrvϟryhGʛ_R*=eqz沽?{-v㟟Օ_.y VݕRחIn (pDjr{I")QeҜ"4@Jqq`&"ڶ@Qg/-yѼ[p0rE[}+g#[fV~Tt\P53x*3iDD0lLm:D洯l*L,mǴؿ*EY7oW/ iuzլexe*Y7)h-o tkc8MHUAaffr2~F 7GbvmmOhf".P/w#; ̈́",lYSClCpj3YdxGM͠ ﷣fڷL_~mXCH-3d.L\tG3]nz063n6yf_6.>nk{mrv+9ӼX@?w⮿˥ㆿՕ_.+T9b/m?ewkw|#R˸/6Oux批ۍ2?x +ϻ.lԕS<%3ӃZ|lh䗆3d@:__)kKKX,7']Y;[oFP D.~~"7[c\˩ړ孴?8\Ϸy8m~"TÜѱ̸{(AP~A"I? =uSX7%If㬅$F% 70O仭Y{*BS~v<[_>2 Bsnƀ sYeHҙw)iAfR9DxH+P l#6_]ffX'iy ec?{[zyyϋʛ_dبTk˫nQ(k^-.Wbg̏xuK$@leO4Nq <7ɖӁ!gNs9JfI| 7 /зz 'RO`ޠfD'6L獯]Y˟I?AcZ4MĵgǖZl{y..#<ɶ=ҽ-R(W*'^Ō>=y*@<[L'm[΁b=( Y'hP ws}8l#3oC=/0=''Τ(хOɴ],v+FAp\F *]vGe/3y"-%|zQ_篣PydoϘeh-|]VO/ry\ ?ƫ+-\"UCFZVh&X"aD4¢s$"1-Lɂ;yqձ$Z sMA=?b[8bY&E v,=FWEKXŲuX|ΆpyW/œ#jVT ǜsr+UZ|Cه%#~S=<q:y뢐TGd?GZZ&H~(td $/f )jF0ȹAE7ٛtZ!AȩUxUF *WƽRXUVYv$c=귧FX._Aʩ1m*2 jۆ_a\N~w=h&O](,hviqd7;/˶qulA?c㮝׸xuKZ=|b4ms%KS Gp%{Xh}H ï>k$:m﹃, N8u 1l̈́Sxw[]Uqڎ ?1:O^MC_ 'Ec+uL̍/?Kzф9(ӠϿҍbs$ɤ;SJ=5+U (JÇg0?W䁞pP#{NXn㞧W29È׃gGe۔Oc ){YGuzv#28pz|;7Ẃ>agաjךH'ΰԺy(SǟާW%Ī7f0GILۡL>\Mi'w=m'VdkCdP{4E{=m<\KL"ӑG Ԓp"?w㮝ǸxuK$ _ jI۳5p\g|ΠX= A)RF_\]t֌aۺ%(u /Hl(Bng N{ɹq}1I#Tlqynb>=ͧLփͤN˸\;&*"M>ckeҌ{dc',gg208"Mc$e~a^~ŦYdE/.I;+f/ךi2m{7˸\ %nq&‘I}x9Ìllw7N}̧I |e `ްSqw79713{]y)m~,޸'7.6o L8ٌOfG-buu?B 2;>c>yq\fϳ+ܖS=m3U:0~wkB3zُNeP{ut`o@9WydpC?¬ƠC;J!4?w}}} ǫ+2\W`Yi6c1{޹Tܫ4`3Aƥf~g' : ]Kss˟\e6|;Ӽtw98Pب_aʜ1zv'un }^XKΎpb """"""KiDDDDDDDpi4@DDDDDDD4A """""""DDDDDDD5j @ """""""@DDDDDDDPA """""""DDDDDDD5j @ """""""@DDDDDDD'kt"DDDDDD t .Bs,A """""""j !ǁVYUK:Ēn++loc]_cK:-qQ|QeO${N@e}UÒ-魖tK:GdO!Ғے^xz9^+cIhIhIWq}M㼬Ϻ7tU/i+Y$-,H*.`,g}=L% OyJ*o/;u98,з@DDDDDp ]qKXt!T6kvݾ5u@DDDDDDNp{ra@[e`?!M Cmzu9qj7=)ʼnѓxH/jX--iK%Yk|߿r(t]kIo[G|Ux+fIGyI[Y4(bIaIksSrI`(>=Cq9< MhxA~s ⑏3jFSWW\DDDDD$ `v^&}\x+=''J_9θTC]z": \XȤi9^8ש+."""""GUc@Ar're*Á;=[{ \/:7A """""O.C>.g,ktZ%eϲ1n*dTkvK:=Y-i1-鵖uwt uf',DK-F^K%YWsoâ8C,(ztaDn$K15E-F/coXcĊ49{w8ް;kۙY݀:dzS a&0)sfM.x @ @ <+26\d (ge&2-01[ @ @P,Q2c%3㼠" Js@ @ {䦒54({?Bo~m^W˽ J\J"@ f@jauE@ R @ KCsAa! @ @ @ @ ^ h7UDaƷ/VZZ-G0H#kg,6g&>j\ (h3=(6DL3MeZ-!GerJ ()[| eEke[e@pl<ȅ y׀%䅥/gS8|,y\TQrSzƼe7g:cLѸ$MUqwq%DM5lnӐqC9=o^um&n\A mG&._ͤM]N54 RX3!g&AUϪ,aLU 8 1@/Ry׏W2d|FW@:=hw o,cX +(\a OPI bΨ6@j'A]T#NQc>fѾ*7} [`-+ b; Otb??ǩ䐽|7i mּ F*hJ\zϢ땉z$x.JI](0hGm '=~hŦ#1@`L_#/de(v&ØnF ~%)0jnn?\˒A,  s CT\ͨ$ 49tVͽ=꡽wvé,r71BA`Vɏ1KG̩>9uctC&ौrڨMxX9L7? CagH-?JlihW 07rVxʬv `fAPRLUjhݭ L<&?Vɋg}^݆;rDj nӲ]g3qL +O]gjцǎ#h\gl{ZpY8"5L]߳Zp0@dˋ`6DŽ{2jT}Jeĭg;pZ- ͧOi'9+eep~зbR53lHY|P,pqOř|Mc# B}^#\͌g^)y)o^{?²ª2o4*Hd(J g 1'g3W\5hMqqvūv{Ʈi`U}7ua3BEMw|z|OUoWS-RnƆwQ֣͋?Z?iH6Gw:Yt wIŦT輘 cm2uyRZLLP'K%q8 E\u+Ȥ6>LhƆ rLw7`6bKrT)qU}Y5!^n^4~X9%.cϱ/ߦnej͢;|lvH+{Z0!廬7cpR؛# cFƣU5լӲe'GU*]ae?-'d>G(36k+͸ف2l6Lgڮ{(=PSˈ5Яo?#+!//89msR[ $bŒKO_&zps?<]$sςe~Gݼ;+Nݸ%\T"rJWi`igIğ$G<1{NJ |mrԣnH: QumKzRciRs.uJZTyՍ>^q6RV+iL ~Kщիn-m u(WyYq^ZDM҉]$wj-MJZX:T,VwFOW RRHXT @.^ #/ 6t+/O=n҈NK\ei,9зd|t:xQ)ߏ;RŎJ'N/:7-X+n^ ɭ |T`i/RQSN_$3PznR_Ok%6\7gEu: JZuWV#9_(aRi ץ™Ao"'?c~tnsʍ&ټGKEQ1ztCWs1|D%99T%ѼTVGB QGYqW^@P<~C\:n|;9v"WJ"6Io qrΕWq|\T&+S&W➲WQe|dViߘዶr4 Z3oɵEiO3R*fXY@%qǣ"`Yj<)R!ɋ`/OEJ#:d$ei,9dзaYqs:5wt%?>5^T4-s0x^U_}1+-//~@~S6 1 +lj$R~M+w3ڵN24#|]g;*-1{-p4ұ IDATwJ[?"pzkjk@pw@+ڏvr?%:ɬ#Xekly$$o!lB %\1h5q(Mt-v!F&JPP ̈́C8~x]:2]=_zۿŘԻDeowWttwSXW.y1V`?]YW~9XO!ni;03r`K:^XϝЯ7QPhvEګWV^^ 6֨Gr +IP&W  >P0(ͯ1e E(gS!Ug񙾗@b(GQf>B"9qWf˽wsu=}FR)*զe_,]=1ʓUlr/+":-HI3Ly:7zH\ګ)>/1d4>FWhoj7lau~u]?IA'(K9asܓ,}p^\ag QJ6] G;5.5@=)^,1P`Q|ܷP*Q{ӢkkF tټ$>&ɧj3%\qϧZrlY~|NZQ)UT݇/cgZ£`47+aľffJiS[ u3歝ÈbgRiM`l^_ xU|Cv)\]5bb{BiV q27_fޟԝ-QqׇY Q)4/S;|IP)UՃ ϝM:/;7i7o&dث>=bn;n̵$Eq'EDj |ӓݞH6\P[qi5E' ġy14#ټ$ -b.P;6O1@嶳xL]B+[Qb[/;&7vg`|s0zȒw=ք=2/nIU:Qϵ4JjѱؓJ_2h)k9#)7Mj(RvIT& Kɱ`i=RPDF/J/.5)ULrh1FZtMk҉?KcZJ*LR~mKZV KZ0C\=Dݼ $jz߹ҶV"SzHϋJ(Ca!i fo<,gŵ;&-`>~kkVO]L|7Iv?R[iƣUVj#3 O=&;q/>ۥ}$36j4Q 8J{1?J{_ϰH?i"˵ᜅKgioǤ{}sޟ\gl{Z3ɱ4iPIJ7JHfn%[^lҥk;T{(V^GZ4+GvJ5nT( c۫WM^0-/1jKb5 vޝLz]:CZ5c9oOiVO3uߌ[gݤ-6B3,!iRYK뒩JaldI /Nky@bjO ȝ\/;uT"qƥ iF)s%֕0`T;H@ K]o{:9P c/oI]\ 򞹴 F ZG @ @ @ )IK ZV@ ɭܟ{Iq@ (b^"c]Xb @ @$@ @ dT#y/{eo0}[f5h)/:ށ38RA{" EnOh1؝W}}[Q"%w #ZGpbDTIAXf|#ID<;/0'M3ڻ =p%A*ݐK0鞣[p__enSr9,^k'p[fPE6Dc,cNSܧtj.x ,H/˟ ?eTZQvfmDsϘz_MhZQWr P=L-lT%fgv& !U~ K0n  ˂S|+63fdT\EYV\|֔ ኌz$-0Wc֌_~Ñ_9`s;\fl^W>; j dAV>Ⅴ^&}{ HE3AޤGQ 9NbEeZ"~ Ke,܏cK)eX >$yh~ͪfL֊?!teoѮn Me?>b=1Gi7xtWu,S߮0g<P9aN|\bڧi y^) R/b:zsCJ&>E$5QSw4US$Bܪ00a(.pHNOx֛<˷F=CU:ƿA[B&ozKciqubs97 0b_tL‰o:)ۿ0\3>|@g35Y/_{oLTdG .) &c .j5]kufeew'$2p ̀{hDcgq_VQCrO0%z7NE؂I85쥌YARɄu=}J1['au7(^ ;Aa3hVJA ^A"reHR% K?: \56 | 3wi\A3f+|<]y)R.xyCDDs_,TrB_,}.$E!F8syptf.g)Z]VѨAz6eAN2nSܴrR_kBw}쮒 | mJq7#iR\Y{q]cYg 'lm]47z49+I0wr9s 8Y<9@Ru#leg`Ofy3`b@ xQtI^Gf_𞂅@EbjRm3ެ'͚ŐnC\J@7N}{giR1 *,l]dG2֏<$_Ð5'I`{6Gs|(U #nceY:ϙ;SBXY〵5u0k^p-@55!sM*Xc}tl )?Y'oXXRFf~Մe;*1`Wm茭UZO\wl (ExBij f4+8zV>IUޝ%V4>$Yȝs̘ JYC2guʤJtYU/2PcCR>r`C74D # H=^;@aG"ѼQ 7}TݜݖURbnIQ#q+M '`Y0-΢W X[Z_f/𧴡}#yul^Dӹпs}z2sϮ7?35*dyڝqבQ}$W+)PSE}c=Wٍ{0? 'F00CDJ[YTSƶ)v׼b5> o駍ii̦kX[Qv/fmIS0 ֥xG݄loI.hb '00spWP@G%׀`(' ,W9_to+ |e\nх@ xI/͍k#Q,GєONΆ{a 3]z~2N<<\^!HTsn%`mAz2Ѻ\5o*Z5 C@JsxfWmg>1-ecyb:[ HAGyl4AڼvRUoo_0t{+M7-t#Z8S2g,GǺz(ߺb_?E潼๨5qs]5pwXORFYO _QiTMDO%LB90ž/NsG#rOitߺYm~CNuwYAW|X{ 'f7)Sw54lUMmGZT1ƫ G_Ė`".۾=.-驿r˕+;ukWz-'㑤ЇlS]bFtƉ(kbiEJ{ G O ͫ?kh"2kf7Y1 &:k- `"<%Kȱg#cfBfu2m 9֞gUf"^9?[2&'b_sU֗];>̽$9G2@iaMC3MR^Xjvnyr1ڀQd,0&#գTqOuܬIk97d£2T٩leߴeM \Np ME0kz w;OŹ'8t"3?/ze?[m?]KON%϶rupŒT4žKqTI 8DqǙ:#|3KRV,We+!!KGzj(t];e7\PXR8`NfxڄmD;__HH{G|_KQQ}ҋBbw [54ȱb#Azj,vrƜnˬOugeTL6s>6tƀ3 Ħ@N-_!UCxK;2l출_PmxU}4Mg3wz>B/ G>yx]wmbĭ<|V/+RxpuK&PJO]uuD1wR>"EBtA2ݳگ\PU5z3$K%z&”ꇎ[ǏX1bo姟/J?"OYvԉ ܑpٮH|E?2!lXnp h{` Ɠ񅃳dlRxL [g,2+,2(KCͤIΕK0ɂԸ\Ei(Up ~Ԝk4SAM#&~А؝_긻l0ogaxۼƻmXٿ>]1~d_D*ˆ|ygOf'(jpri/kя5[Va([2 ݺz`9]ߢR5uU4\^<8?oW0(̼r6V.x֣2L) UvP<][Xm쀹>5~BƆ]eAG*)-,w6|VzYB=.zVbz+A{?}ZM6l3#mjFuQ*=vϹ]]~tP>w4,!kdvBIDATRo1p6}W c96o ;@p]D+{1&Qv^Vy хO^5D C9fjh$(LjXzK)M{@ `^"c]`ƥ+B݈-fD{{b1 #:EGG9:Cw-~CoDrpI҆9,O7Os@ 2b+9U>EKeX#LؚQzТ ~T+:?E9}EK.;:vz@ @PPXcvXB݆(1^ %\D3@ @j# @ @UC2IENDB`tremc-tremc-19592ce/screenshots/tremc-details-20171214.png000066400000000000000000004527311507451042200230150ustar00rootroot00000000000000PNG  IHDR<jugAMA a cHRMz&u0`:pQ<bKGDIDATxw|SU'it#mݥ=ʰ q" Sq! "/Cd2dohҽҦmҕAЛ&%ܜs=ɹph߾} &ڟK! }p>0^t Gy(QG#vaq/Y}&"8꿃G/F$f_#^HG'(3!ڹSƋjkaqu^ɻv>>5܈TRIAV(~\|ɨ6f2hOOhq|/7O'w$ׄ5_k*p^aS<ߎ!C+cD7\5qmv9 w2\;wꥅ'5i7(7EJIy{O{O'vyH- RxAz r0RcaV Ϥii^ˬ>UYžr"d%8tKN5i횿hVLOl-K &]'kw->#B0| sn(JR#Nڶ' Ǻ}3Yg=c>)Lp@yۣ߱ydy[ l""8 Kklђos"cΛ#"Ro棱{%V,6~b^o.}kEͷ3l\Oz83NSLD$}\?y)zk6obѢq\;QyFݬNæ&=f !DnߎGjnXOkkcBݏ+hmҞ+$p')˿]4sUv#}8ڕ}Aޱ?y>}zND\3WhCn/o/Ⱥy""csw5KchE*2,&/ޚ>ſ4:DăIsB]MDDlSWՁG+NѴdDD~#>1#3rEci5/’FhZFsOgoHLFylbgomK̈q;vVRVXƵt;oH4-܅Q YR[` oʕsƒЅ{vg)EQ'{Vrv3S^toz-MyV飧 Kݫ]A}ngw$pl?m=|77Zt}/;Wl~oX{V4"4t;Y-תsXOz\!+¾0k%߹LSr"?iy}\$" UŲ3gΟxSf҆ PuΊա|y;ѱKjxS?>T^ua7E;|1:%--#_neK 6PyLZvk͋DNFf6.};pw|܋˴?qw~"7|ejkY=uqgJ010_1?2+~]bO)"է}&۱Oٓ Zx+/+%.,d0+* K v`ܴ]M,߰nbcO[_e^6L2]cnZjrקĬ?QZs՘E1hџdTf;R2X2xto9v*s`Tŝsߵ%3Ξ?)Q/`߹q1AG|A}n[Ekѽ{6ㅖ/ h՞hy~-5UEYfM_)<浑:y;TTKR :1W="$]̸mNK7| tҀNf(I.fga_/0:d^t3~EIi٨e|ѥ`…2nׄN<-.x/oUE>Ϗ?< gZUƿ__ɶjwFwtBy/?{mbW+5zN=ǽ%kڳ(@I긝%P/)OrݧqPgݿ$aQXjI+A~tǾSttI])N}bs*;S?>9x{?z`v,j7f>îKﮤD{vI}.Ph""R$_>=,e/%*-,Yx+//soH T\KpeSmf;C Od9;7 >{Ꞓ^a]+WLS9kb_o>H)w8ϧiR}[5]ID$O}$)[}g|91_i5 ʣ_Uj qbdT-zƱEy]SJɸPԻ`GK]8?ףQ 2]ww+L&=ܹ -?^Yaޞh~-5?JpwMjk`Nq۩+7Nu9%r,}ϙ_xwN\?፿ r'-hEz};+)jթ[wG ~${A"Zy~e~W,;{YvȐmFdֵs-q"g´]2A߈#h;r|G{oi1eSDQrDM qb:9>׫U@u4NWZkxYG,wƔYw~kA FLQx)z+goɬ^SJnybDwʳnkgl|GYd?a8®S>1SZz<>[ .n.ُda̅jdMܻvQvvv3DMMjGD$*}ݳ?.&Țu1{@`ummDdXDTQQuXG7H`ૣNdߊk%-.?oII2"W_VZ~צf_0(OT$_rgLMwV.h"CE}0:Čd7EF)IwPgco'oY>νAb[<~ͨ5@XE`ie)EnވFIFㅖ/z N9|\ (Js32)3__XLDD Z qX[[JJ$u_8?K<ӣ\+;vh߾]؟{Xz 1-Od"b7ǹެdY"[Jv/Ξѱʍ ]ڢsVeEkܼQۖo q5V}'PGD;y3ExgSQچC$HdD푭1gzeNW&tbђxꛬTIDBafl<[ߐS|}X"g2^xi^}3ǟ'V[q^8_{c~BJl{L0Ҭ{1ѷ'<[s(ԮOrLˣ򛚙JbeS\^PPJD\ 'r(3N|SK ve; ֶk̬L"rnMMHSQ!#2!Ҕ yu&oNJxJ EyDsxKJΎ( 7WE UO$nh%:z'¢wMw< q|wS^y(x5ұA'Ȓ'"rf1sۙ<.^M>>\ύnWqJr od/b޿""RU-V3?<:~98٧{|ׯ6L,xq {c]9vaZVD{.a`_ao&;3rII]n''PiX /ͮznO {;ׁÃTtmݿp.Xmmx p"[ fDܤD)Y:9 &ub|&3=뫋jsfw+ K>UH$Fo o?< #ay+:q=ϟޖ=1ECC̩,lX:<:x!<=<"LNJ N@7w۶\'DgfUy|TsLWB(16^E#]tQjF_^9fFt~O:,a;p4i'Ok/Kɪ&-ٵ vbe?^Q822Q$W-阎/L냞=^>贽mqńHsިMUW&_ݽzG?hD'znVN(;ȬcgVۣLˣ Μ#E%_̯zXELP[fWdY諣)*>a͞TpJ|u}|v/z%VAQ/[p f>~TUӀ&4qU{OX0BG5&\W.)I-6%E -(=͟ΝhsXǗ_#}h+"]̬,jYvƆJ_>5V UQމ/\FUYV")Xo,\'Z/냞ۓ;^|?MolAm݈:l7.VaۧoA(iE&]:|Bwŧe&[9oО^Vm35ov'z9t 1tpow̨nOyҝkZEiM`eʦ+}︚rګ=.9gĂu]n9x*,:9W\ 9xw7bXO73.,nScm9+lϗWm'#S $|S]z>ڍO{&~ffVV^̮MvLw{ʯߢѾ߻kY92 vvn\s~'{f|VV }w?V_]rQjj5( Z=:!Ǡ(;o{>Ȕ̌,3K`.A/ ^آ}|=x5 STE[gfku~'"f+v_̕L:;b찎Be Mǀd<}ؘo>[ g֝Zߧ"SRK!MtSHYZ[ZUfVB7w[NޠSxE}s{JNj O%Z~Gwݾ} Ex&)w.XtP8˥\9SKr׷Bqo}c}kk] l„ ^h\?O\s0^[s׋#AsqDV}x3VDDͳŒVbfݺZgg$Kv w0p%w(Ej3bt{ @1.7N?{NKW&0hܰ9Lv<} oHZ.sѳF #+o7㓙ĥI++/!D\aKY @+z.ɔymdelPTHE5/YcRY_J-{LqM÷ܬhVRH>!)KɌL<:عp[?p}?kG@o v\S*96-#Ԧh 旇y|oؓĶ\\""#~ׯ' -,gUeRPܚy۴i~ŵ_$g'yiS<s@#k32lW\|P$$"b4-*el-M_j皝1OtljҔ\+⺚yY g{7e"s&_/;ﲴ"K2غ-k<𯜂j#ϼd{&2hl#"Rλ,kfi5""trU ocz;Sr̽LHSX*>+ܗMx&f^K&`ZF,6>M m%cܞV5ƶmfT]"0ϬBE&%+f}ISWkԊEY_%e)H@Nۂ=BxBYAUJ( )ˑ)7SvӜZؓw_TRj).,qv N))ILy^>[ѻG^æ,T|ͿrT9Yyބ[,5qi,>"#(x4&֑BK#{SIX$٫0ejl1#Ҙ@ev3-2Gz+\rH9ɱo%[yX oL(Z^0^ҦA#.ޞkc31m(cTCuYb5ng@7Z{X|{<L';xŮhUIBPvKl<"☺'([Z`Gk~tKl%7'־ J"c++"bs_mo4IyjʮYOFNmȷ_'k@Ӥ %)$xw4QU%ǖe;?Y Kzk7*S*'b'_Qť`-R3#>Y:?ee?WϞkg(Qt^8522e*ZBrm#ʖ*ܜi27SN5+he4˺3)$FGĝ%}imzovu\WwKvG.2!/xc|<_TP8.,G!"TʈLȔ)R"+Vy6=;X!3[aJԲ>4Uh<+v[,COD<KI X%?Ie ^bcVKOe!.1Ƚ__~.)v[)9 GޓOYyQ\>;Ŏ DƎ﹘.?U\/(rp1 g"/%y͈V] [rNAZzn]M?)<Ӆ o_0V*[EF\K",) v4*'d y\䜁tДV01ЩM\ρ([V!׆߮Ϊ#Og2"++K/k͈SO7UQr4q:]V>'lOv~6o{(MU"F;dz%ʯ"ծԌ/jH_VZ9l[KٖISR.).:S(x)hEǀ՛ƶఈ4b\XYLBDl%2VqյMHy?r^FqvM*JnZL-=C S~L}vE5}g\Z̻ERb9y٤EߡuiOA+K¶aR=:ejj# Z51.29y[ANg$ϑDF֓tudkaY[$߽gbC|Baw,ު"kWq5JZcfEK^{ /RG,QXYn^;t]Qrg~v.Z_IY5'~5e/,S)3N6YQ8𾳩F^q ȺAQ?x zi5ob2LODm-gbloz)f&>ӊlmnoatⱂ<.G%*/K4,>AslA1H̱25s*D* gAgז_&]K2EB#cgSGWVT*s Ķn<>nm׳WҢǥ躳┥J#]>=fF!I]e6TJ"ũ'IGL~9zl-'\fRA_"IJe]D9XbjvpyYoʹnfݬ^s9Eg02}gKW汹}=([h$ o`=.x>zcFr޽G/y>s׮<0V)FUlF4Ǹ7pqVqt0@=>̴.-- Ko,-pr V`p$ iI+08BY?`wCwؿ~oQh'tў]m֒%Wf~xz}O`_5ko9|ʺpOxTeGcg?k"4eD#nia .<_=4 2sIA%MNNc!4^C ʢlq+xn i%iF iX<FV/B[g'q(++#g%;U<8TxtZ¹ې}qZEy1'J"{WڛX[#O 3߱}254JX¶tӭo+_^A}ASbk[_`ع뀐nnNn]ε:vv&3 7}"XUwLkY[ kG.͝4yyac=y_Vt:\CDĶ5wѴO5 ݽ[ھ[S }eL'};>W "m࠙ qsW}Y?+)>6}!$ʅ=C|v|FN~ f rfئn}dvE +EIc#i)a.{_?z'X&w^ *)xxZB{ )Jog[."%UnB: X6]'ml37gsNY#|-X2`ԴQMRՊwvo>U "$"Ul~NGCIEʾ/,hޗ{%VeHY{|b"^n-٤Y /%aH)sQ!tF?/Djd,x1BMDȸ-2=m Q'ΜoVޛD}wDD;Vd[alFg# G=q۴hp],4j=Hc𚌕Fs]}cd*эzpˣ+Y}[6]aˁkea<ӊeyA=|l-8;==-=%2smfo\t R.g\} 2Y%Q!cۗh'3fllԍߌ^f҆0xQx;[Ir3SݽlzGSvqss1iRaҚۙU5=49EDh9# T_GzC$%+YYyD%|׳q1U4c[sI3D5l,9#%|fzM8+NZu1j^8Di))>yX'w9v^6){K6޿oN~.B VInfVF/7}5u~yΰC_\_nUʉ8}ASHRn=dAs&= puuqU߻qȡ-9/1_^>nb5611߷o~4u962C%gn2i5{{n(kRWLm ?O+WovWVk7~Wi׭⪦/#-!US^eEɺzdT%$?棗HЩיfZ3i~!R\`"p&[a/rOvG*h[LdL򂜬"S;/vݯMnYD-ZުyLaB*VwԳw&N=hBOgKGZ+U >z9OJ T mGcQ|G^sZg@ MT!J͖rD4119t݉V݆Ö(~jS=3l&髿e8E- MDѹتG̬8,<}W@HK6$ďԱo*<<}mm N qRZTadտsh[9Ѳs^ qRZPLB~zm-G-Lz{e.,"RJMBw=lYٜ:|~yX!\|QQD^+}q˒b[v8fSԔ37ԣCs4-0/1gSXFYZX$ۺsӷߛ6;Bgv<10\yjy@BDd,pwRi^r^˿r$³[7!HFZzG^=ʹw/DT)NXo[~:Ņ2n^}&Tjܶzޔ*U5MEE zGߊfe{#:ˑ存2 }8lMg3*k}1]I j oKyp ݺBrU#h;w[)P֡-l/nKlGѯ62U}+mgr7uࢯa;V9SN\+<Èmg/)IܒK?~v(rj\Bϻ'-+ :,s?J\>É+K&v0L;],~33SR&O ލ4{Ky"O *,L4:Xw1'FۨON7Q;٥J];~=g.Vxv}ؖD~jTRZT!ϋ73ԭYn>Wð9c'#"(_CDdd_w99]_?Bѻ>ys>NQ{_Zg~tfGO>(]>@],ߠfgBlإ+ҶP_7}_YvW+靟/1dkNV{{kF}stQKSt6 ve)o]=]>?_ϫYU.{-6~83[㵼H&"qs i˲+Z;+pʌל{1;?0p$um֎w8ùj/odiedgkCDEEEDD.c& ;_t8>w{c_+joRZۿzZI>]/~檲UMԎCg q3̇G>n}vY2#{Ƚ^sI+211#z|ҪoĞz_E2P;crc&7UpZ6[ۊ̎ U m:յG`n@D$}Ór߯4)DBW3ڔhk.*ˀ/1wF/2깸7ygx/:Mhhc4?;X5 \NPswޭS[v|x=ߨԾxd ٶm+v^:ħoN<\ i~=S&mN""rvvt_Vf|K6LIDd'B;""Yimi'ͭoų4?72VMc_b{0_.QΑ'VZz<ǟ2 0#"u߯~Mڡϝio]_7!ލ_^=#=WUNVOiQQ/2qQ={Lj2GF&XUcxit'ڿٵ&JedT4VTM,(8JznesEIz;w"ލMicKDrsn☳5'"WWS>i'HSÏ$3|$w%oB2>{Y³/Ng7je%QNzJL6盟e}{L6K^z=/6ȇTTTqM,Dڴ|4y2HJdil7&z0g7nޝҌ{1a/F1n66DH;n llH,kӨ~깿QXԬ c|K>QNvLJR1|wǟ2 +++#*?y 'l˔*H@kWOHӎQU~i{5:O/m~k+˷$@*oMSUU]WxcoGW-},l)GND%%*"#"" 3MJ!r2IZ)%fMKr4 gv%E_Pv]z:}ldłIR"]s?<МjpʒʊMd M&}W?y=U)]ts7wPèal5eIGeG޳񶆈vB6ql66W-& V&mDQEmVi _ KAA6LpHTX/o\?ZsKUPD`O0ks<3^}탮大+͖4"_TDdhOFZZzGzkraDzi7fN<">סhqh X.󾦨ȒemmE$""enn>;x< i3֞6iVO*4JiVLة}6{S <̓JCD]:53[^CDOOWC-^fn! ,K<NJ?k`疭HRR\UlLTFlNƪ3mA$&vit0%IHӽizocKD$h}@ǣ3z_U\BT;uaRDǩ;thb7qDkv{t^=W[+ooSf>vlUƊxƌ0<)Ioѱfh̜'$T.Y;^ɽĄ={V|wF?[Wy;${DD2l\th$jIYo6}rSg3o꟏Db@=eDݛ+pgefkA&hfQ\^݈H!i#?n; ~ |kY~6%cYX4lѓҋ//'"GG5բ+D!ӆ7g&qԁ6D>x&-~É <`F3nIx!SC=G(O=H~R ͮzohY)Ms"1sy4Նf-8ino "rs엿+Kz=z~S,\lyu¾?_bVo7sMlB&/v'Q͜<in/L#դ~pr}0W/JFAMP%0vy}ጎ1i鿽z鹿Wz?iCsj;Ļ q 6m9\٠*eV;~|Atɒ%3mhFqu~u/~쪨jEl#_lS2ʱ>=\3lVY?Ֆz)Sw&71H s5|'1űDKnn_a SJ $F63#E^oz-,ڌ^eRZ+.SjԊrDR|"9<~ח/U]&deH&6^BR˗;b>X{ɽ;4KX4ژsXDji_}{2!]GVuL_?φ:]ٕ ]vbWu}g1ʢB1H-gæsG}[%s[;+\}&L; z罉:NZ+Q1Y YD{6n>Q7>ʈ;: 9Ǯȸ{s>`i?ʈ߾òA[sf~%tI#H\Ϡ~깿%sYog>UE%,k5MG^gf Qǟ> afYg)1\[dh &kl-|oäRcElnGTy֯iF{eXg/ `l@DWZƑ/~8_{~ 5>~jۈ݉@ݻMiJ#SXXnը_C>>m&w.R~+IrH.K}fg5>dk %I~7%Rh;/'MYĵ2>{gZwU{G%G5Qܿ&km~@ M}aY?9/Oq?7Tq\@>[ZW::zbbܸ4O̭=|eie1:xgܩ?UNQ"pMMJ2]?};=]UVrɴ"o[M-k ovyuլ+źDOֺʏ8Qljga157Q^>Jp):v:A&)MLFqǷlڣG☋7D>jʀ5EQ5߄MD5~@wt|yF'jVJ;&+ޝ]wG+w d┤SHQ~$k:UeYѷr*4&<*-Hthш&22Q\Dtcjnnbe*$iQ's%Rw*y8]+Iv Zxim6::; z?WNٙm'5u7 <½{d \9џ7dH-#OMD5B``F8'tHD,ɷr%RD޺|'B`nH{r݊X6>z2*[.U"V@@^ssS{u^W뿧.˸uO'J(x. ie0~v]62 _t/AqO=<=@x`̴$ iI+08HZA V`p$ iI+08HZA V`p$ iI+08HZA V`p$ iI+08HZA V`pB5H !gV-%i"K4G0eъgv|/upX8x~8sk̖VEbL&qtWDE#Gkԛl9_ܗ03 &~üN_GGS!"Epؓs/|nvwַP;>4׷U_ }vM*A_!{o~W#""mh]#}w?J+o%f"^O' 'N^Ho@"PB EnW<|7И}9h7ʈ䷷~ݍ(刃NyD}RѥWv]CCF Bh҄ 0N]3^uSSϨck*_/yS{0?x+<p@\?zvX}o|4Ʀ#FySX-K p~ h$C{y5~=M/ ZÛ*9v5WG?7O}_^z,KԮ oyx_oCkV٦גe^\o5{2Rwòl7aԬWxY[k̬qw߹J/]/ѱEh](ש[^߫fZDƭ[[ѧd*N!QVDDus0Xw9xgFUfc&;/{GoQPݱmҏ{֏soz:;;_Y;ԩ̊"$"ҶڙdʙjwoQ=FMI{Yy{U$ŷs/=g֦*e25,nڌK,%O_<9k8~f ;{=[Q3od7ܕ}YꃗCNt4q[y&38׎w(;Reuϼ??AJa?fJi ^{u xcGD-.WvߚֻK./9#ϣ3s|n`@Kde=SGͩ̌L""n/;WTt䟋u6eV'L)u̻!K3-ɚ/oNҮWjS]~#SSSv3f̘1cƌ^uVAD56o_' @UF{ulflo8^ƪ.sIOsk?XU{ϺWMȒj⫛ceull[[j52V:[mY-/wSdjwǬ1Y:@'h^(qI5nS'$[s˙Y#8 ~I'ίo8r=>=3] H~E{._OFV}Q3_GL7h5Lr|]&<2D PDĪ呑DY$4mvx7~S|.]3oOuK#~;gP.Ve}fRMpȨ!5{HzjQ1o|J=FB'''gWv=vP=Q2]י_}nSG_x\3Wiɥ\2gƄa={nW/`wYu?L+/o/ 1Wms׾]nUMCEɓ)oIVPT5Rm~㺸 QtP6~ t'eBz0Ȉ]+A)׭Q׾8Ș(?ս_iW?QE[iO, 貊# Q̃شcW&7aycf#24yL &%V5Yj^356*,/I*5\}iov =+/&"S_EȽ}}*tlc^mx!C&nEgd^͈z'*IܝDY=SezXl6QYoM_v4S!odK$5b{۔;ЍS?U&ZtNe/ݳHzXe?saM"\t_у:p~Tc_^UHo\]]kٖ`fg@ %,--oRPP@$fAnXY^C_D\V?g僆'mV)/~.Iң i{I42":M!QybV~O?ZjJ 2ܺwW^s7. Ȭ.X\z?DM-mCGFvz݈},ibRg t '()o[ʛr-y|E'J^§*%"R*Xyi!!B n쉟/Q89S;Pyەju3 n;_{<{ŅDSJ<=`UN{SߪkyB!1]3w|@@vZzkݣqSUi#Dy蝪W8}^_1fyWyXG'_ y=)f寚jEDD11Dڻ;EGgfegUD#uvGG&vX~BPwRFɹE/X{]zc28O&FC<u/۾QƊkl$pe^fBBpw`2l(;z榤N4ع] JY37c>sfZcNNt*7%=qr`Uy!G.u[QSGz/ INS v+) jP@d֩RxElLl6|L*僤QSqzw/krİ:)N|5fn/ }O^]՗బG5`?+A?r۫U|꒔H[nC6v vf`v_'2Ћb7H,XDv#n|sx*\89~O2׏ܿ:@t=q˖T_=;䱿|nGu)S^|"gYnT͔26iu%]zҍԤǧ?nh~ybX~#_{DD9Ygȃ(4-i.\]$חM/ ˤnU>~eWzzBO#)aG~X<;gv&DD6w7:xLe1ać9LY SXeuzur!"">vbʺGIP#z.;3zw4.h3gzi c~iF$noT:ɳ'䥪IAiwYq}Ũɫ/k7~-R^ZsBLjNJ^YhEy5w7tco,Q=v_%qL.=ǹ=8WnFݜ}8[YQzbT\VGbPGLq|zɴ}^\lۣ@ئc߉ nTSgͭ߮Ip?p%Gֹc}k~ҥ)=ʘL9F|y?gb;F-=j~v}>;x-滋q G_w-ʪ}>c;'͜zS 3ZUHwc77=~q _QڶMU]p7εy{'G. :s5xנS[+Dy`bb"#IT}D+g?~|cqkr%{n}lu&/V>:Ē?kQvK95h]l,-^~1 ""Lcn*RmAa̗g'3ȈHl""a [؏$,QuYIZ£WG4ʵE\(#"*?LD&]ᅡhUI<Ʊt2jFQu`='?W|ǎ%|_ z2No،_iR;Z5S7˦ lW^Q s'Nibqۻlzcӿ3L+c|3]aT6ud,?_"z}k&y'_*jtO}Cy/G۵\e+0iѽ毊_;[\g23H;{JÍ byFx2m|1_Y?dm4w.D/ljL,A}Lx}΂<4ajk^iE5G:#7-\}||7c@.Š$ ߾fMa&~wapTxt*e׷p1X[Y:ex4 ؿ $5pP쒼kK>i#Aq#B;:ۚSYaVBąNFu<ߩ_iͳqгuR41ќY8n m=I4!SȘ{y;U{7/Ön~iio v2VټnŌekW`oo[:U<}rÏ>k'Nڟ7Ln Vt0v6VlR L:uЁolSI/kF(!;n|ٻzGu4eݼ.6IN])-..~ț,c +3Udg7X4UKzԥIIjsߞC k 6>^8QHr3RsήA/ 3_}7/}zݾo/^P֙KY͉HY^R*4ZG)>k'ڟf7̎Vn @TSp=[8v'\MD\A!Ϟ3?)gt"Y&[7??qJ}\KPtnin EIV,""2sKxxo-nVPٕ~PR no.""ߨu8ZܭqK̮Byɟo!#":xuX"Mc_]?U H|,Dp]{RaVg/ЖoiӜ4^ۚAQ4ʡkر7{Y:)2~k5"2VDGYL"~Cwܫ!|޲d 5"KDȲ^-NGDpZ}Ac~V\zt"ݛOUH-I8îH۾{6uvpG,ϯTHmEdaڌf-JZ&77f~y|",+}E|"*).{QgXĿy˰}0[41ow@0{t߷G`?$73+#+ZeyA=|l-8;==-=%M+UG lז{jj7qmDB:= e*h[LdL򂜬"S;/vݯMnD=e~faH-UdbbUKs׽zDT\\DgXLϴ}2l jyfivW#9SK -8ը|RCK_{of[^}(,wC+6ͨr3<)Uėڽ`7eU{D;to:;{uGL)>\"_(oAAB"EBR{ H$Yy]Ow@g' ! VX KDP(Yk֪u*Jk]Z=.8EY= vBb*p=y@4FO` :Bjfɕ1g4[g走w:RZdgc<)ő\Ba̙$7 ={V6fpuT f/JWH{fh-xԡ.z/B{'X΃f|9{ȗZ #!T_ 8rtsC0Ξ;X*CO@4|V1A./I9E2g5Ɵ`{P=?%<>hF4^U|DxB!0,=:u&\1pq~Rde;mi1Iɂdg |iin‡Yr^XrWvw]߈A?w{'W/ݍ'Ϻy@=OaX, nx[2?: .Xl2ØE_tcJvm+Jo#Ͱhw͖#uV7_;k7JM_yλ rO Խ 2Z3> }z:+YkkJ<*.W ^_$3[Seg~X?u[Ά6K' O4>K tGdgg$fV<;ٷ.,iڳ:O=nDDQs4h{eL<ޞ GʌW!BVEZ%nUmfv!ϼ+iilnnPKf泧uF95LLZQMkXrbeORp9\fV}7<ڣe?sNo6]u0Z tBrdc.jeZL=- }g.i 97m\غhſmzd'ϦpT\,*V8ѹ ND$4^77.2yBo&۶{=Ey1(ףA ?&@ɕ'2JQQ]_$} hrJO+ @!HNi+̌ ,-mn-Ą$o<èn }9y6Z66p'&,))ͬ<9sV'|*/l:ْHXd{Vwp}fo"ވ_B!ԑwΎ H f Q3CG@SѓMnԩ3. ⪲zN~}˿?>Re+@ǐ>3؇Ȩ$ǿ-53 ={VujLlmmE.y'ݧL0is~>y"^PnݬjK̾&jEz}ʹٹq}rA]qgWM{h=?~ (WjGB!:&QjѮ/SQd]߸e?mI=s[e/+zNe:MQI8~78w3TSy}lX2ĖRp~ x"D@uW-u?}L2pӜ6e@YiI\ʒըwfM7w s#%4nmز~筁gu̲-2'$q^`)G]h~zZ@a[黾MCp{3V-`xHT?p\ip|!#Bu=%=v0c+w++#-y,*ᕿY'q(Ftנ UP& -yz15*)D,-*XpuH2a$Ի͊ffgad1byMP,gpXt->>1lN^$`4?,H&)*iA=q{5ٱ2wČkMYIbV@;= /"ѡGubeө$y -jԞBh{&bWo4m{@0^_3B!PջJaQN^fֺen\:(c 4~)TMй6663h;XFeo\G.m^yAu26{9QʅI7oG_8$jI=i}!}ؙ8ʂ;ogU6e_?Ͷ ϐZ}"k:+Tz @"8›n˳U s+mIxA I‰_]bcl6֮.~ک3ܞ#D _Y+1D4^͈_3B!P!q8B!B!(B!B!8B!B!BV!B!BH` !B!BiLZ!B!B!I+B!B!q0iB!B!4&B!B!B!B!8B!B!BV!B!BH` !B!BiLZ!B!B!I+B!B!q0iB!B!4&B!B!| LB~> D;)d5Rq^꓇w]Z&ǀ"B!BH0iWBu TI/Of2QRIAD VV=BF]?bVmfstѻ|VtبЛX!B!V f]|n;'Aѥߞ]ŗ7/8dC u}:Ugs+ahxS+B!B!ï䕅ɑ,^Ut _}lIj"HKs܉~SUB!BJ+DLY_F7vhqMzΘ7ՏY'FPg =~<ՁB!BJ+DTMSr~n͵ZIs;t܉0/B!B!0i+{:Հ~ox!B!B0LZ!%&\fXt?Z }Ϛ۱b B!B!{B УHQ{rœx; 0Z!B!"VH--Xξ}ÄB!B!eH \.ԔK1!B!RLZ!%p8yy B!B&a$݌@I+B!BilIݸšV43Ϗxu/a~-F !B!Ba B2:eR}ēdЩ| ;EÆB!B!0iZu8zPw6$o?>?u`B!B!D &>A߮ ~.ӘZ*IJ_3/ԳDeZUYoQ­ T+TS~߬!vmd6144kYg)ƪF!B!Pul6kT!?%ͬOe0@.޹vTO}`krO"HUT~SƯҹ66ܗ3B!B/HB!B!4 CB!B!4 &B!B!B!B!8B!B!BV!B!BH` !B!BiLZ!B!B!I+B!B!q0iB!B!4&B!B!B!B!8B!B!BV!B!BH` !B!BiLZ!B!B!!xO0ܧ-Em?Ia@j7G6y "j0:D,&/Go!v¶!u77 S*~89:r} fh,{WM_vTmI5'lxf,qQNJȋ=.cC!0iaHz6XQKrR޽q7GC? %B-NEʲn4AkNw[tLFl><=mEɋWkzzNz6&\cZ+R߇nBI*B\]~A-` PW]"if]m ?B#`JN^0[}\.'ӸLnWlqqI2Dfd澧Xܿط??>9+o|?a5!ZP|noD{߯'6**r2$9[*sRb;~񱸮Z vu4Щ墼G1N-8>oEbtٯ/W> ''+i{%Vo>imo=|'N bңGf6=iU CW4QHЇ0O }oWc;E1f+;Ge=tSNuBVw`[72>K,ll<-YfǸY3>d}*KkfVԾ=g/ ~cw6oψtcW|7ʁ *;;ߵkT7_/nHL"*#{{}ϟv^HVY|ނ4o`> ҊbaAѣG7:Px9^kovۛFN=]/ke÷TVV|U?߫B_4٭? @xߢ^t"OB`Jc rgdz<2aF0#Ȟ=O"r~EU㘂8@t}=2@ݭmmCQ{ďY4'Atw |%Ҽk;s#Jd]KgG/97,fAKVNt֑<9JjEupY)>H%² ]:O{vDNض1q; -a^vLJY~'WunTc?bkccageZW>uaCozDaݽw/OWGHTuXTfUW":?#v;$٩o8~}mߛIikkӴwѾ-_B) VYaL?^t`7>YYZZ .܀d%ٯ?Iߤ> {-Ib|>s6*ru9u ߾i߽p9/Mpگ`@I <ӝ,J?ZR;v ;ݺ)>XlWJJBjϭďWO5/@DduawIǫho?>?utTS =f ĉjUkĵv\ĆU1g d:;}жR-/(h,P^Vv̼&Z 8< ,h'<ʴqr1Ugeuݾ6%%m-!eCX@m[h6vvd4Et,Bƌ%R$.aYV|ݫ7]S(}JeF@*ɭ={u.V`rڧFR*~~@:+*=-l>B!4LZi iʡC& ]>1h􃋧τ_{TOUV.>{2lq_G5~QqšUGzhW, JROb9d!_~>kͥG ;P&v.T2tdzNgx;[¼{\ϗ$N_ӛ~(wdr _t#&b}6 2`w2̬2ŷeM. >uqHSR3۴ǫnꍧh{:]/q:wf_vu$]%;pvX,x<+:[Z[4Ui0̓&\-)Ö,ב&߹t\n /f~ܹǴe%+s_1C_~7>>OעuKYCx'x&CϏC)yR 1F.Y5O-˷PS%`˰מ_l٩yXW=@*xY$9%]{jԌBLZ}Ξ`}k#Et~4 h+]!I>eJdduSabDۧǟa ܪ;{7{~ !RC9i'W.Zotfh>_L7ROnb6!kaSxN$3[Seg~X7"U_2Qٰy=f6T\ǽۆߟURtGt7%d$>X9wj۶E-\B0fݘ=JتpF xÿ0Ot D l.uwd ˓;۱q1=nf~[ Pt|tevDxMTnoKC rgtFW/h MNQ]`knq%U\U%\םmoo<̫ȥ+;F^2u}Tpx٪CreRI+W^b6%vnscxlR:nx]Ն0f_f82r?mԞf?[4:}A3zCߙ GBM[.wEVt-. @6r \exq>S =mZ]o޾Z8QCszʮm\z%JoHw[˅\s/2(*#+Ͽs0c~_{,zò' ).@X=ik4ȳO33biommO6ي2ZhdNIɂ֝( uvSoP]]NT%d RT!o2 GZS<Ώ=%Wl jB!y0iUyq]8l=x=&RY~6WE~;?Є,߳vq۳֬ܥ~MNbϢɾ/I[7nck6p?kS `?u K2m@ $\8|nɿZRuur`ZE`ǍWU {(CbW2EWwklJ7;^ѭ'Sǒ׼qw}6}B@߀ijyjRr3.MLLSJvpQYY)kjJzy0; )fY v̆t:9íDڛTRyl=[sMLMLن@gsO󣃃# @.HHԔ;E!x\;wrNwsYӖfaӅwtEi@dQ;؛LOY71fwj*n.uYT$0f4bآ22P:yXQQmۍ׉\/nswS[[]ȨR">mGͪtI=TAZPuMp{֍O]=X | >B!͇I+M'/zד;&,!&;dvb]raOk%o(H2]p}v2AJ8x퍻0$n>y).Mx$b8sFTc!<7@oM0Lgݺ骺Aݏh{k4wlw򴴧oOQn'7NRl.(+)Àp1G2udܹcK qm~g@P VVLS}H,'%`',jWoyRpv?՚ImU}9y6Z66p'{_T_J-/57B \ 91D V&^:ƞ&}zJF% ?jf_dfN:sy j7o+6nYS&L9o]gU \!*,,,A ߱O M'@E3wud[W_G'B6j>,w7+/_(/,SS3ڧ B!V :]C:\m7{ZtH@5:lΪ=P+8r<-?13?_6+S.o]*'Gjل[ƫB.I ۏO: Um_NMh輏R|.]Qvдn*D2z[>e9kAgn_yk_5?&/YEST7B@Pp?Κ1̝;  B"(*]ڵ3ZX ,kva?GZ`GUSg p qLIq~Aab5&$1J+^LM"K+TG6db񲊒j!M䥂߶Oj} Y+C^Wmbhhײή9Igf]\9LlY .~zL+\:e}$ (߲hI3s%\KK,=_Z+WMvb՜7uuWDm^p0Ex8^uS"w_D[ie,٣=.1TX\6 6vja#F=7颂"f)novpƝ[!=L&)Uri wJYɂ{02j%OrŽ/R챃]?^_av^mcUY7p`Ep~ 3wm bĢS17f\4KkEybu zlhSMM!jLZiCk^ϻkN,C=(J}6`\[uS e͋}wYZXX*9w?{r•G->Í5'O}c_wTrqBpbW~{%de>؉۹5O+ʯmx'#FF[OlxnoEÔ&q-?rdecɂԇO\;unG|gnlF+?NxW:S!$kh4i5'*7)B@MR򳗯f%$JAx 4;|â!\̭u 7ݸt>"Q\"[[_G}˾~ Ъԝ B!_BR P~xˏ!U׊޳xo 3^<~=tt(+/ B!^x{ B*@ ԪK O:9;_<')C{ 0%rޱ@F"5_O dXd:B`A!>`BHzvC|“3N!_}†\5t&?I]I=g5;q?9[TZQC5042w`7㺬v_X/!Ї״B-ȶMlP{y˪_޵+#loH g~5߆&yE+玟,5᰿ /!a 6wg翓GސhXuckה ORXu_B` !B!Bi|z B!B!8B!B!BV!B!BH` !B!BiLZ!B!B!I+B!B!q0iB!B!4&B!B!B!B!8Z p˾G6x&Mָ=׫ыI/;W{%x"B!R#n[{5w>loF#8!gW43I ̭xk̠VsEUxB!j LZX\%oe$3 @hg}3h.F44#{䳴F|h~A=l BG-!B!&/oOIfǸY2,Jp6oψ+! B!*`Jc) `ꑖ$?CI+B!R LZ!#1oiOP =~<7mFIJلnte2tާ2f-%־ #;yhx_3*$*k@`+7g-[Jk+Ee,+K#*7_^-!B!>8V.>{2TǯXbMki/b9d!_~>k&>/TsYU/oS ޭ6*-o@۱["KP#WtP1 _viS3 %2h:{Zei,j粒聬0ek@ax0e"X[|2_G^p|q22,u+1LFs]͜;}ʴ>1_l3 ˡ/?WZ!B!9N}trn8&nd5|pk,k'ZE&J8ڼ>{'.Q;tGX4?a7yG)rO^ӻOu'~/%_s7ɥ^y:Yr d>8sԵR|!$N!!:rʳ\:H$ .;ÀۚuZPRTR5B@[ +L*R*_4i!B!^ՇL"<4w\\9rh1x.Ν;w-xv^^[o*kZjAecªW)?.3XK_|*1@[0Y,-y+mGGVjʔ]L)G!Bi>JJK[%%'GIr/Y.*;npy:6VsO>R ή_72^J5۶[A*VwV򴴧@5S-@X:;Bեi!B!!LMZόK̾&tʈu7]Ow ]̩Sg.]ՀuvC.wLvvLEF]m6m  sB>0 fc)wAƍz$Q-q[Eg2#2x\/$RK%EERwa+B!B^x"05~4yj1umw#j`xXlB$*8a d߲㗕ӃxLZfBe:Mi0o?]8w3vдn!KGrrHnGLuv~>oodJj7nݦyTY}Ь [miMӔߍr7%??bLpBu?"J>~u}(tϱ+WD~Snţ ɟX7V&4!B!>{,CG=HXXcsGgx]USg p qLIq~Aab5&$1J+^: q(Ftנ UP& -yz15F"KgfjD "oW62p򵬳~luͬ+ ۽d{Lwx~yed%E#We8w9m߶l* ^'>cdOK'UJI\CNvfu8a̜=ccjK%`ejjm,/yr ׬uq$yYɭ#D_꫉;g,_s>.*(i,[n״u!B!xR=~ߡl(~xJrsvБ>:yϽW$DҸFt]&P[&)LqASQkE;>)(2\̌E'>_}_X/ &\p3C704651Ԗ?ΟE洴v]NZ!ˑo[,r3*TXnaS8!P _%_NlVQvҀFHթB޾Td3&\\~=[w}XPm7̓]pĵ4i[W[0J$ic5Ҫ*N^O4|]05T\A546gO_=#{ǣ;jX|''K>OK Y6ngt麆&,JIfØa]ͨl@ų[q%4}m2fBuYao?zS&kB!BApp%`A8;&ZxNض1T㸭w1!B!^5geaAB!B-ʇ>TO B!B InȂoCx3`<B!Bw&lzu:#B!B0ioBQAnjB!B!"B!B! #B!B!I+B!B!q0iB!B!4&B!B!B!B!8B!B!BV!B!BH` !B!BiLZ!B!B!!P 64&,F# d=n摦iTf!>]'l<w7 B!"bЧ +_MapBJNEʲr<^$rpV?QWש߻dMxjzz[Nz6,B!m"jg,tW.8Kg.am!&ԋL39ݿ_O>K+:lT7mNa 9)X|5̽ޜkHd%\?ZH$Fq1aK rro:& tbhTxi֯ ւsK'h(Q 'dGf,*aNNVҭ'^iO%/3g'uW~2cug1*qD!B蝇I+(ALwOFOʗɽq0Nj2>l+Ǯn@^Uwwk+n]G~>cO."`utsW6-[ڰ1p'͛9$XXP1stѻ'Tkz4 =U*T{=g/ VـLyt&Y M^8Sgbco߾YqTP5hDc 4 Z0 &{b%8$"B!äUQl>7Ej^@bKbFRcJ9rn} KF9ХyvFvȺ3 ^:+snXt덩V 4,=zHDr)h;4__:wՅ?y!&P?V2Y|G~i;b5;t}=2@ݭmmx8ZX8'KQ -%ʣ=c64Qkv\ΪT ]:cIMe{M/=3|\.ꪧeӉIB!>bf{v_M]޿̵ lT&=ذfpUy)B_!!d/j^-*J ʊe5`7)ض~ѤG/[R?m9 M{Chfn݁դҒVd@v ϖS .n#V5)QO7&"S?zXG6޽S۶ר"_؞[3(͍*}-Qdώn] B!a+Z߯ 2t Tu`@/;s&,?ٓ]l}_u*.;>h}ig/>QU 6p6zGъ X-^'<~ߪ Ht^~C8s8l}(L|ҍ2u46Ν7%쮬9#.@i +Jn9ɽI`z+MO*m#YNNlb[%GdO|3{ `ud͗dЩ| ;Ec4C_JJĭZ㥘Z0/.O˃uTTz3{JK)RװR{<WH_A>TQupp@[kkKc/4aݽw/OWGHTuXTfUc38‚gΦUf>qTԳfjB!л VCuvq$݊&JuM-y…c VPuN\k'O_Olw^ 2xן8)m!VM`=ܻ+27`/}Y$E('''Hej;0hĘGwn~h.D4 vg֍GM2f҇7 `@b[Cz@Z(/x_\09LdEy9ĺk:{oߴ^qN/d><]*Erkݽzռ&c{Œ  )kl+@+W^Go?0!BI1bh]8Rs`W*ʴ gAw[[ir/Y>U4U?ϗE*޹zլ_LV]hx{*$ߎ퀌p _t#&b}6 gĤ'Һ\.4%5-$byF$&Me v2f3y9)1OWǽxIslY7Q>OTl?yW߁Ri<ۣ?}b]',,a?Ci1jTYO~ (lXk`)D~jM8b R/n cwp2V*e8etw ܗgO5rj@əwTg ϾہEgj+*d. @6r \eh{0vs3^0FZ797:eww.t]a̾&O#.,ZȩS{ul߲fӛw‘s~ӖtOŢrieaS{@DDRIb{ /SѼsʄ?n,^æ"Or׊ '&7զD a` Oꥡ*Q,ϼ+iilnnzkAT'7fTB!PäUK$ 011(#>E[YII9ӠRjtTf5D[V0dCqqq=z!UeZ__%b .i7IKIt-H\[|1ad#a oKoL P-|/S^tu*Md{#N ߺIt[<a 7/*xV_7N{-Zsg\uY gπ{9uy{nڰח^«?GGޞ f.kg[,xP#id˜klbblbb6:Ch&PAģB!:&Z 'GZ,<2Nެϛe! NnSDfsȅ7{rA~^NVnzH%#=u-.QH`22iŰE_ed"aK:k+J¼}3s='rQ-|j+]{o{rdQ;؛Leo&u@Va?k;cM@_^UE*Zx @$9+9yx4tkx}<kR2j0)\؍N-\,ZE"u ՑUDi̜] /WA B!BVS$ $w-gC&S}$A! Fo[v[7dc.o\9rh1x.Ν;w-xv^^[o*oGEEESS 7vebaqq+vTV 0叄,.\YWfU5.f+Dӡ0hxY.؝\m bo?sp&ةi&EE~Y9j`׿=P(FחnQ(K>vnC'ꂰB\&dx+J,/-V ,;#V:&[Wy))K>*D"蚛6w%gteV%n݉xt%@H:nT>疎5jԨ9\şmfF(+-mĄ$9H5O]X+_ɭ'@2z=< O>R ή_72^ƪT[✌B!P=xZ׎\ȓ5^RM|W{P(p CE2pxG_+~)|]\{KH d5ڐ2j.[V'Q9+B9u˂T_QײcHnFNCyfdT˰q?ePx53L?ػ#ɭʨ䵻ڷu>c£<׬ٸؾ\ XEKTk={Vuj̵dkk+2@Evvkf}d-Qd3B`Ѥ~Yk2}MZ3io :6Pt5"W`5|Nusma!BIV%wj5h n\&zZ@:o>5Vـ=|&06Z4ːy ff Ī; Qm;n=cׅ's[e/+zGEe:Mi8~7*8w3<} BTiRt-?Y ?]:;e,\lsܒؒ<ﳏ\.A]a[J1lS\mMYlZmi~ڳ{\-hZ2`d#'Z]3.8~ƐJ%:$Ưzwr16bkW?Kx|ԙYm:^Z%#^6/Р:[(‚¤/L5U_DK[@|#ӓn[3`bs%,y;me>v4MBc0!BpM+:݁(Œ!B!P3@:>$S.5?g\=Rq*@6r2ׯB!лV3ݐ߆dg /x`|f "j>o;8 B!5jOd&v6˽eկwE !B!PsJ+ړPTPp;n:B!BV!B!BHB!B!B!B!8B!B!BV!B!BH` !B!BiLZ!B!B!I+B!B!q0iB!B!4&B3h4]0! N:whF=,{WM_vT sg`աw7G6y "j0:D,&/Goe8naw)ZP'gʙ6=Xx[t˾G6x&MIE}Hp~WwN|tn6< 3VM('%~U}h{",wnSj%'^t3L$?--U[su?qvǝz/eK?h0Daش}|'x{ʋQcNt6̴Kb9KqǪ?jJ+ O\Cl+gc+A!y1V` Zw Fܿط??>9+mf=pPn\CZ0 +sגD*IzƄO.-J}xD*fstѻ|VtبЛle}[>>s+֕>] ޮ:\(3*5>;kmo89>J_WkbǤiK3 #,U%/o^p0E2v>_^l_pހYeiymamkq|Ǟ\*H˅E4bХlZ3m?EPޓgAZQ,,(9zX:zU]Ƈ3}9y?t--Q׋ǻ@!ަ޽zҫ5_uחRi=՚t:C{?Ө/GZSHr Dۛ.F9UE9y{{vͿ6_o$&]>*#/㗿ܭk6b5h[˕m'__L[DfǸY" 20'#b'?KHw|h8Fh&g/[T֫+3UD{09GW/ t¤;GwޞjkpOÒأD?+(si_N5sW]P&{-U @Q߻8[)*OgMէ_mrc4'Atw|Ǩ0|gl2tK;)wSn/=-Y9YG^o*c}.%yExFK_uחR$&'vv:}DkU<=؊R@\7ko KF9ХyvFvȺ3 ^:+snXtyzLJVO'dw%)6r9/*wwS\iYiMXy{oz.{KIǴU Uc 026i#?zTf(" u?K 9 P*S!BeLۡIwII ޟUtҒ]^NRn:eʼn?m3}ە~!R eGw8*+e~OJW~U~Z>mmiዛe ԉ 9RFuk>i<=z}o&rohfy ^[E%cԂ[M%QQ!woTk}w5c0P]Q^{GwX{Tj=v6Ej=tG]lml,⬌鏣N_`d=ٯZk ubQ-i-+ɡ7m FmB:0xX9RÇfwɰ޻#>IDATkO*J:v,*I xz7NVSS Ĵw㦄UM%%b5m_̕-ِhÀ.?'ݧ{ `udɪnTG;(C|\:iAս^u(bG%ԫM~ N=|X"xqNx qGg/w-EU]:|(:{At%7ޜ$0pz/}h׫YH@֡iH)^2$돥Jo1kg`WTV~4LJN^s@k@/{ &-+H{do Gupp@[kkKc/쎎 _Gk 9V]RnSQϚ[Yk+k|E5? \~t3W; r`2tET> 읿"\.P-ߗ[y?:Sx Ym!]5x 7!Ѽ;"k-{3i4mmm6.?ڷq'}'%ݞUCHZ=̟loh W6f3׌2IQ pz}hs)3׵))mkig /ҔX@m[HّzKQ =f ĉjUkĵv\Ķ=Ϥu5PX=؇KYH\òs>WohDfv=QFdJDr+G~ݣ8tp9/Mp9-c0TM7Ǐ8kRH*3vKJNd9*ݿ7@tCMyȏJx%1m|}uy{RX,@W oJVɿa'[#[75ɨKވ.3ekko|߷3';ʦ0mmPr/'~7>U>}eb'Krgt%\躺@eh W)˪nôQ>f ,y&X/00|}LǺ8R@!+/0VVl~<7B.| C˵174{;tK 5w/\x9~|ڋx9w3 /yw[ES]{GP TjnjQ㳊#?ļ{R-Gu_͛g{٪[u@hHJNJǤ%yO 럑)ڰn~W#iBa!9p^$(Ö,ב&߹t\n /f~ܹǴe%+s,[]'&Fgg N09TA--V}`W*ʴ gAwPCj}_vBK/4[ L=-F,v+$g~LL3+ʘ Ĝ?"Y[o,>#&v׮8nBތ$٣kt\U߳ %1*l.0|1eu /9\eo1᛾&=rIy/B\Ky_7\I~kk'ܕ ׹'=j ճ3{^a?F>HP6673ǯXbMk/hr4C<}ޏO6!Kأ%Ye9p”af,L\|q8O}aXy5@;=Q;6E J]SцUzRɞ5xK` :}HeO;G4xp~o1>*;h+; 6_ǟa ܪ;{|h| I*P2R>Su@*8lՉJ*ɾspenv?׍ HKȡ E@qRݝ:@ ]'@f|,E kﺼ˃~?WppŜ;^y^&'_-;uJW:=;rſה;y9=OU|/9f؈n4;vf:JXlϝg]5u*I-ՄxPxzx^]1T/h ͛Nmlf)b6%_.=]15&C[O}VF `ި[dF ;)i֕D r9q%C[NqU#1 5EYW.9wfy(5I+ڲbd.gmk@׿_W0'+=*_65pcMSdםx0fݘ]JEx>d>RC{:w*7R|Tu>1]]=:4:H (ZZu/->lsaʀvdQ~5)[QO=Ǐ%S+ݯy"`sE%bUޞ[^"hj v#O@70xۚlڒlzImޟ͟B2a?r<' g-;o8Pk{R&gg&PAZkFmW8|x?_.A1{ERM8R `?u +s#?{o[/ߛG]95ЊM!*$8c9%]}5zP(0gh@ing l;Y,tslyv$uDq&Z]AS`HH?\'ׄNczKm0P"j~eFz6֞`ow3۾TXQ*LO ?ywߎ1c8UThoVMuKvO)n]O8EE2m7 n^piMmmu!ie{#X[7?u`2`uuu1XQT$0f4bآ22Pۖ}RLUuDz޾hot /4˟cZZyȤ*HXVF^-y1 y*+^x/ȱ?33^h 捻-yki6|3 Y+oc6Uz9= Ixɪ{7cd6xmG/2>𴰳N|2dJk()YƉvLTA;]L,/-V ,;#VK/-0dmD 'thjؾ<`һ U -ˁ W}֕Y|pͦj,D\̔9’ qM*-ئJO۞xjX<%Z~ڣ?,.`t(***0昚R 񩐖 ۺ'ժ}*p0_#+Az썹T##}ʢ⎸L"<4wVe!*51(VRAwgc9e~|2Fcnmn, ZN.f;օ ):UF*;?>pmp~o6SCʣqwm@3r% @tZ‹Դ<-)Pv$dEZUճJ++J rفoOTN* Lj"'7ll,:gCjGt%@H-+D"蚛:ÖѕYϺu'ձ𺸲d5=P &疎5jԨ9ۭOT9=QӜf~o}|87O"\͜4߽p~o>iE2:eR}ē^.X 8iqUɦ'3yq: U`aagog 4)0],xiT充`jjR-?[:6RxA!>vrwzsMnԩ3. &$z}͚1wo2l\onZn eYe-Ψ䵻ڷu zN~}˿?kLLB$n)gk˯[Cu^ hyNN@oW @VJJ5MK0_6XRUԨk 1 KCyfdToC'>IF޳V `a権VM(4@UUKmkk +/qI פ5'- sB0۰e޸]z}|νL@•6ok㿦5!:M8?yv:V_I36S-Eh{͝4߽>]A'.aQ_2H-~_]oj Pn3x^f#Q{ɂ\U=Rd ><ajj,EP `kh*bXٛ*_~QNN5mhWYm7nOj\y?o0>zuضVm 㿘u k/@}3V-`xuB.ˡ]g2@nӼ`wS}* cd>hֆ6e*[ W@۫O`@? *o]!ie&cmDbj] lKFʺqxs7@rk/|!AÂd2Nߘsh~RtH\x>: sUVS l: /eRK9{Lj#KE"LLjkEKbnKёȯsf]\9*Z+WMvb՜7uu+6/8 Y+C^Qmbhhȫ&yvϑbZV_QeE#m gJ<.Y*z !~_뫵K6ёwft~s"ުcda=f I\K. >i[%V_O}Vfܻjy1 X<|7\ %;l=hP}ZL5ٳcWWM96-d3%% @ 4+xAQu3Fr]d5DgogVeJ'_>t| 34(:5_[02>Ds!'ޞh{X0%ꪯVowއ42m VJic\|CPl4ďo K'+K>|ک3w m %](IJ~jdHH945MJȲE&pp $DZog rr63-+ܸvDqku} xT?7N6s1M3.8~ƐJ%Sd#FF[~o$4ކεyUQuw_hE_KZ6~!9[Z1%yqNms0x*__ijmEܒ1j'*koN2`İn|;"NxhKPT=mu|Z?9?yg<ۛ-T_}r{M4߽>]H·BjqvL^w]*|stVKl_!kNs4*4F {n<)8Wx.#i2"E3)? !yP;;yYYϊyܬ `1|j?v ;>XHsAp~@??>$S.5?]g\wE6r2ׯTm\恵U̯:P;-> VMq.ޱ+Bx}pq~'iǤBmDҳ,̟Xw͐6DK+=u,h׏]߲\kY[tr?,[5;8zo)[w }RkZ!d&v6˽eկwEzч1뫀?g7Y͟ah!^/znp~?&j}9y=|~;9˾G!`"Ї1iB!B!4>=!B!BiLZ!B!B!I+B!B!q0iB!B!4&B!B!B!B!8B!B!BV!B!BH` !B!BiLZ! ێ=|%p~_v|7N B!B5I+bpYj#C uƞㇸ0~B!B5I+j֭vUH2D U`б B!j LB!?DfǸY"9B!BM` ztPT<)xcA!B!<=_cJ!B!V`;anƱ[(zٙ3)eϞ\=|jfuso{Q[ #8+iSg e 73rV:Tmm-GoQ k#jT[S )6s Nŕ5Sv>T̺HiJֱdm sA>~"P6 G$B!B"LZD)'H%ׯ1zyRxXd64 -Ol23$Ȕ,̩ľO#c)ͬT[Yp'eO)9y'oyy^q+`B!BV#ՋzQR^^ ztW@<2}է-/TĤfyʭnj Ǐ.\ /<Ωky-4n~MpD"B!P+¤MVҥ٧ԉ\!h4ڦ>m׳%sˑ=2H %?["-ТQp!B!PGDg0o'44L]WV}<,9iy]G wT,xҩF;QO_ 4"4&&5=o奥brpv]1m~oǿܲkų&O"zA3413s7 r5Sn-&vh B!P_O&rA uBnNJ3 IXɁH#$B!B$CXZ@h:=,ĉK0X!B!:\ O4澰{י|ǫ Lè>}j1V!B!:LZ!J7{M4ߤU/=sGB!B!a` 8/Eb7 %ܒg\!B!POD!B!BjbG!B!BjV!B!BH` !B!BLZ!B!B!I+B!B!v0iB!B!&B!B!B!B!R;B!aPT B `B!P#cԣ-\~??7DzPGݩE~opׄ"N iY~xV^:j[\k~.ugY^Bk"0}gʹeݱJ G;yN]6E]7_Lt]B V-Ğ}>ĆvG_ Svyٌ6On=3k֒{U ^?hЃ }lIغk-<75嫢G^}A1]jZ蝧-d%Iϫ.dJr59ji,#_Aϑk`K;TO?B!&ԃ%^47J3HtgC{Z24j^8{#|CT ֆZAtdCm}m u)1D U߰=쬭Li’gns1=Wf[3y8uܤ[\O3G{Ǧzn5^t9*mٓ'B7|l:{m)Xzkݪu>KYI8_:Ts'fcGmjY qw2cjS*yi㮞_\H 8@UY~jg<׫&[Z^nxQ+:xi⊊ԓkבVt`ɲ٭3bȪm#[R0an6\JG!ä8']1܆*,JK{#1v>ݯO׮<ũS|#=f/ V!|?sNqUTiҩslYu,魫pmw'weebAQnV!­W[~OntyH2ýXWrJnlұSAïo]'T .M$uo_fU5VX^^&hҵPSPPM#<[@*Iv~YdBuuv}#)e7; -_QqK{@&D=wߌFR=v54w v ybZoiyE̳|YÖnfnEk?׸~)N 7)ngn>#,=*i+z~ݨ߿a%$px'!BjVh reD=}y/;\L(#?~\aqƞj`c(? @at/F~eʷ-ŷS겯Ptb;q.XXp TD 3K<ᓑO+@6t7uT?>.ȜƋ iߎ{lhm[՝ݻoogkR]<:1.\|ua5Rr8)v"G?)_t>KOaM4 ^6USZo_N-Iʹ6hi"쥣Ņ7l;F D-˞ӗ `^6+g^xr+?ɻ|>QrdžBJq?f0ٝ^Vj2w;ҿ3T"`Ҥ%d5OUO?B!>=ɭF{ظ}*N] M8 H>XO4V мt,R|e*$ޓ]h+^i<à>e [ۗm7"{]N:¼Ϟ)95=`ĄU뼎SoZSazhVS^J׽bq5bYo0N;fwۗnO]uU頰í45(dN-h\(aׄuaT_'Z @6t8(ܘA 3[Ln--cwܓYByۛIg7Lmޥ~fL.dd<}vJy/=VȆ=cSsʴ#mҌAuŏ׫y:O3S'Qx*2ˠNv֖FEy9i#\Km~YFEB!ނIVE4kx?S HK@Pa#r!33DXZ,'O3'O޽cw92ӚcF5"ǯdXYVξ=zߌr7 XȓZy8yu[K3=SտL& X 9bdߋXYff*SܵvmS֔VbR'ncS3"[̺I2ɥ+忻b%WҧOIFr#ڍjI;lN?ٯ}{]3ϗQ~~N M@l=A$Mn6>>,-Zvu'ގ?Nn_yV6$xV@3yU@q4?Vm)z_bb+ 7ùJ/h0, (PY^BAZɋg~2:h5L}@t-sgkke-3٫o%1Z^~~~z&'(xĘvk!d&tՖHHd#!ؘ=hPkiJ`fx}-JOob٘TOO~5sHK ZLk^.AA|Фc(xB!LZ.Qkrn_}nr@Bf 9o䩟ŭSx$ˡKWLbkJ+{!RWȌ]O]/]QO)-ϰ"OҲK>2{jeӂlx֩0{hwm0⯿y#]CLE0Jr;8R@WU,.w=ZQDxʢdyoY@?G= ,7785-%љL2uUZU N6p7GUو/F{jfwj+ MG@~1U!B-j]+~yQP_}|ffCLd9/*)g9*h<߷V/\{/a)f3kCQɱ$ ]}+=ʖpxFW0a?f4]CD(Ol+ڌblٯѝFwYݦ.tub,`OgSeŒW|m ~#̊ V wp}B:+ OS mOKZPrVx;.qoGX!G~PC#2`P` (*={o:ih|>|Ae_6d!MĚۗ|um'Zc('jO)'Ҽ6ȁ\4_4ϛ3'ݫ`OT.+;k_(mk@?zqEƝlyQ{\ɪ$& GXdGwI~3؈ Pj`5l0k$uxE^w?"I!IJIu@9pK qF>arhu6d=gm9gIa\Pv.݃u F""=ٴ}\$mn%ߊ{}3#ϩ|Ps0ţ35ths2q޶?-gkP($۩_=t!E⪀Djǜ0b>_K]]mN\t=epXlF}`יImJdD#D]@ܰۗ09Wmm4y%U/J76׫-/~Aϗ0ힴ[|z }q;$ɳT ,& 9YA9%b\z9՜/=ee< %\hD D--MPK>} yxT[Rb BF9:U!B1LZn~(F& % M{̍tY.DGG{)vz:X @ېzifGG',V͂,ٓ%G^=ڮMH,O Y4'Q?f(ʏ.-Pۧ¸ێj|ǽzURwt쀝+̡R(i=z:1d)QޝWcɖ1vv5ZINڪ8^F??HP΋& rJo|3N/ w~Bm'5\& =$Ee9_GAҿ؏t=;q9@S<&z1pp0:gX[JB!:2LZPuu>hji44!hS@\隚]Y. / $rz4 7""W>K}t[$FXp(Yj7rZ1-q`_9u|q=3e} JkX)ήϮlh $F^9鹴(cܨ~?4)lAj:OWg3Dro_ J2@23(ollP-}GN '%ڳ Jl憱7[ZZ `i2⤧ϥ~==i5ۍbYHI-Wc 5W 05UP]ߜ-rzff4(~]&47Hi @iYWP^`4=v($ثxo~f~Fh"o<[T^y!Zy{1|({B!CB٩uaC͸]QbB3]:I!i¸8I,)wNS1k@44q77WdMfNo>{P_&gxzX?TCVna)3Wl[RIKX]IUyy劖WAIu#[5uɝuXs2E9 ]@rrT]-z[7Gen yUshFd{Ucx|V?S ގʭ>qM'1zrSS=}3O:jtP&> Ҝ en_Y3msţ9D52jGդԼ똤agg ).**/-#U[N4s+zd=ǕEx|vU=jf4*uB!&Z*•b),G.~D HuYj# ^>;Շͤm56lh["&;J{DdY?޵'D@qQFZ/e5z NM9Gd'kF:&j[ӏ_2V@zHL%ל-u@4j})?kϮ4\ XZ6akU s2=ęIՠu?gͻfgƥHŷ~asD2:օ/oK.co~QGS\͛rvrK2C&xu]٣}:]{L]Yt @]ѽ;\mxM*?ד[W( Z3Ě~ן$݃;/K$6I\whKvţלּRU*~] W rRDΆIpcp8N \vrgB[䐭CVMrHA 6$7WEW9TS{ #t!sRMiӣ[]ʯW.>_||;yD}m'h t0䦭W G>{T/*/% )i'퇞3 !@Ў3ZOyǗRW;ձQw7 4Yaa` 1q۶W^Zsԛ} -xg(@4ghC?i#Dm Mp}jI.diA&xrhÁDCyYcw\édؒљrWأxuBq_JpH,?{M T=@G1%T4\4-g4i VhFf422/l>Eg KNoX]3ey[hƦ,= "HʓӭO8!Bolgk s:SwFJ;m߾Frv"'w*"Of\t6L=!iØ&W$ݼall,PߺEl\/4$ZKP̹}"|Ny.QȨv6ث9Q򾵆D1w{0&0>=U=?Hʽs~?C{XIwbb'Q8eo} Hi, 4 X4 Qvy낇>CBu7a05iOE]ɭ >r9/}/( ИZWQU+M.d¤[H,+)7crkOB%nڠGx=ؾ}yW߻!gkI3i6^ם Ҟ.,Ix$٘Tun#ueC57ރ g>Cy2AoJ3M8-jG6{`'ZQoJ-?fxwGKs3#Sj%_iRb\3K<8?|ƶ11pd JK D&DE!lliy!_ݹgh?O;sK Kjn~Yn{1I_BL&F5Mع%Dy܎Ogh7:>w=;05,BQgۢ^w`sQZa~)K-C4]p炍Q<B!jcU+ǗDh;rs;hƧ}Jo92nVNnCd_aS*3V!B=?B*@q "קEw:&ή SQyjD}+g3/L[yuϔUWGc:g WMv(ݽ7@!BV)c?x撂G"%k7| #;ROJ{ҚY#y /X9U߈WlVhÂQU3bwB!P;B څ^>EUuGGⶍWI׍*.qEos"YFjM{b8!BLZ! i 8&aױK:bƧ}Z1;`ڀ(;br B!jg@B!B!vv!B!BH` !B!BLZ!B!B!I+B!B!v0iB!B!&B!B!B!B!R;B!B!BjV!B!BH`SEaw0Ԟ@pԩS~g1o7aS^u/B!BH-a;+"!+N-,TUmHc(^Mds`'}?Ҏ2l1$O*4~;p{"B!>je⊼RH:zNfN^}FeGl]s Ba1dնƱ᣶iNfLmJ]%0qsK$o O&&>iϿ[eWӇ{( ؾc_ߺ|O|E:dQUhvjE!B}0iն$̸x"Z o[nEi\q"?Q>h[zzkvDa]5rLnƎp+/,mV1üO>$R dC~SN3y-r?jߎ^m!B&Au D11a*5.Ahc){O*)m_z?4WiY'jJ DJKKSjRXqj: Do^%Gl`QՀ_}IVW&#||۾B!R&ڃ@Ueeӿizz; t EyѧOG4^tPV MM 2߂S4X® "_TC{MgJ_Cҷ`w`w z lcDDC~5sHK ZLk^.AA|o1Z^~~~z&'(xĘv{"T*N3݀b!Ojݿ[lY3IJW?~ ųZ aDZ ٬S{[iDȯ[:{[:{w㮛E^׾!BVmiҹ߸P9wYÿIin~E}]"ȀٔS,Mh]뫴kۖ\{ϗ7==OJ9tW\_$ˡKWLbkJ+{!RWȌ]O]/]Qt҇wrroݛmO)p*pl=5j;U+eek逤$ԡK勤@dx 0y)$)^Σ9ؿ|r8sXO}S?[spC)Ԉbj :pI>~1C;૯=wȭyk]ÆuKR.cvז S.9B o5dʔ!n:ʏ/}CKo:%(/"ьllJhj?̘늮l_B!PIV4GH4}C}u7v_nV9VV pCGZvhyRlX&9ǖ:[Jba޽ j78e#$ έ^߻4^T7aBcy_j4 W6N FFT0#cIn: ͽp뻦ck]WV)^+~9ͺ;6x e_?հì)Jy3HKqCݢ={=l'Qo2> ȸwߔ]>d9/vJ**ϼsxUBC_y*݃Z }3&Mƃ" ߹j9_pk^_!m\z }dX@aN̴-, !iˀ0411*e޽HzhłU,c#n/-cc= B!@y >Iy׏fɻSא]w,1T9K"NhUZLȤ@$Z?m־!B6kZ%cr@v|V?$fVtZc2d\ @w5Vp#r$Q3 C32h.Qxq%h(ޟ=ypol1a^h[e*۴OM ^{\.k}&yL~(}A"B!&XOs$@:1PZǠ.}Z׾lQ8vXh~B3V2w4P'aEF/_;)hLtUCT=0-եߗ'w֒DOຌ7yۗ`4_4㖮܎O_z̍J̳{/戁b?n!&Qs଍;vnGW۱>l&$mcaF6wJԿ_Orjyޤ;h¢N" w6-ÜNmpXНGT'ħ*SGb*h欞oC߸U[c9_EkƎuz;1D RY#~X=Α GmҥdK_}=Wx%_iE!B\Ӫwа.cGޗrI鿞t0%!$J8y$sEyὦKRi#qbՔdݸfyc*Ϣk߹R;r2B>_ "#T6P00m[r/9p}d7,[<)d`aQNPei"~ɍ~yļ#k+xeI+zNVYT"@=ɥZeK3FgD:L@*x~nǮHr՗dްf̱ ),+*.ьMYzD'[p^+ F < $5¢*ҘFQMDJTGx/lv ƒ%RɀMڒchR;L[g*&ƙ2אg˪5 4RoAsͪttĚ73<^Z}B!B1MHV=>n)W/'6NHK2kl,j^rTs@Nץ7߱RbqP/go72vUȄr02ԂZAIFk13|KHoGMqfͯ$>svB(FP፿~q2Kz秗0ئZ5YQ]̪RZNI&yĵjeSW$22ZL#Suŗ6Ny+Vd--A"_U3Q7: QӀe@)Kz|׎#2Ǡ~.u l>-7fs'EFv+u<νk7d$Xoj  3w䗝rT",xw;i5uDMm=}#c=R8{~:5 QS45@5Y{y]Fv&_iT[:~ 2QGe1 %蚟Stc yblz?ԗGŗk1u5Z:JH_s=Rj?*h_B!"0LB ;7qB!BH V}㟄GB!Bj /YB!B!B!B!R;B!B!BjV!B!BHB!B!V!B!BH` !B!BLZ!B!B!I+B!B!v0iB!B!&B!B!B!B!R;B!B!BjV!FR1}t8B!1o??7Dzk󼛼+ZesX%qtwWu޳zϽ2x ;:&<'F`iqw˺cɕ8B!лyҊc+$$ĀR[^xkj>E鯣gfҒk=Gv=/x(Sq>2QjԿU蝧-d%Iϫj#Վx<0Y54rjͭk&TB!BiۤoR)ʲ,] Okeҏ$GSO]G [1j%NgHB!ԌLZ,\b[_|C'LsG#x O6 3;JIw4]ll6F5w5u'S=OxRr|v>;B!Bokä@O@ɍN+GgjHVS4OW؍;܊TpBzO*8pkȹ#,;V!B{z R^fߍ|>kZ,w)rt*36;|x,ot;̊Ty}d2<~?T~af~oB!Tʹ/:.'r<2($FeQ^nNÈ3RRU'Fv7楥=}MO=gZѧ'ɋ {&9l,i!暕i7G|9ۤ3BVԠhhjj:q]E"s9j{ s9Ҭz'M?fuf/9f݄[BY['# 7EyYn;z#KM G'x TŴ[Ȩ],-M 4N?1vcDC12W'; ?7;3i :%:ijsAA XPqbJ<ʩQ\]`8x9ɱNEf+OYwd2%T&G>%('m.}u4c2 u '';'Sk*n_={WPwjR!BYq1EC _p"B9T$եY|-g/o][>+WioF@,I<w-kkK5Ų1#'< yU["!܆2ccCuD*oՊl_ZW% 4V,N/n`BAqc15^dսItӠE~LqmŲvfY;{u\d'^:^=I"Se 6g2ŤmiDnVHHP!33DXZ,'O3'O޽cw9U|B>y֯wʰdUT{xy##!=qjo+M5}KgoKgnu]MK1k@RWj2,+vgߞ~VoQ w p\?AVO/d8xۺcO鳘؊= GխmB!Pjä8hQnm ʅ7Q4~Pk05'nqϝ3xW7+^^?`2a_= G(!RL2ĭJ9tW\_CƗ[:+Ԓi25W}vެS`׶-~q9<=O*}HQJL|^X]N{xO B<ffGpm//^]D(M4ƃ~$Dzϭb1h#˦TyRerؚҊ3{^H(f2cpSKE4GZ:~sf/)?ǥGE|fdcCͯTH|ZNzMUscw6myc=fLdkKin~E}]"ȀٔS,Mh]C:D;૯=wȭyϢ 6k]Z}&K;F5 B!-?L~.l9URx j~Z:jaIMD%%] T:>?w#(\^o.Pu2l;WߟAZwϕ,D, O<^ RރL 1۾Vsm>矿m\R.m`Ztb RqQ|2ۻs[FЈyLQܑ__dd@&w18*?2 bα嫎>(y [y<]O5~AL)Ȕ,̩F O*ڟ ]8YԝgPtJor>ZuVi)E#ͼjHRNĕ `6Uܢfn9<|dY@9%I?}ZKLLZ!B񡲚„=f3ha='}Rh  δeoo` wtt"H9I,kFd &YgU@Wɶ!PZWq@ &ĄE R,SS27/_D!884yJ3Me}J/8NVi!n~(Ʀ/sWO #>h2>;/ܳ9lNo6 W$'$$$>~49'jI]|@ߐI~B!Bnϖ;sCIs7mq/-ܽ-޹I J9k)P~9+$z駞SR__$IMy={\y~$F/: e. 奥>#M 4tiogxܒ=Oa-m555-M$"W>K}t[8IDAT^T-R9_#y~UT|iÊQ) 2V0??D{gx@tql}!B6KZ̃y8wf|9Nײ#r$Q3r>{P_&axzXx+KJĴWR1X98t)f!+0sʔ+-Vb\.ŵQ1n^nqq=js=m[vm_x g_n'e*{'5FMB# @8._xئxQ4>mݦ$ f#C{{:,;#D.aOO32hny\ ::y!B:a22lfh @ kXu:g4<_OrjyhkJ0A$ZSGb*h欞oC߸U[LerǏf뽨f`q ZI x0* θ?4]{vWI-ҥdK_}=W>E0vP˦ XY3p];x"M~iE j{^vjSY{PxukFJ -ǯ}Q{?p!BZӪ6q*fMi7bX\O{<'}I k:o˰²rؔAIyr Ghy݃;/K$6I\whKvţ٪}AC\ذG7["2ǻT%Ng{^xiR`b~=歋 !/xq(TSAsͪttĚ73KRRuozW__ޗjچ7T",-UIdRqP (/NtN$0shcWSW-}]PWt=r[Ka[ƧE8S2{C,QyY&@*x~vǮH^ğIi-+ -TbMcaYjO§i!=OI D324AyaӾw(^ݽP{fhB!hK㏮wťgON& }(/.,M|VK!_ݹgh?O;sK Kjn~Yn{P=Ӥĸdb`La.˻}{zޜEjia')LsD#P=4K]KȻBG 9\VcP3LcYК$1&\"w} fkecɀҴĜg7__Q Ӣ"|D91w-$ ۚ\8-%$7⼈[Yk 9o޾Od21jOj S]> pcۘX82O""{-፨xgB!"0LBH0?a^ V^.QEJ:z频l4C33~S W]_3ecYu0u?Mb=ܹ`cB! ">v7zTEu7+LA̓fco9 #:!7'%ʙ[@xl>1lJ?b !B5V!5Ļg ULMP,-M@ 4gvć%.PAlU4cx B搴1 !SsAU^q1J,-'Xnm$:LA!SgM 0^ٺln'jjkk 9+ň BfV!EbuM@"r[bo7{ޚ%}x8(փAmj`]{b1cB! VOYڣ9C_@WC$pK ҞreB!B!B!R;;B!B!R;B!B!BjV!B!BH` !B!BLZ!B!B!I+B!B!v0iB!B!&B!B!B!B!R;d BHߝZX6FKˎnӖ_yN]6E]7_LtnC1:6 _0OK8>r8B!Ԯ0ijs)Yk?:уW֡wHW8;e@_7\~(:&܏x!' V:v}IKԵ3 >, 29Ou h:Xb](GmiMi~!0||zXj)߿a%$pxD _}=AXZ]Q5wB}0iնXuK~N`P*u:vJmOtoHesYf ކZJVS4CV̜P1Ҏcߏ!>Mv |L=']ʸnj޷~/ݶthmzi1t D-W#F|_{w5^} i=D>xh4o;ǏBgZrhjjT5iVA!쬭-4*rsF*|lj<7뤵WȆ΁y;3bAq{)iy:1P^)ѝ95=`ĄU`?'k s3CjmyIγGgU7Sb3dXNv662~nvfrߪ݄[BY['# 7EyYn;z#VotPV MM 2߂S4P® "EJՇ@c9+\G37EZmT}Dp tۗ|qP5r6X2~Xrd)oOtWyUJR{-:o}lmwĊ94n}ܝYӧsjC+4D$v 5?A=1vcbc=nt*UCCA>+Nyx ٰgc{ʂaYvcOėM11Y=,Dǻ?WiV]Y=yߓ]fuf/9[ܸ:6y֯nHeXu2j! jy饚 o[7c_~Iz,L& iZ0}Bg}$e|=ʽg`ϓvy 1g)$r`9y9yst˩oמD; Z4(Z,kgWgJ+U^jJRҀM dJՇioF@,I<w-ST߾-okg {[۱gAr3O|g/ϵgW9^-g]jj6Yk@^݋wZcu[&2`6DtG|A?tAZT +6ˊٷ߾[oʔgmIVzfWX6fza/0ןI+HzFqv6jYz""aj20=Qy/2jK$$ې_fl`HY_V_03<# 5q)$B~ I߂ł% 끰ʥ6putiXDAD}m^{j&_Y?\.Q%_۶ڋZǑ?]H^<][&Lg%Duא)S)}W}[b[" ٲ2 -2Y͜ |1D<~U;fldz(5Ev4{[4W g[b>@)Qf|qt?Hin~E}]"ȀٔS,Mh]$_x[r+ ^ gJ^V4ǑKWeS*nY[|+/y7erm:PlPRQyëO6`x2 a/\i`c~9 Y>E1w_O8WwyO A'i)=e\] 1 ,gmNRC]_+̚ȵG_ 2< Xx*H0}z:SAwW rNﻘ d}틮E/ _^=WqFb,K?Lz5Hy3hlDǻdQ[[' WsYRcd(㱃G5~D!*ͩr+C}|~ڣ)oo }XAܩoK)st4R|CT=󛷜O9YSTfݏ8w"ҾO#c)lAT޵?/65QwhdWPwzH~_w:{ɳT_y[t4\YUUM+E۷izD+41Y,"шUVJzgInY:/ZYX+`koՕ*ͽ+[)'TS4pp0:X[+T ;s>x0r94X\l4pq5G-)`Ҵ),!NNNBtt+$ WSK^ᔳ>N)')Y-&(ܾ-UYΗhQD;#eN\}GjLo9KxrR`Mrq1¨ 8;;dжs0*6^ڴ?P :wt0b!SZx*LL \LMܼ|(=~e xW-n~(F&E8;xGBLZ;K`1mmmm wow+Z%+Jwvv5p@owg+@+0'17*We %tid(/-mvGE\@OCЂ%P]]^o&g}(rd<#@/ ql$yKxr8i~^fZ n6 SKɕAWgSH.s'p8-KEc׿\'7/)Mn9,@P8*l|n^<ƺ1W]PG=x~/>~?j555-@}#~?"`J=B)h򊒔^?z(iNn..:u 5{ǒK괤 elPҵU$:AxMqee]U_o[>"NP`Z}KKK&&$(j%3[VPM{jNj>xYB3iib)585ΞP*s/ `r"/ּ@szn\r'6XBcSyzzi%;>w HRf@$(؋pB X~ZPo_'@/.LPF)^r3éг88Ҡyr1@Aҳr :V"'W5S/I$_Hŗ68  -Enq$~{8X=fWS'jw|R -==[Рȷ-=8i8n<#BO&DvU=jfcb+ʠz;+@[lY퇙S\my0K5=Y 6'ԺD|wXhd yɠ } D#ն}KK`HU]}Ϟq<8WIEVT}3O:j"A ͉~)AIu#m/y)pd y< =)I 4S' s-tN&ATT)QUޞ l:jd؎4{Lo+ھf&ϜlKO:;p\GF= ߏw{tZ1E H]xT|?"B (דZuj7 4aъ Te/XoؾitjnB;ONOmLˏtU_5f{1G q4ѥVgmܱs,7O];x"M~i6Z^DڔcDVqʹoG߮ fegLU>SGb*h欞oC߸U[c@߾9G6u7-_m2hd(qR޻2=ęIՠu?g ڞ%o \R{32]T [un#ueC57ރ g>39eo} Hio/)夦A)Y^MJ.QݾHN._R:nbOscW4aLEl\/4$ZKP̹}"|l]Ҵ?_jF i, Z M3lL[Pz_}q&z;7/HJ4|W~Զuъ\ߩ<c{B)d21 !Z#yݓߨh բ^h$+>"|}NԠ9e){qn-&vǶB9\ !TNۑmP+rGɱip :!7'%ʙ[@$apg8BOYBH(^8 eiiB~'>|,xD!>>BA~C%Dc<ʉvc:sSxU"W>y^ٺV- ;BupB .d.z׶>0Abo7{޺HZ3gq&{Ucq<"BjgZ!HKx1 ]zXOFF{"Y{tq1gjnIAڳT.v=#B!V!B!BHB!B!BH` !B!BLZ!B!B!I+B!B!v0iB!B!&B!B!B!B!R;B!B!BjV!B!BH1H^ߝZX6F㓈'9`yM^|~p44ϩK>xD!BNZtlzYPjKS߾v~^ FAXd=3kV!#;vt"I._4q8r'{pk`Qk#t 5 "3;JI0AQj ]IB!BD' mg@Nf'ݿգHXF0`4Pja}b:ꑴϸ>ynA6 B!zşHDw# H%8Ѹnj޷~/z}ݶ 9s~_M@|(㉐jt m͏wGqp۶iB!R?ϴ/:.'{F#|H{*ՋT:KOWoZtI_#Ҭrw2g*sOinY :%:ijsAA XPqbJX!Lr>vOYB<5+nrVI3&9?ogJmޥ~fL.dd<}vJyMع%ֱqsߡ5֩fgd<~ʳ[ePH@';kkK#ʢܜg q._y=`䀮l'{sa^Zgj߹n_&&Lg3c O}2+fɊ2v=> dh$(zv9mb3dXNv662~nvfrU rQp|)yOƻC{MgJ_Cҷ`w`w z lcDnK)-|M5@R]ZbZ{r ص>(UioF@,I<w-{z,34*,AǮs'ZREyv/!i:N}3 ĵ2ڙer_!!33DXZ,'O3'O޽cw9Udg?>/wY[-{ g| 6g2ŤmiDnVHԄ:>yUx(/MOO/ZY yr׮3O*dʌwyNj|WZW% 4V,N/lB/$% {~/xڜ =G,5Ў߯fUB!B) ?|[m-]p1㒦?BQjhp3h..ζpt&0y)-ϰ"OҲK>2{jeӂl*JKo:%(/"ьll!Kc}ۍ|-[FX|qjɴS+O>bo*܆vW_1{Vﺑ[B6pذuiY]9Z\scw6myc=='O,n͕kFSƯ\jMFv9C2u|*c|^<][&Lg%Duא)SwQ҇ī2cEfv#= ?:vi6ىc=~Ia5P/,xb.Bt$"̞ *%@Yx̘9ܥҥaJw&g<[^ǗwϔaKN7zu5?ƃ~$Dzϭb1h#˦Ty*iQo:/[3M$%_B._$&kCM$Lxgm[r?Hsxztf2a??}(fflwjϮz矿m\Vb=tK\1fĪ/&VK dw6e,[ĜcW}PP) xF=hHSS(6䋏䏧|])9LQܑ_o2!@vQ%Q[r0 5Ml8Ick*ǻ叿|+6x1 X12wB!LZĶog~?52EאYw]3\qQeJލ˕-ۙTEr(y33D)9ye(j6VS#>DT@Wdfɉ6v)48jt mȫs7EW.mvܗҮuW>)| {X+%"U(Iy) V\ 2^0;5 ](K-n?g1rq\ b8/D!A}<컄w Ir#^En8zF"#"t}{Ljngh!JX$M?{ S_wonˡjM R(]%DTYZbǑ"C&KOuˣj}|ԮFlxG6ޡτSvnOhc z:•TeWdvdAm;7t\lj_I^0Ϝ=6wEϳKyn=*k:q](6NO~Ν(RÝI2)+-z8zRQa&W,liX%(Y2Sۉͷq=߭G!Liv,, HZ]}Z"!">ݭlQs*)ȈIԢxeGmfYFL]pw;bۜhU6ױ:ffD$pQ~uny4_Ix3޵rv}s{Y|LRؽOO{Ƭ]kH?>]ؖYQ 2"W7K1-N`B$yBOuˣj[KJҊ^Tsv 3慙~Μo$+m9>Ս3窈}lۤ,O&`T2a3TNl^VTlY$J GebRy`ZZVWwy oPWX4pv,x·BmRpd~D>3H^_^RXTZ[Y$Ֆ؇o$KZ1͆2HYw7i3_ 2jʑ3%eEŕ*PI {M\%~n6wSurIi5DcɊc8G]RQ!#yWvhUC߿4i^v$ 9{\Ϯ,~|cF: ƢKmt0MԍQs|M >vŖ!jחGJ4+]A/]()-#ҢZH)TWWg\;R$iMJ} Ɍ3eQ)*И$R(+= "Yޑ/?8*"AXt.܉Ñ "NZQC=٭_owGK[W҂ף)i(?jGj.HNOzl瑤%nG u'_x_ }yօ];S'GcNef̉cwEe6y>nd߾"Fwn5/[ھ=_Ͽ?|d_Nv*MJwrq;BxUuW(o :XJ ]kCL_f0|-YyBJcYmJZt|x"C}7}:X2J.GɎw5{OIw)܊[SQQ|rǷJaff(hIVzˈ5cvOE:Sojim1pBmٵ>F5D=XhgM1?$cEDF8'5DeZh%1wۖHwCb-@.^={EȸԼZ)C/]Wm9ۀXVmTseL:y^S߼{IPVm&=tHť%⒂4PZ A Vuh$@ iZI+:HZa#OwomLw2qa(KUŶb^L:c*'|gdՕ奷⣎(jDI1܌o0߃T{Z^!)ȸ7 zj`|NCEAy=z=^V[z"b|#3{wGwQc,6@^#.'/,;+o kgjducWGB| ᖤ{eu?z ޜ%`W^;% 3;Tt]T~Ѵ? ѯ탅ɽoʽDN\qתˢr3:>26BҪ򬸄 "2s&ʢ_n-+^2RpuF|xz`*LHWW˂a_lÔe'ئ!CvwpeřŒ;ڶ 9S˦nop貍_f.:bA4?@'ً; q0su&ыFZ ps66T$^<̭{،^D'o,?Y._ۼd {Ǽv59]u}ק,;EǗNry/sVWs{'5-z ŒUgdmET?^juu8::l";wp'-lVmy#vvrt5QUfge&w Nb3^4ٞ-64"NMQ^nNzܑ'$sa(ᙕb }GlmĪ.ʻxz9 h?HZimcID26<==~0{ýq-Jned6ڐ\9sz8xP]^>$ck4j6z "וW ݅]lYtC;:Mz} c5eROO>nܸf(+c:tɇRG@ʔy-TPhs/,ff|^ꐳϞ<=^䒊zGw;{lrctB=m/)k+Jtv&)(ypwwxs+.%$tv }ÖCiuEFޣޘ9Ju*|zv =q+;_ߨ'YC't:xu[޶dY I1_)li!=}ۀ9 /׃Q2*,#7Umzs^3~<ܸכּt4m~In6Iĺ@o6UEd)UWm;rfzvD"Ki>#Y>|>KR%1u~'Ma%`]\jRϡtSBi4^ 3-.!^#!"iūO;mV}韖_-gCf}&o.;uK]'Wx?&|f %lOcٍhw]eUM_/o̗</4V~HR\-3nOb ~#s;퀏rrS7 c<ׂ':I{X|}~/ߋKiqp;ov_ݩ|ѓyن G&0] DT|؍R2I~6gGYQ73f]!{f>k>)s/{w5)3/Xv퀾INL|h4װ?0ÅԯEPbUT{.a̽WKU+6_*Sԉo^/#,}ǾKIGګ.D;.Sx'2Iޥ?4|fĩ!羌}j{ș#8_-ߖz˓['XEDϩ/{ْ`^_KJ_ѩvY4Ľ{WޡuOZ}sPe~~{jv{ܗخ%=q,q Auڟ;0DHJѱԊZ؏2U;skkk)#ƒvOCScƽ׻~ܖ( æ'> y]֒R2սDGgFLqzy )Òpͨ^J;ԗKT~vʼ#.`hy53\giU{aû[LCR$WD p`GH2;$QηpCSQ 5)ܼkFsd[!Q>4|Wno"%"b8'䩧4%Ej'rLI8z|6UbPǀCҊy<֧#7+ZpBִ&cDz'8O J"~ܙTy,T DG~Z?V$7Ihֵo'Z@44+ǫXa!Bǟ?e2Y=sKC"VW?YOWGiU6 %33"4>UK$DD9羻[+zV;yLQ0Re?.zVq|&"oHǜ8+s >RiZBDzᨷ/g'"R2Z@!1Yv+*{SZ""s U为:1TOySR ȾYU+I+-SuhL- 1=Â-H~[7uv6"Regf5µq3}h{U9Z_ y-Gsd0h;ϧs'4<?[if.Z0\t(\=P1 Ld#?4j+!DՔU'MK}2"wq>&dD1g4+IL7"/T<͎&w2L=S Y(l>##EZpDDL]{!\YF-|Se* ɣ'Y ;^}z?0V_PPG6W=lފO"rDL0\|F$"&W3ӥ\ _v+MHd2q!ۻX뵡T7Μ"Z9FN4'.%城5cq~.ơFV=.<ÿw}}IN{G2G:iI>dj]|aM+L=z./-<$ijg#2sX#Ԑmgʢ>S U c1I(ybN37oBU:u*?VTTȈngҤO'8>!6sVj3 ck:oIJ?+b᳤eu&L"euʞ#eg~3k[KUfF__)Ѹ=wVmu}gJ\Cik/~=_ʢ7V+Xh j,׆Ms$v}~WrLIYQqoa%4a29Þ}S$Rv*>yڸݸ:}x#R#_~?pTD^u\#+aD-ܗJسFdkkccaʨ+JJuG(v#ҋ}E0*ܶ?.ɞR7ZI !^vч.T?39^<إ7Su8~X:a _G 3CIyaոym R0!!)>6UI{ȾޝT?@_';[[;:q~[g!n|033C /i̺WC8aeXQչX'>G:1UYߐ#$`M+x,]>Ν`,>| $E/ɐf<6)% ۾z܈ij9.(xb@:XVuh$@ iZI+:HZA V /~?~jn5fΝ;}W&mعs_ ';wv։·,fxOgs [sΝKt5|@O3Xg7q/EŽHs(lq=EMy&_zE̺ϷCrɲs T/NB,HuFٵ^??FsI+7WC_ޙ^*.*OX_3_oZ bv"/0_jN,}*W])J<uX<ۿ{VGhkL8@XZ ps66T$^<̭ezM UEygv:RVCGGWWMD0I#^ڶ SjO-)ua(ᙕb }GlmĪ.ʻxz9 nW t!"s'_cΨOOFx4JH.>ˆ/6MqMeo͈b[~o~Z)5ɡƍ7dC#NmhDpg';sܜ#OIP_Ϧۀa]L '';'3܉sOkma_lÔe'ئ!CvwpeřŒ'bF_pnhx =Y{og޽1`$4!E]Ey-}"fݔem_y|S}ՎS G9g|JZ]ܾaˡ6I>}BqU?nu5{w{W>[:]x HG`og¡fV7Pr+#CzBGkCV֗e7Ó05cl(a}z`z?}n~%"WNNߖufaqZuAG>=T7ň&rHzjE5MXr"2c=bb-x:lʕz/s3vf7|y!=7DIdp&~pGvǟθ=u-Sz{y_GWh^_^o1n<[+rz 9Gc-I^nJO6`wK`Ե )oo`O%I=HLCiӆ{eJȉ܉SeKDщ.ŌH.*i㛺f<5wxμ+.D;.~FND$]sge*9qj',gM'e+6B$"R6g^ܱlrI~6g/~?y-Xr;%N?a\ub%""S|@|aH) "{-R*ػt?Ǐ_.`- p 'ejdt6ӷ3+5(>Q7U-e'Kت6Ψ#eYDĴ07[^3G:p9ZsdƽI׭;XH=hC};9d7gHVyu,xYҦ? vof-7rjeJyCUAJ|V"ut| M#ŭ-&G"'"2oK߶.R8; nFbzxy܉H)ey|uxW}ՍSϙ5i%%*?ǁfyGVٹ+-,PShZx9WZ{_J}6EybRLkȻ|dcq`2;,DHQgJ]k˯Y|rRVVVNDթw殔KDw~8kL_ۻ3Ӣ.9Dcq[Zaz後Gc;<Ȉo{rSUmm}KCZZ.urv}sW{!6WI=8DDd%7FOM3/ g"R,99C5ՙ_U߸B3=|]lͅD35ii}P)dH^YYCd`hdԺoh5^<_ [Y~s{HL\\L*lA->kZ_I焹0YUNoHH.5>::>n "(uD Eir;;lzzRaTz&KL<<\B2\jEim_5qTwsMZDTYZsbPǀCtjo'"*,(h0;5 ](K-n?g1Z\R(U4h@ug.i{x<""OwGn$W(R_y'B a%ľKHLy`*i[.ȟn| 8DT).ǢnU(Iy) V\ 2UmGַlclxg jU6u:ffD$wEW[SKD$0'*kw^nAt䧅cE{dB63 %ȸg%> Q.+NmO;|7o/OzD}kΔj iq DHݩDig܉"u<ܙ.r.5:9$;i_uٶ/Vʌ,"b9YWW'&*#D$kת|_~#X*>ŢR\*{SV}ۆk7`:x|Tz&*Aħ:%=%I1'vm]ɬY_(PQ4Y/($"m<ŕO)DItuwaIEB&#"bX֟k_u٦/BQ 2"W7i9t" J9Y"9gC Ә|saӇ}:;3_iq}LSRٻrb}bg?9mE Yz0 tOv }/ބ늊7jy4ZqblG5k^o6H"uvq#yJJQFRnmm2DckJJҊ^YU7m2&ҥӱzL""߸&q!uǯMWUf| GF +V͋40&Cf}~×?܎ſ0S=wVmu}gJ\Cik/J5ՔBFtWZ^Q۞mt !ev5]_,6.9SRVT\[X u$Lΰg5Rpd~D>3H^_^RXTZ[Y$ՖXiJyֆI.ll0Wjǒn[qm")ٸHrgٻĤ"ٵvg~3k[KUfF__)ѬjzԴvxgb뛘|#T%z#zwwPiz|NVb[|**aw#ߧ)4+)rsx,ȗ`eW],:} `Xc.KV7hKv6Vܚ„ؼ6͌S^.)mTyqL,3_<[#nG u'_x6ԷʞW/ԿYcuiiQG^̔-֖! ߻1v@N6Ɯ̘vl T?(Eib4%Ws%dgegL[.tA6f„qP~Wxj?;vc! lOeŬymzA] =^<uŸ}B :Tu. ;$a7ԇCD {X |+WK$/ >h_x!iZA Vuh$@ iZI+:HZA Vuh$@ iZI+:HZA Vuh$@ iZI+:HZA Vuh$@ iZI+:HZA Vuh$@ iZI+:HZA Vuh$@mAܹs lXvv4716VƝ=vtRhCМi((GcEOZqzoz*JW!ǿbO\_zE̺ϣCNZ.m@2qFƥIo0A;/˵|}tdxI+z4k%y*7%678X1͋&*8{{ ➑ O cgZ~}޴ =7?ŊUg6ieg@.Q]3`[0eى:"GȐ]l,\Yuq7xy݆Fwvrp3ש)I;doezM UEygv:"VCGGWWMD0I#m*>xn6s{vGK DGF9]u}ק,osa(ᙕb }GlmĪ.ʻxz9 -W8tyR/3nivn?IebIxAI+HyBD!w_ ;[=!{3[鐼0Vo_AG6zֺvAƏo "y]yE]hտONޟl(&"ۙp8لjUCdyܼw0H&)W){)Cν*ګXF|}Cd *Cn{ےeWuDOAKGmE&,DTVT,Szsf_ru߯eUHY|sGGn~Ӭmg G9p$iG~So!=簷Ȝut`Zuu߶#ngH*b6{z]3B-yO ߮cC ?|-qT?JD;6mc*Mtq4Պl7$m_n9xۯ[[OtC%ַrz%$)~T}Rmnxx<ދXoۀqa\uϨVJϭ?S %ReJR.W6~̧s˗lRxwn^vY׻nSwKg>r9vD)-ϸ0JP}2B2.> `Gcu퉕J"R)*sOWnIGZ.Iج[q~]h32tO\ȭTJiyV\Yg}3V Uzd-hZf똷Ǻ޼5 ^x1_ؚ3 0z後pߞIk{w>YcZ"CWW^2U1Y[[jǰwxQ4:jk.ٻX\[M]NH'& ̈́mcrԛtc!)ENdؾjދz{`DB' ju?\_ϊ1qq1%F=6Tlml~cԵԘxf<" ՍA%%Hgn++k ñ F;C8(O+"־?/5iU/'k’C"u2zޝ.y>m\`)tԨA܄ܔDQn^R̵hq9HYaGЈCDbvATG* w8l dpw.I}jދ&'fPOwo>vDBD} Z+ 6pH\NAQ.OCUkhd$R>AHrXv~)AIdo;J"cj/VgO_vANI)$"~f7_,9*I꿿oo hQV~kWߑwM!N!a:~\l@+ WE{PId9ldOr,f#ODcghMMTٙYZ>擕2"wq_yĉb" ׬|??[if.Z0\tWԒ~@3,؂7*j@?@:W:!BMD,=Gش%_N% ';NZhO$Sy~~q>&;mg4a7kp=iEDDݟ/inB1o!Qa3?ڙN[XZ?m+]F]9rRŷ0I^aϾ);J~uן7qyi%yLS;Y{n_|)K;+6l3ʋK%JD_uqʹ$~n6wSurIi5DcɊc8GV\RQ!#yW>U(S>e\*=|,Nھ`y ڋJVv*KqE"nݰ@RU[~$؊7.<}\Œ˔VV03F߿W/TM-~4(3JnVz~rV=ZʤЁ ;c'R+WJo|^v:,޷q{Ru&03J]4h$@ iZI+:HZA Vuh$@ iZI+:HZA Vwp\@h!$Aܹs:h.3j̚;w~{MXyլ^Sx0g޼tbU y2HysKW! ~JEy4;Eu_%,(/uEQFADvd1zƉ(q%XDQ?j/O\^{*kءc.]HZ= Lxu 5q 8nܖ{G SfUq~~n}#3$Vg R&DՖ]>xB=ʣA}.Ok1jIQ]ّݶfV#DŽuqr+$%9WU+S7.Ďnl';^ks^]\ƊܤE)>[z"b|#3{wGwQc,6@|{ME``9p:هC墿WؕNb(6ͭ"qui.[Zh찏Vl9b xLͽ_?~ Y+xO\7o 57=_7sdeR]3+vn]^dGRCk׌W#ϕjUɎۘ7^O!"Y,;۹v=Sn.SOb6%I I=o}+I$R($keЉx<^[8ƾ=ƁSv&Yh{Q}ԥlOB_~{A @x0It~KN޽{O!kYU~|""v{tvd0 *ז__/SGyf2՚Bn|SӶf4֕'+OԳ}<+gˣI}/O+>#-Ŵ_zwᅬnSDzLc?yެa>}*zr!36R۾8!GW^ۿ7oȈm9p7?ke^BeM}kJWmi`ˮߛLXb&{v nVBRs۱odϪ;i+ w4~@7 Y׫ݻ *ݞڹzŝS||WuT˶ZK/ 3:W7*sHʼnoT#&0 l/KOO8gwT@knC#;;98ؙ}2e?׫s๓O]wiZrߟn><ʵQN)20ǡ7^gp_!QՙgHR*Q65ILFD,kz(:2&:wr2!pᄓ,\LMA''CJj\v(ژN玲cyOʚIT!.iak׳gg/α 2 󉨲R?t&҄kK"RW< *cbs&:y9REnLtE0^'ԇE/w5i><ɕ_-j$N;NRxk4j6z "וW ݅]lYt὏e̼]=@V6Nװ>R{]vj((Xt34|_f[nsWw*ho2]L7r3ŷ DDE&9{ěsXqH.)$oPXJ{L镉cdrV|Pz{3Ip#QADT={u ɋl>k8x|x*I~}%G"grzDL6[}=mg G9p$iG~;Oc ~#sҸDWIS/^ղS~Hڄ1('2*=LhFDbqeJ9Y eLHTyD}^ f:|,̌MeJoݪ!7G+aV?.\c^|fMIaQ~Z}JO}|q=~uNC:{/so>3 N͜0ɳWO^n ȩא#{4jŒjbػoJ|tb1c>Z;w{AXv#>Z4]WY{ jE9% ->R6T&^ ÏwtGM0VAҪ#&UR|ʯ_&/.=xg866]?9 ""RJ+zmo6ߵ3Gj&Oǒ'\N拈S_>%QP] ?2RT\v߸L*=~8W" 0{RFFLMf_Ntboo**+LLJWkyU6GmL{pp׮5O> Yw@GJ8 {~D70и?kOODl Vv^6^uźc1ϟlel$077$1yP{^\RL<G%ΦNtލʐEH=y+#+:7{IovƤ??}֙vZ3ƮȽz"!ѐiaC'XCN^x`=N.l"ͨ+"Ue{^cOdֳ1p;cED*hזAkƺ o[ӻM{]d )ȟ$ҟ Z3sԐs_F>2+ r ͈r"$:_¸E,}Μmr`;<Ȉo{\ܣ} I3IwsoLX1՜53 y]ֺ13sN)McdzIu]7d5ʉ̄B&\hFTLҚ9HHҎyt}ZbM3O#?AS/|rA%:yz JȆb5ijuF-"_Ω鳧2S${С;U%ŕRe=M?󝃚(SזaϟK{սK^=L镨Zb1gEfS"YEs3që֞,i5:֊[cSsx5QOɩ1ch?MVvaSWu6Vp\y 6T3+#ee=p$SdfNhkåzrOqYc)op_hQGN%P!i!iqx=}DW6DȾz|PAg򔫫H)JJn݌u7qq1%F=6Tlml^&gv;o)KOov?nl>jynrtty1N DG^7kRַQ{Pw=+W%O)؟Woםr O$vg7,'X"i=yEʑ ,Ww<ߢ ?9o#C#{}gذ[W?eMI^㞼\\,.:Z芖_88o+LNmLtqԌCTZY >b`AM_7x1q\hnaanaanjLDQXjW}Vt$&Vv,yQ}VB\kl2YZUŒeeSԷ-Qo$)))S&N:sY΄akcKD)O}+/+S ՂS׎>dkRS9Hbƿ޵[?󬮬ч:IDlNަ-8S'oS"*Jլ oox7X :"q "ճN;;+,hhWWB$?-nʴSNm'6ӳs@{t_3-*gW;=ǘe<$oڵr u "@&z蘙1TQ]iaxʐ =D姿}9=^n:.>kV9mR+&W=U}w?fV~}P3ET:xԮuX'Sߟ˦3Q x\-J+&/?_`H=  v<# RR tW~N c˗3П3DT*.Պ*//'Y[U>A\+R";G c׷Ä kXͭX-7_,5) g}|O7$Ȉϝ8=S.OO97-huU5(?ǖ';t!_! QiY,jc"Ϳ+*6&tKqR"n?Dd>_Y |?u"9ؙ-Zj)៭ti3^.|o˦v5Mm-CB=T}:]gS~~#eNԙ̶Gj\ XXYQmyT $K=x(UٖfyF i _H^xrK{Ą0eGDٹǺb)A㭩*;3yƇkf/R/g8 +-ɷ7i)"7jy/nLoʍk?`ISgxedD1ov$r&Ds-ݦ~:DUSZ]&%vRb 3D5u dէڅ_Q񻾾d`'gp,m3~gW^}z?6ˏ9~G[0 u&[d9qNم|No;׶[_ ˣ=.UWU>x{LXnĠNF]omµ fJSYN~{ʸWRO+/}WG-~b9a1^pbPe>ߩĤLxon4.暌x=<Ӊ)4xFx#n1A|W4rpAmYKɭK1uc}G q MrPC_#T nukh=QUlLJye~ۗ [s7;)|pe"dűVf`{}=obiw5wșJJh$yer=HBFtJDZ^Q>1Z<~M,L^zr6L)+̆r ~j̚wlvt !ev-Q՟b$B;!I۾9ŋ֖GW{@`JDT]ݾ3u*?IJ]G+ %Lc*amj5۫LVU5<HY%:m)m*Z%Kk/F'u=hECUyDk"4g3ZuYD]zGqOqoWwy oPWX4pv,x·'q~g ֩S-ܳ${'|O חֲv|6I% ˟}BAĨNo!UDDJY8sR<{ϗ+hq=UJ20.ag_`w.5ŊVOV@[`7.Rl#΋qe=zte^sTu{"ƘSs]QA-D?@_';[[;:q~[g!nlTowLm;KAcWyBG{W7 {YǥLΝM2VDlsR>[@Dl]]Vm3_7#c]E]</S~\d^%yQ#Co<+G%1wۖH׶dNȅ#+0&! d6`zZqmx\Rj߫MAq n]> dnST)?ůQ!WfFL5#F{|= <7.ٴ3i@DD|+WK$(% NpƖo~̴h$@ iZI+:HZA &JhBhwHZz$I;\RsU+/>d*,cǎΎǶSO^<2/txi؍\#Me9gر"ahjMBr_STG3G/97nqmܻڵ3kX=Ocgi,ͿuKt讃6T^ظjOv{ ^ɍ;?/c~Xu[LIk-?&@hiFU _|VR_Yn_D_{i+mp<%; ~Nܻ3dww _YZ {grORq$Ɣ{uirmb؝ut5}[XGtXܷ x]ib뱃:F+ژbLaݛ.b Y\ҺuҢV![ڿ*&NMU'??|Gs~[o-̝Q6~+)p<޽jr.] naYsNDy Sm]46va>"%֘SkuZdiK46mFеUw}W㝞w8x(2ɳ獁ɜ@&~*${g|'];C={!GS}/6:7Rbr=L5ЫyNuX*]@K.}R.=~qS ];Ujo2/jtpV_7Lycj~"g7%JΟhn%׾AM멯G }&lD$# e;Ƨ&O^?rQJh]͒%dwjp{#n\^3jWWW]T~g.;I w2Vr7++T;UJy,u;om(hMȼu+abɩs݅-'olM}EyD裇˥C /|=м W?[h`ikg=|@7~f9=ۋ!<@T\FAѻ߄^N^ޜFYcCC(fرke. 6}EUҲ2wwg`wG^F"J=lu+Y:W;*U$hƎt-vc| WY3^Սn)=ocY~od?J{fZFedikcFpD͏.;5~Ef>^ڿŠq0[J:sVLd<#agx񧋣R&8yj|e D72>έ?x/Lާ(8v)1~̼i>cUPCCF1~eQ o{çyN-:9Of348wTQuOCz4t>8"yCHMN/R읜<"Ɔ94qmdueဖG%G&|fN|/_RO y޴|oAQQZM:gfV#-(*2Ք#8k>uϔ$)36rjxos>Aӿnz&֟5m3Wsw曣{*i學n=B}үŇn<_0~g"RIDZW̨_e?Ko*#|*yw2d\hJTSV*&:6qĴV&]Ebu'nb_k]Ăw̟v{cD,>ǣ=wpi\tou5wcۏߓdkux9;|FHIϾkG1d+ĉG7+>?z, |8фunvXw1 XLKSclnݫ LǛg~vN/Nk,xwlg^Y&߭iGw쫓s?ݏ!+)-Ld=E!$DkK33xiեV} {'be7`r'mS}~EDdʲӣ'ˁ07o$+i/- rP,<}W%p8[23ڀź{(T9<lmRLdODĴc1HIK#~҉IgpRŝʺRO<_ q|l""߶פĢۢZ^R|.(}_|'bgau=|;5^ol~G g9*[5|VT{yۿ5PYz%_kc18FߚjB$Oަ[R2DW,D1r ?=/-^ɓ_xg[ԲցWd>&MU?DLD66mxP'"VAqvqeUddIid+$qJהYDLWW'ӾIoشW9[oO6t4g~.nuM̯ۮ奟O 糉5{ʒo^mm}|J/K7og4w;=%gEiY[_~YїLML\ֶ9/-]ɸ{}&I}۩ԊOk_"F+RǓGP4HJ 2qߓIJ1>x?jd[ym̏&yW"ikkMKiQg?İ`ʷv-wMdyʤ× JΟHz뷩( ޾U#pjf bp|$`^ɺAA])5IJ"Ѱ"b oxtM5ȘFou鱯ۖ&G86?cHQ6UGi`RV;ڟm0vѝ<]]yW3x 7?O8/ fϟOeه^gǛ-`1g/~;涥_m:tDlrqdW%Uuf2t<]'޼2CÚW(w.וÒ3€?{F{F? A)ͽ>GӤUx)`o`FL 3"uiwZ~0ҹm*ѩS9Jb4tBEclo/7""q-]ޱH3R򙺶=_zipwVO+h}_ޫ8qLLڒ SDDwt(+#v293)IʴoǤԔ'yR"[W'Ҿ}e 0{tQAA/9_Ed(f3 _dg""N {Nl"q^-NR$ѿBYX$"RܾUm^G-.1CYweOzgOEk_ʘͫve0︅%?{g_%3]Fh{ jǎ>?KuER%1 ̭-0JK^J$g:S^&N1]98)7#C9}SNtYɳ3+UF󰰹RHs )&E Ro,I*;bs>WtKDOy__ %ϫ!62o ~D!QeYO9Ro?'NhcˡLYqqa=A91l.uJ$~5,rSggTH lqn&Uo \\m{Ă&&{ 9ǭ,JKK,4?. 22Ɂn̎AWR *3=CEԪksjUYQ{<5M,7v D.59WWɈX(|* [>A\o~? "iNm+ff "K8 rw+*H kgk_:%);_?W㉚ۿ`{6[ܦgO2ٳF²ll߆ NUVy'j}hbO O^dOD=rcۄ nP)Eq=,K&A!d29lfգsQ)?xF3oϱbrHWI['[z<}\>KKkc`hO\vVᰍOt?Ĥ䄸iWo72<=Fˮ35#{P?R9D(u͆eddQXcJ\\8\]@:UDV.OO>]{F7nU@ȈK$8a?2>\eעA^lɭssju:ywẅJJd\]^\\JDBSӎA7_: Q.htkP&[00dՕKn^^r֭揙_l,[rTrԛ{֑BYknnM{Խͯ xLOXu_|pC[b-n|`l%b &[A*C#}<<o4.rm(n~Tm ?lJ)#DD΅mF0jJ:K\}\ve[̞<\:4>|N-W*﮶aBI3VDl(rZ:e2uX "jP4Z[{'tiCi-VU<4Xm] җtt-uɭpٹ)2R3}EuMgq20 b kƒO3Q7jMbԈ-f7Y#qc4뚘Decnl$F 6">ADi|?/x˽sO9Z9!"O hq@;O}Kh%NDD*uK+6fڬ6o`E1}ݧ0ȁG.5._*#b)EG&Y[^PMO"i@V+ˏRiOT[Yt$=]Aȸ vqji4:|(1>t͟Gwl}]Dd#e^Sr~>|@53^ܱgϕ:ϝH-$iiKvړn7䝘XOs#.>`>9ED26`0>xi;RREM^*"Yv}#ٟi]-ׯfOS#AȮd"2]pAݬB)ʅP"";[l,;غam31fc]-&hNPыGTܮc. ֖R"ϿuVqzHqrJKm..DTTTȴWsV%˫G7˃DۛRoZ\z tIۢ\vK>apU}꤃JcR46⩶2UqE\rSZu5 Onu{;_ژGDD7^ydvەn-k;4'cs^՘wL.l·nzSpE{h|"yՖ3Wl`S-y)eWU*򥓼Tߪv{o7g]x}pyHohkOt4jÁm؜SxBM7S$^9Sn}3 9`waSEX-asG98Ng2aqD\Z8K.r(q9ݿOߛb,L x{ն4Uڌ&gg3jwr\CBGDb'ɣiBHs.)Eo?`t% ;.hD}f5έ _ R8Buz#ʿv캊HnO':뇌3soh[]1iv_h:v/S<`=;c#QMDd&Ǭ|f\SWa6OԤP^†\1Q>ц/ooZ՟V~K)ӚbL X鴚禆p[ߞlN}*!H_~0zٺ}ݔ -o;4#Wc6~Ӧu"DW],1[c:>ByCcqfZ˒;˷_ÌҞQ17LdgƬ!VOI?3܉J~=x⬆Fvx}g{2^NWõ*ߎ /[:ūd Dk;Ve|?ݩ/i.u9{_8v9TY ut /=28f)9˙'\$/8:ίyX$/;}$z_v?Ycu0~{q(D\{E" 8uA?d3ZLC[qj\),-v?k$PEI! |ןؗp-ni{7Q7W K]\XReW'lS}vqsr~'I]e"5xDD,QqR;R6 .n2vA^Dd?/:jʎ@ٔ!"ŏ 1pĔԿh:^g,\c""zWVʏR@$3G>*(d3uo3?5cʁ+?dg.E̪SZi}nȴvW=.S-BVͮ= vFƃ726,dbsys{^?SQۛoŜ\MPgEbէr~˳]kK_.^3=qӂo\eYޣF<(;1Θ \A&9rC5u9Z]]t>ZwٙY_) $d.7KMQajr=k@EYu[gB  kܰ֬S7+:op8]̱)MceAv%:{ 8|t{[T~J?_JH7I]W"*P+ϭ,f٪*ԽWH`6|Ǹ>}/hŮcGtt&j0B:k~befe*ac4uڈW,&,[0&uIM>~ho˫>ܝwxs*GLB^pcS^UTVQMOuޞlΗQڧ9~%/%t3tʹ ӬW_Y3 '],*S;x+|dhŒ?6EK#B+[Q;?ӌC ~Dٷ7>jFI֣i%M]="'x^K1#zGQ9^RR򢃥AsYv\\ܟ96q PnUݹ:._&KF{F̳Wk7Ou:O[]ͳ D)&CڴbK''PUu]P X=_B<+=ӊO~U1ދe4Q{q]Xg$莤Z]LSkܱ0;MjĴc2q6L@)ou9H>ϛͶ#K|٠KMS&MqH2vj:k{Ei庲BܵX2L!%6tq&5OYgoŒdдc-2sֆ\Н|WvӟJ|oѼ)o|orbQbeMTT}nVj>ro?Kf..}7)#'Wc6*n SkM`/w9uL:iNˤ:FЙszR_#r |T9'"VOaj7&tR٤1mX9˕,*Gt^N޴_89"C `UE7S[mgsaX/tJ+FԼ!Y=t߸9خHMZA mљ8y{u5]vD'x3g褹SV:g XN^A=#&nO2\uIDlQWߟld G>sx5'UUYEE]@,]A~r kܒH'AJbWiAAæNRIv!h{ AB  %6o0$<)0lK&!` h6A+9ZA lV`s h6A+9ZA lV`s h6A+9ZA lV`s h6A+9ZA lV`s% IIENDB`tremc-tremc-19592ce/settings.cfg000066400000000000000000000061041507451042200166210ustar00rootroot00000000000000[Connection] password = username = port = 9091 host = localhost path = /transmission/rpc ssl = False [Sorting] # Set startup torrent list sort order. Possible sort keys are: # name, addedDate, percentDone, seeders, leechers, sizeWhenDone, status, # uploadedEver, rateUpload, rateDownload, uploadRatio, peersConnected, # downloadDir, mainTrackerDomain. # Prepend ':' for reversed sort. # Examples: # # order = :name # Sorts by torrent name in reversed alphabetical order. # # order = sizeWhenDone # Sorts by torrent size, small to large. order = name [Filtering] # Set startup torrent list filter. Possible filter keys are: # uploading, downloading, active, paused, seeding, incomplete, verifying, # private, isolated, selected, honors. # Use 'invert=True' to shoe only torrents not matching filter. filter = invert = False [Misc] lines_per_torrent = 3 torrentname_is_progressbar = True file_viewer = xdg-open %%s file_open_in_terminal = True view_selected = False rdns = True geoip_database = /xyz torrent_numbers = True profile = x_selection = clipboard [Colors] # Set colors of various interface elements. # Each element has background and foreground color. # Each color is default or one of the eight curses colors: # Black, White, Red, Green, Blue, Cyan, Yellow, Magenta. # Default is the default background or foreground color. title_seed = bg:green,fg:black title_download = bg:blue,fg:black title_idle = bg:cyan,fg:black title_verify = bg:magenta,fg:black title_paused = bg:default,fg:default title_error = bg:red,fg:default download_rate = bg:default,fg:blue upload_rate = bg:default,fg:red eta+ratio = bg:default,fg:default filter_status = bg:default,fg:red multi_filter_status = bg:default,fg:blue dialog = bg:default,fg:default dialog_important = bg:default,fg:red file_prio_high = bg:default,fg:red file_prio_normal = bg:default,fg:default file_prio_low = bg:default,fg:yellow file_prio_off = bg:default,fg:blue [Profiles] # Define filter/sort profiles. See README.md for details. # Examples: # # profile1 = regex#=ubuntu#=:uploadRatio # A profile named '1' shows only torrents whose name contains ubuntu (case # insensitive match), sorted by upload ratio from large to small. # This profile can be selected from the profile menu or by pressing '1'. # # profile2 = incomplete#=#=percentDone # A profile named '2' shows incomplete torrents sorted by progress. # # profileABC = regex#=ubuntu#=:incomplete#= #& # location#=/torrents#=peersConnected # A profile named 'ABC' shows only torrents that satisfy one of the # conditions: # 1. Name contains ubuntu and complete; # 2. Download location is /torrents. # The torrent list is sorted by number of peers connected. # This profile can be selected from the profile menu. # Show all torrents, sort by name: profile0 = [ListKeys] # Configure keys in torrent list only. backslash = select_search_torrent_fulltext gt = select_search_torrent_regex_fulltext y = toggle_torrent_numbers [DetailsKeys] # Configure keys in torrent details only. y = view_file [CommonKeys] # Configure keys globally. Y = verify_torrent v = move_torrent tremc-tremc-19592ce/tremc000077500000000000000000007675641507451042200153700ustar00rootroot00000000000000#!/usr/bin/env python3 # -*- coding: utf-8 -*- ######################################################################## # This program is free software: you can redistribute it and/or modify # # it under the terms of the GNU General Public License as published by # # the Free Software Foundation, either version 3 of the License, or # # (at your option) any later version. # # # # This program is distributed in the hope that it will be useful, # # but WITHOUT ANY WARRANTY; without even the implied warranty of # # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # # GNU General Public License for more details: # # http://www.gnu.org/licenses/gpl-3.0.txt # ######################################################################## import json import time import datetime import re import base64 import socket import argparse import sys import os import signal import unicodedata import locale import curses import curses.ascii from textwrap import wrap from subprocess import call, Popen import netrc import operator from getpass import getpass import urllib.request import urllib.error import urllib.parse import configparser import enum import io try: # Threading was optional until python 3.7, so work without it. import threading except ImportError: threading = None try: import IPy except ImportError: pass try: import pyperclip except ImportError: pass locale.setlocale(locale.LC_ALL, '') PROG = 'tremc' class ConfigParserUpdate(configparser.ConfigParser): def update_file(self, fp, add_missing=True): """Update the specified configuration file to match the current configuration data. Ordering (including blank lines) and comments are preserved. Minor whitespace normalisation (that preceding continuation lines and inline comments) does occur. If add_missing is True, then new options are added to the end of sections (if a section is in a file twice, the last section will be used); new sections are added to the end of the file. This does mean that if the configuration was read from multiple files, the file that is output to will contain the options from all of those files. To avoid this, use add_missing=False, and update each of the input files. Default values are not added; nor are the __name__ options. """ sections = {} current = io.StringIO() replacement = [current] sect = None opt = None written = [] # Default to " = " to match write(), but use the most recent # separator found if the file has any options. vi = " = " while True: line = fp.readline() if not line: break # Comment or blank line? if line.strip() == '' or line[0] in '#;' or \ (line.split(None, 1)[0].lower() == 'rem' and \ line[0] in "rR"): current.write(line) continue # Continuation line? if line[0].isspace() and sect is not None and opt: if ';' in line: # ';' is a comment delimiter only if it follows # a spacing character pos = line.find(';') if line[pos-1].isspace(): comment = line[pos-1:] # Get rid of the newline, and put in the comment. current.seek(-1, 1) current.write(comment + "\n") continue # A section header or option header? else: # Is it a section header? mo = self.SECTCRE.match(line) if mo: # Remember the most recent section with this name, # so that any missing options can be added to it. if sect: sections[sect] = current sect = mo.group('header') current = StringIO.StringIO() replacement.append(current) if sect in self.sections(): current.write(line) # So sections can't start with a continuation line: opt = None # An option line? else: mo = self.OPTCRE.match(line) if mo: opt, vi, value = mo.group('option', 'vi', 'value') comment = "" if vi in ('=', ':') and ';' in value: # ';' is a comment delimiter only if it follows # a spacing character pos = value.find(';') if value[pos-1].isspace(): comment = value[pos-1:] opt = opt.rstrip().lower() if self.has_option(sect, opt): value = self.get(sect, opt) # Fix continuations. value = value.replace("\n", "\n\t") current.write("%s%s%s%s\n" % (opt, vi, value.replace('%','%%'), comment)) written.append((sect, opt)) if sect: sections[sect] = current if add_missing: # Add any new sections. sects = [configparser.DEFAULTSECT] sects.extend(self.sections()) #sects.sort() for sect in sects: if sect == configparser.DEFAULTSECT: opts = self._defaults.keys() else: # Must use _section here to avoid defaults. opts = self._sections[sect].keys() #opts.sort() if sect in sections: output = sections[sect] or current else: output = current output.write("[%s]\n" % (sect,)) sections[sect] = None for opt in opts: if opt != "__name__" and not (sect, opt) in written: value = self.get(sect, opt) # Fix continuations. value = value.replace("\n", "\n\t") output.write("%s%s%s\n" % (opt, vi, value.replace('%','%%'))) written.append((sect, opt)) output.write("\n") # Copy across the new file. fp.seek(0) fp.truncate() for sect in replacement: if sect is not None: fp.write(sect.getvalue()) fp.write("\n") # Global constants and constant configuration class GConfig: VERSION = '0.9.5' TRNSM_VERSION_MIN = '1.90' TRNSM_VERSION_MAX = '4.1' RPC_VERSION_MIN = 8 RPC_VERSION_MAX = 18 STARTTIME = time.time() DEBUG = False ENCODING = locale.getpreferredencoding() or 'UTF-8' # error codes class errors(enum.IntEnum): CONNECTION_ERROR = 1 JSON_ERROR = 2 CONFIGFILE_ERROR = 3 FILTERS_WITH_PARAM = ['tracker', 'regex', 'location', 'locationsubs', 'label', 'group'] speed_k = 1024 def __init__(self): default_config_path = xdg_config_home(PROG + '/settings.cfg') parser = argparse.ArgumentParser(description="%(prog)s " + self.VERSION, usage="(prog)s [options] [torrent] -- transmission-remote-args ...", epilog="Positional arguments are passed to transmission-remote. Use -- to separate from %(prog)s arguments") parser.add_argument("-v", "--version", action="version", version="%(prog)s " + self.VERSION, help="Show version number and supported Transmission versions.") parser.add_argument("-c", "--connect", action="store", dest="connection", default="", help="Point to the server using pattern [username[:password]@]host[:port]/[path]. User will be prompted for a password if required.") parser.add_argument("-s", "--ssl", action="store_true", dest="ssl", default=False, help="Connect to Transmission using SSL.") parser.add_argument("-f", "--config", action="store", dest="configfile", default=default_config_path, help="Path to configuration file.") parser.add_argument("--create-config", action="store_true", default=False, help="Create configuration file CONFIGFILE with default values.") parser.add_argument("-l", "--list-actions", action="store_true", dest='listactions', default=False, help="List available actions for key mapping.") parser.add_argument("-k", "--list-keys", action="store_true", dest='listkeys', default=False, help="List available key names for key mapping.") parser.add_argument("-n", "--netrc", action="store_true", dest="use_netrc", default=False, help="Get authentication info from your ~/.netrc file.") parser.add_argument("-X", "--skip-version-check", "--permissive", action="store_true", dest="PERMISSIVE", default=False, help="Proceed even if the running transmission daemon seems incompatible, or the terminal is too small.") parser.add_argument("-p", "--profile", action="store", dest="profile", help="Select profile to use.") parser.add_argument("-r", "--reverse-dns", action="store_true", dest="rdns", default=False, help="Toggle reverse DNS peers addresses.") parser.add_argument("-d", "--debug", action="store", dest="DEBUG", nargs='?', default=False, help="Enable debugging messages.") parser.add_argument('transmissionremote_args', nargs='*', metavar='A', help="Torrent files to add using transmission-remote") cmd_args = parser.parse_args() for i in vars(cmd_args).keys(): setattr(self, i, vars(cmd_args)[i]) if self.DEBUG is None: self.DEBUG = True self.debug_file = sys.stderr elif isinstance(self.DEBUG, str): try: self.debug_file = open(self.DEBUG, "a", buffering=1) except OSError: self.DEBUG = False pass if not self.create_config and not os.path.isfile(self.configfile) and '/' not in self.configfile: if os.path.isfile(xdg_config_home(PROG + '/' + self.configfile)): self.configfile = xdg_config_home(PROG + '/' + self.configfile) elif os.path.isfile(xdg_config_home(PROG + '/' + self.configfile + '.cfg')): self.configfile = xdg_config_home(PROG + '/' + self.configfile + '.cfg') self.configfile = self.configfile config = ConfigParserUpdate() self.config = config config.optionxform = lambda option: option config.add_section('Connection') config.add_section('Sorting') config.add_section('Filtering') config.add_section('Misc') config.add_section('Colors') config.add_section('Profiles') config.read(self.configfile) self.history_file = '' if PROG in os.path.dirname(self.configfile): self.history_file = os.path.join(os.path.dirname(self.configfile), 'history.json') elif PROG in os.path.basename(self.configfile): self.history_file = self.configfile.rsplit(PROG, 1)[0] + PROG + '-history.json' self.geoip2_database = config.get('Misc', 'geoip2_database', fallback='') self.rdns = self.rdns ^ config.getboolean('Misc', 'rdns', fallback=False) # Handle connection details self.host = config.get('Connection', 'host', fallback='localhost') self.port = config.getint('Connection', 'port', fallback=9091) self.path = config.get('Connection', 'path', fallback='/transmission/rpc') self.username = config.get('Connection', 'username', fallback='') self.password = config.get('Connection', 'password', fallback='') un_pw = os.environ.get("TR_AUTH") if un_pw: self.username = un_pw.split(":")[0] self.password = ":".join(un_pw.split(":")[1:]) if self.use_netrc: self.username, self.password = read_netrc(hostname=self.host) if self.connection: try: if self.connection.count('@') == 1: auth, self.connection = self.connection.split('@') if auth.count(':') == 0: self.username = auth elif auth.count(':') == 1: self.username, self.password = auth.split(':') if self.connection.count(':') == 1: self.host, port = self.connection.split(':') if port.count('/') >= 1: port, self.path = port.split('/', 1) self.port = int(port) else: self.host = self.connection self.ssl = False # Don't use ssl from config file if given connection info on command line. except ValueError: exit_prog("Wrong connection pattern: %s\n" % self.connection) self.ssl = self.ssl | config.getboolean('Connection', 'ssl', fallback=False) url = '%s:%d/%s' % (self.host, self.port, self.path) url = url.replace('//', '/') # double-/ doesn't work for some reason self.url = 'https://%s' % url if self.ssl else 'http://%s' % url if self.create_config: config.set('Connection', 'host', self.host) config.set('Connection', 'port', str(self.port)) config.set('Connection', 'path', self.path) config.set('Connection', 'username', self.username) config.set('Connection', 'password', self.password) config.set('Connection', 'ssl', str(self.ssl)) create_config(self.configfile, self.connection, config) try: self.ipy = True # extract ipv4 from ipv6 addresses self.IPV6_RANGE_6TO4 = IPy.IP('2002::/16') self.IPV6_RANGE_TEREDO = IPy.IP('2001::/32') self.IPV4_ONES = 0xffffffff except NameError: self.ipy = False self.clipboard = 'pyperclip' in sys.modules self.sort_options = [ ('name', '_Name'), ('addedDate', '_Age'), ('percentDone', '_Progress'), ('seeders', '_Seeds'), ('leechers', 'Lee_ches'), ('sizeWhenDone', 'Si_ze'), ('status', 'S_tatus'), ('uploadedEver', 'Up_loaded'), ('rateUpload', '_Upload Speed'), ('rateDownload', '_Download Speed'), ('uploadRatio', '_Ratio'), ('peersConnected', 'P_eers'), ('downloadDir', 'L_ocation'), ('mainTrackerDomain', 'Trac_ker'), ('queuePosition', '_Queue Position'), ('activityDate', 'Last activit_y'), ('eta', 'Time Le_ft'), ('doneDate', 'T_ime Done'), ('reverse', 'Re_verse') ] self.file_sort_options = [ ('name', '_Name'), ('progress', '_Progress'), ('length', 'Si_ze'), ('bytesCompleted', '_Downloaded'), ('priority', 'P_riority'), ('none', '_Torrent order'), ('reverse', 'Re_verse') ] filter_string = config.get('Filtering', 'filter', fallback='') self.filters = [[{}]] if '#=' in filter_string: self.filters[0] = parse_single_filter_str(filter_string) else: self.filters[0][0]['name'] = filter_string self.filters[0][0]['inverse'] = config.getboolean('Filtering', 'invert', fallback=False) self.filters[0][0]['label'] = '' self.filters[0][0]['group'] = '' self.filters[0][0]['regex'] = '' self.filters[0][0]['tracker'] = '' self.filters[0][0]['location'] = '' self.sort_orders = parse_sort_str(config.get('Sorting', 'order', fallback='name'), [x[0] for x in self.sort_options]) self.file_sort_key = 'name' self.file_sort_reverse = False self.histories = load_history(self.history_file) self.narrow_threshold = config.getint('Misc', 'narrow_threshold', fallback=73) self.lines_per_torrent = config.getint('Misc', 'lines_per_torrent', fallback=3) self.torrentname_is_progressbar = config.getboolean('Misc', 'torrentname_is_progressbar', fallback=True) self.file_viewer = config.get('Misc', 'file_viewer', fallback='xdg-open %%s') self.file_open_in_terminal = config.getboolean('Misc', 'file_open_in_terminal', fallback=True) self.view_selected = config.getboolean('Misc', 'view_selected', fallback=False) self.torrent_numbers = config.getboolean('Misc', 'torrent_numbers', fallback=False) self.save_conf = config.getboolean('Misc', 'save_conf', fallback=False) self.profiles = parse_config_profiles(config, [x[0] for x in self.sort_options]) self.x_selection = config.get('Misc', 'x_selection', fallback='clipboard') if self.profile not in self.profiles: self.profile = config.get('Misc', 'profile', fallback=self.profile) self.actions = { # First in list: 0=all 1=list 2=details 3=files 4=tracker 16=movement # +256 for RPC>=14, +512 for RPC>=16, +1024 for RPC>=17 'list_key_bindings': [0, ['F1', '?'], 'List key bindings'], 'quit_now': [0, ['^w'], 'Quit immediately'], 'quit': [1, ['q'], 'Quit'], 'leave_details': [2, ['BACKSPACE', 'q'], 'Back to torrent list'], 'go_back_or_unfocus': [2, ['ESC', 'BREAK'], 'Unfocus or back to torrent list'], 'daemon_quit': [0, ['X'], 'Ask daemon to quit'], 'options_dialog': [0, ['O'], PROG + ' options menu'], 'save_config': [0, [], 'Save config file'], 'server_options_dialog': [1, ['o'], 'Server options menu'], 'toggle_compact_torrentlist': [1, ['C'], 'Cycle torrent line height'], 'toggle_torrent_numbers': [1, [], 'Toggle torrent number in list'], 'turtle_mode': [1, ['t'], 'Toggle turtle mode'], 'unmapped_actions': [0, '`', 'Show actions not mapped to keys'], 'global_upload': [0, ['u'], 'Set global upload'], 'global_download': [0, ['d'], 'Set global download limit'], 'torrent_upload': [0, ['U'], 'Set torrent maximum upload rate'], 'torrent_download': [0, ['D'], 'Set torrent maximum download rate'], 'group_upload': [0, [], 'Set group maximum upload rate'], 'group_download': [0, [], 'Set group maximum download rate'], 'seed_ratio': [0, ['L'], 'Set seed ratio limit for focused torrent'], 'bandwidth_priority_inc': [0, ['+'], 'Increase torrent bandwidth priority'], 'bandwidth_priority_dec': [0, ['-'], 'Decrease torrent bandwidth priority'], 'honors_limits': [0, ['*'], 'Toggle torrent honors session limits'], 'sequential_download': [2048, [], 'Toggle sequential download'], 'pause_unpause_torrent': [0, ['p'], 'Pause/Unpause torrent'], 'pause_unpause_all_torrent': [0, ['P'], 'Pause/Unpause all torrents'], 'start_now_torrent': [0, ['N'], 'Start torrent now'], 'verify_torrent': [0, ['v', 'y'], 'Verify torrent'], 'move_torrent': [0, ['m'], 'Move torrent'], 'rename_torrent_selected_file': [0, ['F'], 'Rename torrent/file'], 'reannounce_torrent': [0, ['n'], 'Reannounce torrent'], 'show_stats': [0, ['S'], 'Show upload/download stats'], 'remove': [1, ['DC', 'r'], 'Remove selected/focused torrents, keeping content'], 'remove_focused': [1, [], 'Remove focused torrent keeping content'], 'remove_selected': [1, ['^r'], 'Remove selected torrents'], 'remove_data': [0, [], 'Remove selected/focused torrents and content'], 'remove_focused_data': [0, ['SDC', 'R'], 'Remove torrent and content'], 'remove_selected_data': [1, [], 'Remove selected torrents and content'], 'copy_magnet_link': [0, ['M'], 'Copy Magnet Link to the System Clipboard'], 'remove_labels': [512, ['^l'], 'Remove labels'], 'add_label': [512, ['b'], 'Add label'], 'set_labels': [512, ['B'], 'Set labels'], 'set_group': [1024, [], 'Set group'], 'group_get': [1024, [], 'Get group list'], 'move_queue_down': [257, ['J'], 'Move torrent down in queue'], 'move_queue_up': [257, ['K'], 'Move torrent up in queue'], 'profile_menu': [1, ['e'], 'Profile menu'], 'save_profile': [1, ['E'], 'Save profile'], 'search_torrent': [1, ['/'], 'Find torrent'], 'search_torrent_regex': [1, ['.'], 'Find torrents matching regular expression'], 'search_torrent_fulltext': [1, [], 'Find torrent (full text)'], 'search_torrent_regex_fulltext': [1, [], 'Find torrents matching regular expression (full text)'], 'set_filter': [1, ['f'], 'Set filter'], 'add_filter': [1, ['T'], 'Add filter'], 'add_filter_line': [1, ['^t'], 'Add filter line'], 'edit_filters': [1, ['I'], 'Edit list of filters'], 'invert_filters': [1, ['~'], 'Reverse filters'], 'show_torrent_sort_order_menu': [1, ['s'], 'Sort torrent list'], 'select_unselect_torrent': [1, ['SPACE'], 'Select/unselect torrent'], 'select_unselect_torrents': [1, ['A'], 'Select/Deselect all torrents'], 'invert_selection_torrents': [1, ['i'], 'Invert torrent selection'], 'select_search_torrent': [1, [','], 'Select torrents matching pattern'], 'select_search_torrent_regex': [1, ['<'], 'Select torrents matching regex'], 'select_search_torrent_fulltext': [1, [], 'Select torrents matching pattern (full text)'], 'select_search_torrent_regex_fulltext': [1, [], 'Select torrents matching regex (full text)'], 'enter_details': [1, ['ENTER', 'RIGHT', 'l'], 'Enter torrent details view'], 'add_torrent': [1, ['a'], 'Add torrent'], 'add_torrent_paused': [1, ['^a'], 'Add torrent paused'], 'unfocus_torrent': [1, ['ESC', 'BREAK'], 'Unfocus torrent'], 'tab_overview': [2, ['o'], 'Jump to overview'], 'tab_files': [2, ['f'], 'Jump to file list'], 'tab_peers': [2, ['e'], 'Jump to peer list'], 'tab_trackers': [2, ['t'], 'Jump to tracker list'], 'tab_chunks': [2, ['c'], 'Jump to chunk list'], 'next_details': [2, ['TAB'], 'Next details tab'], 'prev_details': [2, ['BTAB'], 'Previous details tab'], 'file_priority_or_switch_details_next': [2, ['RIGHT', 'l'], 'Raise file priority or Previous tab'], 'file_priority_or_switch_details_prev': [2, ['LEFT', 'h'], 'Lower file priority or Previous tab'], 'add_tracker_or_select_all_files': [2, ['a'], 'Select/Deselect all files or add torrent'], 'view_file': [3, ['ENTER'], 'View file'], 'view_file_command': [3, ['|'], 'Run command on file'], 'view_torrent': [1, [], "View torrent's single file"], 'view_torrent_command': [1, [], "Run command on torrent's single file"], 'move_to_next_directory': [3, ['J'], 'Next diectory'], 'move_to_previous_directory': [3, ['K'], 'Previous directory'], 'show_file_sort_order_menu': [3, ['s'], 'Sort file list'], 'visual_select_files': [3, ['V'], 'Visually select files'], 'select_search_file': [3, [','], 'Select files matching pattern'], 'select_files_dir': [3, ['A'], 'Select/Deselect directory'], 'search_file': [3, ['/'], 'Search file list'], 'rename_dir': [3, ['C'], 'Rename directory inside torrent'], 'select_search_file_regex': [3, ['<'], 'Select files matching regex'], 'search_file_regex': [3, ['.'], 'Find files matching regex'], 'invert_selection_files': [3, ['i'], 'Invert selection'], 'select_file': [3, ['SPACE'], 'Select/unselect file'], 'file_info': [3, ['x'], 'Show file info'], 'remove_tracker': [4, ['DC', 'r'], 'Remove tracker'], 'remove_all_trackers': [0, [], 'Remove all trackers from torrent'], 'page_up': [16, ['PPAGE', '^b'], 'Page Up'], 'page_down': [16, ['NPAGE', '^f'], 'Page Down'], 'line_up': [16, ['UP', 'k', '^p'], 'Up'], 'line_down': [16, ['DOWN', 'j', '^n'], 'Down'], 'scroll_line_up': [16, ['^k'], 'Scroll one line up'], 'scroll_line_down': [16, ['^i'], 'Scroll one line down'], 'go_home': [16, ['HOME', 'g'], 'Home'], 'go_end': [16, ['END', 'G'], 'End'], } self.keys = [x for x in dir(K) if x[0] != '_'] + \ [x[4:] for x in dir(curses) if x[:4] == 'KEY_'] exit = False if self.listactions: list_actions(self.actions) exit = True if self.listkeys: list_keys() exit = True if exit: sys.exit(0) def init_colors(self, config): colors = { 'title_seed': 'bg:green,fg:black', 'title_download': 'bg:blue,fg:black', 'title_idle': 'bg:cyan,fg:black', 'title_verify': 'bg:magenta,fg:black', 'title_paused': 'bg:default,fg:default', 'title_paused_done': 'title_paused', 'title_error': 'bg:red,fg:default', 'title_seed_incomp': 'a:r', 'title_download_incomp': 'a:r', 'title_idle_incomp': 'a:r', 'title_verify_incomp': 'a:r', 'title_paused_incomp': 'a:r', 'title_paused_done_incomp': 'title_paused_incomp', 'title_error_incomp': 'a:r', 'title_other': 'bg:default,fg:default', 'download_rate': 'bg:default,fg:blue,a:b', 'upload_rate': 'bg:black,fg:red,a:b', 'eta+ratio': 'bg:default,fg:default,a:b', 'filter_status': 'bg:red,fg:black', 'sort_status': 'bg:red,fg:black', 'multi_filter_status': 'bg:blue,fg:black', 'dialog': 'bg:default,fg:default,a:rb', 'dialog_important': 'bg:default,fg:red,a:r', 'dialog_text': 'dialog,a:*r', 'dialog_text_important': 'dialog_important,a:*r', 'menu_focused': 'dialog,a:*r', 'file_line': '', 'dir_line': '', 'file_prio_high': 'fg:red,bg:default', 'file_prio_normal': 'fg:default,bg:default', 'file_prio_low': 'fg:yellow,bg:default', 'file_prio_off': 'fg:blue,bg:default', 'top_line': 'a:r', 'bottom_line': 'a:r', 'chunk_have': 'a:r', 'chunk_dont_have': '', } colors.update(config) self.colors = dict() self.term_has_colors = curses.has_colors() curses.start_color() if self.term_has_colors: curses.use_default_colors() # file list attributes: generate focused and selected attributes if they are not defined by the user for attr in ('file_line', 'file_prio_high', 'file_prio_normal', 'file_prio_low', 'file_prio_off'): if attr + '_f' not in colors: colors[attr + '_f'] = attr + ',a:*r' if attr + '_s' not in colors: colors[attr + '_s'] = attr + ',a:*b*i' if attr + '_f_s' not in colors: colors[attr + '_f_s'] = attr + '_f' + ',a:*b*i' for name in list(colors.keys()): self.colors[name] = self._parse_color_pair(colors[name]) if self.term_has_colors: curses.init_pair(self.colors[name]['ind'], self.colors[name]['fg'], self.colors[name]['bg']) def _parse_color_pair(self, pair): attrs = { 'r': curses.A_REVERSE, 'b': curses.A_BOLD, 'i': curses.A_ITALIC, 'k': curses.A_BLINK, 'd': curses.A_DIM, 'u': curses.A_UNDERLINE, } parts = pair.split(',') bg_name = [x for x in parts if x[:3] == 'bg:'][0].split(':')[1].upper() if 'bg:' in pair else None fg_name = [x for x in parts if x[:3] == 'fg:'][0].split(':')[1].upper() if 'fg:' in pair else None attrs_name = next((x[2:] for x in parts if x[:2] == 'a:'), '') element_copy = next((x for x in parts if x in self.colors), None) color_pair = {'ind': len(list(self.colors.keys())) + 1} color_pair['bg'] = -1 color_pair['fg'] = -1 color_pair['at'] = curses.A_NORMAL if element_copy: color_pair['bg'] = self.colors[element_copy]['bg'] color_pair['fg'] = self.colors[element_copy]['fg'] color_pair['at'] = self.colors[element_copy]['at'] if bg_name: if bg_name == 'DEFAULT': color_pair['bg'] = -1 else: color_pair['bg'] = getattr(curses, 'COLOR_' + bg_name, -1) if fg_name: if fg_name == 'DEFAULT': color_pair['fg'] = -1 else: color_pair['fg'] = getattr(curses, 'COLOR_' + fg_name, -1) for i in range(len(attrs_name)): if attrs_name[i] == '0': color_pair['at'] = curses.A_NORMAL if attrs_name[i] in attrs: if i > 0 and attrs_name[i-1] == '-': color_pair['at'] = color_pair['at'] & ~attrs[attrs_name[i]] elif i > 0 and attrs_name[i-1] == '*': color_pair['at'] = color_pair['at'] ^ attrs[attrs_name[i]] else: color_pair['at'] = color_pair['at'] | attrs[attrs_name[i]] return color_pair def element_attr(self, name, st=False): try: if st: name = 'st_' + name if name not in self.colors: return curses.A_REVERSE return curses.color_pair(self.colors[name]['ind']) + self.colors[name]['at'] except: # This only happens if when a bug manifests, but it's better to not # crach even in this situation. pdebug('element_attr', name, st) return 0 def save_profile(self, name, profile): filters_str = ' #& '.join(('#='.join((filter_config_str(f) for f in l)) for l in profile['filter'])) profile_str = filters_str + '#=' + sort_config_str(profile['sort']) self.config.set('Profiles', 'profile' + name, profile_str) def save_config(self): save_keys = { 'Connection': ['host', 'port', 'path', 'username', 'password', 'ssl'], 'Misc': ['lines_per_torrent', 'torrentname_is_progressbar', 'file_viewer', 'file_open_in_terminal', 'view_selected', 'rdns', 'geoip2_database', 'torrent_numbers', 'save_conf'] } for section, keys in save_keys.items(): for key in keys: self.config.set(section, key, str(getattr(self, key)).replace('%','%%')) for name, profile in self.profiles.items(): self.save_profile(name, profile) fp = open(self.configfile,"w+") self.config.update_file(fp) class LoginException(Exception): pass class Keys: TAB = 9 LF = 10 CR = 13 ESC = 27 SPACE = 32 EXCLAMATION = 33 QUOT = 34 HASH = 35 DOLLAR = 36 PERCENT = 37 AMPERSAND = 38 APOSTROPHE = 39 LPAREN = 40 RPAREN = 41 STAR = 42 PLUS = 43 COMMA = 44 MINUS = 45 DOT = 46 SLASH = 47 COLON = 58 SEMICOLON = 59 LT = 60 EQUAL = 61 GT = 62 QUES = 63 AT = 64 LBRACKET = 91 BACKSLASH = 92 RBRACKET = 93 CARET = 94 UL = 95 BACKTICK = 96 LBRACE = 123 PIPE = 124 RBRACE = 125 TILDE = 126 DEL = 127 def __init__(self): for i in range(1, 27): setattr(self, chr(64 + i), 64 + i) setattr(self, chr(64 + i) + '_', i) setattr(self, chr(96 + i), 96 + i) for i in range(0, 10): setattr(self, 'n' + str(i), ord('0') + i) K = Keys() def country_code_by_addr_vany(geo_ip, geo_ip6, addr): if gconfig.geoip2: try: return geo_ip.country(addr).country.iso_code except Exception: return '?' if '.' in addr: return geo_ip.country_code_by_addr(addr) if ':' not in addr: return '?' if gconfig.ipy: ip = IPy.IP(addr) if ip in gconfig.IPV6_RANGE_6TO4: addr = str(IPy.IP(ip.int() >> 80 & gconfig.IPV4_ONES)) return geo_ip.country_code_by_addr(addr) if ip in gconfig.IPV6_RANGE_TEREDO: addr = str(IPy.IP(ip.int() & gconfig.IPV4_ONES ^ gconfig.IPV4_ONES)) return geo_ip.country_code_by_addr(addr) if hasattr(geo_ip6, 'country_code_by_addr_v6'): return geo_ip6.country_code_by_addr_v6(addr) return '?' def pdebug(*argv): if gconfig.DEBUG: print(time.time() - gconfig.STARTTIME, ": ", *argv, file=gconfig.debug_file, flush=True) # define config defaults class Normalizer: def __init__(self): self.values = {} def add(self, key, value, max_len): if key not in list(self.values.keys()): self.values[key] = [float(value)] else: if len(self.values[key]) >= max_len: self.values[key].pop(0) self.values[key].append(float(value)) return self.get(key) def get(self, key): if key not in list(self.values.keys()): return 0.0 return sum(self.values[key]) / len(self.values[key]) class TransmissionRequest: """Handle communication with Transmission server.""" def __init__(self, url, method=None, tag=None, arguments=None, server=None): """server is not really optional""" self.url = url self.open_request = None self.last_update = 0 self.server = server if method and tag: self.set_request_data(method, tag, arguments) def set_request_data(self, method, tag, arguments=None): request_data = {'method': method, 'tag': tag} if arguments: request_data['arguments'] = arguments self.http_request = urllib.request.Request(self.url, bytes(json.dumps(request_data), gconfig.ENCODING)) def send_request(self): """Ask for information from server OR submit command.""" try: if self.server.session_id: self.http_request.add_header('X-Transmission-Session-Id', self.server.session_id) self.open_request = urllib.request.urlopen(self.http_request) except AttributeError: # request data (http_request) isn't specified yet -- data will be available on next call pass # authentication except urllib.error.HTTPError as e: try: msg = html2text(str(e.read())) except Exception: msg = str(e) # extract session id and send request again m = re.search(r'X-Transmission-Session-Id:\s*(\w+)', msg) try: self.server.session_id = m.group(1) self.send_request() except AttributeError: if e.code == 401: raise LoginException exit_prog(str(msg) + "\n", gconfig.errors.CONNECTION_ERROR) except urllib.error.URLError as msg: exit_prog("Cannot connect to %s: %s" % (self.http_request.host, msg.reason), gconfig.errors.CONNECTION_ERROR) def get_response(self): """Get response to previously sent request.""" if self.open_request is None: return {'result': 'no open request'} response = b'' while True: try: chunk = self.open_request.read() except ConnectionResetError: return {'result': 'connection reset by peer'} except Exception as e: pdebug(str(e)) return {'result': 'Exception'} if not chunk: break response += chunk try: data = json.loads(response.decode("utf-8")) except ValueError: exit_prog("Cannot parse response: %s\n" % response, gconfig.errors.JSON_ERROR) self.open_request = None return data # End of Class TransmissionRequest class Transmission: """Higher level of data exchange""" STATUS_STOPPED = 0 # Torrent is stopped STATUS_CHECK_WAIT = 1 # Queued to check files STATUS_CHECK = 2 # Checking files STATUS_DOWNLOAD_WAIT = 3 # Queued to download STATUS_DOWNLOAD = 4 # Downloading STATUS_SEED_WAIT = 5 # Queued to seed STATUS_SEED = 6 # Seeding TAG_TORRENT_LIST = 7 TAG_TORRENT_DETAILS = 77 TAG_SESSION_STATS = 21 TAG_SESSION_GET = 22 TAG_SESSION_CLOSE = 23 TAG_GROUP_GET = 80 LIST_FIELDS = ['id', 'name', 'downloadDir', 'status', 'trackerStats', 'desiredAvailable', 'rateDownload', 'rateUpload', 'eta', 'uploadRatio', 'sizeWhenDone', 'haveValid', 'haveUnchecked', 'addedDate', 'uploadedEver', 'error', 'errorString', 'recheckProgress', 'peersConnected', 'uploadLimit', 'downloadLimit', 'uploadLimited', 'downloadLimited', 'bandwidthPriority', 'peersSendingToUs', 'peersGettingFromUs', 'totalSize', 'seedRatioLimit', 'seedRatioMode', 'isPrivate', 'magnetLink', 'honorsSessionLimits', 'metadataPercentComplete', 'activityDate', 'doneDate', ] DETAIL_FIELDS = ['files', 'priorities', 'wanted', 'peers', 'trackers', 'dateCreated', 'startDate', 'leftUntilDone', 'comment', 'creator', 'hashString', 'pieceCount', 'pieceSize', 'pieces', 'downloadedEver', 'corruptEver', 'peersFrom'] + LIST_FIELDS def __init__(self, url, username, password): self.url = url self.session_id = 0 if username and password: register_credentials(username, password, url) # check rpc version request = TransmissionRequest(url, 'session-get', self.TAG_SESSION_GET, server=self) try: request.send_request() except LoginException: try: if not username: username = input('Username: ') password = getpass() except: print('') exit() register_credentials(username, password, url) request.send_request() response = request.get_response() self.rpc_version = response['arguments']['rpc-version'] self.version = response['arguments']['version'].split()[0] # rpc version too old? version_error = "Unsupported Transmission version: " + str(response['arguments']['version']) + \ " -- RPC protocol version: " + str(response['arguments']['rpc-version']) + "\n" skip_msg = "Proceeding anyway because of --skip-version-check.\n" min_msg = "Please install Transmission version " + gconfig.TRNSM_VERSION_MIN + " or higher.\n" alternative_msg = "Alternatively start the program with the option '--skip-version-check', '--permissive', or '-X' to inhibit version checking\n" try: if response['arguments']['rpc-version'] < gconfig.RPC_VERSION_MIN: if gconfig.PERMISSIVE: pdebug(version_error + skip_msg) else: exit_prog(version_error + min_msg + alternative_msg) except KeyError: exit_prog(version_error + min_msg) # rpc version too new? if response['arguments']['rpc-version'] > gconfig.RPC_VERSION_MAX: if gconfig.PERMISSIVE: pdebug(version_error + skip_msg) else: exit_prog(version_error + "Please install Transmission version " + gconfig.TRNSM_VERSION_MAX + " or lower.\n" + alternative_msg) # setup compatibility to Transmission <2.40 if self.rpc_version < 14: Transmission.STATUS_CHECK_WAIT = 1 << 0 Transmission.STATUS_CHECK = 1 << 1 Transmission.STATUS_DOWNLOAD_WAIT = 1 << 2 Transmission.STATUS_DOWNLOAD = 1 << 2 Transmission.STATUS_SEED_WAIT = 1 << 3 Transmission.STATUS_SEED = 1 << 3 Transmission.STATUS_STOPPED = 1 << 4 # Queue was implemented in Transmission v2.4 if self.rpc_version >= 14: self.LIST_FIELDS.append('queuePosition') self.DETAIL_FIELDS.append('queuePosition') else: gconfig.sort_options.remove(('queuePosition', '_Queue Position')) if gconfig.sort_orders[0]['name'] == 'queuePosition': # Use default sort if set to invalid queuePosition. gconfig.sort_orders = [{'name': 'name', 'reverse': False}] if self.rpc_version >= 16: self.LIST_FIELDS.append('labels') self.DETAIL_FIELDS.append('labels') if self.rpc_version >= 17: self.LIST_FIELDS.append('group') self.DETAIL_FIELDS.append('group') if self.rpc_version >= 18: self.DETAIL_FIELDS.append('sequential_download') self.LIST_FIELDS.append('sequential_download') # set up request list self.requests = {'torrent-list': TransmissionRequest(url, 'torrent-get', self.TAG_TORRENT_LIST, {'fields': self.LIST_FIELDS}, server=self), 'session-stats': TransmissionRequest(url, 'session-stats', self.TAG_SESSION_STATS, 21, server=self), 'session-get': TransmissionRequest(url, 'session-get', self.TAG_SESSION_GET, server=self), 'torrent-details': TransmissionRequest(url, server=self)} self.torrent_cache = [] self.trackers = set() self.locations = set() self.labels = set() self.groups = set() self.status_cache = dict() self.torrent_details_cache = dict() self.peer_progress_cache = dict() self.hosts_cache = dict() self.geo_ips_cache = dict() if gconfig.geoip1: self.geo_ip = GeoIP.new(GeoIP.GEOIP_MEMORY_CACHE) try: self.geo_ip6 = GeoIP.open_type(GeoIP.GEOIP_COUNTRY_EDITION_V6, GeoIP.GEOIP_MEMORY_CACHE) except AttributeError: self.geo_ip6 = None except GeoIP.error: self.geo_ip6 = None elif gconfig.geoip2: self.geo_ip6 = None try: self.geo_ip = geoip2.database.Reader(gconfig.geoip2_database) except Exception: gconfig.geoip = False # make sure there are no undefined values self.wait_for_torrentlist_update(True) self.requests['torrent-details'] = TransmissionRequest(self.url, server=self) def update(self, delay, tag_waiting_for=0): """Maintain up-to-date data.""" tag_waiting_for_occurred = False for request in list(self.requests.values()): if time.time() - request.last_update >= delay: request.last_update = time.time() response = request.get_response() if response['result'] == 'no open request': request.send_request() elif response['result'] == 'success': tag = self.parse_response(response) if tag == tag_waiting_for: tag_waiting_for_occurred = True return tag_waiting_for_occurred if tag_waiting_for else None def parse_response(self, response): def get_main_tracker_domain(torrent): if torrent['trackerStats']: trackers = sorted(torrent['trackerStats'], key=operator.itemgetter('tier', 'id')) return urllib.parse.urlparse(trackers[0]['announce']).hostname # Trackerless torrents return "None" # response is a reply to torrent-get if response['tag'] == self.TAG_TORRENT_LIST or response['tag'] == self.TAG_TORRENT_DETAILS: for t in response['arguments']['torrents']: t['uploadRatio'] = round(float(t['uploadRatio']), 2) t['percentDone'] = percent(float(t['sizeWhenDone']), float(t['haveValid'] + t['haveUnchecked'])) t['available'] = t['desiredAvailable'] + t['haveValid'] + t['haveUnchecked'] if t['downloadDir'][-1] != '/': t['downloadDir'] += '/' try: t['seeders'] = max([x['seederCount'] for x in t['trackerStats']]) t['leechers'] = max([x['leecherCount'] for x in t['trackerStats']]) except ValueError: t['seeders'] = t['leechers'] = -1 t['isIsolated'] = not self.can_has_peers(t) t['mainTrackerDomain'] = get_main_tracker_domain(t) if t['mainTrackerDomain']: self.trackers.add(t['mainTrackerDomain']) self.locations.add(homedir2tilde(t['downloadDir'])) if self.rpc_version >= 16: for l in t['labels']: self.labels.add(l) if self.rpc_version >= 17: self.groups.add(t['group']) if response['tag'] == self.TAG_TORRENT_LIST: self.torrent_cache = response['arguments']['torrents'] elif response['tag'] == self.TAG_TORRENT_DETAILS: # torrent list may be empty sometimes after deleting # torrents. no idea why and why the server sends us # TAG_TORRENT_DETAILS, but just passing seems to help.(?) try: if len(response['arguments']['torrents']) > 1: self.torrent_details_cache = response['arguments']['torrents'] else: torrent_details = response['arguments']['torrents'][0] torrent_details['pieces'] = base64.decodebytes(bytes(torrent_details['pieces'], gconfig.ENCODING)) self.torrent_details_cache = torrent_details self.upgrade_peerlist() except IndexError: pass elif response['tag'] == self.TAG_SESSION_STATS: self.status_cache.update(response['arguments']) elif response['tag'] == self.TAG_SESSION_GET: self.status_cache.update(response['arguments']) return response['tag'] def upgrade_peerlist(self): for index, peer in enumerate(self.torrent_details_cache['peers']): ip = peer['address'] peerid = ip + self.torrent_details_cache['hashString'] # make sure peer cache exists if peerid not in self.peer_progress_cache: self.peer_progress_cache[peerid] = { 'last_progress': peer['progress'], 'last_update': time.time(), 'download_speed': 0, 'time_left': 0 } this_peer = self.peer_progress_cache[peerid] this_torrent = self.torrent_details_cache # estimate how fast a peer is downloading if peer['progress'] < 1: this_time = time.time() time_diff = this_time - this_peer['last_update'] progress_diff = peer['progress'] - this_peer['last_progress'] if this_peer['last_progress'] and progress_diff > 0 and time_diff > 5: download_left = this_torrent['totalSize'] - \ (this_torrent['totalSize'] * peer['progress']) downloaded = this_torrent['totalSize'] * progress_diff this_peer['download_speed'] = \ norm.add(peerid + ':download_speed', downloaded / time_diff, 10) this_peer['time_left'] = download_left / this_peer['download_speed'] this_peer['last_update'] = this_time # infrequent progress updates lead to increasingly inaccurate # estimates, so we go back to elif time_diff > 60: this_peer['download_speed'] = 0 this_peer['time_left'] = 0 this_peer['last_update'] = time.time() this_peer['last_progress'] = peer['progress'] # remember progress this_torrent['peers'][index].update(this_peer) # resolve and locate peer's ip if gconfig.rdns and ip not in self.hosts_cache: threading.Thread(target=reverse_dns, args=(self.hosts_cache, ip), daemon=True).start() if gconfig.geoip and ip not in self.geo_ips_cache: self.geo_ips_cache[ip] = country_code_by_addr_vany(self.geo_ip, self.geo_ip6, ip) def get_rpc_version(self): return self.rpc_version def get_global_stats(self): return self.status_cache def get_torrent_list(self, sort_orders): def sort_value(value): # Always return a string, so everything is comparable if isinstance(value, (int, float)): # 20 digits should be quite enough for anything (for now) return "%027.6f" % value elif isinstance(value, str): return value.lower() else: return str(value) try: for sort_order in sort_orders: self.torrent_cache.sort(key=lambda x: sort_value(x[sort_order['name']]), reverse=sort_order['reverse']) except IndexError: return [] return self.torrent_cache def get_torrent_by_id(self, t_id): i = 0 while self.torrent_cache[i]['id'] != t_id: i += 1 return self.torrent_cache[i] if self.torrent_cache[i]['id'] == t_id else None def get_torrent_details(self): return self.torrent_details_cache def set_torrent_details_id(self, t_id): if isinstance(t_id, int) and t_id < 0: self.requests['torrent-details'] = TransmissionRequest(self.url, server=self) else: self.requests['torrent-details'].set_request_data('torrent-get', self.TAG_TORRENT_DETAILS, {'ids': t_id, 'fields': self.DETAIL_FIELDS}) def get_hosts(self): return self.hosts_cache def get_geo_ips(self): return self.geo_ips_cache def get_free_space(self): request = TransmissionRequest(self.url, 'session-get', self.TAG_SESSION_GET, server=self) request.send_request() response = request.get_response() path = response['arguments']['download-dir'] request = TransmissionRequest(self.url, 'free-space', 1, {'path': path}, server=self) request.send_request() response = request.get_response() if 'size-bytes' in response['arguments']: free = response['arguments']['size-bytes'] else: free = 0 return free # free space in bytes def set_option(self, option_name, option_value): request = TransmissionRequest(self.url, 'session-set', 1, {option_name: option_value}, server=self) request.send_request() self.wait_for_status_update() # torrent_id is -1 for global or a non-empty list of ids def set_rate_limit(self, direction, new_limit, torrent_id=-1, group = None): data = dict() if new_limit <= -1: new_limit = None limit_enabled = False else: limit_enabled = True if group is not None: request_type = 'group-set' data['name'] = group data['speed-limit-' + direction] = new_limit data['speed-limit-' + direction + '-enabled'] = limit_enabled elif torrent_id == -1: request_type = 'session-set' data['speed-limit-' + direction] = new_limit data['speed-limit-' + direction + '-enabled'] = limit_enabled else: request_type = 'torrent-set' data['ids'] = torrent_id data[direction + 'loadLimit'] = new_limit data[direction + 'loadLimited'] = limit_enabled request = TransmissionRequest(self.url, request_type, 1, data, server=self) request.send_request() self.wait_for_torrentlist_update() def set_seed_ratio(self, ratio, ids=-1): data = dict() if ratio == -1: ratio = None mode = 0 # Use global settings elif ratio == 0: ratio = None mode = 2 # Seed regardless of ratio elif ratio >= 0: mode = 1 # Stop seeding at seedRatioLimit else: return data['ids'] = ids data['seedRatioLimit'] = ratio data['seedRatioMode'] = mode request = TransmissionRequest(self.url, 'torrent-set', 1, data, server=self) request.send_request() self.wait_for_torrentlist_update() def toggle_sequential_download(self, torrent_ids): if torrent_ids: new_honors = not all([self.get_torrent_by_id(t)['sequential_download'] for t in torrent_ids]) request = TransmissionRequest(self.url, 'torrent-set', 1, {'ids': torrent_ids, 'sequential_download': new_honors}, server=self) request.send_request() self.wait_for_torrentlist_update() def toggle_honors_session_limits(self, torrent_ids): if torrent_ids: new_honors = not all([self.get_torrent_by_id(t)['honorsSessionLimits'] for t in torrent_ids]) request = TransmissionRequest(self.url, 'torrent-set', 1, {'ids': torrent_ids, 'honorsSessionLimits': new_honors}, server=self) request.send_request() self.wait_for_torrentlist_update() def increase_bandwidth_priority(self, torrent_ids): if torrent_ids: current = min([self.get_torrent_by_id(t)['bandwidthPriority'] for t in torrent_ids]) if current < 1: request = TransmissionRequest(self.url, 'torrent-set', 1, {'ids': torrent_ids, 'bandwidthPriority': current + 1}, server=self) request.send_request() self.wait_for_torrentlist_update() def decrease_bandwidth_priority(self, torrent_ids): if torrent_ids: current = max([self.get_torrent_by_id(t)['bandwidthPriority'] for t in torrent_ids]) if current > -1: request = TransmissionRequest(self.url, 'torrent-set', 1, {'ids': torrent_ids, 'bandwidthPriority': current - 1}, server=self) request.send_request() self.wait_for_torrentlist_update() def move_queue(self, torrent_id, new_position): args = {'ids': [torrent_id]} if new_position in ('up', 'down', 'top', 'bottom'): method_name = 'queue-move-' + new_position elif isinstance(new_position, int): method_name = 'torrent-set' args['queuePosition'] = min(max(new_position, 0), len(self.torrent_cache) - 1) else: raise ValueError("Is not up/down/top/bottom/: %s" % new_position) request = TransmissionRequest(self.url, method_name, 1, args, server=self) request.send_request() self.wait_for_torrentlist_update() def toggle_turtle_mode(self): self.set_option('alt-speed-enabled', not self.status_cache['alt-speed-enabled']) def add_torrent(self, location, paused=False): args = {'paused': paused} try: with open(location, 'rb') as fp: args['metainfo'] = base64.b64encode(fp.read()).decode() # If the file doesn't exist or we can't open it, then it is either a url or needs to # be open by the server except IOError: args['filename'] = location request = TransmissionRequest(self.url, 'torrent-add', 1, args, server=self) request.send_request() response = request.get_response() return response['result'] if response['result'] != 'success' else '' def daemon_quit(self): request = TransmissionRequest(self.url, 'session-close', self.TAG_SESSION_CLOSE, server=self) request.send_request() self.wait_for_update(self.TAG_SESSION_CLOSE) def stop_torrents(self, ids): request = TransmissionRequest(self.url, 'torrent-stop', 1, {'ids': ids}, server=self) request.send_request() self.wait_for_torrentlist_update() def start_torrents(self, ids): request = TransmissionRequest(self.url, 'torrent-start', 1, {'ids': ids}, server=self) request.send_request() self.wait_for_torrentlist_update() def start_now_torrent(self, ids): request = TransmissionRequest(self.url, 'torrent-start-now', 1, {'ids': ids}, server=self) request.send_request() self.wait_for_torrentlist_update() def verify_torrent(self, ids): request = TransmissionRequest(self.url, 'torrent-verify', 1, {'ids': ids}, server=self) request.send_request() self.wait_for_torrentlist_update() def reannounce_torrent(self, ids): request = TransmissionRequest(self.url, 'torrent-reannounce', 1, {'ids': ids}, server=self) request.send_request() self.wait_for_torrentlist_update() def move_torrent(self, torrent_id, new_location): request = TransmissionRequest(self.url, 'torrent-set-location', 1, {'ids': torrent_id, 'location': new_location, 'move': True}, server=self) request.send_request() self.wait_for_torrentlist_update() def remove_torrent(self, ids, data=False): request = TransmissionRequest(self.url, 'torrent-remove', 1, {'ids': ids, 'delete-local-data': data}, server=self) request.send_request() self.wait_for_torrentlist_update() def rename_torrent_file(self, t_id, path, newname): request = TransmissionRequest(self.url, 'torrent-rename-path', 1, {'ids': [t_id], 'path': path, 'name': newname}, server=self) request.send_request() response = request.get_response() return response['result'] def set_group(self, ids, group): data = { 'ids': ids, 'group': group } request = TransmissionRequest(self.url, 'torrent-set', 1, data, server=self) request.send_request() response = request.get_response() return response['result'] if response['result'] != 'success' else '' def set_labels(self, ids, labels): data = { 'ids': ids, 'labels': labels } request = TransmissionRequest(self.url, 'torrent-set', 1, data, server=self) request.send_request() response = request.get_response() return response['result'] if response['result'] != 'success' else '' def add_label(self, ids, label): ret = '' for i in ids: t = self.get_torrent_by_id(i) if label not in t['labels']: data = { 'ids': [i], 'labels': t['labels'] + [label] } request = TransmissionRequest(self.url, 'torrent-set', 1, data, server=self) request.send_request() response = request.get_response() if ret == '': ret = response['result'] if response['result'] != 'success' else '' return ret def add_torrent_tracker(self, t_id, tracker): data = { 'ids': [t_id], 'trackerAdd': [tracker] } request = TransmissionRequest(self.url, 'torrent-set', 1, data, server=self) request.send_request() response = request.get_response() return response['result'] if response['result'] != 'success' else '' def remove_torrent_tracker(self, t_id, tracker): data = {'ids': t_id, 'trackerRemove': tracker} request = TransmissionRequest(self.url, 'torrent-set', 1, data, server=self) request.send_request() response = request.get_response() self.wait_for_torrentlist_update() return response['result'] if response['result'] != 'success' else '' def increase_file_priority(self, file_nums): file_nums = list(file_nums) ref_num = file_nums[0] for num in file_nums: if not self.torrent_details_cache['wanted'][num]: ref_num = num break if self.torrent_details_cache['priorities'][num] < \ self.torrent_details_cache['priorities'][ref_num]: ref_num = num current_priority = self.torrent_details_cache['priorities'][ref_num] if not self.torrent_details_cache['wanted'][ref_num]: self.set_file_priority(self.torrent_details_cache['id'], file_nums, 'low') elif current_priority <= -1: self.set_file_priority(self.torrent_details_cache['id'], file_nums, 'normal') elif current_priority == 0: self.set_file_priority(self.torrent_details_cache['id'], file_nums, 'high') def decrease_file_priority(self, file_nums): file_nums = list(file_nums) ref_num = file_nums[0] for num in file_nums: if self.torrent_details_cache['priorities'][num] > \ self.torrent_details_cache['priorities'][ref_num]: ref_num = num current_priority = self.torrent_details_cache['priorities'][ref_num] if current_priority >= 1: self.set_file_priority(self.torrent_details_cache['id'], file_nums, 'normal') elif current_priority == 0: self.set_file_priority(self.torrent_details_cache['id'], file_nums, 'low') elif current_priority <= -1: self.set_file_priority(self.torrent_details_cache['id'], file_nums, 'off') def set_file_priority(self, torrent_id, file_nums, priority): request_data = {'ids': [torrent_id]} if priority == 'off': request_data['files-unwanted'] = file_nums else: request_data['files-wanted'] = file_nums request_data['priority-' + priority] = file_nums request = TransmissionRequest(self.url, 'torrent-set', 1, request_data, server=self) request.send_request() self.wait_for_details_update() def get_num_file_priority(self, torrent_id, file_num): if self.torrent_details_cache['wanted'][file_num]: return self.torrent_details_cache['priorities'][file_num] else: # Small enough ? return -999 def get_file_priority(self, torrent_id, file_num): priority = self.torrent_details_cache['priorities'][file_num] if not self.torrent_details_cache['wanted'][file_num]: return 'off' if priority <= -1: return 'low' if priority == 0: return 'normal' if priority >= 1: return 'high' return '?' def wait_for_torrentlist_update(self, wait=False): if wait or not threading: self.wait_for_update(self.TAG_TORRENT_LIST) else: threading.Thread(target=self.wait_for_update, args=[self.TAG_TORRENT_LIST], daemon=True).start() def wait_for_details_update(self): self.wait_for_update(self.TAG_TORRENT_DETAILS) def wait_for_status_update(self): self.wait_for_update(self.TAG_SESSION_GET) def wait_for_update(self, update_id): self.update(0) # send request while True: # wait for response if self.update(0, update_id): break time.sleep(0.1) def get_status(self, torrent, narrow): if narrow: if torrent['status'] == Transmission.STATUS_STOPPED: status = 'P' elif torrent['status'] == Transmission.STATUS_CHECK: status = 'V' elif torrent['status'] == Transmission.STATUS_CHECK_WAIT: status = 'wV' elif torrent['isIsolated']: status = 'X' elif torrent['status'] == Transmission.STATUS_DOWNLOAD: status = ('I', 'D')[torrent['rateDownload'] > 0] if torrent['metadataPercentComplete'] < 1: status += 'M' elif torrent['status'] == Transmission.STATUS_DOWNLOAD_WAIT: status = 'wD%d' % torrent['queuePosition'] elif torrent['status'] == Transmission.STATUS_SEED: status = 'S' elif torrent['status'] == Transmission.STATUS_SEED_WAIT: status = 'wS%d' % torrent['queuePosition'] else: status = '?' else: if torrent['status'] == Transmission.STATUS_STOPPED: status = 'paused' elif torrent['status'] == Transmission.STATUS_CHECK: status = 'verifying' elif torrent['status'] == Transmission.STATUS_CHECK_WAIT: status = 'will verify' elif torrent['isIsolated']: status = 'isolated' elif torrent['status'] == Transmission.STATUS_DOWNLOAD: status = ('idle', 'downloading')[torrent['rateDownload'] > 0] if torrent['metadataPercentComplete'] < 1: status += ' metadata' elif torrent['status'] == Transmission.STATUS_DOWNLOAD_WAIT: status = 'will download (%d)' % torrent['queuePosition'] elif torrent['status'] == Transmission.STATUS_SEED: status = 'seeding' elif torrent['status'] == Transmission.STATUS_SEED_WAIT: status = 'will seed (%d)' % torrent['queuePosition'] else: status = 'unknown state' return status def can_has_peers(self, torrent): """ Will return True if at least one tracker was successfully queried recently, or if DHT is enabled for this torrent and globally, False otherwise. """ # Torrent has trackers? if torrent['trackerStats']: # Did we try to connect a tracker? if any([tracker['hasAnnounced'] for tracker in torrent['trackerStats']]): for tracker in torrent['trackerStats']: if tracker['lastAnnounceSucceeded']: return True # We didn't try yet; assume at least one is online else: return True # Torrent can use DHT? # ('dht-enabled' may be missing; assume DHT is available until we can say for sure) return 'dht-enabled' not in self.status_cache or \ (self.status_cache['dht-enabled'] and not torrent['isPrivate']) def get_bandwidth_priority(self, torrent): if torrent['bandwidthPriority'] == -1: return '-' if torrent['bandwidthPriority'] == 0: return ' ' if torrent['bandwidthPriority'] == 1: return '+' return '?' def get_honors_session_limits(self, torrent): return ' ' if torrent['honorsSessionLimits'] else '*' def get_stats(self): request = TransmissionRequest(self.url, 'session-stats', 1, server=self) request.send_request() response = request.get_response() return response['arguments'] def group_get(self): request = TransmissionRequest(self.url, 'group-get', self.TAG_GROUP_GET, server=self) request.send_request() response = request.get_response() if 'arguments' in response and 'group' in response['arguments']: return response['arguments']['group'] return None # End of Class Transmission # User Interface class Interface: TRACKER_ITEM_HEIGHT = 6 def __init__(self, server): self.server = server if gconfig.profile in gconfig.profiles: self.apply_profile(gconfig.profiles[gconfig.profile]) self.torrents = self.server.get_torrent_list(gconfig.sort_orders) self.stats = self.server.get_global_stats() self.torrent_details = [] self.selected_torrent = -1 # changes to >-1 when focus >-1 & user hits return self.highlight_dialog = False self.search_focus = 0 # like self.focus but for searches in torrent list self.focused_id = -1 # the id (provided by Transmission) of self.torrents[self.focus] self.focus = -1 # -1: nothing focused; 0: top of list; <# of torrents>-1: bottom of list self.selected = set() self.scrollpos = 0 # start of torrentlist self.torrents_per_page = 0 # will be set by manage_layout() self.rateDownload_width = self.rateUpload_width = len(scale_bytes()) self.rateDownload_width = self.get_rateDownload_width(self.torrents) self.rateUpload_width = self.get_rateUpload_width(self.torrents) self.details_category_focus = 0 # overview/files/peers/tracker in details self.focus_detaillist = -1 # same as focus but for details self.selected_files = set() # marked files in details self.file_index_map = {} # Maps local torrent's file indices to server file indices self.scrollpos_detaillist = [0] * 5 # same as scrollpos but for details self.max_overview_scroll = 0 self.exit_now = False self.vmode_id = -1 self.filters_inverted = False self.force_narrow = None self.beeped = False self.common_keybindings = { K.n0: self.action_profile_selected, K.n1: self.action_profile_selected, K.n2: self.action_profile_selected, K.n3: self.action_profile_selected, K.n4: self.action_profile_selected, K.n5: self.action_profile_selected, K.n6: self.action_profile_selected, K.n7: self.action_profile_selected, K.n8: self.action_profile_selected, K.n9: self.action_profile_selected, curses.KEY_SEND: lambda: self.move_queue('bottom'), curses.KEY_SHOME: lambda: self.move_queue('top'), curses.KEY_SLEFT: lambda: self.move_queue('ppage'), curses.KEY_SRIGHT: lambda: self.move_queue('npage'), } self.list_keybindings = {} self.details_keybindings = {} set_keys(gconfig.actions, self.common_keybindings, [0], self) set_keys(gconfig.actions, self.list_keybindings, [1], self) set_keys(gconfig.actions, self.details_keybindings, [2, 3, 4], self) self.filelist_needs_refresh = False self.sorted_files = None self.action_keys = {a:set(d[1]) for a, d in gconfig.actions.items()} parse_config_key(self, gconfig.config, gconfig, self.common_keybindings, self.details_keybindings, self.list_keybindings, self.action_keys) try: self.init_screen() self.run() except curses.error: self.restore_screen() raise else: self.restore_screen() def action_save_config(self): gconfig.save_config() def apply_profile(self, profile): gconfig.sort_orders = [s.copy() for s in profile['sort']] # copy filter array from profile gconfig.filters = [[f.copy() for f in l] for l in profile['filter']] self.filters_inverted = False def save_profile(self, profile): gconfig.profiles[profile] = {'filter': [[f.copy() for f in l] for l in gconfig.filters], 'sort': [s.copy() for s in gconfig.sort_orders]} def action_save_profile(self): name = self.dialog_input_text("Profile name to save:", "") if name: self.save_profile(name) def action_profile_selected(self, p): if p in range(K.n0, K.n9 + 1): p = chr(p) if p in gconfig.profiles: self.apply_profile(gconfig.profiles[p]) def init_screen(self): os.environ['ESCDELAY'] = '0' # make escape usable self.screen = curses.initscr() curses.noecho() curses.cbreak() self.screen.keypad(1) curses.halfdelay(10) # STDIN timeout hide_cursor() gconfig.init_colors(dict(gconfig.config.items('Colors'))) # http://bugs.python.org/issue2675 try: del os.environ['LINES'] del os.environ['COLUMNS'] except KeyError: pass signal.signal(signal.SIGWINCH, lambda y, frame: self.get_screen_size()) self.get_screen_size() def restore_screen(self): curses.endwin() def get_screen_size(self): time.sleep(0.1) # prevents curses.error on rapid resizing while True: try: curses.endwin() except curses.error: pass self.screen.refresh() self.height, self.width = self.screen.getmaxyx() # Tracker list breaks if width smaller than 73 if not gconfig.PERMISSIVE and (self.width < 40 or self.height < 16): self.screen.erase() self.screen.addstr(0, 0, "Terminal too small", curses.A_BOLD) self.screen.addstr(1, 0, "Resize terminal or") self.screen.addstr(2, 0, "Press 'q' to quit") c = self.screen.getch() if c in gconfig.esc_keys_w: exit_prog() else: break self.manage_layout() # There are two extra lines here: One for a possible invisible line of # the last torrent, the other for avoiding 'last char of window bug'. self.pad = curses.newpad(self.height, self.width) def manage_layout(self): self.recalculate_torrents_per_page() self.detaillines_per_page = self.height - 8 self.narrow = self.width < gconfig.narrow_threshold if self.force_narrow is None else self.force_narrow if self.selected_torrent > -1: self.rateDownload_width = self.get_rateDownload_width([self.torrent_details]) self.rateUpload_width = self.get_rateUpload_width([self.torrent_details]) self.torrent_title_width = self.width - self.rateUpload_width - 2 # show downloading column only if torrents is downloading if self.torrent_details['status'] == Transmission.STATUS_DOWNLOAD: self.torrent_title_width -= self.rateDownload_width + 2 elif self.torrents: self.visible_torrents_start = self.scrollpos // gconfig.lines_per_torrent self.visible_torrents = self.torrents[self.visible_torrents_start: self.visible_torrents_start + self.torrents_per_page] self.rateDownload_width = self.get_rateDownload_width(self.visible_torrents) self.rateUpload_width = self.get_rateUpload_width(self.visible_torrents) self.torrent_title_width = self.width - self.rateUpload_width - 2 # show downloading column only if any downloading torrents are visible if [x for x in self.visible_torrents if x['status'] == Transmission.STATUS_DOWNLOAD]: self.torrent_title_width -= self.rateDownload_width + 2 else: self.visible_torrents = [] self.torrent_title_width = 80 def get_rateDownload_width(self, torrents): if torrents == []: return 4 new_width = max([len(scale_bytes(x['rateDownload'])) for x in torrents]) new_width = max(max([len(scale_time(x['eta'])) for x in torrents]), new_width) new_width = max(len(scale_bytes(self.stats['downloadSpeed'])), new_width) new_width = max(self.rateDownload_width, new_width) # don't shrink return new_width def get_rateUpload_width(self, torrents): if torrents == []: return 4 new_width = max([len(scale_bytes(x['rateUpload'])) for x in torrents] + [0]) new_width = max(max([len(num2str(x['uploadRatio'], '%.02f')) for x in torrents] + [0]), new_width) new_width = max(len(scale_bytes(self.stats['uploadSpeed'])), new_width) new_width = max(self.rateUpload_width, new_width) # don't shrink return new_width def recalculate_torrents_per_page(self): self.mainview_height = self.height - 2 self.torrents_per_page = (self.mainview_height + gconfig.lines_per_torrent - 1) // gconfig.lines_per_torrent self.last_torrent_partial = self.torrents_per_page * gconfig.lines_per_torrent - self.mainview_height > max(0, gconfig.lines_per_torrent - 2) def run(self): self.draw_title_bar() self.draw_stats() self.draw_torrent_list() while True: self.server.update(1) if self.selected_torrent == -1: self.draw_torrent_list() else: self.draw_details() self.stats = self.server.get_global_stats() self.draw_title_bar() # show shortcuts and stuff self.draw_stats() # show global states self.screen.move(0, 0) # in case cursor can't be invisible if self.handle_user_input() == -1: # No input for one second, so update file list. # It takes a long time, so avoid when handling user input if self.selected_torrent > -1 and self.details_category_focus == 1: self.filelist_needs_refresh = True self.server.set_torrent_details_id(self.torrents[self.focus]['id']) self.server.wait_for_details_update() if self.exit_now: save_history(gconfig.history_file, gconfig.histories) return def action_daemon_quit(self): if self.dialog_yesno("Ask daemon to shut down?"): self.server.daemon_quit() def action_go_back_or_unfocus(self): if self.focus_detaillist > -1: # unfocus and deselect file self.focus_detaillist = -1 self.scrollpos_detaillist = [0] * 5 self.selected_files = set() else: # return from details self.action_leave_details() def action_unfocus_torrent(self): if self.focus > -1: self.scrollpos = 0 # unfocus main list self.focus = -1 elif gconfig.filters[0][0]['name']: gconfig.filters = [[{'name': '', 'inverse': False}]] # reset filter def action_leave_details(self): self.server.set_torrent_details_id(-1) self.selected_torrent = -1 self.details_category_focus = 0 self.scrollpos_detaillist = [0] * 5 self.selected_files = set() self.vmode_id = -1 def action_quit(self): self.exit_now = True def action_quit_now(self): self.exit_now = True def action_turtle_mode(self): self.server.toggle_turtle_mode() def action_move_queue_down(self): self.move_queue('down') def action_move_queue_up(self): self.move_queue('up') def action_add_torrent_paused(self): self.action_add_torrent(paused=True) def action_add_torrent(self, paused=False): free_space = None if self.server.get_rpc_version() >= 15: # 10^9 instead of 2^30 to be consistent with web interface free_space = float(self.server.get_free_space()) / (10**9) # Bytes > GB pause = "(paused) " if paused else "" location = self.dialog_input_text("Add " + pause + "torrent from file, URL or pure hash" + (" - HDD (free): %.3f GB" % free_space if free_space else ""), homedir2tilde(os.getcwd() + os.sep), tab_complete='files') if location: if re.match('^[0-9a-fA-F]{40}$', location): location = 'magnet:?xt=urn:btih:{}'.format(location) error = self.server.add_torrent(tilde2homedir(location), paused=paused) if error: msg = wrap("Couldn't add torrent \"%s\":" % location) msg.extend(wrap(error, self.width - 4)) self.dialog_ok("\n".join(msg)) def action_enter_details(self): if self.focus > -1: self.screen.clear() self.selected_torrent = self.focus self.server.set_torrent_details_id(self.torrents[self.focus]['id']) self.server.wait_for_details_update() self.screen_files = -1 def action_show_torrent_sort_order_menu(self): if self.selected_torrent == -1: choice, inverse, _ = self.dialog_menu('Sort order', gconfig.sort_options, list(map(lambda x: x[0] == gconfig.sort_orders[-1]['name'], gconfig.sort_options)).index(True) + 1, extended=True) if choice != -128: if choice == 'reverse': gconfig.sort_orders[-1]['reverse'] = not gconfig.sort_orders[-1]['reverse'] else: gconfig.sort_orders.append({'name': choice, 'reverse': inverse}) while len(gconfig.sort_orders) > 2: gconfig.sort_orders.pop(0) def action_show_file_sort_order_menu(self): choice, inverse, _ = self.dialog_menu('Sort order', gconfig.file_sort_options, extended=True) if choice != -128: if choice != gconfig.file_sort_key: self.focus_detaillist = -1 self.filelist_needs_refresh = True if choice == 'reverse': gconfig.file_sort_reverse = not gconfig.file_sort_reverse else: gconfig.file_sort_key = choice gconfig.file_sort_reverse = inverse def action_show_stats(self): title = "Global statistics" win = None while True: stats = self.server.get_stats() total_ul = stats['cumulative-stats']['uploadedBytes'] total_dl = stats['cumulative-stats']['downloadedBytes'] total_ratio = 'Inf' if not total_dl else str(round(float(total_ul) / float(total_dl), 2)) total_time = stats['cumulative-stats']['secondsActive'] session_ul = stats['current-stats']['uploadedBytes'] session_dl = stats['current-stats']['downloadedBytes'] session_ratio = 'Inf' if not session_dl else str(round(float(session_ul) / float(session_dl), 2)) session_time = stats['current-stats']['secondsActive'] message = ("CURRENT SESSION\n" " Uploaded: {s_ul:7}\n" " Downloaded: {s_dl:7}\n" " Ratio: {s_ratio:7}\n" " Duration: {s_duration:7}\n\n" "TOTAL\n" " Uploaded: {t_ul:7}\n" " Downloaded: {t_dl:7}\n" " Ratio: {t_ratio:7}\n" " Duration: {t_duration:7}\n").format(s_ul=scale_bytes(session_ul, digits=2), s_dl=scale_bytes(session_dl, digits=2), s_ratio=session_ratio, s_duration=scale_time(session_time, long=True), t_ul=scale_bytes(total_ul, digits=2), t_dl=scale_bytes(total_dl, digits=2), t_ratio=total_ratio, t_duration=scale_time(total_time, long=True)) width = max([len(x) for x in message.split("\n")]) + 4 width = min(self.width, width) height = min(self.height, message.count("\n") + 3) if win is None: win = self.window(height, width, message=message, title=title) else: self.win_message(win, height, width, message) key = self.wingetch(win) if key in gconfig.esc_keys_w: return -1 self.update_torrent_list([win]) def action_unmapped_actions(self): actions = [] letters = 'abcdefghijklmnopqrstuvwxyz' i = 0 accepted = [0, 1] if self.selected_torrent == -1 else [0, 2, 3, 4] for a in gconfig.actions: if not self.action_keys[a] and gconfig.actions[a][0] & 15 in accepted: actions.append((a, '_' + letters[i] + '. ' + gconfig.actions[a][2])) i += 1 if actions == []: return c = self.dialog_menu('Choose action', actions, 0) if isinstance(c, str): f = getattr(self, 'action_' + c, lambda: None) f() def choose_profile(self): profiles = [] keys = set({}) for p in gconfig.profiles: if p[0] in keys: profiles.append((p, p)) else: keys.add(p[0]) profiles.append((p, '_' + p)) c = self.dialog_menu('Choose profile', profiles, 0) if c in gconfig.profiles: self.apply_profile(gconfig.profiles[c]) def filter_menu(self, oldfilter={'name': '', 'inverse': False}, prompt="", winstack=[]): new_filter = oldfilter.copy() options = [('uploading', '_Uploading'), ('downloading', '_Downloading'), ('active', 'Ac_tive'), ('paused', '_Paused'), ('seeding', '_Seeding'), ('incomplete', 'In_complete'), ('verifying', 'Verif_ying'), ('private', 'P_rivate'), ('isolated', '_Isolated'), ('tracker', 'Trac_ker'), ('regex', 'Regular e_xpression'), ('location', 'L_ocation'), ('locationsubs', '_Location (subdirectories)'), ('selected', 'S_elected'), ('honors', '_Honors limits'), ('partwanted', 'Part _wanted'), ('error', 'Error/Warnin_g'), ('invert', 'In_vert'), ('', '_All')] if self.server.get_rpc_version() >= 16: options.insert(-2, ('label', 'La_bel')) if self.server.get_rpc_version() >= 16: options.insert(-2, ('group', 'Ba_ndwidth group')) try: s = list(map(lambda x: x[0] == oldfilter['name'], options)).index(True) + 1 except Exception: s = 0 choice, inverse, win = self.dialog_menu(prompt, options, s, extended=True, winstack=winstack) if choice != -128: if choice == 'invert': new_filter['inverse'] = not new_filter['inverse'] else: if choice in ['tracker', 'location', 'locationsubs', 'label', 'group']: if choice == 'tracker': select = sorted(self.server.trackers) min_select = 2 elif choice.startswith('location'): select = sorted(self.server.locations) min_select = 2 elif choice == 'label': select = sorted(self.server.labels) min_select = 1 elif choice == 'group': select = sorted(self.server.groups) min_select = 1 current_choice = new_filter[choice] if choice in new_filter else '' if len(select) < min_select: # Nothing to select return None indexes = 'abcdefghijklmnopqrstuvwxyz1234567890' select_list = [] i = 0 for x in select: if i < len(indexes): select_list.append((x, '_' + indexes[i] + '. ' + x)) i = i + 1 else: select_list.append((x, ' ' + x)) try: s = list(map(lambda x: x[0] == current_choice, select_list)).index(True) + 1 except Exception: s = 0 selected = self.dialog_menu('Select ' + choice, select_list, s, winstack=winstack + [win]) if selected not in select: return None new_filter[choice] = selected elif choice == 'regex': regex = self.dialog_input_text('Regular expression to filter (case insensitive):', new_filter['regex'] if 'regex' in new_filter else '', winstack=winstack + [win], history=gconfig.histories['regex']) if regex == '': return None new_filter['regex'] = regex new_filter['name'] = choice new_filter['inverse'] = inverse return new_filter return None def action_set_filter(self): prompt = ('Show only', 'Filter all')[gconfig.filters[0][0]['inverse']] new_filter = self.filter_menu(gconfig.filters[0][0], prompt=prompt) if new_filter: gconfig.filters = [[new_filter]] self.filters_inverted = False def action_invert_filters(self): self.filters_inverted = not self.filters_inverted def action_add_filter_line(self): new_filter = self.filter_menu(prompt="Add filter:") if new_filter: gconfig.filters.append([new_filter]) def action_add_filter(self): new_filter = self.filter_menu(prompt="Add filter:") if new_filter: gconfig.filters[0].append(new_filter) def action_edit_filters(self): gconfig.filters = self.dialog_filters() def action_global_upload(self): current_limit = (-1, self.stats['speed-limit-up'])[self.stats['speed-limit-up-enabled']] limit = self.dialog_input_number("Global upload limit in kilobytes per second", current_limit) if limit == -128: return self.server.set_rate_limit('up', limit) def action_global_download(self): current_limit = (-1, self.stats['speed-limit-down'])[self.stats['speed-limit-down-enabled']] limit = self.dialog_input_number("Global download limit in kilobytes per second", current_limit) if limit == -128: return self.server.set_rate_limit('down', limit) def action_group_upload(self): self.group_set_limit('up') def action_group_download(self): self.group_set_limit('down') def action_group_get(self): gs = self.server.group_get() if gs: namewidth = max((len(g['name']) for g in gs)) groups_str = "name".rjust(4 + namewidth) + ": down, up ignores session\n\n" for g in gs: groups_str += (g['name'].rjust(4 + namewidth) + ": " + ("{:9}, ".format(g['downloadLimit']) if g['downloadLimited'] else "unlimited, ") + ("{:9}".format(g['uploadLimit']) if g['uploadLimited'] else "unlimited") + ("\n" if g['honorsSessionLimits'] else " *\n")) self.dialog_ok(groups_str) def group_set_limit(self, direction): group = '' if self.selected_torrent > -1: group = self.torrent_details['group'] elif self.focus > -1: group = self.torrents[self.focus]['group'] if not group: return current_limit = (-1, self.stats['speed-limit-'+direction])[self.stats['speed-limit-'+direction+'-enabled']] limit = self.dialog_input_number(direction.title()+'load limit in kilobytes per second for group '+group, current_limit) if limit == -128: return self.server.set_rate_limit(direction, limit, group=group) def selected_ids(self): # If viewing torrent details, act on viewed torrent, even if there is a # selection. if self.selected_torrent > -1: return [self.torrent_details['id']] if self.selected: return list(self.selected) if self.focus == -1: return [] return [self.torrents[self.focus]['id']] # Decide which torrent name to show for confirmation/prompt: # If focused torret is in the selection - select it. # Otherwise, the first selected torrent. # Also calculate the extra line: "and %d more", if more than one # torrent is selected. def get_focused(self, ids): if ids == []: return (None, "") focused = self.torrents[self.focus] if (self.focus > -1 and self.torrents[self.focus]['id'] in ids) \ else self.server.get_torrent_by_id(ids[0]) extraline = "\nand %d more" % (len(ids) - 1) if len(ids) > 1 else "" return (focused, extraline) def torrent_up_down_load(self, direction): ids = self.selected_ids() if ids and direction in ['up', 'down']: focused, extraline = self.get_focused(ids) current_limit = (-1, focused[direction + 'loadLimit'])[focused[direction + 'loadLimited']] limit = self.dialog_input_number(direction.capitalize() + "load limit in kilobytes per second for\n%s" % focused['name'] + extraline, current_limit) if limit == -128: return self.server.set_rate_limit(direction, limit, ids) def action_torrent_upload(self): self.torrent_up_down_load('up') def action_torrent_download(self): self.torrent_up_down_load('down') def action_seed_ratio(self): ids = self.selected_ids() if ids: focused = self.torrents[self.focus] if (self.focus > -1 and self.torrents[self.focus]['id'] in ids) \ else self.server.get_torrent_by_id(ids[0]) if focused['seedRatioMode'] == 0: # Use global settings current_limit = '' elif focused['seedRatioMode'] == 1: # Stop seeding at seedRatioLimit current_limit = focused['seedRatioLimit'] elif focused['seedRatioMode'] == 2: # Seed regardless of ratio current_limit = -1 limit = self.dialog_input_number("Seed ratio limit for\n%s" % focused['name'] + ("\nand %d more" % (len(ids) - 1) if len(ids) > 1 else ""), current_limit, floating_point=True, allow_empty=True) if limit == -1: limit = 0 if limit == -2: # -2 means 'empty' in dialog_input_number return codes limit = -1 self.server.set_seed_ratio(float(limit), ids) def action_sequential_download(self): self.server.toggle_sequential_download(self.selected_ids()) def action_honors_limits(self): self.server.toggle_honors_session_limits(self.selected_ids()) def action_bandwidth_priority_dec(self): self.server.decrease_bandwidth_priority(self.selected_ids()) def action_bandwidth_priority_inc(self): self.server.increase_bandwidth_priority(self.selected_ids()) def action_copy_magnet_link(self): if self.focus > -1 and gconfig.clipboard: magnet = self.torrents[self.focus]['magnetLink'] try: ## Initialize clipboard pyperclip.copy("") if gconfig.x_selection == "clipboard": pyperclip.copy(magnet) elif gconfig.x_selection == "primary": pyperclip.copy(magnet, primary=True) except Exception as e: self.dialog_ok(str(e)) def move_queue(self, direction): # queue was implemmented in Transmission v2.4 if self.server.get_rpc_version() >= 14 and self.focus > -1: if direction in ('ppage', 'npage'): new_position = self.torrents[self.focus]['queuePosition'] if direction == 'ppage': new_position -= 10 else: new_position += 10 else: new_position = direction self.server.move_queue(self.torrents[self.focus]['id'], new_position) def action_pause_unpause_torrent(self): ids = self.selected_ids() if ids: if any(self.server.get_torrent_by_id(i)['status'] == Transmission.STATUS_STOPPED for i in ids): self.server.start_torrents(ids) else: self.server.stop_torrents(ids) def action_start_now_torrent(self): ids = self.selected_ids() if ids: self.server.start_now_torrent(ids) def action_pause_unpause_all_torrent(self): if len(self.torrents) > 0: focused_torrent = self.torrents[max(0, self.focus)] if focused_torrent['status'] == Transmission.STATUS_STOPPED: self.server.start_torrents([t['id'] for t in self.torrents]) else: self.server.stop_torrents([t['id'] for t in self.torrents]) def action_verify_torrent(self): ids = self.selected_ids() ids = [i for i in ids if self.server.get_torrent_by_id(i)['status'] not in [Transmission.STATUS_CHECK, Transmission.STATUS_CHECK_WAIT]] if ids: self.server.verify_torrent(ids) def action_reannounce_torrent(self): ids = self.selected_ids() if ids: self.server.reannounce_torrent(ids) def conditional_remove(self, ids, first, data=False): if ids: hard="WARNING: this will remove more than one torrent" if len(ids)>1 else None if first in ids: ids.remove(first) else: first=ids.pop(0) name = self.server.get_torrent_by_id(first)['name'][:self.width - 20] if ids: extraline = " And:\n" for i in ids[:self.height - 12]: extraline = extraline + " " + self.server.get_torrent_by_id(i)['name'][:self.width - 8] + "\n" if len(ids)> self.height - 12: extraline += " and even %d more." % (len(ids)-self.height - 12) else: extraline = "\n" ids.append(first) question = "Remove AND DELETE" if data else "Remove" if self.dialog_yesno(question + " %s?" % name + extraline, hard=hard, important=data): self.server.remove_torrent(ids, data=data) if self.selected_torrent > -1: self.action_leave_details() self.focus_next_after_delete() def action_remove(self): ids = self.selected_ids() if ids: focused, extraline = self.get_focused(ids) self.conditional_remove(ids, focused['id']) def action_remove_selected(self): if not self.selected: return ids = self.selected_ids() if ids: focused, extraline = self.get_focused(ids) self.conditional_remove(ids, focused['id']) def action_remove_focused(self): if self.focus > -1: ids = [self.torrents[self.focus]['id']] self.conditional_remove(ids, ids[0]) def action_remove_data(self): ids = self.selected_ids() if ids: focused, extraline = self.get_focused(ids) self.conditional_remove(ids, focused['id'], data=True) def action_remove_selected_data(self): if not self.selected: return ids = self.selected_ids() if ids: focused, extraline = self.get_focused(ids) self.conditional_remove(ids, focused['id'], data=True) def action_remove_focused_data(self): if self.focus > -1: ids = [self.torrents[self.focus]['id']] self.conditional_remove(ids, ids[0], data=True) def focus_next_after_delete(self): """ Focus next torrent after user deletes torrent self.torrents still includes the deleted torrent """ new_focus = min(self.focus + 1, len(self.torrents) - 2) if new_focus != self.focus: self.focused_id = self.torrents[new_focus]['id'] else: self.focused_id = self.torrents[new_focus + 1]['id'] def add_tracker(self): if self.server.get_rpc_version() < 10: self.dialog_ok("You need Transmission v2.10 or higher to add trackers.") return tracker = self.dialog_input_text('Add tracker URL:', history=gconfig.histories['tracker'], fixed_history=list(self.server.trackers)) if tracker: t = self.torrent_details response = self.server.add_torrent_tracker(t['id'], tracker) if response: msg = wrap("Couldn't add tracker: %s" % response) self.dialog_ok("\n".join(msg)) def action_remove_all_trackers(self): ids = self.selected_ids() if ids: response = self.server.remove_torrent_tracker(ids, list(range(100))) if response: msg = wrap("Couldn't remove trackers: %s" % response) self.dialog_ok("\n".join(msg)) def action_remove_tracker(self): if self.details_category_focus == 3: if self.server.get_rpc_version() < 10: self.dialog_ok("You need Transmission v2.10 or higher to remove trackers.") return t = self.torrent_details if (self.scrollpos_detaillist[3] >= 0 and self.scrollpos_detaillist[3] < len(t['trackerStats']) and self.dialog_yesno("Do you want to remove this tracker?")): tracker = t['trackerStats'][self.scrollpos_detaillist[3]] response = self.server.remove_torrent_tracker([t['id']], [tracker['id']]) if response: msg = wrap("Couldn't remove tracker: %s" % response) self.dialog_ok("\n".join(msg)) def action_page_up(self): self.movement_keys('page_up') def action_page_down(self): self.movement_keys('page_down') def action_line_up(self): self.movement_keys('line_up') def action_line_down(self): self.movement_keys('line_down') def action_scroll_line_up(self): self.movement_keys('scroll_line_up') def action_scroll_line_down(self): self.movement_keys('scroll_line_down') def action_go_home(self): self.movement_keys('home') def action_go_end(self): self.movement_keys('end') def movement_keys(self, action): if self.selected_torrent == -1 and len(self.torrents) > 0: if action == 'line_up': self.focus, self.scrollpos = self.move_up(self.focus, self.scrollpos, gconfig.lines_per_torrent) elif action == 'line_down': self.focus, self.scrollpos = self.move_down(self.focus, self.scrollpos, gconfig.lines_per_torrent, self.torrents_per_page, len(self.torrents)) elif action == 'scroll_line_up': self.focus, self.scrollpos = self.scroll_line_up(self.focus, self.scrollpos, gconfig.lines_per_torrent, self.torrents_per_page, len(self.torrents)) elif action == 'scroll_line_down': self.focus, self.scrollpos = self.scroll_line_down(self.focus, self.scrollpos, gconfig.lines_per_torrent, self.torrents_per_page, len(self.torrents)) elif action == 'page_up': self.focus, self.scrollpos = self.move_page_up(self.focus, self.scrollpos, gconfig.lines_per_torrent, self.torrents_per_page) elif action == 'page_down': self.focus, self.scrollpos = self.move_page_down(self.focus, self.scrollpos, gconfig.lines_per_torrent, self.torrents_per_page, len(self.torrents)) elif action == 'home': self.focus, self.scrollpos = self.move_to_top() elif action == 'end': self.focus, self.scrollpos = self.move_to_end(gconfig.lines_per_torrent, self.torrents_per_page, len(self.torrents)) self.focused_id = self.torrents[self.focus]['id'] elif self.selected_torrent > -1: # overview if self.details_category_focus == 0: if action == 'line_up' and self.scrollpos_detaillist[0] > 0: self.scrollpos_detaillist[0] -= 1 elif action == 'line_down' and self.scrollpos_detaillist[0] < self.max_overview_scroll: self.scrollpos_detaillist[0] += 1 elif action == 'home': self.scrollpos_detaillist[0] = 0 elif action == 'end': self.scrollpos_detaillist[0] = self.max_overview_scroll # file list if self.details_category_focus == 1: # focus/movement if action == 'line_up': self.focus_detaillist, self.scrollpos_detaillist[1] = \ self.move_up(self.focus_detaillist, self.scrollpos_detaillist[1], 1) elif action == 'line_down': self.focus_detaillist, self.scrollpos_detaillist[1] = \ self.move_down(self.focus_detaillist, self.scrollpos_detaillist[1], 1, self.detaillines_per_page, len(self.torrent_details['files'])) elif action == 'page_up': self.focus_detaillist, self.scrollpos_detaillist[1] = \ self.move_page_up(self.focus_detaillist, self.scrollpos_detaillist[1], 1, self.screen_files if self.screen_files > 0 else self.detaillines_per_page) elif action == 'page_down': self.focus_detaillist, self.scrollpos_detaillist[1] = \ self.move_page_down(self.focus_detaillist, self.scrollpos_detaillist[1], 1, self.screen_files if self.screen_files > 0 else self.detaillines_per_page, len(self.torrent_details['files'])) elif action == 'home': self.focus_detaillist, self.scrollpos_detaillist[1] = self.move_to_top() elif action == 'end': self.focus_detaillist, self.scrollpos_detaillist[1] = \ self.move_to_end(1, self.detaillines_per_page, len(self.torrent_details['files'])) # visual mode if self.vmode_id > -1: if self.vmode_id < self.focus_detaillist: self.selected_files = {self.file_index_map[x] for x in range(self.vmode_id, self.focus_detaillist + 1)} elif self.focus_detaillist > -1: self.selected_files = {self.file_index_map[x] for x in range(self.focus_detaillist, self.vmode_id + 1)} list_len = 0 ppage = 1 # peer list movement if self.details_category_focus == 2: list_len = len(self.torrent_details['peers']) lines_per_page = self.detaillines_per_page # tracker list movement elif self.details_category_focus == 3: list_len = len(self.torrent_details['trackerStats']) lines_per_page = max(1, self.detaillines_per_page // (self.TRACKER_ITEM_HEIGHT + 2)) ppage = 0 # pieces list movement elif self.details_category_focus == 4: piece_count = self.torrent_details['pieceCount'] margin = len(str(piece_count)) + 2 map_width = int(str(self.width - margin - 1)[0:-1] + '0') list_len = (piece_count // map_width) + 1 lines_per_page = self.detaillines_per_page if list_len: if action == 'line_up': if self.scrollpos_detaillist[self.details_category_focus] > 0: self.scrollpos_detaillist[self.details_category_focus] -= 1 elif action == 'line_down': if self.scrollpos_detaillist[self.details_category_focus] < list_len - 1: self.scrollpos_detaillist[self.details_category_focus] += 1 elif action == 'page_up': self.scrollpos_detaillist[self.details_category_focus] = \ max(self.scrollpos_detaillist[self.details_category_focus] - lines_per_page - ppage, 0) elif action == 'page_down': self.scrollpos_detaillist[self.details_category_focus] = min(list_len - 1, self.scrollpos_detaillist[self.details_category_focus] + lines_per_page) elif action == 'home': self.scrollpos_detaillist[self.details_category_focus] = 0 elif action == 'end': self.scrollpos_detaillist[self.details_category_focus] = list_len - 1 # Disallow scrolling past the last item that would cause blank # space to be displayed in pieces and peer lists. if self.details_category_focus in (2, 4): self.scrollpos_detaillist[self.details_category_focus] = min(self.scrollpos_detaillist[self.details_category_focus], max(0, list_len - self.detaillines_per_page)) def action_file_priority_or_switch_details_next(self): if self.details_category_focus == 1 and \ (self.selected_files or self.focus_detaillist > -1): if self.selected_files: files = self.selected_files self.server.increase_file_priority(files) elif self.focus_detaillist > -1: self.server.increase_file_priority([self.file_index_map[self.focus_detaillist]]) self.filelist_needs_refresh = True else: self.action_next_details() def action_file_priority_or_switch_details_prev(self): if self.details_category_focus == 1 and \ (self.selected_files or self.focus_detaillist > -1): if self.selected_files: files = self.selected_files self.server.decrease_file_priority(files) elif self.focus_detaillist > -1: self.server.decrease_file_priority([self.file_index_map[self.focus_detaillist]]) self.filelist_needs_refresh = True else: self.action_prev_details() def action_rename_dir(self): self.rename_torrent_selected_file(True) def action_rename_torrent_selected_file(self): self.rename_torrent_selected_file() def rename_torrent_selected_file(self, rename_dir=False): def rename_dialog(oldname): filename = os.path.basename(oldname) msg = 'Rename "%s"\nto:' % oldname newname = self.dialog_input_text(msg, filename, tab_complete='dirs') if newname: if len(newname.split(os.sep)) > 1: self.dialog_ok("Moving is not supported.") else: result = self.server.rename_torrent_file(self.torrents[self.focus]['id'], oldname, newname) if result == 'success': return os.path.join(os.path.dirname(oldname), newname) self.dialog_ok('Couldn\'t rename\n"%s"\nto\n"%s":\n%s' % (oldname, newname, result)) return None return None if self.selected_torrent > -1 and self.details_category_focus == 1 and self.focus_detaillist >= 0: # rename files in torrent file_id = self.file_index_map[self.focus_detaillist] name = self.torrent_details['files'][file_id]['name'] if rename_dir: name = os.path.dirname(name) if not os.sep in name: # Don't rename torrent return newpath = rename_dialog(name) if newpath: if not rename_dir: # This shows new name immediately, but for dirs it is # simpler to wait for the new name from the server self.torrent_details['files'][file_id]['name'] = newpath self.filelist_needs_refresh = True # force read elif self.focus > -1: # rename torrent folder rename_dialog(self.torrents[self.focus]['name']) def action_add_tracker_or_select_all_files(self): # File list if self.details_category_focus == 1: self.select_unselect_file('all') # Trackers elif self.details_category_focus == 3: self.add_tracker() def action_visual_select_files(self): self.select_unselect_file('visual') def action_invert_selection_files(self): self.select_unselect_file('invert') def action_select_file(self): self.select_unselect_file('file') def action_select_files_dir(self): self.select_unselect_file('dir') def select_unselect_file(self, action): if self.details_category_focus == 1 and self.focus_detaillist >= 0: # file selection with space if action == 'file': self.selected_files.symmetric_difference_update({self.file_index_map[self.focus_detaillist]}) self.action_line_down() # (un)select directory elif action == 'dir': file_id = self.file_index_map[self.focus_detaillist] focused_dir = os.path.dirname(self.torrent_details['files'][file_id]['name']) if file_id in self.selected_files: for focus in range(0, len(self.torrent_details['files'])): file_id = self.file_index_map[focus] if self.torrent_details['files'][file_id]['name'].startswith(focused_dir): self.selected_files.discard(file_id) else: for focus in range(0, len(self.torrent_details['files'])): file_id = self.file_index_map[focus] if self.torrent_details['files'][file_id]['name'].startswith(focused_dir): self.selected_files.add(file_id) self.action_move_to_next_directory() # (un)select all files elif action == 'all': if self.selected_files: self.selected_files = set() else: self.selected_files = set(range(0, len(self.torrent_details['files']))) elif action == 'invert': self.selected_files = set(range(0, len(self.torrent_details['files']))).difference(self.selected_files) elif action == 'visual': if self.selected_files: self.selected_files = set() if self.vmode_id != -1: self.vmode_id = -1 else: self.selected_files.symmetric_difference_update({self.file_index_map[self.focus_detaillist]}) self.vmode_id = self.focus_detaillist def action_move_to_next_directory(self): if self.details_category_focus == 1: self.focus_detaillist = max(self.focus_detaillist, 0) file_id = self.file_index_map[self.focus_detaillist] focused_dir = os.path.dirname(self.torrent_details['files'][file_id]['name']) while self.torrent_details['files'][file_id]['name'].startswith(focused_dir) \ and self.focus_detaillist < len(self.torrent_details['files']) - 1: self.action_line_down() file_id = self.file_index_map[self.focus_detaillist] def action_move_to_previous_directory(self): if self.details_category_focus == 1: self.focus_detaillist = max(self.focus_detaillist, 0) file_id = self.file_index_map[self.focus_detaillist] focused_dir = os.path.dirname(self.torrent_details['files'][file_id]['name']) while self.torrent_details['files'][file_id]['name'].startswith(focused_dir) \ and self.focus_detaillist > 0: self.action_line_up() file_id = self.file_index_map[self.focus_detaillist] def action_file_info(self): if self.details_category_focus == 1 and self.focus_detaillist > -1: file_id = self.file_index_map[self.focus_detaillist] name = self.torrent_details['files'][file_id]['name'] if '/' in name: name = '/'.join(name.split('/')[1:]) size = str(self.torrent_details['files'][file_id]['length']) have = str(self.torrent_details['files'][file_id]['bytesCompleted']).rjust(len(size)) msg = "%s\nSize: %s\nHave: %s" % (name, size, have) width = max(len(name), len(size), 15)+10 win = self.window(6, width, msg) while True: key = self.wingetch(win) if key in gconfig.esc_keys_w: return -1 def action_view_file(self): self.view_file(gconfig.file_viewer, gconfig.file_open_in_terminal) def action_view_torrent(self): self.view_file(gconfig.file_viewer, gconfig.file_open_in_terminal) def view_file(self, file_viewer, file_open_in_terminal): torrent = None if self.selected_torrent == -1: if self.focus == -1: return torrent = self.torrents[self.focus]['id'] self.server.set_torrent_details_id(torrent) self.server.wait_for_details_update() self.server.set_torrent_details_id(-1) if len(self.server.get_torrent_details()['files']) > 1: return files = [0] else: files = [] if self.details_category_focus == 1 or torrent: details = self.server.get_torrent_details() stats = self.server.get_global_stats() if files: pass elif gconfig.view_selected and self.selected_files: files = self.selected_files elif self.focus_detaillist >= 0: files = [self.file_index_map[self.focus_detaillist]] else: return file_names = [] for file_server_index in files: file_name = details['files'][file_server_index]['name'] download_dir = details['downloadDir'] incomplete_dir = stats['incomplete-dir'] + '/' file_path = None possible_file_locations = [ download_dir + file_name, download_dir + file_name + '.part', incomplete_dir + file_name, incomplete_dir + file_name + '.part' ] for f in possible_file_locations: if os.path.isfile(f): file_path = f break if file_path: file_names.append(file_path) if " %t" not in file_viewer and not file_names: self.dialog_ok("Could not find file:\n%s" % (file_name)) return viewer_cmd = [] for argstr in file_viewer.split(" "): if argstr == '%s': viewer_cmd.extend(file_names) elif argstr == '%t': viewer_cmd.append(gconfig.host) viewer_cmd.append(download_dir) viewer_cmd.append(incomplete_dir) for file_server_index in files: viewer_cmd.append(details['files'][file_server_index]['name']) else: viewer_cmd.append(argstr) try: if file_open_in_terminal: self.restore_screen() call(viewer_cmd) self.get_screen_size() else: devnull = open(os.devnull, 'wb') Popen(viewer_cmd, stdout=devnull, stderr=devnull) devnull.close() except OSError as err: self.get_screen_size() self.dialog_ok("%s:\n%s" % (" ".join(viewer_cmd), err)) hide_cursor() if gconfig.file_viewer != file_viewer: if self.selected_torrent == -1: file_type = self.torrents[self.focus]['name'].split('.')[-1].lower() else: file_type = self.server.get_torrent_details()['files'][self.file_index_map[self.focus_detaillist]]['name'].split('.')[-1].lower() gconfig.histories['types'][file_type] = file_viewer def action_view_torrent_command(self): if self.focus >= 0: torrent = self.torrents[self.focus]['id'] self.server.set_torrent_details_id(torrent) self.server.wait_for_details_update() self.server.set_torrent_details_id(-1) if len(self.server.get_torrent_details()['files']) > 1: return self.action_view_file_command(torrent=self.torrents[self.focus]) def action_view_file_command(self, torrent=None): if torrent or (self.details_category_focus == 1 and self.focus_detaillist > -1): if torrent: file_type = torrent['name'].split('.')[-1].lower() else: file_type = self.server.get_torrent_details()['files'][self.file_index_map[self.focus_detaillist]]['name'].split('.')[-1].lower() if file_type in gconfig.histories['types']: command = gconfig.histories['types'][file_type] else: command = '' self.dialog_input_text('Command to run (%s will be replaced by file name)', command, tab_complete='executable', on_enter=self.view_file_command, history=gconfig.histories['command'], fixed_history=[gconfig.file_viewer]) def view_file_command(self, pattern, inc=1, search=None): self.view_file(pattern, gconfig.file_open_in_terminal if inc == 1 else not inc) return True def action_tab_files(self): self.filelist_needs_refresh = True self.details_category_focus = 1 def action_tab_overview(self): self.details_category_focus = 0 def action_tab_peers(self): self.details_category_focus = 2 def action_tab_trackers(self): self.details_category_focus = 3 def action_tab_chunks(self): self.details_category_focus = 4 def action_profile_menu(self): if len(gconfig.profiles) >= 1: self.choose_profile() def action_remove_labels(self): if self.server.get_rpc_version() < 16: return ids = self.selected_ids() if ids: focused, extraline = self.get_focused(ids) name = focused['name'][:self.width - 15] if self.dialog_yesno("Remove labels from %s?" % name + extraline): self.server.set_labels(ids, []) def action_set_group(self): if self.server.get_rpc_version() < 16: return ids = self.selected_ids() if ids: focused, extraline = self.get_focused(ids) msg = ('Set group of "%s"' % focused['name']) + extraline + '\nto:' group = self.dialog_input_text(msg, '') if group: self.server.set_group(ids, group) def action_set_labels(self): if self.server.get_rpc_version() < 16: return ids = self.selected_ids() if ids: focused, extraline = self.get_focused(ids) msg = ('Set labels of "%s"' % focused['name']) + extraline + '\nto:' labels_str = self.dialog_input_text(msg, '', history=gconfig.histories['labels'], fixed_history=list(self.server.labels)) labels = [s.strip() for s in labels_str.split(',')] if labels: self.server.set_labels(ids, labels) def action_add_label(self): if self.server.get_rpc_version() < 16: return ids = self.selected_ids() if ids: focused, extraline = self.get_focused(ids) msg = ('Label to add to "%s"' % focused['name']) + extraline label = self.dialog_input_text(msg, '', history=gconfig.histories['label'], fixed_history=list(self.server.labels)) if label: self.server.add_label(ids, label) def action_toggle_compact_torrentlist(self): gconfig.lines_per_torrent = gconfig.lines_per_torrent % 3 + 1 self.recalculate_torrents_per_page() self.follow_list_focus() def action_toggle_torrent_numbers(self): gconfig.torrent_numbers = not gconfig.torrent_numbers def action_move_torrent(self): ids = self.selected_ids() if ids: focused, extraline = self.get_focused(ids) location = homedir2tilde(self.torrents[self.focus]['downloadDir']) msg = ('Move "%s"' % focused['name']) + extraline + '\nfrom %s to' % location path = self.dialog_input_text(msg, location, tab_complete='dirs', history=gconfig.histories['location'], fixed_history=list(self.server.locations)) if path: self.server.move_torrent(ids, tilde2homedir(path)) def handle_user_input(self): c = self.screen.getch() if c == -1: return -1 if c in self.common_keybindings: f = self.common_keybindings[c] elif self.selected_torrent == -1: f = self.list_keybindings.get(c, None) else: f = self.details_keybindings.get(c, None) if f: #Temporarily: if f == self.action_profile_selected: f(c) else: f() try: if self.selected_torrent == -1: self.draw_torrent_list() else: self.draw_details() except Exception as e: pdebug('caught %s in handle_user_input(): %s\n' % (type(e), str(e))) return c def action_invert_selection_torrents(self): if self.selected_torrent == -1: self.action_select_unselect_torrent(invert=True) def action_select_unselect_torrents(self): if self.selected_torrent == -1: self.action_select_unselect_torrent(all_torrents=True) def action_select_unselect_torrent(self, all_torrents=False, invert=False): if all_torrents: if self.selected: self.selected = set() else: self.selected = {x['id'] for x in self.torrents} elif invert: self.selected.symmetric_difference_update({x['id'] for x in self.torrents}) else: if self.focus != -1: self.selected.symmetric_difference_update(set([self.torrents[self.focus]['id']])) self.action_line_down() def filter_torrent(self, t, filtr): if filtr['name'] == 'downloading': return filtr['inverse'] != (t['rateDownload'] > 0) if filtr['name'] == 'uploading': return filtr['inverse'] != (t['rateUpload'] > 0) if filtr['name'] == 'paused': return filtr['inverse'] != (t['status'] == Transmission.STATUS_STOPPED) if filtr['name'] == 'seeding': return filtr['inverse'] != (t['status'] == Transmission.STATUS_SEED or t['status'] == Transmission.STATUS_SEED_WAIT) if filtr['name'] == 'incomplete': return filtr['inverse'] != (t['percentDone'] < 100) if filtr['name'] == 'private': return filtr['inverse'] != t['isPrivate'] if filtr['name'] == 'active': return filtr['inverse'] != (t['peersGettingFromUs'] > 0 or t['peersSendingToUs'] > 0 or t['status'] == Transmission.STATUS_CHECK) if filtr['name'] == 'verifying': return filtr['inverse'] != (t['status'] == Transmission.STATUS_CHECK or t['status'] == Transmission.STATUS_CHECK_WAIT) if filtr['name'] == 'isolated': return filtr['inverse'] != t['isIsolated'] if filtr['name'] == 'honors': return filtr['inverse'] != t['honorsSessionLimits'] if filtr['name'] == 'selected': return filtr['inverse'] != (t['id'] in self.selected) if filtr['name'] == 'tracker': return filtr['inverse'] != (t['mainTrackerDomain'] == filtr['tracker']) if filtr['name'] == 'regex': return filtr['inverse'] != bool(re.search(filtr['regex'], t['name'], flags=re.I)) if filtr['name'] == 'location': return filtr['inverse'] != (homedir2tilde(t['downloadDir']) == filtr['location']) if filtr['name'] == 'locationsubs': return filtr['inverse'] != (homedir2tilde(t['downloadDir']).startswith(filtr['locationsubs'])) if filtr['name'] == 'label': return filtr['inverse'] != (filtr['label'] in t['labels']) if filtr['name'] == 'group': return filtr['inverse'] != (filtr['group'] == t['group']) if filtr['name'] == 'partwanted': return filtr['inverse'] != (t['totalSize'] > t['sizeWhenDone']) if filtr['name'] == 'error': return filtr['inverse'] != (t['error'] > 0) return True # Unknown filter does not filter anything def filter_torrent_list(self): self.torrents = [t for t in self.torrents if any(all(self.filter_torrent(t, f) for f in fs) for fs in gconfig.filters) != self.filters_inverted] # Also filter selected: self.selected.intersection_update({t['id'] for t in self.torrents}) def follow_list_focus(self): if self.focus == -1: return # check if list is empty or id to look for isn't in list ids = [t['id'] for t in self.torrents] if len(self.torrents) == 0 or self.focused_id not in ids: self.focus, self.scrollpos = -1, 0 return # find focused_id self.focus = min(self.focus, len(self.torrents) - 1) if self.torrents[self.focus]['id'] != self.focused_id: for i, t in enumerate(self.torrents): if t['id'] == self.focused_id: self.focus = i break # make sure the focus is not above the visible area while self.focus < (self.scrollpos / gconfig.lines_per_torrent): self.scrollpos -= gconfig.lines_per_torrent # make sure the focus is not below the visible area while self.focus > (self.scrollpos / gconfig.lines_per_torrent) + self.torrents_per_page - 1 - int(self.last_torrent_partial): self.scrollpos += gconfig.lines_per_torrent # keep min and max bounds self.scrollpos = min(self.scrollpos, (len(self.torrents) - self.torrents_per_page + int(self.last_torrent_partial)) * gconfig.lines_per_torrent) self.scrollpos = max(0, self.scrollpos) def torrent_text(self, t, search, details=[]): if search in ['fulltext', 'regex_fulltext']: s = t['name'] if 'labels' in t: s += '; ' + ','.join(t['labels']) if 'commnets' in t: s += '; ' + t['comment'] s += details[t['id']] return s.lower() return t['name'].lower() def get_torrents_filenames(self): self.server.set_torrent_details_id([t['id'] for t in self.torrents]) self.server.wait_for_details_update() self.server.set_torrent_details_id(-1) return {t['id']: ', '.join(f['name'] for f in t['files']) for t in self.server.get_torrent_details()} def draw_torrent_list(self, search_keyword='', search='', refresh=True): self.torrents = self.server.get_torrent_list(gconfig.sort_orders) self.filter_torrent_list() if search_keyword and search: if search in ['fulltext', 'regex_fulltext']: torrents_files = self.get_torrents_filenames() else: torrents_files = None if search in ['pattern', 'fulltext']: matched_torrents = [t for t in self.torrents if search_keyword.lower() in self.torrent_text(t, search, torrents_files)] elif search in ['regex', 'regex_fulltext']: try: regex = re.compile(search_keyword, re.I) matched_torrents = [t for t in self.torrents if regex.search(self.torrent_text(t, search, torrents_files))] except Exception: matched_torrents = self.torrents if matched_torrents: self.focus = 0 if self.search_focus >= len(matched_torrents): self.search_focus = 0 if self.search_focus < 0: self.search_focus = len(matched_torrents) - 1 self.focused_id = matched_torrents[self.search_focus]['id'] self.highlight_dialog = False else: self.highlight_dialog = True self.beep() else: self.search_focus = 0 self.highlight_dialog = False self.follow_list_focus() self.manage_layout() self.pad.erase() ypos = 0 for i in range(len(self.visible_torrents)): ypos += self.draw_torrentlist_item(self.visible_torrents[i], (i == self.focus - self.visible_torrents_start), gconfig.lines_per_torrent == 1, ypos, i + self.visible_torrents_start) if refresh: self.pad.refresh(0, 0, 1, 0, self.mainview_height, self.width - 1) self.screen.refresh() def draw_torrentlist_item(self, torrent, focused, compact, y, idx=-1): # the torrent name is also a progress bar selected = torrent['id'] in self.selected self.draw_torrentlist_title(torrent, focused, self.torrent_title_width, y, idx) if torrent['status'] == Transmission.STATUS_DOWNLOAD: self.draw_downloadrate(torrent, y, selected) if torrent['status'] == Transmission.STATUS_DOWNLOAD or torrent['status'] == Transmission.STATUS_SEED or selected: self.draw_uploadrate(torrent, y, selected) if not compact: # the line below the title/progress if torrent['percentDone'] < 100 and torrent['status'] == Transmission.STATUS_DOWNLOAD: self.draw_eta(torrent, y) self.draw_ratio(torrent, y, False) self.draw_torrentlist_status(torrent, focused, y) return gconfig.lines_per_torrent # number of lines that were used for drawing the list item # Draw ratio in place of upload rate if upload rate = 0 if not torrent['rateUpload']: self.draw_ratio(torrent, y - 1, selected) return 1 def draw_downloadrate(self, torrent, ypos, selected): tag = gconfig.element_attr('download_rate', st=selected) self.pad.move(ypos, self.width - self.rateDownload_width - self.rateUpload_width - 3) self.pad.addch(curses.ACS_DARROW, (0, curses.A_BOLD)[torrent['downloadLimited']]) rate = ('', scale_bytes(torrent['rateDownload']))[torrent['rateDownload'] > 0] self.pad.addstr(rate.rjust(self.rateDownload_width), tag) def draw_uploadrate(self, torrent, ypos, selected): tag = gconfig.element_attr('upload_rate', st=selected) self.pad.move(ypos, self.width - self.rateUpload_width - 1) self.pad.addch(curses.ACS_UARROW, (0, curses.A_BOLD)[torrent['uploadLimited']]) rate = ('', scale_bytes(torrent['rateUpload']))[torrent['rateUpload'] > 0] self.pad.addstr(rate.rjust(self.rateUpload_width), tag) def draw_ratio(self, torrent, ypos, selected): tag = gconfig.element_attr('eta+ratio', st=selected) self.pad.addch(ypos + 1, self.width - self.rateUpload_width - 1, curses.ACS_BULLET, (0, curses.A_BOLD)[torrent['uploadRatio'] < 1 and torrent['uploadRatio'] >= 0]) self.pad.addstr(ypos + 1, self.width - self.rateUpload_width, num2str(torrent['uploadRatio'], '%.02f').rjust(self.rateUpload_width), tag) def draw_eta(self, torrent, ypos): self.pad.addch(ypos + 1, self.width - self.rateDownload_width - self.rateUpload_width - 3, curses.ACS_PLMINUS) self.pad.addstr(ypos + 1, self.width - self.rateDownload_width - self.rateUpload_width - 2, scale_time(torrent['eta']).rjust(self.rateDownload_width), gconfig.element_attr('eta+ratio')) def draw_torrentlist_title(self, torrent, focused, width, ypos, idx): if gconfig.torrent_numbers and idx >= 0: numwidth = len("%i" % (len(self.torrents) + 1)) width = width - numwidth - 1 self.pad.addstr(ypos, 0, str(int(idx + 1)).rjust(numwidth)+' ') else: self.pad.move(ypos, 0) if torrent['status'] == Transmission.STATUS_SEED or (torrent['percentDone'] == 100 and torrent['status'] == Transmission.STATUS_STOPPED): if torrent['seedRatioMode'] == 0: # Use global limit if set, otherwise unlimited limit = self.stats['seedRatioLimit'] if self.stats['seedRatioLimited'] else -1 elif torrent['seedRatioMode'] == 1: # Stop seeding at seedRatioLimit limit = torrent['seedRatioLimit'] elif torrent['seedRatioMode'] == 2: # Seed regardless of ratio limit = -1 if limit > 0: percentDone = min((torrent['uploadRatio'] * 100) / limit, 100) elif limit < 0: percentDone = 100 else: percentDone = 0 elif torrent['status'] == Transmission.STATUS_CHECK: percentDone = float(torrent['recheckProgress']) * 100 else: percentDone = torrent['percentDone'] str_f = "%s" if self.narrow else "%7s" size = str_f % scale_bytes(torrent['sizeWhenDone']) if torrent['percentDone'] < 100: if torrent['seeders'] <= 0 and torrent['status'] != Transmission.STATUS_CHECK: size = str_f % scale_bytes(torrent['available']) + "/" + size size = str_f % scale_bytes(torrent['haveValid'] + torrent['haveUnchecked']) + "/" + size size = '| ' + size title = ljust_columns(torrent['name'], width - len(size)) + size if torrent['isIsolated']: element_name = 'title_error' elif torrent['status'] == Transmission.STATUS_SEED or \ torrent['status'] == Transmission.STATUS_SEED_WAIT: element_name = 'title_seed' elif torrent['status'] == Transmission.STATUS_STOPPED: element_name = 'title_paused_done' if torrent['percentDone'] == 100 else 'title_paused' elif torrent['status'] == Transmission.STATUS_CHECK or \ torrent['status'] == Transmission.STATUS_CHECK_WAIT: element_name = 'title_verify' elif torrent['rateDownload'] == 0: element_name = 'title_idle' elif torrent['percentDone'] < 100: element_name = 'title_download' else: element_name = 'title_other' tag = gconfig.element_attr(element_name+'_incomp') tag_done = gconfig.element_attr(element_name) if focused: tag += curses.A_BOLD tag_done += curses.A_BOLD if gconfig.torrentname_is_progressbar: bar_width = int(float(width) * (float(percentDone) / 100)) # Estimate widths, which works for anything ASCII bar_complete = title[:bar_width] bar_incomplete = title[bar_width:] # Adjust for East-Asian (wide) characters while not bar_width - 1 <= len_columns(bar_complete) <= bar_width: if len_columns(bar_complete) > bar_width: bar_incomplete = bar_complete[-1] + bar_incomplete bar_complete = bar_complete[:-1] else: bar_complete += bar_incomplete[0] bar_incomplete = bar_incomplete[1:] self.pad.addstr(bar_complete, tag_done) self.pad.addstr(bar_incomplete, tag) else: self.pad.addstr(title, tag_done) def draw_torrentlist_status(self, torrent, focused, ypos): peers = '' parts = [self.server.get_status(torrent, self.narrow)] if torrent['isIsolated'] and torrent['peersConnected'] <= 0: if not torrent['trackerStats']: parts[0] = "No tracker and no DHT" if self.narrow else "Unable to find peers without trackers and DHT disabled" else: tracker_errors = [tracker['lastAnnounceResult'] or tracker['lastScrapeResult'] for tracker in torrent['trackerStats']] parts[0] = [te for te in tracker_errors if te][0] else: pct_f = "%.0f%%" if self.narrow else " (%.2f%%)" if torrent['status'] == Transmission.STATUS_CHECK: parts[0] += pct_f % (torrent['recheckProgress'] * 100) elif torrent['status'] == Transmission.STATUS_DOWNLOAD: if torrent['metadataPercentComplete'] < 1: parts[0] += pct_f % (torrent['metadataPercentComplete'] * 100) else: parts[0] += pct_f % torrent['percentDone'] if not self.narrow: parts[0] = parts[0].ljust(20) # seeds and leeches will be appended right justified later if self.narrow: peers = "s:%s l:%s" % (num2str(torrent['seeders']), num2str(torrent['leechers'])) else: peers = "%5s seed%s " % (num2str(torrent['seeders']), ('s', ' ')[torrent['seeders'] == 1]) peers += "%5s leech%s" % (num2str(torrent['leechers']), ('es', ' ')[torrent['leechers'] == 1]) # show additional information if enough room if self.narrow: if self.torrent_title_width - sum([len(x) for x in parts]) - len(peers) > 9 and torrent['uploadedEver'] > 0: uploaded = scale_bytes(torrent['uploadedEver']) parts.append("U:%s" % uploaded) if self.torrent_title_width - sum([len(x) for x in parts]) - len(peers) > 6: parts.append("P:%d" % torrent['peersConnected']) else: if self.torrent_title_width - sum([len(x) for x in parts]) - len(peers) > 18: uploaded = scale_bytes(torrent['uploadedEver']) parts.append("%7s uploaded" % ('nothing', uploaded)[uploaded != '0B']) if self.torrent_title_width - sum([len(x) for x in parts]) - len(peers) > 12: parts.append("%4s peer%s" % (torrent['peersConnected'], ('s', ' ')[torrent['peersConnected'] == 1])) if focused: tags = curses.A_REVERSE + curses.A_BOLD else: tags = 0 remaining_space = self.torrent_title_width - sum([len(x) for x in parts], len(peers)) - 3 delimiter = ' ' * int(remaining_space / (len(parts))) line = self.server.get_bandwidth_priority(torrent) + self.server.get_honors_session_limits(torrent) + ' ' + delimiter.join(parts) # make sure the peers element is always right justified line += ' ' * int(self.torrent_title_width - len(line) - len(peers)) + peers self.pad.addstr(ypos + 1, 0, line, tags) def draw_details(self, search_keyword=None, search='', refresh=True): self.torrent_details = self.server.get_torrent_details() self.manage_layout() self.pad.erase() # torrent name + progress bar self.draw_torrentlist_item(self.torrent_details, False, False, 0) # divider + menu menu_items = ['_Overview', "_Files", 'P_eers', '_Trackers', '_Chunks'] xpos = max(0, int((self.width - sum([len(x) for x in menu_items]) - len(menu_items)) / 2)) for item in menu_items: self.pad.move(3, xpos) tags = curses.A_BOLD if menu_items.index(item) == self.details_category_focus: tags += curses.A_REVERSE title = item.split('_') self.pad.addstr(title[0], tags) self.pad.addstr(title[1][0], tags + curses.A_UNDERLINE) self.pad.addstr(title[1][1:], tags) xpos += len(item) + 1 # which details to display if self.details_category_focus == 0: self.draw_details_overview(5) elif self.details_category_focus == 1: if search_keyword: self.draw_filelist_search(search_keyword, search=search) else: self.draw_filelist(5) elif self.details_category_focus == 2: self.draw_peerlist(5) elif self.details_category_focus == 3: self.draw_trackerlist(5) elif self.details_category_focus == 4: self.draw_pieces_map(5) if refresh: self.pad.refresh(0, 0, 1, 0, self.mainview_height, self.width) self.screen.refresh() def draw_details_overview(self, ypos): t = self.torrent_details if self.narrow: strings = ['C: ', 'D: ', 'U: ', '', 'Swarm: ', '', '%s', 'unlimited', 'BW limits: ', 'D: ', 'U: ', 'Private torrent', ] else: strings = ['connected to ', 'downloading from ', 'uploading to ', '', 'Swarm speed: ', '', 'pause torrent after distributing %s copies', 'unlimited (ignore global limits)', 'Bandwidth limits: ', 'Download: ', 'Upload: ', 'Private to this tracker -- DHT and PEX disabled', ] info = [] info.append(['Hash: ', "%s" % t['hashString']]) info.append(['ID: ', "%s" % t['id']]) wanted = 0 for i, _ in enumerate(t['files']): if t['wanted'][i]: wanted += t['files'][i]['length'] sizes = ['Size: ', "%s; " % scale_bytes(t['totalSize'], long=True), "%s wanted; " % (scale_bytes(wanted, long=True), 'everything')[t['totalSize'] == wanted]] if t['available'] < t['totalSize']: sizes.append("%s available; " % scale_bytes(t['available'], long=True)) sizes.extend(["%s left" % scale_bytes(t['leftUntilDone'], long=True)]) info.append(sizes) nr_files = len(t['files']) info.append(['Files: ', "%d; " % nr_files]) complete = list(map(lambda x: x['bytesCompleted'] == x['length'], t['files'])).count(True) not_complete = [x for x in t['files'] if x['bytesCompleted'] != x['length']] partial = list(map(lambda x: x['bytesCompleted'] > 0, not_complete)).count(True) nr_wanted = t['wanted'].count(1) if nr_wanted < nr_files: info[-1].append("%d wanted; " % nr_wanted) if complete == len(t['files']): info[-1].append("all complete") else: info[-1].append("%d complete; " % complete) info[-1].append("%d commenced" % partial) info.append(['Chunks: ', "%s; " % t['pieceCount'], "%s each" % scale_bytes(t['pieceSize'], long=True)]) info.append(['Download: ']) info[-1].append("%s" % scale_bytes(t['downloadedEver'], long=True) + " (%.2f%%) received; " % percent(t['sizeWhenDone'], t['downloadedEver'])) info[-1].append("%s" % scale_bytes(t['haveValid'], long=True) + " (%.2f%%) verified; " % percent(t['sizeWhenDone'], t['haveValid'])) info[-1].append("%s corrupt" % scale_bytes(t['corruptEver'], long=True)) if t['percentDone'] < 100: info[-1][-1] += '; ' if t['rateDownload']: info[-1].append("receiving %s per second" % scale_bytes(t['rateDownload'], long=True)) if t['downloadLimited']: info[-1][-1] += " (throttled to %s)" % scale_bytes(t['downloadLimit'] * gconfig.speed_k, long=True) try: copies_distributed = (float(t['uploadedEver']) / float(t['sizeWhenDone'])) except ZeroDivisionError: copies_distributed = 0 info.append(['Upload: ', "%s (%.0f%%) transmitted" % (scale_bytes(t['uploadedEver'], long=True), t['uploadRatio'] * 100)]) if t['rateUpload']: info.append(" Sending %s per second" % scale_bytes(t['rateUpload'], long=True)) if t['uploadLimited']: info[-1] += " (throttled to %s)" % scale_bytes(t['uploadLimit'] * gconfig.speed_k, long=True) info.append(['Ratio: ', '%.2f copies distributed' % copies_distributed]) norm_upload_rate = norm.add('%s:rateUpload' % t['id'], t['rateUpload'], 50) format_str = "%X" if self.narrow else "%x %X" if norm_upload_rate > 0: target_ratio = self.get_target_ratio() bytes_left = (max(t['downloadedEver'], t['sizeWhenDone']) * target_ratio) - t['uploadedEver'] time_left = bytes_left / norm_upload_rate info.append(' Approaching %.2f ... %s' % (target_ratio, timestamp(time.time() + time_left, narrow=self.narrow, time_format=format_str))) info.append(['Seed limit: ']) if t['seedRatioMode'] == 0: if self.stats['seedRatioLimited']: info[-1].append('default (' + strings[6] % self.stats['seedRatioLimit'] + ')') else: info[-1].append('default (unlimited)') elif t['seedRatioMode'] == 1: info[-1].append(strings[6] % t['seedRatioLimit']) elif t['seedRatioMode'] == 2: info[-1].append(strings[7]) info.append([strings[8]]) unlimited = 'session' if t['honorsSessionLimits'] else 'unlimited' info[-1].append(strings[9] + (scale_bytes(t['downloadLimit'] * gconfig.speed_k) if t['downloadLimited'] else unlimited) + "; ") info[-1].append(strings[10] + (scale_bytes(t['uploadLimit'] * gconfig.speed_k) if t['uploadLimited'] else unlimited)) if self.server.get_rpc_version() >= 18: info.append(['Sequential: ', ('disabled', 'enabled')[t['sequential_download']]]) info.append(['Peers: ', strings[0] + "%d; " % t['peersConnected'], strings[1] + "%d; " % t['peersSendingToUs'], strings[2] + "%d" % t['peersGettingFromUs']]) # average peer speed incomplete_peers = [peer for peer in self.torrent_details['peers'] if peer['progress'] < 1] if incomplete_peers: # use at least 2/3 or 10 of incomplete peers to make an estimation active_peers = [peer for peer in incomplete_peers if peer['download_speed']] min_active_peers = min(10, max(1, round(len(incomplete_peers) * 0.666))) if 1 <= len(active_peers) >= min_active_peers: swarm_speed = sum([peer['download_speed'] for peer in active_peers]) / len(active_peers) info.append(['Swarm speed: ', "%s on average; " % scale_bytes(swarm_speed), "distribution of 1 copy takes %s" % scale_time(int(t['totalSize'] / swarm_speed), long=True)]) else: info.append(['Swarm speed: ', strings[3] % (min_active_peers, len(active_peers))]) else: info.append([strings[4], strings[5]]) info.append(['Privacy: ']) if t['isPrivate']: info[-1].append('Private to this tracker -- DHT and PEX disabled') else: info[-1].append('Public torrent') info.append(['Location: ', "%s" % homedir2tilde(t['downloadDir'])]) if t['creator']: info.append(['Creator: ', "%s" % t['creator']]) if 'labels' in t and t['labels']: info.append(['Labels: ']) info[-1].extend([s + '; ' for s in t['labels'][:-1]]) info[-1].append(t['labels'][-1]) if 'group' in t and t['group']: info.append(['Group: ', t['group']]) info.append(['']) info.append(['Created: ', "%s" % timestamp(t['dateCreated'], narrow=self.narrow, time_format=format_str)]) info.append(['Added: ', "%s" % timestamp(t['addedDate'], narrow=self.narrow, time_format=format_str)]) info.append(['Started: ', "%s" % timestamp(t['startDate'], narrow=self.narrow, time_format=format_str)]) info.append(['Activity: ', "%s" % timestamp(t['activityDate'], narrow=self.narrow, time_format=format_str)]) if t['percentDone'] < 100 and t['eta'] > 0: info.append(['Finishing: ', "%s" % timestamp(time.time() + t['eta'], narrow=self.narrow, time_format=format_str)]) elif t['doneDate'] <= 0: info.append(['Finishing: ', 'sometime']) else: info.append(['Finished: ', "%s" % timestamp(t['doneDate'], narrow=self.narrow, time_format=format_str)]) if t['comment']: info.append(['']) info.append(['Comment: ', t['comment']]) ypos = self.draw_details_list(ypos, info) self.max_overview_scroll = max(self.max_overview_scroll, ypos + self.scrollpos_detaillist[0] - self.height + 3) return ypos + 1 def get_target_ratio(self): t = self.torrent_details if t['seedRatioMode'] == 1: return t['seedRatioLimit'] # individual limit if t['seedRatioMode'] == 0 and self.stats['seedRatioLimited']: return self.stats['seedRatioLimit'] # global limit # round up to next 10/5/1 if t['uploadRatio'] >= 100: step_size = 10.0 elif t['uploadRatio'] >= 10: step_size = 5.0 else: step_size = 1.0 return int(round((t['uploadRatio'] + step_size / 2) / step_size) * step_size) def draw_filelist_search(self, search_keyword=None, search=''): if search_keyword and search: if search == 'pattern': matched_files = [f for f in self.sorted_files if search_keyword.lower() in os.path.basename(f['name'].lower())] elif search == 'regex': try: regex = re.compile(search_keyword, re.I) matched_files = [f for f in self.sorted_files if regex.search(os.path.basename(f['name']))] except Exception: matched_files = self.sorted_files if matched_files: if self.search_focus >= len(matched_files): self.search_focus = 0 if self.search_focus < 0: self.search_focus = len(matched_files) - 1 self.focus_detaillist = self.sorted_files.index(matched_files[self.search_focus]) self.highlight_dialog = False else: self.highlight_dialog = True self.beep() else: self.search_focus = 0 self.highlight_dialog = False self.draw_filelist(5) self.pad.refresh(0, 0, 1, 0, self.mainview_height, self.width) self.screen.refresh() def draw_filelist(self, ypos): column_names = ' # Progress Size Priority Filename' self.pad.addstr(ypos, 0, column_names.ljust(self.width), curses.A_UNDERLINE) ypos += 1 numfiles = 0 for line, priority, sel, focus in self.create_filelist(): if priority: attr_suffix = ('', '_f')[focus] + ('', '_s')[sel] curses_tags = gconfig.element_attr('file_line' + attr_suffix) priority_start = 28 - (len(priority) + 1) // 2 priority_end = priority_start + len(priority) priority_tag = gconfig.element_attr('file_prio_' + priority + attr_suffix) self.pad.addstr(ypos, 0, line[0:priority_start], curses_tags) self.pad.addstr(ypos, priority_start, line[priority_start:priority_end], priority_tag) self.pad.addstr(ypos, priority_end, line[priority_end:33], curses_tags) self.pad.addstr(ypos, 33, line[33:], curses_tags) numfiles = numfiles + 1 else: curses_tags = gconfig.element_attr('dir_line') self.pad.addstr(ypos, 0, line, curses_tags) if self.pad.getyx()[1] > 0: self.pad.addstr(' ' * (self.width - self.pad.getyx()[1]), curses_tags) ypos += 1 if ypos > self.height - 3: self.screen_files = numfiles return def create_filelist(self): # Build new mapping between sorted local files and transmission-daemon's unsorted files. if self.filelist_needs_refresh: self.filelist_needs_refresh = False if self.focus_detaillist > -1: # focus_detaillist is the file index in visible list, in order # to facilitate movement and display. So save name in order to # find the new position of the file later. focused_filename = self.torrent_details['files'][self.file_index_map[self.focus_detaillist]]['name'] if gconfig.file_sort_key in ['name', 'length', 'bytesCompleted']: self.sorted_files = sorted(self.torrent_details['files'], key=lambda x: x[gconfig.file_sort_key], reverse=gconfig.file_sort_reverse) elif gconfig.file_sort_key == 'progress': self.sorted_files = sorted(self.torrent_details['files'], key=lambda x: x['bytesCompleted'] / x['length'] if x['length'] > 0 else 0, reverse=gconfig.file_sort_reverse) elif gconfig.file_sort_key == 'priority': positions = sorted(range(len(self.torrent_details['files'])), key=lambda x: self.server.get_num_file_priority(self.torrent_details['id'], x), reverse=gconfig.file_sort_reverse) self.sorted_files = [self.torrent_details['files'][x] for x in positions] else: if gconfig.file_sort_reverse: self.sorted_files = list(reversed(self.torrent_details['files'])) else: self.sorted_files = self.torrent_details['files'][:] self.file_index_map = {i: self.torrent_details['files'].index(f) for i, f in enumerate(self.sorted_files)} # Find the focused file in new sorted list. First check if it is in # the same index, as that is the most common case. if self.focus_detaillist > -1 and focused_filename != self.torrent_details['files'][self.file_index_map[self.focus_detaillist]]['name']: self.focus_detaillist = next(i for i in range(len(self.sorted_files)) if self.torrent_details['files'][self.file_index_map[i]]['name'] == focused_filename) self.filelist_cache = [] self.filelist_cache_pos = [] self.filelist_cache_pos_dict = dict() current_folder = [] current_depth = 0 pos = 0 pos_before_focus = 0 index = 0 for file in self.sorted_files: f = file['name'].split('/') f_len = len(f) - 1 if f[:f_len] != current_folder: [current_depth, pos] = self.create_filelist_transition(f, current_folder, self.filelist_cache, current_depth, pos) current_folder = f[:f_len] priority = self.server.get_file_priority(self.torrent_details['id'], self.file_index_map[index]) self.filelist_cache.append((self.create_filelist_line(f[-1], index, percent(file['length'], file['bytesCompleted']), file['length'], current_depth, priority), priority)) self.filelist_cache_pos.append(pos) self.filelist_cache_pos_dict[index + pos] = index index += 1 if self.focus_detaillist == -1: start = 0 end = min(self.detaillines_per_page, len(self.filelist_cache)) line_to_show = -1 else: pos_before_focus = self.filelist_cache_pos[self.focus_detaillist] line_to_show = self.focus_detaillist + pos_before_focus lines_before = self.detaillines_per_page // 2 if line_to_show >= lines_before: start = line_to_show - lines_before else: start = 0 if len(self.filelist_cache) >= start + self.detaillines_per_page: end = start + self.detaillines_per_page else: end = len(self.filelist_cache) start = end - self.detaillines_per_page if end >= self.detaillines_per_page else 0 for i in range(start, end): yield (*self.filelist_cache[i], i in self.filelist_cache_pos_dict and self.file_index_map[self.filelist_cache_pos_dict[i]] in self.selected_files, i == line_to_show) def create_filelist_transition(self, f, current_folder, filelist, current_depth, pos): """ Create directory transition from to , both of which are an array of strings, each one representing one subdirectory in their path (e.g. /tmp/a/c would result in [temp, a, c]). is a list of strings that will later be drawn to screen. This function only creates directory strings, and is responsible for managing depth (i.e. indentation) between different directories. """ f_len = len(f) - 1 # Amount of subdirectories in f current_folder_len = len(current_folder) # Amount of subdirectories in # current_folder # Number of directory parts from f and current_directory that are identical same = 0 while (same < current_folder_len and same < f_len and f[same] == current_folder[same]): same += 1 for _ in range(current_folder_len - same): current_depth -= 1 # Stepping out of a directory, but not into a new directory if f_len < current_folder_len and f_len == same: return [current_depth, pos] # Increase depth for each new directory that appears in f, # but not in current_directory while current_depth < f_len: filelist.append(('%s\\ %s' % (' ' * current_depth + ' ' * 34, f[current_depth]), '')) current_depth += 1 pos += 1 return [current_depth, pos] def create_filelist_line(self, name, index, percent, length, current_depth, priority): line = "%s %6.2f%%" % (str(index + 1).rjust(4), percent) + \ ' ' + scale_bytes(length).rjust(7) + \ ' ' + priority.center(8) + \ " %s| %s" % (' ' * current_depth, name) return line[:self.width] def draw_peerlist(self, ypos): # Start drawing list either at the "selected" index, or at the index # that is required to display all remaining items without further scrolling. last_possible_index = max(0, len(self.torrent_details['peers']) - self.detaillines_per_page) start = min(self.scrollpos_detaillist[2], last_possible_index) end = start + self.detaillines_per_page peers = self.torrent_details['peers'][start:end] # Find width of columns clientname_width = 0 address_width = 0 port_width = 0 for peer in peers: if len(peer['clientName']) > clientname_width: clientname_width = len(peer['clientName']) if len(peer['address']) > address_width: address_width = len(peer['address']) if len(str(peer['port'])) > port_width: port_width = len(str(peer['port'])) # Column names column_names = 'Flags %3d Down %3d Up Progress ETA ' % \ (self.torrent_details['peersSendingToUs'], self.torrent_details['peersGettingFromUs']) column_names += 'Client'.ljust(clientname_width + 1) \ + 'Address'.ljust(address_width + port_width + 1) if gconfig.geoip: column_names += 'Country' if gconfig.rdns: column_names += ' Host' self.pad.addnstr(ypos, 0, column_names.ljust(self.width), self.width, curses.A_UNDERLINE) ypos += 1 # Peers hosts = self.server.get_hosts() geo_ips = self.server.get_geo_ips() for _, peer in enumerate(peers): if gconfig.rdns: if peer['address'] in hosts: host_name = hosts[peer['address']] else: host_name = "" upload_tag = download_tag = 0 if peer['rateToPeer']: upload_tag = curses.A_BOLD if peer['rateToClient']: download_tag = curses.A_BOLD self.pad.move(ypos, 0) # Flags self.pad.addstr("%-6s " % peer['flagStr']) # Down self.pad.addstr("%7s " % scale_bytes(peer['rateToClient']), download_tag) # Up self.pad.addstr("%7s " % scale_bytes(peer['rateToPeer']), upload_tag) # Progress if peer['progress'] < 1: self.pad.addstr("%7.2f%%" % (float(peer['progress']) * 100)) else: self.pad.addstr("%7.2f%%" % (float(peer['progress']) * 100), curses.A_BOLD) # ETA if self.width >= 55: if peer['progress'] < 1 and peer['download_speed'] > gconfig.speed_k: self.pad.addstr(" %8s %4s " % ('~' + scale_bytes(peer['download_speed']), '~' + scale_time(peer['time_left']))) else: if peer['progress'] < 1: self.pad.addstr(" ") else: self.pad.addstr(" ") # Client if self.width >= 55 + clientname_width + 1: self.pad.addstr(peer['clientName'].ljust(clientname_width + 1)) # Address:Port if self.width >= 55 + clientname_width + address_width + port_width + 3: self.pad.addstr(peer['address'].rjust(address_width) + ':' + str(peer['port']).ljust(port_width) + ' ') # Country if self.width >= 55 + clientname_width + address_width + port_width + 3 + 7: if gconfig.geoip: self.pad.addstr(" %2s " % geo_ips[peer['address']]) # Host if self.width >= 55 + clientname_width + address_width + port_width + 3 + 10: if gconfig.rdns: self.pad.addnstr(host_name, self.width - self.pad.getyx()[1], curses.A_DIM) ypos += 1 def draw_trackerlist(self, ypos): top = ypos - 1 def addstr(ypos, xpos, *args): if top < ypos < self.mainview_height: self.pad.addstr(ypos, xpos, *args) tracker_per_page = max(1, self.detaillines_per_page // (self.TRACKER_ITEM_HEIGHT + 2)) page = self.scrollpos_detaillist[3] // tracker_per_page start = tracker_per_page * page end = tracker_per_page * (page + 1) tlist = self.torrent_details['trackerStats'][start:end] # keep position in range when last tracker gets deleted self.scrollpos_detaillist[3] = min(self.scrollpos_detaillist[3], len(self.torrent_details['trackerStats']) - 1) # show newly added tracker when list was empty before if self.torrent_details['trackerStats']: self.scrollpos_detaillist[3] = max(0, self.scrollpos_detaillist[3]) current_tier = -1 for _, t in enumerate(tlist): announce_msg_size = scrape_msg_size = 0 selected = t == self.torrent_details['trackerStats'][self.scrollpos_detaillist[3]] if current_tier != t['tier']: current_tier = t['tier'] tiercolor = curses.A_BOLD + curses.A_REVERSE \ if selected else curses.A_REVERSE addstr(ypos, 0, ("Tier %d" % (current_tier + 1)).ljust(self.width), tiercolor) ypos += 1 if selected: for i in range(self.TRACKER_ITEM_HEIGHT): addstr(ypos + i, 0, ' ', curses.A_BOLD + curses.A_REVERSE) format_str = "%X" if self.narrow else "%x %X" addstr(ypos + 1, 4, "Last announce: %s" % timestamp(t['lastAnnounceTime'], narrow=self.narrow, time_format=format_str)) addstr(ypos + 2, 4, "Next announce: %s" % timestamp(t['nextAnnounceTime'], narrow=self.narrow, time_format=format_str)) addstr(ypos + 3, 4, " Last scrape: %s" % timestamp(t['lastScrapeTime'], narrow=self.narrow, time_format=format_str)) addstr(ypos + 4, 4, " Next scrape: %s" % timestamp(t['nextScrapeTime'], narrow=self.narrow, time_format=format_str)) if t['lastScrapeSucceeded']: if self.narrow: seeds = "S:%s" % num2str(t['seederCount']) leeches = "P:%s" % num2str(t['leecherCount']) else: seeds = "%s seed%s" % (num2str(t['seederCount']), ('s', '')[t['seederCount'] == 1]) leeches = "%s leech%s" % (num2str(t['leecherCount']), ('es', '')[t['leecherCount'] == 1]) addstr(ypos + 5, 4, "Tracker knows: %s and %s" % (seeds, leeches), curses.A_BOLD) else: if t['lastScrapeResult']: if self.narrow: addstr(ypos + 5, 11, "Scrape: %s" % t['lastScrapeResult'].replace("Tracker gave HTTP response code ", "")[:self.width - 20]) else: addstr(ypos + 5, 11, "Scrape: %s" % t['lastScrapeResult'][:self.width - 20]) if t['lastAnnounceSucceeded']: peers = "%s peer%s" % (num2str(t['lastAnnouncePeerCount']), ('s', '')[t['lastAnnouncePeerCount'] == 1]) addstr(ypos, 2, t['announce'][:self.width - 2], curses.A_BOLD + curses.A_UNDERLINE) addstr(ypos + 6, 11, "Result: %s received" % peers, curses.A_BOLD) else: addstr(ypos, 2, t['announce'][:self.width - 2], curses.A_UNDERLINE) if t['lastAnnounceResult']: if self.narrow: addstr(ypos + 6, 9, "Announce: %s" % t['lastAnnounceResult'].replace("Tracker gave HTTP response code ", "")[:self.width - 20]) else: addstr(ypos + 6, 9, "Announce: %s" % t['lastAnnounceResult'][:self.width - 20]) ypos += max(announce_msg_size, scrape_msg_size) ypos += 7 def draw_pieces_map(self, ypos): if self.torrent_details['totalSize'] == 0: # No pieces in file with no metadata return if self.torrent_details['haveValid'] / self.torrent_details['totalSize'] < 0.5: default_attr = gconfig.element_attr('chunk_dont_have') new_attr = gconfig.element_attr('chunk_have') change_attr = 0x80 skip_run = 0 else: new_attr = gconfig.element_attr('chunk_dont_have') default_attr = gconfig.element_attr('chunk_have') change_attr = 0 skip_run = 255 pieces = self.torrent_details['pieces'] piece_count = self.torrent_details['pieceCount'] margin = len(str(piece_count)) + 2 map_width = (self.width - margin - 1) // 10 * 10 start = self.scrollpos_detaillist[4] * map_width end = min(start + (self.height - ypos - 3) * map_width, piece_count) last_line = (end - 1) // map_width if end <= start: return for x in range(10, map_width, 10): self.pad.addstr(ypos, x + margin - 1, str(x), curses.A_BOLD) format_str = "%%%dd" % (margin - 2) yp = ypos + 1 for counter in range(self.scrollpos_detaillist[4], last_line + 1): self.pad.addstr(yp, 1, format_str % (counter * map_width), curses.A_BOLD) if counter == last_line: self.pad.addstr(yp, margin, '-' * ((end - 1) % map_width + 1), default_attr) else: self.pad.addstr(yp, margin, '-' * map_width, default_attr) yp = yp + 1 counter = start block = (pieces[start >> 3]) << (start & 7) while counter < end: if counter & 7 == 0: block = (pieces[counter >> 3]) while block == skip_run and counter < end - 8: counter += 8 block = (pieces[counter >> 3]) if block & 0x80 == change_attr: self.pad.chgat(ypos + 1 + (counter-start) // map_width, margin + (counter-start) % map_width, 1, new_attr) block <<= 1 counter += 1 if counter >= end: counter = end - 1 missing_pieces = piece_count - counter - 1 if missing_pieces: line = "-- %d more --" % (missing_pieces) xpos = (self.width - len(line)) / 2 self.pad.addstr(int(self.height - 3), int(xpos), line, curses.A_REVERSE) def draw_details_list(self, ypos, info): yp = ypos - self.scrollpos_detaillist[0] if self.narrow: key_width = 1 else: key_width = max([len(x[0]) for x in info]) self.pad.move(ypos, 0) for i in info: xp = 0 if self.narrow and i[0] == 'Hash: ' and self.width < 46: value_x = 0 else: if i[0] == 'Comment: ': i = [i[0]] + list(wrap_multiline(i[1], self.width - 1, initial_indent=i[0].rjust(key_width + 1))) # Ugly but does the work - wrapping takes key into # account, but the actual text must not include it: i[1] = i[1][len(i[0].rjust(key_width + 1)):] if yp >= ypos: if self.narrow: self.pad.addstr(yp, 0, i[0].rjust(key_width), curses.A_BOLD) # key else: self.pad.addstr(yp, 1, i[0].rjust(key_width)) # key xp = key_width value_x = key_width # value part may be wrapped if it gets too long for v in i[1:]: if xp + len(v) >= self.width - 1: yp += 1 if yp > self.mainview_height: return yp if yp >= ypos: self.pad.move(yp, value_x) xp = value_x if yp >= ypos: self.pad.addnstr(v, self.width - value_x) xp += len(v) yp += 1 if yp > self.mainview_height: return yp return yp def action_next_details(self): if self.details_category_focus >= 4: self.details_category_focus = 0 else: self.details_category_focus += 1 if self.details_category_focus == 1: # We moved to file list self.filelist_needs_refresh = True self.focus_detaillist = -1 self.pad.erase() def action_prev_details(self): if self.details_category_focus <= 0: self.details_category_focus = 4 else: self.details_category_focus -= 1 if self.details_category_focus == 1: # We moved to file list self.filelist_needs_refresh = True self.pad.erase() def move_up(self, focus, scrollpos, step_size): if focus < 0: focus = -1 else: focus -= 1 if scrollpos / step_size - focus > 0: scrollpos -= step_size scrollpos = max(0, scrollpos) while scrollpos % step_size: scrollpos -= 1 return focus, scrollpos def move_down(self, focus, scrollpos, step_size, elements_per_page, list_height): if focus < list_height - 1: focus += 1 if focus + 1 - scrollpos / step_size > elements_per_page: scrollpos += step_size return focus, scrollpos def scroll_line_down(self, focus, scrollpos, step_size, elements_per_page, list_height): if focus >=0 and focus > scrollpos/step_size: # when focus isn't at the top of the view. scrollpos += step_size return focus, scrollpos def scroll_line_up(self, focus, scrollpos, step_size, elements_per_page, list_height): if focus >= 0 and focus - scrollpos/step_size < elements_per_page: scrollpos -= step_size return focus, scrollpos def move_page_up(self, focus, scrollpos, step_size, elements_per_page): focus = max(0, focus - elements_per_page + 1) scrollpos = max(0, scrollpos - (elements_per_page - 1) * step_size) return focus, scrollpos def move_page_down(self, focus, scrollpos, step_size, elements_per_page, list_height): focus += (elements_per_page - 1) scrollpos += (elements_per_page - 1) * step_size if focus >= list_height: scrollpos -= (focus - list_height + 1) * step_size focus = list_height - 1 return focus, scrollpos def move_to_top(self): return 0, 0 def move_to_end(self, step_size, elements_per_page, list_height): focus = list_height - 1 scrollpos = max(0, (list_height - elements_per_page) * step_size) return focus, scrollpos def draw_stats(self): try: self.screen.addstr(self.height - 1, 0, ' '.center(self.width), gconfig.element_attr('bottom_line')) except curses.error: # curses can print to the last char (bottom right corner), but it raises an exception. pass self.draw_torrents_stats() self.draw_global_rates() def draw_torrents_stats(self): if self.selected_torrent > -1 and self.details_category_focus == 2: self.screen.addstr((self.height - 1), 0, ("%d peer%s connected (" % (self.torrent_details['peersConnected'], ('s', '')[self.torrent_details['peersConnected'] == 1]) + "Trackers:%d " % self.torrent_details['peersFrom']['fromTracker'] + "DHT:%d " % self.torrent_details['peersFrom']['fromDht'] + "LTEP:%d " % self.torrent_details['peersFrom']['fromLtep'] + "PEX:%d " % self.torrent_details['peersFrom']['fromPex'] + "Incoming:%d " % self.torrent_details['peersFrom']['fromIncoming'] + "Cache:%d)" % self.torrent_details['peersFrom']['fromCache'])[:self.width-1], gconfig.element_attr('bottom_line')) elif self.vmode_id > -1: self.screen.addstr((self.height - 1), 0, "-- VISUAL --", gconfig.element_attr('bottom_line')) else: if self.narrow: strings = ['T', 'D', 'S', 'P', '', '!', ' ', ' S:%d', ' F:%d', 'Sz'] else: strings = ["Torrent%s:" % ('s', '')[len(self.torrents) == 1], "Downloading:", "Seeding:", "Paused:", "Filter:", "not ", " Sort by:", " Selected:%d", " Files:%d", 'Size:'] self.screen.addstr((self.height - 1), 0, strings[0], gconfig.element_attr('bottom_line')) self.screen.addstr("%d (" % len(self.torrents), gconfig.element_attr('bottom_line')) downloading = len([x for x in self.torrents if x['status'] == Transmission.STATUS_DOWNLOAD]) seeding = len([x for x in self.torrents if x['status'] == Transmission.STATUS_SEED]) paused = len([x for x in self.torrents if x['status'] in [Transmission.STATUS_STOPPED, Transmission.STATUS_CHECK_WAIT, Transmission.STATUS_CHECK, Transmission.STATUS_DOWNLOAD_WAIT, Transmission.STATUS_SEED_WAIT]]) total_size = sum(x['sizeWhenDone'] for x in self.torrents) total_done = percent(total_size, sum(x['haveValid'] for x in self.torrents)) if downloading > 0: self.screen.addstr(strings[1], gconfig.element_attr('bottom_line')) self.screen.addstr("%d " % downloading, gconfig.element_attr('bottom_line')) if seeding > 0: self.screen.addstr(strings[2], gconfig.element_attr('bottom_line')) self.screen.addstr("%d " % seeding, gconfig.element_attr('bottom_line')) if paused > 0: self.screen.addstr(strings[3], gconfig.element_attr('bottom_line')) self.screen.addstr("%d " % paused, gconfig.element_attr('bottom_line')) self.screen.addstr(strings[9] + scale_bytes(total_size), gconfig.element_attr('bottom_line')) if total_done < 100: self.screen.addstr("[%.2f%%]" % total_done, gconfig.element_attr('bottom_line')) self.screen.addstr(") ", gconfig.element_attr('bottom_line')) if self.selected_torrent == -1: if gconfig.filters[0][0]['name']: self.screen.addstr(strings[4], gconfig.element_attr('bottom_line')) if not self.narrow and gconfig.filters[0][0]['name'] in gconfig.FILTERS_WITH_PARAM: filter_param = ('=', '!=')[gconfig.filters[0][0]['inverse']] + gconfig.filters[0][0][gconfig.filters[0][0]['name']][-16:] not_str = '' else: filter_param = '' not_str = ('', strings[5])[gconfig.filters[0][0]['inverse']] safe_addstr(self.screen, not_str + gconfig.filters[0][0]['name'] + filter_param, gconfig.element_attr('filter_status' if len(gconfig.filters[0]) <= 1 else 'multi_filter_status') ^ (curses.A_REVERSE if self.filters_inverted else 0)) # show last sort order (if terminal size permits it) if gconfig.sort_orders and self.width - self.screen.getyx()[1] > 20: self.screen.addstr(strings[6], gconfig.element_attr('bottom_line')) name = [name[1] for name in gconfig.sort_options if name[0] == gconfig.sort_orders[-1]['name']][0] name = name.replace('_', '').lower() curses_tags = gconfig.element_attr('sort_status') if gconfig.sort_orders[-1]['reverse']: self.screen.addch(curses.ACS_DARROW, curses_tags) else: self.screen.addch(curses.ACS_UARROW, curses_tags) safe_addstr(self.screen, name, curses_tags) if self.selected and self.width - self.screen.getyx()[1] > 20: self.screen.addstr(strings[7] % len(self.selected), gconfig.element_attr('bottom_line')) else: if self.details_category_focus == 1: if gconfig.file_sort_key and self.width - self.screen.getyx()[1] > 20: self.screen.addstr(strings[6], gconfig.element_attr('bottom_line')) name = [name[1] for name in gconfig.file_sort_options if name[0] == gconfig.file_sort_key][0] name = name.replace('_', '').lower() curses_tags = gconfig.element_attr('filter_status') if gconfig.file_sort_reverse: self.screen.addch(curses.ACS_DARROW, curses_tags) else: self.screen.addch(curses.ACS_UARROW, curses_tags) self.screen.addstr(name[:10], curses_tags) if self.width - self.screen.getyx()[1] > 20 and len(self.torrent_details['files']) > 1: self.screen.addstr(strings[8] % len(self.torrent_details['files']), gconfig.element_attr('bottom_line')) if self.width - self.screen.getyx()[1] > 20 and self.selected_files: self.screen.addstr(strings[7] % len(self.selected_files), gconfig.element_attr('bottom_line')) def draw_global_rates(self): # ↑1.2K ↓3.4M # ^ ^^ => +3 rates_width = self.rateDownload_width + self.rateUpload_width + 3 if self.stats['alt-speed-enabled']: upload_limit = "/%dK" % self.stats['alt-speed-up'] download_limit = "/%dK" % self.stats['alt-speed-down'] else: upload_limit = ('', "/%dK" % self.stats['speed-limit-up'])[self.stats['speed-limit-up-enabled']] download_limit = ('', "/%dK" % self.stats['speed-limit-down'])[self.stats['speed-limit-down-enabled']] limits = {'dn_limit': download_limit, 'up_limit': upload_limit} limits_width = len(limits['dn_limit']) + len(limits['up_limit']) if self.stats['alt-speed-enabled']: if self.narrow: self.screen.move(self.height - 1, self.width - rates_width - limits_width - 1) self.screen.addch(curses.ACS_TTEE, gconfig.element_attr('bottom_line') | curses.A_BOLD) else: self.screen.move(self.height - 1, self.width - rates_width - limits_width - len('Turtle mode ')) self.screen.addstr('Turtle mode ', gconfig.element_attr('bottom_line') | curses.A_BOLD) self.screen.move(self.height - 1, self.width - rates_width - limits_width) self.screen.addch(curses.ACS_DARROW, gconfig.element_attr('bottom_line')) self.screen.addstr(scale_bytes(self.stats['downloadSpeed']).rjust(self.rateDownload_width)[:self.rateDownload_width], gconfig.element_attr('download_rate')) self.screen.addstr(limits['dn_limit'], gconfig.element_attr('bottom_line')) self.screen.addch(' ', gconfig.element_attr('bottom_line')) self.screen.addch(curses.ACS_UARROW, gconfig.element_attr('bottom_line')) try: self.screen.addstr(scale_bytes(self.stats['uploadSpeed']).rjust(self.rateUpload_width)[:self.rateUpload_width], gconfig.element_attr('upload_rate')) self.screen.addstr(limits['up_limit'], gconfig.element_attr('bottom_line')) except curses.error: # curses can print to the last char (bottom right corner), but it raises an exception. pass def draw_title_bar(self): self.screen.addstr(0, 0, ' '.center(self.width), gconfig.element_attr('top_line')) w = self.draw_connection_status() self.draw_quick_help(self.width - w - 2) def draw_connection_status(self): if self.narrow: status = "V.%s@%s:%s" % (self.server.version, gconfig.host, gconfig.port) else: status = "Transmission %s@%s:%s" % (self.server.version, gconfig.host, gconfig.port) self.screen.addstr(0, 0, status, gconfig.element_attr('top_line')) return len(status) def draw_quick_help(self, maxwidth): help_strings = [('?', 'Help')] if self.selected_torrent == -1: if self.focus >= 0: help_strings = [('enter', 'View'), ('p', 'Pause/Unpause'), ('r', 'Remove'), ('v', 'Verify')] else: help_strings = [('/', 'Search'), ('f', 'Filter'), ('s', 'Sort')] + help_strings + [('o', 'Options'), ('q', 'Quit')] else: help_strings = [('Move with', 'cursor keys'), ('q', 'Back to List')] if self.details_category_focus == 1 and self.focus_detaillist > -1: help_strings = [('enter', 'Open File'), ('space', '(De)Select File'), ('V', 'Visually Select Files'), ('left/right', 'De-/Increase Priority'), ('esc', 'Unfocus/-select')] + help_strings elif self.details_category_focus == 2: help_strings = [('F1/?', 'Explain flags')] + help_strings elif self.details_category_focus == 3: help_strings = [('a', 'Add Tracker'), ('r', 'Remove Tracker')] + help_strings # Greedy algorithm line = '' for x in help_strings: t = "%s:%s" % (x[0], x[1]) if len(line) + len(t) + 1 <= maxwidth: line = line + ' ' + t self.screen.addstr(0, self.width - len(line), line, gconfig.element_attr('top_line')) def action_list_key_bindings(self): def key_name(k): map_key_names = {'UP': 'Up', 'DC': 'Del', 'SDC': 'Shift-Del', 'PPAGE': 'PgUp', 'NPAGE': 'PgDn'} if k in map_key_names: return map_key_names[k] if len(k) == 2 and k[0] == 1 and k[1].isdigit(): return k[1] if len(k) == 2 and k[1] == '_': return '^'+k[0] return k.title() if len(k) > 2 else k title = 'Help Menu' if self.details_category_focus == 2: title = 'Peer status flags' message = " O Optimistic unchoke\n" + \ " D Downloading from this peer\n" + \ " d We would download from this peer if they'd let us\n" + \ " U Uploading to peer\n" + \ " u We would upload to this peer if they'd ask\n" + \ " K Peer has unchoked us, but we're not interested\n" + \ " ? We unchoked this peer, but they're not interested\n" + \ " E Encrypted Connection\n" + \ " H Peer was discovered through DHT\n" + \ " X Peer was discovered through Peer Exchange (PEX)\n" + \ " I Peer is an incoming connection\n" + \ " T Peer is connected via uTP" else: message = '' if self.selected_torrent == -1: categories = [0, 1] elif self.details_category_focus == 1: categories = [0, 2, 3] elif self.details_category_focus == 3: categories = [0, 2, 4] else: categories = [0, 2] movement_keys = True for a, d in gconfig.actions.items(): if d[1] and d[0] & 15 in categories: if d[0] & 256 and self.server.get_rpc_version() < 14: continue if d[0] & 512 and self.server.get_rpc_version() < 16: continue if d[0] & 1024 and self.server.get_rpc_version() < 17: continue if d[0] & 2048 and self.server.get_rpc_version() < 18: continue if d[0] == 16 and movement_keys: movement_keys = False message += ' Movement Keys:\n' if a == 'profile_menu': message += " 0..9 Select profile\n" if a == 'move_queue_down': message += "Shft+Lft/Rght Move focused torrent in queue up/down by 10\n" message += "Shft+Home/End Move focused torrent to top/bottom of queue\n" keys_str = '/'.join(key_name(k) for k in d[1])[-13:].rjust(13) message += keys_str + ' ' + d[2] + '\n' width = max([len(x) for x in message.split("\n")]) + 4 width = min(self.width, width) height = min(self.height, message.count("\n") + 3) while True: win, last = self.help_window(height, width, message=message, title=title) while True: c = self.wingetch(win) if c in [K.SPACE, curses.KEY_NPAGE]: win, last = self.help_window(height, width, message=message, title=title, first=last, win=win) elif c >= 0: return self.update_torrent_list([win]) def beep(self): if not self.beeped: curses.beep() self.beeped = True def wingetch(self, win): c = win.getch() if c == K.W_: self.exit_now = True if c > -1: self.beeped = False return c def win_message(self, win, height, width, message, first=0): ypos = 1 lines = message.split("\n") pages = (len(lines) - 1) // (height - 2) + 1 page = first // (height - 2) + 1 for line in lines[first:]: if len_columns(line) > width - 3: line = ljust_columns(line, width - 6) + '...' if ypos < height - 1: # ypos == height-1 is frame border win.addstr(ypos, 2, line) ypos += 1 else: # Do not write outside of frame border win.addstr(height - 1, 2, "%d/%d" % (page, pages)) return win, ypos + first - 1 if pages > 1: win.addstr(height - 1, 2, "%d/%d" % (page, pages)) return win, 0 def window(self, height, width, message='', title='', xpos=None, attr='dialog'): return self.real_window(height, width, message=message, title=title, xpos=xpos, attr=attr)[0] def help_window(self, height, width, message='', title='', first=0, win=None): return self.real_window(height, width, message=message, title=title, first=first, win=win) def real_window(self, height, width, message='', title='', first=0, win=None, keypad=True, xpos=None, attr='dialog'): height = min(self.mainview_height, height) width = min(self.width, width) ypos = int((self.height - height) / 2) if xpos is None: xpos = int((self.width - width) / 2) if not win: win = curses.newwin(height, width, ypos, xpos) win.keypad(keypad) else: win.erase() win.box() win.bkgd(' ', gconfig.element_attr(attr)) if width >= 20: win.addch(height - 1, width - 19, curses.ACS_RTEE) win.addstr(height - 1, width - 18, " Close with Esc ") win.addch(height - 1, width - 2, curses.ACS_LTEE) if width >= (len(title) + 6) and title != '': win.addch(0, 1, curses.ACS_RTEE) win.addstr(0, 2, " " + title + " ") win.addch(0, len(title) + 4, curses.ACS_LTEE) return self.win_message(win, height, width, message, first) def dialog_ok(self, message): height = 3 + message.count("\n") width = max(max([len_columns(x) for x in message.split("\n")]), 40) + 4 win = self.window(height, width, message=message) while True: c = self.wingetch(win) if c in gconfig.esc_keys_w: return -1 self.update_torrent_list([win]) def dialog_yesno(self, message, important=False, hard=None): if hard: important = True message = message + "\n" + hard + "\n\n Press ctrl-y to accept.\n" attr = 'dialog_important' if important else 'dialog' height = 5 + message.count("\n") width = max(len_columns(message), 8) + 4 win = self.window(height, width, message=message, attr=attr) choice = False while True: win.move(int(height - 2), int(width / 2) - 4) if not hard: if choice: bg = win.getbkgd() win.bkgdset(gconfig.element_attr('menu_focused')) win.addstr('Y', curses.A_UNDERLINE) win.addstr('es') win.bkgdset(bg) win.addstr(' ') win.addstr('N', curses.A_UNDERLINE) win.addstr('o') else: win.addstr('Y', curses.A_UNDERLINE) win.addstr('es') win.addstr(' ') bg = win.getbkgd() win.bkgdset(gconfig.element_attr('menu_focused')) win.addstr('N', curses.A_UNDERLINE) win.addstr('o') win.bkgdset(bg) c = self.wingetch(win) if hard: if c == K.Y_: return True if c in (K.n, K.LF, K.CR, curses.KEY_ENTER, K.SPACE): return False if c in gconfig.esc_keys_w_no_ascii: return 0 else: if c == K.y: return True if c == K.n: return False if c == K.TAB: choice = not choice elif c in (curses.KEY_LEFT, K.h): choice = True elif c in (curses.KEY_RIGHT, K.l): choice = False elif c in (K.LF, K.CR, curses.KEY_ENTER, K.SPACE): return choice if c in gconfig.esc_keys_w_no_ascii: return 0 self.update_torrent_list([win]) def dialog_input_text(self, message, text='', on_change=None, on_enter=None, tab_complete=None, maxwidth=9999, align='center', history=None, history_max=10, fixed_history=[], search='', winstack=[]): """tab_complete values: 'files': complete with any files/directories 'dirs': complete only with directories 'torrent_list': complete with names from the torrent list 'executable': complete with executable name any false value: do not complete """ path_executables=set() self.highlight_dialog = False if history is not None: localhistory = fixed_history + history + [text] else: localhistory = [text] history_pos = len(localhistory) - 1 width = min(maxwidth, self.width - 4) textwidth = width - 4 height = message.count("\n") + 4 if align == 'center': xpos = None elif align == 'right': xpos = self.width - width win = self.window(height, width, message=message, xpos=xpos) show_cursor() if not isinstance(text, str): text = str(text, gconfig.ENCODING) index = len(text) initial_text = text tab_count = 0 while True: # Cut the text into pages, each as long as the text field # The current page is determined by index position page = index // textwidth displaytext = text[textwidth * page:textwidth * (page + 1)] displayindex = index - textwidth * page color = gconfig.element_attr('dialog_text_important') if self.highlight_dialog \ else gconfig.element_attr('dialog_text') bg = win.getbkgd() win.bkgdset(0) win.addstr(height - 2, 2, displaytext.ljust(textwidth), color) win.bkgdset(bg) win.move(height - 2, displayindex + 2) c = self.wingetch(win) if history is not None: if c in (curses.KEY_UP, K.P_): history_pos = (history_pos - 1) % len(localhistory) text = localhistory[history_pos] index = len(text) if c in (curses.KEY_DOWN, K.N_): history_pos = (history_pos + 1) % len(localhistory) text = localhistory[history_pos] index = len(text) if c in gconfig.esc_keys_w_no_ascii: hide_cursor() return '' if c == K.X_: text = '' index = 0 if index < len(text) and (c in (curses.KEY_RIGHT, K.F_)): index += 1 elif index > 0 and (c in (curses.KEY_LEFT, K.B_)): index -= 1 elif (c in (curses.KEY_BACKSPACE, K.DEL)) and index > 0: text = text[:index - 1] + (index < len(text) and text[index:] or '') index -= 1 tab_count = 0 elif index < len(text) and (c in (curses.KEY_DC, K.D_)): text = text[:index] + text[index + 1:] elif index < len(text) and c == K.K_: text = text[:index] elif c == K.U_: # Delete from cursor until beginning of line text = text[index:] index = 0 elif c in (curses.KEY_HOME, K.A_): index = 0 elif c in (curses.KEY_END, K.E_): index = len(text) elif c in (K.LF, K.CR, curses.KEY_ENTER, K.R_, K.T_): if history is not None and text != '': try: p = history.index(text) history.pop(p) except Exception: p = -1 if len(history) >= history_max: history.pop(0) history.append(text) if on_enter: if c in (K.LF, K.CR, curses.KEY_ENTER): inc = 1 elif c == K.R_: inc = -1 else: inc = 0 if on_enter(text, inc=inc, search=search): hide_cursor() return None else: hide_cursor() return text elif 32 <= c < 127: text = text[:index] + chr(c) + (index < len(text) and text[index:] or '') index += 1 elif c == K.TAB and tab_complete: if tab_count == 0: initial_text = text else: text = initial_text possible_choices = [] if tab_complete in ('files', 'dirs'): (dirname, filename) = os.path.split(tilde2homedir(text)) if not dirname: dirname = str(os.getcwd()) try: possible_choices = [os.path.join(dirname, choice) for choice in os.listdir(dirname) if choice.startswith(filename)] possible_choices.sort() except OSError: continue if tab_complete == 'dirs': possible_choices = [d for d in possible_choices if os.path.isdir(d)] elif tab_complete == 'torrent_list': possible_choices = [t['name'] for t in self.torrents if t['name'].startswith(text)] elif tab_complete == 'file_list': possible_choices = [f for f in [os.path.basename(g['name']) for g in self.sorted_files] if f.startswith(text)] elif tab_complete == 'executable': if not path_executables: paths = os.environ["PATH"].split(":") for p in paths: if os.path.isdir(p): path_executables.update(os.listdir(p)) possible_choices = list(p for p in path_executables if p.startswith(text)) if possible_choices: text = os.path.commonprefix(possible_choices) if tab_complete in ('files', 'dirs'): num_possible_choices = len(possible_choices) if num_possible_choices == 1 and os.path.isdir(text) and not text.endswith(os.sep): text += os.sep elif tab_count <= num_possible_choices: if tab_count == num_possible_choices: tab_count = 0 text = possible_choices[tab_count] tab_count += 1 text = homedir2tilde(text) index = len(text) if on_change: if localhistory[-1] != text: on_change(text) if localhistory[-1] != text and text not in localhistory: localhistory[-1] = text self.update_torrent_list(winstack + [win], pattern=text, search=search) def action_search_torrent(self): self.dialog_input_text('Search torrent by title:', on_enter=self.increment_search, tab_complete='torrent_list', history=gconfig.histories['search'], maxwidth=60, align='right', search='pattern') def action_search_torrent_fulltext(self): self.dialog_input_text('Search torrent by title (full text):', on_enter=self.increment_search, tab_complete='torrent_list', history=gconfig.histories['search'], maxwidth=60, align='right', search='fulltext') def action_search_torrent_regex(self): self.dialog_input_text('Regex search torrent by title:', on_enter=self.increment_search, tab_complete='torrent_list', history=gconfig.histories['regex'], maxwidth=60, align='right', search='regex') def action_search_torrent_regex_fulltext(self): self.dialog_input_text('Regex search torrent by title (full text):', on_enter=self.increment_search, tab_complete='torrent_list', maxwidth=60, align='right', search='regex_fulltext') def action_search_file(self): self.dialog_input_text('Search file by title:', on_enter=self.increment_file_search, tab_complete='file_list', history=gconfig.histories['search'], maxwidth=60, align='right', search='pattern') def action_search_file_regex(self): self.dialog_input_text('Regex search file by title:', on_enter=self.increment_file_search, tab_complete='file_list', history=gconfig.histories['regex'], maxwidth=60, align='right', search='regex') def increment_file_search(self, pattern, inc=1, search=None): self.search_focus += inc def increment_search(self, pattern, inc=1, search=None): self.search_focus += inc def action_select_search_torrent(self): self.dialog_input_text('Select torrents matching pattern', on_enter=self.select_pattern_torrents, tab_complete='torrent_list', history=gconfig.histories['search'], maxwidth=60, align='right', search='pattern') def action_select_search_torrent_fulltext(self): self.server.set_torrent_details_id([t['id'] for t in self.torrents]) self.server.wait_for_details_update() self.server.set_torrent_details_id(-1) self.dialog_input_text('Select torrents matching pattern (full text)', on_enter=self.select_pattern_torrents, tab_complete='torrent_list', history=gconfig.histories['search'], maxwidth=60, align='right', search='fulltext') def action_select_search_torrent_regex(self): self.dialog_input_text('Select torrents matching regex', on_enter=self.select_pattern_torrents, tab_complete='torrent_list', history=gconfig.histories['regex'], maxwidth=60, align='right', search='regex') def action_select_search_torrent_regex_fulltext(self): self.dialog_input_text('Select torrents matching regex (full text)', on_enter=self.select_pattern_torrents, tab_complete='torrent_list', history=gconfig.histories['regex'], maxwidth=60, align='right', search='regex_fulltext') def action_select_search_file(self): self.dialog_input_text('Select files matching pattern', on_enter=self.select_pattern_files, tab_complete='file_list', history=gconfig.histories['search'], maxwidth=60, align='right', search='pattern') def action_select_search_file_regex(self): self.dialog_input_text('Select files matching regex', on_enter=self.select_pattern_files, tab_complete='file_list', history=gconfig.histories['regex'], maxwidth=60, align='right', search='regex') def select_pattern_torrents(self, pattern, inc=1, search=None): if search in ['fulltext', 'regex_fulltext']: torrents_files = self.get_torrents_filenames() else: torrents_files = None if search in ['pattern', 'fulltext']: matched_torrents = {t['id'] for t in self.torrents if pattern.lower() in self.torrent_text(t, search, torrents_files)} elif search in ['regex', 'regex_fulltext']: try: regex = re.compile(pattern, re.I) matched_torrents = {t['id'] for t in self.torrents if regex.search(self.torrent_text(t, search, torrents_files))} except Exception: return True else: return True if inc == 1: self.selected = matched_torrents elif inc == 0: self.selected.intersection_update(matched_torrents) elif inc == -1: self.selected.update(matched_torrents) return True def select_pattern_files(self, pattern, inc=1, search=None): if search == 'pattern': matched_files = {self.file_index_map[i] for i in range(len(self.sorted_files)) if pattern.lower() in os.path.basename(self.sorted_files[i]['name'].lower())} elif search == 'regex': try: regex = re.compile(pattern, re.I) matched_files = {self.file_index_map[i] for i in range(len(self.sorted_files)) if regex.search(os.path.basename(self.sorted_files[i]['name']))} except Exception: return True else: return True if inc == 1: self.selected_files = matched_files elif inc == 0: self.selected_files.intersection_uodate(matched_files) elif inc == -1: self.selected_files.update(matched_files) return True def dialog_input_number(self, message, current_value, floating_point=False, allow_empty=False, allow_zero=True, allow_negative_one=True, winstack=[]): if not allow_zero: allow_negative_one = False width = max(max([len(x) for x in message.split("\n")]), 40) + 4 width = min(self.width, width) height = message.count("\n") + 6 show_cursor() win = self.window(height, width, message=message) value = str(current_value) if floating_point: bigstep = 1 smallstep = 0.1 else: bigstep = 100 smallstep = 10 win.addstr(height - 4, 2, (" up/down +/- %-3s" % bigstep).rjust(width - 4)) win.addstr(height - 3, 2, ("left/right +/- %3s" % smallstep).rjust(width - 4)) if allow_negative_one: win.addstr(height - 3, 2, "-1 means unlimited") if allow_empty: win.addstr(height - 4, 2, "leave empty for default") while True: bg = win.getbkgd() win.bkgdset(0) win.addstr(height - 2, 2, value.ljust(width - 4), gconfig.element_attr('dialog_text')) win.bkgdset(bg) win.move(height - 2, len(value) + 2) c = self.wingetch(win) if c in gconfig.esc_keys_w: hide_cursor() return -128 if c in (K.LF, K.CR, curses.KEY_ENTER): hide_cursor() try: if allow_empty and len(value) <= 0: return -2 if floating_point: return float(value) return int(value) except ValueError: return -1 elif c in (curses.KEY_BACKSPACE, curses.KEY_DC, K.DEL, 8): value = value[:-1] elif c in (K.U_, K.X_): value = '' elif len(value) >= width - 5: self.beep() elif K.n1 <= c <= K.n9: value += chr(c) elif allow_zero and c == K.n0 and value != '-' and not value.startswith('0'): value += chr(c) elif allow_negative_one and c == K.MINUS and len(value) == 0: value += chr(c) elif floating_point and c == K.DOT and '.' not in value: value += chr(c) elif c != -1: try: if value == '': value = 0 number = float(value) if floating_point else int(value) if c in (curses.KEY_LEFT, K.h): number -= smallstep elif c in (curses.KEY_RIGHT, K.l): number += smallstep elif c in (curses.KEY_DOWN, K.j): number -= bigstep elif c in (curses.KEY_UP, K.k): number += bigstep if not allow_zero and number <= 0: number = 1 elif not allow_negative_one and number < 0: number = 0 elif number < 0: # value like -0.6 isn't useful number = -1 value = ("%.2f" % number).rstrip('0').rstrip('.') if floating_point else str(number) except ValueError: pass self.update_torrent_list(winstack + [win]) def dialog_menu(self, title, options, focus=1, extended=False, winstack=[]): height = len(options) + 2 paging = False if self.mainview_height < height: height = self.mainview_height paging = True pagelines = height - 2 width = max(max([len(x[1]) + 3 for x in options]), len(title) + 3) win = self.window(height, width) win.addstr(0, 1, title) if paging: if width > 35: win.addstr(height - 1, 1, "More...") else: win.addstr(height - 1, 1, "+") old_page = 0 while True: page = (focus - 1) // pagelines if page < 0: page = 0 if page != old_page: for i in range(1, height - 1): win.addstr(i, 2, ' ' * (width - 4), 0) keymap = self.dialog_list_menu_options(win, width, options, focus, page * pagelines, (page + 1) * pagelines) c = self.wingetch(win) if 47 < c < 123 and chr(c).lower() in keymap: return (options[keymap[chr(c).lower()]][0], chr(c).isupper(), win) if extended else options[keymap[chr(c).lower()]][0] if c in gconfig.esc_keys_w: return (-128, False, win) if extended else -128 if c in (K.LF, K.CR, curses.KEY_ENTER): return (options[focus - 1][0], False, win) if extended else options[focus - 1][0] if c == curses.KEY_BACKSPACE and extended: return (options[focus - 1][0], True, win) if c in (curses.KEY_DOWN, K.j, K.N_): focus += 1 if focus > len(options): focus = 1 elif c in (curses.KEY_UP, K.k, K.P_): focus -= 1 if focus < 1: focus = len(options) elif c in (curses.KEY_HOME, K.g): focus = 1 elif c in (curses.KEY_END, K.G): focus = len(options) elif c == -1: self.update_torrent_list(winstack + [win]) def dialog_list_menu_options(self, win, width, options, focus, startline, endline): keys = dict() i = 1 for option in options: title = option[1].split('_', 1) if startline < i <= endline: if i == focus: bg = win.getbkgd() win.bkgdset(gconfig.element_attr('menu_focused')) win.addstr(i - startline, 2, title[0]) if len(title) > 1: win.addstr(title[1][0], curses.A_UNDERLINE) win.addstr(title[1][1:]) keys[title[1][0].lower()] = i - 1 win.addstr(''.ljust(width - len(option[1]) - 3)) if i == focus: win.bkgdset(bg) i += 1 return keys def action_server_options_dialog(self): enc_options = [('required', '_required'), ('preferred', '_preferred'), ('tolerated', '_tolerated')] first_time = True while True: options = [] options.append(('Peer _Port', "%d" % self.stats['peer-port'])) options.append(('UP_nP/NAT-PMP', ('disabled', 'enabled ')[self.stats['port-forwarding-enabled']])) options.append(('Peer E_xchange', ('disabled', 'enabled ')[self.stats['pex-enabled']])) options.append(('_Distributed Hash Table', ('disabled', 'enabled ')[self.stats['dht-enabled']])) options.append(('_Local Peer Discovery', ('disabled', 'enabled ')[self.stats['lpd-enabled']])) options.append(('Protocol En_cryption', "%s" % self.stats['encryption'])) # uTP support was added in Transmission v2.3 if self.server.get_rpc_version() >= 13: options.append(('_Micro Transport Protocol', ('disabled', 'enabled')[self.stats['utp-enabled']])) options.append(('_Global Peer Limit', "%d" % self.stats['peer-limit-global'])) options.append(('Peer Limit per _Torrent', "%d" % self.stats['peer-limit-per-torrent'])) options.append(('Turtle m_ode', ('disabled', 'enabled ')[self.stats['alt-speed-enabled']])) options.append(('T_urtle Mode UL Limit', "%dK" % self.stats['alt-speed-up'])) options.append(('Tu_rtle Mode DL Limit', "%dK" % self.stats['alt-speed-down'])) options.append(('_Seed Ratio Limit', "%s" % ('unlimited', self.stats['seedRatioLimit'])[self.stats['seedRatioLimited']])) # queue was implemented in Transmission v2.4 if self.server.get_rpc_version() >= 14: options.append(('Do_wnload Queue Size', "%s" % ('disabled', self.stats['download-queue-size'])[self.stats['download-queue-enabled']])) options.append(('S_eed Queue Size', "%s" % ('disabled', self.stats['seed-queue-size'])[self.stats['seed-queue-enabled']])) if self.server.get_rpc_version() >= 18: options.append(('Sequent_ial download', "%s" % ('disabled', 'enabled')[self.stats['sequential_download']])) if first_time: first_time = False max_len = max([sum([len(re.sub('_', '', x)) for x in y[0]]) for y in options]) width = min(max(len(gconfig.file_viewer) + 6, 15) + max_len, self.width) height = len(options) + 2 paging = False if self.mainview_height < height: height = self.mainview_height paging = True pagelines = height - 2 page = 0 old_page = -1 win = self.window(height, width, '', "Server Options") if paging: if width > 35: win.addstr(height - 1, 1, "More...") else: win.addstr(height - 1, 1, "+") for i in range(1, height - 1): win.addstr(i, 2, ' ' * (width - 3), 0) linestart, lineend = page * pagelines, (page + 1) * pagelines line_num = 1 for option in options: parts = re.split('_', option[0]) parts_len = sum([len(x) for x in parts]) if linestart < line_num <= lineend: win.addstr(line_num - linestart, max_len - parts_len + 2, parts.pop(0)) for part in parts: win.addstr(part[0], curses.A_UNDERLINE) win.addstr(part[1:] + ': ' + option[1]) line_num += 1 key = self.wingetch(win) if key in gconfig.esc_keys_w_enter: return if key == K.p: port = self.dialog_input_number("Port for incoming connections", self.stats['peer-port'], allow_negative_one=False, winstack=[win]) if 0 <= port <= 65535: self.server.set_option('peer-port', port) elif port != -128: # user hit ESC self.dialog_ok('Port must be in the range of 0 - 65535') elif key == K.n: self.server.set_option('port-forwarding-enabled', (1, 0)[self.stats['port-forwarding-enabled']]) elif key == K.x: self.server.set_option('pex-enabled', (1, 0)[self.stats['pex-enabled']]) elif key == K.d: self.server.set_option('dht-enabled', (1, 0)[self.stats['dht-enabled']]) elif key == K.l: self.server.set_option('lpd-enabled', (1, 0)[self.stats['lpd-enabled']]) # uTP support was added in Transmission v2.3 elif key == K.m and self.server.get_rpc_version() >= 13: self.server.set_option('utp-enabled', (1, 0)[self.stats['utp-enabled']]) elif key == K.g: limit = self.dialog_input_number("Maximum number of connected peers", self.stats['peer-limit-global'], allow_negative_one=False, winstack=[win]) if limit >= 0: self.server.set_option('peer-limit-global', limit) elif key == K.t: limit = self.dialog_input_number("Maximum number of connected peers per torrent", self.stats['peer-limit-per-torrent'], allow_negative_one=False, winstack=[win]) if limit >= 0: self.server.set_option('peer-limit-per-torrent', limit) elif key == K.s: limit = self.dialog_input_number('Stop seeding with upload/download ratio', (-1, self.stats['seedRatioLimit'])[self.stats['seedRatioLimited']], floating_point=True, winstack=[win]) if limit >= 0: self.server.set_option('seedRatioLimit', limit) self.server.set_option('seedRatioLimited', True) elif limit < 0 and limit != -128: self.server.set_option('seedRatioLimited', False) elif key == K.c: choice = self.dialog_menu('Encryption', enc_options, list(map(lambda x: x[0] == self.stats['encryption'], enc_options)).index(True) + 1, winstack=[win]) if choice != -128: self.server.set_option('encryption', choice) elif key == K.o: self.server.toggle_turtle_mode() elif key == K.u: limit = self.dialog_input_number('Upload limit for Turtle Mode in kilobytes per second', self.stats['alt-speed-up'], allow_negative_one=False, winstack=[win]) if limit != -128: self.server.set_option('alt-speed-up', limit) elif key == K.r: limit = self.dialog_input_number('Download limit for Turtle Mode in kilobytes per second', self.stats['alt-speed-down'], allow_negative_one=False, winstack=[win]) if limit != -128: self.server.set_option('alt-speed-down', limit) # Queue was implemmented in Transmission v2.4 elif key == K.w and self.server.get_rpc_version() >= 14: queue_size = self.dialog_input_number('Download Queue size', (0, self.stats['download-queue-size'])[self.stats['download-queue-enabled']], allow_negative_one=False, winstack=[win]) if queue_size != -128: if queue_size == 0: self.server.set_option('download-queue-enabled', False) elif queue_size > 0: if not self.stats['download-queue-enabled']: self.server.set_option('download-queue-enabled', True) self.server.set_option('download-queue-size', queue_size) elif key == K.e and self.server.get_rpc_version() >= 14: queue_size = self.dialog_input_number('Seed Queue size', (0, self.stats['seed-queue-size'])[self.stats['seed-queue-enabled']], allow_negative_one=False, winstack=[win]) if queue_size != -128: if queue_size == 0: self.server.set_option('seed-queue-enabled', False) elif queue_size > 0: if not self.stats['seed-queue-enabled']: self.server.set_option('seed-queue-enabled', True) self.server.set_option('seed-queue-size', queue_size) elif key == K.i and self.server.get_rpc_version() >= 18: self.server.set_option('sequential_download', not self.stats['sequential_download']) elif key == K.SPACE: page = page + 1 if page > (len(options) - 1) / pagelines: page = 0 self.update_torrent_list([win]) def action_options_dialog(self): first_time = True while True: options = [] options.append(('Version', gconfig.VERSION)) options.append(('Terminal size', "%d x %d " % (self.width, self.height))) options.append(('Title is Progress _Bar', ('no', 'yes')[gconfig.torrentname_is_progressbar])) options.append(('File _Viewer', "%s" % gconfig.file_viewer)) options.append(("View _files", ('focused', 'selected')[gconfig.view_selected])) if threading: options.append(("Show peers' _reverse DNS", ('no', 'yes')[gconfig.rdns])) options.append(("Show torrent _numbers", ('no', 'yes')[gconfig.torrent_numbers])) options.append(("_Display format", ('wide', 'narrow')[self.narrow])) options.append(("Save _config on exit", ('no', 'yes')[gconfig.save_conf])) if first_time: first_time = False max_len = max([sum([len(re.sub('_', '', x)) for x in y[0]]) for y in options]) width = min(max(len(gconfig.file_viewer) + 6, 15) + max_len, self.width) height = len(options) + 2 paging = False if self.mainview_height < height: height = self.mainview_height paging = True pagelines = height - 2 page = 0 old_page = -1 win = self.window(height, width, '', "Global Options") if paging: if width > 35: win.addstr(height - 1, 1, "More...") else: win.addstr(height - 1, 1, "+") for i in range(1, height - 1): win.addstr(i, 2, ' ' * (width - 3), 0) linestart, lineend = page * pagelines, (page + 1) * pagelines line_num = 1 for option in options: parts = re.split('_', option[0]) parts_len = sum([len(x) for x in parts]) if linestart < line_num <= lineend: win.addstr(line_num - linestart, max_len - parts_len + 2, parts.pop(0)) if parts: win.addstr(parts[0][0], curses.A_UNDERLINE) win.addstr(parts[0][1:]) win.addstr(': ' + option[1]) line_num += 1 key = self.wingetch(win) if key in gconfig.esc_keys_w_enter: return if key == K.b: gconfig.torrentname_is_progressbar = not gconfig.torrentname_is_progressbar elif key == K.d: self.force_narrow = not self.narrow elif key == K.c: gconfig.save_conf = not gconfig.save_conf elif key == K.f: gconfig.view_selected = not gconfig.view_selected elif key == K.r: gconfig.rdns = threading and not gconfig.rdns elif key == K.n: gconfig.torrent_numbers = not gconfig.torrent_numbers elif key == K.v: viewer = self.dialog_input_text('File Viewer\nExample: xdg-viewer %s', gconfig.file_viewer, tab_complete='executable', winstack=[win]) if viewer: gconfig.file_viewer = viewer elif key == K.SPACE: page = page + 1 if page > (len(options) - 1) / pagelines: page = 0 self.update_torrent_list([win]) def dialog_filters(self): filters = [[f.copy() for f in l] for l in gconfig.filters] filters.append([]) changed = True current = [0, 0] oldheight, oldwidth = -1, -1 needupdate = False while True: if changed: changed = False lines = [] i = 0 for fl in filters: lines.append(', '.join([filter2string(f) for f in fl]) + ', ') if lines[-1] == ', ': lines[-1] = '' if current[0] == len(lines) - 1: current[1] = 0 if i == current[0]: commas = [i for i, v in enumerate(lines[-1]) if v == ','] commas.append(len(lines[-1]) + 1) commas.insert(0, -2) i += 1 height = len(lines) + 3 width = min(max([14] + [len(s) for s in lines]) + 6, self.width - 2, ) if height > oldheight or width > oldwidth or win is None: win = self.window(height, width, title='Filters') oldheight, oldwidth = height, width y = 1 for s in lines: win.addnstr(y, 2, s, width - 4) win.addstr(" " * (oldwidth - 3 - win.getyx()[1])) y += 1 win.chgat(1 + current[0], commas[current[1]] + 4, commas[current[1] + 1] - commas[current[1]] - 2, curses.A_UNDERLINE) c = self.wingetch(win) if c in gconfig.esc_keys_w: return gconfig.filters if c in (K.LF, K.CR, curses.KEY_ENTER): filters = [f for f in filters if f != []] return [[{'name': '', 'inverse': False}]] if filters == [] else filters if c == curses.KEY_UP and current[0] > 0: current[0] -= 1 changed = True if c == curses.KEY_DOWN and current[0] < len(filters) - 1: current[0] += 1 changed = True if c == curses.KEY_RIGHT and current[1] < len(filters[current[0]]): current[1] += 1 changed = True if c == curses.KEY_LEFT and current[1] > 0: current[1] -= 1 changed = True if c in (K.d, curses.KEY_DC) and current[1] < len(filters[current[0]]): filters[current[0]].pop(current[1]) changed = True if c == K.f: f = filters[current[0]][current[1]].copy() if current[1] < len(filters[current[0]]) else {'name': '', 'inverse': False} f = self.filter_menu(oldfilter=f, winstack=[win]) if f: if current[1] < len(filters[current[0]]): filters[current[0]][current[1]] = f else: filters[current[0]].append(f) if current[0] == len(filters) - 1: filters.append([]) changed = True needupdate = True if current[1] > len(filters[current[0]]): current[1] = len(filters[current[0]]) if c == -1 or needupdate: needupdate = False self.update_torrent_list([win]) def update_torrent_list(self, winstack=[], pattern='', search=''): self.server.update(1) self.draw_stats() if self.selected_torrent == -1: self.draw_torrent_list(search_keyword=pattern, search=search, refresh=False) else: self.draw_details(search_keyword=pattern, search=search, refresh=False) self.pad.noutrefresh(0, 0, 1, 0, self.mainview_height, self.width - 1) self.screen.noutrefresh() for win in winstack[:-1]: win.redrawwin() win.refresh() winstack[-1].redrawwin() # End of class Interface def load_history(filename): if filename: try: history = json.load(open(filename, "r")) assert isinstance(history, dict) except Exception: history = {} else: history = {} for i in ['label', 'labels', 'location', 'tracker', 'command', 'search', 'regex']: if i not in history: history[i] = [] if 'types' not in history: history['types'] = {} return history def save_history(filename, history): if filename: try: oldhistory = json.load(open(filename, "r")) except Exception: oldhistory = {} if oldhistory != history: try: json.dump(history, open(filename, "w")) except Exception: pass def reverse_dns(cache, address): try: cache[address] = socket.gethostbyaddr(address)[0] except Exception: cache[address] = '' def percent(full, part): try: percent = 100 / (float(full) / float(part)) except ZeroDivisionError: percent = 0.0 return percent def scale_time(seconds, long=False): minute_in_sec = float(60) hour_in_sec = float(3600) day_in_sec = float(86400) month_in_sec = 27.321661 * day_in_sec # from wikipedia year_in_sec = 365.25 * day_in_sec # from wikipedia if seconds < 0: return ('?', 'some time')[long] if seconds < minute_in_sec: if long: return 'now' if seconds < 5 else "%d second%s" % (seconds, ('', 's')[seconds > 1]) return "%ds" % seconds if seconds < hour_in_sec: minutes = round(seconds / minute_in_sec, 0) if long: return "%d minute%s" % (minutes, ('', 's')[minutes > 1]) return "%dm" % minutes if seconds < day_in_sec: hours = round(seconds / hour_in_sec, 0) if long: return "%d hour%s" % (hours, ('', 's')[hours > 1]) return "%dh" % hours if seconds < month_in_sec: days = round(seconds / day_in_sec, 0) if long: return "%d day%s" % (days, ('', 's')[days > 1]) return "%dd" % days if seconds < year_in_sec: months = round(seconds / month_in_sec, 0) if long: return "%d month%s" % (months, ('', 's')[months > 1]) return "%dM" % months years = round(seconds / year_in_sec, 0) if long: return "%d year%s" % (years, ('', 's')[years > 1]) return "%dy" % years def timestamp(timestamp, time_format="%x %X", narrow=False): if timestamp < 1: return 'never' if timestamp > 2147483647: # Max value of 32bit signed integer (2^31-1) # Timedelta objects do not fail on timestamps # resulting in a date later than 2038 try: date = (datetime.datetime.fromtimestamp(0) + datetime.timedelta(seconds=timestamp)) except OverflowError: return 'some day in the distant future' date = (datetime.datetime.fromtimestamp(0) + datetime.timedelta(seconds=timestamp)) timeobj = date.timetuple() else: timeobj = time.localtime(timestamp) if time_format == "%X" and (timestamp - time.time() < -86400 or timestamp - time.time() > 86400): time_format = "%x" absolute = time.strftime(time_format, timeobj) if narrow: if timestamp > time.time(): relative = '+' + scale_time(int(timestamp - time.time()), not narrow) else: relative = '-' + scale_time(int(time.time() - timestamp), not narrow) else: if timestamp > time.time(): relative = 'in ' + scale_time(int(timestamp - time.time()), True) else: relative = scale_time(int(time.time() - timestamp), True) + ' ago' if relative.startswith('now') or relative.endswith('now'): relative = 'now' return "%s (%s)" % (absolute, relative) def scale_bytes(num=0, long=False, k=1024, digits=1): if num >= k * k * k * k: scaled_num = round((num / k / k / k / k), digits) unit = 'T' elif num >= k * k * k: scaled_num = round((num / k / k / k), digits) unit = 'G' elif num >= k * k: scaled_num = round((num / k / k), digits) unit = 'M' else: scaled_num = round((num / k), digits) unit = 'K' # handle 0 num special if num == 0 and long: return 'nothing' return num2str(num) + ' [' + num2str(scaled_num) + unit + ']' if long else str(scaled_num) + unit def homedir2tilde(path): return re.sub(r'^' + os.environ['HOME'], '~', path) def tilde2homedir(path): return re.sub(r'^~', os.environ['HOME'], path) def html2text(s): s = re.sub(r'', "\n", s) s = re.sub(r'

', ' ', s) s = re.sub(r'<[^>]*?>', '', s) return s def hide_cursor(): try: curses.curs_set(0) # hide cursor if possible except curses.error: pass # some terminals seem to have problems with that def show_cursor(): try: curses.curs_set(1) except curses.error: pass def safe_addstr(win, string, attr): win.addstr(string[:win.getmaxyx()[1] - win.getyx()[1] - 1], attr) def wrap_multiline(text, width, initial_indent='', subsequent_indent=' '): if subsequent_indent is None: subsequent_indent = ' ' * len(initial_indent) for line in text.splitlines(): # this is required because wrap() strips empty lines if not line.strip(): yield line continue for line in wrap(line, width, replace_whitespace=False, initial_indent=initial_indent, subsequent_indent=subsequent_indent): yield line initial_indent = subsequent_indent def ljust_columns(text, max_width, padchar=' '): """ Returns a string that is exactly display columns wide, padded with if necessary. Accounts for characters that are displayed two columns wide, i.e. kanji. """ chars = [] columns = 0 max_width = max(0, max_width) for character in text: width = len_columns(character) if columns + width <= max_width: chars.append(character) columns += width else: break # Fill up any remaining space while columns < max_width: assert len(padchar) == 1 chars.append(padchar) columns += 1 return ''.join(chars) def len_columns(text): """ Returns the amount of columns that would occupy. """ columns = 0 ret = 0 for character in text: if character in ['\n']: columns = 0 columns += 2 if unicodedata.east_asian_width(character) in ('W', 'F') else 1 if columns > ret: ret = columns return ret def num2str(num, num_format='%s'): if int(num) == -1: return '?' if int(num) == -2: return 'oo' if num > 999: return (re.sub(r'(\d{3})', r'\g<1>,', str(num)[::-1])[::-1]).lstrip(',') return num_format % num lastexitcode = -1 def exit_prog(msg='', exitcode=0): global lastexitcode try: curses.endwin() except curses.error: pass if msg or exitcode: print(msg, file=sys.stderr) if lastexitcode == -1: lastexitcode = exitcode elif exitcode == 0: exitcode = lastexitcode if gconfig.save_conf: gconfig.save_config() sys.exit(exitcode) def read_netrc(file=os.environ['HOME'] + '/.netrc', hostname=None): try: login = password = '' try: login, _, password = netrc.netrc(file).authenticators(hostname) except TypeError: pass try: netrc.netrc(file).hosts[hostname] except KeyError: if hostname != 'localhost': pdebug("Unknown machine in %s: %s" % (file, hostname)) if login and password: pdebug("Using default login: %s" % login) else: sys.exit(gconfig.errors.CONFIGFILE_ERROR) except netrc.NetrcParseError as e: exit_prog("Error in %s at line %s: %s\n" % (e.filename, e.lineno, e.msg)) except IOError as msg: exit_prog("Cannot read %s: %s\n" % (file, msg)) return login, password def register_credentials(username, password, url): password_mgr = urllib.request.HTTPPasswordMgrWithDefaultRealm() password_mgr.add_password(None, url, username, password) authhandler = urllib.request.HTTPBasicAuthHandler(password_mgr) opener = urllib.request.build_opener(authhandler) urllib.request.install_opener(opener) # create initial config file def create_config(configfile, connection, config): # create directory if necessary config_dir = os.path.dirname(configfile) if config_dir != '' and not os.path.isdir(config_dir): try: os.makedirs(config_dir) except OSError as msg: print(msg) sys.exit(gconfig.errors.CONFIGFILE_ERROR) # write file if not save_config(configfile, config, force=True): sys.exit(gconfig.errors.CONFIGFILE_ERROR) print("Wrote config file: %s" % configfile) sys.exit(0) def save_config(filepath, config, force=False): if force or os.path.isfile(filepath): try: config.write(open(filepath, 'w')) os.chmod(filepath, 0o600) # config may contain password return 1 except IOError as msg: print("Cannot write config file %s:\n%s" % (filepath, msg), file=sys.stderr) return 0 return -1 def sort_config_str(sorts): if sorts: return ','.join([(':' if f['reverse'] else '') + f['name'] for f in sorts]) return "name" def filter_config_str(f): s = ':' if f['inverse'] else '' s += f['name'] + '#=' if f['name'] in gconfig.FILTERS_WITH_PARAM: s += f[f['name']] return s def filter2string(f): s = '~' if f['inverse'] else '' s += f['name'] if f['name'] in gconfig.FILTERS_WITH_PARAM: s += '=' + f[f['name']] return s def parse_sort_str(sort_str, orders): sort_orders = [] for i in sort_str.split(','): x = i.split(':') if len(x) > 1 and x[1] in orders: sort_orders.append({'name': x[1], 'reverse': True}) elif x[0] in orders: sort_orders.append({'name': x[0], 'reverse': False}) if sort_orders == []: sort_orders = [{'name': 'name', 'reverse': False}] return sort_orders def parse_filter_str(s): s = s.split(" #& ") ret = [] for t in s: ret.append(parse_single_filter_str(t)) return ret def parse_single_filter_str(s): if s == '': return {'name': '', 'inverse': False} s = s.split('#=') if len(s) == 0 or len(s) % 2 == 1: return [{'name': '', 'inverse': False}] ret = [] for i in range(0, len(s), 2): f = {} if s[i].startswith(':'): f['inverse'] = True s[i] = s[i][1:] else: f['inverse'] = False f['name'] = s[i] if s[i] in GConfig.FILTERS_WITH_PARAM: f[s[i]] = s[i + 1] ret.append(f) return ret def parse_config_profiles(config, orders): if 'Profiles' not in config: return {} ret = {} for i in config['Profiles']: if i.startswith('profile'): name = i[7:] if name: s = config['Profiles'][i].rsplit('#=', 1) if s == ['']: ret[name] = {'sort': [{'name': 'name', 'reverse': False}], 'filter': [[{'name': '', 'inverse': False}]]} elif len(s) == 2: ret[name] = {} ret[name]['sort'] = parse_sort_str(s[1], orders) ret[name]['filter'] = parse_filter_str(s[0]) return ret def get_key(key): if key == 'ENTER': return (K.LF, K.CR, curses.KEY_ENTER) if len(key) == 2 and key[0] == '^': # Convert usual ctrl notation (^a) to ours (a_) key = key[1] + '_' if len(key) == 1: return (ord(key),) key = key.upper() k = getattr(K, key, ()) try: return (k or getattr(curses, 'KEY_'+key),) except AttributeError: return () def set_key(key, key_actions, interface, action, delete=None): for k in get_key(key): key_actions[k] = getattr(interface, 'action_'+action, lambda: None) if delete and k in delete: del delete[k] def set_keys(actions, key_actions, accepted, interface): for a in actions: if actions[a][0] & 15 in accepted: for k in actions[a][1]: set_key(k, key_actions, interface, a) def parse_config_key(interface, config, gconfig, common_keys, details_keys, list_keys, action_keys): sections = {'ListKeys': list_keys, 'CommonKeys': common_keys, 'DetailsKeys': details_keys} for section in sections: if section in config: for key in config[section]: if config[section][key] in gconfig.actions: set_key(key, sections[section], interface, config[section][key], common_keys if section != 'CommonKeys' else None) for k in action_keys.values(): k.discard(key) if 'Misc' in config and 'cancel' in config['Misc']: gconfig.esc_keys = tuple() for i in config['Misc']['cancel'].split(','): k = get_key(i) if k: gconfig.esc_keys += k else: gconfig.esc_keys = (K.ESC, K.q, curses.KEY_BREAK) gconfig.esc_keys_no_ascii = tuple(x for x in gconfig.esc_keys if x not in range(32, 127)) gconfig.esc_keys_w = gconfig.esc_keys + (K.W_,) gconfig.esc_keys_w_enter = gconfig.esc_keys_w + (K.LF, K.CR, curses.KEY_ENTER) gconfig.esc_keys_w_no_ascii = tuple(x for x in gconfig.esc_keys_w if x not in range(32, 127)) def list_keys(): print('ASCII:') names = {} for k in dir(Keys): if 'A' <= k[0] <= 'Z': names[getattr(Keys, k)] = k for i in range(32, 127): if i < K.n0 or (K.n9 < i < K.A) or (K.Z < i < K.a) or (i > K.z): print(chr(i), ' ', names[i]) print('\nCurses:\n' + ', '.join(x[4:] for x in dir(curses) if x[:4] == 'KEY_')) def list_actions(actions): modes = {0: 'both', 1: 'list', 2: 'details', 3: 'details', 4: 'details', 16: 'movement'} for a, d in actions.items(): print(a.ljust(36), modes[d[0] & 255].ljust(8), '/'.join(d[1]).rjust(13), d[2]) def xdg_config_home(*args): p = os.environ.get('XDG_CONFIG_HOME') if p is None or not os.path.isabs(p): p = os.path.expanduser('~/.config') return os.path.join(p, *args) if __name__ == '__main__': # command line parameters gconfig = GConfig() # forward arguments after '--' to transmission-remote if gconfig.transmissionremote_args: cmd = ['transmission-remote', '%s:%s' % (gconfig.host, gconfig.port)] # transmission-remote requires --auth or --authenv before any other # parameters which require authentication. Otherwise, auth fails. if gconfig.username and gconfig.password: os.environ["TR_AUTH"] = "{0}:{1}".format(gconfig.username, gconfig.password) cmd.extend(['--authenv']) # one argument and it doesn't start with '-' --> treat it like it's a torrent link/url if len(gconfig.transmissionremote_args) == 1 and not gconfig.transmissionremote_args[0].startswith('-'): cmd.extend(['-a', gconfig.transmissionremote_args[0]]) else: cmd.extend(gconfig.transmissionremote_args) pdebug("EXECUTING:\n%s\nRESPONSE:" % ' '.join(cmd)) try: retcode = call(cmd) except OSError as msg: exit_prog("Could not execute the above command: %s\n" % msg.strerror, 128) exit_prog('', retcode) if gconfig.rdns and not threading: gconfig.rdns = False gconfig.geoip1 = False gconfig.geoip2 = False try: import geoip2.database gconfig.geoip2 = True except ImportError: pass if gconfig.geoip2: if not os.path.isfile(gconfig.geoip2_database): try: for l in open('/etc/GeoIP.conf', "r").read(): s = l.split() if len(s) == 2 and s[0] == 'DatabaseDirectory': gconfig.geoip2_database = s[1] + '/GeoLite2-Country.mmdb' except Exception: pass if not os.path.isfile(gconfig.geoip2_database): gconfig.geoip2_database = "/usr/share/GeoIP/GeoLite2-Country.mmdb" if not os.path.isfile(gconfig.geoip2_database): gconfig.geoip2_database = "/var/lib/GeoIP/GeoLite2-Country.mmdb" if not os.path.isfile(gconfig.geoip2_database): gconfig.geoip2 = False if not gconfig.geoip2: try: import GeoIP gconfig.geoip1 = True except ImportError: pass gconfig.geoip = gconfig.geoip1 or gconfig.geoip2 norm = Normalizer() try: Interface(Transmission(gconfig.url, gconfig.username, gconfig.password)) except Exception: import traceback traceback.print_exc(file=sys.stderr) sys.stderr.flush() finally: exit_prog() tremc-tremc-19592ce/tremc.1000066400000000000000000000050641507451042200155000ustar00rootroot00000000000000.TH tremc 1 "11 August 2020" "" "tremc" .SH NAME tremc \- a console client for the Transmission BitTorrent client .SH SYNOPSIS .B tremc .RI [ options ] .RI [ filename-or-URL ] .br .SH DESCRIPTION .B tremc is a curses interface for the Transmission BitTorrent daemon. .br If a filename or an URL, is given on the command line it is passed (preceded by -a) to transmission-remote, so that the torrent is added. .br If -- followed by any options appears on the command line, the following options are passed to transmission-remote, together with server and authentication information. .br Otherwise, the main curses interface opens. .SH OPTIONS .B .IP "--version" Show version number and exit .B .IP "-h --help" Show usage information and a list of options .B .IP "-c \fICONNECTION\fB --connect=\fICONNECTION\fR" Point to the server. \fICONNECTION\fR must match the following pattern: .br [username[:password]@]host[:port][path] .br Default: localhost:9091 .B .IP "-s --ssl" Use SSL to connect to the server. .br Default: don't use SSL .B .IP "--create-config" Create configuration file with default values. .br \fINOTE:\fR A config file won't be created unless you provide this option at least once. .B .IP "-f \fICONFIGFILE\fB --config=\fICONFIGFILE\fR" Set path to configuration file. if not creating a config file, and CONFIGFILE does not exist (and contains no slashes), the config directory is also searched for CONFIGFILE or CONFIGFILE.cfg. .br Default: ~/.config/tremc/settings.cfg .B .IP "-l --list-actions" List available actions for key mapping. .B .IP "-k --list-keys" List key names for key mapping. .B .IP "-n --netrc" Get authentication info from ~/.netrc. .B .IP "-X, --skip-version-check, --permissive" Proceed even if the running transmission daemon seems incompatible, or the terminal is too small. .B .IP "-p \fIPROFILE\fB --profile \fIPROFILE\fR" Select profile to use. .B .IP "-r --reverse-dns" Toggle display of reverse DNS of peers addresses. .br Default: on, but may be set in the config file. .B .IP "-d [\fILOGIFLE\fB] --debug [\fILOGIFLE\fB]\fR" Enable debugging messages to stderr, or to LOGFILE if provided. .IP "-- \fIOPTIONS\fR" Use the known server connection to pass \fIOPTIONS\fR on to \fBtransmission-remote\fR. .B .SH FILES .IP ~/.config/tremc/settings.cfg \#.br \#tremc overwrites the configuration file on exit. \#.br \#Keep that in mind if you edit it manually. .SH SEE ALSO .BR transmission-create (1), .BR transmission-daemon (1), .BR transmission-edit (1), .BR transmission-gtk (1), .BR transmission-qt (1), .BR transmission-remote (1), .BR transmission-show (1).