├── .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 | The famous HACKERMANS twitch.tv emote 7 | 8 | 9 | ![PyPI - Status](https://img.shields.io/pypi/status/twitchez?style=flat-square) 10 | ![PyPI](https://img.shields.io/pypi/v/twitchez?style=flat-square) 11 | ![PyPI - License](https://img.shields.io/pypi/l/twitchez?style=flat-square) 12 | 13 | ![PyPI - Downloads](https://img.shields.io/pypi/dm/twitchez?style=flat-square) 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 | --------------------------------------------------------------------------------