├── .flake8 ├── .github └── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── feature_request.md │ └── question.md ├── .gitignore ├── .hound.yml ├── .idea ├── codeStyles │ ├── Project.xml │ └── codeStyleConfig.xml └── runConfigurations │ └── pytest.xml ├── .travis.yml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── pythonbits ├── __init__.py ├── __main__.py ├── api_utils.py ├── bb.py ├── config.py ├── ffmpeg.py ├── imagehosting.py ├── imdb.py ├── imgur.py ├── logging.py ├── musicbrainz.py ├── ptpimg.py ├── scene.py ├── submission.py ├── templating.py ├── torrent.py ├── tracker.py └── tvdb.py ├── setup.cfg ├── setup.py ├── tests ├── __init__.py ├── pythonbits.cfg ├── test_config.py └── test_dummy.py └── tox.ini /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | ignore = BLK100,E24,E226,W504,E121,E126,E123,W503,E704 3 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## **Description** 11 | A clear and concise description of what the bug is, including steps to reproduce. 12 | 13 | ## **Additional context** 14 | Add any other context about the problem here. 15 | - `pythonbits` version: [e.g. 3.0, or a specific git commit ID] 16 | 17 | ## **Full log** 18 | If applicable, add the pythonbits debug log here. 19 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/question.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Question 3 | about: Ask a question to clear up any ambiguities 4 | title: '' 5 | labels: question 6 | assignees: '' 7 | 8 | --- 9 | 10 | 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # Distribution / packaging 7 | .Python 8 | build/ 9 | develop-eggs/ 10 | dist/ 11 | downloads/ 12 | eggs/ 13 | .eggs/ 14 | lib/ 15 | lib64/ 16 | parts/ 17 | sdist/ 18 | var/ 19 | wheels/ 20 | *.egg-info/ 21 | .installed.cfg 22 | *.egg 23 | MANIFEST 24 | .tox 25 | .python-version 26 | venv 27 | -------------------------------------------------------------------------------- /.hound.yml: -------------------------------------------------------------------------------- 1 | fail_on_violations: true 2 | flake8: 3 | enabled: true 4 | config_file: .flake8 5 | -------------------------------------------------------------------------------- /.idea/codeStyles/Project.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 10 | -------------------------------------------------------------------------------- /.idea/codeStyles/codeStyleConfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | -------------------------------------------------------------------------------- /.idea/runConfigurations/pytest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 19 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | os: linux 2 | dist: xenial 3 | language: python 4 | cache: 5 | pip: true 6 | python: 7 | - '3.5' 8 | install: 9 | - pip install tox-travis 10 | env: 11 | - TOX_SKIP_ENV=flake8 12 | script: 13 | - tox 14 | deploy: 15 | provider: pypi 16 | username: mueslo 17 | password: 18 | secure: mxidlBUmwJXWaUQi7oLY23ZlXcs85TWwfdkUWfd3OoeMdzGHmff6dN4xq9AX+etRdI5j1MVyjAt1He7TOja+vKUXKrnMw0n+zr1t0Z+VwupFbQrYYJeYqzw39SUaoYJ7TzrT4qAor1AiPluNt3TDW8TB0oNbAXslo+kT1vu2hyQKgAptP6XebXDiAfpGswQtVXkaRJc1mwRLtfOToBwZoCaMRB6INgjfhg0yQlXl95TU/El0v2IJi9+w2rMJfpXHQFA4CRlUgUUmD4+Iqqbo5Bv5EmRyCj5Awkr4MXL1xZR6NbiD41n9hQhMiZ4Vfz39i4fHsSoTRYmUEXhIAVZeL4CNqu3xhp9WrjpnurN0Fr0yOO6yNWG1Tz/HyT6oppjDHrnrEcqKkBQUqRFlJACVcKtlpEC0PR5oJ2DFcffmoAgCgh6mGD4BO3rLpd5d3hlGmg3eXcfmkYxJUjQesOaSlpaFYf8QzVpNpOwS3yxF22/c6VCb1pmh3uMirKtxOHZLU7NplO2f8nJEfo0jyqKr13YSIoK1uyRwMot0kQFsUI2D5klxVS1Xi2FAsT69OlROXeZAQgVZMO6RWWhQwJ1yiZUTA454bV1W4uQuaWaGkQZ5pigU6IB7HdbCEl9IJ/FvcNvrDJ78MoTMlk4+R+LdY5dcOP6qSTlbrCx0XsKqnQM= 19 | on: 20 | tags: true 21 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 3.1 2 | 3 | Major feature update 4 | 5 | new: 6 | - music & audiobook submissions 7 | - basic threading in API requests (up to ~5x speedup for season packs) 8 | - multi-episode submissions 9 | - config migration (API keys now embedded, imgur re-auth required)* 10 | - metacritic rating for movies 11 | - added directors to tags (thanks to znedw) 12 | 13 | *if you encounter issues, delete the entire `[Imgur]` section from your config. 14 | 15 | fixed: 16 | - bug in source selection 17 | - IMDb cast ordering (stars are now always first) 18 | - missing MPAA rating 19 | 20 | ## 3.0.3 21 | 22 | Minor feature update 23 | 24 | new: ptpimg support (thanks to znedw) 25 | fixed: episode naming 26 | 27 | ## 3.0.2 28 | 29 | Maintenance update (thanks to plotski, eeeeve, ...) 30 | 31 | breaking change: dropped Python 2 support 32 | 33 | new: 34 | - "AKA" for international titles 35 | 36 | fixed: 37 | - lotsa new codecs 38 | - 4K! 39 | - mediainfo changes 40 | - tvdb_api changes 41 | 42 | ## 3.0 43 | 44 | Complete rewrite 45 | 46 | new: 47 | - automated submission 48 | - data copying for submission 49 | - PROPER parsing 50 | - scene check 51 | - torrent black-holing 52 | - persistent config file 53 | - Python 3 support 54 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | Preamble 9 | 10 | The GNU General Public License is a free, copyleft license for 11 | software and other kinds of works. 12 | 13 | The licenses for most software and other practical works are designed 14 | to take away your freedom to share and change the works. By contrast, 15 | the GNU General Public License is intended to guarantee your freedom to 16 | share and change all versions of a program--to make sure it remains free 17 | software for all its users. We, the Free Software Foundation, use the 18 | GNU General Public License for most of our software; it applies also to 19 | any other work released this way by its authors. You can apply it to 20 | your programs, too. 21 | 22 | When we speak of free software, we are referring to freedom, not 23 | price. Our General Public Licenses are designed to make sure that you 24 | have the freedom to distribute copies of free software (and charge for 25 | them if you wish), that you receive source code or can get it if you 26 | want it, that you can change the software or use pieces of it in new 27 | free programs, and that you know you can do these things. 28 | 29 | To protect your rights, we need to prevent others from denying you 30 | these rights or asking you to surrender the rights. Therefore, you have 31 | certain responsibilities if you distribute copies of the software, or if 32 | you modify it: responsibilities to respect the freedom of others. 33 | 34 | For example, if you distribute copies of such a program, whether 35 | gratis or for a fee, you must pass on to the recipients the same 36 | freedoms that you received. You must make sure that they, too, receive 37 | or can get the source code. And you must show them these terms so they 38 | know their rights. 39 | 40 | Developers that use the GNU GPL protect your rights with two steps: 41 | (1) assert copyright on the software, and (2) offer you this License 42 | giving you legal permission to copy, distribute and/or modify it. 43 | 44 | For the developers' and authors' protection, the GPL clearly explains 45 | that there is no warranty for this free software. For both users' and 46 | authors' sake, the GPL requires that modified versions be marked as 47 | changed, so that their problems will not be attributed erroneously to 48 | authors of previous versions. 49 | 50 | Some devices are designed to deny users access to install or run 51 | modified versions of the software inside them, although the manufacturer 52 | can do so. This is fundamentally incompatible with the aim of 53 | protecting users' freedom to change the software. The systematic 54 | pattern of such abuse occurs in the area of products for individuals to 55 | use, which is precisely where it is most unacceptable. Therefore, we 56 | have designed this version of the GPL to prohibit the practice for those 57 | products. If such problems arise substantially in other domains, we 58 | stand ready to extend this provision to those domains in future versions 59 | of the GPL, as needed to protect the freedom of users. 60 | 61 | Finally, every program is threatened constantly by software patents. 62 | States should not allow patents to restrict development and use of 63 | software on general-purpose computers, but in those that do, we wish to 64 | avoid the special danger that patents applied to a free program could 65 | make it effectively proprietary. To prevent this, the GPL assures that 66 | patents cannot be used to render the program non-free. 67 | 68 | The precise terms and conditions for copying, distribution and 69 | modification follow. 70 | 71 | TERMS AND CONDITIONS 72 | 73 | 0. Definitions. 74 | 75 | "This License" refers to version 3 of the GNU General Public License. 76 | 77 | "Copyright" also means copyright-like laws that apply to other kinds of 78 | works, such as semiconductor masks. 79 | 80 | "The Program" refers to any copyrightable work licensed under this 81 | License. Each licensee is addressed as "you". "Licensees" and 82 | "recipients" may be individuals or organizations. 83 | 84 | To "modify" a work means to copy from or adapt all or part of the work 85 | in a fashion requiring copyright permission, other than the making of an 86 | exact copy. The resulting work is called a "modified version" of the 87 | earlier work or a work "based on" the earlier work. 88 | 89 | A "covered work" means either the unmodified Program or a work based 90 | on the Program. 91 | 92 | To "propagate" a work means to do anything with it that, without 93 | permission, would make you directly or secondarily liable for 94 | infringement under applicable copyright law, except executing it on a 95 | computer or modifying a private copy. Propagation includes copying, 96 | distribution (with or without modification), making available to the 97 | public, and in some countries other activities as well. 98 | 99 | To "convey" a work means any kind of propagation that enables other 100 | parties to make or receive copies. Mere interaction with a user through 101 | a computer network, with no transfer of a copy, is not conveying. 102 | 103 | An interactive user interface displays "Appropriate Legal Notices" 104 | to the extent that it includes a convenient and prominently visible 105 | feature that (1) displays an appropriate copyright notice, and (2) 106 | tells the user that there is no warranty for the work (except to the 107 | extent that warranties are provided), that licensees may convey the 108 | work under this License, and how to view a copy of this License. If 109 | the interface presents a list of user commands or options, such as a 110 | menu, a prominent item in the list meets this criterion. 111 | 112 | 1. Source Code. 113 | 114 | The "source code" for a work means the preferred form of the work 115 | for making modifications to it. "Object code" means any non-source 116 | form of a work. 117 | 118 | A "Standard Interface" means an interface that either is an official 119 | standard defined by a recognized standards body, or, in the case of 120 | interfaces specified for a particular programming language, one that 121 | is widely used among developers working in that language. 122 | 123 | The "System Libraries" of an executable work include anything, other 124 | than the work as a whole, that (a) is included in the normal form of 125 | packaging a Major Component, but which is not part of that Major 126 | Component, and (b) serves only to enable use of the work with that 127 | Major Component, or to implement a Standard Interface for which an 128 | implementation is available to the public in source code form. A 129 | "Major Component", in this context, means a major essential component 130 | (kernel, window system, and so on) of the specific operating system 131 | (if any) on which the executable work runs, or a compiler used to 132 | produce the work, or an object code interpreter used to run it. 133 | 134 | The "Corresponding Source" for a work in object code form means all 135 | the source code needed to generate, install, and (for an executable 136 | work) run the object code and to modify the work, including scripts to 137 | control those activities. However, it does not include the work's 138 | System Libraries, or general-purpose tools or generally available free 139 | programs which are used unmodified in performing those activities but 140 | which are not part of the work. For example, Corresponding Source 141 | includes interface definition files associated with source files for 142 | the work, and the source code for shared libraries and dynamically 143 | linked subprograms that the work is specifically designed to require, 144 | such as by intimate data communication or control flow between those 145 | subprograms and other parts of the work. 146 | 147 | The Corresponding Source need not include anything that users 148 | can regenerate automatically from other parts of the Corresponding 149 | Source. 150 | 151 | The Corresponding Source for a work in source code form is that 152 | same work. 153 | 154 | 2. Basic Permissions. 155 | 156 | All rights granted under this License are granted for the term of 157 | copyright on the Program, and are irrevocable provided the stated 158 | conditions are met. This License explicitly affirms your unlimited 159 | permission to run the unmodified Program. The output from running a 160 | covered work is covered by this License only if the output, given its 161 | content, constitutes a covered work. This License acknowledges your 162 | rights of fair use or other equivalent, as provided by copyright law. 163 | 164 | You may make, run and propagate covered works that you do not 165 | convey, without conditions so long as your license otherwise remains 166 | in force. You may convey covered works to others for the sole purpose 167 | of having them make modifications exclusively for you, or provide you 168 | with facilities for running those works, provided that you comply with 169 | the terms of this License in conveying all material for which you do 170 | not control copyright. Those thus making or running the covered works 171 | for you must do so exclusively on your behalf, under your direction 172 | and control, on terms that prohibit them from making any copies of 173 | your copyrighted material outside their relationship with you. 174 | 175 | Conveying under any other circumstances is permitted solely under 176 | the conditions stated below. Sublicensing is not allowed; section 10 177 | makes it unnecessary. 178 | 179 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 180 | 181 | No covered work shall be deemed part of an effective technological 182 | measure under any applicable law fulfilling obligations under article 183 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or 184 | similar laws prohibiting or restricting circumvention of such 185 | measures. 186 | 187 | When you convey a covered work, you waive any legal power to forbid 188 | circumvention of technological measures to the extent such circumvention 189 | is effected by exercising rights under this License with respect to 190 | the covered work, and you disclaim any intention to limit operation or 191 | modification of the work as a means of enforcing, against the work's 192 | users, your or third parties' legal rights to forbid circumvention of 193 | technological measures. 194 | 195 | 4. Conveying Verbatim Copies. 196 | 197 | You may convey verbatim copies of the Program's source code as you 198 | receive it, in any medium, provided that you conspicuously and 199 | appropriately publish on each copy an appropriate copyright notice; 200 | keep intact all notices stating that this License and any 201 | non-permissive terms added in accord with section 7 apply to the code; 202 | keep intact all notices of the absence of any warranty; and give all 203 | recipients a copy of this License along with the Program. 204 | 205 | You may charge any price or no price for each copy that you convey, 206 | and you may offer support or warranty protection for a fee. 207 | 208 | 5. Conveying Modified Source Versions. 209 | 210 | You may convey a work based on the Program, or the modifications to 211 | produce it from the Program, in the form of source code under the 212 | terms of section 4, provided that you also meet all of these conditions: 213 | 214 | a) The work must carry prominent notices stating that you modified 215 | it, and giving a relevant date. 216 | 217 | b) The work must carry prominent notices stating that it is 218 | released under this License and any conditions added under section 219 | 7. This requirement modifies the requirement in section 4 to 220 | "keep intact all notices". 221 | 222 | c) You must license the entire work, as a whole, under this 223 | License to anyone who comes into possession of a copy. This 224 | License will therefore apply, along with any applicable section 7 225 | additional terms, to the whole of the work, and all its parts, 226 | regardless of how they are packaged. This License gives no 227 | permission to license the work in any other way, but it does not 228 | invalidate such permission if you have separately received it. 229 | 230 | d) If the work has interactive user interfaces, each must display 231 | Appropriate Legal Notices; however, if the Program has interactive 232 | interfaces that do not display Appropriate Legal Notices, your 233 | work need not make them do so. 234 | 235 | A compilation of a covered work with other separate and independent 236 | works, which are not by their nature extensions of the covered work, 237 | and which are not combined with it such as to form a larger program, 238 | in or on a volume of a storage or distribution medium, is called an 239 | "aggregate" if the compilation and its resulting copyright are not 240 | used to limit the access or legal rights of the compilation's users 241 | beyond what the individual works permit. Inclusion of a covered work 242 | in an aggregate does not cause this License to apply to the other 243 | parts of the aggregate. 244 | 245 | 6. Conveying Non-Source Forms. 246 | 247 | You may convey a covered work in object code form under the terms 248 | of sections 4 and 5, provided that you also convey the 249 | machine-readable Corresponding Source under the terms of this License, 250 | in one of these ways: 251 | 252 | a) Convey the object code in, or embodied in, a physical product 253 | (including a physical distribution medium), accompanied by the 254 | Corresponding Source fixed on a durable physical medium 255 | customarily used for software interchange. 256 | 257 | b) Convey the object code in, or embodied in, a physical product 258 | (including a physical distribution medium), accompanied by a 259 | written offer, valid for at least three years and valid for as 260 | long as you offer spare parts or customer support for that product 261 | model, to give anyone who possesses the object code either (1) a 262 | copy of the Corresponding Source for all the software in the 263 | product that is covered by this License, on a durable physical 264 | medium customarily used for software interchange, for a price no 265 | more than your reasonable cost of physically performing this 266 | conveying of source, or (2) access to copy the 267 | Corresponding Source from a network server at no charge. 268 | 269 | c) Convey individual copies of the object code with a copy of the 270 | written offer to provide the Corresponding Source. This 271 | alternative is allowed only occasionally and noncommercially, and 272 | only if you received the object code with such an offer, in accord 273 | with subsection 6b. 274 | 275 | d) Convey the object code by offering access from a designated 276 | place (gratis or for a charge), and offer equivalent access to the 277 | Corresponding Source in the same way through the same place at no 278 | further charge. You need not require recipients to copy the 279 | Corresponding Source along with the object code. If the place to 280 | copy the object code is a network server, the Corresponding Source 281 | may be on a different server (operated by you or a third party) 282 | that supports equivalent copying facilities, provided you maintain 283 | clear directions next to the object code saying where to find the 284 | Corresponding Source. Regardless of what server hosts the 285 | Corresponding Source, you remain obligated to ensure that it is 286 | available for as long as needed to satisfy these requirements. 287 | 288 | e) Convey the object code using peer-to-peer transmission, provided 289 | you inform other peers where the object code and Corresponding 290 | Source of the work are being offered to the general public at no 291 | charge under subsection 6d. 292 | 293 | A separable portion of the object code, whose source code is excluded 294 | from the Corresponding Source as a System Library, need not be 295 | included in conveying the object code work. 296 | 297 | A "User Product" is either (1) a "consumer product", which means any 298 | tangible personal property which is normally used for personal, family, 299 | or household purposes, or (2) anything designed or sold for incorporation 300 | into a dwelling. In determining whether a product is a consumer product, 301 | doubtful cases shall be resolved in favor of coverage. For a particular 302 | product received by a particular user, "normally used" refers to a 303 | typical or common use of that class of product, regardless of the status 304 | of the particular user or of the way in which the particular user 305 | actually uses, or expects or is expected to use, the product. A product 306 | is a consumer product regardless of whether the product has substantial 307 | commercial, industrial or non-consumer uses, unless such uses represent 308 | the only significant mode of use of the product. 309 | 310 | "Installation Information" for a User Product means any methods, 311 | procedures, authorization keys, or other information required to install 312 | and execute modified versions of a covered work in that User Product from 313 | a modified version of its Corresponding Source. The information must 314 | suffice to ensure that the continued functioning of the modified object 315 | code is in no case prevented or interfered with solely because 316 | modification has been made. 317 | 318 | If you convey an object code work under this section in, or with, or 319 | specifically for use in, a User Product, and the conveying occurs as 320 | part of a transaction in which the right of possession and use of the 321 | User Product is transferred to the recipient in perpetuity or for a 322 | fixed term (regardless of how the transaction is characterized), the 323 | Corresponding Source conveyed under this section must be accompanied 324 | by the Installation Information. But this requirement does not apply 325 | if neither you nor any third party retains the ability to install 326 | modified object code on the User Product (for example, the work has 327 | been installed in ROM). 328 | 329 | The requirement to provide Installation Information does not include a 330 | requirement to continue to provide support service, warranty, or updates 331 | for a work that has been modified or installed by the recipient, or for 332 | the User Product in which it has been modified or installed. Access to a 333 | network may be denied when the modification itself materially and 334 | adversely affects the operation of the network or violates the rules and 335 | protocols for communication across the network. 336 | 337 | Corresponding Source conveyed, and Installation Information provided, 338 | in accord with this section must be in a format that is publicly 339 | documented (and with an implementation available to the public in 340 | source code form), and must require no special password or key for 341 | unpacking, reading or copying. 342 | 343 | 7. Additional Terms. 344 | 345 | "Additional permissions" are terms that supplement the terms of this 346 | License by making exceptions from one or more of its conditions. 347 | Additional permissions that are applicable to the entire Program shall 348 | be treated as though they were included in this License, to the extent 349 | that they are valid under applicable law. If additional permissions 350 | apply only to part of the Program, that part may be used separately 351 | under those permissions, but the entire Program remains governed by 352 | this License without regard to the additional permissions. 353 | 354 | When you convey a copy of a covered work, you may at your option 355 | remove any additional permissions from that copy, or from any part of 356 | it. (Additional permissions may be written to require their own 357 | removal in certain cases when you modify the work.) You may place 358 | additional permissions on material, added by you to a covered work, 359 | for which you have or can give appropriate copyright permission. 360 | 361 | Notwithstanding any other provision of this License, for material you 362 | add to a covered work, you may (if authorized by the copyright holders of 363 | that material) supplement the terms of this License with terms: 364 | 365 | a) Disclaiming warranty or limiting liability differently from the 366 | terms of sections 15 and 16 of this License; or 367 | 368 | b) Requiring preservation of specified reasonable legal notices or 369 | author attributions in that material or in the Appropriate Legal 370 | Notices displayed by works containing it; or 371 | 372 | c) Prohibiting misrepresentation of the origin of that material, or 373 | requiring that modified versions of such material be marked in 374 | reasonable ways as different from the original version; or 375 | 376 | d) Limiting the use for publicity purposes of names of licensors or 377 | authors of the material; or 378 | 379 | e) Declining to grant rights under trademark law for use of some 380 | trade names, trademarks, or service marks; or 381 | 382 | f) Requiring indemnification of licensors and authors of that 383 | material by anyone who conveys the material (or modified versions of 384 | it) with contractual assumptions of liability to the recipient, for 385 | any liability that these contractual assumptions directly impose on 386 | those licensors and authors. 387 | 388 | All other non-permissive additional terms are considered "further 389 | restrictions" within the meaning of section 10. If the Program as you 390 | received it, or any part of it, contains a notice stating that it is 391 | governed by this License along with a term that is a further 392 | restriction, you may remove that term. If a license document contains 393 | a further restriction but permits relicensing or conveying under this 394 | License, you may add to a covered work material governed by the terms 395 | of that license document, provided that the further restriction does 396 | not survive such relicensing or conveying. 397 | 398 | If you add terms to a covered work in accord with this section, you 399 | must place, in the relevant source files, a statement of the 400 | additional terms that apply to those files, or a notice indicating 401 | where to find the applicable terms. 402 | 403 | Additional terms, permissive or non-permissive, may be stated in the 404 | form of a separately written license, or stated as exceptions; 405 | the above requirements apply either way. 406 | 407 | 8. Termination. 408 | 409 | You may not propagate or modify a covered work except as expressly 410 | provided under this License. Any attempt otherwise to propagate or 411 | modify it is void, and will automatically terminate your rights under 412 | this License (including any patent licenses granted under the third 413 | paragraph of section 11). 414 | 415 | However, if you cease all violation of this License, then your 416 | license from a particular copyright holder is reinstated (a) 417 | provisionally, unless and until the copyright holder explicitly and 418 | finally terminates your license, and (b) permanently, if the copyright 419 | holder fails to notify you of the violation by some reasonable means 420 | prior to 60 days after the cessation. 421 | 422 | Moreover, your license from a particular copyright holder is 423 | reinstated permanently if the copyright holder notifies you of the 424 | violation by some reasonable means, this is the first time you have 425 | received notice of violation of this License (for any work) from that 426 | copyright holder, and you cure the violation prior to 30 days after 427 | your receipt of the notice. 428 | 429 | Termination of your rights under this section does not terminate the 430 | licenses of parties who have received copies or rights from you under 431 | this License. If your rights have been terminated and not permanently 432 | reinstated, you do not qualify to receive new licenses for the same 433 | material under section 10. 434 | 435 | 9. Acceptance Not Required for Having Copies. 436 | 437 | You are not required to accept this License in order to receive or 438 | run a copy of the Program. Ancillary propagation of a covered work 439 | occurring solely as a consequence of using peer-to-peer transmission 440 | to receive a copy likewise does not require acceptance. However, 441 | nothing other than this License grants you permission to propagate or 442 | modify any covered work. These actions infringe copyright if you do 443 | not accept this License. Therefore, by modifying or propagating a 444 | covered work, you indicate your acceptance of this License to do so. 445 | 446 | 10. Automatic Licensing of Downstream Recipients. 447 | 448 | Each time you convey a covered work, the recipient automatically 449 | receives a license from the original licensors, to run, modify and 450 | propagate that work, subject to this License. You are not responsible 451 | for enforcing compliance by third parties with this License. 452 | 453 | An "entity transaction" is a transaction transferring control of an 454 | organization, or substantially all assets of one, or subdividing an 455 | organization, or merging organizations. If propagation of a covered 456 | work results from an entity transaction, each party to that 457 | transaction who receives a copy of the work also receives whatever 458 | licenses to the work the party's predecessor in interest had or could 459 | give under the previous paragraph, plus a right to possession of the 460 | Corresponding Source of the work from the predecessor in interest, if 461 | the predecessor has it or can get it with reasonable efforts. 462 | 463 | You may not impose any further restrictions on the exercise of the 464 | rights granted or affirmed under this License. For example, you may 465 | not impose a license fee, royalty, or other charge for exercise of 466 | rights granted under this License, and you may not initiate litigation 467 | (including a cross-claim or counterclaim in a lawsuit) alleging that 468 | any patent claim is infringed by making, using, selling, offering for 469 | sale, or importing the Program or any portion of it. 470 | 471 | 11. Patents. 472 | 473 | A "contributor" is a copyright holder who authorizes use under this 474 | License of the Program or a work on which the Program is based. The 475 | work thus licensed is called the contributor's "contributor version". 476 | 477 | A contributor's "essential patent claims" are all patent claims 478 | owned or controlled by the contributor, whether already acquired or 479 | hereafter acquired, that would be infringed by some manner, permitted 480 | by this License, of making, using, or selling its contributor version, 481 | but do not include claims that would be infringed only as a 482 | consequence of further modification of the contributor version. For 483 | purposes of this definition, "control" includes the right to grant 484 | patent sublicenses in a manner consistent with the requirements of 485 | this License. 486 | 487 | Each contributor grants you a non-exclusive, worldwide, royalty-free 488 | patent license under the contributor's essential patent claims, to 489 | make, use, sell, offer for sale, import and otherwise run, modify and 490 | propagate the contents of its contributor version. 491 | 492 | In the following three paragraphs, a "patent license" is any express 493 | agreement or commitment, however denominated, not to enforce a patent 494 | (such as an express permission to practice a patent or covenant not to 495 | sue for patent infringement). To "grant" such a patent license to a 496 | party means to make such an agreement or commitment not to enforce a 497 | patent against the party. 498 | 499 | If you convey a covered work, knowingly relying on a patent license, 500 | and the Corresponding Source of the work is not available for anyone 501 | to copy, free of charge and under the terms of this License, through a 502 | publicly available network server or other readily accessible means, 503 | then you must either (1) cause the Corresponding Source to be so 504 | available, or (2) arrange to deprive yourself of the benefit of the 505 | patent license for this particular work, or (3) arrange, in a manner 506 | consistent with the requirements of this License, to extend the patent 507 | license to downstream recipients. "Knowingly relying" means you have 508 | actual knowledge that, but for the patent license, your conveying the 509 | covered work in a country, or your recipient's use of the covered work 510 | in a country, would infringe one or more identifiable patents in that 511 | country that you have reason to believe are valid. 512 | 513 | If, pursuant to or in connection with a single transaction or 514 | arrangement, you convey, or propagate by procuring conveyance of, a 515 | covered work, and grant a patent license to some of the parties 516 | receiving the covered work authorizing them to use, propagate, modify 517 | or convey a specific copy of the covered work, then the patent license 518 | you grant is automatically extended to all recipients of the covered 519 | work and works based on it. 520 | 521 | A patent license is "discriminatory" if it does not include within 522 | the scope of its coverage, prohibits the exercise of, or is 523 | conditioned on the non-exercise of one or more of the rights that are 524 | specifically granted under this License. You may not convey a covered 525 | work if you are a party to an arrangement with a third party that is 526 | in the business of distributing software, under which you make payment 527 | to the third party based on the extent of your activity of conveying 528 | the work, and under which the third party grants, to any of the 529 | parties who would receive the covered work from you, a discriminatory 530 | patent license (a) in connection with copies of the covered work 531 | conveyed by you (or copies made from those copies), or (b) primarily 532 | for and in connection with specific products or compilations that 533 | contain the covered work, unless you entered into that arrangement, 534 | or that patent license was granted, prior to 28 March 2007. 535 | 536 | Nothing in this License shall be construed as excluding or limiting 537 | any implied license or other defenses to infringement that may 538 | otherwise be available to you under applicable patent law. 539 | 540 | 12. No Surrender of Others' Freedom. 541 | 542 | If conditions are imposed on you (whether by court order, agreement or 543 | otherwise) that contradict the conditions of this License, they do not 544 | excuse you from the conditions of this License. If you cannot convey a 545 | covered work so as to satisfy simultaneously your obligations under this 546 | License and any other pertinent obligations, then as a consequence you may 547 | not convey it at all. For example, if you agree to terms that obligate you 548 | to collect a royalty for further conveying from those to whom you convey 549 | the Program, the only way you could satisfy both those terms and this 550 | License would be to refrain entirely from conveying the Program. 551 | 552 | 13. Use with the GNU Affero General Public License. 553 | 554 | Notwithstanding any other provision of this License, you have 555 | permission to link or combine any covered work with a work licensed 556 | under version 3 of the GNU Affero General Public License into a single 557 | combined work, and to convey the resulting work. The terms of this 558 | License will continue to apply to the part which is the covered work, 559 | but the special requirements of the GNU Affero General Public License, 560 | section 13, concerning interaction through a network will apply to the 561 | combination as such. 562 | 563 | 14. Revised Versions of this License. 564 | 565 | The Free Software Foundation may publish revised and/or new versions of 566 | the GNU General Public License from time to time. Such new versions will 567 | be similar in spirit to the present version, but may differ in detail to 568 | address new problems or concerns. 569 | 570 | Each version is given a distinguishing version number. If the 571 | Program specifies that a certain numbered version of the GNU General 572 | Public License "or any later version" applies to it, you have the 573 | option of following the terms and conditions either of that numbered 574 | version or of any later version published by the Free Software 575 | Foundation. If the Program does not specify a version number of the 576 | GNU General Public License, you may choose any version ever published 577 | by the Free Software Foundation. 578 | 579 | If the Program specifies that a proxy can decide which future 580 | versions of the GNU General Public License can be used, that proxy's 581 | public statement of acceptance of a version permanently authorizes you 582 | to choose that version for the Program. 583 | 584 | Later license versions may give you additional or different 585 | permissions. However, no additional obligations are imposed on any 586 | author or copyright holder as a result of your choosing to follow a 587 | later version. 588 | 589 | 15. Disclaimer of Warranty. 590 | 591 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 592 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 593 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY 594 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, 595 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 596 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM 597 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF 598 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 599 | 600 | 16. Limitation of Liability. 601 | 602 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 603 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS 604 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY 605 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE 606 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF 607 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD 608 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), 609 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF 610 | SUCH DAMAGES. 611 | 612 | 17. Interpretation of Sections 15 and 16. 613 | 614 | If the disclaimer of warranty and limitation of liability provided 615 | above cannot be given local legal effect according to their terms, 616 | reviewing courts shall apply local law that most closely approximates 617 | an absolute waiver of all civil liability in connection with the 618 | Program, unless a warranty or assumption of liability accompanies a 619 | copy of the Program in return for a fee. 620 | 621 | END OF TERMS AND CONDITIONS 622 | 623 | How to Apply These Terms to Your New Programs 624 | 625 | If you develop a new program, and you want it to be of the greatest 626 | possible use to the public, the best way to achieve this is to make it 627 | free software which everyone can redistribute and change under these terms. 628 | 629 | To do so, attach the following notices to the program. It is safest 630 | to attach them to the start of each source file to most effectively 631 | state the exclusion of warranty; and each file should have at least 632 | the "copyright" line and a pointer to where the full notice is found. 633 | 634 | 635 | Copyright (C) 636 | 637 | This program is free software: you can redistribute it and/or modify 638 | it under the terms of the GNU General Public License as published by 639 | the Free Software Foundation, either version 3 of the License, or 640 | (at your option) any later version. 641 | 642 | This program is distributed in the hope that it will be useful, 643 | but WITHOUT ANY WARRANTY; without even the implied warranty of 644 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 645 | GNU General Public License for more details. 646 | 647 | You should have received a copy of the GNU General Public License 648 | along with this program. If not, see . 649 | 650 | Also add information on how to contact you by electronic and paper mail. 651 | 652 | If the program does terminal interaction, make it output a short 653 | notice like this when it starts in an interactive mode: 654 | 655 | Copyright (C) 656 | This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 657 | This is free software, and you are welcome to redistribute it 658 | under certain conditions; type `show c' for details. 659 | 660 | The hypothetical commands `show w' and `show c' should show the appropriate 661 | parts of the General Public License. Of course, your program's commands 662 | might be different; for a GUI interface, you would use an "about box". 663 | 664 | You should also get your employer (if you work as a programmer) or school, 665 | if any, to sign a "copyright disclaimer" for the program, if necessary. 666 | For more information on this, and how to apply and follow the GNU GPL, see 667 | . 668 | 669 | The GNU General Public License does not permit incorporating your program 670 | into proprietary programs. If your program is a subroutine library, you 671 | may consider it more useful to permit linking proprietary applications with 672 | the library. If this is what you want to do, use the GNU Lesser General 673 | Public License instead of this License. But first, please read 674 | . -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # pythonBits 2 | [![GitHub release](https://img.shields.io/github/release/mueslo/pythonbits.svg)](https://GitHub.com/mueslo/pythonBits/releases/) 3 | [![GitHub release](https://img.shields.io/github/release/mueslo/pythonbits.svg?include_prereleases)](https://GitHub.com/mueslo/pythonBits/releases/) 4 | [![PyPI version](https://img.shields.io/pypi/v/pythonbits.svg)](https://pypi.python.org/pypi/pythonbits/) 5 | [![PyPI pyversions](https://img.shields.io/pypi/pyversions/pythonbits.svg)](https://pypi.python.org/pypi/pythonbits/) 6 | [![GitHub commits since release](https://img.shields.io/github/commits-since/mueslo/pythonbits/latest.svg)](https://github.com/mueslo/pythonBits/commits/master) 7 | [![GitHub license](https://img.shields.io/github/license/mueslo/pythonbits.svg)](https://github.com/mueslo/pythonbits/blob/master/LICENSE) 8 | [![Build Status](https://img.shields.io/travis/mueslo/pythonBits/master.svg)](https://travis-ci.org/mueslo/pythonBits) 9 | #### A Python description generator for movies and TV shows 10 | 11 | ## Install 12 | 1. (Optional, highly recommended) Set up a virtualenv to avoid polluting your system with dependencies. 13 | - with virtualenvwrapper: `mkvirtualenv pythonbits` 14 | - activate the virtualenv with `workon pythonbits` 15 | 2. Install pythonBits in one of the following ways 16 | - install via `pip install pythonbits` 17 | - clone and `pip install .` 18 | - pipx 19 | - (dev) clone, install requirements from setup.py and run as `python -m pythonbits` instead of `pythonbits` 20 | 3. Install mediainfo, ffmpeg and mktorrent>=1.1 such that they are accessible for pythonBits 21 | - you can also manually specify things such as the torrent file or screenshots, this will prevent the programs from being called, removing the dependency 22 | 23 | If you don't want to use a virtualenv but keep system pollution with PyPI packages to a minimum, install via `pip install --user`. For more information, visit [this site](https://packaging.python.org/guides/installing-using-pip-and-virtualenv/). 24 | 25 | ## Usage 26 | ``` 27 | usage: pythonbits [-h] [--version] [-v] [-c {tv,movie}] [-u FIELD VALUE] [-i] 28 | [-t] [-s] [-d] [-b] [-f FIELD [FIELD ...]] 29 | [--num-cast NUM_CAST] [--num-screenshots NUM_SCREENSHOTS] 30 | PATH [TITLE] 31 | ``` 32 | Use `pythonbits --help` to get a more extensive usage overview 33 | 34 | ## Examples 35 | pythonBits will attempt to guess as much information as possible from the filename. Unlike in previous releases, explicitly specifying a category or title is usually not necessary. PATH can also reference a directory, e.g. for season packs. 36 | 37 | In most cases it is enough to just run `pythonbits ` to generate a media description. If running the desired features requires uploading data to remote servers, you will be prompted to confirm this finalization before it occurs. 38 | 39 | * Print mediainfo: `pythonbits -i `, equivalent to `pythonbits -f mediainfo ` 40 | * Make screenshots: `pythonbits -s ` 41 | * Write a description: `pythonbits -d ` 42 | * Make a torrent file: `pythonbits -t ` 43 | * Generate complete submission and post it: `pythonbits -b ` (Note: YOU are responsible for your uploads) 44 | * Generate complete submission, use supplied torrent file and tags: `pythonbits -b -u torrentfile -u tags "whatever,tags.you.like" ` 45 | 46 | In case the media title and type cannot be guessed from the path alone, you can explicitly specify them, e.g. `pythonbits "Doctor Who (2005) S06"`or `pythonbits -c movie`. 47 | 48 | You can increase the verbosity of log messages printed to the screen by appending `-v`. This would print `INFO` messages. To print `DEBUG` messages, append twice, i.e. `-vv`. 49 | 50 | You can also import pythonbits to use in your own Python projects. For reference on how to best use it, take a look at `__main__.py`. Once you have created an appropriate `Submission` instance `s`, you can access any desired feature, for example `s['title']`, `s['tags']` or `s['cover']`. 51 | -------------------------------------------------------------------------------- /pythonbits/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | __title__ = "pythonBits" 4 | __version__ = "3.1b2" 5 | __copyright__ = "Copyright 2018, The pythonBits Authors" 6 | __maintainer__ = "mueslo" 7 | __license__ = "GPLv3" 8 | 9 | _release = __title__ + " " + __version__ 10 | _github = 'https://github.com/' + __maintainer__ + '/' + __title__ 11 | 12 | flags = set() 13 | -------------------------------------------------------------------------------- /pythonbits/__main__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from os import path 3 | from argparse import ArgumentParser 4 | 5 | from . import __version__ as version, flags 6 | from . import bb 7 | from . import logging 8 | from .submission import SubmissionAttributeError, cat_map 9 | 10 | 11 | def parse_args(): 12 | parser = ArgumentParser( 13 | description=("A Python pretty printer for generating attractive movie " 14 | "descriptions with screenshots.")) 15 | parser.add_argument('--version', action='version', version=version) 16 | parser.add_argument("-v", action="count", default=0, 17 | help="increase output verbosity") 18 | parser.add_argument("path", metavar='PATH', 19 | help="File or directory of media") 20 | parser.add_argument("title", metavar='TITLE', nargs='?', 21 | help=("Explicitly identify media title " 22 | "(e.g. \"Lawrence of Arabia\" or \"The Walking " 23 | "Dead S01\") (optional)")) 24 | 25 | parser.add_argument("-c", "--category", choices=list(cat_map.keys())) 26 | parser.add_argument("-u", "--set-field", nargs=2, action='append', 27 | metavar=('FIELD', 'VALUE'), default=[], 28 | help="Use supplied values to use for fields, e.g. " 29 | "torrentfile /path/to/torrentfile") 30 | 31 | def n_to_p(x): return "--" + x.replace('_', '-') 32 | 33 | # shorthand features 34 | feature_d = { 35 | # todo: these default values can vary by Submission.default_fields and 36 | # wouldn't make sense for e.g. music 37 | 'description': {'short_param': '-d', 'default': True, 38 | 'help': "Generate description of media"}, 39 | 'mediainfo': {'short_param': '-i', 'default': True, 40 | 'help': "Generate mediainfo output"}, 41 | 'screenshots': {'short_param': '-s', 'default': True, 42 | 'help': "Generate screenshots and upload to imgur"}, 43 | 'torrentfile': {'short_param': '-t', 'default': False, 44 | 'help': "Create torrent file"}, 45 | 'submit': {'short_param': '-b', 'default': False, 46 | 'help': "Generate complete submission and post it"}, 47 | } 48 | 49 | feature_toggle = parser.add_argument_group( 50 | title="Feature toggle", 51 | description="Enables only the selected features, " 52 | "while everything else will not be executed.") 53 | 54 | for name, vals in feature_d.items(): 55 | short = vals.pop('short_param') 56 | default = vals.pop('default') 57 | vals['help'] += " (default " + str(default) + ")" 58 | feature_toggle.add_argument(short, n_to_p(name), action='append_const', 59 | const=name, dest='fields', default=[], 60 | **vals) 61 | 62 | # explicit/extra features 63 | feature_toggle.add_argument( 64 | '-f', '--features', action='store', default=[], dest='fields_ex', 65 | nargs='+', metavar='FIELD', 66 | help="Output values of any field(s), e.g. tags") 67 | 68 | # todo: move to submission.py 69 | options_d = { 70 | 'num_screenshots': {'type': int, 'default': 2, 71 | 'help': "Number of screenshots"}, 72 | 'num_cast': {'type': int, 'default': 10, 73 | 'help': "Number of actors to use in tags"}, 74 | 'num_directors': {'type': int, 'default': 2, 75 | 'help': "Number of directors to use in tags"}, 76 | 'data_method': {'type': str, 'default': 'auto', 77 | 'choices': ['hard', 'sym', 'copy', 'move'], 78 | 'help': "Data method to use for placing media files"}, 79 | 'headless': {'action': 'store_true', 'default': False, 80 | 'help': 'Skip user interaction if possible or exit'}, 81 | } 82 | 83 | options = parser.add_argument_group( 84 | title="Tunables", 85 | description="Additional options such as number of screenshots") 86 | for name, vals in options_d.items(): 87 | vals['help'] += " (default " + str(vals['default']) + ")" 88 | options.add_argument(n_to_p(name), **vals) 89 | 90 | args = parser.parse_args() 91 | logging.sh.level -= args.v 92 | logging.log.debug("Arguments: {}", args) 93 | 94 | args.options = {} 95 | for o in options_d.keys(): 96 | args.options[o] = getattr(args, o) 97 | 98 | headless = args.options.pop('headless') 99 | if headless: 100 | flags.add('headless') 101 | 102 | set_field = dict(args.set_field) 103 | 104 | Category = cat_map.get(args.category, bb.BbSubmission) 105 | 106 | set_field['options'] = args.options 107 | set_field['path'] = path.abspath(args.path) 108 | set_field['title_arg'] = args.title 109 | get_field = args.fields + args.fields_ex 110 | 111 | return Category, set_field, get_field 112 | 113 | 114 | def _main(Category, set_fields, get_fields): 115 | sub = Category(**set_fields) 116 | 117 | while True: 118 | try: 119 | sub.show_fields(get_fields) 120 | except SubmissionAttributeError as e: 121 | logging.log.debug(type(e).__name__ + ': ' + str(e)) 122 | _sub = sub.subcategorise() 123 | if type(_sub) == type(sub): 124 | raise 125 | sub = _sub 126 | else: 127 | break 128 | 129 | headless = 'headless' in flags 130 | if sub.needs_finalization(): 131 | if headless or sub.confirm_finalization(get_fields): 132 | sub.finalize() 133 | else: 134 | return 135 | 136 | print(sub.show_fields(get_fields)) 137 | 138 | 139 | def main(): 140 | Category, set_fields, get_fields = parse_args() 141 | with logging.log.catch_exceptions( 142 | "An exception occured.\nFull log stored at file://{}", 143 | logging.LOG_FILE): 144 | _main(Category, set_fields, get_fields) 145 | 146 | 147 | if __name__ == '__main__': 148 | main() 149 | -------------------------------------------------------------------------------- /pythonbits/api_utils.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from hashlib import sha256, sha224 3 | 4 | from .config import config 5 | 6 | 7 | def get_psk(): 8 | seed = config.get('Tracker', 'domain').encode('utf8') 9 | test = sha224(seed).hexdigest() 10 | if not test.endswith('f280f') and not test.endswith('5abc3'): 11 | raise Exception('Wrong domain! ' 12 | 'Manually fix {}'.format(config.config_path)) 13 | return sha256(seed).hexdigest() 14 | 15 | 16 | def d(a): 17 | psk = get_psk() 18 | return "".join([chr(ord(a[i]) ^ ord(psk[i])) for i in range(len(a))]) 19 | -------------------------------------------------------------------------------- /pythonbits/bb.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import os 3 | import sys 4 | import shutil 5 | import re 6 | import subprocess 7 | 8 | from textwrap import dedent 9 | from collections import namedtuple, abc 10 | from concurrent.futures.thread import ThreadPoolExecutor 11 | from datetime import timedelta 12 | from mimetypes import guess_type 13 | 14 | import pymediainfo 15 | import mutagen 16 | import guessit 17 | from unidecode import unidecode 18 | from requests.exceptions import HTTPError 19 | 20 | from .config import config 21 | from .logging import log 22 | from .torrent import make_torrent 23 | from . import tvdb 24 | from . import imdb 25 | from . import musicbrainz as mb 26 | from . import imagehosting 27 | from .ffmpeg import FFMpeg 28 | from . import templating as bb 29 | from .submission import (Submission, form_field, finalize, cat_map, 30 | SubmissionAttributeError, rlinput) 31 | from .tracker import Tracker 32 | from .scene import is_scene_crc, query_scene_fname 33 | 34 | 35 | def format_tag(tag): 36 | tag = unidecode(tag) 37 | if '/' in tag: 38 | # Multiple actors can be listed as a single actor like this: 39 | # "Thierry Kazazian / Max Mittleman" 40 | # (e.g. for "Miraculous: Tales of Ladybug & Cat Noir") 41 | tag = tag[:tag.index('/')].strip() 42 | return tag.replace(' ', '.').replace('-', '.').replace('\'', '.').lower() 43 | 44 | 45 | def format_choices(choices): 46 | return ", ".join([ 47 | str(num) + ": " + value 48 | for num, value in enumerate(choices) 49 | ]) 50 | 51 | 52 | def uniq(seq): 53 | seen = set() 54 | seen_add = seen.add 55 | return [x for x in seq if not (x in seen or seen_add(x))] 56 | 57 | 58 | class BbSubmission(Submission): 59 | default_fields = ("form_title", "tags", "cover") 60 | 61 | def show_fields(self, fields): 62 | return super(BbSubmission, self).show_fields( 63 | fields or self.default_fields) 64 | 65 | def confirm_finalization(self, fields): 66 | return super(BbSubmission, self).confirm_finalization( 67 | fields or self.default_fields) 68 | 69 | def subcategory(self): 70 | path = self['path'] 71 | if os.path.isfile(path): 72 | files = [(os.path.getsize(path), path)] 73 | else: 74 | files = [] 75 | 76 | for root, _, fs in os.walk(path): 77 | for f in fs: 78 | fpath = os.path.join(root, f) 79 | files.append((os.path.getsize(fpath), fpath)) 80 | 81 | for _, path in sorted(files, reverse=True): 82 | mime_guess, _ = guess_type(path) 83 | if mime_guess: 84 | mime_guess = mime_guess.split('/') 85 | if mime_guess[0] == 'video': 86 | return VideoSubmission 87 | elif mime_guess[0] == 'audio': 88 | return AudioSubmission 89 | 90 | log.info("Unable to guess submission category using known mimetypes") 91 | while True: 92 | cat = input("Please manually specify category. " 93 | "\nOptions: {}" 94 | "\nCategory: ".format(", ".join(cat_map.keys()))) 95 | try: 96 | return cat_map[cat] 97 | except KeyError: 98 | print('Invalid category.') 99 | 100 | def subcategorise(self): 101 | log.debug('Attempting to narrow category') 102 | SubCategory = self.subcategory() 103 | if type(self) == SubCategory: 104 | return self 105 | 106 | log.info("Narrowing category from {} to {}", 107 | type(self).__name__, SubCategory.__name__) 108 | sub = SubCategory(**self.fields) 109 | sub.depends_on = self.depends_on 110 | return sub 111 | 112 | @staticmethod 113 | def submit(payload): 114 | t = Tracker() 115 | return t.upload(**payload) 116 | 117 | @form_field('scene', 'checkbox') 118 | def _render_scene(self): 119 | # todo: if path is directory, choose file for crc 120 | path = os.path.normpath(self['path']) # removes trailing slash 121 | try: 122 | try: 123 | if os.path.exists(path) and not os.path.isdir(path): 124 | return is_scene_crc(path) 125 | except KeyboardInterrupt: 126 | sys.stdout.write('...skipped\n') 127 | 128 | query_scene_fname(path) 129 | except HTTPError as e: 130 | log.notice(e) 131 | 132 | while True: 133 | choice = input('Is this a scene release? [y/N] ') 134 | 135 | if not choice or choice.lower() == 'n': 136 | return False 137 | elif choice.lower() == 'y': 138 | return True 139 | 140 | def data_method(self, source, target): 141 | def copy(source, target): 142 | if os.path.isfile(source): 143 | return shutil.copy(source, target) 144 | if os.path.isdir(source): 145 | return shutil.copytree(source, target) 146 | raise Exception('Source {} is neither ' 147 | 'file nor directory'.format(source)) 148 | 149 | cat_methods_map = { 150 | 'movie': ['hard', 'sym', 'copy', 'move'], 151 | 'tv': ['hard', 'sym', 'copy', 'move'], 152 | 'music': ['copy', 'move'], 153 | } 154 | 155 | method_map = {'hard': os.link, 156 | 'sym': os.symlink, 157 | 'copy': copy, 158 | 'move': shutil.move} 159 | 160 | # use cmd line option if specified 161 | option_method = self['options'].get('data_method', 'auto') 162 | if option_method != 'auto': 163 | method = option_method 164 | else: 165 | pref_method = config.get('Torrent', 'data_method') 166 | if pref_method not in method_map: 167 | log.warning( 168 | 'Preferred method {} not valid. ' 169 | 'Choices are {}'.format(pref_method, 170 | list(method_map.keys()))) 171 | try: 172 | # todo fix this, proper category mapping, 173 | # e.g. 'music' <-> bb.MusicSubmission 174 | category = ('music' if isinstance(self, AudioSubmission) 175 | else 'movie') 176 | except AttributeError: 177 | log.warning("{} does not have a category attribute", 178 | type(self).__name__) 179 | category = 'movie' # use movie data methods 180 | 181 | cat_methods = cat_methods_map[category] 182 | if pref_method in cat_methods: 183 | # use user preferred method if in category method list 184 | method = pref_method 185 | else: 186 | # otherwise use first category method 187 | method = cat_methods[0] 188 | 189 | log.notice('Copying data using \'{}\' method', method) 190 | return method_map[method](source, target) 191 | 192 | @finalize 193 | @form_field('file_input', 'file') 194 | def _render_torrentfile(self): 195 | return make_torrent(self['path']) 196 | 197 | def _finalize_torrentfile(self): 198 | # move data to upload directory 199 | up_dir = config.get('Torrent', 'upload_dir') 200 | path_dir, path_base = os.path.split(self['path']) 201 | if up_dir and not os.path.samefile(up_dir, path_dir): 202 | target = os.path.join(up_dir, path_base) 203 | if not os.path.exists(target): 204 | self.data_method(self['path'], target) 205 | else: 206 | log.notice('Data method target already exists, skipping...') 207 | 208 | # black hole 209 | bh_dir = config.get('Torrent', 'black_hole') 210 | if bh_dir: 211 | fname = os.path.basename(self['torrentfile']) 212 | dest = os.path.join(bh_dir, fname) 213 | 214 | try: 215 | assert os.path.exists(bh_dir) 216 | assert not os.path.isfile(dest) 217 | except AssertionError as e: 218 | log.error(e) 219 | else: 220 | shutil.copy(self['torrentfile'], dest) 221 | log.notice("Torrent file copied to {}", dest) 222 | 223 | return self['torrentfile'] 224 | 225 | @form_field('type') 226 | def _render_form_type(self): 227 | try: 228 | return self._form_type 229 | except AttributeError: 230 | raise SubmissionAttributeError(type(self).__name__ + 231 | ' has no _form_type attribute') 232 | 233 | @form_field('submit') 234 | def _render_form_submit(self): 235 | return 'true' 236 | 237 | 238 | title_tv_re = ( 239 | r"^(?P.+)(?<!season) " 240 | r"(?P<season_marker>(s|season |))" 241 | r"(?P<season>((?<= s)[0-9]{2,})|(?<= )[0-9]+(?=x)|(?<=season )[0-9]+(?=$))" 242 | r"((?P<episode_marker>[ex])(?P<episode>[0-9]+))?$") 243 | 244 | TvSpecifier = namedtuple('TvSpecifier', ['title', 'season', 'episode']) 245 | 246 | 247 | class VideoSubmission(BbSubmission): 248 | default_fields = BbSubmission.default_fields 249 | 250 | def _render_guess(self): 251 | return dict(guessit.guessit(self['path'])) 252 | 253 | def subcategory(self): 254 | if type(self) == VideoSubmission: 255 | if self['tv_specifier']: 256 | return TvSubmission 257 | else: 258 | return MovieSubmission 259 | return type(self) 260 | 261 | def _render_title(self): 262 | # Use format "<original title> AKA <english title>" where applicable 263 | title_original = self['summary']['title'] 264 | title_english = self['summary']['titles'].get('XWW', None) 265 | if title_english is not None and title_original != title_english: 266 | return '{} AKA {}'.format(title_original, title_english) 267 | else: 268 | return title_original 269 | 270 | def _render_tv_specifier(self): 271 | # if title is specified, look if season/episode are set 272 | if self['title_arg']: 273 | match = re.match(title_tv_re, self['title_arg'], 274 | re.IGNORECASE) 275 | if match: 276 | episode = match.group('episode') 277 | return TvSpecifier( 278 | match.group('title'), int(match.group('season')), 279 | episode and int(episode)) # if episode is None 280 | 281 | # todo: test tv show name from title_arg, but episode from filename 282 | 283 | guess = self['guess'] 284 | if guess['type'] == 'episode': 285 | if self['title_arg']: 286 | title = self['title_arg'] 287 | else: 288 | title = guess['title'] 289 | try: 290 | season = guess['season'] 291 | except KeyError: 292 | raise Exception('Could not find a season in the path name. ' 293 | 'Try specifying it in the TITLE argument, ' 294 | 'e.g. "Some TV Show S02" for a season 2 pack') 295 | return TvSpecifier(title, season, guess.get('episode')) 296 | 297 | @form_field('tags') 298 | def _render_tags(self): 299 | # todo: get episode-specific actors (from imdb?) 300 | 301 | n = self['options']['num_cast'] 302 | d = self['options']['num_directors'] 303 | tags = list(self['summary']['genres']) 304 | 305 | if 'directors' in self['summary']: 306 | tags += [a['name'] 307 | for a in self['summary']['directors'][:d] 308 | if a['name']] 309 | 310 | if 'cast' in self['summary']: 311 | tags += [a['name'] 312 | for a in self['summary']['cast'][:n] 313 | if a['name']] 314 | 315 | tags = uniq(tags) 316 | 317 | # Maximum tags length is 200 characters 318 | def tags_string(tags): 319 | return ",".join(format_tag(tag) for tag in tags) 320 | while len(tags_string(tags)) > 200: 321 | del tags[-1] 322 | return tags_string(tags) 323 | 324 | def _render_mediainfo_path(self): 325 | assert os.path.exists(self['path']) 326 | if os.path.isfile(self['path']): 327 | return self['path'] 328 | 329 | contained_files = [] 330 | for dp, dns, fns in os.walk(self['path']): 331 | contained_files += [os.path.join(dp, fn) for fn in fns 332 | if (os.path.getsize(os.path.join(dp, fn)) 333 | > 10 * 2**20)] 334 | if len(contained_files) == 1: 335 | return contained_files[0] 336 | 337 | print("\nWhich file would you like to run mediainfo on? Choices are") 338 | contained_files.sort() 339 | for k, v in enumerate(contained_files): 340 | print("{}: {}".format(k, os.path.relpath(v, self['path']))) 341 | while True: 342 | try: 343 | choice = input( 344 | "Enter [0-{}]: ".format(len(contained_files) - 1)) 345 | return contained_files[int(choice)] 346 | except (ValueError, IndexError): 347 | pass 348 | 349 | @finalize 350 | def _render_screenshots(self): 351 | ns = self['options']['num_screenshots'] 352 | ffmpeg = FFMpeg(self['mediainfo_path']) 353 | return ffmpeg.take_screenshots(ns) 354 | 355 | def _finalize_screenshots(self): 356 | return imagehosting.upload(*self['screenshots']) 357 | 358 | def _render_mediainfo(self): 359 | try: 360 | path = self['mediainfo_path'] 361 | if os.name == "nt": 362 | mi = subprocess.Popen([r"mediainfo", path], shell=True, 363 | stdout=subprocess.PIPE 364 | ).communicate()[0].decode('utf8') 365 | else: 366 | mi = subprocess.Popen([r"mediainfo", path], 367 | stdout=subprocess.PIPE 368 | ).communicate()[0].decode('utf8') 369 | except OSError: 370 | sys.stderr.write( 371 | "Error: Media Info not installed, refer to " 372 | "http://mediainfo.sourceforge.net/en for installation") 373 | exit(1) 374 | else: 375 | # Replace absolute path with file name 376 | mi_dir = os.path.dirname(self['mediainfo_path']) + '/' 377 | mi = mi.replace(mi_dir, '') 378 | 379 | # bB's mediainfo parser expects "Xbps" instead of "Xb/s" 380 | mi = mi.replace('Kb/s', 'Kbps') \ 381 | .replace('kb/s', 'Kbps') \ 382 | .replace('Mb/s', 'Mbps') 383 | return mi 384 | 385 | def _render_tracks(self): 386 | video_tracks = [] 387 | audio_tracks = [] 388 | text_tracks = [] 389 | general = None 390 | 391 | mi = pymediainfo.MediaInfo.parse(self['mediainfo_path']) 392 | 393 | for track in mi.tracks: 394 | if track.track_type == 'General': 395 | general = track.to_data() 396 | elif track.track_type == 'Video': 397 | video_tracks.append(track.to_data()) 398 | elif track.track_type == 'Audio': 399 | audio_tracks.append(track.to_data()) 400 | elif track.track_type == 'Text': 401 | text_tracks.append(track.to_data()) 402 | else: 403 | log.debug("Unknown track {}", track) 404 | 405 | assert general is not None 406 | assert len(video_tracks) == 1 407 | video_track = video_tracks[0] 408 | 409 | assert len(audio_tracks) >= 1 410 | 411 | return {'general': general, 412 | 'video': video_track, 413 | 'audio': audio_tracks, 414 | 'text': text_tracks} 415 | 416 | def _render_source(self): 417 | sources = ('BluRay', 'BluRay 3D', 'WEB-DL', 418 | 'WebRip', 'HDTV', 'DVDRip', 'DVDSCR', 'CAM') 419 | # ignored: R5, TeleSync, PDTV, SDTV, BluRay RC, HDRip, VODRip, 420 | # TC, SDTV, DVD5, DVD9, HD-DVD 421 | 422 | # todo: replace with guess from self['guess'] 423 | regpath = self['path'].lower().replace('-', '') 424 | if 'bluray' in regpath: 425 | if '3d' in regpath: 426 | return 'BluRay 3D' 427 | return 'BluRay' 428 | elif 'webrip' in regpath: 429 | return 'WebRip' 430 | elif 'hdtv' in regpath: 431 | return 'HDTV' 432 | elif 'web' in regpath: 433 | return 'WEB-DL' 434 | # elif 'dvdscr' in self['path'].lower(): 435 | # markers['source'] = 'DVDSCR' 436 | else: 437 | print("File:", self['path']) 438 | print("Choices:", format_choices(sources)) 439 | while True: 440 | choice = input("Please specify a source by number: ") 441 | try: 442 | return sources[int(choice)] 443 | except (ValueError, IndexError): 444 | print("Please enter a valid choice") 445 | 446 | def _render_container(self): 447 | general = self['tracks']['general'] 448 | if general['format'] == 'Matroska': 449 | return 'MKV' 450 | elif general['format'] == 'AVI': 451 | return 'AVI' 452 | elif general['format'] == 'MPEG-4': 453 | return 'MP4' 454 | elif general['format'] == 'BDAV': 455 | return 'm2ts' 456 | else: 457 | raise RuntimeError("Unknown or unsupported container '{}'".format( 458 | general.format)) 459 | 460 | def _render_video_codec(self): 461 | video_track = self['tracks']['video'] 462 | codec_id = video_track['codec_id'] 463 | 464 | match_list = [('(V_MPEG4/ISO/)?AVC1?', 'H.264'), 465 | ('(V_MPEGH/ISO/)?HEVC', 'H.265'), 466 | ('(V_MS/VFW/FOURCC / )?WVC1', 'VC-1'), 467 | ('VP9', 'VP9'), 468 | ('XVID', 'XVid'), 469 | ('(MP42|DX[45]0)', 'DivX'), 470 | ('(V_)?MPEG2', 'MPEG-2') 471 | ] 472 | 473 | norm_codec_id = None 474 | for rx, rv in match_list: 475 | rx = re.compile(rx, flags=re.IGNORECASE) 476 | if rx.match(codec_id): 477 | norm_codec_id = rv 478 | break 479 | else: 480 | if video_track['format'] == 'MPEG Video': 481 | if video_track['format_version'] == 'Version 1': 482 | norm_codec_id = 'MPEG-1' 483 | elif video_track['format_version'] == 'Version 2': 484 | norm_codec_id = 'MPEG-2' 485 | elif video_track['format'] == 'AVC': 486 | norm_codec_id = 'H.264' 487 | 488 | # x264/5 is not a codec, but the rules is the rules 489 | if (norm_codec_id == 'H.264' and 490 | 'x264' in video_track.get('writing_library', '')): 491 | return 'x264' 492 | elif (norm_codec_id == 'H.265' and 493 | 'x265' in video_track.get('writing_library', '')): 494 | return 'x265' 495 | elif norm_codec_id: 496 | return norm_codec_id 497 | 498 | msg = "Unknown or unsupported video codec '{}' ({}, {})".format( 499 | video_track.get('codec_id'), 500 | video_track.get('format'), 501 | video_track.get('writing_library')) 502 | raise RuntimeError(msg) 503 | 504 | def _render_audio_codec(self): 505 | audio_track = self['tracks']['audio'][0] # main audio track 506 | if audio_track.get('codec_id_hint') == 'MP3': 507 | return 'MP3' 508 | elif 'Dolby Atmos' in audio_track['commercial_name']: 509 | return 'Dolby Atmos' 510 | elif 'DTS-HD' in audio_track['commercial_name']: 511 | if audio_track.get('other_format', '') == 'DTS XLL X': 512 | return 'DTS:X' 513 | return 'DTS-HD' 514 | 515 | codec_id = audio_track['codec_id'] 516 | if codec_id.startswith('A_'): 517 | codec_id = codec_id[2:] 518 | 519 | match_list = [('(E?AC-?3|2000)', 'AC-3'), 520 | ('DTS', 'DTS'), 521 | ('FLAC', 'FLAC'), 522 | ('(AAC|MP4A)', 'AAC'), 523 | ('(MP3|MPA1L3|55)', 'MP3'), 524 | ('TRUEHD', 'True-HD'), 525 | ('PCM', 'PCM'), 526 | ] 527 | 528 | for rx, rv in match_list: 529 | rx = re.compile(rx, flags=re.IGNORECASE) 530 | if rx.match(codec_id): 531 | return rv 532 | 533 | raise ValueError("Unknown or unsupported audio codec '{}'".format( 534 | audio_track['codec_id'])) 535 | 536 | def _render_resolution(self): 537 | resolutions = ('2160p', '1080p', '720p', '1080i', '720i', 538 | '480p', '480i', 'SD') 539 | 540 | # todo: replace with regex? 541 | # todo: compare result with mediainfo 542 | for res in resolutions: 543 | if res.lower() in self['path'].lower(): 544 | # warning: 'sd' might match any ol' title, but it's last anyway 545 | return res 546 | else: 547 | print("File:", self['path']) 548 | print("Choices:", format_choices(resolutions)) 549 | while True: 550 | choice = input("Please specify a resolution by number: ") 551 | try: 552 | return resolutions[int(choice)] 553 | except (ValueError, IndexError): 554 | print("Please enter a valid choice") 555 | # from mediainfo and filename 556 | 557 | def _render_additional(self): 558 | additional = [] 559 | video_track = self['tracks']['video'] 560 | audio_tracks = self['tracks']['audio'] 561 | text_tracks = self['tracks']['text'] 562 | 563 | # print [(track.title, track.language) for track in text_tracks] 564 | # todo: rule checking, e.g. 565 | # main_audio = audio_tracks[0] 566 | # if (main_audio.language and main_audio.language != 'en' and 567 | # not self['tracks']['text']): 568 | # raise BrokenRule("Missing subtitles") 569 | 570 | if 'remux' in os.path.basename(self['path']).lower(): 571 | additional.append('REMUX') 572 | 573 | if self['guess'].get('proper_count') and self['scene']: 574 | additional.append('PROPER') 575 | 576 | edition = self['guess'].get('edition') 577 | if isinstance(edition, str): 578 | additional.append(edition) 579 | elif isinstance(edition, abc.Sequence): 580 | additional.extend(edition) 581 | 582 | if 'BT.2020' in video_track.get('color_primaries', ''): 583 | additional.append('HDR10') 584 | 585 | for track in audio_tracks[1:]: 586 | if 'title' in track and 'commentary' in track['title'].lower(): 587 | additional.append('w. Commentary') 588 | break 589 | if text_tracks: 590 | additional.append('w. Subtitles') 591 | 592 | return additional 593 | 594 | def _render_form_release_info(self): 595 | return " / ".join(self['additional']) 596 | 597 | @finalize 598 | @form_field('image') 599 | def _render_cover(self): 600 | return self['summary']['cover'] 601 | 602 | def _finalize_cover(self): 603 | return imagehosting.upload(self['cover']) 604 | 605 | 606 | class TvSubmission(VideoSubmission): 607 | default_fields = VideoSubmission.default_fields + ('form_description',) 608 | _cat_id = 'tv' 609 | _form_type = 'TV' 610 | __form_fields__ = { 611 | 'form_title': ('title', 'text'), 612 | 'form_description': ('desc', 'text'), 613 | } 614 | 615 | @property 616 | def season(self): 617 | return self['tv_specifier'].season 618 | 619 | def _render_guess(self): 620 | return dict(guessit.guessit(self['path'], 621 | options=('--type', 'episode'))) 622 | 623 | def _render_search_title(self): 624 | return self['tv_specifier'].title 625 | 626 | def _render_tvdb_id(self): 627 | return None 628 | 629 | def subcategory(self): 630 | if type(self) == TvSubmission: 631 | if self['tv_specifier'].episode is None: 632 | return SeasonSubmission 633 | else: 634 | return EpisodeSubmission 635 | 636 | return type(self) 637 | 638 | @staticmethod 639 | def tvdb_title_i18n(result): 640 | try: 641 | tvdb_sum = result.summary() 642 | imdb_id = tvdb_sum['show_imdb_id'] 643 | i = imdb.IMDB() 644 | imdb_info = i.get_info(imdb_id) 645 | except Exception as e: 646 | log.error(e) 647 | return {'titles': {}} 648 | 649 | imdb_sum = imdb_info.summary() 650 | tvdb_title = tvdb_sum['title'] 651 | titles_d = {} 652 | # Original title 653 | titles_d['title'] = imdb_sum['title'] 654 | # dict of international titles 655 | titles_d['titles'] = imdb_sum['titles'] 656 | # "XWW" is IMDb's international title, but unlike TVDB, it doesn't 657 | # include the year if there are multiple shows with the same name. 658 | if 'XWW' in titles_d['titles']: 659 | titles_d['titles']['XWW'] = tvdb_title 660 | return titles_d 661 | 662 | def _render_markers(self): 663 | return [self['source'], self['video_codec'], 664 | self['audio_codec'], self['container'], 665 | self['resolution']] + self['additional'] 666 | 667 | def _render_description(self): 668 | sections = [("Description", self['section_description']), 669 | ("Information", self['section_information'])] 670 | 671 | description = "\n".join(bb.section(*s) for s in sections) 672 | description += bb.release 673 | return description 674 | 675 | @form_field('desc') 676 | def _render_form_description(self): 677 | ss = "".join(map(bb.img, self['screenshots'])) 678 | return (self['description'] + "\n" + 679 | bb.section("Screenshots", bb.center(ss)) + 680 | bb.mi(self['mediainfo'])) 681 | 682 | 683 | class EpisodeSubmission(TvSubmission): 684 | @property 685 | def episodes(self): 686 | episodes = self['tv_specifier'].episode 687 | if isinstance(episodes, abc.Sequence): 688 | return episodes 689 | return [episodes] 690 | 691 | @form_field('title') 692 | def _render_form_title(self): 693 | return "{t} S{s:02d}{es} [{m}]".format( 694 | t=self['title'], s=self.season, 695 | es="".join("E{:02d}".format(e) 696 | for e in self.episodes), 697 | m=" / ".join(self['markers'])) 698 | 699 | def _render_summary(self): 700 | t = tvdb.TVDB() 701 | results = t.search(self['tv_specifier'], self['tvdb_id']) 702 | title_i18n = self.tvdb_title_i18n(results[0]) 703 | summaries = [] 704 | show_summary = results[0].show_summary() 705 | for result in results: 706 | summary = result.summary() 707 | summaries.append(summary) 708 | 709 | ks = summaries[0].keys() 710 | assert all(s.keys() == ks for s in summaries) 711 | summary = {k: [s[k] for s in summaries] for k in ks} 712 | summary.update(**show_summary) 713 | summary.update(**title_i18n) 714 | summary['cover'] = summary['cover'][0] 715 | directors = uniq([n for names in summary['directors'] for n in names]) 716 | summary['directors'] = [{'name': name} for name in directors] 717 | writers = uniq([n for names in summary['writers'] for n in names]) 718 | summary['writers'] = [{'name': name} for name in writers] 719 | return summary 720 | 721 | def _render_section_description(self): 722 | summary = self['summary'] 723 | return (summary['seriessummary'] + 724 | "".join(bb.spoiler(es, "Episode description") 725 | for es in summary['episodesummary'])) 726 | 727 | def _render_section_information(self): 728 | s = self['summary'] 729 | links = [[('TVDB', u)] for u in s['url']] 730 | rating_bb = [] 731 | 732 | for i, imdb_id in enumerate(s['imdb_id']): 733 | if imdb_id: 734 | links[i].append( 735 | ('IMDb', "https://www.imdb.com/title/" + imdb_id)) 736 | 737 | i = imdb.IMDB() 738 | rating, votes = i.get_rating(imdb_id) 739 | 740 | rating_bb.append( 741 | (bb.format_rating(rating[0], max=rating[1]) + " " + 742 | bb.s1("({votes} votes)".format(votes=votes)))) 743 | else: 744 | rating_bb.append("") 745 | 746 | description = dedent("""\ 747 | [b]Episode titles[/b]: {title} 748 | [b]Aired[/b]: {air_date} on {network} 749 | [b]IMDb Rating[/b]: {rating} 750 | [b]Directors[/b]: {directors} 751 | [b]Writer(s)[/b]: {writers} 752 | [b]Content rating[/b]: {contentrating}""").format( 753 | title=' | '.join( 754 | "{} ({})".format( 755 | t, ", ".join(bb.link(*l) for l in ls)) # noqa: E741 756 | for t, ls in zip(s['episode_title'], links)), 757 | air_date=' | '.join(s['air_date']), 758 | network=s['network'], 759 | rating=' | '.join(rating_bb), 760 | directors=' | '.join(d['name'] for d in s['directors']), 761 | writers=' | '.join(w['name'] for w in s['writers']), 762 | contentrating=s['contentrating'] 763 | ) 764 | return description 765 | 766 | 767 | class SeasonSubmission(TvSubmission): 768 | @form_field('title') 769 | def _render_form_title(self): 770 | return "{t} - Season {s} [{m}]".format( 771 | t=self['title'], 772 | s=self['tv_specifier'].season, 773 | m=" / ".join(self['markers'])) 774 | 775 | def _render_summary(self): 776 | t = tvdb.TVDB() 777 | result = t.search(self['tv_specifier'], self['tvdb_id']) 778 | summary = result.summary() 779 | summary.update(self.tvdb_title_i18n(result)) 780 | return summary 781 | 782 | def _render_section_description(self): 783 | summary = self['summary'] 784 | return summary['seriessummary'] 785 | 786 | def _render_section_information(self): 787 | s = self['summary'] 788 | links = [('TVDB', s['url'])] 789 | 790 | imdb_id = s.get('show_imdb_id') 791 | if imdb_id: 792 | links.append(('IMDb', 793 | "https://www.imdb.com/title/" + imdb_id)) 794 | 795 | description = dedent("""\ 796 | [b]Network[/b]: {network} 797 | [b]Content rating[/b]: {contentrating}\n""").format( 798 | contentrating=s['contentrating'], 799 | network=s['network'], 800 | ) 801 | 802 | i = imdb.IMDB() 803 | # todo unify rating_bb and episode_fmt 804 | 805 | def episode_fmt(e): 806 | if not e['imdb_id']: 807 | return bb.link(e['title'], e['url']) + "\n" 808 | 809 | try: 810 | rating, votes = i.get_rating(e['imdb_id']) 811 | except ValueError: 812 | return '' 813 | else: 814 | return (bb.link(e['title'], e['url']) + "\n" + 815 | bb.s1(bb.format_rating(*rating))) 816 | 817 | with ThreadPoolExecutor() as executor: 818 | episodes = executor.map(episode_fmt, s['episodes']) 819 | description += "[b]Episodes[/b]:\n" + bb.list(episodes, style=1) 820 | return description 821 | 822 | 823 | class MovieSubmission(VideoSubmission): 824 | default_fields = (VideoSubmission.default_fields + 825 | ("description", "mediainfo", "screenshots")) 826 | _cat_id = 'movie' 827 | _form_type = 'Movies' 828 | __form_fields__ = { 829 | # field -> form field, type 830 | 'source': ('source', 'text'), 831 | 'video_codec': ('videoformat', 'text'), 832 | 'audio_codec': ('audioformat', 'text'), 833 | 'container': ('container', 'text'), 834 | 'resolution': ('resolution', 'text'), 835 | 'form_release_info': ('remaster_title', 'text'), 836 | 'mediainfo': ('release_desc', 'text'), 837 | 'screenshots': (lambda i, v: 'screenshot' + str(i + 1), 'text'), 838 | } 839 | 840 | def _render_guess(self): 841 | return dict(guessit.guessit(self['path'], 842 | options=('--type', 'movie'))) 843 | 844 | def _render_search_title(self): 845 | if self['title_arg']: 846 | return self['title_arg'] 847 | 848 | return self['guess']['title'] 849 | 850 | @form_field('title') 851 | def _render_form_title(self): 852 | return self['title'] 853 | 854 | @form_field('year') 855 | def _render_year(self): 856 | if 'summary' in self.fields: 857 | return self['summary']['year'] 858 | 859 | elif 'year' in self['guess']: 860 | return self['guess']['year'] 861 | 862 | else: 863 | while True: 864 | year = input('Please enter year: ') 865 | try: 866 | year = int(year) 867 | except ValueError: 868 | pass 869 | else: 870 | return year 871 | 872 | def _render_summary(self): 873 | i = imdb.IMDB() 874 | movie = i.search(self['search_title']) 875 | return movie.summary() 876 | 877 | def _render_section_information(self): 878 | def imdb_link(r): 879 | return bb.link(r['name'], "https://www.imdb.com"+r['id']) 880 | 881 | # todo: synopsis/longer description 882 | n = self['options']['num_cast'] 883 | summary = self['summary'] 884 | metacritic = summary['metacritic'] 885 | links = [("IMDb", summary['url'])] 886 | 887 | try: 888 | links.append(("Metacritic", metacritic['metacriticUrl'])) 889 | except (TypeError, KeyError): 890 | pass 891 | 892 | return dedent("""\ 893 | [b]Title[/b]: {name} ({links}) 894 | [b]MPAA[/b]: {mpaa} 895 | [b]IMDb rating[/b]: {rating} [size=1]({votes} votes)[/size] 896 | [b]Metacritic[/b]: {metascore} [size=1]({metacount} reviews)[/size] | \ 897 | {metauser} [size=1]({metavotes} votes)[/size] 898 | [b]Runtime[/b]: {runtime} 899 | [b]Director(s)[/b]: {directors} 900 | [b]Writer(s)[/b]: {writers} 901 | [b]Cast[/b]: {cast}""").format( 902 | links=", ".join(bb.link(*l) for l in links), # noqa: E741 903 | name=summary['name'], 904 | mpaa=summary['mpaa'], 905 | rating=bb.format_rating(summary['rating'][0], 906 | max=summary['rating'][1]), 907 | metascore=str(metacritic.get('metaScore')), 908 | metacount=str(metacritic.get('reviewCount', 0)), 909 | metauser=str(metacritic.get('userScore')), 910 | metavotes=str(metacritic.get('userRatingCount', 0)), 911 | votes=summary['votes'], 912 | runtime=summary['runtime'], 913 | directors=" | ".join(imdb_link(d) for d in summary['directors']), 914 | writers=" | ".join(imdb_link(w) for w in summary['writers']), 915 | cast=" | ".join(imdb_link(a) for a in summary['cast'][:n]) 916 | ) 917 | 918 | def _render_section_description(self): 919 | s = self['summary'] 920 | return s['description'] 921 | 922 | def _render_description(self): 923 | # todo: templating, rottentomatoes, ... 924 | 925 | sections = [("Description", self['section_description']), 926 | ("Information", self['section_information'])] 927 | 928 | description = "\n".join(bb.section(*s) for s in sections) 929 | description += bb.release 930 | 931 | return description 932 | 933 | @form_field('desc') 934 | def _render_form_description(self): 935 | return self['description'] 936 | 937 | 938 | class AudioSubmission(BbSubmission): 939 | default_fields = ("description", "form_tags", "year", "cover", 940 | "title", "format", "bitrate") 941 | 942 | def subcategory(self): 943 | release, rg = self['release'] 944 | 945 | if 'Audiobook' in rg.get('secondary-type-list', []): 946 | return AudiobookSubmission 947 | return MusicSubmission 948 | 949 | @form_field('format') 950 | def _render_format(self): 951 | # MP3, FLAC, Ogg, AAC, DTS 5.1 Audio, 24bit FLAC 952 | # choices = ('MP3', 'FLAC', 'Ogg', 'AAC', '24bit FLAC') 953 | 954 | tl_format = { 955 | 'MP3': 'MP3', 956 | 'EasyMP3': 'MP3', 957 | 'OggVorbis': 'Ogg', 958 | 'OggOpus': 'Ogg', 959 | 'FLAC': 'FLAC', 960 | 'AAC': 'AAC', 961 | } 962 | 963 | tags = self['tags'] 964 | format = tl_format[tags['format']] 965 | if format == 'FLAC' and tags['bits_per_sample'] >= 24: 966 | format = '24bit FLAC' 967 | 968 | return format 969 | 970 | @form_field('bitrate') 971 | def _render_bitrate(self): 972 | # 192, V2 (VBR), 256, V0 (VBR), 320, Lossless, Other) 973 | format = self['format'] 974 | tags = self['tags'] 975 | if format == 'MP3': 976 | br_mode = tags['bitrate_mode'] 977 | enc_settings = tags['encoder_settings'] 978 | if ('-V 0' in enc_settings or 979 | 'preset extereme' in enc_settings): 980 | return 'V0 (VBR)' 981 | elif ('-V 2' in enc_settings or 982 | 'preset standard' in enc_settings): 983 | return 'V2 (VBR)' 984 | elif br_mode in [mutagen.mp3.BitrateMode.CBR, 985 | mutagen.mp3.BitrateMode.UNKNOWN]: 986 | if abs(tags['bitrate']-192000) < 100: 987 | return '192' 988 | elif abs(tags['bitrate']-256000) < 100: 989 | return '256' 990 | elif abs(tags['bitrate']-320000) < 100: 991 | return '320' 992 | log.debug("br_mode: {}\nenc_settings: {}", br_mode, enc_settings) 993 | 994 | elif 'FLAC' in format: 995 | return 'Lossless' 996 | 997 | log.debug("format:{}\ntags:{}", format, tags) 998 | log.notice('Unrecognized format/bitrate, assuming "Other"') 999 | return 'Other' 1000 | 1001 | def _render_mediainfo_path(self): 1002 | assert os.path.isdir(self['path']) 1003 | 1004 | # get first file over 1 MiB 1005 | for dp, _, fns in os.walk(self['path']): 1006 | for fn in fns: 1007 | g = guess_type(fn)[0] 1008 | if g and g.startswith('audio'): 1009 | return os.path.join(dp, fn) # return full path 1010 | raise Exception('No media file found') 1011 | 1012 | def _render_tracklist(self): 1013 | release, _ = self['release'] 1014 | full_tracklist = [] 1015 | mediumlist = release['medium-list'] 1016 | 1017 | DEFAULT_FORMAT = 'CD' 1018 | for medium in mediumlist: 1019 | log.debug('medium {}', medium.keys()) 1020 | title = medium.get('format', DEFAULT_FORMAT) 1021 | if len(mediumlist) > 1: 1022 | title += " {}".format(medium['position']) 1023 | if 'title' in medium: 1024 | title += ": {}".format(medium['title']) 1025 | 1026 | tracklist = [ 1027 | (t['number'], t['recording']['title'], 1028 | timedelta(milliseconds=int(t['recording']['length']))) 1029 | for t in medium['track-list']] 1030 | full_tracklist.append((title, tracklist)) 1031 | 1032 | return full_tracklist 1033 | 1034 | def _render_tags(self): 1035 | tags = mutagen.File(self['mediainfo_path'], easy=True) 1036 | # if type(tags) == mutagen.mp3.MP3: 1037 | # tags = mutagen.mp3.MP3(self['mediainfo_path'], ID3=EasyID3) 1038 | 1039 | log.debug('tagsdir', dir(tags.info)) 1040 | log.debug('type tags', type(tags)) 1041 | log.debug('tags', tags.pprint()) 1042 | 1043 | return {'artist': tags.get('albumartist', tags['artist'])[0], 1044 | 'title': tags['album'][0], 1045 | 'rid': tags.get('musicbrainz_albumid', [None])[0], 1046 | 'format': type(tags).__name__, 1047 | 'bitrate': tags.info.bitrate, 1048 | 'bitrate_mode': getattr(tags.info, 'bitrate_mode', None), 1049 | 'bits_per_sample': getattr(tags.info, 'bits_per_sample', 1050 | None), 1051 | 'encoder_info': getattr(tags.info, 'encoder_info', None), 1052 | 'encoder_settings': getattr(tags.info, 'encoder_settings', 1053 | None), 1054 | } 1055 | 1056 | def _render_release(self): 1057 | tags = self['tags'] 1058 | if tags['rid']: 1059 | log.info('Found MusicBrainz release in tags') 1060 | release = mb.musicbrainzngs.get_release_by_id( 1061 | tags['rid'], 1062 | includes=['release-groups', 'media', 'recordings', 1063 | 'url-rels'])['release'] 1064 | rg = mb.musicbrainzngs.get_release_group_by_id( 1065 | release['release-group']['id'], 1066 | includes=['tags', 'artist-credits', 'url-rels'] 1067 | )['release-group'] 1068 | 1069 | else: 1070 | if self['title_arg']: 1071 | query_artist = None 1072 | query = self['title_arg'] 1073 | else: 1074 | query_artist = tags['artist'] 1075 | query = tags['title'] 1076 | release, rg = mb.find_release(query, artist=query_artist) 1077 | 1078 | # identify self: 1079 | # - num tracks todo 1080 | # - scan for mb tags 1081 | log.debug('release-group {}', rg) 1082 | log.debug('release', release) 1083 | 1084 | # todo: assert release group matches! 1085 | # e.g.: assert # of tracks equal 1086 | # and if not, generate basic info from release group only 1087 | 1088 | return release, rg 1089 | 1090 | def _render_summary(self): 1091 | release, rg = self['release'] 1092 | 1093 | return { 1094 | 'artist': rg['artist-credit-phrase'], 1095 | 'title': rg['title'], 1096 | 'year': rg['first-release-date'][:4], 1097 | 'tags': [t['name'] for t in 1098 | sorted(rg.get('tag-list', []), 1099 | key=lambda t: int(t['count']))][-5:], 1100 | 'media': ([m.get('format', 'CD') for m in release['medium-list']] 1101 | if release else None), 1102 | } 1103 | 1104 | @finalize 1105 | @form_field('image') 1106 | def _render_cover(self): 1107 | release, rg = self['release'] 1108 | cover = None 1109 | if release: 1110 | cover = mb.get_release_cover(release['id']) 1111 | cover = cover or mb.get_release_group_cover(rg['id']) 1112 | 1113 | if cover is None: 1114 | cover = input('No cover art found, please manually type cover ' 1115 | 'location: ') 1116 | return cover 1117 | 1118 | def _finalize_cover(self): 1119 | return imagehosting.upload(self['cover']) 1120 | 1121 | @form_field('year') 1122 | def _render_year(self): 1123 | return self['summary']['year'] 1124 | 1125 | def _render_links(self): 1126 | release, rg = self['release'] 1127 | try: 1128 | return rg['url-relation-list'] 1129 | except KeyError: 1130 | log.warning('No links found for release group, trying release.') 1131 | 1132 | try: 1133 | return release['url-relation-list'] 1134 | except KeyError: 1135 | log.warning('No links found for release.') 1136 | return [] 1137 | 1138 | def _render_section_information(self): 1139 | release, rg = self['release'] 1140 | urls = self['links'] 1141 | mb_link = "https://musicbrainz.org/release-group/" + rg['id'] 1142 | urls.insert(0, {'type': 'MusicBrainz', 'target': mb_link}) 1143 | return dedent("""\ 1144 | [b]Title[/b]: {title} ({links}) 1145 | [b]Artist(s)[/b]: {artist} 1146 | [b]Type[/b]: {type} 1147 | [b]Original release[/b]: {firstrel}""").format( 1148 | title=rg['title'], 1149 | artist=rg['artist-credit-phrase'], 1150 | links=", ".join(bb.link(u['type'], u['target']) for u in urls), 1151 | type=rg['type'], 1152 | firstrel=rg['first-release-date'], 1153 | ) 1154 | 1155 | def _render_section_tracklist(self): 1156 | s = "" 1157 | for title, tracks in self['tracklist']: 1158 | s += title 1159 | s += bb.table("".join(bb.tr(bb.td(i) + 1160 | bb.td(tt) + 1161 | bb.td(str(l).split(".")[0])) 1162 | for i, tt, l in tracks)) 1163 | 1164 | return s 1165 | 1166 | @form_field('album_desc') 1167 | def _render_description(self): 1168 | sections = [("Information", self['section_information'])] 1169 | 1170 | description = "\n".join(bb.section(*s) for s in sections) 1171 | description += bb.release 1172 | 1173 | return description 1174 | 1175 | @form_field('release_desc') 1176 | def _render_release_desc(self): 1177 | release, rg = self['release'] 1178 | tags = self['tags'] 1179 | s = dedent("""\ 1180 | [b]MusicBrainz[/b]: [url]{release}[/url] 1181 | [b]Status[/b]: {status} 1182 | [b]Release[/b]: {thisrel} ({country})""").format( 1183 | release="https://musicbrainz.org/release/" + release['id'], 1184 | status=release.get('status', "Unknown"), 1185 | thisrel=release.get('date', "Unknown"), 1186 | country=release.get('country', "Unknown"), 1187 | ) 1188 | 1189 | if tags['encoder_info']: 1190 | s += "\n[b]Encoder[/b]: " + tags['encoder_info'] 1191 | 1192 | if tags['encoder_settings']: 1193 | s += "\n[b]Encoder settings[/b]: " + tags['encoder_settings'] 1194 | 1195 | sections = [("Release Information", s), 1196 | ("Track list", self['section_tracklist'])] 1197 | 1198 | description = "\n".join(bb.section(*s) for s in sections) 1199 | description += bb.release 1200 | 1201 | return description 1202 | 1203 | @form_field('scene', 'checkbox') 1204 | def _render_scene(self): 1205 | return False 1206 | 1207 | def _get_tags(self, required_tags): 1208 | tags = self['summary']['tags'] 1209 | if not tags: 1210 | tags = input("No tags found. Please enter tags " 1211 | "(comma-separated): ").split(',') 1212 | tags = set(format_tag(tag) for tag in tags) 1213 | tags -= {'audiobook'} 1214 | while True: 1215 | try: 1216 | assert tags & required_tags != set() 1217 | except AssertionError: 1218 | print("Default tags:\n" + ", ".join(sorted(required_tags))) 1219 | print("Submission must contain at least one default tag.") 1220 | tags = rlinput("Enter tags: ", ",".join(tags)).split(',') 1221 | tags = set(format_tag(tag) for tag in tags) 1222 | else: 1223 | return ",".join(tags) 1224 | 1225 | 1226 | class AudiobookSubmission(AudioSubmission): 1227 | _cat_id = 'audiobook' 1228 | _form_type = 'Audiobooks' 1229 | 1230 | @form_field('tags') 1231 | def _render_form_tags(self): 1232 | _defaults = {'fiction', 'non.fiction'} 1233 | return self._get_tags(_defaults) 1234 | 1235 | @form_field('title') 1236 | def _render_title(self): 1237 | return "{} - {}".format( 1238 | self['summary']['artist'], self['summary']['title']) 1239 | 1240 | 1241 | class MusicSubmission(AudioSubmission): 1242 | default_fields = (AudioSubmission.default_fields + ( 1243 | 'artist', 'remaster', 'remaster_year', 'remaster_title', 'media',)) 1244 | _cat_id = 'music' 1245 | _form_type = 'Music' 1246 | 1247 | @form_field('remaster_true', 'checkbox') 1248 | def _render_remaster(self): 1249 | # todo user input function/module to reduce boilerplating 1250 | return bool( 1251 | input('Is this a special/remastered edition? [y/N] ').lower() 1252 | == 'y') 1253 | 1254 | @form_field('remaster_year') 1255 | def _render_remaster_year(self): 1256 | if self['remaster']: 1257 | return input('Please enter the remaster year: ') 1258 | 1259 | @form_field('remaster_title') 1260 | def _render_remaster_title(self): 1261 | if self['remaster']: 1262 | return (input('Please enter the remaster title (optional): ') 1263 | or None) 1264 | 1265 | @form_field('media') 1266 | def _render_media(self): 1267 | # choices = ['CD', 'DVD', 'Vinyl', 'Soundboard', 'DAT', 'Web'] 1268 | 1269 | media = self['summary']['media'] 1270 | if len(media) > 1: 1271 | log.debug(media) 1272 | media = media[0] 1273 | 1274 | if media == 'CD': 1275 | return media 1276 | elif media == 'Digital Media': 1277 | return 'Web' 1278 | elif "vinyl" in media.lower(): 1279 | return 'Vinyl' 1280 | 1281 | raise NotImplementedError(media) 1282 | 1283 | @form_field('tags') 1284 | def _render_form_tags(self): 1285 | _defaults = { 1286 | 'acoustic', 'alternative', 'ambient', 'blues', 'classic.rock', 1287 | 'classical', 'country', 'dance', 'dubstep', 'electronic', 1288 | 'experimental', 'folk', 'funk', 'hardcore', 'heavy.metal', 1289 | 'hip.hop', 'indie', 'indie.pop', 'instrumental', 'jazz', 'metal', 1290 | 'pop', 'post.hardcore', 'post.rock', 'progressive.rock', 1291 | 'psychedelic', 'punk', 'reggae', 'rock', 'soul', 'trance', 1292 | 'trip.hop'} 1293 | return self._get_tags(_defaults) 1294 | 1295 | @form_field('artist') 1296 | def _render_artist(self): 1297 | return self['summary']['artist'] 1298 | 1299 | @form_field('title') 1300 | def _render_title(self): 1301 | return self['summary']['title'] 1302 | -------------------------------------------------------------------------------- /pythonbits/config.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from io import open 3 | from os import path, chmod, makedirs 4 | 5 | import configparser 6 | import getpass 7 | import appdirs 8 | 9 | from . import __title__ as appname 10 | from .logging import log 11 | 12 | CONFIG_NAME = appname.lower() + '.cfg' 13 | CONFIG_DIR = appdirs.user_config_dir(appname.lower()) 14 | CONFIG_PATH = path.join(CONFIG_DIR, CONFIG_NAME) 15 | CONFIG_VERSION = 1 16 | DEFAULT = object() 17 | 18 | if not path.exists(CONFIG_DIR): 19 | makedirs(CONFIG_DIR, 0o700) 20 | 21 | 22 | class ConfidentialOption(Exception): 23 | pass 24 | 25 | 26 | class UnregisteredOption(Exception): 27 | pass 28 | 29 | 30 | class Config(): 31 | registry = {} 32 | 33 | def __init__(self, config_path=None): 34 | self.config_path = config_path or CONFIG_PATH 35 | self._config = configparser.ConfigParser(allow_no_value=True) 36 | 37 | def register(self, section, option, query, ask=False, getpass=False): 38 | self.registry[(section, option)] = { 39 | 'query': query, 'ask': ask, 'getpass': getpass} 40 | 41 | def _write(self): 42 | with open(self.config_path, 'w') as configfile: 43 | self._config.write(configfile) 44 | chmod(self.config_path, 0o600) 45 | 46 | def set(self, section, option, value): 47 | if not self._config.has_section(section): 48 | self._config.add_section(section) 49 | self._config.set(section, option, value) 50 | self._write() 51 | 52 | def get(self, section, option, default=DEFAULT): 53 | self._config.read(self.config_path) 54 | 55 | try: 56 | value = self._config.get(section, option) 57 | if value is None: 58 | raise ConfidentialOption 59 | return value 60 | except (configparser.NoSectionError, configparser.NoOptionError, 61 | ConfidentialOption) as e: 62 | # if getter default is set, return it instead 63 | if default is not DEFAULT: 64 | return default 65 | 66 | # get registered config option 67 | try: 68 | reg_option = self.registry[(section, option)] 69 | except KeyError: 70 | raise UnregisteredOption((section, option)) 71 | 72 | # get value from user query 73 | if reg_option['getpass']: 74 | value = getpass.getpass(reg_option['query'] + ": ") 75 | else: 76 | value = input(reg_option['query'] + ": ").strip() 77 | 78 | # user does not want to be prompted to save this option 79 | if isinstance(e, ConfidentialOption): 80 | return value 81 | 82 | # user has choice ('ask') to save option value 83 | if reg_option['ask']: 84 | c = input('Would you like to save this value in {}? ' 85 | 'nr = no, and remember choice\n' 86 | '[Y/n/nr]'.format(self.config_path)).lower() 87 | 88 | if c == 'n': 89 | return value 90 | elif c == 'nr': 91 | self.set(section, option, None) 92 | return value 93 | 94 | self.set(section, option, value) 95 | return value 96 | 97 | 98 | def backup(config): 99 | from datetime import datetime 100 | t = datetime.now() 101 | 102 | p = config.config_path 103 | config.config_path = (config.config_path + "." + 104 | t.strftime("%Y-%m-%dT%H-%M-%S") + '.bak') 105 | config._write() 106 | log.notice('Old config backed up at {}', config.config_path) 107 | config.config_path = p 108 | 109 | 110 | def imgur_api_change(config): 111 | if config.get('Imgur', 'client_id', None) is not None: 112 | config._config.remove_section('Imgur') 113 | config._write() 114 | else: 115 | log.warning('section already removed') 116 | 117 | 118 | def migrate_config(config): 119 | migrations = {0: (1, imgur_api_change)} 120 | version_args = lambda v: ('General', 'version', v) # noqa: E731 121 | 122 | cur = int(config.get(*version_args(0))) 123 | if cur in migrations: 124 | backup(config) 125 | while True: 126 | cur = int(config.get(*version_args(0))) 127 | try: 128 | new, mig = migrations[cur] 129 | except KeyError: 130 | break 131 | else: 132 | log.notice('Migrating config from {} to {}'.format(cur, new)) 133 | mig(config) 134 | config.set(*version_args(str(new))) 135 | 136 | 137 | config = Config() 138 | migrate_config(config) 139 | -------------------------------------------------------------------------------- /pythonbits/ffmpeg.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | ffmpeg.py 4 | 5 | Created by Ichabond on 2012-07-01. 6 | Copyright (c) 2012 Baconseed. All rights reserved. 7 | """ 8 | import os 9 | import subprocess 10 | import re 11 | 12 | from tempfile import mkdtemp 13 | from concurrent.futures.thread import ThreadPoolExecutor 14 | 15 | 16 | class FfmpegException(Exception): 17 | pass 18 | 19 | 20 | class FFMpeg(object): 21 | def __init__(self, filepath): 22 | self.file = filepath 23 | self.ffmpeg = None 24 | self.tempdir = mkdtemp(prefix="pythonbits-") + os.sep 25 | 26 | def duration(self): 27 | self.ffmpeg = subprocess.Popen([r"ffmpeg", "-i", self.file], 28 | stdout=subprocess.PIPE, 29 | stderr=subprocess.STDOUT) 30 | ffmpeg_out = self.ffmpeg.stdout.read().decode('utf8') 31 | ffmpeg_duration = re.findall( 32 | r'Duration:\D(\d{2}):(\d{2}):(\d{2})', ffmpeg_out) 33 | if not ffmpeg_duration: 34 | raise FfmpegException("ffmpeg output did not contain 'Duration'") 35 | dur = ffmpeg_duration[0] 36 | dur_hh = int(dur[0]) 37 | dur_mm = int(dur[1]) 38 | dur_ss = int(dur[2]) 39 | return dur_hh * 3600 + dur_mm * 60 + dur_ss 40 | 41 | def make_screenshot(self, seek, fname_out): 42 | subprocess.Popen( 43 | [r"ffmpeg", 44 | "-ss", str(seek), 45 | "-i", self.file, 46 | "-vframes", "1", 47 | "-y", 48 | "-f", "image2", 49 | "-vf", "scale='max(sar,1)*iw':'max(1/sar,1)*ih'", fname_out], 50 | stdout=subprocess.PIPE, stderr=subprocess.STDOUT).communicate() 51 | return fname_out 52 | 53 | def take_screenshots(self, num_screenshots): 54 | duration = self.duration() 55 | stops = range(20, 81, 60 // (num_screenshots - 1)) 56 | 57 | with ThreadPoolExecutor() as executor: 58 | imgs = executor.map( 59 | lambda x: self.make_screenshot(x[0], x[1]), 60 | [(duration * stop / 100, 61 | os.path.join(self.tempdir, "screen%s.png" % stop)) 62 | for stop in stops]) 63 | return list(imgs) 64 | -------------------------------------------------------------------------------- /pythonbits/imagehosting.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from .config import config 3 | 4 | from .imgur import ImgurUploader 5 | from .ptpimg import PtpImgUploader 6 | 7 | config.register('ImageHosting', 'provider', 8 | "Enter a provider for image hosting, supported options are " 9 | "ptpimg or imgur", 10 | ask=True) 11 | 12 | 13 | def get_provider(): 14 | provider = config.get('ImageHosting', 'provider') 15 | if provider.lower() == 'imgur': 16 | return ImgurUploader 17 | elif provider.lower() == 'ptpimg': 18 | return PtpImgUploader 19 | raise Exception('Unknown image hosting provider in config {}'.format( 20 | config.config_path 21 | )) 22 | 23 | 24 | def upload(*images, uploader=None): 25 | if not uploader: 26 | provider = get_provider() 27 | uploader = provider() 28 | upload_gen = uploader.upload(*images) 29 | if len(images) == 1: 30 | return next(upload_gen) 31 | return list(upload_gen) 32 | -------------------------------------------------------------------------------- /pythonbits/imdb.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from concurrent.futures import ThreadPoolExecutor 3 | 4 | import imdbpie 5 | from attrdict import AttrDict 6 | 7 | from .logging import log 8 | 9 | 10 | def get(o, *attrs, **kwargs): 11 | rv = o 12 | used = [] 13 | for a in attrs: 14 | used.append(a) 15 | try: 16 | rv = rv[a] 17 | except KeyError: 18 | log.warning('Cannot get {}: {} missing from IMDb API response', 19 | ".".join(attrs), ".".join(used)) 20 | return kwargs.get('default') 21 | return rv 22 | 23 | 24 | class ImdbResult(object): 25 | def __init__(self, movie): 26 | log.debug("ImdbResult {}", movie) 27 | self.movie = movie 28 | 29 | @property 30 | def description(self): 31 | outline = get(self.movie, 'plot', 'outline') 32 | if outline: 33 | return outline['text'] 34 | summaries = get(self.movie, 'plot', 'summaries') 35 | if summaries: 36 | return summaries[0]['text'] 37 | 38 | @property 39 | def runtime(self): 40 | runtime = get(self.movie, 'base', 'runningTimeInMinutes') 41 | return runtime and str(runtime) + " min" 42 | 43 | @property 44 | def url(self): 45 | movie_id = get(self.movie, 'base', 'id') 46 | if movie_id: 47 | return "https://www.imdb.com" + movie_id 48 | 49 | @property 50 | def cast(self): 51 | cast = get(self.movie, 'credits', 'cast', default=[]) 52 | stars = get(self.movie, 'stars', default=[]) 53 | star_ids = set(star['id'] for star in stars) 54 | return stars + [actor for actor in cast if actor['id'] not in star_ids] 55 | 56 | @property 57 | def mpaa_rating(self): 58 | try: 59 | return self.movie.certificate.certificate 60 | except Exception: 61 | return 'Not rated' 62 | 63 | def summary(self): 64 | return { 65 | 'title': get(self.movie, 'base', 'title'), 66 | 'titles': get(self.movie, 'titles'), 67 | 'directors': get(self.movie, 'credits', 'director', default=[]), 68 | 'runtime': self.runtime, 69 | 'rating': (get(self.movie, 'ratings', 'rating'), 10), 70 | 'metacritic': get(self.movie, 'metacriticScore'), 71 | 'name': get(self.movie, 'base', 'title'), 72 | 'votes': get(self.movie, 'ratings', 'ratingCount', default=0), 73 | 'cover': get(self.movie, 'base', 'image', 'url'), 74 | 'genres': get(self.movie, 'genres', default=[]), 75 | 'cast': self.cast, 76 | 'writers': get(self.movie, 'credits', 'writer', default=[]), 77 | 'mpaa': self.mpaa_rating, 78 | 'description': self.description, 79 | 'url': self.url, 80 | 'year': get(self.movie, 'base', 'year')} 81 | 82 | 83 | class IMDB(object): 84 | def __init__(self): 85 | self.imdb = imdbpie.Imdb() 86 | self.movie = None 87 | 88 | def get_rating(self, imdb_id): 89 | try: 90 | res = self.imdb.get_title_ratings(imdb_id) 91 | except LookupError: 92 | res = {} 93 | return (res.get('rating'), 10), res.get('ratingCount', 0) 94 | 95 | def search(self, title): 96 | log.debug("Searching IMDb for '{}'", title) 97 | results = self.imdb.search_for_title(title) 98 | if len(results) == 1: 99 | return self.get_info(results[0]['imdb_id']) 100 | 101 | print("Results:") 102 | for i, movie in enumerate(results): 103 | print("%s: %s (%s)" % (i, movie['title'], movie['year'])) 104 | 105 | while True: 106 | choice = input('Select number or enter an alternate' 107 | ' search term (or an IMDb id): [0-%s, 0 default] ' % 108 | (len(results) - 1)) 109 | try: 110 | choice = int(choice) 111 | except ValueError: 112 | if choice: 113 | return self.search(choice) 114 | choice = 0 115 | 116 | try: 117 | result = results[choice] 118 | except IndexError: 119 | pass 120 | else: 121 | log.debug("Found IMDb item {}", result['imdb_id']) 122 | return self.get_info(result['imdb_id']) 123 | 124 | def get_info(self, imdb_id): 125 | log.debug('imdb getinfo') 126 | with ThreadPoolExecutor() as executor: 127 | f_movie = executor.submit(self.imdb.get_title, imdb_id) 128 | f_credits = executor.submit(self.imdb.get_title_credits, imdb_id) 129 | f_aux = executor.submit(self.imdb.get_title_auxiliary, imdb_id) 130 | f_genres = executor.submit(self.imdb.get_title_genres, imdb_id) 131 | f_versions = executor.submit(self.imdb.get_title_versions, imdb_id) 132 | 133 | movie = AttrDict(f_movie.result()) 134 | movie.credits = f_credits.result()['credits'] 135 | movie.stars = f_aux.result()['principals'] 136 | movie.genres = f_genres.result()['genres'] 137 | movie.certificate = f_aux.result().get('certificate') 138 | title_versions = f_versions.result() 139 | movie.titles = {item["region"]: item["title"] 140 | for item in title_versions.get('alternateTitles', []) 141 | if "region" in item and "title" in item} 142 | return ImdbResult(movie) 143 | -------------------------------------------------------------------------------- /pythonbits/imgur.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import requests 3 | import json 4 | from urllib.parse import urlparse 5 | 6 | from .api_utils import d 7 | from .config import config 8 | from .logging import log 9 | 10 | API_URL = 'https://api.imgur.com/' 11 | USER_URL_TEMPLATE = ("https://api.imgur.com/oauth2/" 12 | "authorize?client_id=%s&response_type=pin") 13 | client_id = 'US\x01]T\\RPQ\x06YP\x03V\x07' 14 | client_secret = ('VSW\x0eVT\x03\x07\x03\x01\x0fR\x07\x01\x02RVSP\x06V\x01\x03T' 15 | '\x01\x08\r\x03P\\Q\x0eYRP\x03\x03VU\x01') 16 | 17 | 18 | class ImgurAuth(object): 19 | def __init__(self): 20 | self.refresh_token = config.get('Imgur', 'refresh_token', None) 21 | self.access_token = None 22 | 23 | def prepare(self): 24 | if self.access_token: 25 | # Already prepared 26 | return 27 | 28 | if self.refresh_token: 29 | self.refresh_access_token() 30 | 31 | while not self.access_token: 32 | log.notice("You are not currently logged in.") 33 | self.request_login() 34 | 35 | def request_login(self): 36 | user_url = USER_URL_TEMPLATE % d(client_id) 37 | print("pythonBits needs access to your account.") 38 | print("To authorize:") 39 | print((" 1. In your browser, open: " + user_url)) 40 | print(" 2. Log in to Imgur and authorize the application") 41 | print(" 3. Enter the displayed PIN number below") 42 | pin = input("PIN: ") 43 | self.fetch_access_token('pin', pin) 44 | 45 | def refresh_access_token(self): 46 | self.fetch_access_token('refresh_token', self.refresh_token) 47 | 48 | def fetch_access_token(self, grant_type, value): 49 | # grant type: pin or refresh_token 50 | data = { 51 | 'client_id': d(client_id), 52 | 'client_secret': d(client_secret), 53 | 'grant_type': grant_type, 54 | grant_type: value 55 | } 56 | res = requests.post(API_URL + "/oauth2/token", data=data) 57 | res.raise_for_status() 58 | 59 | response = json.loads(res.text) 60 | self.access_token = response["access_token"] 61 | 62 | if response["refresh_token"]: 63 | self.refresh_token = response["refresh_token"] 64 | config.set('Imgur', 'refresh_token', self.refresh_token) 65 | 66 | log.notice("Logged in to Imgur as {}", response["account_username"]) 67 | 68 | def get_auth_headers(self): 69 | return {"Authorization": "Bearer %s" % self.access_token} 70 | 71 | 72 | class ImgurUploader(object): 73 | # todo: upload to album to avoid clutter 74 | def __init__(self): 75 | self.imgur_auth = ImgurAuth() 76 | 77 | def upload(self, *images): 78 | self.imgur_auth.prepare() 79 | for image in images: 80 | params = {'headers': self.imgur_auth.get_auth_headers()} 81 | 82 | if urlparse(image).scheme in ('http', 'https'): 83 | params['data'] = {'image': image} 84 | elif urlparse(image).scheme in ('file', ''): 85 | params['files'] = {'image': open(urlparse(image).path, "rb")} 86 | else: 87 | raise Exception('Unknown image URI scheme', 88 | urlparse(image).scheme) 89 | res = requests.post(API_URL + "3/image", **params) 90 | res.raise_for_status() # raises if invalid api request 91 | response = json.loads(res.text) 92 | 93 | link = response["data"]["link"] 94 | extensions = [path.split(".")[-1] 95 | for path in (image, link)] 96 | if extensions[0] != extensions[1]: 97 | log.warn("Imgur converted {} to a {}.", 98 | extensions[0], extensions[1]) 99 | 100 | log.notice("Image URL: {}", link) 101 | yield link 102 | -------------------------------------------------------------------------------- /pythonbits/logging.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import os 3 | import sys 4 | 5 | import appdirs 6 | import logbook.more 7 | 8 | from . import __title__ as appname 9 | 10 | 11 | class StreamHandler(logbook.more.ColorizingStreamHandlerMixin, 12 | logbook.StreamHandler): 13 | pass 14 | 15 | 16 | def issue_logging(): 17 | """Logs to disk only when error occurs""" 18 | def factory(record, handler): 19 | return logbook.FileHandler(LOG_FILE, level='DEBUG', 20 | mode='w', bubble=True) 21 | return logbook.FingersCrossedHandler(factory, bubble=True) 22 | 23 | 24 | LOG_DIR = appdirs.user_log_dir(appname.lower()) 25 | LOG_FILE = os.path.join(LOG_DIR, appname.lower() + '.log') 26 | if not os.path.exists(LOG_DIR): 27 | os.makedirs(LOG_DIR, 0o700) 28 | 29 | sh = StreamHandler(sys.stdout, level='NOTICE', bubble=True) 30 | sh.push_application() 31 | issue_logging().push_application() 32 | 33 | log = logbook.Logger(appname) 34 | -------------------------------------------------------------------------------- /pythonbits/musicbrainz.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import musicbrainzngs 3 | import terminaltables 4 | 5 | from . import __title__ as appname, __version__ as version, _github as github 6 | 7 | 8 | musicbrainzngs.set_useragent(appname, version, github) 9 | 10 | 11 | def get_release_group_cover(release_group_id): 12 | try: 13 | data = musicbrainzngs.get_release_group_image_list(release_group_id) 14 | except musicbrainzngs.musicbrainz.ResponseError: 15 | return None 16 | 17 | for image in data["images"]: 18 | if "Front" in image["types"] and image["approved"]: 19 | return image["thumbnails"]["large"] 20 | 21 | 22 | def get_release_cover(release_id): 23 | try: 24 | data = musicbrainzngs.get_image_list(release_id) 25 | except musicbrainzngs.musicbrainz.ResponseError: 26 | return None 27 | 28 | for image in data["images"]: 29 | if "Front" in image["types"] and image["approved"]: 30 | return image["thumbnails"]["large"] 31 | 32 | 33 | def find_release_group(release_title, artist=None): 34 | results = musicbrainzngs.search_release_groups( 35 | release_title, artist=artist, limit=10)['release-group-list'] 36 | table_data = [('Index', 'Artist', 'Title', 'Type')] 37 | # max_width = table.column_max_width(2) 38 | for i, r in enumerate(results): 39 | # title = '\n'.join(wrap(r['title'], max_width)) 40 | table_data.append((i, r['artist-credit-phrase'], 41 | r['title'], r.get('type', '?'))) 42 | 43 | print(terminaltables.SingleTable(table_data).table) 44 | while True: 45 | choice = input( 46 | "Select the release group (or enter a different query): ") 47 | try: 48 | choice = int(choice) 49 | except ValueError: 50 | if choice != '': 51 | return find_release_group(choice) 52 | continue 53 | 54 | try: 55 | choice = results[choice] 56 | except IndexError: 57 | pass 58 | else: 59 | return musicbrainzngs.get_release_group_by_id( 60 | choice['id'], 61 | includes=['tags', 'artist-credits', 'url-rels'] 62 | )['release-group'] 63 | 64 | 65 | def find_release(release_title, artist=None): 66 | release_group = find_release_group(release_title, artist=artist) 67 | 68 | results = musicbrainzngs.search_releases( 69 | 'rgid:'+release_group['id'])['release-list'] 70 | 71 | table_data = [ 72 | ('Index', 'Title', '# Tracks', 'Date', 'CC', 'Label', 'Status', 73 | 'Format'), ] 74 | 75 | for i, r in enumerate(results): 76 | try: 77 | label = r['label-info-list'][0]['label']['name'] 78 | except KeyError: 79 | label = '?' 80 | table_data.append((i, r['title'], r['medium-list'][0]['track-count'], 81 | r.get('date', '?'), r.get('country', '?'), 82 | label, r.get('status', '?'), 83 | r['medium-list'][0].get('format', '?'))) 84 | 85 | print(terminaltables.SingleTable(table_data).table) 86 | while True: 87 | choice = input( 88 | "Select the exact release, if known (Enter to skip): ") 89 | try: 90 | choice = results[int(choice)] 91 | except (IndexError, ValueError): 92 | if choice == '': 93 | return None, release_group 94 | else: 95 | release = musicbrainzngs.get_release_by_id( 96 | choice['id'], includes=['release-groups', 'media', 97 | 'recordings', 'url-rels'])['release'] 98 | return release, release_group 99 | -------------------------------------------------------------------------------- /pythonbits/ptpimg.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # pylint: disable=invalid-name 3 | """ 4 | Upload image file or image URL to the ptpimg.me image hosting. 5 | 6 | Borrowed from 7 | https://github.com/theirix/ptpimg-uploader/blob/master/ptpimg_uploader.py 8 | 9 | """ 10 | 11 | import contextlib 12 | import mimetypes 13 | import os 14 | from io import BytesIO 15 | from textwrap import dedent 16 | 17 | import requests 18 | 19 | from .config import config 20 | from .logging import log 21 | 22 | mimetypes.init() 23 | 24 | config.register( 25 | 'PtpImg', 'api_key', 26 | dedent("""\ 27 | To find your PTPImg API key, login to https://ptpimg.me, open the page 28 | source (i.e. "View->Developer->View source" menu in Chrome), find the 29 | string api_key and copy the hexademical string from the value attribute. 30 | Your API key should look like 43fe0fee-f935-4084-8a38-3e632b0be68c. 31 | 3. Enter the API Key below 32 | API Key""")) 33 | 34 | 35 | class UploadFailed(Exception): 36 | def __str__(self): 37 | msg, *args = self.args 38 | return msg.format(*args) 39 | 40 | 41 | class PtpImgUploader: 42 | """ Upload image or image URL to the ptpimg.me image hosting """ 43 | 44 | def __init__(self, timeout=None): 45 | self.api_key = config.get('PtpImg', 'api_key') 46 | self.timeout = timeout 47 | 48 | @staticmethod 49 | def _handle_result(res): 50 | image_url = 'https://ptpimg.me/{0}.{1}'.format( 51 | res['code'], res['ext']) 52 | return image_url 53 | 54 | def _perform(self, files=None, **data): 55 | # Compose request 56 | headers = {'referer': 'https://ptpimg.me/index.php'} 57 | data['api_key'] = self.api_key 58 | url = 'https://ptpimg.me/upload.php' 59 | 60 | resp = requests.post( 61 | url, headers=headers, data=data, files=files, timeout=self.timeout) 62 | 63 | # pylint: disable=no-member 64 | if resp.status_code == requests.codes.ok: 65 | try: 66 | print('Successful response', resp.json()) 67 | # r.json() is like this: [{'code': 'ulkm79', 'ext': 'jpg'}] 68 | return [self._handle_result(r) for r in resp.json()] 69 | except ValueError as e: 70 | raise UploadFailed( 71 | 'Failed decoding body:\n{0}\n{1!r}', e, resp.content 72 | ) from None 73 | else: 74 | raise UploadFailed( 75 | 'Failed. Status {0}:\n{1}', resp.status_code, resp.content) 76 | 77 | def upload_files(self, *filenames): 78 | log.notice('Got files to upload {} to ptpimg', filenames) 79 | """ Upload files using form """ 80 | # The ExitStack closes files for us when the with block exits 81 | with contextlib.ExitStack() as stack: 82 | files = {} 83 | for i, filename in enumerate(filenames): 84 | open_file = stack.enter_context(open(filename, 'rb')) 85 | mime_type, _ = mimetypes.guess_type(filename) 86 | if not mime_type or mime_type.split('/')[0] != 'image': 87 | raise ValueError( 88 | 'Unknown image file type {}'.format(mime_type)) 89 | 90 | name = os.path.basename(filename) 91 | try: 92 | # until https://github.com/shazow/urllib3/issues/303 is 93 | # resolved, only use the filename if it is Latin-1 safe 94 | name.encode('latin1') 95 | except UnicodeEncodeError: 96 | name = 'justfilename' 97 | files['file-upload[{}]'.format(i)] = ( 98 | name, open_file, mime_type) 99 | 100 | log.notice('Processed and trying to upload {} to ptpimg', files) 101 | return self._perform(files=files) 102 | 103 | def upload_urls(self, *urls): 104 | log.notice('Got links to upload {} to ptpimg', urls) 105 | """ Upload image URLs by downloading them before """ 106 | with contextlib.ExitStack() as stack: 107 | files = {} 108 | for i, url in enumerate(urls): 109 | resp = requests.get(url, timeout=self.timeout) 110 | if resp.status_code != requests.codes.ok: 111 | raise ValueError( 112 | 'Cannot fetch url {} with error {}'.format( 113 | url, resp.status_code)) 114 | 115 | mime_type = resp.headers['content-type'] 116 | if not mime_type or mime_type.split('/')[0] != 'image': 117 | raise ValueError( 118 | 'Unknown image file type {}'.format(mime_type)) 119 | open_file = stack.enter_context(BytesIO(resp.content)) 120 | files['file-upload[{}]'.format(i)] = ( 121 | 'file-{}'.format(i), open_file, mime_type) 122 | 123 | return self._perform(files=files) 124 | 125 | def upload(self, *images): 126 | files, urls = _partition(images) 127 | if urls: 128 | yield from self.upload_urls(*urls) 129 | if files: 130 | yield from self.upload_files(*files) 131 | 132 | 133 | def _partition(files_or_urls): 134 | files, urls = [], [] 135 | for file_or_url in files_or_urls: 136 | if os.path.exists(file_or_url): 137 | files.append(file_or_url) 138 | elif file_or_url.startswith('http'): 139 | urls.append(file_or_url) 140 | else: 141 | raise ValueError( 142 | 'Not an existing file or image URL: {}'.format(file_or_url)) 143 | return files, urls 144 | -------------------------------------------------------------------------------- /pythonbits/scene.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import os 3 | import requests 4 | import progressbar 5 | from base64 import b64decode 6 | from zlib import crc32 7 | 8 | from .logging import log 9 | 10 | srrdb = b64decode('aHR0cHM6Ly9zcnJkYi5jb20v').decode('utf8') 11 | 12 | 13 | def check_scene_rename(fname, release): 14 | release_url = srrdb + "release/details/{}".format(release) 15 | r = requests.get(release_url) 16 | 17 | if fname not in r.text: 18 | log.warning('Possibly renamed scene file!\n' 19 | '\tFilename {}\n\tnot found at {}', 20 | fname, release_url) 21 | 22 | 23 | def crc(path): 24 | log.debug('Calculating CRC32 value') 25 | checksum = 0 26 | fsize = os.path.getsize(path) 27 | i = 0 28 | chunk_size = 4 * 2**20 29 | with open(path, 'rb') as f: 30 | with progressbar.DataTransferBar(max_value=fsize, 31 | max_error=False) as bar: 32 | while True: 33 | i += 1 34 | data = f.read(chunk_size) 35 | if not data: 36 | return checksum & 0xFFFFFFFF 37 | 38 | bar.update(i*chunk_size) 39 | checksum = crc32(data, checksum) 40 | 41 | 42 | def is_scene_crc(path): 43 | checksum = crc(path) 44 | log.debug('CRC32 {:08X}', checksum) 45 | r = requests.get(srrdb + 'api/search/archive-crc:%08X' % checksum) 46 | r.raise_for_status() 47 | 48 | scene = int(r.json()['resultsCount']) != 0 49 | if int(r.json()['resultsCount']) > 1: 50 | log.warning('More than one srrDB result for CRC32 query') 51 | log.info('Scene checkbox set to {} ' 52 | 'due to CRC query result'.format(scene)) 53 | 54 | if scene: 55 | release = r.json()['results'][0]['release'] 56 | fname = os.path.basename(path) 57 | check_scene_rename(fname, release) 58 | 59 | return scene 60 | 61 | 62 | def query_scene_fname(path): 63 | if os.path.isfile(path): 64 | query = os.path.splitext(os.path.basename(path))[0] 65 | elif os.path.isdir(path): 66 | query = os.path.basename(path) 67 | elif not os.path.exists(path): 68 | raise FileNotFoundError('File or directory not found: %s' % (path,)) 69 | else: 70 | raise Exception('wat') 71 | 72 | # full search (slow) 73 | r = requests.get(srrdb + "api/search/{}".format(query)) 74 | r.raise_for_status() 75 | results = r.json()['results'] 76 | 77 | if results: 78 | print('Found srrDB results for filename:') 79 | print("\t" + "\n".join(r['release'] for r in results)) 80 | else: 81 | print('No results found in srrDB for query "{}"'.format(query)) 82 | -------------------------------------------------------------------------------- /pythonbits/submission.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import os 3 | import re 4 | import copy 5 | import inspect 6 | try: 7 | import readline 8 | except ImportError: 9 | import pyreadline as readline 10 | 11 | 12 | from .logging import log 13 | 14 | 15 | def rlinput(prompt, prefill=''): 16 | readline.set_startup_hook(lambda: readline.insert_text(prefill)) 17 | try: 18 | return input(prompt) 19 | finally: 20 | readline.set_startup_hook() 21 | 22 | 23 | class SubmissionAttributeError(Exception): 24 | pass 25 | 26 | 27 | re_frender = re.compile("^_render_(?=[a-z_]*$)") 28 | cat_map = {} 29 | 30 | 31 | class RegisteringType(type): 32 | def __init__(cls, name, bases, attrs): 33 | cls.registry = copy.deepcopy(getattr(cls, 'registry', 34 | {'mappers': {}, 'types': {}})) 35 | 36 | if hasattr(cls, '_cat_id'): 37 | if cls._cat_id not in cat_map: 38 | cat_map[cls._cat_id] = cls 39 | 40 | def add_mapper(f, ff, fft): 41 | log.debug("{} adding mapper {} for {} ({})", 42 | cls.__name__, f, ff, fft) 43 | if f in cls.registry: 44 | log.warning("Overwriting {} for class {} with {} " 45 | "(previous value: {})", f, name, ff, 46 | cls.registry['mappers'][f]) 47 | cls.registry['mappers'][f] = ff 48 | cls.registry['types'][ff] = fft 49 | 50 | # get form_field mappers from dunder string 51 | form_field_mappers = getattr(cls, '__form_fields__', {}) 52 | for field, (form_field, form_field_type) in form_field_mappers.items(): 53 | add_mapper(field, form_field, form_field_type) 54 | 55 | for key, val in attrs.items(): 56 | try: 57 | form_field, form_field_type = getattr(val, 'form_field') 58 | except AttributeError: 59 | pass # most attributes are not a form_field mapper 60 | else: 61 | field, n = re.subn(re_frender, '', key) 62 | assert n == 1 # only then is it a field renderer 63 | add_mapper(field, form_field, form_field_type) 64 | 65 | # get fields that need finalization 66 | if getattr(val, 'needs_finalization', False): 67 | field, n = re.subn(re_frender, '', key) 68 | assert n == 1 # only then is it a field renderer 69 | cls._to_finalize = getattr(cls, '_to_finalize', []) + [field] 70 | 71 | 72 | form_field_types = {'text', 'checkbox', 'file'} # todo select 73 | 74 | 75 | def form_field(field, type='text'): 76 | def decorator(f): 77 | f.form_field = (field, type) 78 | return f 79 | return decorator 80 | 81 | 82 | def finalize(f): 83 | f.needs_finalization = True 84 | return f 85 | 86 | 87 | class CachedRenderer(object): 88 | def __init__(self, **kwargs): 89 | log.debug("Creating cached renderer {}", kwargs) 90 | self.fields = kwargs 91 | self.depends_on = {} 92 | 93 | def __getitem__(self, field): 94 | # todo: better way to track dependencies. explicit @requires decorator? 95 | try: 96 | # get first calling field 97 | caller = next(level[3] for level in inspect.stack() 98 | if level[3].startswith('_render_')) 99 | except StopIteration: 100 | pass 101 | else: 102 | caller, n = re.subn(re_frender, '', caller, count=1) 103 | if n: # called by another cached field 104 | self.depends_on[field] = self.depends_on.setdefault( 105 | field, set()) | {caller} 106 | log.debug('Adding {} dependency {} -> {}', 107 | type(self).__name__, caller, field) 108 | 109 | try: 110 | return self.fields[field] 111 | except KeyError: 112 | try: 113 | field_renderer = getattr(self, '_render_' + field) 114 | except AttributeError: 115 | raise SubmissionAttributeError( 116 | self.__class__.__name__ + " does not contain or " 117 | "has no rules to generate field '" + field + "'") 118 | 119 | log.debug('Rendering field {}[\'{}\']', type(self).__name__, field) 120 | rv = field_renderer() 121 | self.fields[field] = rv 122 | return rv 123 | 124 | def __setitem__(self, key, value): 125 | self.invalidate_field_cache(key) 126 | self.fields[key] = value 127 | 128 | def invalidate_field_cache(self, field): 129 | try: 130 | dependent_fields = self.depends_on.pop(field) 131 | except KeyError: 132 | pass 133 | self.fields.pop(field, None) and log.debug( 134 | 'del inval leaf {}', field) 135 | else: 136 | for f in dependent_fields: 137 | self.invalidate_field_cache(f) 138 | self.fields.pop(field, None) and log.debug( 139 | 'del inval node {}', field) 140 | 141 | 142 | def build_payload(fd_val, form_field, fft): 143 | # it's either a form field id 144 | if isinstance(form_field, str): 145 | if fft == 'text': 146 | yield 'data', form_field, fd_val 147 | elif fft == 'checkbox' and fd_val: 148 | yield 'data', form_field, 'on' 149 | elif fft == 'file': 150 | yield 'files', form_field, (os.path.basename(fd_val), 151 | open(fd_val, 'rb'), 152 | 'application/octet-stream') 153 | 154 | # or a rule to generate form field ids 155 | elif callable(form_field): 156 | for i, val in enumerate(fd_val): 157 | for pair in build_payload( 158 | val, form_field(i, val), fft): 159 | yield pair # yield from 160 | 161 | else: 162 | raise AssertionError(form_field, fd_val) 163 | 164 | 165 | def toposort(depends_on): 166 | depends_on = copy.deepcopy(depends_on) 167 | sorted_funcs = [] 168 | 169 | depends = (set(f for v in depends_on.values() for f in v) - 170 | set(depends_on.keys())) 171 | for d in depends: 172 | depends_on[d] = set() 173 | 174 | ready_funcs = set(func for func, deps in depends_on.items() if not deps) 175 | while ready_funcs: 176 | executed = ready_funcs.pop() 177 | depends_on.pop(executed) 178 | sorted_funcs.append(executed) 179 | from_selection = [func for func, deps in depends_on.items() 180 | if executed in deps] 181 | for func in from_selection: 182 | depends_on[func].remove(executed) 183 | if not depends_on[func]: 184 | ready_funcs.add(func) 185 | 186 | if depends_on: 187 | raise Exception("Cyclic dependencies present: {}".format( 188 | depends_on)) 189 | else: 190 | return sorted_funcs 191 | 192 | 193 | class Submission(CachedRenderer, metaclass=RegisteringType): 194 | def __repr__(self): 195 | return "\n".join( 196 | ["Field {k}:\n\t{v}\n".format(k=k, v=v) 197 | for k, v in list(self.fields.items())]) 198 | 199 | @finalize 200 | def _render_submit(self): 201 | # todo dict map field names 202 | # todo truncate long fields in preview 203 | 204 | return self.show_fields(list(self.registry['mappers'].keys())) 205 | 206 | def _finalize_submit(self): 207 | return self.submit(self['payload']) 208 | 209 | def needs_finalization(self): 210 | return set(self._to_finalize) & set(self.fields.keys()) 211 | 212 | def finalize(self): 213 | needs_finalization = self.needs_finalization() 214 | order = toposort(self.depends_on) 215 | needs_finalization = sorted(needs_finalization, 216 | key=lambda x: order.index(x), 217 | reverse=True) 218 | for f in needs_finalization: 219 | self[f] = getattr(self, '_finalize_' + f)() 220 | 221 | setattr(self, 'finalized', None) 222 | 223 | @staticmethod 224 | def submit(payload): 225 | raise NotImplementedError 226 | 227 | def show_fields(self, fields): 228 | def format_val(val): 229 | if isinstance(val, str) and os.path.exists(val): 230 | s = 'file://' + str(val) 231 | elif isinstance(val, list) or isinstance(val, tuple): 232 | s = "\n".join(format_val(v) for v in val) 233 | else: 234 | s = val 235 | log.debug("No rule for formatting {} {}", type(val), val) 236 | return str(s) 237 | 238 | consolewidth = 80 239 | s = "" 240 | for field in fields: 241 | val = self[field] 242 | field_str = field 243 | if field in self._to_finalize and not hasattr(self, 'finalized'): 244 | field_str += " (will be finalized)" 245 | s += (" " + field_str + " ").center(consolewidth, "=") + "\n" 246 | s += format_val(val) + "\n" 247 | 248 | s += "="*consolewidth + "\n" 249 | return s 250 | 251 | def confirm_finalization(self, fields): 252 | # todo: disable editing on certain fields, e.g. those dependent on 253 | # fields that require finalization 254 | 255 | print(self.show_fields(fields)) 256 | while True: 257 | print("Reminder: YOU are responsible for following the " 258 | "submission rules!") 259 | choice = input('Finalize these values? This will upload or ' 260 | 'submit all necessary data. [y/n] ') 261 | 262 | if not choice: 263 | pass 264 | elif choice.lower() == 'n': 265 | amend = input("Amend a field? [N/<field name>] ") 266 | if not amend.lower() or amend.lower() == 'n': 267 | return False 268 | 269 | try: 270 | val = self[amend] 271 | except SubmissionAttributeError: 272 | print("No field named", amend) 273 | print("Choices are:", list(self.fields.keys())) 274 | else: 275 | if not (isinstance(val, str) or 276 | isinstance(val, bool) or 277 | isinstance(val, int)): 278 | print("Can't amend value of type", type(val)) 279 | continue 280 | 281 | new_value = rlinput("New (empty to cancel): ", val) 282 | 283 | if new_value: 284 | if isinstance(val, bool): 285 | string_true = {'true', 'True', 'y', 'yes'} 286 | string_false = {'false', 'False', 'n', 'no'} 287 | assert new_value in string_true | string_false 288 | new_value = (new_value not in string_false) 289 | elif isinstance(val, int): 290 | new_value = int(new_value) 291 | 292 | self[amend] = new_value 293 | 294 | print(self.show_fields(fields)) 295 | 296 | elif choice.lower() == 'y': 297 | return True 298 | 299 | def _render_payload(self): 300 | # must be rendered directly from editable fields 301 | 302 | payload = {'files': {}, 'data': {}} 303 | for fd_name, form_field in self.registry['mappers'].items(): 304 | fd_val = self[fd_name] 305 | fft = self.registry['types'][form_field] 306 | # todo: handle input types 307 | for req_type, ff, val in build_payload(fd_val, form_field, fft): 308 | payload[req_type][ff] = val 309 | 310 | return payload 311 | -------------------------------------------------------------------------------- /pythonbits/templating.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from functools import partial 3 | from math import floor 4 | 5 | from . import _release, _github 6 | 7 | 8 | # tag like [name=value] 9 | def tag(tag_name): 10 | def func(value=None): 11 | if value: 12 | return "[" + tag_name + "=" + str(value) + "]" 13 | return "[" + tag_name + "]" 14 | return func 15 | 16 | 17 | # tag like [name=tv]ev[/name] 18 | def tag_enc(tag_name): 19 | return lambda ev, tv=None: (tag(tag_name)(tv) + str(ev) + 20 | tag('/' + tag_name)()) 21 | 22 | 23 | img = tag('img') 24 | b = tag_enc('b') 25 | link = tag_enc('url') 26 | size = tag_enc('size') 27 | quote = tag_enc('quote') 28 | spoiler = tag_enc('spoiler') 29 | table = tag_enc('table') 30 | tr = tag_enc('tr') 31 | td = tag_enc('td') 32 | mi = tag_enc('mediainfo') 33 | s1 = partial(size, tv=1) 34 | s2 = partial(size, tv=2) # default 35 | s3 = partial(size, tv=3) 36 | s4 = partial(size, tv=4) 37 | s7 = partial(size, tv=7) 38 | align = tag_enc('align') 39 | center = partial(align, tv='center') 40 | color = tag_enc('color') 41 | _list = tag_enc('list') 42 | 43 | 44 | def list(x, style=None): 45 | v = "".join("[*]"+x for x in x) 46 | return _list(v, style) 47 | 48 | 49 | # formats color tuple (255, 235, 85) to hexadecimal string "#ffeb55" 50 | def fmt_col(c): 51 | return "#" + "{:02x}{:02x}{:02x}".format(*c) 52 | 53 | 54 | def h(x): 55 | s = "" 56 | for c in x: 57 | if c.isupper(): 58 | s += s3(c) 59 | else: 60 | s += c.upper() 61 | return b(s) 62 | 63 | 64 | def section(name, content): 65 | return center(h(name)) + quote(content) 66 | 67 | 68 | release = align(link(color(s1("Generated by " + _release), '#999'), 69 | _github), 'right') 70 | 71 | 72 | def format_rating(rating, max, limit=10, s=None, fill=None, empty=None): 73 | if rating is None: 74 | return "No rating" 75 | 76 | s = s or '★' 77 | fill = fill or [0xff, 0xff, 0x00] 78 | empty = empty or [0xa0, 0xa0, 0xa0] 79 | 80 | limit = min(max, limit) 81 | num_stars = rating * limit / max 82 | black_stars = int(floor(num_stars)) 83 | partial_star = num_stars - black_stars 84 | white_stars = limit - black_stars - 1 85 | 86 | pf = [comp * partial_star for comp in fill] 87 | pe = [comp * (1 - partial_star) for comp in empty] 88 | partial_color = fmt_col(tuple(map(lambda x, y: int(x+y), pf, pe))) 89 | 90 | stars = (color(s * black_stars, fmt_col(fill)) + 91 | color(s, partial_color) + 92 | color(s * white_stars, fmt_col(empty))) 93 | return str(rating) + '/' + str(max) + ' ' + stars 94 | -------------------------------------------------------------------------------- /pythonbits/torrent.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import os 3 | import re 4 | import subprocess 5 | import math 6 | import tempfile 7 | from urllib.parse import urlparse 8 | 9 | from . import _release as release 10 | from .config import config 11 | from .logging import log 12 | 13 | config.register('Torrent', 'black_hole', 14 | "Enter a directory where you would like to save the created " 15 | "torrent file. Temporary directory will be used if left blank." 16 | "\nDirectory", 17 | ask=True) 18 | config.register('Torrent', 'upload_dir', 19 | "Enter a directory where the media files should be placed " 20 | "so the torrent client has access to them for seeding. If " 21 | "left blank, no action will be taken." 22 | "\nDirectory", 23 | ask=True) 24 | config.register('Torrent', 'data_method', 25 | "Enter a preferred method to use for placing media files in " 26 | "the upload directory. Choices are: 'hard', 'sym', 'copy', " 27 | "'move'. Unless explicitly overridden, further restrictions " 28 | "are automatically applied, e.g. music will be copied or " 29 | "moved even if the preferred data method is linking." 30 | "\nData method") 31 | 32 | COMMAND = "mktorrent" 33 | 34 | 35 | def log2(x): 36 | return math.log(x) / math.log(2) 37 | 38 | 39 | def get_size(fname): 40 | if os.path.isfile(fname): 41 | return os.path.getsize(fname) 42 | else: 43 | return sum(get_size(os.path.join(fname, f)) for f in os.listdir(fname)) 44 | 45 | 46 | def piece_size_exp(size): 47 | min_psize_exp = 15 # 32 KiB piece size 48 | max_psize_exp = 24 # 16 MiB piece size 49 | target_pnum_exp = 10 # 1024 pieces 50 | 51 | psize_exp = int(math.floor(log2(size) - target_pnum_exp)) 52 | return max(min(psize_exp, max_psize_exp), min_psize_exp) 53 | 54 | 55 | class MkTorrentException(Exception): 56 | pass 57 | 58 | 59 | def get_version(): 60 | try: 61 | mktorrent = subprocess.Popen( 62 | [COMMAND], stdout=subprocess.PIPE, stderr=subprocess.PIPE) 63 | out = mktorrent.communicate()[0].decode('utf8') 64 | return tuple(map(int, re.search( 65 | r"(?<=^mktorrent )[\d.]+", out).group(0).split('.'))) 66 | except OSError: 67 | raise MkTorrentException( 68 | "Could not find mktorrent, please ensure it is installed.") 69 | 70 | 71 | def make_torrent(fname): 72 | fsize = get_size(fname) 73 | psize_exp = piece_size_exp(fsize) 74 | 75 | announce_url = config.get('Tracker', 'announce_url') 76 | tracker = urlparse(announce_url).hostname 77 | comment = "Created by {} for {}".format(release, tracker) 78 | 79 | out_dir = tempfile.mkdtemp() 80 | out_fname = os.path.splitext(os.path.split(fname)[1])[0] + ".torrent" 81 | out_fname = os.path.join(out_dir, out_fname) 82 | 83 | params = [ 84 | "-p", 85 | "-l", str(psize_exp), 86 | "-a", announce_url, 87 | "-c", comment, 88 | "-o", out_fname, 89 | ] 90 | 91 | version = get_version() 92 | target_version = (1, 1) 93 | if version < target_version: 94 | log.warning("Cannot modify infohash by tracker since an old version " 95 | "({}<{}) of mktorrent is installed. Be careful with " 96 | "cross-seeding.", 97 | ".".join(map(str, version)), 98 | ".".join(map(str, target_version))) 99 | else: 100 | params.extend(["-s", tracker]) 101 | 102 | call = [COMMAND] + params + [fname] 103 | mktorrent = subprocess.Popen(call, shell=False) 104 | 105 | log.info("Waiting for torrent creation to complete...") 106 | mktorrent.wait() 107 | if mktorrent.returncode: 108 | raise MkTorrentException() 109 | 110 | return out_fname 111 | -------------------------------------------------------------------------------- /pythonbits/tracker.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import contextlib 3 | import re 4 | import os 5 | import time 6 | from http.cookiejar import MozillaCookieJar 7 | 8 | import requests 9 | import appdirs 10 | 11 | from . import __version__ as version, __title__ as title 12 | from .config import config 13 | from .logging import log 14 | 15 | config.register('Tracker', 'announce_url', 16 | "Please enter your personal announce URL") 17 | config.register('Tracker', 'username', "Username", ask=True) 18 | config.register('Tracker', 'password', "Password", ask=True, getpass=True) 19 | config.register('Tracker', 'domain', 20 | "Please enter the tracker's domain, e.g. 'mydomain.net'") 21 | 22 | 23 | class TrackerException(Exception): 24 | pass 25 | 26 | 27 | class Tracker(): 28 | headers = {'User-Agent': '{}/{}'.format(title, version)} 29 | 30 | def _login(self, session, _tries=0): 31 | maxtries = 30 32 | 33 | domain = config.get('Tracker', 'domain') 34 | login_url = "https://{}/login.php".format(domain) 35 | payload = {'username': config.get('Tracker', 'username'), 36 | 'password': config.get('Tracker', 'password'), 37 | 'keeplogged': "1", 38 | 'login': "Log in!"} 39 | 40 | resp = session.post(login_url, data=payload) 41 | resp.raise_for_status() 42 | 43 | if 'href="login.php"' in resp.text: 44 | if 'id="loginform"' in resp.text: 45 | raise TrackerException("Login failed (wrong credentials?)") 46 | elif 'You are banned' in resp.text: 47 | raise TrackerException( 48 | "Login failed (login attempts exceeded)") 49 | else: 50 | # We encountered the login bug that sends you to "/" (which 51 | # doesn't contain the login form) without logging you in 52 | # todo: convert this to a retry decorator 53 | if _tries < maxtries: 54 | backoff = min(2**_tries/100, 5.) 55 | log.info('Encountered login bug; trying again after ' 56 | '{}s back-off'.format(backoff)) 57 | time.sleep(backoff) 58 | self._login(session, _tries=_tries+1) 59 | else: 60 | log.notice('Encountered login bug; ' 61 | 'giving up after ' 62 | '{} login attempts'.format(_tries)) 63 | raise TrackerException("Login failed (server login bug)") 64 | elif 'logout.php' in resp.text: 65 | # Login successful, find and remember logout URL 66 | match = re.search(r"logout\.php\?auth=[0-9a-f]{32}", resp.text) 67 | if match: 68 | self._logout_url = "https://{}/{}".format( 69 | domain, match.group(0)) 70 | else: 71 | raise TrackerException("Couldn't find logout URL") 72 | else: 73 | log.error(resp.text) 74 | raise TrackerException("Couldn't determine login status from HTML") 75 | 76 | def _logout(self, session): 77 | logout_url = getattr(self, '_logout_url') 78 | if logout_url: 79 | delattr(self, '_logout_url') 80 | resp = session.get(logout_url) 81 | if 'logout.php' in resp.text: 82 | raise TrackerException("Logout failed") 83 | else: 84 | raise TrackerException("No logout URL: Unable to logout") 85 | 86 | @contextlib.contextmanager 87 | def login(self): 88 | log.notice("Logging in {} to {}", 89 | config.get('Tracker', 'username'), 90 | config.get('Tracker', 'domain')) 91 | cj_path = os.path.join(appdirs.user_cache_dir(title.lower()), 92 | 'tracker_cookies.txt') 93 | with requests.Session() as s: 94 | s.cookies = MozillaCookieJar(cj_path) 95 | try: 96 | s.cookies.load() 97 | except FileNotFoundError: 98 | s.cookies.save() 99 | s.headers.update(self.headers) 100 | self._login(s) 101 | yield s 102 | self._logout(s) 103 | s.cookies.save() 104 | log.notice("Logged out {} of ", 105 | config.get('Tracker', 'username'), 106 | config.get('Tracker', 'domain')) 107 | 108 | def upload(self, **kwargs): 109 | url = "https://{}/upload.php".format(config.get('Tracker', 'domain')) 110 | with self.login() as session: 111 | log.notice("Posting submission") 112 | resp = session.post(url, **kwargs) 113 | resp.raise_for_status() 114 | 115 | # TODO: Catch this somehow: 116 | # <p style="color: red;text-align:center;">You must enter at least 117 | # one tag. Maximum length is 200 characters.</p> 118 | 119 | if resp.history: 120 | # todo: check if url is good, might have been logged out 121 | # (unlikely) 122 | return resp.url 123 | else: 124 | log.error('Response: %s' % resp) 125 | err_match = re.search(r''.join( 126 | (r'(No torrent file uploaded.*?)', 127 | re.escape(r'</p>'))), 128 | resp.text) 129 | if err_match: 130 | log.error('Error: %s' % err_match.group(1)) 131 | else: 132 | log.debug(resp.text) 133 | log.error('Unknown error') 134 | raise TrackerException('Failed to upload submission') 135 | -------------------------------------------------------------------------------- /pythonbits/tvdb.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from collections.abc import Sequence 3 | import tvdb_api 4 | 5 | from .api_utils import d 6 | 7 | api_key = 'RS\nvT<%<g#~bsS~3' 8 | 9 | 10 | class TvdbResult(object): 11 | def __init__(self, show, season, episode=None): 12 | self.show = show 13 | self.season = season 14 | self.episode = episode 15 | 16 | def banner(self, season_number): 17 | # todo offer choice of cover if multiple? 18 | # todo cache banner upload per season? 19 | # get best banner, preferably for season 20 | 21 | def best_banner(banners): 22 | def get_rating(banner): 23 | return banner.get('ratingsInfo', {}).get('average', 0) 24 | sorted_banners = sorted(banners, key=get_rating) 25 | return sorted_banners[-1] 26 | 27 | try: 28 | season_banners = self.show['_banners']['season'] 29 | best_banner = best_banner( 30 | [banner for banner in season_banners['raw'] 31 | if banner['subKey'] == str(season_number)]) 32 | return (season_banners[best_banner['resolution']] 33 | [best_banner['id']]['_bannerpath']) 34 | except (IndexError, KeyError): 35 | for key in ('poster', 'series', 'fanart'): 36 | try: 37 | series_banners = self.show['_banners'][key] 38 | best_banner = best_banner(series_banners['raw']) 39 | return (series_banners[best_banner['resolution']] 40 | [best_banner['id']]['_bannerpath']) 41 | except (IndexError, KeyError): 42 | pass 43 | raise Exception('Unable to find cover') 44 | 45 | def summary(self): 46 | return { 47 | 'title': self.show['seriesName'], 48 | 'network': self.show['network'], 49 | 'genres': self.show['genre'], 50 | 'seriessummary': self.show['overview'], 51 | 'cast': self.show['_actors'], 52 | # 'seasons': len(self.show), 53 | # 'status': self.show['status'], 54 | 'contentrating': self.show['rating'], 55 | 'show_imdb_id': self.show['imdbId'], 56 | } 57 | 58 | 59 | class TvdbSeason(TvdbResult): 60 | def summary(self): 61 | s = super(TvdbSeason, self).summary() 62 | some_episode = next(iter(self.season.values())) 63 | season_number = some_episode['airedSeason'] 64 | series_url = 'https://thetvdb.com/series/%s' % (self.show['slug'],) 65 | s.update(**{'num_episodes': len(self.season), 66 | 'episodes': []}) 67 | for episode_number in self.season: 68 | episode = self.season[episode_number] 69 | episode_url = 'https://thetvdb.com/series/{}/episodes/{}'.format( 70 | self.show['slug'], episode['id']) 71 | 72 | s["episodes"].append({ 73 | 'title': episode['episodeName'], 74 | 'url': episode_url, 75 | 'imdb_id': episode['imdbId'], 76 | 'rating': episode['siteRating']}) 77 | s['url'] = series_url 78 | s['cover'] = self.banner(season_number) 79 | s['season'] = season_number 80 | return s 81 | 82 | 83 | class TvdbEpisode(TvdbResult): 84 | def show_summary(self): 85 | return super(TvdbEpisode, self).summary() 86 | 87 | def summary(self): 88 | summary = self.show_summary() 89 | summary.update(**{ 90 | 'season': self.episode['airedSeason'], 91 | 'episode': self.episode['airedEpisodeNumber'], 92 | 'episode_title': self.episode['episodeName'], 93 | 'imdb_id': self.episode['imdbId'], 94 | 'directors': self.episode['directors'], 95 | 'air_date': self.episode['firstAired'], 96 | # 'air_dow': self.show['airsDayOfWeek'], 97 | # 'air_time': self.show['airsTime'], 98 | 'writers': self.episode['writers'], 99 | 'rating': self.episode['siteRating'], 100 | 'votes': self.episode['siteRatingCount'], 101 | 'episodesummary': self.episode['overview'], 102 | 'language': self.episode['language'], 103 | 'url': 'https://thetvdb.com/series/{}'.format( 104 | self.show['slug']), 105 | 'cover': self.banner(self.episode['airedSeason'])}) 106 | return summary 107 | 108 | 109 | class TVDB(object): 110 | def __init__(self, interactive=True): 111 | self.tvdb = tvdb_api.Tvdb(interactive=interactive, banners=True, 112 | actors=True, apikey=d(api_key)) 113 | 114 | def search(self, tv_specifier, tvdb_id=None): 115 | show = self.tvdb[tvdb_id or tv_specifier.title] 116 | season = show[tv_specifier.season] 117 | if tv_specifier.episode is not None: 118 | if not isinstance(tv_specifier.episode, Sequence): 119 | episode = season[tv_specifier.episode] 120 | return [TvdbEpisode(show, season, episode)] 121 | 122 | # multi-episode 123 | return [TvdbEpisode(show, season, season[e]) 124 | for e in tv_specifier.episode] 125 | 126 | return TvdbSeason(show, season) 127 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | description-file = README.md 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from setuptools import setup, find_packages 3 | import codecs 4 | import re 5 | from os import path 6 | 7 | here = path.abspath(path.dirname(__file__)) 8 | 9 | 10 | def read(*parts): 11 | with codecs.open(path.join(here, *parts), 'r') as fp: 12 | return fp.read() 13 | 14 | 15 | def find_version(*file_paths): 16 | version_file = read(*file_paths) 17 | version_match = re.search(r"^__version__ = ['\"]([^'\"]*)['\"]", 18 | version_file, re.M) 19 | if version_match: 20 | return version_match.group(1) 21 | raise RuntimeError("Unable to find version string.") 22 | 23 | 24 | setup( 25 | name='pythonbits', 26 | version=find_version("pythonbits", "__init__.py"), 27 | description="A pretty printer for media", 28 | license='GPLv3', 29 | url='https://github.com/mueslo/pythonBits', 30 | packages=find_packages(exclude=['contrib', 'docs', 'tests']), 31 | install_requires=[ 32 | "configparser~=3.5", 33 | "imdbpie~=5.5", 34 | "requests~=2.18", 35 | "tvdb-api~=3.0", 36 | "attrdict~=2.0", 37 | "appdirs~=1.4", 38 | "pymediainfo~=2.2", 39 | "guessit~=3.1", 40 | "unidecode~=1.0", 41 | "logbook~=1.2", 42 | "pyreadline~=2.1", 43 | "progressbar2~=3.38", 44 | "mutagen~=1.44", 45 | "musicbrainzngs~=0.7", 46 | "terminaltables~=3.1", 47 | ], 48 | python_requires=">=3.5,<3.10", 49 | tests_require=['tox', 'pytest', 'flake8', 'pytest-logbook'], 50 | classifiers=[ 51 | "Development Status :: 5 - Production/Stable", 52 | "Environment :: Console", 53 | "Operating System :: OS Independent", 54 | "Programming Language :: Python", 55 | "Programming Language :: Python :: 3", 56 | "Programming Language :: Python :: 3.5", 57 | "Programming Language :: Python :: 3.6", 58 | "Programming Language :: Python :: 3.7", 59 | "Programming Language :: Python :: 3.8", 60 | "Programming Language :: Python :: 3.9", 61 | "License :: OSI Approved :: GNU General Public License v3 (GPLv3)", 62 | "Topic :: Internet", 63 | "Topic :: Multimedia", 64 | "Topic :: Utilities"], 65 | entry_points={ 66 | 'console_scripts': [ 67 | 'pythonbits = pythonbits.__main__:main', 68 | ] 69 | }, 70 | ) 71 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | from unittest import mock 3 | import pythonbits.config as config 4 | 5 | dir_path = os.path.dirname(os.path.realpath(__file__)) 6 | config.config = config.Config(dir_path + '/pythonbits.cfg') 7 | config.config._write = mock.MagicMock() 8 | -------------------------------------------------------------------------------- /tests/pythonbits.cfg: -------------------------------------------------------------------------------- 1 | [General] 2 | version = 1 3 | 4 | [Tracker] 5 | domain = mydomain.net 6 | 7 | [Foo] 8 | bar = baz 9 | bar2 10 | ham 11 | spam 12 | spam2 = spam2_value -------------------------------------------------------------------------------- /tests/test_config.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from unittest import mock 3 | 4 | import pythonbits.config as config 5 | c = config.config 6 | 7 | c.register('Foo', 'bar', 'bar?') 8 | c.register('Foo', 'bar2', 'bar2?') 9 | c.register('Foo', 'bar3', 'bar3?', ask=True) 10 | c.register('Foo', 'bar4', 'bar4?', ask=True) 11 | c.register('Foo', 'ham', 'ham?') 12 | c.register('Foo', 'eggs', 'eggs?') 13 | 14 | 15 | def test_get(): 16 | # todo: atomic tests with fresh config file 17 | 18 | # registered option, present in config file 19 | assert c.get('Foo', 'bar') == 'baz' 20 | assert c.get('Foo', 'bar', 'bar_default') == 'baz' 21 | with mock.patch('builtins.input', lambda q: 'some_input'): 22 | assert c.get('Foo', 'ham') == 'some_input' 23 | 24 | # registered option, present in config file, but empty 25 | assert c.get('Foo', 'bar2', 'bar2_default') == 'bar2_default' 26 | with mock.patch('builtins.input', lambda q: 'bar2_input'): 27 | assert c.get('Foo', 'bar2') == 'bar2_input' 28 | # assert c.get('Foo', 'bar2') == 'bar2_input' # see todo: non-mocked _write 29 | 30 | # registered option, not present in config file, ask=True 31 | assert c.get('Foo', 'bar3', 'bar3_default') == 'bar3_default' 32 | with mock.patch('builtins.input', side_effect=['bar3_input', 'y']) as m: 33 | assert c.get('Foo', 'bar3') == 'bar3_input' 34 | with pytest.raises(StopIteration): # make sure all consumed 35 | next(m.side_effect) 36 | assert c.get('Foo', 'bar3') == 'bar3_input' 37 | 38 | # lifecycle test: registered option, not present, ask=True 39 | assert c.get('Foo', 'bar4', 'bar4_default') == 'bar4_default' 40 | with mock.patch('builtins.input', side_effect=['bar4_input', 'n']) as m: 41 | assert c.get('Foo', 'bar4') == 'bar4_input' 42 | with pytest.raises(StopIteration): # make sure all consumed 43 | next(m.side_effect) 44 | assert c.get('Foo', 'bar4', 'bar4_default2') == 'bar4_default2' 45 | with mock.patch('builtins.input', side_effect=['bar4_input2', 'nr']) as m: 46 | assert c.get('Foo', 'bar4') == 'bar4_input2' 47 | with pytest.raises(StopIteration): # make sure all consumed 48 | next(m.side_effect) 49 | assert c.get('Foo', 'bar4', 'bar4_default3') == 'bar4_default3' 50 | with mock.patch('builtins.input', side_effect=['bar4_input3']) as m: 51 | assert c.get('Foo', 'bar4') == 'bar4_input3' 52 | with pytest.raises(StopIteration): # make sure all consumed 53 | next(m.side_effect) 54 | 55 | # unregistered option, present in config file, but empty 56 | assert c.get('Foo', 'spam', 'spam_default') == 'spam_default' 57 | with pytest.raises(config.UnregisteredOption): 58 | c.get('Foo', 'spam') 59 | 60 | # unregistered option, present in config file 61 | assert c.get('Foo', 'spam2') == 'spam2_value' 62 | assert c.get('Foo', 'spam2', 'spam2_default') == 'spam2_value' 63 | 64 | # unregistered option, not present in config file 65 | assert c.get('Foo', 'spam3', 'spam3_default') == 'spam3_default' 66 | with pytest.raises(config.UnregisteredOption): 67 | c.get('Foo', 'spam3') 68 | 69 | # registered option, not present in config file 70 | assert c.get('Foo', 'eggs', default='default_input') == 'default_input' 71 | 72 | with mock.patch('builtins.input', lambda q: 'more_input'): 73 | assert c.get('Foo', 'eggs') == 'more_input' 74 | 75 | assert c.get('Foo', 'eggs') == 'more_input' 76 | -------------------------------------------------------------------------------- /tests/test_dummy.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import pythonbits.submission as submission # noqa: E402 3 | import pythonbits.bb as bb # noqa: E402 4 | import pytest # noqa: E402 5 | 6 | 7 | def test_attribute_logic(): 8 | s = submission.Submission(fieldname='somevalue', title='overrides_render') 9 | with pytest.raises(submission.SubmissionAttributeError): 10 | s['nonexistent_attribute'] 11 | 12 | assert s['fieldname'] == 'somevalue' 13 | assert s['title'] == 'overrides_render' 14 | 15 | 16 | # title, path, correct_specifier 17 | tv_names = [(None, 'some.series.s02e11.avi', ('some series', 2, 11)), 18 | (None, 'another series s04e02.mkv', ('another series', 4, 2)), 19 | (None, 'A.Series.S10.BluRay', ('A Series', 10, None)), 20 | (None, 'different.format.4x12.WEB-DL.DTS.MUESLo.mkv', 21 | ('different format', 4, 12)), 22 | (None, 'good.show.s01e09e10.1080p.bluray.x264-nomads.mkv', 23 | ('good show', 1, [9, 10])), 24 | ("Yet Another Video Submission", 'yavs.s04e03.mkv', 25 | ("Yet Another Video Submission", 4, 3)), 26 | ("Firefly S02", 'Firefly.S01E03.mkv', ("Firefly", 2, None)), 27 | ('Ramen Brothers Season 4', None, ('Ramen Brothers', 4, None)), 28 | ('Ramen Brothers S04', None, ('Ramen Brothers', 4, None)), 29 | ('Ramen Brothers 4', "filename", None), 30 | ('Ramen Brothers 4', "rb.s01e02.avi", ('Ramen Brothers 4', 1, 2)), 31 | ] 32 | 33 | 34 | @pytest.mark.parametrize("title,path,specifier", tv_names) 35 | def test_tv_specifier(title, path, specifier): 36 | s = bb.VideoSubmission(path=path, title_arg=title) 37 | assert s['tv_specifier'] == specifier 38 | 39 | 40 | def test_proper(): 41 | tracks = {'general': "", 42 | 'video': dict(), 43 | 'audio': "", 44 | 'text': ["sub", "title"]} 45 | 46 | s = bb.VideoSubmission( 47 | path=("Some.Awesome.Show.S26E12.REPACK." 48 | "With.A.Show.Title.720p.WEB-DL.AAC2.0.H.264-pontifex.mkv"), 49 | title_arg=None, 50 | scene=True, 51 | tracks=tracks) 52 | 53 | assert s['additional'][0] == 'PROPER' 54 | 55 | 56 | normalise_pairs = [ 57 | ("Basic Feature", "basic.feature"), 58 | ("Name O'Comment-Doublename", "name.o.comment.doublename"), 59 | ("François and Влади́мир like Ümläutß", 60 | "francois.and.vladimir.like.umlautss"), 61 | ("Blarb Børgen Ålpotef", "blarb.borgen.alpotef"), 62 | ] 63 | 64 | 65 | @pytest.mark.parametrize("input, correct", normalise_pairs) 66 | def test_normalise_tags(input, correct): 67 | assert bb.format_tag(input) == correct 68 | 69 | 70 | def test_unicode(): 71 | s = submission.Submission(somefield="卧虎藏龙") 72 | s.show_fields(('somefield',)) 73 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py35,py36,py37,py38,py39,flake8 3 | skip_missing_interpreters = true 4 | 5 | [testenv] 6 | commands = 7 | pytest {posargs: tests} 8 | deps = 9 | pytest 10 | 11 | [testenv:flake8] 12 | commands = 13 | flake8 14 | deps = 15 | flake8 16 | --------------------------------------------------------------------------------