├── .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 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/.idea/codeStyles/codeStyleConfig.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/.idea/runConfigurations/pytest.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
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 | [](https://GitHub.com/mueslo/pythonBits/releases/)
3 | [](https://GitHub.com/mueslo/pythonBits/releases/)
4 | [](https://pypi.python.org/pypi/pythonbits/)
5 | [](https://pypi.python.org/pypi/pythonbits/)
6 | [](https://github.com/mueslo/pythonBits/commits/master)
7 | [](https://github.com/mueslo/pythonbits/blob/master/LICENSE)
8 | [](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.+)(?(s|season |))"
241 | r"(?P((?<= s)[0-9]{2,})|(?<= )[0-9]+(?=x)|(?<=season )[0-9]+(?=$))"
242 | r"((?P[ex])(?P[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 " AKA " 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/] ")
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 | #
You must enter at least
117 | # one tag. Maximum length is 200 characters.
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'