├── .github ├── FUNDING.yml └── workflows │ └── build.yml ├── LICENSE ├── README.md ├── pyproject.toml └── src └── cast_control ├── __init__.py ├── __main__.py ├── adapter.py ├── app ├── __init__.py ├── cli.py ├── daemon.py ├── run.py └── state.py ├── assets ├── icon │ ├── authors.yml │ ├── cc-black.svg │ ├── cc-template.svg │ ├── cc-white.svg │ └── licenses.yml ├── mpris_plasma.png ├── mpris_widget.png └── template.desktop ├── base.py ├── device ├── __init__.py ├── base.py ├── device.py ├── listeners.py └── wrapper.py ├── protocols.py └── py.typed /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [alexdelorenzo] 4 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*.*.*" 7 | 8 | jobs: 9 | build-pkg: 10 | runs-on: ubuntu-20.04 11 | 12 | steps: 13 | - name: Checkout code 14 | uses: actions/checkout@v4 15 | 16 | - name: Set up Python 17 | uses: actions/setup-python@v5 18 | with: 19 | python-version: '3.12' 20 | 21 | - name: Install deps 22 | run: | 23 | cat /etc/apt/sources.list 24 | sudo sed -i 's/# deb-src/deb-src/' /etc/apt/sources.list 25 | sudo apt update 26 | sudo apt install python-gi-dev build-essential cmake gobject-introspection 27 | sudo apt build-dep python-gi-dev 28 | 29 | - name: Install the latest version of Rye 30 | uses: eifinger/setup-rye@v4 31 | 32 | - name: Sync Rye 33 | run: | 34 | rye sync 35 | 36 | - name: Create build 37 | run: | 38 | rye build 39 | 40 | - name: Create GitHub Release 41 | uses: softprops/action-gh-release@v2 42 | if: startsWith(github.ref, 'refs/tags/') 43 | 44 | with: 45 | body: "*To be updated*" 46 | 47 | files: | 48 | dist/*.tar.gz 49 | dist/*.whl 50 | 51 | - name: Upload Release Build 52 | uses: actions/upload-artifact@v4 53 | with: 54 | name: dist 55 | path: dist/* 56 | 57 | publish-pypi: 58 | runs-on: ubuntu-20.04 59 | 60 | needs: build-pkg 61 | 62 | permissions: 63 | id-token: write 64 | 65 | environment: 66 | name: pypi 67 | url: https://pypi.org/p/cast_control 68 | 69 | steps: 70 | - name: Download artifact 71 | uses: actions/download-artifact@v3 72 | 73 | - name: Publish to PyPI 74 | if: startsWith(github.ref, 'refs/tags/') 75 | uses: pypa/gh-action-pypi-publish@release/v1 76 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2021 Alex DeLorenzo. 2 | 3 | This project is distributed under the terms of the AGPL-3.0. 4 | If you'd like to use this project with a different license, please get in touch. 5 | 6 | GNU AFFERO GENERAL PUBLIC LICENSE 7 | Version 3, 19 November 2007 8 | 9 | Copyright (C) 2007 Free Software Foundation, Inc. 10 | Everyone is permitted to copy and distribute verbatim copies 11 | of this license document, but changing it is not allowed. 12 | 13 | Preamble 14 | 15 | The GNU Affero General Public License is a free, copyleft license for 16 | software and other kinds of works, specifically designed to ensure 17 | cooperation with the community in the case of network server software. 18 | 19 | The licenses for most software and other practical works are designed 20 | to take away your freedom to share and change the works. By contrast, 21 | our General Public Licenses are intended to guarantee your freedom to 22 | share and change all versions of a program--to make sure it remains free 23 | software for all its users. 24 | 25 | When we speak of free software, we are referring to freedom, not 26 | price. Our General Public Licenses are designed to make sure that you 27 | have the freedom to distribute copies of free software (and charge for 28 | them if you wish), that you receive source code or can get it if you 29 | want it, that you can change the software or use pieces of it in new 30 | free programs, and that you know you can do these things. 31 | 32 | Developers that use our General Public Licenses protect your rights 33 | with two steps: (1) assert copyright on the software, and (2) offer 34 | you this License which gives you legal permission to copy, distribute 35 | and/or modify the software. 36 | 37 | A secondary benefit of defending all users' freedom is that 38 | improvements made in alternate versions of the program, if they 39 | receive widespread use, become available for other developers to 40 | incorporate. Many developers of free software are heartened and 41 | encouraged by the resulting cooperation. However, in the case of 42 | software used on network servers, this result may fail to come about. 43 | The GNU General Public License permits making a modified version and 44 | letting the public access it on a server without ever releasing its 45 | source code to the public. 46 | 47 | The GNU Affero General Public License is designed specifically to 48 | ensure that, in such cases, the modified source code becomes available 49 | to the community. It requires the operator of a network server to 50 | provide the source code of the modified version running there to the 51 | users of that server. Therefore, public use of a modified version, on 52 | a publicly accessible server, gives the public access to the source 53 | code of the modified version. 54 | 55 | An older license, called the Affero General Public License and 56 | published by Affero, was designed to accomplish similar goals. This is 57 | a different license, not a version of the Affero GPL, but Affero has 58 | released a new version of the Affero GPL which permits relicensing under 59 | this license. 60 | 61 | The precise terms and conditions for copying, distribution and 62 | modification follow. 63 | 64 | TERMS AND CONDITIONS 65 | 66 | 0. Definitions. 67 | 68 | "This License" refers to version 3 of the GNU Affero General Public License. 69 | 70 | "Copyright" also means copyright-like laws that apply to other kinds of 71 | works, such as semiconductor masks. 72 | 73 | "The Program" refers to any copyrightable work licensed under this 74 | License. Each licensee is addressed as "you". "Licensees" and 75 | "recipients" may be individuals or organizations. 76 | 77 | To "modify" a work means to copy from or adapt all or part of the work 78 | in a fashion requiring copyright permission, other than the making of an 79 | exact copy. The resulting work is called a "modified version" of the 80 | earlier work or a work "based on" the earlier work. 81 | 82 | A "covered work" means either the unmodified Program or a work based 83 | on the Program. 84 | 85 | To "propagate" a work means to do anything with it that, without 86 | permission, would make you directly or secondarily liable for 87 | infringement under applicable copyright law, except executing it on a 88 | computer or modifying a private copy. Propagation includes copying, 89 | distribution (with or without modification), making available to the 90 | public, and in some countries other activities as well. 91 | 92 | To "convey" a work means any kind of propagation that enables other 93 | parties to make or receive copies. Mere interaction with a user through 94 | a computer network, with no transfer of a copy, is not conveying. 95 | 96 | An interactive user interface displays "Appropriate Legal Notices" 97 | to the extent that it includes a convenient and prominently visible 98 | feature that (1) displays an appropriate copyright notice, and (2) 99 | tells the user that there is no warranty for the work (except to the 100 | extent that warranties are provided), that licensees may convey the 101 | work under this License, and how to view a copy of this License. If 102 | the interface presents a list of user commands or options, such as a 103 | menu, a prominent item in the list meets this criterion. 104 | 105 | 1. Source Code. 106 | 107 | The "source code" for a work means the preferred form of the work 108 | for making modifications to it. "Object code" means any non-source 109 | form of a work. 110 | 111 | A "Standard Interface" means an interface that either is an official 112 | standard defined by a recognized standards body, or, in the case of 113 | interfaces specified for a particular programming language, one that 114 | is widely used among developers working in that language. 115 | 116 | The "System Libraries" of an executable work include anything, other 117 | than the work as a whole, that (a) is included in the normal form of 118 | packaging a Major Component, but which is not part of that Major 119 | Component, and (b) serves only to enable use of the work with that 120 | Major Component, or to implement a Standard Interface for which an 121 | implementation is available to the public in source code form. A 122 | "Major Component", in this context, means a major essential component 123 | (kernel, window system, and so on) of the specific operating system 124 | (if any) on which the executable work runs, or a compiler used to 125 | produce the work, or an object code interpreter used to run it. 126 | 127 | The "Corresponding Source" for a work in object code form means all 128 | the source code needed to generate, install, and (for an executable 129 | work) run the object code and to modify the work, including scripts to 130 | control those activities. However, it does not include the work's 131 | System Libraries, or general-purpose tools or generally available free 132 | programs which are used unmodified in performing those activities but 133 | which are not part of the work. For example, Corresponding Source 134 | includes interface definition files associated with source files for 135 | the work, and the source code for shared libraries and dynamically 136 | linked subprograms that the work is specifically designed to require, 137 | such as by intimate data communication or control flow between those 138 | subprograms and other parts of the work. 139 | 140 | The Corresponding Source need not include anything that users 141 | can regenerate automatically from other parts of the Corresponding 142 | Source. 143 | 144 | The Corresponding Source for a work in source code form is that 145 | same work. 146 | 147 | 2. Basic Permissions. 148 | 149 | All rights granted under this License are granted for the term of 150 | copyright on the Program, and are irrevocable provided the stated 151 | conditions are met. This License explicitly affirms your unlimited 152 | permission to run the unmodified Program. The output from running a 153 | covered work is covered by this License only if the output, given its 154 | content, constitutes a covered work. This License acknowledges your 155 | rights of fair use or other equivalent, as provided by copyright law. 156 | 157 | You may make, run and propagate covered works that you do not 158 | convey, without conditions so long as your license otherwise remains 159 | in force. You may convey covered works to others for the sole purpose 160 | of having them make modifications exclusively for you, or provide you 161 | with facilities for running those works, provided that you comply with 162 | the terms of this License in conveying all material for which you do 163 | not control copyright. Those thus making or running the covered works 164 | for you must do so exclusively on your behalf, under your direction 165 | and control, on terms that prohibit them from making any copies of 166 | your copyrighted material outside their relationship with you. 167 | 168 | Conveying under any other circumstances is permitted solely under 169 | the conditions stated below. Sublicensing is not allowed; section 10 170 | makes it unnecessary. 171 | 172 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 173 | 174 | No covered work shall be deemed part of an effective technological 175 | measure under any applicable law fulfilling obligations under article 176 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or 177 | similar laws prohibiting or restricting circumvention of such 178 | measures. 179 | 180 | When you convey a covered work, you waive any legal power to forbid 181 | circumvention of technological measures to the extent such circumvention 182 | is effected by exercising rights under this License with respect to 183 | the covered work, and you disclaim any intention to limit operation or 184 | modification of the work as a means of enforcing, against the work's 185 | users, your or third parties' legal rights to forbid circumvention of 186 | technological measures. 187 | 188 | 4. Conveying Verbatim Copies. 189 | 190 | You may convey verbatim copies of the Program's source code as you 191 | receive it, in any medium, provided that you conspicuously and 192 | appropriately publish on each copy an appropriate copyright notice; 193 | keep intact all notices stating that this License and any 194 | non-permissive terms added in accord with section 7 apply to the code; 195 | keep intact all notices of the absence of any warranty; and give all 196 | recipients a copy of this License along with the Program. 197 | 198 | You may charge any price or no price for each copy that you convey, 199 | and you may offer support or warranty protection for a fee. 200 | 201 | 5. Conveying Modified Source Versions. 202 | 203 | You may convey a work based on the Program, or the modifications to 204 | produce it from the Program, in the form of source code under the 205 | terms of section 4, provided that you also meet all of these conditions: 206 | 207 | a) The work must carry prominent notices stating that you modified 208 | it, and giving a relevant date. 209 | 210 | b) The work must carry prominent notices stating that it is 211 | released under this License and any conditions added under section 212 | 7. This requirement modifies the requirement in section 4 to 213 | "keep intact all notices". 214 | 215 | c) You must license the entire work, as a whole, under this 216 | License to anyone who comes into possession of a copy. This 217 | License will therefore apply, along with any applicable section 7 218 | additional terms, to the whole of the work, and all its parts, 219 | regardless of how they are packaged. This License gives no 220 | permission to license the work in any other way, but it does not 221 | invalidate such permission if you have separately received it. 222 | 223 | d) If the work has interactive user interfaces, each must display 224 | Appropriate Legal Notices; however, if the Program has interactive 225 | interfaces that do not display Appropriate Legal Notices, your 226 | work need not make them do so. 227 | 228 | A compilation of a covered work with other separate and independent 229 | works, which are not by their nature extensions of the covered work, 230 | and which are not combined with it such as to form a larger program, 231 | in or on a volume of a storage or distribution medium, is called an 232 | "aggregate" if the compilation and its resulting copyright are not 233 | used to limit the access or legal rights of the compilation's users 234 | beyond what the individual works permit. Inclusion of a covered work 235 | in an aggregate does not cause this License to apply to the other 236 | parts of the aggregate. 237 | 238 | 6. Conveying Non-Source Forms. 239 | 240 | You may convey a covered work in object code form under the terms 241 | of sections 4 and 5, provided that you also convey the 242 | machine-readable Corresponding Source under the terms of this License, 243 | in one of these ways: 244 | 245 | a) Convey the object code in, or embodied in, a physical product 246 | (including a physical distribution medium), accompanied by the 247 | Corresponding Source fixed on a durable physical medium 248 | customarily used for software interchange. 249 | 250 | b) Convey the object code in, or embodied in, a physical product 251 | (including a physical distribution medium), accompanied by a 252 | written offer, valid for at least three years and valid for as 253 | long as you offer spare parts or customer support for that product 254 | model, to give anyone who possesses the object code either (1) a 255 | copy of the Corresponding Source for all the software in the 256 | product that is covered by this License, on a durable physical 257 | medium customarily used for software interchange, for a price no 258 | more than your reasonable cost of physically performing this 259 | conveying of source, or (2) access to copy the 260 | Corresponding Source from a network server at no charge. 261 | 262 | c) Convey individual copies of the object code with a copy of the 263 | written offer to provide the Corresponding Source. This 264 | alternative is allowed only occasionally and noncommercially, and 265 | only if you received the object code with such an offer, in accord 266 | with subsection 6b. 267 | 268 | d) Convey the object code by offering access from a designated 269 | place (gratis or for a charge), and offer equivalent access to the 270 | Corresponding Source in the same way through the same place at no 271 | further charge. You need not require recipients to copy the 272 | Corresponding Source along with the object code. If the place to 273 | copy the object code is a network server, the Corresponding Source 274 | may be on a different server (operated by you or a third party) 275 | that supports equivalent copying facilities, provided you maintain 276 | clear directions next to the object code saying where to find the 277 | Corresponding Source. Regardless of what server hosts the 278 | Corresponding Source, you remain obligated to ensure that it is 279 | available for as long as needed to satisfy these requirements. 280 | 281 | e) Convey the object code using peer-to-peer transmission, provided 282 | you inform other peers where the object code and Corresponding 283 | Source of the work are being offered to the general public at no 284 | charge under subsection 6d. 285 | 286 | A separable portion of the object code, whose source code is excluded 287 | from the Corresponding Source as a System Library, need not be 288 | included in conveying the object code work. 289 | 290 | A "User Product" is either (1) a "consumer product", which means any 291 | tangible personal property which is normally used for personal, family, 292 | or household purposes, or (2) anything designed or sold for incorporation 293 | into a dwelling. In determining whether a product is a consumer product, 294 | doubtful cases shall be resolved in favor of coverage. For a particular 295 | product received by a particular user, "normally used" refers to a 296 | typical or common use of that class of product, regardless of the status 297 | of the particular user or of the way in which the particular user 298 | actually uses, or expects or is expected to use, the product. A product 299 | is a consumer product regardless of whether the product has substantial 300 | commercial, industrial or non-consumer uses, unless such uses represent 301 | the only significant mode of use of the product. 302 | 303 | "Installation Information" for a User Product means any methods, 304 | procedures, authorization keys, or other information required to install 305 | and execute modified versions of a covered work in that User Product from 306 | a modified version of its Corresponding Source. The information must 307 | suffice to ensure that the continued functioning of the modified object 308 | code is in no case prevented or interfered with solely because 309 | modification has been made. 310 | 311 | If you convey an object code work under this section in, or with, or 312 | specifically for use in, a User Product, and the conveying occurs as 313 | part of a transaction in which the right of possession and use of the 314 | User Product is transferred to the recipient in perpetuity or for a 315 | fixed term (regardless of how the transaction is characterized), the 316 | Corresponding Source conveyed under this section must be accompanied 317 | by the Installation Information. But this requirement does not apply 318 | if neither you nor any third party retains the ability to install 319 | modified object code on the User Product (for example, the work has 320 | been installed in ROM). 321 | 322 | The requirement to provide Installation Information does not include a 323 | requirement to continue to provide support service, warranty, or updates 324 | for a work that has been modified or installed by the recipient, or for 325 | the User Product in which it has been modified or installed. Access to a 326 | network may be denied when the modification itself materially and 327 | adversely affects the operation of the network or violates the rules and 328 | protocols for communication across the network. 329 | 330 | Corresponding Source conveyed, and Installation Information provided, 331 | in accord with this section must be in a format that is publicly 332 | documented (and with an implementation available to the public in 333 | source code form), and must require no special password or key for 334 | unpacking, reading or copying. 335 | 336 | 7. Additional Terms. 337 | 338 | "Additional permissions" are terms that supplement the terms of this 339 | License by making exceptions from one or more of its conditions. 340 | Additional permissions that are applicable to the entire Program shall 341 | be treated as though they were included in this License, to the extent 342 | that they are valid under applicable law. If additional permissions 343 | apply only to part of the Program, that part may be used separately 344 | under those permissions, but the entire Program remains governed by 345 | this License without regard to the additional permissions. 346 | 347 | When you convey a copy of a covered work, you may at your option 348 | remove any additional permissions from that copy, or from any part of 349 | it. (Additional permissions may be written to require their own 350 | removal in certain cases when you modify the work.) You may place 351 | additional permissions on material, added by you to a covered work, 352 | for which you have or can give appropriate copyright permission. 353 | 354 | Notwithstanding any other provision of this License, for material you 355 | add to a covered work, you may (if authorized by the copyright holders of 356 | that material) supplement the terms of this License with terms: 357 | 358 | a) Disclaiming warranty or limiting liability differently from the 359 | terms of sections 15 and 16 of this License; or 360 | 361 | b) Requiring preservation of specified reasonable legal notices or 362 | author attributions in that material or in the Appropriate Legal 363 | Notices displayed by works containing it; or 364 | 365 | c) Prohibiting misrepresentation of the origin of that material, or 366 | requiring that modified versions of such material be marked in 367 | reasonable ways as different from the original version; or 368 | 369 | d) Limiting the use for publicity purposes of names of licensors or 370 | authors of the material; or 371 | 372 | e) Declining to grant rights under trademark law for use of some 373 | trade names, trademarks, or service marks; or 374 | 375 | f) Requiring indemnification of licensors and authors of that 376 | material by anyone who conveys the material (or modified versions of 377 | it) with contractual assumptions of liability to the recipient, for 378 | any liability that these contractual assumptions directly impose on 379 | those licensors and authors. 380 | 381 | All other non-permissive additional terms are considered "further 382 | restrictions" within the meaning of section 10. If the Program as you 383 | received it, or any part of it, contains a notice stating that it is 384 | governed by this License along with a term that is a further 385 | restriction, you may remove that term. If a license document contains 386 | a further restriction but permits relicensing or conveying under this 387 | License, you may add to a covered work material governed by the terms 388 | of that license document, provided that the further restriction does 389 | not survive such relicensing or conveying. 390 | 391 | If you add terms to a covered work in accord with this section, you 392 | must place, in the relevant source files, a statement of the 393 | additional terms that apply to those files, or a notice indicating 394 | where to find the applicable terms. 395 | 396 | Additional terms, permissive or non-permissive, may be stated in the 397 | form of a separately written license, or stated as exceptions; 398 | the above requirements apply either way. 399 | 400 | 8. Termination. 401 | 402 | You may not propagate or modify a covered work except as expressly 403 | provided under this License. Any attempt otherwise to propagate or 404 | modify it is void, and will automatically terminate your rights under 405 | this License (including any patent licenses granted under the third 406 | paragraph of section 11). 407 | 408 | However, if you cease all violation of this License, then your 409 | license from a particular copyright holder is reinstated (a) 410 | provisionally, unless and until the copyright holder explicitly and 411 | finally terminates your license, and (b) permanently, if the copyright 412 | holder fails to notify you of the violation by some reasonable means 413 | prior to 60 days after the cessation. 414 | 415 | Moreover, your license from a particular copyright holder is 416 | reinstated permanently if the copyright holder notifies you of the 417 | violation by some reasonable means, this is the first time you have 418 | received notice of violation of this License (for any work) from that 419 | copyright holder, and you cure the violation prior to 30 days after 420 | your receipt of the notice. 421 | 422 | Termination of your rights under this section does not terminate the 423 | licenses of parties who have received copies or rights from you under 424 | this License. If your rights have been terminated and not permanently 425 | reinstated, you do not qualify to receive new licenses for the same 426 | material under section 10. 427 | 428 | 9. Acceptance Not Required for Having Copies. 429 | 430 | You are not required to accept this License in order to receive or 431 | run a copy of the Program. Ancillary propagation of a covered work 432 | occurring solely as a consequence of using peer-to-peer transmission 433 | to receive a copy likewise does not require acceptance. However, 434 | nothing other than this License grants you permission to propagate or 435 | modify any covered work. These actions infringe copyright if you do 436 | not accept this License. Therefore, by modifying or propagating a 437 | covered work, you indicate your acceptance of this License to do so. 438 | 439 | 10. Automatic Licensing of Downstream Recipients. 440 | 441 | Each time you convey a covered work, the recipient automatically 442 | receives a license from the original licensors, to run, modify and 443 | propagate that work, subject to this License. You are not responsible 444 | for enforcing compliance by third parties with this License. 445 | 446 | An "entity transaction" is a transaction transferring control of an 447 | organization, or substantially all assets of one, or subdividing an 448 | organization, or merging organizations. If propagation of a covered 449 | work results from an entity transaction, each party to that 450 | transaction who receives a copy of the work also receives whatever 451 | licenses to the work the party's predecessor in interest had or could 452 | give under the previous paragraph, plus a right to possession of the 453 | Corresponding Source of the work from the predecessor in interest, if 454 | the predecessor has it or can get it with reasonable efforts. 455 | 456 | You may not impose any further restrictions on the exercise of the 457 | rights granted or affirmed under this License. For example, you may 458 | not impose a license fee, royalty, or other charge for exercise of 459 | rights granted under this License, and you may not initiate litigation 460 | (including a cross-claim or counterclaim in a lawsuit) alleging that 461 | any patent claim is infringed by making, using, selling, offering for 462 | sale, or importing the Program or any portion of it. 463 | 464 | 11. Patents. 465 | 466 | A "contributor" is a copyright holder who authorizes use under this 467 | License of the Program or a work on which the Program is based. The 468 | work thus licensed is called the contributor's "contributor version". 469 | 470 | A contributor's "essential patent claims" are all patent claims 471 | owned or controlled by the contributor, whether already acquired or 472 | hereafter acquired, that would be infringed by some manner, permitted 473 | by this License, of making, using, or selling its contributor version, 474 | but do not include claims that would be infringed only as a 475 | consequence of further modification of the contributor version. For 476 | purposes of this definition, "control" includes the right to grant 477 | patent sublicenses in a manner consistent with the requirements of 478 | this License. 479 | 480 | Each contributor grants you a non-exclusive, worldwide, royalty-free 481 | patent license under the contributor's essential patent claims, to 482 | make, use, sell, offer for sale, import and otherwise run, modify and 483 | propagate the contents of its contributor version. 484 | 485 | In the following three paragraphs, a "patent license" is any express 486 | agreement or commitment, however denominated, not to enforce a patent 487 | (such as an express permission to practice a patent or covenant not to 488 | sue for patent infringement). To "grant" such a patent license to a 489 | party means to make such an agreement or commitment not to enforce a 490 | patent against the party. 491 | 492 | If you convey a covered work, knowingly relying on a patent license, 493 | and the Corresponding Source of the work is not available for anyone 494 | to copy, free of charge and under the terms of this License, through a 495 | publicly available network server or other readily accessible means, 496 | then you must either (1) cause the Corresponding Source to be so 497 | available, or (2) arrange to deprive yourself of the benefit of the 498 | patent license for this particular work, or (3) arrange, in a manner 499 | consistent with the requirements of this License, to extend the patent 500 | license to downstream recipients. "Knowingly relying" means you have 501 | actual knowledge that, but for the patent license, your conveying the 502 | covered work in a country, or your recipient's use of the covered work 503 | in a country, would infringe one or more identifiable patents in that 504 | country that you have reason to believe are valid. 505 | 506 | If, pursuant to or in connection with a single transaction or 507 | arrangement, you convey, or propagate by procuring conveyance of, a 508 | covered work, and grant a patent license to some of the parties 509 | receiving the covered work authorizing them to use, propagate, modify 510 | or convey a specific copy of the covered work, then the patent license 511 | you grant is automatically extended to all recipients of the covered 512 | work and works based on it. 513 | 514 | A patent license is "discriminatory" if it does not include within 515 | the scope of its coverage, prohibits the exercise of, or is 516 | conditioned on the non-exercise of one or more of the rights that are 517 | specifically granted under this License. You may not convey a covered 518 | work if you are a party to an arrangement with a third party that is 519 | in the business of distributing software, under which you make payment 520 | to the third party based on the extent of your activity of conveying 521 | the work, and under which the third party grants, to any of the 522 | parties who would receive the covered work from you, a discriminatory 523 | patent license (a) in connection with copies of the covered work 524 | conveyed by you (or copies made from those copies), or (b) primarily 525 | for and in connection with specific products or compilations that 526 | contain the covered work, unless you entered into that arrangement, 527 | or that patent license was granted, prior to 28 March 2007. 528 | 529 | Nothing in this License shall be construed as excluding or limiting 530 | any implied license or other defenses to infringement that may 531 | otherwise be available to you under applicable patent law. 532 | 533 | 12. No Surrender of Others' Freedom. 534 | 535 | If conditions are imposed on you (whether by court order, agreement or 536 | otherwise) that contradict the conditions of this License, they do not 537 | excuse you from the conditions of this License. If you cannot convey a 538 | covered work so as to satisfy simultaneously your obligations under this 539 | License and any other pertinent obligations, then as a consequence you may 540 | not convey it at all. For example, if you agree to terms that obligate you 541 | to collect a royalty for further conveying from those to whom you convey 542 | the Program, the only way you could satisfy both those terms and this 543 | License would be to refrain entirely from conveying the Program. 544 | 545 | 13. Remote Network Interaction; Use with the GNU General Public License. 546 | 547 | Notwithstanding any other provision of this License, if you modify the 548 | Program, your modified version must prominently offer all users 549 | interacting with it remotely through a computer network (if your version 550 | supports such interaction) an opportunity to receive the Corresponding 551 | Source of your version by providing access to the Corresponding Source 552 | from a network server at no charge, through some standard or customary 553 | means of facilitating copying of software. This Corresponding Source 554 | shall include the Corresponding Source for any work covered by version 3 555 | of the GNU General Public License that is incorporated pursuant to the 556 | following paragraph. 557 | 558 | Notwithstanding any other provision of this License, you have 559 | permission to link or combine any covered work with a work licensed 560 | under version 3 of the GNU General Public License into a single 561 | combined work, and to convey the resulting work. The terms of this 562 | License will continue to apply to the part which is the covered work, 563 | but the work with which it is combined will remain governed by version 564 | 3 of the GNU General Public License. 565 | 566 | 14. Revised Versions of this License. 567 | 568 | The Free Software Foundation may publish revised and/or new versions of 569 | the GNU Affero General Public License from time to time. Such new versions 570 | will be similar in spirit to the present version, but may differ in detail to 571 | address new problems or concerns. 572 | 573 | Each version is given a distinguishing version number. If the 574 | Program specifies that a certain numbered version of the GNU Affero General 575 | Public License "or any later version" applies to it, you have the 576 | option of following the terms and conditions either of that numbered 577 | version or of any later version published by the Free Software 578 | Foundation. If the Program does not specify a version number of the 579 | GNU Affero General Public License, you may choose any version ever published 580 | by the Free Software Foundation. 581 | 582 | If the Program specifies that a proxy can decide which future 583 | versions of the GNU Affero General Public License can be used, that proxy's 584 | public statement of acceptance of a version permanently authorizes you 585 | to choose that version for the Program. 586 | 587 | Later license versions may give you additional or different 588 | permissions. However, no additional obligations are imposed on any 589 | author or copyright holder as a result of your choosing to follow a 590 | later version. 591 | 592 | 15. Disclaimer of Warranty. 593 | 594 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 595 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 596 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY 597 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, 598 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 599 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM 600 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF 601 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 602 | 603 | 16. Limitation of Liability. 604 | 605 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 606 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS 607 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY 608 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE 609 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF 610 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD 611 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), 612 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF 613 | SUCH DAMAGES. 614 | 615 | 17. Interpretation of Sections 15 and 16. 616 | 617 | If the disclaimer of warranty and limitation of liability provided 618 | above cannot be given local legal effect according to their terms, 619 | reviewing courts shall apply local law that most closely approximates 620 | an absolute waiver of all civil liability in connection with the 621 | Program, unless a warranty or assumption of liability accompanies a 622 | copy of the Program in return for a fee. 623 | 624 | END OF TERMS AND CONDITIONS 625 | 626 | How to Apply These Terms to Your New Programs 627 | 628 | If you develop a new program, and you want it to be of the greatest 629 | possible use to the public, the best way to achieve this is to make it 630 | free software which everyone can redistribute and change under these terms. 631 | 632 | To do so, attach the following notices to the program. It is safest 633 | to attach them to the start of each source file to most effectively 634 | state the exclusion of warranty; and each file should have at least 635 | the "copyright" line and a pointer to where the full notice is found. 636 | 637 | 638 | Copyright (C) 639 | 640 | This program is free software: you can redistribute it and/or modify 641 | it under the terms of the GNU Affero General Public License as published 642 | by the Free Software Foundation, either version 3 of the License, or 643 | (at your option) any later version. 644 | 645 | This program is distributed in the hope that it will be useful, 646 | but WITHOUT ANY WARRANTY; without even the implied warranty of 647 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 648 | GNU Affero General Public License for more details. 649 | 650 | You should have received a copy of the GNU Affero General Public License 651 | along with this program. If not, see . 652 | 653 | Also add information on how to contact you by electronic and paper mail. 654 | 655 | If your software can interact with users remotely through a computer 656 | network, you should also make sure that it provides a way for users to 657 | get its source. For example, if your program is a web application, its 658 | interface could display a "Source" link that leads users to an archive 659 | of the code. There are many ways you could offer source, and different 660 | solutions will be better for different programs; see section 13 for the 661 | specific requirements. 662 | 663 | You should also get your employer (if you work as a programmer) or school, 664 | if any, to sign a "copyright disclaimer" for the program, if necessary. 665 | For more information on this, and how to apply and follow the GNU AGPL, see 666 | . 667 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 |

