aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--COPYING674
-rw-r--r--NEWS26
-rw-r--r--README.md66
-rw-r--r--screenshot.pngbin0 -> 10512 bytes
-rw-r--r--transmission-remote-cli.1104
-rwxr-xr-xtransmission-remote-cli.py3123
6 files changed, 3993 insertions, 0 deletions
diff --git a/COPYING b/COPYING
new file mode 100644
index 0000000..94a9ed0
--- /dev/null
+++ b/COPYING
@@ -0,0 +1,674 @@
+ GNU GENERAL PUBLIC LICENSE
+ Version 3, 29 June 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+ Preamble
+
+ The GNU General Public License is a free, copyleft license for
+software and other kinds of works.
+
+ The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works. By contrast,
+the GNU General Public License is intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users. We, the Free Software Foundation, use the
+GNU General Public License for most of our software; it applies also to
+any other work released this way by its authors. You can apply it to
+your programs, too.
+
+ When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+ To protect your rights, we need to prevent others from denying you
+these rights or asking you to surrender the rights. Therefore, you have
+certain responsibilities if you distribute copies of the software, or if
+you modify it: responsibilities to respect the freedom of others.
+
+ For example, if you distribute copies of such a program, whether
+gratis or for a fee, you must pass on to the recipients the same
+freedoms that you received. You must make sure that they, too, receive
+or can get the source code. And you must show them these terms so they
+know their rights.
+
+ Developers that use the GNU GPL protect your rights with two steps:
+(1) assert copyright on the software, and (2) offer you this License
+giving you legal permission to copy, distribute and/or modify it.
+
+ For the developers' and authors' protection, the GPL clearly explains
+that there is no warranty for this free software. For both users' and
+authors' sake, the GPL requires that modified versions be marked as
+changed, so that their problems will not be attributed erroneously to
+authors of previous versions.
+
+ Some devices are designed to deny users access to install or run
+modified versions of the software inside them, although the manufacturer
+can do so. This is fundamentally incompatible with the aim of
+protecting users' freedom to change the software. The systematic
+pattern of such abuse occurs in the area of products for individuals to
+use, which is precisely where it is most unacceptable. Therefore, we
+have designed this version of the GPL to prohibit the practice for those
+products. If such problems arise substantially in other domains, we
+stand ready to extend this provision to those domains in future versions
+of the GPL, as needed to protect the freedom of users.
+
+ Finally, every program is threatened constantly by software patents.
+States should not allow patents to restrict development and use of
+software on general-purpose computers, but in those that do, we wish to
+avoid the special danger that patents applied to a free program could
+make it effectively proprietary. To prevent this, the GPL assures that
+patents cannot be used to render the program non-free.
+
+ The precise terms and conditions for copying, distribution and
+modification follow.
+
+ TERMS AND CONDITIONS
+
+ 0. Definitions.
+
+ "This License" refers to version 3 of the GNU General Public License.
+
+ "Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+ "The Program" refers to any copyrightable work licensed under this
+License. Each licensee is addressed as "you". "Licensees" and
+"recipients" may be individuals or organizations.
+
+ To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy. The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+ A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+ To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy. Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+ To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies. Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+ An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License. If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+ 1. Source Code.
+
+ The "source code" for a work means the preferred form of the work
+for making modifications to it. "Object code" means any non-source
+form of a work.
+
+ A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+ The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form. A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+ The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities. However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work. For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+ The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+ The Corresponding Source for a work in source code form is that
+same work.
+
+ 2. Basic Permissions.
+
+ All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met. This License explicitly affirms your unlimited
+permission to run the unmodified Program. The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work. This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+ You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force. You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright. Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+ Conveying under any other circumstances is permitted solely under
+the conditions stated below. Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+ No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+ When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+ 4. Conveying Verbatim Copies.
+
+ You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+ You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+ 5. Conveying Modified Source Versions.
+
+ You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+ a) The work must carry prominent notices stating that you modified
+ it, and giving a relevant date.
+
+ b) The work must carry prominent notices stating that it is
+ released under this License and any conditions added under section
+ 7. This requirement modifies the requirement in section 4 to
+ "keep intact all notices".
+
+ c) You must license the entire work, as a whole, under this
+ License to anyone who comes into possession of a copy. This
+ License will therefore apply, along with any applicable section 7
+ additional terms, to the whole of the work, and all its parts,
+ regardless of how they are packaged. This License gives no
+ permission to license the work in any other way, but it does not
+ invalidate such permission if you have separately received it.
+
+ d) If the work has interactive user interfaces, each must display
+ Appropriate Legal Notices; however, if the Program has interactive
+ interfaces that do not display Appropriate Legal Notices, your
+ work need not make them do so.
+
+ A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit. Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+ 6. Conveying Non-Source Forms.
+
+ You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+ a) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by the
+ Corresponding Source fixed on a durable physical medium
+ customarily used for software interchange.
+
+ b) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by a
+ written offer, valid for at least three years and valid for as
+ long as you offer spare parts or customer support for that product
+ model, to give anyone who possesses the object code either (1) a
+ copy of the Corresponding Source for all the software in the
+ product that is covered by this License, on a durable physical
+ medium customarily used for software interchange, for a price no
+ more than your reasonable cost of physically performing this
+ conveying of source, or (2) access to copy the
+ Corresponding Source from a network server at no charge.
+
+ c) Convey individual copies of the object code with a copy of the
+ written offer to provide the Corresponding Source. This
+ alternative is allowed only occasionally and noncommercially, and
+ only if you received the object code with such an offer, in accord
+ with subsection 6b.
+
+ d) Convey the object code by offering access from a designated
+ place (gratis or for a charge), and offer equivalent access to the
+ Corresponding Source in the same way through the same place at no
+ further charge. You need not require recipients to copy the
+ Corresponding Source along with the object code. If the place to
+ copy the object code is a network server, the Corresponding Source
+ may be on a different server (operated by you or a third party)
+ that supports equivalent copying facilities, provided you maintain
+ clear directions next to the object code saying where to find the
+ Corresponding Source. Regardless of what server hosts the
+ Corresponding Source, you remain obligated to ensure that it is
+ available for as long as needed to satisfy these requirements.
+
+ e) Convey the object code using peer-to-peer transmission, provided
+ you inform other peers where the object code and Corresponding
+ Source of the work are being offered to the general public at no
+ charge under subsection 6d.
+
+ A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+ A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling. In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage. For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product. A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+ "Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source. The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+ If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information. But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+ The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed. Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+ Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+ 7. Additional Terms.
+
+ "Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law. If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+ When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it. (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.) You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+ Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+ a) Disclaiming warranty or limiting liability differently from the
+ terms of sections 15 and 16 of this License; or
+
+ b) Requiring preservation of specified reasonable legal notices or
+ author attributions in that material or in the Appropriate Legal
+ Notices displayed by works containing it; or
+
+ c) Prohibiting misrepresentation of the origin of that material, or
+ requiring that modified versions of such material be marked in
+ reasonable ways as different from the original version; or
+
+ d) Limiting the use for publicity purposes of names of licensors or
+ authors of the material; or
+
+ e) Declining to grant rights under trademark law for use of some
+ trade names, trademarks, or service marks; or
+
+ f) Requiring indemnification of licensors and authors of that
+ material by anyone who conveys the material (or modified versions of
+ it) with contractual assumptions of liability to the recipient, for
+ any liability that these contractual assumptions directly impose on
+ those licensors and authors.
+
+ All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10. If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term. If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+ If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+ Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+ 8. Termination.
+
+ You may not propagate or modify a covered work except as expressly
+provided under this License. Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+ However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+ Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+ Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License. If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+ 9. Acceptance Not Required for Having Copies.
+
+ You are not required to accept this License in order to receive or
+run a copy of the Program. Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance. However,
+nothing other than this License grants you permission to propagate or
+modify any covered work. These actions infringe copyright if you do
+not accept this License. Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+ 10. Automatic Licensing of Downstream Recipients.
+
+ Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License. You are not responsible
+for enforcing compliance by third parties with this License.
+
+ An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations. If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+ You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License. For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+ 11. Patents.
+
+ A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based. The
+work thus licensed is called the contributor's "contributor version".
+
+ A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version. For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+ Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+ In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement). To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+ If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients. "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+ If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+ A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License. You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+ Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+ 12. No Surrender of Others' Freedom.
+
+ If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all. For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+ 13. Use with the GNU Affero General Public License.
+
+ Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU Affero General Public License into a single
+combined work, and to convey the resulting work. The terms of this
+License will continue to apply to the part which is the covered work,
+but the special requirements of the GNU Affero General Public License,
+section 13, concerning interaction through a network will apply to the
+combination as such.
+
+ 14. Revised Versions of this License.
+
+ The Free Software Foundation may publish revised and/or new versions of
+the GNU General Public License from time to time. Such new versions will
+be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+ Each version is given a distinguishing version number. If the
+Program specifies that a certain numbered version of the GNU General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation. If the Program does not specify a version number of the
+GNU General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+ If the Program specifies that a proxy can decide which future
+versions of the GNU General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+ Later license versions may give you additional or different
+permissions. However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+ 15. Disclaimer of Warranty.
+
+ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+ 16. Limitation of Liability.
+
+ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+ 17. Interpretation of Sections 15 and 16.
+
+ If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Programs
+
+ If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+ To do so, attach the following notices to the program. It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+ <one line to give the program's name and a brief idea of what it does.>
+ Copyright (C) <year> <name of author>
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+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:
+
+ <program> Copyright (C) <year> <name of author>
+ 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
+<http://www.gnu.org/licenses/>.
+
+ 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
+<http://www.gnu.org/philosophy/why-not-lgpl.html>.
diff --git a/NEWS b/NEWS
new file mode 100644
index 0000000..ca526f7
--- /dev/null
+++ b/NEWS
@@ -0,0 +1,26 @@
+1.1.1 2012-04-02
+ BUGFIXES:
+ - ACS characters in pieces view look better but are extremely slow in
+ some terminals (rxvt)
+ - Fix UnicodeEncodeError with t['lastAnnounceResult']
+
+
+1.1 2012-02-29
+ BUGFIXES:
+ - Crash when pressing up or down in empty torrent list
+ - Append missing '/' to downloadDir to fix sorting by location
+ - Set individual torrent's seed limit as float, not integer
+ - Encode tracker errors as UTF-8
+
+ - New keybindings:
+ - g/G Move to top/bottom
+ - C-f/b Move one page forward/backward
+ - C-n/p Move to next/previous item
+ - Space View torrent details
+ - New options in config dialog ('o'):
+ - Turtle Mode Upload/Download Limit
+ - Torrent Title is Progress Bar
+ - Replace upload rate with seed ratio in compact mode.
+ - Show sort order in status line
+ - Use ACS characters in pieces view
+ - More compact status line
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..49cbb22
--- /dev/null
+++ b/README.md
@@ -0,0 +1,66 @@
+# A console client for the BitTorrent client [Transmission](http://www.transmissionbt.com/ "Transmission Homepage").
+
+**Download the latest version for [Transmission 1.90-2.50](http://github.com/fagga/transmission-remote-cli/raw/master/transmission-remote-cli.py).**
+
+
+## Modules
+
+For Python 2.5 or older, you need [simplejson](http://pypi.python.org/pypi/simplejson/) which should be
+packaged in any Linux distribution. The Debian/Ubuntu package is called
+`python-simplejson`.
+
+### Optional Modules (you don't need them but they add features):
+- GeoIP: Guess which country peers come from.
+- adns: Resolve IPs to host names.
+
+Debian/Ubuntu package names are `python-adns` and `python-geoip`.
+
+
+## Connection information
+Authentication and host/port can be set via command line with one
+of these patterns:
+`$ transmission-remote-cli.py -c homeserver`
+`$ transmission-remote-cli.py -c homeserver:1234`
+`$ transmission-remote-cli.py -c johndoe:secretbirthday@homeserver`
+`$ transmission-remote-cli.py -c johndoe:secretbirthday@homeserver:1234`
+
+You can write this (and other) stuff into a configuration file:
+`$ transmission-remote-cli.py -c johndoe:secretbirthday@homeserver:1234 --create-config`
+
+No configuration file is created automatically, you have to do this
+somehow. However, if the file exists, it is re-written when trcli exits to
+remember some settings. This means you shouldn't have trcli running when
+editing your configuration file.
+
+If you don't like the default configuration file path
+~/.config/transmission-remote-cli/settings.cfg, change it:
+`$ transmission-remote-cli.py -f ~/.trclirc --create-config`
+
+
+## Calling transmission-remote
+transmission-remote-cli forwards all arguments after '--' to
+transmission-remote. This is useful if your daemon requires authentication
+and/or doesn't listen on the default localhost:9091 for
+instructions. transmission-remote-cli reads HOST:PORT and authentication from
+the config file and forwards them on to transmission-remote, along with your
+arguments.
+
+Some examples:
+`$ transmission-remote-cli.py -- -l`
+`$ transmission-remote-cli.py -- -t 2 -i`
+`$ transmission-remote-cli.py -- -as`
+
+
+## Add torrents
+If you provide only one command line argument and it doesn't start with '-',
+it's treated like 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 transmission-remote-cli.py.
+
+`$ transmission-remote-cli.py http://link/to/file.torrent`
+`$ transmission-remote-cli.py path/to/some/torrent-file`
+
+
+## Contact
+Feel free to request new features or provide bug reports.
+You can find my email address [here](http://github.com/fagga).
diff --git a/screenshot.png b/screenshot.png
new file mode 100644
index 0000000..778f0ab
--- /dev/null
+++ b/screenshot.png
Binary files differ
diff --git a/transmission-remote-cli.1 b/transmission-remote-cli.1
new file mode 100644
index 0000000..3f57ebc
--- /dev/null
+++ b/transmission-remote-cli.1
@@ -0,0 +1,104 @@
+.Dd Oct 25, 2011
+.Dt TRANSMISSION-REMOTE-CLI 1
+.Os
+.Sh NAME
+.Nm transmission-remote-cli
+.Nd a console client for the Transmission BitTorrent client
+.Sh SYNOPSIS
+.Nm
+.Op OPTION
+.Op Ar filename-or-URL
+.Sh DESCRIPTION
+.Nm
+is a console client for the Transmission BitTorrent client
+.Sh OPTIONS
+.Bl -tag -with Ds
+.It Fl -version
+Show version number and exit
+.It Fl h Fl -help
+Show this help message and exit
+.It Fl "c \fICONNECTION\fR" Fl -connect=\fICONNECTION\fR
+Point to the server using pattern: [username:password@]host[:port]/[path]
+.It Fl s Fl -ssl
+Connect to Transmission via SSL
+.It Fl "f \fICONFIGFILE\fR" Fl -config=\fICONFIGFILE\fR
+Path to configuration file
+.It Fl -create-config
+Create configuration file \fICONFIGFILE\fR
+.It Fl n Fl -netrc
+Get authentication info from your ~/.netrc file
+.It Fl -
+Forward options after '--' and auth info to transmission-remote
+.Sh FILES
+Settings can be saved in ~/.config/transmission-remote-cli/settings.cfg, authentication settings in ~/.netrc
+.Sh EXAMPLES
+Connection information
+
+.Ed
+Authentication and host/port can be set via command line with one of these patterns:
+.Bd -literal -offset indent
+$ transmission-remote-cli \-c homeserver
+$ transmission-remote-cli \-c homeserver:1234
+$ transmission-remote-cli \-c johndoe:secretbirthday@homeserver
+$ transmission-remote-cli \-c johndoe:secretbirthday@homeserver:1234
+
+.Ed
+Configuration file
+
+.Ed
+You can write this (and other settings) to a configuration file:
+.Bd -literal -offset indent
+$ transmission-remote-cli.py \-c johndoe:secretbirthday@homeserver:1234 \-\-create-config
+
+.Ed
+No configuration file is created automatically, you have to do this somehow. However, if the file exists, it is re-written when trcli exits to remember some settings. This means you shouldn't have trcli running when editing your configuration file.
+
+.Ed
+If you don't like the default configuration file path ~/.config/transmission-remote-cli/settings.cfg, change it:
+.Bd -literal -offset indent
+$ transmission-remote-cli.py -f ~/.trclirc --create-config
+
+.Ed
+Calling transmission-remote
+
+.Ed
+transmission-remote-cli forwards all arguments after '--' to transmission-remote. This is useful if your daemon requires authentication and/or doesn't listen on the default localhost:9091 for instructions. transmission-remote-cli reads HOST:PORT and authentication from the config file and forwards them on to transmission-remote, along with your arguments.
+
+.Ed
+Some examples:
+.Bd -literal -offset indent
+$ transmission-remote-cli.py -- -l
+$ transmission-remote-cli.py -- -t 2 -i
+$ transmission-remote-cli.py -- -as
+
+.Ed
+Add torrents
+
+.Pp
+If you provide only one command line argument and it doesn't start with '-', it's treated like 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 transmission-remote-cli.py.
+.Bd -literal -offset indent
+$ transmission-remote-cli.py http://link/to/file.torrent
+$ transmission-remote-cli.py path/to/some/torrent-file
+.El
+.Sh AUTHOR
+.An -nosplit
+.An Benjamin (fagga),
+.An contributors .
+.Sh SEE ALSO
+.Xr transmission-create 1 ,
+.Xr transmission-daemon 1 ,
+.Xr transmission-edit 1 ,
+.Xr transmission-gtk 1 ,
+.Xr transmission-qt 1 ,
+.Xr transmission-remote 1 ,
+.Xr transmission-show 1
+.Sh COPYRIGHT
+Copyright (C) 2011 Ben Thompson.
+
+Permission is granted to copy, distribute and/or modify this document
+under the terms of the GNU Free Documentation License, Version 1.3
+or any later version published by the Free Software Foundation;
+with no Invariant Sections, no Front-Cover Texts, and no Back-Cover Texts.
+A copy of the license is included in the section entitled "GNU
+Free Documentation License" <\fBhttp://www.gnu.org/copyleft/fdl.html\fR>.
diff --git a/transmission-remote-cli.py b/transmission-remote-cli.py
new file mode 100755
index 0000000..e7dfb9e
--- /dev/null
+++ b/transmission-remote-cli.py
@@ -0,0 +1,3123 @@
+#!/usr/bin/env python
+########################################################################
+# This is transmission-remote-cli, whereas 'cli' stands for 'Curses #
+# Luminous Interface', a client for the daemon of the BitTorrent #
+# client Transmission. #
+# #
+# 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 #
+########################################################################
+
+VERSION = '1.1.1'
+
+TRNSM_VERSION_MIN = '1.90'
+TRNSM_VERSION_MAX = '2.50'
+RPC_VERSION_MIN = 8
+RPC_VERSION_MAX = 14
+
+# error codes
+CONNECTION_ERROR = 1
+JSON_ERROR = 2
+CONFIGFILE_ERROR = 3
+
+
+# use simplejson if available because it seems to be faster
+try:
+ import simplejson as json
+except ImportError:
+ try:
+ # Python 2.6 comes with a json module ...
+ import json
+ # ...but there is also an old json module that doesn't support .loads/.dumps.
+ json.dumps ; json.dumps
+ except (ImportError,AttributeError):
+ quit("Please install simplejson or Python 2.6 or higher.")
+
+import time
+import re
+import base64
+import httplib
+import urllib2
+import socket
+socket.setdefaulttimeout(None)
+import ConfigParser
+from optparse import OptionParser, SUPPRESS_HELP
+import sys
+import os
+import signal
+import unicodedata
+import locale
+locale.setlocale(locale.LC_ALL, '')
+import curses
+import curses.ascii
+from textwrap import wrap
+from subprocess import call
+import netrc
+
+
+# optional features provided by non-standard modules
+features = {'dns':False, 'geoip':False, 'ipy':False}
+try: import adns; features['dns'] = True # resolve IP to host name
+except ImportError: features['dns'] = False
+
+try: import GeoIP; features['geoip'] = True # show country peer seems to be in
+except ImportError: features['geoip'] = False
+
+try: import IPy; features['ipy'] = True # extract ipv4 from ipv6 addresses
+except ImportError: features['ipy'] = False
+
+
+if features['ipy']:
+ IPV6_RANGE_6TO4 = IPy.IP('2002::/16')
+ IPV6_RANGE_TEREDO = IPy.IP('2001::/32')
+ IPV4_ONES = 0xffffffff
+
+if features['geoip']:
+ def country_code_by_addr_vany(geo_ip, geo_ip6, addr):
+ if '.' in addr:
+ return geo_ip.country_code_by_addr(addr)
+ if not ':' in addr:
+ return None
+ if features['ipy']:
+ ip = IPy.IP(addr)
+ if ip in IPV6_RANGE_6TO4:
+ addr = str(IPy.IP(ip.int() >> 80 & IPV4_ONES))
+ return geo_ip.country_code_by_addr(addr)
+ elif ip in IPV6_RANGE_TEREDO:
+ addr = str(IPy.IP(ip.int() & IPV4_ONES ^ 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)
+
+
+# define config defaults
+config = ConfigParser.SafeConfigParser()
+config.add_section('Connection')
+config.set('Connection', 'password', '')
+config.set('Connection', 'username', '')
+config.set('Connection', 'port', '9091')
+config.set('Connection', 'host', 'localhost')
+config.set('Connection', 'path', '/transmission/rpc')
+config.set('Connection', 'ssl', 'False')
+config.add_section('Sorting')
+config.set('Sorting', 'order', 'name')
+config.add_section('Filtering')
+config.set('Filtering', 'filter', '')
+config.set('Filtering', 'invert', 'False')
+config.add_section('Misc')
+config.set('Misc', 'compact_list', 'False')
+config.set('Misc', 'torrentname_is_progressbar', 'True')
+config.add_section('Colors')
+config.set('Colors', 'title_seed', 'bg:green,fg:black')
+config.set('Colors', 'title_download', 'bg:blue,fg:black')
+config.set('Colors', 'title_idle', 'bg:cyan,fg:black')
+config.set('Colors', 'title_verify', 'bg:magenta,fg:black')
+config.set('Colors', 'title_paused', 'bg:black,fg:white')
+config.set('Colors', 'download_rate', 'bg:black,fg:blue')
+config.set('Colors', 'upload_rate', 'bg:black,fg:red')
+config.set('Colors', 'eta+ratio', 'bg:black,fg:white')
+config.set('Colors', 'filter_status', 'bg:red,fg:black')
+config.set('Colors', 'dialog', 'bg:black,fg:white')
+config.set('Colors', 'dialog_important', 'bg:red,fg:black')
+config.set('Colors', 'button', 'bg:white,fg:black')
+config.set('Colors', 'button_focused', 'bg:black,fg:white')
+config.set('Colors', 'file_prio_high', 'bg:red,fg:black')
+config.set('Colors', 'file_prio_normal', 'bg:white,fg:black')
+config.set('Colors', 'file_prio_low', 'bg:yellow,fg:black')
+config.set('Colors', 'file_prio_off', 'bg:blue,fg:black')
+
+
+class ColorManager:
+ def __init__(self, config):
+ self.config = dict()
+ for name in config.keys():
+ self.config[name] = self._parse_color_pair(config[name])
+
+ def _parse_color_pair(self, pair):
+ # BG and FG are intentionally switched here because colors are always
+ # used with curses.A_REVERSE. (To be honest, I forgot why, probably
+ # has something to do with how highlighting focus works.)
+ bg_name = pair.split(',')[1].split(':')[1].upper()
+ fg_name = pair.split(',')[0].split(':')[1].upper()
+ return { 'id': len(self.config.keys()) + 1,
+ 'bg':eval('curses.COLOR_' + bg_name),
+ 'fg':eval('curses.COLOR_' + fg_name) }
+
+ def get_id(self, name): return self.config[name]['id']
+ def get_bg(self, name): return self.config[name]['bg']
+ def get_fg(self, name): return self.config[name]['fg']
+ def get_names(self): return self.config.keys()
+
+
+
+authhandler = None
+session_id = 0
+
+# Handle communication with Transmission server.
+class TransmissionRequest:
+ def __init__(self, host, port, path, method=None, tag=None, arguments=None):
+ self.url = create_url(host, port, path)
+ self.open_request = None
+ self.last_update = 0
+ 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 = urllib2.Request(url=self.url, data=json.dumps(request_data))
+
+ def send_request(self):
+ """Ask for information from server OR submit command."""
+
+ global session_id
+ try:
+ if session_id:
+ self.http_request.add_header('X-Transmission-Session-Id', session_id)
+ self.open_request = urllib2.urlopen(self.http_request)
+ debug(self.http_request.get_data() + "\n\n")
+ except AttributeError:
+ # request data (http_request) isn't specified yet -- data will be available on next call
+ pass
+
+ # authentication
+ except urllib2.HTTPError, e:
+ try:
+ msg = html2text(str(e.read()))
+ except:
+ msg = str(e)
+
+ # extract session id and send request again
+ m = re.search('X-Transmission-Session-Id:\s*(\w+)', msg)
+ try:
+ session_id = m.group(1)
+ self.send_request()
+ except AttributeError:
+ quit(str(msg) + "\n", CONNECTION_ERROR)
+
+ except urllib2.URLError, msg:
+ try:
+ reason = msg.reason[1]
+ except IndexError:
+ reason = str(msg.reason)
+ quit("Cannot connect to %s: %s\n" % (self.http_request.host, reason), CONNECTION_ERROR)
+
+ def get_response(self):
+ """Get response to previously sent request."""
+
+ if self.open_request == None:
+ return {'result': 'no open request'}
+ response = self.open_request.read()
+ # work around regression in Python 2.6.5, caused by http://bugs.python.org/issue8797
+ if authhandler:
+ authhandler.retried = 0
+ try:
+ data = json.loads(unicode(response))
+ debug(data)
+ except ValueError:
+ quit("Cannot parse response: %s\n" % response, JSON_ERROR)
+ self.open_request = None
+ return data
+
+
+# End of Class TransmissionRequest
+
+
+# Higher level of data exchange
+class Transmission:
+ 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
+
+ LIST_FIELDS = [ 'id', 'name', 'downloadDir', 'status', 'trackerStats', 'desiredAvailable',
+ 'rateDownload', 'rateUpload', 'eta', 'uploadRatio',
+ 'sizeWhenDone', 'haveValid', 'haveUnchecked', 'addedDate',
+ 'uploadedEver', 'errorString', 'recheckProgress',
+ 'peersConnected', 'uploadLimit', 'downloadLimit',
+ 'uploadLimited', 'downloadLimited', 'bandwidthPriority',
+ 'peersSendingToUs', 'peersGettingFromUs',
+ 'seedRatioLimit', 'seedRatioMode' ]
+
+ DETAIL_FIELDS = [ 'files', 'priorities', 'wanted', 'peers', 'trackers',
+ 'activityDate', 'dateCreated', 'startDate', 'doneDate',
+ 'totalSize', 'leftUntilDone', 'comment', 'isPrivate',
+ 'hashString', 'pieceCount', 'pieceSize', 'pieces',
+ 'downloadedEver', 'corruptEver', 'peersFrom' ] + LIST_FIELDS
+
+ def __init__(self, host, port, path, username, password):
+ self.host = host
+ self.port = port
+ self.path = path
+
+ if username and password:
+ password_mgr = urllib2.HTTPPasswordMgrWithDefaultRealm()
+ password_mgr.add_password(None, create_url(host, port, path), username, password)
+ global authhandler
+ authhandler = urllib2.HTTPBasicAuthHandler(password_mgr)
+ opener = urllib2.build_opener(authhandler)
+ urllib2.install_opener(opener)
+
+ # check rpc version
+ request = TransmissionRequest(host, port, path, 'session-get', self.TAG_SESSION_GET)
+ request.send_request()
+ response = request.get_response()
+
+ self.rpc_version = response['arguments']['rpc-version']
+
+ # rpc version too old?
+ version_error = "Unsupported Transmission version: " + str(response['arguments']['version']) + \
+ " -- RPC protocol version: " + str(response['arguments']['rpc-version']) + "\n"
+
+ min_msg = "Please install Transmission version " + TRNSM_VERSION_MIN + " or higher.\n"
+ try:
+ if response['arguments']['rpc-version'] < RPC_VERSION_MIN:
+ quit(version_error + min_msg)
+ except KeyError:
+ quit(version_error + min_msg)
+
+ # rpc version too new?
+ if response['arguments']['rpc-version'] > RPC_VERSION_MAX:
+ quit(version_error + "Please install Transmission version " + TRNSM_VERSION_MAX + " or lower.\n")
+
+ # 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
+
+ # set up request list
+ self.requests = {'torrent-list':
+ TransmissionRequest(host, port, path, 'torrent-get', self.TAG_TORRENT_LIST, {'fields': self.LIST_FIELDS}),
+ 'session-stats':
+ TransmissionRequest(host, port, path, 'session-stats', self.TAG_SESSION_STATS, 21),
+ 'session-get':
+ TransmissionRequest(host, port, path, 'session-get', self.TAG_SESSION_GET),
+ 'torrent-details':
+ TransmissionRequest(host, port, path)}
+
+ self.torrent_cache = []
+ self.status_cache = dict()
+ self.torrent_details_cache = dict()
+ self.peer_progress_cache = dict()
+ self.hosts_cache = dict()
+ self.geo_ips_cache = dict()
+ if features['dns']: self.resolver = adns.init()
+ if features['geoip']:
+ 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
+
+ # make sure there are no undefined values
+ self.wait_for_torrentlist_update()
+ self.requests['torrent-details'] = TransmissionRequest(self.host, self.port, self.path)
+
+
+ def update(self, delay, tag_waiting_for=0):
+ """Maintain up-to-date data."""
+
+ tag_waiting_for_occurred = False
+
+ for request in 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
+
+ if tag_waiting_for:
+ return tag_waiting_for_occurred
+ else:
+ return None
+
+
+
+ def parse_response(self, response):
+ # 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(map(lambda x: x['seederCount'], t['trackerStats']))
+ t['leechers'] = max(map(lambda x: x['leecherCount'], t['trackerStats']))
+ except ValueError:
+ t['seeders'] = t['leechers'] = -1
+
+ 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:
+ torrent_details = response['arguments']['torrents'][0]
+ torrent_details['pieces'] = base64.decodestring(torrent_details['pieces'])
+ 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 not self.peer_progress_cache.has_key(peerid):
+ self.peer_progress_cache[peerid] = {'last_progress':peer['progress'], 'last_update':time.time(),
+ 'download_speed':0, 'time_left':0}
+
+ # estimate how fast a peer is downloading
+ if peer['progress'] < 1:
+ this_time = time.time()
+ time_diff = this_time - self.peer_progress_cache[peerid]['last_update']
+ progress_diff = peer['progress'] - self.peer_progress_cache[peerid]['last_progress']
+ if self.peer_progress_cache[peerid]['last_progress'] and progress_diff > 0 and time_diff > 5:
+ downloaded = self.torrent_details_cache['totalSize'] * progress_diff
+ avg_speed = downloaded / time_diff
+
+ if self.peer_progress_cache[peerid]['download_speed'] > 0: # make it less jumpy
+ avg_speed = (self.peer_progress_cache[peerid]['download_speed'] + avg_speed) /2
+
+ download_left = self.torrent_details_cache['totalSize'] - \
+ (self.torrent_details_cache['totalSize']*peer['progress'])
+ time_left = download_left / avg_speed
+
+ self.peer_progress_cache[peerid]['last_update'] = this_time # remember update time
+ self.peer_progress_cache[peerid]['download_speed'] = avg_speed
+ self.peer_progress_cache[peerid]['time_left'] = time_left
+
+ self.peer_progress_cache[peerid]['last_progress'] = peer['progress'] # remember progress
+ self.torrent_details_cache['peers'][index].update(self.peer_progress_cache[peerid])
+
+ # resolve and locate peer's ip
+ if features['dns'] and not self.hosts_cache.has_key(ip):
+ try:
+ self.hosts_cache[ip] = self.resolver.submit_reverse(ip, adns.rr.PTR)
+ except adns.Error:
+ pass
+ if features['geoip'] and not self.geo_ips_cache.has_key(ip):
+ self.geo_ips_cache[ip] = country_code_by_addr_vany(self.geo_ip, self.geo_ip6, ip)
+ if self.geo_ips_cache[ip] == None:
+ self.geo_ips_cache[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):
+ try:
+ for sort_order in sort_orders:
+ if isinstance(self.torrent_cache[0][sort_order['name']], (str, unicode)):
+ self.torrent_cache.sort(key=lambda x: x[sort_order['name']].lower(),
+ reverse=sort_order['reverse'])
+ else:
+ self.torrent_cache.sort(key=lambda x: x[sort_order['name']],
+ reverse=sort_order['reverse'])
+ except IndexError:
+ return []
+ return self.torrent_cache
+
+ def get_torrent_by_id(self, id):
+ i = 0
+ while self.torrent_cache[i]['id'] != id: i += 1
+ if self.torrent_cache[i]['id'] == id:
+ return self.torrent_cache[i]
+ else:
+ return None
+
+
+ def get_torrent_details(self):
+ return self.torrent_details_cache
+ def set_torrent_details_id(self, id):
+ if id < 0:
+ self.requests['torrent-details'] = TransmissionRequest(self.host, self.port, self.path)
+ else:
+ self.requests['torrent-details'].set_request_data('torrent-get', self.TAG_TORRENT_DETAILS,
+ {'ids':id, 'fields': self.DETAIL_FIELDS})
+
+ def get_hosts(self):
+ return self.hosts_cache
+
+ def get_geo_ips(self):
+ return self.geo_ips_cache
+
+
+ def set_option(self, option_name, option_value):
+ request = TransmissionRequest(self.host, self.port, self.path, 'session-set', 1, {option_name: option_value})
+ request.send_request()
+ self.wait_for_status_update()
+
+
+ def set_rate_limit(self, direction, new_limit, torrent_id=-1):
+ data = dict()
+ if new_limit <= -1:
+ new_limit = None
+ limit_enabled = False
+ else:
+ limit_enabled = True
+
+ if torrent_id < 0:
+ type = 'session-set'
+ data['speed-limit-'+direction] = new_limit
+ data['speed-limit-'+direction+'-enabled'] = limit_enabled
+ else:
+ type = 'torrent-set'
+ data['ids'] = [torrent_id]
+ data[direction+'loadLimit'] = new_limit
+ data[direction+'loadLimited'] = limit_enabled
+
+ request = TransmissionRequest(self.host, self.port, self.path, type, 1, data)
+ request.send_request()
+ self.wait_for_torrentlist_update()
+
+
+ def set_seed_ratio(self, ratio, torrent_id=-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'] = [torrent_id]
+ data['seedRatioLimit'] = ratio
+ data['seedRatioMode'] = mode
+ request = TransmissionRequest(self.host, self.port, self.path, 'torrent-set', 1, data)
+ request.send_request()
+ self.wait_for_torrentlist_update()
+
+
+ def increase_bandwidth_priority(self, torrent_id):
+ torrent = self.get_torrent_by_id(torrent_id)
+ if torrent == None or torrent['bandwidthPriority'] >= 1:
+ return False
+ else:
+ new_priority = torrent['bandwidthPriority'] + 1
+ request = TransmissionRequest(self.host, self.port, self.path, 'torrent-set', 1,
+ {'ids': [torrent_id], 'bandwidthPriority':new_priority})
+ request.send_request()
+ self.wait_for_torrentlist_update()
+
+ def decrease_bandwidth_priority(self, torrent_id):
+ torrent = self.get_torrent_by_id(torrent_id)
+ if torrent == None or torrent['bandwidthPriority'] <= -1:
+ return False
+ else:
+ new_priority = torrent['bandwidthPriority'] - 1
+ request = TransmissionRequest(self.host, self.port, self.path, 'torrent-set', 1,
+ {'ids': [torrent_id], 'bandwidthPriority':new_priority})
+ 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):
+ request = TransmissionRequest(self.host, self.port, self.path, 'torrent-add', 1, {'filename': location})
+ request.send_request()
+ response = request.get_response()
+ if response['result'] != 'success':
+ return response['result']
+ else:
+ return ''
+
+ def stop_torrent(self, id):
+ request = TransmissionRequest(self.host, self.port, self.path, 'torrent-stop', 1, {'ids': [id]})
+ request.send_request()
+ self.wait_for_torrentlist_update()
+
+ def start_torrent(self, id):
+ request = TransmissionRequest(self.host, self.port, self.path, 'torrent-start', 1, {'ids': [id]})
+ request.send_request()
+ self.wait_for_torrentlist_update()
+
+ def verify_torrent(self, id):
+ request = TransmissionRequest(self.host, self.port, self.path, 'torrent-verify', 1, {'ids': [id]})
+ request.send_request()
+ self.wait_for_torrentlist_update()
+
+ def reannounce_torrent(self, id):
+ request = TransmissionRequest(self.host, self.port, self.path, 'torrent-reannounce', 1, {'ids': [id]})
+ request.send_request()
+ self.wait_for_torrentlist_update()
+
+ def move_torrent(self, torrent_id, new_location):
+ request = TransmissionRequest(self.host, self.port, self.path, 'torrent-set-location', 1,
+ {'ids': torrent_id, 'location': new_location, 'move': True})
+ request.send_request()
+ self.wait_for_torrentlist_update()
+
+ def remove_torrent(self, id):
+ request = TransmissionRequest(self.host, self.port, self.path, 'torrent-remove', 1, {'ids': [id]})
+ request.send_request()
+ self.wait_for_torrentlist_update()
+
+ def remove_torrent_local_data(self, id):
+ request = TransmissionRequest(self.host, self.port, self.path, 'torrent-remove', 1, {'ids': [id], 'delete-local-data':True})
+ request.send_request()
+ self.wait_for_torrentlist_update()
+
+ def add_torrent_tracker(self, id, tracker):
+ data = { 'ids' : [id],
+ 'trackerAdd' : [tracker] }
+ request = TransmissionRequest(self.host, self.port, self.path, 'torrent-set', 1, data)
+ request.send_request()
+ response = request.get_response()
+ return response['result'] if response['result'] != 'success' else ''
+
+ def remove_torrent_tracker(self, id, tracker):
+ data = { 'ids' : [id],
+ 'trackerRemove' : [tracker] }
+ request = TransmissionRequest(self.host, self.port, self.path, 'torrent-set', 1, data)
+ 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
+ elif 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.host, self.port, self.path, 'torrent-set', 1, request_data)
+ request.send_request()
+ self.wait_for_details_update()
+
+ 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'
+ elif priority <= -1: return 'low'
+ elif priority == 0: return 'normal'
+ elif priority >= 1: return 'high'
+ return '?'
+
+ def wait_for_torrentlist_update(self):
+ self.wait_for_update(7)
+ def wait_for_details_update(self):
+ self.wait_for_update(77)
+ def wait_for_status_update(self):
+ self.wait_for_update(22)
+ 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):
+ 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['status'] == Transmission.STATUS_DOWNLOAD:
+ status = ('idle','downloading')[torrent['rateDownload'] > 0]
+ elif torrent['status'] == Transmission.STATUS_DOWNLOAD_WAIT:
+ status = 'will download'
+ elif torrent['status'] == Transmission.STATUS_SEED:
+ status = 'seeding'
+ elif torrent['status'] == Transmission.STATUS_SEED_WAIT:
+ status = 'will seed'
+ else:
+ status = 'unknown state'
+ return status
+
+ def get_bandwidth_priority(self, torrent):
+ if torrent['bandwidthPriority'] == -1:
+ return '-'
+ elif torrent['bandwidthPriority'] == 0:
+ return ' '
+ elif torrent['bandwidthPriority'] == 1:
+ return '+'
+ else:
+ return '?'
+
+# End of Class Transmission
+
+
+
+
+
+# User Interface
+class Interface:
+ TRACKER_ITEM_HEIGHT = 6
+
+ def __init__(self):
+ self.filter_list = config.get('Filtering', 'filter')
+ self.filter_inverse = config.getboolean('Filtering', 'invert')
+ self.sort_orders = parse_sort_str(config.get('Sorting', 'order'))
+ self.compact_list = config.getboolean('Misc', 'compact_list')
+ self.torrentname_is_progressbar = config.getboolean('Misc', 'torrentname_is_progressbar')
+
+ self.torrents = server.get_torrent_list(self.sort_orders)
+ self.stats = server.get_global_stats()
+ self.torrent_details = []
+ self.selected_torrent = -1 # changes to >-1 when focus >-1 & user hits return
+ self.all_paused = False
+ 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.scrollpos = 0 # start of torrentlist
+ self.torrents_per_page = 0 # will be set by manage_layout()
+ self.rateDownload_width = self.rateUpload_width = 2
+
+ self.details_category_focus = 0 # overview/files/peers/tracker in details
+ self.focus_detaillist = -1 # same as focus but for details
+ self.selected_files = [] # marked files in details
+ self.scrollpos_detaillist = 0 # same as scrollpos but for details
+ self.compact_torrentlist = False # draw only one line for each torrent in compact mode
+ self.exit_now = False
+
+ self.keybindings = {
+ ord('?'): self.call_list_key_bindings,
+ curses.KEY_F1: self.call_list_key_bindings,
+ 27: self.go_back_or_unfocus,
+ curses.KEY_BREAK: self.go_back_or_unfocus,
+ 12: self.go_back_or_unfocus,
+ curses.KEY_BACKSPACE: self.leave_details,
+ ord('q'): self.go_back_or_quit,
+ ord('o'): self.o_key,
+ ord('\n'): self.select_torrent_detail_view,
+ curses.KEY_RIGHT: self.right_key,
+ ord('l'): self.l_key,
+ ord('s'): self.show_sort_order_menu,
+ ord('f'): self.f_key,
+ ord('u'): self.global_upload,
+ ord('d'): self.global_download,
+ ord('U'): self.torrent_upload,
+ ord('D'): self.torrent_download,
+ ord('L'): self.seed_ratio,
+ ord('t'): self.t_key,
+ ord('+'): self.bandwidth_priority,
+ ord('-'): self.bandwidth_priority,
+ ord('p'): self.pause_unpause_torrent,
+ ord('P'): self.pause_unpause_all_torrent,
+ ord('v'): self.verify_torrent,
+ ord('y'): self.verify_torrent,
+ ord('r'): self.r_key,
+ curses.KEY_DC: self.r_key,
+ ord('R'): self.remove_torrent_local_data,
+ curses.KEY_SDC: self.remove_torrent_local_data,
+ curses.KEY_UP: self.movement_keys,
+ ord('k'): self.movement_keys,
+ curses.KEY_DOWN: self.movement_keys,
+ ord('j'): self.movement_keys,
+ curses.KEY_PPAGE: self.movement_keys,
+ curses.KEY_NPAGE: self.movement_keys,
+ curses.KEY_HOME: self.movement_keys,
+ curses.KEY_END: self.movement_keys,
+ ord('g'): self.movement_keys,
+ ord('G'): self.movement_keys,
+ curses.ascii.ctrl(ord('f')): self.movement_keys,
+ curses.ascii.ctrl(ord('b')): self.movement_keys,
+ curses.ascii.ctrl(ord('n')): self.movement_keys,
+ curses.ascii.ctrl(ord('p')): self.movement_keys,
+ ord("\t"): self.move_in_details,
+ curses.KEY_BTAB: self.move_in_details,
+ ord('e'): self.move_in_details,
+ ord('c'): self.move_in_details,
+ ord('C'): self.toggle_compact_torrentlist,
+ ord('h'): self.file_pritority_or_switch_details,
+ curses.KEY_LEFT: self.file_pritority_or_switch_details,
+ ord(' '): self.space_key,
+ ord('a'): self.a_key,
+ ord('m'): self.move_torrent,
+ ord('n'): self.reannounce_torrent,
+ ord('/'): self.dialog_search_torrentlist
+ }
+
+ 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'), ('reverse','Re_verse')
+ ]
+
+
+ try:
+ self.init_screen()
+ self.run()
+ except:
+ self.restore_screen()
+ (exc_type, exc_value, exc_traceback) = sys.exc_info()
+ raise exc_type, exc_value, exc_traceback
+ else:
+ self.restore_screen()
+
+
+ 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()
+
+ # enable colors if available
+ try:
+ curses.start_color()
+ self.colors = ColorManager(dict(config.items('Colors')))
+ for name in sorted(self.colors.get_names()):
+ curses.init_pair(self.colors.get_id(name),
+ self.colors.get_fg(name),
+ self.colors.get_bg(name))
+ except:
+ pass
+
+ # http://bugs.python.org/issue2675
+ try:
+ del os.environ['LINES']
+ del os.environ['COLUMNS']
+ except:
+ pass
+
+ # http://bugs.python.org/issue2675
+ try:
+ del os.environ['LINES']
+ del os.environ['COLUMNS']
+ except:
+ 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:
+ curses.endwin()
+ self.screen.refresh()
+ self.height, self.width = self.screen.getmaxyx()
+ # Tracker list breaks if width smaller than 73
+ if self.width < 73 or self.height < 16:
+ self.screen.erase()
+ self.screen.addstr(0,0, "Terminal too small", curses.A_REVERSE + curses.A_BOLD)
+ time.sleep(1)
+ else:
+ break
+ self.manage_layout()
+
+ def manage_layout(self):
+ self.tlist_item_height = 3 if not self.compact_list else 1
+ self.pad_height = max((len(self.torrents)+1) * self.tlist_item_height, self.height)
+ self.pad = curses.newpad(self.pad_height, self.width)
+ self.mainview_height = self.height - 2
+ self.torrents_per_page = self.mainview_height / self.tlist_item_height
+ self.detaillistitems_per_page = self.height - 8
+
+ 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:
+ visible_torrents = self.torrents[self.scrollpos/self.tlist_item_height : self.scrollpos/self.tlist_item_height + self.torrents_per_page + 1]
+ self.rateDownload_width = self.get_rateDownload_width(visible_torrents)
+ self.rateUpload_width = self.get_rateUpload_width(visible_torrents)
+
+ self.torrent_title_width = self.width - self.rateUpload_width - 2
+ # show downloading column only if any downloading torrents are visible
+ if filter(lambda x: x['status']==Transmission.STATUS_DOWNLOAD, visible_torrents):
+ self.torrent_title_width -= self.rateDownload_width + 2
+ else:
+ self.torrent_title_width = 80
+
+ def get_rateDownload_width(self, torrents):
+ new_width = max(map(lambda x: len(scale_bytes(x['rateDownload'])), torrents))
+ new_width = max(max(map(lambda x: len(scale_time(x['eta'])), 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):
+ new_width = max(map(lambda x: len(scale_bytes(x['rateUpload'])), torrents))
+ new_width = max(max(map(lambda x: len(num2str(x['uploadRatio'], '%.02f')), torrents)), 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 run(self):
+ self.draw_title_bar()
+ self.draw_stats()
+ self.draw_torrent_list()
+
+ while True:
+ server.update(1)
+
+ # display torrentlist
+ if self.selected_torrent == -1:
+ self.draw_torrent_list()
+
+ # display some torrent's details
+ else:
+ self.draw_details()
+
+ self.stats = 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
+ self.handle_user_input()
+ if self.exit_now:
+ sort_str = ','.join(map(lambda x: ('','reverse:')[x['reverse']] + x['name'], self.sort_orders))
+ config.set('Sorting', 'order', sort_str)
+ config.set('Filtering', 'filter', self.filter_list)
+ config.set('Filtering', 'invert', str(self.filter_inverse))
+ config.set('Misc', 'compact_list', str(self.compact_list))
+ config.set('Misc', 'torrentname_is_progressbar', str(self.torrentname_is_progressbar))
+ save_config(cmd_args.configfile)
+ return
+
+ def go_back_or_unfocus(self, c):
+ if self.focus_detaillist > -1: # unfocus and deselect file
+ self.focus_detaillist = -1
+ self.scrollpos_detaillist = 0
+ self.selected_files = []
+ elif self.selected_torrent > -1: # return from details
+ self.details_category_focus = 0
+ self.selected_torrent = -1
+ self.selected_files = []
+ else:
+ if self.focus > -1:
+ self.scrollpos = 0 # unfocus main list
+ self.focus = -1
+ elif self.filter_list:
+ self.filter_list = '' # reset filter
+
+ def leave_details(self, c):
+ if self.selected_torrent > -1:
+ server.set_torrent_details_id(-1)
+ self.selected_torrent = -1
+ self.details_category_focus = 0
+ self.scrollpos_detaillist = 0
+ self.selected_files = []
+
+ def go_back_or_quit(self, c):
+ if self.selected_torrent == -1:
+ self.exit_now = True
+ else: # return to list view
+ server.set_torrent_details_id(-1)
+ self.selected_torrent = -1
+ self.details_category_focus = 0
+ self.focus_detaillist = -1
+ self.scrollpos_detaillist = 0
+ self.selected_files = []
+
+ def space_key(self, c):
+ # File list
+ if self.selected_torrent > -1 and self.details_category_focus == 1:
+ self.select_unselect_file(c)
+ # Torrent list
+ elif self.selected_torrent == -1:
+ self.select_torrent_detail_view(c)
+
+ def a_key(self, c):
+ # File list
+ if self.selected_torrent > -1 and self.details_category_focus == 1:
+ self.select_unselect_file(c)
+ # Trackers
+ elif self.selected_torrent > -1 and self.details_category_focus == 3:
+ self.add_tracker()
+
+ # Do nothing in other detail tabs
+ elif self.selected_torrent > -1:
+ pass
+ else:
+ self.add_torrent()
+
+ def o_key(self, c):
+ if self.selected_torrent == -1:
+ self.draw_options_dialog()
+ elif self.selected_torrent > -1:
+ self.details_category_focus = 0
+
+ def l_key(self, c):
+ if self.focus > -1 and self.selected_torrent == -1:
+ self.select_torrent_detail_view(c)
+ elif self.selected_torrent > -1:
+ self.file_pritority_or_switch_details(c)
+
+ def t_key(self, c):
+ if self.selected_torrent == -1:
+ server.toggle_turtle_mode()
+ elif self.selected_torrent > -1:
+ self.details_category_focus = 3
+
+ def f_key(self, c):
+ if self.selected_torrent == -1:
+ self.show_state_filter_menu(c)
+ elif self.selected_torrent > -1:
+ self.details_category_focus = 1
+
+ def r_key(self, c):
+ # Torrent list
+ if self.selected_torrent == -1:
+ self.remove_torrent(c)
+ # Trackers
+ elif self.selected_torrent > -1 and self.details_category_focus == 3:
+ self.remove_tracker()
+
+ def right_key(self, c):
+ if self.focus > -1 and self.selected_torrent == -1:
+ self.select_torrent_detail_view(c)
+ else:
+ self.file_pritority_or_switch_details(c)
+
+ def add_torrent(self):
+ location = self.dialog_input_text("Add torrent from file or URL", os.getcwd())
+ if location:
+ error = server.add_torrent(location)
+ if error:
+ msg = wrap("Couldn't add torrent \"%s\":" % location)
+ msg.extend(wrap(error, self.width-4))
+ self.dialog_ok("\n".join(msg))
+
+ def select_torrent_detail_view(self, c):
+ if self.focus > -1 and self.selected_torrent == -1:
+ self.screen.clear()
+ self.selected_torrent = self.focus
+ server.set_torrent_details_id(self.torrents[self.focus]['id'])
+ server.wait_for_details_update()
+
+ def show_sort_order_menu(self, c):
+ if self.selected_torrent == -1:
+ choice = self.dialog_menu('Sort order', self.sort_options,
+ map(lambda x: x[0]==self.sort_orders[-1]['name'], self.sort_options).index(True)+1)
+ if choice != -128:
+ if choice == 'reverse':
+ self.sort_orders[-1]['reverse'] = not self.sort_orders[-1]['reverse']
+ else:
+ self.sort_orders.append({'name':choice, 'reverse':False})
+ while len(self.sort_orders) > 2:
+ self.sort_orders.pop(0)
+
+ def show_state_filter_menu(self, c):
+ if self.selected_torrent == -1:
+ options = [('uploading','_Uploading'), ('downloading','_Downloading'),
+ ('active','Ac_tive'), ('paused','_Paused'), ('seeding','_Seeding'),
+ ('incomplete','In_complete'), ('verifying','Verif_ying'),
+ ('invert','In_vert'), ('','_All')]
+ choice = self.dialog_menu(('Show only','Filter all')[self.filter_inverse], options,
+ map(lambda x: x[0]==self.filter_list, options).index(True)+1)
+ if choice != -128:
+ if choice == 'invert':
+ self.filter_inverse = not self.filter_inverse
+ else:
+ if choice == '':
+ self.filter_inverse = False
+ self.filter_list = choice
+
+ def global_upload(self, c):
+ 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
+ server.set_rate_limit('up', limit)
+
+ def global_download(self, c):
+ 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
+ server.set_rate_limit('down', limit)
+
+ def torrent_upload(self, c):
+ if self.focus > -1:
+ current_limit = (-1,self.torrents[self.focus]['uploadLimit'])[self.torrents[self.focus]['uploadLimited']]
+ limit = self.dialog_input_number("Upload limit in kilobytes per second for\n%s" % \
+ self.torrents[self.focus]['name'], current_limit)
+ if limit == -128:
+ return
+ server.set_rate_limit('up', limit, self.torrents[self.focus]['id'])
+
+ def torrent_download(self, c):
+ if self.focus > -1:
+ current_limit = (-1,self.torrents[self.focus]['downloadLimit'])[self.torrents[self.focus]['downloadLimited']]
+ limit = self.dialog_input_number("Download limit in Kilobytes per second for\n%s" % \
+ self.torrents[self.focus]['name'], current_limit)
+ if limit == -128:
+ return
+ server.set_rate_limit('down', limit, self.torrents[self.focus]['id'])
+
+ def seed_ratio(self, c):
+ if self.focus > -1:
+ if self.torrents[self.focus]['seedRatioMode'] == 0: # Use global settings
+ current_limit = ''
+ elif self.torrents[self.focus]['seedRatioMode'] == 1: # Stop seeding at seedRatioLimit
+ current_limit = self.torrents[self.focus]['seedRatioLimit']
+ elif self.torrents[self.focus]['seedRatioMode'] == 2: # Seed regardless of ratio
+ current_limit = -1
+ limit = self.dialog_input_number("Seed ratio limit for\n%s" % self.torrents[self.focus]['name'],
+ 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
+ server.set_seed_ratio(float(limit), self.torrents[self.focus]['id'])
+
+ def bandwidth_priority(self, c):
+ if c == ord('-') and self.focus > -1:
+ server.decrease_bandwidth_priority(self.torrents[self.focus]['id'])
+ elif c == ord('+') and self.focus > -1:
+ server.increase_bandwidth_priority(self.torrents[self.focus]['id'])
+
+ def pause_unpause_torrent(self, c):
+ if self.focus > -1:
+ if self.selected_torrent > -1:
+ t = self.torrent_details
+ else:
+ t = self.torrents[self.focus]
+ if t['status'] == Transmission.STATUS_STOPPED:
+ server.start_torrent(t['id'])
+ else:
+ server.stop_torrent(t['id'])
+
+ def pause_unpause_all_torrent(self, c):
+ if self.all_paused:
+ for t in self.torrents:
+ server.start_torrent(t['id'])
+ self.all_paused = False
+ else:
+ for t in self.torrents:
+ server.stop_torrent(t['id'])
+ self.all_paused = True
+
+ def verify_torrent(self, c):
+ if self.focus > -1:
+ if self.torrents[self.focus]['status'] != Transmission.STATUS_CHECK \
+ and self.torrents[self.focus]['status'] != Transmission.STATUS_CHECK_WAIT:
+ server.verify_torrent(self.torrents[self.focus]['id'])
+
+ def reannounce_torrent(self, c):
+ if self.focus > -1:
+ server.reannounce_torrent(self.torrents[self.focus]['id'])
+
+ def remove_torrent(self, c):
+ if self.focus > -1:
+ name = self.torrents[self.focus]['name'][0:self.width - 15]
+ if self.dialog_yesno("Remove %s?" % name) == True:
+ if self.selected_torrent > -1: # leave details
+ server.set_torrent_details_id(-1)
+ self.selected_torrent = -1
+ self.details_category_focus = 0
+ server.remove_torrent(self.torrents[self.focus]['id'])
+
+ def remove_torrent_local_data(self, c):
+ if self.focus > -1:
+ name = self.torrents[self.focus]['name'][0:self.width - 15]
+ if self.dialog_yesno("Remove and delete %s?" % name, important=True) == True:
+ if self.selected_torrent > -1: # leave details
+ server.set_torrent_details_id(-1)
+ self.selected_torrent = -1
+ self.details_category_focus = 0
+ server.remove_torrent_local_data(self.torrents[self.focus]['id'])
+
+ def add_tracker(self):
+ if 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:')
+ if tracker:
+ t = self.torrent_details
+ response = server.add_torrent_tracker(t['id'], tracker)
+
+ if response:
+ msg = wrap("Couldn't add tracker: %s" % response)
+ self.dialog_ok("\n".join(msg))
+
+ def remove_tracker(self):
+ if 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 >= 0 and \
+ self.scrollpos_detaillist < len(t['trackerStats']) and \
+ self.dialog_yesno("Do you want to remove this tracker?") is True):
+
+ tracker = t['trackerStats'][self.scrollpos_detaillist]
+ response = 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 movement_keys(self, c):
+ if self.selected_torrent == -1 and len(self.torrents) > 0:
+ if c == curses.KEY_UP or c == ord('k') or c == curses.ascii.ctrl(ord('p')):
+ self.focus, self.scrollpos = self.move_up(self.focus, self.scrollpos, self.tlist_item_height)
+ elif c == curses.KEY_DOWN or c == ord('j') or c == curses.ascii.ctrl(ord('n')):
+ self.focus, self.scrollpos = self.move_down(self.focus, self.scrollpos, self.tlist_item_height,
+ self.torrents_per_page, len(self.torrents))
+ elif c == curses.KEY_PPAGE or c == curses.ascii.ctrl(ord('b')):
+ self.focus, self.scrollpos = self.move_page_up(self.focus, self.scrollpos, self.tlist_item_height,
+ self.torrents_per_page)
+ elif c == curses.KEY_NPAGE or c == curses.ascii.ctrl(ord('f')):
+ self.focus, self.scrollpos = self.move_page_down(self.focus, self.scrollpos, self.tlist_item_height,
+ self.torrents_per_page, len(self.torrents))
+ elif c == curses.KEY_HOME or c == ord('g'):
+ self.focus, self.scrollpos = self.move_to_top()
+ elif c == curses.KEY_END or c == ord('G'):
+ self.focus, self.scrollpos = self.move_to_end(self.tlist_item_height, self.torrents_per_page, len(self.torrents))
+ self.focused_id = self.torrents[self.focus]['id']
+ elif self.selected_torrent > -1:
+ # file list
+ if self.details_category_focus == 1:
+ # focus/movement
+ if c == curses.KEY_UP or c == ord('k') or c == curses.ascii.ctrl(ord('p')):
+ self.focus_detaillist, self.scrollpos_detaillist = \
+ self.move_up(self.focus_detaillist, self.scrollpos_detaillist, 1)
+ elif c == curses.KEY_DOWN or c == ord('j') or c == curses.ascii.ctrl(ord('n')):
+ self.focus_detaillist, self.scrollpos_detaillist = \
+ self.move_down(self.focus_detaillist, self.scrollpos_detaillist, 1,
+ self.detaillistitems_per_page, len(self.torrent_details['files']))
+ elif c == curses.KEY_PPAGE or c == curses.ascii.ctrl(ord('b')):
+ self.focus_detaillist, self.scrollpos_detaillist = \
+ self.move_page_up(self.focus_detaillist, self.scrollpos_detaillist, 1,
+ self.detaillistitems_per_page)
+ elif c == curses.KEY_NPAGE or c == curses.ascii.ctrl(ord('f')):
+ self.focus_detaillist, self.scrollpos_detaillist = \
+ self.move_page_down(self.focus_detaillist, self.scrollpos_detaillist, 1,
+ self.detaillistitems_per_page, len(self.torrent_details['files']))
+ elif c == curses.KEY_HOME or c == ord('g'):
+ self.focus_detaillist, self.scrollpos_detaillist = self.move_to_top()
+ elif c == curses.KEY_END or c == ord('G'):
+ self.focus_detaillist, self.scrollpos_detaillist = \
+ self.move_to_end(1, self.detaillistitems_per_page, len(self.torrent_details['files']))
+ list_len = 0
+
+ # peer list movement
+ if self.details_category_focus == 2:
+ list_len = len(self.torrent_details['peers'])
+
+ # tracker list movement
+ elif self.details_category_focus == 3:
+ list_len = len(self.torrent_details['trackerStats'])
+
+ # 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 = int(piece_count / map_width) + 1
+
+ if list_len:
+ if c == curses.KEY_UP or c == ord('k') or c == curses.ascii.ctrl(ord('p')):
+ if self.scrollpos_detaillist > 0:
+ self.scrollpos_detaillist -= 1
+ elif c == curses.KEY_DOWN or c == ord('j') or c == curses.ascii.ctrl(ord('n')):
+ if self.scrollpos_detaillist < list_len - 1:
+ self.scrollpos_detaillist += 1
+ elif c == curses.KEY_PPAGE or c == curses.ascii.ctrl(ord('b')):
+ self.scrollpos_detaillist = \
+ max(self.scrollpos_detaillist - self.detaillistitems_per_page - 1, 0)
+ elif c == curses.KEY_NPAGE or c == curses.ascii.ctrl(ord('f')):
+ if self.scrollpos_detaillist + self.detaillistitems_per_page >= list_len:
+ self.scrollpos_detaillist = list_len - 1
+ else:
+ self.scrollpos_detaillist += self.detaillistitems_per_page
+ elif c == curses.KEY_HOME or c == ord('g'):
+ self.scrollpos_detaillist = 0
+ elif c == curses.KEY_END or c == ord('G'):
+ self.scrollpos_detaillist = 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 = min(self.scrollpos_detaillist,
+ max(0, list_len - self.detaillistitems_per_page))
+
+ def file_pritority_or_switch_details(self, c):
+ if self.selected_torrent > -1:
+ # file priority OR walk through details
+ if c == curses.KEY_RIGHT or c == ord('l'):
+ if self.details_category_focus == 1 and \
+ (self.selected_files or self.focus_detaillist > -1):
+ if self.selected_files:
+ files = set(self.selected_files)
+ server.increase_file_priority(files)
+ elif self.focus_detaillist > -1:
+ server.increase_file_priority([self.focus_detaillist])
+ else:
+ self.scrollpos_detaillist = 0
+ self.next_details()
+ elif c == curses.KEY_LEFT or c == ord('h'):
+ if self.details_category_focus == 1 and \
+ (self.selected_files or self.focus_detaillist > -1):
+ if self.selected_files:
+ files = set(self.selected_files)
+ server.decrease_file_priority(files)
+ elif self.focus_detaillist > -1:
+ server.decrease_file_priority([self.focus_detaillist])
+ else:
+ self.scrollpos_detaillist = 0
+ self.prev_details()
+
+ def select_unselect_file(self, c):
+ if self.selected_torrent > -1 and self.details_category_focus == 1 and self.focus_detaillist >= 0:
+ # file selection with space
+ if c == ord(' '):
+ try:
+ self.selected_files.pop(self.selected_files.index(self.focus_detaillist))
+ except ValueError:
+ self.selected_files.append(self.focus_detaillist)
+ curses.ungetch(curses.KEY_DOWN) # move down
+ # (un)select all files
+ elif c == ord('a'):
+ if self.selected_files:
+ self.selected_files = []
+ else:
+ self.selected_files = range(0, len(self.torrent_details['files']))
+
+ def move_in_details(self, c):
+ if self.selected_torrent > -1:
+ if c == ord("\t"):
+ self.next_details()
+ elif c == curses.KEY_BTAB:
+ self.prev_details()
+ elif c == ord('e'):
+ self.details_category_focus = 2
+ elif c == ord('c'):
+ self.details_category_focus = 4
+
+ def call_list_key_bindings(self, c):
+ self.list_key_bindings()
+
+ def toggle_compact_torrentlist(self, c):
+ self.compact_list = not self.compact_list
+
+ def move_torrent(self, c):
+ if self.focus > -1:
+ location = homedir2tilde(self.torrents[self.focus]['downloadDir'])
+ msg = 'Move "%s" from\n%s to' % (self.torrents[self.focus]['name'], location)
+ path = self.dialog_input_text(msg, location)
+ if path:
+ server.move_torrent(self.torrents[self.focus]['id'], tilde2homedir(path))
+
+ def handle_user_input(self):
+ c = self.screen.getch()
+ if c == -1:
+ return 0
+
+ f = self.keybindings.get(c, None)
+ if f:
+ f(c)
+
+ # update view
+ if self.selected_torrent == -1:
+ self.draw_torrent_list()
+ else:
+ self.draw_details()
+
+ def filter_torrent_list(self):
+ unfiltered = self.torrents
+ if self.filter_list == 'downloading':
+ self.torrents = [t for t in self.torrents if t['rateDownload'] > 0]
+ elif self.filter_list == 'uploading':
+ self.torrents = [t for t in self.torrents if t['rateUpload'] > 0]
+ elif self.filter_list == 'paused':
+ self.torrents = [t for t in self.torrents if t['status'] == Transmission.STATUS_STOPPED]
+ elif self.filter_list == 'seeding':
+ self.torrents = [t for t in self.torrents if t['status'] == Transmission.STATUS_SEED \
+ or t['status'] == Transmission.STATUS_SEED_WAIT]
+ elif self.filter_list == 'incomplete':
+ self.torrents = [t for t in self.torrents if t['percentDone'] < 100]
+ elif self.filter_list == 'active':
+ self.torrents = [t for t in self.torrents if t['peersGettingFromUs'] > 0 \
+ or t['peersSendingToUs'] > 0 \
+ or t['status'] == Transmission.STATUS_CHECK]
+ elif self.filter_list == 'verifying':
+ self.torrents = [t for t in self.torrents if t['status'] == Transmission.STATUS_CHECK \
+ or t['status'] == Transmission.STATUS_CHECK_WAIT]
+ # invert list?
+ if self.filter_inverse:
+ self.torrents = [t for t in unfiltered if t not 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/self.tlist_item_height):
+ self.scrollpos -= self.tlist_item_height
+ # make sure the focus is not below the visible area
+ while self.focus > (self.scrollpos/self.tlist_item_height) + self.torrents_per_page-1:
+ self.scrollpos += self.tlist_item_height
+ # keep min and max bounds
+ self.scrollpos = min(self.scrollpos, (len(self.torrents) - self.torrents_per_page) * self.tlist_item_height)
+ self.scrollpos = max(0, self.scrollpos)
+
+ def draw_torrent_list(self, search_keyword=''):
+ self.torrents = server.get_torrent_list(self.sort_orders)
+ self.filter_torrent_list()
+
+ if search_keyword:
+ matched_torrents = [t for t in self.torrents if search_keyword.lower() in t['name'].lower()]
+ if matched_torrents:
+ self.focus = 0
+ if self.search_focus >= len(matched_torrents):
+ self.search_focus = 0
+ self.focused_id = matched_torrents[self.search_focus]['id']
+ self.highlight_dialog = False
+ else:
+ self.highlight_dialog = True
+ curses.beep()
+ else:
+ self.search_focus = 0
+
+ self.follow_list_focus()
+ self.manage_layout()
+
+ ypos = 0
+ for i in range(len(self.torrents)):
+ ypos += self.draw_torrentlist_item(self.torrents[i],
+ (i == self.focus),
+ self.compact_list,
+ ypos)
+
+ self.pad.refresh(self.scrollpos,0, 1,0, self.mainview_height,self.width-1)
+ self.screen.refresh()
+
+
+ def draw_torrentlist_item(self, torrent, focused, compact, y):
+ # the torrent name is also a progress bar
+ self.draw_torrentlist_title(torrent, focused, self.torrent_title_width, y)
+
+ rates = ''
+ if torrent['status'] == Transmission.STATUS_DOWNLOAD:
+ self.draw_downloadrate(torrent, y)
+ if torrent['status'] == Transmission.STATUS_DOWNLOAD or torrent['status'] == Transmission.STATUS_SEED:
+ self.draw_uploadrate(torrent, y)
+
+ 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)
+ self.draw_torrentlist_status(torrent, focused, y)
+
+ return 3 # number of lines that were used for drawing the list item
+ else:
+ # Draw ratio in place of upload rate if upload rate = 0
+ if not torrent['rateUpload']:
+ self.draw_ratio(torrent, y - 1)
+
+ return 1
+
+ def draw_downloadrate(self, torrent, ypos):
+ 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),
+ curses.color_pair(self.colors.get_id('download_rate')) + curses.A_BOLD + curses.A_REVERSE)
+ def draw_uploadrate(self, torrent, ypos):
+ 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),
+ curses.color_pair(self.colors.get_id('upload_rate')) + curses.A_BOLD + curses.A_REVERSE)
+ def draw_ratio(self, torrent, ypos):
+ self.pad.addch(ypos+1, self.width-self.rateUpload_width-1, curses.ACS_DIAMOND,
+ (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),
+ curses.color_pair(self.colors.get_id('eta+ratio')) + curses.A_BOLD + curses.A_REVERSE)
+ 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),
+ curses.color_pair(self.colors.get_id('eta+ratio')) + curses.A_BOLD + curses.A_REVERSE)
+
+
+ def draw_torrentlist_title(self, torrent, focused, width, ypos):
+ if torrent['status'] == Transmission.STATUS_CHECK:
+ percentDone = float(torrent['recheckProgress']) * 100
+ else:
+ percentDone = torrent['percentDone']
+
+ bar_width = int(float(width) * (float(percentDone)/100))
+
+ size = "%6s" % scale_bytes(torrent['sizeWhenDone'])
+ if torrent['percentDone'] < 100:
+ if torrent['seeders'] <= 0 and torrent['status'] != Transmission.STATUS_CHECK:
+ size = "%6s / " % scale_bytes(torrent['available']) + size
+ size = "%6s / " % scale_bytes(torrent['haveValid'] + torrent['haveUnchecked']) + size
+ size = '| ' + size
+ title = ljust_columns(torrent['name'], width - len(size)) + size
+
+ if torrent['status'] == Transmission.STATUS_SEED \
+ or torrent['status'] == Transmission.STATUS_SEED_WAIT:
+ color = curses.color_pair(self.colors.get_id('title_seed'))
+ elif torrent['status'] == Transmission.STATUS_STOPPED:
+ color = curses.color_pair(self.colors.get_id('title_paused'))
+ elif torrent['status'] == Transmission.STATUS_CHECK \
+ or torrent['status'] == Transmission.STATUS_CHECK_WAIT:
+ color = curses.color_pair(self.colors.get_id('title_verify'))
+ elif torrent['rateDownload'] == 0:
+ color = curses.color_pair(self.colors.get_id('title_idle'))
+ elif torrent['percentDone'] < 100:
+ color = curses.color_pair(self.colors.get_id('title_download'))
+ else:
+ color = 0
+
+ tag = curses.A_REVERSE
+ tag_done = tag + color
+ if focused:
+ tag += curses.A_BOLD
+ tag_done += curses.A_BOLD
+
+ if self.torrentname_is_progressbar:
+ # addstr() dies when you tell it to draw on the last column of the
+ # terminal, so we have to catch this exception.
+ try:
+ self.pad.addstr(ypos, 0, title[0:bar_width].encode('utf-8'), tag_done)
+ self.pad.addstr(ypos, bar_width, title[bar_width:].encode('utf-8'), tag)
+ except:
+ pass
+ else:
+ self.pad.addstr(ypos, 0, title.encode('utf-8'), tag_done)
+
+
+ def draw_torrentlist_status(self, torrent, focused, ypos):
+ peers = ''
+ parts = [server.get_status(torrent)]
+
+ # show tracker error if appropriate
+ if torrent['errorString'] and \
+ not torrent['seeders'] and not torrent['leechers'] and \
+ not torrent['status'] == Transmission.STATUS_STOPPED:
+ parts[0] = torrent['errorString'].encode('utf-8')
+
+ else:
+ if torrent['status'] == Transmission.STATUS_CHECK:
+ parts[0] += " (%d%%)" % int(float(torrent['recheckProgress']) * 100)
+ elif torrent['status'] == Transmission.STATUS_DOWNLOAD:
+ parts[0] += " (%d%%)" % torrent['percentDone']
+ parts[0] = parts[0].ljust(20)
+
+ # seeds and leeches will be appended right justified later
+ 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.torrent_title_width - sum(map(lambda x: len(x), parts)) - len(peers) > 18:
+ uploaded = scale_bytes(torrent['uploadedEver'])
+ parts.append("%7s uploaded" % ('nothing',uploaded)[uploaded != '0B'])
+
+ if self.torrent_title_width - sum(map(lambda x: len(x), parts)) - len(peers) > 22:
+ parts.append("%4s peer%s connected" % (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(map(lambda x: len(x), parts), len(peers)) - 2
+ delimiter = ' ' * int(remaining_space / (len(parts)))
+
+ line = server.get_bandwidth_priority(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):
+ self.torrent_details = server.get_torrent_details()
+ self.manage_layout()
+
+ # details could need more space than the torrent list
+ self.pad_height = max(50, len(self.torrent_details['files'])+10, (len(self.torrents)+1)*3, self.height)
+ self.pad = curses.newpad(self.pad_height, self.width)
+
+ # torrent name + progress bar
+ self.draw_torrentlist_item(self.torrent_details, False, False, 0)
+
+ # divider + menu
+ menu_items = ['_Overview', "_Files", 'P_eers', '_Trackers', 'Pie_ces' ]
+ xpos = int((self.width - sum(map(lambda x: len(x), 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:
+ 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)
+
+ self.pad.refresh(0,0, 1,0, self.height-2,self.width)
+ self.screen.refresh()
+
+
+ def draw_details_overview(self, ypos):
+ t = self.torrent_details
+ info = []
+ info.append(['Hash: ', "%s" % t['hashString']])
+ info.append(['ID: ', "%s" % t['id']])
+
+ wanted = 0
+ for i, file_info in enumerate(t['files']):
+ if t['wanted'][i] == True: wanted += t['files'][i]['length']
+
+ sizes = ['Size: ', "%s; " % scale_bytes(t['totalSize'], 'long'),
+ "%s wanted; " % (scale_bytes(wanted, 'long'),'everything') [t['totalSize'] == wanted]]
+ if t['available'] < t['totalSize']:
+ sizes.append("%s available; " % scale_bytes(t['available'], 'long'))
+ sizes.extend(["%s left" % scale_bytes(t['leftUntilDone'], 'long')])
+ info.append(sizes)
+
+ info.append(['Files: ', "%d; " % len(t['files'])])
+ complete = map(lambda x: x['bytesCompleted'] == x['length'], t['files']).count(True)
+ not_complete = filter(lambda x: x['bytesCompleted'] != x['length'], t['files'])
+ partial = map(lambda x: x['bytesCompleted'] > 0, not_complete).count(True)
+ 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(['Pieces: ', "%s; " % t['pieceCount'],
+ "%s each" % scale_bytes(t['pieceSize'], 'long')])
+
+ info.append(['Download: '])
+ info[-1].append("%s" % scale_bytes(t['downloadedEver'], 'long') + \
+ " (%d%%) received; " % int(percent(t['sizeWhenDone'], t['downloadedEver'])))
+ info[-1].append("%s" % scale_bytes(t['haveValid'], 'long') + \
+ " (%d%%) verified; " % int(percent(t['sizeWhenDone'], t['haveValid'])))
+ info[-1].append("%s corrupt" % scale_bytes(t['corruptEver'], 'long'))
+ if t['percentDone'] < 100:
+ info[-1][-1] += '; '
+ if t['rateDownload']:
+ info[-1].append("receiving %s per second" % scale_bytes(t['rateDownload'], 'long'))
+ if t['downloadLimited']:
+ info[-1][-1] += " (throttled to %s)" % scale_bytes(t['downloadLimit']*1024, 'long')
+ else:
+ info[-1].append("no reception in progress")
+
+ try:
+ copies_distributed = (float(t['uploadedEver']) / float(t['sizeWhenDone']))
+ except ZeroDivisionError:
+ copies_distributed = 0
+ info.append(['Upload: ', "%s " % scale_bytes(t['uploadedEver'], 'long') + \
+ "(%.2f copies) distributed; " % copies_distributed])
+ if t['rateUpload']:
+ info[-1].append("sending %s per second" % scale_bytes(t['rateUpload'], 'long'))
+ if t['uploadLimited']:
+ info[-1][-1] += " (throttled to %s)" % scale_bytes(t['uploadLimit']*1024, 'long')
+ else:
+ info[-1].append("no transmission in progress")
+
+ info.append(['Seed limit: '])
+ if t['seedRatioMode'] == 0:
+ if self.stats['seedRatioLimited']:
+ info[-1].append('default (pause torrent after distributing %s copies)' % self.stats['seedRatioLimit'])
+ else:
+ info[-1].append('default (unlimited)')
+ elif t['seedRatioMode'] == 1:
+ info[-1].append('pause torrent after distributing %s copies' % t['seedRatioLimit'])
+ elif t['seedRatioMode'] == 2:
+ info[-1].append('unlimited (ignore global limits)')
+
+ info.append(['Peers: ',
+ "connected to %d; " % t['peersConnected'],
+ "downloading from %d; " % t['peersSendingToUs'],
+ "uploading to %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')])
+ else:
+ info.append(['Swarm speed: ', "<gathering info from %d peers, %d done>" % \
+ (min_active_peers, len(active_peers))])
+ else:
+ info.append(['Swarm speed: ', "<no downloading peers connected>"])
+
+
+ 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'])])
+
+ ypos = self.draw_details_list(ypos, info)
+
+ self.draw_details_eventdates(ypos+1)
+ return ypos+1
+
+ def draw_details_eventdates(self, ypos):
+ t = self.torrent_details
+
+ self.pad.addstr(ypos, 1, ' Created: ' + timestamp(t['dateCreated']))
+ self.pad.addstr(ypos+1, 1, ' Added: ' + timestamp(t['addedDate']))
+ self.pad.addstr(ypos+2, 1, ' Started: ' + timestamp(t['startDate']))
+ self.pad.addstr(ypos+3, 1, ' Activity: ' + timestamp(t['activityDate']))
+
+ if t['percentDone'] < 100 and t['eta'] > 0:
+ self.pad.addstr(ypos+4, 1, 'Finishing: ' + timestamp(time.time() + t['eta']))
+ elif t['doneDate'] <= 0:
+ self.pad.addstr(ypos+4, 1, 'Finishing: sometime')
+ else:
+ self.pad.addstr(ypos+4, 1, ' Finished: ' + timestamp(t['doneDate']))
+
+ if t['comment']:
+ if self.width >= 90:
+ width = self.width - 50
+ comment = wrap_multiline(t['comment'], width, initial_indent='Comment: ')
+ for i, line in enumerate(comment):
+ if(ypos+i > self.height-1):
+ break
+ self.pad.addstr(ypos+i, 50, line.encode('utf8'))
+ else:
+ width = self.width - 2
+ comment = wrap_multiline(t['comment'], width, initial_indent='Comment: ')
+ for i, line in enumerate(comment):
+ self.pad.addstr(ypos+6+i, 2, line.encode('utf8'))
+
+ 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
+
+ for line in self.create_filelist():
+ curses_tags = 0
+ # highlight focused/selected line(s)
+ while line.startswith('_'):
+ if line[1] == 'S':
+ curses_tags = curses.A_BOLD
+ line = line[2:]
+ if line[1] == 'F':
+ curses_tags += curses.A_REVERSE
+ line = line[2:]
+ try:
+ self.pad.addstr(ypos, 0, ' '*self.width, curses_tags)
+ except: pass
+
+ # colored priority (only in the first 30 chars, the rest is filename)
+ xpos = 0
+ for part in re.split('(high|normal|low|off)', line[0:30], 1):
+ if part == 'high':
+ self.pad.addstr(ypos, xpos, part,
+ curses_tags + curses.color_pair(self.colors.get_id('file_prio_high')))
+ elif part == 'normal':
+ self.pad.addstr(ypos, xpos, part,
+ curses_tags + curses.color_pair(self.colors.get_id('file_prio_normal')))
+ elif part == 'low':
+ self.pad.addstr(ypos, xpos, part,
+ curses_tags + curses.color_pair(self.colors.get_id('file_prio_low')))
+ elif part == 'off':
+ self.pad.addstr(ypos, xpos, part,
+ curses_tags + curses.color_pair(self.colors.get_id('file_prio_off')))
+ else:
+ self.pad.addstr(ypos, xpos, part.encode('utf-8'), curses_tags)
+ xpos += len(part)
+ self.pad.addstr(ypos, xpos, line[30:].encode('utf-8'), curses_tags)
+ ypos += 1
+ if ypos > self.height:
+ break
+
+ def create_filelist(self):
+ filelist = []
+ files = self.torrent_details['files']
+ current_folder = []
+ current_depth = 0
+ index = 0
+ pos = 0
+ pos_before_focus = 0
+ for file in 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, filelist, current_depth, pos)
+ current_folder = f[:f_len]
+ filelist.append(self.create_filelist_line(f[-1], index, percent(file['length'], file['bytesCompleted']),
+ file['length'], current_depth))
+ index += 1
+ if self.focus_detaillist == index - 1:
+ pos_before_focus = pos
+ if index + pos >= self.focus_detaillist + 1 + pos + self.detaillistitems_per_page/2 \
+ and index + pos >= self.detaillistitems_per_page:
+ if self.focus_detaillist + 1 + pos_before_focus < self.detaillistitems_per_page / 2:
+ return filelist
+ return filelist[self.focus_detaillist + 1 + pos_before_focus - self.detaillistitems_per_page / 2
+ : self.focus_detaillist + 1 + pos_before_focus + self.detaillistitems_per_page / 2]
+ begin = len(filelist) - self.detaillistitems_per_page
+ return filelist[begin > 0 and begin or 0:]
+
+ def create_filelist_transition(self, f, current_folder, filelist, current_depth, pos):
+ f_len = len(f) - 1
+ current_folder_len = len(current_folder)
+ same = 0
+ while same < current_folder_len and same < f_len and f[same] == current_folder[same]:
+ same += 1
+ for i in range(current_folder_len - same):
+ current_depth -= 1
+ filelist.append(' '*current_depth + ' '*31 + '/')
+ pos += 1
+ if f_len < current_folder_len:
+ return [current_depth, pos]
+ while current_depth < f_len:
+ filelist.append('%s\\ %s' % (' '*current_depth + ' '*31 , f[current_depth]))
+ current_depth += 1
+ pos += 1
+ return [current_depth, pos]
+
+ def create_filelist_line(self, name, index, percent, length, current_depth):
+ line = "%s %6.1f%%" % (str(index+1).rjust(3), percent) + \
+ ' '+scale_bytes(length).rjust(5) + \
+ ' '+server.get_file_priority(self.torrent_details['id'], index).center(8) + \
+ " %s| %s" % (' '*current_depth, name[0:self.width-31-current_depth])
+ if index == self.focus_detaillist:
+ line = '_F' + line
+ if index in self.selected_files:
+ line = '_S' + line
+ return line
+
+ 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.detaillistitems_per_page)
+ start = min(self.scrollpos_detaillist, last_possible_index)
+ end = start + self.detaillistitems_per_page
+ peers = self.torrent_details['peers'][start:end]
+
+ # Find width of columns
+ clientname_width = 0
+ address_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'])
+
+ # 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 + 2) \
+ + " Address".ljust(address_width + 2)
+ if features['geoip']: column_names += " Country"
+ if features['dns']: column_names += " Host"
+
+ self.pad.addstr(ypos, 0, column_names.ljust(self.width), curses.A_UNDERLINE)
+ ypos += 1
+
+ # Peers
+ hosts = server.get_hosts()
+ geo_ips = server.get_geo_ips()
+ for index, peer in enumerate(peers):
+ if features['dns']:
+ try:
+ try:
+ host = hosts[peer['address']].check()
+ host_name = host[3][0]
+ except (IndexError, KeyError):
+ host_name = "<not resolvable>"
+ except adns.NotReady:
+ host_name = "<resolving>"
+ except adns.Error, msg:
+ host_name = msg
+
+ upload_tag = download_tag = line_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("%5s " % scale_bytes(peer['rateToClient']), download_tag)
+ # Up
+ self.pad.addstr("%5s " % scale_bytes(peer['rateToPeer']), upload_tag)
+
+ # Progress
+ if peer['progress'] < 1: self.pad.addstr("%3d%%" % (float(peer['progress'])*100))
+ else: self.pad.addstr("%3d%%" % (float(peer['progress'])*100), curses.A_BOLD)
+
+ # ETA
+ if peer['progress'] < 1 and peer['download_speed'] > 1024:
+ self.pad.addstr(" @ ")
+ self.pad.addch(curses.ACS_PLMINUS)
+ self.pad.addstr("%-5s " % scale_bytes(peer['download_speed']))
+ self.pad.addch(curses.ACS_PLMINUS)
+ self.pad.addstr("%-4s " % scale_time(peer['time_left']))
+ else:
+ self.pad.addstr(" ")
+ # Client
+ self.pad.addstr(peer['clientName'].ljust(clientname_width + 2).encode('utf-8'))
+ # Address
+ self.pad.addstr(peer['address'].ljust(address_width + 2))
+ # Country
+ if features['geoip']: self.pad.addstr(" %2s " % geo_ips[peer['address']])
+ # Host
+ if features['dns']: self.pad.addstr(host_name.encode('utf-8'), curses.A_DIM)
+ ypos += 1
+
+#TODO
+# 1. Issue #14 on GitHub is asking for feature to be able to modify trackers.
+ def draw_trackerlist(self, ypos):
+ top = ypos - 1
+ def addstr(ypos, xpos, *args):
+ if ypos > top and ypos < self.height - 2:
+ self.pad.addstr(ypos, xpos, *args)
+
+ tracker_per_page = self.detaillistitems_per_page // self.TRACKER_ITEM_HEIGHT
+ page = self.scrollpos_detaillist // 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 = min(self.scrollpos_detaillist,
+ len(self.torrent_details['trackerStats'])-1)
+ # show newly added tracker when list was empty before
+ if self.torrent_details['trackerStats']:
+ self.scrollpos_detaillist = max(0, self.scrollpos_detaillist)
+
+ current_tier = -1
+ for index, t in enumerate(tlist):
+ announce_msg_size = scrape_msg_size = 0
+ selected = t == self.torrent_details['trackerStats'][self.scrollpos_detaillist]
+
+ 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(4):
+ addstr(ypos+i, 0, ' ', curses.A_BOLD + curses.A_REVERSE)
+
+ addstr(ypos+1, 4, "Last announce: %s" % timestamp(t['lastAnnounceTime']))
+ addstr(ypos+1, 57, " Last scrape: %s" % timestamp(t['lastScrapeTime']))
+
+ if t['lastAnnounceSucceeded']:
+ peers = "%s peer%s" % (num2str(t['lastAnnouncePeerCount']), ('s', '')[t['lastAnnouncePeerCount']==1])
+ addstr(ypos, 2, t['announce'], curses.A_BOLD + curses.A_UNDERLINE)
+ addstr(ypos+2, 11, "Result: ")
+ addstr(ypos+2, 19, "%s received" % peers, curses.A_BOLD)
+ else:
+ addstr(ypos, 2, t['announce'], curses.A_UNDERLINE)
+ addstr(ypos+2, 9, "Response:")
+ announce_msg_size = self.wrap_and_draw_result(top, ypos+2, 19, t['lastAnnounceResult'].encode('utf-8'))
+
+ if t['lastScrapeSucceeded']:
+ seeds = "%s seed%s" % (num2str(t['seederCount']), ('s', '')[t['seederCount']==1])
+ leeches = "%s leech%s" % (num2str(t['leecherCount']), ('es', '')[t['leecherCount']==1])
+ addstr(ypos+2, 57, "Tracker knows: ")
+ addstr(ypos+2, 72, "%s and %s" % (seeds, leeches), curses.A_BOLD)
+ else:
+ addstr(ypos+2, 62, "Response:")
+ scrape_msg_size += self.wrap_and_draw_result(top, ypos+2, 72, t['lastScrapeResult'])
+
+ ypos += max(announce_msg_size, scrape_msg_size)
+
+ addstr(ypos+3, 4, "Next announce: %s" % timestamp(t['nextAnnounceTime']))
+ addstr(ypos+3, 57, " Next scrape: %s" % timestamp(t['nextScrapeTime']))
+
+ ypos += 5
+
+ def wrap_and_draw_result(self, top, ypos, xpos, result):
+ result = wrap(result, 30)
+ i = 0
+ for i, line in enumerate(result):
+ if ypos+i > top and ypos+i < self.height - 2:
+ self.pad.addstr(ypos+i, xpos, line, curses.A_UNDERLINE)
+ return i
+
+
+ def draw_pieces_map(self, ypos):
+ pieces = self.torrent_details['pieces']
+ piece_count = self.torrent_details['pieceCount']
+ margin = len(str(piece_count)) + 2
+
+ map_width = int(str(self.width-margin-1)[0:-1] + '0')
+ for x in range(10, map_width, 10):
+ self.pad.addstr(ypos, x+margin-1, str(x), curses.A_BOLD)
+
+ start = self.scrollpos_detaillist * map_width
+ end = min(start + (self.height - ypos - 3) * map_width, piece_count)
+ if end <= start: return
+ block = ord(pieces[start >> 3]) << (start & 7)
+
+ format = "%%%dd" % (margin - 2)
+ for counter in xrange(start, end):
+ if counter % map_width == 0:
+ ypos += 1 ; xpos = margin
+ self.pad.addstr(ypos, 1, format % counter, curses.A_BOLD)
+ else:
+ xpos += 1
+
+ if counter & 7 == 0:
+ block = ord(pieces[counter >> 3])
+ piece = block & 0x80
+ if piece: self.pad.addch(ypos, xpos, ' ', curses.A_REVERSE)
+ else: self.pad.addch(ypos, xpos, '_')
+ block <<= 1
+
+ missing_pieces = piece_count - counter - 1
+ if missing_pieces:
+ line = "%d further piece%s" % (missing_pieces, ('','s')[missing_pieces>1])
+ xpos = (self.width - len(line)) / 2
+ self.pad.addstr(self.height-3, xpos, line, curses.A_REVERSE)
+
+ def draw_details_list(self, ypos, info):
+ key_width = max(map(lambda x: len(x[0]), info))
+ for i in info:
+ self.pad.addstr(ypos, 1, i[0].rjust(key_width).encode('utf-8')) # key
+ # value part may be wrapped if it gets too long
+ for v in i[1:]:
+ y, x = self.pad.getyx()
+ if x + len(v) >= self.width:
+ ypos += 1
+ self.pad.move(ypos, key_width+1)
+ self.pad.addstr(v.encode('utf-8'))
+ ypos += 1
+ return ypos
+
+ def next_details(self):
+ if self.details_category_focus >= 4:
+ self.details_category_focus = 0
+ else:
+ self.details_category_focus += 1
+ self.focus_detaillist = -1
+ self.scrollpos_detaillist = 0
+ self.pad.erase()
+
+ def prev_details(self):
+ if self.details_category_focus <= 0:
+ self.details_category_focus = 4
+ else:
+ self.details_category_focus -= 1
+ 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 move_page_up(self, focus, scrollpos, step_size, elements_per_page):
+ for x in range(elements_per_page - 1):
+ focus, scrollpos = self.move_up(focus, scrollpos, step_size)
+ if focus < 0: focus = 0
+ return focus, scrollpos
+
+ def move_page_down(self, focus, scrollpos, step_size, elements_per_page, list_height):
+ if focus < 0: focus = 0
+ for x in range(elements_per_page - 1):
+ focus, scrollpos = self.move_down(focus, scrollpos, step_size, elements_per_page, list_height)
+ 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):
+ self.screen.insstr(self.height-1, 0, ' '.center(self.width), curses.A_REVERSE)
+ 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.insstr((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'],
+ curses.A_REVERSE)
+ else:
+ self.screen.addstr((self.height-1), 0, "Torrent%s:" % ('s','')[len(self.torrents) == 1],
+ curses.A_REVERSE)
+ self.screen.addstr("%d (" % len(self.torrents), curses.A_REVERSE)
+
+ downloading = len(filter(lambda x: x['status']==Transmission.STATUS_DOWNLOAD, self.torrents))
+ seeding = len(filter(lambda x: x['status']==Transmission.STATUS_SEED, self.torrents))
+ paused = self.stats['pausedTorrentCount']
+
+ self.screen.addstr("Downloading:", curses.A_REVERSE)
+ self.screen.addstr("%d " % downloading, curses.A_REVERSE)
+ self.screen.addstr("Seeding:", curses.A_REVERSE)
+ self.screen.addstr("%d " % seeding, curses.A_REVERSE)
+ self.screen.addstr("Paused:", curses.A_REVERSE)
+ self.screen.addstr("%d) " % paused, curses.A_REVERSE)
+
+ if self.filter_list:
+ self.screen.addstr("Filter:", curses.A_REVERSE)
+ self.screen.addstr("%s%s" % (('','not ')[self.filter_inverse], self.filter_list),
+ curses.color_pair(self.colors.get_id('filter_status'))
+ + curses.A_REVERSE)
+
+ # show last sort order (if terminal size permits it)
+ curpos_y, curpos_x = self.screen.getyx()
+ if self.sort_orders and self.width - curpos_x > 20:
+ self.screen.addstr(" Sort by:", curses.A_REVERSE)
+ name = [name[1] for name in self.sort_options if name[0] == self.sort_orders[-1]['name']][0]
+ name = name.replace('_', '').lower()
+ curses_tags = curses.color_pair(self.colors.get_id('filter_status')) + curses.A_REVERSE
+ if self.sort_orders[-1]['reverse']:
+ self.screen.addch(curses.ACS_DARROW, curses_tags)
+ else:
+ self.screen.addch(curses.ACS_UARROW, curses_tags)
+ try: # 'name' may be too long
+ self.screen.addstr(name, curses_tags)
+ except curses.error:
+ pass
+
+ def draw_global_rates(self):
+ 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']:
+ self.screen.move(self.height-1, self.width-rates_width - limits_width - len('Turtle mode '))
+ self.screen.addstr('Turtle mode', curses.A_REVERSE + curses.A_BOLD)
+ self.screen.addch(' ', curses.A_REVERSE)
+
+ self.screen.move(self.height - 1, self.width - rates_width - limits_width)
+ self.screen.addch(curses.ACS_DARROW, curses.A_REVERSE)
+ self.screen.addstr(scale_bytes(self.stats['downloadSpeed']).rjust(self.rateDownload_width),
+ curses.color_pair(self.colors.get_id('download_rate'))
+ + curses.A_REVERSE + curses.A_BOLD)
+ self.screen.addstr(limits['dn_limit'], curses.A_REVERSE)
+ self.screen.addch(' ', curses.A_REVERSE)
+ self.screen.addch(curses.ACS_UARROW, curses.A_REVERSE)
+ self.screen.insstr(limits['up_limit'], curses.A_REVERSE)
+ self.screen.insstr(scale_bytes(self.stats['uploadSpeed']).rjust(self.rateUpload_width),
+ curses.color_pair(self.colors.get_id('upload_rate'))
+ + curses.A_REVERSE + curses.A_BOLD)
+
+
+
+ def draw_title_bar(self):
+ self.screen.insstr(0, 0, ' '.center(self.width), curses.A_REVERSE)
+ self.draw_connection_status()
+ self.draw_quick_help()
+ def draw_connection_status(self):
+ status = "Transmission @ %s:%s" % (server.host, server.port)
+ if cmd_args.DEBUG:
+ status = "%d x %d " % (self.width, self.height) + status
+ self.screen.addstr(0, 0, status.encode('utf-8'), curses.A_REVERSE)
+
+ def draw_quick_help(self):
+ help = [('?','Show Keybindings')]
+
+ if self.selected_torrent == -1:
+ if self.focus >= 0:
+ help = [('enter','View Details'), ('p','Pause/Unpause'), ('r','Remove'), ('v','Verify')]
+ else:
+ help = [('/','Search'), ('f','Filter'), ('s','Sort')] + help + [('o','Options'), ('q','Quit')]
+ else:
+ help = [('Move with','cursor keys'), ('q','Back to List')]
+ if self.details_category_focus == 1 and self.focus_detaillist > -1:
+ help = [('space','(De)Select File'),
+ ('left/right','De-/Increase Priority'),
+ ('escape','Unfocus/-select')] + help
+ elif self.details_category_focus == 2:
+ help = [('F1/?','Explain flags')] + help
+ elif self.details_category_focus == 3:
+ help = [('a','Add Tracker'),('r','Remove Tracker')] + help
+
+ line = ' | '.join(map(lambda x: "%s %s" % (x[0], x[1]), help))
+ line = line[0:self.width]
+ self.screen.insstr(0, self.width-len(line), line, curses.A_REVERSE)
+
+
+ def list_key_bindings(self):
+ title = 'Help Menu'
+ message = " F1/? Show this help\n" + \
+ " u/d Adjust maximum global upload/download rate\n" + \
+ " U/D Adjust maximum upload/download rate for focused torrent\n" + \
+ " L Set seed ratio limit for focused torrent\n" + \
+ " +/- Adjust bandwidth priority for focused torrent\n" + \
+ " p Pause/Unpause torrent\n" + \
+ " P Pause/Unpause all torrents\n" + \
+ " v/y Verify torrent\n" + \
+ " m Move torrent\n" + \
+ " n Reannounce torrent\n" + \
+ " a Add torrent\n" + \
+ " Del/r Remove torrent and keep content\n" + \
+ " Shift+Del/R Remove torrent and delete content\n"
+ # Torrent list
+ if self.selected_torrent == -1:
+ message += " / Search in torrent list\n" + \
+ " f Filter torrent list\n" + \
+ " s Sort torrent list\n" \
+ " Enter/Right View torrent's details\n" + \
+ " o Configuration options\n" + \
+ " t Toggle turtle mode\n" + \
+ " C Toggle compact list mode\n" + \
+ " Esc Unfocus\n" + \
+ " q Quit"
+ else:
+ # Peer list
+ 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:
+ # Viewing torrent details
+ message += " o Jump to overview\n" + \
+ " f Jump to file list\n" + \
+ " e Jump to peer list\n" + \
+ " t Jump to tracker information\n" + \
+ " Tab/Right Jump to next view\n" + \
+ " Shift+Tab/Left Jump to previous view\n"
+ if self.details_category_focus == 1: # files
+ if self.focus_detaillist > -1:
+ message += " Left/Right Decrease/Increase file priority\n"
+ message += " Up/Down Select file\n" + \
+ " Space Select/Deselect focused file\n" + \
+ " a Select/Deselect all files\n" + \
+ " Esc Unfocus+Unselect or Back to torrent list\n" + \
+ " q/Backspace Back to torrent list"
+ else:
+ message += "q/Backspace/Esc Back to torrent list"
+
+ width = max(map(lambda x: len(x), message.split("\n"))) + 4
+ width = min(self.width, width)
+ height = min(self.height, message.count("\n")+3)
+ win = self.window(height, width, message=message, title=title)
+ while True:
+ if win.getch() >= 0: return
+
+
+ def window(self, height, width, message='', title=''):
+ height = min(self.height, height)
+ width = min(self.width, width)
+ ypos = int( (self.height - height) / 2 )
+ xpos = int( (self.width - width) / 2 )
+ win = curses.newwin(height, width, ypos, xpos)
+ win.box()
+ win.bkgd(' ', curses.A_REVERSE + curses.A_BOLD)
+
+ 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)
+
+ ypos = 1
+ for line in message.split("\n"):
+ if len(line) > width:
+ line = line[0:width-7] + '...'
+ win.addstr(ypos, 2, line.encode('utf-8'))
+ ypos += 1
+ return win
+
+
+ def dialog_ok(self, message):
+ height = 3 + message.count("\n")
+ width = max(max(map(lambda x: len(x), message.split("\n"))), 40) + 4
+ win = self.window(height, width, message=message)
+ while True:
+ if win.getch() >= 0: return
+
+ def dialog_yesno(self, message, important=False):
+ height = 5 + message.count("\n")
+ width = max(len(message), 8) + 4
+ win = self.window(height, width, message=message)
+ win.keypad(True)
+
+ if important:
+ win.bkgd(' ', curses.color_pair(self.colors.get_id('dialog_important'))
+ + curses.A_REVERSE)
+
+ focus_tags = curses.color_pair(self.colors.get_id('button_focused'))
+ unfocus_tags = 0
+
+ input = False
+ while True:
+ win.move(height-2, (width/2)-4)
+ if input:
+ win.addstr('Y', focus_tags + curses.A_UNDERLINE)
+ win.addstr('es', focus_tags)
+ win.addstr(' ')
+ win.addstr('N', curses.A_UNDERLINE)
+ win.addstr('o')
+ else:
+ win.addstr('Y', curses.A_UNDERLINE)
+ win.addstr('es')
+ win.addstr(' ')
+ win.addstr('N', focus_tags + curses.A_UNDERLINE)
+ win.addstr('o', focus_tags)
+
+ c = win.getch()
+ if c == ord('y'):
+ return True
+ elif c == ord('n'):
+ return False
+ elif c == ord("\t"):
+ input = not input
+ elif c == curses.KEY_LEFT or c == ord('h'):
+ input = True
+ elif c == curses.KEY_RIGHT or c == ord('l'):
+ input = False
+ elif c == ord("\n") or c == ord(' '):
+ return input
+ elif c == 27 or c == curses.KEY_BREAK:
+ return -1
+
+ def dialog_input_text(self, message, input='', on_change=None, on_enter=None):
+ width = self.width - 4
+ textwidth = self.width - 8
+ height = message.count("\n") + 4
+
+ win = self.window(height, width, message=message)
+ win.keypad(True)
+ show_cursor()
+ index = len(input)
+ 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 = input[textwidth*page:textwidth*(page + 1)]
+ displayindex = index - textwidth*page
+
+ color = (curses.color_pair(self.colors.get_id('dialog_important')) if self.highlight_dialog
+ else curses.color_pair(self.colors.get_id('dialog')))
+ win.addstr(height - 2, 2, displaytext.ljust(textwidth), color)
+ win.move(height - 2, displayindex + 2)
+ c = win.getch()
+ if c == 27 or c == curses.KEY_BREAK:
+ hide_cursor()
+ return ''
+ elif index < len(input) and ( c == curses.KEY_RIGHT or c == curses.ascii.ctrl(ord('f')) ):
+ index += 1
+ elif index > 0 and ( c == curses.KEY_LEFT or c == curses.ascii.ctrl(ord('b')) ):
+ index -= 1
+ elif (c == curses.KEY_BACKSPACE or c == 127) and index > 0:
+ input = input[:index - 1] + (index < len(input) and input[index:] or '')
+ index -= 1
+ if on_change: on_change(input)
+ elif index < len(input) and ( c == curses.KEY_DC or c == curses.ascii.ctrl(ord('d')) ):
+ input = input[:index] + input[index + 1:]
+ if on_change: on_change(input)
+ elif index < len(input) and c == curses.ascii.ctrl(ord('k')):
+ input = input[:index]
+ if on_change: on_change(input)
+ elif c == curses.KEY_HOME or c == curses.ascii.ctrl(ord('a')):
+ index = 0
+ elif c == curses.KEY_END or c == curses.ascii.ctrl(ord('e')):
+ index = len(input)
+ elif c == ord('\n'):
+ if on_enter:
+ on_enter(input)
+ else:
+ hide_cursor()
+ return input
+ elif c >= 32 and c < 127:
+ input = input[:index] + chr(c) + (index < len(input) and input[index:] or '')
+ index += 1
+ if on_change: on_change(input)
+ if on_change: win.redrawwin()
+
+ def dialog_search_torrentlist(self, c):
+ self.dialog_input_text('Search torrent by title:',
+ on_change=self.draw_torrent_list,
+ on_enter=self.increment_search)
+
+ def increment_search(self, input):
+ self.search_focus += 1
+ self.draw_torrent_list(input)
+
+
+ def dialog_input_number(self, message, current_value,
+ cursorkeys=True, floating_point=False, allow_empty=False,
+ allow_zero=True, allow_negative_one=True):
+ if not allow_zero:
+ allow_negative_one = False
+
+ width = max(max(map(lambda x: len(x), message.split("\n"))), 40) + 4
+ width = min(self.width, width)
+ height = message.count("\n") + (4,6)[cursorkeys]
+
+ show_cursor()
+ win = self.window(height, width, message=message)
+ win.keypad(True)
+ input = str(current_value)
+ if cursorkeys:
+ 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:
+ win.addstr(height-2, 2, input.ljust(width-4), curses.color_pair(self.colors.get_id('dialog')))
+ win.move(height - 2, len(input) + 2)
+ c = win.getch()
+ if c == 27 or c == ord('q') or c == curses.KEY_BREAK:
+ hide_cursor()
+ return -128
+ elif c == ord("\n"):
+ try:
+ if allow_empty and len(input) <= 0:
+ hide_cursor()
+ return -2
+ elif floating_point:
+ hide_cursor()
+ return float(input)
+ else:
+ hide_cursor()
+ return int(input)
+ except ValueError:
+ hide_cursor()
+ return -1
+
+ elif c == curses.KEY_BACKSPACE or c == curses.KEY_DC or c == 127 or c == 8:
+ input = input[:-1]
+ elif len(input) >= width-5:
+ curses.beep()
+ elif c >= ord('1') and c <= ord('9'):
+ input += chr(c)
+ elif allow_zero and c == ord('0') and input != '-' and not input.startswith('0'):
+ input += chr(c)
+ elif allow_negative_one and c == ord('-') and len(input) == 0:
+ input += chr(c)
+ elif floating_point and c == ord('.') and not '.' in input:
+ input += chr(c)
+
+ elif cursorkeys and c != -1:
+ try:
+ if input == '': input = 0
+ if floating_point: number = float(input)
+ else: number = int(input)
+ if c == curses.KEY_LEFT or c == ord('h'): number -= smallstep
+ elif c == curses.KEY_RIGHT or c == ord('l'): number += smallstep
+ elif c == curses.KEY_DOWN or c == ord('j'): number -= bigstep
+ elif c == curses.KEY_UP or c == ord('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: # input like -0.6 isn't useful
+ number = -1
+ input = str(number)
+ except ValueError:
+ pass
+
+ def dialog_menu(self, title, options, focus=1):
+ height = len(options) + 2
+ width = max(max(map(lambda x: len(x[1])+3, options)), len(title)+3)
+ win = self.window(height, width)
+
+ win.addstr(0,1, title)
+ win.keypad(True)
+
+ old_focus = focus
+ while True:
+ keymap = self.dialog_list_menu_options(win, width, options, focus)
+ c = win.getch()
+
+ if c > 96 and c < 123 and chr(c) in keymap:
+ return options[keymap[chr(c)]][0]
+ elif c == 27 or c == ord('q'):
+ return -128
+ elif c == ord("\n"):
+ return options[focus-1][0]
+ elif c == curses.KEY_DOWN or c == ord('j') or c == curses.ascii.ctrl(ord('n')):
+ focus += 1
+ if focus > len(options): focus = 1
+ elif c == curses.KEY_UP or c == ord('k') or c == curses.ascii.ctrl(ord('p')):
+ focus -= 1
+ if focus < 1: focus = len(options)
+ elif c == curses.KEY_HOME or c == ord('g'):
+ focus = 1
+ elif c == curses.KEY_END or c == ord('G'):
+ focus = len(options)
+
+ def dialog_list_menu_options(self, win, width, options, focus):
+ keys = dict()
+ i = 1
+ for option in options:
+ title = option[1].split('_')
+ if i == focus: tag = curses.color_pair(self.colors.get_id('dialog'))
+ else: tag = 0
+ win.addstr(i,2, title[0], tag)
+ win.addstr(title[1][0], tag + curses.A_UNDERLINE)
+ win.addstr(title[1][1:], tag)
+ win.addstr(''.ljust(width - len(option[1]) - 3), tag)
+
+ keys[title[1][0].lower()] = i-1
+ i+=1
+ return keys
+
+ def draw_options_dialog(self):
+ enc_options = [('required','_required'), ('preferred','_preferred'), ('tolerated','_tolerated')]
+ seed_ratio = self.stats['seedRatioLimit']
+ 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 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(('_Seed Ratio Limit', "%s" % ('unlimited',self.stats['seedRatioLimit'])[self.stats['seedRatioLimited']]))
+ 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(('Title is Progress _Bar', ('no','yes')[self.torrentname_is_progressbar]))
+
+ max_len = max([sum([len(re.sub('_', '', x)) for x in y[0]]) for y in options])
+ win = self.window(len(options)+2, max_len+15, '', "Global Options")
+
+ line_num = 1
+ for option in options:
+ parts = re.split('_', option[0])
+ parts_len = sum([len(x) for x in parts])
+
+ win.addstr(line_num, 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
+
+ c = win.getch()
+ if c == 27 or c == ord('q') or c == ord("\n"):
+ return
+ elif c == ord('p'):
+ port = self.dialog_input_number("Port for incoming connections",
+ self.stats['peer-port'],
+ cursorkeys=False)
+ if port >= 0 and port <= 65535:
+ 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 c == ord('n'):
+ server.set_option('port-forwarding-enabled',
+ (1,0)[self.stats['port-forwarding-enabled']])
+ elif c == ord('x'):
+ server.set_option('pex-enabled', (1,0)[self.stats['pex-enabled']])
+ elif c == ord('d'):
+ server.set_option('dht-enabled', (1,0)[self.stats['dht-enabled']])
+ elif c == ord('l'):
+ server.set_option('lpd-enabled', (1,0)[self.stats['lpd-enabled']])
+ # uTP support was added in Transmission v2.3
+ elif c == ord('m') and server.get_rpc_version() >= 13:
+ server.set_option('utp-enabled', (1,0)[self.stats['utp-enabled']])
+ elif c == ord('g'):
+ limit = self.dialog_input_number("Maximum number of connected peers",
+ self.stats['peer-limit-global'],
+ allow_negative_one=False)
+ if limit >= 0:
+ server.set_option('peer-limit-global', limit)
+ elif c == ord('t'):
+ limit = self.dialog_input_number("Maximum number of connected peers per torrent",
+ self.stats['peer-limit-per-torrent'],
+ allow_negative_one=False)
+ if limit >= 0:
+ server.set_option('peer-limit-per-torrent', limit)
+ elif c == ord('s'):
+ limit = self.dialog_input_number('Stop seeding with upload/download ratio',
+ (-1,self.stats['seedRatioLimit'])[self.stats['seedRatioLimited']],
+ floating_point=True)
+ if limit >= 0:
+ server.set_option('seedRatioLimit', limit)
+ server.set_option('seedRatioLimited', True)
+ elif limit < 0 and limit != -128:
+ server.set_option('seedRatioLimited', False)
+ elif c == ord('c'):
+ choice = self.dialog_menu('Encryption', enc_options,
+ map(lambda x: x[0]==self.stats['encryption'], enc_options).index(True)+1)
+ if choice != -128:
+ server.set_option('encryption', choice)
+ elif c == ord('u'):
+ limit = self.dialog_input_number('Upload limit for Turtle Mode in kilobytes per second',
+ self.stats['alt-speed-up'],
+ allow_negative_one=False)
+ server.set_option('alt-speed-up', limit)
+ elif c == ord('r'):
+ limit = self.dialog_input_number('Download limit for Turtle Mode in kilobytes per second',
+ self.stats['alt-speed-down'],
+ allow_negative_one=False)
+ server.set_option('alt-speed-down', limit)
+ elif c == ord('b'):
+ self.torrentname_is_progressbar = not self.torrentname_is_progressbar
+
+ self.draw_torrent_list()
+
+# End of class Interface
+
+
+
+def percent(full, part):
+ try: percent = 100/(float(full) / float(part))
+ except ZeroDivisionError: percent = 0.0
+ return percent
+
+
+def scale_time(seconds, type='short'):
+ 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')[type=='long']
+
+ elif seconds < minute_in_sec:
+ if type == 'long':
+ if seconds < 5:
+ return 'now'
+ else:
+ return "%d second%s" % (seconds, ('', 's')[seconds>1])
+ else:
+ return "%ds" % seconds
+
+ elif seconds < hour_in_sec:
+ minutes = round(seconds / minute_in_sec, 0)
+ if type == 'long':
+ return "%d minute%s" % (minutes, ('', 's')[minutes>1])
+ else:
+ return "%dm" % minutes
+
+ elif seconds < day_in_sec:
+ hours = round(seconds / hour_in_sec, 0)
+ if type == 'long':
+ return "%d hour%s" % (hours, ('', 's')[hours>1])
+ else:
+ return "%dh" % hours
+
+ elif seconds < month_in_sec:
+ days = round(seconds / day_in_sec, 0)
+ if type == 'long':
+ return "%d day%s" % (days, ('', 's')[days>1])
+ else:
+ return "%dd" % days
+
+ elif seconds < year_in_sec:
+ months = round(seconds / month_in_sec, 0)
+ if type == 'long':
+ return "%d month%s" % (months, ('', 's')[months>1])
+ else:
+ return "%dM" % months
+
+ else:
+ years = round(seconds / year_in_sec, 0)
+ if type == 'long':
+ return "%d year%s" % (years, ('', 's')[years>1])
+ else:
+ return "%dy" % years
+
+
+def timestamp(timestamp):
+ if timestamp < 1:
+ return 'never'
+
+ date_format = "%x %X"
+ absolute = time.strftime(date_format, time.localtime(timestamp))
+ if timestamp > time.time():
+ relative = 'in ' + scale_time(int(timestamp - time.time()), 'long')
+ else:
+ relative = scale_time(int(time.time() - timestamp), 'long') + ' ago'
+
+ if relative.startswith('now') or relative.endswith('now'):
+ relative = 'now'
+ return "%s (%s)" % (absolute, relative)
+
+
+def scale_bytes(bytes, type='short'):
+ if bytes >= 1073741824:
+ scaled_bytes = round((bytes / 1073741824.0), 2)
+ unit = 'G'
+ elif bytes >= 1048576:
+ scaled_bytes = round((bytes / 1048576.0), 1)
+ if scaled_bytes >= 100:
+ scaled_bytes = int(scaled_bytes)
+ unit = 'M'
+ elif bytes >= 1024:
+ scaled_bytes = int(bytes / 1024)
+ unit = 'K'
+ else:
+ scaled_bytes = round((bytes / 1024.0), 1)
+ unit = 'K'
+
+
+ # handle 0 bytes special
+ if bytes == 0 and type == 'long':
+ return 'nothing'
+
+ # convert to integer if .0
+ if int(scaled_bytes) == float(scaled_bytes):
+ scaled_bytes = str(int(scaled_bytes))
+ else:
+ scaled_bytes = str(scaled_bytes).rstrip('0')
+
+ if type == 'long':
+ return num2str(bytes) + ' [' + scaled_bytes + unit + ']'
+ else:
+ return scaled_bytes + 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(str):
+ str = re.sub(r'</h\d+>', "\n", str)
+ str = re.sub(r'</p>', ' ', str)
+ str = re.sub(r'<[^>]*?>', '', str)
+ return str
+
+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 wrap_multiline(text, width, initial_indent='', subsequent_indent=None):
+ 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 <max_width> display columns wide,
+ padded with <padchar> 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 <text> would occupy. """
+ columns = 0
+ for character in text:
+ columns += 2 if unicodedata.east_asian_width(character) in ('W', 'F') else 1
+ return columns
+
+
+def num2str(num, format='%s'):
+ if int(num) == -1:
+ return '?'
+ elif int(num) == -2:
+ return 'oo'
+ else:
+ if num > 999:
+ return (re.sub(r'(\d{3})', '\g<1>,', str(num)[::-1])[::-1]).lstrip(',')
+ else:
+ return format % num
+
+
+def debug(data):
+ if cmd_args.DEBUG:
+ file = open("debug.log", 'a')
+ if type(data) == type(str()):
+ file.write(data.encode('utf-8'))
+ else:
+ import pprint
+ pp = pprint.PrettyPrinter(indent=4)
+ file.write("\n====================\n" + pp.pformat(data) + "\n====================\n\n")
+ file.close
+
+def quit(msg='', exitcode=0):
+ try:
+ curses.endwin()
+ except curses.error:
+ pass
+
+ # if this is a graceful exit and config file is present
+ if not msg and not exitcode:
+ save_config(cmd_args.configfile)
+ else:
+ print >> sys.stderr, msg,
+ os._exit(exitcode)
+
+
+def explode_connection_string(connection):
+ host, port, path = \
+ config.get('Connection', 'host'), \
+ config.getint('Connection', 'port'), \
+ config.get('Connection', 'path')
+ username, password = \
+ config.get('Connection', 'username'), \
+ config.get('Connection', 'password')
+ try:
+ if connection.count('@') == 1:
+ auth, connection = connection.split('@')
+ if auth.count(':') == 1:
+ username, password = auth.split(':')
+ if connection.count(':') == 1:
+ host, port = connection.split(':')
+ if port.count('/') >= 1:
+ port, path = port.split('/', 1)
+ port = int(port)
+ else:
+ host = connection
+ except ValueError:
+ quit("Wrong connection pattern: %s\n" % connection)
+ return host, port, path, username, password
+
+def create_url(host, port, path):
+ url = '%s:%d/%s' % (host, port, path)
+ url = url.replace('//', '/') # double-/ doesn't work for some reason
+ if config.getboolean('Connection', 'ssl'):
+ return 'https://%s' % url
+ else:
+ return 'http://%s' % url
+
+def read_netrc(file=os.environ['HOME'] + '/.netrc', hostname=None):
+ try:
+ login = password = ''
+ try:
+ login, account, password = netrc.netrc(file).authenticators(hostname)
+ except TypeError:
+ pass
+ try:
+ netrc.netrc(file).hosts[hostname]
+ except KeyError:
+ if hostname != 'localhost':
+ print "Unknown machine in %s: %s" % (file, hostname)
+ if login and password:
+ print "Using default login: %s" % login
+ else:
+ exit(CONFIGFILE_ERROR)
+ except netrc.NetrcParseError, e:
+ quit("Error in %s at line %s: %s\n" % (e.filename, e.lineno, e.msg))
+ except IOError, msg:
+ quit("Cannot read %s: %s\n" % (file, msg))
+ return login, password
+
+
+# create initial config file
+def create_config(option, opt_str, value, parser):
+ configfile = parser.values.configfile
+ config.read(configfile)
+ if parser.values.connection:
+ host, port, path, username, password = explode_connection_string(parser.values.connection)
+ config.set('Connection', 'host', host)
+ config.set('Connection', 'port', str(port))
+ config.set('Connection', 'path', path)
+ config.set('Connection', 'username', username)
+ config.set('Connection', 'password', password)
+
+ # create directory if necessary
+ dir = os.path.dirname(configfile)
+ if dir != '' and not os.path.isdir(dir):
+ try:
+ os.makedirs(dir)
+ except OSError, msg:
+ print msg
+ exit(CONFIGFILE_ERROR)
+
+ # write file
+ if not save_config(configfile, force=True):
+ exit(CONFIGFILE_ERROR)
+ print "Wrote config file: %s" % configfile
+ exit(0)
+
+def save_config(filepath, force=False):
+ if force or os.path.isfile(filepath):
+ try:
+ config.write(open(filepath, 'w'))
+ os.chmod(filepath, 0600) # config may contain password
+ return 1
+ except IOError, msg:
+ print >> sys.stderr, "Cannot write config file %s:\n%s" % (filepath, msg)
+ return 0
+ return -1
+
+def parse_sort_str(sort_str):
+ sort_orders = []
+ for i in sort_str.split(','):
+ x = i.split(':')
+ if len(x) > 1:
+ sort_orders.append( { 'name':x[1], 'reverse':True } )
+ else:
+ sort_orders.append( { 'name':x[0], 'reverse':False } )
+ return sort_orders
+
+
+# command line parameters
+default_config_path = os.environ['HOME'] + '/.config/transmission-remote-cli/settings.cfg'
+parser = OptionParser(usage="%prog [options] [-- transmission-remote options]",
+ version="%%prog %s" % VERSION,
+ description="%%prog %s" % VERSION)
+parser.add_option("-c", "--connect", action="store", dest="connection", default="",
+ help="Point to the server using pattern [username:password@]host[:port]/[path]")
+parser.add_option("-s", "--ssl", action="store_true", dest="ssl", default=False,
+ help="Connect to transmission using SSL.")
+parser.add_option("-f", "--config", action="store", dest="configfile", default=default_config_path,
+ help="Path to configuration file.")
+parser.add_option("--create-config", action="callback", callback=create_config,
+ help="Create configuration file CONFIGFILE with default values.")
+parser.add_option("-n", "--netrc", action="store_true", dest="use_netrc", default=False,
+ help="Get authentication info from your ~/.netrc file.")
+parser.add_option("--debug", action="store_true", dest="DEBUG", default=False,
+ help="Everything passed to the debug() function will be added to the file debug.log.")
+(cmd_args, transmissionremote_args) = parser.parse_args()
+
+
+# read config from config file
+config.read(cmd_args.configfile)
+
+# command line connection data can override config file
+if cmd_args.connection:
+ host, port, path, username, password = explode_connection_string(cmd_args.connection)
+ config.set('Connection', 'host', host)
+ config.set('Connection', 'port', str(port))
+ config.set('Connection', 'path', path)
+ config.set('Connection', 'username', username)
+ config.set('Connection', 'password', password)
+if cmd_args.use_netrc:
+ username, password = read_netrc(hostname=config.get('Connection','host'))
+ config.set('Connection', 'username', username)
+ config.set('Connection', 'password', password)
+if cmd_args.ssl:
+ config.set('Connection', 'ssl', 'True')
+
+
+
+# forward arguments after '--' to transmission-remote
+if transmissionremote_args:
+ cmd = ['transmission-remote', '%s:%s' %
+ (config.get('Connection', 'host'), config.get('Connection', 'port'))]
+
+ # one argument and it doesn't start with '-' --> treat it like it's a torrent link/url
+ if len(transmissionremote_args) == 1 and not transmissionremote_args[0].startswith('-'):
+ cmd.extend(['-a', transmissionremote_args[0]])
+
+ if config.get('Connection', 'username') and config.get('Connection', 'password'):
+ cmd_print = cmd
+ cmd_print.extend(['--auth', '%s:PASSWORD' % config.get('Connection', 'username')])
+ print "EXECUTING:\n%s\nRESPONSE:" % ' '.join(cmd_print)
+ cmd.extend(['--auth', '%s:%s' % (config.get('Connection', 'username'), config.get('Connection', 'password'))])
+ cmd.extend(transmissionremote_args)
+ else:
+ print "EXECUTING:\n%s\nRESPONSE:" % ' '.join(cmd)
+
+ try:
+ retcode = call(cmd)
+ except OSError, msg:
+ quit("Could not execute the above command: %s\n" % msg, 128)
+ quit('', retcode)
+
+
+server = Transmission(config.get('Connection', 'host'),
+ config.getint('Connection', 'port'),
+ config.get('Connection', 'path'),
+ config.get('Connection', 'username'),
+ config.get('Connection', 'password'))
+Interface()
+