├── .editorconfig
├── .gitignore
├── LICENSE
├── README.md
├── requirements.txt
├── setup.py
├── tez
└── twitchez
├── __init__.py
├── __main__.py
├── auth.py
├── bmark.py
├── clip.py
├── command.py
├── conf.py
├── config
├── blank.jpg
├── default.conf
└── defkeys.conf
├── data.py
├── fs.py
├── hints.py
├── init.py
├── iselect.py
├── keys.py
├── keys_help.py
├── notify.py
├── open_chat.py
├── open_cmd.py
├── paged.py
├── pages.py
├── render.py
├── search.py
├── tabs.py
├── thumbnails.py
└── utils.py
/.editorconfig:
--------------------------------------------------------------------------------
1 | # top-most EditorConfig file
2 | root = true
3 |
4 | # Unix-style newlines with a newline ending every file
5 | [*]
6 | end_of_line = lf
7 | insert_final_newline = true
8 |
9 | indent_style = space
10 | indent_size = 4
11 | charset = utf-8
12 | trim_trailing_whitespace = true
13 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *.txt
2 | __pycache__/
3 | venv/
4 | twitchez.egg-info/
5 | build/
6 | dist/
7 | tests/
8 |
--------------------------------------------------------------------------------
/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 | .
675 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # twitchez
2 |
3 |
4 |
7 |
8 |
9 | 
10 | 
11 | 
12 |
13 | 
14 |
15 |
16 | twitchez - TUI client for twitch.tv with thumbnails support that works right in your terminal.
17 |
18 | Support of rendering images by the terminal is not required, ueberzugpp will handle that.\
19 | You may ask -- **"Is this magic?"** -- Well **YES**, the black magic! Welcome to the club!
20 |
21 | Since **v0.0.7** twitchez supports **ueberzugpp** -- this expands list of supported platforms:\
22 | **linux / macOS / windows / freeBSD / X11 / Wayland /** any terminal with **SIXEL** support e.g.
23 | [WezTerm](https://github.com/wez/wezterm)
24 |
25 | ### Leave a star to show interest in further development of the project ⭐️
26 |
27 | https://user-images.githubusercontent.com/15724752/152787467-dc2a8871-43e5-4530-94b1-e14383c8b18e.mp4
28 |
29 | ## Features
30 | * Explore twitch without leaving your terminal
31 | * Flexible configuration via user config (including custom cmd)
32 | * Completely keyboard driven workflow
33 | * Zero mouse interaction. `F1 / ?` for help about key mappings
34 | * Redefine keys and hint chars for your keyboard layout
35 | * Link hints similar as in (Vimium, Surfingkeys, etc.)
36 | * Interactive select of one entry from all
37 | ([fzf](https://github.com/junegunn/fzf),
38 | [dmenu](https://tools.suckless.org/dmenu/),
39 | or any other program via custom cmd)
40 | * Bookmarks & Tabs (add, delete, next/prev, jump to tab by name)
41 | * Following live channels
42 | * Streams per category
43 | * Videos per channel (archive/past broadcasts, clips, highlights, uploads)
44 | * Open video/stream url in external video player
45 | ([streamlink](https://github.com/streamlink/streamlink),
46 | [mpv](https://github.com/mpv-player/mpv),
47 | or any other program via custom cmd)
48 | * Three independent user cmd and keys to open url as (stream, video, extra)
49 | * Copy url to clipboard
50 | * Open chat url in default browser or via custom cmd
51 | * Thumbnails are drawn by the [ueberzugpp](https://github.com/jstkdng/ueberzugpp) (optional dependency)
52 |
53 | ## Configuration
54 | Look inside `twitchez/config/` dir to see all available settings, those are defaults.\
55 | **Do not change default config files**, create new in the user config dir: `config.conf`, `keys.conf`.\
56 | The default user config dir is `$XDG_CONFIG_HOME/twitchez/`, or `$HOME/.config/twitchez/` by default.\
57 | Settings from default config files are used as fallback for settings you haven't changed in your user config.
58 |
59 | ## Install
60 | ### Pip
61 | Install [twitchez](https://pypi.org/project/twitchez/) via [pip](https://pip.pypa.io/en/stable/)
62 | into user-wide environment:
63 | ```
64 | $ pip3 install --user twitchez
65 | ```
66 | or system-wide environment:
67 | ```
68 | # pip3 install twitchez
69 | ```
70 | To update, add the `--upgrade` or `-U` option.
71 |
72 | #### Install ueberzugpp to display thumbnails (Optional)
73 | If [ueberzugpp](https://github.com/jstkdng/ueberzugpp?tab=readme-ov-file#install)
74 | is not installed **text mode without thumbnails** will be used.
75 |
76 | You also can [build from source](https://github.com/jstkdng/ueberzugpp?tab=readme-ov-file#build-from-source)
77 | and install **build dir** e.g. `# sudo cmake --install build`
78 |
79 | ## Troubleshooting
80 | ##### If you installed ueberzugpp but still not see thumbnails:
81 | * override default ueberzugpp output **via twitchez user config** *(check **default.conf** it has example)*
82 | * check available **output** options in **ueberzugpp** via `$ ueberzugpp layer --help`
83 | * x11 and/or wayland (may not be available if disabled in compilation) -- build ueberzugpp from source
84 | * if you want to draw via e.g. sixel, make sure that your terminal have such capability
85 | * [WezTerm](https://github.com/wez/wezterm) has sixel support, try to launch twitchez in it
86 |
87 | ##### If thumbnails partially overlap underlying text (it is very font dependent):
88 | * set width/height modifier in user config
89 | * adjust your terminal font size by +1 etc
90 | * try different terminal font
91 |
92 | ## License
93 | [GPL-3.0](https://choosealicense.com/licenses/gpl-3.0/)
94 |
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | aiohttp
2 | requests
3 |
--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | # coding=utf-8
3 |
4 | import twitchez
5 |
6 | from setuptools import find_packages, setup
7 |
8 | import re
9 |
10 |
11 | def remove_gif(text: str) -> str:
12 | return re.sub(r"\n^
$\n\n", "", text, flags=re.DOTALL | re.MULTILINE)
13 |
14 |
15 | def remove_video(text: str) -> str:
16 | return re.sub(r"http.*\.mp4\n\n", "", text)
17 |
18 |
19 | def replace_gh_video_via_mdlink(text: str) -> str:
20 | return re.sub(r"(http.*\.mp4)\n\n", r"### [CLICK TO WATCH DEMO VIDEO](\1)\n\n", text)
21 |
22 |
23 | def clean_md(text: str) -> str:
24 | """Clean the markdown text for pypi.org.
25 | directly embedded github videos are not supported by pypi.org.
26 | """
27 | # text = remove_gif(text)
28 | text = replace_gh_video_via_mdlink(text)
29 | return text
30 |
31 |
32 | with open("README.md", "r") as f:
33 | long_description = f.read()
34 | with open("requirements.txt", "r") as f:
35 | requirements = [line.strip() for line in f]
36 |
37 | long_description = clean_md(long_description)
38 |
39 | setup(
40 | name="twitchez",
41 | version=twitchez.__version__,
42 | license=twitchez.__license__,
43 | url=twitchez.__url_project__,
44 | author=twitchez.__author__,
45 | description=twitchez.__description__,
46 | long_description=long_description,
47 | long_description_content_type="text/markdown",
48 | packages=find_packages(),
49 | python_requires=">=3.6",
50 | install_requires=requirements,
51 | extras_require={
52 | "thumbnails": ["ueberzugpp", "ueberzug"],
53 | },
54 | package_data={
55 | "twitchez": ["config/*.conf", "config/blank.jpg"],
56 | },
57 | entry_points={
58 | "console_scripts": ["twitchez=twitchez.__main__:main"]
59 | },
60 | classifiers=[
61 | "License :: OSI Approved :: GNU General Public License v3 (GPLv3)",
62 | "Programming Language :: Python :: 3",
63 | "Environment :: Console :: Curses",
64 | "Operating System :: POSIX",
65 | "Operating System :: Unix",
66 | "Development Status :: 4 - Beta",
67 | ],
68 | keywords="twitch TUI terminal curses ui client thumbnail image twitch.tv",
69 | project_urls={
70 | "Bug Reports": twitchez.__url_bug_reports__,
71 | "Source": twitchez.__url_repository__,
72 | },
73 | )
74 |
--------------------------------------------------------------------------------
/tez:
--------------------------------------------------------------------------------
1 | #!/usr/bin/python
2 | # -*- coding: utf-8 -*-
3 | # I included this file just for those who do not know about the setup.py entry point
4 | # I can not guarantee that this launcher will work on your machine!
5 | # better install twitchez from pip and use the original launcher = 'twitchez'
6 |
7 | # NOTE: this launcher in not tested! Do not forget to: chmod +x ./tez
8 |
9 | import re
10 | import sys
11 | from twitchez.__main__ import main
12 | if __name__ == '__main__':
13 | sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
14 | sys.exit(main())
15 |
--------------------------------------------------------------------------------
/twitchez/__init__.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | # coding=utf-8
3 |
4 | from pathlib import Path
5 | import curses
6 |
7 | __version__ = "0.1.0"
8 | __license__ = "GPLv3"
9 | __description__ = "twitchez - TUI client for twitch.tv with thumbnails support that works right in your terminal"
10 | __url_repository__ = "https://github.com/WANDEX/twitchez"
11 | __url_bug_reports__ = "https://github.com/WANDEX/twitchez/issues"
12 | __url_project__ = __url_repository__
13 | __author__ = "WANDEX"
14 |
15 | # Constants
16 | ENCODING = "utf-8"
17 | HEADER_H = 2
18 | TWITCHEZDIR = Path(__file__).parent.resolve()
19 |
20 | # NOTE: STDSCR defined here for ease of reuse, to be able to see actual curses funcs
21 | # and clarify variable type for: interpreter, diagnostic messages, developer, etc.
22 | # we are initializing the actual application later with the curses.wrapper()
23 | # then we override this global constant by the actual stdscr right after initialization
24 | try:
25 | STDSCR = curses.initscr()
26 | # all of the following to safely terminate temporary initialized curses application
27 | # and restore the terminal to default settings and operating mode.
28 | STDSCR.keypad(False)
29 | curses.echo()
30 | curses.nocbreak()
31 | STDSCR.refresh() # fix: endwin() requires intervening screen update (new libncurses)
32 | curses.endwin()
33 | except Exception:
34 | try:
35 | curses.endwin()
36 | except Exception:
37 | pass
38 |
--------------------------------------------------------------------------------
/twitchez/__main__.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | # coding=utf-8
3 |
4 |
5 | def check_auth_data():
6 | """Check user auth token before launch of the main program."""
7 | from twitchez import fs
8 | private_file = fs.private_data_path()
9 | # private_file is empty -> get auth token & write to private_file
10 | if not private_file.stat().st_size:
11 | from twitchez import auth
12 | auth.get_auth_token()
13 | print("Launch me again!")
14 | exit(69)
15 |
16 |
17 | def main():
18 | check_auth_data()
19 | from twitchez import init
20 | init.main()
21 |
22 |
23 | if __name__ == "__main__":
24 | main()
25 |
--------------------------------------------------------------------------------
/twitchez/auth.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | # coding=utf-8
3 |
4 | from random import randint
5 | from twitchez.clip import clip
6 | from twitchez.data import write_private_data
7 | import requests
8 |
9 |
10 | def generate_nonce(length=8):
11 | """Generate pseudorandom number."""
12 | return ''.join([str(randint(0, 9)) for _ in range(length)])
13 |
14 |
15 | def get_user_id(token, c_id):
16 | """Get user id by access token."""
17 | url = "https://api.twitch.tv/helix/users"
18 | headers = {
19 | "Authorization": f"Bearer {token}",
20 | "Client-Id": c_id
21 | }
22 | try:
23 | r = requests.get(url, headers=headers)
24 | except Exception as err:
25 | raise Exception(err)
26 | return(r.json()['data'][0]['id'])
27 |
28 |
29 | def get_auth_token():
30 | """Read more here:
31 | 'https://dev.twitch.tv/docs/authentication/getting-tokens-oauth/#oauth-implicit-code-flow'
32 | """
33 | client_id = "dadsrpg93f0tvvq8zhbno69m2e3spr" # this application client id
34 | redirect_uri = "https://localhost"
35 | scope = "user:read:follows"
36 | state = generate_nonce() # against simple CSRF attacks
37 | url = "".join((
38 | "https://id.twitch.tv/oauth2/authorize"
39 | f"?client_id={client_id}"
40 | f"&redirect_uri={redirect_uri}"
41 | "&response_type=token"
42 | f"&scope={scope}"
43 | f"&state={state}"
44 | ))
45 | try:
46 | r = requests.get(url)
47 | except Exception as err:
48 | raise Exception(err)
49 | if state in r.url: # for safety check that 'state' is substring in response url
50 | bold = "\033[1m"
51 | end = "\033[0;0m"
52 | # copy url to the clipboard if we can
53 | # do not show user note if clipboard cmd is not set or executable is not found
54 | clip(r.url, show_note=False)
55 | print("1) Open following url in your browser.")
56 | print("2) If asked to login into twitch, you are required to do so, in order to get 'access_token' only known by twitch, and now also known by YOU! B)")
57 | print(f"{bold}After successful login, page is not existing! ALL WORK AS EXPECTED!{end}")
58 | print("3) Copy from browser url - part of 'access_token' content (from '=' to first '&' excluding those symbols!) and paste that as input here.")
59 | print(f"'{r.url}'")
60 | access_token = input("access_token=").strip()
61 | # try to get user_id by new access_token & validate that user put right access_token
62 | user_id = get_user_id(access_token, client_id)
63 | # write to private file for using in further requests
64 | write_private_data(user_id, access_token, client_id)
65 | print("SUCCESS")
66 | else:
67 | print(f"original state: '{state}' not matches state in response!")
68 | print("^ because of that - to prevent possible 'CSRF attack' on you, application was stopped!")
69 | exit(66)
70 |
71 |
72 | if __name__ == "__main__":
73 | # execute when ran directly
74 | get_auth_token()
75 |
--------------------------------------------------------------------------------
/twitchez/bmark.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | # coding=utf-8
3 |
4 | from ast import literal_eval
5 | from pathlib import Path
6 | from twitchez import conf
7 | from twitchez import fs
8 | from twitchez.iselect import iselect
9 | from twitchez.tabs import find_tab, cpname_set, tab_add_new
10 |
11 | bmsf = Path(fs.get_data_dir("data"), "bookmarks").resolve().as_posix()
12 | SECT = "BMARKS"
13 |
14 |
15 | def bmark_list() -> list:
16 | """Return list of bookmark (name, dict) tuples."""
17 | return conf.dta_list(SECT, bmsf)
18 |
19 |
20 | def bmark_names() -> list:
21 | """Return list of bookmark names."""
22 | return [bname for bname, _ in bmark_list()]
23 |
24 |
25 | def bmark_save(page_name: str, page_dict: dict):
26 | """Save bookmark."""
27 | conf.dta_set(page_name, page_dict, SECT, bmsf)
28 |
29 |
30 | def bmark_add():
31 | """Find tab and save as bookmark."""
32 | page_dict = find_tab({})
33 | # handle cancel of the command
34 | if not page_dict:
35 | return
36 | page_name = page_dict.get("page_name")
37 | bmark_save(page_name, page_dict)
38 |
39 |
40 | def bmark_find() -> tuple[str, dict]:
41 | """Find and return (name, dict) tuple of the selected bookmark."""
42 | bnames = bmark_names()
43 | mulstr = "\n".join(bnames)
44 | bname = iselect(mulstr, 130)
45 | # handle cancel of the command
46 | if (bname == 130):
47 | return "", {}
48 | # get dict by the key
49 | bdict = {}
50 | for key, val in bmark_list():
51 | if (key == bname):
52 | bdict = dict(literal_eval(val))
53 | break
54 | return bname, bdict
55 |
56 |
57 | def bmark_del():
58 | """Delete bookmark by the name."""
59 | bname, _ = bmark_find()
60 | if (not bname):
61 | return
62 | conf.dta_rmo(bname, SECT, bmsf)
63 |
64 |
65 | def bmark_open(fallback: dict) -> dict:
66 | """Open bookmark by the name."""
67 | bname, bdict = bmark_find()
68 | if (not bname or not bdict):
69 | return fallback
70 | tab_add_new(bname)
71 | cpname_set(bname)
72 | return bdict
73 |
--------------------------------------------------------------------------------
/twitchez/clip.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | # coding=utf-8
3 |
4 | from shutil import which
5 | from twitchez import ENCODING
6 | from twitchez import command
7 | from twitchez import conf
8 | from twitchez.notify import notify
9 | import subprocess
10 |
11 | clip_cmd = conf.setting("clip_cmd")
12 | executable = command.first_cmd_word(clip_cmd)
13 | without_funcs = command.without_funcs(executable)
14 | cmd_check = command.cmd_check(executable)
15 |
16 |
17 | def xclip_cmd() -> list:
18 | cmd = "xclip -in -selection clipboard"
19 | return cmd.split()
20 |
21 |
22 | def xsel_cmd() -> list:
23 | cmd = "xsel -i --clipboard"
24 | return cmd.split()
25 |
26 |
27 | def raise_user_note():
28 | """raise exception for regular user without traceback."""
29 | if without_funcs:
30 | return
31 | a = "A program for copying content to clipboard was not found at your 'PATH'."
32 | b = "You can install 'xclip' and it will be working by default."
33 | c = "Also you can set your own program cmd via 'clip_cmd = your cmd' in config."
34 | d = "If you want to use this program without using it's clipboard functions,"
35 | e = "simply paste next line in your config:"
36 | f = "clip_cmd = false"
37 | full_text = f"\n{a}\n{b}\n{c}\n{d}\n{e}\n{f}\n"
38 | raise Exception(full_text)
39 |
40 |
41 | def get_clip_cmd(show_note: bool) -> list:
42 | """Check & return cmd if executable is on PATH."""
43 | cmd = []
44 | # prefer clip_cmd if set in config and executable found at PATH
45 | if cmd_check:
46 | cmd = clip_cmd.split()
47 | elif which("xclip"):
48 | cmd = xclip_cmd()
49 | elif which("xsel"):
50 | cmd = xsel_cmd()
51 | else:
52 | if show_note:
53 | raise_user_note()
54 | else:
55 | return []
56 | return cmd
57 |
58 |
59 | def clip(content: str, show_note=True):
60 | """Copy content to clipboard."""
61 | if without_funcs:
62 | return
63 | cmd = get_clip_cmd(show_note=show_note)
64 | if not cmd:
65 | return
66 | text = content.strip()
67 | p = subprocess.Popen(cmd, stdin=subprocess.PIPE, close_fds=True)
68 | p.communicate(input=text.encode(ENCODING))
69 | p.wait() # wait for process to finish
70 | if p.returncode == 0:
71 | notify(text, "C:", show_note=show_note)
72 | else:
73 | notify(f"ERROR({p.returncode}): probably malformed cmd!",
74 | "NOT copied:", error=True, show_note=show_note)
75 |
--------------------------------------------------------------------------------
/twitchez/command.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | # coding=utf-8
3 |
4 | from twitchez import conf
5 |
6 | from shutil import which
7 |
8 |
9 | def first_cmd_word(cmd: str) -> str:
10 | return cmd.split()[0]
11 |
12 |
13 | def without_funcs(executable: str) -> bool:
14 | """return True if funcs are disabled via cmd in settings."""
15 | if "false" in executable.lower():
16 | return True
17 | return False
18 |
19 |
20 | def executable_check(executable: str) -> bool:
21 | """return True if executable(first word from cmd) set in config and found at PATH."""
22 | if executable != "undefined":
23 | if which(executable):
24 | return True
25 | return False
26 |
27 |
28 | def cmd_check(executable: str) -> bool:
29 | """return True if funcs are enabled & checks of executable are passed."""
30 | if not without_funcs(executable):
31 | if executable_check(executable):
32 | return True
33 | return False
34 |
35 |
36 | def conf_cmd_check(conf_cmd: str) -> tuple[bool, str]:
37 | """check, return cmd from config."""
38 | cmd = conf.setting(conf_cmd)
39 | executable = first_cmd_word(cmd)
40 | cmd_ok = cmd_check(executable)
41 | return cmd_ok, cmd
42 |
43 |
--------------------------------------------------------------------------------
/twitchez/conf.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | # coding=utf-8
3 |
4 | from configparser import ConfigParser, NoSectionError
5 | from pathlib import Path
6 | from twitchez import TWITCHEZDIR, fs
7 |
8 |
9 | def read_conf(*configs):
10 | """Read config files with omitted section.
11 | Last config takes precedence over previous configs.
12 | """
13 | parser = ConfigParser()
14 | section = "[GENERAL]\n"
15 | for f in configs:
16 | if Path(f).is_file(): # check that file exist
17 | with open(f) as stream:
18 | parser.read_string(section + stream.read())
19 | return parser
20 |
21 |
22 | glob_conf_dir = Path(TWITCHEZDIR, "config").resolve()
23 |
24 | glob_conf = Path(glob_conf_dir, "default.conf").resolve()
25 | user_conf = Path(fs.get_user_conf_dir(), "config.conf").resolve()
26 |
27 | glob_keys = Path(glob_conf_dir, "defkeys.conf").resolve()
28 | user_keys = Path(fs.get_user_conf_dir(), "keys.conf").resolve()
29 |
30 | temp_vars = Path(fs.get_tmp_dir(), "vars").resolve()
31 | data_vars = Path(fs.get_data_dir("data"), "data").resolve()
32 |
33 | config = read_conf(glob_conf, user_conf)
34 | keymap = read_conf(glob_keys, user_keys)
35 |
36 |
37 | def setting(keyname):
38 | found = config.get("GENERAL", keyname)
39 | return found
40 |
41 |
42 | def key(keyname):
43 | found = keymap.get("GENERAL", keyname, fallback="")
44 | return found
45 |
46 |
47 | def tmp_set(option, value, section="GENERAL"):
48 | """Set tmp variable value."""
49 | temp = ConfigParser()
50 | temp.read(temp_vars)
51 | if not temp.has_section(section):
52 | temp.add_section(section)
53 | temp.set(str(section), str(option), str(value))
54 | with open(temp_vars, "w") as f:
55 | temp.write(f, space_around_delimiters=False)
56 |
57 |
58 | def tmp_get(keyname, fallback, section="GENERAL"):
59 | """Get tmp variable value."""
60 | temp = ConfigParser()
61 | temp.read(temp_vars)
62 | if not temp.has_section(section):
63 | temp.add_section(section)
64 | if fallback or not temp.has_option(section, keyname):
65 | found = temp.get(section, keyname, fallback=fallback)
66 | else:
67 | found = temp.get(section, keyname)
68 | return found
69 |
70 |
71 | def cfpath(fallback: Path, fpath="") -> Path:
72 | """Return fallback path, if fpath is not valid."""
73 | ppath = Path(fpath)
74 | if not fpath or not ppath.is_file:
75 | ppath = fallback
76 | return ppath.resolve()
77 |
78 |
79 | def dta_file(fpath="") -> Path:
80 | """Return fpath path or fallback to the default data file path."""
81 | return cfpath(data_vars, fpath)
82 |
83 |
84 | def cp_dta(fpath: Path) -> tuple[ConfigParser, Path]:
85 | """Set defaults, read config & return class object."""
86 | fpath = dta_file(fpath.as_posix())
87 | dta = ConfigParser()
88 | # fix: preserve capitalization (option as is without transformation)
89 | # read more: https://docs.python.org/3/library/configparser.html#ConfigParser.optionxform(option)
90 | dta.optionxform = lambda option: option
91 | dta.read(fpath)
92 | return dta, fpath
93 |
94 |
95 | def dta_set(option, value, section="GENERAL", fpath=""):
96 | """Set data variable value."""
97 | fpath = dta_file(fpath)
98 | dta, fp = cp_dta(fpath)
99 | if not dta.has_section(section):
100 | dta.add_section(section)
101 | dta.set(str(section), str(option), str(value))
102 | with open(fp, "w") as f:
103 | dta.write(f, space_around_delimiters=False)
104 |
105 |
106 | def dta_get(option, fallback, section="GENERAL", fpath=""):
107 | """Get data variable value."""
108 | fpath = dta_file(fpath)
109 | dta, _ = cp_dta(fpath)
110 | if not dta.has_section(section):
111 | dta.add_section(section)
112 | if fallback or not dta.has_option(section, option):
113 | found = dta.get(section, option, fallback=fallback)
114 | else:
115 | found = dta.get(section, option)
116 | return found
117 |
118 |
119 | def dta_rmo(option: str, section="GENERAL", fpath=""):
120 | """Remove data option."""
121 | fpath = dta_file(fpath)
122 | dta, fp = cp_dta(fpath)
123 | dta.remove_option(section, option)
124 | with open(fp, "w") as f:
125 | dta.write(f, space_around_delimiters=False)
126 |
127 |
128 | def dta_list(section="GENERAL", fpath="") -> list:
129 | """Return a list of (name, value) tuples for each option in a section."""
130 | fpath = dta_file(fpath)
131 | dta, _ = cp_dta(fpath)
132 | try:
133 | return dta.items(section)
134 | except NoSectionError:
135 | return []
136 |
--------------------------------------------------------------------------------
/twitchez/config/blank.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/WANDEX/twitchez/4de01729556943e071d69b7770d67245985c5e35/twitchez/config/blank.jpg
--------------------------------------------------------------------------------
/twitchez/config/default.conf:
--------------------------------------------------------------------------------
1 | # [1–9]: the larger, the smaller the thumbnail and grid size.
2 | grid_size = 6
3 |
4 | # width/height modifier for perfect placement of thumbnails in the grid
5 | # +-int in terminal cells (very font dependent)
6 | wmod = 0
7 | hmod = 0
8 |
9 | # The higher the value, the more rows of cells there will be in the grid
10 | # [0-3]: 0 => thumbnails mode.
11 | text_mode = 0
12 |
13 | # visible length of one emoji in terminal cells
14 | emoji_cells = 2
15 |
16 | hint_chars = jklhuiopyfdsagrewtvcxzmnb
17 |
18 | # expire time of default notifications
19 | notify_time = 2000
20 |
21 | # set your own program cmd to send desktop notifications
22 | # (can include all options except summary & body)
23 | # false - to disable all notifications
24 | notify_cmd = undefined
25 |
26 | # set your own program cmd for copying into clipboard
27 | clip_cmd = undefined
28 |
29 | # set your own program cmd for opening url
30 | open_chat_cmd = undefined
31 | open_stream_cmd = undefined
32 | open_video_cmd = mpv
33 | open_extra_cmd = undefined
34 |
35 | # set your own program cmd for interactive select of one entry from all
36 | select_cmd = undefined
37 |
38 | # NOTE: $ ueberzugpp layer --help
39 | # set your own ueberzugpp cmd to override default output etc.
40 | # example: ueberzug_cmd = ueberzugpp layer --no-cache --silent --output sixel
41 | ueberzug_cmd = undefined
42 |
43 | # vim: ft=cfg
44 |
--------------------------------------------------------------------------------
/twitchez/config/defkeys.conf:
--------------------------------------------------------------------------------
1 | bmark_add = A
2 | bmark_delete = D
3 | bmark_open = B
4 | full_title = t
5 | keys_help = ?
6 | tab_find = T
7 | tab_add = a
8 | tab_delete = d
9 | tab_next = ]
10 | tab_prev = [
11 | quit = q
12 | redraw = r
13 | redownload = R
14 | hint_open_stream = s
15 | hint_open_video = v
16 | hint_open_extra = V
17 | hint_open_chat = x
18 | hint_clip_url = c
19 | scroll_top = g
20 | scroll_bot = G
21 | scroll_up = k
22 | scroll_down = j
23 | scroll_up_page = K
24 | scroll_down_page = J
25 | yank_urls = u
26 | yank_urls_page = U
27 |
28 | # vim: ft=cfg
29 |
--------------------------------------------------------------------------------
/twitchez/data.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | # coding=utf-8
3 |
4 | from pathlib import Path
5 | from requests import get
6 | from twitchez import fs
7 | import json
8 |
9 |
10 | def validate_data(d :dict):
11 | """Check and handle status code if found in data."""
12 | status = 0 # initial status code (ALL OK)
13 | if "status" in d:
14 | status = int(d["status"])
15 | if not status:
16 | return
17 | # ^ if status code not 0 -> status code processing
18 | if status == 401: # Invalid OAuth token
19 | fs.private_data_path(recreate=True)
20 | # message to the user
21 | a = "Invalid OAuth token! (probably the old token has expired)"
22 | b = "Launch application again to generate new OAuth token."
23 | raise Exception(f"{str(d)}\n^{a}\n{b}")
24 | else:
25 | a = "returned request data:"
26 | b = "*** Unhandled status code! ***"
27 | raise Exception(f"{a}\n{str(d)}\n{b} ({status})")
28 |
29 |
30 | def write_private_data(user_id, access_token, client_id):
31 | """Write private data to file for using in further requests."""
32 | file_path = fs.private_data_path(recreate=True)
33 | data = {
34 | "u_id": user_id,
35 | "token": access_token,
36 | "c_id": client_id
37 | }
38 | with open(file_path, "w") as file:
39 | json.dump(data, file, indent=4)
40 |
41 |
42 | def get_private_data(key) -> str:
43 | """Get value by the key from .private file."""
44 | file_path = fs.private_data_path()
45 | with open(file_path, "r") as file:
46 | data = json.load(file)
47 | return data[key]
48 |
49 |
50 | def cache_file_path(file_name, *subdirs) -> Path:
51 | """Get cache file path by file name, optionally supports subdirs."""
52 | if subdirs:
53 | path = Path(fs.get_cache_dir(), *subdirs)
54 | path.mkdir(parents=True, exist_ok=True)
55 | else:
56 | path = fs.get_cache_dir()
57 | return Path(path, file_name)
58 |
59 |
60 | def update_cache(file_name, json_data, *subdirs) -> Path:
61 | """Update json file from cache and return file path."""
62 | if subdirs:
63 | file_path = cache_file_path(file_name, *subdirs)
64 | else:
65 | file_path = cache_file_path(file_name)
66 | data = json.dumps(json_data, indent=2)
67 | with open(file_path, "w") as file:
68 | file.write(data)
69 | return file_path
70 |
71 |
72 | def read_cache(file_name, *subdirs) -> dict:
73 | """Read json file from cache and return data."""
74 | if subdirs:
75 | file_path = cache_file_path(file_name, *subdirs)
76 | else:
77 | file_path = cache_file_path(file_name)
78 | with open(file_path, "r") as file:
79 | data = json.load(file)
80 | return data
81 |
82 |
83 | def get_entries(json_data, key, root_key='data') -> list:
84 | """Create and return list of values from json data where all entries found by key."""
85 | found = []
86 | for entry in json_data[root_key]:
87 | found.append(entry[key])
88 | return found
89 |
90 |
91 | def create_id_dict(json_data) -> dict:
92 | """Create and return dict with id as the key."""
93 | streams = {}
94 | ids = get_entries(json_data, 'id')
95 | for stream, id in zip(json_data['data'], ids):
96 | streams[id] = stream
97 | return streams
98 |
99 |
100 | def following_live_data() -> dict:
101 | """Return data of user 'following live channels' page."""
102 | u_id = get_private_data("u_id") # user_id
103 | token = get_private_data("token") # auth token
104 | c_id = get_private_data("c_id") # client-Id of this program
105 | url = f"https://api.twitch.tv/helix/streams/followed?user_id={u_id}"
106 | headers = {
107 | "Authorization": f"Bearer {token}",
108 | "Client-Id": c_id
109 | }
110 | r = get(url, headers=headers)
111 | d = r.json()
112 | validate_data(d)
113 | return d
114 |
115 |
116 | def get_categories(query: str) -> list:
117 | """Returns a list of categories that match the query via name either entirely or partially."""
118 | first = 100 # Maximum number of objects to return. (Twitch API Maximum: 100)
119 | token = get_private_data("token")
120 | c_id = get_private_data("c_id")
121 | url = f"https://api.twitch.tv/helix/search/categories?first={first}&query={query}"
122 | headers = {
123 | "Authorization": f"Bearer {token}",
124 | "Client-Id": c_id
125 | }
126 | r = get(url, headers=headers)
127 | d = r.json()
128 | validate_data(d)
129 | return d["data"]
130 |
131 |
132 | def get_categories_terse_data(query: str) -> dict:
133 | categories = get_categories(query)
134 | terse_info = {}
135 | for c in categories:
136 | # dict key is id = tuple of ...
137 | terse_info[c["id"]] = c["name"], c["box_art_url"]
138 | return terse_info
139 |
140 |
141 | def get_categories_terse_mulstr(query: str) -> str:
142 | """Return multiline string with terse categories data. (for interactive select)"""
143 | d = get_categories_terse_data(query)
144 | mstr = ""
145 | names = []
146 | for v in d.values():
147 | name, _ = v
148 | names.append(name)
149 | maxlen = len(max(names, key=len)) # max length of longest string in list
150 | for id, v in d.items():
151 | name, _ = v
152 | mstr += f"{str(name):<{int(maxlen)}} [{id}]\n"
153 | return mstr.strip() # to remove blank line
154 |
155 |
156 | def category_data(category_id) -> dict:
157 | """Return json data for streams in certain category."""
158 | first = 100 # Maximum number of objects to return. (Twitch API Maximum: 100)
159 | token = get_private_data("token")
160 | c_id = get_private_data("c_id")
161 | url = f"https://api.twitch.tv/helix/streams?first={first}&game_id={category_id}"
162 | headers = {
163 | "Authorization": f"Bearer {token}",
164 | "Client-Id": c_id
165 | }
166 | r = get(url, headers=headers)
167 | d = r.json()
168 | validate_data(d)
169 | return d
170 |
171 |
172 | def get_channels(query: str, live_only=False) -> dict:
173 | """Returns a list of channels that match the query via channel name.
174 | (users who have streamed within the past 6 months)
175 | """
176 | first = 5 # Maximum number of objects to return. (Twitch API Maximum: 100)
177 | token = get_private_data("token")
178 | c_id = get_private_data("c_id")
179 | url = f"https://api.twitch.tv/helix/search/channels?first={first}&live_only={str(live_only)}&query={query}"
180 | headers = {
181 | "Authorization": f"Bearer {token}",
182 | "Client-Id": c_id
183 | }
184 | r = get(url, headers=headers)
185 | d = r.json()
186 | validate_data(d)
187 | return d["data"]
188 |
189 |
190 | def get_channels_terse_data(query: str, live_only=False) -> dict:
191 | """Return id dict with channels: (broadcaster_login, display_name, profile_image_url) only."""
192 | channels = get_channels(query, live_only)
193 | terse_info = {}
194 | for ch in channels:
195 | # dict key is channel id = tuple of ...
196 | terse_info[ch["id"]] = ch["broadcaster_login"], ch["display_name"], ch["thumbnail_url"]
197 | return terse_info
198 |
199 |
200 | def get_channels_terse_mulstr(query: str, live_only=False) -> str:
201 | """Return multiline string with terse channels data. (for interactive select)"""
202 | d = get_channels_terse_data(query, live_only)
203 | mstr = ""
204 | maxlen = 15
205 | for id, ch in d.items():
206 | login, name, _ = ch
207 | mstr += f"{str(login):<{maxlen}} {str(name):<{maxlen}} [{id}]\n"
208 | return mstr.strip() # to remove blank line
209 |
210 |
211 | def get_channel_videos(user_id, type="all") -> dict:
212 | """Gets videos information by user ID."""
213 | first = 100 # Maximum number of objects to return. (Twitch API Maximum: 100)
214 | token = get_private_data("token")
215 | c_id = get_private_data("c_id")
216 | url = f"https://api.twitch.tv/helix/videos?type={type}&first={first}&user_id={user_id}"
217 | headers = {
218 | "Authorization": f"Bearer {token}",
219 | "Client-Id": c_id
220 | }
221 | r = get(url, headers=headers)
222 | d = r.json()
223 | validate_data(d)
224 | return d
225 |
226 |
227 | def get_channel_clips(broadcaster_id) -> dict:
228 | """Gets clips information by broadcaster ID."""
229 | first = 100 # Maximum number of objects to return. (Twitch API Maximum: 100)
230 | token = get_private_data("token")
231 | c_id = get_private_data("c_id")
232 | url = f"https://api.twitch.tv/helix/clips?first={first}&broadcaster_id={broadcaster_id}"
233 | headers = {
234 | "Authorization": f"Bearer {token}",
235 | "Client-Id": c_id
236 | }
237 | r = get(url, headers=headers)
238 | d = r.json()
239 | validate_data(d)
240 | return d
241 |
242 |
243 | def page_data(page_dict) -> dict:
244 | """Get and return page data based on page_dict."""
245 | pd = page_dict
246 | ptype = pd.get("type", "streams")
247 | if ptype == "videos":
248 | if pd["category"] == "clips":
249 | json_data = get_channel_clips(pd["user_id"])
250 | else:
251 | json_data = get_channel_videos(pd["user_id"], pd["category"])
252 | else:
253 | if pd["category"] == "Following Live":
254 | json_data = following_live_data()
255 | else:
256 | json_data = category_data(pd["category_id"])
257 | return json_data
258 |
--------------------------------------------------------------------------------
/twitchez/fs.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | # coding=utf-8
3 |
4 | from os import environ
5 | from pathlib import Path
6 | from tempfile import gettempdir
7 |
8 |
9 | def set_owner_only_permissions(path: Path) -> Path:
10 | """Set owner only path permissions."""
11 | if path.is_dir():
12 | path.chmod(0o700, follow_symlinks=True)
13 | else:
14 | path.chmod(0o600, follow_symlinks=True)
15 | return path
16 |
17 |
18 | def private_data_path(recreate=False) -> Path:
19 | """Check that the .private file exists, if not -> create empty file.
20 | Also set r+w dir & file permissions to owner only & return path to file.
21 | """
22 | private_dir = get_data_dir(".private") # create dir if not exist
23 | file_path = Path(private_dir, ".private")
24 | # remove file with old private data for authentication
25 | if recreate and file_path.exists():
26 | file_path.unlink(missing_ok=True)
27 | if not file_path.exists():
28 | file_path.touch(exist_ok=True) # create empty file
29 | set_owner_only_permissions(private_dir) # set dir permissions
30 | set_owner_only_permissions(file_path) # set file permissions
31 | return file_path
32 |
33 |
34 | def get_cache_dir() -> Path:
35 | """Check ENV variables, create cache dir and return it's path."""
36 | dirname = "twitchez"
37 | if "TWITCHEZ_CACHE_DIR" in environ:
38 | cache_home = environ["TWITCHEZ_CACHE_DIR"]
39 | elif "XDG_CACHE_HOME" in environ:
40 | cache_home = environ["XDG_CACHE_HOME"]
41 | else:
42 | cache_home = Path(Path.home(), ".cache")
43 | cache_dir = Path(cache_home, dirname)
44 | # create cache_dir if not exist
45 | Path(cache_dir).mkdir(parents=True, exist_ok=True)
46 | return cache_dir
47 |
48 |
49 | def get_data_dir(*subdirs) -> Path:
50 | """Return path to data dir and create optional subdirs if they doesn't already exist."""
51 | dirname = "twitchez"
52 | if "TWITCHEZ_DATA_DIR" in environ:
53 | data_home = environ["TWITCHEZ_DATA_DIR"]
54 | elif "XDG_DATA_HOME" in environ:
55 | data_home = environ["XDG_DATA_HOME"]
56 | else:
57 | data_home = Path(Path.home(), ".local", "share")
58 | if not subdirs:
59 | data_path = Path(data_home, dirname)
60 | else:
61 | data_path = Path(data_home, dirname, *subdirs)
62 | # create data_path dirs if not exist
63 | Path(data_path).mkdir(parents=True, exist_ok=True)
64 | return data_path
65 |
66 |
67 | def get_tmp_dir(*subdirs) -> Path:
68 | """Return path to tmp dir and create optional subdirs if they doesn't already exist."""
69 | dirname = "twitchez"
70 | if not subdirs:
71 | tmp_dir_path = Path(gettempdir(), dirname)
72 | else:
73 | tmp_dir_path = Path(gettempdir(), dirname, *subdirs)
74 | Path(tmp_dir_path).mkdir(parents=True, exist_ok=True)
75 | return tmp_dir_path
76 |
77 |
78 | def get_user_conf_dir() -> Path:
79 | """Check ENV variables, get user config dir and return it's path."""
80 | dirname = "twitchez"
81 | if "XDG_CONFIG_HOME" in environ:
82 | config_home = environ["XDG_CONFIG_HOME"]
83 | else:
84 | config_home = Path(Path.home(), ".config")
85 | config_dir = Path(config_home, dirname)
86 | return config_dir
87 |
--------------------------------------------------------------------------------
/twitchez/hints.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | # coding=utf-8
3 |
4 | from twitchez import STDSCR
5 | from twitchez import conf
6 | import re
7 |
8 |
9 | def get_hint_chars() -> str:
10 | return str(conf.setting("hint_chars"))
11 |
12 |
13 | def total(items) -> tuple[int, int]:
14 | """Return (total_seq, hint_length) based on hint_chars,
15 | individual hint_length formula and len of items.
16 | total_seq = number of possible sequences,
17 | hint_length = [1-3] length of each hint.
18 | """
19 | hcl = len(get_hint_chars())
20 | sqr = hcl ** 2
21 | if hcl >= len(items):
22 | hint_length = 1
23 | total_seq = hcl
24 | elif sqr >= len(items):
25 | hint_length = 2
26 | total_seq = sqr
27 | else:
28 | hint_length = 3
29 | total_seq = sqr * 2 - hcl
30 | return total_seq, hint_length
31 |
32 |
33 | def shorten_uniq_seq(out_seq):
34 | """Shorten all unique hint sequences and insert at the beginning."""
35 | tmp_seq = out_seq.copy()
36 | # shorten all sequences by one character
37 | for i, seq in enumerate(tmp_seq):
38 | wlc = seq[:-1] # seq without last char
39 | tmp_seq.pop(i)
40 | tmp_seq.insert(i, wlc)
41 | # remove all elements that occurs more than once
42 | seq_set = set(tmp_seq) # set() for less loop iterations
43 | for seq in seq_set:
44 | occurs = tmp_seq.count(seq) # the number of times an element occurs
45 | if occurs > 1:
46 | while seq in tmp_seq:
47 | tmp_seq.remove(seq)
48 | if not tmp_seq: # short unique sequences not found
49 | return out_seq
50 | # each letter associated with its position index
51 | order = {}
52 | for i, c in enumerate(get_hint_chars()):
53 | order[c] = i
54 | # compute the seq score by the order in which the letters appear in the sequence
55 | seq_score = {}
56 | for seq in tmp_seq:
57 | s1 = order[seq[0]]
58 | s2 = 0
59 | if len(seq) > 1:
60 | s2 = order[seq[1]]
61 | score = s1 + s2
62 | seq_score[seq] = score
63 | # dict of sequences sorted by the sequence score
64 | sorted_by_score = dict(sorted(seq_score.items(), key=lambda x: x[1]))
65 | sorted_short_seq = list(sorted_by_score.keys())
66 | sorted_short_seq.reverse() # reverse() => we insert at the beginning
67 | # replace original seq by the shorter sequence as it occurs only once
68 | for sseq in sorted_short_seq:
69 | # original long seq found by the short seq
70 | llseq = [s for s in out_seq if re.search(f"^{sseq}.", s)]
71 | lseq = str(llseq[0])
72 | if lseq and lseq in out_seq:
73 | out_seq.remove(lseq)
74 | # insert all short sequences at the beginning
75 | out_seq.insert(0, sseq)
76 | return out_seq
77 |
78 |
79 | def gen_hint_seq(items) -> list:
80 | """Generate from hint_chars list of unique sequences."""
81 | _, hint_length = total(items)
82 | hint_chars = get_hint_chars()
83 |
84 | # one letter length hints
85 | if hint_length == 1:
86 | return list(hint_chars)[:len(items)]
87 |
88 | # simple repeated values of hint_length
89 | repeated = []
90 | for c in hint_chars:
91 | # nn ee oo ... (if length_chars=2)
92 | repeated.append(c * hint_length)
93 |
94 | # make unique combinations of letters in strict order
95 | # generates sequence of 2 or 3 letter length hints
96 | combinations = []
97 | for r in repeated:
98 | new_seq = ""
99 | for ci in range(hint_length, 1, -1):
100 | pi = ci - 1
101 | for c in hint_chars:
102 | new_seq = r[:pi] + c + r[ci:]
103 | if new_seq in repeated:
104 | continue # skip
105 | if new_seq in combinations:
106 | continue # skip
107 | combinations.append(new_seq)
108 |
109 | hint_sequences = []
110 | hint_sequences.extend(repeated)
111 | hint_sequences.extend(combinations)
112 | # limit by the number of sequences that is enough for all items
113 | out_seq = hint_sequences[:len(items)]
114 | # NOTE: short seq are more convenient to type
115 | out_seq = shorten_uniq_seq(out_seq)
116 | # limit the number of sequences, strictly after shortening! (just in case)
117 | if len(out_seq) > len(items):
118 | return out_seq[:len(items)]
119 | else:
120 | return out_seq
121 |
122 |
123 | def hint(items: list) -> list:
124 | """Return hint sequences for items."""
125 | return gen_hint_seq(items)
126 |
127 |
128 | def find_seq(hints) -> str:
129 | """Input characters until only one hint sequence is found."""
130 | cinput = ""
131 | select = hints
132 | while len(select) > 1:
133 | c = str(STDSCR.get_wch())
134 | cinput += c
135 | select = [s for s in select if re.search(f"^{cinput}", s)]
136 | if not select:
137 | return ""
138 | if len(select) != 1:
139 | raise ValueError(f"len:({len(select)}) Only one item should be in the list:\n{select}")
140 | return str(select[0])
141 |
--------------------------------------------------------------------------------
/twitchez/init.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | # coding=utf-8
3 |
4 | from twitchez import STDSCR
5 | from twitchez import keys
6 | from twitchez import keys_help
7 | from twitchez import render
8 | from twitchez import tabs
9 | from twitchez import thumbnails
10 | from twitchez.keys import other_keys as k
11 |
12 | from collections.abc import Callable
13 | import curses
14 |
15 |
16 | def set_curses_start_defaults():
17 | """Set curses start defaults."""
18 | curses.use_default_colors()
19 | curses.curs_set(0) # Turn off cursor
20 | STDSCR.keypad(True) # human friendly: curses.KEY_LEFT etc.
21 |
22 |
23 | def wch() -> tuple[str, int, bool]:
24 | """Handle exceptions, and return character variables with explicit type."""
25 | try:
26 | wch = STDSCR.get_wch()
27 | except KeyboardInterrupt: # Ctrl+c etc.
28 | thumbnails.draw_stop(safe=True)
29 | STDSCR.clear()
30 | curses.endwin()
31 | return "", 0, True # fail/interrupt is True => break
32 | # explicit type conversion to be absolutely sure about character type!
33 | ci = int(wch) if isinstance(wch, int) else 0 # int else fallback to 0
34 | ch = str(wch)
35 | return ch, ci, False
36 |
37 |
38 | def handle_resize(ci: int, redraw: Callable, redrawall: Callable):
39 | """Handle resize events, especially repeated resize -> simple redraw,
40 | when repeated resize stopped -> redraw everything including thumbnails."""
41 | if ci == curses.KEY_RESIZE: # terminal resize event
42 | thumbnails.draw_stop()
43 | _rew = STDSCR.derwin(0, 0) # resize event window (invisible)
44 | _rew.timeout(300)
45 | c = _rew.getch()
46 | # if next character is also a resize event
47 | if c == curses.KEY_RESIZE:
48 | _rew.timeout(150)
49 | # -> loop in simple redraw without thumbnails
50 | while c == curses.KEY_RESIZE:
51 | c = _rew.getch()
52 | STDSCR.clear()
53 | redraw()
54 | redrawall()
55 | return True
56 | return False
57 |
58 |
59 | def show_pressed_chars(ch: str, ci: int):
60 | """Show last pressed key chars at the bottom-right corner."""
61 | h, w = STDSCR.getmaxyx()
62 | try:
63 | if ci != 0:
64 | STDSCR.insstr(h - 1, w - 8, f" ci:{ci} ")
65 | else:
66 | STDSCR.insstr(h - 1, w - 8, ch)
67 | except ValueError: # bypass ValueError: embedded null character
68 | pass
69 |
70 |
71 | def run(stdscr):
72 | global STDSCR
73 | STDSCR = stdscr # override global STDSCR by the stdscr from wrapper
74 | page_dict = tabs.cpdict() # last used page/tab
75 | page = render.Page(page_dict)
76 |
77 | set_curses_start_defaults()
78 |
79 | def redraw():
80 | """Reinitialize variables & redraw everything."""
81 | thumbnails.draw_stop()
82 | STDSCR.clear()
83 | h, w = STDSCR.getmaxyx()
84 | if h < 3 or w < 3:
85 | return
86 | page.draw()
87 | thumbnails.draw_start()
88 |
89 | redraw() # draw once just before the loop start
90 |
91 | # Infinite loop to read every key press.
92 | while True:
93 | ch, ci, interrupt = wch()
94 | if interrupt:
95 | break
96 | if handle_resize(ci, page.draw, redraw):
97 | continue
98 | show_pressed_chars(ch, ci)
99 |
100 | if ch == k.get("quit"):
101 | break
102 | if ch == k.get("redraw"):
103 | page = render.Page(page_dict)
104 | redraw()
105 | continue
106 | if ch == k.get("redownload"):
107 | page = render.Page(page_dict, force_redownload=True)
108 | redraw()
109 | continue
110 | if ch == k.get("keys_help") or ci == curses.KEY_F1:
111 | keys_help.help()
112 | redraw()
113 | continue
114 | if ch == k.get("full_title"):
115 | STDSCR.clear()
116 | fbox = render.Boxes.drawn_boxes[0]
117 | # toggle full title drawing
118 | if not fbox.fulltitle:
119 | page.draw(fulltitle=True)
120 | else:
121 | page.draw()
122 | continue
123 | if ch in keys.bmark_keys.values():
124 | page_dict = keys.bmark_action(ch, page_dict)
125 | page = render.Page(page_dict)
126 | redraw()
127 | continue
128 | if keys.scroll(ch, page.draw, redraw):
129 | continue
130 | if ch in keys.tab_keys.values():
131 | page_dict = keys.tabs_action(ch, page_dict)
132 | page = render.Page(page_dict)
133 | redraw()
134 | continue
135 | if keys.yank(ch):
136 | continue
137 | if ch in keys.hint_keys.values():
138 | if keys.hints(ch):
139 | # redraw all including thumbnails
140 | redraw()
141 | else:
142 | # simple redraw without thumbnails
143 | STDSCR.clear()
144 | page.draw()
145 | continue
146 | # end of the infinite while loop
147 |
148 |
149 | def main():
150 | try:
151 | curses.wrapper(run)
152 | finally:
153 | thumbnails.draw_stop(safe=True)
154 |
155 |
156 | if __name__ == "__main__":
157 | main()
158 |
--------------------------------------------------------------------------------
/twitchez/iselect.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | # coding=utf-8
3 |
4 | from twitchez import STDSCR
5 | from twitchez import command
6 | from twitchez import conf
7 | from twitchez import thumbnails
8 |
9 | from os import environ
10 | from shutil import which
11 |
12 | import curses
13 | import subprocess
14 |
15 |
16 | select_cmd = conf.setting("select_cmd")
17 | executable = command.first_cmd_word(select_cmd)
18 | without_funcs = command.without_funcs(executable)
19 | cmd_check = command.cmd_check(executable)
20 |
21 |
22 | def dmenu_cmd() -> list:
23 | cmd = "dmenu -i -l 50"
24 | return cmd.split()
25 |
26 |
27 | def fzf_cmd() -> list:
28 | cmd = environ.get("FZF_DEFAULT_COMMAND", "fzf")
29 | cmd += " --no-multi"
30 | return cmd.split()
31 |
32 |
33 | def raise_user_note():
34 | """raise exception for regular user without traceback."""
35 | if without_funcs:
36 | return
37 | a = "A program for selecting of one line from all was not found at your 'PATH'."
38 | b = "You can install 'fzf' or 'dmenu' and it will be working by default."
39 | c = "Also you can set your own program cmd via 'select_cmd = your cmd' in config."
40 | d = "If you want to use this program without using it's interactive select functions,"
41 | e = "simply paste next line in your config:"
42 | f = "select_cmd = false"
43 | full_text = f"\n{a}\n{b}\n{c}\n{d}\n{e}\n{f}\n"
44 | raise Exception(full_text)
45 |
46 |
47 | def get_select_cmd():
48 | """Check & return cmd if executable is on PATH."""
49 | cmd = []
50 | # prefer select_cmd if set in config and found at PATH
51 | if cmd_check:
52 | cmd = select_cmd.split()
53 | elif which("fzf"):
54 | cmd = fzf_cmd()
55 | elif which("dmenu"):
56 | cmd = dmenu_cmd()
57 | else:
58 | raise_user_note()
59 | return cmd
60 |
61 |
62 | def iselect(multilinestr: str, fallback):
63 | """Interactive select of one line from all."""
64 | if without_funcs:
65 | return 130
66 | text = multilinestr.strip()
67 | cmd = get_select_cmd()
68 | # for fzf and similar console selectors working directly in terminal
69 | if cmd[0] != "dmenu" and cmd[0] != "rofi":
70 | STDSCR.refresh() # fix: endwin() requires intervening screen update (new libncurses)
71 | curses.endwin() # fix: hide application to be able to see selector after calling subprocess
72 | thumbnails.draw_stop() # hide thumbnails, they will be redrawn in the next redraw() call.
73 | p = subprocess.run(cmd, input=text, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
74 | sel = str(p.stdout).strip()
75 | if p.returncode == 1 or p.returncode == 130:
76 | # dmenu(1), fzf(130) => command was canceled (Esc)
77 | return 130
78 | elif p.returncode != 0:
79 | raise Exception(f"select cmd ERROR({p.returncode})\n{p.stderr}\n")
80 | # return fallback if input is not a substring of multilinestr
81 | if sel not in multilinestr:
82 | return fallback
83 | return sel
84 |
--------------------------------------------------------------------------------
/twitchez/keys.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | # coding=utf-8
3 |
4 | from twitchez import STDSCR
5 | from twitchez import bmark
6 | from twitchez import data
7 | from twitchez import search
8 | from twitchez import tabs
9 | from twitchez import thumbnails
10 | from twitchez import utils
11 | from twitchez.clip import clip
12 | from twitchez.conf import key as ck
13 | from twitchez.notify import notify
14 | from twitchez.render import Boxes
15 |
16 | from collections.abc import Callable
17 | import curses
18 |
19 | bmark_keys = {
20 | "bmark_add": ck("bmark_add"),
21 | "bmark_delete": ck("bmark_delete"),
22 | "bmark_open": ck("bmark_open"),
23 | }
24 |
25 | hint_keys = {
26 | "hint_clip_url": ck("hint_clip_url"),
27 | "hint_open_chat": ck("hint_open_chat"),
28 | "hint_open_stream": ck("hint_open_stream"),
29 | "hint_open_extra": ck("hint_open_extra"),
30 | "hint_open_video": ck("hint_open_video"),
31 | }
32 |
33 | scroll_keys = {
34 | "scroll_top": ck("scroll_top"),
35 | "scroll_bot": ck("scroll_bot"),
36 | "scroll_up": ck("scroll_up"),
37 | "scroll_down": ck("scroll_down"),
38 | "scroll_up_page": ck("scroll_up_page"),
39 | "scroll_down_page": ck("scroll_down_page")
40 | }
41 |
42 | tab_keys = {
43 | "tab_add": ck("tab_add"),
44 | "tab_delete": ck("tab_delete"),
45 | "tab_find": ck("tab_find"),
46 | "tab_next": ck("tab_next"),
47 | "tab_prev": ck("tab_prev"),
48 | }
49 |
50 | other_keys = {
51 | "quit": ck("quit"),
52 | "redraw": ck("redraw"),
53 | "redownload": ck("redownload"),
54 | "full_title": ck("full_title"),
55 | "keys_help": ck("keys_help"),
56 | "yank_urls": ck("yank_urls"),
57 | "yank_urls_page": ck("yank_urls_page"),
58 | }
59 |
60 |
61 | def rkt(timeout=500, fallback="") -> str:
62 | """Read key with timeout, without raising exception.
63 | (No exception if no input -> return fallback key or empty string.)
64 | """
65 | _rsw = STDSCR.derwin(0, 0)
66 | _rsw.timeout(timeout)
67 | try:
68 | c = str(_rsw.get_wch())
69 | except curses.error: # "no input" etc.
70 | c = fallback
71 | return c
72 |
73 |
74 | def bmark_action(ch: str, fallback: dict):
75 | """Bookmark action based on key."""
76 | page_dict = fallback
77 | if ch == bmark_keys.get("bmark_add"):
78 | bmark.bmark_add()
79 | elif ch == bmark_keys.get("bmark_delete"):
80 | bmark.bmark_del()
81 | elif ch == bmark_keys.get("bmark_open"):
82 | page_dict = bmark.bmark_open(fallback)
83 | return page_dict
84 |
85 |
86 | def hints(ch: str):
87 | """Show box hints, and make some action based on key and hint.
88 | If terminal was resized while hints were being shown -> cancel & redraw all.
89 | """
90 | # get the initial sum to check later if terminal was resized
91 | xysum = utils.was_resized(0)
92 |
93 | boxes, hint = Boxes().show_boxes_hint()
94 | type = ""
95 |
96 | if ch == hint_keys.get("hint_clip_url"):
97 | type = "copy_url"
98 | elif ch == hint_keys.get("hint_open_chat"):
99 | type = "open_chat"
100 | elif ch == hint_keys.get("hint_open_stream"):
101 | type = "stream"
102 | elif ch == hint_keys.get("hint_open_video"):
103 | type = "video"
104 | elif ch == hint_keys.get("hint_open_extra"):
105 | type = "extra"
106 |
107 | if type == "copy_url":
108 | if not boxes.copy_url(hint) and not utils.was_resized(xysum):
109 | return False
110 | elif type == "open_chat":
111 | if not boxes.open_chat(hint) and not utils.was_resized(xysum):
112 | return False
113 | elif type:
114 | if not boxes.open_url(hint, type) and not utils.was_resized(xysum):
115 | return False
116 |
117 | # redraw all including thumbnails
118 | return True
119 |
120 |
121 | def scroll_grid(redraw: Callable) -> str:
122 | """Scroll page grid based on the input key."""
123 | c = rkt(100)
124 | STDSCR.clear()
125 | grid = redraw()
126 | if c == scroll_keys.get("scroll_down"):
127 | grid.shift_index("down")
128 | elif c == scroll_keys.get("scroll_up"):
129 | grid.shift_index("up")
130 | elif c == scroll_keys.get("scroll_down_page"):
131 | grid.shift_index("down", page=True)
132 | elif c == scroll_keys.get("scroll_up_page"):
133 | grid.shift_index("up", page=True)
134 | elif c == scroll_keys.get("scroll_top"):
135 | grid.shift_index("top")
136 | elif c == scroll_keys.get("scroll_bot"):
137 | grid.shift_index("bot")
138 | return c
139 |
140 |
141 | def scroll(ch: str, redraw: Callable, redrawall: Callable):
142 | """Scroll page, especially handle repeated scroll keys."""
143 | if ch in scroll_keys.values():
144 | thumbnails.draw_stop()
145 | c = rkt(100)
146 | curses.unget_wch(ch)
147 | # if no next input key after timeout -> scroll once
148 | if not c:
149 | c = scroll_grid(redraw)
150 | else:
151 | # if next character is also a scroll key
152 | # -> loop in simple redraw without thumbnails
153 | while c in scroll_keys.values():
154 | c = scroll_grid(redraw)
155 | redrawall()
156 | return True
157 | return False
158 |
159 |
160 | def tabs_action(ch: str, fallback: dict):
161 | """Tabs actions."""
162 | if ch == tab_keys.get("tab_add"):
163 | page_dict = search.select_page(fallback)
164 | elif ch == tab_keys.get("tab_delete"):
165 | page_dict = tabs.delete_tab()
166 | elif ch == tab_keys.get("tab_find"):
167 | page_dict = tabs.find_tab()
168 | elif ch == tab_keys.get("tab_next"):
169 | page_dict, _ = tabs.next_tab()
170 | elif ch == tab_keys.get("tab_prev"):
171 | page_dict, _ = tabs.prev_tab()
172 | else:
173 | page_dict = fallback
174 | return page_dict
175 |
176 |
177 | def yank_urls(full_page=False):
178 | """Yank urls of visible boxes or all urls of the page."""
179 | urls = ""
180 | if full_page:
181 | page_dict = tabs.cpdict() # current page/tab
182 | json_data = data.page_data(page_dict)
183 | if "url" in json_data["data"][0]:
184 | page_urls = data.get_entries(json_data, "url")
185 | for url in page_urls:
186 | urls += f"{url}\n"
187 | else:
188 | for box in Boxes.drawn_boxes:
189 | urls += f"{box.url}\n"
190 | if urls:
191 | clip(urls)
192 | else:
193 | notify("This page does not have 'url' entries in json data.")
194 |
195 |
196 | def yank(ch: str):
197 | if ch == other_keys.get("yank_urls") or ch == other_keys.get("yank_urls_page"):
198 | if ch == other_keys.get("yank_urls"):
199 | yank_urls()
200 | elif ch == other_keys.get("yank_urls_page"):
201 | yank_urls(full_page=True)
202 | return True
203 | return False
204 |
--------------------------------------------------------------------------------
/twitchez/keys_help.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | # coding=utf-8
3 |
4 | from twitchez import HEADER_H
5 | from twitchez import STDSCR
6 | from twitchez import __version__
7 | from twitchez import keys
8 | from twitchez import thumbnails
9 |
10 | import curses
11 | import re
12 |
13 |
14 | def short_desc(string: str) -> str:
15 | """Make short readable description by removing one of specific patterns from string.
16 | Also replace _ characters by whitespace.
17 | """
18 | # Python 3.10.1 BUG:
19 | # >>> "tab_add".lstrip("tab_") => produces "dd" while it should be "add"
20 | patterns_to_strip = ["scroll_", "hint_", "tab", "bmark"]
21 | for pattern in patterns_to_strip:
22 | if string.startswith(pattern):
23 | string = string.lstrip(pattern)
24 | break # remove only the first pattern found
25 | out = string.replace("_", " ").strip()
26 | return out
27 |
28 |
29 | def table_lines(keysdict, header) -> list:
30 | """Generate simple list of strings with key and short description.
31 | Each line in the list is the same length (trailing whitespaces).
32 | """
33 | kws = 4 # num of ws after key char
34 | hws = kws + 1 # num of ws before header
35 | maxlen = 15
36 | for t in keysdict.keys():
37 | maxlen = max(maxlen, len(short_desc(t))) # max length of longest line
38 | # header leading & trailing whitespaces
39 | hlws = " " * hws
40 | htws = " " * (maxlen - len(header) - len(hlws) + hws)
41 | outheader = hlws + header + htws
42 | list_of_lines = []
43 | list_of_lines.append(outheader)
44 | frmtstr = "{:" + str(kws) + "} {:" + str(maxlen) + "}"
45 | for name, key in keysdict.items():
46 | desc = short_desc(name)
47 | string = frmtstr.format(key, desc)
48 | list_of_lines.append(string)
49 | return list_of_lines
50 |
51 |
52 | def append_blank_lines(table: list, num_of_out_lines: int) -> list:
53 | """Append to the table empty lines of the same max width till num of total lines.
54 | Used to make all tables of equal line count.
55 | """
56 | maxlen = len(max(table)) # max length of longest line (max len of element)
57 | blank_line = " " * maxlen
58 | while len(table) < num_of_out_lines:
59 | table.append(blank_line)
60 | return table
61 |
62 |
63 | def simple_tables(area_width) -> tuple[int, str]:
64 | """Simple string tables as grid that fit in area_width.
65 | returns: total line count, multiline string as table.
66 | """
67 | sk = table_lines(keys.scroll_keys, "[SCROLL]")
68 | tk = table_lines(keys.tab_keys, "[TABS]")
69 | hk = table_lines(keys.hint_keys, "[HINTS]")
70 | ok = table_lines(keys.other_keys, "[OTHER]")
71 | bk = table_lines(keys.bmark_keys, "[BMARK]")
72 | tables = [sk, tk, hk, ok, bk]
73 | maxln = len(max(tables, key=len)) # max num of lines in table (max num of elements in list)
74 | maxlen = len(max(max(t, key=len) for t in tables)) # max length of longest line
75 | # make all tables of equal line count
76 | sk = append_blank_lines(sk, maxln)
77 | tk = append_blank_lines(tk, maxln)
78 | hk = append_blank_lines(hk, maxln)
79 | ok = append_blank_lines(ok, maxln)
80 | bk = append_blank_lines(bk, maxln)
81 | tables = [sk, tk, hk, ok, bk]
82 |
83 | # even spacing and indent from left
84 | maxcolnum = area_width // maxlen
85 | if maxcolnum > 5: # limit max number of table columns
86 | maxcolnum = 5
87 | free_cols = area_width - int(maxlen * maxcolnum)
88 | if free_cols < maxcolnum or maxcolnum < 2:
89 | rem_on_col = 0
90 | else:
91 | rem_on_col = free_cols // maxcolnum - 1
92 | if rem_on_col == 0 or maxcolnum < 2:
93 | indentstr = ""
94 | else:
95 | # NOTE: the difference with one non-half indent is especially noticeable
96 | # that the center is shifted at large terminal widths (200-239 cols)
97 | indentstr = " " * (rem_on_col // 2)
98 | strtemplateraw = indentstr + "{}" + indentstr
99 |
100 | out = ""
101 | add_row = "\n\n" # new lines for the new row of tables
102 | # TODO: figure out how to make following code less ugly... (currently it is more like hardcoded)
103 | for num in range(len(tables), 0, -1):
104 | strtemplate = strtemplateraw * num
105 | if num == 5:
106 | out += "\n".join(strtemplate.format(t1, t2, t3, t4, t5) for t1, t2, t3, t4, t5 in zip(sk, tk, hk, ok, bk))
107 | elif num == 4:
108 | out += "\n".join(strtemplate.format(t1, t2, t3, t4) for t1, t2, t3, t4 in zip(sk, tk, hk, ok))
109 | out += add_row
110 | out += "\n".join(strtemplateraw.format(t5) for t5 in bk)
111 | elif num == 3:
112 | # FIXME
113 | out += "\n".join(strtemplate.format(t1, t2, t3) for t1, t2, t3 in zip(sk, tk, hk))
114 | out += add_row
115 | out += "\n".join((strtemplateraw * 2).format(t4, t5) for t4, t5 in zip(ok, bk))
116 | elif num == 2:
117 | out += "\n".join(strtemplate.format(t1, t2) for t1, t2 in zip(sk, tk))
118 | out += add_row
119 | out += "\n".join(strtemplate.format(t3, t4) for t3, t4 in zip(hk, ok))
120 | out += add_row
121 | out += "\n".join(strtemplateraw.format(t5) for t5 in bk)
122 | elif num == 1:
123 | for _t in tables:
124 | out += "\n".join(strtemplateraw.format(t1) for t1 in _t)
125 | out += add_row
126 | else:
127 | out = "E" * area_width
128 | out = out.rstrip() # trim empty lines from the end of the out string & trailing ws
129 | maxlinelen = len(max(out.splitlines(), key=len)) # max length of longest line
130 | if maxlinelen <= area_width:
131 | break
132 | else:
133 | out = "" # clear
134 | # replace repeating empty lines by a single empty line
135 | out = re.sub(r'\n\s*\n', '\n\n', out, re.MULTILINE)
136 | tln = out.count("\n") + 1 # total lines count
137 | return tln, out
138 |
139 |
140 | def push_text(win, text: str, pos=0):
141 | """Add text str into window respecting it's height.
142 | Also update text after changing scroll position.
143 | (simple text string scrolling).
144 | """
145 | win.clear()
146 | h, _ = win.getmaxyx()
147 | lines = text.splitlines()
148 | if h >= len(lines):
149 | # all lines of text can simply be placed inside a win
150 | win.addstr(text)
151 | else:
152 | # addstr only slice of text that can fit inside a win
153 | text_slice = "\n".join(lines[pos:h + pos])
154 | win.addstr(text_slice)
155 | win.refresh()
156 |
157 |
158 | def help():
159 | """Draw help window with key mappings and their description."""
160 | H, W = STDSCR.getmaxyx()
161 | if H < 10 or W < 20:
162 | return
163 |
164 | thumbnails.draw_stop()
165 |
166 | y, x = HEADER_H - 1, 2
167 | h, w = H - y * 2, W - x * 2
168 |
169 | close_help_keys = [
170 | curses.KEY_RESIZE, # close on terminal resize event
171 | curses.KEY_F1,
172 | keys.other_keys.get("keys_help"),
173 | keys.other_keys.get("quit"),
174 | ]
175 | scroll_help_keys = keys.scroll_keys.values()
176 |
177 | v_str = f"v{__version__}"
178 | title = f" twitchez {v_str} "
179 | t_h_c = w // 2 - len(title) // 2 # title horizontal center
180 |
181 | win = STDSCR.derwin(h, w, y, x)
182 | win.clear()
183 | win.border()
184 | win.addstr(0, t_h_c, title, curses.A_BOLD)
185 | win.refresh()
186 |
187 | pad_y, pad_x = 2, 5
188 | pad_h = h - pad_y
189 | pad_w = w - pad_x
190 |
191 | pad = win.subpad(pad_h, pad_w, pad_y, pad_x)
192 | pad.scrollok(True)
193 |
194 | tln, table = simple_tables(pad_w)
195 | push_text(pad, table)
196 |
197 | end = tln - pad_h
198 | pos = 0
199 |
200 | while True:
201 | c = STDSCR.get_wch()
202 | # enable scrolling only if content doesn't fit in height entirely
203 | if tln > pad_h:
204 | if c in scroll_help_keys:
205 | if c == keys.scroll_keys.get("scroll_down"):
206 | pos += 1
207 | elif c == keys.scroll_keys.get("scroll_up"):
208 | pos -= 1
209 | elif c == keys.scroll_keys.get("scroll_down_page"):
210 | pos += 5
211 | elif c == keys.scroll_keys.get("scroll_up_page"):
212 | pos -= 5
213 | elif c == keys.scroll_keys.get("scroll_top"):
214 | pos = 0
215 | elif c == keys.scroll_keys.get("scroll_bot"):
216 | pos = end
217 | # limit scroll
218 | if pos < 0:
219 | pos = 0
220 | elif pos > end:
221 | pos = end
222 | push_text(pad, table, pos)
223 | continue
224 | if c in close_help_keys:
225 | pad.clear()
226 | pad.refresh()
227 | break
228 |
229 |
230 | if __name__ == "__main__":
231 | def print_w_info(width):
232 | """To be able to see where are: width limit, center."""
233 | ln, table = simple_tables(width)
234 | fstring = " w:[{0}] ln:({1}) "
235 | info = fstring.format(width, ln)
236 | half = width // 2 # approx (as this is terminal cells)
237 | halfs = "─" * (half - 2) # 2 = "x" as center + extra "─" in beg
238 | beg = "┌─" + info + halfs[len(info):]
239 | end = halfs + "┐"
240 | bar = beg + "x" + end
241 | print(bar)
242 | print(table)
243 |
244 | print("=" * 100)
245 | print_w_info(70)
246 | print_w_info(80)
247 | print_w_info(100)
248 | print_w_info(130)
249 |
--------------------------------------------------------------------------------
/twitchez/notify.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | # coding=utf-8
3 |
4 | from shutil import which
5 | from twitchez import ENCODING
6 | from twitchez import command
7 | from twitchez import conf
8 | import subprocess
9 |
10 | notify_cmd = conf.setting("notify_cmd")
11 | executable = command.first_cmd_word(notify_cmd)
12 | without_funcs = command.without_funcs(executable)
13 | cmd_check = command.cmd_check(executable)
14 |
15 |
16 | def expire_time():
17 | """return expire time for notifications from config."""
18 | return conf.setting("notify_time")
19 |
20 |
21 | def dunstify_cmd() -> list:
22 | # NOTE: dunst stack tag 'hi' stands for 'history ignore' and can be used for that purpose.
23 | # (requires creating matching rule in dunst config)
24 | t = expire_time()
25 | DST = "string:x-dunst-stack-tag"
26 | cmd = f"dunstify -t {t} -u low -h {DST}:twitchez -h {DST}:hi"
27 | return cmd.split()
28 |
29 |
30 | def notify_send_cmd() -> list:
31 | t = expire_time()
32 | DST = "string:x-dunst-stack-tag"
33 | cmd = f"notify-send -t {t} -u low -h {DST}:twitchez -h {DST}:hi"
34 | return cmd.split()
35 |
36 |
37 | def raise_user_note():
38 | """raise exception for regular user without traceback."""
39 | if without_funcs:
40 | return
41 | a = "A program to send desktop notifications was not found at your 'PATH'."
42 | b = "You can install 'notify-send' and it will be working by default."
43 | c = "Also you can set your own program cmd via 'notify_cmd = your cmd' in config."
44 | d = "If you want to use this program without seeing any notifications from it,"
45 | e = "simply paste next line in your config:"
46 | f = "notify_cmd = false"
47 | full_text = f"\n{a}\n{b}\n{c}\n{d}\n{e}\n{f}\n"
48 | raise Exception(full_text)
49 |
50 |
51 | def get_notify_cmd(show_note: bool) -> list:
52 | """Check & return cmd if executable is on PATH."""
53 | cmd = []
54 | # prefer notify_cmd if set in config and executable found at PATH
55 | if cmd_check:
56 | cmd = notify_cmd.split()
57 | elif which("dunstify"):
58 | cmd = dunstify_cmd()
59 | elif which("notify-send"):
60 | cmd = notify_send_cmd()
61 | else:
62 | if show_note:
63 | raise_user_note()
64 | else:
65 | return []
66 | return cmd
67 |
68 |
69 | def notify(body="", summary="", error=False, show_note=True):
70 | """Show user notification."""
71 | if without_funcs:
72 | return
73 | cmd = get_notify_cmd(show_note=show_note)
74 | if not cmd:
75 | return
76 | s = f"{summary}"
77 | b = f"{body}"
78 | # NOTE: if user specified custom notify_cmd that does not support additional args
79 | # => cmd will break. Because we append variable amount of options after getting cmd.
80 | if error:
81 | cmd.append("-u")
82 | cmd.append("critical")
83 | cmd.append("-t")
84 | cmd.append("8000")
85 | cmd.append(s)
86 | cmd.append(b)
87 | p = subprocess.Popen(cmd, text=True, encoding=ENCODING)
88 | p.wait() # wait for process to finish
89 |
--------------------------------------------------------------------------------
/twitchez/open_chat.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | # coding=utf-8
3 |
4 | from twitchez import command
5 | from twitchez import conf
6 |
7 | import subprocess
8 | import os
9 |
10 | try:
11 | import webbrowser
12 | except ImportError:
13 | has_webbrowser = False
14 | else:
15 | has_webbrowser = True
16 |
17 |
18 | chat_cmd = conf.setting("open_chat_cmd")
19 | executable = command.first_cmd_word(chat_cmd)
20 | without_funcs = command.without_funcs(executable)
21 | cmd_check = command.cmd_check(executable)
22 |
23 |
24 | def open_chat(channel_login):
25 | """Open twitch chat of the channel."""
26 | url = f"https://www.twitch.tv/popout/{channel_login}/chat?popout="
27 | if cmd_check:
28 | cmd = chat_cmd.split()
29 | cmd.append(url)
30 | sub = subprocess.Popen
31 | sub(cmd,
32 | stdin=subprocess.DEVNULL,
33 | stdout=subprocess.DEVNULL,
34 | stderr=subprocess.DEVNULL)
35 | elif has_webbrowser and not without_funcs:
36 | """Open in default browser using webbrowser module.
37 | Following (currently) are the only way to suppress stdout & stderr produced by webbrowser.open().
38 | We suppress everything for the case if webbrowser.open() outputs something before opening.
39 | Read more here: 'https://stackoverflow.com/a/2323563'.
40 | """
41 | savout = os.dup(1) # stdout
42 | saverr = os.dup(2) # stderr
43 | os.close(1)
44 | os.close(2)
45 | os.open(os.devnull, os.O_RDWR)
46 | try:
47 | webbrowser.open_new(url)
48 | finally:
49 | os.dup2(savout, 1)
50 | os.dup2(saverr, 2)
51 |
52 |
53 | if __name__ == "__main__":
54 | open_chat("LIRIK")
55 |
--------------------------------------------------------------------------------
/twitchez/open_cmd.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | # coding=utf-8
3 |
4 | from twitchez import command
5 | from twitchez import conf
6 | from twitchez.notify import notify
7 |
8 | from shutil import which
9 |
10 | import subprocess
11 |
12 |
13 | def custom_cmd_check(type) -> tuple[bool, str]:
14 | if type == "stream":
15 | type_cmd = conf.setting("open_stream_cmd")
16 | elif type == "video":
17 | type_cmd = conf.setting("open_video_cmd")
18 | elif type == "extra":
19 | type_cmd = conf.setting("open_extra_cmd")
20 | else:
21 | type_cmd = conf.setting("open_stream_cmd")
22 | if type_cmd == "undefined":
23 | return False, ""
24 | executable = command.first_cmd_word(type_cmd)
25 | cmd_check = command.cmd_check(executable)
26 | return cmd_check, type_cmd
27 |
28 |
29 | def custom_cmd(type_cmd, url) -> list:
30 | cmd = f"{type_cmd} {url}"
31 | return cmd.split()
32 |
33 |
34 | def streamlink_cmd(url) -> list:
35 | streamlink_title_format = True
36 | quality = "best" # hardcoded default
37 | cmd = "streamlink --quiet".split()
38 | if streamlink_title_format:
39 | # those are streamlink formatting variables
40 | title = '{author} - {category} -- {title}'
41 | cmd.append("--title")
42 | cmd.append(title)
43 | cmd_args: list = f"{url} {quality}".split()
44 | cmd.extend(cmd_args)
45 | return cmd
46 |
47 |
48 | def mpv_cmd(url) -> list:
49 | cmd = f"mpv {url}"
50 | return cmd.split()
51 |
52 |
53 | def raise_user_note():
54 | """raise exception for regular user without traceback."""
55 | a = "A program for opening url was not found at your 'PATH'."
56 | b = "You can install 'streamlink' and/or 'mpv' + 'youtube-dl/yt-dlp' and it will be working by default."
57 | c = "Also you can set your own program cmd via 'open_*_cmd = your cmd' in config."
58 | full_text = f"\n{a}\n{b}\n{c}\n"
59 | raise Exception(full_text)
60 |
61 |
62 | def get_open_cmd(url, type):
63 | """Check & return cmd if executable is on PATH."""
64 | cmd = []
65 | # prefer custom open_cmd if set in config and found at PATH
66 | cmd_check, type_cmd = custom_cmd_check(type)
67 | if cmd_check and type_cmd:
68 | cmd = custom_cmd(type_cmd, url)
69 | # following are defaults and fallback if custom open cmd not set in config
70 | elif which("streamlink"):
71 | cmd = streamlink_cmd(url)
72 | elif which("mpv"):
73 | cmd = mpv_cmd(url)
74 | else:
75 | raise_user_note()
76 | return cmd
77 |
78 |
79 | def open_url(url, type):
80 | """Open stream/video url with external/custom program."""
81 | cmd = get_open_cmd(url, type)
82 | if not cmd:
83 | return
84 | notify(url, "opening:", show_note=False)
85 | sub = subprocess.Popen
86 | sub(cmd,
87 | start_new_session=True, # to not close video/stream after closing twitchez (POSIX only)
88 | stdin=subprocess.DEVNULL,
89 | stdout=subprocess.DEVNULL,
90 | stderr=subprocess.DEVNULL)
91 |
--------------------------------------------------------------------------------
/twitchez/paged.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | # coding=utf-8
3 |
4 | FLPN = "Following Live"
5 |
6 |
7 | def following_live() -> dict:
8 | """Following Live page dict."""
9 | return {
10 | "type": "streams",
11 | "category": FLPN,
12 | "page_name": FLPN
13 | }
14 |
15 |
16 | def stream(category_name: str, category_id: str) -> dict:
17 | """Stream page dict."""
18 | return {
19 | "type": "streams",
20 | "category": category_name,
21 | "page_name": category_name,
22 | "category_id": category_id
23 | }
24 |
25 |
26 | def video(video_type: str, user_id: str, user_name: str) -> dict:
27 | """Video page dict."""
28 | return {
29 | "type": "videos",
30 | "category": video_type,
31 | "page_name": f"{user_name} ({video_type})",
32 | "user_name": user_name,
33 | "user_id": user_id
34 | }
35 |
--------------------------------------------------------------------------------
/twitchez/pages.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | # coding=utf-8
3 |
4 | from twitchez import HEADER_H
5 | from twitchez import data
6 | from twitchez import render
7 | from twitchez import thumbnails
8 | from twitchez import utils
9 | from twitchez.utils import strws
10 | from twitchez.tabs import tab_upd
11 |
12 | from pathlib import Path
13 |
14 |
15 | class Pages:
16 |
17 | def __init__(self, page_dict: dict, force_redownload=False):
18 | self.page_dict = page_dict
19 | self.page_name = page_dict["page_name"]
20 | self.cache_file_name = f"{strws(self.page_name)}.json"
21 | self.force_redownload: bool = force_redownload
22 | tab_upd(self.page_name, self.page_dict) # => update tabs
23 |
24 | def cache_subdirs(self):
25 | """Return list of subdirs (to unpack them later as args)."""
26 | subdirs = []
27 | pd = self.page_dict
28 | ptype = pd.get("type", "streams")
29 | subdirs.append(ptype)
30 | if ptype == "videos":
31 | if "user_name" in pd:
32 | subdirs.append(strws(pd["user_name"]))
33 | if "category" in pd:
34 | subdirs.append(strws(pd["category"]))
35 | return subdirs
36 |
37 | def cache_path(self) -> Path:
38 | return data.cache_file_path(self.cache_file_name, *self.cache_subdirs())
39 |
40 | def update_cache(self) -> Path:
41 | return data.update_cache(self.cache_file_name, data.page_data(self.page_dict), *self.cache_subdirs())
42 |
43 | def read_cache(self) -> dict:
44 | return data.read_cache(self.cache_file_name, *self.cache_subdirs())
45 |
46 | def time_to_update_cache(self) -> bool:
47 | """Return True if path mtime > 5 mins from now.
48 | (default twitch API update time).
49 | """
50 | if self.force_redownload:
51 | self.force_redownload = False # switch off to not redownload on each redraw call
52 | return True
53 | fnf = not Path(self.cache_path()).is_file() # abbrev: file not found
54 | if fnf or utils.secs_since_mtime(self.cache_path()) > 300:
55 | return True
56 | else:
57 | return False
58 |
59 | def update_data(self) -> dict:
60 | """Update json data & return thumbnail paths."""
61 | subdirs = self.cache_subdirs()
62 | if self.time_to_update_cache():
63 | self.update_cache()
64 | json_data = self.read_cache()
65 | ids = data.get_entries(json_data, 'id')
66 | thumbnail_urls_raw = data.get_entries(json_data, 'thumbnail_url')
67 | thumbnail_paths = thumbnails.download_thumbnails(ids, thumbnail_urls_raw, *subdirs)
68 | else:
69 | # do not download thumbnails, find previously downloaded thumbnails paths
70 | json_data = self.read_cache()
71 | ids = data.get_entries(json_data, 'id')
72 | thumbnail_paths = thumbnails.find_thumbnails(ids, *subdirs)
73 | return thumbnail_paths
74 |
75 | def grid_func(self):
76 | """Return grid class object for prepared objects of thumbnails and boxes."""
77 | if thumbnails.text_mode():
78 | if self.time_to_update_cache():
79 | self.update_cache()
80 | thumbnail_paths = {}
81 | else:
82 | thumbnail_paths = self.update_data()
83 | json_data = self.read_cache()
84 | did = data.create_id_dict(json_data) # dict with id as the key
85 | ids = list(did.keys())
86 | boxes = render.Boxes()
87 | grid = render.Grid(ids, self.page_name)
88 | for id, (x, y) in grid.coords.items():
89 | d = did[id]
90 | title = utils.tryencoding(d["title"])
91 | if "creator_name" in d: # => clips
92 | # this is actually not login but name -> we do not need that anyway for clips
93 | user_login = d["broadcaster_name"]
94 | user_name = d["creator_name"]
95 | else:
96 | # used for composing stream url
97 | user_login = d["user_login"]
98 | user_name = d["user_name"]
99 | if not user_name: # if user_name is empty (rare, but such case exist!)
100 | user_name = user_login
101 | # NOTE: videos DOES NOT HAVE game_name/category!
102 | if "game_name" in d: # => live streams
103 | category = d["game_name"]
104 | elif "created_at" in d: # => videos page
105 | category = utils.sdate(d["created_at"])
106 | elif "published_at" in d:
107 | category = utils.sdate(d["published_at"])
108 | else:
109 | category = ""
110 | if "viewer_count" in d: # => live streams
111 | views = d["viewer_count"]
112 | elif "view_count" in d: # => videos page
113 | views = d["view_count"]
114 | else:
115 | views = ""
116 | box = render.Box(user_login, user_name, title, category, x, y)
117 | if "url" in d:
118 | box.url = d["url"] # videos have specific url
119 | if "duration" in d:
120 | box.duration = utils.duration(str(d["duration"]))
121 | box.viewers = str(views)
122 | if thumbnail_paths:
123 | box.img_path = thumbnail_paths[id]
124 | thumbnails.Thumbnail(id, thumbnail_paths[id], x, y + HEADER_H)
125 | boxes.add(box)
126 | return grid
127 |
--------------------------------------------------------------------------------
/twitchez/render.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | # coding=utf-8
3 |
4 | from twitchez import HEADER_H
5 | from twitchez import STDSCR
6 | from twitchez import conf
7 | from twitchez import hints
8 | from twitchez import open_chat
9 | from twitchez import open_cmd
10 | from twitchez import pages
11 | from twitchez import utils
12 | from twitchez.clip import clip
13 | from twitchez.tabs import tab_names_ordered
14 | from twitchez.thumbnails import container_size
15 |
16 | from itertools import islice
17 | from threading import Thread
18 | from typing import TypeVar
19 | import curses
20 |
21 |
22 | SelfBoxes = TypeVar("SelfBoxes", bound="Boxes")
23 |
24 |
25 | class Boxes:
26 | """Operate on list of Boxes"""
27 | boxlist = []
28 | drawn_boxes = []
29 |
30 | def add(self, obj):
31 | """Add box object to list."""
32 | self.boxlist.append(obj)
33 |
34 | def draw(self, parent, grid, fulltitle=False):
35 | """Draw boxes."""
36 | self.drawn_boxes.clear()
37 | stop = len(grid.coordinates())
38 | for box in islice(self.boxlist, stop):
39 | if fulltitle:
40 | box.fulltitle = True
41 | box.draw(parent)
42 | self.drawn_boxes.append(box)
43 | parent.refresh()
44 | self.boxlist.clear()
45 |
46 | def show_hints_boxes(self):
47 | """Show hints for visible/drawn boxes."""
48 | boxes = self.drawn_boxes
49 | hseq = hints.hint(boxes)
50 | for box, hint in zip(boxes, hseq):
51 | box.hint = hint
52 | box.show_hint()
53 | return hints.find_seq(hseq)
54 |
55 | def show_boxes_hint(self: SelfBoxes) -> tuple[SelfBoxes, str]:
56 | return self, self.show_hints_boxes()
57 |
58 | def get_box_attr_hint(self, hint, attr):
59 | """return attribute value of box object found by the hint."""
60 | boxes = self.drawn_boxes
61 | if not hasattr(boxes[0], attr):
62 | raise AttributeError(f"'{attr}' -> {boxes[0]} does not have such attribute!")
63 | for box in boxes:
64 | if getattr(box, "hint") == hint:
65 | return getattr(box, attr)
66 | raise Exception(f"value of ATTR:'{attr}' by the HINT:'{hint}' not found!")
67 |
68 | def copy_url(self, hint):
69 | if not hint:
70 | return
71 | value = self.get_box_attr_hint(hint, "url")
72 | clip(value)
73 |
74 | def open_url(self, hint, type):
75 | if not hint:
76 | return
77 | value = self.get_box_attr_hint(hint, "url")
78 | open_cmd.open_url(value, type)
79 |
80 | def open_chat(self, hint):
81 | if not hint:
82 | return
83 | value = self.get_box_attr_hint(hint, "user_login")
84 | open_chat.open_chat(value)
85 |
86 |
87 | class Box:
88 | """Box with info about the stream/video inside the Grid."""
89 | w, h = container_size()
90 | last = h - 2 # last line of the box
91 |
92 | def __init__(self, user_login, user_name, title, category, x, y):
93 | self.user_login = user_login # for composing url
94 | self.user_name = user_name
95 | self.title = utils.strclean(title)
96 | self.category = category
97 | self.x = x
98 | self.y = y
99 | self.url = f"https://www.twitch.tv/{self.user_login}"
100 | self.hint = ""
101 | self.img_path = ""
102 | self.viewers = ""
103 | self.duration = ""
104 | self.fulltitle = False
105 |
106 | def draw(self, parent):
107 | """Draw Box."""
108 | win = parent.derwin(self.h, self.w, self.y, self.x)
109 | win.addnstr(self.last, 0, f"{self.category}", self.w)
110 | if self.duration:
111 | duration = f"[{self.duration}]"
112 | rside = self.w - len(duration)
113 | win.addnstr(self.last - 1, 0, f"{self.user_name}", rside, curses.A_BOLD)
114 | win.addstr(self.last - 1, rside, duration)
115 | else:
116 | win.addnstr(self.last - 1, 0, f"{self.user_name}", self.w, curses.A_BOLD)
117 | if self.viewers:
118 | viewers = f" {self.viewers}"
119 | rside = self.w - len(viewers)
120 | win.addstr(self.last, rside, viewers, curses.A_BOLD)
121 | if self.fulltitle:
122 | max_len = int(self.w * 3) # 3 box widths (lines)
123 | title = utils.word_wrap_title(self.title, self.w, max_len)
124 | try:
125 | win.addnstr(self.last - 2, 0, title, max_len)
126 | except Exception:
127 | win.box()
128 | else:
129 | title = utils.strtoolong(self.title, self.w)
130 | win.addnstr(self.last - 2, 0, title, self.w)
131 |
132 | def show_hint(self):
133 | """Create window with hint character."""
134 | if self.hint: # if hint not empty -> show hint
135 | curses.init_pair(1, curses.COLOR_WHITE, curses.COLOR_RED)
136 | if len(self.hint) == 1:
137 | hint = f" {self.hint} "
138 | elif len(self.hint) == 2:
139 | hint = f" {self.hint}"
140 | else:
141 | hint = f"{self.hint}"
142 | lh = len(hint)
143 | win = curses.newwin(1, lh + 1, self.y + self.h - 1, self.x + self.w - lh)
144 | win.addstr(hint, curses.color_pair(1))
145 | win.refresh()
146 |
147 |
148 | class Grid:
149 | """Grid of boxes inside the Window."""
150 | w, h = container_size()
151 |
152 | def __init__(self, key_list: list, page_name: str):
153 | self.key_list = key_list
154 | self.page_name = page_name
155 | self.__ba = BodyArea()
156 | self.area_cols = self.__ba.cols
157 | self.area_rows = self.__ba.rows
158 | self.key_start_index = self.index()
159 | self.coords = self.coordinates()
160 |
161 | def capacity(self) -> tuple[int, int, int]:
162 | """Return - how many boxes can fit in: (cols, rows, total)."""
163 | cols = self.area_cols // self.w
164 | rows = self.area_rows // self.h
165 | total = cols * rows
166 | return cols, rows, total
167 |
168 | def spacing(self, cols, rows) -> tuple[int, int]:
169 | """Calculate even spacing between grid elements.
170 | returns spacing: cols, rows.
171 | """
172 | if cols < 1:
173 | c = 0
174 | else:
175 | c = int(self.area_cols - self.w * cols) // cols
176 | if rows < 1:
177 | r = 0
178 | else:
179 | r = int(self.area_rows - self.h * rows) // rows
180 | return c, r
181 |
182 | def index(self, start_index="") -> int:
183 | """Set/Get initial grid index."""
184 | if str(start_index): # str -> to check if not empty (even 0 value)
185 | index = int(start_index)
186 | conf.tmp_set("grid_index", index, self.page_name)
187 | else:
188 | index = int(conf.tmp_get("grid_index", 0, self.page_name))
189 | if not index or index < 0:
190 | index = 0
191 | conf.tmp_set("grid_index", index, self.page_name)
192 | return index
193 |
194 | def shift_index(self, dir="down", page=False) -> int:
195 | """Shift value of the key start index."""
196 | cols, _, total = self.capacity()
197 | elems_total = len(self.key_list)
198 | remainder = elems_total % cols
199 | if remainder == 0:
200 | end_of_page = elems_total - total
201 | else:
202 | end_of_page = elems_total - total - remainder + cols
203 | if elems_total <= total:
204 | start_index = 0
205 | else:
206 | grid_index = self.index()
207 | if dir == "top":
208 | start_index = 0
209 | elif dir == "bot":
210 | start_index = end_of_page
211 | elif page:
212 | if dir == "down":
213 | start_index = grid_index + total
214 | else:
215 | start_index = grid_index - total
216 | else:
217 | if dir == "down":
218 | start_index = grid_index + cols
219 | else:
220 | start_index = grid_index - cols
221 | if start_index < 0:
222 | start_index = 0
223 | elif start_index > end_of_page:
224 | start_index = end_of_page
225 | self.index(str(start_index))
226 | return start_index
227 |
228 | def coordinates(self) -> dict:
229 | """Return dict with: tuple(X, Y) values where each key_list element is the key."""
230 | initial_x, initial_y = 0, 0
231 | cols, rows, total = self.capacity()
232 | total += self.key_start_index # for scrolling
233 | scols, srows = self.spacing(cols, rows)
234 | # for more even spacing from both sides
235 | sc = scols // 2
236 | sr = srows // 2
237 | x = initial_x + sc
238 | y = initial_y + sr
239 | current_col = 1
240 | coordinates = {}
241 | for key in islice(self.key_list, self.key_start_index, total):
242 | if cols > 2:
243 | x += sc
244 | coordinates[key] = (x, y)
245 | if current_col < cols:
246 | current_col += 1
247 | x += sc + self.w
248 | else:
249 | current_col = 1
250 | x = initial_x + sc
251 | y += sr + self.h
252 | return coordinates
253 |
254 |
255 | class Page:
256 | """Page which renders everything."""
257 |
258 | def __init__(self, page_dict, force_redownload=False):
259 | self.pages_class = pages.Pages(page_dict, force_redownload)
260 | self.page_name = self.pages_class.page_name
261 | self.grid_func = self.pages_class.grid_func
262 | self.loaded = False
263 |
264 | def loading(self):
265 | """Simple animation to show that something is being done (Page loading).
266 | Currently the animation cycle is very short and ends even
267 | if the loading is not yet finished, '*' - static indicator of this.
268 |
269 | This is done intentionally to not ruin everything
270 | if raise() or crash occurred while thread is not yet finished and etc.
271 | The animation is short to quickly return to the terminal if an error is raised.
272 | """
273 | # NOTE: currently ANIMATION introduces extra wait time during draw() calls
274 | # on simple and fast operations like redraw() => so animation is disabled.
275 | # It does not feel like the fancy animation is worth it.
276 | ANIMATION = False
277 |
278 | def animation():
279 | """Animation length is intentionally short."""
280 | chars = "-\\|/" # animation chars
281 | for _ in range(8):
282 | for c in chars:
283 | win.insstr(c)
284 | win.refresh()
285 | curses.napms(25)
286 | # finish animation right now!
287 | if self.loaded:
288 | return
289 |
290 | def anima_thread():
291 | t = Thread(target=animation())
292 | t.start()
293 | t.join()
294 |
295 | try:
296 | win = curses.newwin(1, 1, 0, 0)
297 | except Exception:
298 | return
299 |
300 | try:
301 | if ANIMATION:
302 | anima_thread()
303 | else:
304 | win.insstr("*")
305 | finally:
306 | if self.loaded:
307 | win.erase()
308 | else:
309 | # leave a static indicator about not yet finished loading
310 | # it probably will be cleared by some clear of the screen
311 | # so we do not bother much about clearing of the static indicator :)
312 | win.insstr("*")
313 | win.refresh()
314 |
315 | def draw_header(self):
316 | """Draw page header."""
317 | indent = 2 # indent from side
318 | indent_between = " "
319 | separator = "|" # separator between tabs
320 | between_tabs = indent_between + separator + indent_between
321 | logo = "[twitchez]"
322 | c_page = self.page_name # current page name
323 | _, w = STDSCR.getmaxyx()
324 | head = STDSCR.derwin(HEADER_H - 1, w, 0, 0)
325 | other_tabs = ""
326 | # tab order where current page is always first in list (to look as carousel)
327 | taborder = []
328 | tnames = tab_names_ordered()
329 | cpni = tnames.index(c_page)
330 | taborder.extend(tnames[cpni:])
331 | taborder.extend(tnames[:cpni])
332 | for tab in taborder:
333 | if tab == c_page:
334 | continue # skip current tab
335 | # indent_between with separator for each additional tab page
336 | other_tabs += between_tabs + tab
337 | icp = indent + len(c_page) # width of current page with indent
338 | all_tabs = indent + len(c_page + other_tabs)
339 | if w > all_tabs: # if we can fit all tabs
340 | head.addnstr(0, indent, c_page, len(c_page), curses.A_REVERSE) # current Tab page
341 | head.addnstr(0, icp, other_tabs, len(other_tabs))
342 | if w > all_tabs + len(logo) + 1: # if window have enough width for logo
343 | wllimit = w - len(logo) - indent
344 | head.addnstr(0, wllimit, logo, len(logo), curses.A_BOLD)
345 | else:
346 | # crop tabs visually that do not fit into the window
347 | # > character signalize about cropping (existence of other tabs)
348 | if w > indent:
349 | wclimit = w - indent - 1
350 | c_page = c_page[:wclimit - 1] + ">"
351 | head.addnstr(0, indent, c_page, wclimit, curses.A_REVERSE)
352 | if w > icp:
353 | wolimit = w - icp - 1
354 | other_tabs = other_tabs[:wolimit - 1] + ">"
355 | head.addnstr(0, icp, other_tabs, wolimit)
356 | head.refresh()
357 | return head
358 |
359 | def draw_body(self, grid, fulltitle=False):
360 | """Draw page body."""
361 | body = BodyArea().window()
362 | if fulltitle:
363 | Boxes().draw(body, grid, fulltitle)
364 | else:
365 | Boxes().draw(body, grid)
366 | return body
367 |
368 | def draw(self, fulltitle=False):
369 | """return grid and draw full page."""
370 | self.loading()
371 | grid = self.grid_func()
372 | self.draw_body(grid, fulltitle)
373 | self.draw_header()
374 | self.loaded = True # finish animation of loading if not yet ended
375 | return grid
376 |
377 |
378 | class BodyArea:
379 | """Body area size of the window in the terminal cells."""
380 |
381 | def __init__(self):
382 | self.rows, self.cols = STDSCR.getmaxyx()
383 | self.rows = self.rows - HEADER_H
384 |
385 | def window(self):
386 | """Create & return body window."""
387 | return STDSCR.derwin(self.rows, self.cols, HEADER_H, 0)
388 |
--------------------------------------------------------------------------------
/twitchez/search.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | # coding=utf-8
3 |
4 | from twitchez import ENCODING
5 | from twitchez import STDSCR
6 | from twitchez import data
7 | from twitchez import iselect
8 | from twitchez import paged
9 | from twitchez.notify import notify
10 | import curses
11 | import re
12 |
13 |
14 | def inputwin(prompt: str) -> str:
15 | """Show input window at the last line of the stdscr window,
16 | return input str after pressing Enter key.
17 | """
18 | h, w = STDSCR.getmaxyx()
19 | win = curses.newwin(1, w // 3, h - 1, 0)
20 | win.addstr(0, 0, prompt, curses.A_REVERSE)
21 | win.refresh()
22 | curses.echo()
23 | # indent from the prompt by one character
24 | input = win.getstr(0, len(prompt) + 1)
25 | # convert bytes to string (remove the b prefix)
26 | decoded = str(input, ENCODING).strip()
27 | curses.noecho()
28 | win.erase()
29 | win.refresh()
30 |
31 | # Esc = b'\x1b', ^C = b'\x03'
32 | ignorebyte = ['\x1b', '\x03']
33 | ignorelist = ['/', '\\']
34 | ignorelist.extend(ignorebyte)
35 |
36 | # handle Esc or ^C from input as cancel command
37 | if any(_ in decoded for _ in ignorelist):
38 | notify("input was ignored!")
39 | return ""
40 | return decoded
41 |
42 |
43 | def selected_category(fallback: dict) -> dict:
44 | input = inputwin("category:")
45 | if not input:
46 | return fallback
47 | mulstr = data.get_categories_terse_mulstr(input)
48 | selection = iselect.iselect(mulstr, 130)
49 | if selection == 130:
50 | return fallback
51 | id_pattern = re.compile(r"\[(\d+)\]$")
52 | sel_name = re.sub(id_pattern, "", selection).strip()
53 | sel_id = re.search(id_pattern, selection).group(1)
54 | category_id = sel_id
55 | category_name = sel_name
56 | page_dict = paged.stream(category_name, category_id)
57 | return page_dict
58 |
59 |
60 | def selected_channel(video_type, fallback: dict) -> dict:
61 | input = inputwin("channel:")
62 | if not input:
63 | return fallback
64 | mulstr = data.get_channels_terse_mulstr(input)
65 | selection = iselect.iselect(mulstr, 130)
66 | if selection == 130:
67 | return fallback
68 | id_pattern = re.compile(r"\[(\d+)\]$")
69 | sel_id = re.search(id_pattern, selection).group(1)
70 | __sel_user = re.sub(id_pattern, "", selection).strip()
71 | sel_user = re.sub(r"^.*\s", "", __sel_user).strip()
72 | user_id = sel_id
73 | user_name = sel_user
74 | page_dict = paged.video(video_type, user_id, user_name)
75 | return page_dict
76 |
77 |
78 | def select_page(fallback: dict) -> dict:
79 | """Interactive select of page to open, return page_dict of that page or fallback page."""
80 | msel = "category streams\nchannel videos\nfollowing live"
81 | main_sel = iselect.iselect(msel, 130)
82 | if main_sel == 130: # handle cancel of the command
83 | return fallback
84 | if "following" in main_sel:
85 | return paged.following_live()
86 | elif "streams" in main_sel:
87 | return selected_category(fallback)
88 | # => videos page
89 | vtypes = "archive\nclips\nhighlight\nupload"
90 | video_type = iselect.iselect(vtypes, 130)
91 | if video_type == 130:
92 | return fallback
93 | return selected_channel(video_type, fallback)
94 |
--------------------------------------------------------------------------------
/twitchez/tabs.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | # coding=utf-8
3 |
4 | from ast import literal_eval
5 | from pathlib import Path
6 | from twitchez import conf
7 | from twitchez import fs
8 | from twitchez import paged
9 | from twitchez.iselect import iselect
10 |
11 | FILE = Path(fs.get_data_dir("data"), "tabs").resolve().as_posix()
12 | LTABS = "LTABS"
13 | DTABS = "DTABS"
14 |
15 |
16 | def tabs_list() -> list:
17 | """Return list of tabs (name, dict) tuples."""
18 | return conf.dta_list(DTABS, FILE)
19 |
20 |
21 | def tabs_dnames() -> list:
22 | """Return list of tab names (keys) of the dictionaries."""
23 | return [tname for tname, _ in tabs_list()]
24 |
25 |
26 | def tab_names_ordered() -> list:
27 | """Return an ordered list of opened tab names."""
28 | names_list_str = conf.dta_get("ltabs", "", LTABS, FILE)
29 | try:
30 | names_list = literal_eval(names_list_str)
31 | except Exception as e:
32 | raise ValueError(f"names_list_str: '{names_list_str}'\n{e}")
33 | return names_list
34 |
35 |
36 | def tabs_upd(tabs: list):
37 | """Update/set list of tabs."""
38 | conf.dta_set("ltabs", tabs, LTABS, FILE)
39 | # remove all not opened tabs (real tabs data with dicts)
40 | for tname in tabs_dnames():
41 | if tname not in tabs:
42 | conf.dta_rmo(tname, DTABS, FILE)
43 |
44 |
45 | def cpname_set(pname: str):
46 | """Set value of the current page/tab name."""
47 | conf.dta_set("cpname", pname, LTABS, FILE)
48 |
49 |
50 | def tab_upd(page_name: str, page_dict: dict):
51 | """Update tabs, set current page name, add page to the tabs list (if not exist)."""
52 | cpname_set(page_name) # set the new current page name (NOTE: before everything else!)
53 | conf.dta_set(page_name, page_dict, DTABS, FILE)
54 | tab_add_new(page_name)
55 |
56 |
57 | def cpname() -> str:
58 | """Get current page name, set to the first tab name as fallback."""
59 | cpn = conf.dta_get("cpname", "", LTABS, FILE)
60 | tabs = tab_names_ordered()
61 | if not cpn or cpn not in tabs:
62 | if not tabs:
63 | cpn = paged.FLPN # fallback to following live page
64 | else:
65 | cpn = tabs[0] # set first tab name as the current page name
66 | cpname_set(cpn)
67 | return cpn
68 |
69 |
70 | def cpdict() -> dict:
71 | """Get current page dict."""
72 | return pdict(cpname())
73 |
74 |
75 | def pdict(page_name="") -> dict:
76 | """Return page dict by the page name or (current tab/page by default)."""
77 | if not page_name: # return page_dict of current tab/page
78 | pdict_str = conf.dta_get(cpname(), "", DTABS, FILE)
79 | else:
80 | pdict_str = conf.dta_get(page_name, cpname(), DTABS, FILE)
81 | if not pdict_str or pdict_str == paged.FLPN or page_name == paged.FLPN:
82 | return paged.following_live() # fallback to following live page
83 | try:
84 | page_dict = literal_eval(pdict_str)
85 | except Exception as e:
86 | raise ValueError(f"pdict_str: '{pdict_str}'\n{e}")
87 | return page_dict
88 |
89 |
90 | def tab_add_new(page_name: str):
91 | """Add new tab/page to the tabs list (if not exist)."""
92 | tabs = tab_names_ordered()
93 | if not tabs:
94 | tabs.append(page_name)
95 | tabs_upd(tabs)
96 | cpn = cpname()
97 | return
98 | # do not add the same tab twice
99 | if page_name not in tabs:
100 | cpn = cpname()
101 | # find index of current page name and insert new tab after that index
102 | cindex = tabs.index(cpn)
103 | nindex = cindex + 1
104 | tabs.insert(nindex, page_name)
105 | tabs_upd(tabs)
106 | return
107 |
108 |
109 | def delete_tab(page_name="") -> dict:
110 | """Delete tab by page name or current tab/page and return page_dict of the previous tab."""
111 | ctab = cpname()
112 | tabs = tab_names_ordered()
113 | if (page_name != ctab and page_name in tabs):
114 | tab_to_delete = page_name
115 | tab_to_jump = ctab
116 | else:
117 | tab_to_delete = ctab
118 | _, tab_to_jump = prev_tab()
119 |
120 | if (tab_to_delete in tabs):
121 | tabs.remove(tab_to_delete)
122 |
123 | tabs_upd(tabs)
124 | return pdict(tab_to_jump)
125 |
126 |
127 | def find_tab(fallback=cpdict()) -> dict:
128 | """Find and return page dict of selected tab or fallback to current tab (by default)."""
129 | tabs = tab_names_ordered()
130 | mulstr = "\n".join(tabs) # each list element on it's own line
131 | tabname = iselect(mulstr, 130)
132 | # handle cancel of the command
133 | if tabname == 130:
134 | return fallback
135 | return pdict(tabname)
136 |
137 |
138 | def next_tab() -> tuple[dict, str]:
139 | """Return (page_dict, page_name) tuple of the next tab (carousel)."""
140 | tabs = tab_names_ordered()
141 | cindex = tabs.index(cpname())
142 | nindex = cindex + 1
143 | if nindex > len(tabs) - 1:
144 | ntabname = tabs[0]
145 | else:
146 | ntabname = tabs[nindex]
147 | return pdict(ntabname), ntabname
148 |
149 |
150 | def prev_tab() -> tuple[dict, str]:
151 | """Return (page_dict, page_name) tuple of the prev tab (carousel)."""
152 | tabs = tab_names_ordered()
153 | cindex = tabs.index(cpname())
154 | pindex = cindex - 1
155 | ptabname = tabs[pindex]
156 | return pdict(ptabname), ptabname
157 |
--------------------------------------------------------------------------------
/twitchez/thumbnails.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | # coding=utf-8
3 |
4 | from twitchez import command
5 | from twitchez import conf
6 | from twitchez import fs
7 | from twitchez import utils
8 |
9 | from pathlib import Path
10 | from shutil import which
11 | from sys import version_info, stdout
12 | from threading import Timer
13 |
14 | import aiohttp
15 | import asyncio
16 | import json
17 | import os # listdir, sep, devnull, basename, splitext
18 | import subprocess
19 |
20 |
21 | # check if executables at PATH
22 | HAS_UEBERZUG = bool(which("ueberzugpp")) | bool(which("ueberzug"))
23 | # also check user cmd in case executable provided via full path
24 | HAS_UEBERZUG |= command.conf_cmd_check("ueberzug_cmd")[0]
25 |
26 |
27 | def raise_user_note():
28 | """raise exception for regular user without traceback."""
29 | raise Exception(
30 | "\n\n"
31 | "Neither ueberzugpp nor ueberzug were found at PATH. While text_mode is not enabled.\n"
32 | "You can install 'ueberzugpp' and it will be working by default.\n"
33 | "It supports more output options and platforms than old version of ueberzug written in python.\n"
34 | "Also you can set your own program cmd via 'ueberzug_cmd = your cmd' in config.\n"
35 | "This will allow you to override default ueberzugpp layer --output for you machine, etc.\n"
36 | "If you want to use this program without thumbnails,\n"
37 | "simply paste next line in your config:\n"
38 | "text_mode = 3\n"
39 | )
40 |
41 |
42 | def get_ueberzug_cmd() -> list:
43 | """Check & return cmd if executable is on PATH."""
44 | cmd = ""
45 | user_cmd_ok, ueberzug_cmd = command.conf_cmd_check("ueberzug_cmd")
46 | if user_cmd_ok:
47 | # prefer ueberzug_cmd if set in config and found at PATH or if full path provided
48 | cmd = ueberzug_cmd
49 | elif which("ueberzugpp"):
50 | # NOTE: --no-cache is important, without it ueberzugpp will show old cached thumbnails!
51 | cmd = "ueberzugpp layer --no-cache --silent"
52 | elif which("ueberzug"):
53 | # seems like python version of ueberzug have no layer specific options
54 | cmd = "ueberzug layer"
55 | else:
56 | raise_user_note()
57 | return cmd.split()
58 |
59 |
60 | def text_mode() -> int:
61 | """Text mode: 0 => thumbnails mode (min: 0, max: 3).
62 | [1-3] => do not do anything with thumbnails do not even download them!
63 | The higher the value, the more rows of cells there will be in the grid.
64 | """
65 | tm = int(conf.setting("text_mode"))
66 | if tm < 0:
67 | tm = 0
68 | elif tm > 3:
69 | tm = 3
70 | # explicit text mode if ueberzug not found (optional dependency)
71 | if not HAS_UEBERZUG and tm < 1:
72 | tm = 1
73 | return tm
74 |
75 |
76 | def rdiv() -> int:
77 | """Thumbnail resolution divisor (min: 2, max: 10)."""
78 | div = 1 + int(conf.setting("grid_size"))
79 | if div < 2:
80 | div = 2
81 | elif div > 10:
82 | div = 10
83 | return div
84 |
85 |
86 | def container_size(thumbnail=False) -> tuple[int, int]:
87 | """Return tuple: (width, height) - based on divisor key in table.
88 | Selected values are close as possible to the real resolution of the thumbnails.
89 | Except couple values where visual result more appropriate: with (2,3,4) as divisor.
90 | """
91 | table = {
92 | 10: (24, 7),
93 | 9: (27, 8),
94 | 8: (30, 9),
95 | 7: (35, 10),
96 | 6: (40, 11),
97 | 5: (48, 13),
98 | 4: (56, 15),
99 | 3: (76, 20),
100 | 2: (90, 24),
101 | }
102 | # use fallback key if div key not found
103 | _def_fix: tuple = (40, 11) # fix: None is not assignable
104 | w, h = tuple(table.get(rdiv(), table.get(6, _def_fix)))
105 | # width/height modifier for perfect placement of thumbnails in the grid (very font dependent)
106 | w += int(conf.setting("wmod"))
107 | h += int(conf.setting("hmod"))
108 | tm = text_mode()
109 | if tm:
110 | return w, h - tm
111 | elif thumbnail:
112 | return w, h
113 | else:
114 | NLC = 3 # num of content lines in the box
115 | return w, h + NLC
116 |
117 |
118 | def thumbnail_resolution() -> tuple[int, int]:
119 | """Return tuple: (width, height) - based on divisor key in table.
120 | really simple: divisor = 10
121 | (1920, 1080) / 10 = (192, 108)
122 | The only values that don't match the actual result: divisor=2.
123 | """
124 | table = {
125 | 10: (192, 108),
126 | 9: (213, 120),
127 | 8: (240, 135),
128 | 7: (274, 154),
129 | 6: (320, 180),
130 | 5: (384, 216),
131 | 4: (480, 270),
132 | 3: (640, 360),
133 | 2: (720, 405), # actual (960, 540) is overkill!
134 | }
135 | # use fallback key if div key not found
136 | _def_fix: tuple = (320, 180) # fix: None is not assignable
137 | return table.get(rdiv(), table.get(6, _def_fix))
138 |
139 |
140 | def get_thumbnail_urls(rawurls) -> list:
141 | """Return thumbnail urls with {width} and {height} replaced."""
142 | width, height = thumbnail_resolution()
143 | urls = []
144 | for url in rawurls:
145 | # fix: video thumbnails currently have weird format with % characters
146 | if url and "%{" in url:
147 | # remove % character from thumbnail url
148 | url = url.replace("%{", "{")
149 | if not url:
150 | url = ""
151 | else:
152 | url = url.format(width=width, height=height)
153 | urls.append(url)
154 | return urls
155 |
156 |
157 | async def fetch_image(session, url):
158 | """Asynchronously fetch image from url."""
159 | if not url:
160 | return None
161 | async with session.get(url) as response:
162 | return await response.read()
163 |
164 |
165 | async def get_thumbnails_async(ids: list, rawurls: list, *subdirs) -> dict:
166 | """Asynchronously download thumbnails and return paths.
167 | (Actual realization)
168 | """
169 | thumbnail_paths = {}
170 | urls = get_thumbnail_urls(rawurls)
171 | tmpd = fs.get_tmp_dir("thumbnails", *subdirs)
172 | blank_thumbnail = Path(conf.glob_conf_dir, "blank.jpg")
173 | tasks = []
174 | async with aiohttp.ClientSession() as session:
175 | for url in urls:
176 | tasks.append(fetch_image(session, url))
177 | # wait until all thumbnails with non empty url are fetched
178 | thumbnails = await asyncio.gather(*tasks)
179 |
180 | for tid, thumbnail in zip(ids, thumbnails):
181 | thumbnail_fname = f"{tid}.jpg"
182 | thumbnail_path = Path(tmpd, thumbnail_fname)
183 | if thumbnail is None:
184 | if thumbnail_path.is_file() and thumbnail_path.samefile(blank_thumbnail):
185 | pass
186 | else:
187 | # remove symlink or file before creating new symlink
188 | if thumbnail_path.is_file():
189 | thumbnail_path.unlink(missing_ok=True)
190 | # create symlink of blank_thumbnail
191 | thumbnail_path.symlink_to(blank_thumbnail)
192 | else:
193 | # NOTE: if existing thumbnail_path is symlink, original blank thumbnail
194 | # will be replaced by the thumbnail_path image, to prevent that
195 | # => remove symlink before writing new image file
196 | if thumbnail_path.is_symlink():
197 | thumbnail_path.unlink(missing_ok=True)
198 | with open(thumbnail_path, 'wb') as f:
199 | f.write(thumbnail)
200 | thumbnail_paths[tid] = str(thumbnail_path)
201 | return thumbnail_paths
202 |
203 |
204 | def download_thumbnails(ids: list, rawurls: list, *subdirs) -> dict:
205 | """Asynchronously download thumbnails and return paths.
206 | (Wrapper with asyncio run/run_until_complete)
207 | """
208 | if version_info >= (3, 7): # Python 3.7+
209 | return asyncio.run(get_thumbnails_async(ids, rawurls, *subdirs))
210 | else: # Python 3.5-3.6
211 | loop = asyncio.get_event_loop()
212 | try:
213 | return loop.run_until_complete(get_thumbnails_async(ids, rawurls, *subdirs))
214 | finally:
215 | loop.close()
216 |
217 |
218 | def find_thumbnails(ids: list, *subdirs) -> dict:
219 | """Find and return previously downloaded thumbnails paths."""
220 | tmpd = fs.get_tmp_dir("thumbnails", *subdirs)
221 | blank_thumbnail = Path(conf.glob_conf_dir, "blank.jpg")
222 |
223 | tnames = utils.replace_pattern_in_all(os.listdir(tmpd), ".jpg", "")
224 | differ = list(set(tnames).difference(set(ids)))
225 | fnames = utils.add_str_to_list(differ, ".jpg") # add file extension back
226 | for fname in fnames:
227 | # remove thumbnail files/symlinks which id not in ids list
228 | Path(tmpd, fname).unlink(missing_ok=True)
229 |
230 | thumbnail_list = utils.insert_to_all(os.listdir(tmpd), tmpd, opt_sep=os.sep)
231 | thumbnail_paths = {}
232 | for path in thumbnail_list:
233 | tid = os.path.basename(os.path.splitext(path)[0]) # file basename without .ext
234 | thumbnail_paths[tid] = path
235 | # fix: if thumbnail_paths does not have id from ids
236 | # this usually happens if text mode without thumbnails was previously set
237 | for tid in ids:
238 | if tid not in thumbnail_paths.keys():
239 | thumbnail_paths[tid] = str(blank_thumbnail)
240 | return thumbnail_paths
241 |
242 |
243 | class Thumbnail:
244 | """Prepare Thumbnail ueberzug parameters and add to Thumbnails."""
245 | w, h = container_size(thumbnail=True)
246 |
247 | def __init__(self, identifier, img_path, x, y):
248 | self.identifier = identifier
249 | self.img_path = img_path
250 | self.x = x
251 | self.y = y
252 | self.ue_params = self._ue_params()
253 |
254 | def _ue_params(self) -> dict[str, str]:
255 | """Return dict for thumbnail with all parameters required by ueberzug.
256 | Append parameters of the Thumbnail to the list of Thumbnails parameters.
257 | """
258 | uep = {
259 | "action": "add",
260 | "scaler": "fit_contain",
261 | "identifier": self.identifier,
262 | "path": self.img_path,
263 | "height": self.h,
264 | "width": self.w,
265 | "y": self.y,
266 | "x": self.x,
267 | }
268 | if not text_mode():
269 | Thumbnails.uepl.append(uep)
270 | return uep
271 |
272 |
273 | class Thumbnails:
274 | uepl: list[dict[str, str]] = [] # ueberzug list of thumbnail parameters
275 | tm = text_mode()
276 |
277 | is_initialized = False
278 | working_dir = fs.get_tmp_dir()
279 |
280 | @staticmethod
281 | def json_schema_thumbnails(uepl: list) -> str:
282 | """New Line Delimited JSON, one ueberzug thumbnail parameters data per line."""
283 | nl_json = ""
284 | for th_params in uepl:
285 | nl_json += json.dumps(th_params) + '\n'
286 | return nl_json
287 |
288 | @staticmethod
289 | def PopenType() -> subprocess.Popen:
290 | """Get proper type and object properties at initialization.
291 | By executing common/smallest/fastest program existing in nearly every OS.
292 | """
293 | return subprocess.Popen(["echo"], stdin=subprocess.PIPE, stdout=subprocess.DEVNULL)
294 |
295 | def __init__(self):
296 | self.sub_proc = self.PopenType()
297 |
298 | def init_check(self) -> bool:
299 | return self.is_initialized and self.sub_proc.poll() is None
300 |
301 | def init(self):
302 | """start ueberzug subprocess."""
303 | assert (self.sub_proc.stdin is not None) # "None" [reportOptionalMemberAccess]
304 | if (self.init_check()):
305 | return
306 | cmd: list = get_ueberzug_cmd()
307 | # we do not want to close subprocess because that stops the drawing.
308 | with open(os.devnull, "wb", 0) as devnull:
309 | self.sub_proc = subprocess.Popen(
310 | cmd,
311 | cwd=self.working_dir,
312 | stderr=devnull,
313 | stdout=stdout.buffer,
314 | stdin=subprocess.PIPE,
315 | universal_newlines=True,
316 | )
317 | self.is_initialized = True
318 |
319 | def execute(self, **kwargs):
320 | """execute ueberzug action/cmd."""
321 | self.init()
322 | assert (self.sub_proc.stdin is not None) # "None" [reportOptionalMemberAccess]
323 | # NOTE: direct interaction with the stdin as we do not want to close subprocess.
324 | if kwargs:
325 | # NOTE: mainly for the cleanup (remove action)
326 | self.sub_proc.stdin.write(json.dumps(kwargs) + '\n')
327 | else:
328 | self.sub_proc.stdin.write(self.json_schema_thumbnails(self.uepl))
329 | self.sub_proc.stdin.flush()
330 |
331 | def clear(self):
332 | """cleanup from the old thumbnails data."""
333 | assert (self.sub_proc.stdin is not None) # "None" [reportOptionalMemberAccess]
334 | if self.sub_proc and not self.sub_proc.stdin.closed:
335 | for th_params in self.uepl:
336 | # remove previously added but no longer needed thumbnails
337 | self.execute(action="remove", identifier=th_params["identifier"])
338 | # clear list of the thumbnails parameters
339 | self.uepl.clear()
340 |
341 | def quit(self):
342 | """wrapper for the fast & safe termination of subprocess."""
343 | if self.init_check():
344 | timer_kill = Timer(1, self.sub_proc.kill, [])
345 | try:
346 | self.sub_proc.terminate()
347 | timer_kill.start()
348 | self.sub_proc.communicate()
349 | finally:
350 | timer_kill.cancel()
351 |
352 | def start(self):
353 | """Start drawing images via subprocess."""
354 | if self.tm:
355 | return
356 | self.execute()
357 |
358 | def finish(self, safe=False):
359 | """Finish drawing images and optionally terminate subprocess."""
360 | if self.tm:
361 | return
362 | self.clear()
363 | if safe:
364 | self.quit()
365 |
366 |
367 | THUMBNAILS: Thumbnails = Thumbnails()
368 |
369 |
370 | def draw_start():
371 | if not HAS_UEBERZUG:
372 | return
373 | THUMBNAILS.start()
374 |
375 |
376 | def draw_stop(safe=False):
377 | if not HAS_UEBERZUG:
378 | return
379 | THUMBNAILS.finish(safe)
380 |
--------------------------------------------------------------------------------
/twitchez/utils.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | # coding=utf-8
3 |
4 | from twitchez import STDSCR
5 | from datetime import datetime
6 | from difflib import SequenceMatcher
7 | from os.path import getmtime
8 | from re import compile
9 | from twitchez import conf
10 | import textwrap
11 | import time
12 |
13 |
14 | # visible length of one emoji in terminal cells
15 | EMOJI_CELLS = int(conf.setting("emoji_cells"))
16 |
17 | EMOJI_PATTERN = compile(
18 | "["
19 | "\U0001F1E0-\U0001F1FF" # flags (iOS)
20 | "\U0001F300-\U0001F5FF" # symbols & pictographs
21 | "\U0001F600-\U0001F64F" # emoticons
22 | "\U0001F680-\U0001F6FF" # transport & map symbols
23 | "\U0001F700-\U0001F77F" # alchemical symbols
24 | "\U0001F780-\U0001F7FF" # Geometric Shapes Extended
25 | "\U0001F800-\U0001F8FF" # Supplemental Arrows-C
26 | "\U0001F900-\U0001F9FF" # Supplemental Symbols and Pictographs
27 | "\U0001FA00-\U0001FA6F" # Chess Symbols
28 | "\U0001FA70-\U0001FAFF" # Symbols and Pictographs Extended-A
29 | "\U00002702-\U000027B0" # Dingbats
30 | "\U000024C2-\U0001F251"
31 | "]+"
32 | )
33 |
34 |
35 | def tryencoding(string: str) -> str:
36 | """Return string in default encoding or
37 | if not printable -> try to re-encode into utf-16."""
38 | if not string.isprintable():
39 | try:
40 | string = string.encode('utf-16', 'surrogatepass').decode("utf-16", "ignore")
41 | except (UnicodeEncodeError, UnicodeDecodeError) as e:
42 | string = str(e)
43 | return string
44 |
45 |
46 | def demojize(str: str) -> str:
47 | """Return string without emojis."""
48 | return EMOJI_PATTERN.sub(r'', str)
49 |
50 |
51 | def emoji_count(str: str) -> int:
52 | """Returns the count of emojis in a string."""
53 | return len(str) - len(demojize(str))
54 |
55 |
56 | def tlen(str: str) -> int:
57 | """Return len of str respecting emoji visible length in terminal cells.
58 | EMOJI_CELLS: visible length of one emoji in terminal cells.
59 | """
60 | if EMOJI_CELLS < 2:
61 | return len(str)
62 | else:
63 | return EMOJI_CELLS * emoji_count(str) + len(demojize(str))
64 |
65 |
66 | def was_resized(xysum=0) -> int:
67 | """HACK: to be able to check if terminal was resized
68 | between calls of the function inside some function (long running).
69 | Compares sum of xy between the initial
70 | and the actual xy sum at the call time of the function.
71 | """
72 | def sumyx() -> int:
73 | return sum(STDSCR.getmaxyx()) # sum of tuple[x, y]
74 |
75 | def was_resized_between_calls() -> int:
76 | return 0 if xysum == sumyx() else 1
77 |
78 | # just return the initial xysum at the call time
79 | if xysum == 0:
80 | return sumyx()
81 | else:
82 | return was_resized_between_calls()
83 |
84 |
85 | def secs_since_mtime(path):
86 | """time_now - target_mtime = int(secs)."""
87 | return int(time.time() - getmtime(path))
88 |
89 |
90 | def replace_pattern_in_all(inputlist, oldstr, newstr) -> list:
91 | """Replace oldstr with newstr in all items from a list."""
92 | outputlist = []
93 | for e in inputlist:
94 | outputlist.append(str(e).replace(oldstr, newstr))
95 | return outputlist
96 |
97 |
98 | def add_str_to_list(input_list, string) -> list:
99 | """Add string to the end of all elements in a list."""
100 | outputlist = [e + str(string) for e in input_list]
101 | return outputlist
102 |
103 |
104 | def insert_to_all(list, string, opt_sep="") -> list:
105 | """ Insert the string at the beginning of all items in a list. """
106 | string = str(string)
107 | if opt_sep:
108 | string = f"{string}{opt_sep}"
109 | string += '% s'
110 | list = [string % i for i in list]
111 | return list
112 |
113 |
114 | def strws(str: str) -> str:
115 | """Return a str without whitespaces & slash characters - replaced by '_'."""
116 | return str.strip().replace(' ', '_').replace('/', '_').replace('\\', '_')
117 |
118 |
119 | def strclean(str: str) -> str:
120 | """return slightly cleaner string."""
121 | # remove unneeded characters from string
122 | s = str.replace("\n", " ").replace("\t", " ")
123 | # replace repeating whitespaces by single whitespace
124 | s = ' '.join(s.split())
125 | s = s.strip()
126 | return s
127 |
128 |
129 | def strtoolong(str: str, width: int, indicator="..") -> str:
130 | """Return str slice of width with indicator at the end.
131 | (to show that the string cannot fit completely in width)
132 | """
133 | if tlen(str) > width:
134 | str_fit_in_width = str[:width]
135 | # visible width in terminal cells that str occupies
136 | terminal_cells = tlen(str_fit_in_width)
137 | if terminal_cells > width:
138 | ec = emoji_count(str_fit_in_width)
139 | cut = ec + len(indicator)
140 | out_str = str_fit_in_width[:-cut] + indicator
141 | else:
142 | out_str = str_fit_in_width[:-len(indicator)] + indicator
143 | return out_str
144 | else:
145 | return str
146 |
147 |
148 | def word_wrap_title(string: str, width: int, max_len: int, max_lines=3) -> str:
149 | """Word wrap title string."""
150 | string = strclean(string)
151 | if tlen(string) <= width:
152 | return string
153 | title_lines = textwrap.wrap(
154 | string, width, max_lines=max_lines,
155 | expand_tabs=False, replace_whitespace=True,
156 | break_long_words=True, break_on_hyphens=True, drop_whitespace=True
157 | )
158 | out_str = ""
159 | cline = 0
160 | for line in title_lines:
161 | cline += 1
162 | if len(line) == width:
163 | out_str += line
164 | else:
165 | out_str += f"{line}\n"
166 | # limit string len
167 | if len(out_str) > max_len:
168 | out_str = out_str[:max_len]
169 | # add mask only if length of last line met condition
170 | if len(title_lines[-1]) < width // 2:
171 | mask = " " # mask to differentiate from underlying text
172 | out_str = out_str[:-len(mask) + 1] + mask
173 | return out_str
174 |
175 |
176 | def sdate(isodate: str) -> str:
177 | """Take iso date str and return shorten date str."""
178 | # remove Z character from default twitch date (2021-12-08T11:43:43Z)
179 | idate = isodate.replace("Z", "")
180 | vdate = datetime.fromisoformat(idate).isoformat(' ', 'minutes')
181 | today = datetime.today().isoformat(' ', 'minutes')
182 | current_year = today[:4]
183 | if current_year not in vdate:
184 | pattern = vdate[-6:] # cut off only time
185 | else:
186 | sm = SequenceMatcher(None, vdate, today)
187 | match = sm.find_longest_match(0, len(vdate), 0, len(today))
188 | # longest common string between two
189 | pattern = vdate[match.a: match.a + match.size - 1]
190 | # remove pattern, cut leading '-' and strip whitespaces
191 | sdate = str(vdate).replace(pattern, "").strip("-").strip()
192 | return sdate
193 |
194 |
195 | def duration(duration: str, simple=False, noprocessing=False) -> str:
196 | """Take twitch duration str and return duration with : as separators.
197 | Can optionally return a str without processing or with simple str processing.
198 | """
199 | if noprocessing:
200 | return duration
201 | if simple:
202 | # downside is very variable length of str and subjective ugliness of result.
203 | return duration.replace("h", ":").replace("m", ":").replace("s", ":").strip(":")
204 | # Don't see any real benefit of the following code over a silly simple one-liner :)
205 | # Result of the following algorithm are prettier, but also produces longer str.
206 | if "h" in duration:
207 | # extract hours from string
208 | H, _, _ = duration.partition("h")
209 | H = int(H.strip())
210 | # fix: if hours > 23 => put hours as simple str into format
211 | if H > 23:
212 | ifmt = f"{H}h%Mm%Ss"
213 | ofmt = f"{H}:%M:%S"
214 | else:
215 | ifmt = "%Hh%Mm%Ss"
216 | ofmt = "%H:%M:%S"
217 | elif "m" in duration:
218 | ifmt = "%Mm%Ss"
219 | ofmt = "%M:%S"
220 | else:
221 | return duration
222 | idur = datetime.strptime(duration, ifmt)
223 | odur = str(idur.strftime(ofmt))
224 | return odur
225 |
--------------------------------------------------------------------------------