3 | 4 | ✔️ Check out 5 | Cast Control Desktop 📺 6 | beta 👍 7 | 8 |

9 |

10 | 11 | 12 | # 📺 Control Chromecasts from Linux 13 | 14 | `cast_control` is [a daemon](https://en.wikipedia.org/wiki/Daemon_(computing)) utility that allows you to control media 15 | playback on casting devices from the Linux desktop. 16 | 17 | While this service runs, it collects data about the media and apps playing on your casting devices and displays it on your computer. 18 | 19 | ### Integrations 20 | 21 | `cast_control` controls Chromecasts and casting devices via D-Bus 22 | and [MPRIS media player controls](https://specifications.freedesktop.org/mpris-spec/2.2/). 23 | 24 | MPRIS is the standard media player interface on Linux desktops. 25 | 26 | MPRIS integration is [enabled by default](https://github.com/KDE/plasma-workspace/tree/master/applets/mediacontroller) 27 | in Plasma Desktop, and, along with GNOME's volume control 28 | widget, [there are widgets for GNOME](https://extensions.gnome.org/extension/1379/mpris-indicator-button/), too. [ 29 | `playerctl` provides a CLI](https://github.com/altdesktop/playerctl) for controlling media players through MPRIS. 30 | 31 | Check out [`▶️ mpris_server`](https://github.com/alexdelorenzo/mpris_server) if you want to integrate MPRIS support into 32 | your media player. 33 | 34 | ## Screenshots 35 | 36 | Controlling a Chromecast via Plasma Desktop's Media Player widget: 37 | 38 | 39 | 40 | ## Features 41 | 42 | * [x] Control music and video playback 43 | * [x] Control app playback 44 | * [x] View playback information in real-time 45 | * [x] Display thumbnail and title 46 | * [x] Display playback position and media length 47 | * [x] Seek forward and backward 48 | * [x] Play, pause, and stop playback 49 | * [x] Volume up and down 50 | * [x] Play next and previous 51 | * [x] Quit casted app 52 | * [x] Open media from D-Bus 53 | * [x] Play YouTube videos 54 | * [ ] Playlist integration 55 | 56 | ## Installation 57 | 58 | ### Requirements 59 | 60 | - Python >= 3.12 61 | - Linux / *BSD / [macOS](https://github.com/zbentley/dbus-osx-examples) 62 | - [D-Bus](https://www.freedesktop.org/wiki/Software/dbus/) 63 | - [PyGObject](https://pypi.org/project/PyGObject/) 64 | - See `project.dependencies` in `pyproject.toml` 65 | 66 | ### Build requirements 67 | 68 | - Python >=3.12 69 | - [Rye](https://rye.astral.sh/) 70 | - `hatchling` 71 | - All installation requirements 72 | 73 | 74 | #### Installing PyGObject 75 | 76 | On Debian-derived distributions like Ubuntu, install `python3-gi` with `apt`. 77 | 78 | On Arch, you'll want to install `python-gobject` and `gobject-introspection`, or install 79 | `cast_control` [directly from the AUR](https://aur.archlinux.org/packages/cast_control/). 80 | 81 | On macOS, install [`pygobject3`](https://formulae.brew.sh/formula/pygobject3) via `brew`. 82 | 83 | Use `pip` to install `PyGObject>=3.34.0` if there are no installation candidates available in your vendor's package 84 | repositories. 85 | 86 | ### PyPI 87 | 88 | ```bash 89 | $ python3 -m pip install cast_control 90 | ``` 91 | 92 | You'll get a `cast_control` executable added to your `$PATH`. 93 | 94 | ### GitHub 95 | 96 | Check out [the releases page on GitHub](https://github.com/alexdelorenzo/cast_control/releases) for stable releases. 97 | 98 | If you'd like to use the development branch, clone the repository. 99 | 100 | Once you have a source copy, run `python3 -m pip install -r requirements.txt`, followed by `python3 setup.py install`. 101 | 102 | You'll get a `cast_control` executable added to your `$PATH`. 103 | 104 | ### AUR 105 | 106 | If you're on Arch, you can install 107 | `cast_control` [directly from the AUR](https://aur.archlinux.org/packages/cast_control/). 108 | Thanks, [@yochananmarqos](https://github.com/yochananmarqos)! 109 | 110 | ```bash 111 | $ yay -S cast_control 112 | ``` 113 | 114 | ### Upgrades 115 | 116 | Stable releases are uploaded to PyPI. You can upgrade your `cast_control` installation like so: 117 | 118 | ```bash 119 | $ python3 -m pip --upgrade cast_control 120 | ``` 121 | 122 | See the [releases page](https://github.com/alexdelorenzo/cast_control/releases) on GitHub. 123 | 124 | ## Usage 125 | 126 | You'll need to make sure that your computer can make network connections with your casting devices. It also helps to 127 | know the names of the devices in advance. 128 | 129 | ### Launch 130 | 131 | Installing the package via PyPI, GitHub or the AUR will add `cast_control` to your `pip` executables path: 132 | 133 | ```bash 134 | $ which cast_control 135 | ~/.local/bin/cast_control 136 | ``` 137 | 138 | If you have your `pip` executables path added to your shell's `$PATH`, you can launch `cast_control` like so: 139 | 140 | ```bash 141 | $ cast_control --help 142 | ``` 143 | 144 | Or, using the short name launcher `castctl`: 145 | 146 | ```bash 147 | $ castctl --help 148 | ``` 149 | 150 | You can also launch `cast_control` via its Python module. This can be useful if your `$PATH` doesn't point to your `pip` 151 | executables. 152 | 153 | ```bash 154 | $ python3 -m cast_control --help 155 | ``` 156 | 157 | ### Help 158 | 159 | #### Shell completion 160 | 161 | To enable Bash completion for `cast_control`, add the following to your `~/.bashrc`: 162 | 163 | ```bash 164 | eval "$(_CAST_CONTROL_COMPLETE=bash_source cast_control)" 165 | ``` 166 | 167 | For the `zsh` and `fish` shells, check 168 | out [the documentation here](https://click.palletsprojects.com/en/8.0.x/shell-completion/#enabling-completion). 169 | 170 | #### Help text 171 | 172 | ```bash 173 | $ cast_control --help 174 | Usage: cast_control [OPTIONS] COMMAND [ARGS]... 175 | 176 | Control casting devices via Linux media controls and desktops. 177 | 178 | This daemon connects your casting device directly to the D-Bus media player 179 | interface. 180 | 181 | See https://github.com/alexdelorenzo/cast_control for more information. 182 | 183 | Options: 184 | -L, --license Show license and copyright information. 185 | -V, --version Show version information. 186 | --help Show this message and exit. 187 | 188 | Commands: 189 | connect Connect to the device and run the service in the foreground. 190 | service Connect, disconnect or reconnect the background service to or... 191 | ``` 192 | 193 | ##### `connect` command 194 | 195 | ```bash 196 | $ cast_control connect --help 197 | Usage: cast_control connect [OPTIONS] 198 | 199 | Connect to the device and run the service in the foreground. 200 | 201 | Options: 202 | -n, --name TEXT Connect to a device via its name, otherwise control 203 | the first device found. 204 | -h, --host TEXT Connect to a device via its hostname or IP address, 205 | otherwise control the first device found. 206 | -u, --uuid TEXT Connect to a device via its UUID, otherwise control 207 | the first device found. 208 | -w, --wait FLOAT Seconds to wait between trying to make initial 209 | successful connections to a device. 210 | -r, --retry-wait FLOAT Seconds to wait between reconnection attempts if a 211 | successful connection is interrupted. [default: 212 | 5.0] 213 | -i, --icon Use a lighter icon instead of the dark icon. The 214 | lighter icon goes well with dark themes. [default: 215 | False] 216 | -l, --log-level TEXT Set the debugging log level. [default: WARN] 217 | --help Show this message and exit. 218 | ``` 219 | 220 | ##### `service` command 221 | 222 | ```bash 223 | $ cast_control service --help 224 | Usage: cast_control service [OPTIONS] COMMAND [ARGS]... 225 | 226 | Connect, disconnect or reconnect the background service to or from your 227 | device. 228 | 229 | Options: 230 | --help Show this message and exit. 231 | 232 | Commands: 233 | connect Connect the background service to the device. 234 | disconnect Disconnect the background service from the device. 235 | reconnect Reconnect the background service to the device. 236 | log Show the service log. 237 | ``` 238 | 239 | ###### `service connect` command 240 | 241 | ```bash 242 | $ cast_control service connect --help 243 | Usage: cast_control service connect [OPTIONS] 244 | 245 | Connect the background service to the device. 246 | 247 | Options: 248 | -n, --name TEXT Connect to a device via its name, otherwise control 249 | the first device found. 250 | -h, --host TEXT Connect to a device via its hostname or IP address, 251 | otherwise control the first device found. 252 | -u, --uuid TEXT Connect to a device via its UUID, otherwise control 253 | the first device found. 254 | -w, --wait FLOAT Seconds to wait between trying to make initial 255 | successful connections to a device. 256 | -r, --retry-wait FLOAT Seconds to wait between reconnection attempts if a 257 | successful connection is interrupted. [default: 258 | 5.0] 259 | -i, --icon Use a lighter icon instead of the dark icon. The 260 | lighter icon goes well with dark themes. [default: 261 | False] 262 | -l, --log-level TEXT Set the debugging log level. [default: WARN] 263 | --help Show this message and exit. 264 | ``` 265 | 266 | ### Connect to a device 267 | 268 | Connect to a device named "My Device": 269 | 270 | ```bash 271 | $ cast_control connect --name "My Device" 272 | ``` 273 | 274 | Connect to a device named "My Device" and run `cast_control` in the background: 275 | 276 | ```bash 277 | $ cast_control service connect --name "My Device" 278 | ``` 279 | 280 | After launching `cast_control`, you can use any MPRIS client to interact with it. MPRIS support is built in directly to 281 | Plasma Desktop and GNOME 3, and you can use `playerctl` on the command-line. 282 | 283 | ### Retry until a Chromecast is found 284 | 285 | You can use the `-w/--wait` flag to specify a waiting period in seconds before `cast_control` will try to find a casting 286 | device again if one is not found initially. 287 | 288 | For example, if you want to wait 60 seconds between scans for devices, you can run the following: 289 | 290 | ```bash 291 | $ export SECONDS=60 292 | $ cast_control connect --wait $SECONDS 293 | # or 294 | $ cast_control service connect --wait $SECONDS 295 | ``` 296 | 297 | This is useful if you'd like to start `cast_control` at login, and there is a chance that your device isn't on, or 298 | you're on a different network. 299 | 300 | ### Reconnect or disconnect the background service 301 | 302 | If the background service is running, you can force it to reconnect and restart, or disconnect it entirely. 303 | 304 | ```bash 305 | $ cast_control service reconnect 306 | # or 307 | $ cast_control service disconnect 308 | ``` 309 | 310 | ### Open a URI on a Chromecast 311 | 312 | Get the D-Bus name for your device using `playerctl`. 313 | 314 | ```bash 315 | $ playerctl --list-all 316 | My_Device 317 | ``` 318 | 319 | Use the D-Bus name to issue commands to it. 320 | 321 | ```bash 322 | $ export URL="http://ccmixter.org/content/gmz/gmz_-_Parametaphoriquement.mp3" 323 | $ playerctl --player My_Device open "$URL" 324 | ``` 325 | 326 | This will play a song on your device. 327 | 328 | ### Open a YouTube video 329 | 330 | You can cast YouTube videos the same way you can cast a generic URI. 331 | 332 | ```bash 333 | $ export VIDEO="https://www.youtube.com/watch?v=I4nkgJdVZFA" 334 | $ playerctl --player My_Device open "$VIDEO" 335 | ``` 336 | 337 | ### Logs 338 | 339 | You can set the log level using the `-l/--log-level` flag with the `connect` or `service connect` commands: 340 | 341 | ```bash 342 | $ cast_control connect --log-level debug 343 | ``` 344 | 345 | Here's a [list of log levels supported by 346 | `cast_control`](https://docs.python.org/3/library/logging.html#logging-levels). 347 | 348 | You can view the background service's log file with the `service log` command: 349 | 350 | ```bash 351 | $ cast_control service log 352 | ``` 353 | 354 | ## Support 355 | 356 | Want to support this project and [other open-source projects](https://github.com/alexdelorenzo) like it? 357 | 358 | Buy Me A Coffee 359 | 360 | ## License 361 | 362 | See `LICENSE`. If you'd like to use this project with a different license, please get in touch. 363 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "cast_control" 3 | version = "0.17.0" 4 | description = "📺 Control Chromecasts from the Linux desktop and console" 5 | authors = [{ name = "Alex DeLorenzo", email = "projects@alexdelorenzo.dev" }] 6 | license = { text = "AGPL-3.0" } 7 | readme = "README.md" 8 | homepage = "https://cast.firstbyte.dev" 9 | requires-python = ">=3.13" 10 | 11 | dependencies = [ 12 | "aiopath>=0.7.6 ; python_version >= '3.12'", 13 | "app_paths>=0.0.8, <0.1.0", 14 | "appdirs>=1.4.4, <1.5.0", 15 | "click>=8.1.7, <9.0.0", 16 | "daemons>=1.3.2, <1.4.0", 17 | "iteration_utilities>=0.13.0, <0.14.0", 18 | "mpris_server>=0.9.0, <=0.10.0", 19 | "PyChromecast>=14.0.2, <15.0.0", 20 | "pydbus>=0.6.0, <0.7.0", 21 | "PyGObject>=3.34.0", 22 | "rich>=13.6.0, <14.0.0", 23 | "validators>=0.22.0, <0.23.0", 24 | ] 25 | 26 | [project.urls] 27 | Homepage = "https://cast.firstbyte.dev" 28 | Source = "https://github.com/alexdelorenzo/cast_control" 29 | 30 | [project.scripts] 31 | cast_control = "cast_control.app.cli:cli" 32 | castctl = "cast_control.app.cli:cli" 33 | 34 | [tool.rye] 35 | managed = true 36 | dev-dependencies = [] 37 | 38 | [tool.rye.scripts] 39 | console_scripts = [ 40 | "cast_control = cast_control.app.cli:cli", 41 | "castctl = cast_control.app.cli:cli" 42 | ] 43 | 44 | [tool.rye.package-data] 45 | cast_control = [ 46 | "assets/*.desktop", 47 | "assets/icon/cc-*.svg", 48 | "assets/icon/*.yml" 49 | ] 50 | 51 | [tool.hatch.metadata] 52 | allow-direct-references = true 53 | 54 | [tool.hatch.build.targets.wheel] 55 | packages = ["src/cast_control"] 56 | 57 | [build-system] 58 | requires = ["hatchling"] 59 | build-backend = "hatchling.build" 60 | -------------------------------------------------------------------------------- /src/cast_control/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Final 4 | 5 | 6 | # module metadata 7 | __author__: Final[str] = 'Alex DeLorenzo ' 8 | __license__: Final[str] = 'AGPL-3.0' 9 | __copyright__: Final[str] = \ 10 | f'Copyright 2024 {__author__}. Licensed under terms of the {__license__}.' 11 | __version__: Final[str] = '0.16.1' 12 | 13 | NAME: Final[str] = 'cast_control' 14 | SHORT_NAME: Final[str] = 'castctl' 15 | DESCRIPTION: Final[str] = '📺 Control Chromecasts from Linux and D-Bus' 16 | HOMEPAGE: Final[str] = "https://github.com/alexdelorenzo/cast_control" 17 | TITLE: Final[str] = 'Cast Control' 18 | 19 | ENTRYPOINT_NAME: Final[str] = 'cli' 20 | CLI_MODULE_NAME: Final[str] = f'{NAME}.app.cli' 21 | 22 | 23 | # packaging metadata 24 | CMD_PT: Final[str] = f'{CLI_MODULE_NAME}:{ENTRYPOINT_NAME}' 25 | PY_VERSION: Final[str] = '>=3.12' 26 | PKGS: Final[list[str]] = [ 27 | NAME, 28 | f'{NAME}.app', 29 | f'{NAME}.device', 30 | ] 31 | 32 | PROJECT_URLS: Final[dict[str, str]] = { 33 | 'Homepage': 'https://alexdelorenzo.dev/', 34 | 'Source': HOMEPAGE 35 | } 36 | 37 | ASSET_DIRS: Final[list[str]] = [ 38 | 'assets/*.desktop', 39 | 'assets/icon/cc-*.svg', 40 | 'assets/icon/*.yml', 41 | ] 42 | 43 | CONSOLE_SCRIPTS: Final[list[str]] = [ 44 | f'{NAME} = {CMD_PT}', 45 | f'{SHORT_NAME} = {CMD_PT}', 46 | ] 47 | 48 | PKG_DATA: Final[dict[str, list[str]]] = { 49 | NAME: ASSET_DIRS 50 | } 51 | 52 | ENTRY_POINTS: Final[dict[str, list[str]]] = { 53 | 'console_scripts': CONSOLE_SCRIPTS 54 | } 55 | -------------------------------------------------------------------------------- /src/cast_control/__main__.py: -------------------------------------------------------------------------------- 1 | from anyio._backends import * 2 | 3 | from .app.cli import cli 4 | 5 | 6 | if __name__ == "__main__": 7 | cli() 8 | -------------------------------------------------------------------------------- /src/cast_control/adapter.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import override 4 | 5 | from mpris_server import ( 6 | DbusObj, LoopStatus, MIME_TYPES, Metadata, Microseconds, MprisAdapter, PlayState, 7 | PlayerAdapter, Rate, RootAdapter, Track, TrackListAdapter, URI, Volume, 8 | ) 9 | 10 | from .base import Device 11 | from .device.wrapper import DeviceWrapper 12 | from .protocols import DeviceIntegration 13 | 14 | 15 | class DeviceRootAdapter(DeviceIntegration, RootAdapter): 16 | @override 17 | def can_quit(self) -> bool: 18 | return self.wrapper.can_quit() 19 | 20 | @override 21 | def get_desktop_entry(self) -> str: 22 | return self.wrapper.get_desktop_entry() 23 | 24 | @override 25 | def get_mime_types(self) -> list[str]: 26 | return MIME_TYPES 27 | 28 | @override 29 | def get_uri_schemes(self) -> list[str]: 30 | return URI 31 | 32 | @override 33 | def has_tracklist(self) -> bool: 34 | return self.wrapper.has_tracklist() 35 | 36 | @override 37 | def quit(self): 38 | self.wrapper.quit() 39 | 40 | 41 | class DevicePlayerAdapter(DeviceIntegration, PlayerAdapter): 42 | @override 43 | def can_control(self) -> bool: 44 | return self.wrapper.can_control() 45 | 46 | @override 47 | def can_go_next(self) -> bool: 48 | return self.wrapper.can_play_next() 49 | 50 | @override 51 | def can_go_previous(self) -> bool: 52 | return self.wrapper.can_play_prev() 53 | 54 | @override 55 | def can_pause(self) -> bool: 56 | return self.wrapper.can_pause() 57 | 58 | @override 59 | def can_play(self) -> bool: 60 | return self.wrapper.can_play() 61 | 62 | @override 63 | def can_seek(self) -> bool: 64 | return self.wrapper.can_seek() 65 | 66 | @override 67 | def get_art_url(self, track: int = None) -> str: 68 | return self.wrapper.get_art_url(track) 69 | 70 | @override 71 | def get_current_position(self) -> Microseconds: 72 | return self.wrapper.get_current_position() 73 | 74 | @override 75 | def get_current_track(self) -> Track: 76 | return self.wrapper.get_current_track() 77 | 78 | @override 79 | def get_next_track(self) -> Track: 80 | return self.wrapper.get_next_track() 81 | 82 | @override 83 | def get_playstate(self) -> PlayState: 84 | return self.wrapper.get_playstate() 85 | 86 | @override 87 | def get_previous_track(self) -> Track: 88 | return self.wrapper.get_previous_track() 89 | 90 | @override 91 | def get_rate(self) -> Rate: 92 | return self.wrapper.get_rate() 93 | 94 | @override 95 | def get_shuffle(self) -> bool: 96 | return False 97 | 98 | @override 99 | def get_stream_title(self) -> str: 100 | return self.wrapper.get_stream_title() 101 | 102 | @override 103 | def get_volume(self) -> Volume: 104 | return self.wrapper.get_volume() 105 | 106 | @override 107 | def is_mute(self) -> bool: 108 | return self.wrapper.is_mute() 109 | 110 | @override 111 | def is_playlist(self) -> bool: 112 | return self.wrapper.is_playlist() 113 | 114 | @override 115 | def is_repeating(self) -> bool: 116 | return self.wrapper.is_repeating() 117 | 118 | @override 119 | def metadata(self) -> Metadata: 120 | return self.wrapper.metadata() 121 | 122 | @override 123 | def next(self): 124 | self.wrapper.next() 125 | 126 | @override 127 | def open_uri(self, uri: str): 128 | self.wrapper.open_uri(uri) 129 | 130 | @override 131 | def pause(self): 132 | self.wrapper.pause() 133 | 134 | @override 135 | def play(self): 136 | self.wrapper.play() 137 | 138 | @override 139 | def previous(self): 140 | self.wrapper.previous() 141 | 142 | @override 143 | def resume(self): 144 | self.play() 145 | 146 | @override 147 | def seek(self, time: Microseconds, track_id: DbusObj | None = None): 148 | self.wrapper.seek(time) 149 | 150 | @override 151 | def set_icon(self, lighter: bool = False): 152 | self.wrapper.set_icon(lighter) 153 | 154 | @override 155 | def set_loop_status(self, value: LoopStatus): 156 | pass 157 | 158 | @override 159 | def set_mute(self, value: bool): 160 | self.wrapper.set_mute(value) 161 | 162 | @override 163 | def set_rate(self, value: Rate): 164 | pass 165 | 166 | @override 167 | def set_repeating(self, value: bool): 168 | pass 169 | 170 | @override 171 | def set_shuffle(self, value: bool): 172 | pass 173 | 174 | @override 175 | def set_volume(self, value: Volume): 176 | self.wrapper.set_volume(value) 177 | 178 | @override 179 | def stop(self): 180 | self.wrapper.stop() 181 | 182 | 183 | class DeviceTrackListAdapter(DeviceIntegration, TrackListAdapter): 184 | @override 185 | def add_track(self, uri: str, after_track: DbusObj, set_as_current: bool): 186 | self.wrapper.add_track(uri, after_track, set_as_current) 187 | 188 | @override 189 | def can_edit_tracks(self) -> bool: 190 | return self.wrapper.can_edit_tracks() 191 | 192 | @override 193 | def get_tracks(self) -> list[DbusObj]: 194 | return self.wrapper.get_tracks() 195 | 196 | 197 | class DeviceAdapter(MprisAdapter, DevicePlayerAdapter, DeviceRootAdapter, DeviceTrackListAdapter): 198 | @override 199 | def __init__(self, device: Device): 200 | self.wrapper = DeviceWrapper(device) 201 | super().__init__(self.wrapper.name) 202 | -------------------------------------------------------------------------------- /src/cast_control/app/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/cast_control/app/cli.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | from typing import Final, NamedTuple 5 | 6 | import click 7 | 8 | from .daemon import Args, MprisDaemon, get_daemon, get_daemon_from_args 9 | from .run import run_safe 10 | from .. import CLI_MODULE_NAME, ENTRYPOINT_NAME, HOMEPAGE, __copyright__, __version__ 11 | from ..base import DEFAULT_DEVICE_NAME, DEFAULT_RETRY_WAIT, LOG, LOG_LEVEL, NAME, Rc, Seconds 12 | 13 | 14 | assert __name__ == CLI_MODULE_NAME 15 | 16 | 17 | log: Final[logging.Logger] = logging.getLogger(__name__) 18 | 19 | LOG_MODE: Final[str] = 'r' 20 | LOG_END: Final[str] = '' 21 | 22 | VERSION_INFO: Final[str] = f'{NAME} v{__version__}' 23 | 24 | NOT_RUNNING_MSG: Final[str] = "Daemon isn't running." 25 | HELP: Final[str] = f''' 26 | Control casting devices via Linux media controls and desktops. 27 | 28 | This daemon connects your casting device to the D-Bus media player interface (MPRIS). 29 | 30 | See {HOMEPAGE} for more information. 31 | ''' 32 | 33 | 34 | type KwargsVal = bool | str | int | float | click.ParamType 35 | 36 | 37 | class CliArgs(NamedTuple): 38 | args: tuple[str, ...] 39 | kwargs: dict[str, KwargsVal] 40 | 41 | 42 | NAME_ARGS: Final[CliArgs] = CliArgs( 43 | args=('--name', '-n'), 44 | kwargs=dict( 45 | default=DEFAULT_DEVICE_NAME, 46 | show_default=True, 47 | type=click.STRING, 48 | help="Connect to a device via its name, otherwise control the first device found." 49 | ) 50 | ) 51 | 52 | HOST_ARGS: Final[CliArgs] = CliArgs( 53 | args=('--host', '-h'), 54 | kwargs=dict( 55 | default=None, 56 | show_default=True, 57 | type=click.STRING, 58 | help="Connect to a device via its hostname or IP address, otherwise control the first device found." 59 | ) 60 | ) 61 | 62 | UUID_ARGS: Final[CliArgs] = CliArgs( 63 | args=('--uuid', '-u'), 64 | kwargs=dict( 65 | default=None, 66 | show_default=True, 67 | type=click.STRING, 68 | help="Connect to a device via its UUID, otherwise control the first device found." 69 | ) 70 | ) 71 | 72 | WAIT_ARGS: Final[CliArgs] = CliArgs( 73 | args=('--wait', '-w'), 74 | kwargs=dict( 75 | default=None, 76 | show_default=True, 77 | type=click.FLOAT, 78 | help="Seconds to wait between trying to make initial successful connections to a device." 79 | ) 80 | ) 81 | 82 | RETRY_ARGS: Final[CliArgs] = CliArgs( 83 | args=('--retry-wait', '-r'), 84 | kwargs=dict( 85 | default=DEFAULT_RETRY_WAIT, 86 | show_default=True, 87 | type=click.FLOAT, 88 | help="Seconds to wait between reconnection attempts if a successful connection is interrupted." 89 | ) 90 | ) 91 | 92 | ICON_ARGS: Final[CliArgs] = CliArgs( 93 | args=('--icon', '-i'), 94 | kwargs=dict( 95 | is_flag=True, 96 | default=False, 97 | show_default=True, 98 | type=click.BOOL, 99 | help="Use a lighter icon instead of the dark icon. The lighter icon goes well with dark themes." 100 | ) 101 | ) 102 | 103 | LOG_ARGS: Final[CliArgs] = CliArgs( 104 | args=('--log-level', '-l'), 105 | kwargs=dict( 106 | default=LOG_LEVEL, 107 | show_default=True, 108 | type=click.STRING, 109 | help='Set the debugging log level.' 110 | ) 111 | ) 112 | 113 | 114 | # see https://alexdelorenzo.dev/notes/click 115 | class OrderAsCreated(click.Group): 116 | """List `click` commands in the order they're declared.""" 117 | 118 | def list_commands(self, ctx: click.Context) -> list[str]: 119 | return list(self.commands) 120 | 121 | 122 | @click.group( 123 | help=HELP, 124 | invoke_without_command=True 125 | ) 126 | @click.option( 127 | '--license', '-L', 128 | is_flag=True, default=False, type=click.BOOL, 129 | help="Show license and copyright information." 130 | ) 131 | @click.option( 132 | '--version', '-V', 133 | is_flag=True, default=False, type=click.BOOL, 134 | help="Show version information." 135 | ) 136 | @click.pass_context 137 | def cli( 138 | ctx: click.Context, 139 | license: bool, 140 | version: bool, 141 | ): 142 | if license: 143 | click.echo(__copyright__) 144 | 145 | if version: 146 | click.echo(VERSION_INFO) 147 | 148 | if license or version: 149 | quit(Rc.OK) 150 | 151 | elif ctx.invoked_subcommand: 152 | return 153 | 154 | help: str = cli.get_help(ctx) 155 | click.echo(help) 156 | 157 | 158 | assert cli.name == ENTRYPOINT_NAME 159 | 160 | 161 | @cli.command(help='Connect to a device and run the service in the foreground.') 162 | @click.option(*NAME_ARGS.args, **NAME_ARGS.kwargs) 163 | @click.option(*HOST_ARGS.args, **HOST_ARGS.kwargs) 164 | @click.option(*UUID_ARGS.args, **UUID_ARGS.kwargs) 165 | @click.option(*WAIT_ARGS.args, **WAIT_ARGS.kwargs) 166 | @click.option(*RETRY_ARGS.args, **RETRY_ARGS.kwargs) 167 | @click.option(*ICON_ARGS.args, **ICON_ARGS.kwargs) 168 | @click.option(*LOG_ARGS.args, **LOG_ARGS.kwargs) 169 | def connect( 170 | name: str | None, 171 | host: str | None, 172 | uuid: str | None, 173 | wait: Seconds | None, 174 | retry_wait: Seconds | None, 175 | icon: bool, 176 | log_level: str 177 | ): 178 | args = Args(name, host, uuid, wait, retry_wait, icon, log_level, set_logging=True) 179 | run_safe(args) 180 | 181 | 182 | @cli.group( 183 | cls=OrderAsCreated, 184 | help='Connect, disconnect or reconnect the background service to or from your device.', 185 | ) 186 | def service(): 187 | pass 188 | 189 | 190 | @service.command(help='Connect the background service to a device.') 191 | @click.option(*NAME_ARGS.args, **NAME_ARGS.kwargs) 192 | @click.option(*HOST_ARGS.args, **HOST_ARGS.kwargs) 193 | @click.option(*UUID_ARGS.args, **UUID_ARGS.kwargs) 194 | @click.option(*WAIT_ARGS.args, **WAIT_ARGS.kwargs) 195 | @click.option(*RETRY_ARGS.args, **RETRY_ARGS.kwargs) 196 | @click.option(*ICON_ARGS.args, **ICON_ARGS.kwargs) 197 | @click.option(*LOG_ARGS.args, **LOG_ARGS.kwargs) 198 | def connect( 199 | name: str | None, 200 | host: str | None, 201 | uuid: str | None, 202 | wait: Seconds | None, 203 | retry_wait: Seconds | None, 204 | icon: bool, 205 | log_level: str 206 | ): 207 | args = Args(name, host, uuid, wait, retry_wait, icon, log_level) 208 | args.save() 209 | 210 | try: 211 | daemon = get_daemon_from_args(run_safe, args) 212 | daemon.start() 213 | 214 | except Exception as e: 215 | log.exception(e) 216 | log.error("Error launching daemon.") 217 | 218 | args.delete() 219 | 220 | 221 | @service.command(help='Disconnect the background service from the device.') 222 | def disconnect(): 223 | daemon = get_daemon() 224 | 225 | if not daemon.pid: 226 | log.error(NOT_RUNNING_MSG) 227 | quit(Rc.NOT_RUNNING) 228 | 229 | daemon.stop() 230 | Args.delete() 231 | 232 | 233 | @service.command(help='Reconnect the background service to the device.') 234 | def reconnect(): 235 | daemon: MprisDaemon | None = None 236 | args = Args.load() 237 | 238 | if args: 239 | daemon = get_daemon_from_args(run_safe, args) 240 | 241 | if not args or not daemon.pid: 242 | log.error(NOT_RUNNING_MSG) 243 | quit(Rc.NOT_RUNNING) 244 | 245 | daemon.restart() 246 | 247 | 248 | @service.command(help='Show the service log.') 249 | def log(): 250 | click.echo(f"") 251 | 252 | # a large log could hang Python or the system 253 | # iterate over the file instead of using Path.read_text() 254 | with LOG.open(LOG_MODE) as file: 255 | for line in file: 256 | print(line, end=LOG_END) 257 | 258 | 259 | if __name__ == "__main__": 260 | cli() 261 | -------------------------------------------------------------------------------- /src/cast_control/app/daemon.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import pickle 4 | from collections.abc import Callable 5 | from functools import partial 6 | from pathlib import Path 7 | from typing import NamedTuple 8 | from uuid import UUID 9 | 10 | from daemons.prefab.run import RunDaemon 11 | 12 | from .state import setup_logging 13 | from ..base import ARGS, ARGS_STEM, DEFAULT_ICON, DEFAULT_NO_DEVICE_NAME, DEFAULT_RETRY_WAIT, DEFAULT_SET_LOG, \ 14 | DEFAULT_WAIT, LOG, LOG_LEVEL, PID, Seconds 15 | 16 | 17 | class MprisDaemon[**P, T](RunDaemon): 18 | target: Callable[P, T] | None = None 19 | args: Args | None = None 20 | _logging: str | None = None 21 | 22 | @property 23 | def logging(self) -> str | None: 24 | return self._logging 25 | 26 | @logging.setter 27 | def logging(self, val: str | None): 28 | self._logging = val 29 | 30 | def set_target[**P, T]( 31 | self, 32 | func: Callable[P, T] | None = None, 33 | *args, 34 | **kwargs 35 | ): 36 | if not func: 37 | self.target = None 38 | return 39 | 40 | self.target = partial(func, *args, **kwargs) 41 | 42 | def set_target_via_args[**P, T]( 43 | self, 44 | func: Callable[P, T] | None = None, 45 | args: Args | None = None 46 | ): 47 | if not func: 48 | self.target = None 49 | return 50 | 51 | self.args = args 52 | self.logging = args.set_logging 53 | self.target = partial(func, args) 54 | 55 | def setup_logging(self): 56 | if self.args: 57 | level = self.args.log_level 58 | 59 | else: 60 | level = self.logging 61 | 62 | setup_logging(level, file=LOG) 63 | 64 | def run(self): 65 | if not self.target: 66 | return 67 | 68 | self.setup_logging() 69 | self.target() 70 | 71 | 72 | class Args(NamedTuple): 73 | name: str | None = None 74 | host: str | None = None 75 | uuid: UUID | str | None = None 76 | wait: Seconds | None = DEFAULT_WAIT 77 | retry_wait: Seconds | None = DEFAULT_RETRY_WAIT 78 | icon: bool = DEFAULT_ICON 79 | log_level: str = LOG_LEVEL 80 | set_logging: bool = DEFAULT_SET_LOG 81 | background: bool = False 82 | 83 | @staticmethod 84 | def load(identifier: str | None = None) -> Args | None: 85 | if identifier: 86 | args = ARGS.with_stem(f'{identifier}{ARGS_STEM}') 87 | 88 | else: 89 | args = ARGS 90 | 91 | if args.exists(): 92 | dump = args.read_bytes() 93 | return pickle.loads(dump) 94 | 95 | return None 96 | 97 | @staticmethod 98 | def delete(): 99 | if ARGS.exists(): 100 | ARGS.unlink() 101 | 102 | def save(self) -> Path: 103 | dump = pickle.dumps(self) 104 | ARGS.write_bytes(dump) 105 | 106 | return ARGS 107 | 108 | @property 109 | def file(self) -> Path: 110 | name, host, uuid, *_ = self 111 | device = get_name(name, host, uuid) 112 | 113 | return ARGS.with_stem(f'{device}{ARGS_STEM}') 114 | 115 | 116 | def get_daemon[**P, T]( 117 | func: Callable[P, T] | None = None, 118 | *args, 119 | _pidfile: str = str(PID), 120 | **kwargs, 121 | ) -> MprisDaemon: 122 | daemon = MprisDaemon(pidfile=_pidfile) 123 | daemon.set_target(func, *args, **kwargs) 124 | 125 | return daemon 126 | 127 | 128 | def get_daemon_from_args[**P, T]( 129 | func: Callable[P, T] | None = None, 130 | args: Args | None = None, 131 | _pidfile: str = str(PID), 132 | ) -> MprisDaemon: 133 | daemon = MprisDaemon(pidfile=_pidfile) 134 | daemon.set_target_via_args(func, args) 135 | 136 | return daemon 137 | 138 | 139 | def get_name(name: str | None, host: str | None, uuid: UUID | str | None) -> str: 140 | return name or host or uuid or DEFAULT_NO_DEVICE_NAME 141 | -------------------------------------------------------------------------------- /src/cast_control/app/run.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | from time import sleep 5 | from typing import Final, NoReturn 6 | from uuid import UUID 7 | 8 | from mpris_server import Server 9 | 10 | from .daemon import Args, get_name 11 | from .state import setup_logging 12 | from ..adapter import DeviceAdapter 13 | from ..base import DEFAULT_ICON, DEFAULT_RETRY_WAIT, DEFAULT_SET_LOG, DEFAULT_WAIT, LOG_LEVEL, \ 14 | NoDevicesFound, Rc, Seconds 15 | from ..device.device import find_device 16 | from ..device.listeners import EventListener 17 | 18 | 19 | log: Final[logging.Logger] = logging.getLogger(__name__) 20 | 21 | 22 | def create_server( 23 | name: str | None = None, 24 | host: str | None = None, 25 | uuid: UUID | str | None = None, 26 | retry_wait: Seconds | None = DEFAULT_RETRY_WAIT, 27 | ) -> Server | None: 28 | if not (device := find_device(name, host, uuid, retry_wait)): 29 | return None 30 | 31 | adapter = DeviceAdapter(device) 32 | server = Server(name, adapter) 33 | 34 | EventListener.register(server, device) 35 | server.publish() 36 | 37 | return server 38 | 39 | 40 | def retry_until_found( 41 | name: str | None = None, 42 | host: str | None = None, 43 | uuid: UUID | str | None = None, 44 | wait: Seconds | None = DEFAULT_WAIT, 45 | retry_wait: Seconds | None = DEFAULT_RETRY_WAIT, 46 | ) -> Server | None | NoReturn: 47 | """ 48 | If the device isn't found, keep trying to find it. 49 | 50 | If `wait` is None, then retrying is disabled. 51 | """ 52 | 53 | while True: 54 | if server := create_server(name, host, uuid, retry_wait): 55 | return server 56 | 57 | elif wait is None: 58 | return None 59 | 60 | device = get_name(name, host, uuid) 61 | log.warning(f'{device} not found. Waiting {wait} seconds before retrying.') 62 | sleep(wait) 63 | 64 | 65 | def run_server( 66 | name: str | None = None, 67 | host: str | None = None, 68 | uuid: UUID | str | None = None, 69 | wait: Seconds | None = DEFAULT_WAIT, 70 | retry_wait: Seconds | None = DEFAULT_RETRY_WAIT, 71 | icon: bool = DEFAULT_ICON, 72 | log_level: str = LOG_LEVEL, 73 | set_logging: bool = DEFAULT_SET_LOG, 74 | background: bool = False, 75 | ): 76 | if set_logging: 77 | setup_logging(log_level) 78 | 79 | if not (server := retry_until_found(name, host, uuid, wait, retry_wait)): 80 | device = get_name(name, host, uuid) 81 | raise NoDevicesFound(device) 82 | 83 | server.adapter.set_icon(icon) 84 | server.loop(background=background) 85 | 86 | 87 | def run_safe(args: Args): 88 | try: 89 | run_server(*args) 90 | 91 | except NoDevicesFound as e: 92 | log.error(f'Device {e} not found.') 93 | quit(Rc.NO_DEVICE) 94 | -------------------------------------------------------------------------------- /src/cast_control/app/state.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | from asyncio import gather, run 5 | from collections.abc import Callable 6 | from functools import cache, wraps 7 | from os import stat_result 8 | from pathlib import Path 9 | 10 | from aiopath import AsyncPath 11 | from rich.logging import RichHandler 12 | 13 | from ..base import DARK_END, DARK_ICON, DATA_DIR, DESKTOP_NAME, DESKTOP_SUFFIX, DESKTOP_TEMPLATE, LIGHT_END, \ 14 | LIGHT_ICON, LOG_FILE_MODE, LOG_LEVEL, NAME, PATHS, SRC_DIR, USER_DIRS, singleton 15 | 16 | 17 | type Decoratable[**P, T] = Callable[P, T] 18 | type Decorated[**P, T] = Callable[P, T] 19 | 20 | 21 | def setup_logging( 22 | level: str = LOG_LEVEL, 23 | file: Path | None = None, 24 | ): 25 | level = level.upper() 26 | 27 | if file: 28 | create_user_dirs() 29 | 30 | logging.basicConfig( 31 | level=level, 32 | filename=file, 33 | filemode=LOG_FILE_MODE, 34 | ) 35 | 36 | else: 37 | handlers = [RichHandler(rich_tracebacks=True)] 38 | logging.basicConfig(level=level, handlers=handlers) 39 | 40 | 41 | # check for user dirs and create them asynchronously 42 | async def _create_user_dirs(): 43 | await PATHS.create_user_paths() 44 | 45 | paths = map(AsyncPath, USER_DIRS) 46 | coros = (path.mkdir(parents=True, exist_ok=True) for path in paths) 47 | 48 | await gather(*coros) 49 | 50 | 51 | @singleton 52 | def create_user_dirs(): 53 | run(_create_user_dirs()) 54 | 55 | 56 | def ensure_user_dirs_exist[**P, T](func: Decoratable) -> Decorated: 57 | @wraps(func) 58 | def new_func(*args: P.args, **kwargs: P.kwargs) -> T: 59 | create_user_dirs() 60 | return func(*args, **kwargs) 61 | 62 | return new_func 63 | 64 | 65 | def get_stat(file: Path) -> stat_result: 66 | return file.stat() 67 | 68 | 69 | @singleton 70 | def get_src_stat() -> stat_result: 71 | return get_stat(SRC_DIR) 72 | 73 | 74 | @singleton 75 | def get_template() -> list[str]: 76 | return DESKTOP_TEMPLATE \ 77 | .read_text() \ 78 | .splitlines() 79 | 80 | 81 | def is_older_than_module(other: Path) -> bool: 82 | src_stat = get_src_stat() 83 | other_stat = get_stat(other) 84 | 85 | return src_stat.st_ctime > other_stat.st_ctime 86 | 87 | 88 | def get_paths(light_icon: bool = True) -> tuple[Path, Path]: 89 | icon_path = LIGHT_ICON if light_icon else DARK_ICON 90 | name_suffix = LIGHT_END if light_icon else DARK_END 91 | new_name = f'{NAME}{name_suffix}{DESKTOP_SUFFIX}' 92 | desktop_path = DATA_DIR / new_name 93 | 94 | return desktop_path, icon_path 95 | 96 | 97 | @cache 98 | def new_file_from_template(file: Path, icon_path: Path): 99 | *lines, name, icon = get_template() 100 | name += DESKTOP_NAME 101 | icon += str(icon_path) 102 | lines = (*lines, name, icon) 103 | text = '\n'.join(lines) 104 | 105 | file.write_text(text) 106 | 107 | 108 | @cache 109 | @ensure_user_dirs_exist 110 | def create_desktop_file(light_icon: bool = True) -> Path: 111 | file, icon = get_paths(light_icon) 112 | 113 | if not file.exists() or is_older_than_module(file): 114 | new_file_from_template(file, icon) 115 | 116 | return file 117 | -------------------------------------------------------------------------------- /src/cast_control/assets/icon/authors.yml: -------------------------------------------------------------------------------- 1 | cc-black.svg: 2 | Author: Y2kcrazyjoker4 3 | For: Wikpedia 4 | 5 | cc-white.svg: 6 | Author: Me 7 | For: This project 8 | -------------------------------------------------------------------------------- /src/cast_control/assets/icon/cc-black.svg: -------------------------------------------------------------------------------- 1 | 2 | 14 | 16 | 17 | 19 | image/svg+xml 20 | 22 | ic_cast_black_24dp 23 | 24 | 25 | 26 | 27 | ic_cast_black_24dp 29 | Created with Sketch. 31 | 33 | 40 | 43 | 46 | 51 | 58 | 59 | 60 | 64 | 67 | 68 | 69 | 70 | -------------------------------------------------------------------------------- /src/cast_control/assets/icon/cc-template.svg: -------------------------------------------------------------------------------- 1 | 2 | 21 | 23 | 24 | 26 | image/svg+xml 27 | 29 | ic_cast_white_24dp 30 | 31 | 32 | 33 | 53 | 54 | ic_cast_white_24dp 56 | Created with Sketch. 58 | 60 | 70 | 73 | 76 | 82 | 89 | 90 | 91 | 95 | 98 | 99 | 100 | 101 | -------------------------------------------------------------------------------- /src/cast_control/assets/icon/cc-white.svg: -------------------------------------------------------------------------------- 1 | 2 | 21 | 23 | 24 | 26 | image/svg+xml 27 | 29 | ic_cast_white_24dp 30 | 31 | 32 | 33 | 53 | 54 | ic_cast_white_24dp 56 | Created with Sketch. 58 | 60 | 70 | 73 | 76 | 82 | 89 | 90 | 91 | 95 | 98 | 99 | 100 | 101 | -------------------------------------------------------------------------------- /src/cast_control/assets/icon/licenses.yml: -------------------------------------------------------------------------------- 1 | cc-black.svg: 2 | Author: Y2kcrazyjoker4 3 | License: CC-BY 3.0 Unported 4 | Link: https://commons.wikimedia.org/wiki/File:Chromecast_cast_button_icon.svg#Licensing 5 | 6 | cc-white.svg: 7 | Author: Me 8 | License: Same as cc-black.svg 9 | Link: Same as cc-black.svg 10 | -------------------------------------------------------------------------------- /src/cast_control/assets/mpris_plasma.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexdelorenzo/cast_control/fe920403c48da028de77ccd64d30b5bd9864f152/src/cast_control/assets/mpris_plasma.png -------------------------------------------------------------------------------- /src/cast_control/assets/mpris_widget.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexdelorenzo/cast_control/fe920403c48da028de77ccd64d30b5bd9864f152/src/cast_control/assets/mpris_widget.png -------------------------------------------------------------------------------- /src/cast_control/assets/template.desktop: -------------------------------------------------------------------------------- 1 | [Desktop Entry] 2 | Encoding=UTF-8 3 | Name= 4 | Icon= 5 | -------------------------------------------------------------------------------- /src/cast_control/base.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from collections.abc import Callable 4 | from decimal import Context, Decimal, ROUND_HALF_UP, getcontext 5 | from enum import IntEnum, StrEnum, auto 6 | from functools import lru_cache 7 | from pathlib import Path 8 | from typing import Final 9 | 10 | from app_paths import AsyncAppPaths, get_paths 11 | from pychromecast import Chromecast 12 | from pychromecast.controllers.media import MediaStatus 13 | from pychromecast.controllers.receiver import CastStatus, LaunchFailure 14 | from pychromecast.socket_client import ConnectionStatus 15 | 16 | from . import NAME, __author__, __version__ 17 | 18 | 19 | Seconds = Decimal 20 | 21 | DESKTOP_NAME: Final[str] = 'Cast Control' 22 | LOG_LEVEL: Final[str] = 'WARN' 23 | 24 | NO_DURATION: Final[int] = 0 25 | NO_DELTA: Final[int] = 0 26 | NO_DEVICE_NAME: Final[str] = 'NO_NAME' 27 | NO_STR: Final[str] = '' 28 | NO_PORT: Final[int | None] = None 29 | 30 | # older Python requires an explicit 31 | # maxsize param for lru_cache() 32 | SINGLETON: Final[int] = 1 33 | 34 | YOUTUBE: Final[str] = 'YouTube' 35 | 36 | US_IN_SEC: Final[int] = 1_000_000 # seconds to microseconds 37 | DEFAULT_TRACK: Final[str] = '/track/1' 38 | DEFAULT_DISC_NO: Final[int] = 1 39 | 40 | DEFAULT_RETRY_WAIT: Final[Seconds] = Seconds(5.0) 41 | DEFAULT_WAIT: Final[Seconds] = Seconds(30) 42 | DEFAULT_DEVICE_NAME: Final[str] = DESKTOP_NAME 43 | DEFAULT_NO_DEVICE_NAME: Final[str] = 'Device' 44 | 45 | LOG_FILE_MODE: Final[str] = 'w' # create a new log on service start 46 | DEFAULT_ICON: Final[bool] = False 47 | DEFAULT_SET_LOG: Final[bool] = False 48 | 49 | DESKTOP_SUFFIX: Final[str] = '.desktop' 50 | NO_DESKTOP_FILE: Final[str] = '' 51 | 52 | ARGS_STEM: Final[str] = '-args' 53 | LIGHT_END: Final[str] = '-light' 54 | DARK_END: Final[str] = '-dark' 55 | 56 | PATHS: Final[AsyncAppPaths] = get_paths( 57 | NAME, 58 | __author__, 59 | __version__, 60 | is_async=True 61 | ) 62 | DATA_DIR: Final[Path] = Path(PATHS.user_data_path) 63 | LOG_DIR: Final[Path] = Path(PATHS.user_log_path) 64 | STATE_DIR: Final[Path] = Path(PATHS.user_state_path) 65 | 66 | USER_DIRS: Final[tuple[Path, ...]] = DATA_DIR, LOG_DIR, STATE_DIR 67 | 68 | PID: Final[Path] = STATE_DIR / f'{NAME}.pid' 69 | ARGS: Final[Path] = STATE_DIR / f'service{ARGS_STEM}.tmp' 70 | LOG: Final[Path] = LOG_DIR / f'{NAME}.log' 71 | 72 | SRC_DIR: Final[Path] = Path(__file__).parent 73 | ASSETS_DIR: Final[Path] = SRC_DIR / 'assets' 74 | DESKTOP_TEMPLATE: Final[Path] = ASSETS_DIR / f'template{DESKTOP_SUFFIX}' 75 | 76 | ICON_DIR: Final[Path] = ASSETS_DIR / 'icon' 77 | DARK_SVG: Final[Path] = ICON_DIR / 'cc-black.svg' 78 | LIGHT_SVG: Final[Path] = ICON_DIR / 'cc-white.svg' 79 | TEMPLATE_SVG: Final[Path] = ICON_DIR / 'cc-template.svg' 80 | 81 | LIGHT_ICON = LIGHT_THUMB = LIGHT_SVG 82 | DEFAULT_THUMB = DARK_ICON = DARK_SVG 83 | 84 | PRECISION: Final[int] = 4 85 | CONTEXT: Final[Context] = getcontext() 86 | CONTEXT.prec = PRECISION 87 | CONTEXT.rounding = ROUND_HALF_UP 88 | 89 | Device = Chromecast 90 | Status = MediaStatus | CastStatus | ConnectionStatus | LaunchFailure 91 | 92 | type Decorated[** P, T] = Callable[P, T] 93 | type Decoratable[** P, T] = Callable[P, T] 94 | type Decorator[** P, T] = Callable[[Decoratable], Decorated] 95 | 96 | 97 | class NoDevicesFound(Exception): 98 | pass 99 | 100 | 101 | class MediaType(StrEnum): 102 | GENERIC = auto() 103 | MOVIE = auto() 104 | MUSICTRACK = auto() 105 | PHOTO = auto() 106 | TVSHOW = auto() 107 | 108 | 109 | class Rc(IntEnum): 110 | OK = 0 111 | NO_DEVICE = auto() 112 | NOT_RUNNING = auto() 113 | 114 | 115 | singleton: Final[Decorator] = lru_cache(SINGLETON) 116 | -------------------------------------------------------------------------------- /src/cast_control/device/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/cast_control/device/base.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from collections import deque 4 | from collections.abc import Iterable, Iterator 5 | from enum import StrEnum 6 | from itertools import chain 7 | from typing import Any, Final, NamedTuple, Self, TYPE_CHECKING 8 | from urllib.parse import ParseResult, parse_qs, urlparse 9 | 10 | from iteration_utilities import unique_everseen 11 | from pychromecast.controllers.bbciplayer import BbcIplayerController 12 | from pychromecast.controllers.bbcsounds import BbcSoundsController 13 | from pychromecast.controllers.bubbleupnp import BubbleUPNPController 14 | from pychromecast.controllers.dashcast import DashCastController 15 | from pychromecast.controllers.homeassistant_media import HomeAssistantMediaController 16 | from pychromecast.controllers.media import DefaultMediaReceiverController 17 | from pychromecast.controllers.multizone import MultizoneController 18 | from pychromecast.controllers.plex import PlexController 19 | from pychromecast.controllers.receiver import ReceiverController 20 | from pychromecast.controllers.supla import SuplaController 21 | from pychromecast.controllers.yleareena import YleAreenaController 22 | from pychromecast.controllers.youtube import YouTubeController 23 | from validators import url 24 | 25 | from ..base import Device, MediaType 26 | 27 | if TYPE_CHECKING: 28 | from ..protocols import Wrapper 29 | 30 | 31 | URL_PROTO: Final[str] = 'https' 32 | SKIP_FIRST: Final[slice] = slice(1, None) 33 | 34 | 35 | class CachedIcon(NamedTuple): 36 | url: str 37 | app_id: str | None = None 38 | title: str | None = None 39 | 40 | 41 | class Controllers(NamedTuple): 42 | bbc_ip: BbcIplayerController | None = None 43 | bbc_sound: BbcSoundsController | None = None 44 | bubble: BubbleUPNPController | None = None 45 | dash: DashCastController | None = None 46 | default: DefaultMediaReceiverController | None = None 47 | ha_media: HomeAssistantMediaController | None = None 48 | multizone: MultizoneController | None = None 49 | plex: PlexController | None = None 50 | receiver: ReceiverController | None = None 51 | supla: SuplaController | None = None 52 | yle: YleAreenaController | None = None 53 | youtube: YouTubeController | None = None 54 | 55 | # plex_api: PlexApiController | None = None 56 | # ha: HomeAssistantController | None= None 57 | 58 | @classmethod 59 | def new(cls: type[Self], device: Device | None) -> Self: 60 | return cls( 61 | BbcIplayerController(), 62 | BbcSoundsController(), 63 | BubbleUPNPController(), 64 | DashCastController(), 65 | DefaultMediaReceiverController(), 66 | HomeAssistantMediaController(), 67 | MultizoneController(device.uuid) if device else None, 68 | PlexController(), 69 | ReceiverController(), 70 | SuplaController(), 71 | YleAreenaController(), 72 | YouTubeController(), 73 | # HomeAssistantController(), 74 | ) 75 | 76 | def register(self, device: Device): 77 | for controller in self: 78 | if controller: 79 | device.register_handler(controller) 80 | 81 | 82 | class Titles(NamedTuple): 83 | title: str | None = None 84 | artist: str | None = None 85 | album: str | None = None 86 | comments: str | None = None 87 | 88 | 89 | class TitlesBuilder(Iterable[str]): 90 | title: str | None = None 91 | artist: str | None = None 92 | album: str | None = None 93 | comments: str | None = None 94 | 95 | _titles: deque[str] 96 | 97 | def __init__( 98 | self, 99 | *titles: str, 100 | title: str | None = None, 101 | artist: str | None = None, 102 | album: str | None = None, 103 | comments: str | None = None, 104 | ): 105 | self._titles = deque(titles) 106 | self.set(title=title, artist=artist, album=album, comments=comments) 107 | 108 | def __bool__(self) -> bool: 109 | return any(self) 110 | 111 | def __contains__(self, value: Any) -> bool: 112 | return value in iter(self) 113 | 114 | def __iter__(self) -> Iterator[str]: 115 | titles = chain(self.titles, self._titles) 116 | return filter(bool, titles) 117 | 118 | def __len__(self) -> int: 119 | return len(tuple(self)) 120 | 121 | def __repr__(self) -> str: 122 | return repr(self.build()) 123 | 124 | @property 125 | def titles(self) -> tuple[str | None, ...]: 126 | return self.title, self.artist, self.album, self.comments 127 | 128 | def add(self, *titles: str): 129 | titles = (title for title in titles if title and title not in self) 130 | self._titles.extend(titles) 131 | 132 | def set( 133 | self, 134 | *, 135 | title: str | None = None, 136 | artist: str | None = None, 137 | album: str | None = None, 138 | comments: str | None = None, 139 | overwrite: bool = True, 140 | ): 141 | if title: 142 | if overwrite or not self.title: 143 | self.title = title 144 | 145 | elif title not in self._titles: 146 | self.add(title) 147 | 148 | if artist: 149 | if overwrite or not self.artist: 150 | self.artist = artist 151 | 152 | else: 153 | self.add(artist) 154 | 155 | if album: 156 | if overwrite or not self.album: 157 | self.album = album 158 | 159 | else: 160 | self.add(album) 161 | 162 | if comments: 163 | if overwrite or not self.comments: 164 | self.comments = comments 165 | 166 | else: 167 | self.add(comments) 168 | 169 | def build(self) -> Titles: 170 | titles: list[str] = [] 171 | rest: deque[str] = deque(unique_everseen(self._titles)) 172 | 173 | for item in self.titles: 174 | titles.append(item if item else rest.popleft() if rest else None) 175 | 176 | return Titles(*titles) 177 | 178 | 179 | class YoutubeUrl(StrEnum): 180 | long = 'youtube.com' 181 | short = 'youtu.be' 182 | 183 | watch_endpoint = 'watch' 184 | playlist_endpoint = 'playlist' 185 | 186 | video_query = 'v' 187 | playlist_query = 'list' 188 | 189 | video = f'{URL_PROTO}://{long}/{watch_endpoint}?{video_query}=' 190 | playlist = f'{URL_PROTO}://{long}/{playlist_endpoint}?{playlist_query}=' 191 | 192 | @classmethod 193 | def domain(cls: type[Self], uri: str | ParseResult) -> Self | None: 194 | match get_domain(uri): 195 | case cls.long: 196 | return cls.long 197 | 198 | case cls.short: 199 | return cls.short 200 | 201 | return None 202 | 203 | @classmethod 204 | def get_content_id(cls: type[Self], uri: str | None) -> str | None: 205 | return get_content_id(uri) 206 | 207 | @classmethod 208 | def get_url(cls: type[Self], video_id: str | None = None, playlist_id: str | None = None) -> str | None: 209 | if video_id: 210 | return f"{cls.video}{video_id}" 211 | 212 | elif playlist_id: 213 | return f"{cls.playlist}{playlist_id}" 214 | 215 | return None 216 | 217 | @classmethod 218 | def is_youtube(cls: type[Self], uri: str | None) -> bool: 219 | if not uri: 220 | return False 221 | 222 | uri = uri.casefold() 223 | 224 | return get_domain(uri) in cls 225 | 226 | @classmethod 227 | def type(cls: type[Self], uri: str | None) -> Self | None: 228 | if not (which := cls.which(uri)): 229 | return None 230 | 231 | if cls.watch_endpoint in uri: 232 | return cls.video 233 | 234 | elif cls.playlist_endpoint in uri: 235 | return cls.playlist 236 | 237 | return which 238 | 239 | @classmethod 240 | def which(cls: type[Self], uri: str | None) -> Self | None: 241 | if not cls.is_youtube(uri): 242 | return None 243 | 244 | if cls.long in uri: 245 | return cls.long 246 | 247 | elif cls.short in uri: 248 | return cls.short 249 | 250 | return None 251 | 252 | 253 | def get_domain(uri: str | ParseResult) -> str | None: 254 | if not url(uri): 255 | return None 256 | 257 | if isinstance(uri, str): 258 | uri = urlparse(uri) 259 | 260 | *_, name, tld = uri.netloc.split(".") 261 | 262 | return f"{name}.{tld}" 263 | 264 | 265 | def get_content_id(uri: str) -> str | None: 266 | if not url(uri) or not YoutubeUrl.is_youtube(uri): 267 | return None 268 | 269 | parsed = urlparse(uri) 270 | content_id: str | None = None 271 | 272 | match YoutubeUrl.domain(uri), YoutubeUrl.type(uri): 273 | case YoutubeUrl.long, YoutubeUrl.video: 274 | qs = parse_qs(parsed.query) 275 | [content_id] = qs[YoutubeUrl.video_query] 276 | 277 | case YoutubeUrl.long, YoutubeUrl.playlist: 278 | qs = parse_qs(parsed.query) 279 | [content_id] = qs[YoutubeUrl.playlist_query] 280 | 281 | case YoutubeUrl.short, YoutubeUrl.video | YoutubeUrl.playlist: 282 | content_id = parsed.path[SKIP_FIRST] 283 | 284 | return content_id 285 | 286 | 287 | def get_media_type(wrapper: Wrapper) -> MediaType | None: 288 | if not (status := wrapper.media_status): 289 | return None 290 | 291 | if status.media_is_movie: 292 | return MediaType.MOVIE 293 | 294 | elif status.media_is_tvshow: 295 | return MediaType.TVSHOW 296 | 297 | elif status.media_is_photo: 298 | return MediaType.PHOTO 299 | 300 | elif status.media_is_musictrack: 301 | return MediaType.MUSICTRACK 302 | 303 | elif status.media_is_generic: 304 | return MediaType.GENERIC 305 | 306 | return None 307 | -------------------------------------------------------------------------------- /src/cast_control/device/device.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import NamedTuple 4 | from uuid import UUID 5 | 6 | from pychromecast import get_chromecast_from_host, get_chromecasts, get_listed_chromecasts 7 | 8 | from ..base import DEFAULT_DEVICE_NAME, DEFAULT_RETRY_WAIT, Device, NO_PORT, NO_STR, Seconds 9 | 10 | 11 | class Host(NamedTuple): 12 | host: str 13 | port: int | None = NO_PORT 14 | uuid: str = NO_STR 15 | model_name: str = NO_STR 16 | friendly_name: str = DEFAULT_DEVICE_NAME 17 | 18 | 19 | def get_device_via_host( 20 | host: str, 21 | name: str | None = DEFAULT_DEVICE_NAME, 22 | retry_wait: Seconds | float | None = DEFAULT_RETRY_WAIT, 23 | ) -> Device | None: 24 | name = name or DEFAULT_DEVICE_NAME 25 | info = Host(host, friendly_name=name) 26 | 27 | if device := get_chromecast_from_host(info, retry_wait=float(retry_wait)): 28 | device.wait() 29 | return device 30 | 31 | return None # explicit 32 | 33 | 34 | def get_devices(retry_wait: Seconds | float | None = DEFAULT_RETRY_WAIT) -> list[Device]: 35 | devices, service_browser = get_chromecasts(retry_wait=float(retry_wait)) 36 | service_browser.stop_discovery() 37 | 38 | return devices 39 | 40 | 41 | def get_listed_devices( 42 | name: str | None = None, 43 | uuid: UUID | str | None = None, 44 | retry_wait: Seconds | float | None = DEFAULT_RETRY_WAIT, 45 | ) -> list[Device]: 46 | devices, service_browser = get_listed_chromecasts( 47 | friendly_names=[name], 48 | uuids=[uuid], 49 | retry_wait=float(retry_wait), 50 | ) 51 | service_browser.stop_discovery() 52 | 53 | return devices 54 | 55 | 56 | def get_first(devices: list[Device]) -> Device | None: 57 | if not devices: 58 | return None 59 | 60 | first, *_ = devices 61 | first.wait() 62 | 63 | return first 64 | 65 | 66 | def get_device_via_uuid( 67 | uuid: UUID | str | None = None, 68 | retry_wait: Seconds | float | None = DEFAULT_RETRY_WAIT, 69 | ) -> Device | None: 70 | devices = get_devices(retry_wait) 71 | 72 | if not uuid: 73 | return get_first(devices) 74 | 75 | uuid = UUID(uuid) 76 | 77 | for device in devices: 78 | if device.uuid == uuid: 79 | device.wait() 80 | 81 | return device 82 | 83 | if devices := get_listed_devices(uuid=uuid, retry_wait=retry_wait): 84 | return get_first(devices) 85 | 86 | return None 87 | 88 | 89 | def get_device( 90 | name: str | None = None, 91 | retry_wait: Seconds | float | None = DEFAULT_RETRY_WAIT, 92 | ) -> Device | None: 93 | devices = get_devices(retry_wait) 94 | 95 | if not name: 96 | return get_first(devices) 97 | 98 | devices += get_listed_devices(name, retry_wait=retry_wait) 99 | 100 | name = name.casefold() 101 | 102 | for device in devices: 103 | if device.name.casefold() == name: 104 | device.wait() 105 | 106 | return device 107 | 108 | return None 109 | 110 | 111 | def find_device( 112 | name: str | None = DEFAULT_DEVICE_NAME, 113 | host: str | None = None, 114 | uuid: UUID | str | None = None, 115 | retry_wait: Seconds | float | None = DEFAULT_RETRY_WAIT, 116 | ) -> Device | None: 117 | device: Device | None = None 118 | 119 | if host: 120 | device = get_device_via_host(host, name, retry_wait) 121 | 122 | if uuid and not device: 123 | device = get_device_via_uuid(uuid, retry_wait) 124 | 125 | if name and not device: 126 | device = get_device(name, retry_wait) 127 | 128 | no_identifiers = not (host or name or uuid) 129 | 130 | if no_identifiers: 131 | device = get_device(retry_wait=retry_wait) 132 | 133 | return device 134 | -------------------------------------------------------------------------------- /src/cast_control/device/listeners.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | from abc import ABC, abstractmethod 5 | from typing import Final, Self, override 6 | 7 | from mpris_server import EventAdapter, Server 8 | from pychromecast.controllers.media import MediaStatus, MediaStatusListener 9 | from pychromecast.controllers.receiver import CastStatus, CastStatusListener, LaunchErrorListener, LaunchFailure 10 | from pychromecast.socket_client import ConnectionStatus, ConnectionStatusListener 11 | 12 | from ..adapter import DeviceAdapter 13 | from ..base import Device, Status 14 | 15 | 16 | log: Final[logging.Logger] = logging.getLogger(__name__) 17 | 18 | 19 | # status with volume attributes 20 | VolumeStatus = MediaStatus | CastStatus 21 | 22 | 23 | class BaseEventListener( 24 | CastStatusListener, 25 | ConnectionStatusListener, 26 | LaunchErrorListener, 27 | MediaStatusListener, 28 | ABC 29 | ): 30 | """Event listeners that conform to PyChromecast's API""" 31 | 32 | @override 33 | @abstractmethod 34 | def load_media_failed(self, item: int, error_code: int): 35 | pass 36 | 37 | @override 38 | @abstractmethod 39 | def new_cast_status(self, status: CastStatus): 40 | pass 41 | 42 | @override 43 | @abstractmethod 44 | def new_connection_status(self, status: ConnectionStatus): 45 | pass 46 | 47 | @override 48 | @abstractmethod 49 | def new_launch_error(self, status: LaunchFailure): 50 | pass 51 | 52 | @override 53 | @abstractmethod 54 | def new_media_status(self, status: MediaStatus): 55 | pass 56 | 57 | 58 | class BaseEventAdapter(EventAdapter): 59 | server: Server 60 | device: Device 61 | 62 | name: str 63 | adapter: DeviceAdapter | None 64 | 65 | @override 66 | def __init__(self, server: Server, device: Device): 67 | self.server = server 68 | self.device = device 69 | 70 | self.name = self.device.name 71 | self.adapter = self.server.adapter 72 | 73 | super().__init__( 74 | root=self.server.root, 75 | player=self.server.player, 76 | tracklist=self.server.tracklist, 77 | playlists=self.server.playlists, 78 | ) 79 | 80 | @classmethod 81 | def register(cls: type[Self], server: Server, device: Device) -> Self: 82 | events = cls(server, device) 83 | events.set_and_register() 84 | 85 | return events 86 | 87 | def set_and_register(self): 88 | self.server.set_event_adapter(self) 89 | 90 | 91 | class EventListener(BaseEventAdapter, BaseEventListener): 92 | def _update_volume(self, status: Status | None = None): 93 | if isinstance(status, VolumeStatus): 94 | self.on_volume() 95 | 96 | def _update_metadata(self, status: Status | None = None): 97 | self._update_volume(status) 98 | 99 | # wire up local integration with mpris 100 | self.adapter.on_new_status() 101 | 102 | # wire up mpris_server with cc events 103 | self.on_root_all() 104 | self.on_player_all() 105 | self.on_tracklist_all() 106 | # self.on_playlists_all() 107 | # self.emit_all() 108 | 109 | @override 110 | def set_and_register(self): 111 | super().set_and_register() 112 | register_event_listener(self, self.device) 113 | 114 | @override 115 | def load_media_failed(self, item: int, error_code: int): 116 | log.error(f'Load media failed: {error_code=}, {item=}') 117 | self._update_metadata() 118 | 119 | @override 120 | def new_cast_status(self, status: CastStatus): 121 | log.debug(f'Handling new cast status: {status}') 122 | self._update_metadata(status) 123 | 124 | @override 125 | def new_connection_status(self, status: ConnectionStatus): 126 | log.info(f'Handling new connection status: {status}') 127 | self._update_metadata(status) 128 | 129 | @override 130 | def new_launch_error(self, status: LaunchFailure): 131 | log.error(f'Handling new launch error: {status}') 132 | self._update_metadata(status) 133 | 134 | @override 135 | def new_media_status(self, status: MediaStatus): 136 | log.debug(f'Handling new media status: {status}') 137 | self._update_metadata(status) 138 | 139 | 140 | def register_event_listener[E: BaseEventListener](events: E, device: Device): 141 | device.register_connection_listener(events) 142 | device.register_launch_error_listener(events) 143 | device.register_status_listener(events) 144 | device.media_controller.register_status_listener(events) 145 | -------------------------------------------------------------------------------- /src/cast_control/device/wrapper.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | from decimal import Decimal 5 | from mimetypes import guess_type 6 | from typing import Final, override 7 | 8 | from mpris_server import ( 9 | Album, Artist, BEGINNING, DEFAULT_RATE, DbusObj, LoopStatus, MetadataObj, Microseconds, Paths, PlayState, Rate, Track, 10 | ValidMetadata, Volume, get_track_id, 11 | ) 12 | from pychromecast.controllers.media import MediaController, MediaImage, MediaStatus 13 | from pychromecast.controllers.receiver import CastStatus 14 | from pychromecast.socket_client import ConnectionStatus 15 | 16 | from .base import CachedIcon, Controllers, Titles, TitlesBuilder, YoutubeUrl 17 | from .. import TITLE 18 | from ..app.state import create_desktop_file, ensure_user_dirs_exist 19 | from ..base import DEFAULT_DISC_NO, DEFAULT_THUMB, Device, \ 20 | LIGHT_THUMB, NO_DELTA, NO_DESKTOP_FILE, \ 21 | NO_DURATION, Seconds, US_IN_SEC, singleton 22 | from ..protocols import CliIntegration, ListenerIntegration, ModuleIntegration, Wrapper 23 | 24 | 25 | log: Final[logging.Logger] = logging.getLogger(__name__) 26 | 27 | 28 | RESOLUTION: Final[int] = 1 29 | MAX_TITLES: Final[int] = 3 30 | 31 | NO_ARTIST: Final[str] = '' 32 | NO_SUFFIX: Final[str] = '' 33 | 34 | PREFIX_NOT_YOUTUBE: Final[str] = 'http' 35 | 36 | 37 | class StatusMixin(Wrapper): 38 | @override 39 | @property 40 | def cast_status(self) -> CastStatus | None: 41 | return self.device.status or None 42 | 43 | @override 44 | @property 45 | def media_status(self) -> MediaStatus | None: 46 | return self.media_controller.status or None 47 | 48 | @override 49 | @property 50 | def connection_status(self) -> ConnectionStatus | None: 51 | return self.device.socket_client.receiver_controller.status or None 52 | 53 | @override 54 | @property 55 | def media_controller(self) -> MediaController: 56 | return self.device.media_controller 57 | 58 | 59 | class ControllersMixin(Wrapper): 60 | controllers: Controllers 61 | 62 | @override 63 | def __init__(self): 64 | self._setup_controllers() 65 | super().__init__() 66 | 67 | def _setup_controllers(self): 68 | self.controllers = Controllers.new(self.device) 69 | self.controllers.register(self.device) 70 | 71 | def _launch_youtube(self): 72 | if not (youtube := self.controllers.youtube): 73 | return 74 | 75 | youtube.launch() 76 | 77 | def _play_youtube(self, video_id: str): 78 | if not (youtube := self.controllers.youtube): 79 | return 80 | 81 | if not youtube.is_active: 82 | self._launch_youtube() 83 | 84 | youtube.quick_play(media_id=video_id, timeout=30) 85 | 86 | @property 87 | def is_youtube(self) -> bool: 88 | if youtube := self.controllers.youtube: 89 | return youtube.is_active 90 | 91 | return False 92 | 93 | @override 94 | def open_uri(self, uri: str): 95 | if content_id := YoutubeUrl.get_content_id(uri): 96 | self._play_youtube(content_id) 97 | return 98 | 99 | mimetype, _ = guess_type(uri) 100 | self.media_controller.play_media(uri, mimetype) 101 | 102 | @override 103 | def add_track(self, uri: str, after_track: DbusObj, set_as_current: bool): 104 | if not (youtube := self.controllers.youtube): 105 | self.open_uri(uri) 106 | return 107 | 108 | if content_id := YoutubeUrl.get_content_id(uri): 109 | youtube.add_to_queue(content_id) 110 | 111 | if content_id and set_as_current: 112 | youtube.play_video(content_id) 113 | 114 | elif set_as_current: 115 | self.open_uri(uri) 116 | 117 | 118 | class TitlesMixin(Wrapper): 119 | @override 120 | @property 121 | def titles(self) -> Titles: 122 | titles: TitlesBuilder = TitlesBuilder() 123 | 124 | if title := self.media_status.title: 125 | titles.set(title=title) 126 | 127 | if (subtitle := self.get_subtitle()) and self.is_youtube: 128 | titles.set(artist=subtitle) 129 | 130 | elif subtitle: 131 | titles.add(subtitle) 132 | 133 | if status := self.media_status: 134 | if title := status.series_title: 135 | titles.set(title=title, overwrite=False) 136 | 137 | if artist := status.artist: 138 | titles.set(artist=artist) 139 | 140 | if album := status.album_name: 141 | titles.set(album=album) 142 | 143 | if app_name := self.device.app_display_name: 144 | if not titles.artist: 145 | titles.set(artist=app_name) 146 | 147 | elif not titles.album: 148 | titles.set(album=app_name) 149 | 150 | elif not titles.title: 151 | titles.set(title=app_name) 152 | 153 | titles.add(TITLE) 154 | 155 | return titles.build() 156 | 157 | def get_subtitle(self) -> str | None: 158 | if not (status := self.media_status) or not (metadata := status.media_metadata): 159 | return None 160 | 161 | if subtitle := metadata.get('subtitle'): 162 | return subtitle 163 | 164 | return None 165 | 166 | 167 | class TimeMixin(Wrapper, ListenerIntegration, ModuleIntegration): 168 | _longest_duration: Microseconds | None 169 | 170 | @override 171 | def __init__(self): 172 | self._longest_duration = NO_DURATION 173 | super().__init__() 174 | 175 | def _reset_longest_duration(self): 176 | if not self.has_current_time(): 177 | self._longest_duration = None 178 | 179 | @override 180 | def on_new_status(self, *args, **kwargs): 181 | self._reset_longest_duration() 182 | super().on_new_status(*args, **kwargs) 183 | 184 | @override 185 | @property 186 | def current_time(self) -> Seconds | None: 187 | if not (status := self.media_status): 188 | return None 189 | 190 | if time := status.adjusted_current_time or status.current_time: 191 | return Seconds(time) 192 | 193 | return None 194 | 195 | @override 196 | def get_duration(self) -> Microseconds: 197 | if (status := self.media_status) and (duration := status.duration) is not None: 198 | duration = Seconds(duration) 199 | duration_us = duration * US_IN_SEC 200 | 201 | return round(duration_us) 202 | 203 | current: Microseconds = self.get_current_position() 204 | longest: Microseconds = self._longest_duration 205 | 206 | if longest and longest > current: 207 | return longest 208 | 209 | elif current: 210 | self._longest_duration = current 211 | return current 212 | 213 | return NO_DURATION 214 | 215 | @override 216 | def get_current_position(self) -> Microseconds: 217 | position: Seconds | None = self.current_time 218 | 219 | if not position: 220 | return BEGINNING 221 | 222 | position_us = position * US_IN_SEC 223 | return round(position_us) 224 | 225 | @override 226 | def has_current_time(self) -> bool: 227 | current_time: Seconds | None = self.current_time 228 | 229 | if current_time is None: 230 | return False 231 | 232 | current_time = round(current_time, RESOLUTION) 233 | 234 | return current_time > BEGINNING 235 | 236 | @override 237 | def seek(self, time: Microseconds, *_): 238 | microseconds = Decimal(time) 239 | seconds: int = round(microseconds / US_IN_SEC) 240 | 241 | self.media_controller.seek(seconds) 242 | 243 | @override 244 | def get_rate(self) -> Rate: 245 | if not (status := self.media_status): 246 | return DEFAULT_RATE 247 | 248 | if rate := status.playback_rate: 249 | return rate 250 | 251 | return DEFAULT_RATE 252 | 253 | @override 254 | def set_rate(self, value: Rate): 255 | pass 256 | 257 | 258 | class IconsMixin(Wrapper, CliIntegration): 259 | cached_icon: CachedIcon | None 260 | light_icon: bool 261 | 262 | def _set_cached_icon(self, url: str | None = None): 263 | if not url: 264 | self.cached_icon = None 265 | return 266 | 267 | app_id = self.device.app_id 268 | title, *_ = self.titles 269 | self.cached_icon = CachedIcon(url, app_id, title) 270 | 271 | def _can_use_cache(self) -> bool: 272 | if not (icon := self.cached_icon) or not icon.url: 273 | return False 274 | 275 | app_id = self.device.app_id 276 | title, *_ = self.titles 277 | 278 | return icon.app_id == app_id and icon.title == title 279 | 280 | def _get_icon_from_device(self) -> str | None: 281 | url: str | None 282 | 283 | if (status := self.media_status) and (images := status.images): 284 | first: MediaImage 285 | 286 | first, *_ = images 287 | url, *_ = first 288 | self._set_cached_icon(url) 289 | 290 | return url 291 | 292 | if (status := self.cast_status) and (url := status.icon_url): 293 | self._set_cached_icon(url) 294 | return url 295 | 296 | if not self._can_use_cache(): 297 | return None 298 | 299 | if icon := self.cached_icon: 300 | return icon.url 301 | 302 | return None 303 | 304 | @ensure_user_dirs_exist 305 | def _get_default_icon(self) -> str: 306 | if self.light_icon: 307 | return str(LIGHT_THUMB) 308 | 309 | return str(DEFAULT_THUMB) 310 | 311 | @override 312 | def get_art_url(self, track: int | None = None) -> str: 313 | if icon := self._get_icon_from_device(): 314 | return icon 315 | 316 | return self._get_default_icon() 317 | 318 | @override 319 | @singleton 320 | def get_desktop_entry(self) -> Paths: 321 | try: 322 | return create_desktop_file(self.light_icon) 323 | 324 | except Exception as e: 325 | log.exception(e) 326 | log.error("Couldn't load desktop file.") 327 | 328 | return NO_DESKTOP_FILE 329 | 330 | @override 331 | def set_icon(self, lighter: bool = False): 332 | self.light_icon: bool = lighter 333 | 334 | 335 | class MetadataMixin(Wrapper): 336 | def _get_url(self) -> str | None: 337 | content_id: str | None = None 338 | 339 | if status := self.media_status: 340 | content_id = status.content_id 341 | 342 | if self._is_youtube_video(content_id): 343 | return YoutubeUrl.get_url(content_id) 344 | 345 | return content_id 346 | 347 | def _is_youtube_video(self, content_id: str | None) -> bool: 348 | if not (youtube := self.controllers.youtube): 349 | return False 350 | 351 | if not content_id or not youtube.is_active: 352 | return False 353 | 354 | return not content_id.startswith(PREFIX_NOT_YOUTUBE) 355 | 356 | @override 357 | def metadata(self) -> ValidMetadata: 358 | title, artist, album, comments = self.titles 359 | 360 | dbus_name: DbusObj = get_track_id(title) 361 | artists: list[str] = [artist] if artist else [] 362 | comments: list[str] = [comments] if comments else [] 363 | track_no: int | None = None 364 | 365 | if status := self.media_status: 366 | track_no = status.track 367 | 368 | return MetadataObj( 369 | album=album, 370 | album_artists=artists, 371 | art_url=self.get_art_url(), 372 | artists=artists, 373 | comments=comments, 374 | disc_number=DEFAULT_DISC_NO, 375 | length=self.get_duration(), 376 | title=title, 377 | track_id=dbus_name, 378 | track_number=track_no, 379 | url=self._get_url(), 380 | ) 381 | 382 | @override 383 | def get_stream_title(self) -> str: 384 | if status := self.media_status: 385 | return status.title 386 | 387 | return self.titles.title 388 | 389 | @override 390 | def get_current_track(self) -> Track: 391 | title, artist, album, comments = self.titles 392 | 393 | dbus_name: DbusObj = get_track_id(title) 394 | artists: list[Artist] = [Artist(artist)] if artist else [] 395 | track_no: int | None = None 396 | art_url = self.get_art_url() 397 | 398 | if status := self.media_status: 399 | track_no = status.track 400 | 401 | return Track( 402 | album=Album(art_url, artists, album), 403 | art_url=art_url, 404 | artists=artists, 405 | comments=[comments] if comments else [], 406 | disc_number=DEFAULT_DISC_NO, 407 | length=self.get_duration(), 408 | name=title, 409 | track_id=dbus_name, 410 | track_number=track_no, 411 | ) 412 | 413 | 414 | class PlaybackMixin(Wrapper): 415 | @override 416 | def get_playstate(self) -> PlayState: 417 | if self.media_status.player_is_playing: 418 | return PlayState.PLAYING 419 | 420 | elif self.media_status.player_is_paused: 421 | return PlayState.PAUSED 422 | 423 | return PlayState.STOPPED 424 | 425 | @override 426 | def is_repeating(self) -> bool: 427 | return False 428 | 429 | @override 430 | def is_playlist(self) -> bool: 431 | return self.can_play_next() or self.can_play_prev() 432 | 433 | @override 434 | def get_shuffle(self) -> bool: 435 | return False 436 | 437 | @override 438 | def set_shuffle(self, value: bool): 439 | pass 440 | 441 | @override 442 | def quit(self): 443 | self.device.quit_app() 444 | 445 | @override 446 | def next(self): 447 | self.media_controller.queue_next() 448 | 449 | @override 450 | def previous(self): 451 | self.media_controller.queue_prev() 452 | 453 | @override 454 | def pause(self): 455 | self.media_controller.pause() 456 | 457 | @override 458 | def resume(self): 459 | self.play() 460 | 461 | @override 462 | def stop(self): 463 | self.media_controller.stop() 464 | 465 | @override 466 | def play(self): 467 | self.media_controller.play() 468 | 469 | @override 470 | def set_repeating(self, value: bool): 471 | pass 472 | 473 | @override 474 | def set_loop_status(self, value: LoopStatus): 475 | pass 476 | 477 | 478 | class VolumeMixin(Wrapper): 479 | @override 480 | def get_volume(self) -> Volume | None: 481 | if status := self.cast_status: 482 | return Volume(status.volume_level) 483 | 484 | return None 485 | 486 | @override 487 | def set_volume(self, value: Volume): 488 | if (current := self.get_volume()) is None: 489 | return 490 | 491 | volume = Volume(value) 492 | delta: float = float(volume - current) 493 | 494 | # can't adjust vol by 0 495 | if delta > NO_DELTA: # vol up 496 | self.device.volume_up(delta) 497 | 498 | elif delta < NO_DELTA: 499 | self.device.volume_down(abs(delta)) 500 | 501 | @override 502 | def is_mute(self) -> bool | None: 503 | if status := self.cast_status or self.media_status: 504 | return status.volume_muted 505 | 506 | return False 507 | 508 | @override 509 | def set_mute(self, value: bool): 510 | self.device.set_volume_muted(value) 511 | 512 | 513 | class AbilitiesMixin(Wrapper): 514 | @override 515 | def can_quit(self) -> bool: 516 | return True 517 | 518 | @override 519 | def can_play(self) -> bool: 520 | state = self.get_playstate() 521 | 522 | return state is not PlayState.STOPPED 523 | 524 | @override 525 | def can_control(self) -> bool: 526 | return True 527 | # return self.can_play() or self.can_pause() \ 528 | # or self.can_play_next() or self.can_play_prev() \ 529 | # or self.can_seek() 530 | 531 | @override 532 | def can_edit_tracks(self) -> bool: 533 | return False 534 | 535 | @override 536 | def can_play_next(self) -> bool: 537 | if status := self.media_status: 538 | return status.supports_queue_next 539 | 540 | return False 541 | 542 | @override 543 | def can_play_prev(self) -> bool: 544 | if status := self.media_status: 545 | return status.supports_queue_prev 546 | 547 | return False 548 | 549 | @override 550 | def can_pause(self) -> bool: 551 | if status := self.media_status: 552 | return status.supports_pause 553 | 554 | return False 555 | 556 | @override 557 | def can_seek(self) -> bool: 558 | if status := self.media_status: 559 | return status.supports_seek 560 | 561 | return False 562 | 563 | 564 | class TracklistMixin(Wrapper): 565 | @override 566 | def has_tracklist(self) -> bool: 567 | return bool(self.get_tracks()) 568 | 569 | @override 570 | def get_tracks(self) -> list[DbusObj]: 571 | title, *_ = self.titles 572 | 573 | if title: 574 | return [get_track_id(title)] 575 | 576 | return [] 577 | 578 | 579 | class DeviceWrapper( 580 | AbilitiesMixin, 581 | ControllersMixin, 582 | IconsMixin, 583 | MetadataMixin, 584 | PlaybackMixin, 585 | StatusMixin, 586 | TimeMixin, 587 | TitlesMixin, 588 | TracklistMixin, 589 | VolumeMixin, 590 | ): 591 | """Wraps implementation details for device API""" 592 | 593 | @override 594 | def __init__(self, device: Device): 595 | self.device = device 596 | super().__init__() 597 | 598 | @override 599 | def __repr__(self) -> str: 600 | cls = type(self) 601 | 602 | return f'<{cls.__name__} for {self.device}>' 603 | -------------------------------------------------------------------------------- /src/cast_control/protocols.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Protocol, override, runtime_checkable 4 | 5 | from mpris_server import DbusObj, LoopStatus, Metadata, Microseconds, Paths, PlayState, Rate, Track, \ 6 | ValidMetadata, Volume 7 | from pychromecast.controllers.media import MediaController, MediaStatus 8 | from pychromecast.controllers.receiver import CastStatus 9 | from pychromecast.socket_client import ConnectionStatus 10 | 11 | from .base import DEFAULT_ICON, Device, NAME 12 | from .device.base import CachedIcon, Controllers, Titles 13 | 14 | 15 | @runtime_checkable 16 | class CliIntegration(Protocol): 17 | def set_icon(self, lighter: bool = False): ... 18 | 19 | 20 | @runtime_checkable 21 | class ListenerIntegration(Protocol): 22 | def on_new_status(self, *args, **kwargs): 23 | """Callback for event listener""" 24 | 25 | 26 | @runtime_checkable 27 | class ModuleIntegration(Protocol): 28 | def get_duration(self) -> Microseconds: ... 29 | 30 | 31 | @runtime_checkable 32 | class Statuses(Protocol): 33 | @property 34 | def cast_status(self) -> CastStatus | None: ... 35 | 36 | @property 37 | def connection_status(self) -> ConnectionStatus | None: ... 38 | 39 | @property 40 | def media_controller(self) -> MediaController: ... 41 | 42 | @property 43 | def media_status(self) -> MediaStatus | None: ... 44 | 45 | 46 | @runtime_checkable 47 | class Properties(Protocol): 48 | device: Device 49 | controllers: Controllers 50 | 51 | cached_icon: CachedIcon | None = None 52 | light_icon: bool = DEFAULT_ICON 53 | 54 | @property 55 | def name(self) -> str: 56 | return self.device.name or NAME 57 | 58 | @property 59 | def is_youtube(self) -> bool: ... 60 | 61 | @property 62 | def titles(self) -> Titles: ... 63 | 64 | 65 | @runtime_checkable 66 | class RootAdapterIntegration(Protocol): 67 | def can_quit(self) -> bool: ... 68 | 69 | def get_desktop_entry(self) -> str: ... 70 | 71 | def get_mime_types(self) -> list[str]: ... 72 | 73 | def get_uri_schemes(self) -> list[str]: ... 74 | 75 | def has_tracklist(self) -> bool: ... 76 | 77 | def quit(self): ... 78 | 79 | 80 | @runtime_checkable 81 | class TrackListAdapterIntegration(Protocol): 82 | def add_track(self, uri: str, after_track: DbusObj, set_as_current: bool): ... 83 | 84 | def can_edit_tracks(self) -> bool: ... 85 | 86 | def get_tracks(self) -> list[DbusObj]: ... 87 | 88 | 89 | @runtime_checkable 90 | class PlayerAdapterIntegration(Protocol): 91 | def can_control(self) -> bool: ... 92 | 93 | def can_go_next(self) -> bool: ... 94 | 95 | def can_go_previous(self) -> bool: ... 96 | 97 | def can_pause(self) -> bool: ... 98 | 99 | def can_play(self) -> bool: ... 100 | 101 | def can_seek(self) -> bool: ... 102 | 103 | def get_art_url(self, track: int = None) -> str: ... 104 | 105 | def get_current_position(self) -> Microseconds: ... 106 | 107 | def get_current_track(self) -> Track: ... 108 | 109 | def get_next_track(self) -> Track: ... 110 | 111 | def get_playstate(self) -> PlayState: ... 112 | 113 | def get_previous_track(self) -> Track: ... 114 | 115 | def get_rate(self) -> Rate: ... 116 | 117 | def get_shuffle(self) -> bool: ... 118 | 119 | def get_stream_title(self) -> str: ... 120 | 121 | def get_volume(self) -> Volume: ... 122 | 123 | def is_mute(self) -> bool: ... 124 | 125 | def is_playlist(self) -> bool: ... 126 | 127 | def is_repeating(self) -> bool: ... 128 | 129 | def metadata(self) -> Metadata: ... 130 | 131 | def next(self): ... 132 | 133 | def open_uri(self, uri: str): ... 134 | 135 | def pause(self): ... 136 | 137 | def play(self): ... 138 | 139 | def previous(self): ... 140 | 141 | def resume(self): ... 142 | 143 | def seek(self, time: Microseconds, track_id: DbusObj | None = None): ... 144 | 145 | def set_icon(self, lighter: bool = False): ... 146 | 147 | def set_loop_status(self, value: LoopStatus): ... 148 | 149 | def set_mute(self, value: bool): ... 150 | 151 | def set_rate(self, value: Rate): ... 152 | 153 | def set_repeating(self, value: bool): ... 154 | 155 | def set_shuffle(self, value: bool): ... 156 | 157 | def set_volume(self, value: Volume): ... 158 | 159 | def stop(self): ... 160 | 161 | 162 | @runtime_checkable 163 | class AdapterIntegration(Protocol): 164 | def add_track(self, uri: str, after_track: DbusObj, set_as_current: bool): ... 165 | 166 | def can_control(self) -> bool: ... 167 | 168 | def can_edit_tracks(self) -> bool: ... 169 | 170 | def can_pause(self) -> bool: ... 171 | 172 | def can_play(self) -> bool: ... 173 | 174 | def can_play_next(self) -> bool: ... 175 | 176 | def can_play_prev(self) -> bool: ... 177 | 178 | def can_quit(self) -> bool: ... 179 | 180 | def can_seek(self) -> bool: ... 181 | 182 | def get_art_url(self, track: int | None = None) -> str: ... 183 | 184 | def get_desktop_entry(self) -> Paths: ... 185 | 186 | def get_playstate(self) -> PlayState: ... 187 | 188 | def get_rate(self) -> Rate: ... 189 | 190 | def get_shuffle(self) -> bool: ... 191 | 192 | def get_stream_title(self) -> str: ... 193 | 194 | def get_tracks(self) -> list[DbusObj]: ... 195 | 196 | def get_volume(self) -> Volume: ... 197 | 198 | def has_tracklist(self) -> bool: ... 199 | 200 | def has_current_time(self) -> bool: ... 201 | 202 | def is_mute(self) -> bool: ... 203 | 204 | def is_playlist(self) -> bool: ... 205 | 206 | def is_repeating(self) -> bool: ... 207 | 208 | def metadata(self) -> ValidMetadata: ... 209 | 210 | def next(self): ... 211 | 212 | def open_uri(self, uri: str): ... 213 | 214 | def pause(self): ... 215 | 216 | def play(self): ... 217 | 218 | def previous(self): ... 219 | 220 | def quit(self): ... 221 | 222 | def resume(self): ... 223 | 224 | def seek(self, time: Microseconds, track_id: DbusObj | None = None): ... 225 | 226 | def set_loop_status(self, value: LoopStatus): ... 227 | 228 | def set_mute(self, value: bool): ... 229 | 230 | def set_rate(self, value: Rate): ... 231 | 232 | def set_repeating(self, value: bool): ... 233 | 234 | def set_shuffle(self, value: bool): ... 235 | 236 | def set_volume(self, value: Volume): ... 237 | 238 | def stop(self): ... 239 | 240 | 241 | @runtime_checkable 242 | class Wrapper( 243 | AdapterIntegration, 244 | CliIntegration, 245 | ListenerIntegration, 246 | ModuleIntegration, 247 | Properties, 248 | Statuses, 249 | Protocol 250 | ): 251 | pass 252 | 253 | 254 | @runtime_checkable 255 | class DeviceIntegration[W: Wrapper](CliIntegration, ListenerIntegration, ModuleIntegration, Protocol): 256 | wrapper: W 257 | 258 | @override 259 | def get_duration(self) -> Microseconds: 260 | return self.wrapper.get_duration() 261 | 262 | @override 263 | def on_new_status(self, *args, **kwargs): 264 | self.wrapper.on_new_status(*args, **kwargs) 265 | 266 | @override 267 | def set_icon(self, lighter: bool = False): 268 | self.wrapper.set_icon(lighter) 269 | -------------------------------------------------------------------------------- /src/cast_control/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexdelorenzo/cast_control/fe920403c48da028de77ccd64d30b5bd9864f152/src/cast_control/py.typed --------------------------------------------------------------------------------