├── 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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------