├── .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 += '{tag}>'.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 |
--------------------------------------------------------------------------------