├── CONTRIBUTING.md ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.md ├── archive ├── audiodevice-src.zip └── audiodevice.zip ├── bin ├── audiodevice └── mkchromecast ├── changelog.md ├── images ├── Awesome_BI.png ├── Gnome1.png ├── Gnome2.png ├── Kde5_1.png ├── Kde5_2.png ├── change_samplerate1.png ├── change_samplerate2.png ├── exmark.png ├── google.icns ├── google.png ├── google_b.icns ├── google_b.png ├── google_nodev.icns ├── google_nodev.png ├── google_nodev_b.icns ├── google_nodev_b.png ├── google_nodev_w.icns ├── google_nodev_w.png ├── google_w.icns ├── google_w.png ├── google_working.icns ├── google_working.png ├── google_working_b.icns ├── google_working_b.png ├── google_working_w.icns ├── google_working_w.png ├── max.svg ├── mkchromecast.xpm ├── mkchromecast_linux.gif ├── musicalnote.icns ├── musicalnote.png ├── mute.svg ├── screencast.png └── two-arrows-refresh-symbol.png ├── man └── mkchromecast.1 ├── mkchromecast.1 ├── mkchromecast.desktop ├── mkchromecast ├── __init__.py ├── _arg_parsing.py ├── audio.py ├── audio_devices.py ├── cast.py ├── colors.py ├── config.py ├── constants.py ├── getch │ ├── __init__.py │ ├── getch.py │ └── pause.py ├── messages.py ├── node.py ├── pipeline_builder.py ├── preferences.py ├── pulseaudio.py ├── resolution.py ├── stream_infra.py ├── systray.py ├── tray_threading.py ├── utils.py ├── version.py └── video.py ├── nodejs ├── README.md ├── html5-video-streamer.js ├── package-lock.json ├── package.json └── recompile_node.sh ├── notifier ├── LICENSE └── terminal-notifier.app │ └── Contents │ ├── Info.plist │ ├── MacOS │ └── terminal-notifier │ ├── PkgInfo │ └── Resources │ ├── Terminal.icns │ └── en.lproj │ ├── Credits.rtf │ ├── InfoPlist.strings │ └── MainMenu.nib ├── requirements.txt ├── setup.py ├── start_tray.py ├── test.py └── tests ├── __init__.py ├── test_config.py ├── test_constants.py ├── test_instantiate.py ├── test_messages.py ├── test_pipeline_builder.py └── test_utils.py /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | Contributing 2 | ============ 3 | 4 | If you want to contribute, you can help by [reporting 5 | issues](https://github.com/muammar/mkchromecast/issues) or by [creating pull 6 | requests](https://github.com/muammar/mkchromecast/pulls) with your 7 | developments/improvements. If your case is the latter, visit 8 | [Development](https://github.com/muammar/mkchromecast/wiki/development) section 9 | in the Wiki. 10 | 11 | A list of contributors can be found on GitHub at 12 | [mkchromecast/graphs/contributors](https://github.com/muammar/mkchromecast/graphs/contributors). 13 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Muammar El Khatib 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | All files located in the nodejs and archive directories are externally 24 | maintained codes used by this software which have their own licenses; we 25 | recommend you read them, as their terms may differ from the terms above. 26 | 27 | Files in `mkchromecast/getch` are maintained under the MIT license. For more 28 | information please see: https://github.com/joeyespo/py-getch. 29 | 30 | Files in `notifier`, except for `Terminal.icns` are maintained under the MIT 31 | license. For more information see: https://github.com/julienXX/terminal-notifier#license 32 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include mkchromecast/getch/*.py 2 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # This file is part of mkchromecast. It is used to build the macOS app. 2 | # It does the following: 3 | # 4 | # 1) It changes the strings tray and debug to True. 5 | # 2) Build the application using py2app. 6 | # 3) Copy Qt plugins. 7 | # 4) macdeployqt 8 | # 9 | # The clean target does a `git clean -f -d` to delete all untracked 10 | # directories, and does a `git checkout mkchromecast/__init__.py`. 11 | # 12 | # Note: Be careful when using this Makefile, because all files not tracked will 13 | # be deleted, and all changes to mkchromecast/__init__.py will be discarded if 14 | # they are not committed. 15 | # 16 | # How to use it? 17 | # ============== 18 | # Test the start_tray.py script: 19 | # make clean 20 | # make sed 21 | # python start_tray.py 22 | # Test the application locally 23 | # make clean 24 | # make test 25 | # check inside the dist/ directory 26 | # Deploy with debug 27 | # make clean 28 | # make debug 29 | # check inside the dist/ directory 30 | # Deploy 31 | # make clean 32 | # make deploy 33 | # check inside the dist/ directory 34 | # 35 | # Note again that make clean will do a checkout of mkchromecast/__init__.py. 36 | # 37 | # Muammar El Khatib 38 | # 39 | 40 | .PHONY: sed \ 41 | sed_notray \ 42 | test \ 43 | debug \ 44 | deploy \ 45 | clean \ 46 | deepclean 47 | 48 | # This target is used to test the start_tray.py script that is used to deploy 49 | # the macOS app 50 | sed: 51 | sed -i -e 's/tray = args.tray/tray = True/g' mkchromecast/__init__.py 52 | sed -i -e 's/debug = args.debug/debug = True/g' mkchromecast/__init__.py 53 | 54 | # This target is used when the tray should be disabled 55 | sed_notray: 56 | sed -i -e 's/tray = args.tray/tray = False/g' mkchromecast/__init__.py 57 | sed -i -e 's/debug = args.debug/debug = True/g' mkchromecast/__init__.py 58 | 59 | 60 | # This target creates the app just to be used locally 61 | test: 62 | sed -i -e 's/tray = args.tray/tray = True/g' mkchromecast/__init__.py 63 | sed -i -e 's/debug = args.debug/debug = True/g' mkchromecast/__init__.py 64 | python3 setup.py py2app -A 65 | 66 | # This target creates a standalone app with debugging enabled 67 | debug: 68 | sed -i -e 's/tray = args.tray/tray = True/g' mkchromecast/__init__.py 69 | sed -i -e 's/debug = args.debug/debug = True/g' mkchromecast/__init__.py 70 | python3 setup.py py2app 71 | cp -R /usr/local/Cellar/qt/5.15.1/plugins dist/Mkchromecast.app/Contents/PlugIns 72 | /usr/local/Cellar/qt/5.15.1/bin/macdeployqt dist/Mkchromecast.app 73 | 74 | # This target creates a standalone app with debugging disabled 75 | deploy: 76 | sed -i -e 's/tray = args.tray/tray = True/g' mkchromecast/__init__.py 77 | sed -i -e 's/debug = args.debug/debug = False/g' mkchromecast/__init__.py 78 | python3 setup.py py2app 79 | cp -R /usr/local/Cellar/qt/5.15.1/plugins dist/Mkchromecast.app/Contents/PlugIns 80 | /usr/local/Cellar/qt/5.15.1/bin/macdeployqt dist/Mkchromecast.app -dmg 81 | 82 | # This cleans 83 | clean: 84 | git clean -f -d 85 | git checkout mkchromecast/__init__.py 86 | rm -f mkchromecast/*.pyc 87 | rm -fr .eggs/ 88 | 89 | deepclean: 90 | git clean -f -d 91 | git checkout mkchromecast/__init__.py 92 | rm -f mkchromecast/*.pyc 93 | rm -rf dist build 94 | rm -fr .eggs/ 95 | -------------------------------------------------------------------------------- /archive/audiodevice-src.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/muammar/mkchromecast/6e583366ae23b56a33c1ad4ca164e04d64174538/archive/audiodevice-src.zip -------------------------------------------------------------------------------- /archive/audiodevice.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/muammar/mkchromecast/6e583366ae23b56a33c1ad4ca164e04d64174538/archive/audiodevice.zip -------------------------------------------------------------------------------- /bin/audiodevice: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/muammar/mkchromecast/6e583366ae23b56a33c1ad4ca164e04d64174538/bin/audiodevice -------------------------------------------------------------------------------- /bin/mkchromecast: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | # This file is part of mkchromecast. 4 | import atexit 5 | import os 6 | import signal 7 | import subprocess 8 | import sys 9 | from typing import List, Optional 10 | 11 | HERE = os.path.dirname(os.path.realpath(__file__)) 12 | if os.path.exists(os.path.join(HERE, '..', 'mkchromecast')): 13 | sys.path.insert(0, os.path.join(HERE, '..')) 14 | 15 | import mkchromecast 16 | from mkchromecast.version import __version__ 17 | from mkchromecast.audio_devices import (inputint, inputdev, outputdev, 18 | outputint) 19 | from mkchromecast import cast 20 | from mkchromecast import colors 21 | from mkchromecast.constants import OpMode 22 | from mkchromecast.pulseaudio import create_sink, get_sink_list, remove_sink 23 | from mkchromecast.utils import terminate, checkmktmp, writePidFile 24 | 25 | 26 | def maybe_execute_single_action(mkcc: mkchromecast.Mkchromecast): 27 | """Potentially executes a one-off action, followed by exiting.""" 28 | 29 | if mkcc.operation == OpMode.RESET: 30 | # TODO(xsdg): unify the various entry and cleanup codepaths. 31 | if mkcc.platform == "Darwin": 32 | inputint() 33 | outputint() 34 | else: 35 | get_sink_list() 36 | remove_sink() 37 | terminate() 38 | sys.exit(0) 39 | 40 | if mkcc.operation == OpMode.VERSION: 41 | print("mkchromecast " + "v" + colors.success(__version__)) 42 | sys.exit(0) 43 | 44 | 45 | # TODO(xsdg): Stop using conditional imports all over the place. 46 | class CastProcess(object): 47 | """Class to manage cast process""" 48 | def __init__(self, mkcc: mkchromecast.Mkchromecast): 49 | print(colors.bold('Mkchromecast ') + 'v' + __version__) 50 | self.mkcc = mkcc 51 | 52 | # Type declarations 53 | self.cc: cast.Casting 54 | 55 | def run(self): 56 | self.cc = cast.Casting(self.mkcc) 57 | checkmktmp() 58 | writePidFile() 59 | 60 | atexit.register(self.terminate_app) 61 | 62 | self.check_connection() 63 | if self.mkcc.operation == OpMode.DISCOVER: 64 | # TODO(xsdg): Move this to maybe_execute_single_action. 65 | self.cc.initialize_cast() 66 | terminate() 67 | elif self.mkcc.operation == OpMode.AUDIOCAST: 68 | self.start_audiocast() 69 | elif self.mkcc.operation == OpMode.SOURCE_URL: 70 | self.start_source_url() 71 | elif self.mkcc.operation == OpMode.YOUTUBE: 72 | self.start_youtube() 73 | elif self.mkcc.operation == OpMode.TRAY: 74 | self.start_tray() 75 | elif self.mkcc.operation in {OpMode.INPUT_FILE, OpMode.SCREENCAST}: 76 | self.cast_video() 77 | else: 78 | raise Exception( 79 | f'Unsupported or unexpected operation: {self.mkcc.operation}') 80 | 81 | def start_audiocast(self): 82 | if self.mkcc.platform == "Linux" and self.mkcc.adevice is None: 83 | print('Creating Pulseaudio Sink...') 84 | print(colors.warning( 85 | 'Open Pavucontrol and Select the Mkchromecast Sink.')) 86 | create_sink() 87 | 88 | print(colors.important('Starting Local Streaming Server')) 89 | if self.mkcc.backend == 'node': 90 | import mkchromecast.node 91 | mkchromecast.node.stream_audio() 92 | else: 93 | import mkchromecast.audio 94 | mkchromecast.audio.main() 95 | print(colors.success('[Done]')) 96 | 97 | self.cc.initialize_cast() 98 | self.get_devices(self.mkcc.select_device) 99 | 100 | if self.mkcc.platform == "Darwin": 101 | print('Switching to BlackHole...') 102 | inputdev() 103 | outputdev() 104 | print(colors.success('[Done]')) 105 | 106 | self.cc.play_cast() 107 | self.block_until_exit() 108 | 109 | def start_source_url(self): 110 | self.cc.initialize_cast() 111 | self.get_devices(self.mkcc.select_device) 112 | self.cc.play_cast() 113 | self.block_until_exit() 114 | 115 | def start_youtube(self): 116 | import mkchromecast.audio 117 | mkchromecast.audio.main() 118 | self.cc.initialize_cast() 119 | self.get_devices(self.mkcc.select_device) 120 | self.cc.play_cast() 121 | self.block_until_exit() 122 | 123 | def cast_video(self): 124 | """This method launches video casting""" 125 | 126 | if self.mkcc.platform == 'Linux': 127 | print('Creating Pulseaudio Sink...') 128 | print(colors.warning('Open Pavucontrol and Select the ' 129 | 'Mkchromecast Sink.')) 130 | create_sink() 131 | 132 | print(colors.important('Starting Video Cast Process...')) 133 | import mkchromecast.video 134 | mkchromecast.video.main() 135 | self.cc.initialize_cast() 136 | self.get_devices(self.mkcc.select_device) 137 | self.cc.play_cast() 138 | self.block_until_exit() 139 | 140 | def get_devices(self, select_device: bool, write_to_pickle: bool = True): 141 | """Get chromecast name, and let user select one from a list if 142 | select_device flag is True. 143 | """ 144 | # This is done for the case that -s is passed 145 | if select_device is True: 146 | self.cc.select_a_device() 147 | self.cc.input_device(write_to_pickle=write_to_pickle) 148 | self.cc.get_devices() 149 | else: 150 | self.cc.get_devices() 151 | 152 | def check_connection(self): 153 | """Check if the computer is connected to a network""" 154 | if self.cc.ip == '127.0.0.1': # We verify the local IP. 155 | print(colors.error('Your Computer is not Connected to Any ' 156 | 'Network')) 157 | terminate() 158 | 159 | def terminate_app(self): 160 | """Terminate the app (kill app)""" 161 | if self.mkcc.debug: 162 | print(f'terminate_app running in pid {os.getpid()}') 163 | 164 | self.cc.stop_cast() 165 | if self.mkcc.platform == 'Darwin': 166 | inputint() 167 | outputint() 168 | elif self.mkcc.platform == 'Linux': 169 | remove_sink() 170 | terminate() # Does not return. 171 | 172 | def print_controls_msg(self): 173 | """Messages shown when controls is True""" 174 | print('') 175 | print(colors.important('Controls:')) 176 | print(colors.important('=========')) 177 | print('') 178 | print(colors.options( 'Volume Up:') + ' u') 179 | print(colors.options( 'Volume Down:') + ' d') 180 | print(colors.options( 'Attach device:') + ' a') 181 | 182 | if self.mkcc.videoarg is True: 183 | print(colors.options( 'Pause Casting:')+' p') 184 | print(colors.options( 'Resume Casting:')+' r') 185 | print(colors.options('Quit the Application:')+' q or Ctrl-C') 186 | print('') 187 | 188 | def block_until_exit(self) -> None: 189 | """Method to show controls""" 190 | try: 191 | if self.mkcc.control: 192 | from mkchromecast.getch import getch 193 | 194 | self.print_controls_msg() 195 | 196 | while(True): 197 | key = getch() 198 | if(key == 'u'): 199 | self.cc.volume_up() 200 | if self.mkcc.backend == 'ffmpeg': 201 | if self.mkcc.debug is True: 202 | self.print_controls_msg() 203 | elif(key == 'd'): 204 | self.cc.volume_down() 205 | if self.mkcc.backend == 'ffmpeg': 206 | if self.mkcc.debug is True: 207 | self.print_controls_msg() 208 | elif (key == 'a'): 209 | print(self.cc.available_devices) 210 | self.get_devices(self.mkcc.select_device, 211 | write_to_pickle=False) 212 | self.cc.play_cast() 213 | elif(key == 'p'): 214 | if self.mkcc.videoarg is True: 215 | print('Pausing Casting Process...') 216 | action = 'pause' 217 | self.backend_handler(action, self.mkcc.backend) 218 | if self.mkcc.backend == 'ffmpeg': 219 | if self.mkcc.debug is True: 220 | self.print_controls_msg() 221 | elif(key == 'r'): 222 | if self.mkcc.videoarg is True: 223 | print('Resuming Casting Process...') 224 | action = 'resume' 225 | self.backend_handler(action, self.mkcc.backend) 226 | if self.mkcc.backend == 'ffmpeg': 227 | if self.mkcc.debug is True: 228 | self.print_controls_msg() 229 | elif(key in {'q', '\x03'}): 230 | # "q" or ^D 231 | raise KeyboardInterrupt 232 | 233 | else: 234 | if self.mkcc.platform == 'Linux' and self.mkcc.adevice is None: 235 | print(colors.warning('Remember to open pavucontrol and select ' 236 | 'the mkchromecast sink.')) 237 | print('') 238 | print(colors.error('Ctrl-C to kill the Application at any Time')) 239 | print('') 240 | signal.pause() 241 | 242 | except KeyboardInterrupt: 243 | print() 244 | print(colors.error('Quitting application...')) 245 | self.terminate_app() 246 | 247 | def backend_handler(self, action, backend): 248 | """Methods to handle pause and resume state of backends""" 249 | # TODO(xsdg): Woah, this isn't right. We should specifically only 250 | # send signals to processes that are our children (and we should do so 251 | # by using the Popen library, and not by shelling out to pkill). 252 | if action == 'pause' and backend == 'ffmpeg': 253 | subprocess.call(['pkill', '-STOP', '-f', 'ffmpeg']) 254 | elif action == 'resume' and backend == 'ffmpeg': 255 | subprocess.call(['pkill', '-CONT', '-f', 'ffmpeg']) 256 | elif (action == 'pause' and backend == 'node' and 257 | self.mkcc.platform == 'Linux'): 258 | subprocess.call(['pkill', '-STOP', '-f', 'nodejs']) 259 | elif (action == 'resume' and backend == 'node' and 260 | self.mkcc.platform == 'Linux'): 261 | subprocess.call(['pkill', '-CONT', '-f', 'nodejs']) 262 | elif (action == 'pause' and backend == 'node' and 263 | self.mkcc.platform == 'Darwin'): 264 | subprocess.call(['pkill', '-STOP', '-f', 'node']) 265 | elif (action == 'resume' and backend == 'node' and 266 | self.mkcc.platform == 'Darwin'): 267 | subprocess.call(['pkill', '-CONT', '-f', 'node']) 268 | 269 | if action == 'pause': 270 | self.cc.pause() 271 | elif action == 'resume': 272 | self.cc.play() 273 | 274 | def start_tray(self): 275 | """This method starts the system tray""" 276 | import mkchromecast.systray 277 | # TODO(xsdg): checkmktmp and writePidFile are guaranteed to have been 278 | # called already. 279 | checkmktmp() 280 | writePidFile() 281 | mkchromecast.systray.main() 282 | 283 | 284 | if __name__ == "__main__": 285 | mkcc = mkchromecast.Mkchromecast() 286 | maybe_execute_single_action(mkcc) 287 | 288 | CastProcess(mkcc).run() 289 | -------------------------------------------------------------------------------- /changelog.md: -------------------------------------------------------------------------------- 1 | * Mkchromecast (0.3.9) **unreleased** 2 | 3 | - New flag `--loop` to loop video indefinitely while streaming. Closes 4 | #113. 5 | - New flag `--mtype` to specify media type. Closes #128. 6 | - Modified how pyqt is used to accomodate pyqt 5.5, which is what comes in 7 | Ubuntu 16.04 LTS 8 | - Removed the -max_muxing_queue_size ffmpeg flag because it's not supported 9 | in ffmpeg 2.8.11. 10 | - Screencast with audio. 11 | - 10bits mkv files are now encoded to 8bits. This fixes #156. 12 | - node streaming updated to work with `node v9.8.0`. 13 | - Improved pause/resume when casting videos. Closes #97. 14 | - Added support for Fedora packages. Thanks to @leamas. 15 | - setup.py Linux support. Closes #173. Thanks to @leamas. 16 | - `nodejs` content is dropped. We provide now `package.json` and 17 | `package-lock.json` files. This will considerably reduce repository size. 18 | Thanks to @leamas. 19 | - Using the `--youtube` flag works for audio-only streaming. 20 | - Fixed inconsistency when node is not installed. 21 | - Refactored IP assignment. Thanks to Rick Brown. 22 | - You can attach devices to a running streaming audio session 23 | (experimental). 24 | - Move from SoundFlower to BlackHole for macOS. Closes #289. 25 | 26 | * mkchromecast (0.3.8.1) **2017/12/24** 27 | 28 | - A bug when no devices where found has been fixed in this release. 29 | 30 | * mkchromecast (0.3.8) **2017/12/23** 31 | 32 | - Improved stability. 33 | - The macOS bundle is now renamed with capital M, and will not be showing 34 | in the dock. 35 | - Chunk size changed from 1024 to 64. Added two more variables that 36 | decreases the delay considerably. 37 | - Improved ffmpeg commands for pulseaudio part of the code. 38 | - node streaming updated to work with `node v9.3.0`. 39 | - Manpage is now shipped in main branches. 40 | - TypeError caused by a print statement for Soco devices has been fixed. 41 | Closes #80. 42 | - Added Opus codec support. 43 | - It is now possible to screencast using mkchromecast. 44 | - Using the `--youtube` flag works with all supported websites by youtube-dl. 45 | - Correct signal handling using the `signal` module. PR #87 by @Foxboron. 46 | - Renamed `--reconnect` to `--hijack`. Closes #25. 47 | - New flag `tries` to to limit the number of tries to connect to 48 | a chromecast. Closes #54. 49 | - Allow custom server port with ffmpeg or avconv. Related to #122. 50 | - Error with "width not divisible by 2 (853x480)". Closes issue #119. 51 | - The `segment_time` flag has been fixed. Closes issue #71. 52 | - New flag `command` for setting a custom ffmpeg command. Closes issue 53 | #109. 54 | 55 | * mkchromecast (0.3.7.1) **2017/05/21** 56 | 57 | - macOS bundle built in Yosemite to add more compatibility. 58 | - Bumped version to match debian official release. 59 | 60 | * mkchromecast (0.3.7) **2017/05/20** 61 | 62 | - node streaming updated to work with `node v7.10.0`. 63 | - Added ALSA device configuration in preferences pane. 64 | - Improved systray stability, and usability. 65 | - gstreamer support for capturing audio. 66 | - Fixed problem related to setting `ogg` and `aac` bitrate. Closes #21. 67 | - A `--segment-time` option added to modify the segment time when using 68 | ffmpeg. 69 | - A `--reconnect` option that monitors if mkchromecast has disconnected 70 | from google cast, and tries to reconnect. 71 | Closes [#25](https://github.com/muammar/mkchromecast/issues/25). 72 | - ALSA device can be set from systray. 73 | - Add support for newer `pychromecast` versions. 74 | Closes [#32](https://github.com/muammar/mkchromecast/pull/32). 75 | - Making ping code python 3 compatible. Closes: 76 | [#35](https://github.com/muammar/mkchromecast/pull/35). 77 | - Fixed problem when having various Google cast devices. Closes 78 | [#50](https://github.com/muammar/mkchromecast/issues/50) 79 | - Added support to 192000Hz sampling rate support (Closes: 80 | [#39](https://github.com/muammar/mkchromecast/issues/39)). 81 | - Fixed a minimal problem with size of preferences pane. 82 | - Video support. 83 | - node is a supported backend for streaming video. 84 | - Added Sonos speakers support. 85 | - FLAC codec supports bitrate. 86 | 87 | * mkchromecast (0.3.6) **released**: 2016/09/19 88 | 89 | - The node streaming has been updated to work with `node v6.6.0`. 90 | - `Ctrl-C` now raises `KeyboardInterrupt` when using `--volume` option from 91 | console. 92 | - Improvements under the hood. 93 | - Now mkchromecast does not need pulseaudio to cast!. 94 | - You can play from stream using the `--source-url` flag. 95 | - `-d` option has been enable to discover available Google cast devices. 96 | - `--host` flag allows users to manually enter the local ip. Closes 97 | [#17](https://github.com/muammar/mkchromecast/issues/17). 98 | - `-n` flag allows users to pass the name of their cast devices. 99 | 100 | * mkchromecast (0.3.5) **released**: 2016/08/26 101 | 102 | - Added close button for preferences pane. Closes 103 | [#13](https://github.com/muammar/mkchromecast/issues/13) 104 | - Improvements for cases where there are virtual network interfaces 105 | present. 106 | - Dropped `-re` flag from ffmpeg and avconv commands. 107 | - Renamed `ffmpeg.py` to `audio.py`. 108 | 109 | * mkchromecast (0.3.4) **released**: 2016/08/19 110 | 111 | - New white icons. 112 | - Added 96000Hz sampling rate support for `ffmpeg` and `avconv` backends. 113 | Closes [#11](https://github.com/muammar/mkchromecast/issues/11). 114 | - Fixed 48000Hz sample rate case. 115 | - The node streaming has been updated to work with `node v6.4.0`. 116 | 117 | * mkchromecast (0.3.3) **released**: 2016/08/16 118 | 119 | - Improved MultiRoom support. Closes 120 | [#8](https://github.com/muammar/mkchromecast/issues/8). 121 | 122 | * mkchromecast (0.3.2) **released**: 2016/06/15 123 | 124 | - Improvements for cases where chromecasts have non-ascii characters in 125 | their names. Closes 126 | [#7](https://github.com/muammar/mkchromecast/issues/7). 127 | 128 | * mkchromecast (0.3.1) **released**: 2016/08/12 129 | 130 | - Improved Preferences window. 131 | - The node streaming has been updated to work with `node v6.3.1`. 132 | - Improvements in pulseaudio.py for preventing subprocess.Popen from 133 | displaying output. 134 | - Improvements in `Check For Updates` method. 135 | - Added new option `--chunk-size` to control chunk's size of flask server 136 | when streaming using `ffmpeg` or `avconv`. 137 | 138 | * mkchromecast (0.3.0) **released**: 2016/07/12 139 | 140 | - Youtube URLs can be played piping the audio using `youtube-dl`. 141 | - New method for discovering local IP in macOS. This will improve the 142 | stability of the system tray application. Closes 143 | [#5](https://github.com/muammar/mkchromecast/issues/5). 144 | - Now it is possible to select a different color for the system tray icon. 145 | This is useful when using dark themes. 146 | - The node streaming has been updated to work with `node v6.3.0`. 147 | - Improved stability when using system tray icon. 148 | - New method in preferences window to reset to default configurations. 149 | Closes [#6](https://github.com/muammar/mkchromecast/issues/6). 150 | 151 | * mkchromecast (0.2.9.1) **released**: 2016/06/29 152 | 153 | - Fixing `stop` segfault. 154 | 155 | * mkchromecast (0.2.9) **released**: 2016/06/29 156 | 157 | - Improved stability when using system tray icon. 158 | - New `search at launch` option. When enabled, the system tray search for 159 | available Google cast devices at launch time. 160 | - The node streaming server has been updated to work with `node v6.2.2`. 161 | 162 | * mkchromecast (0.2.8) **released**: 2016/06/22 163 | 164 | - Preferences and volume windows always on top. 165 | - The node streaming has been updated to work with v6.2.1. This improves 166 | stability for macOS users when streaming with node. 167 | - It is now possible to check for updates using the system tray icon. 168 | - Scale factor added for retina resolutions. 169 | 170 | * mkchromecast (0.2.7) **released**: 2016/06/16 171 | 172 | - Volume now set to max/40 instead of max/10. I have remarked that changing 173 | volume directly in the chromecast is more stable and faster than doing it 174 | from the streaming part, e.g. pavucontrol, or soundflower. 175 | - General improvements in system tray's behavior. 176 | - An error when setting volume to maximum has been fixed. 177 | - Now the muted time when launching a cast session is reduced. This is 178 | possible given that Soundflower changes input/output devices 179 | automatically. Linux users have to select a sink in pavucontrol. 180 | - Change from `Mac OS X` to `macOS`. 181 | 182 | * mkchromecast (0.2.6) **released**: 2016/06/08 183 | 184 | - Volume now set to max/20 instead of max/10. 185 | - This release lets Linux users cast using `parec` with external libraries 186 | instead of `ffmpeg` or `avconv`. 187 | - The program does not import any PyQt5 module when launched without the 188 | system tray. 189 | - A deb package is now provided in this release. 190 | - The system tray will retry stopping the google cast before closing 191 | abruptly. 192 | - The Mac OS X application now supports models below the year 2010. See 193 | [#4](https://github.com/muammar/mkchromecast/issues/4). 194 | 195 | * mkchromecast (0.2.5) **released**: 2016/05/25 196 | 197 | - This release fixes a problem with the system tray menu failing to read 198 | configuration files when changing from lossless to lossy audio coding 199 | formats. 200 | 201 | * mkchromecast (0.2.4) **released**: 2016/05/21 202 | 203 | - This release fixes the system tray menu for Linux. 204 | - Now avconv is supported. 205 | - Pass the `--update` option to update the repository. 206 | - It is now possible to control the Google cast device volume from the 207 | systray. 208 | - You can reboot the Google cast devices from the systray. 209 | - There is a Preferences window to control `backends`, `bitrates`, `sample 210 | rates`, `audio coding formats` and `notifications`. 211 | 212 | * mkchromecast (0.2.3.1) **released**: 2016/05/08 213 | 214 | - This release fixes the Signal 9 passed by error in the Stand-alone 215 | application. 216 | 217 | * mkchromecast (0.2.3) **released**: 2016/05/08 218 | 219 | - The code has been partially refactored to ease maintenance. 220 | - Printed messages have been improved. 221 | - Stand-alone application for Mac OS X is released. It only works for the 222 | `node` backend. 223 | 224 | * mkchromecast (0.2.2) **released**: 2016/05/03 225 | 226 | - Fixed error with Python2 when importing threading module. 227 | 228 | * mkchromecast (0.2.1) **released**: 2016/05/02 229 | 230 | - Method for monitoring `ffmpeg` backend. This update is useful for those 231 | who use the system tray menu. 232 | 233 | * mkchromecast (0.2.0.2) **released**: 2016/05/01 234 | 235 | - Fixed wav for Linux. 236 | 237 | * mkchromecast (0.2.0.1) **released**: 2016/05/01 238 | 239 | - Catching some minimal errors. 240 | 241 | * mkchromecast (0.2.0) **released**: 2016/05/01 242 | 243 | - Linux support. 244 | - Linux only plays with ffmpeg. 245 | - Now by default log level for `ffmpeg` backend is set to minimum. 246 | 247 | * mkchromecast (0.1.9.1) **released**: 2016/04/27 248 | 249 | - Fixed node bug introduced in f635c5d66649767a031ac560d7c32ba6bffe33fe. 250 | 251 | * mkchromecast (0.1.9) **released**: 2016/04/25 252 | 253 | - Play headless youtube URL. 254 | - Now you can control volume up and down from mkchromecast. So, no need 255 | of using the Android app for this anymore. 256 | 257 | * mkchromecast (0.1.8.1) **released**: 2016/04/21 258 | 259 | - Set maximum bitrates and sample rates values for `node`. 260 | 261 | * mkchromecast (0.1.8) **released**: 2016/04/21 262 | 263 | - Set maximum bitrates and sample rates values for both backends. 264 | - New icon when no google cast devices are found. 265 | - `streaming.py` was renamed to `node.py`. 266 | - Tested stability: 3hrs streaming at 320k with the `ffmpeg` backend. 267 | 268 | * mkchromecast (0.1.7) **released**: 2016/04/18 269 | 270 | - The bitrate and sample rates can be modified in both node and ffmpeg. 271 | These options are useful when you router is not very powerful. 272 | - node_modules have been updated. 273 | - An error preventing launching without options has been fixed. 274 | - If PyQt5 is not present in the system, mkchromecast does not try to load 275 | it. 276 | 277 | * mkchromecast (0.1.6) **released**: 2016/04/16 278 | 279 | - ffmpeg is now a supported backend. You can check how to use this backend 280 | by consulting `python mkchromecast.py -h`. 281 | - The following codecs are supported: 'mp3', 'ogg', 'aac', 'wav', 'flac'. 282 | - Improved screen messages. 283 | - Date format in changelog has been changed. 284 | 285 | * mkchromecast (0.1.5) **released**: Wed Apr 13 18:08:44 2016 +0200 286 | 287 | This version has the following improvements: 288 | 289 | - If the application fails, the process that ensures streaming with node will 290 | kill all streaming servers created by mkchromecast. 291 | - Now there is a systray menu that you can launch as follows: 292 | `python mkchromecast.py -t`. 293 | 294 | - To use it, you need to install PyQt5. In homebrew you can do it as 295 | follows: `brew install pyqt5 --with-python`. You can use the package 296 | manager of your preference. 297 | - In a future release, a standalone application will be provided. 298 | 299 | * mkchromecast (0.1.4) **released**: Mon Mar 28 19:00:28 2016 +0200 300 | 301 | - Now you can pass arguments to mkchromecast. For more information: 302 | `python mkchromecast -h`. 303 | - In this version you can choose devices from a list using: 304 | `python mkchromecast -s`. 305 | - Some improvements to the messages printed on screen. 306 | 307 | * mkchromecast (0.1.3) **released**: Sun Mar 27 16:17:11 2016 +0200 308 | 309 | - Updated requirements.txt. 310 | - Now some help can be shown. 311 | - The code is now licensed under MIT. I think this license is simpler. 312 | 313 | * mkchromecast (0.1.2) **released**: Sat Mar 26 18:49:18 2016 +0100 314 | 315 | This new revision has the following improvements: 316 | 317 | - mkchromecast has been ported to work with Python3. This is also possible 318 | because pychromecast works as well. The nodejs binary has been recompiled, 319 | and node_modules have been updated. The program seems to be more stable. 320 | 321 | * mkchromecast (0.1.1) **released**: Fri Mar 25 23:59:12 2016 +0100 322 | 323 | - In this new version multithreading is dropped in favor of 324 | multiprocessing. This is to reconnect the streaming server easily. Killing 325 | processes is easier than killing threading it seems. 326 | 327 | I strongly encourage you to upgrade to this version. 328 | 329 | * mkchromecast (0.1.0) **released**: Fri Mar 25 13:21:12 2016 +0100 330 | 331 | - In this beta release, the program casts to the first google cast found in 332 | the list. If the node streaming server fails, the program reconnects. 333 | -------------------------------------------------------------------------------- /images/Awesome_BI.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/muammar/mkchromecast/6e583366ae23b56a33c1ad4ca164e04d64174538/images/Awesome_BI.png -------------------------------------------------------------------------------- /images/Gnome1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/muammar/mkchromecast/6e583366ae23b56a33c1ad4ca164e04d64174538/images/Gnome1.png -------------------------------------------------------------------------------- /images/Gnome2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/muammar/mkchromecast/6e583366ae23b56a33c1ad4ca164e04d64174538/images/Gnome2.png -------------------------------------------------------------------------------- /images/Kde5_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/muammar/mkchromecast/6e583366ae23b56a33c1ad4ca164e04d64174538/images/Kde5_1.png -------------------------------------------------------------------------------- /images/Kde5_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/muammar/mkchromecast/6e583366ae23b56a33c1ad4ca164e04d64174538/images/Kde5_2.png -------------------------------------------------------------------------------- /images/change_samplerate1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/muammar/mkchromecast/6e583366ae23b56a33c1ad4ca164e04d64174538/images/change_samplerate1.png -------------------------------------------------------------------------------- /images/change_samplerate2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/muammar/mkchromecast/6e583366ae23b56a33c1ad4ca164e04d64174538/images/change_samplerate2.png -------------------------------------------------------------------------------- /images/exmark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/muammar/mkchromecast/6e583366ae23b56a33c1ad4ca164e04d64174538/images/exmark.png -------------------------------------------------------------------------------- /images/google.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/muammar/mkchromecast/6e583366ae23b56a33c1ad4ca164e04d64174538/images/google.icns -------------------------------------------------------------------------------- /images/google.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/muammar/mkchromecast/6e583366ae23b56a33c1ad4ca164e04d64174538/images/google.png -------------------------------------------------------------------------------- /images/google_b.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/muammar/mkchromecast/6e583366ae23b56a33c1ad4ca164e04d64174538/images/google_b.icns -------------------------------------------------------------------------------- /images/google_b.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/muammar/mkchromecast/6e583366ae23b56a33c1ad4ca164e04d64174538/images/google_b.png -------------------------------------------------------------------------------- /images/google_nodev.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/muammar/mkchromecast/6e583366ae23b56a33c1ad4ca164e04d64174538/images/google_nodev.icns -------------------------------------------------------------------------------- /images/google_nodev.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/muammar/mkchromecast/6e583366ae23b56a33c1ad4ca164e04d64174538/images/google_nodev.png -------------------------------------------------------------------------------- /images/google_nodev_b.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/muammar/mkchromecast/6e583366ae23b56a33c1ad4ca164e04d64174538/images/google_nodev_b.icns -------------------------------------------------------------------------------- /images/google_nodev_b.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/muammar/mkchromecast/6e583366ae23b56a33c1ad4ca164e04d64174538/images/google_nodev_b.png -------------------------------------------------------------------------------- /images/google_nodev_w.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/muammar/mkchromecast/6e583366ae23b56a33c1ad4ca164e04d64174538/images/google_nodev_w.icns -------------------------------------------------------------------------------- /images/google_nodev_w.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/muammar/mkchromecast/6e583366ae23b56a33c1ad4ca164e04d64174538/images/google_nodev_w.png -------------------------------------------------------------------------------- /images/google_w.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/muammar/mkchromecast/6e583366ae23b56a33c1ad4ca164e04d64174538/images/google_w.icns -------------------------------------------------------------------------------- /images/google_w.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/muammar/mkchromecast/6e583366ae23b56a33c1ad4ca164e04d64174538/images/google_w.png -------------------------------------------------------------------------------- /images/google_working.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/muammar/mkchromecast/6e583366ae23b56a33c1ad4ca164e04d64174538/images/google_working.icns -------------------------------------------------------------------------------- /images/google_working.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/muammar/mkchromecast/6e583366ae23b56a33c1ad4ca164e04d64174538/images/google_working.png -------------------------------------------------------------------------------- /images/google_working_b.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/muammar/mkchromecast/6e583366ae23b56a33c1ad4ca164e04d64174538/images/google_working_b.icns -------------------------------------------------------------------------------- /images/google_working_b.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/muammar/mkchromecast/6e583366ae23b56a33c1ad4ca164e04d64174538/images/google_working_b.png -------------------------------------------------------------------------------- /images/google_working_w.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/muammar/mkchromecast/6e583366ae23b56a33c1ad4ca164e04d64174538/images/google_working_w.icns -------------------------------------------------------------------------------- /images/google_working_w.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/muammar/mkchromecast/6e583366ae23b56a33c1ad4ca164e04d64174538/images/google_working_w.png -------------------------------------------------------------------------------- /images/max.svg: -------------------------------------------------------------------------------- 1 | 2 | image/svg+xml 23 | 26 | 29 | 30 | -------------------------------------------------------------------------------- /images/mkchromecast_linux.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/muammar/mkchromecast/6e583366ae23b56a33c1ad4ca164e04d64174538/images/mkchromecast_linux.gif -------------------------------------------------------------------------------- /images/musicalnote.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/muammar/mkchromecast/6e583366ae23b56a33c1ad4ca164e04d64174538/images/musicalnote.icns -------------------------------------------------------------------------------- /images/musicalnote.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/muammar/mkchromecast/6e583366ae23b56a33c1ad4ca164e04d64174538/images/musicalnote.png -------------------------------------------------------------------------------- /images/mute.svg: -------------------------------------------------------------------------------- 1 | 2 | image/svg+xml 61 | 62 | 68 | 74 | -------------------------------------------------------------------------------- /images/screencast.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/muammar/mkchromecast/6e583366ae23b56a33c1ad4ca164e04d64174538/images/screencast.png -------------------------------------------------------------------------------- /images/two-arrows-refresh-symbol.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/muammar/mkchromecast/6e583366ae23b56a33c1ad4ca164e04d64174538/images/two-arrows-refresh-symbol.png -------------------------------------------------------------------------------- /man/mkchromecast.1: -------------------------------------------------------------------------------- 1 | .\" Hey, EMACS: -*- nroff -*- 2 | .\" (C) Copyright 2016 Muammar El Khatib , 3 | .\" 4 | .\" First parameter, NAME, should be all caps 5 | .\" Second parameter, SECTION, should be 1-8, maybe w/ subsection 6 | .\" other parameters are allowed: see man(7), man(1) 7 | .TH Mkchromecast "1" "March 30 2017" 8 | .\" Please adjust this date whenever revising the manpage. 9 | .\" 10 | .\" Some roff macros, for reference: 11 | .\" .nh disable hyphenation 12 | .\" .hy enable hyphenation 13 | .\" .ad l left justify 14 | .\" .ad b justify to both left and right margins 15 | .\" .nf disable filling 16 | .\" .fi enable filling 17 | .\" .br insert line break 18 | .\" .sp insert n+1 empty lines 19 | .\" for manpage-specific macros, see man(7) 20 | .SH NAME 21 | mkchromecast \- cast audio and video to Google cast devices 22 | .SH SYNOPSIS 23 | .B mkchromecast 24 | .RI [ options ] 25 | .SH DESCRIPTION 26 | This manual page documents briefly the 27 | .B mkchromecast 28 | commands. 29 | .PP 30 | .\" TeX users may be more comfortable with the \fB\fP and 31 | .\" \fI\fP escape sequences to invode bold face and italics, 32 | .\" respectively. 33 | \fBmkchromecast\fP is a program that allows you to cast your audio or video 34 | files to your Google cast devices. 35 | .SH OPTIONS 36 | This program follows the usual GNU command line syntax, with long 37 | options starting with two dashes (`-'). 38 | A summary of options is included below. 39 | For a complete description, see the Info files. 40 | .TP 41 | .B \-h, \-\-help 42 | Show summary of options. 43 | .TP 44 | .B \-v, \-\-version 45 | Show version of program. 46 | .SH SEE ALSO 47 | .\".BR bar (1), 48 | .\".BR baz (1). 49 | .\".br 50 | The program is documented fully executing 51 | .IR "mkchromecast -h" , 52 | that should give you complete access to the usage. 53 | -------------------------------------------------------------------------------- /mkchromecast.1: -------------------------------------------------------------------------------- 1 | .\" Hey, EMACS: -*- nroff -*- 2 | .\" (C) Copyright 2017 Muammar El Khatib , 3 | .\" 4 | .\" First parameter, NAME, should be all caps 5 | .\" Second parameter, SECTION, should be 1-8, maybe w/ subsection 6 | .\" other parameters are allowed: see man(7), man(1) 7 | .TH Mkchromecast "1" "December 23 2017" 8 | .\" Please adjust this date whenever revising the manpage. 9 | .\" 10 | .\" Some roff macros, for reference: 11 | .\" .nh disable hyphenation 12 | .\" .hy enable hyphenation 13 | .\" .ad l left justify 14 | .\" .ad b justify to both left and right margins 15 | .\" .nf disable filling 16 | .\" .fi enable filling 17 | .\" .br insert line break 18 | .\" .sp insert n+1 empty lines 19 | .\" for manpage-specific macros, see man(7) 20 | .SH NAME 21 | mkchromecast \- cast audio and video to Google cast devices 22 | .SH SYNOPSIS 23 | .B mkchromecast 24 | .RI [ options ] 25 | .SH DESCRIPTION 26 | This manual page documents briefly the 27 | .B mkchromecast 28 | commands. 29 | .PP 30 | .\" TeX users may be more comfortable with the \fB\fP and 31 | .\" \fI\fP escape sequences to invode bold face and italics, 32 | .\" respectively. 33 | \fBmkchromecast\fP is a program that allows you to cast your audio or video 34 | files to your Google cast or Sonos devices. 35 | .SH OPTIONS 36 | This program follows the usual GNU command line syntax, with long 37 | options starting with two dashes (`-'). 38 | A summary of options is included below. 39 | For a complete description, see the Info files. 40 | .TP 41 | .B \-h, \-\-help 42 | Show summary of options. 43 | .TP 44 | .B \-v, \-\-version 45 | Show version of program. 46 | .SH SEE ALSO 47 | .\".BR bar (1), 48 | .\".BR baz (1). 49 | .\".br 50 | The program is documented fully executing 51 | .IR "mkchromecast -h" , 52 | that should give you complete access to the usage. 53 | -------------------------------------------------------------------------------- /mkchromecast.desktop: -------------------------------------------------------------------------------- 1 | [Desktop Entry] 2 | Name=mkchromecast 3 | Comment=Cast your Linux audio or video files to your Google cast or Sonos devices 4 | Exec=/usr/bin/mkchromecast -t 5 | Terminal=false 6 | Type=Application 7 | Icon=/usr/share/pixmaps/mkchromecast.xpm 8 | Categories=AudioVideo;Audio; 9 | Keywords=audio;chromecast;google;cast;pychromecast; 10 | -------------------------------------------------------------------------------- /mkchromecast/__init__.py: -------------------------------------------------------------------------------- 1 | # This file is part of Mkchromecast. 2 | 3 | import os 4 | import platform 5 | import shlex 6 | import subprocess 7 | import sys 8 | from typing import Optional 9 | 10 | from mkchromecast import _arg_parsing 11 | from mkchromecast import colors 12 | from mkchromecast import config 13 | from mkchromecast import constants 14 | from mkchromecast.constants import OpMode 15 | from mkchromecast.utils import terminate, check_url 16 | from mkchromecast.resolution import resolutions 17 | 18 | class Mkchromecast: 19 | """A singleton object that encapsulates Mkchromecast state.""" 20 | _parsed_args = None 21 | 22 | def __init__(self, args = None): 23 | # TODO(xsdg): Require arg parsing to be done outside of this class. 24 | first_parse: bool = False 25 | if not args: 26 | if not self._parsed_args: 27 | Mkchromecast._parsed_args = _arg_parsing.Parser.parse_args() 28 | first_parse = True 29 | args = Mkchromecast._parsed_args 30 | 31 | self.args = args 32 | self.debug: bool = args.debug 33 | 34 | # Operating Mode 35 | self.operation: OpMode 36 | if args.discover: 37 | self.operation = OpMode.DISCOVER 38 | elif args.input_file: 39 | self.operation = OpMode.INPUT_FILE 40 | elif args.reset: 41 | self.operation = OpMode.RESET 42 | elif args.screencast: 43 | self.operation = OpMode.SCREENCAST 44 | elif args.source_url: 45 | self.operation = OpMode.SOURCE_URL 46 | elif args.tray: 47 | self.operation = OpMode.TRAY 48 | elif args.version: 49 | self.operation = OpMode.VERSION 50 | elif args.youtube: 51 | self.operation = OpMode.YOUTUBE 52 | else: 53 | self.operation = OpMode.AUDIOCAST 54 | 55 | # Need platform and debug (above) for config loading. 56 | self.platform: str = platform.system() 57 | 58 | tray_config: Optional[config.Config] = None 59 | if self.operation == OpMode.TRAY: 60 | # TODO(xsdg): Probably don't need to initialize most of this class 61 | # for Tray mode. 62 | tray_config = config.Config(platform=self.platform, 63 | read_only=True, 64 | debug=self.debug) 65 | tray_config.load_and_validate() 66 | 67 | # Arguments with no dependencies. 68 | # Groupings are mostly carried over from earlier code; unclear how 69 | # meaningful they are. 70 | self.adevice: Optional[str] = ( 71 | tray_config.alsa_device if tray_config else args.alsa_device) 72 | 73 | self.notifications: bool = ( 74 | tray_config.notifications if tray_config else args.notifications) 75 | self.search_at_launch: Optional[bool] 76 | if tray_config: 77 | self.search_at_launch = tray_config.search_at_launch 78 | else: 79 | self.search_at_launch = None 80 | self.colors: Optional[str] = tray_config.colors if tray_config else None 81 | self.tray: bool = args.tray 82 | 83 | self.discover: bool = args.discover 84 | self.host: Optional[str] = args.host 85 | # TODO(xsdg): Switch input_file to pathlib.Path 86 | self.input_file: Optional[str] = args.input_file 87 | self.source_url: Optional[str] = args.source_url 88 | self.subtitles: Optional[str] = args.subtitles 89 | self.hijack: bool = args.hijack 90 | self.device_name: Optional[str] = args.name 91 | self.port: int = args.port # TODO(xsdg): Validate range 0..65535. 92 | self.fps: str = args.fps # TODO(xsdg): Why is this typed as a str? 93 | 94 | self.mtype: Optional[str] = args.mtype 95 | self.reset: bool = args.reset 96 | self.version: bool = args.version 97 | 98 | self.screencast: bool = args.screencast 99 | self.display: Optional[str] = args.display 100 | self.vcodec: str = args.vcodec 101 | self.loop: bool = args.loop 102 | self.seek: Optional[str] = args.seek 103 | 104 | self.control: bool = args.control 105 | self.tries: Optional[int] = args.tries 106 | self.videoarg: bool = args.video 107 | 108 | # Arguments that depend on other arguments. 109 | self.select_device: bool = True if self.tray else args.select_device 110 | 111 | backend_options = constants.backend_options_for_platform( 112 | self.platform, args.video 113 | ) 114 | 115 | self.backend: Optional[str] 116 | if tray_config: 117 | self.backend = tray_config.backend 118 | elif args.encoder_backend: 119 | if args.encoder_backend not in backend_options: 120 | print(colors.error(f"Backend {args.encoder_backend} is not in " 121 | "supported backends: ")) 122 | for backend in backend_options: 123 | print(f"- {backend}.") 124 | sys.exit(0) 125 | 126 | # encoder_backend is reasonable. 127 | self.backend = args.encoder_backend 128 | else: 129 | if args.video: 130 | self.backend = "ffmpeg" 131 | elif self.platform == "Darwin": 132 | self.backend = "node" 133 | else: # self.platform == "Linux" 134 | self.backend = "parec" 135 | 136 | codec_choices = ["mp3", "ogg", "aac", "opus", "wav", "flac"] 137 | self.codec: str 138 | 139 | if self.operation == OpMode.SOURCE_URL: 140 | self.codec = args.codec 141 | elif tray_config: # OpMode.TRAY 142 | # TODO(xsdg): Validate config codec. 143 | self.codec = tray_config.codec 144 | elif self.backend == "node": 145 | if args.codec != "mp3": 146 | print(colors.warning(f"Setting codec from {args.codec} to mp3, " 147 | "as required by node backend")) 148 | self.codec = "mp3" 149 | else: # not source_url and backend != "node" 150 | if args.codec not in codec_choices: 151 | print(colors.options(f"Selected audio codec: {args.codec}.")) 152 | print(colors.error("Supported audio codecs are: ")) 153 | for codec in codec_choices: 154 | print(f"- {codec}") 155 | sys.exit(0) 156 | 157 | self.codec = args.codec 158 | 159 | # TODO(xsdg): Add support for yt-dlp 160 | command_choices = ["ffmpeg", "yt-dlp"] 161 | self.command: Optional[str] 162 | if not args.command: 163 | self.command = None 164 | else: 165 | # TODO(xsdg): Unbreak this so that it can accept a full command 166 | # string, and not just a command name. 167 | if args.command not in command_choices: 168 | print(colors.options(f"Configured command: {args.command}")) 169 | print(colors.error("Supported commands are: ")) 170 | for command in command_choices: 171 | print(f"- {command}") 172 | sys.exit(0) 173 | 174 | self.command = args.command 175 | 176 | resolution_choices = [r.lower() for r in resolutions.keys()] 177 | self.resolution: Optional[str] 178 | if not args.resolution: 179 | self.resolution = None 180 | else: 181 | if args.resolution.lower() not in resolution_choices: 182 | print(colors.options( 183 | f"Configured resolution: {args.resolution.lower()}")) 184 | print(colors.error("Supported resolutions are: ")) 185 | for resolution in resolution_choices: 186 | print(f"- {resolution}") 187 | sys.exit(0) 188 | 189 | self.resolution = args.resolution 190 | 191 | self.bitrate: int 192 | if tray_config: 193 | self.bitrate = tray_config.bitrate 194 | elif self.codec in constants.CODECS_WITH_BITRATE: 195 | if args.bitrate <= 0: 196 | print(colors.error("Bitrate must be a positive integer")) 197 | sys.exit(0) 198 | 199 | self.bitrate = args.bitrate 200 | else: 201 | # Will be ignored downstream. 202 | self.bitrate = constants.DEFAULT_BITRATE 203 | 204 | if args.chunk_size <= 0: 205 | print(colors.error("Chunk size must be a positive integer")) 206 | sys.exit(0) 207 | self.chunk_size: int = args.chunk_size 208 | 209 | if args.sample_rate < 22050: 210 | print(colors.error("Sample rate must be at least 22050")) 211 | sys.exit(0) 212 | 213 | self.samplerate: int 214 | if tray_config: 215 | self.samplerate = tray_config.samplerate 216 | elif self.codec == "opus": 217 | self.samplerate = 48000 218 | else: 219 | self.samplerate = args.sample_rate 220 | 221 | self.segment_time: Optional[int] 222 | if args.segment_time and self.backend not in ["parec", "node"]: 223 | self.segment_time = args.segment_time 224 | else: 225 | self.segment_time = None 226 | 227 | self.youtube_url: Optional[str] 228 | if not args.youtube: 229 | self.youtube_url = None 230 | else: 231 | if not check_url(args.youtube): 232 | youtube_error = """ 233 | You need to provide a URL that is supported by yt-dlp. 234 | """ 235 | 236 | # TODO(xsdg): Switch to yt-dlp. 237 | message = """ 238 | For a list of supported sources please visit: 239 | https://github.com/yt-dlp/yt-dlp/blob/master/supportedsites.md?plain=1 240 | 241 | Note that the URLs have to start with https. 242 | """ 243 | print(colors.error(youtube_error)) 244 | print(message) 245 | 246 | sys.exit(0) 247 | 248 | # TODO(xsdg): Warn that we're overriding the backend here. 249 | # Especially since this doesn't account for platform. 250 | self.youtube_url = args.youtube 251 | self.backend = "ffmpeg" 252 | 253 | # Argument validation. 254 | self._validate_input_file() 255 | 256 | 257 | # Diagnostic messages 258 | if first_parse: 259 | self._debug(f"Parsed args in process {os.getpid()}") 260 | 261 | self._debug(f"ALSA device name: {self.adevice}") 262 | self._debug(f"Google Cast name: {self.device_name}") 263 | self._debug(f"backend: {self.backend}") 264 | 265 | # TODO(xsdg): These were just printed warnings in the original, but 266 | # should they be errors? 267 | if self.mtype and not self.videoarg: 268 | print(colors.warning( 269 | "The media type argument is only supported for video.")) 270 | 271 | if self.loop and self.videoarg: 272 | print(colors.warning( 273 | "The loop and video arguments aren't compatible.")) 274 | 275 | if self.command and not self.videoarg: 276 | print(colors.warning( 277 | "The --command option only works for video.")) 278 | 279 | def _validate_input_file(self) -> None: 280 | if not self.input_file: 281 | return 282 | if os.path.isfile(self.input_file): 283 | return 284 | 285 | # NOTE: Prior implementation did a reset in the case that input_file was 286 | # specified by did not exist. That... doesn't really make much sense, 287 | # since it should be treated as an argument parsing/validation error. So 288 | # I've dropped that behavior. 289 | self._fatal_error(colors.warning( 290 | "Specified input file does not exist or is not a file.")) 291 | 292 | def _debug(self, msg: str) -> None: 293 | if not self.debug: 294 | return 295 | # TODO(xsdg): Maybe use stderr for debug messages? 296 | print(msg) 297 | 298 | def _fatal_error(self, msg: str) -> None: 299 | """Prints the specified message and then exits.""" 300 | print(colors.warning(msg)) 301 | sys.exit() 302 | 303 | def __enter__(self): 304 | """Starts performing whatever task is requested.""" 305 | 306 | 307 | -------------------------------------------------------------------------------- /mkchromecast/_arg_parsing.py: -------------------------------------------------------------------------------- 1 | """Parses arguments for mkchromecast. 2 | 3 | This is intended to be moved to `bin/mkchromecast` eventually, since it defines 4 | the command-line interface. 5 | """ 6 | # This file is part of Mkchromecast. 7 | 8 | import argparse 9 | import os 10 | 11 | from mkchromecast import constants 12 | from mkchromecast.resolution import resolutions 13 | 14 | 15 | def invalid_arg(error_msg: str): 16 | """Returns a lambda that will raise an ArgumentTypeError when called. 17 | 18 | Args: 19 | error_msg: The error message that will be used to construct the 20 | ArgumentTypeError. 21 | """ 22 | 23 | def raise_arg_type_error(): 24 | raise argparse.ArgumentTypeError(error_msg) 25 | return raise_arg_type_error 26 | 27 | 28 | Parser = argparse.ArgumentParser( 29 | description=""" 30 | This is a program to cast your macOS audio, or Linux audio to your Google 31 | Cast devices. 32 | 33 | It is written in Python, and it can stream via node.js, ffmpeg, parec 34 | (Linux pulseaudio users), or ALSA (Linux users). Mkchromecast is capable of 35 | using lossy and lossless audio formats provided that ffmpeg or parec is 36 | installed. Additionally, a system tray menu is available. 37 | 38 | Linux users that have installed the debian package need to launch the 39 | command `mkchromecast`, e.g.: 40 | 41 | mkchromecast 42 | 43 | whereas, installation from source needs users to go inside the cloned git 44 | repository and execute: 45 | 46 | python mkchromecast.py 47 | 48 | The two examples above will make Mkchromecast streams with node.js (or 49 | parec in Linux) together with mp3 audio coding format at a sample rate of 50 | 44100Hz and an average bitrate of 192k (defaults). These defaults can be 51 | changed using the --sample-rate and -b flags. It is useful to modify these 52 | parameters when your wireless router is not very powerful, or in the case 53 | you don't want to degrade the sound quality. For more information visit the 54 | wiki and the FAQ https://github.com/muammar/mkchromecast/wiki/. 55 | 56 | 57 | """, 58 | formatter_class=argparse.RawTextHelpFormatter, 59 | ) 60 | 61 | # All of the "do this action" arguments. 62 | _ActionGroupContainer = Parser.add_argument_group( 63 | title='Actions', 64 | description=('Optional actions that mkchromecast can perform. These are ' 65 | 'mutually-exclusive.'), 66 | ) 67 | _ActionGroup = _ActionGroupContainer.add_mutually_exclusive_group() 68 | 69 | Parser.add_argument( 70 | "--alsa-device", 71 | type=str, 72 | default=None, 73 | help=""" 74 | Set the ALSA device name. This option is useful when you are using pure 75 | ALSA in your system. 76 | 77 | Example: 78 | python mkchromecast.py --encoder-backend ffmpeg --alsa-device hw:2,1 79 | 80 | It only works for the ffmpeg backend, and it is not useful for pulseaudio 81 | users. For more information read the README.Debian file shipped in the 82 | Debian package or https://github.com/muammar/mkchromecast/wiki/ALSA. 83 | """, 84 | ) 85 | 86 | Parser.add_argument( 87 | "-b", 88 | "--bitrate", 89 | type=int, 90 | default=192, 91 | help=""" 92 | Set the audio encoder's bitrate. The default is set to be 192k average 93 | bitrate. Will be ignored for lossless codecs like flac or wav. 94 | 95 | Example: 96 | 97 | ffmpeg: 98 | python mkchromecast.py --encoder-backend ffmpeg -c ogg -b 128 99 | 100 | node: 101 | python mkchromecast.py -b 128 102 | 103 | This option works with all backends. The example above sets the average 104 | bitrate to 128k. 105 | """, 106 | ) 107 | 108 | Parser.add_argument( 109 | "--chunk-size", 110 | type=int, 111 | default="64", 112 | help=""" 113 | Set the chunk size base for streaming in the Flask server. Default to 64. 114 | This option only works when using the ffmpeg backend. This number is the 115 | base to set both the buffer_size (defined by 2 * chunk_size**2) in Flask 116 | server and the frame_size (defined by 32 * chunk_size). 117 | 118 | Example: 119 | 120 | ffmpeg: 121 | python mkchromecast.py --encoder-backend ffmpeg -c ogg -b 128 --chunk-size 2048 122 | 123 | """, 124 | ) 125 | 126 | Parser.add_argument( 127 | "-c", 128 | "--codec", 129 | type=str, 130 | default="mp3", 131 | help=""" 132 | Set the audio codec. 133 | 134 | Example: 135 | python mkchromecast.py --encoder-backend ffmpeg -c ogg 136 | 137 | Possible codecs: 138 | - mp3 [192k] MPEG Audio Layer III (default) 139 | - ogg [192k] Ogg Vorbis 140 | - aac [192k] Advanced Audio Coding (AAC) 141 | - wav [HQ] Waveform Audio File Format 142 | - flac [HQ] Free Lossless Audio Codec 143 | 144 | This option only works for the ffmpeg and parec backends. 145 | """, 146 | ) 147 | 148 | Parser.add_argument( 149 | "--command", 150 | type=str, 151 | default=None, 152 | help=""" 153 | Set an ffmpeg command for streaming video. 154 | 155 | Example: 156 | python3 mkchromecast.py --video --command 'ffmpeg -re -i \ 157 | /path/to/myvideo.mp4 -map_chapters -1 -vcodec libx264 -preset ultrafast \ 158 | -tune zerolatency -maxrate 10000k -bufsize 20000k -pix_fmt yuv420p -g \ 159 | 60 -f mp4 -max_muxing_queue_size 9999 -movflags \ 160 | frag_keyframe+empty_moov pipe:1' 161 | 162 | Note that for the output you have to use pipe:1 to stream. This option only 163 | works for the ffmpeg backend. 164 | """, 165 | ) 166 | 167 | Parser.add_argument( 168 | "--control", 169 | action="store_true", 170 | default=False, 171 | help=""" 172 | Control some actions of your Google Cast Devices. Use the 'u' and 'd' keys 173 | to perform volume up and volume down respectively, or press 'p' and 'r' to 174 | pause and resume cast process (only works with ffmpeg). Note that to kill 175 | the application using this option, you need to press the 'q' key or 176 | 'Ctrl-c'. 177 | """, 178 | ) 179 | 180 | Parser.add_argument( 181 | "--debug", 182 | action="store_true", 183 | help=""" 184 | Option for debugging purposes. 185 | """, 186 | ) 187 | 188 | _ActionGroup.add_argument( 189 | "-d", 190 | "--discover", 191 | action="store_true", 192 | default=False, 193 | help=""" 194 | Use this option if you want to know the friendly name of a Google Cast 195 | device. 196 | """, 197 | ) 198 | 199 | Parser.add_argument( 200 | "--display", 201 | type=str, 202 | default=os.environ.get("DISPLAY", ":0"), 203 | help=""" 204 | Set the DISPLAY for screen captures. Defaults to current environment 205 | value of DISPLAY or ':0' if DISPLAY is unset. 206 | """, 207 | ) 208 | 209 | Parser.add_argument( 210 | "--encoder-backend", 211 | type=str, 212 | default=None, 213 | choices=constants.ALL_BACKENDS, 214 | help=""" 215 | Set the backend for all encoders. 216 | Possible backends: 217 | - node (default in macOS) 218 | - parec (default in Linux) 219 | - ffmpeg 220 | 221 | Example: 222 | python mkchromecast.py --encoder-backend ffmpeg 223 | """, 224 | ) 225 | 226 | Parser.add_argument( 227 | "--hijack", 228 | action="store_true", 229 | default=False, 230 | help=""" 231 | This flag monitors if connection with google cast has been lost, and try to 232 | hijack it. 233 | """, 234 | ) 235 | 236 | Parser.add_argument( 237 | "--host", 238 | type=str, 239 | default=None, 240 | help=""" 241 | Set the ip of the local host. This option is useful if the local ip of your 242 | computer is not being detected correctly, or in the case you have more than 243 | one network device available. 244 | 245 | Example: 246 | python mkchromecast.py --encoder-backend ffmpeg --host 192.168.1.1 247 | 248 | You can pass it to all available backends. 249 | """, 250 | ) 251 | 252 | _ActionGroup.add_argument( 253 | "-i", 254 | "--input-file", 255 | type=str, 256 | default=None, 257 | help=""" 258 | Stream a file. 259 | 260 | Example: 261 | python mkchromecast.py -i /path/to/file.mp4 262 | """, 263 | ) 264 | 265 | Parser.add_argument( 266 | "--loop", 267 | action="store_true", 268 | default=False, 269 | help=""" 270 | Loop video indefinitely while streaming 271 | """, 272 | ) 273 | 274 | Parser.add_argument( 275 | "--mtype", 276 | type=str, 277 | default=None, 278 | help=""" 279 | Specify the media type for video streaming. 280 | 281 | Example: 282 | python mkchromecast.py --video -i "/path/to/file.avi" --mtype 'video/x-msvideo' 283 | """, 284 | ) 285 | 286 | Parser.add_argument( 287 | "-n", 288 | "--name", 289 | type=str, 290 | default=None, 291 | help=""" 292 | Use this option if you know the name of the Google Cast you want to 293 | connect. 294 | 295 | Example: 296 | python mkchromecast.py -n mychromecast 297 | """, 298 | ) 299 | 300 | Parser.add_argument( 301 | "--notifications", 302 | action="store_true", 303 | help=""" 304 | Use this flag to enable the notifications. 305 | """, 306 | ) 307 | 308 | Parser.add_argument( 309 | "-p", 310 | "--port", 311 | type=int, 312 | default="5000", 313 | help=""" 314 | Set the listening port for local webserver. 315 | 316 | Example: 317 | 318 | ffmpeg: 319 | python mkchromecast.py --encoder-backend ffmpeg -p 5100 320 | 321 | """, 322 | ) 323 | 324 | 325 | _ActionGroup.add_argument( 326 | "-r", 327 | "--reset", 328 | action="store_true", 329 | help=""" 330 | When the application fails, and you have no audio in your computer, use 331 | this option to reset the computer's audio. 332 | """, 333 | ) 334 | 335 | 336 | Parser.add_argument( 337 | "--resolution", 338 | type=str, 339 | default=None, 340 | help=""" 341 | Set the resolution of the streamed video. The following resolutions are 342 | supported: 343 | 344 | """ 345 | + "\n".join(" - {0} ({2}).".format(k, *v) for k, v in resolutions.items()), 346 | ) 347 | 348 | Parser.add_argument( 349 | "-s", 350 | "--select-device", 351 | action="store_true", 352 | help=""" 353 | If you have more than one Google Cast device use this option. 354 | """, 355 | ) 356 | 357 | Parser.add_argument( 358 | "--sample-rate", 359 | type=int, 360 | default="44100", 361 | help=""" 362 | Set the sample rate. The default sample rate obtained from avfoundation 363 | audio device input in ffmpeg using BlackHole for macOS is 44100Hz (in 364 | Linux can be 44100Hz or 48000Hz). You can change this in the Audio MIDI 365 | Setup in the "BlackHole 16ch" audio device. You need to change the 366 | "Format" in both input/output from 44100Hz to maximum 96000Hz. I think 367 | that more than 48000Hz is not necessary, but this is up to the users' 368 | preferences. 369 | 370 | Note that re-sampling to higher sample rates is not a good idea. It was 371 | indeed an issue in the chromecast audio. See: https://goo.gl/yNVODZ. 372 | 373 | Example: 374 | 375 | ffmpeg: 376 | python mkchromecast.py --encoder-backend ffmpeg -c ogg -b 128 --sample-rate 32000 377 | 378 | node: 379 | python mkchromecast.py -b 128 --sample-rate 32000 380 | 381 | This option works for both backends. The example above sets the sample rate 382 | to 32000Hz, and the bitrate to 128k. 383 | 384 | Which sample rate to use? 385 | 386 | - 192000Hz: maximum sampling rate supported in google cast audio 387 | without using High Dynamic Range. Only supported by aac, wav and flac 388 | codecs. 389 | - 96000Hz: maximum sampling rate supported in google cast audio using 390 | High Dynamic Range. Only supported by aac, wav and flac codecs. 391 | - 48000Hz: sampling rate of audio in DVDs. 392 | - 44100Hz: sampling rate of audio CDs giving a 20 kHz maximum 393 | frequency. 394 | - 32000Hz: sampling rate of audio quality a little below FM radio 395 | bandwidth. 396 | - 22050Hz: sampling rate of audio quality of AM radio. 397 | 398 | For more information see: http://wiki.audacityteam.org/wiki/Sample_Rates. 399 | """, 400 | ) 401 | 402 | _ActionGroup.add_argument( 403 | "--screencast", 404 | action="store_true", 405 | default=False, 406 | help=""" 407 | Use this flag to cast your Desktop Google cast devices. It is only working 408 | with ffmpeg. You may want to you use the --resolution option together with 409 | this flag. 410 | 411 | Examples: 412 | 413 | python mkchromecast.py --video --screencast 414 | """, 415 | ) 416 | 417 | Parser.add_argument( 418 | "--seek", 419 | type=str, 420 | default=None, 421 | help=""" 422 | Option to seeking when casting video. The format to set the time is 423 | HH:MM:SS. 424 | 425 | Example: 426 | python mkchromecast.py --video -i "/path/to/file.mp4" --seek 00:23:00 427 | 428 | """, 429 | ) 430 | 431 | Parser.add_argument( 432 | "--segment-time", 433 | type=int, 434 | default=None, 435 | help=""" 436 | Segmentate audio for improved live streaming when using ffmpeg. 437 | 438 | Example: 439 | python mkchromecast.py --encoder-backend ffmpeg --segment-time 2 440 | 441 | """, 442 | ) 443 | 444 | _ActionGroup.add_argument( 445 | "--source-url", 446 | type=str, 447 | default=None, 448 | help=""" 449 | This option allows you to pass any source URL to your Google Cast device. 450 | You have to specify the codec with -c flag when using it. 451 | 452 | Example: 453 | 454 | Source URL, port and extension: 455 | python mkchromecast.py --source-url http://192.99.131.205:8000/pvfm1.ogg -c ogg --control 456 | 457 | Source URL, no port, and extension: 458 | python mkchromecast.py --source-url http://example.com/name.ogg -c ogg --control 459 | 460 | Source URL without extension: 461 | python mkchromecast.py --source-url http://example.com/name -c aac --control 462 | 463 | Supported source URLs are: 464 | 465 | - http://url:port/name.mp3 466 | - http://url:port/name.ogg 467 | - http://url:port/name.mp4 (use the aac codec) 468 | - http://url:port/name.wav 469 | - http://url:port/name.flac 470 | 471 | .m3u or .pls are not yet available. 472 | """, 473 | ) 474 | 475 | Parser.add_argument( 476 | "--subtitles", 477 | type=str, 478 | default=None, 479 | help=""" 480 | Set subtitles. 481 | """, 482 | ) 483 | 484 | _ActionGroup.add_argument( 485 | "-t", 486 | "--tray", 487 | action="store_true", 488 | help=""" 489 | This option let you launch Mkchromecast as a systray menu (beta). 490 | """, 491 | ) 492 | 493 | Parser.add_argument( 494 | "--tries", 495 | type=int, 496 | default=None, 497 | help=""" 498 | Limit the number of times the underlying socket associated with your 499 | Chromecast objects will retry connecting 500 | """, 501 | ) 502 | 503 | Parser.add_argument( 504 | "--update", 505 | type=invalid_arg("Argument dropped."), 506 | help="Do not use. Argument dropped.", 507 | ) 508 | 509 | _ActionGroup.add_argument( 510 | "-v", 511 | "--version", 512 | action="store_true", 513 | help=""" 514 | Show the version""", 515 | ) 516 | 517 | Parser.add_argument( 518 | "--vcodec", 519 | type=str, 520 | default="libx264", 521 | help=""" 522 | Set a custom vcodec for ffmpeg when capturing screen. Defaults to libx264 523 | """, 524 | ) 525 | 526 | # TODO(xsdg): Probably best to replace this with --suppress-video. Otherwise, 527 | # we should either auto-detect audio-only usecases, or always send video. 528 | Parser.add_argument( 529 | "--video", 530 | action="store_true", 531 | default=False, 532 | help=r""" 533 | Use this flag to cast video to your Google cast devices. It is only working 534 | with ffmpeg. 535 | 536 | Examples: 537 | 538 | Cast a file: 539 | python mkchromecast.py --video -i "/path/to/file.mp4" 540 | 541 | Cast from source-url: 542 | python mkchromecast.py --source-url http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4 -c mp4 --control --video 543 | 544 | Cast a youtube-url: 545 | python mkchromecast.py -y https://www.youtube.com/watch\?v\=VuMBaAZn3II --video 546 | 547 | """, 548 | ) 549 | Parser.add_argument( 550 | "--volume", 551 | type=invalid_arg("Renamed to --control"), 552 | help="Do not use. Renamed to --control.", 553 | ) 554 | 555 | _ActionGroup.add_argument( 556 | "-y", 557 | "--youtube", 558 | type=str, 559 | default=None, 560 | help=""" 561 | Stream from sources supported by yt-dlp. This option needs 562 | the yt-dlp package, and it also gives you access to all its 563 | supported websites such as Dailymotion, LiveLeak, and Vimeo. 564 | 565 | For a comprehensive list, check: 566 | http://rg3.github.io/yt-dlp/supportedsites.html. 567 | 568 | Example: 569 | python mkchromecast.py -y https://www.youtube.com/watch?v=NVvAJhZVBTc 570 | 571 | Note that this is only working for websites running over https. 572 | """, 573 | ) 574 | 575 | Parser.add_argument( 576 | "--fps", 577 | type=str, 578 | default="25", 579 | help=""" 580 | Frames per second to use when --screencast is used. Defaults to 25. 581 | """, 582 | ) 583 | -------------------------------------------------------------------------------- /mkchromecast/audio.py: -------------------------------------------------------------------------------- 1 | # This file is part of mkchromecast. 2 | 3 | """ 4 | Google Cast device has to point out to http://ip:5000/stream 5 | """ 6 | 7 | import os 8 | import re 9 | import shutil 10 | from typing import Union 11 | 12 | import mkchromecast 13 | from mkchromecast import colors 14 | from mkchromecast import constants 15 | from mkchromecast import pipeline_builder 16 | from mkchromecast import stream_infra 17 | from mkchromecast import utils 18 | from mkchromecast.constants import OpMode 19 | import mkchromecast.messages as msg 20 | 21 | 22 | backend = stream_infra.BackendInfo() 23 | 24 | # TODO(xsdg): Encapsulate this so that we don't do this work on import. 25 | _mkcc = mkchromecast.Mkchromecast() 26 | command: Union[str, list[str]] 27 | media_type: str 28 | 29 | # We make local copies of these attributes because they are sometimes modified. 30 | # TODO(xsdg): clean this up more when we refactor this file. 31 | host = _mkcc.host 32 | port = _mkcc.port 33 | platform = _mkcc.platform 34 | 35 | ip = utils.get_effective_ip(platform, host_override=host, fallback_ip="0.0.0.0") 36 | 37 | frame_size = 32 * _mkcc.chunk_size 38 | buffer_size = 2 * _mkcc.chunk_size**2 39 | 40 | encode_settings = pipeline_builder.EncodeSettings( 41 | codec=_mkcc.codec, 42 | adevice=_mkcc.adevice, 43 | bitrate=_mkcc.bitrate, 44 | frame_size=frame_size, 45 | samplerate=str(_mkcc.samplerate), 46 | segment_time=_mkcc.segment_time 47 | ) 48 | 49 | debug = _mkcc.debug 50 | 51 | if debug is True: 52 | print( 53 | ":::audio::: chunk_size, frame_size, buffer_size: %s, %s, %s" 54 | % (_mkcc.chunk_size, frame_size, buffer_size) 55 | ) 56 | 57 | # This is to take the youtube URL 58 | if _mkcc.operation == OpMode.YOUTUBE: 59 | print(colors.options("The Youtube URL chosen: ") + _mkcc.youtube_url) 60 | 61 | try: 62 | import urlparse 63 | 64 | url_data = urlparse.urlparse(_mkcc.youtube_url) 65 | query = urlparse.parse_qs(url_data.query) 66 | except ImportError: 67 | import urllib.parse 68 | 69 | url_data = urllib.parse.urlparse(_mkcc.youtube_url) 70 | query = urllib.parse.parse_qs(url_data.query) 71 | video = query["v"][0] 72 | print(colors.options("Playing video:") + " " + video) 73 | command = ["yt-dlp", "-o", "-", _mkcc.youtube_url] 74 | media_type = "audio/mp4" 75 | else: 76 | backend.name = _mkcc.backend 77 | backend.path = backend.name 78 | 79 | # TODO(xsdg): Why is this only run in tray mode??? 80 | if _mkcc.operation == OpMode.TRAY and backend.name in {"ffmpeg", "parec"}: 81 | import os 82 | import getpass 83 | 84 | # TODO(xsdg): We should not be setting up a custom path like this. We 85 | # should be respecting the path that the user has set, and requiring 86 | # them to specify an absolute path if the backend isn't in their PATH. 87 | username = getpass.getuser() 88 | backend_search_path = ( 89 | f"./bin:./nodejs/bin:/Users/{username}/bin:/usr/local/bin:" 90 | "/usr/local/sbin:/usr/bin:/bin:/usr/sbin:/sbin:/opt/X11/bin:" 91 | f"/usr/X11/bin:/usr/games:{os.environ['PATH']}" 92 | ) 93 | 94 | backend.path = shutil.which(backend.name, path=backend_search_path) 95 | if debug: 96 | print(f"Searched for {backend.name} in PATH {backend_search_path}") 97 | print(f"Resolved to {repr(backend.path)}") 98 | 99 | if encode_settings.codec == "mp3": 100 | media_type = "audio/mpeg" 101 | else: 102 | media_type = f"audio/{encode_settings.codec}" 103 | 104 | print(colors.options("Selected backend:") + f" {backend}") 105 | print(colors.options("Selected audio codec:") + f" {encode_settings.codec}") 106 | 107 | if backend.name != "node": 108 | encode_settings.bitrate = utils.clamp_bitrate(encode_settings.codec, 109 | encode_settings.bitrate) 110 | 111 | if encode_settings.bitrate != "None": 112 | print(colors.options("Using bitrate:") + f" {encode_settings.bitrate}") 113 | 114 | if encode_settings.codec in constants.QUANTIZED_SAMPLE_RATE_CODECS: 115 | encode_settings.samplerate = str(utils.quantize_sample_rate( 116 | encode_settings.codec, 117 | int(encode_settings.samplerate)) 118 | ) 119 | 120 | print(colors.options("Using sample rate:") + f" {encode_settings.samplerate}Hz") 121 | 122 | builder = pipeline_builder.Audio(backend, platform, encode_settings) 123 | command = builder.command 124 | 125 | if debug is True: 126 | print(":::audio::: command " + str(command)) 127 | 128 | 129 | def _flask_init(): 130 | # TODO(xsdg): Update init_audio to take an EncodeSettings. 131 | stream_infra.FlaskServer.init_audio( 132 | adevice=encode_settings.adevice, 133 | backend=backend, 134 | bitrate=encode_settings.bitrate, 135 | buffer_size=buffer_size, 136 | codec=encode_settings.codec, 137 | command=command, 138 | media_type=media_type, 139 | platform=platform, 140 | samplerate=encode_settings.samplerate) 141 | 142 | 143 | def main(): 144 | pipeline = stream_infra.PipelineProcess(_flask_init, ip, port, platform) 145 | pipeline.start() 146 | -------------------------------------------------------------------------------- /mkchromecast/audio_devices.py: -------------------------------------------------------------------------------- 1 | # This file is part of mkchromecast. 2 | 3 | import subprocess 4 | import os.path 5 | 6 | """ 7 | These functions are used to switch input/out to BlackHole back and forth. 8 | 9 | To call them: 10 | from mkchromecast.audio_devices import * 11 | name() 12 | """ 13 | 14 | 15 | def inputdev(): 16 | if os.path.exists("./bin/audiodevice") is True: 17 | inputdevtosf2 = ['./bin/audiodevice input "BlackHole 16ch"'] 18 | else: 19 | inputdevtosf2 = ['./audiodevice input "BlackHole 16ch"'] 20 | subprocess.Popen(inputdevtosf2, shell=True) 21 | return 22 | 23 | 24 | def outputdev(): 25 | if os.path.exists("./bin/audiodevice") is True: 26 | outputdevtosf2 = ['./bin/audiodevice output "BlackHole 16ch"'] 27 | else: 28 | outputdevtosf2 = ['./audiodevice output "BlackHole 16ch"'] 29 | subprocess.Popen(outputdevtosf2, shell=True) 30 | return 31 | 32 | 33 | def inputint(): 34 | if os.path.exists("./bin/audiodevice") is True: 35 | inputinttosf2 = ['./bin/audiodevice input "Internal Microphone"'] 36 | else: 37 | inputinttosf2 = ['./audiodevice input "Internal Microphone"'] 38 | subprocess.call(inputinttosf2, shell=True) 39 | return 40 | 41 | 42 | def outputint(): 43 | if os.path.exists("./bin/audiodevice") is True: 44 | outputinttosf2 = ['./bin/audiodevice output "Internal Speakers"'] 45 | else: 46 | outputinttosf2 = ['./audiodevice output "Internal Speakers"'] 47 | subprocess.call(outputinttosf2, shell=True) 48 | return 49 | -------------------------------------------------------------------------------- /mkchromecast/colors.py: -------------------------------------------------------------------------------- 1 | # This file is part of mkchromecast. 2 | # Credits to https://gist.github.com/Jossef/0ee20314577925b4027f 3 | 4 | try: 5 | unicode # Python 2 6 | except NameError: 7 | unicode = str # Python 3 8 | 9 | 10 | def color(text, **user_styles): 11 | styles = { 12 | # styles 13 | "reset": "\033[0m", 14 | "bold": "\033[01m", 15 | "disabled": "\033[02m", 16 | "underline": "\033[04m", 17 | "reverse": "\033[07m", 18 | "strike_through": "\033[09m", 19 | "invisible": "\033[08m", 20 | # text colors 21 | "fg_black": "\033[30m", 22 | "fg_red": "\033[31m", 23 | "fg_green": "\033[32m", 24 | "fg_orange": "\033[33m", 25 | "fg_blue": "\033[34m", 26 | "fg_purple": "\033[35m", 27 | "fg_cyan": "\033[36m", 28 | "fg_light_grey": "\033[37m", 29 | "fg_dark_grey": "\033[90m", 30 | "fg_light_red": "\033[91m", 31 | "fg_light_green": "\033[92m", 32 | "fg_yellow": "\033[93m", 33 | "fg_light_blue": "\033[94m", 34 | "fg_pink": "\033[95m", 35 | "fg_light_cyan": "\033[96m", 36 | # background colors 37 | "bg_black": "\033[40m", 38 | "bg_red": "\033[41m", 39 | "bg_green": "\033[42m", 40 | "bg_orange": "\033[43m", 41 | "bg_blue": "\033[44m", 42 | "bg_purple": "\033[45m", 43 | "bg_cyan": "\033[46m", 44 | "bg_light_grey": "\033[47m", 45 | } 46 | 47 | color_text = "" 48 | for style in user_styles: 49 | try: 50 | color_text += styles[style] 51 | except KeyError: 52 | raise KeyError("def color: parameter `{}` does not exist".format(style)) 53 | 54 | color_text += text 55 | try: 56 | return "\033[0m{}\033[0m".format(color_text) 57 | except UnicodeEncodeError: 58 | return "\033[0m{}\033[0m".format(unicode(color_text).encode("utf-8")) 59 | 60 | 61 | def error(text): 62 | return color(text, bold=True, fg_red=True) 63 | 64 | 65 | def important(text): 66 | return color(text, bold=False, fg_blue=True) 67 | 68 | 69 | def options(text): 70 | return color(text, underline=True) 71 | 72 | 73 | def bold(text): 74 | return color(text, bold=True) 75 | 76 | 77 | def warning(text): 78 | return color(text, fg_orange=True) 79 | 80 | 81 | def success(text): 82 | return color(text, fg_green=True) 83 | -------------------------------------------------------------------------------- /mkchromecast/config.py: -------------------------------------------------------------------------------- 1 | # This file is part of mkchromecast. 2 | 3 | import configparser 4 | import os 5 | import pathlib 6 | from typing import Optional 7 | 8 | # NOTE: Can't import mkchromecast because that would create a circular dependency. 9 | 10 | # Section name. 11 | SETTINGS = "settings" 12 | 13 | # Field names. 14 | BACKEND = "backend" 15 | CODEC = "codec" 16 | BITRATE = "bitrate" 17 | SAMPLERATE = "samplerate" 18 | NOTIFICATIONS = "notifications" 19 | COLORS = "colors" 20 | SEARCH_AT_LAUNCH = "search_at_launch" 21 | ALSA_DEVICE = "alsa_device" 22 | 23 | 24 | def _default_config_path(platform: str) -> pathlib.Path: 25 | config_dir: pathlib.PurePath 26 | if platform == "Darwin": 27 | config_dir = pathlib.PosixPath( 28 | "~/Library/Application Support/mkchromecast") 29 | else: # Linux 30 | xdg_config_home = pathlib.PosixPath( 31 | os.environ.get("XDG_CONFIG_HOME", "~/.config")) 32 | config_dir = xdg_config_home / "mkchromecast" 33 | 34 | # TODO(xsdg): Switch this back to mkchromecast.cfg. 35 | config_path = (config_dir / "mkchromecast_beta.cfg").expanduser() 36 | 37 | print(f":::config::: WARNING: USING BETA CONFIG PATH: {config_path}") 38 | return config_path 39 | 40 | 41 | class Config: 42 | """This represents a configuration, as backed by a config on disk. 43 | 44 | To use without updating settings, you can either use the `load_and_validate` 45 | method directly, or use it as a context manager, which will call that 46 | function. 47 | 48 | The context manager usage will _also_ consider saving updated files back to 49 | disk (depending on whether the instance is specified as read-only or not). 50 | That is the only supported way to write updated values to disk. 51 | """ 52 | 53 | def __init__(self, 54 | platform: str, 55 | config_path: Optional[os.PathLike] = None, 56 | read_only: bool = False, 57 | debug: bool = False): 58 | self._debug = debug 59 | self._platform = platform 60 | self._read_only = read_only 61 | 62 | if config_path: 63 | self._config_path = config_path 64 | else: 65 | self._config_path = _default_config_path(self._platform) 66 | 67 | self._config = configparser.ConfigParser() 68 | 69 | self._default_conf = { 70 | CODEC: "mp3", 71 | BITRATE: 192, 72 | SAMPLERATE: 44100, 73 | NOTIFICATIONS: False, 74 | COLORS: "black", 75 | SEARCH_AT_LAUNCH: False, 76 | ALSA_DEVICE: None, 77 | } 78 | 79 | if self._platform == "Darwin": 80 | self._default_conf[BACKEND] = "node" 81 | else: 82 | self._default_conf[BACKEND] = "parec" 83 | 84 | def __enter__(self): 85 | """Parses config file and returns self""" 86 | self.load_and_validate() 87 | 88 | return self 89 | 90 | def __exit__(self, *exc): 91 | self._maybe_write_config() 92 | 93 | def load_and_validate(self) -> None: 94 | """Loads config from disk and validates that no settings are missing. 95 | 96 | If any settings are missing, they are set to the default value, and if 97 | this Config was not created read-only, the completed config will be 98 | written back to disk. 99 | """ 100 | self._config.read(self._config_path) 101 | self._update_any_missing_values() 102 | 103 | def _maybe_write_config(self) -> None: 104 | """Writes the config to config_file unless read-only mode was used.""" 105 | if self._read_only: 106 | return 107 | 108 | with open(self._config_path, "wt") as config_file: 109 | self._config.write(config_file) 110 | 111 | def _update_any_missing_values(self) -> None: 112 | """Sets any missing values to their defaults.""" 113 | if not self._config.has_section(SETTINGS): 114 | print(f":::config::: Creating missing section '{SETTINGS}'") 115 | self._config.add_section(SETTINGS) 116 | 117 | expected_keys = self._default_conf.keys() 118 | missing_keys: list[str] = [] 119 | for key in expected_keys: 120 | if not self._config.has_option(SETTINGS, key): 121 | missing_keys.append(key) 122 | if self._debug: 123 | print(f":::config::: Setting missing key {key} to default " 124 | "value.") 125 | 126 | # We use setattr to avoid bypassing any validation code that 127 | # might exist. 128 | setattr(self, key, self._default_conf[key]) 129 | 130 | if missing_keys: 131 | if self._read_only: 132 | print(":::config::: Missing keys _not_ being saved for " 133 | "read-only config") 134 | else: 135 | if self._debug: 136 | print(":::config::: Re-writing config to add missing keys: " 137 | f"{missing_keys}") 138 | 139 | self._maybe_write_config() 140 | 141 | # TODO(xsdg): Refactor this to avoid code duplication. Sadly, 142 | # functools.partialmethod doesn't work with properties. 143 | @property 144 | def backend(self) -> str: 145 | return self._config.get(SETTINGS, BACKEND) 146 | 147 | @backend.setter 148 | def backend(self, value: str) -> None: 149 | self._config.set(SETTINGS, BACKEND, value) 150 | 151 | @property 152 | def codec(self) -> str: 153 | return self._config.get(SETTINGS, CODEC) 154 | 155 | @codec.setter 156 | def codec(self, value: str) -> None: 157 | self._config.set(SETTINGS, CODEC, value) 158 | 159 | @property 160 | def bitrate(self) -> int: 161 | return self._config.getint(SETTINGS, BITRATE) 162 | 163 | @bitrate.setter 164 | def bitrate(self, value: int) -> None: 165 | self._config.set(SETTINGS, BITRATE, str(value)) 166 | 167 | @property 168 | def samplerate(self) -> int: 169 | return self._config.getint(SETTINGS, SAMPLERATE) 170 | 171 | @samplerate.setter 172 | def samplerate(self, value: int) -> None: 173 | self._config.set(SETTINGS, SAMPLERATE, str(value)) 174 | 175 | @property 176 | def notifications(self) -> bool: 177 | return self._config.getboolean(SETTINGS, NOTIFICATIONS) 178 | 179 | @notifications.setter 180 | def notifications(self, value: bool) -> None: 181 | self._config.set(SETTINGS, NOTIFICATIONS, str(value)) 182 | 183 | @property 184 | def colors(self) -> str: 185 | return self._config.get(SETTINGS, COLORS) 186 | 187 | @colors.setter 188 | def colors(self, value: str) -> None: 189 | self._config.set(SETTINGS, COLORS, value) 190 | 191 | @property 192 | def search_at_launch(self) -> bool: 193 | return self._config.getboolean(SETTINGS, SEARCH_AT_LAUNCH) 194 | 195 | @search_at_launch.setter 196 | def search_at_launch(self, value: bool) -> None: 197 | self._config.set(SETTINGS, SEARCH_AT_LAUNCH, str(value)) 198 | 199 | @property 200 | def alsa_device(self) -> Optional[str]: 201 | stored_value = self._config.get(SETTINGS, ALSA_DEVICE) 202 | if stored_value == "None": 203 | return None 204 | 205 | return stored_value 206 | 207 | @alsa_device.setter 208 | def alsa_device(self, value: Optional[str]) -> None: 209 | self._config.set(SETTINGS, ALSA_DEVICE, str(value)) 210 | -------------------------------------------------------------------------------- /mkchromecast/constants.py: -------------------------------------------------------------------------------- 1 | # This file is part of mkchromecast. 2 | 3 | import enum 4 | from typing import List 5 | 6 | 7 | @enum.unique 8 | class OpMode(enum.Enum): 9 | AUDIOCAST = enum.auto() 10 | DISCOVER = enum.auto() 11 | INPUT_FILE = enum.auto() 12 | RESET = enum.auto() 13 | SCREENCAST = enum.auto() 14 | SOURCE_URL = enum.auto() 15 | TRAY = enum.auto() 16 | VERSION = enum.auto() 17 | YOUTUBE = enum.auto() 18 | 19 | 20 | # Formerly, "no96k", which was misleading because it implied that (for instance) 21 | # 88200 was valid, which it is not. 22 | MAX_48K_CODECS = {"ogg", "mp3"} 23 | MAX_48K_SAMPLE_RATES = [22050, 32000, 44100, 48000] 24 | ALL_SAMPLE_RATES = MAX_48K_SAMPLE_RATES + [88200, 96000, 176000, 192000] 25 | QUANTIZED_SAMPLE_RATE_CODECS = ["mp3", "ogg", "aac", "opus", "wav", "flac"] 26 | 27 | def sample_rates_for_codec(codec: str) -> List[int]: 28 | """Returns the appropriate sample rates for the given codec.""" 29 | if codec in MAX_48K_CODECS: 30 | return MAX_48K_SAMPLE_RATES 31 | 32 | return ALL_SAMPLE_RATES 33 | 34 | 35 | DARWIN_BACKENDS = ["node", "ffmpeg"] 36 | LINUX_VIDEO_BACKENDS = ["node", "ffmpeg"] 37 | LINUX_BACKENDS = ["ffmpeg", "parec"] 38 | ALL_BACKENDS = ["node", "ffmpeg", "parec"] 39 | 40 | def backend_options_for_platform(platform: str, video: bool = False): 41 | if platform == "Darwin": 42 | return DARWIN_BACKENDS 43 | 44 | if video: 45 | return LINUX_VIDEO_BACKENDS 46 | 47 | return LINUX_BACKENDS 48 | 49 | 50 | DEFAULT_BITRATE = 192 51 | CODECS_WITH_BITRATE = ["aac", "mp3", "ogg", "opus"] 52 | # TODO(xsdg): Reverse how this is defined. 53 | ALL_CODECS = QUANTIZED_SAMPLE_RATE_CODECS 54 | NODE_CODEC = "mp3" 55 | 56 | -------------------------------------------------------------------------------- /mkchromecast/getch/__init__.py: -------------------------------------------------------------------------------- 1 | """\ 2 | py-getch 3 | -------- 4 | 5 | Portable getch() for Python. 6 | 7 | :copyright: (c) 2013-2015 by Joe Esposito. 8 | :license: MIT, see LICENSE for more details. 9 | """ 10 | 11 | __version__ = "1.0.1" 12 | 13 | from .getch import getch 14 | from .pause import pause, pause_exit 15 | 16 | 17 | __all__ = ["__version__", "getch", "pause", "pause_exit"] 18 | -------------------------------------------------------------------------------- /mkchromecast/getch/getch.py: -------------------------------------------------------------------------------- 1 | try: 2 | from msvcrt import getch 3 | except ImportError: 4 | 5 | def getch(): 6 | """ 7 | Gets a single character from STDIO. 8 | """ 9 | import sys 10 | import tty 11 | import termios 12 | 13 | fd = sys.stdin.fileno() 14 | old = termios.tcgetattr(fd) 15 | try: 16 | tty.setraw(fd) 17 | return sys.stdin.read(1) 18 | finally: 19 | termios.tcsetattr(fd, termios.TCSADRAIN, old) 20 | -------------------------------------------------------------------------------- /mkchromecast/getch/pause.py: -------------------------------------------------------------------------------- 1 | from __future__ import print_function, unicode_literals 2 | 3 | import sys 4 | from .getch import getch 5 | 6 | 7 | def pause(message="Press any key to continue . . . "): 8 | """ 9 | Prints the specified message if it's not None and waits for a keypress. 10 | """ 11 | if message is not None: 12 | print(message, end="") 13 | sys.stdout.flush() 14 | getch() 15 | print() 16 | 17 | 18 | def pause_exit(status=None, message="Press any key to exit"): 19 | """ 20 | Prints the specified message if it is not None, waits for a keypress, then 21 | exits the interpreter by raising SystemExit(status). 22 | If the status is omitted or None, it defaults to zero (i.e., success). 23 | If the status is numeric, it will be used as the system exit status. 24 | If it is another kind of object, it will be printed and the system 25 | exit status will be 1 (i.e., failure). 26 | """ 27 | pause(message) 28 | sys.exit(status) 29 | -------------------------------------------------------------------------------- /mkchromecast/messages.py: -------------------------------------------------------------------------------- 1 | # This file is part of mkchromecast. 2 | 3 | from typing import Any, Iterable, List 4 | 5 | from mkchromecast import colors 6 | from mkchromecast import constants 7 | 8 | 9 | def print_samplerate_warning(codec: str) -> None: 10 | """Prints a warning when sample rates are set incorrectly.""" 11 | str_rates = [ 12 | f"{rate}Hz" for rate in constants.sample_rates_for_codec(codec) 13 | ] 14 | joined_rates = ", ".join(str_rates) 15 | print(colors.warning( 16 | f"Sample rates supported by {codec} are: {joined_rates}." 17 | ) 18 | ) 19 | -------------------------------------------------------------------------------- /mkchromecast/node.py: -------------------------------------------------------------------------------- 1 | # This file is part of mkchromecast. 2 | """ 3 | These functions are used to get up the streaming server using node. 4 | 5 | To call them: 6 | from mkchromecast.node import * 7 | name() 8 | """ 9 | 10 | # This file is audio-only for node. Video via node is (currently) handled 11 | # completely within video.py. 12 | 13 | import multiprocessing 14 | import os 15 | import pickle 16 | import psutil 17 | import time 18 | import re 19 | import sys 20 | import signal 21 | import subprocess 22 | 23 | import mkchromecast 24 | from mkchromecast.audio_devices import inputint, outputint 25 | from mkchromecast import colors 26 | from mkchromecast import constants 27 | from mkchromecast import utils 28 | from mkchromecast.cast import Casting 29 | from mkchromecast.constants import OpMode 30 | 31 | 32 | def streaming(mkcc: mkchromecast.Mkchromecast): 33 | print(colors.options("Selected backend:") + " " + mkcc.backend) 34 | 35 | if mkcc.debug is True: 36 | print( 37 | ":::node::: variables %s, %s, %s, %s, %s" 38 | % (mkcc.backend, mkcc.codec, mkcc.bitrate, mkcc.samplerate, mkcc.notifications) 39 | ) 40 | 41 | bitrate: int 42 | samplerate: int 43 | if mkcc.youtube_url is None: 44 | if mkcc.backend == "node": 45 | bitrate = utils.clamp_bitrate(mkcc.codec, bitrate) 46 | print(colors.options("Using bitrate: ") + f"{bitrate}k.") 47 | 48 | if mkcc.codec in constants.QUANTIZED_SAMPLE_RATE_CODECS: 49 | samplerate = utils.quantize_sample_rate(mkcc.codec, mkcc.samplerate) 50 | 51 | print(colors.options("Using sample rate:") + f" {samplerate}Hz.") 52 | 53 | """ 54 | Node section 55 | """ 56 | paths = ["/usr/local/bin/node", "./bin/node", "./nodejs/bin/node"] 57 | 58 | for path in paths: 59 | if os.path.exists(path) is True: 60 | webcast = [ 61 | path, 62 | "./nodejs/node_modules/webcast-osx-audio/bin/webcast.js", 63 | "-b", 64 | str(bitrate), 65 | "-s", 66 | str(samplerate), 67 | "-p", 68 | "5000", 69 | "-u", 70 | "stream", 71 | ] 72 | break 73 | else: 74 | webcast = None 75 | print(colors.warning("Node is not installed...")) 76 | print( 77 | colors.warning("Use your package manager or their official " "installer...") 78 | ) 79 | pass 80 | 81 | if webcast is not None: 82 | p = subprocess.Popen(webcast) 83 | 84 | if mkcc.debug is True: 85 | print(":::node::: node command: %s." % webcast) 86 | 87 | f = open("/tmp/mkchromecast.pid", "rb") 88 | pidnumber = int(pickle.load(f)) 89 | print(colors.options("PID of main process:") + " " + str(pidnumber)) 90 | 91 | localpid = os.getpid() 92 | print(colors.options("PID of streaming process: ") + str(localpid)) 93 | 94 | while p.poll() is None: 95 | try: 96 | time.sleep(0.5) 97 | # With this I ensure that if the main app fails, everything 98 | # will get back to normal 99 | if psutil.pid_exists(pidnumber) is False: 100 | inputint() 101 | outputint() 102 | parent = psutil.Process(localpid) 103 | # or parent.children() for recursive=False 104 | for child in parent.children(recursive=True): 105 | child.kill() 106 | parent.kill() 107 | except KeyboardInterrupt: 108 | print("Ctrl-c was requested") 109 | sys.exit(0) 110 | except IOError: 111 | print("I/O Error") 112 | sys.exit(0) 113 | except OSError: 114 | print("OSError") 115 | sys.exit(0) 116 | else: 117 | print(colors.warning("Reconnecting node streaming...")) 118 | if mkcc.platform == "Darwin" and mkcc.notifications: 119 | if os.path.exists("images/google.icns") is True: 120 | noticon = "images/google.icns" 121 | else: 122 | noticon = "google.icns" 123 | if mkcc.debug is True: 124 | print( 125 | ":::node::: platform, tray, notifications: %s, %s, %s." 126 | % (mkcc.platform, mkcc.tray, mkcc.notifications) 127 | ) 128 | 129 | if mkcc.platform == "Darwin" and mkcc.operation == OpMode.TRAY and mkcc.notifications: 130 | reconnecting = [ 131 | "./notifier/terminal-notifier.app/Contents/MacOS/terminal-notifier", 132 | "-group", 133 | "cast", 134 | "-contentImage", 135 | noticon, 136 | "-title", 137 | "mkchromecast", 138 | "-subtitle", 139 | "node server failed", 140 | "-message", 141 | "Reconnecting...", 142 | ] 143 | subprocess.Popen(reconnecting) 144 | 145 | if mkcc.debug is True: 146 | print( 147 | ":::node::: reconnecting notifier command: %s." % reconnecting 148 | ) 149 | 150 | # This could potentially cause forkbomb-like behavior where each new 151 | # child process would create a new child process, ad infinitum. 152 | raise Exception("Internal error: Never worked; needs to be fixed.") 153 | 154 | relaunch(stream_audio, recasting, kill) 155 | return 156 | 157 | 158 | class multi_proc(object): 159 | def __init__(self): 160 | self._mkcc = mkchromecast.Mkchromecast() 161 | self.proc = multiprocessing.Process(target=streaming, args=(self._mkcc,)) 162 | self.proc.daemon = False 163 | 164 | def start(self): 165 | self.proc.start() 166 | 167 | 168 | def kill(): 169 | pid = os.getpid() 170 | os.kill(pid, signal.SIGTERM) 171 | return 172 | 173 | 174 | def relaunch(func1, func2, func3): 175 | func1() 176 | func2() 177 | func3() 178 | return 179 | 180 | 181 | def recasting(): 182 | mkcc = mkchromecast.Mkchromecast() 183 | start = Casting(mkcc) 184 | start.initialize_cast() 185 | start.get_devices() 186 | start.play_cast() 187 | return 188 | 189 | 190 | def stream_audio(): 191 | st = multi_proc() 192 | st.start() 193 | -------------------------------------------------------------------------------- /mkchromecast/pipeline_builder.py: -------------------------------------------------------------------------------- 1 | # This file is part of mkchromecast. 2 | 3 | from dataclasses import dataclass 4 | import os 5 | from typing import Optional, Union 6 | 7 | import mkchromecast 8 | from mkchromecast import colors 9 | from mkchromecast import constants 10 | from mkchromecast import resolution 11 | from mkchromecast import stream_infra 12 | from mkchromecast import utils 13 | from mkchromecast.constants import OpMode 14 | 15 | SubprocessCommand = Union[list[str], str, os.PathLike] 16 | 17 | @dataclass 18 | class EncodeSettings: 19 | codec: str 20 | adevice: Optional[str] 21 | bitrate: int 22 | frame_size: int 23 | samplerate: str 24 | segment_time: Optional[int] 25 | ffmpeg_debug: bool = False 26 | 27 | 28 | class Audio: 29 | 30 | _ffmpeg_fmt_to_acodec: dict[str, str] = { 31 | "mp3": "libmp3lame", 32 | "ogg": "libvorbis", 33 | "adts": "aac", 34 | "opus": "libopus", 35 | "wav": "pcm_s24le", 36 | "flac": "flac", 37 | } 38 | 39 | def __init__(self, 40 | backend: stream_infra.BackendInfo, 41 | platform: str, 42 | encode_settings: EncodeSettings): 43 | self._backend = backend 44 | self._platform = platform 45 | self._settings = encode_settings 46 | 47 | # TODO(xsdg): Use SubprocessCommand here. 48 | @property 49 | def command(self) -> list[str]: 50 | if self._platform == "Darwin": 51 | return self._build_ffmpeg_command() 52 | else: # platform == "Linux" 53 | if self._backend.name == "ffmpeg": 54 | return self._build_ffmpeg_command() 55 | 56 | elif self._backend.name == "parec": 57 | return self._build_linux_other_command() 58 | 59 | else: 60 | raise Exception(f"Unsupported backend: {self._backend.name}") 61 | 62 | def _input_command(self) -> list[str]: 63 | """Returns an appropriate set of input arguments for the pipeline. 64 | 65 | Considers the platform and (on Linux) whether we're configured to use 66 | pulse or alsa. 67 | """ 68 | if self._platform == "Darwin": 69 | return ["-f", "avfoundation", "-i", ":BlackHole 16ch"] 70 | else: # platform == "Linux" 71 | # NOTE(xsdg): Warning on console: 72 | # [Pulse indev @ 0x564d070e7440] The "frame_size" option is deprecated: set number of bytes per frame 73 | cmd: list[str] = [ 74 | "-ac", "2", 75 | "-ar", "44100", 76 | "-frame_size", str(self._settings.frame_size), 77 | "-fragment_size", str(self._settings.frame_size), 78 | ] 79 | 80 | if self._settings.adevice: 81 | cmd.extend(["-f", "alsa", "-i", self._settings.adevice]) 82 | else: 83 | cmd.extend(["-f", "pulse", "-i", "Mkchromecast.monitor"]) 84 | 85 | return cmd 86 | 87 | def _build_ffmpeg_command(self) -> list[str]: 88 | fmt = self._settings.codec 89 | # Special case: the ffmpeg format for AAC is ADTS 90 | if self._settings.codec == "aac": 91 | fmt = "adts" 92 | 93 | # Runs ffmpeg with debug logging enabled. 94 | maybe_debug_cmd: list[str] = ( 95 | [] if not self._settings.ffmpeg_debug else ["-loglevel", "panic"] 96 | ) 97 | 98 | maybe_bitrate_cmd: list[str] 99 | if self._settings.codec in constants.CODECS_WITH_BITRATE: 100 | maybe_bitrate_cmd = ["-b:a", f"{self._settings.bitrate}k"] 101 | else: 102 | maybe_bitrate_cmd = [] 103 | 104 | # TODO(xsdg): It's really weird that the legacy code excludes 105 | # specifically Darwin/ogg and Linux/aac. Do some more testing to 106 | # determine if this was just a copy-paste error or if there's an 107 | # underlying motivation for which of these don't use segment_time. 108 | maybe_segment_cmd: list[str] 109 | if self._settings.segment_time and ( 110 | (self._platform == "Darwin" and fmt != "ogg") or 111 | (self._platform == "Linux" and fmt != "adts")): 112 | maybe_segment_cmd = [ 113 | "-f", "segment", 114 | "-segment_time", str(self._settings.segment_time) 115 | ] 116 | else: 117 | maybe_segment_cmd = [] 118 | 119 | # TODO(xsdg): Figure out and document why -segment_time and -cutoff are 120 | # clustered specifically for aac. 121 | maybe_cutoff_cmd: list[str] 122 | if fmt == "adts" and self._settings.segment_time: 123 | maybe_cutoff_cmd = ["-cutoff", "18000"] 124 | else: # fmt != "adts" or bool(segment_time) == False 125 | maybe_cutoff_cmd = [] 126 | 127 | return [self._backend.path, 128 | *maybe_debug_cmd, 129 | *self._input_command(), 130 | *maybe_segment_cmd, 131 | "-f", fmt, 132 | "-acodec", self._ffmpeg_fmt_to_acodec[fmt], 133 | "-ac", "2", 134 | "-ar", self._settings.samplerate, 135 | *maybe_bitrate_cmd, 136 | *maybe_cutoff_cmd, 137 | "pipe:", 138 | ] 139 | 140 | def _build_linux_other_command(self) -> list[str]: 141 | if self._settings.codec == "mp3": 142 | return ["lame", 143 | "-b", str(self._settings.bitrate), 144 | "-r", 145 | "-"] 146 | 147 | if self._settings.codec == "ogg": 148 | return ["oggenc", 149 | "-b", str(self._settings.bitrate), 150 | "-Q", 151 | "-r", 152 | "--ignorelength", 153 | "-"] 154 | 155 | # Original comment: AAC > 128k for Stereo, Default sample rate: 44100Hz. 156 | if self._settings.codec == "aac": 157 | # TODO(xsdg): This always applies the 18kHz cutoff, in contrast to 158 | # the ffmpeg code which only applies it when segment_time is 159 | # included. Figure out this discrepancy. 160 | return ["faac", 161 | "-b", str(self._settings.bitrate), 162 | "-X", 163 | "-P", 164 | "-c", "18000", 165 | "-o", "-", 166 | "-"] 167 | 168 | if self._settings.codec == "opus": 169 | return ["opusenc", 170 | "-", 171 | "--raw", 172 | "--bitrate", str(self._settings.bitrate), 173 | "--raw-rate", self._settings.samplerate, 174 | "-"] 175 | 176 | if self._settings.codec == "wav": 177 | return ["sox", 178 | "-t", "raw", 179 | "-b", "16", 180 | "-e", "signed", 181 | "-c", "2", 182 | "-r", self._settings.samplerate, 183 | "-", 184 | "-t", "wav", 185 | "-b", "16", 186 | "-e", "signed", 187 | "-c", "2", 188 | "-r", self._settings.samplerate, 189 | "-L", 190 | "-"] 191 | 192 | if self._settings.codec == "flac": 193 | return ["flac", 194 | "-", 195 | "-c", 196 | "--channels", "2", 197 | "--bps", "16", 198 | "--sample-rate", self._settings.samplerate, 199 | "--endian", "little", 200 | "--sign", "signed", 201 | "-s"] 202 | 203 | raise Exception(f"Can't handle unexpected codec {self._settings.codec}") 204 | 205 | 206 | def is_mkv(filename: str) -> bool: 207 | return filename.endswith("mkv") 208 | 209 | 210 | @dataclass 211 | class VideoSettings: 212 | display: Optional[str] # TODO(xsdg): Should this be Optional? 213 | fps: str 214 | input_file: Optional[str] 215 | loop: bool 216 | operation: OpMode 217 | resolution: Optional[str] 218 | screencast: bool 219 | seek: Optional[str] 220 | subtitles: Optional[str] 221 | user_command: Optional[str] # TODO(xsdg): check type. 222 | vcodec: str 223 | youtube_url: Optional[str] 224 | 225 | 226 | class Video: 227 | # Differences compared to original policies: 228 | # - Using `veryfast` preset across the board, instead of `ultrafast`. 229 | # - Differences in vencode policy (see function). 230 | # - Avoids running ffmpeg with panic loglevel when --debug specified. 231 | # 232 | # Note that this implementation remains broken (as was the original) in that 233 | # "-vf" can only be specified once per stream, but will end up being 234 | # specified twice if both subtitles and resolution are used. 235 | 236 | def __init__(self, video_settings: VideoSettings): 237 | self._settings = video_settings 238 | 239 | @property 240 | def command(self) -> SubprocessCommand: 241 | if self._settings.operation == OpMode.YOUTUBE: 242 | return ["yt-dlp", "-o", "-", self._settings.youtube_url] 243 | 244 | if self._settings.operation == OpMode.SCREENCAST: 245 | return self._screencast_command() 246 | 247 | if self._settings.user_command: 248 | return self._settings.user_command 249 | 250 | if self._settings.operation == OpMode.INPUT_FILE: 251 | return self._input_file_command() 252 | 253 | # TODO(xsdg): Figure out if there's any way to actually get here. 254 | raise Exception("Internal error: Unexpected video operation mode " 255 | f"{self._settings.operation}") 256 | 257 | def _screencast_command(self) -> list[str]: 258 | screen_size = resolution.resolution( 259 | self._settings.resolution or "1080p", 260 | self._settings.screencast 261 | ) 262 | 263 | maybe_veryfast_cmd: list[str] 264 | if self._settings.vcodec != "h264_nvenc": 265 | maybe_veryfast_cmd = ["-preset", "veryfast"] 266 | else: 267 | maybe_veryfast_cmd = [] 268 | 269 | return ["ffmpeg", 270 | "-ac", "2", 271 | "-ar", "44100", 272 | "-frame_size", "2048", 273 | "-fragment_size", "2048", 274 | "-f", "pulse", 275 | "-ac", "2", 276 | "-i", "Mkchromecast.monitor", 277 | "-f", "x11grab", 278 | "-r", self._settings.fps, 279 | "-s", screen_size, 280 | "-i", "{}+0,0".format(self._settings.display), 281 | "-vcodec", self._settings.vcodec, 282 | *maybe_veryfast_cmd, 283 | "-tune", "zerolatency", 284 | "-maxrate", "10000k", 285 | "-bufsize", "20000k", 286 | "-pix_fmt", "yuv420p", 287 | "-g", "60", # '-c:a', 'copy', '-ac', '2', 288 | # '-b', '900k', 289 | "-f", "mp4", 290 | "-movflags", "frag_keyframe+empty_moov", 291 | "-ar", "44100", 292 | "-acodec", "libvorbis", 293 | "pipe:1", 294 | ] 295 | 296 | @staticmethod 297 | def _input_file_subtitle( 298 | subtitles: Optional[str], 299 | is_mkv: bool) -> tuple[list[str], list[str]]: 300 | """Returns input_file arguments related to subtitles. 301 | 302 | Depending on the pipeline settings, this will return arguments to be 303 | specified alongside the input streams, and/or arguments to be specified 304 | near the output settings. 305 | 306 | Returns: 307 | A tuple of (input-adjacent args, output-adjacent args). 308 | """ 309 | if not subtitles: 310 | return ([], [],) 311 | 312 | if not is_mkv: 313 | # Only output-adjacent args for non-mkv input files. 314 | output_args = ["-vf", f"subtitles={subtitles}"] 315 | return ([], output_args,) 316 | 317 | print(colors.warning("Subtitles with mkv are not supported yet.")) 318 | # NOTE(xsdg): Here's an excerpt from the original command: 319 | # "-i", _mkcc.input_file, 320 | # "-i", _mkcc.subtitles, 321 | # "-c:v", "copy", 322 | # "-c:a", "copy", 323 | # "-c:s", "mov_text", 324 | # "-map", "0:0", 325 | # "-map", "0:1", 326 | # "-map", "1:0", 327 | # 328 | # The first number in the "-map" command corresponds to an input 329 | # stream. So "1" is the subtitle stream, and "0" is input_file. 330 | 331 | # NOTE(xsdg): In the original command, 332 | # "-max_muxing_queue_size" came after "-f" and before 333 | # "-movflags". We may need to move it to be functionally 334 | # equivalent to the original command. 335 | 336 | input_args = ["-i", subtitles, 337 | "-codec:s", "mov_text", 338 | "-map", "1:0"] 339 | output_args = ["-max_muxing_queue_size", "9999"] 340 | return (input_args, output_args,) 341 | 342 | @staticmethod 343 | def _input_file_vencode(input_file: str, res: Optional[str]) -> list[str]: 344 | """Specifies the video encoding args according to a simple policy. 345 | 346 | 1) If any reencoding is being done (for instance, to rescale), use 347 | libx264 vcodec with yuv420p pixel format 348 | 2) If input pixel format is yuv420p10le (HDR), re-encode using libx264 349 | with yuv420p pixel format. 350 | 3) Otherwise, copy input with no re-encoding. 351 | """ 352 | 353 | # Original policy was _extremely_ inconsistent and wasn't worth 354 | # replicating. Note that this may cause some regressions which would 355 | # need to be re-fixed going forward. 356 | 357 | input_is_mkv = is_mkv(input_file) 358 | copy_strategy = ["-vcodec", "copy"] 359 | reencode_strategy = [ 360 | "-vcodec", "libx264", 361 | "-preset", "veryfast", 362 | "-tune", "zerolatency", 363 | "-maxrate", "10000k", 364 | "-bufsize", "20000k", 365 | "-pix_fmt", "yuv420p", 366 | "-g", "60" 367 | ] 368 | 369 | if res: 370 | # Rescaling always requires re-encoding. 371 | # TODO(xsdg): Optimization: if the input resolution already matches 372 | # the output resolution, avoid re-encoding? 373 | return reencode_strategy 374 | 375 | # TODO(xsdg): Why does mkv or not-mkv matter here? 376 | if not input_is_mkv: 377 | # Return early to avoid expensive check_file_info. 378 | return copy_strategy 379 | 380 | # NOTE(xsdg): A video from youtube with the following specs played 381 | # without issue using the copy strategy, so I'm not sure if the 382 | # following workaround is still necessary: 383 | # 384 | # Stream #0:0(eng): Video: vp9 (Profile 2) (vp09 / 0x39307076), yuv420p10le(tv, bt2020nc/bt2020/smpte2084), 3840x2160 [SAR 1:1 DAR 16:9], q=2-31, 59.94 fps, 59.94 tbr, 16k tbn (default) 385 | # Metadata: 386 | # DURATION : 00:05:13.779000000 387 | # Side data: 388 | # Content Light Level Metadata, MaxCLL=1100, MaxFALL=180 389 | # Mastering Display Metadata, has_primaries:1 has_luminance:1 r(0.6780,0.3220) g(0.2450,0.7030) b(0.1380 0.0520) wp(0.3127, 0.3290) min_luminance=0.000100, max_luminance=1000.000000 390 | 391 | 392 | # TODO(xsdg): rename "what" from "bit-depth" to something more accurate. 393 | pixel_format = utils.check_file_info(input_file, what="bit-depth") 394 | if pixel_format == "yuv420p10le": 395 | return reencode_strategy 396 | 397 | return copy_strategy 398 | 399 | @staticmethod 400 | def _input_file_aencode(has_subtitles: bool, input_is_mkv: bool) -> list[str]: 401 | # Original acodec policy: 402 | #sub None, mkv False -> [] 403 | #sub None mkv True -> ["-acodec", "libmp3lame", "-q:a", "0"] 404 | #sub not None, mkv False -> [] 405 | #sub not None, mkv True -> ["-c:a", "copy"] 406 | 407 | # acodec is only specified for mkv files. 408 | if not input_is_mkv: 409 | return [] 410 | 411 | if has_subtitles: 412 | return ["-codec:a", "copy"] 413 | else: 414 | return ["-codec:a", "libmp3lame", 415 | "-q:a", "0"] 416 | 417 | def _input_file_command(self) -> list[str]: 418 | # Commands adapted from: 419 | # https://trac.ffmpeg.org/wiki/EncodingForStreamingSites#Streamingafile 420 | if not self._settings.input_file: 421 | raise Exception("Internal error: input file is not specified.") 422 | 423 | input_is_mkv = is_mkv(self._settings.input_file) 424 | 425 | maybe_loop_cmd: list[str] = ( 426 | ["-stream_loop", "-1"] if self._settings.loop else []) 427 | 428 | maybe_seek_cmd: list[str] = ( 429 | ["-ss", self._settings.seek] if self._settings.seek else []) 430 | 431 | maybe_resolution_cmd: list[str] 432 | if self._settings.resolution: 433 | maybe_resolution_cmd = [ 434 | "-vf", 435 | resolution.resolutions[self._settings.resolution][0] 436 | ] 437 | else: 438 | maybe_resolution_cmd = [] 439 | 440 | maybe_input_subtitle_cmd, maybe_filter_subtitle_cmd = ( 441 | self._input_file_subtitle(self._settings.subtitles, input_is_mkv)) 442 | 443 | vencode_cmd = self._input_file_vencode(self._settings.input_file, 444 | self._settings.resolution) 445 | 446 | aencode_cmd = self._input_file_aencode(bool(self._settings.subtitles), 447 | input_is_mkv) 448 | 449 | return [ 450 | "ffmpeg", 451 | *maybe_loop_cmd, 452 | *maybe_seek_cmd, 453 | "-re", 454 | "-i", self._settings.input_file, 455 | *maybe_input_subtitle_cmd, 456 | "-map_chapters", "-1", 457 | *vencode_cmd, 458 | *aencode_cmd, 459 | "-f", "mp4", 460 | "-movflags", "frag_keyframe+empty_moov", 461 | *maybe_filter_subtitle_cmd, 462 | *maybe_resolution_cmd, 463 | "pipe:1", 464 | ] 465 | -------------------------------------------------------------------------------- /mkchromecast/preferences.py: -------------------------------------------------------------------------------- 1 | # This file is part of mkchromecast. 2 | 3 | import getpass 4 | import os 5 | import sys 6 | from typing import Any, Optional 7 | import webbrowser 8 | 9 | import mkchromecast 10 | from mkchromecast import constants 11 | from mkchromecast import config 12 | from mkchromecast.constants import OpMode 13 | from mkchromecast.utils import is_installed 14 | 15 | """ 16 | Check if external programs are available to build the preferences 17 | """ 18 | 19 | # TODO(xsdg): Encapsulate this so that we don't do this work on import. 20 | _mkcc = mkchromecast.Mkchromecast() 21 | BITRATE_OPTIONS = [128, 160, 192, 224, 256, 320, 500] 22 | VISUAL_BOOL = {True: "enabled", False: "disabled"} 23 | USER = getpass.getuser() 24 | 25 | if _mkcc.platform == "Darwin": 26 | # TODO(xsdg): This seems really inappropriate. We should be respecting the 27 | # user's PATH rather than potentially running binaries that they don't 28 | # expect. 29 | PATH = ( 30 | "./bin:./nodejs/bin:/Users/" 31 | + str(USER) 32 | + "/bin:/usr/local/bin:/usr/local/sbin:/usr/bin:/bin:/usr/sbin:/sbin:/opt/X11/bin:/usr/X11/bin:/usr/games:" 33 | + os.environ["PATH"] 34 | ) 35 | else: 36 | PATH = os.environ["PATH"] 37 | 38 | if _mkcc.debug is True: 39 | print("USER =" + str(USER)) 40 | print("PATH =" + str(PATH)) 41 | 42 | 43 | if _mkcc.operation == OpMode.TRAY: 44 | from PyQt5.QtWidgets import ( 45 | QWidget, 46 | QLabel, 47 | QComboBox, 48 | QApplication, 49 | QPushButton, 50 | QLineEdit, 51 | ) 52 | from PyQt5 import QtCore 53 | 54 | class preferences(QWidget): 55 | def __init__(self, scale_factor): 56 | super().__init__() 57 | 58 | self.scale_factor = scale_factor 59 | self.config = config.Config(platform=_mkcc.platform, 60 | read_only=False, 61 | debug=_mkcc.debug) 62 | # TODO(xsdg): Move UI creation out of the constructor. 63 | self.config.load_and_validate() 64 | self.initUI() 65 | 66 | def initUI(self): 67 | self.init_backend() 68 | self.init_codec() 69 | self.init_bitrate() 70 | self.init_samplerates() 71 | self.init_iconcolors() 72 | self.init_notifications() 73 | self.init_searchatlaunch() 74 | 75 | self.qle_alsadevice: Optional[QLineEdit] = None 76 | if _mkcc.platform == "Linux": 77 | self.init_alsadevice() 78 | 79 | self.init_buttons() 80 | self.init_window() 81 | 82 | def init_backend(self): 83 | backend_options = constants.backend_options_for_platform( 84 | _mkcc.platform 85 | ) 86 | backends: list[str] = [] 87 | for option in backend_options: 88 | if is_installed(option, PATH, _mkcc.debug): 89 | backends.append(option) 90 | 91 | self.backend = QLabel("Select Backend", self) 92 | self.backend.move(20 * self.scale_factor, 24 * self.scale_factor) 93 | self.qcbackend = QComboBox(self) 94 | self.qcbackend.move(180 * self.scale_factor, 20 * self.scale_factor) 95 | self.qcbackend.setMinimumContentsLength(7) 96 | for backend in backends: 97 | self.qcbackend.addItem(backend) 98 | 99 | self.jump_to_item_or_start(self.qcbackend, self.config.backend) 100 | 101 | self.qcbackend.activated[str].connect(self.onActivatedbk) 102 | 103 | def init_codec(self): 104 | self.codec = QLabel("Audio Coding Format", self) 105 | self.codec.move(20 * self.scale_factor, 56 * self.scale_factor) 106 | self.qccodec = QComboBox(self) 107 | self.qccodec.move(180 * self.scale_factor, 54 * self.scale_factor) 108 | self.qccodec.setMinimumContentsLength(7) 109 | 110 | self.update_available_codecs() 111 | self.jump_to_item_or_start(self.qccodec, self.config.codec) 112 | 113 | self.qccodec.activated[str].connect(self.onActivatedcc) 114 | 115 | def init_bitrate(self): 116 | """ 117 | Bitrate 118 | """ 119 | self.bitrate = QLabel("Select Bitrate (kbit/s)", self) 120 | self.bitrate.move(20 * self.scale_factor, 88 * self.scale_factor) 121 | self.qcbitrate = QComboBox(self) 122 | self.qcbitrate.move(180 * self.scale_factor, 88 * self.scale_factor) 123 | self.qcbitrate.setMinimumContentsLength(7) 124 | 125 | self.update_available_bitrates() 126 | self.jump_to_item_or_start(self.qcbitrate, str(self.config.bitrate)) 127 | 128 | self.qcbitrate.activated[str].connect(self.onActivatedbt) 129 | 130 | def init_samplerates(self): 131 | """ 132 | Sample rate 133 | """ 134 | self.samplerate = QLabel("Sample Rate (Hz)", self) 135 | self.samplerate.move(20 * self.scale_factor, 120 * self.scale_factor) 136 | self.qcsamplerate = QComboBox(self) 137 | self.qcsamplerate.move(180 * self.scale_factor, 120 * self.scale_factor) 138 | self.qcsamplerate.setMinimumContentsLength(7) 139 | 140 | for samplerate in constants.ALL_SAMPLE_RATES: 141 | self.qcsamplerate.addItem(str(samplerate)) 142 | self.jump_to_item_or_start(self.qcsamplerate, 143 | str(self.config.samplerate)) 144 | 145 | self.qcsamplerate.activated[str].connect(self.onActivatedsr) 146 | 147 | def init_iconcolors(self): 148 | """ 149 | Icon colors 150 | """ 151 | self.colors = QLabel("Icon Colors", self) 152 | self.colors.move(20 * self.scale_factor, 152 * self.scale_factor) 153 | self.qccolors = QComboBox(self) 154 | self.qccolors.move(180 * self.scale_factor, 152 * self.scale_factor) 155 | self.qccolors.setMinimumContentsLength(7) 156 | 157 | colors_list = ["black", "blue", "white"] 158 | for color in colors_list: 159 | self.qccolors.addItem(color) 160 | self.jump_to_item_or_start(self.qccolors, self.config.colors) 161 | 162 | self.qccolors.activated[str].connect(self.onActivatedcolors) 163 | 164 | def init_notifications(self): 165 | """ 166 | Notifications 167 | """ 168 | self.notifications = QLabel("Notifications", self) 169 | self.notifications.move(20 * self.scale_factor, 184 * self.scale_factor) 170 | self.qcnotifications = QComboBox(self) 171 | self.qcnotifications.move(180 * self.scale_factor, 184 * self.scale_factor) 172 | self.qcnotifications.setMinimumContentsLength(7) 173 | 174 | for item in VISUAL_BOOL.values(): 175 | self.qcnotifications.addItem(item) 176 | self.jump_to_item_or_start( 177 | self.qcnotifications, VISUAL_BOOL[self.config.notifications]) 178 | 179 | self.qcnotifications.activated[str].connect(self.onActivatednotify) 180 | 181 | def init_searchatlaunch(self): 182 | """ 183 | Search at launch 184 | """ 185 | self.atlaunch = QLabel("Search At Launch", self) 186 | self.atlaunch.move(20 * self.scale_factor, 214 * self.scale_factor) 187 | self.qcatlaunch = QComboBox(self) 188 | self.qcatlaunch.move(180 * self.scale_factor, 214 * self.scale_factor) 189 | self.qcatlaunch.setMinimumContentsLength(7) 190 | 191 | for item in VISUAL_BOOL.values(): 192 | self.qcatlaunch.addItem(item) 193 | self.jump_to_item_or_start( 194 | self.qcatlaunch, VISUAL_BOOL[self.config.search_at_launch]) 195 | 196 | self.qcatlaunch.activated[str].connect(self.onActivatedatlaunch) 197 | 198 | def init_alsadevice(self): 199 | """ 200 | Set the ALSA Device 201 | """ 202 | self.alsadevice = QLabel("ALSA Device", self) 203 | self.alsadevice.move(20 * self.scale_factor, 244 * self.scale_factor) 204 | self.qle_alsadevice = QLineEdit(self) 205 | self.qle_alsadevice.move(179 * self.scale_factor, 244 * self.scale_factor) 206 | self.qle_alsadevice.setFixedWidth(84 * self.scale_factor) 207 | 208 | if self.config.alsa_device: 209 | self.qle_alsadevice.setText(self.config.alsa_device) 210 | 211 | self.qle_alsadevice.textChanged[str].connect(self.onActivatedalsadevice) 212 | 213 | def init_buttons(self): 214 | """ 215 | Buttons 216 | """ 217 | resetbtn = QPushButton("Reset Settings", self) 218 | resetbtn.move(10 * self.scale_factor, 274 * self.scale_factor) 219 | resetbtn.clicked.connect(self.reset_configuration) 220 | 221 | faqbtn = QPushButton("FAQ", self) 222 | faqbtn.move(138 * self.scale_factor, 274 * self.scale_factor) 223 | faqbtn.clicked.connect( 224 | lambda: webbrowser.open( 225 | "https://github.com/muammar/mkchromecast/wiki/FAQ" 226 | ) 227 | ) 228 | 229 | def init_window(self): 230 | """ 231 | Geometry and window's title 232 | """ 233 | self.setGeometry( 234 | 300 * self.scale_factor, 235 | 300 * self.scale_factor, 236 | 300 * self.scale_factor, 237 | 200 * self.scale_factor, 238 | ) 239 | if _mkcc.platform == "Darwin": 240 | # This is to fix the size of the window 241 | self.setFixedSize(310 * self.scale_factor, 320 * self.scale_factor) 242 | else: 243 | # This is to fix the size of the window 244 | self.setFixedSize(282 * self.scale_factor, 320 * self.scale_factor) 245 | self.setWindowFlags( 246 | QtCore.Qt.WindowCloseButtonHint 247 | | QtCore.Qt.WindowMinimizeButtonHint 248 | | QtCore.Qt.WindowStaysOnTopHint 249 | ) 250 | self.setWindowTitle("Mkchromecast Preferences") 251 | 252 | def reset_configuration(self): 253 | self.configurations.write_defaults() 254 | 255 | # Select/set default item for each preference widget. 256 | self.jump_to_item_or_start(self.qcbackend, self.config.backend) 257 | self.jump_to_item_or_start(self.qccodec, self.config.codec) 258 | self.jump_to_item_or_start(self.qcbitrate, str(self.config.bitrate)) 259 | self.jump_to_item_or_start( 260 | self.qcsamplerate, str(self.config.samplerate)) 261 | self.jump_to_item_or_start(self.qccolors, self.config.colors) 262 | self.jump_to_item_or_start( 263 | self.qcnotifations, VISUAL_BOOL[self.config.notifications]) 264 | self.jump_to_item_or_start( 265 | self.qcatlaunch, VISUAL_BOOL[self.config.search_at_launch]) 266 | if self.qle_alsadevice: 267 | self.qle_alsadevice.clear() 268 | if self.config.alsa_device: 269 | self.qle_alsadevice.setText(self.config.alsa_device) 270 | 271 | def jump_to_item_or_start(self, qcb: QComboBox, needle: str) -> bool: 272 | """Finds and jumps to the specified item in the QComboBox. 273 | 274 | If the item is not found, jumps to index 0 instead. 275 | 276 | Returns: 277 | True if the item was found and selected, False otherwise. 278 | """ 279 | needle_index = qcb.findText(needle) 280 | qcb.setCurrentIndex(needle_index) 281 | return needle_index >= 0 282 | 283 | def update_available_codecs(self): 284 | if self.config.backend == "node": 285 | codecs = [constants.NODE_CODEC] 286 | else: 287 | codecs = constants.ALL_CODECS 288 | 289 | if _mkcc.debug is True: 290 | print("Codecs: %s." % codecs) 291 | 292 | self.qccodec.clear() 293 | self.qccodec.move(180 * self.scale_factor, 54 * self.scale_factor) 294 | self.qccodec.setMinimumContentsLength(7) 295 | for codec in codecs: 296 | self.qccodec.addItem(codec) 297 | self.jump_to_item_or_start(self.qccodec, self.config.codec) 298 | 299 | def update_available_bitrates(self): 300 | bitrates: list[Any] 301 | if self.config.codec in constants.CODECS_WITH_BITRATE: 302 | bitrate_idx = BITRATE_OPTIONS.index(self.config.bitrate) 303 | bitrates = BITRATE_OPTIONS 304 | else: 305 | bitrate_idx = 0 306 | bitrates = [None] 307 | 308 | self.qcbitrate.clear() 309 | self.qcbitrate.move(180 * self.scale_factor, 88 * self.scale_factor) 310 | for bitrate in bitrates: 311 | self.qcbitrate.addItem(str(bitrate)) 312 | self.qcbitrate.setCurrentIndex(bitrate_idx) 313 | 314 | def onActivatedbk(self, backend): 315 | # TODO(xsdg): input validation? 316 | with self.config: 317 | self.config.backend = backend 318 | if backend == "node": 319 | # Node only supports the mp3 codec. 320 | self.config.codec = constants.NODE_CODEC 321 | 322 | self.update_available_codecs() 323 | 324 | def onActivatedcc(self, codec): 325 | with self.config: 326 | self.config.codec = codec 327 | 328 | self.update_available_bitrates() 329 | 330 | def onActivatedbt(self, bitrate): 331 | with self.config: 332 | self.config.bitrate = int(bitrate) 333 | 334 | def onActivatedsr(self, samplerate): 335 | with self.config: 336 | self.config.samplerate = int(samplerate) 337 | 338 | def onActivatednotify(self, setting): 339 | # TODO(xsdg): Switch this to a checkbox. 340 | with self.config: 341 | self.config.notifications = setting == "enabled" 342 | 343 | def onActivatedcolors(self, colors): 344 | with self.config: 345 | self.config.colors = colors 346 | 347 | def onActivatedatlaunch(self, setting): 348 | with self.config: 349 | self.config.search_at_launch = setting == "enabled" 350 | 351 | def onActivatedalsadevice(self, alsa_device): 352 | with self.config: 353 | if alsa_device: 354 | self.config.alsa_device = alsa_device 355 | else: 356 | self.config.alsa_device = None 357 | 358 | 359 | if __name__ == "__main__": 360 | app = QApplication(sys.argv) 361 | p = preferences() 362 | p.show() 363 | sys.exit(app.exec_()) 364 | -------------------------------------------------------------------------------- /mkchromecast/pulseaudio.py: -------------------------------------------------------------------------------- 1 | # This file is part of mkchromecast. 2 | 3 | import subprocess 4 | import time 5 | import re 6 | 7 | _sink_num = None 8 | 9 | 10 | def create_sink(): 11 | global _sink_num 12 | 13 | sink_name = "Mkchromecast" 14 | 15 | create_sink = [ 16 | "pactl", 17 | "load-module", 18 | "module-null-sink", 19 | "sink_name=" + sink_name, 20 | "sink_properties=device.description=" + sink_name, 21 | ] 22 | 23 | cs = subprocess.Popen(create_sink, stdout=subprocess.PIPE, stderr=subprocess.PIPE) 24 | csoutput, cserror = cs.communicate() 25 | _sink_num = csoutput[:-1] 26 | 27 | return 28 | 29 | 30 | def remove_sink(): 31 | global _sink_num 32 | 33 | if _sink_num is None: 34 | return 35 | 36 | if not isinstance(_sink_num, list): 37 | _sink_num = [_sink_num] 38 | 39 | for num in _sink_num: 40 | remove_sink = [ 41 | "pactl", 42 | "unload-module", 43 | num.decode("utf-8") if type(num) == bytes else str(num), 44 | ] 45 | rms = subprocess.run( 46 | remove_sink, 47 | stdout=subprocess.PIPE, 48 | stderr=subprocess.PIPE, 49 | timeout=60, 50 | check=True, 51 | ) 52 | 53 | 54 | def check_sink(): 55 | try: 56 | check_sink = ["pactl", "list", "sinks"] 57 | chk = subprocess.Popen( 58 | check_sink, stdout=subprocess.PIPE, stderr=subprocess.PIPE 59 | ) 60 | chkoutput, chkerror = chk.communicate() 61 | except FileNotFoundError: 62 | return None 63 | 64 | try: 65 | if "Mkchromecast" in chkoutput: 66 | return True 67 | else: 68 | return False 69 | except TypeError: 70 | if "Mkchromecast" in chkoutput.decode("utf-8"): 71 | return True 72 | else: 73 | return False 74 | 75 | 76 | def get_sink_list(): 77 | """Get a list of sinks with a name prefix of Mkchromecast and save to _sink_num. 78 | 79 | Used to clear any residual sinks from previous failed actions. The number 80 | saved to _sink_num is the module index, which can be passed to pactl. 81 | """ 82 | global _sink_num 83 | 84 | cmd = ["pactl", "list", "sinks"] 85 | result = subprocess.run( 86 | cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=60, check=True 87 | ) 88 | 89 | pattern = re.compile( 90 | r"^Sink\s*#\d+\s*$(?:\n^.*?$)*?\n\s*?Name:\s*?Mkchromecast.*" 91 | + r"\s*?$(?:\n^.*?$)*?\n^\s*?Owner Module: (?P\d+?)\s*?$", 92 | re.MULTILINE, 93 | ) 94 | matches = pattern.findall(result.stdout.decode("utf-8"), re.MULTILINE) 95 | 96 | _sink_num = [int(i) for i in matches] 97 | -------------------------------------------------------------------------------- /mkchromecast/resolution.py: -------------------------------------------------------------------------------- 1 | # This file is part of mkchromecast. 2 | 3 | # TODO(xsdg): Move this to pipeline_builder.py. 4 | resolutions = { 5 | "480p": ("scale-854:-1", "854x480:"), 6 | "720p": ("scale=1280:-1", "1280x720"), 7 | "1366x768": ("scale=1366x768", "1366x768"), 8 | "1080p": ("scale=1920x1080", "1920x1080"), 9 | "2k": ("scale=2048x1148", "2048x1148"), 10 | "1440p": ("scale=2560x1440", "2560x1440"), 11 | "uhd": ("scale=3840x2160", "3840x2160"), 12 | "2160p": ("scale=3840x2160", "3840x2160"), 13 | "4k": ("scale=4096:-1", "4096x2160"), 14 | } 15 | 16 | 17 | def resolution(res, screencast): 18 | res = resolutions[res.lower()] 19 | if not screencast: 20 | return ["-vf", res[0]] 21 | else: 22 | return res[1] 23 | -------------------------------------------------------------------------------- /mkchromecast/stream_infra.py: -------------------------------------------------------------------------------- 1 | # This file is part of mkchromecast. 2 | 3 | from dataclasses import dataclass 4 | import flask 5 | from functools import partial 6 | import multiprocessing 7 | import os 8 | import pickle 9 | import psutil 10 | from subprocess import Popen, PIPE 11 | import sys 12 | import textwrap 13 | import threading 14 | import time 15 | from typing import Callable, Optional, Union 16 | 17 | import mkchromecast 18 | from mkchromecast.audio_devices import inputint, outputint 19 | from mkchromecast import colors 20 | 21 | FlaskViewReturn = Union[str, flask.Response] 22 | 23 | 24 | @dataclass 25 | class BackendInfo: 26 | name: Optional[str] = None 27 | # TODO(xsdg): Switch to pathlib for this. 28 | path: Optional[str] = None 29 | 30 | 31 | # TODO(xsdg): Consider porting to https://github.com/pallets-eco/flask-classful 32 | # for a more natural approach to using Flask in an encapsulated way. 33 | class FlaskServer: 34 | """Singleton Flask server for Chromecast audio and video casting. 35 | 36 | Given that Flask is module-based, this "class" encapsulates the state at a 37 | class level, and not at an instance level. 38 | """ 39 | 40 | _app: Optional[flask.Flask] = None 41 | _video_mode: Optional[bool] = None 42 | 43 | _mkcc: mkchromecast.Mkchromecast 44 | _stream_url: str = "stream" 45 | 46 | # Common arguments. 47 | _command: Union[str, list[str]] 48 | _media_type: str 49 | 50 | # Audio arguments. 51 | _adevice: Optional[str] 52 | _backend: BackendInfo 53 | _bitrate: int 54 | _buffer_size: int 55 | _codec: str 56 | _platform: str 57 | _samplerate: str 58 | 59 | # Video arguments. 60 | _chunk_size: int 61 | 62 | @staticmethod 63 | def _init_common(video_mode: bool) -> None: 64 | if FlaskServer._app is not None or FlaskServer._video_mode is not None: 65 | raise Exception("Flask Server can only be initialized once.") 66 | 67 | FlaskServer._app = flask.Flask("mkchromecast") 68 | FlaskServer._app.add_url_rule("/", view_func=FlaskServer._index) 69 | 70 | # TODO(xsdg): Maybe just have distinct audio and video endpoints? 71 | if video_mode: 72 | FlaskServer._app.add_url_rule("/stream", 73 | view_func=FlaskServer._stream_video) 74 | else: 75 | FlaskServer._app.add_url_rule("/stream", 76 | view_func=FlaskServer._stream_audio) 77 | 78 | FlaskServer._video_mode = video_mode 79 | 80 | @staticmethod 81 | def init_audio(adevice: Optional[str], 82 | backend: BackendInfo, 83 | bitrate: int, 84 | buffer_size: int, 85 | codec: str, 86 | command: Union[str, list[str]], 87 | media_type: str, 88 | platform: str, 89 | samplerate: str) -> None: 90 | FlaskServer._init_common(video_mode=False) 91 | 92 | FlaskServer._adevice = adevice 93 | FlaskServer._backend = backend 94 | FlaskServer._bitrate = bitrate 95 | FlaskServer._buffer_size = buffer_size 96 | FlaskServer._codec = codec 97 | FlaskServer._command = command 98 | FlaskServer._media_type = media_type 99 | FlaskServer._platform = platform 100 | FlaskServer._samplerate = samplerate 101 | 102 | @staticmethod 103 | def init_video(chunk_size: int, 104 | command: Union[str, list[str]], 105 | media_type: str) -> None: 106 | FlaskServer._init_common(video_mode=True) 107 | 108 | FlaskServer._chunk_size = chunk_size 109 | FlaskServer._command = command 110 | FlaskServer._media_type = media_type 111 | 112 | @staticmethod 113 | def run(host: str, port: int) -> None: 114 | FlaskServer._ensure_initialized() 115 | 116 | # NOTE(xsdg): video.py used threaded=True and didn't specify 117 | # passthrough_errors. audio.py used passthrough_errors=False and didn't 118 | # specify threaded. 119 | # I _believe_ that threaded is a bad idea, since it would potentially 120 | # launch multiple streaming pipelines. I could be wrong about that, 121 | # though. 122 | 123 | # Original comment: Note that passthrough_errors=False is useful when 124 | # reconnecting. In that way, flask won't die. 125 | FlaskServer._app.run(host=host, port=port, passthrough_errors=False) 126 | 127 | @staticmethod 128 | def _ensure_initialized(): 129 | if FlaskServer._app is None or FlaskServer._video_mode is None: 130 | raise Exception("Flask Server needs to be initialized first.") 131 | 132 | @staticmethod 133 | def _ensure_audio_mode(): 134 | FlaskServer._ensure_initialized() 135 | if FlaskServer._video_mode == True: 136 | raise Exception( 137 | "Tried to use audio mode, but Flask Server was initialized in " 138 | "video mode.") 139 | 140 | @staticmethod 141 | def _ensure_video_mode(): 142 | FlaskServer._ensure_initialized() 143 | if FlaskServer._video_mode == False: 144 | raise Exception( 145 | "Tried to use vidio mode, but Flask Server was initialized in " 146 | "audio mode.") 147 | 148 | @staticmethod 149 | def _index() -> FlaskViewReturn: 150 | FlaskServer._ensure_initialized() 151 | 152 | # TODO(xsdg): Add head and body tags? 153 | if FlaskServer._video_mode: 154 | return textwrap.dedent(f"""\ 155 | 156 | Play {FlaskServer._stream_url} 157 | 161 | """) 162 | else: 163 | return textwrap.dedent(f"""\ 164 | 165 | Play {FlaskServer._stream_url} 166 | 170 | """) 171 | 172 | @staticmethod 173 | def _stream_video() -> flask.Response: 174 | FlaskServer._ensure_video_mode() 175 | 176 | process = Popen(FlaskServer._command, stdout=PIPE, bufsize=-1) 177 | read_chunk = partial(os.read, process.stdout.fileno(), FlaskServer._chunk_size) 178 | return flask.Response(iter(read_chunk, b""), mimetype=FlaskServer._media_type) 179 | 180 | @staticmethod 181 | def _stream_audio(): 182 | FlaskServer._ensure_audio_mode() 183 | 184 | if ( 185 | FlaskServer._platform == "Linux" 186 | and FlaskServer._backend.name == "parec" 187 | and FlaskServer._backend.path is not None 188 | ): 189 | c_parec = [FlaskServer._backend.path, "--format=s16le", "-d", "Mkchromecast.monitor"] 190 | parec = Popen(c_parec, stdout=PIPE) 191 | 192 | try: 193 | process = Popen(FlaskServer._command, stdin=parec.stdout, stdout=PIPE, bufsize=-1) 194 | except FileNotFoundError: 195 | print("Failed to execute {}".format(FlaskServer._command)) 196 | message = "Have you installed lame, see https://github.com/muammar/mkchromecast#linux-1?" 197 | raise Exception(message) 198 | 199 | else: 200 | process = Popen(FlaskServer._command, stdout=PIPE, bufsize=-1) 201 | read_chunk = partial(os.read, process.stdout.fileno(), FlaskServer._buffer_size) 202 | return flask.Response(iter(read_chunk, b""), mimetype=FlaskServer._media_type) 203 | 204 | 205 | # Launching the pipeline command in a separate process. 206 | class PipelineProcess: 207 | def __init__(self, flask_init: Callable, host: str, port: int, platform: str): 208 | self._proc = multiprocessing.Process( 209 | target=PipelineProcess.start_app, 210 | args=(flask_init, host, port, platform,) 211 | ) 212 | self._proc.daemon = True 213 | 214 | def start(self): 215 | self._proc.start() 216 | 217 | @staticmethod 218 | def start_app(flask_init: Callable, host: str, port: int, platform: str): 219 | """Starting the streaming server.""" 220 | monitor_daemon = ParentMonitor(platform) 221 | monitor_daemon.start() 222 | 223 | flask_init() 224 | FlaskServer.run(host=host, port=port) 225 | 226 | 227 | class ParentMonitor(object): 228 | """Thread that terminates this process if the main process dies. 229 | 230 | A normal running of mkchromecast will have 2 threads in the streaming 231 | process when ffmpeg is used. 232 | """ 233 | 234 | def __init__(self, platform: str): 235 | self._monitor_thread = threading.Thread(target=ParentMonitor._monitor_loop, 236 | args=(platform,)) 237 | self._monitor_thread.daemon = True 238 | 239 | def start(self): 240 | self._monitor_thread.start() 241 | 242 | @staticmethod 243 | def _monitor_loop(platform: str): 244 | with open("/tmp/mkchromecast.pid", "rb") as pid_file: 245 | main_pid = int(pickle.load(pid_file)) 246 | print(colors.options("PID of main process:") + f" {main_pid}") 247 | 248 | local_pid = os.getpid() 249 | print(colors.options("PID of streaming process:") + f" {os.getpid()}") 250 | 251 | while psutil.pid_exists(local_pid): 252 | try: 253 | time.sleep(0.5) 254 | # With this I ensure that if the main app fails, everything 255 | # will get back to normal 256 | if not psutil.pid_exists(main_pid): 257 | if platform == "Darwin": 258 | inputint() 259 | outputint() 260 | else: 261 | from mkchromecast.pulseaudio import remove_sink 262 | 263 | remove_sink() 264 | parent = psutil.Process(local_pid) 265 | # TODO(xsdg): This is unlikely to finish, given that this 266 | # code itself is running in one of the child processes. We 267 | # should instead signal the parent to terminate, and have it 268 | # handle child cleanup on its own. 269 | for child in parent.children(recursive=True): 270 | child.kill() 271 | parent.kill() 272 | 273 | except KeyboardInterrupt: 274 | print("Ctrl-c was requested") 275 | sys.exit(0) 276 | except IOError: 277 | print("I/O Error") 278 | sys.exit(0) 279 | except OSError: 280 | print("OSError") 281 | sys.exit(0) 282 | -------------------------------------------------------------------------------- /mkchromecast/tray_threading.py: -------------------------------------------------------------------------------- 1 | # This file is part of mkchromecast. 2 | 3 | import os 4 | import socket 5 | 6 | import mkchromecast 7 | from mkchromecast import audio 8 | from mkchromecast import cast 9 | from mkchromecast import colors 10 | from mkchromecast import config 11 | from mkchromecast import node 12 | from mkchromecast.audio_devices import inputdev, outputdev 13 | from mkchromecast.constants import OpMode 14 | from mkchromecast.pulseaudio import create_sink, check_sink 15 | from PyQt5.QtCore import QObject, pyqtSignal, pyqtSlot 16 | 17 | 18 | # TODO(xsdg): Encapsulate this so that we don't do this work on import. 19 | _mkcc = mkchromecast.Mkchromecast() 20 | 21 | 22 | class Search(QObject): 23 | finished = pyqtSignal() 24 | intReady = pyqtSignal(list) 25 | 26 | @pyqtSlot() 27 | def _search_cast_(self): 28 | # This should fix the error socket.gaierror making the system tray to 29 | # be closed. 30 | try: 31 | cc = cast.Casting(_mkcc) 32 | cc.initialize_cast() 33 | self.intReady.emit(cc.available_devices) 34 | self.finished.emit() 35 | except socket.gaierror: 36 | if _mkcc.debug is True: 37 | print(colors.warning( 38 | ":::Threading::: Socket error, failed to search for devices")) 39 | self.intReady.emit([]) 40 | self.finished.emit() 41 | 42 | 43 | class Player(QObject): 44 | pcastfinished = pyqtSignal() 45 | pcastready = pyqtSignal(str) 46 | 47 | @pyqtSlot() 48 | def _play_cast_(self): 49 | global cast 50 | config_ = config.Config(platform=_mkcc.platform, 51 | read_only=True, 52 | debug=_mkcc.debug) 53 | with config_: 54 | if config_.backend == "node": 55 | node.stream_audio() 56 | else: 57 | # TODO(xsdg): Drop this reload stuff. 58 | try: 59 | reload(mkchromecast.audio) 60 | except NameError: 61 | from importlib import reload 62 | 63 | reload(mkchromecast.audio) 64 | mkchromecast.audio.main() 65 | if _mkcc.platform == "Linux": 66 | # We create the sink only if it is not available 67 | if check_sink() is False and _mkcc.adevice is None: 68 | create_sink() 69 | 70 | start = cast.Casting(_mkcc) 71 | start.initialize_cast() 72 | try: 73 | start.get_devices() 74 | start.play_cast() 75 | cast = start.cast 76 | # Let's change inputs at the end to avoid muting sound too early. 77 | # For Linux it does not matter given that user has to select sink 78 | # in pulse audio. Therefore the sooner it is available, the 79 | # better. 80 | if _mkcc.platform == "Darwin": 81 | inputdev() 82 | outputdev() 83 | self.pcastready.emit("_play_cast_ success") 84 | except AttributeError: 85 | self.pcastready.emit("_play_cast_ failed") 86 | self.pcastfinished.emit() 87 | 88 | 89 | url = "https://api.github.com/repos/muammar/mkchromecast/releases/latest" 90 | 91 | 92 | class Updater(QObject): 93 | """This class is employed to check for new mkchromecast versions""" 94 | 95 | upcastfinished = pyqtSignal() 96 | updateready = pyqtSignal(str) 97 | 98 | @pyqtSlot() 99 | def _updater_(self): 100 | chk = cast.Casting(_mkcc) 101 | if chk.ip == "127.0.0.1" or None: # We verify the local IP. 102 | self.updateready.emit("None") 103 | else: 104 | try: 105 | from mkchromecast.version import __version__ 106 | import requests 107 | 108 | response = requests.get(url).text.split(",") 109 | 110 | for e in response: 111 | if "tag_name" in e: 112 | version = e.strip('"tag_name":') 113 | break 114 | 115 | if version > __version__: 116 | print("Version %s is available to download" % version) 117 | self.updateready.emit(version) 118 | else: 119 | print("You are up to date.") 120 | self.updateready.emit("False") 121 | except UnboundLocalError: 122 | self.updateready.emit("error1") 123 | except requests.exceptions.ConnectionError: 124 | self.updateready.emit("error1") 125 | 126 | self.upcastfinished.emit() 127 | -------------------------------------------------------------------------------- /mkchromecast/utils.py: -------------------------------------------------------------------------------- 1 | # This file is part of mkchromecast. 2 | 3 | import json 4 | import os 5 | import pickle 6 | import psutil 7 | import socket 8 | import subprocess 9 | from typing import List, Optional 10 | from urllib.parse import urlparse 11 | 12 | from mkchromecast import colors 13 | from mkchromecast import constants 14 | from mkchromecast import messages 15 | 16 | 17 | def quantize_sample_rate(codec: str, 18 | sample_rate: int, 19 | limit_to_48k: bool = False) -> int: 20 | """Takes an arbitrary sample rate and aligns it to a standard value. 21 | 22 | It does this by rounding up to the next standard value, while staying below 23 | a reasonable maximum for the specified codec. 24 | 25 | Args: 26 | codec: The name of the codec in use. 27 | sample_rate: The original sample rate. 28 | 29 | Returns: 30 | An integer sample rate that has been aligned to the closest standard 31 | value. 32 | """ 33 | # The behavior as implemented here differs from the legacy behavior. 34 | # The audio.py legacy behavior excluding no96k codecs was as follows: 35 | # 22000 < x <= 27050 --> 22050 36 | # 27050 < x <= 36000 --> 32000 37 | # 36000 < x <= 43000 --> 44100 Sample rates < 44100 would jump to 48000. 38 | # 43000 < x <= 72000 --> 48000 39 | # 72000 < x <= 90000 --> 88200 40 | # 90000 < x <= 96000 --> 96000 41 | # 96000 < x <= 176000 --> 176000 42 | # 176000 < x --> 192000 43 | # 44 | # For no96k codecs (ogg and mp3), it was as follows: 45 | # 22000 < x <= 27050 --> 22050 46 | # 27050 < x <= 36000 --> 32000 47 | # 36000 < x <= 43000 --> 44100 48 | # 43000 < x <= 72000 --> 48000 49 | # 72000 < x <= 90000 --> 88200 Illegal sample rate for ogg or mp3. 50 | # 90000 < x <= 96000 --> 48000 51 | # 96000 < x <= 176000 --> 48000 52 | # 176000 < x --> 48000 53 | # 54 | # The node.py legacy behavior was as follows: 55 | # 22000 < x <= 27050 --> 22050 56 | # 27050 < x <= 36000 --> 32000 57 | # 36000 < x <= 43000 --> 44100 58 | # 43000 < x <= 72000 --> 48000 59 | # 72000 < x --> 48000 for no96k, x (unbounded) otherwise. 60 | 61 | # Target rates must be sorted in increasing order. 62 | target_rates: List[int] 63 | if limit_to_48k: 64 | target_rates = constants.MAX_48K_SAMPLE_RATES 65 | else: 66 | target_rates = constants.sample_rates_for_codec(codec) 67 | 68 | if sample_rate in target_rates: 69 | return sample_rate 70 | 71 | for target_rate in target_rates: 72 | if sample_rate < target_rate: 73 | # Because we're traversing in increasing order, the first time we 74 | # find a target_rate that's greater than the sample rate, we know 75 | # that's the next-largest value, so we can return that immediately. 76 | messages.print_samplerate_warning(codec) 77 | return target_rate 78 | 79 | # If we make it to this point, sample_rate is above the max target_rate, so 80 | # we just clamp to the max target_rate. 81 | messages.print_samplerate_warning(codec) 82 | print(colors.warning("Sample rate set to maximum!")) 83 | return target_rates[-1] 84 | 85 | 86 | def clamp_bitrate(codec: str, bitrate: Optional[int]) -> int: 87 | # Legacy logic (also used str for bitrate rather than int): 88 | # if bitrate == "192" -> "192k" 89 | # elif bitrate == "None" -> pass 90 | # else 91 | # if codec == "mp3" and bitrate > 320 -> "320" + warning 92 | # elif codec == "ogg" and bitrate > 500 -> "500" + warning 93 | # elif codec == "aac" and bitrate < 500 -> "500" + warning 94 | # else -> bitrate + "k" 95 | 96 | if bitrate is None: 97 | print(colors.warning("Setting bitrate to default of " 98 | f"{constants.DEFAULT_BITRATE}")) 99 | return constants.DEFAULT_BITRATE 100 | 101 | if bitrate <= 0: 102 | print(colors.warning(f"Bitrate of {bitrate} was invalid; setting to " 103 | f"{constants.DEFAULT_BITRATE}")) 104 | return constants.DEFAULT_BITRATE 105 | 106 | max_bitrate_for_codec: dict[str, int] = { 107 | "mp3": 320, 108 | "ogg": 500, 109 | "aac": 500, 110 | } 111 | max_bitrate: Optional[int] = max_bitrate_for_codec.get(codec, None) 112 | 113 | if max_bitrate is None: 114 | # codec bitrate is unlimited. 115 | return bitrate 116 | 117 | if bitrate > max_bitrate: 118 | print(colors.warning( 119 | f"Configured bitrate {bitrate} exceeds max {max_bitrate} for " 120 | f"{codec} codec; setting to max." 121 | )) 122 | return max_bitrate 123 | 124 | return bitrate 125 | 126 | 127 | def terminate() -> None: 128 | del_tmp() 129 | parent_pid = os.getpid() 130 | parent = psutil.Process(parent_pid) 131 | for child in parent.children(recursive=True): 132 | child.kill() 133 | parent.kill() 134 | 135 | 136 | def del_tmp(debug: bool = False) -> None: 137 | """Delete files created in /tmp/""" 138 | delete_me = ["/tmp/mkchromecast.tmp", "/tmp/mkchromecast.pid"] 139 | 140 | if debug: 141 | print(colors.important("Cleaning up /tmp/...")) 142 | 143 | for f in delete_me: 144 | if os.path.exists(f): 145 | os.remove(f) 146 | 147 | if debug: 148 | print(colors.success("[Done]")) 149 | 150 | 151 | def is_installed(name, path, debug) -> bool: 152 | PATH = path 153 | iterate = PATH.split(":") 154 | for item in iterate: 155 | verifyif = str(item + "/" + name) 156 | if os.path.exists(verifyif) is False: 157 | continue 158 | else: 159 | if debug is True: 160 | print("Program %s found in %s." % (name, verifyif)) 161 | return True 162 | return False 163 | 164 | 165 | def check_url(url): 166 | """Check if a URL is correct""" 167 | try: 168 | result = urlparse(url) 169 | return True if [result.scheme, result.netloc, result.path] else False 170 | except Exception as e: 171 | return False 172 | 173 | 174 | def writePidFile() -> None: 175 | pid_filename = "/tmp/mkchromecast.pid" 176 | # This is to verify that pickle tmp file exists 177 | if os.path.exists(pid_filename): 178 | os.remove(pid_filename) 179 | 180 | pid = str(os.getpid()) 181 | with open(pid_filename, "wb") as pid_file: 182 | pickle.dump(pid, pid_file) 183 | 184 | 185 | def checkmktmp() -> None: 186 | # This is to verify that pickle tmp file exists 187 | if os.path.exists("/tmp/mkchromecast.tmp"): 188 | os.remove("/tmp/mkchromecast.tmp") 189 | 190 | 191 | def check_file_info(name, what=None): 192 | """Check things about files""" 193 | 194 | command = [ 195 | "ffprobe", 196 | "-show_format", 197 | "-show_streams", 198 | "-loglevel", "quiet", 199 | "-print_format", "json", 200 | name, 201 | ] 202 | 203 | info = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE) 204 | info_out, info_error = info.communicate() 205 | d = json.loads(info_out) 206 | 207 | if what == "bit-depth": 208 | bit_depth = d["streams"][0]["pix_fmt"] 209 | return bit_depth 210 | elif what == "resolution": 211 | resolution = d["streams"][0]["height"] 212 | resolution = str(resolution) + "p" 213 | return resolution 214 | 215 | 216 | def get_effective_ip(platform, host_override=None, fallback_ip="127.0.0.1"): 217 | if host_override is None: 218 | return resolve_ip(platform, fallback_ip=fallback_ip) 219 | else: 220 | return host_override 221 | 222 | 223 | def resolve_ip(platform, fallback_ip): 224 | if platform == "Linux": 225 | resolved_ip = _resolve_ip_linux() 226 | else: 227 | resolved_ip = _resolve_ip_nonlinux() 228 | if resolved_ip is None: 229 | resolved_ip = fallback_ip 230 | return resolved_ip 231 | 232 | 233 | def _resolve_ip_linux(): 234 | s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 235 | s.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) 236 | try: 237 | s.connect(("8.8.8.8", 80)) 238 | except socket.error: 239 | return None 240 | return s.getsockname()[0] 241 | 242 | 243 | def _resolve_ip_nonlinux(): 244 | try: 245 | return socket.gethostbyname(f"{socket.gethostname()}.local") 246 | except socket.gaierror: 247 | return _get_first_network_ip_by_netifaces() 248 | 249 | 250 | def _get_first_network_ip_by_netifaces(): 251 | import netifaces 252 | 253 | interfaces = netifaces.interfaces() 254 | for interface in interfaces: 255 | if interface == "lo": 256 | continue 257 | iface = netifaces.ifaddresses(interface).get(netifaces.AF_INET) 258 | if iface != None and iface[0]["addr"] != "127.0.0.1": 259 | for e in iface: 260 | return str(e["addr"]) 261 | -------------------------------------------------------------------------------- /mkchromecast/version.py: -------------------------------------------------------------------------------- 1 | # This file is part of mkchromecast. 2 | 3 | __version__ = "0.3.9" 4 | -------------------------------------------------------------------------------- /mkchromecast/video.py: -------------------------------------------------------------------------------- 1 | # This file is part of mkchromecast. 2 | 3 | """ 4 | Google Cast device has to point out to http://ip:5000/stream 5 | """ 6 | 7 | import getpass 8 | import os 9 | import pickle 10 | import subprocess 11 | 12 | import mkchromecast 13 | from mkchromecast import colors 14 | from mkchromecast import pipeline_builder 15 | from mkchromecast import stream_infra 16 | from mkchromecast import utils 17 | from mkchromecast.constants import OpMode 18 | 19 | def _flask_init(): 20 | mkcc = mkchromecast.Mkchromecast() 21 | 22 | # TODO(xsdg): Passing args in one-by-one to facilitate refactoring 23 | # the Mkchromecast object so that it has argument groups instead of just a 24 | # giant set of uncoordinated and conflicting arguments. 25 | encode_settings = pipeline_builder.VideoSettings( 26 | display=mkcc.display, 27 | fps=mkcc.fps, 28 | input_file=mkcc.input_file, 29 | loop=mkcc.loop, 30 | operation=mkcc.operation, 31 | resolution=mkcc.resolution, 32 | screencast=mkcc.screencast, 33 | seek=mkcc.seek, 34 | subtitles=mkcc.subtitles, 35 | user_command=mkcc.command, 36 | vcodec=mkcc.vcodec, 37 | youtube_url=mkcc.youtube_url, 38 | ) 39 | builder = pipeline_builder.Video(encode_settings) 40 | if mkcc.debug is True: 41 | print(f":::ffmpeg::: pipeline_builder command: {builder.command}") 42 | 43 | media_type = mkcc.mtype or "video/mp4" 44 | stream_infra.FlaskServer.init_video( 45 | chunk_size=mkcc.chunk_size, 46 | command=builder.command, 47 | media_type=(mkcc.mtype or "video/mp4") 48 | ) 49 | 50 | 51 | def main(): 52 | mkcc = mkchromecast.Mkchromecast() 53 | ip = utils.get_effective_ip( 54 | mkcc.platform, host_override=mkcc.host, fallback_ip="0.0.0.0") 55 | 56 | if mkcc.backend != "node": 57 | pipeline = stream_infra.PipelineProcess(_flask_init, ip, mkcc.port, mkcc.platform) 58 | pipeline.start() 59 | else: 60 | print("Starting Node") 61 | 62 | # TODO(xsdg): This implies that the `node` backend is only compatible 63 | # with INPUT_FILE OpMode, for video. Double-check what's happening here 64 | # and then implement that constraint directly in the Mkchromecast class. 65 | if mkcc.operation != OpMode.INPUT_FILE: 66 | print(colors.warning( 67 | "The node video backend requires and only supports the input " 68 | "file operation (-i argument).")) 69 | utils.terminate() 70 | 71 | if mkcc.platform == "Darwin": 72 | PATH = ( 73 | "./bin:./nodejs/bin:/Users/" 74 | + str(getpass.getuser()) 75 | + "/bin:/usr/local/bin:/usr/local/sbin:" 76 | + "/usr/bin:/bin:/usr/sbin:" 77 | + "/sbin:/opt/X11/bin:/usr/X11/bin:/usr/games:" 78 | + os.environ["PATH"] 79 | ) 80 | else: 81 | PATH = os.environ["PATH"] 82 | 83 | if mkcc.debug is True: 84 | print("PATH = %s." % PATH) 85 | 86 | node_names = ["node"] 87 | nodejs_dir = ["./nodejs/"] 88 | 89 | # TODO(xsdg): This is not necessarily where mkchromecast is installed, 90 | # and may point to an unrelated mkchromecast install. 91 | if mkcc.platform == "Linux": 92 | node_names.append("nodejs") 93 | nodejs_dir.append("/usr/share/mkchromecast/nodejs/") 94 | 95 | for name in node_names: 96 | if utils.is_installed(name, PATH, mkcc.debug): 97 | for path in nodejs_dir: 98 | if os.path.isdir(path): 99 | path = path + "html5-video-streamer.js" 100 | webcast = [name, path, mkcc.input_file] 101 | break 102 | 103 | try: 104 | subprocess.Popen(webcast) 105 | except: 106 | # TODO(xsdg): Capture a specific exception here. 107 | print( 108 | colors.warning( 109 | "Nodejs is not installed in your system. " 110 | "Please, install it to use this backend." 111 | ) 112 | ) 113 | print(colors.warning("Closing the application...")) 114 | utils.terminate() 115 | -------------------------------------------------------------------------------- /nodejs/README.md: -------------------------------------------------------------------------------- 1 | Building node and npm 2 | ===================== 3 | 4 | To build this you need node and npm. Install them directly from 5 | https://nodejs.org/en/download/, using Homebrew on MacOS or with 6 | package managers on Linux. 7 | 8 | From that point, just use *npm install* to load the dependencies. 9 | -------------------------------------------------------------------------------- /nodejs/html5-video-streamer.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Inspired by: http://stackoverflow.com/questions/4360060/video-streaming-with-html-5-via-node-js 3 | */ 4 | 5 | var http = require('http'), 6 | fs = require('fs'), 7 | util = require('util'); 8 | 9 | http.createServer(function (req, res) { 10 | var path = process.argv[2]; 11 | var stat = fs.statSync(path); 12 | var total = stat.size; 13 | if (req.headers['range']) { 14 | var range = req.headers.range; 15 | var parts = range.replace(/bytes=/, "").split("-"); 16 | var partialstart = parts[0]; 17 | var partialend = parts[1]; 18 | 19 | var start = parseInt(partialstart, 10); 20 | var end = partialend ? parseInt(partialend, 10) : total-1; 21 | var chunksize = (end-start)+1; 22 | console.log('RANGE: ' + start + ' - ' + end + ' = ' + chunksize); 23 | 24 | var file = fs.createReadStream(path, {start: start, end: end}); 25 | res.writeHead(206, { 26 | 'Content-Range': 'bytes ' + start + '-' + end + '/' + total, 27 | 'Accept-Ranges': 'bytes', 28 | 'Content-Length': chunksize, 29 | 'Content-Type': 'video/mp4' 30 | }); 31 | file.pipe(res); 32 | } else { 33 | console.log('ALL: ' + total); 34 | res.writeHead(200, { 35 | 'Content-Length': total, 36 | 'Content-Type': 37 | 'video/mp4' }); 38 | fs.createReadStream(path).pipe(res); 39 | } 40 | }).listen(5000, '0.0.0.0'); 41 | console.log('Server running at http://0.0.0.0:5000/'); 42 | -------------------------------------------------------------------------------- /nodejs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mkchromecast", 3 | "version": "0.3.8", 4 | "description": "Cast sound and video form to chromecast devices", 5 | "main": "html5-video-streamer.js", 6 | "dependencies": { 7 | "webcast-osx-audio": "^1.0.1" 8 | }, 9 | "devDependencies": {}, 10 | "scripts": { 11 | "test": "echo \"Error: no test specified\" && exit 1" 12 | }, 13 | "repository": { 14 | "type": "git", 15 | "url": "git+https://github.com/muammar/mkchromecast.git" 16 | }, 17 | "keywords": [ 18 | "chromecast" 19 | ], 20 | "author": "Muammar El Khatib", 21 | "license": "MIT", 22 | "bugs": { 23 | "url": "https://github.com/muammar/mkchromecast/issues" 24 | }, 25 | "homepage": "https://github.com/muammar/mkchromecast#readme" 26 | } 27 | -------------------------------------------------------------------------------- /nodejs/recompile_node.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # This file is part of mkchromecast. 4 | 5 | localpwd=`pwd` 6 | 7 | VER="$@" 8 | 9 | echo "Deleting old node version" 10 | 11 | rm -R node-* 12 | 13 | echo 14 | echo "Downloading new version from https://nodejs.org..." 15 | echo 16 | 17 | wget https://nodejs.org/dist/v$VER/node-v$VER.tar.gz 18 | 19 | echo 20 | echo 21 | echo "Untar new version" 22 | echo 23 | 24 | tar zxvf node-v$VER.tar.gz 25 | rm node-v$VER.tar.gz 26 | cd node-v$VER/ 27 | ./configure 28 | ./configure --prefix=$localpwd/node-$VER/ 29 | 30 | make -j4 31 | 32 | make install 33 | 34 | echo 35 | echo "Deleting building directory" 36 | echo 37 | 38 | rm -r ../node-v$VER/ 39 | 40 | echo "Creating symlinks" 41 | 42 | cd $localpwd/bin/ 43 | rm * 44 | ln -s ../node-$VER/bin/npm 45 | ln -s ../node-$VER/bin/node 46 | 47 | cd ../../bin/ 48 | rm node 49 | ln -s ../nodejs/node-$VER/bin/node 50 | 51 | echo 52 | echo "Done" 53 | -------------------------------------------------------------------------------- /notifier/LICENSE: -------------------------------------------------------------------------------- 1 | Please see: 2 | https://github.com/julienXX/terminal-notifier#license 3 | -------------------------------------------------------------------------------- /notifier/terminal-notifier.app/Contents/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | BuildMachineOSBuild 6 | 15G1004 7 | CFBundleDevelopmentRegion 8 | en 9 | CFBundleExecutable 10 | terminal-notifier 11 | CFBundleIconFile 12 | Terminal 13 | CFBundleIdentifier 14 | nl.superalloy.oss.terminal-notifier 15 | CFBundleInfoDictionaryVersion 16 | 6.0 17 | CFBundleName 18 | terminal-notifier 19 | CFBundlePackageType 20 | APPL 21 | CFBundleShortVersionString 22 | 1.7.1 23 | CFBundleSignature 24 | ???? 25 | CFBundleSupportedPlatforms 26 | 27 | MacOSX 28 | 29 | CFBundleVersion 30 | 16 31 | DTCompiler 32 | com.apple.compilers.llvm.clang.1_0 33 | DTPlatformBuild 34 | 8A218a 35 | DTPlatformVersion 36 | GM 37 | DTSDKBuild 38 | 16A300 39 | DTSDKName 40 | macosx10.12 41 | DTXcode 42 | 0800 43 | DTXcodeBuild 44 | 8A218a 45 | LSMinimumSystemVersion 46 | 10.8 47 | LSUIElement 48 | 49 | NSHumanReadableCopyright 50 | Copyright © 2012-2016 Eloy Durán, Julien Blanchard. All rights reserved. 51 | NSMainNibFile 52 | MainMenu 53 | NSPrincipalClass 54 | NSApplication 55 | NSUserNotificationAlertStyle 56 | alert 57 | 58 | 59 | -------------------------------------------------------------------------------- /notifier/terminal-notifier.app/Contents/MacOS/terminal-notifier: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/muammar/mkchromecast/6e583366ae23b56a33c1ad4ca164e04d64174538/notifier/terminal-notifier.app/Contents/MacOS/terminal-notifier -------------------------------------------------------------------------------- /notifier/terminal-notifier.app/Contents/PkgInfo: -------------------------------------------------------------------------------- 1 | APPL???? -------------------------------------------------------------------------------- /notifier/terminal-notifier.app/Contents/Resources/Terminal.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/muammar/mkchromecast/6e583366ae23b56a33c1ad4ca164e04d64174538/notifier/terminal-notifier.app/Contents/Resources/Terminal.icns -------------------------------------------------------------------------------- /notifier/terminal-notifier.app/Contents/Resources/en.lproj/Credits.rtf: -------------------------------------------------------------------------------- 1 | {\rtf0\ansi{\fonttbl\f0\fswiss Helvetica;} 2 | {\colortbl;\red255\green255\blue255;} 3 | \paperw9840\paperh8400 4 | \pard\tx560\tx1120\tx1680\tx2240\tx2800\tx3360\tx3920\tx4480\tx5040\tx5600\tx6160\tx6720\ql\qnatural 5 | 6 | \f0\b\fs24 \cf0 Engineering: 7 | \b0 \ 8 | Some people\ 9 | \ 10 | 11 | \b Human Interface Design: 12 | \b0 \ 13 | Some other people\ 14 | \ 15 | 16 | \b Testing: 17 | \b0 \ 18 | Hopefully not nobody\ 19 | \ 20 | 21 | \b Documentation: 22 | \b0 \ 23 | Whoever\ 24 | \ 25 | 26 | \b With special thanks to: 27 | \b0 \ 28 | Mom\ 29 | } 30 | -------------------------------------------------------------------------------- /notifier/terminal-notifier.app/Contents/Resources/en.lproj/InfoPlist.strings: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/muammar/mkchromecast/6e583366ae23b56a33c1ad4ca164e04d64174538/notifier/terminal-notifier.app/Contents/Resources/en.lproj/InfoPlist.strings -------------------------------------------------------------------------------- /notifier/terminal-notifier.app/Contents/Resources/en.lproj/MainMenu.nib: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/muammar/mkchromecast/6e583366ae23b56a33c1ad4ca164e04d64174538/notifier/terminal-notifier.app/Contents/Resources/en.lproj/MainMenu.nib -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | PyGObject 2 | requests 3 | psutil 4 | Flask 5 | netifaces 6 | pychromecast>=4.2 7 | PyQt5 8 | soco 9 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | r""" 2 | Linux/MacOS build script for mkchromecast. 3 | 4 | MacOS usage: 5 | python3 setup.py py2app 6 | cp -R /usr/local/Cellar/qt5/5.6.0/plugins \ 7 | dist/mkchromecast.app/Contents/PlugIns 8 | macdeployqt dist/mkchromecast.app 9 | 10 | On MacOS you need to install using pip3 the following: 11 | 12 | bs4 13 | google 14 | 15 | On Linux, this is a standard distutils script supporting 16 | 17 | python3 setup.py build 18 | python3 setup.py install 19 | 20 | etc., with standard setup.py options. Note that there are some non-python 21 | dependencies not listed here; see README.md. 22 | """ 23 | import os 24 | import platform 25 | 26 | from glob import glob 27 | from setuptools import setup 28 | 29 | exec(open("mkchromecast/version.py").read()) 30 | 31 | ROOT = os.path.dirname(__file__) 32 | ROOT = ROOT if ROOT else "." 33 | 34 | LINUX_DATA = [ 35 | ( 36 | "share/mkchromecast/nodejs", 37 | [ 38 | "nodejs/package.json", 39 | "nodejs/package-lock.json", 40 | "nodejs/html5-video-streamer.js", 41 | ], 42 | ), 43 | ("share/mkchromecast/images", glob("images/google*.png")), 44 | ("share/applications/", ["mkchromecast.desktop"]), 45 | ("share/man/man1", ["mkchromecast.1"]), 46 | ] 47 | LINUX_REQUIRES = ["flask", "mutagen", "netifaces", "psutil", "PyQt5", "requests"] 48 | LINUX_CLASSIFIERS = [ 49 | "Development Status :: 4 - Beta", 50 | "Intended Audience :: End Users/Desktop", 51 | "License :: OSI Approved :: MIT License", 52 | "Programming Language :: Python :: 3.6", 53 | ] 54 | 55 | APP = ["start_tray.py"] 56 | APP_NAME = "Mkchromecast" 57 | DATA_FILES = ["bin/audiodevice", "bin/mkchromecast", "nodejs", "notifier"] 58 | DATA_FILES.extend(glob("images/google*.icns")) 59 | 60 | OPTIONS = { 61 | "argv_emulation": True, 62 | "prefer_ppc": True, 63 | "iconfile": "images/google.icns", 64 | "includes": [ 65 | "google", 66 | "sip", 67 | "PyQt5", 68 | "PyQt5.QtCore", 69 | "PyQt5.QtGui", 70 | "PyQt5.QtWidgets", 71 | "Flask", 72 | "configparser", 73 | ], 74 | "packages": ["requests"], 75 | "plist": { 76 | "CFBundleName": APP_NAME, 77 | "CFBundleDisplayName": APP_NAME, 78 | "CFBundleGetInfoString": "Cast macOS audio to your Google cast devices and Sonos speakers", 79 | "CFBundleIdentifier": "com.mkchromecast.osx", 80 | "CFBundleVersion": __version__, 81 | "CFBundleShortVersionString": __version__, 82 | "NSHumanReadableCopyright": "Copyright (c) 2017, Muammar El Khatib, All Rights Reserved", 83 | "LSPrefersPPC": True, 84 | "LSUIElement": True, 85 | }, 86 | } 87 | 88 | if platform.system() == "Darwin": 89 | setup( 90 | name=APP_NAME, 91 | app=APP, 92 | data_files=DATA_FILES, 93 | packages=["Mkchromecast"], 94 | platforms=["i386", "x86_64"], 95 | options={"py2app": OPTIONS}, 96 | setup_requires=["py2app"], 97 | ) 98 | 99 | elif platform.system() == "Linux": 100 | setup( 101 | name="mkchromecast", 102 | version=__version__, 103 | description="Cast Linux audio or video to Google Cast devices", 104 | long_description=open(ROOT + "/README.md").read(), 105 | include_package_data=True, 106 | license="MIT", 107 | url="http://mkchromecast.com/", 108 | author="Muammar El Khatib", 109 | author_email="http://muammar.me/", 110 | keywords=["chromecast"], 111 | packages=["mkchromecast"], 112 | scripts=["bin/mkchromecast"], 113 | classifiers=LINUX_CLASSIFIERS, 114 | data_files=LINUX_DATA, 115 | requires=LINUX_REQUIRES, 116 | ) 117 | -------------------------------------------------------------------------------- /start_tray.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | # This file is part of mkchromecast. It is used to build the macOS app. 4 | from mkchromecast.utils import checkmktmp, writePidFile 5 | import mkchromecast.systray 6 | 7 | # TODO(xsdg): This should go through mkchromecast and shouldn't be a separate 8 | # entrypoint. 9 | checkmktmp() 10 | writePidFile() 11 | mkchromecast.systray.main() 12 | -------------------------------------------------------------------------------- /test.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | # This file is part of mkchromecast. 4 | 5 | import argparse 6 | import logging 7 | import os 8 | import pathlib 9 | import shutil 10 | import subprocess 11 | import sys 12 | import time 13 | import unittest 14 | 15 | import pychromecast 16 | 17 | 18 | # Modify this to enable debug logging of test outputs. 19 | ENABLE_DEBUG_LOGGING = False 20 | 21 | # This argument parser will steal arguments from unittest. So we only define 22 | # arguments that are needed for the integration test, and that won't collide 23 | # with arguments that unittest cares about (like --help). 24 | integration_arg_parser = argparse.ArgumentParser(add_help=False, allow_abbrev=False) 25 | 26 | integration_arg_parser.add_argument( 27 | "--test-connect-to", 28 | type=str, 29 | help=( 30 | "Enables integration test mode, and attempts to connect to the " 31 | "Chromecast with the specified friendly name" 32 | ), 33 | ) 34 | 35 | integration_args = ... 36 | 37 | 38 | class MkchromecastTests(unittest.TestCase): 39 | def setUp(self): 40 | # TODO(xsdg): Do something better than just listing files by hand. 41 | target_names = [ 42 | "bin/mkchromecast", 43 | "mkchromecast/", 44 | "setup.py", 45 | "start_tray.py", 46 | "test.py" 47 | ] 48 | 49 | # Makes target names absolute. 50 | self.parent_dir = pathlib.Path(__file__).parent 51 | self.type_targets = [self.parent_dir / name for name in target_names] 52 | 53 | def testMyPy(self): 54 | """Runs the mypy static type analyzer, if it's installed.""" 55 | if not shutil.which("mypy"): 56 | self.skipTest("mypy not installed") 57 | 58 | mypy_cmd = [ 59 | "mypy", 60 | "--ignore-missing-imports", 61 | "--no-namespace-packages", 62 | "--check-untyped-defs" 63 | ] 64 | 65 | mypy_result = subprocess.run( 66 | mypy_cmd + self.type_targets, 67 | stdout=subprocess.PIPE, 68 | stderr=subprocess.STDOUT, 69 | encoding="utf8", 70 | ) 71 | 72 | if mypy_result.returncode: 73 | self.fail(mypy_result.stdout) 74 | else: 75 | # Debug-log for diagnostic purposes. 76 | logging.debug(mypy_result.stdout) 77 | 78 | def testPytype(self): 79 | """Runs the pytype static type analyzer, if it's installed.""" 80 | if not shutil.which("pytype"): 81 | self.skipTest("pytype not installed") 82 | 83 | pytype_cmd = ["pytype", "-k", "-j", "auto", "--no-cache"] 84 | pytype_result = subprocess.run( 85 | pytype_cmd + self.type_targets, 86 | stdout=subprocess.PIPE, 87 | stderr=subprocess.STDOUT, 88 | encoding="utf8", 89 | ) 90 | 91 | if pytype_result.returncode: 92 | self.fail(pytype_result.stdout) 93 | else: 94 | # Debug-log for diagnostic purposes. 95 | logging.debug(pytype_result.stdout) 96 | 97 | def testExecUnitTests(self): 98 | """Runs the Mkchromecast unit test suite.""" 99 | tests_dir = self.parent_dir / "tests" 100 | pytest_cmd = [ 101 | "python3", 102 | "-m", "unittest", 103 | "discover", 104 | "-s", tests_dir, 105 | "-t", tests_dir, 106 | ] 107 | 108 | # Set PYTHONPATH to include parentdir, so that the unit tests can 109 | # import mkchromecast regardless of how the current file is executed. 110 | custom_env = os.environ.copy() 111 | orig_python_path = os.environ.get("PYTHONPATH", "") 112 | custom_env["PYTHONPATH"] = f"{self.parent_dir}:{orig_python_path}" 113 | pytest_result = subprocess.run( 114 | pytest_cmd, 115 | env=custom_env, 116 | stdout=subprocess.PIPE, 117 | stderr=subprocess.STDOUT, 118 | encoding="utf8", 119 | ) 120 | 121 | if pytest_result.returncode: 122 | self.fail(pytest_result.stdout) 123 | else: 124 | # Always show unit test output, even when they pass. 125 | logging.info("\n" + pytest_result.stdout) 126 | 127 | # "ZZ" prefix so this runs last. 128 | def testZZEndToEndIntegration(self): 129 | args = integration_args # Shorthand. 130 | 131 | if not args.test_connect_to: 132 | self.skipTest("Specify --test-connect-to to run integration test") 133 | 134 | # TODO(xsdg): pychromecast API has changed significantly since this was 135 | # written, so this test is currently broken. 136 | self.skipTest("Integration test is currently broken :(") 137 | 138 | cast = pychromecast.get_chromecast(friendly_name=args.test_connect_to) 139 | print("Connected to Chromecast") 140 | mc = cast.media_controller 141 | print("Playing BigBuckBunny.mp4 (video)") 142 | # TODO(xsdg): use https. 143 | mc.play_media( 144 | "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4", 145 | "video/mp4", 146 | ) 147 | time.sleep(15) 148 | print("Stopping video and sleeping for 5 secs") 149 | mc.stop() 150 | time.sleep(5) 151 | print("Playing Canon mp3 (audio)") 152 | # TODO(xsdg): This link is broken; find something else that's more 153 | # stable. Also, https. 154 | mc.play_media("http://www.stephaniequinn.com/Music/Canon.mp3", "audio/mp3") 155 | time.sleep(15) 156 | print("Stopping audio and quitting app") 157 | mc.stop() 158 | cast.quit_app() 159 | 160 | 161 | if __name__ == "__main__": 162 | loglevel = logging.INFO if not ENABLE_DEBUG_LOGGING else logging.DEBUG 163 | logging.basicConfig(format="%(levelname)s:%(message)s", level=loglevel) 164 | 165 | # Steals known arguments from unittest in order to properly configure the 166 | # integration test. 167 | integration_args, skipped_argv = integration_arg_parser.parse_known_args() 168 | sys.argv[1:] = skipped_argv 169 | unittest.main(verbosity=2) 170 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | # this file is part of mkchromecast. 2 | -------------------------------------------------------------------------------- /tests/test_config.py: -------------------------------------------------------------------------------- 1 | # this file is part of mkchromecast. 2 | 3 | import configparser 4 | import os 5 | import pathlib 6 | import unittest 7 | from unittest import mock 8 | 9 | from mkchromecast import config 10 | 11 | class ClampBitrateTests(unittest.TestCase): 12 | def setUp(self): 13 | self.enterContext(mock.patch.object(os, "environ", autospec=True)) 14 | self.enterContext(mock.patch.object(configparser, "ConfigParser", autospec=True)) 15 | self.enterContext(mock.patch("builtins.open", autospec=True)) 16 | 17 | def testInstantiateNewConfig(self): 18 | mock_parser = configparser.ConfigParser() 19 | config_path = pathlib.PurePath("/fake_dir/fake_path.txt") 20 | conf = config.Config(platform="Linux", config_path=config_path) 21 | with conf: 22 | mock_parser.read.assert_called_once_with(config_path) 23 | 24 | mock_parser.write.assert_called_once() 25 | 26 | def testInstantiateNewReadOnlyConfig(self): 27 | mock_parser = configparser.ConfigParser() 28 | config_path = pathlib.PurePath("/fake_dir/fake_path.txt") 29 | conf = config.Config( 30 | platform="Darwin", config_path=config_path, read_only=True) 31 | with conf: 32 | mock_parser.read.assert_called_once_with(config_path) 33 | 34 | mock_parser.write.assert_not_called() 35 | 36 | def testPropertyGetters(self): 37 | config_path = pathlib.PurePath("/fake_dir/fake_path.txt") 38 | conf = config.Config("Linux", config_path) 39 | mock_parser = configparser.ConfigParser.return_value 40 | props = { 41 | "codec": str, 42 | "bitrate": int, 43 | "samplerate": int, 44 | "notifications": bool, 45 | "colors": str, 46 | "search_at_launch": bool, 47 | "alsa_device": str} 48 | 49 | for prop_name, prop_type in props.items(): 50 | with self.subTest(prop=prop_name): 51 | mock_parser.reset_mock() 52 | _ = getattr(conf, prop_name) 53 | 54 | if prop_type == str: 55 | mock_parser.get.assert_called_once_with( 56 | config.SETTINGS, prop_name) 57 | mock_parser.getboolean.assert_not_called() 58 | mock_parser.getint.assert_not_called() 59 | elif prop_type == int: 60 | mock_parser.get.assert_not_called() 61 | mock_parser.getint.assert_called_once_with( 62 | config.SETTINGS, prop_name) 63 | mock_parser.getboolean.assert_not_called() 64 | elif prop_type == bool: 65 | mock_parser.get.assert_not_called() 66 | mock_parser.getint.assert_not_called() 67 | mock_parser.getboolean.assert_called_once_with( 68 | config.SETTINGS, prop_name) 69 | 70 | def testPropertySetters(self): 71 | config_path = pathlib.PurePath("/fake_dir/fake_path.txt") 72 | conf = config.Config("Linux", config_path) 73 | mock_parser = configparser.ConfigParser.return_value 74 | props = { 75 | "backend": str, 76 | "codec": str, 77 | "bitrate": int, 78 | "samplerate": int, 79 | "notifications": bool, 80 | "colors": str, 81 | "search_at_launch": bool, 82 | "alsa_device": str} 83 | 84 | for prop_name, prop_type in props.items(): 85 | with self.subTest(prop=prop_name): 86 | mock_parser.reset_mock() 87 | value = prop_type() 88 | setattr(conf, prop_name, value) 89 | 90 | mock_parser.set.assert_called_once_with( 91 | config.SETTINGS, prop_name, str(value)) 92 | 93 | def testEmptyAlsaDevice(self): 94 | config_path = pathlib.PurePath("/fake_dir/fake_path.txt") 95 | conf = config.Config("Linux", config_path) 96 | mock_parser = configparser.ConfigParser.return_value 97 | none_str = "None" 98 | 99 | conf.alsa_device = None 100 | mock_parser.set.assert_called_once_with( 101 | config.SETTINGS, config.ALSA_DEVICE, none_str) 102 | 103 | mock_parser.get.return_value = none_str 104 | self.assertIsNone(conf.alsa_device) 105 | 106 | mock_parser.get.return_value = "some value" 107 | self.assertEqual("some value", conf.alsa_device) 108 | -------------------------------------------------------------------------------- /tests/test_constants.py: -------------------------------------------------------------------------------- 1 | # this file is part of mkchromecast. 2 | 3 | import unittest 4 | 5 | from mkchromecast import constants 6 | 7 | class ConstantsTests(unittest.TestCase): 8 | def testMax48kSampleRates(self): 9 | self.assertEqual(constants.MAX_48K_SAMPLE_RATES, 10 | constants.sample_rates_for_codec("mp3")) 11 | self.assertEqual(constants.ALL_SAMPLE_RATES, 12 | constants.sample_rates_for_codec("aac")) 13 | 14 | def testLinuxBackends(self): 15 | """Ensures video argument is used correctly, with the right default.""" 16 | self.assertIn( 17 | "parec", 18 | constants.backend_options_for_platform("Linux", video=False) 19 | ) 20 | self.assertNotIn( 21 | "parec", 22 | constants.backend_options_for_platform("Linux", video=True) 23 | ) 24 | self.assertEqual( 25 | constants.backend_options_for_platform("Linux"), 26 | constants.backend_options_for_platform("Linux", video=False) 27 | ) 28 | 29 | 30 | if __name__ == "__main__": 31 | unittest.main(verbosity=2) 32 | -------------------------------------------------------------------------------- /tests/test_instantiate.py: -------------------------------------------------------------------------------- 1 | # this file is part of mkchromecast. 2 | 3 | import unittest 4 | from unittest import mock 5 | 6 | import mkchromecast 7 | from mkchromecast import _arg_parsing 8 | from mkchromecast import config 9 | from mkchromecast import constants 10 | 11 | class BasicInstantiationTest(unittest.TestCase): 12 | def testInstantiate(self): 13 | # TODO(xsdg): Do a better job of mocking the args parser. 14 | 15 | mock_args = mock.Mock() 16 | # Here we set the minimal required args for __init__ to not sys.exit. 17 | mock_args.encoder_backend = None 18 | mock_args.bitrate = constants.DEFAULT_BITRATE 19 | mock_args.codec = 'mp3' 20 | mock_args.command = None 21 | mock_args.resolution = None 22 | mock_args.chunk_size = 64 23 | mock_args.sample_rate = 44100 24 | mock_args.youtube = None 25 | mock_args.input_file = None 26 | mkcc = mkchromecast.Mkchromecast(mock_args) 27 | 28 | def testMP3CodecNodeBackend(self): 29 | """This test evaluates the assignment of the MP3 codec when the Node Backend is selected""" 30 | 31 | mock_args = mock.Mock() 32 | # Here we set the minimal required args for __init__ to not sys.exit. 33 | mock_args.encoder_backend = 'node' 34 | mock_args.bitrate = constants.DEFAULT_BITRATE 35 | mock_args.codec = 'mp3' 36 | mock_args.command = None 37 | mock_args.resolution = None 38 | mock_args.chunk_size = 64 39 | mock_args.sample_rate = 44100 40 | mock_args.youtube = None 41 | mock_args.input_file = None 42 | mkcc = mkchromecast.Mkchromecast(mock_args) 43 | 44 | def testTrayModeInstantiation(self): 45 | mock_config = mock.create_autospec(config.Config, spec_set=True) 46 | self.enterContext(mock.patch.object(config, "Config", return_value=mock_config)) 47 | 48 | mock_args = mock.Mock() 49 | # Here we set the minimal required args for __init__ to not sys.exit. 50 | mock_args.encoder_backend = None 51 | mock_args.bitrate = constants.DEFAULT_BITRATE 52 | mock_args.codec = 'mp3' 53 | mock_args.command = None 54 | mock_args.resolution = None 55 | mock_args.chunk_size = 64 56 | mock_args.sample_rate = 44100 57 | mock_args.youtube = None 58 | mock_args.input_file = None 59 | 60 | # Now, we set the args to trigger tray mode. 61 | mock_args.discover = False 62 | mock_args.input_file = None 63 | mock_args.reset = False 64 | mock_args.screencast = False 65 | mock_args.source_url = None 66 | mock_args.tray = True 67 | 68 | # Setting the mock config contents. 69 | mock_config.backend = "backend" 70 | mock_config.codec = "codec" 71 | mock_config.bitrate = 12345 72 | mock_config.samplerate = 54321 73 | mock_config.notifications = True 74 | mock_config.colors = "colors" 75 | mock_config.search_at_launch = False 76 | mock_config.alsa_device = "alsa_device" 77 | 78 | mkcc = mkchromecast.Mkchromecast(mock_args) 79 | 80 | # We should find that the mock config values are returned by mkcc, even 81 | # when they are defined differently in args (for instance, bitrate, 82 | # codec, and samplerate above) 83 | self.assertEqual(mkcc.backend, "backend") 84 | self.assertEqual(mkcc.codec, "codec") 85 | self.assertEqual(mkcc.bitrate, 12345) 86 | self.assertEqual(mkcc.samplerate, 54321) 87 | self.assertEqual(mkcc.notifications, True) 88 | self.assertEqual(mkcc.colors, "colors") 89 | self.assertEqual(mkcc.search_at_launch, False) 90 | self.assertEqual(mkcc.adevice, "alsa_device") 91 | 92 | 93 | if __name__ == "__main__": 94 | unittest.main(verbosity=2) 95 | -------------------------------------------------------------------------------- /tests/test_messages.py: -------------------------------------------------------------------------------- 1 | # this file is part of mkchromecast. 2 | 3 | import unittest 4 | from unittest import mock 5 | 6 | from mkchromecast import messages 7 | 8 | class BasicMessagesTest(unittest.TestCase): 9 | def setUp(self): 10 | self.mock_print = self.enterContext(mock.patch("builtins.print", autospec=True)) 11 | 12 | def testNormalSampleRate(self): 13 | codec_name = "aac" 14 | messages.print_samplerate_warning(codec_name) 15 | 16 | self.mock_print.assert_called_once() 17 | print_str = self.mock_print.call_args.args[0] 18 | self.assertIn(codec_name, print_str) 19 | self.assertIn("22050Hz", print_str) 20 | self.assertIn("48000Hz", print_str) 21 | self.assertIn("192000Hz", print_str) 22 | 23 | def testNo96kSampleRate(self): 24 | codec_name = "mp3" 25 | messages.print_samplerate_warning(codec_name) 26 | 27 | self.mock_print.assert_called_once() 28 | print_str = self.mock_print.call_args.args[0] 29 | self.assertIn(codec_name, print_str) 30 | self.assertIn("22050Hz", print_str) 31 | self.assertIn("48000Hz", print_str) 32 | self.assertNotIn("192000Hz", print_str) 33 | 34 | 35 | if __name__ == "__main__": 36 | unittest.main(verbosity=2) 37 | -------------------------------------------------------------------------------- /tests/test_pipeline_builder.py: -------------------------------------------------------------------------------- 1 | # this file is part of mkchromecast. 2 | 3 | import unittest 4 | from unittest import mock 5 | 6 | from mkchromecast import pipeline_builder 7 | from mkchromecast import stream_infra 8 | from mkchromecast import utils 9 | from mkchromecast.constants import OpMode 10 | 11 | class AudioBuilderTests(unittest.TestCase): 12 | 13 | def create_builder(self, 14 | backend_name: str, 15 | platform: str, 16 | **special_encoder_kwargs): 17 | encoder_kwargs: dict[str, Any] = { 18 | "codec": "mp3", 19 | "adevice": None, 20 | "bitrate": "160", 21 | "frame_size": 32 * 128, 22 | "samplerate": "22050", 23 | "segment_time": None 24 | } 25 | 26 | encoder_kwargs |= special_encoder_kwargs 27 | backend = stream_infra.BackendInfo(backend_name, backend_name) 28 | settings = pipeline_builder.EncodeSettings(**encoder_kwargs) 29 | 30 | return pipeline_builder.Audio(backend, platform, settings) 31 | 32 | def testDarwinInputCommand(self): 33 | builder = self.create_builder("ffmpeg", "Darwin") 34 | 35 | input_cmd = builder._input_command() 36 | self.assertIn("avfoundation", input_cmd) 37 | self.assertIn(":BlackHole 16ch", input_cmd) 38 | self.assertNotIn("alsa", input_cmd) 39 | self.assertNotIn("pulse", input_cmd) 40 | self.assertNotIn("-frame_size", input_cmd) 41 | 42 | def testLinuxPulseInputCommand(self): 43 | builder = self.create_builder("ffmpeg", "Linux", adevice=None) 44 | 45 | input_cmd = builder._input_command() 46 | self.assertIn("pulse", input_cmd) 47 | self.assertIn("Mkchromecast.monitor", input_cmd) 48 | self.assertNotIn("avfoundation", input_cmd) 49 | self.assertNotIn("alsa", input_cmd) 50 | self.assertIn("-frame_size", input_cmd) 51 | 52 | def testLinuxAlsaInputCommand(self): 53 | adevice="hw:2,1" 54 | builder = self.create_builder("ffmpeg", "Linux", adevice=adevice) 55 | 56 | input_cmd = builder._input_command() 57 | self.assertIn("alsa", input_cmd) 58 | self.assertIn(adevice, input_cmd) 59 | self.assertNotIn("avfoundation", input_cmd) 60 | self.assertNotIn("pulse", input_cmd) 61 | self.assertIn("-frame_size", input_cmd) 62 | 63 | def testDebugSpecialCase(self): 64 | self.assertIn( 65 | "-loglevel", 66 | self.create_builder("ffmpeg", "Darwin", ffmpeg_debug=True).command) 67 | self.assertNotIn( 68 | "-loglevel", 69 | self.create_builder("ffmpeg", "Darwin", ffmpeg_debug=False).command) 70 | 71 | def testBitrateSpecialCase(self): 72 | # wav doesn't emit bitrate. 73 | self.assertIn( 74 | "-b:a", 75 | self.create_builder("ffmpeg", "Darwin", codec="mp3").command) 76 | self.assertNotIn( 77 | "-b:a", 78 | self.create_builder("ffmpeg", "Darwin", codec="wav").command) 79 | 80 | # ffmpeg should use "k" suffix. 81 | self.assertIn( 82 | "160k", 83 | self.create_builder("ffmpeg", "Linux", bitrate=160).command) 84 | self.assertNotIn( 85 | "160", 86 | self.create_builder("ffmpeg", "Linux", bitrate=160).command) 87 | 88 | # non-ffmpeg (parec in this case) should omit "k" suffix. 89 | self.assertIn( 90 | "160", 91 | self.create_builder("parec", "Linux", bitrate=160).command) 92 | self.assertNotIn( 93 | "160k", 94 | self.create_builder("parec", "Linux", bitrate=160).command) 95 | 96 | def testSegmentTimeSpecialCase(self): 97 | self.assertIn( 98 | "-segment_time", 99 | self.create_builder("ffmpeg", "Darwin", codec="mp3", segment_time=2).command) 100 | self.assertIn( 101 | "-segment_time", 102 | self.create_builder("ffmpeg", "Darwin", codec="aac", segment_time=2).command) 103 | self.assertIn( 104 | "-segment_time", 105 | self.create_builder("ffmpeg", "Linux", codec="ogg", segment_time=2).command) 106 | 107 | self.assertNotIn( 108 | "-segment_time", 109 | self.create_builder("ffmpeg", "Darwin", codec="ogg", segment_time=2).command) 110 | self.assertNotIn( 111 | "-segment_time", 112 | self.create_builder("ffmpeg", "Linux", codec="aac", segment_time=2).command) 113 | 114 | def testCutoffSpecialCase(self): 115 | # We should emit cutoff IFF codec == "aac" and segment_time is not None. 116 | self.assertIn( 117 | "-cutoff", 118 | self.create_builder("ffmpeg", "Darwin", codec="aac", segment_time=2).command) 119 | self.assertIn( 120 | "-cutoff", 121 | self.create_builder("ffmpeg", "Linux", codec="aac", segment_time=2).command) 122 | 123 | # Not aac -> no cutoff. 124 | self.assertNotIn( 125 | "-cutoff", 126 | self.create_builder("ffmpeg", "Darwin", codec="mp3", segment_time=2).command) 127 | self.assertNotIn( 128 | "-cutoff", 129 | self.create_builder("ffmpeg", "Linux", codec="mp3", segment_time=2).command) 130 | 131 | # Empty segment time -> no cutoff. 132 | self.assertNotIn( 133 | "-cutoff", 134 | self.create_builder("ffmpeg", "Darwin", codec="aac", segment_time=None).command) 135 | self.assertNotIn( 136 | "-cutoff", 137 | self.create_builder("ffmpeg", "Linux", codec="aac", segment_time=None).command) 138 | 139 | def testFullLinux(self): 140 | exp_command = [ 141 | "ffmpeg", 142 | "-ac", "2", 143 | "-ar", "44100", 144 | "-frame_size", str(32*128), 145 | "-fragment_size", str(32*128), 146 | "-f", "pulse", 147 | "-i", "Mkchromecast.monitor", 148 | "-f", "mp3", 149 | "-acodec", "libmp3lame", 150 | "-ac", "2", 151 | "-ar", "22050", 152 | "-b:a", "160k", 153 | "pipe:", 154 | ] 155 | 156 | self.assertEqual( 157 | exp_command, 158 | self.create_builder("ffmpeg", "Linux").command) 159 | 160 | def testFullDarwin(self): 161 | exp_command = [ 162 | "ffmpeg", 163 | "-f", "avfoundation", 164 | "-i", ":BlackHole 16ch", 165 | "-f", "segment", 166 | "-segment_time", "2", 167 | "-f", "adts", 168 | "-acodec", "aac", 169 | "-ac", "2", 170 | "-ar", "22050", 171 | "-b:a", "160k", 172 | "-cutoff", "18000", 173 | "pipe:", 174 | ] 175 | 176 | self.assertEqual( 177 | exp_command, 178 | self.create_builder("ffmpeg", "Darwin", codec="aac", segment_time=2).command) 179 | 180 | # TODO(xsdg): Use pyparameterized for this. 181 | def testLinuxOther(self): 182 | binary_for_codecs: dict[str, str] = { 183 | "mp3": "lame", 184 | "ogg": "oggenc", 185 | "aac": "faac", 186 | "opus": "opusenc", 187 | "wav": "sox", 188 | "flac": "flac", 189 | } 190 | 191 | for codec, binary in binary_for_codecs.items(): 192 | command = self.create_builder("parec", "Linux", codec=codec).command 193 | self.assertEqual( 194 | binary, command[0], f"Unexpected binary for codec {codec}") 195 | 196 | with self.assertRaisesRegex(Exception, "unexpected codec.*noexist"): 197 | _ = self.create_builder("parec", "Linux", codec="noexist").command 198 | 199 | 200 | class VideoBuilderTests(unittest.TestCase): 201 | 202 | def create_builder(self, 203 | **special_encoder_kwargs): 204 | encoder_kwargs: dict[str, Any] = { 205 | "display": ":0", 206 | "fps": "25", 207 | "input_file": "/path/to/file.mp4", 208 | "loop": False, 209 | "resolution": None, 210 | "screencast": False, 211 | "seek": None, 212 | "subtitles": None, 213 | "user_command": None, 214 | "vcodec": "libx264", 215 | "youtube_url": None, 216 | } 217 | 218 | encoder_kwargs |= special_encoder_kwargs 219 | settings = pipeline_builder.VideoSettings(**encoder_kwargs) 220 | 221 | return pipeline_builder.Video(settings) 222 | 223 | def testEmptySubtitleCommands(self): 224 | empty_sub = ([], [],) 225 | self.assertEqual( 226 | empty_sub, 227 | pipeline_builder.Video._input_file_subtitle(None, is_mkv=False) 228 | ) 229 | self.assertEqual( 230 | empty_sub, 231 | pipeline_builder.Video._input_file_subtitle(None, is_mkv=True) 232 | ) 233 | 234 | def testMkvSubtitleCommands(self): 235 | sub_file = "subtitles.srt" 236 | input_args, output_args = pipeline_builder.Video._input_file_subtitle( 237 | sub_file, is_mkv=True 238 | ) 239 | 240 | self.assertIn("-i", input_args) 241 | i_index = input_args.index("-i") 242 | self.assertEqual(sub_file, input_args[i_index + 1]) 243 | 244 | self.assertIn("-max_muxing_queue_size", output_args) 245 | self.assertNotIn("-vf", output_args) 246 | 247 | def testNonMkvSubtitleCommands(self): 248 | sub_file = "subtitles.srt" 249 | input_args, output_args = pipeline_builder.Video._input_file_subtitle( 250 | sub_file, is_mkv=False 251 | ) 252 | 253 | self.assertEqual([], input_args) 254 | 255 | self.assertIn("-vf", output_args) 256 | o_index = output_args.index("-vf") 257 | self.assertEqual(f"subtitles={sub_file}", output_args[o_index + 1]) 258 | 259 | def testAudioEncodeCommands(self): 260 | # Shorthand for convenience. 261 | aencode_fxn = pipeline_builder.Video._input_file_aencode 262 | 263 | self.assertEqual([], aencode_fxn(True, False)) 264 | self.assertEqual([], aencode_fxn(False, False)) 265 | 266 | self.assertIn("copy", aencode_fxn(True, True)) 267 | self.assertNotIn("libmp3lame", aencode_fxn(True, True)) 268 | 269 | self.assertNotIn("copy", aencode_fxn(False, True)) 270 | self.assertIn("libmp3lame", aencode_fxn(False, True)) 271 | 272 | def testVideoEncodeCommands(self): 273 | self.enterContext(mock.patch.object(utils, "check_file_info", autospec=True)) 274 | utils.check_file_info.side_effect = Exception("Should not be called") 275 | 276 | # Shorthand for convenience. 277 | vencode_fxn = pipeline_builder.Video._input_file_vencode 278 | 279 | # Whenever resolution is specified, we should see the reencode strategy. 280 | self.assertIn("libx264", vencode_fxn("input.mp4", res="1080p")) 281 | self.assertIn("libx264", vencode_fxn("input.mkv", res="1080p")) 282 | self.assertNotIn("copy", vencode_fxn("input.mkv", res="1080p")) 283 | 284 | # We should always copy for non-mkv without resolution specified. 285 | self.assertIn("copy", vencode_fxn("input.mp4", res=None)) 286 | self.assertNotIn("libx264", vencode_fxn("input.mp4", res=None)) 287 | 288 | # For mkv without resolution, we should only reencode yuv420p10le. 289 | utils.check_file_info.side_effect = None 290 | utils.check_file_info.return_value = "yuv420p" 291 | self.assertIn("copy", vencode_fxn("input.mkv", res=None)) 292 | utils.check_file_info.assert_called_once() 293 | 294 | utils.check_file_info.reset_mock() 295 | utils.check_file_info.return_value = "yuv420p10le" 296 | self.assertIn("libx264", vencode_fxn("input.mkv", res=None)) 297 | utils.check_file_info.assert_called_once() 298 | 299 | def testSpotCheckReencodeFullCommand(self): 300 | exp_command = [ 301 | "ffmpeg", 302 | "-re", 303 | "-i", "input_file.mp4", 304 | "-map_chapters", "-1", 305 | "-vcodec", "libx264", 306 | "-preset", "veryfast", 307 | "-tune", "zerolatency", 308 | "-maxrate", "10000k", 309 | "-bufsize", "20000k", 310 | "-pix_fmt", "yuv420p", 311 | "-g", "60", 312 | "-f", "mp4", 313 | "-movflags", "frag_keyframe+empty_moov", 314 | "-vf", "scale=1920x1080", 315 | "pipe:1", 316 | ] 317 | 318 | builder = self.create_builder(operation=OpMode.INPUT_FILE, 319 | input_file="input_file.mp4", 320 | resolution="1080p") 321 | self.assertEqual(exp_command, builder.command) 322 | 323 | def testSpotCheckCopyFullCommand(self): 324 | exp_command = [ 325 | "ffmpeg", 326 | "-stream_loop", "-1", 327 | "-ss", "hh:mm:ss", 328 | "-re", 329 | "-i", "input_file.mp4", 330 | "-map_chapters", "-1", 331 | "-vcodec", "copy", 332 | "-f", "mp4", 333 | "-movflags", "frag_keyframe+empty_moov", 334 | "pipe:1", 335 | ] 336 | 337 | builder = self.create_builder(operation=OpMode.INPUT_FILE, 338 | input_file="input_file.mp4", 339 | resolution=None, 340 | loop=True, 341 | seek="hh:mm:ss") 342 | self.assertEqual(exp_command, builder.command) 343 | 344 | 345 | if __name__ == "__main__": 346 | unittest.main(verbosity=2) 347 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | # this file is part of mkchromecast. 2 | 3 | import unittest 4 | from unittest import mock 5 | 6 | from mkchromecast import utils 7 | 8 | class ClampBitrateTests(unittest.TestCase): 9 | def setUp(self): 10 | self.mock_print = self.enterContext(mock.patch("builtins.print", autospec=True)) 11 | 12 | def testMissingBitrate(self): 13 | utils.clamp_bitrate("codec", None) 14 | 15 | self.mock_print.assert_called_once() 16 | print_str = self.mock_print.call_args.args[0] 17 | self.assertNotIn("invalid", print_str) 18 | self.assertIn("default", print_str) 19 | 20 | def testInvalidBitrate(self): 21 | for bitrate in -192, -1, 0: 22 | with self.subTest(bitrate=bitrate): 23 | self.mock_print.reset_mock() 24 | utils.clamp_bitrate("codec", bitrate) 25 | 26 | self.mock_print.assert_called_once() 27 | print_str = self.mock_print.call_args.args[0] 28 | self.assertIn("invalid", print_str) 29 | self.assertIn("192", print_str) 30 | 31 | def testNoClamp(self): 32 | # Codecs other than mp3, ogg, or aac shouldn't have an upper bound. 33 | cases = {"mp3": 320, "ogg": 500, "aac": 500, "opus": 1048576} 34 | for codec, bitrate in cases.items(): 35 | with self.subTest(codec=codec): 36 | self.mock_print.reset_mock() 37 | self.assertEqual(bitrate, utils.clamp_bitrate(codec, bitrate)) 38 | self.mock_print.assert_not_called() 39 | 40 | def testClamp(self): 41 | cases = {"mp3": 321, "ogg": 501, "aac": 501} 42 | for codec, bitrate in cases.items(): 43 | with self.subTest(codec=codec): 44 | self.mock_print.reset_mock() 45 | self.assertEqual(bitrate - 1, 46 | utils.clamp_bitrate(codec, bitrate)) 47 | 48 | self.mock_print.assert_called_once() 49 | print_str = self.mock_print.call_args.args[0] 50 | self.assertIn(codec, print_str) 51 | self.assertIn(str(bitrate), print_str) 52 | self.assertIn(str(bitrate - 1), print_str) 53 | 54 | 55 | if __name__ == "__main__": 56 | unittest.main(verbosity=2) 57 | --------------------------------------------------------------------------------