├── .gitignore ├── LICENSE ├── Makefile ├── README.md ├── man └── pulseaudio-dlna.1 ├── pulseaudio_dlna ├── __init__.py ├── __main__.py ├── application.py ├── codecs.py ├── covermodes.py ├── daemon.py ├── encoders │ ├── __init__.py │ ├── avconv.py │ ├── ffmpeg.py │ └── generic.py ├── holder.py ├── images.py ├── images │ ├── default.png │ ├── distribution-debian.png │ ├── distribution-fedora.png │ ├── distribution-gentoo.png │ ├── distribution-linuxmint.png │ ├── distribution-opensuse.png │ ├── distribution-ubuntu.png │ └── distribution-unknown.png ├── notification.py ├── plugins │ ├── __init__.py │ ├── chromecast │ │ ├── __init__.py │ │ ├── mdns.py │ │ ├── pycastv2 │ │ │ ├── __init__.py │ │ │ ├── cast_channel_pb2.py │ │ │ ├── cast_socket.py │ │ │ ├── commands.py │ │ │ └── example.py │ │ └── renderer.py │ ├── dlna │ │ ├── __init__.py │ │ ├── pyupnpv2 │ │ │ ├── __init__.py │ │ │ └── byto.py │ │ ├── renderer.py │ │ └── ssdp │ │ │ ├── __init__.py │ │ │ ├── discover.py │ │ │ └── listener.py │ └── renderer.py ├── pulseaudio.py ├── recorders.py ├── rules.py ├── streamserver.py ├── utils │ ├── __init__.py │ ├── encoding.py │ ├── git.py │ ├── network.py │ ├── psutil.py │ └── subprocess.py └── workarounds.py ├── samples └── images │ ├── application.png │ └── pavucontrol-sample.png ├── scripts ├── bootstrap.sh ├── capture.sh ├── chromecast-beam.py ├── fritzbox-device-sharing.py └── radio.py └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Distribution / packaging 9 | .Python 10 | env/ 11 | build/ 12 | share/python-wheels/ 13 | material/ 14 | develop-eggs/ 15 | dist/ 16 | downloads/ 17 | eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | pip-selfcheck.json 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | 46 | # Translations 47 | *.mo 48 | *.pot 49 | 50 | # Django stuff: 51 | *.log 52 | 53 | # Sphinx documentation 54 | docs/_build/ 55 | 56 | # PyBuilder 57 | target/ 58 | 59 | .codeintel 60 | *.sublime-* 61 | 62 | dist/ 63 | bin/ 64 | include/ 65 | local/ 66 | 67 | *.iml 68 | .idea -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # This file is part of pulseaudio-dlna. 2 | 3 | # pulseaudio-dlna is free software: you can redistribute it and/or modify 4 | # it under the terms of the GNU General Public License as published by 5 | # the Free Software Foundation, either version 3 of the License, or 6 | # (at your option) any later version. 7 | 8 | # pulseaudio-dlna is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU General Public License for more details. 12 | 13 | # You should have received a copy of the GNU General Public License 14 | # along with pulseaudio-dlna. If not, see . 15 | 16 | python ?= python2.7 17 | user ?= $(shell whoami) 18 | 19 | all: pulseaudio_dlna.egg-info 20 | 21 | venv: 22 | @echo "venv is deprecated. It is just 'make' now." 23 | 24 | pulseaudio_dlna.egg-info: setup.py bin/pip 25 | bin/pip install --editable . && touch $@ 26 | bin/pip: 27 | virtualenv --system-site-packages -p $(python) . 28 | 29 | ifdef DEB_HOST_ARCH 30 | DESTDIR ?= / 31 | PREFIX ?= usr/ 32 | install: 33 | $(python) setup.py install --no-compile --prefix="$(PREFIX)" --root="$(DESTDIR)" --install-layout=deb 34 | else 35 | DESTDIR ?= / 36 | PREFIX ?= /usr/local 37 | install: 38 | $(python) setup.py install --no-compile --prefix="$(PREFIX)" 39 | endif 40 | 41 | release: manpage 42 | pdebuild --buildresult dist 43 | lintian --pedantic dist/*.deb dist/*.dsc dist/*.changes 44 | sudo chown -R $(user) dist/ 45 | 46 | manpage: man/pulseaudio-dlna.1 47 | 48 | man/pulseaudio-dlna.1: pulseaudio_dlna.egg-info 49 | export USE_PKG_VERSION=1; help2man -n "Stream audio to DLNA devices and Chromecasts" "bin/pulseaudio-dlna" > /tmp/pulseaudio-dlna.1 50 | mv /tmp/pulseaudio-dlna.1 man/pulseaudio-dlna.1 51 | 52 | clean: 53 | rm -rf build dist $(shell find pulseaudio_dlna -name "__pycache__") 54 | rm -rf *.egg-info *.egg bin local lib lib64 include share pyvenv.cfg 55 | rm -rf docs htmlcov .coverage .tox pip-selfcheck.json 56 | -------------------------------------------------------------------------------- /man/pulseaudio-dlna.1: -------------------------------------------------------------------------------- 1 | .\" DO NOT MODIFY THIS FILE! It was generated by help2man 1.47.2. 2 | .TH PULSEAUDIO-DLNA "1" "April 2016" "pulseaudio-dlna 0.5.2" "User Commands" 3 | .SH NAME 4 | pulseaudio-dlna \- Stream audio to DLNA devices and Chromecasts 5 | .SH DESCRIPTION 6 | .SS "Usage:" 7 | .TP 8 | pulseaudio\-dlna pulseaudio\-dlna [\-\-host ] [\-\-port ][\-\-encoder | \fB\-\-codec\fR ] [\-\-bit\-rate=] 9 | [\-\-encoder\-backend ] 10 | [\-\-filter\-device=] 11 | [\-\-renderer\-urls ] 12 | [\-\-request\-timeout ] 13 | [\-\-msearch\-port=] [\-\-ssdp\-mx ] [\-\-ssdp\-ttl ] [\-\-ssdp\-amount ] 14 | [\-\-cover\-mode ] 15 | [\-\-auto\-reconnect] 16 | [\-\-debug] 17 | [\-\-fake\-http10\-content\-length] [\-\-fake\-http\-content\-length] 18 | [\-\-disable\-switchback] [\-\-disable\-ssdp\-listener] [\-\-disable\-device\-stop] [\-\-disable\-workarounds] 19 | .TP 20 | pulseaudio\-dlna [\-\-host ] [\-\-create\-device\-config] [\-\-update\-device\-config] 21 | [\-\-msearch\-port=] [\-\-ssdp\-mx ] [\-\-ssdp\-ttl ] [\-\-ssdp\-amount ] 22 | .IP 23 | pulseaudio\-dlna [\-h | \fB\-\-help\fR | \fB\-\-version]\fR 24 | .SH OPTIONS 25 | .TP 26 | \fB\-\-create\-device\-config\fR 27 | Discovers all devices in your network and write a config for them. 28 | That config can be editied manually to adjust various settings. 29 | You can set: 30 | .TP 31 | \- Device name 32 | \- Codec order (The first one is used if the encoder binary is available on your system) 33 | \- Various codec settings such as the mime type, specific rules or 34 | .TP 35 | the bit rate (depends on the codec) 36 | A written config is loaded by default if the \fB\-\-encoder\fR and \fB\-\-bit\-rate\fR options are not used. 37 | .TP 38 | \fB\-\-update\-device\-config\fR 39 | Same as \fB\-\-create\-device\-config\fR but preserves your existing config from being overwritten 40 | .TP 41 | \fB\-\-host=\fR 42 | Set the server ip. 43 | .TP 44 | \fB\-p\fR \fB\-\-port=\fR 45 | Set the server port [default: 8080]. 46 | .TP 47 | \fB\-e\fR \fB\-\-encoder=\fR 48 | Deprecated alias for \fB\-\-codec\fR 49 | .TP 50 | \fB\-c\fR \fB\-\-codec=\fR 51 | Set the audio codec. 52 | Possible codecs are: 53 | .TP 54 | \- mp3 55 | MPEG Audio Layer III (MP3) 56 | .TP 57 | \- ogg 58 | Ogg Vorbis (OGG) 59 | .TP 60 | \- flac 61 | Free Lossless Audio Codec (FLAC) 62 | .TP 63 | \- wav 64 | Waveform Audio File Format (WAV) 65 | .TP 66 | \- opus 67 | Opus Interactive Audio Codec (OPUS) 68 | .TP 69 | \- aac 70 | Advanced Audio Coding (AAC) 71 | .TP 72 | \- l16 73 | Linear PCM (L16) 74 | .TP 75 | \fB\-\-encoder\-backend=\fR 76 | Set the backend for all encoders. 77 | Possible backends are: 78 | .TP 79 | \- generic (default) 80 | \- ffmpeg 81 | \- avconv 82 | .TP 83 | \fB\-b\fR \fB\-\-bit\-rate=\fR 84 | Set the audio encoder's bitrate. 85 | .TP 86 | \fB\-\-filter\-device=\fR 87 | Set a name filter for devices which should be added. 88 | Devices which get discovered, but won't match the 89 | filter text will be skipped. 90 | .TP 91 | \fB\-\-renderer\-urls=\fR 92 | Set the renderer urls yourself. no discovery will commence. 93 | .TP 94 | \fB\-\-request\-timeout=\fR 95 | Set the timeout for requests in seconds [default: 15]. 96 | .TP 97 | \fB\-\-ssdp\-ttl=\fR 98 | Set the SSDP socket's TTL [default: 10]. 99 | .TP 100 | \fB\-\-ssdp\-mx=\fR 101 | Set the MX value of the SSDP discovery message [default: 3]. 102 | .TP 103 | \fB\-\-ssdp\-amount=\fR 104 | Set the amount of SSDP discovery messages being sent [default: 5]. 105 | .TP 106 | \fB\-\-msearch\-port=\fR 107 | Set the source port of the MSEARCH socket [default: random]. 108 | .TP 109 | \fB\-\-cover\-mode=\fR 110 | Set the cover mode [default: default]. 111 | Possible modes are: 112 | .TP 113 | \- disabled 114 | No icon is shown 115 | .TP 116 | \- default 117 | The application icon is shown 118 | .TP 119 | \- distribution 120 | The icon of your distribution is shown 121 | .TP 122 | \- application 123 | The audio application's icon is shown 124 | .TP 125 | \fB\-\-debug\fR 126 | enables detailed debug messages. 127 | .TP 128 | \fB\-\-auto\-reconnect\fR 129 | If set, the application tries to reconnect devices in case the stream collapsed 130 | .TP 131 | \fB\-\-fake\-http\-content\-length\fR 132 | If set, the content\-length of HTTP requests will be set to 100 GB. 133 | .TP 134 | \fB\-\-disable\-switchback\fR 135 | If set, streams won't switched back to the default sink if a device disconnects. 136 | .TP 137 | \fB\-\-disable\-ssdp\-listener\fR 138 | If set, the application won't bind to the port 1900 and therefore the automatic discovery of new devices won't work. 139 | .TP 140 | \fB\-\-disable\-device\-stop\fR 141 | If set, the application won't send any stop commands to renderers at all 142 | .TP 143 | \fB\-\-disable\-workarounds\fR 144 | If set, the application won't apply any device workarounds 145 | .TP 146 | \fB\-v\fR \fB\-\-version\fR 147 | Show the version. 148 | .TP 149 | \fB\-h\fR \fB\-\-help\fR 150 | Show the help. 151 | .SH EXAMPLES 152 | .IP 153 | \- pulseaudio\-dlna 154 | .IP 155 | will start pulseaudio\-dlna on port 8080 and stream your PulseAudio streams encoded with mp3. 156 | .IP 157 | \- pulseaudio\-dlna \-\-encoder ogg 158 | .IP 159 | will start pulseaudio\-dlna on port 8080 and stream your PulseAudio streams encoded with Ogg Vorbis. 160 | .IP 161 | \- pulseaudio\-dlna \-\-port 10291 \-\-encoder flac 162 | .IP 163 | will start pulseaudio\-dlna on port 10291 and stream your PulseAudio streams encoded with FLAC. 164 | .IP 165 | \- pulseaudio\-dlna \-\-filter\-device 'Nexus 5,TV' 166 | .IP 167 | will just use devices named Nexus 5 or TV even when more devices got discovered. 168 | .IP 169 | \- pulseaudio\-dlna \-\-renderer\-urls http://192.168.1.7:7676/smp_10_ 170 | .IP 171 | won't discover upnp devices by itself. Instead it will search for upnp renderers 172 | at the specified locations. You can specify multiple locations via urls 173 | separated by comma (,). Most users won't ever need this option, but since 174 | UDP multicast packages won't work (most times) over VPN connections this is 175 | very useful if you ever plan to stream to a UPNP device over VPN. 176 | .SH "SEE ALSO" 177 | The full documentation for 178 | .B pulseaudio-dlna 179 | is maintained as a Texinfo manual. If the 180 | .B info 181 | and 182 | .B pulseaudio-dlna 183 | programs are properly installed at your site, the command 184 | .IP 185 | .B info pulseaudio-dlna 186 | .PP 187 | should give you access to the complete manual. 188 | -------------------------------------------------------------------------------- /pulseaudio_dlna/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | # This file is part of pulseaudio-dlna. 4 | 5 | # pulseaudio-dlna is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | 10 | # pulseaudio-dlna is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU General Public License for more details. 14 | 15 | # You should have received a copy of the GNU General Public License 16 | # along with pulseaudio-dlna. If not, see . 17 | 18 | from __future__ import unicode_literals 19 | 20 | import os 21 | import pkg_resources 22 | 23 | import utils.git 24 | 25 | try: 26 | version = pkg_resources.get_distribution(__package__).version 27 | except pkg_resources.DistributionNotFound: 28 | version = 'unknown' 29 | 30 | if os.environ.get('USE_PKG_VERSION', None) == '1': 31 | branch, rev = None, None 32 | else: 33 | branch, rev = utils.git.get_head_version() 34 | 35 | __version__ = '{version}{rev}'.format( 36 | version=version, 37 | rev='+git-{} ({})'.format(rev, branch) if rev else '', 38 | ) 39 | -------------------------------------------------------------------------------- /pulseaudio_dlna/__main__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | # This file is part of pulseaudio-dlna. 4 | 5 | # pulseaudio-dlna is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | 10 | # pulseaudio-dlna is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU General Public License for more details. 14 | 15 | # You should have received a copy of the GNU General Public License 16 | # along with pulseaudio-dlna. If not, see . 17 | 18 | ''' 19 | Usage: 20 | pulseaudio-dlna [--host ] [--port ][--encoder | --codec ] [--bit-rate=] 21 | [--encoder-backend ] 22 | [--filter-device=] 23 | [--renderer-urls ] 24 | [--request-timeout ] 25 | [--chunk-size ] 26 | [--msearch-port=] [--ssdp-mx ] [--ssdp-ttl ] [--ssdp-amount ] 27 | [--cover-mode ] 28 | [--auto-reconnect] 29 | [--debug] 30 | [--fake-http10-content-length] [--fake-http-content-length] 31 | [--disable-switchback] [--disable-ssdp-listener] [--disable-device-stop] [--disable-workarounds] [--disable-mimetype-check] 32 | pulseaudio-dlna [--host ] [--create-device-config] [--update-device-config] 33 | [--msearch-port=] [--ssdp-mx ] [--ssdp-ttl ] [--ssdp-amount ] 34 | pulseaudio-dlna [-h | --help | --version] 35 | 36 | Options: 37 | --create-device-config Discovers all devices in your network and write a config for them. 38 | That config can be editied manually to adjust various settings. 39 | You can set: 40 | - Device name 41 | - Codec order (The first one is used if the encoder binary is available on your system) 42 | - Various codec settings such as the mime type, specific rules or 43 | the bit rate (depends on the codec) 44 | A written config is loaded by default if the --encoder and --bit-rate options are not used. 45 | --update-device-config Same as --create-device-config but preserves your existing config from being overwritten 46 | --host= Set the server ip. 47 | -p --port= Set the server port [default: 8080]. 48 | -e --encoder= Deprecated alias for --codec 49 | -c --codec= Set the audio codec. 50 | Possible codecs are: 51 | - mp3 MPEG Audio Layer III (MP3) 52 | - ogg Ogg Vorbis (OGG) 53 | - flac Free Lossless Audio Codec (FLAC) 54 | - wav Waveform Audio File Format (WAV) 55 | - opus Opus Interactive Audio Codec (OPUS) 56 | - aac Advanced Audio Coding (AAC) 57 | - l16 Linear PCM (L16) 58 | --encoder-backend= Set the backend for all encoders. 59 | Possible backends are: 60 | - generic (default) 61 | - ffmpeg 62 | - avconv 63 | -b --bit-rate= Set the audio encoder's bitrate. 64 | --filter-device= Set a name filter for devices which should be added. 65 | Devices which get discovered, but won't match the 66 | filter text will be skipped. 67 | --renderer-urls= Set the renderer urls yourself. no discovery will commence. 68 | --request-timeout= Set the timeout for requests in seconds [default: 15]. 69 | --chunk-size= Set the stream's chunk size [default: 4096]. 70 | --ssdp-ttl= Set the SSDP socket's TTL [default: 10]. 71 | --ssdp-mx= Set the MX value of the SSDP discovery message [default: 3]. 72 | --ssdp-amount= Set the amount of SSDP discovery messages being sent [default: 5]. 73 | --msearch-port= Set the source port of the MSEARCH socket [default: random]. 74 | --cover-mode= Set the cover mode [default: default]. 75 | Possible modes are: 76 | - disabled No icon is shown 77 | - default The application icon is shown 78 | - distribution The icon of your distribution is shown 79 | - application The audio application's icon is shown 80 | --debug enables detailed debug messages. 81 | --auto-reconnect If set, the application tries to reconnect devices in case the stream collapsed 82 | --fake-http-content-length If set, the content-length of HTTP requests will be set to 100 GB. 83 | --disable-switchback If set, streams won't switched back to the default sink if a device disconnects. 84 | --disable-ssdp-listener If set, the application won't bind to the port 1900 and therefore the automatic discovery of new devices won't work. 85 | --disable-device-stop If set, the application won't send any stop commands to renderers at all 86 | --disable-workarounds If set, the application won't apply any device workarounds 87 | --disable-mimetype-check If set, the application won't check the device's mime type capabilities 88 | -v --version Show the version. 89 | -h --help Show the help. 90 | 91 | Examples: 92 | - pulseaudio-dlna 93 | 94 | will start pulseaudio-dlna on port 8080 and stream your PulseAudio streams encoded with mp3. 95 | 96 | - pulseaudio-dlna --encoder ogg 97 | 98 | will start pulseaudio-dlna on port 8080 and stream your PulseAudio streams encoded with Ogg Vorbis. 99 | 100 | - pulseaudio-dlna --port 10291 --encoder flac 101 | 102 | will start pulseaudio-dlna on port 10291 and stream your PulseAudio streams encoded with FLAC. 103 | 104 | - pulseaudio-dlna --filter-device 'Nexus 5,TV' 105 | 106 | will just use devices named Nexus 5 or TV even when more devices got discovered. 107 | 108 | - pulseaudio-dlna --renderer-urls http://192.168.1.7:7676/smp_10_ 109 | 110 | won't discover upnp devices by itself. Instead it will search for upnp renderers 111 | at the specified locations. You can specify multiple locations via urls 112 | separated by comma (,). Most users won't ever need this option, but since 113 | UDP multicast packages won't work (most times) over VPN connections this is 114 | very useful if you ever plan to stream to a UPNP device over VPN. 115 | 116 | ''' 117 | 118 | 119 | from __future__ import unicode_literals 120 | 121 | import sys 122 | import os 123 | import docopt 124 | import logging 125 | import socket 126 | import getpass 127 | 128 | 129 | def main(argv=sys.argv[1:]): 130 | 131 | import pulseaudio_dlna 132 | options = docopt.docopt(__doc__, version=pulseaudio_dlna.__version__) 133 | 134 | level = logging.DEBUG 135 | if not options['--debug']: 136 | level = logging.INFO 137 | logging.getLogger('requests').setLevel(logging.WARNING) 138 | logging.getLogger('urllib3').setLevel(logging.WARNING) 139 | 140 | logging.basicConfig( 141 | level=level, 142 | format='%(asctime)s %(name)-46s %(levelname)-8s %(message)s', 143 | datefmt='%m-%d %H:%M:%S') 144 | logger = logging.getLogger('pulseaudio_dlna.__main__') 145 | 146 | if not acquire_lock(): 147 | print('The application is shutting down, since there already seems to ' 148 | 'be a running instance.') 149 | return 1 150 | 151 | if os.geteuid() == 0: 152 | logger.info('Running as root. Starting daemon ...') 153 | import pulseaudio_dlna.daemon 154 | daemon = pulseaudio_dlna.daemon.Daemon() 155 | daemon.run() 156 | else: 157 | import pulseaudio_dlna.application 158 | app = pulseaudio_dlna.application.Application() 159 | app.run(options) 160 | return 0 161 | 162 | 163 | def acquire_lock(): 164 | acquire_lock._lock_socket = socket.socket( 165 | socket.AF_UNIX, socket.SOCK_DGRAM) 166 | try: 167 | name = '/com/masmu/pulseaudio_dlna/{}'.format(getpass.getuser()) 168 | acquire_lock._lock_socket.bind('\0' + name) 169 | return True 170 | except socket.error: 171 | return False 172 | 173 | if __name__ == "__main__": 174 | sys.exit(main()) 175 | -------------------------------------------------------------------------------- /pulseaudio_dlna/codecs.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | # This file is part of pulseaudio-dlna. 4 | 5 | # pulseaudio-dlna is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | 10 | # pulseaudio-dlna is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU General Public License for more details. 14 | 15 | # You should have received a copy of the GNU General Public License 16 | # along with pulseaudio-dlna. If not, see . 17 | 18 | from __future__ import unicode_literals 19 | 20 | import functools 21 | import logging 22 | import re 23 | import inspect 24 | import sys 25 | 26 | import pulseaudio_dlna.encoders 27 | import pulseaudio_dlna.rules 28 | 29 | logger = logging.getLogger('pulseaudio_dlna.codecs') 30 | 31 | BACKENDS = ['generic', 'ffmpeg', 'avconv', 'pulseaudio'] 32 | CODECS = {} 33 | 34 | 35 | class UnknownBackendException(Exception): 36 | def __init__(self, backend): 37 | Exception.__init__( 38 | self, 39 | 'You specified an unknown backend "{}"!'.format(backend) 40 | ) 41 | 42 | 43 | class UnknownCodecException(Exception): 44 | def __init__(self, codec): 45 | Exception.__init__( 46 | self, 47 | 'You specified an unknown codec "{}"!'.format(codec), 48 | ) 49 | 50 | 51 | class UnsupportedCodecException(Exception): 52 | def __init__(self, codec, backend): 53 | Exception.__init__( 54 | self, 55 | 'You specified an unsupported codec "{}" for the ' 56 | 'backend "{}"!'.format(codec, backend), 57 | ) 58 | 59 | 60 | def set_backend(backend): 61 | if backend in BACKENDS: 62 | BaseCodec.BACKEND = backend 63 | return 64 | raise UnknownBackendException(backend) 65 | 66 | 67 | def set_codecs(identifiers): 68 | step = 3 69 | priority = (len(CODECS) + 1) * step 70 | for identifier, _type in CODECS.iteritems(): 71 | _type.ENABLED = False 72 | _type.PRIORITY = 0 73 | for identifier in identifiers: 74 | try: 75 | CODECS[identifier].ENABLED = True 76 | CODECS[identifier].PRIORITY = priority 77 | priority = priority - step 78 | except KeyError: 79 | raise UnknownCodecException(identifier) 80 | 81 | 82 | def enabled_codecs(): 83 | codecs = [] 84 | for identifier, _type in CODECS.iteritems(): 85 | if _type.ENABLED: 86 | codecs.append(_type()) 87 | return codecs 88 | 89 | 90 | @functools.total_ordering 91 | class BaseCodec(object): 92 | 93 | ENABLED = True 94 | IDENTIFIER = None 95 | BACKEND = 'generic' 96 | PRIORITY = None 97 | 98 | def __init__(self): 99 | self.mime_type = None 100 | self.suffix = None 101 | self.rules = pulseaudio_dlna.rules.Rules() 102 | 103 | @property 104 | def enabled(self): 105 | return type(self).ENABLED 106 | 107 | @enabled.setter 108 | def enabled(self, value): 109 | type(self).ENABLED = value 110 | 111 | @property 112 | def priority(self): 113 | return type(self).PRIORITY 114 | 115 | @priority.setter 116 | def priority(self, value): 117 | type(self).PRIORITY = value 118 | 119 | @property 120 | def specific_mime_type(self): 121 | return self.mime_type 122 | 123 | @property 124 | def encoder(self): 125 | return self.encoder_type() 126 | 127 | @property 128 | def encoder_type(self): 129 | if self.BACKEND in self.ENCODERS: 130 | return self.ENCODERS[self.BACKEND] 131 | else: 132 | raise UnsupportedCodecException(self.IDENTIFIER, self.BACKEND) 133 | 134 | @classmethod 135 | def accepts(cls, mime_type): 136 | for accepted_mime_type in cls.SUPPORTED_MIME_TYPES: 137 | if mime_type.lower().startswith(accepted_mime_type.lower()): 138 | return True 139 | return False 140 | 141 | def get_recorder(self, monitor): 142 | if self.BACKEND == 'pulseaudio': 143 | return pulseaudio_dlna.recorders.PulseaudioRecorder( 144 | monitor, codec=self) 145 | else: 146 | return pulseaudio_dlna.recorders.PulseaudioRecorder(monitor) 147 | 148 | def __eq__(self, other): 149 | return type(self) is type(other) 150 | 151 | def __gt__(self, other): 152 | return type(self) is type(other) 153 | 154 | def __str__(self, detailed=False): 155 | return '<{} enabled="{}" priority="{}" mime_type="{}" ' \ 156 | 'backend="{}">{}{}'.format( 157 | self.__class__.__name__, 158 | self.enabled, 159 | self.priority, 160 | self.specific_mime_type, 161 | self.BACKEND, 162 | ('\n' if len(self.rules) > 0 else '') + '\n'.join( 163 | [' - ' + str(rule) for rule in self.rules] 164 | ) if detailed else '', 165 | '\n ' + str(self.encoder) if detailed else '', 166 | ) 167 | 168 | def to_json(self): 169 | attributes = ['priority', 'suffix', 'mime_type'] 170 | d = { 171 | k: v for k, v in self.__dict__.iteritems() 172 | if k not in attributes 173 | } 174 | d['mime_type'] = self.specific_mime_type 175 | d['identifier'] = self.IDENTIFIER 176 | return d 177 | 178 | 179 | class BitRateMixin(object): 180 | 181 | def __init__(self): 182 | self.bit_rate = None 183 | 184 | @property 185 | def encoder(self): 186 | return self.encoder_type(self.bit_rate) 187 | 188 | def __eq__(self, other): 189 | return type(self) is type(other) and self.bit_rate == other.bit_rate 190 | 191 | def __gt__(self, other): 192 | return type(self) is type(other) and self.bit_rate > other.bit_rate 193 | 194 | 195 | class Mp3Codec(BitRateMixin, BaseCodec): 196 | 197 | SUPPORTED_MIME_TYPES = ['audio/mpeg', 'audio/mp3'] 198 | IDENTIFIER = 'mp3' 199 | ENCODERS = { 200 | 'generic': pulseaudio_dlna.encoders.LameMp3Encoder, 201 | 'ffmpeg': pulseaudio_dlna.encoders.FFMpegMp3Encoder, 202 | 'avconv': pulseaudio_dlna.encoders.AVConvMp3Encoder, 203 | } 204 | PRIORITY = 18 205 | 206 | def __init__(self, mime_string=None): 207 | BaseCodec.__init__(self) 208 | BitRateMixin.__init__(self) 209 | self.suffix = 'mp3' 210 | self.mime_type = mime_string or 'audio/mp3' 211 | 212 | 213 | class WavCodec(BaseCodec): 214 | 215 | SUPPORTED_MIME_TYPES = ['audio/wav', 'audio/x-wav'] 216 | IDENTIFIER = 'wav' 217 | ENCODERS = { 218 | 'generic': pulseaudio_dlna.encoders.SoxWavEncoder, 219 | 'ffmpeg': pulseaudio_dlna.encoders.FFMpegWavEncoder, 220 | 'avconv': pulseaudio_dlna.encoders.AVConvWavEncoder, 221 | 'pulseaudio': pulseaudio_dlna.encoders.NullEncoder, 222 | } 223 | PRIORITY = 15 224 | 225 | def __init__(self, mime_string=None): 226 | BaseCodec.__init__(self) 227 | self.suffix = 'wav' 228 | self.mime_type = mime_string or 'audio/wav' 229 | 230 | 231 | class L16Codec(BaseCodec): 232 | 233 | SUPPORTED_MIME_TYPES = ['audio/l16'] 234 | IDENTIFIER = 'l16' 235 | ENCODERS = { 236 | 'generic': pulseaudio_dlna.encoders.SoxL16Encoder, 237 | 'ffmpeg': pulseaudio_dlna.encoders.FFMpegL16Encoder, 238 | 'avconv': pulseaudio_dlna.encoders.AVConvL16Encoder, 239 | } 240 | PRIORITY = 1 241 | 242 | def __init__(self, mime_string=None): 243 | BaseCodec.__init__(self) 244 | self.suffix = 'pcm16' 245 | self.mime_type = 'audio/L16' 246 | 247 | self.sample_rate = None 248 | self.channels = None 249 | 250 | if mime_string: 251 | match = re.match( 252 | '(.*?)(?P.*?);' 253 | '(.*?)rate=(?P.*?);' 254 | '(.*?)channels=(?P\d)', mime_string) 255 | if match: 256 | self.mime_type = match.group('mime_type') 257 | self.sample_rate = int(match.group('sample_rate')) 258 | self.channels = int(match.group('channels')) 259 | 260 | @property 261 | def specific_mime_type(self): 262 | if self.sample_rate and self.channels: 263 | return '{};rate={};channels={}'.format( 264 | self.mime_type, self.sample_rate, self.channels) 265 | else: 266 | return self.mime_type 267 | 268 | @property 269 | def encoder(self): 270 | return self.encoder_type(self.sample_rate, self.channels) 271 | 272 | def __eq__(self, other): 273 | return type(self) is type(other) and ( 274 | self.sample_rate == other.sample_rate and 275 | self.channels == other.channels) 276 | 277 | def __gt__(self, other): 278 | return type(self) is type(other) and ( 279 | self.sample_rate > other.sample_rate and 280 | self.channels > other.channels) 281 | 282 | 283 | class AacCodec(BitRateMixin, BaseCodec): 284 | 285 | SUPPORTED_MIME_TYPES = ['audio/aac', 'audio/x-aac'] 286 | IDENTIFIER = 'aac' 287 | ENCODERS = { 288 | 'generic': pulseaudio_dlna.encoders.FaacAacEncoder, 289 | 'ffmpeg': pulseaudio_dlna.encoders.FFMpegAacEncoder, 290 | 'avconv': pulseaudio_dlna.encoders.AVConvAacEncoder, 291 | } 292 | PRIORITY = 12 293 | 294 | def __init__(self, mime_string=None): 295 | BaseCodec.__init__(self) 296 | BitRateMixin.__init__(self) 297 | self.suffix = 'aac' 298 | self.mime_type = mime_string or 'audio/aac' 299 | 300 | 301 | class OggCodec(BitRateMixin, BaseCodec): 302 | 303 | SUPPORTED_MIME_TYPES = ['audio/ogg', 'audio/x-ogg', 'application/ogg'] 304 | IDENTIFIER = 'ogg' 305 | ENCODERS = { 306 | 'generic': pulseaudio_dlna.encoders.OggencOggEncoder, 307 | 'ffmpeg': pulseaudio_dlna.encoders.FFMpegOggEncoder, 308 | 'avconv': pulseaudio_dlna.encoders.AVConvOggEncoder, 309 | 'pulseaudio': pulseaudio_dlna.encoders.NullEncoder, 310 | } 311 | PRIORITY = 6 312 | 313 | def __init__(self, mime_string=None): 314 | BaseCodec.__init__(self) 315 | BitRateMixin.__init__(self) 316 | self.suffix = 'ogg' 317 | self.mime_type = mime_string or 'audio/ogg' 318 | 319 | 320 | class FlacCodec(BaseCodec): 321 | 322 | SUPPORTED_MIME_TYPES = ['audio/flac', 'audio/x-flac'] 323 | IDENTIFIER = 'flac' 324 | ENCODERS = { 325 | 'generic': pulseaudio_dlna.encoders.FlacFlacEncoder, 326 | 'ffmpeg': pulseaudio_dlna.encoders.FFMpegFlacEncoder, 327 | 'avconv': pulseaudio_dlna.encoders.AVConvFlacEncoder, 328 | 'pulseaudio': pulseaudio_dlna.encoders.NullEncoder, 329 | } 330 | PRIORITY = 9 331 | 332 | def __init__(self, mime_string=None): 333 | BaseCodec.__init__(self) 334 | self.suffix = 'flac' 335 | self.mime_type = mime_string or 'audio/flac' 336 | 337 | 338 | class OpusCodec(BitRateMixin, BaseCodec): 339 | 340 | SUPPORTED_MIME_TYPES = ['audio/opus', 'audio/x-opus'] 341 | IDENTIFIER = 'opus' 342 | ENCODERS = { 343 | 'generic': pulseaudio_dlna.encoders.OpusencOpusEncoder, 344 | 'ffmpeg': pulseaudio_dlna.encoders.FFMpegOpusEncoder, 345 | 'avconv': pulseaudio_dlna.encoders.AVConvOpusEncoder, 346 | } 347 | PRIORITY = 3 348 | 349 | def __init__(self, mime_string=None): 350 | BaseCodec.__init__(self) 351 | BitRateMixin.__init__(self) 352 | self.suffix = 'opus' 353 | self.mime_type = mime_string or 'audio/opus' 354 | 355 | 356 | def load_codecs(): 357 | if len(CODECS) == 0: 358 | logger.debug('Loaded codecs:') 359 | for name, _type in inspect.getmembers(sys.modules[__name__]): 360 | if inspect.isclass(_type) and issubclass(_type, BaseCodec): 361 | if _type is not BaseCodec: 362 | logger.debug(' {} = {}'.format(_type.IDENTIFIER, _type)) 363 | CODECS[_type.IDENTIFIER] = _type 364 | return None 365 | 366 | load_codecs() 367 | -------------------------------------------------------------------------------- /pulseaudio_dlna/covermodes.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | # This file is part of pulseaudio-dlna. 4 | 5 | # pulseaudio-dlna is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | 10 | # pulseaudio-dlna is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU General Public License for more details. 14 | 15 | # You should have received a copy of the GNU General Public License 16 | # along with pulseaudio-dlna. If not, see . 17 | 18 | from __future__ import unicode_literals 19 | 20 | import sys 21 | import inspect 22 | import socket 23 | import platform 24 | import logging 25 | 26 | logger = logging.getLogger('pulseaudio_dlna.covermodes') 27 | 28 | 29 | MODES = {} 30 | 31 | 32 | class UnknownCoverModeException(Exception): 33 | def __init__(self, cover_mode): 34 | Exception.__init__( 35 | self, 36 | 'You specified an unknown cover mode "{}"!'.format(cover_mode) 37 | ) 38 | 39 | 40 | def validate(cover_mode): 41 | if cover_mode not in MODES: 42 | raise UnknownCoverModeException(cover_mode) 43 | 44 | 45 | class BaseCoverMode(object): 46 | 47 | IDENTIFIER = None 48 | 49 | def __init__(self): 50 | self.bridge = None 51 | 52 | @property 53 | def artist(self): 54 | return 'Liveaudio on {}'.format(socket.gethostname()) 55 | 56 | @property 57 | def title(self): 58 | return ', '.join(self.bridge.sink.stream_client_names) 59 | 60 | @property 61 | def thumb(self): 62 | return None 63 | 64 | def get(self, bridge): 65 | try: 66 | self.bridge = bridge 67 | return self.artist, self.title, self.thumb 68 | finally: 69 | self.bridge = None 70 | 71 | 72 | class DisabledCoverMode(BaseCoverMode): 73 | 74 | IDENTIFIER = 'disabled' 75 | 76 | 77 | class DefaultCoverMode(BaseCoverMode): 78 | 79 | IDENTIFIER = 'default' 80 | 81 | @property 82 | def thumb(self): 83 | try: 84 | return self.bridge.device.get_image_url('default.png') 85 | except: 86 | return None 87 | 88 | 89 | class DistributionCoverMode(BaseCoverMode): 90 | 91 | IDENTIFIER = 'distribution' 92 | 93 | @property 94 | def thumb(self): 95 | dist_name, dist_ver, dist_arch = platform.linux_distribution() 96 | logger.debug(dist_name) 97 | if dist_name == 'Ubuntu': 98 | dist_icon = 'ubuntu' 99 | elif dist_name == 'debian': 100 | dist_icon = 'debian' 101 | elif dist_name == 'fedora': 102 | dist_icon = 'fedora' 103 | elif dist_name == 'LinuxMint': 104 | dist_icon = 'linuxmint' 105 | elif dist_name == 'openSUSE' or dist_name == 'SuSE': 106 | dist_icon = 'opensuse' 107 | elif dist_name == 'gentoo': 108 | dist_icon = 'gentoo' 109 | else: 110 | dist_icon = 'unknown' 111 | try: 112 | return self.bridge.device.get_image_url( 113 | 'distribution-{}.png'.format(dist_icon)) 114 | except: 115 | return None 116 | 117 | 118 | class ApplicationCoverMode(BaseCoverMode): 119 | 120 | IDENTIFIER = 'application' 121 | 122 | @property 123 | def thumb(self): 124 | try: 125 | return self.bridge.device.get_sys_icon_url( 126 | self.bridge.sink.primary_application_name) 127 | except: 128 | return None 129 | 130 | 131 | def load_modes(): 132 | if len(MODES) == 0: 133 | for name, _type in inspect.getmembers(sys.modules[__name__]): 134 | if inspect.isclass(_type) and issubclass(_type, BaseCoverMode): 135 | if _type is not BaseCoverMode: 136 | MODES[_type.IDENTIFIER] = _type 137 | return None 138 | 139 | load_modes() 140 | -------------------------------------------------------------------------------- /pulseaudio_dlna/daemon.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | # This file is part of pulseaudio-dlna. 4 | 5 | # pulseaudio-dlna is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | 10 | # pulseaudio-dlna is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU General Public License for more details. 14 | 15 | # You should have received a copy of the GNU General Public License 16 | # along with pulseaudio-dlna. If not, see . 17 | 18 | from __future__ import unicode_literals 19 | 20 | from gi.repository import GObject 21 | 22 | import dbus 23 | import dbus.mainloop.glib 24 | import logging 25 | import os 26 | import sys 27 | import setproctitle 28 | import functools 29 | import signal 30 | import pwd 31 | 32 | import pulseaudio_dlna.utils.subprocess 33 | import pulseaudio_dlna.utils.psutil as psutil 34 | 35 | logger = logging.getLogger('pulseaudio_dlna.daemon') 36 | 37 | 38 | REQUIRED_ENVIRONMENT_VARS = [ 39 | 'DISPLAY', 40 | 'DBUS_SESSION_BUS_ADDRESS', 41 | 'PATH', 42 | 'XDG_RUNTIME_DIR', 43 | 'LANG' 44 | ] 45 | 46 | 47 | def missing_env_vars(environment): 48 | env = [] 49 | for var in REQUIRED_ENVIRONMENT_VARS: 50 | if var not in environment: 51 | env.append(var) 52 | return env 53 | 54 | 55 | class Daemon(object): 56 | def __init__(self): 57 | dbus.mainloop.glib.DBusGMainLoop(set_as_default=True) 58 | setproctitle.setproctitle('pulseaudio-daemon') 59 | self.mainloop = GObject.MainLoop() 60 | self.processes = [] 61 | self.check_id = None 62 | self.is_checking = False 63 | 64 | self._check_processes() 65 | 66 | signals = ( 67 | ('NameOwnerChanged', 'org.freedesktop.DBus.{}', 68 | self.on_name_owner_changed), 69 | ) 70 | self.bus = dbus.SystemBus() 71 | self.core = self.bus.get_object('org.freedesktop.DBus', '/') 72 | for sig_name, interface, sig_handler in signals: 73 | self.bus.add_signal_receiver(sig_handler, sig_name) 74 | 75 | def shutdown(self, signal_number=None, frame=None): 76 | logger.info('Daemon.shutdown') 77 | for proc in self.processes: 78 | if proc.is_attached: 79 | proc.detach() 80 | self.processes = [] 81 | 82 | def on_name_owner_changed(self, name, new_owner, old_owner): 83 | if not self.is_checking: 84 | if self.check_id: 85 | GObject.source_remove(self.check_id) 86 | self.check_id = GObject.timeout_add( 87 | 3000, self._check_processes) 88 | 89 | def _check_processes(self): 90 | self.is_checking = True 91 | self.check_id = None 92 | logger.info('Checking pulseaudio processes ...') 93 | 94 | procs = PulseAudioFinder.get_processes() 95 | for proc in procs: 96 | if proc not in self.processes: 97 | logger.info('Adding pulseaudio process ({})'.format(proc.pid)) 98 | self.processes.append(proc) 99 | 100 | gone, alive = psutil.wait_procs(self.processes, timeout=2) 101 | for proc in gone: 102 | if proc.is_attached: 103 | proc.detach() 104 | logger.info('Removing pulseaudio process ({})'.format(proc.pid)) 105 | self.processes.remove(proc) 106 | for proc in alive: 107 | if not proc.is_attached and not proc.disabled: 108 | proc.attach() 109 | 110 | self.is_checking = False 111 | return False 112 | 113 | def run(self): 114 | try: 115 | self.mainloop.run() 116 | except KeyboardInterrupt: 117 | self.shutdown() 118 | 119 | 120 | @functools.total_ordering 121 | class PulseAudioProcess(psutil.Process): 122 | 123 | DISPLAY_MANAGERS = ['gdm', 'lightdm', 'kdm', None] 124 | UID_MIN = 500 125 | 126 | def __init__(self, *args, **kwargs): 127 | psutil.Process.__init__(*args, **kwargs) 128 | self.application = None 129 | self.disabled = False 130 | 131 | @property 132 | def env(self): 133 | return self._get_proc_env(self.pid) 134 | 135 | @property 136 | def compressed_env(self): 137 | env = {} 138 | if self.env: 139 | for k in REQUIRED_ENVIRONMENT_VARS: 140 | if k in self.env: 141 | env[k] = self.env[k] 142 | return env 143 | 144 | @property 145 | def uid(self): 146 | return self.uids()[0] 147 | 148 | @property 149 | def gid(self): 150 | return self.gids()[0] 151 | 152 | @property 153 | def is_attached(self): 154 | if self.application: 155 | if self.application.poll() is None: 156 | return True 157 | return False 158 | 159 | def attach(self): 160 | 161 | if not self._is_pulseaudio_user_process(): 162 | self.disabled = True 163 | logger.info('Ignoring pulseaudio process ({pid})!'.format( 164 | pid=self.pid)) 165 | return 166 | 167 | logger.info('Attaching application to pulseaudio ({pid})'.format( 168 | pid=self.pid)) 169 | 170 | proc_env = self.env 171 | if not proc_env: 172 | logger.error( 173 | 'Could not get the environment of pulseaudio ({pid}). ' 174 | 'Aborting.'.format(pid=self.pid)) 175 | return 176 | 177 | missing_env = missing_env_vars(proc_env) 178 | if len(missing_env) > 0: 179 | logger.warning( 180 | 'The following environment variables were not set: "{}". ' 181 | 'Starting as root may not work!'.format(','.join(missing_env))) 182 | 183 | try: 184 | self.application = ( 185 | pulseaudio_dlna.utils.subprocess.GobjectSubprocess( 186 | sys.argv, 187 | uid=self.uid, 188 | gid=self.gid, 189 | env=self.compressed_env, 190 | cwd=os.getcwd())) 191 | except OSError as e: 192 | self.application = None 193 | self.disabled = True 194 | logger.error( 195 | 'Could not attach to pulseaudio ({pid}) - {msg}!'.format( 196 | pid=self.pid, msg=e)) 197 | 198 | def detach(self): 199 | app_pid = self.application.pid 200 | if app_pid: 201 | logger.info('Detaching application ({app_pid}) from ' 202 | 'pulseaudio ({pid})'.format( 203 | pid=self.pid, app_pid=app_pid)) 204 | self._kill_process_tree(app_pid) 205 | self.application = None 206 | 207 | def _is_pulseaudio_user_process(self): 208 | return (self.uid >= self.UID_MIN and 209 | self._get_uid_name(self.uid) not in self.DISPLAY_MANAGERS) 210 | 211 | def _kill_process_tree(self, pid, timeout=3): 212 | try: 213 | p = psutil.Process(pid) 214 | for child in p.children(): 215 | self._kill_process_tree(child.pid) 216 | p.send_signal(signal.SIGTERM) 217 | p.wait(timeout=timeout) 218 | except psutil.TimeoutExpired: 219 | logger.info( 220 | 'Process {} did not exit, sending SIGKILL ...'.format(pid)) 221 | p.kill() 222 | except psutil.NoSuchProcess: 223 | logger.info('Process {} has exited.'.format(pid)) 224 | 225 | def _get_uid_name(self, uid): 226 | try: 227 | return pwd.getpwuid(uid).pw_name 228 | except KeyError: 229 | return None 230 | 231 | def _get_proc_env(self, pid): 232 | env = {} 233 | location = '/proc/{pid}/environ'.format(pid=pid) 234 | try: 235 | with open(location) as f: 236 | content = f.read() 237 | for line in content.split('\0'): 238 | try: 239 | key, value = line.split('=', 1) 240 | env[key] = value 241 | except ValueError: 242 | pass 243 | return env 244 | except IOError: 245 | return None 246 | 247 | def __eq__(self, other): 248 | return self.pid == other.pid 249 | 250 | def __gt__(self, other): 251 | return self.pid > other.pid 252 | 253 | 254 | class PulseAudioFinder(object): 255 | @staticmethod 256 | def get_processes(): 257 | processes = [] 258 | try: 259 | for proc in psutil.process_iter(): 260 | if proc.name() == 'pulseaudio': 261 | proc.__class__ = PulseAudioProcess 262 | if not hasattr(proc, 'application'): 263 | proc.application = None 264 | if not hasattr(proc, 'disabled'): 265 | proc.disabled = False 266 | processes.append(proc) 267 | except psutil.NoSuchProcess: 268 | pass 269 | return processes 270 | -------------------------------------------------------------------------------- /pulseaudio_dlna/encoders/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | # This file is part of pulseaudio-dlna. 4 | 5 | # pulseaudio-dlna is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | 10 | # pulseaudio-dlna is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU General Public License for more details. 14 | 15 | # You should have received a copy of the GNU General Public License 16 | # along with pulseaudio-dlna. If not, see . 17 | 18 | from __future__ import unicode_literals 19 | 20 | import distutils.spawn 21 | import inspect 22 | import sys 23 | import logging 24 | 25 | logger = logging.getLogger('pulseaudio_dlna.encoder') 26 | 27 | ENCODERS = [] 28 | 29 | 30 | class InvalidBitrateException(Exception): 31 | def __init__(self, bit_rate): 32 | Exception.__init__( 33 | self, 34 | 'You specified an invalid bit rate "{}"!'.format(bit_rate), 35 | ) 36 | 37 | 38 | class UnsupportedBitrateException(Exception): 39 | def __init__(self, bit_rate, cls): 40 | Exception.__init__( 41 | self, 42 | 'You specified an unsupported bit rate for the {encoder}! ' 43 | 'Supported bit rates are "{bit_rates}"! '.format( 44 | encoder=cls.__name__, 45 | bit_rates=','.join( 46 | str(e) for e in cls.SUPPORTED_BIT_RATES 47 | ) 48 | ) 49 | ) 50 | 51 | 52 | def set_bit_rate(bit_rate): 53 | try: 54 | bit_rate = int(bit_rate) 55 | except ValueError: 56 | raise InvalidBitrateException(bit_rate) 57 | 58 | for _type in ENCODERS: 59 | if hasattr(_type, 'DEFAULT_BIT_RATE') and \ 60 | hasattr(_type, 'SUPPORTED_BIT_RATES'): 61 | if bit_rate in _type.SUPPORTED_BIT_RATES: 62 | _type.DEFAULT_BIT_RATE = bit_rate 63 | 64 | 65 | def _find_executable(path): 66 | # The distutils module uses python's ascii default encoding and is 67 | # therefore not capable of handling unicode properly when it contains 68 | # non-ascii characters. 69 | encoding = 'utf-8' 70 | result = distutils.spawn.find_executable(path.encode(encoding)) 71 | if result is not None and type(result) is str: 72 | result = result.decode(encoding) 73 | return result 74 | 75 | 76 | class BaseEncoder(object): 77 | 78 | AVAILABLE = True 79 | 80 | def __init__(self): 81 | self._binary = None 82 | self._command = [] 83 | self._bit_rate = None 84 | self._writes_header = False 85 | 86 | @property 87 | def binary(self): 88 | return self._binary 89 | 90 | @property 91 | def command(self): 92 | return [self.binary] + self._command 93 | 94 | @property 95 | def available(self): 96 | return type(self).AVAILABLE 97 | 98 | @property 99 | def writes_header(self): 100 | return self._writes_header 101 | 102 | def validate(self): 103 | if not type(self).AVAILABLE: 104 | result = _find_executable(self.binary) 105 | if result is not None and result.endswith(self.binary): 106 | type(self).AVAILABLE = True 107 | return type(self).AVAILABLE 108 | 109 | @property 110 | def supported_bit_rates(self): 111 | raise UnsupportedBitrateException() 112 | 113 | def __str__(self): 114 | return '<{} available="{}">'.format( 115 | self.__class__.__name__, 116 | unicode(self.available), 117 | ) 118 | 119 | 120 | class BitRateMixin(object): 121 | 122 | DEFAULT_BIT_RATE = 192 123 | 124 | @property 125 | def bit_rate(self): 126 | return self._bit_rate 127 | 128 | @bit_rate.setter 129 | def bit_rate(self, value): 130 | if int(value) in self.SUPPORTED_BIT_RATES: 131 | self._bit_rate = value 132 | else: 133 | raise UnsupportedBitrateException() 134 | 135 | @property 136 | def supported_bit_rates(self): 137 | return self.SUPPORTED_BIT_RATES 138 | 139 | def __str__(self): 140 | return '<{} available="{}" bit-rate="{}">'.format( 141 | self.__class__.__name__, 142 | unicode(self.available), 143 | unicode(self.bit_rate), 144 | ) 145 | 146 | 147 | class SamplerateChannelMixin(object): 148 | 149 | @property 150 | def sample_rate(self): 151 | return self._sample_rate 152 | 153 | @sample_rate.setter 154 | def sample_rate(self, value): 155 | self._sample_rate = int(value) 156 | 157 | @property 158 | def channels(self): 159 | return self._channels 160 | 161 | @channels.setter 162 | def channels(self, value): 163 | self._channels = int(value) 164 | 165 | def __str__(self): 166 | return '<{} available="{}" sample-rate="{}" channels="{}">'.format( 167 | self.__class__.__name__, 168 | unicode(self.available), 169 | unicode(self.sample_rate), 170 | unicode(self.channels), 171 | ) 172 | 173 | 174 | class NullEncoder(BaseEncoder): 175 | 176 | def __init__(self, *args, **kwargs): 177 | BaseEncoder.__init__(self) 178 | self._binary = 'cat' 179 | self._command = [] 180 | 181 | 182 | from pulseaudio_dlna.encoders.generic import * 183 | from pulseaudio_dlna.encoders.ffmpeg import * 184 | from pulseaudio_dlna.encoders.avconv import * 185 | 186 | 187 | def load_encoders(): 188 | if len(ENCODERS) == 0: 189 | logger.debug('Loaded encoders:') 190 | for name, _type in inspect.getmembers(sys.modules[__name__]): 191 | if inspect.isclass(_type) and issubclass(_type, BaseEncoder): 192 | if _type is not BaseEncoder: 193 | logger.debug(' {}'.format(_type)) 194 | ENCODERS.append(_type) 195 | return None 196 | 197 | load_encoders() 198 | -------------------------------------------------------------------------------- /pulseaudio_dlna/encoders/avconv.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | # This file is part of pulseaudio-dlna. 4 | 5 | # pulseaudio-dlna is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | 10 | # pulseaudio-dlna is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU General Public License for more details. 14 | 15 | # You should have received a copy of the GNU General Public License 16 | # along with pulseaudio-dlna. If not, see . 17 | 18 | from __future__ import unicode_literals 19 | 20 | import logging 21 | 22 | from pulseaudio_dlna.encoders.ffmpeg import ( 23 | FFMpegMp3Encoder, FFMpegWavEncoder, FFMpegL16Encoder, FFMpegAacEncoder, 24 | FFMpegOggEncoder, FFMpegFlacEncoder, FFMpegOpusEncoder) 25 | 26 | logger = logging.getLogger('pulseaudio_dlna.encoder.avconv') 27 | 28 | 29 | class AVConvMp3Encoder(FFMpegMp3Encoder): 30 | 31 | def __init__(self, bit_rate=None): 32 | super(AVConvMp3Encoder, self).__init__(bit_rate=bit_rate) 33 | self._binary = 'avconv' 34 | 35 | 36 | class AVConvWavEncoder(FFMpegWavEncoder): 37 | 38 | def __init__(self, bit_rate=None): 39 | super(AVConvWavEncoder, self).__init__() 40 | self._binary = 'avconv' 41 | 42 | 43 | class AVConvL16Encoder(FFMpegL16Encoder): 44 | 45 | def __init__(self, sample_rate=None, channels=None): 46 | super(AVConvL16Encoder, self).__init__( 47 | sample_rate=sample_rate, channels=channels) 48 | self._binary = 'avconv' 49 | 50 | 51 | class AVConvAacEncoder(FFMpegAacEncoder): 52 | 53 | def __init__(self, bit_rate=None): 54 | super(AVConvAacEncoder, self).__init__(bit_rate=bit_rate) 55 | self._binary = 'avconv' 56 | 57 | 58 | class AVConvOggEncoder(FFMpegOggEncoder): 59 | 60 | def __init__(self, bit_rate=None): 61 | super(AVConvOggEncoder, self).__init__(bit_rate=bit_rate) 62 | self._binary = 'avconv' 63 | 64 | 65 | class AVConvFlacEncoder(FFMpegFlacEncoder): 66 | 67 | def __init__(self, bit_rate=None): 68 | super(AVConvFlacEncoder, self).__init__() 69 | self._binary = 'avconv' 70 | 71 | 72 | class AVConvOpusEncoder(FFMpegOpusEncoder): 73 | 74 | def __init__(self, bit_rate=None): 75 | super(AVConvOpusEncoder, self).__init__(bit_rate=bit_rate) 76 | self._binary = 'avconv' 77 | -------------------------------------------------------------------------------- /pulseaudio_dlna/encoders/ffmpeg.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | # This file is part of pulseaudio-dlna. 4 | 5 | # pulseaudio-dlna is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | 10 | # pulseaudio-dlna is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU General Public License for more details. 14 | 15 | # You should have received a copy of the GNU General Public License 16 | # along with pulseaudio-dlna. If not, see . 17 | 18 | from __future__ import unicode_literals 19 | 20 | import logging 21 | 22 | from pulseaudio_dlna.encoders import ( 23 | BitRateMixin, SamplerateChannelMixin, BaseEncoder) 24 | 25 | logger = logging.getLogger('pulseaudio_dlna.encoder.ffmpeg') 26 | 27 | 28 | class FFMpegMixin(object): 29 | 30 | def _ffmpeg_command( 31 | self, format, bit_rate=None, sample_rate=None, channels=None): 32 | command = [ 33 | '-loglevel', 'panic', 34 | ] 35 | command.extend([ 36 | '-ac', '2', 37 | '-ar', '44100', 38 | '-f', 's16le', 39 | '-i', '-', 40 | ]) 41 | command.extend([ 42 | '-strict', '-2', 43 | '-f', format, 44 | ]) 45 | if bit_rate: 46 | command.extend(['-b:a', str(bit_rate) + 'k']) 47 | if sample_rate: 48 | command.extend(['-ar', str(sample_rate)]) 49 | if channels: 50 | command.extend(['-ac', str(channels)]) 51 | command.append('pipe:') 52 | return command 53 | 54 | 55 | class FFMpegMp3Encoder(BitRateMixin, FFMpegMixin, BaseEncoder): 56 | 57 | SUPPORTED_BIT_RATES = [32, 40, 48, 56, 64, 80, 96, 112, 58 | 128, 160, 192, 224, 256, 320] 59 | 60 | def __init__(self, bit_rate=None): 61 | BaseEncoder.__init__(self) 62 | self.bit_rate = bit_rate or FFMpegMp3Encoder.DEFAULT_BIT_RATE 63 | 64 | self._writes_header = True 65 | self._binary = 'ffmpeg' 66 | self._command = self._ffmpeg_command('mp3', bit_rate=self.bit_rate) 67 | 68 | 69 | class FFMpegWavEncoder(FFMpegMixin, BaseEncoder): 70 | 71 | def __init__(self): 72 | BaseEncoder.__init__(self) 73 | 74 | self._writes_header = True 75 | self._binary = 'ffmpeg' 76 | self._command = self._ffmpeg_command('wav') 77 | 78 | 79 | class FFMpegL16Encoder(SamplerateChannelMixin, FFMpegMixin, BaseEncoder): 80 | def __init__(self, sample_rate=None, channels=None): 81 | BaseEncoder.__init__(self) 82 | self.sample_rate = sample_rate or 44100 83 | self.channels = channels or 2 84 | 85 | self._writes_header = None 86 | self._binary = 'ffmpeg' 87 | self._command = self._ffmpeg_command( 88 | 's16be', sample_rate=self.sample_rate, channels=self.channels) 89 | 90 | 91 | class FFMpegAacEncoder(BitRateMixin, FFMpegMixin, BaseEncoder): 92 | 93 | SUPPORTED_BIT_RATES = [32, 40, 48, 56, 64, 80, 96, 112, 94 | 128, 160, 192, 224, 256, 320] 95 | 96 | def __init__(self, bit_rate=None): 97 | BaseEncoder.__init__(self) 98 | self.bit_rate = bit_rate or FFMpegAacEncoder.DEFAULT_BIT_RATE 99 | 100 | self._writes_header = False 101 | self._binary = 'ffmpeg' 102 | self._command = self._ffmpeg_command('adts', bit_rate=self.bit_rate) 103 | 104 | 105 | class FFMpegOggEncoder(BitRateMixin, FFMpegMixin, BaseEncoder): 106 | 107 | SUPPORTED_BIT_RATES = [32, 40, 48, 56, 64, 80, 96, 112, 108 | 128, 160, 192, 224, 256, 320] 109 | 110 | def __init__(self, bit_rate=None): 111 | BaseEncoder.__init__(self) 112 | self.bit_rate = bit_rate or FFMpegOggEncoder.DEFAULT_BIT_RATE 113 | 114 | self._writes_header = True 115 | self._binary = 'ffmpeg' 116 | self._command = self._ffmpeg_command('ogg', bit_rate=self.bit_rate) 117 | 118 | 119 | class FFMpegFlacEncoder(FFMpegMixin, BaseEncoder): 120 | 121 | def __init__(self): 122 | BaseEncoder.__init__(self) 123 | 124 | self._writes_header = True 125 | self._binary = 'ffmpeg' 126 | self._command = self._ffmpeg_command('flac') 127 | 128 | 129 | class FFMpegOpusEncoder(BitRateMixin, FFMpegMixin, BaseEncoder): 130 | 131 | SUPPORTED_BIT_RATES = [i for i in range(6, 257)] 132 | 133 | def __init__(self, bit_rate=None): 134 | BaseEncoder.__init__(self) 135 | self.bit_rate = bit_rate or FFMpegOpusEncoder.DEFAULT_BIT_RATE 136 | 137 | self._writes_header = True 138 | self._binary = 'ffmpeg' 139 | self._command = self._ffmpeg_command('opus', bit_rate=self.bit_rate) 140 | -------------------------------------------------------------------------------- /pulseaudio_dlna/encoders/generic.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | # This file is part of pulseaudio-dlna. 4 | 5 | # pulseaudio-dlna is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | 10 | # pulseaudio-dlna is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU General Public License for more details. 14 | 15 | # You should have received a copy of the GNU General Public License 16 | # along with pulseaudio-dlna. If not, see . 17 | 18 | from __future__ import unicode_literals 19 | 20 | import logging 21 | 22 | from pulseaudio_dlna.encoders import ( 23 | BitRateMixin, SamplerateChannelMixin, BaseEncoder) 24 | 25 | logger = logging.getLogger('pulseaudio_dlna.encoder.generic') 26 | 27 | 28 | class LameMp3Encoder(BitRateMixin, BaseEncoder): 29 | 30 | SUPPORTED_BIT_RATES = [32, 40, 48, 56, 64, 80, 96, 112, 31 | 128, 160, 192, 224, 256, 320] 32 | 33 | def __init__(self, bit_rate=None): 34 | BaseEncoder.__init__(self) 35 | self.bit_rate = bit_rate or LameMp3Encoder.DEFAULT_BIT_RATE 36 | 37 | self._writes_header = False 38 | self._binary = 'lame' 39 | self._command = ['-b', str(self.bit_rate), '-r', '-'] 40 | 41 | 42 | class SoxWavEncoder(BaseEncoder): 43 | def __init__(self): 44 | BaseEncoder.__init__(self) 45 | 46 | self._writes_header = True 47 | self._binary = 'sox' 48 | self._command = ['-t', 'raw', '-b', '16', '-e', 'signed', '-c', '2', 49 | '-r', '44100', '-', 50 | '-t', 'wav', '-b', '16', '-e', 'signed', '-c', '2', 51 | '-r', '44100', 52 | '-L', '-', 53 | ] 54 | 55 | 56 | class SoxL16Encoder(SamplerateChannelMixin, BaseEncoder): 57 | def __init__(self, sample_rate=None, channels=None): 58 | BaseEncoder.__init__(self) 59 | self.sample_rate = sample_rate or 44100 60 | self.channels = channels or 2 61 | 62 | self._writes_header = True 63 | self._binary = 'sox' 64 | self._command = ['-t', 'raw', '-b', '16', '-e', 'signed', '-c', '2', 65 | '-r', '44100', '-', 66 | '-t', 'wav', '-b', '16', '-e', 'signed', 67 | '-c', str(self.channels), 68 | '-r', '44100', 69 | '-B', '-', 70 | 'rate', str(self.sample_rate), 71 | ] 72 | 73 | 74 | class FaacAacEncoder(BitRateMixin, BaseEncoder): 75 | 76 | SUPPORTED_BIT_RATES = [32, 40, 48, 56, 64, 80, 96, 112, 77 | 128, 160, 192, 224, 256, 320] 78 | 79 | def __init__(self, bit_rate=None): 80 | BaseEncoder.__init__(self) 81 | self.bit_rate = bit_rate or FaacAacEncoder.DEFAULT_BIT_RATE 82 | 83 | self._writes_header = None 84 | self._binary = 'faac' 85 | self._command = ['-b', str(self.bit_rate), 86 | '-X', '-P', '-o', '-', '-'] 87 | 88 | 89 | class OggencOggEncoder(BitRateMixin, BaseEncoder): 90 | 91 | SUPPORTED_BIT_RATES = [32, 40, 48, 56, 64, 80, 96, 112, 92 | 128, 160, 192, 224, 256, 320] 93 | 94 | def __init__(self, bit_rate=None): 95 | BaseEncoder.__init__(self) 96 | self.bit_rate = bit_rate or OggencOggEncoder.DEFAULT_BIT_RATE 97 | 98 | self._writes_header = True 99 | self._binary = 'oggenc' 100 | self._command = ['-b', str(self.bit_rate), 101 | '-Q', '-r', '--ignorelength', '-'] 102 | 103 | 104 | class FlacFlacEncoder(BaseEncoder): 105 | 106 | def __init__(self, bit_rate=None): 107 | BaseEncoder.__init__(self) 108 | 109 | self._writes_header = True 110 | self._binary = 'flac' 111 | self._command = ['-', '-c', '--channels', '2', '--bps', '16', 112 | '--sample-rate', '44100', 113 | '--endian', 'little', '--sign', 'signed', '-s'] 114 | 115 | 116 | class OpusencOpusEncoder(BitRateMixin, BaseEncoder): 117 | 118 | SUPPORTED_BIT_RATES = [i for i in range(6, 257)] 119 | 120 | def __init__(self, bit_rate=None): 121 | BaseEncoder.__init__(self) 122 | self.bit_rate = bit_rate or OpusencOpusEncoder.DEFAULT_BIT_RATE 123 | 124 | self._writes_header = True 125 | self._binary = 'opusenc' 126 | self._command = ['--bitrate', str(self.bit_rate), 127 | '--padding', '0', '--max-delay', '0', 128 | '--expect-loss', '1', '--framesize', '2.5', 129 | '--raw-rate', '44100', 130 | '--raw', '-', '-'] 131 | -------------------------------------------------------------------------------- /pulseaudio_dlna/holder.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | # This file is part of pulseaudio-dlna. 4 | 5 | # pulseaudio-dlna is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | 10 | # pulseaudio-dlna is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU General Public License for more details. 14 | 15 | # You should have received a copy of the GNU General Public License 16 | # along with pulseaudio-dlna. If not, see . 17 | 18 | from __future__ import unicode_literals 19 | 20 | import logging 21 | import threading 22 | import requests 23 | import traceback 24 | import setproctitle 25 | import signal 26 | import time 27 | 28 | logger = logging.getLogger('pulseaudio_dlna.holder') 29 | 30 | 31 | class Holder(object): 32 | def __init__( 33 | self, plugins, 34 | pulse_queue=None, device_filter=None, device_config=None, 35 | proc_title=None): 36 | self.plugins = plugins 37 | self.device_filter = device_filter or None 38 | self.device_config = device_config or {} 39 | self.pulse_queue = pulse_queue 40 | self.devices = {} 41 | self.proc_title = proc_title 42 | self.lock = threading.Lock() 43 | self.__running = True 44 | 45 | def initialize(self): 46 | signal.signal(signal.SIGTERM, self.shutdown) 47 | if self.proc_title: 48 | setproctitle.setproctitle(self.proc_title) 49 | 50 | def shutdown(self, *args): 51 | if self.__running: 52 | logger.info('Holder.shutdown()') 53 | self.__running = False 54 | 55 | def search(self, ttl=None, host=None): 56 | self.initialize() 57 | threads = [] 58 | for plugin in self.plugins: 59 | thread = threading.Thread( 60 | target=plugin.discover, args=[self], 61 | kwargs={'ttl': ttl, 'host': host}) 62 | thread.daemon = True 63 | threads.append(thread) 64 | try: 65 | for thread in threads: 66 | thread.start() 67 | while self.__running: 68 | all_dead = True 69 | time.sleep(0.1) 70 | for thread in threads: 71 | if thread.is_alive(): 72 | all_dead = False 73 | break 74 | if all_dead: 75 | break 76 | except: 77 | traceback.print_exc() 78 | logger.info('Holder.search()') 79 | 80 | def lookup(self, locations): 81 | self.initialize() 82 | xmls = {} 83 | for url in locations: 84 | try: 85 | response = requests.get(url, timeout=5) 86 | logger.debug('Response from device ({url})\n{response}'.format( 87 | url=url, response=response.text)) 88 | xmls[url] = response.content 89 | except requests.exceptions.Timeout: 90 | logger.warning( 91 | 'Could no connect to {url}. ' 92 | 'Connection timeout.'.format(url=url)) 93 | except requests.exceptions.ConnectionError: 94 | logger.warning( 95 | 'Could no connect to {url}. ' 96 | 'Connection refused.'.format(url=url)) 97 | 98 | for plugin in self.plugins: 99 | for url, xml in xmls.items(): 100 | device = plugin.lookup(url, xml) 101 | self.add_device(device) 102 | 103 | def add_device(self, device): 104 | if not device: 105 | return 106 | try: 107 | self.lock.acquire() 108 | if device.udn not in self.devices: 109 | if device.validate(): 110 | config = self.device_config.get(device.udn, None) 111 | device.activate(config) 112 | if not self.device_filter or \ 113 | device.name in self.device_filter: 114 | if config: 115 | logger.info( 116 | 'Using device configuration:\n{}'.format( 117 | device.__str__(True))) 118 | self.devices[device.udn] = device 119 | self._send_message('add_device', device) 120 | else: 121 | logger.info('Skipped the device "{name}" ...'.format( 122 | name=device.label)) 123 | else: 124 | if device.validate(): 125 | self._send_message('update_device', device) 126 | finally: 127 | self.lock.release() 128 | 129 | def remove_device(self, device_id): 130 | if not device_id or device_id not in self.devices: 131 | return 132 | try: 133 | self.lock.acquire() 134 | device = self.devices[device_id] 135 | self._send_message('remove_device', device) 136 | del self.devices[device_id] 137 | finally: 138 | self.lock.release() 139 | 140 | def _send_message(self, _type, device): 141 | if self.pulse_queue: 142 | self.pulse_queue.put({ 143 | 'type': _type, 144 | 'device': device 145 | }) 146 | -------------------------------------------------------------------------------- /pulseaudio_dlna/images.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | # This file is part of pulseaudio-dlna. 4 | 5 | # pulseaudio-dlna is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | 10 | # pulseaudio-dlna is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU General Public License for more details. 14 | 15 | # You should have received a copy of the GNU General Public License 16 | # along with pulseaudio-dlna. If not, see . 17 | 18 | from __future__ import unicode_literals 19 | from __future__ import with_statement 20 | 21 | import tempfile 22 | import logging 23 | import gi 24 | 25 | logger = logging.getLogger('pulseaudio_dlna.images') 26 | 27 | 28 | class UnknownImageExtension(Exception): 29 | def __init__(self, path): 30 | Exception.__init__( 31 | self, 32 | 'The file "{}" has an unsupported file extension!'.format(path) 33 | ) 34 | 35 | 36 | class ImageNotAccessible(Exception): 37 | def __init__(self, path): 38 | Exception.__init__( 39 | self, 40 | 'The file "{}" is not accessible!'.format(path) 41 | ) 42 | 43 | 44 | class IconNotFound(Exception): 45 | def __init__(self, icon_name): 46 | Exception.__init__( 47 | self, 48 | 'The icon "{}" could not be found!'.format(icon_name) 49 | ) 50 | 51 | 52 | class MissingDependencies(Exception): 53 | def __init__(self, message, dependencies): 54 | Exception.__init__( 55 | self, 56 | '{} - Could not load one of following modules "{}"!'.format( 57 | message, ','.join(dependencies)) 58 | ) 59 | 60 | 61 | def get_icon_by_name(name, size=256): 62 | try: 63 | gi.require_version('Gtk', '3.0') 64 | from gi.repository import Gtk 65 | except: 66 | raise MissingDependencies( 67 | 'Unable to lookup system icons!', 68 | ['gir1.2-gtk-3.0'] 69 | ) 70 | 71 | icon_theme = Gtk.IconTheme.get_default() 72 | icon = icon_theme.lookup_icon(name, size, 0) 73 | if icon: 74 | file_path = icon.get_filename() 75 | _type = get_type_by_filepath(file_path) 76 | return _type(file_path) 77 | else: 78 | raise IconNotFound(name) 79 | 80 | 81 | def get_type_by_filepath(path): 82 | if path.endswith('.png'): 83 | return PngImage 84 | elif path.endswith('.jpg'): 85 | return JpgImage 86 | elif path.endswith('.svg'): 87 | return SvgPngImage 88 | raise UnknownImageExtension(path) 89 | 90 | 91 | class BaseImage(object): 92 | def __init__(self, path, cached=True): 93 | self.path = path 94 | self.content_type = None 95 | self.cached = cached 96 | 97 | if self.cached: 98 | self._read_data() 99 | 100 | def _read_data(self): 101 | try: 102 | with open(self.path) as h: 103 | self._data = h.read() 104 | except EnvironmentError: 105 | raise ImageNotAccessible(self.path) 106 | 107 | @property 108 | def data(self): 109 | if self.cached: 110 | return self._data 111 | else: 112 | return self._read_data() 113 | 114 | 115 | class PngImage(BaseImage): 116 | def __init__(self, path, cached=True): 117 | BaseImage.__init__(self, path, cached) 118 | self.content_type = 'image/png' 119 | 120 | 121 | class SvgPngImage(BaseImage): 122 | def __init__(self, path, cached=True, size=256): 123 | try: 124 | gi.require_version('Rsvg', '2.0') 125 | from gi.repository import Rsvg 126 | except: 127 | raise MissingDependencies( 128 | 'Unable to convert SVG image to PNG!', ['gir1.2-rsvg-2.0'] 129 | ) 130 | try: 131 | import cairo 132 | except: 133 | raise MissingDependencies( 134 | 'Unable to convert SVG image to PNG!', ['cairo'] 135 | ) 136 | 137 | tmp_file = tempfile.NamedTemporaryFile() 138 | image_surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, size, size) 139 | rsvg_handle = Rsvg.Handle.new_from_file(path) 140 | 141 | context = cairo.Context(image_surface) 142 | context.scale( 143 | float(size) / rsvg_handle.props.height, 144 | float(size) / rsvg_handle.props.width 145 | ) 146 | rsvg_handle.render_cairo(context) 147 | image_surface.write_to_png(tmp_file.name) 148 | 149 | BaseImage.__init__(self, tmp_file.name, cached=True) 150 | 151 | 152 | class JpgImage(BaseImage): 153 | def __init__(self, path, cached=True): 154 | BaseImage.__init__(self, path, cached) 155 | self.content_type = 'image/jpeg' 156 | -------------------------------------------------------------------------------- /pulseaudio_dlna/images/default.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/masmu/pulseaudio-dlna/4472928dd23f274193f14289f59daec411023ab0/pulseaudio_dlna/images/default.png -------------------------------------------------------------------------------- /pulseaudio_dlna/images/distribution-debian.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/masmu/pulseaudio-dlna/4472928dd23f274193f14289f59daec411023ab0/pulseaudio_dlna/images/distribution-debian.png -------------------------------------------------------------------------------- /pulseaudio_dlna/images/distribution-fedora.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/masmu/pulseaudio-dlna/4472928dd23f274193f14289f59daec411023ab0/pulseaudio_dlna/images/distribution-fedora.png -------------------------------------------------------------------------------- /pulseaudio_dlna/images/distribution-gentoo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/masmu/pulseaudio-dlna/4472928dd23f274193f14289f59daec411023ab0/pulseaudio_dlna/images/distribution-gentoo.png -------------------------------------------------------------------------------- /pulseaudio_dlna/images/distribution-linuxmint.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/masmu/pulseaudio-dlna/4472928dd23f274193f14289f59daec411023ab0/pulseaudio_dlna/images/distribution-linuxmint.png -------------------------------------------------------------------------------- /pulseaudio_dlna/images/distribution-opensuse.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/masmu/pulseaudio-dlna/4472928dd23f274193f14289f59daec411023ab0/pulseaudio_dlna/images/distribution-opensuse.png -------------------------------------------------------------------------------- /pulseaudio_dlna/images/distribution-ubuntu.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/masmu/pulseaudio-dlna/4472928dd23f274193f14289f59daec411023ab0/pulseaudio_dlna/images/distribution-ubuntu.png -------------------------------------------------------------------------------- /pulseaudio_dlna/images/distribution-unknown.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/masmu/pulseaudio-dlna/4472928dd23f274193f14289f59daec411023ab0/pulseaudio_dlna/images/distribution-unknown.png -------------------------------------------------------------------------------- /pulseaudio_dlna/notification.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | # This file is part of pulseaudio-dlna. 4 | 5 | # pulseaudio-dlna is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | 10 | # pulseaudio-dlna is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU General Public License for more details. 14 | 15 | # You should have received a copy of the GNU General Public License 16 | # along with pulseaudio-dlna. If not, see . 17 | 18 | from __future__ import unicode_literals 19 | 20 | import logging 21 | 22 | import notify2 23 | 24 | logger = logging.getLogger('pulseaudio_dlna.notification') 25 | 26 | 27 | def show(title, message, icon=''): 28 | try: 29 | notice = notify2.Notification(title, message, icon) 30 | notice.set_timeout(notify2.EXPIRES_DEFAULT) 31 | notice.show() 32 | except: 33 | logger.info( 34 | 'notify2 failed to display: {title} - {message}'.format( 35 | title=title, 36 | message=message)) 37 | 38 | try: 39 | notify2.init('pulseaudio_dlna') 40 | except: 41 | logger.error('notify2 could not be initialized! Notifications will ' 42 | 'most likely not work.') 43 | -------------------------------------------------------------------------------- /pulseaudio_dlna/plugins/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | # This file is part of pulseaudio-dlna. 4 | 5 | # pulseaudio-dlna is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | 10 | # pulseaudio-dlna is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU General Public License for more details. 14 | 15 | # You should have received a copy of the GNU General Public License 16 | # along with pulseaudio-dlna. If not, see . 17 | 18 | from __future__ import unicode_literals 19 | 20 | import functools 21 | 22 | 23 | class BasePlugin(object): 24 | def __init__(self): 25 | self.st_header = None 26 | self.holder = None 27 | 28 | def lookup(self, locations, data): 29 | raise NotImplementedError() 30 | 31 | def discover(self, ttl=None, host=None): 32 | raise NotImplementedError() 33 | 34 | @staticmethod 35 | def add_device_after(f, *args): 36 | @functools.wraps(f) 37 | def wrapper(*args, **kwargs): 38 | device = f(*args, **kwargs) 39 | self = args[0] 40 | if self.holder: 41 | self.holder.add_device(device) 42 | return device 43 | return wrapper 44 | 45 | @staticmethod 46 | def remove_device_after(f, *args): 47 | @functools.wraps(f) 48 | def wrapper(*args, **kwargs): 49 | device_id = f(*args, **kwargs) 50 | self = args[0] 51 | if self.holder: 52 | self.holder.remove_device(device_id) 53 | return device_id 54 | return wrapper 55 | -------------------------------------------------------------------------------- /pulseaudio_dlna/plugins/chromecast/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | # This file is part of pulseaudio-dlna. 4 | 5 | # pulseaudio-dlna is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | 10 | # pulseaudio-dlna is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU General Public License for more details. 14 | 15 | # You should have received a copy of the GNU General Public License 16 | # along with pulseaudio-dlna. If not, see . 17 | 18 | from __future__ import unicode_literals 19 | 20 | import logging 21 | 22 | import pulseaudio_dlna.plugins 23 | import pulseaudio_dlna.plugins.chromecast.mdns 24 | from pulseaudio_dlna.plugins.chromecast.renderer import ChromecastRendererFactory 25 | 26 | logger = logging.getLogger('pulseaudio_dlna.plugins.chromecast') 27 | 28 | 29 | class ChromecastPlugin(pulseaudio_dlna.plugins.BasePlugin): 30 | 31 | GOOGLE_MDNS_DOMAIN = '_googlecast._tcp.local.' 32 | 33 | def __init__(self, *args): 34 | pulseaudio_dlna.plugins.BasePlugin.__init__(self, *args) 35 | 36 | def lookup(self, url, xml): 37 | return ChromecastRendererFactory.from_xml(url, xml) 38 | 39 | def discover(self, holder, ttl=None, host=None): 40 | self.holder = holder 41 | mdns = pulseaudio_dlna.plugins.chromecast.mdns.MDNSListener( 42 | domain=self.GOOGLE_MDNS_DOMAIN, 43 | host=host, 44 | cb_on_device_added=self._on_device_added, 45 | cb_on_device_removed=self._on_device_removed 46 | ) 47 | mdns.run(ttl) 48 | 49 | @pulseaudio_dlna.plugins.BasePlugin.add_device_after 50 | def _on_device_added(self, mdns_info): 51 | if mdns_info: 52 | return ChromecastRendererFactory.from_mdns_info(mdns_info) 53 | return None 54 | 55 | @pulseaudio_dlna.plugins.BasePlugin.remove_device_after 56 | def _on_device_removed(self, mdns_info): 57 | return None 58 | -------------------------------------------------------------------------------- /pulseaudio_dlna/plugins/chromecast/mdns.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | # This file is part of pulseaudio-dlna. 4 | 5 | # pulseaudio-dlna is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | 10 | # pulseaudio-dlna is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU General Public License for more details. 14 | 15 | # You should have received a copy of the GNU General Public License 16 | # along with pulseaudio-dlna. If not, see . 17 | 18 | from __future__ import unicode_literals 19 | 20 | from gi.repository import GObject 21 | 22 | import logging 23 | import zeroconf 24 | import time 25 | 26 | logger = logging.getLogger('pulseaudio_dlna.plugins.chromecast.mdns') 27 | 28 | 29 | class MDNSHandler(object): 30 | 31 | def __init__(self, server): 32 | self.server = server 33 | 34 | def add_service(self, zeroconf, type, name): 35 | info = zeroconf.get_service_info(type, name) 36 | if self.server.cb_on_device_added: 37 | self.server.cb_on_device_added(info) 38 | 39 | def remove_service(self, zeroconf, type, name): 40 | info = zeroconf.get_service_info(type, name) 41 | if self.server.cb_on_device_removed: 42 | self.server.cb_on_device_removed(info) 43 | 44 | 45 | class MDNSListener(object): 46 | 47 | def __init__( 48 | self, domain, 49 | host=None, 50 | cb_on_device_added=None, cb_on_device_removed=None): 51 | self.domain = domain 52 | self.host = host 53 | self.cb_on_device_added = cb_on_device_added 54 | self.cb_on_device_removed = cb_on_device_removed 55 | 56 | def run(self, ttl=None): 57 | if self.host: 58 | self.zeroconf = zeroconf.Zeroconf(interfaces=[self.host]) 59 | else: 60 | self.zeroconf = zeroconf.Zeroconf() 61 | zeroconf.ServiceBrowser(self.zeroconf, self.domain, MDNSHandler(self)) 62 | 63 | if ttl: 64 | GObject.timeout_add(ttl * 1000, self.shutdown) 65 | 66 | self.__running = True 67 | self.__mainloop = GObject.MainLoop() 68 | context = self.__mainloop.get_context() 69 | try: 70 | while self.__running: 71 | if context.pending(): 72 | context.iteration(True) 73 | else: 74 | time.sleep(0.01) 75 | except KeyboardInterrupt: 76 | pass 77 | self.zeroconf.close() 78 | logger.info('MDNSListener.run()') 79 | 80 | def shutdown(self): 81 | logger.info('MDNSListener.shutdown()') 82 | self.__running = False 83 | -------------------------------------------------------------------------------- /pulseaudio_dlna/plugins/chromecast/pycastv2/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | # This file is part of pulseaudio-dlna. 4 | 5 | # pulseaudio-dlna is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | 10 | # pulseaudio-dlna is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU General Public License for more details. 14 | 15 | # You should have received a copy of the GNU General Public License 16 | # along with pulseaudio-dlna. If not, see . 17 | 18 | from __future__ import unicode_literals 19 | 20 | import time 21 | import logging 22 | 23 | import commands 24 | import cast_socket 25 | 26 | logger = logging.getLogger('pycastv2') 27 | 28 | 29 | class ChannelClosedException(Exception): 30 | pass 31 | 32 | 33 | class TimeoutException(Exception): 34 | pass 35 | 36 | 37 | class LaunchErrorException(Exception): 38 | pass 39 | 40 | 41 | class ChannelController(object): 42 | def __init__(self, socket): 43 | self.request_id = 1 44 | self.transport_id = 'receiver-0' 45 | self.session_id = None 46 | self.app_id = None 47 | 48 | self.channels = [] 49 | 50 | self.socket = socket 51 | self.socket.add_send_listener(self._handle_send) 52 | self.socket.add_read_listener(self._handle_response) 53 | self.socket.send_and_wait(commands.StatusCommand()) 54 | 55 | def _get_unused_request_id(self): 56 | self.request_id += 1 57 | return self.request_id - 1 58 | 59 | def _handle_send(self, command): 60 | if command.request_id is not None: 61 | command.request_id = (command.request_id or 62 | self._get_unused_request_id()) 63 | if command.session_id is not None: 64 | command.session_id = command.session_id or self.session_id 65 | command.sender_id = command.sender_id or 'sender-0' 66 | if command.destination_id is None: 67 | command.destination_id = 'receiver-0' 68 | else: 69 | command.destination_id = (command.destination_id or 70 | self.transport_id) 71 | if not self.is_channel_connected(command.destination_id): 72 | self.connect_channel(command.destination_id) 73 | return command 74 | 75 | def _handle_response(self, response): 76 | if 'type' in response: 77 | response_type = response['type'] 78 | if response_type == 'RECEIVER_STATUS': 79 | if 'applications' in response['status']: 80 | applications = response['status']['applications'][0] 81 | self.transport_id = ( 82 | applications.get('transportId') or self.transport_id) 83 | self.session_id = ( 84 | applications.get('sessionId') or self.session_id) 85 | self.app_id = ( 86 | applications.get('appId') or self.app_id) 87 | else: 88 | self.transport_id = 'receiver-0' 89 | self.session_id = None 90 | self.app_id = None 91 | elif response_type == 'PING': 92 | self.socket.send(commands.PongCommand()) 93 | elif response_type == 'CLOSE': 94 | raise ChannelClosedException() 95 | elif response_type == 'LAUNCH_ERROR': 96 | raise LaunchErrorException() 97 | 98 | def is_channel_connected(self, destination_id): 99 | return destination_id in self.channels 100 | 101 | def connect_channel(self, destination_id): 102 | self.channels.append(destination_id) 103 | self.socket.send(commands.ConnectCommand(destination_id)) 104 | 105 | def disconnect_channel(self, destination_id): 106 | self.socket.send(commands.CloseCommand(destination_id)) 107 | self.channels.remove(destination_id) 108 | 109 | def __str__(self): 110 | return ('\n' 111 | ' request_id: {request_id}\n' 112 | ' transport_id: {transport_id}\n' 113 | ' session_id: {session_id}\n' 114 | ' app_id: {app_id}'.format( 115 | request_id=self.request_id, 116 | transport_id=self.transport_id, 117 | session_id=self.session_id, 118 | app_id=self.app_id)) 119 | 120 | 121 | class ChromecastController(): 122 | 123 | APP_BACKDROP = 'E8C28D3C' 124 | WAIT_INTERVAL = 0.1 125 | 126 | def __init__(self, ip, port, timeout=10): 127 | self.timeout = timeout 128 | self.socket = cast_socket.CastSocket(ip, port) 129 | self.channel_controller = ChannelController(self.socket) 130 | 131 | def is_app_running(self, app_id): 132 | return self.channel_controller.app_id == app_id 133 | 134 | def launch_application(self, app_id): 135 | if not self.is_app_running(app_id): 136 | self.socket.send(commands.LaunchCommand(app_id)) 137 | start_time = time.time() 138 | while not self.is_app_running(app_id): 139 | self.socket.send_and_wait(commands.StatusCommand()) 140 | current_time = time.time() 141 | if current_time - start_time > self.timeout: 142 | raise TimeoutException() 143 | time.sleep(self.WAIT_INTERVAL) 144 | else: 145 | logger.debug('Starting not necessary. Application is running ...') 146 | 147 | def stop_application(self): 148 | if not self.is_app_running(self.APP_BACKDROP): 149 | self.socket.send(commands.StopCommand()) 150 | start_time = time.time() 151 | while not self.is_app_running(None): 152 | self.socket.send_and_wait(commands.StatusCommand()) 153 | current_time = time.time() 154 | if current_time - start_time > self.timeout: 155 | raise TimeoutException() 156 | time.sleep(self.WAIT_INTERVAL) 157 | else: 158 | logger.debug('Stop not necessary. Backdrop is running ...') 159 | 160 | def disconnect_application(self): 161 | if not self.is_app_running(self.APP_BACKDROP): 162 | self.socket.send(commands.CloseCommand(destination_id=False)) 163 | start_time = time.time() 164 | while not self.is_app_running(None): 165 | try: 166 | self.socket.send_and_wait(commands.StatusCommand()) 167 | except cast_socket.ConnectionTerminatedException: 168 | break 169 | current_time = time.time() 170 | if current_time - start_time > self.timeout: 171 | raise TimeoutException() 172 | time.sleep(self.WAIT_INTERVAL) 173 | else: 174 | logger.debug('Closing not necessary. Backdrop is running ...') 175 | 176 | def wait(self, timeout): 177 | self.socket.wait(timeout) 178 | 179 | def cleanup(self): 180 | self.socket.close() 181 | 182 | 183 | class LoadCommand(commands.BaseCommand): 184 | def __init__(self, url, mime_type, artist=None, title=None, thumb=None, 185 | session_id=None, destination_id=None, namespace=None): 186 | commands.BaseCommand.__init__(self) 187 | self.data = { 188 | 'autoplay': True, 189 | 'currentTime': 0, 190 | 'media': {'contentId': url, 191 | 'contentType': mime_type, 192 | 'streamType': 'LIVE', 193 | }, 194 | 'type': 'LOAD' 195 | } 196 | if artist or title or thumb: 197 | self.data['media']['metadata'] = { 198 | 'metadataType': 3, 199 | } 200 | if artist: 201 | self.data['media']['metadata']['artist'] = artist 202 | if title: 203 | self.data['media']['metadata']['title'] = title 204 | if thumb: 205 | self.data['media']['metadata']['images'] = [ 206 | {'url': thumb}, 207 | ] 208 | 209 | self.request_id = False 210 | self.session_id = False 211 | self.destination_id = destination_id 212 | self.namespace = namespace or 'urn:x-cast:com.google.cast.media' 213 | 214 | 215 | class LoadFailedException(Exception): 216 | pass 217 | 218 | 219 | class MediaPlayerController(ChromecastController): 220 | 221 | APP_MEDIA_PLAYER = 'CC1AD845' 222 | 223 | PLAYER_STATE_BUFFERING = 'BUFFERING' 224 | PLAYER_STATE_PLAYING = 'PLAYING' 225 | PLAYER_STATE_PAUSED = 'PAUSED' 226 | PLAYER_STATE_IDLE = 'IDLE' 227 | 228 | def __init__(self, ip, port, timeout=10): 229 | ChromecastController.__init__(self, ip, port, timeout) 230 | self._media_session_id = None 231 | self._current_time = None 232 | self._media = None 233 | self._playback_rate = None 234 | self._volume = None 235 | self._player_state = None 236 | 237 | self.socket.add_read_listener(self._handle_response) 238 | 239 | def launch(self): 240 | self.launch_application(self.APP_MEDIA_PLAYER) 241 | 242 | def load(self, url, mime_type, artist=None, title=None, thumb=None): 243 | self.launch() 244 | try: 245 | self.socket.send_and_wait( 246 | LoadCommand( 247 | url, mime_type, 248 | artist=artist, 249 | title=title, 250 | thumb=thumb, 251 | destination_id=False)) 252 | return True 253 | except (cast_socket.NoResponseException, LoadFailedException): 254 | return False 255 | 256 | def set_volume(self, volume): 257 | self.socket.send_and_wait(commands.SetVolumeCommand(volume)) 258 | 259 | def set_mute(self, muted): 260 | self.socket.send_and_wait(commands.SetVolumeMuteCommand(muted)) 261 | 262 | def _update_attribute(self, name, value): 263 | if value is not None: 264 | setattr(self, name, value) 265 | 266 | def _handle_response(self, response): 267 | if 'type' in response: 268 | if response['type'] == 'MEDIA_STATUS': 269 | try: 270 | status = response['status'][0] 271 | except IndexError: 272 | return 273 | self._update_attribute( 274 | '_media_session_id', status.get('mediaSessionId', None)) 275 | self._update_attribute( 276 | '_current_time', status.get('currentTime', None)) 277 | self._update_attribute( 278 | '_media', status.get('media', None)) 279 | self._update_attribute( 280 | '_playback_rate', status.get('playbackRate', None)) 281 | self._update_attribute( 282 | '_volume', status.get('volume', None)) 283 | self._update_attribute( 284 | '_player_state', status.get('playerState', None)) 285 | elif response['type'] == 'LOAD_FAILED': 286 | raise LoadFailedException() 287 | 288 | @property 289 | def player_state(self): 290 | return self._player_state 291 | 292 | @property 293 | def is_playing(self): 294 | return (self._player_state is not None and 295 | self._player_state == self.PLAYER_STATE_PLAYING) 296 | 297 | @property 298 | def is_paused(self): 299 | return (self._player_state is not None and 300 | self._player_state == self.PLAYER_STATE_PAUSED) 301 | 302 | @property 303 | def is_idle(self): 304 | return (self._player_state is not None and 305 | self._player_state == self.PLAYER_STATE_IDLE) 306 | 307 | @property 308 | def volume(self): 309 | return self._volume.get('level') if self._volume else None 310 | 311 | @property 312 | def is_muted(self): 313 | return self._volume.get('muted') if self._volume else None 314 | -------------------------------------------------------------------------------- /pulseaudio_dlna/plugins/chromecast/pycastv2/cast_channel_pb2.py: -------------------------------------------------------------------------------- 1 | # Generated by the protocol buffer compiler. DO NOT EDIT! 2 | # source: cast_channel.proto 3 | 4 | from google.protobuf import descriptor as _descriptor 5 | from google.protobuf import message as _message 6 | from google.protobuf import reflection as _reflection 7 | from google.protobuf import descriptor_pb2 8 | # @@protoc_insertion_point(imports) 9 | 10 | 11 | 12 | 13 | DESCRIPTOR = _descriptor.FileDescriptor( 14 | name='cast_channel.proto', 15 | package='extensions.api.cast_channel', 16 | serialized_pb='\n\x12\x63\x61st_channel.proto\x12\x1b\x65xtensions.api.cast_channel\"\xe3\x02\n\x0b\x43\x61stMessage\x12R\n\x10protocol_version\x18\x01 \x02(\x0e\x32\x38.extensions.api.cast_channel.CastMessage.ProtocolVersion\x12\x11\n\tsource_id\x18\x02 \x02(\t\x12\x16\n\x0e\x64\x65stination_id\x18\x03 \x02(\t\x12\x11\n\tnamespace\x18\x04 \x02(\t\x12J\n\x0cpayload_type\x18\x05 \x02(\x0e\x32\x34.extensions.api.cast_channel.CastMessage.PayloadType\x12\x14\n\x0cpayload_utf8\x18\x06 \x01(\t\x12\x16\n\x0epayload_binary\x18\x07 \x01(\x0c\"!\n\x0fProtocolVersion\x12\x0e\n\nCASTV2_1_0\x10\x00\"%\n\x0bPayloadType\x12\n\n\x06STRING\x10\x00\x12\n\n\x06\x42INARY\x10\x01\"\x0f\n\rAuthChallenge\"B\n\x0c\x41uthResponse\x12\x11\n\tsignature\x18\x01 \x02(\x0c\x12\x1f\n\x17\x63lient_auth_certificate\x18\x02 \x02(\x0c\"~\n\tAuthError\x12\x44\n\nerror_type\x18\x01 \x02(\x0e\x32\x30.extensions.api.cast_channel.AuthError.ErrorType\"+\n\tErrorType\x12\x12\n\x0eINTERNAL_ERROR\x10\x00\x12\n\n\x06NO_TLS\x10\x01\"\xc6\x01\n\x11\x44\x65viceAuthMessage\x12=\n\tchallenge\x18\x01 \x01(\x0b\x32*.extensions.api.cast_channel.AuthChallenge\x12;\n\x08response\x18\x02 \x01(\x0b\x32).extensions.api.cast_channel.AuthResponse\x12\x35\n\x05\x65rror\x18\x03 \x01(\x0b\x32&.extensions.api.cast_channel.AuthErrorB\x02H\x03') 17 | 18 | 19 | 20 | _CASTMESSAGE_PROTOCOLVERSION = _descriptor.EnumDescriptor( 21 | name='ProtocolVersion', 22 | full_name='extensions.api.cast_channel.CastMessage.ProtocolVersion', 23 | filename=None, 24 | file=DESCRIPTOR, 25 | values=[ 26 | _descriptor.EnumValueDescriptor( 27 | name='CASTV2_1_0', index=0, number=0, 28 | options=None, 29 | type=None), 30 | ], 31 | containing_type=None, 32 | options=None, 33 | serialized_start=335, 34 | serialized_end=368, 35 | ) 36 | 37 | _CASTMESSAGE_PAYLOADTYPE = _descriptor.EnumDescriptor( 38 | name='PayloadType', 39 | full_name='extensions.api.cast_channel.CastMessage.PayloadType', 40 | filename=None, 41 | file=DESCRIPTOR, 42 | values=[ 43 | _descriptor.EnumValueDescriptor( 44 | name='STRING', index=0, number=0, 45 | options=None, 46 | type=None), 47 | _descriptor.EnumValueDescriptor( 48 | name='BINARY', index=1, number=1, 49 | options=None, 50 | type=None), 51 | ], 52 | containing_type=None, 53 | options=None, 54 | serialized_start=370, 55 | serialized_end=407, 56 | ) 57 | 58 | _AUTHERROR_ERRORTYPE = _descriptor.EnumDescriptor( 59 | name='ErrorType', 60 | full_name='extensions.api.cast_channel.AuthError.ErrorType', 61 | filename=None, 62 | file=DESCRIPTOR, 63 | values=[ 64 | _descriptor.EnumValueDescriptor( 65 | name='INTERNAL_ERROR', index=0, number=0, 66 | options=None, 67 | type=None), 68 | _descriptor.EnumValueDescriptor( 69 | name='NO_TLS', index=1, number=1, 70 | options=None, 71 | type=None), 72 | ], 73 | containing_type=None, 74 | options=None, 75 | serialized_start=577, 76 | serialized_end=620, 77 | ) 78 | 79 | 80 | _CASTMESSAGE = _descriptor.Descriptor( 81 | name='CastMessage', 82 | full_name='extensions.api.cast_channel.CastMessage', 83 | filename=None, 84 | file=DESCRIPTOR, 85 | containing_type=None, 86 | fields=[ 87 | _descriptor.FieldDescriptor( 88 | name='protocol_version', full_name='extensions.api.cast_channel.CastMessage.protocol_version', index=0, 89 | number=1, type=14, cpp_type=8, label=2, 90 | has_default_value=False, default_value=0, 91 | message_type=None, enum_type=None, containing_type=None, 92 | is_extension=False, extension_scope=None, 93 | options=None), 94 | _descriptor.FieldDescriptor( 95 | name='source_id', full_name='extensions.api.cast_channel.CastMessage.source_id', index=1, 96 | number=2, type=9, cpp_type=9, label=2, 97 | has_default_value=False, default_value=unicode("", "utf-8"), 98 | message_type=None, enum_type=None, containing_type=None, 99 | is_extension=False, extension_scope=None, 100 | options=None), 101 | _descriptor.FieldDescriptor( 102 | name='destination_id', full_name='extensions.api.cast_channel.CastMessage.destination_id', index=2, 103 | number=3, type=9, cpp_type=9, label=2, 104 | has_default_value=False, default_value=unicode("", "utf-8"), 105 | message_type=None, enum_type=None, containing_type=None, 106 | is_extension=False, extension_scope=None, 107 | options=None), 108 | _descriptor.FieldDescriptor( 109 | name='namespace', full_name='extensions.api.cast_channel.CastMessage.namespace', index=3, 110 | number=4, type=9, cpp_type=9, label=2, 111 | has_default_value=False, default_value=unicode("", "utf-8"), 112 | message_type=None, enum_type=None, containing_type=None, 113 | is_extension=False, extension_scope=None, 114 | options=None), 115 | _descriptor.FieldDescriptor( 116 | name='payload_type', full_name='extensions.api.cast_channel.CastMessage.payload_type', index=4, 117 | number=5, type=14, cpp_type=8, label=2, 118 | has_default_value=False, default_value=0, 119 | message_type=None, enum_type=None, containing_type=None, 120 | is_extension=False, extension_scope=None, 121 | options=None), 122 | _descriptor.FieldDescriptor( 123 | name='payload_utf8', full_name='extensions.api.cast_channel.CastMessage.payload_utf8', index=5, 124 | number=6, type=9, cpp_type=9, label=1, 125 | has_default_value=False, default_value=unicode("", "utf-8"), 126 | message_type=None, enum_type=None, containing_type=None, 127 | is_extension=False, extension_scope=None, 128 | options=None), 129 | _descriptor.FieldDescriptor( 130 | name='payload_binary', full_name='extensions.api.cast_channel.CastMessage.payload_binary', index=6, 131 | number=7, type=12, cpp_type=9, label=1, 132 | has_default_value=False, default_value="", 133 | message_type=None, enum_type=None, containing_type=None, 134 | is_extension=False, extension_scope=None, 135 | options=None), 136 | ], 137 | extensions=[ 138 | ], 139 | nested_types=[], 140 | enum_types=[ 141 | _CASTMESSAGE_PROTOCOLVERSION, 142 | _CASTMESSAGE_PAYLOADTYPE, 143 | ], 144 | options=None, 145 | is_extendable=False, 146 | extension_ranges=[], 147 | serialized_start=52, 148 | serialized_end=407, 149 | ) 150 | 151 | 152 | _AUTHCHALLENGE = _descriptor.Descriptor( 153 | name='AuthChallenge', 154 | full_name='extensions.api.cast_channel.AuthChallenge', 155 | filename=None, 156 | file=DESCRIPTOR, 157 | containing_type=None, 158 | fields=[ 159 | ], 160 | extensions=[ 161 | ], 162 | nested_types=[], 163 | enum_types=[ 164 | ], 165 | options=None, 166 | is_extendable=False, 167 | extension_ranges=[], 168 | serialized_start=409, 169 | serialized_end=424, 170 | ) 171 | 172 | 173 | _AUTHRESPONSE = _descriptor.Descriptor( 174 | name='AuthResponse', 175 | full_name='extensions.api.cast_channel.AuthResponse', 176 | filename=None, 177 | file=DESCRIPTOR, 178 | containing_type=None, 179 | fields=[ 180 | _descriptor.FieldDescriptor( 181 | name='signature', full_name='extensions.api.cast_channel.AuthResponse.signature', index=0, 182 | number=1, type=12, cpp_type=9, label=2, 183 | has_default_value=False, default_value="", 184 | message_type=None, enum_type=None, containing_type=None, 185 | is_extension=False, extension_scope=None, 186 | options=None), 187 | _descriptor.FieldDescriptor( 188 | name='client_auth_certificate', full_name='extensions.api.cast_channel.AuthResponse.client_auth_certificate', index=1, 189 | number=2, type=12, cpp_type=9, label=2, 190 | has_default_value=False, default_value="", 191 | message_type=None, enum_type=None, containing_type=None, 192 | is_extension=False, extension_scope=None, 193 | options=None), 194 | ], 195 | extensions=[ 196 | ], 197 | nested_types=[], 198 | enum_types=[ 199 | ], 200 | options=None, 201 | is_extendable=False, 202 | extension_ranges=[], 203 | serialized_start=426, 204 | serialized_end=492, 205 | ) 206 | 207 | 208 | _AUTHERROR = _descriptor.Descriptor( 209 | name='AuthError', 210 | full_name='extensions.api.cast_channel.AuthError', 211 | filename=None, 212 | file=DESCRIPTOR, 213 | containing_type=None, 214 | fields=[ 215 | _descriptor.FieldDescriptor( 216 | name='error_type', full_name='extensions.api.cast_channel.AuthError.error_type', index=0, 217 | number=1, type=14, cpp_type=8, label=2, 218 | has_default_value=False, default_value=0, 219 | message_type=None, enum_type=None, containing_type=None, 220 | is_extension=False, extension_scope=None, 221 | options=None), 222 | ], 223 | extensions=[ 224 | ], 225 | nested_types=[], 226 | enum_types=[ 227 | _AUTHERROR_ERRORTYPE, 228 | ], 229 | options=None, 230 | is_extendable=False, 231 | extension_ranges=[], 232 | serialized_start=494, 233 | serialized_end=620, 234 | ) 235 | 236 | 237 | _DEVICEAUTHMESSAGE = _descriptor.Descriptor( 238 | name='DeviceAuthMessage', 239 | full_name='extensions.api.cast_channel.DeviceAuthMessage', 240 | filename=None, 241 | file=DESCRIPTOR, 242 | containing_type=None, 243 | fields=[ 244 | _descriptor.FieldDescriptor( 245 | name='challenge', full_name='extensions.api.cast_channel.DeviceAuthMessage.challenge', index=0, 246 | number=1, type=11, cpp_type=10, label=1, 247 | has_default_value=False, default_value=None, 248 | message_type=None, enum_type=None, containing_type=None, 249 | is_extension=False, extension_scope=None, 250 | options=None), 251 | _descriptor.FieldDescriptor( 252 | name='response', full_name='extensions.api.cast_channel.DeviceAuthMessage.response', index=1, 253 | number=2, type=11, cpp_type=10, label=1, 254 | has_default_value=False, default_value=None, 255 | message_type=None, enum_type=None, containing_type=None, 256 | is_extension=False, extension_scope=None, 257 | options=None), 258 | _descriptor.FieldDescriptor( 259 | name='error', full_name='extensions.api.cast_channel.DeviceAuthMessage.error', index=2, 260 | number=3, type=11, cpp_type=10, label=1, 261 | has_default_value=False, default_value=None, 262 | message_type=None, enum_type=None, containing_type=None, 263 | is_extension=False, extension_scope=None, 264 | options=None), 265 | ], 266 | extensions=[ 267 | ], 268 | nested_types=[], 269 | enum_types=[ 270 | ], 271 | options=None, 272 | is_extendable=False, 273 | extension_ranges=[], 274 | serialized_start=623, 275 | serialized_end=821, 276 | ) 277 | 278 | _CASTMESSAGE.fields_by_name['protocol_version'].enum_type = _CASTMESSAGE_PROTOCOLVERSION 279 | _CASTMESSAGE.fields_by_name['payload_type'].enum_type = _CASTMESSAGE_PAYLOADTYPE 280 | _CASTMESSAGE_PROTOCOLVERSION.containing_type = _CASTMESSAGE; 281 | _CASTMESSAGE_PAYLOADTYPE.containing_type = _CASTMESSAGE; 282 | _AUTHERROR.fields_by_name['error_type'].enum_type = _AUTHERROR_ERRORTYPE 283 | _AUTHERROR_ERRORTYPE.containing_type = _AUTHERROR; 284 | _DEVICEAUTHMESSAGE.fields_by_name['challenge'].message_type = _AUTHCHALLENGE 285 | _DEVICEAUTHMESSAGE.fields_by_name['response'].message_type = _AUTHRESPONSE 286 | _DEVICEAUTHMESSAGE.fields_by_name['error'].message_type = _AUTHERROR 287 | DESCRIPTOR.message_types_by_name['CastMessage'] = _CASTMESSAGE 288 | DESCRIPTOR.message_types_by_name['AuthChallenge'] = _AUTHCHALLENGE 289 | DESCRIPTOR.message_types_by_name['AuthResponse'] = _AUTHRESPONSE 290 | DESCRIPTOR.message_types_by_name['AuthError'] = _AUTHERROR 291 | DESCRIPTOR.message_types_by_name['DeviceAuthMessage'] = _DEVICEAUTHMESSAGE 292 | 293 | class CastMessage(_message.Message): 294 | __metaclass__ = _reflection.GeneratedProtocolMessageType 295 | DESCRIPTOR = _CASTMESSAGE 296 | 297 | # @@protoc_insertion_point(class_scope:extensions.api.cast_channel.CastMessage) 298 | 299 | class AuthChallenge(_message.Message): 300 | __metaclass__ = _reflection.GeneratedProtocolMessageType 301 | DESCRIPTOR = _AUTHCHALLENGE 302 | 303 | # @@protoc_insertion_point(class_scope:extensions.api.cast_channel.AuthChallenge) 304 | 305 | class AuthResponse(_message.Message): 306 | __metaclass__ = _reflection.GeneratedProtocolMessageType 307 | DESCRIPTOR = _AUTHRESPONSE 308 | 309 | # @@protoc_insertion_point(class_scope:extensions.api.cast_channel.AuthResponse) 310 | 311 | class AuthError(_message.Message): 312 | __metaclass__ = _reflection.GeneratedProtocolMessageType 313 | DESCRIPTOR = _AUTHERROR 314 | 315 | # @@protoc_insertion_point(class_scope:extensions.api.cast_channel.AuthError) 316 | 317 | class DeviceAuthMessage(_message.Message): 318 | __metaclass__ = _reflection.GeneratedProtocolMessageType 319 | DESCRIPTOR = _DEVICEAUTHMESSAGE 320 | 321 | # @@protoc_insertion_point(class_scope:extensions.api.cast_channel.DeviceAuthMessage) 322 | 323 | 324 | DESCRIPTOR.has_options = True 325 | DESCRIPTOR._options = _descriptor._ParseOptions(descriptor_pb2.FileOptions(), 'H\003') 326 | # @@protoc_insertion_point(module_scope) 327 | -------------------------------------------------------------------------------- /pulseaudio_dlna/plugins/chromecast/pycastv2/cast_socket.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | # This file is part of pulseaudio-dlna. 4 | 5 | # pulseaudio-dlna is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | 10 | # pulseaudio-dlna is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU General Public License for more details. 14 | 15 | # You should have received a copy of the GNU General Public License 16 | # along with pulseaudio-dlna. If not, see . 17 | 18 | from __future__ import unicode_literals 19 | 20 | import ssl 21 | import socket 22 | import logging 23 | import struct 24 | import json 25 | import time 26 | import traceback 27 | import select 28 | 29 | import cast_channel_pb2 30 | 31 | logger = logging.getLogger('pycastv2.cast_socket') 32 | 33 | 34 | class NoResponseException(Exception): 35 | pass 36 | 37 | 38 | class ConnectionTerminatedException(Exception): 39 | pass 40 | 41 | 42 | class BaseChromecastSocket(object): 43 | def __init__(self, ip, port): 44 | self.sock = socket.socket() 45 | self.sock = ssl.wrap_socket(self.sock) 46 | self.sock.connect((ip, port)) 47 | self.agent = 'chromecast_v2' 48 | 49 | def _generate_message(self, 50 | source_id='sender-0', destination_id='receiver-0', 51 | namespace=None): 52 | message = cast_channel_pb2.CastMessage() 53 | message.protocol_version = message.CASTV2_1_0 54 | message.source_id = source_id 55 | message.destination_id = destination_id 56 | message.payload_type = cast_channel_pb2.CastMessage.STRING 57 | if namespace: 58 | message.namespace = namespace 59 | return message 60 | 61 | def close(self): 62 | self.sock.close() 63 | logger.debug('Chromecast socket was cleaned up.') 64 | 65 | def send(self, data, sender_id, destination_id, namespace=None): 66 | json_data = json.dumps(data) 67 | message = self._generate_message( 68 | source_id=sender_id, 69 | destination_id=destination_id, 70 | namespace=namespace) 71 | message.payload_utf8 = json_data 72 | size = struct.pack('>I', message.ByteSize()) 73 | formatted_message = size + message.SerializeToString() 74 | self.sock.sendall(formatted_message) 75 | 76 | def read(self, timeout=10): 77 | try: 78 | start_time = time.time() 79 | data = str('') 80 | while len(data) < 4: 81 | if time.time() - start_time > timeout: 82 | raise NoResponseException() 83 | part = self.sock.recv(1) 84 | if len(part) == 0: 85 | raise ConnectionTerminatedException() 86 | data += part 87 | length = struct.unpack('>I', data)[0] 88 | data = str('') 89 | while len(data) < length: 90 | part = self.sock.recv(2048) 91 | data += part 92 | message = self._generate_message() 93 | message.ParseFromString(data) 94 | response = json.loads(message.payload_utf8) 95 | return response 96 | except ssl.SSLError as e: 97 | if e.message == 'The read operation timed out': 98 | raise NoResponseException() 99 | else: 100 | logger.debug('Catched exception:') 101 | traceback.print_exc() 102 | return {} 103 | 104 | 105 | class CastSocket(BaseChromecastSocket): 106 | def __init__(self, ip, port): 107 | BaseChromecastSocket.__init__(self, ip, port) 108 | self.read_listeners = [] 109 | self.send_listeners = [] 110 | self.response_cache = {} 111 | 112 | def send(self, command): 113 | for listener in self.send_listeners: 114 | command = listener(command) 115 | logger.debug('Sending message:\n{command}'.format( 116 | command=command)) 117 | BaseChromecastSocket.send( 118 | self, 119 | data=command.data, 120 | sender_id=command.sender_id, 121 | destination_id=command.destination_id, 122 | namespace=command.namespace) 123 | return command.request_id 124 | 125 | def read(self, timeout=None): 126 | if timeout is not None: 127 | self.wait_for_read(timeout) 128 | response = BaseChromecastSocket.read(self) 129 | logger.debug('Recieved message:\n {message}'.format( 130 | message=json.dumps(response, indent=2))) 131 | for listener in self.read_listeners: 132 | listener(response) 133 | return response 134 | 135 | def add_read_listener(self, listener): 136 | self.read_listeners.append(listener) 137 | 138 | def add_send_listener(self, listener): 139 | self.send_listeners.append(listener) 140 | 141 | def send_and_wait(self, command): 142 | req_id = self.send(command) 143 | return self.wait_for_response_id(req_id) 144 | 145 | def wait_for_read(self, timeout=None): 146 | start_time = time.time() 147 | while True: 148 | if self._is_socket_readable(): 149 | return 150 | current_time = time.time() 151 | if current_time - start_time > timeout: 152 | raise NoResponseException() 153 | time.sleep(0.1) 154 | 155 | def wait_for_response_id(self, req_id, timeout=10): 156 | start_time = time.time() 157 | while True: 158 | if not self._is_socket_readable(): 159 | time.sleep(0.1) 160 | else: 161 | response = self.read() 162 | self._add_to_response_cache(response) 163 | if req_id in self.response_cache: 164 | return response 165 | current_time = time.time() 166 | if current_time - start_time > timeout: 167 | raise NoResponseException() 168 | return None 169 | 170 | def wait_for_response_type(self, _type, timeout=10): 171 | start_time = time.time() 172 | while True: 173 | if not self._is_socket_readable(): 174 | time.sleep(0.1) 175 | else: 176 | response = self.read() 177 | self._add_to_response_cache(response) 178 | if response.get('type', None) == _type: 179 | return response 180 | current_time = time.time() 181 | if current_time - start_time > timeout: 182 | raise NoResponseException() 183 | return None 184 | 185 | def wait(self, timeout=10): 186 | start_time = time.time() 187 | while True: 188 | if not self._is_socket_readable(): 189 | time.sleep(0.1) 190 | else: 191 | response = self.read() 192 | self._add_to_response_cache(response) 193 | current_time = time.time() 194 | if current_time - start_time > timeout: 195 | return 196 | return None 197 | 198 | def _add_to_response_cache(self, response): 199 | if 'requestId' in response: 200 | req_id = response['requestId'] 201 | if int(req_id) != 0: 202 | self.response_cache[req_id] = response 203 | 204 | def _is_socket_readable(self): 205 | try: 206 | r, w, e = select.select([self.sock], [], [self.sock], 0) 207 | for sock in r: 208 | return True 209 | for sock in e: 210 | raise NoResponseException() 211 | except socket.error: 212 | raise NoResponseException() 213 | return False 214 | -------------------------------------------------------------------------------- /pulseaudio_dlna/plugins/chromecast/pycastv2/commands.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | # This file is part of pulseaudio-dlna. 4 | 5 | # pulseaudio-dlna is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | 10 | # pulseaudio-dlna is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU General Public License for more details. 14 | 15 | # You should have received a copy of the GNU General Public License 16 | # along with pulseaudio-dlna. If not, see . 17 | 18 | from __future__ import unicode_literals 19 | 20 | 21 | class BaseCommand(object): 22 | def __init__(self): 23 | self._sender_id = None 24 | self._destination_id = None 25 | self._namespace = None 26 | self._data = None 27 | 28 | @property 29 | def sender_id(self): 30 | return self._sender_id 31 | 32 | @sender_id.setter 33 | def sender_id(self, value): 34 | self._sender_id = value 35 | 36 | @property 37 | def destination_id(self): 38 | return self._destination_id 39 | 40 | @destination_id.setter 41 | def destination_id(self, value): 42 | self._destination_id = value 43 | 44 | @property 45 | def request_id(self): 46 | if 'requestId' in self.data: 47 | return self.data['requestId'] 48 | return 0 49 | 50 | @request_id.setter 51 | def request_id(self, value): 52 | if value is not None: 53 | self.data['requestId'] = value 54 | 55 | @property 56 | def session_id(self): 57 | if 'sessionId' in self.data: 58 | return self.data['sessionId'] 59 | return None 60 | 61 | @session_id.setter 62 | def session_id(self, value): 63 | if value is not None: 64 | self.data['sessionId'] = value 65 | 66 | @property 67 | def namespace(self): 68 | return self._namespace 69 | 70 | @namespace.setter 71 | def namespace(self, value): 72 | self._namespace = value 73 | 74 | @property 75 | def data(self): 76 | return self._data 77 | 78 | @data.setter 79 | def data(self, value): 80 | self._data = value 81 | 82 | def __str__(self): 83 | return ('<{class_name}>\n' 84 | ' namespace: {namespace}\n' 85 | ' destination_id: {destination_id}\n' 86 | ' data: {data}'.format( 87 | class_name=self.__class__.__name__, 88 | namespace=self.namespace, 89 | destination_id=self.destination_id, 90 | data=self.data)) 91 | 92 | 93 | class ConnectCommand(BaseCommand): 94 | def __init__(self, destination_id=None, namespace=None, agent=None): 95 | BaseCommand.__init__(self) 96 | self.data = { 97 | 'origin': {}, 98 | 'type': 'CONNECT', 99 | 'userAgent': agent or 'Unknown' 100 | } 101 | self.destination_id = destination_id 102 | self.namespace = (namespace or 103 | 'urn:x-cast:com.google.cast.tp.connection') 104 | 105 | 106 | class CloseCommand(BaseCommand): 107 | def __init__(self, destination_id=None, namespace=None): 108 | BaseCommand.__init__(self) 109 | self.data = { 110 | 'origin': {}, 111 | 'type': 'CLOSE' 112 | } 113 | self.destination_id = destination_id 114 | self.namespace = (namespace or 115 | 'urn:x-cast:com.google.cast.tp.connection') 116 | 117 | 118 | class StatusCommand(BaseCommand): 119 | def __init__(self, destination_id=None, namespace=None): 120 | BaseCommand.__init__(self) 121 | self.data = { 122 | 'type': 'GET_STATUS' 123 | } 124 | self.request_id = False 125 | self.destination_id = destination_id 126 | self.namespace = namespace or 'urn:x-cast:com.google.cast.receiver' 127 | 128 | 129 | class LaunchCommand(BaseCommand): 130 | def __init__(self, app_id, destination_id=None, namespace=None): 131 | BaseCommand.__init__(self) 132 | self.data = { 133 | 'appId': app_id, 134 | 'type': 'LAUNCH' 135 | } 136 | self.request_id = False 137 | self.destination_id = destination_id 138 | self.namespace = namespace or 'urn:x-cast:com.google.cast.receiver' 139 | 140 | 141 | class StopCommand(BaseCommand): 142 | def __init__(self, session_id=False, destination_id=None, 143 | namespace=None): 144 | BaseCommand.__init__(self) 145 | self.data = { 146 | 'type': 'STOP' 147 | } 148 | self.session_id = False 149 | self.request_id = False 150 | self.destination_id = destination_id 151 | self.namespace = namespace or 'urn:x-cast:com.google.cast.receiver' 152 | 153 | 154 | class SetVolumeCommand(BaseCommand): 155 | def __init__(self, volume, session_id=False, destination_id=None, 156 | namespace=None): 157 | BaseCommand.__init__(self) 158 | self.data = { 159 | 'type': 'SET_VOLUME', 160 | 'volume': { 161 | 'level': volume, 162 | }, 163 | } 164 | self.request_id = False 165 | self.destination_id = destination_id 166 | self.namespace = namespace or 'urn:x-cast:com.google.cast.receiver' 167 | 168 | 169 | class SetVolumeMuteCommand(BaseCommand): 170 | def __init__(self, muted, session_id=False, destination_id=None, 171 | namespace=None): 172 | BaseCommand.__init__(self) 173 | self.data = { 174 | 'type': 'SET_VOLUME', 175 | 'volume': { 176 | 'muted': muted, 177 | }, 178 | } 179 | self.request_id = False 180 | self.destination_id = destination_id 181 | self.namespace = namespace or 'urn:x-cast:com.google.cast.receiver' 182 | 183 | 184 | class PongCommand(BaseCommand): 185 | def __init__(self, session_id=False, destination_id=None, 186 | namespace=None): 187 | BaseCommand.__init__(self) 188 | self.data = { 189 | 'type': 'PONG' 190 | } 191 | self.session_id = False 192 | self.request_id = False 193 | self.destination_id = destination_id 194 | self.namespace = namespace or 'urn:x-cast:com.google.cast.tp.heartbeat' 195 | -------------------------------------------------------------------------------- /pulseaudio_dlna/plugins/chromecast/pycastv2/example.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | # This file is part of pulseaudio-dlna. 4 | 5 | # pulseaudio-dlna is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | 10 | # pulseaudio-dlna is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU General Public License for more details. 14 | 15 | # You should have received a copy of the GNU General Public License 16 | # along with pulseaudio-dlna. If not, see . 17 | 18 | from __future__ import unicode_literals 19 | 20 | import logging 21 | import __init__ as pycastv2 22 | 23 | logging.basicConfig(level=logging.DEBUG) 24 | 25 | mc = pycastv2.MediaPlayerController('192.168.1.3') 26 | try: 27 | mc.load('http://192.168.1.2:8080/stream.mp3', 'audio/mpeg') 28 | mc.wait(10) 29 | mc.disconnect_application() 30 | # mc.stop_application() 31 | except pycastv2.ChannelClosedException: 32 | print('Channel was closed.') 33 | except pycastv2.TimeoutException: 34 | print('Request timed out.') 35 | finally: 36 | mc.cleanup() 37 | -------------------------------------------------------------------------------- /pulseaudio_dlna/plugins/chromecast/renderer.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | # This file is part of pulseaudio-dlna. 4 | 5 | # pulseaudio-dlna is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | 10 | # pulseaudio-dlna is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU General Public License for more details. 14 | 15 | # You should have received a copy of the GNU General Public License 16 | # along with pulseaudio-dlna. If not, see . 17 | 18 | from __future__ import unicode_literals 19 | 20 | import requests 21 | import logging 22 | import urlparse 23 | import socket 24 | import traceback 25 | import lxml 26 | 27 | import pycastv2 28 | import pulseaudio_dlna.plugins.renderer 29 | import pulseaudio_dlna.rules 30 | import pulseaudio_dlna.codecs 31 | 32 | logger = logging.getLogger('pulseaudio_dlna.plugins.chromecast.renderer') 33 | 34 | 35 | class ChromecastRenderer(pulseaudio_dlna.plugins.renderer.BaseRenderer): 36 | 37 | def __init__( 38 | self, name, ip, port, udn, model_name, model_number, 39 | model_description, manufacturer): 40 | pulseaudio_dlna.plugins.renderer.BaseRenderer.__init__( 41 | self, 42 | udn=udn, 43 | flavour='Chromecast', 44 | name=name, 45 | ip=ip, 46 | port=port or 8009, 47 | model_name=model_name, 48 | model_number=model_number, 49 | model_description=model_description, 50 | manufacturer=manufacturer 51 | ) 52 | 53 | def activate(self, config): 54 | if config: 55 | self.set_rules_from_config(config) 56 | else: 57 | self.codecs = [ 58 | pulseaudio_dlna.codecs.Mp3Codec(), 59 | pulseaudio_dlna.codecs.FlacCodec(), 60 | pulseaudio_dlna.codecs.WavCodec(), 61 | pulseaudio_dlna.codecs.OggCodec(), 62 | pulseaudio_dlna.codecs.AacCodec(), 63 | ] 64 | self.apply_device_rules() 65 | self.prioritize_codecs() 66 | 67 | def play(self, url=None, codec=None, artist=None, title=None, thumb=None): 68 | self._before_play() 69 | url = url or self.get_stream_url() 70 | try: 71 | cast = pycastv2.MediaPlayerController( 72 | self.ip, self.port, self.REQUEST_TIMEOUT) 73 | cast.load( 74 | url, 75 | mime_type=self.codec.mime_type, 76 | artist=artist, 77 | title=title, 78 | thumb=thumb) 79 | self.state = self.STATE_PLAYING 80 | return 200, None 81 | except pycastv2.LaunchErrorException: 82 | message = 'The media player could not be launched. ' \ 83 | 'Maybe the chromecast is still closing a ' \ 84 | 'running player instance. Try again in 30 seconds.' 85 | return 503, message 86 | except pycastv2.ChannelClosedException: 87 | message = 'Connection was closed. I guess another ' \ 88 | 'client is attached to it.' 89 | return 423, message 90 | except pycastv2.TimeoutException: 91 | message = 'PLAY command - Could no connect to "{device}". ' \ 92 | 'Connection timeout.'.format(device=self.label) 93 | return 408, message 94 | except socket.error as e: 95 | if e.errno == 111: 96 | message = 'The chromecast refused the connection. ' \ 97 | 'Perhaps it does not support the castv2 ' \ 98 | 'protocol.' 99 | return 403, message 100 | else: 101 | traceback.print_exc() 102 | return 500, None 103 | except (pulseaudio_dlna.plugins.renderer.NoEncoderFoundException, 104 | pulseaudio_dlna.plugins.renderer.NoSuitableHostFoundException)\ 105 | as e: 106 | return 500, e 107 | except Exception: 108 | traceback.print_exc() 109 | return 500, 'Unknown exception.' 110 | finally: 111 | self._after_play() 112 | cast.cleanup() 113 | 114 | def stop(self): 115 | self._before_stop() 116 | try: 117 | cast = pycastv2.MediaPlayerController( 118 | self.ip, self.port, self.REQUEST_TIMEOUT) 119 | self.state = self.STATE_STOPPED 120 | cast.disconnect_application() 121 | return 200, None 122 | except pycastv2.ChannelClosedException: 123 | message = 'Connection was closed. I guess another ' \ 124 | 'client is attached to it.' 125 | return 423, message 126 | except pycastv2.TimeoutException: 127 | message = 'STOP command - Could no connect to "{device}". ' \ 128 | 'Connection timeout.'.format(device=self.label) 129 | return 408, message 130 | except socket.error as e: 131 | if e.errno == 111: 132 | message = 'The chromecast refused the connection. ' \ 133 | 'Perhaps it does not support the castv2 ' \ 134 | 'protocol.' 135 | return 403, message 136 | else: 137 | traceback.print_exc() 138 | return 500, 'Unknown exception.' 139 | except Exception: 140 | traceback.print_exc() 141 | return 500, 'Unknown exception.' 142 | finally: 143 | self._after_stop() 144 | cast.cleanup() 145 | 146 | def pause(self): 147 | raise NotImplementedError() 148 | 149 | 150 | class ChromecastRendererFactory(object): 151 | 152 | NOTIFICATION_TYPES = [ 153 | 'urn:dial-multiscreen-org:device:dial:1', 154 | ] 155 | 156 | CHROMECAST_MODELS = [ 157 | 'Eureka Dongle', 158 | 'Chromecast Audio', 159 | 'Nexus Player', 160 | 'Freebox Player Mini', 161 | ] 162 | 163 | @classmethod 164 | def from_url(cls, url): 165 | try: 166 | response = requests.get(url, timeout=5) 167 | logger.debug('Response from chromecast device ({url})\n' 168 | '{response}'.format(url=url, response=response.text)) 169 | except requests.exceptions.Timeout: 170 | logger.warning( 171 | 'Could no connect to {url}. ' 172 | 'Connection timeout.'.format(url=url)) 173 | return None 174 | except requests.exceptions.ConnectionError: 175 | logger.warning( 176 | 'Could no connect to {url}. ' 177 | 'Connection refused.'.format(url=url)) 178 | return None 179 | return cls.from_xml(url, response.content) 180 | 181 | @classmethod 182 | def from_xml(cls, url, xml): 183 | url_object = urlparse.urlparse(url) 184 | ip, port = url_object.netloc.split(':') 185 | try: 186 | xml_root = lxml.etree.fromstring(xml) 187 | for device in xml_root.findall('.//{*}device'): 188 | device_type = device.find('{*}deviceType') 189 | device_friendlyname = device.find('{*}friendlyName') 190 | device_udn = device.find('{*}UDN') 191 | device_modelname = device.find('{*}modelName') 192 | device_manufacturer = device.find('{*}manufacturer') 193 | 194 | if device_type.text not in cls.NOTIFICATION_TYPES: 195 | continue 196 | 197 | if device_modelname.text.strip() not in cls.CHROMECAST_MODELS: 198 | logger.info( 199 | 'The Chromecast seems not to be an original one. ' 200 | 'Model name: "{}" Skipping device ...'.format( 201 | device_modelname.text)) 202 | return None 203 | 204 | return ChromecastRenderer( 205 | name=unicode(device_friendlyname.text), 206 | ip=unicode(ip), 207 | port=None, 208 | udn=unicode(device_udn.text), 209 | model_name=unicode(device_modelname.text), 210 | model_number=None, 211 | model_description=None, 212 | manufacturer=unicode(device_manufacturer.text), 213 | ) 214 | except: 215 | logger.error('No valid XML returned from {url}.'.format(url=url)) 216 | return None 217 | 218 | @classmethod 219 | def from_header(cls, header): 220 | if header.get('location', None): 221 | return cls.from_url(header['location']) 222 | 223 | @classmethod 224 | def from_mdns_info(cls, info): 225 | 226 | def _bytes2string(bytes): 227 | ip = [] 228 | for b in bytes: 229 | subnet = int(b.encode('hex'), 16) 230 | ip.append(str(subnet)) 231 | return '.'.join(ip) 232 | 233 | def _get_device_info(info): 234 | try: 235 | return { 236 | 'udn': '{}:{}'.format('uuid', info.properties['id']), 237 | 'type': info.properties['md'].decode('utf-8'), 238 | 'name': info.properties['fn'].decode('utf-8'), 239 | 'ip': _bytes2string(info.address), 240 | 'port': int(info.port), 241 | } 242 | except (KeyError, AttributeError, TypeError): 243 | return None 244 | 245 | device_info = _get_device_info(info) 246 | if device_info: 247 | return ChromecastRenderer( 248 | name=device_info['name'], 249 | ip=device_info['ip'], 250 | port=device_info['port'], 251 | udn=device_info['udn'], 252 | model_name=device_info['type'], 253 | model_number=None, 254 | model_description=None, 255 | manufacturer='Google Inc.' 256 | ) 257 | return None 258 | -------------------------------------------------------------------------------- /pulseaudio_dlna/plugins/dlna/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | # This file is part of pulseaudio-dlna. 4 | 5 | # pulseaudio-dlna is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | 10 | # pulseaudio-dlna is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU General Public License for more details. 14 | 15 | # You should have received a copy of the GNU General Public License 16 | # along with pulseaudio-dlna. If not, see . 17 | 18 | from __future__ import unicode_literals 19 | 20 | import logging 21 | import threading 22 | import traceback 23 | 24 | import pulseaudio_dlna.plugins 25 | import pulseaudio_dlna.plugins.dlna.ssdp 26 | import pulseaudio_dlna.plugins.dlna.ssdp.listener 27 | import pulseaudio_dlna.plugins.dlna.ssdp.discover 28 | from pulseaudio_dlna.plugins.dlna.renderer import DLNAMediaRendererFactory 29 | 30 | logger = logging.getLogger('pulseaudio_dlna.plugins.dlna') 31 | 32 | 33 | class DLNAPlugin(pulseaudio_dlna.plugins.BasePlugin): 34 | 35 | NOTIFICATION_TYPES = [ 36 | 'urn:schemas-upnp-org:device:MediaRenderer:1', 37 | 'urn:schemas-upnp-org:device:MediaRenderer:2', 38 | ] 39 | 40 | def __init__(self, *args): 41 | pulseaudio_dlna.plugins.BasePlugin.__init__(self, *args) 42 | 43 | def lookup(self, url, xml): 44 | return DLNAMediaRendererFactory.from_xml(url, xml) 45 | 46 | def discover(self, holder, ttl=None, host=None): 47 | self.holder = holder 48 | 49 | def launch_discover(): 50 | discover = pulseaudio_dlna.plugins.dlna.ssdp.discover\ 51 | .SSDPDiscover( 52 | cb_on_device_response=self._on_device_response, 53 | host=host, 54 | ) 55 | discover.search(ssdp_ttl=ttl) 56 | 57 | def launch_listener(): 58 | ssdp = pulseaudio_dlna.plugins.dlna.ssdp.listener\ 59 | .ThreadedSSDPListener( 60 | cb_on_device_alive=self._on_device_added, 61 | cb_on_device_byebye=self._on_device_removed, 62 | host=host, 63 | ) 64 | ssdp.run(ttl=ttl) 65 | 66 | threads = [] 67 | for func in [launch_discover, launch_listener]: 68 | thread = threading.Thread(target=func) 69 | thread.daemon = True 70 | threads.append(thread) 71 | try: 72 | for thread in threads: 73 | thread.start() 74 | for thread in threads: 75 | thread.join() 76 | except: 77 | traceback.print_exc() 78 | 79 | logger.info('DLNAPlugin.discover()') 80 | 81 | @pulseaudio_dlna.plugins.BasePlugin.add_device_after 82 | def _on_device_response(self, header, address): 83 | st_header = header.get('st', None) 84 | if st_header and st_header in self.NOTIFICATION_TYPES: 85 | return DLNAMediaRendererFactory.from_header(header) 86 | 87 | @pulseaudio_dlna.plugins.BasePlugin.add_device_after 88 | def _on_device_added(self, header): 89 | nt_header = header.get('nt', None) 90 | if nt_header and nt_header in self.NOTIFICATION_TYPES: 91 | return DLNAMediaRendererFactory.from_header(header) 92 | 93 | @pulseaudio_dlna.plugins.BasePlugin.remove_device_after 94 | def _on_device_removed(self, header): 95 | nt_header = header.get('nt', None) 96 | if nt_header and nt_header in self.NOTIFICATION_TYPES: 97 | device_id = pulseaudio_dlna.plugins.dlna.ssdp._get_device_id( 98 | header) 99 | return device_id 100 | -------------------------------------------------------------------------------- /pulseaudio_dlna/plugins/dlna/pyupnpv2/byto.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | # This file is part of pulseaudio-dlna. 4 | 5 | # pulseaudio-dlna is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | 10 | # pulseaudio-dlna is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU General Public License for more details. 14 | 15 | # You should have received a copy of the GNU General Public License 16 | # along with pulseaudio-dlna. If not, see . 17 | 18 | """A module which runs things without importing unicode_literals 19 | 20 | Sometimes you want pythons builtin functions just to run on raw bytes. Since 21 | the unicode_literals module changes that behavior for many string manipulations 22 | this module is a workarounds for not using future.utils.bytes_to_native_str 23 | method. 24 | 25 | """ 26 | 27 | import re 28 | 29 | 30 | def repair_xml(bytes): 31 | 32 | def strip_namespaces(match): 33 | return 'xmlns{prefix}="{content}"'.format( 34 | prefix=match.group(1) if match.group(1) else '', 35 | content=match.group(2).strip(), 36 | ) 37 | 38 | bytes = re.sub( 39 | r'xmlns(:.*?)?="(.*?)"', strip_namespaces, bytes, 40 | flags=re.IGNORECASE) 41 | 42 | return bytes 43 | -------------------------------------------------------------------------------- /pulseaudio_dlna/plugins/dlna/renderer.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | # This file is part of pulseaudio-dlna. 4 | 5 | # pulseaudio-dlna is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | 10 | # pulseaudio-dlna is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU General Public License for more details. 14 | 15 | # You should have received a copy of the GNU General Public License 16 | # along with pulseaudio-dlna. If not, see . 17 | 18 | from __future__ import unicode_literals 19 | 20 | import logging 21 | import time 22 | import traceback 23 | 24 | import pulseaudio_dlna.pulseaudio 25 | import pulseaudio_dlna.encoders 26 | import pulseaudio_dlna.workarounds 27 | import pulseaudio_dlna.codecs 28 | import pulseaudio_dlna.rules 29 | import pulseaudio_dlna.plugins.renderer 30 | import pyupnpv2 31 | 32 | logger = logging.getLogger('pulseaudio_dlna.plugins.dlna.renderer') 33 | 34 | 35 | class MissingAttributeException(Exception): 36 | def __init__(self, command): 37 | Exception.__init__( 38 | self, 39 | 'The command\'s "{}" response did not contain a required ' 40 | 'attribute!'.format(command.upper()) 41 | ) 42 | 43 | 44 | class DLNAMediaRenderer(pulseaudio_dlna.plugins.renderer.BaseRenderer): 45 | 46 | def __init__(self, upnp_device): 47 | pulseaudio_dlna.plugins.renderer.BaseRenderer.__init__( 48 | self, 49 | udn=upnp_device.udn, 50 | flavour='DLNA', 51 | name=upnp_device.name, 52 | ip=upnp_device.ip, 53 | port=upnp_device.port, 54 | model_name=upnp_device.model_name, 55 | model_number=upnp_device.model_number, 56 | model_description=upnp_device.model_description, 57 | manufacturer=upnp_device.manufacturer 58 | ) 59 | self.upnp_device = upnp_device 60 | pulseaudio_dlna.plugins.dlna.pyupnpv2.UpnpService.TIMEOUT = \ 61 | self.REQUEST_TIMEOUT 62 | 63 | @property 64 | def content_features(self): 65 | return self.upnp_device.av_transport.content_features 66 | 67 | def activate(self, config): 68 | if config: 69 | self.set_rules_from_config(config) 70 | else: 71 | self.codecs = [] 72 | mime_types = self.get_mime_types() 73 | if mime_types: 74 | for mime_type in mime_types: 75 | self.add_mime_type(mime_type) 76 | self.apply_device_fixes() 77 | self.apply_device_rules() 78 | self.prioritize_codecs() 79 | 80 | def _register( 81 | self, stream_url, codec=None, artist=None, title=None, thumb=None): 82 | self._before_register() 83 | try: 84 | codec = codec or self.codec 85 | self.upnp_device.set_av_transport_uri( 86 | stream_url, codec.mime_type, artist, title, thumb) 87 | except Exception as e: 88 | raise e 89 | finally: 90 | self._after_register() 91 | 92 | def play(self, url=None, codec=None, artist=None, title=None, thumb=None): 93 | self._before_play() 94 | try: 95 | stream_url = url or self.get_stream_url() 96 | self._register( 97 | stream_url, codec, artist=artist, title=title, thumb=thumb) 98 | if pulseaudio_dlna.rules.DISABLE_PLAY_COMMAND in self.rules: 99 | logger.info( 100 | 'Disabled play command. Device should be playing ...') 101 | elif self._update_current_state(): 102 | if self.state == self.STATE_STOPPED: 103 | logger.info( 104 | 'Device state is stopped. Sending play command.') 105 | self.upnp_device.play() 106 | elif self.state == self.STATE_PLAYING: 107 | logger.info( 108 | 'Device state is playing. No need ' 109 | 'to send play command.') 110 | else: 111 | logger.info('Device state is unknown!') 112 | return 500, 'Unknown device state!' 113 | else: 114 | logger.warning( 115 | 'Updating device state unsuccessful! ' 116 | 'Sending play command.') 117 | self.upnp_device.play() 118 | self.state = self.STATE_PLAYING 119 | return 200, None 120 | except (pyupnpv2.UnsupportedActionException, 121 | pyupnpv2.CommandFailedException, 122 | pyupnpv2.XmlParsingException, 123 | pyupnpv2.ConnectionErrorException, 124 | pyupnpv2.ConnectionTimeoutException, 125 | pulseaudio_dlna.plugins.renderer.NoEncoderFoundException, 126 | pulseaudio_dlna.plugins.renderer.NoSuitableHostFoundException)\ 127 | as e: 128 | return 500, '"{}" : {}'.format(self.label, str(e)) 129 | except Exception: 130 | traceback.print_exc() 131 | return 500, 'Unknown exception.' 132 | finally: 133 | self._after_play() 134 | 135 | def stop(self): 136 | self._before_stop() 137 | try: 138 | self.upnp_device.stop() 139 | self.state = self.STATE_STOPPED 140 | return 200, None 141 | except (pyupnpv2.UnsupportedActionException, 142 | pyupnpv2.CommandFailedException, 143 | pyupnpv2.XmlParsingException, 144 | pyupnpv2.ConnectionErrorException, 145 | pyupnpv2.ConnectionTimeoutException) as e: 146 | return 500, '"{}" : {}'.format(self.label, str(e)) 147 | except Exception: 148 | traceback.print_exc() 149 | return 500, 'Unknown exception.' 150 | finally: 151 | self._after_stop() 152 | 153 | def get_volume(self): 154 | try: 155 | d = self.upnp_device.get_volume() 156 | return int(d['GetVolumeResponse']['CurrentVolume']) 157 | except KeyError: 158 | e = MissingAttributeException('get_protocol_info') 159 | except (pyupnpv2.UnsupportedActionException, 160 | pyupnpv2.XmlParsingException, 161 | pyupnpv2.ConnectionErrorException, 162 | pyupnpv2.ConnectionTimeoutException) as e: 163 | pass 164 | logger.error('"{}" : {}'.format(self.label, str(e))) 165 | return None 166 | 167 | def set_volume(self, volume): 168 | try: 169 | return self.upnp_device.set_volume(volume) 170 | except (pyupnpv2.UnsupportedActionException, 171 | pyupnpv2.XmlParsingException, 172 | pyupnpv2.ConnectionErrorException, 173 | pyupnpv2.ConnectionTimeoutException) as e: 174 | pass 175 | logger.error('"{}" : {}'.format(self.label, str(e))) 176 | return None 177 | 178 | def get_mute(self): 179 | try: 180 | d = self.upnp_device.get_mute() 181 | return int(d['GetMuteResponse']['CurrentMute']) != 0 182 | except KeyError: 183 | e = MissingAttributeException('get_mute') 184 | except (pyupnpv2.UnsupportedActionException, 185 | pyupnpv2.XmlParsingException, 186 | pyupnpv2.ConnectionErrorException, 187 | pyupnpv2.ConnectionTimeoutException) as e: 188 | pass 189 | logger.error('"{}" : {}'.format(self.label, str(e))) 190 | return None 191 | 192 | def set_mute(self, mute): 193 | try: 194 | return self.upnp_device.set_mute(mute) 195 | except (pyupnpv2.UnsupportedActionException, 196 | pyupnpv2.XmlParsingException, 197 | pyupnpv2.ConnectionErrorException, 198 | pyupnpv2.ConnectionTimeoutException) as e: 199 | pass 200 | logger.error('"{}" : {}'.format(self.label, str(e))) 201 | return None 202 | 203 | def get_mime_types(self): 204 | mime_types = [] 205 | try: 206 | d = self.upnp_device.get_protocol_info() 207 | sinks = d['GetProtocolInfoResponse']['Sink'] 208 | for sink in sinks.split(','): 209 | attributes = sink.strip().split(':') 210 | if len(attributes) >= 4: 211 | mime_types.append(attributes[2]) 212 | return mime_types 213 | except KeyError: 214 | e = MissingAttributeException('get_protocol_info') 215 | except (pyupnpv2.UnsupportedActionException, 216 | pyupnpv2.XmlParsingException, 217 | pyupnpv2.ConnectionErrorException, 218 | pyupnpv2.ConnectionTimeoutException) as e: 219 | pass 220 | logger.error('"{}" : {}'.format(self.label, str(e))) 221 | return None 222 | 223 | def get_transport_state(self): 224 | try: 225 | d = self.upnp_device.get_transport_info() 226 | state = d['GetTransportInfoResponse']['CurrentTransportState'] 227 | return state 228 | except KeyError: 229 | e = MissingAttributeException('get_transport_state') 230 | except (pyupnpv2.XmlParsingException, 231 | pyupnpv2.ConnectionErrorException, 232 | pyupnpv2.ConnectionTimeoutException) as e: 233 | pass 234 | logger.error('"{}" : {}'.format(self.label, str(e))) 235 | return None 236 | 237 | def get_position_info(self): 238 | try: 239 | d = self.upnp_device.get_position_info() 240 | state = d['GetPositionInfoResponse'] 241 | return state 242 | except KeyError: 243 | e = MissingAttributeException('get_position_info') 244 | except (pyupnpv2.UnsupportedActionException, 245 | pyupnpv2.XmlParsingException, 246 | pyupnpv2.ConnectionErrorException, 247 | pyupnpv2.ConnectionTimeoutException) as e: 248 | pass 249 | logger.error('"{}" : {}'.format(self.label, str(e))) 250 | return None 251 | 252 | def _update_current_state(self): 253 | start_time = time.time() 254 | while time.time() - start_time <= self.REQUEST_TIMEOUT: 255 | state = self.get_transport_state() 256 | if state is None: 257 | return False 258 | if state == pyupnpv2.UPNP_STATE_PLAYING: 259 | self.state = self.STATE_PLAYING 260 | return True 261 | elif state == pyupnpv2.UPNP_STATE_STOPPED: 262 | self.state = self.STATE_STOPPED 263 | return True 264 | time.sleep(1) 265 | return False 266 | 267 | 268 | class DLNAMediaRendererFactory(object): 269 | 270 | @classmethod 271 | def _apply_workarounds(cls, device): 272 | if device.manufacturer is not None and \ 273 | device.manufacturer.lower() == 'yamaha corporation': 274 | device.workarounds.append( 275 | pulseaudio_dlna.workarounds.YamahaWorkaround( 276 | device.upnp_device.description_xml)) 277 | return device 278 | 279 | @classmethod 280 | def from_url(cls, url): 281 | upnp_device = pyupnpv2.UpnpMediaRendererFactory.from_url(url) 282 | if upnp_device: 283 | return cls._apply_workarounds(DLNAMediaRenderer(upnp_device)) 284 | return None 285 | 286 | @classmethod 287 | def from_xml(cls, url, xml): 288 | upnp_device = pyupnpv2.UpnpMediaRendererFactory.from_xml(url, xml) 289 | if upnp_device: 290 | return cls._apply_workarounds(DLNAMediaRenderer(upnp_device)) 291 | return None 292 | 293 | @classmethod 294 | def from_header(cls, header): 295 | upnp_device = pyupnpv2.UpnpMediaRendererFactory.from_header(header) 296 | if upnp_device: 297 | return cls._apply_workarounds(DLNAMediaRenderer(upnp_device)) 298 | return None 299 | -------------------------------------------------------------------------------- /pulseaudio_dlna/plugins/dlna/ssdp/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | # This file is part of pulseaudio-dlna. 4 | 5 | # pulseaudio-dlna is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | 10 | # pulseaudio-dlna is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU General Public License for more details. 14 | 15 | # You should have received a copy of the GNU General Public License 16 | # along with pulseaudio-dlna. If not, see . 17 | 18 | from __future__ import unicode_literals 19 | 20 | import re 21 | 22 | 23 | def _get_header_map(header): 24 | header = re.findall(r"(?P.*?):(?P.*?)\n", header) 25 | header = { 26 | k.strip().lower(): v.strip() for k, v in dict(header).items() 27 | } 28 | return header 29 | 30 | 31 | def _get_device_id(header): 32 | if 'usn' in header: 33 | match = re.search( 34 | "(uuid:.*?)::(.*)", header['usn'], re.IGNORECASE) 35 | if match: 36 | return match.group(1) 37 | return None 38 | -------------------------------------------------------------------------------- /pulseaudio_dlna/plugins/dlna/ssdp/discover.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | # This file is part of pulseaudio-dlna. 4 | 5 | # pulseaudio-dlna is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | 10 | # pulseaudio-dlna is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU General Public License for more details. 14 | 15 | # You should have received a copy of the GNU General Public License 16 | # along with pulseaudio-dlna. If not, see . 17 | 18 | from __future__ import unicode_literals 19 | 20 | import socket 21 | import logging 22 | import chardet 23 | import threading 24 | import traceback 25 | 26 | import pulseaudio_dlna.utils.network 27 | import pulseaudio_dlna.plugins.dlna.ssdp 28 | 29 | logger = logging.getLogger('pulseaudio_dlna.discover') 30 | 31 | 32 | class SSDPDiscover(object): 33 | 34 | SSDP_ADDRESS = '239.255.255.250' 35 | SSDP_PORT = 1900 36 | SSDP_MX = 3 37 | SSDP_TTL = 10 38 | SSDP_AMOUNT = 5 39 | 40 | MSEARCH_PORT = 0 41 | MSEARCH_MSG = '\r\n'.join([ 42 | 'M-SEARCH * HTTP/1.1', 43 | 'HOST: {host}:{port}', 44 | 'MAN: "ssdp:discover"', 45 | 'MX: {mx}', 46 | 'ST: ssdp:all', 47 | ]) + '\r\n' * 2 48 | 49 | BUFFER_SIZE = 1024 50 | USE_SINGLE_SOCKET = True 51 | 52 | def __init__(self, cb_on_device_response, host=None): 53 | self.cb_on_device_response = cb_on_device_response 54 | self.host = host 55 | self.addresses = [] 56 | 57 | self.refresh_addresses() 58 | 59 | def refresh_addresses(self): 60 | self.addresses = pulseaudio_dlna.utils.network.ipv4_addresses() 61 | 62 | def search(self, ssdp_ttl=None, ssdp_mx=None, ssdp_amount=None): 63 | ssdp_mx = ssdp_mx or self.SSDP_MX 64 | ssdp_ttl = ssdp_ttl or self.SSDP_TTL 65 | ssdp_amount = ssdp_amount or self.SSDP_AMOUNT 66 | 67 | if self.USE_SINGLE_SOCKET: 68 | self._search(self.host or '', ssdp_ttl, ssdp_mx, ssdp_amount) 69 | else: 70 | if self.host: 71 | self._search(self.host, ssdp_ttl, ssdp_mx, ssdp_amount) 72 | else: 73 | threads = [] 74 | for addr in self.addresses: 75 | thread = threading.Thread( 76 | target=self._search, 77 | args=[addr, ssdp_ttl, ssdp_mx, ssdp_amount]) 78 | threads.append(thread) 79 | try: 80 | for thread in threads: 81 | thread.start() 82 | for thread in threads: 83 | thread.join() 84 | except: 85 | traceback.print_exc() 86 | logger.info('SSDPDiscover.search()') 87 | 88 | def _search(self, host, ssdp_ttl, ssdp_mx, ssdp_amount): 89 | logger.debug('Binding socket to "{}" ...'.format(host or '')) 90 | sock = socket.socket( 91 | socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP) 92 | sock.settimeout(ssdp_mx) 93 | sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) 94 | sock.setsockopt( 95 | socket.IPPROTO_IP, 96 | socket.IP_MULTICAST_TTL, 97 | ssdp_ttl) 98 | sock.bind((host, self.MSEARCH_PORT)) 99 | 100 | for i in range(1, ssdp_amount + 1): 101 | t = threading.Timer( 102 | float(i) / 2, self._send_discover, args=[sock, ssdp_mx]) 103 | t.start() 104 | 105 | while True: 106 | try: 107 | header, address = sock.recvfrom(self.BUFFER_SIZE) 108 | if self.cb_on_device_response: 109 | guess = chardet.detect(header) 110 | header = header.decode(guess['encoding']) 111 | header = pulseaudio_dlna.plugins.dlna.ssdp._get_header_map( 112 | header) 113 | self.cb_on_device_response(header, address) 114 | except socket.timeout: 115 | break 116 | sock.close() 117 | 118 | def _send_discover(self, sock, ssdp_mx): 119 | msg = self.MSEARCH_MSG.format( 120 | host=self.SSDP_ADDRESS, port=self.SSDP_PORT, mx=ssdp_mx) 121 | if self.USE_SINGLE_SOCKET: 122 | for addr in self.addresses: 123 | sock.setsockopt( 124 | socket.SOL_IP, socket.IP_MULTICAST_IF, 125 | socket.inet_aton(addr)) 126 | sock.sendto(msg, (self.SSDP_ADDRESS, self.SSDP_PORT)) 127 | else: 128 | sock.sendto(msg, (self.SSDP_ADDRESS, self.SSDP_PORT)) 129 | -------------------------------------------------------------------------------- /pulseaudio_dlna/plugins/dlna/ssdp/listener.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | # This file is part of pulseaudio-dlna. 4 | 5 | # pulseaudio-dlna is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | 10 | # pulseaudio-dlna is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU General Public License for more details. 14 | 15 | # You should have received a copy of the GNU General Public License 16 | # along with pulseaudio-dlna. If not, see . 17 | 18 | from __future__ import unicode_literals 19 | 20 | from gi.repository import GObject 21 | 22 | import SocketServer 23 | import logging 24 | import socket 25 | import struct 26 | import setproctitle 27 | import time 28 | import chardet 29 | 30 | import pulseaudio_dlna.plugins.dlna.ssdp 31 | 32 | logger = logging.getLogger('pulseaudio_dlna.plugins.dlna.ssdp') 33 | 34 | 35 | class SSDPHandler(SocketServer.BaseRequestHandler): 36 | 37 | SSDP_ALIVE = 'ssdp:alive' 38 | SSDP_BYEBYE = 'ssdp:byebye' 39 | 40 | def handle(self): 41 | packet = self._decode(self.request[0]) 42 | lines = packet.splitlines() 43 | if len(lines) > 0: 44 | if self._is_notify_method(lines[0]): 45 | header = pulseaudio_dlna.plugins.dlna.ssdp._get_header_map( 46 | packet) 47 | nts_header = header.get('nts', None) 48 | if nts_header and nts_header == self.SSDP_ALIVE: 49 | if self.server.cb_on_device_alive: 50 | self.server.cb_on_device_alive(header) 51 | elif nts_header and nts_header == self.SSDP_BYEBYE: 52 | if self.server.cb_on_device_byebye: 53 | self.server.cb_on_device_byebye(header) 54 | 55 | def _decode(self, data): 56 | guess = chardet.detect(data) 57 | for encoding in [guess['encoding'], 'utf-8', 'ascii']: 58 | try: 59 | return data.decode(encoding) 60 | except: 61 | pass 62 | logger.error('Could not decode SSDP packet.') 63 | return '' 64 | 65 | def _is_notify_method(self, method_header): 66 | method = self._get_method(method_header) 67 | return method == 'NOTIFY' 68 | 69 | def _get_method(self, method_header): 70 | return method_header.split(' ')[0] 71 | 72 | 73 | class SSDPListener(SocketServer.UDPServer): 74 | 75 | SSDP_ADDRESS = '239.255.255.250' 76 | SSDP_PORT = 1900 77 | SSDP_TTL = 10 78 | 79 | DISABLE_SSDP_LISTENER = False 80 | 81 | def __init__(self, cb_on_device_alive=None, cb_on_device_byebye=None, 82 | host=None): 83 | self.cb_on_device_alive = cb_on_device_alive 84 | self.cb_on_device_byebye = cb_on_device_byebye 85 | self.host = host 86 | 87 | def run(self, ttl=None): 88 | if self.DISABLE_SSDP_LISTENER: 89 | return 90 | 91 | self.allow_reuse_address = True 92 | SocketServer.UDPServer.__init__( 93 | self, (self.host or '', self.SSDP_PORT), SSDPHandler) 94 | self.socket.setsockopt( 95 | socket.IPPROTO_IP, 96 | socket.IP_ADD_MEMBERSHIP, 97 | self._multicast_struct(self.SSDP_ADDRESS)) 98 | self.socket.setsockopt( 99 | socket.IPPROTO_IP, 100 | socket.IP_MULTICAST_TTL, 101 | self.SSDP_TTL) 102 | 103 | if ttl: 104 | GObject.timeout_add(ttl * 1000, self.shutdown) 105 | 106 | setproctitle.setproctitle('ssdp_listener') 107 | self.serve_forever(self) 108 | logger.info('SSDPListener.run()') 109 | 110 | def _multicast_struct(self, address): 111 | return struct.pack( 112 | '4sl', socket.inet_aton(address), socket.INADDR_ANY) 113 | 114 | 115 | class GobjectMainLoopMixin: 116 | 117 | def serve_forever(self, poll_interval=0.5): 118 | self.__running = False 119 | self.__mainloop = GObject.MainLoop() 120 | 121 | if hasattr(self, 'socket'): 122 | GObject.io_add_watch( 123 | self, GObject.IO_IN | GObject.IO_PRI, self._on_new_request) 124 | 125 | context = self.__mainloop.get_context() 126 | try: 127 | while not self.__running: 128 | if context.pending(): 129 | context.iteration(True) 130 | else: 131 | time.sleep(0.01) 132 | except KeyboardInterrupt: 133 | pass 134 | logger.info('SSDPListener.serve_forever()') 135 | 136 | def _on_new_request(self, sock, *args): 137 | self._handle_request_noblock() 138 | return True 139 | 140 | def shutdown(self, *args): 141 | logger.info('SSDPListener.shutdown()') 142 | try: 143 | self.socket.shutdown(socket.SHUT_RDWR) 144 | except socket.error: 145 | pass 146 | self.__running = True 147 | self.server_close() 148 | 149 | 150 | class ThreadedSSDPListener( 151 | GobjectMainLoopMixin, SocketServer.ThreadingMixIn, SSDPListener): 152 | pass 153 | -------------------------------------------------------------------------------- /pulseaudio_dlna/recorders.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | # This file is part of pulseaudio-dlna. 4 | 5 | # pulseaudio-dlna is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | 10 | # pulseaudio-dlna is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU General Public License for more details. 14 | 15 | # You should have received a copy of the GNU General Public License 16 | # along with pulseaudio-dlna. If not, see . 17 | 18 | from __future__ import unicode_literals 19 | 20 | import pulseaudio_dlna.codecs 21 | 22 | 23 | class BaseRecorder(object): 24 | def __init__(self): 25 | self._command = [] 26 | 27 | @property 28 | def command(self): 29 | return self._command 30 | 31 | 32 | class PulseaudioRecorder(BaseRecorder): 33 | def __init__(self, monitor, codec=None): 34 | BaseRecorder.__init__(self) 35 | self._monitor = monitor 36 | self._codec = codec 37 | self._command = ['parec', '--format=s16le'] 38 | 39 | @property 40 | def monitor(self): 41 | return self._monitor 42 | 43 | @property 44 | def codec(self): 45 | return self._codec 46 | 47 | @property 48 | def file_format(self): 49 | if isinstance(self.codec, pulseaudio_dlna.codecs.WavCodec): 50 | return 'wav' 51 | elif isinstance(self.codec, pulseaudio_dlna.codecs.OggCodec): 52 | return 'oga' 53 | elif isinstance(self.codec, pulseaudio_dlna.codecs.FlacCodec): 54 | return 'flac' 55 | return None 56 | 57 | @property 58 | def command(self): 59 | if not self.codec: 60 | return super(PulseaudioRecorder, self).command + ['-d', self.monitor] 61 | else: 62 | return super(PulseaudioRecorder, self).command + [ 63 | '-d', self.monitor, 64 | '--file-format={}'.format(self.file_format), 65 | ] 66 | -------------------------------------------------------------------------------- /pulseaudio_dlna/rules.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | # This file is part of pulseaudio-dlna. 4 | 5 | # pulseaudio-dlna is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | 10 | # pulseaudio-dlna is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU General Public License for more details. 14 | 15 | # You should have received a copy of the GNU General Public License 16 | # along with pulseaudio-dlna. If not, see . 17 | 18 | from __future__ import unicode_literals 19 | 20 | import functools 21 | import logging 22 | import inspect 23 | import sys 24 | 25 | logger = logging.getLogger('pulseaudio_dlna.rules') 26 | 27 | RULES = {} 28 | 29 | 30 | class RuleNotFoundException(Exception): 31 | def __init__(self, identifier): 32 | Exception.__init__( 33 | self, 34 | 'You specified an invalid rule identifier "{}"!'.format(identifier) 35 | ) 36 | 37 | 38 | @functools.total_ordering 39 | class BaseRule(object): 40 | def __str__(self): 41 | return self.__class__.__name__ 42 | 43 | def __eq__(self, other): 44 | if type(other) is type: 45 | return type(self) is other 46 | try: 47 | if isinstance(other, basestring): 48 | return type(self) is RULES[other] 49 | except: 50 | raise RuleNotFoundException(other) 51 | return type(self) is type(other) 52 | 53 | def __gt__(self, other): 54 | if type(other) is type: 55 | return type(self) > other 56 | try: 57 | if isinstance(other, basestring): 58 | return type(self) > RULES[other] 59 | except: 60 | raise RuleNotFoundException() 61 | return type(self) > type(other) 62 | 63 | def to_json(self): 64 | attributes = [] 65 | d = { 66 | k: v for k, v in self.__dict__.iteritems() 67 | if k not in attributes 68 | } 69 | d['name'] = str(self) 70 | return d 71 | 72 | 73 | class FAKE_HTTP_CONTENT_LENGTH(BaseRule): 74 | pass 75 | 76 | 77 | class DISABLE_DEVICE_STOP(BaseRule): 78 | pass 79 | 80 | 81 | class DISABLE_MIMETYPE_CHECK(BaseRule): 82 | pass 83 | 84 | 85 | class DISABLE_PLAY_COMMAND(BaseRule): 86 | pass 87 | 88 | 89 | class REQUEST_TIMEOUT(BaseRule): 90 | def __init__(self, timeout=None): 91 | self.timeout = float(timeout or 10) 92 | 93 | def __str__(self): 94 | return '{} (timeout="{}")'.format( 95 | self.__class__.__name__, self.timeout) 96 | 97 | 98 | # class EXAMPLE_PROPERTIES_RULE(BaseRule): 99 | # def __init__(self, prop1=None, prop2=None): 100 | # self.prop1 = prop1 or 'abc' 101 | # self.prop2 = prop2 or 'def' 102 | 103 | # def __str__(self): 104 | # return '{} (prop1="{}",prop2="{}")'.format( 105 | # self.__class__.__name__, self.prop1, self.prop2) 106 | 107 | 108 | class Rules(list): 109 | def __init__(self, *args, **kwargs): 110 | list.__init__(self, ()) 111 | self.append(*args) 112 | 113 | def append(self, *args): 114 | for arg in args: 115 | if type(arg) is list: 116 | for value in arg: 117 | self.append(value) 118 | elif type(arg) is dict: 119 | try: 120 | name = arg.get('name', 'missing') 121 | rule = RULES[name]() 122 | except KeyError: 123 | raise RuleNotFoundException(name) 124 | attributes = ['name'] 125 | for k, v in arg.iteritems(): 126 | if hasattr(rule, k) and k not in attributes: 127 | setattr(rule, k, v) 128 | self._add_rule(rule) 129 | elif isinstance(arg, basestring): 130 | try: 131 | rule = RULES[arg]() 132 | self._add_rule(rule) 133 | except KeyError: 134 | raise RuleNotFoundException(arg) 135 | elif isinstance(arg, BaseRule): 136 | self._add_rule(arg) 137 | else: 138 | raise RuleNotFoundException('?') 139 | 140 | def _add_rule(self, rule): 141 | if rule not in self: 142 | list.append(self, rule) 143 | 144 | def to_json(self): 145 | return [rule.to_json() for rule in self] 146 | 147 | 148 | def load_rules(): 149 | if len(RULES) == 0: 150 | logger.debug('Loaded rules:') 151 | for name, _type in inspect.getmembers(sys.modules[__name__]): 152 | if inspect.isclass(_type) and issubclass(_type, BaseRule): 153 | if _type is not BaseRule: 154 | logger.debug(' {} = {}'.format(name, _type)) 155 | RULES[name] = _type 156 | return None 157 | 158 | load_rules() 159 | -------------------------------------------------------------------------------- /pulseaudio_dlna/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/masmu/pulseaudio-dlna/4472928dd23f274193f14289f59daec411023ab0/pulseaudio_dlna/utils/__init__.py -------------------------------------------------------------------------------- /pulseaudio_dlna/utils/encoding.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | # This file is part of pulseaudio-dlna. 4 | 5 | # pulseaudio-dlna is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | 10 | # pulseaudio-dlna is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU General Public License for more details. 14 | 15 | # You should have received a copy of the GNU General Public License 16 | # along with pulseaudio-dlna. If not, see . 17 | 18 | from __future__ import unicode_literals 19 | 20 | import logging 21 | import sys 22 | import locale 23 | import chardet 24 | 25 | logger = logging.getLogger('pulseaudio_dlna.plugins.utils.encoding') 26 | 27 | 28 | class NotBytesException(Exception): 29 | def __init__(self, var): 30 | Exception.__init__( 31 | self, 32 | 'The specified variable is {}". ' 33 | 'Must be bytes.'.format(type(var)) 34 | ) 35 | 36 | 37 | def decode_default(bytes): 38 | if type(bytes) is not str: 39 | raise NotBytesException(bytes) 40 | guess = chardet.detect(bytes) 41 | encodings = { 42 | 'sys.stdout.encoding': sys.stdout.encoding, 43 | 'locale.getpreferredencoding': locale.getpreferredencoding(), 44 | 'chardet.detect': guess['encoding'], 45 | 'utf-8': 'utf-8', 46 | 'latin1': 'latin1', 47 | } 48 | for encoding in encodings.values(): 49 | if encoding and encoding != 'ascii': 50 | try: 51 | return bytes.decode(encoding) 52 | except UnicodeDecodeError: 53 | continue 54 | try: 55 | return bytes.decode('ascii', errors='replace') 56 | except UnicodeDecodeError: 57 | logger.error( 58 | 'Decoding failed using the following encodings: "{}"'.format( 59 | ','.join( 60 | ['{}:{}'.format(f, e) for f, e in encodings.items()] 61 | ))) 62 | return 'Unknown' 63 | 64 | 65 | def _bytes2hex(bytes, seperator=':'): 66 | if type(bytes) is not str: 67 | raise NotBytesException(bytes) 68 | return seperator.join('{:02x}'.format(ord(b)) for b in bytes) 69 | 70 | 71 | def _hex2bytes(hex, seperator=':'): 72 | return b''.join(chr(int(h, 16)) for h in hex.split(seperator)) 73 | -------------------------------------------------------------------------------- /pulseaudio_dlna/utils/git.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | # This file is part of pulseaudio-dlna. 4 | 5 | # pulseaudio-dlna is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | 10 | # pulseaudio-dlna is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU General Public License for more details. 14 | 15 | # You should have received a copy of the GNU General Public License 16 | # along with pulseaudio-dlna. If not, see . 17 | 18 | from __future__ import unicode_literals 19 | 20 | import os 21 | 22 | GIT_DIRECTORY = '../../.git/' 23 | 24 | 25 | def get_head_version(): 26 | 27 | def _get_first_line(path): 28 | try: 29 | with open(path) as f: 30 | content = f.readlines() 31 | return content[0] 32 | except EnvironmentError: 33 | return None 34 | 35 | module_path = os.path.dirname(os.path.abspath(__file__)) 36 | head_path = os.path.join(module_path, GIT_DIRECTORY, 'HEAD') 37 | line = _get_first_line(head_path) 38 | 39 | if not line: 40 | return None, None 41 | elif line.startswith('ref: '): 42 | prefix, ref_path = [s.strip() for s in line.split('ref: ')] 43 | branch = os.path.basename(ref_path) 44 | ref_path = os.path.join(module_path, GIT_DIRECTORY, ref_path) 45 | return branch, (_get_first_line(ref_path) or 'unknown').strip() 46 | else: 47 | return 'detached-head', line.strip() 48 | -------------------------------------------------------------------------------- /pulseaudio_dlna/utils/network.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | # This file is part of pulseaudio-dlna. 4 | 5 | # pulseaudio-dlna is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | 10 | # pulseaudio-dlna is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU General Public License for more details. 14 | 15 | # You should have received a copy of the GNU General Public License 16 | # along with pulseaudio-dlna. If not, see . 17 | 18 | from __future__ import unicode_literals 19 | 20 | import netifaces 21 | import traceback 22 | import socket 23 | import logging 24 | 25 | logger = logging.getLogger('pulseaudio_dlna.utils.network') 26 | 27 | LOOPBACK_IP = '127.0.0.1' 28 | 29 | 30 | def default_ipv4(): 31 | try: 32 | default_if = netifaces.gateways()['default'][netifaces.AF_INET][1] 33 | return netifaces.ifaddresses(default_if)[netifaces.AF_INET][0]['addr'] 34 | except: 35 | traceback.print_exc() 36 | return None 37 | 38 | 39 | def ipv4_addresses(include_loopback=False): 40 | ips = [] 41 | for iface in netifaces.interfaces(): 42 | for link in netifaces.ifaddresses(iface).get(netifaces.AF_INET, []): 43 | ip = link.get('addr', None) 44 | if ip: 45 | if ip != LOOPBACK_IP or include_loopback is True: 46 | ips.append(ip) 47 | return ips 48 | 49 | 50 | def get_host_by_ip(ip): 51 | try: 52 | return __pyroute2_get_host_by_ip(ip) 53 | except ImportError: 54 | logger.warning( 55 | 'Could not import module "pyroute2". ' 56 | 'Falling back to module "netaddr"!') 57 | try: 58 | return __netaddr_get_host_by_ip(ip) 59 | except ImportError: 60 | logger.critical( 61 | 'Could not import module "netaddr". ' 62 | 'Either "pyroute2" or "netaddr" must be available for automatic ' 63 | 'interface detection! You can manually select the appropriate ' 64 | 'host yourself via the --host option.') 65 | return None 66 | 67 | 68 | def __pyroute2_get_host_by_ip(ip): 69 | import pyroute2 70 | ipr = pyroute2.IPRoute() 71 | routes = ipr.get_routes(family=socket.AF_INET, dst=ip) 72 | ipr.close() 73 | for route in routes: 74 | for attr in route.get('attrs', []): 75 | if type(attr) is list: 76 | if attr[0] == 'RTA_PREFSRC': 77 | return attr[1] 78 | else: 79 | if attr.cell[0] == 'RTA_PREFSRC': 80 | return attr.get_value() 81 | logger.critical( 82 | '__pyroute2_get_host_by_ip() - No host found for IP {}!'.format(ip)) 83 | return None 84 | 85 | 86 | def __netaddr_get_host_by_ip(ip): 87 | import netaddr 88 | host = netaddr.IPAddress(ip) 89 | for iface in netifaces.interfaces(): 90 | for link in netifaces.ifaddresses(iface).get(netifaces.AF_INET, []): 91 | addr = link.get('addr', None) 92 | netmask = link.get('netmask', None) 93 | if addr and netmask: 94 | if host in netaddr.IPNetwork('{}/{}'.format(addr, netmask)): 95 | logger.debug( 96 | 'Selecting host "{}" for IP "{}"'.format(addr, ip)) 97 | return addr 98 | logger.critical( 99 | '__netaddr_get_host_by_ip - No host found for IP {}!'.format(ip)) 100 | return None 101 | -------------------------------------------------------------------------------- /pulseaudio_dlna/utils/psutil.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | # This file is part of pulseaudio-dlna. 4 | 5 | # pulseaudio-dlna is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | 10 | # pulseaudio-dlna is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU General Public License for more details. 14 | 15 | # You should have received a copy of the GNU General Public License 16 | # along with pulseaudio-dlna. If not, see . 17 | 18 | from __future__ import absolute_import 19 | from __future__ import unicode_literals 20 | 21 | import logging 22 | import psutil 23 | 24 | logger = logging.getLogger('pulseaudio_dlna.utils.psutil') 25 | 26 | 27 | __series__ = int(psutil.__version__[:1]) 28 | 29 | NoSuchProcess = psutil.NoSuchProcess 30 | TimeoutExpired = psutil.TimeoutExpired 31 | 32 | 33 | def wait_procs(*args, **kwargs): 34 | return psutil.wait_procs(*args, **kwargs) 35 | 36 | 37 | def process_iter(*args, **kwargs): 38 | for p in psutil.process_iter(*args, **kwargs): 39 | p.__class__ = Process 40 | yield p 41 | 42 | 43 | if __series__ >= 2: 44 | class Process(psutil.Process): 45 | pass 46 | else: 47 | class Process(psutil.Process): 48 | 49 | def children(self, *args, **kwargs): 50 | return self.get_children(*args, **kwargs) 51 | 52 | def name(self): 53 | return self._platform_impl.get_process_name() 54 | 55 | def uids(self): 56 | return self._platform_impl.get_process_uids() 57 | 58 | def gids(self): 59 | return self._platform_impl.get_process_gids() 60 | -------------------------------------------------------------------------------- /pulseaudio_dlna/utils/subprocess.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | # This file is part of pulseaudio-dlna. 4 | 5 | # pulseaudio-dlna is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | 10 | # pulseaudio-dlna is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU General Public License for more details. 14 | 15 | # You should have received a copy of the GNU General Public License 16 | # along with pulseaudio-dlna. If not, see . 17 | 18 | from __future__ import absolute_import 19 | from __future__ import unicode_literals 20 | 21 | from gi.repository import GObject 22 | 23 | import subprocess 24 | import threading 25 | import os 26 | import sys 27 | import logging 28 | 29 | logger = logging.getLogger('pulseaudio_dlna.utils.subprocess') 30 | 31 | 32 | class Subprocess(subprocess.Popen): 33 | def __init__(self, cmd, uid=None, gid=None, cwd=None, env=None, 34 | *args, **kwargs): 35 | 36 | self.uid = uid 37 | self.gid = gid 38 | self.cwd = cwd 39 | self.env = env 40 | 41 | super(Subprocess, self).__init__( 42 | cmd, 43 | preexec_fn=self.demote(uid, gid), cwd=cwd, env=env, 44 | stdout=subprocess.PIPE, stderr=subprocess.PIPE, 45 | bufsize=1) 46 | 47 | def demote(self, uid, gid): 48 | def fn_uid_gid(): 49 | os.setgid(gid) 50 | os.setuid(uid) 51 | 52 | def fn_uid(): 53 | os.setuid(uid) 54 | 55 | def fn_gid(): 56 | os.setgid(gid) 57 | 58 | def fn_nop(): 59 | pass 60 | 61 | if uid and gid: 62 | return fn_uid_gid 63 | elif uid: 64 | return fn_uid 65 | elif gid: 66 | return fn_gid 67 | return fn_nop 68 | 69 | 70 | class GobjectMainLoopMixin(object): 71 | def __init__(self, *args, **kwargs): 72 | super(GobjectMainLoopMixin, self).__init__(*args, **kwargs) 73 | for pipe in [self.stdout, self.stderr]: 74 | GObject.io_add_watch( 75 | pipe, GObject.IO_IN | GObject.IO_PRI, self._on_new_data) 76 | 77 | def _on_new_data(self, fd, condition): 78 | line = fd.readline() 79 | sys.stdout.write(line) 80 | sys.stdout.flush() 81 | return True 82 | 83 | 84 | class ThreadedMixIn(object): 85 | def __init__(self, *args, **kwargs): 86 | super(ThreadedMixIn, self).__init__(*args, **kwargs) 87 | self.init_thread(self.stdout) 88 | self.init_thread(self.stderr) 89 | 90 | def init_thread(self, pipe): 91 | def read_all(pipe): 92 | with pipe: 93 | for line in iter(pipe.readline, ''): 94 | sys.stdout.write(line) 95 | sys.stdout.flush() 96 | 97 | t = threading.Thread(target=read_all, args=(pipe, )) 98 | t.daemon = True 99 | t.start() 100 | 101 | 102 | class ThreadedSubprocess(ThreadedMixIn, Subprocess): 103 | pass 104 | 105 | 106 | class GobjectSubprocess(GobjectMainLoopMixin, Subprocess): 107 | pass 108 | -------------------------------------------------------------------------------- /pulseaudio_dlna/workarounds.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | # This file is part of pulseaudio-dlna. 4 | 5 | # pulseaudio-dlna is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | 10 | # pulseaudio-dlna is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU General Public License for more details. 14 | 15 | # You should have received a copy of the GNU General Public License 16 | # along with pulseaudio-dlna. If not, see . 17 | 18 | from __future__ import unicode_literals 19 | 20 | import logging 21 | from lxml import etree 22 | import requests 23 | import urlparse 24 | import traceback 25 | 26 | 27 | logger = logging.getLogger('pulseaudio_dlna.workarounds') 28 | 29 | 30 | class BaseWorkaround(object): 31 | """ 32 | Define functions which are called at specific situations during the 33 | application. 34 | 35 | Those may be: 36 | - before_register 37 | - after_register 38 | - before_play 39 | - after_play 40 | - before_stop 41 | - after_stop 42 | 43 | This may be extended in the future. 44 | """ 45 | 46 | ENABLED = True 47 | 48 | def __init__(self): 49 | pass 50 | 51 | def run(self, method_name, *args, **kwargs): 52 | method = getattr(self, method_name, None) 53 | if self.ENABLED and method and callable(method): 54 | logger.info('Running workaround "{}".'.format(method_name)) 55 | method(*args, **kwargs) 56 | 57 | 58 | class YamahaWorkaround(BaseWorkaround): 59 | # Misc constants 60 | REQUEST_TIMEOUT = 5 61 | ENCODING = 'utf-8' 62 | URL_FORMAT = 'http://{ip}:{port}{url}' 63 | 64 | # MediaRenderer constants 65 | MR_YAMAHA_PREFIX = 'yamaha' 66 | MR_YAMAHA_DEVICE = MR_YAMAHA_PREFIX + ':' + 'X_device' 67 | MR_YAMAHA_URLBASE = MR_YAMAHA_PREFIX + ':' + 'X_URLBase' 68 | MR_YAMAHA_SERVICELIST = MR_YAMAHA_PREFIX + ':' + 'X_serviceList' 69 | MR_YAMAHA_SERVICE = MR_YAMAHA_PREFIX + ':' + 'X_service' 70 | MR_YAMAHA_CONTROLURL = MR_YAMAHA_PREFIX + ':' + 'X_controlURL' 71 | 72 | MR_YAMAHA_URLBASE_PATH = '/'.join([MR_YAMAHA_DEVICE, MR_YAMAHA_URLBASE]) 73 | MR_YAMAHA_CONTROLURL_PATH = '/'.join( 74 | [MR_YAMAHA_DEVICE, MR_YAMAHA_SERVICELIST, MR_YAMAHA_SERVICE, 75 | MR_YAMAHA_CONTROLURL]) 76 | 77 | # YamahaRemoteControl constants 78 | YRC_TAG_ROOT = 'YAMAHA_AV' 79 | YRC_KEY_RC = 'RC' 80 | YRC_CMD_GETPARAM = 'GetParam' 81 | YRC_BASEPATH_CONFIG = 'Config' 82 | YRC_BASEPATH_BASICSTATUS = 'Basic_Status' 83 | YRC_BASEPATH_FEATURES = 'Feature_Existence' 84 | YRC_BASEPATH_INPUTNAMES = 'Name/Input' 85 | YRC_BASEPATH_POWER = 'Power_Control/Power' 86 | YRC_BASEPATH_SOURCE = 'Input/Input_Sel' 87 | YRC_VALUE_POWER_ON = 'On' 88 | YRC_VALUE_POWER_OFF = 'Standby' 89 | 90 | YRC_REQUEST_CONTENTTYPE = 'text/xml; charset="{encoding}"'.format( 91 | encoding=ENCODING) 92 | YRC_REQUEST_TEMPLATE = \ 93 | '' \ 94 | '{request}' 95 | 96 | # Known server modes 97 | YRC_SERVER_MODES = ['SERVER', 'PC'] 98 | 99 | def __init__(self, xml): 100 | BaseWorkaround.__init__(self) 101 | self.enabled = False 102 | 103 | self.control_url = None 104 | self.ip = None 105 | self.port = None 106 | 107 | self.zones = None 108 | self.sources = None 109 | 110 | self.server_mode_zone = None 111 | self.server_mode_source = None 112 | 113 | try: 114 | # Initialize YamahaRemoteControl interface 115 | if (not self._detect_remotecontrolinterface(xml)): 116 | raise Exception() 117 | self.enabled = True 118 | except: 119 | logger.warning( 120 | 'The YamahaWorkaround initialization failed. ' 121 | 'Automatic source switching will not be enabled' 122 | ' - Please switch to server mode manually to enable UPnP' 123 | ' streaming') 124 | logger.debug(traceback.format_exc()) 125 | 126 | def _detect_remotecontrolinterface(self, xml): 127 | # Check for YamahaRemoteControl support 128 | if (not self._parse_xml(xml)): 129 | logger.info('No Yamaha RemoteControl interface detected') 130 | return False 131 | logger.info('Yamaha RemoteControl found: ' + self.URL_FORMAT.format( 132 | ip=self.ip, port=self.port, url=self.control_url)) 133 | # Get supported features 134 | self.zones, self.sources = self._query_supported_features() 135 | if ((self.zones is None) or (self.sources is None)): 136 | logger.error('Failed to query features') 137 | return False 138 | # Determine main zone 139 | logger.info('Supported zones: ' + ', '.join(self.zones)) 140 | self.server_mode_zone = self.zones[0] 141 | logger.info('Using \'{zone}\' as main zone'.format( 142 | zone=self.server_mode_zone 143 | )) 144 | # Determine UPnP server source 145 | if (self.sources): 146 | logger.info('Supported sources: ' + ', '.join(self.sources)) 147 | for source in self.YRC_SERVER_MODES: 148 | if (source not in self.sources): 149 | continue 150 | self.server_mode_source = source 151 | break 152 | else: 153 | logger.warning('Querying supported features failed') 154 | if (not self.server_mode_source): 155 | logger.warning('Unable to determine UPnP server mode source') 156 | return False 157 | logger.info('Using \'{source}\' as UPnP server mode source'.format( 158 | source=self.server_mode_source 159 | )) 160 | return True 161 | 162 | def _parse_xml(self, xml): 163 | # Parse MediaRenderer description XML 164 | xml_root = etree.fromstring(xml) 165 | namespaces = xml_root.nsmap 166 | namespaces.pop(None, None) 167 | 168 | # Determine AVRC URL 169 | url_base = xml_root.find(self.MR_YAMAHA_URLBASE_PATH, namespaces) 170 | control_url = xml_root.find(self.MR_YAMAHA_CONTROLURL_PATH, namespaces) 171 | if ((url_base is None) or (control_url is None)): 172 | return False 173 | ip, port = urlparse.urlparse(url_base.text).netloc.split(':') 174 | if ((not ip) or (not port)): 175 | return False 176 | 177 | self.ip = ip 178 | self.port = port 179 | self.control_url = control_url.text 180 | return True 181 | 182 | def _generate_request(self, cmd, root, path, value): 183 | # Generate headers 184 | headers = { 185 | 'Content-Type': self.YRC_REQUEST_CONTENTTYPE, 186 | } 187 | # Generate XML request 188 | tags = path.split('/') 189 | if (root): 190 | tags = [root] + tags 191 | request = '' 192 | for tag in tags: 193 | request += '<{tag}>'.format(tag=tag) 194 | request += value 195 | for tag in reversed(tags): 196 | request += ''.format(tag=tag) 197 | body = self.YRC_REQUEST_TEMPLATE.format( 198 | encoding=self.ENCODING, 199 | cmd=cmd, 200 | request=request, 201 | ) 202 | # Construct URL 203 | url = self.URL_FORMAT.format( 204 | ip=self.ip, 205 | port=self.port, 206 | url=self.control_url, 207 | ) 208 | return headers, body, url 209 | 210 | def _get(self, root, path, value, filter_path=None): 211 | # Generate request 212 | headers, data, url = self._generate_request('GET', root, path, value) 213 | # POST request 214 | try: 215 | logger.debug('Yamaha RC request: '+data) 216 | response = requests.post( 217 | url, data.encode(self.ENCODING), 218 | headers=headers, timeout=self.REQUEST_TIMEOUT) 219 | logger.debug('Yamaha RC response: ' + response.text) 220 | if response.status_code != 200: 221 | logger.error( 222 | 'Yamaha RC request failed - Status code: {code}'.format( 223 | code=response.status_code)) 224 | return None 225 | except requests.exceptions.Timeout: 226 | logger.error('Yamaha RC request failed - Connection timeout') 227 | return None 228 | # Parse response 229 | xml_root = etree.fromstring(response.content) 230 | if (xml_root.tag != self.YRC_TAG_ROOT): 231 | logger.error("Malformed response: Root tag missing") 232 | return None 233 | # Parse response code 234 | rc = xml_root.get(self.YRC_KEY_RC) 235 | if (not rc): 236 | logger.error("Malformed response: RC attribute missing") 237 | return None 238 | rc = int(rc) 239 | if (rc > 0): 240 | logger.error( 241 | 'Yamaha RC request failed - Response code: {code}'.format( 242 | code=rc)) 243 | return rc 244 | # Only return subtree 245 | result_path = [] 246 | if (root): 247 | result_path.append(root) 248 | result_path.append(path) 249 | if (filter_path): 250 | result_path.append(filter_path) 251 | result_path = '/'.join(result_path) 252 | return xml_root.find(result_path) 253 | 254 | def _put(self, root, path, value): 255 | # Generate request 256 | headers, data, url = self._generate_request('PUT', root, path, value) 257 | # POST request 258 | try: 259 | logger.debug('Yamaha RC request: '+data) 260 | response = requests.post( 261 | url, data.encode(self.ENCODING), 262 | headers=headers, timeout=self.REQUEST_TIMEOUT) 263 | logger.debug('Yamaha RC response: ' + response.text) 264 | if response.status_code != 200: 265 | logger.error( 266 | 'Yamaha RC request failed - Status code: {code}'.format( 267 | code=response.status_code)) 268 | return False 269 | except requests.exceptions.Timeout: 270 | logger.error('Yamaha RC request failed - Connection timeout') 271 | return None 272 | # Parse response 273 | xml_root = etree.fromstring(response.content) 274 | if (xml_root.tag != self.YRC_TAG_ROOT): 275 | logger.error("Malformed response: Root tag missing") 276 | return None 277 | # Parse response code 278 | rc = xml_root.get(self.YRC_KEY_RC) 279 | if (not rc): 280 | logger.error("Malformed response: RC attribute missing") 281 | return None 282 | rc = int(rc) 283 | if (rc > 0): 284 | logger.error( 285 | 'Yamaha RC request failed - Response code: {code}'.format( 286 | code=rc)) 287 | return rc 288 | return 0 289 | 290 | def _query_supported_features(self): 291 | xml_response = self._get('System', 'Config', self.YRC_CMD_GETPARAM) 292 | if (xml_response is None): 293 | return None, None 294 | 295 | xml_features = xml_response.find(self.YRC_BASEPATH_FEATURES) 296 | if (xml_features is None): 297 | logger.debug('Failed to find feature description') 298 | return None, None 299 | 300 | # Features can be retrieved in different ways, most probably 301 | # dependending on the recever's firmware / protocol version 302 | # Here are the different responses known up to now: 303 | # 304 | # 1. Comma-separated list of all features in one single tag, containing 305 | # all input sources 306 | # 2. Each feature is enclosed by a tag along with context information 307 | # depending on the XML path: 308 | # - YRC_BASEPATH_FEATURES: availability and/or support 309 | # (0 == not supported, 1 == supported) 310 | # - YRC_BASEPATH_INPUTNAMES: input/source name 311 | # Every feature is a input source, if it does not contain the 312 | # substring 'Zone'. Otherwise, it is a zone supported by the 313 | # receiver. 314 | zones = [] 315 | sources = [] 316 | if (xml_features.text): 317 | # Format 1: 318 | sources = xml_features.text.split(',') 319 | else: 320 | # Format 2: 321 | for child in xml_features.getchildren(): 322 | if ((not child.text) or (int(child.text) == 0)): 323 | continue 324 | if ('Zone' in child.tag): 325 | zones.append(child.tag) 326 | else: 327 | sources.append(child.tag) 328 | xml_names = xml_response.find(self.YRC_BASEPATH_INPUTNAMES) 329 | if (xml_names is not None): 330 | for child in xml_names.getchildren(): 331 | sources.append(child.tag) 332 | 333 | # If we got no zones up to now, we have to assume, that the receiver 334 | # has no multi zone support. Thus there can be only one! 335 | # Let's call it "System" and pray for the best! 336 | if (len(zones) == 0): 337 | zones.append('System') 338 | 339 | return zones, sources 340 | 341 | def _set_source(self, value, zone=None): 342 | if (not zone): 343 | zone = self.server_mode_zone 344 | self._put(zone, self.YRC_BASEPATH_SOURCE, value) 345 | 346 | def before_register(self): 347 | if (not self.enabled): 348 | return 349 | logger.info('Switching to UPnP server mode') 350 | self._set_source(self.server_mode_source) 351 | -------------------------------------------------------------------------------- /samples/images/application.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/masmu/pulseaudio-dlna/4472928dd23f274193f14289f59daec411023ab0/samples/images/application.png -------------------------------------------------------------------------------- /samples/images/pavucontrol-sample.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/masmu/pulseaudio-dlna/4472928dd23f274193f14289f59daec411023ab0/samples/images/pavucontrol-sample.png -------------------------------------------------------------------------------- /scripts/bootstrap.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | function lecho() { 4 | echo "" 5 | echo "####################################################################" 6 | echo "# $1" 7 | echo "####################################################################" 8 | } 9 | 10 | function install_tmate() { 11 | ensure_private_ssh_key 12 | lecho 'Installing tmate ...' 13 | hash tmate 2>/dev/null || { 14 | sudo add-apt-repository -y ppa:tmate.io/archive 15 | sudo apt-get update 16 | sudo apt-get -y install tmate tmux 17 | } 18 | hash tmux 2>/dev/null || { 19 | sudo apt-get -y install tmux 20 | } 21 | [[ -f ~/.tmate.conf ]] || { 22 | echo 'set -g terminal-overrides ""' > ~/.tmate.conf 23 | echo 'set -g xterm-keys on' >> ~/.tmate.conf 24 | echo 'source-file /usr/share/doc/tmux/examples/screen-keys.conf' >> ~/.tmate.conf 25 | } 26 | echo 'ok!' 27 | } 28 | 29 | function remove_tmate() { 30 | lecho 'Removing tmate ...' 31 | sudo apt-get remove tmate 32 | sudo ppa-purge ppa:tmate.io/archive 33 | } 34 | 35 | function run_tmate() { 36 | hash tmate 2>/dev/null && tmate 37 | } 38 | 39 | function ensure_private_ssh_key() { 40 | lecho 'Ensuring you have a private ssh key ...' 41 | if [ ! -f ~/.ssh/id_rsa ]; then 42 | ssh-keygen -f ~/.ssh/id_rsa -t rsa -b 4096 -N "" 43 | fi 44 | echo 'ok!' 45 | } 46 | 47 | function install_fonts() { 48 | lecho 'Installing powerline fonts' 49 | if [ ! -d /tmp/fonts ]; then 50 | git clone https://github.com/powerline/fonts.git /tmp/fonts 51 | bash /tmp/fonts/install.sh 52 | hash gsettings 2>/dev/null && { 53 | gsettings set org.gnome.desktop.interface monospace-font-name \ 54 | "Droid Sans Mono Dotted for Powerline 9"; 55 | } 56 | fi 57 | echo 'ok!' 58 | } 59 | 60 | function install_dev() { 61 | sudo apt-get install \ 62 | python2.7 \ 63 | python-pip \ 64 | python-setuptools \ 65 | python-dbus \ 66 | python-docopt \ 67 | python-requests \ 68 | python-setproctitle \ 69 | python-gi \ 70 | python-protobuf \ 71 | python-notify2 \ 72 | python-psutil \ 73 | python-concurrent.futures \ 74 | python-chardet \ 75 | python-netifaces \ 76 | python-netaddr \ 77 | python-pyroute2 \ 78 | python-lxml \ 79 | python-zeroconf \ 80 | vorbis-tools \ 81 | sox \ 82 | lame \ 83 | flac \ 84 | opus-tools \ 85 | pavucontrol \ 86 | virtualenv python-dev git-core 87 | [[ -d ~/pulseaudio-dlna ]] || \ 88 | git clone https://github.com/masmu/pulseaudio-dlna.git ~/pulseaudio-dlna 89 | } 90 | 91 | while [ "$#" -gt "0" ]; do 92 | case $1 in 93 | --remote | --tmate) 94 | install_tmate 95 | run_tmate 96 | exit 0 97 | ;; 98 | --install-tmate) 99 | install_tmate 100 | exit 0 101 | ;; 102 | --install-fonts) 103 | install_fonts 104 | exit 0 105 | ;; 106 | --remove-tmate) 107 | remove_tmate 108 | exit 0 109 | ;; 110 | --dev) 111 | install_dev 112 | exit 0 113 | ;; 114 | *) 115 | echo "Unknown option '$1'!" 116 | exit 1 117 | ;; 118 | esac 119 | done 120 | 121 | echo "You did not specify any arguments!" 122 | exit 1 -------------------------------------------------------------------------------- /scripts/capture.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # 3 | # web-ui http://fritz.box/html/capture.html 4 | # 5 | HOST="fritz.box" 6 | ARGUMENTS="" 7 | IFACE="" 8 | 9 | function get_session_id() { 10 | local SID 11 | CHALLENGE=$(wget -O - "http://$HOST/login_sid.lua" 2>/dev/null \ 12 | | sed 's/.*\(.*\)<\/Challenge>.*/\1/') 13 | 14 | CPSTR="$CHALLENGE-$PASSWORD" 15 | MD5=$(echo -n $CPSTR | iconv -f ISO8859-1 -t UTF-16LE \ 16 | | md5sum -b | awk '{print substr($0,1,32)}') 17 | RESPONSE="$CHALLENGE-$MD5" 18 | 19 | SID=$(wget -O - --post-data="?username=&response=$RESPONSE" \ 20 | "http://$HOST/login_sid.lua" 2>/dev/null \ 21 | | sed 's/.*\(.*\)<\/SID>.*/\1/') 22 | echo "$SID" 23 | } 24 | 25 | echo -n "Enter your router password: "; read PASSWORD; 26 | SID=$(get_session_id) 27 | if [ "$SID" == "0000000000000000" ]; then 28 | echo "Authentication failure!" 29 | exit 30 | fi 31 | 32 | echo "" 33 | echo "What do you want to capture?" 34 | echo "INTERNET:" 35 | echo " 1) Internet" 36 | echo " 2) Interface 0" 37 | echo " 3) Routing Interface" 38 | echo "INTERFACES:" 39 | echo " 4) tunl0" 40 | echo " 5) eth0" 41 | echo " 6) eth1" 42 | echo " 7) eth2" 43 | echo " 8) eth3" 44 | echo " 9) lan" 45 | echo " 10) hotspot" 46 | echo " 11) wifi0" 47 | echo " 12) ath0" 48 | echo "WIFI:" 49 | echo " 13) AP 2,4 GHz ath0, Interface 1" 50 | echo " 14) AP 2,4 GHz ath0, Interface 0" 51 | echo " 15) HW 2,4 GHz wifi0, Interface 0" 52 | echo "" 53 | 54 | while true; do 55 | echo -n "Enter your choice [0-15] ('q' for quit): "; read MODE; 56 | if (("$MODE" > "0")) && (("$MODE" < "16")); then 57 | if [ "$MODE" == "1" ]; then 58 | IFACE="2-1" 59 | elif [ "$MODE" == "2" ]; then 60 | IFACE="3-17" 61 | elif [ "$MODE" == "3" ]; then 62 | IFACE="3-0" 63 | elif [ "$MODE" == "4" ]; then 64 | IFACE="1-tunl0" 65 | elif [ "$MODE" == "5" ]; then 66 | IFACE="1-eth0" 67 | elif [ "$MODE" == "6" ]; then 68 | IFACE="1-eth1" 69 | elif [ "$MODE" == "7" ]; then 70 | IFACE="1-eth2" 71 | elif [ "$MODE" == "8" ]; then 72 | IFACE="1-eth3" 73 | elif [ "$MODE" == "9" ]; then 74 | IFACE="1-lan" 75 | elif [ "$MODE" == "10" ]; then 76 | IFACE="1-hotspot" 77 | elif [ "$MODE" == "11" ]; then 78 | IFACE="1-wifi0" 79 | elif [ "$MODE" == "12" ]; then 80 | IFACE="1-ath0" 81 | elif [ "$MODE" == "13" ]; then 82 | IFACE="4-131" 83 | elif [ "$MODE" == "14" ]; then 84 | IFACE="4-130" 85 | elif [ "$MODE" == "15" ]; then 86 | IFACE="4-128" 87 | fi 88 | break 89 | elif [ "$MODE" == "q" ]; then 90 | exit 91 | fi 92 | done 93 | 94 | echo "" 95 | echo "Do you also want to write a pcap file?" 96 | echo "" 97 | 98 | while true; do 99 | echo -n "Enter your choice [y-n] ('q' for quit): "; read WRITE_PCAP; 100 | if [ "$WRITE_PCAP" == "y" ]; then 101 | PCAP_FILE="$(date +%Y-%m-%d_%H:%M:%S).pcap" 102 | WIRESHARK_ARGS="-w $PCAP_FILE" 103 | break 104 | elif [ "$WRITE_PCAP" == "n" ]; then 105 | WIRESHARK_ARGS="" 106 | break 107 | elif [ "$WRITE_PCAP" == "q" ]; then 108 | exit 109 | fi 110 | done 111 | 112 | echo "" 113 | echo "Starting wireshark ..." 114 | echo "" 115 | 116 | wget -O - "http://$HOST/cgi-bin/capture_notimeout?ifaceorminor=$IFACE&snaplen=1600&capture=Start&sid=$SID" \ 117 | 2>/dev/null \ 118 | | wireshark -k $WIRESHARK_ARGS -i - 119 | -------------------------------------------------------------------------------- /scripts/fritzbox-device-sharing.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # -*- coding: utf-8 -*- 3 | 4 | # This file is part of pulseaudio-dlna. 5 | 6 | # pulseaudio-dlna is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU General Public License as published by 8 | # the Free Software Foundation, either version 3 of the License, or 9 | # (at your option) any later version. 10 | 11 | # pulseaudio-dlna is distributed in the hope that it will be useful, 12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | # GNU General Public License for more details. 15 | 16 | # You should have received a copy of the GNU General Public License 17 | # along with pulseaudio-dlna. If not, see . 18 | 19 | from __future__ import unicode_literals 20 | 21 | import requests 22 | import sys 23 | import json 24 | 25 | import pulseaudio_dlna 26 | import pulseaudio_dlna.holder 27 | import pulseaudio_dlna.plugins.dlna 28 | import pulseaudio_dlna.codecs 29 | 30 | 31 | STEPS_PATTERN = """ 32 | Step 1: Open your Fritzbox web interface (http://fritz.box/) 33 | Step 2: Goto Internet -> Permit Access -> Port-Sharing 34 | Step 3: Add a port sharing via the button "New Port Sharing" for each device you want to share. 35 | """ 36 | 37 | SHARE_PATTERN = """############################################################################# 38 | 39 | To share access to the device "{name}" add the following entry: 40 | 41 | ------------------------------------------------------- 42 | 43 | Create New Port Sharing 44 | 45 | [x] Port sharing enabled for "Other applications"') 46 | Name: "DLNA for {name}" (does not matter, just the name of the rule) 47 | Protocol: "TCP" 48 | From Port "{port}" To Port: "{port}" 49 | To Computer: "?" (does not matter, make sure the IP address is correct) 50 | To IP-Address: "{ip}" 51 | To Port: "{port}" 52 | 53 | [OK] 54 | 55 | ------------------------------------------------------- 56 | 57 | The link to share the device is : {link} 58 | """ 59 | 60 | 61 | class IPDetector(): 62 | 63 | TIMEOUT = 5 64 | 65 | def __init__(self): 66 | self.public_ip = None 67 | 68 | def get_public_ip(self): 69 | self.public_ip = self._get_public_ip() 70 | return self.public_ip is not None 71 | 72 | def _get_public_ip(self): 73 | response = requests.get('http://ifconfig.lancode.de') 74 | if response.status_code == 200: 75 | data = json.loads(response.content) 76 | return data.get('ip', None) 77 | return None 78 | 79 | 80 | class DLNADiscover(): 81 | 82 | PLUGINS = [ 83 | pulseaudio_dlna.plugins.dlna.DLNAPlugin(), 84 | ] 85 | TIMEOUT = 5 86 | 87 | def __init__(self, max_workers=10): 88 | self.devices = [] 89 | 90 | def discover_devices(self): 91 | self.devices = self._discover_devices() 92 | return len(self.devices) > 0 93 | 94 | def _discover_devices(self): 95 | holder = pulseaudio_dlna.holder.Holder(self.PLUGINS) 96 | holder.search(ttl=self.TIMEOUT) 97 | return holder.devices.values() 98 | 99 | 100 | ip_detector = IPDetector() 101 | print('Getting your external IP address ...') 102 | if not ip_detector.get_public_ip(): 103 | print('Could not get your external IP! Aborting.') 104 | sys.exit(1) 105 | 106 | print('Discovering devices ...') 107 | dlna_discover = DLNADiscover() 108 | if not dlna_discover.discover_devices(): 109 | print('Could not find any devices! Aborting.') 110 | sys.exit(1) 111 | 112 | print(STEPS_PATTERN) 113 | for device in dlna_discover.devices: 114 | link = device.upnp_device.access_url.replace( 115 | device.ip, ip_detector.public_ip) 116 | print(SHARE_PATTERN.format( 117 | name=device.name, ip=device.ip, port=device.port, link=link)) 118 | -------------------------------------------------------------------------------- /scripts/radio.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # -*- coding: utf-8 -*- 3 | 4 | # This file is part of pulseaudio-dlna. 5 | 6 | # pulseaudio-dlna is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU General Public License as published by 8 | # the Free Software Foundation, either version 3 of the License, or 9 | # (at your option) any later version. 10 | 11 | # pulseaudio-dlna is distributed in the hope that it will be useful, 12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | # GNU General Public License for more details. 15 | 16 | # You should have received a copy of the GNU General Public License 17 | # along with pulseaudio-dlna. If not, see . 18 | 19 | from __future__ import unicode_literals 20 | 21 | import requests 22 | import logging 23 | import sys 24 | import concurrent.futures 25 | 26 | import pulseaudio_dlna 27 | import pulseaudio_dlna.holder 28 | import pulseaudio_dlna.plugins.dlna 29 | import pulseaudio_dlna.plugins.chromecast 30 | import pulseaudio_dlna.codecs 31 | 32 | level = logging.INFO 33 | logging.getLogger('requests').setLevel(logging.WARNING) 34 | logging.getLogger('urllib3').setLevel(logging.WARNING) 35 | 36 | logging.basicConfig( 37 | level=level, 38 | format='%(asctime)s %(name)-46s %(levelname)-8s %(message)s', 39 | datefmt='%m-%d %H:%M:%S') 40 | logger = logging.getLogger('radio') 41 | 42 | 43 | class RadioLauncher(): 44 | 45 | PLUGINS = [ 46 | pulseaudio_dlna.plugins.dlna.DLNAPlugin(), 47 | pulseaudio_dlna.plugins.chromecast.ChromecastPlugin(), 48 | ] 49 | 50 | def __init__(self, max_workers=10): 51 | self.devices = self._discover_devices() 52 | self.thread_pool = concurrent.futures.ThreadPoolExecutor( 53 | max_workers=max_workers) 54 | 55 | def stop(self, name, flavour=None): 56 | self.thread_pool.submit(self._stop, name, flavour) 57 | 58 | def _stop(self, name, flavour=None): 59 | device = self._get_device(name, flavour) 60 | if device: 61 | return_code, message = device.stop() 62 | if return_code == 200: 63 | logger.info( 64 | 'The device "{name}" was instructed to stop'.format( 65 | name=device.label)) 66 | else: 67 | logger.info( 68 | 'The device "{name}" failed to stop ({code})'.format( 69 | name=device.label, code=return_code)) 70 | 71 | def play(self, url, name, flavour=None, 72 | artist=None, title=None, thumb=None): 73 | self.thread_pool.submit( 74 | self._play, url, name, flavour, artist, title, thumb) 75 | 76 | def _play(self, url, name, flavour=None, 77 | artist=None, title=None, thumb=None): 78 | if url.lower().endswith('.m3u'): 79 | url = self._get_playlist_url(url) 80 | codec = self._get_codec(url) 81 | device = self._get_device(name, flavour) 82 | if device: 83 | return_code, message = device.play(url, codec, artist, title, thumb) 84 | if return_code == 200: 85 | logger.info( 86 | 'The device "{name}" was instructed to play'.format( 87 | name=device.label)) 88 | else: 89 | logger.info( 90 | 'The device "{name}" failed to play ({code})'.format( 91 | name=device.label, code=return_code)) 92 | 93 | def _get_device(self, name, flavour=None): 94 | for device in self.devices: 95 | if flavour: 96 | if device.name == name and device.flavour == flavour: 97 | return device 98 | else: 99 | if device.name == name: 100 | return device 101 | return None 102 | 103 | def _get_codec(self, url): 104 | for identifier, _type in pulseaudio_dlna.codecs.CODECS.iteritems(): 105 | codec = _type() 106 | if url.endswith(codec.suffix): 107 | return codec 108 | return pulseaudio_dlna.codecs.Mp3Codec() 109 | 110 | def _get_playlist_url(self, url): 111 | response = requests.get(url=url) 112 | for line in response.content.split('\n'): 113 | if line.lower().startswith('http://'): 114 | return line 115 | return None 116 | 117 | def _discover_devices(self): 118 | holder = pulseaudio_dlna.holder.Holder(self.PLUGINS) 119 | holder.search(ttl=5) 120 | logger.info('Found the following devices:') 121 | for udn, device in holder.devices.items(): 122 | logger.info(' - "{name}" ({flavour})'.format( 123 | name=device.name, flavour=device.flavour)) 124 | return holder.devices.values() 125 | 126 | # Local pulseaudio-dlna installations running in a virutalenv should run this 127 | # script as module: 128 | # python -m scripts/radio [--list | --stop] 129 | 130 | args = sys.argv[1:] 131 | rl = RadioLauncher() 132 | 133 | if len(args) > 0 and args[0] == '--list': 134 | sys.exit(0) 135 | 136 | devices = [ 137 | ('Alle', 'Chromecast'), 138 | ] 139 | 140 | for device in devices: 141 | name, flavour = device 142 | if len(args) > 0 and args[0] == '--stop': 143 | rl.stop(name, flavour) 144 | else: 145 | rl.play( 146 | 'http://www.wdr.de/wdrlive/media/einslive.m3u', name, flavour, 147 | 'Radio', 'Einslive', 148 | 'https://lh4.ggpht.com/7ssDAyz52UL1ahViwMkCrtfbdj45RU1Gqqpw3ncYjMrjhZofECX01j4nBufhCAkRFtRm=w600') 149 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | # This file is part of pulseaudio-dlna. 4 | 5 | # pulseaudio-dlna is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | 10 | # pulseaudio-dlna is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU General Public License for more details. 14 | 15 | # You should have received a copy of the GNU General Public License 16 | # along with pulseaudio-dlna. If not, see . 17 | 18 | import setuptools 19 | 20 | 21 | setuptools.setup( 22 | name="pulseaudio-dlna", 23 | author="Massimo Mund", 24 | author_email="mo@lancode.de", 25 | url="https://github.com/masmu/pulseaudio-dlna", 26 | description="A small DLNA server which brings DLNA / UPNP support" 27 | "to PulseAudio and Linux.", 28 | license="GPLv3", 29 | platforms="Debian GNU/Linux", 30 | classifiers=[ 31 | "Development Status :: 4 - Beta", 32 | "Programming Language :: Python :: 2.7", 33 | "Environment :: Console", 34 | "Topic :: Multimedia :: Sound/Audio", 35 | "License :: OSI Approved :: GNU General Public License v3 (GPLv3)", 36 | ], 37 | version='0.5.2', 38 | py_modules=[], 39 | packages=setuptools.find_packages(), 40 | install_requires=[ 41 | "docopt >= 0.6.1", 42 | "requests >= 2.2.1", 43 | "setproctitle >= 1.0.1", 44 | "protobuf >= 2.5.0", 45 | "notify2 >= 0.3", 46 | "psutil >= 1.2.1", 47 | "futures >= 2.1.6", 48 | "chardet >= 2.0.1", 49 | "pyroute2 >= 0.3.5", 50 | "netifaces >= 0.8", 51 | "lxml >= 3", 52 | "zeroconf >= 0.17.4", 53 | ], 54 | entry_points={ 55 | "console_scripts": [ 56 | "pulseaudio-dlna = pulseaudio_dlna.__main__:main", 57 | ] 58 | }, 59 | data_files=[ 60 | ("share/man/man1", ["man/pulseaudio-dlna.1"]), 61 | ], 62 | package_data={ 63 | "pulseaudio_dlna": ["images/*.png"], 64 | } 65 | ) 66 | --------------------------------------------------------------------------------