././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1677078815.4854946 pa_dlna-0.16/.coveragerc0000644000000000000000000000013614375430437012117 0ustar00[run] omit = */tests/* tools/* [report] exclude_lines = raise NotImplementedError ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1698137260.3381906 pa_dlna-0.16/.dockerignore0000644000000000000000000000002414515702254012441 0ustar00.git **/__pycache__ ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1734184752.5173938 pa_dlna-0.16/.gitignore0000644000000000000000000000014014727307461011762 0ustar00/dist/ /docs/build/ /.coverage .emacs.desktop* images/coverage.svg docs/source/README.rst *.pyc ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1736090488.3433602 pa_dlna-0.16/.gitlab-ci.yml0000644000000000000000000000410214736521570012427 0ustar00# Built from template located at: # https://gitlab.com/gitlab-org/gitlab/-/blob/master/lib/gitlab/ci/templates/Python.gitlab-ci.yml variables: # Change pip's cache directory to be inside the project directory. PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip" default: cache: paths: - .cache/pip before_script: - apt-get update -yq && apt-get install -yq curl ffmpeg - python --version - PIP_BREAK_SYSTEM_PACKAGES=1 python -m pip install psutil libpulse .pulseaudio: &pulseaudio - apt-get update -yq && apt-get install -yq pulseaudio - pulseaudio --verbose --daemonize=yes .pipewire: &pipewire - apt-get update -yq && apt-get install -yq pulseaudio-utils xvfb pipewire wireplumber pipewire-pulse - | export 'XDG_RUNTIME_DIR=/tmp' export 'DISPLAY=:0.0' Xvfb -screen $DISPLAY 1920x1080x24 & pipewire & pipewire-pulse & wireplumber & .unittest-scripts: &unittest-scripts - python -m unittest --verbose --failfast .coverage-scripts: &coverage-scripts - PIP_BREAK_SYSTEM_PACKAGES=1 python -m pip install coverage - python -m coverage run --include="./*" -m unittest - python -m coverage report --show-missing pipwire-tests: image: python:3.11 stage: test script: - *pipewire - sleep 1 - pactl info - *unittest-scripts py38: image: python:3.8 stage: test script: - *pulseaudio - *unittest-scripts py39: image: python:3.9 stage: test script: - *pulseaudio - *unittest-scripts py310: image: python:3.10 stage: test script: - *pulseaudio - *unittest-scripts py311: image: python:3.11 stage: test script: - *pulseaudio - *unittest-scripts py312: image: python:3.12 stage: test script: - *pulseaudio - *unittest-scripts py313: image: python:3.13 stage: test script: - *pulseaudio - *unittest-scripts - *coverage-scripts coverage: '/TOTAL.* (100\%|\d?\d\%)$/' ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1711267710.875378 pa_dlna-0.16/.gitlab/issue_templates/Default.md0000644000000000000000000000106714577757577016463 0ustar00#### Bug report. ### Your environment. - Pulseaudio version: - Pipewire version: - pa-dlna version: - DLNA device name: - Selected encoder: - Network type (wired, wifi): ### Steps to reproduce. ### Relevant logs or configuration. ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1734184752.5207272 pa_dlna-0.16/.readthedocs.yaml0000644000000000000000000000121714727307461013227 0ustar00# .readthedocs.yaml # Read the Docs configuration file. # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details. version: 2 build: os: ubuntu-22.04 tools: python: "3.12" jobs: post_build: - echo "Create the READTHEDOCS_OUTPUT/html/index.html symlink at $READTHEDOCS_OUTPUT/html" - cd "$READTHEDOCS_OUTPUT/html"; if [ -f README.html ]; then cp README.html index.html; else echo "README.html not found"; fi sphinx: configuration: docs/source/conf.py # Build PDF formats: - pdf # Declare the Python requirements required to build your docs. python: install: - requirements: docs/requirements.txt ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1698136106.8999336 pa_dlna-0.16/Dockerfile.pipewire0000644000000000000000000000163314515700053013604 0ustar00# syntax=docker/dockerfile:1 # # Docker file to run the test suite with *pipewire*. # Note that these steps must be repeated after each change in pa-dlna # source code. # # Build an image, run the following command at the root of the repository: # $ sudo /usr/bin/docker build --file=Dockerfile.pipewire --tag=pa-dlna-pipewire . # # And start a container to run the test suite: # $ sudo /usr/bin/docker run -it pa-dlna-pipewire # Or: # $ sudo /usr/bin/docker run -p 4000:80 pa-dlna-pipewire FROM debian:latest RUN apt-get update -yq && apt-get install -yq python3-psutil procps RUN apt-get update -yq && apt-get install -yq curl ffmpeg RUN apt-get update -yq && apt-get install -yq pulseaudio-utils RUN apt-get update -yq && apt-get install -yq xvfb RUN apt-get update -yq && apt-get install -yq pipewire wireplumber pipewire-pulse COPY . /root WORKDIR /root ENTRYPOINT [ "/bin/bash", "tools/docker-pipewire.sh" ] ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1698137397.739331 pa_dlna-0.16/Dockerfile.pulse0000644000000000000000000000260114515702466013116 0ustar00# syntax=docker/dockerfile:1 # # Docker file to run the test suite with *pulseaudio*. # Note that these steps must be repeated after each change in pa-dlna # source code. # # Build an image and run the test suite by running the following command at # the root of the repository: # $ sudo /usr/bin/docker build --file=Dockerfile.pulse --tag=pa-dlna-pulse . # # Then optionaly run /bin/bash interactively in a container, start pulseaudio # (possibly twice, see last comment below) and run the test suite or maybe # only some tests. # $ sudo /usr/bin/docker run -it pa-dlna-pulse FROM debian:latest RUN apt-get update -yq && apt-get install -yq python3-psutil procps RUN apt-get update -yq && apt-get install -yq curl ffmpeg RUN apt-get update -yq && apt-get install -yq pulseaudio-utils RUN apt-get update -yq && apt-get install -yq pulseaudio RUN mkdir /pa-dlna COPY . /pa-dlna # Create the 'padlna' user. # https://stackoverflow.com/questions/27701930/how-to-add-users-to-docker-container RUN useradd -rm -d /home/padlna -s /bin/bash -g root -G sudo -u 900 padlna RUN /bin/find /pa-dlna -exec chown padlna:root {} \; USER padlna WORKDIR /pa-dlna RUN mkdir .config RUN pulseaudio --verbose --daemonize=yes && python3 -m unittest --verbose --failfast # For some reason, when run interactively, starting the pulseaudio daemon # may fail the first time and succeed the next. ENTRYPOINT [ "/bin/bash" ] ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1646844371.9250107 pa_dlna-0.16/LICENSE0000644000000000000000000000207114212154724010773 0ustar00The MIT License (MIT) Copyright (c) 2022 Xavier de Gaye Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1735744189.8802984 pa_dlna-0.16/README.rst0000644000000000000000000001410414735255276011473 0ustar00.. image:: images/coverage.png :alt: [pa-dlna test coverage] `pa-dlna`_ forwards audio streams to DLNA devices. A Python project based on `asyncio`_, that uses `ctypes`_ to interface with the ``libpulse`` library and supports the PulseAudio and PipeWire [#]_ sound servers. `pa-dlna`_ is composed of the following components: * The ``pa-dlna`` program forwards PulseAudio streams to DLNA devices. * The ``upnp-cmd`` is an interactive command line tool for introspection and control of UPnP devices [#]_. * The UPnP Python sub-package is used by both commands. The documentation is hosted at `Read the Docs`_: - The `stable documentation`_ of the last released version. - The `latest documentation`_ of the current GitLab development version. To access the documentation as a pdf document one must click on the icon at the down-right corner of any page. It allows to switch between stable and latest versions and to select the corresponding pdf document. Requirements ------------ Python version 3.8 or more recent. psutil """""" The UPnP sub-package and therefore the ``upnp-cmd`` and ``pa-dlna`` commands depend on the `psutil`_ Python package. This package is available in most distributions as ``python3-psutil`` or ``python-psutil``. It will be installed by ``pip`` as a dependency of ``pa-dlna`` if not already installed as a package of the distribution. libpulse """""""" `libpulse`_ is a Python asyncio interface to the Pulseaudio and Pipewire ``libpulse`` library. It was a sub-package of ``pa-dlna`` and has become a full-fledged package on PyPi. It will be installed by ``pip`` as a dependency of ``pa-dlna``. parec """"" `pa-dlna`_ uses the pulseaudio ``parec`` program [#]_. Depending on the linux distribution it may be already installed as a dependency of pulseaudio or of pipewire-pulse. If not, then the package that owns ``parec`` must be installed. On archlinux the package name is ``libpulse``, on debian it is `pulseaudio-utils`_. systemd """"""" The `python-systemd`_ package is required to run the pa-dlna systemd service unit. Encoders """""""" No other dependency is required by `pa-dlna`_ when the DLNA devices support raw PCM L16 (:rfc:`2586`) [#]_. Optionally, encoders compatible with the audio mime types supported by the devices may be used. ``pa-dlna`` currently supports the `ffmpeg`_ (mp3, wav, aiff, flac, opus, vorbis, aac), the `flac`_ and the `lame`_ (mp3) encoders. The list of supported encoders, whether they are available on this host and their options, is printed by the command that prints the default configuration:: $ pa-dlna --dump-default pavucontrol """"""""""" Optionally, one may install the ``pavucontrol`` package for easier management of associations between sound sources and DLNA devices. Installation ------------ pipewire as a pulseaudio sound server """"""""""""""""""""""""""""""""""""" The ``pipewire``, ``pipewire-pulse`` and ``wireplumber`` packages must be installed and the corresponding programs started. If you are switching from pulseaudio, make sure to remove ``/etc/pulse/client.conf`` or to comment out the setting of ``default-server`` in this file as pulseaudio and pipewire do not use the same unix socket path name. The ``parec`` 's package includes the ``pactl`` program. One may check that the installation of pipewire as a pulseaudio sound server is successfull by running the command:: $ pactl info pa-dlna """"""" Install ``pa-dlna`` with pip:: $ python -m pip install pa-dlna Configuration ------------- A ``pa-dlna.conf`` user configuration file overriding the default configuration may be used to: * Change the preferred encoders ordered list used to select an encoder. * Configure encoder options. * Set an encoder for a given device and configure the options for this device. * Configure the *sample_format*, *rate* and *channels* parameters of the ``parec`` program used to forward PulseAudio streams, for a specific device, for an encoder type or for all devices. See the `configuration`_ section of the pa-dlna documentation. .. _pa-dlna: https://gitlab.com/xdegaye/pa-dlna .. _asyncio: https://docs.python.org/3/library/asyncio.html .. _ctypes: https://docs.python.org/3/library/ctypes.html .. _pulseaudio-utils: https://packages.debian.org/bookworm/pulseaudio-utils .. _pa-dlna issue 15: https://gitlab.com/xdegaye/pa-dlna/-/issues/15 .. _Wireplumber issue 511: https://gitlab.freedesktop.org/pipewire/wireplumber/-/issues/511 .. _Read the Docs: https://about.readthedocs.com/ .. _stable documentation: https://pa-dlna.readthedocs.io/en/stable/ .. _latest documentation: https://pa-dlna.readthedocs.io/en/latest/ .. _psutil: https://pypi.org/project/psutil/ .. _ConnectionManager:3 Service: http://upnp.org/specs/av/UPnP-av-ConnectionManager-v3-Service.pdf .. _ffmpeg: https://www.ffmpeg.org/ffmpeg.html .. _flac: https://xiph.org/flac/ .. _lame: https://lame.sourceforge.io/ .. _configuration: https://pa-dlna.readthedocs.io/en/stable/configuration.html .. _pipewire-pulse: https://docs.pipewire.org/page_man_pipewire_pulse_1.html .. _libpulse: https://pypi.org/project/libpulse/ .. _pa-dlna command: https://pa-dlna.readthedocs.io/en/stable/pa-dlna.html .. _python-systemd: https://www.freedesktop.org/software/systemd/python-systemd/ .. [#] When using PipeWire with the Wireplumber session manager, ``pa-dlna`` must be started before the audio streams that are routed to DLNA devices. Re-starting those audio streams fixes the problem. See `pa-dlna issue 15`_ and `Wireplumber issue 511`_. A workaround may be used with the ``--clients-uuids`` command line option, see the `pa-dlna command`_ documentation. .. [#] The ``pa-dlna`` and ``upnp-cmd`` programs can be run simultaneously. .. [#] The ``parec`` program also uses the ``libpulse`` library which is included in ``parec`` 's package or is installed as a dependency. Note also that this package includes the ``pactl`` and ``pacmd`` programs. .. [#] DLNA devices must support the HTTP GET transfer protocol and must support HTTP 1.1 as specified by Annex A.1 of the `ConnectionManager:3 Service`_ UPnP specification. ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1734184752.5207272 pa_dlna-0.16/docs/Makefile0000644000000000000000000000157414727307461012376 0ustar00# Minimal makefile for Sphinx documentation # # You can set these variables from the command line, and also # from the environment for the first two. SPHINXOPTS ?= SPHINXBUILD ?= sphinx-build SOURCEDIR = source BUILDDIR = build # Put it first so that "make" without argument is like "make help". help: @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) .PHONY: help Makefile # Catch-all target: route all unknown targets to Sphinx using the new # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). .ONESHELL: %: Makefile @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) case html in $@) echo "Makefile: symlink index.html to README.html"; cd "$(BUILDDIR)/html"; if [ -f README.html ]; then ln -sf README.html index.html; else echo "Makefile: *** error: README.html not found"; fi esac ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1672657372.279913 pa_dlna-0.16/docs/requirements.txt0000644000000000000000000000004614354534734014214 0ustar00sphinx >= 5.3 sphinx_rtd_theme >= 1.1 ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1672309148.013752 pa_dlna-0.16/docs/source/common.txt0000644000000000000000000000125114353264634014256 0ustar00.. Directives and external links shared by rst files. .. |br| raw:: html
.. _UPnP Device Architecture: https://openconnectivity.org/upnp-specs/UPnP-arch-DeviceArchitecture-v2.0-20200417.pdf .. _UPnP AV Architecture: http://upnp.org/specs/av/UPnP-av-AVArchitecture-v2.pdf .. _MediaRenderer Device: http://www.upnp.org/specs/av/UPnP-av-MediaRenderer-v3-Device.pdf .. _ConnectionManager: http://upnp.org/specs/av/UPnP-av-ConnectionManager-v3-Service.pdf .. _AVTransport: http://upnp.org/specs/av/UPnP-av-AVTransport-v3-Service.pdf .. _RenderingControl: http://upnp.org/specs/av/UPnP-av-RenderingControl-v3-Service.pdf ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1739957932.0871341 pa_dlna-0.16/docs/source/conf.py0000644000000000000000000000402614755323254013527 0ustar00# Configuration file for the Sphinx documentation builder. # # For the full list of built-in configuration values, see the documentation: # https://www.sphinx-doc.org/en/master/usage/configuration.html # -- Project information ----------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information import sys from pathlib import Path __version__ = 'Unknown version' def conf_py_setup(): global __version__ cur_dir = Path(__file__).parent root_dir = cur_dir.parent.parent try: sys.path.append(str(root_dir)) from pa_dlna import __version__ finally: sys.path.pop() with open(cur_dir / 'README.rst', 'w') as fdest: fdest.write('pa-dlna |version|\n') fdest.write('=================\n\n') with open(root_dir / 'README.rst') as fsrc: content = fsrc.read() fdest.write(content) conf_py_setup() project = 'pa-dlna' copyright = '2025, Xavier de Gaye' author = '' # The short X.Y version version = __version__ # The full version, including alpha/beta/rc tags release = __version__ # -- General configuration --------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration extensions = [] templates_path = ['_templates'] exclude_patterns = [] # -- Options for HTML output ------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output html_theme = 'sphinx_rtd_theme' html_static_path = ['images'] # -- Options for manual page output ------------------------------------------ # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). man_pages = [ ('upnp-cmd', 'upnp-cmd', 'interactive command line tool for introspection' ' and control of UPnP devices', [author], 7), ('pa-dlna', 'pa-dlna', 'UPnP control point forwarding PulseAudio streams' ' to DLNA devices', [author], 7), ] ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1734859208.579487 pa_dlna-0.16/docs/source/configuration.rst0000644000000000000000000002245414731754711015636 0ustar00.. _configuration: Configuration ============= The configuration is defined by the :ref:`default_config` [#]_ overriden by the :ref:`user_configuration` file if it exists. It is used in the following two stages of the ``pa-dlna`` process: - The selection of the encoder and audio mime-type. - The forwarding of an audio stream. Encoder selection ----------------- ``pa-dlna`` fetches the DLNA device supported mime-types using the ``GetProtocolInfo`` UPnP command [#]_ and selects the first encoder/mime-type in the configured ``selection`` option that matches an item of the list returned by ``GetProtocolInfo``. If not already existing, an HTTP server is then started that answers requests on the local IP address where the DLNA device has been discovered. .. _`streaming`: Streaming --------- When PulseAudio (actually libpulse) notifies ``pa-dlna`` of the existence of a new stream from a source to the DLNA sink, it sends a ``SetAVTransportURI`` or ``SetNextAVTransportURI`` [#]_ UPnP SOAP [#]_ action to the device. This command holds: - The stream metadata. - The selected mime-type. - The URL to be used by the device to fetch the stream by initiating an HTTP GET for this URL. Upon responding to the HTTP GET request ``pa-dlna`` forks the ``parec`` process and the selected encoder process [#]_ using the configured options. The output of the ``parec`` process is piped to the encoder process, the output of the encoder process is written to the HTTP socket. It is possible to test an encoder configuration without using a DLNA device with the help of the ffplay program from the ffmpeg suite and a tool that retrieves HTTP files such as curl or wget. Here is an example with the ``L16Encoder``: - Set the L16Encoder at the highest priority in the pa-dlna.conf file. - Run pa-dlna with the ``test-devices`` command line option [#]_:: $ pa-dlna --test-devices audio/L16\;rate=44100\;channels=2 - Start a music player application and play some track. - Associate this source with the ``DLNATest_L16 - 0ab65`` DLNA sink in pavucontrol. - Fetch the stream with curl as a file named ``output`` using the URL printed by the logs [#]_:: $ curl http://127.0.0.1:8080/audio-content/uuid:e7fa8886-6d97-a009-b6b6-6b1171b0ab65 -o output - Play the ``output`` file with the command:: $ ffplay -f s16be -ac 2 -ar 44100 output Encoders configuration ---------------------- The encoders configuration is defined by the :ref:`default_config` that may be overriden by the user's ``pa-dlna.conf`` file. The ``pa-dlna.conf`` file also allows the specification of the encoder and its options for a given DLNA device with a section named [EncoderName.UDN]. In this case the selection of the encoder using the ``selection`` option is by-passed and EncoderName is the selected encoder. UDN is the udn [#]_ of the device as printed by the logs or by the ``upnp-cmd`` command line tool. The default configuration is structured as an `INI file`_, more precisely as text that may be parsed by the `configparser`_ Python module. The user's configuration file is also an INI file and obeys the same rules as the default configuration: * A section is either [DEFAULT], [EncoderName] or [EncoderName.UDN]. The options defined in the [DEFAULT] section apply to all the other sections and are overriden when also defined in the [EncoderName] or [EncoderName.UDN] sections. There is an exception with the ``selection`` option that is only meaningful in a [DEFAULT] section and ignored in all the other sections. * The ``selection`` option is an ordered comma separated list of encoders. This list is used to select the first encoder matching one of the mime-types supported by a discovered DLNA device when there is no specific [EncoderName.UDN] configuration for the given device. * The options defined in the user's ``pa-dlna.conf`` file override the options of the default configuration. * Section names and options are case sensitive. * Boolean values are resticted to ``yes`` or ``no``. .. _user_configuration: User configuration ------------------ The full path name of the user's ``pa-dlna.conf`` file is determined by ``pa-dlna`` as follows: * If the ``XDG_CONFIG_HOME`` environment variable is set, the path name is ``$XDG_CONFIG_HOME/pa-dlna/pa-dlna.conf``. * Otherwise the path name is ``$HOME/.config/pa-dlna/pa-dlna.conf``. When ``pa-dlna.conf`` is not found, the program uses the default configuration. Otherwise it uses the default configuration with its options overriden by the user's configuration and with the added [EncoderName.UDN] sections. Here is an example of a ``pa-dlna.conf`` file:: [DEFAULT] selection = Mp3Encoder, FFMpegMp3Encoder, FFMpegFlacEncoder, [FFMpegFlacEncoder] track_metadata = no [FFMpegMp3Encoder] bitrate = 320 [FFMpegMp3Encoder.uuid:9ab0c000-f668-11de-9976-00a0de98381a] In this example: * The DLNA device whose udn is ``uuid:9ab0c000-f668-11de-9976-00a0de98381a`` uses the FFMpegMp3Encoder with the default bitrate. * The other devices may use the three encoders of the selection, the preferred one being the Mp3Encoder with the default bitrate. * The FFMpegMp3Encoder is only used if the Mp3Encoder (the lame encoder) is not available and in that case it runs with a bitrate of 320 Kbps. * The FFMpegFlacEncoder is used when a DLNA device does not support the 'audio/mp3' and 'audio/mpeg' mime types and in that case its track_metadata option is not set. * If a DLNA device does not support the mp3 or the flac mime types, then it cannot be used even though the device would support one of the other mime types defined in the overriden default configuration. One can verify what is the actual configuration used by ``pa-dlna`` by running the program with the ``--dump-internal`` command line option. A Python dictionary is printed with keys being ``EncoderName`` or ``UDN`` and the values a dictionary of their options. The ``EncoderName`` keys are ordered according to the ``selection`` option. PulseAudio options ------------------ Options used by the ``parec`` and encoder programs (see how those programs are used in the :ref:`streaming` section): *sample_format* The default value is ``s16le``. The encoders supporting the ``audio/L16`` mime types (i.e. uncompressed audio data as defined by `RFC 2586`_) have this option set to ``s16be`` as specified by the RFC and it cannot be modified by the user. See the Pulseaudio supported `sample formats`_. *rate* The Pulseaudio sample rate (default: 44100). *channels* The number of audio channels (default: 2). Common options -------------- *args* The ``args`` option is the encoder program's command line. When the ``args`` option is None, the encoder command line is built from the Pulseaudio options and the encoder's specific options. As all the other options (except ``sample_format`` in some cases, see above) it may be overriden by the user. *track_metadata* * When ``yes``, each track is streamed in its own HTTP session allowing the DLNA device to get each track meta data as described in the :ref:`meta data` section. This is the default. * When ``no``, there is only one HTTP session for all the tracks. Set this option to ``no`` when the logs show ERROR entries upon tracks changes. *soap_minimum_interval* UPnP SOAP actions that start/stop a stream are spread out at ``soap_minimum_interval`` seconds to avoid the problem described at `issue #16`_. This applies only to the SOAP actions that initiate or stop a stream: SetAVTransportURI, SetNextAVTransportURI and Stop. The default is 5 seconds. Encoder specific options ------------------------ Encoder specific options (for example ``bitrate``) are listed in :ref:`default_config` with their default value. They are used to build the encoder command line when ``args`` is None. .. _INI file: https://en.wikipedia.org/wiki/INI_file .. _configparser: https://docs.python.org/3/library/configparser.html#supported-ini-file-structure .. _RFC 2586: https://datatracker.ietf.org/doc/html/rfc2586 .. _sample formats: https://www.freedesktop.org/wiki/Software/PulseAudio/Documentation/User/SupportedAudioFormats/ .. _issue #16: https://gitlab.com/xdegaye/pa-dlna/-/issues/16 .. rubric:: Footnotes .. [#] The default configuration is printed by the command: ```$ pa-dlna --dump-default``` .. [#] The ``GetProtocolInfo`` command in the ``ConnectionManager`` service menu of the ``upnp-cmd`` command line tool prints this same list. .. [#] The ``SetNextAVTransportURI`` is used when the ``track_metadata`` option is set. .. [#] Simple Object Access Protocol. A remote-procedure call mechanism based on XML that sends commands and receives values over HTTP. .. [#] Except when the audio/L16 mime type is selected. .. [#] Note that the ``;`` character must be escaped on the command line or the value of the ``--test-devices`` option must be quoted. .. [#] DLNATest device sink names and URLs are built using the sha1 of the audio mime type and therefore are consistent across ``pa-dlna`` sessions. .. [#] UDN: Unique Device Name. Universally-unique identifier of an UPnP device. ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1740394220.583188 pa_dlna-0.16/docs/source/default-config.rst0000644000000000000000000001125114757047355015656 0ustar00.. File generated by tools/gendoc_default_config.py. DO NOT EDIT THIS FILE DIRECTLY. .. _default_config: Built-in Default Configuration ============================== As printed by the command ``pa-dlna --dump-default``. :: # The pa-dlna default configuration. # # This is the built-in pa-dlna configuration written as text. It can be # parsed by a Python Configuration parser and consists of sections, each led # by a [section] header, followed by option/value entries separated by # '='. See https://docs.python.org/3/library/configparser.html. # # The 'selection' option is written as a multi-line in which case all the # lines after the first line start with a white space. # # The default value of 'selection' lists the encoders in this order: # - mp3 encoders first as mp3 is the most common encoding # - lossless encoders # - then lossy encoders # See https://trac.ffmpeg.org/wiki/Encode/HighQualityAudio. [DEFAULT] selection = Mp3Encoder, FFMpegMp3Encoder, L16Encoder, FFMpegL16WavEncoder, FFMpegAiffEncoder, FlacEncoder, FFMpegFlacEncoder, FFMpegOpusEncoder, FFMpegVorbisEncoder, FFMpegAacEncoder, sample_format = s16le rate = 44100 channels = 2 track_metadata = yes soap_minimum_interval = 5 args = None [FFMpegAacEncoder] # Aac encoder. # # 'bitrate' is expressed in kilobits. # See also https://trac.ffmpeg.org/wiki/Encode/AAC. # # available: yes # pgm: /usr/bin/ffmpeg # mime_types: ['audio/aac', 'audio/x-aac', 'audio/vnd.dlna.adts'] # bitrate = 192 args = -loglevel error -hide_banner -nostats -ac 2 -ar 44100 -f s16le -i - -f adts -c:a aac -b:a 192k pipe:1 [FFMpegAiffEncoder] # Lossless Aiff Encoder. # # available: yes # pgm: /usr/bin/ffmpeg # mime_types: ['audio/aiff'] args = -loglevel error -hide_banner -nostats -ac 2 -ar 44100 -f s16le -i - -f aiff pipe:1 [FFMpegFlacEncoder] # Lossless Flac encoder. # # See also https://ffmpeg.org/ffmpeg-all.html#flac-2. # # available: yes # pgm: /usr/bin/ffmpeg # mime_types: ['audio/flac', 'audio/x-flac'] args = -loglevel error -hide_banner -nostats -ac 2 -ar 44100 -f s16le -i - -f flac pipe:1 [FFMpegL16WavEncoder] # Lossless PCM L16 encoder with a wav container. # # available: yes # pgm: /usr/bin/ffmpeg # mime_types: ['audio/l16'] # sample_format = s16be args = -loglevel error -hide_banner -nostats -ac 2 -ar 44100 -f s16be -i - -f wav pipe:1 [FFMpegMp3Encoder] # Mp3 encoder. # # Setting 'bitrate' to 0 causes VBR encoding to be chosen and 'qscale' # to be used instead, otherwise 'bitrate' is expressed in kilobits. # See also https://trac.ffmpeg.org/wiki/Encode/MP3. # # available: yes # pgm: /usr/bin/ffmpeg # mime_types: ['audio/mp3', 'audio/mpeg'] # bitrate = 256 qscale = 2 args = -loglevel error -hide_banner -nostats -ac 2 -ar 44100 -f s16le -i - -f mp3 -c:a libmp3lame -b:a 256k pipe:1 [FFMpegOpusEncoder] # Opus encoder. # # See also https://wiki.xiph.org/Opus_Recommended_Settings. # # available: yes # pgm: /usr/bin/ffmpeg # mime_types: ['audio/opus', 'audio/x-opus'] # bitrate = 128 args = -loglevel error -hide_banner -nostats -ac 2 -ar 44100 -f s16le -i - -f opus -c:a libopus -b:a 128k pipe:1 [FFMpegVorbisEncoder] # Vorbis encoder. # # Setting 'bitrate' to 0 causes VBR encoding to be chosen and 'qscale' # to be used instead, otherwise 'bitrate' is expressed in kilobits. # See also https://ffmpeg.org/ffmpeg-all.html#libvorbis. # # available: yes # pgm: /usr/bin/ffmpeg # mime_types: ['audio/vorbis', 'audio/x-vorbis'] # bitrate = 256 qscale = 3.0 args = -loglevel error -hide_banner -nostats -ac 2 -ar 44100 -f s16le -i - -f ogg -c:a libvorbis -b:a 256k pipe:1 [FlacEncoder] # Lossless Flac encoder. # # See the flac home page at https://xiph.org/flac/ # See also https://xiph.org/flac/documentation_tools_flac.html # # pgm: /usr/bin/flac # available: yes # mime_types: ['audio/flac', 'audio/x-flac'] args = - --silent --channels 2 --sample-rate 44100 --sign signed --bps 16 --endian little [L16Encoder] # Lossless PCM L16 encoder without a container. # # This encoder does not use an external program for streaming. It only uses # the Pulseaudio parec program. # See also https://datatracker.ietf.org/doc/html/rfc2586. # # mime_types: ['audio/l16'] # sample_format = s16be [Mp3Encoder] # Mp3 encoder from the Lame Project. # # See the Lame Project home page at https://lame.sourceforge.io/ # See lame command line options at # https://svn.code.sf.net/p/lame/svn/trunk/lame/USAGE # # pgm: /usr/bin/lame # available: yes # mime_types: ['audio/mp3', 'audio/mpeg'] # bitrate = 256 quality = 0 args = -r -s 44.1 --signed --bitwidth 16 --little-endian -q 0 -b 256 - ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1740392497.3180857 pa_dlna-0.16/docs/source/development.rst0000644000000000000000000002506114757044061015304 0ustar00Development =========== .. _design: Design ------ .. _meta data: Meta Data """"""""" This feature is enabled on a per encoder or per device basis with the ``track_metadata`` option set to ``yes``. It is enabled by default. When ``pa-dlna`` receives a ``change`` event from pulseaudio and this event is related to a change to the meta data as for example when a new track starts with a new song, the following sequence of events occurs: * ``pa-dlna``: + Writes the last chunk to the HTTP socket (see `Chunked Transfer Coding`_) and sends a ``SetNextAVTransportURI`` SOAP action with the new meta data. + Upon receiving the HTTP GET request from the device, instantiates a new Track and starts a task to run the pulseaudio stream. * The DLNA device: + Gets the ``SetNextAVTransportURI`` with the new meta data and sends a GET request to start a new HTTP session for the next track while still playing the current track from its read buffer. + Still playing the current track, pre-loads the read buffer of the new HTTP session. + Upon receiving the last chunk for the current track, starts playing the next track. This way, the last part of the current track is not truncated by the amount of latency introduced by the device's read buffer and the delay introduced by filling the read buffer of the next track is minimized. Asyncio Tasks """"""""""""" Task names in **bold** characters indicate that there is one such task for each DLNA device, when in *italics* that there may be such tasks for each DLNA device. UPnPControlPoint tasks: ================ ====================================================== ssdp notify Monitor reception of NOTIFY SSDPs. ssdp msearch Send MSEARCH SSDPs at regular intervals. **root devices** Implement control of the aging of an UPnP root device. ================ ====================================================== AVControlPoint tasks: ================ ====================================================== main Instantiate the UPnPControlPoint that starts the UPnP tasks. |br| Create the pulse task, the http_server task, the renderer tasks. |br| Create the shutdown task. |br| Handle UPnP notifications. pulse Monitor pulseaudio sink-input events. *maybe_stop* Handle a ``remove`` pulse event. *http_server* Serve DLNA HTTP requests, one task per IP address. |br| Start the client_connected tasks. **renderers** Act upon pulseaudio events. |br| Run UPnP SOAP actions. abort Abort the pa-dlna program. shutdown Wait on event pushed by the signal handlers. ================ ====================================================== HTTPServer tasks: ================== ====================================================== *client_connected* HTTPServer callback wrapped by asyncio in a task. |br| Start the StreamSession tasks: |br| ``parec | encoder program | HTTP socket``. ================== ====================================================== StreamSession tasks: ==================== ==================================================== *parec process* Start the parec process and wait for its exit. *parec log_stderr* Log the parec process stderr. *encoder process* Start the encoder process and wait for its exit. *encoder log_stderr* Log the encoder process stderr. *track* Write the audio stream to the HTTP socket. ==================== ==================================================== Track tasks: ============== ====================================================== *shutdown* Write the last chunk and close the HTTP socket. ============== ====================================================== DLNA Device Registration """""""""""""""""""""""" For a new DLNA device to be registered, ``pa-dlna`` must establish the **local** network address to be used in the URL that must be advertised to the DLNA device in the ``SetAVTransportURI`` and ``SetNextAVTransportURI`` SOAP actions, so that the DLNA device may initiate the HTTP session and start the streaming. This depends on which event triggered this registration: Reception of the unicast response to an UPnP MSEARCH SSDP. The destination address of the SSDP response is the address that is being looked for. MSEARCH SSDP are sent by ``pa-dlna`` every 60 seconds (default). Reception of an UPnP NOTIFY SSDP, broadcasted by the device [#]_. The DLNA device can be registered only if the source address of this packet belongs to one of the subnets of the network interfaces. That is, the DLNA device and the host belong to the same subnet on this interface and the local IP address on this subnet is the address that is being looked for. The `UPnP Device Architecture`_ specification does not specify the periodicity of NOTIFY SSDPs sent by DLNA devices. Development process [#]_ ------------------------ Requirements """""""""""" Development: * `curl`_ and `ffmpeg`_ are used by some tests of the test suite. When missing, those tests are skipped. `curl`_ is also needed when releasing a new version to fetch the GitLab test coverage badge. * `ffmpeg`_, the `Upmpdcli`_ DLNA Media Renderer, the `MPD`_ Music Player Daemon and a running Pulseaudio or PipeWire sound server are needed to run the tests of the ``test_tracks`` Python module (otherwise those tests are skipped). An audio track sourced by ffmpeg is streamed by pa-dlna to the Upmpdcli DLNA that outputs the stream to MPD, which in turn outputs the stream to a PulseAudio/PipeWire sink created by ``test_tracks``. Monitoring the state of this sink allows checking that the audio track does follow this path. This scenario may be run at the debug log level with the following command:: $ python -m pa_dlna.tests.test_tracks [EncoderName] * `pactl`_ is needed to run the tests that connect to the pulseaudio or pipewire sound server. When missing, those tests are skipped. * `docker`_ may be used to run the test suite in a pulseaudio or pipewire debian container. Follow the instructions written as comments in each of the ``Dockerfile.pulse`` and ``Dockerfile.pipewire`` Docker files. * `coverage`_ is used to get the test suite coverage. * `python-packaging`_ is used to set the development version name as conform to PEP 440. * `flit`_ is used to publish pa-dlna to PyPi and may be used to install pa-dlna locally. At the root of the pa-dlna git repository, use the following command to install pa-dlna locally:: $ flit install --symlink [--python path/to/python] This symlinks pa-dlna into site-packages rather than copying it, so that you can test changes by running the ``pa-dlna`` and ``upnp-cmd`` commands provided that the ``PATH`` environment variable holds ``$HOME/.local/bin``. Otherwise without using `flit`_, one can run those commands from the root of the repository as:: $ python -m pa_dlna.pa_dlna $ python -m pa_dlna.upnp_cmd Documentation: * `Sphinx`_ [#]_. * `Read the Docs theme`_. * Building the pdf documentation: - The latex texlive package group. - Imagemagick version 7 or more recent. Documentation """"""""""""" To build locally the documentation follow these steps: - Generate the ``default-config.rst`` file:: $ python -m tools.gendoc_default_config - Fetch the GitLab test coverage badge:: $ curl -o images/coverage.svg "https://gitlab.com/xdegaye/pa-dlna/badges/master/coverage.svg?min_medium=85&min_acceptable=90&min_good=90" $ magick images/coverage.svg images/coverage.png - Build the html documentation and the man pages:: $ make -C docs clean html man latexpdf Updating development version """""""""""""""""""""""""""" Run the following commands to update the version name at `latest documentation`_ after a bug fix or a change in the features:: $ python -m tools.set_devpt_version_name $ make -C docs clean html man latexpdf $ git commit -m "Update development version name" $ git push Releasing """"""""" * Run the test suite from the root of the project [#]_:: $ python -m unittest --verbose --catch --failfast * Get the test suite coverage:: $ coverage run --include="./*" -m unittest $ coverage report -m * Update ``__version__`` in pa_dlna/__init__.py. * When this new release depends on a more recent libpulse release than previously: + Update ``MIN_LIBPULSE_VERSION`` in pa_dlna/__init__.py. + Update the minimum required libpulse version in pyproject.toml. * Update docs/source/history.rst if needed. * Build locally the documentation, see one of the previous sections. * Commit the changes:: $ git commit -m 'Version 0.n' $ git push * Tag the release and push:: $ git tag -a 0.n -m 'Version 0.n' $ git push --tags * Publish the new version to PyPi:: $ flit publish .. include:: common.txt .. _Chunked Transfer Coding: https://www.rfc-editor.org/rfc/rfc2616#section-3.6.1 .. _Read the Docs theme: https://docs.readthedocs.io/en/stable/faq.html#i-want-to-use-the-read-the-docs-theme-locally .. _Sphinx: https://www.sphinx-doc.org/ .. _curl: https://curl.se/ .. _pactl: https://linux.die.net/man/1/pactl .. _docker: https://docs.docker.com/build/guide/intro/ .. _`coverage`: https://pypi.org/project/coverage/ .. _flit: https://pypi.org/project/flit/ .. _unittest command line options: https://docs.python.org/3/library/unittest.html#command-line-options .. _latest documentation: https://pa-dlna.readthedocs.io/en/latest/ .. _python-packaging: https://github.com/pypa/packaging .. _ffmpeg: https://www.ffmpeg.org/ffmpeg.html .. _Upmpdcli: https://www.lesbonscomptes.com/upmpdcli/ .. _MPD: https://mpd.readthedocs.io/en/latest/user.html .. rubric:: Footnotes .. [#] All sockets bound to the notify multicast address receive the datagram sent by a DLNA device, even though it has been received by only one interface at the physical layer. .. [#] The shell commands in this section are all run from the root of the repository. .. [#] Required versions at ``docs/requirements.txt``. .. [#] See `unittest command line options`_. ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1740392040.2567875 pa_dlna-0.16/docs/source/history.rst0000644000000000000000000001742214757043150014463 0ustar00Release history =============== Version 0.16 - The required libpulse version is now ``0.7`` after the `error handling changes`_ made in the libpulse release. - Fix music player on KDE randomly raises exception while switching to next track (issue #49). - KDE music players (Juk, Elisa, Strawberry) misbehave by sending ``remove`` pulse events just before switching to a next track. A work-around to this problem using a timer is implemented that discards those events (issue #48). - **[Sonos]** Accept HTTP 1.1 chunked encoding response to pa-dlna HTTP 1.0 requests. Version 0.15 - The ``Transfer-Encoding`` HTTP 1.1 header in response to HTTP 1.0 GET requests is not supported (issue #47). - Ignore invalid subelements in ``Icons`` (issue #40). - Use ``friendlyName``, the name displayed by pavucontrol, as Renderer's name. - Add the pa-dlna systemd service unit. - Fix L16Encoder failing to set the correct mime type when the ``ProtocolInfo `` entry is simply ``audio/L16`` without the rate parameter (issue #36). - Added a test framework that runs tests with Upmpdcli (a software DLNA MediaRenderer) and MPD on the PulseAudio or Pipewire sound server. - A pdf document is part of the pa-dlna documentation. To access the documentation as a pdf document one must click on the icon at the down-right corner of any page of the documentation on the web. It allows to switch between stable and latest versions and to select the corresponding pdf document. - Fix the development version name as PEP 440 conformant (issue #33). Version 0.14 - pa-dlna versioning conforms to PEP 440. - Exit with an error message when the ``libpulse`` version is older than the required one. The required libpulse version is currently ``0.5``. - **[Upmpdcli]** Fix cannot play on ``upmpdcli`` tracks whose metadata includes the ``&`` character (issue #30). - Add the ``--clients-uuids`` command line option that may be used as a work around to Wireplumber issue 511 (issue #15). Version 0.13 - The backtraces of unhandled exceptions that occur in asyncio tasks are logged at the debug log level. Otherwise these exceptions are just logged as an error with a message saying that the backtrace can be obtained by running the program at the debug log level. - **[Moode UPNP]** Fix libexpat called by ``upmpdcli`` fails parsing the DIDL-Lite xml strings (issue #29). Version 0.12 - Rename LibPulse.get_events() to get_events_iterator(). The change has been introduced by version 0.4 of the libpulse package (issue #26). - Handle exceptions raised while getting the sink after ``module-null-sink`` has been loaded. - Fix a typo in the installation documentation. Version 0.11 - Import the libpulse package from Pypi. - Support Python version 3.12 - fix some network mock tests by forcing the release of control to the asyncio loop. Version 0.10 - **[Teufel 3sixty]** Handle HTTP HEAD requests from DLNA devices. Some renderers fetch stream meta data via HEAD request before requesting actual media streams. - Fix crash upon parsing empty deviceList in device description. Version 0.9 - Support Pipewire version 1.0 and the previous version 0.3. - Log the name of the sound server and its version. Version 0.8 - Changing the volume level with ``pavucontrol`` does not interfere with the current audio stream. - **[Marantz NR1200]** Support multiple embedded MediaRenderers in a DLNA device. - The ``deviceList`` attribute of UPnPDevice is now a list instead of a dictionary. - Do not age an UPnP root device upon receiving a ``CACHE-CONTROL`` header with a value set to ``max-age=0``. Version 0.7 - Name ``libpulse`` the Python package, interface to the ``libpulse`` library. - Document which TCP/UDP ports may not be blocked by a firewall. - Add the ``--msearch-port`` command line option. - Tests are run in GitLab CI/CD with Pulseaudio and with Pipewire. - Add Docker files to allow running the test suite in Pulseaudio and Pipewire debian containers. - Update the README with package requirements for linux distributions. - The ``psutil`` Python package must be installed now separately as this package is included by many distributions (debian, archlinux, fedora, ...). - Log the sound server name and version. Version 0.6 - **[Yamaha RN402D]** Spread out UPnP SOAP actions that start/stop a stream (issue #16). - Fix the ``args`` option in the [EncoderName.UDN] section of the user configuration is always None. - Log a warning when the sink-input enters the ``suspended`` state. - Fix assertion error upon ``exit`` Pulseaudio event (issue #14). - Support PipeWire. No change is needed to support PipeWire. The test suite runs successfully on PipeWire. - Fix no sound when pa-dlna is started while the track is already playing (issue #13). - Use the built-in libpulse package that uses ctypes to interface with the libpulse library and remove the dependency to ``pulsectl_asyncio``. - Wait for the http server to be ready before starting the renderer task. This also fixes the test_None_nullsink and test_no_path_in_request tests on GitLab CI/CD (issue #12). - Support Python 3.11. Version 0.5 - Log a warning upon an empty body in the HTTP response from a DLNA device (issue #11). - UPnP discovery is triggered by NICs [#]_ state changes (issue #10). - Add the ``--ip-addresses``, ``-a`` command line argument (issue #9). - Fix changing the ``args`` encoder option is ignored (issue #8). Version 0.4 - ``sample_format`` is a new encoder configuration option (issue #3). - The encoders sample format is ``s16le`` except for the ``audio/l16`` encoder (issue #7). - The encoder command line is now updated with ``pa-dlna.conf`` user configuration (issue #6). - Fix the parec command line length keeps increasing at each new track when the encoder is set to track metadata (issue #5). - Fix failing to start a new stream session while the device is still playing when the encoder is set to not track metadata (issue #4). - Fix ``pa-dlna`` hangs when one types in the terminal where the program has been started (issue #2). Version 0.3 - The test coverage of ``pa-dlna`` is 95%. - UPnPControlPoint supports now the context manager protocol, not the asynchronous one. - UPnPControlPoint.get_notification() returns now QUEUE_CLOSED upon closing. - Fix some fatal errors on startup that were silent. Here are the missing error messages that are now printed when one of those fatal errors occurs: + Error: No encoder is available. + Error: The pulseaudio 'parec' program cannot be found. - Fix curl: (18) transfer closed with outstanding read data remaining. - Fix a race condition upon the reception of an SSDP msearch response that occurs just after the reception of an SSDP notification and while the instantiation of the root device is not yet complete. - Failure to set SSDP multicast membership is reported only once. Version 0.2 - Test coverage of the UPnP package is 94%. - Fix unknown UPnPXMLFatalError exception. - The ``description`` commands of ``upnp-cmd`` don't prefix tags with a namespace. - Fix the ``description`` commands of ``upnp-cmd`` when run with Python 3.8. - Fix IndexError exception raised upon OSError in network.Notify.manage_membership(). - Fix removing multicast membership when the socket is closed. - Don't print a stack traceback upon error parsing the configuration file. - Abort on error setting the file logging handler with ``--logfile PATH``. Version 0.1 - Publish the project on PyPi. .. _`error handling changes`: https://libpulse.readthedocs.io/en/stable/history.html .. rubric:: Footnotes .. [#] Network Interface Controller. ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1734184752.5207272 pa_dlna-0.16/docs/source/images/coverage.png0000755000000000000000000000000014727307461022375 2../../../images/coverage.pngustar00././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1735744189.8802984 pa_dlna-0.16/docs/source/index.rst0000644000000000000000000000073114735255276014076 0ustar00.. pa-dlna documentation master file, created by sphinx-quickstart on Tue Dec 6 10:56:11 2022. You can adapt this file completely to your liking, but it should at least contain the root `toctree` directive. pa-dlna ======= .. toctree:: :hidden: :maxdepth: 2 :caption: Table of Contents README usage configuration default-config upnp-cmd pa-dlna systemd development history Repository ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1735744189.8802984 pa_dlna-0.16/docs/source/pa-dlna.rst0000644000000000000000000000674314735255276014314 0ustar00.. _pa-dlna: pa-dlna command =============== Synopsis -------- :program:`pa-dlna` [*options*] UPnP discovery is run on all the networks (except the loopbak interface ``lo``) when the ``--ip-addresses`` and ``--nics`` command line arguments are not used or empty. Otherwise both arguments may be used indifferently or even jointly. Options ------- .. option:: -h, --help Show this help message and exit. .. option:: --version, -v Show program's version number and exit. .. option:: --ip-addresses IP_ADDRESSES, -a IP_ADDRESSES IP_ADDRESSES is a comma separated list of the local IPv4 addresses of the networks where UPnP devices may be discovered (default: ``''``). .. option:: --nics NICS, -n NICS NICS is a comma separated list of the names of network interface controllers where UPnP devices may be discovered, such as ``wlan0,enp5s0`` for example (default: ``''``). .. option:: --msearch-interval MSEARCH_INTERVAL, -m MSEARCH_INTERVAL Set the time interval in seconds between the sending of the MSEARCH datagrams used for UPnP device discovery (default: 60). .. option:: --msearch-port MSEARCH_PORT, -p MSEARCH_PORT Set the local UDP port for receiving MSEARCH response messages from UPnP devices, a value of ``0`` means letting the operating system choose an ephemeral port (default: 0). .. option:: --ttl TTL Set the IP packets time to live to TTL (default: 2). .. option:: --port PORT Set the TCP port on which the HTTP server handles DLNA requests (default: 8080). .. option:: --dump-default, -d Write to stdout (and exit) the default built-in configuration. .. option:: --dump-internal, -i Write to stdout (and exit) the configuration used internally by the program on startup after the pa-dlna.conf user configuration file has been parsed. .. option:: --clients-uuids PATH PATH is the name of the file where are stored the associations between client applications and their DLNA device uuid. This is used to work around `Wireplumber issue 511`_ on Pipewire. Client applications names that play an audio stream are written by pa-dlna to PATH with the uuid of the DLNA device. In a next pa-dlna session and upon discovering a DLNA device, the list of the playback streams currently being currently run by the sound server is inspected by pa-dlna and if one of the client applications names matches an entry in PATH that maps to this DLNA device, then the playback stream is moved to the DLNA device by pa-dlna. These associations can be removed from PATH or commented out by the user upon becoming irrelevant. .. option:: --loglevel {debug,info,warning,error}, -l {debug,info,warning,error} Set the log level of the stderr logging console (default: info). .. option:: --systemd Run as a systemd service unit. .. option:: --logfile PATH, -f PATH Add a file logging handler set at ``debug`` log level whose path name is PATH. .. option:: --nolog-upnp, -u Ignore UPnP log entries at ``debug`` log level. .. option:: --log-aio, -y Do not ignore asyncio log entries at ``debug`` log level; the default is to ignore those verbose logs. .. option:: --test-devices MIME-TYPES, -t MIME-TYPES MIME-TYPES is a comma separated list of distinct audio mime types. A DLNATestDevice is instantiated for each one of these mime types and registered as a virtual DLNA device. Mostly for testing. .. _Wireplumber issue 511: https://gitlab.freedesktop.org/pipewire/wireplumber/-/issues/511 ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1735744189.8802984 pa_dlna-0.16/docs/source/systemd.rst0000644000000000000000000000246714735255276014467 0ustar00systemd ======= Usage ----- The `python-systemd`_ package is required to run the pa-dlna systemd service unit. pa-dlna runs as a `systemd/User`_ service unit (Pulseaudio and Pipewire run also as a user service unit). Only one Control Point (such as pa-dlna) may interact with a given DLNA device and pa-dlna enforces this rule by allowing only one pa-dlna process per Sound Server. .. list-table:: Systemd commands for pa-dlna :widths: 40 60 :header-rows: 1 * - Purpose - Command * - Enable pa-dlna and start it - ``systemctl --user enable --now pa-dlna`` * - Disable pa-dlna and stop it - ``systemctl --user disable --now pa-dlna`` * - Start pa-dlna - ``systemctl --user start pa-dlna`` * - Stop pa-dlna - ``systemctl --user stop pa-dlna`` * - Get the state of pa-dlna - ``systemctl --user status pa-dlna`` * - Print the journal of pa-dlna - ``journalctl --user -u pa-dlna`` The pa-dlna.service unit ------------------------ The ``pa-dlna.service`` unit file is located in the ``systemd`` directory at the root of the pa-dlna git repository. Its content is: .. include:: ../../systemd/pa-dlna.service :code: text .. _python-systemd: https://www.freedesktop.org/software/systemd/python-systemd/ .. _systemd/User: https://wiki.archlinux.org/title/Systemd/User ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1734184752.5207272 pa_dlna-0.16/docs/source/upnp-cmd.rst0000644000000000000000000000276214727307461014513 0ustar00.. _upnp-cmd: upnp-cmd command ================ Synopsis -------- :program:`upnp-cmd` [*options*] UPnP discovery is run on all the networks (except the loopbak interface ``lo``) when the ``--ip-addresses`` and ``--nics`` command line arguments are not used or empty. Otherwise both arguments may be used indifferently or even jointly. Options ------- .. option:: -h, --help Show this help message and exit. .. option:: --version, -v Show program's version number and exit. .. option:: --ip-addresses IP_ADDRESSES, -a IP_ADDRESSES IP_ADDRESSES is a comma separated list of the IPv4 addresses of the networks where UPnP devices may be discovered (default: ``''``). .. option:: --nics NICS, -n NICS NICS is a comma separated list of the names of network interface controllers where UPnP devices may be discovered, such as ``wlan0,enp5s0`` for example (default: ``''``). .. option:: --msearch-interval MSEARCH_INTERVAL, -m MSEARCH_INTERVAL Set the time interval in seconds between the sending of the MSEARCH datagrams used for device discovery (default: 60) .. option:: --ttl TTL Set the IP packets time to live to TTL (default: 2). .. option:: --logfile PATH, -f PATH Add a file logging handler set at ``debug`` log level whose path name is PATH. .. option:: --nolog-upnp, -u Ignore UPnP log entries at ``debug`` log level. .. option:: --log-aio, -y Do not ignore asyncio log entries at ``debug`` log level; the default is to ignore those verbose logs. ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1698504488.3644369 pa_dlna-0.16/docs/source/usage.rst0000644000000000000000000002015714517217450014065 0ustar00Usage ===== :ref:`pa-dlna` usage -------------------- In this section: - A short description of :ref:`networking` relevant to ``pa-dlna``, the list of the UDP/TCP ports being used and what may be done when a firewall is in use. - Events triggering :ref:`discovery` and what happens then. - Configuration of a :ref:`source-sink` between an application as a Pulseaudio source (music player, firefox, etc...) and a DLNA device. The :ref:`pa-dlna` section lists the pa-dlna command line options. .. _networking: DLNA Networking """"""""""""""" UPnP device discovery (and therefore DLNA device discovery) is implemented by two protocols that run independently: 1. To search for devices, an UPnP control point such as pa-dlna: - Send MSEARCH UDP multicast datagrams to ``239.255.255.250:1900``. - Listen to the source IP address and **source UDP port** that is used to send the MSEARCH request for the responses that are sent by the devices. 2. To be notified of UPnP device advertisements, an UPnP control point listens on UDP port ``1900`` to receive NOTIFY UDP multicast datagrams broadcasted by the devices. When pa-dlna is ready to forward a Pulseaudio stream to a DLNA device, it starts an HTTP server, if not already running, that listens on TCP port 8080 (the default) at the local IP address of the network that has been used to discover the DLNA device. This HTTP server only accepts connection requests from the IP addresses of DLNA devices that have been learnt by pa-dlna. The HTTP session is used to forward the Pulseaudio stream. Ports that must be enabled on a network interface by a firewall: - MSEARCH UDP port: This is the UDP port specified by the ``--msearch-port`` command line option of ``pa-dlna``. This option may be used to set the specific **source UDP port** [#]_ of MSEARCH UDP datagrams so that this port may be enabled by a firewall. Otherwise if this option is not used or set to 0 the source port is chosen randomly by the operating system and it is necessary to configure the firewall to enable all UDP ports on the network interface. - NOTIFY UDP port: The port value is set by the UPnP specifications as ``1900``. When blocked by a firewall, UPnP device advertisements are not received but UPnP devices are still discovered with MSEARCH. - HTTP server's TCP port: This is the TCP port specified by the ``--port`` command line option of ``pa-dlna``. The default is port ``8080``. .. _discovery: DLNA device discovery """"""""""""""""""""" UPnP discovery is triggered by NICs [#]_ state changes. That is, whenever a configured NIC or the NIC of a configured IP address becomes up. Here are some examples of events triggering UPnP discovery on an IP address after ``pa-dlna`` or ``upnp-cmd`` [#]_ has been started: - A wifi controller connects to a hotspot and acquires a new IP address through DHCP, possibly a different address from the previous one. - A static IP address has been configured on an ethernet card connected to an ethernet switch and the switch is turned on. ``pa-dlna`` registers a new sink with Pulseaudio upon the discovery of a DLNA device and selects an encoder (see the :ref:`configuration` section for how the encoder is selected). The sink appears in the ``Output Devices`` tab of the ``pavucontrol`` graphical tool and is listed by the ``pactl`` Pulseaudio commands. .. _source-sink: Source-sink association """"""""""""""""""""""" Pulseaudio remembers the association between a source and a sink across different sessions. A thorough description of this feature is given in "PulseAudio under the hood" at `Automatic setup and routing`_. Use ``pavucontrol`` or ``pactl`` to establish this association between a source and a DLNA device while the source is playing and the DLNA device has been registered with Pulseaudio. Establishing this association is needed only once. With ``pavucontrol``: In the ``Playback`` tab, use the drop-down list of the source to select the DLNA sink registered by ``pa-dlna``. With ``pactl``: Get the list of sinks and find the index of the registered DLNA sink:: $ pactl list sinks | grep -e 'Sink' -e 'Name' Get the list of sources and find the index of the source [#]_; the source must be playing:: $ pactl list sink-inputs | grep -e 'Sink Input' -e 'binary' Using both indexes create the association between the sink input and the DLNA sink registered by ``pa-dlna``:: $ pactl move-sink-input When the DLNA device is not registered (``pa-dlna`` is not running or the DLNA device is turned off) Pulseaudio temporarily uses the default sink as the sink for this association. It is usually the host's sound card. See `Default/fallback devices`_. :ref:`upnp-cmd` usage --------------------- An interactive command line tool for introspection and control of UPnP devices. The :ref:`upnp-cmd` section lists the upnp-cmd command line options. Some examples: - When the UPnP device [#]_ is a DLNA device [#]_, running the ``GetProtocolInfo`` command in the ``ConnectionManager`` service menu prints the list of mime types supported by the device. - Commands in the ``RenderingControl`` service allow to control the volume or mute the device. **Note**: Upon ``upnp-cmd`` startup one must allow for the device discovery process to complete before being able to select a device. Commands usage: * Command completion and command arguments completion is enabled with the ```` key. * Help on the current menu is printed by typing ``?`` or ``help``. * Help on one of the commands is printed by typing ``help `` or ``? ``. * Use the arrow keys for command line history. * When the UPnP device is a DLNA device and one is prompted for ``InstanceID`` by some commands, use one of the ``ConnectionIDs`` printed by ``GetCurrentConnectionIDs`` in the ``ConnectionManager`` service. This is usually ``0`` as most DLNA devices do not support ``PrepareForConnection`` and therefore support only one connection. * To return to the previous menu, type ``previous``. * To exit the command type ``quit``, ``EOF``, ```` or ````. The menu hierarchy is as follows: 1. Main menu prompt: [Control Point] 2. Next submenu prompt: ``friendlyName`` of the selected device, for example [Yamaha RN402D]. 3. Next submenu prompt: Either the service name when a service has been selected as for example [ConnectionManager] or ``friendlyName`` of the selected device when an embedded device has been selected. One can select a DLNA device in the main menu and select a service or an embedded device in the device menu. UPnP Library ------------ UPnP devices are discovered by broadcasting MSEARCH SSDPs every 60 seconds (the default) and by handling the NOTIFY SSDPs broadcasted by the devices. The ``max-age`` directive in MSEARCH responses and NOTIFY broadcasts refreshes the aging time of the device. The device is discarded of the list of registered devices when this aging time expires. UPnP eventing is not supported. .. include:: common.txt .. _Default/fallback devices: https://www.freedesktop.org/wiki/Software/PulseAudio/Documentation/User/DefaultDevice/ .. _Automatic setup and routing: https://gavv.net/articles/pulseaudio-under-the-hood/#automatic-setup-and-routing .. rubric:: Footnotes .. [#] Prefer choosing a port in the range 49152–65535. .. [#] Network Interface Controller. .. [#] The list of the IP addresses learnt by pa-dlna through UPnP discovery may be listed with ``upnp-cmd`` by printing the value of the ``ip_monitored`` variable in the main menu. .. [#] A source is called a sink-input by Pulseaudio. .. [#] An UPnP device implements the `UPnP Device Architecture`_ specification. .. [#] A DLNA device is an UPnP device and implements the `MediaRenderer Device`_ specification and the `ConnectionManager`_, `AVTransport`_ and `RenderingControl`_ services. ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1740394248.9297037 pa_dlna-0.16/images/coverage.png0000644000000000000000000000521614757047411013550 0ustar00‰PNG  IHDRt\n} cHRMz&€„ú€èu0ê`:˜pœºQ<bKGDÿÿÿ ½§“ pHYs``ðkBÏ lIDAThÞí™{pTåÆçì9»Énv7Ù]Ø\Œ@HˆE:2*·ÒŽäR *0”ÖN XPpb¦-P0@f:P©À0ŒS:’ŽZ H†ÄÀ@$fÃER!’r¿'{;_ÿˆœ1&ä¢NÑ´ÏÌùc¿÷ûÞç}ßç|—ýŽä÷ûEvv6gΜ¡¡¡ÿ4þ6÷^‡ð­Áat3Õ9Å÷ý%;;›Ã‡ßë˜þëBÜë¾5Ôzoq¨b PrssUrýÅ`ÌùƒªwPêëëïu÷ƒQÐ:_Ê`L¬?¬yË÷:€ï*TÙÈSÌ7öc1X Sì²ÙU'V%¼K[ˆlÆÛ'ßÿgh˜é~Š%±k¸ÙVŠ;4–-W~õ–OØ1ö¸LQø5Ùop¤r72kv6MhÜh¿JÆÕT4ìÕö£!Oò¸ëIdÉÀáÛûȯÍàÙû_â\Õ n‰²^óêSP³ÙLHHõõõ]Š`³Ùðûý´··÷»ˆªªbµZ©««ëˆ¢àp8¨®®FÕjEUÕûZ­V4M£µµµG?6›­Çqý4\ÂÊáéübÿcTûo’rßOX91užùÚk,ÃSu¾³>Ñ ¡‘†n~ ŒX%‘Åû¦AÞ\x˜ áÓɯ;Ú«ígCWúîB fþ0ûuòjŽ22ìAl~ùŸ€1¢÷Eõ®‚Z­Vžþyìv;hšÆÖ­[±Z­¼øâ‹zŸK—.ñÖ[o‘˜˜È²eËxùå—u[¶laß¾}\¾|™E‹‘œœLuu5‡ƒ;vpëÖ-æÍ›Gbb"v»¯×˦M›X³f F£‘`0ˆÃá ##ƒÛ·o#I©©©ÄÆÆÒÜÜL}}=.—‹ôôt,XÀ£>ªsdffRYY9 A£ÕT5UÒ~ÿ-ì&OSFìæ8hôׂ1cÆàL2óYk ­æ}%[ÌûWßÅ2$ÉÀ±«ÙL6“¼ÚõjÃ/a‰“‘$4 `yì65Fêsï¿«  .¤´´”¬¬,dYFUUl6óçϧ¸¸˜]»v!Ë2YYYL˜0ÜÜ\TU%66–²²2† †¢(1~üxFÅÒ¥KHIIañâÅlß¾¯×KDDÏ=÷>Ÿ§ÓIZZÌž=›'žx‚Ý»w“œœŒÅbaùòå¬Zµ »ÝŽ‚±cÇ2zôhcÒ¤I,Y²„ŒŒŒ ZQ_ÆÐ‡¢±™í´y úA²›QC][5DLÁ¤Î"ÉñC¶]YŹºÝü8 n.òH4H·q"Bôj;T¼—ue¡H*Ùö0cØÏ)¸–KÇZîEeÇu|šwà‚&%%±yóf\.—^! deeát:8}ú4ñññäçç“““CJJ  %%…œœE!)) MÓHMMí$UFŽ©õܹs„……!I“'OfÚ´i8L&“¾Ü1‚‚‚yyyÄÅÅ!„èÆa0ºpôWÐêàMþvá/d>ü%Íçˆ6ÆÑÔÑ€Ï@ÁÚcO#É 4x,ò§¬™ñ'–ÖNèî_Éð%¥³MÑ«íhõòŽGÓ‚"‚lrìgíÅÅì˜ùk?bœk"/]\€W똠BL&S—Äïˆj4õvEQ:“ýðÃÉÌÌäàÁƒLž<™Õ«W#„@Ó4ÊËËÉÏÏ×};vL÷ç÷ûuÿn·›+V°víZnܸÁ¸qãX¹r%B¼^o—˜Ìf³>®'ŽãÇXPÉ»/ÿ‘¿{ö1tˆ›²êÏxïÙ"*Ú®#L[¢¢÷½ÔœÏÐÐ („¿‹Ÿª–r†„F!Z;y†„DQÕZ¢W›i¨L»¯ $xaôk¼‘·•qñã))+"«ð÷¤>¼‰±¶Iœ­¿Çøå;Eýêsá¦OŸ®ÿ6 !(**bâĉzÛ¤I“(..FASSׯ_gÙ²e”––ÒÜܹ¿’Àµk×ðx¶°°Q£FQZZÚc OÄp ÍÑå\…¬˜¶š#žƒø”6Œ’ ‹Á¦÷›™0ŸÒêKøƒ>„ü <—1 !§?ÿ'Scæ H*2™Ëé²#}Ú„H*<à‡ÉF±–‹¯-€bT0ÇP ¾ÖÀ]c¿ë Ý¿?6l`ûöí455ÑÑÑÁ–-[xûí·IOOgóæÍX,Ο?OAA&“ Y–ÉÉÉaýúõlܸY–õ—#..Ž]»vQ^^ŽÝnÇãñ°wïÞ.Á”””àóùÈÈÈ@Ó4ÊÊÊtÑ<'OždÛ¶m´µµqöìYâããBðñÇsêÔ)vîÜIEE6›’’öìÙ3  ðJÒ›¸B¢°ªܨ»FZÑ/!F`‘Ãy=ù}j¼•˜ä´€à•#¿BD °bx½þgª:ʹÐv’K•sxsü)4¤äfMÇP"D¯6I’Y1,´œ_£DI\¬9ÃÓq/°:ì5b ì¼’†d»Ë 3oÞ¼-š¦ÑØØˆÙl&44”ºº:ÂÃÃÑ4††ýT°Ùlú¸ööv±Û턆†êí---øý~"##©­­EÓ4l6---„……é…¾3;ï\K}Ïnii¡½½Y–yæ™gp88p  ‡Ûí¦®®Nçè ½}mi¾ÄeŒÄçóÓ¨Õ`MPÐ|‚¦b;Â×£†°‘ Š¥sïoøÄOh´“S-ÿ z-H’„WmÁ:R‰Þm€‚ ×­ø¢k‘h¾¬1Ô馪æa£ |qÜè.èܹs{=kš¦/¯_m—$I?ÈôÁ`Y–û »q¼úê«TTT`·Û>|8ëÖ­ëö_¸?}}>Ó|$Õ¯ø ùȲBŸÁ/ m€M€]¹¿à•UI¾'HsæÌù^]išÆˆ#ðz½|úé§˜Íæ…ï ƒé{è—¡DDDôy«ò]‚‚«W¯"IKç’õu®/ã•§Ë…2uêT:t¯cé7$IBUUý÷×f0 :3jÊ’%K8qâÄ÷j¦~S &A]¦HfÅ<Åòøõü¼Ç8ñ$%tEXtdate:create2025-02-24T10:50:31+00:00÷ à%tEXtdate:modify2025-02-24T10:50:31+00:00†Q²\(tEXtdate:timestamp2025-02-24T10:50:48+00:00NßIIEND®B`‚././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1740394180.9467328 pa_dlna-0.16/pa_dlna/__init__.py0000644000000000000000000000067014757047305013512 0ustar00"""Forward pulseaudio streams to DLNA devices.""" import sys import logging __version__ = '0.16' MIN_PYTHON_VERSION = (3, 8) MIN_LIBPULSE_VERSION = '0.7' # Systemd log level set between WARNING and ERROR. SYSTEMD_LOG_LEVEL = logging.WARNING + 5 _version = sys.version_info[:2] if _version < MIN_PYTHON_VERSION: print(f'error: the python version must be at least' f' {MIN_PYTHON_VERSION}', file=sys.stderr) sys.exit(1) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1735744189.8802984 pa_dlna-0.16/pa_dlna/config.py0000644000000000000000000002443214735255276013226 0ustar00"""Build the default and user configurations.""" import sys import os import pprint import textwrap import logging from configparser import ConfigParser, ParsingError from . import SYSTEMD_LOG_LEVEL from . import encoders as encoders_module logger = logging.getLogger('config') BOOLEAN_WRITE = {'True': 'yes', 'False': 'no'} BOOLEAN_PARSE = {'yes': True, 'no': False} # Encoders configuration. def new_cfg_parser(**kwargs): # 'allow_no_value' to write comments as fake options. parser = ConfigParser(allow_no_value=True, **kwargs) # Do not convert option names to lower case in interpolations. parser.optionxform = str parser.BOOLEAN_STATES = BOOLEAN_PARSE return parser def comments_from_doc(doc): """A generator of comments from text.""" lines = doc.splitlines() doc = lines[0] + '\n' + textwrap.dedent('\n'.join(l for l in lines[1:] if l == '' or l.strip())) for line in doc.splitlines(): yield '# ' + line if line else '#' def user_config_pathname(): base_path = os.environ.get('XDG_CONFIG_HOME') if base_path is None: base_path = os.path.expanduser('~/.config') return os.path.join(base_path, 'pa-dlna', 'pa-dlna.conf') def set_args_in_parser(encoder, section, parser): encoder.set_args() if encoder.args: parser.set(section, 'args', encoder.args) class DefaultConfig: """The default built-in configuration as a dict.""" def __init__(self): self.root_class = encoders_module.ROOT_ENCODER self.parser = None self.empty_comment_cnt = 0 # Build a dictionary of the leaves of the 'root_class' # class hierarchy excluding the direct subclasses. m = encoders_module self.leaves = dict((name, obj) for (name, obj) in m.__dict__.items() if isinstance(obj, type) and issubclass(obj, self.root_class) and obj.__mro__.index(self.root_class) != 1 and not obj.__subclasses__()) self.default_config() def write_empty_comment(self, section): # Make ConfigParser believe that we are adding each time # a different option with no value. self.parser.set(section, "#" + self.empty_comment_cnt * ' ') self.empty_comment_cnt += 1 def default_config(self): """Build a parser holding the built-in default configuration.""" def convert_boolean(obj, attr): val = str(getattr(obj, attr)).strip() if val in BOOLEAN_WRITE: val = BOOLEAN_WRITE[val] return val root = self.root_class() sections = root.selection defaults = {'selection': '\n' + ',\n'.join(sections) + ','} for attr in root.__dict__: if attr != 'selection' and not attr.startswith('_'): val = convert_boolean(root, attr) defaults[attr] = val self.parser = new_cfg_parser(defaults=defaults) for section in sorted(sections): if section not in self.leaves: raise ParsingError(f"'{section}' is not a valid class name") self.parser.add_section(section) encoder = self.leaves[section]() doc = encoder.__class__.__doc__ if doc: for comment in comments_from_doc(doc): if comment == '#': self.write_empty_comment(section) else: self.parser.set(section, comment) self.write_empty_comment(section) write_separator = True for attr in encoder.__dict__: val = convert_boolean(encoder, attr) if attr.startswith('_'): self.parser.set(section, f"# {attr[1:]}: {val}") elif (not hasattr(root, attr) or getattr(root, attr) != getattr(encoder, attr)): if write_separator: write_separator = False self.write_empty_comment(section) self.parser.set(section, attr, val) set_args_in_parser(encoder, section, self.parser) def get_value(self, section, encoder, option, new_val): old_val = getattr(encoder, option) if old_val is not True and old_val is not False: for t in (int, float): if isinstance(old_val, t): try: new_val = t(new_val) if new_val < 0: raise ParsingError( f'{section}.{option}: {new_val} is negative') except ValueError as e: raise ParsingError(f'{section}.{option}: {e}') try: return self.parser.getboolean(section, option) except ValueError: pass return new_val def override_options(self, encoder, section, defaults): encoder.set_args() default_args = encoder.args for option, value in self.parser.items(section): if option.startswith("#") or option == 'selection': continue if (hasattr(encoder, option) and not option.startswith('_')): new_val = self.get_value(section, encoder, option, value) if new_val is not None: # Do not override 'sample_format' in L16 encoders, # as it is correctly set to 's16be' upon instantiation. if (option != 'sample_format' or 'audio/l16' not in (mtype.lower() for mtype in encoder._mime_types)): setattr(encoder, option, new_val) elif option not in defaults: raise ParsingError(f'Unknown option' f" '{section}.{option}'") # Re-evaluate 'args' with possibly modified options, as 'args' itself # has not been customized by the user. if default_args == encoder.args: encoder.set_args() def write(self, fileobject): """Write the configuration to a text file object.""" for comment in comments_from_doc(self.root_class.__doc__): fileobject.write(comment + '\n') fileobject.write('\n') if self.parser is not None: self.parser.write(fileobject) class UserConfig(DefaultConfig): """The user configuration used internally, as a dict. The configuration is derived from the default configuration and the 'pa-dlna.conf' file. Only the encoders selected by the user are listed. """ def __init__(self, systemd=False): super().__init__() assert self.parser is not None self.udns = {} self.encoders = {} # Read the user configuration. user_config = user_config_pathname() try: fileobject = open(user_config) except FileNotFoundError: pass else: with fileobject: loglevel = SYSTEMD_LOG_LEVEL if systemd else logging.INFO logger.log(loglevel, f'Using encoders configuration at {user_config}') self.parser.read_file(fileobject) self.build_dictionaries() def any_available(self): return bool(self.udns or self.encoders) def build_dictionaries(self): def validate(encoder): if not encoder.available: return False # Do not print these attributes. if hasattr(encoder, '_available'): del encoder._available if hasattr(encoder, 'selection'): del encoder.selection return True unsorted_encoders = {} defaults = self.parser.defaults() selection = [s for s in (x.strip() for x in defaults['selection'].split(',')) if s] for section in self.parser: encoder_name, sep, udn = section.partition('.') # An encoder section. if sep == '' and udn == '': if encoder_name not in selection: continue # Error when section is 'encoder_name' followed by a '.'. elif udn == '': raise ParsingError(f"'{section}' is not a valid section") # An [EncoderName.UDN] section. else: pass if encoder_name not in self.leaves: raise ParsingError(f"'{section}' encoder does not exist") encoder = self.leaves[encoder_name]() if udn: set_args_in_parser(encoder, section, self.parser) self.override_options(encoder, section, defaults) if udn == '': unsorted_encoders[encoder_name] = encoder else: if not validate(encoder): continue self.udns[udn] = encoder # Build the encoders dictionary according to the selection's order. for sel in selection: if sel in unsorted_encoders: encoder = unsorted_encoders[sel] if not validate(encoder): continue self.encoders[sel] = encoder else: raise ParsingError(f"'{sel}' in the selection is not a valid" f' encoder') def print_internal_config(self): # The udns are printed first. config = {} for section, udn in self.udns.items(): # The '_encoder' option is first. options = {'_encoder': udn.__class__.__name__} options.update(udn.__dict__) config[section] = options for section, encoder in self.encoders.items(): config[section] = encoder.__dict__ if not config: sys.stdout.write('No encoder is available\n') return encoders_repr = pprint.pformat(config, sort_dicts=False, compact=True) sys.stdout.write('Internal configuration:\n') sys.stdout.write('The keys starting with underscore are read only.\n') sys.stdout.write(f'{encoders_repr}\n') ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1734865062.4893935 pa_dlna-0.16/pa_dlna/encoders.py0000644000000000000000000003034714731770246013560 0ustar00"""Encoders configuration. Attributes starting with '_' are seen by the user as read only options. """ import subprocess import shutil import logging from .upnp.util import NL_INDENT DEFAULT_SELECTION = ( 'Mp3Encoder', 'FFMpegMp3Encoder', # Lossless encoders. 'L16Encoder', 'FFMpegL16WavEncoder', 'FFMpegAiffEncoder', 'FlacEncoder', 'FFMpegFlacEncoder', # Lossy encoders. 'FFMpegOpusEncoder', 'FFMpegVorbisEncoder', 'FFMpegAacEncoder', ) def select_encoder(config, renderer_name, pinfo, udn): """Select the encoder. Return the selected encoder instance, the mime type and protocol info. """ logger = logging.getLogger('encoder') def found_encoder(encoder, proto): if encoder.has_mime_type(proto[2]): logger.info(f"Selected encoder mime type: '{encoder.mime_type}'") return encoder, encoder.mime_type, ':'.join(proto) # The ProtocolInfo format is: # “:â€â€œ:â€â€œ:†# We are interested in the HTTP streaming entries: # http-get:*:mime-type:* protocol_infos = [proto.split(':') for proto in (x.strip() for x in pinfo['Sink'].split(',')) if proto.startswith('http-get:')] mime_types = [proto[2] for proto in protocol_infos] logger.debug(f'{renderer_name} renderer mime types:' + NL_INDENT + f'{mime_types}') # Try first the configured udns. for section, encoder in config.udns.items(): if section == udn: # Check that the list of mime_types holds one of the mime types # supported by this encoder. for proto in protocol_infos: result = found_encoder(encoder, proto) if result is not None: return result else: logger.error(f'No matching mime type for the udn configured' f' on the {encoder} encoder') return None # Then the encoders proper. for encoder in config.encoders.values(): for proto in protocol_infos: result = found_encoder(encoder, proto) if result is not None: return result class Encoder: """The pa-dlna default configuration. This is the built-in pa-dlna configuration written as text. It can be parsed by a Python Configuration parser and consists of sections, each led by a [section] header, followed by option/value entries separated by '='. See https://docs.python.org/3/library/configparser.html. The 'selection' option is written as a multi-line in which case all the lines after the first line start with a white space. The default value of 'selection' lists the encoders in this order: - mp3 encoders first as mp3 is the most common encoding - lossless encoders - then lossy encoders See https://trac.ffmpeg.org/wiki/Encode/HighQualityAudio. """ def __init__(self): self.selection = DEFAULT_SELECTION self.sample_format = 's16le' self.rate = 44100 self.channels = 2 self.track_metadata = True self.soap_minimum_interval = 5 self.args = None @property def available(self): if hasattr(self, '_available'): return self._available return True @property def mime_type(self): assert hasattr(self, 'requested_mtype') return self.requested_mtype def has_mime_type(self, mime_type): if mime_type.lower().strip() in self._mime_types: self.requested_mtype = mime_type return True def set_args(self): raise NotImplementedError @property def command(self): if hasattr(self, '_command'): return self._command elif hasattr(self, '_pgm'): cmd = [self._pgm] cmd.extend(self.args.split()) return cmd @command.setter def command(self, value): """The command setter used by the test suite.""" self._command = value def __str__(self): return self.__class__.__name__ ROOT_ENCODER = Encoder class StandAloneEncoder(Encoder): """Abstract class for standalone encoders.""" def __init__(self): super().__init__() class L16Mixin(): """Mixin class for L16 encoders.""" @property def mime_type(self): assert hasattr(self, 'requested_mtype') return self.requested_mtype def has_mime_type(self, mime_type): # For example 'audio/L16;rate=44100;channels=2'. mtype = [p.strip() for p in mime_type.lower().split(';')] if mtype[0] != self._mime_types[0]: return False rate_channels = [None, None] # list of [rate, channels] for param in mtype[1:]: for (n, prefix) in enumerate(['rate=', 'channels=']): if param.startswith(prefix): try: rate_channels[n] = int(param[len(prefix):]) except ValueError: return False break if (rate_channels[0] not in (None, self.rate) or rate_channels[1] not in (None, self.channels)): return False if rate_channels[0] is None: # The DLNA answer to GetProtocolInfo includes 'audio/L16' without # the rate which is required in the 'Content-type' field of the # HTTP '200 OK' response sent to the DLNA. mime_type = f'{mime_type};rate={self.rate}' if rate_channels[1] is None: mime_type = f'{mime_type};channels={self.channels}' self.requested_mtype = mime_type return True class FlacEncoder(StandAloneEncoder): """Lossless Flac encoder. See the flac home page at https://xiph.org/flac/ See also https://xiph.org/flac/documentation_tools_flac.html """ def __init__(self): self._pgm = shutil.which('flac') self._available = self._pgm is not None self._mime_types = ['audio/flac', 'audio/x-flac'] super().__init__() def set_args(self): endian = 'little' if self.sample_format == 's16le' else 'big' self.args = (f'- --silent --channels {self.channels} ' f'--sample-rate {self.rate} ' f'--sign signed --bps 16 --endian {endian}') class L16Encoder(L16Mixin, StandAloneEncoder): """Lossless PCM L16 encoder without a container. This encoder does not use an external program for streaming. It only uses the Pulseaudio parec program. See also https://datatracker.ietf.org/doc/html/rfc2586. """ def __init__(self): self._mime_types = ['audio/l16'] StandAloneEncoder.__init__(self) self.sample_format = 's16be' def set_args(self): pass class Mp3Encoder(StandAloneEncoder): """Mp3 encoder from the Lame Project. See the Lame Project home page at https://lame.sourceforge.io/ See lame command line options at https://svn.code.sf.net/p/lame/svn/trunk/lame/USAGE """ def __init__(self): self._pgm = shutil.which('lame') self._available = self._pgm is not None self._mime_types = ['audio/mp3', 'audio/mpeg'] super().__init__() self.bitrate = 256 self.quality = 0 def set_args(self): sampling = self.rate / 1000 endian = 'little' if self.sample_format == 's16le' else 'big' self.args = (f'-r -s {sampling} --signed --bitwidth 16 ' f'--{endian}-endian ' f'-q {self.quality} -b {self.bitrate} -') class FFMpegEncoder(Encoder): """Abstract class for ffmpeg encoders. See also https://www.ffmpeg.org/ffmpeg.html. """ PGM = None FORMATS = None ENCODERS = None container = None encoder = None def __init__(self, mime_types, *, sample_format=None): assert self.container is not None if self.FORMATS is None: FFMpegEncoder.FORMATS = '' FFMpegEncoder.PGM = shutil.which('ffmpeg') if self.PGM is not None: proc = subprocess.run([self.PGM, '-formats'], stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True) FFMpegEncoder.FORMATS = proc.stdout self._available = self.container in self.FORMATS self._pgm = self.PGM self._mime_types = mime_types # End of setting options as comments. super().__init__() # Override the default sample_format. if sample_format is not None: self.sample_format = sample_format if self.encoder is not None: if self.ENCODERS is None: FFMpegEncoder.ENCODERS = '' if self.PGM is not None: proc = subprocess.run([self.PGM, '-encoders'], stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True) FFMpegEncoder.ENCODERS = proc.stdout self._available = self.encoder in self.ENCODERS and self._available def extra_args(self): return '' def set_args(self): self.args = (f'-loglevel error -hide_banner -nostats ' f'-ac {self.channels} -ar {self.rate} ' f'-f {self.sample_format} -i - ' f'-f {self.container}') if self.encoder is not None: self.args += f' -c:a {self.encoder}' extra = self.extra_args() if extra: self.args += f' {extra}' self.args += ' pipe:1' class FFMpegAacEncoder(FFMpegEncoder): """Aac encoder. 'bitrate' is expressed in kilobits. See also https://trac.ffmpeg.org/wiki/Encode/AAC. """ container = 'adts' encoder = 'aac' def __init__(self): super().__init__(['audio/aac', 'audio/x-aac', 'audio/vnd.dlna.adts']) self.bitrate = 192 def extra_args(self): return f'-b:a {self.bitrate}k' class FFMpegAiffEncoder(FFMpegEncoder): """Lossless Aiff Encoder.""" container = 'aiff' def __init__(self): super().__init__(['audio/aiff']) class FFMpegFlacEncoder(FFMpegEncoder): """Lossless Flac encoder. See also https://ffmpeg.org/ffmpeg-all.html#flac-2. """ container = 'flac' def __init__(self): super().__init__(['audio/flac', 'audio/x-flac']) class FFMpegL16WavEncoder(L16Mixin, FFMpegEncoder): """Lossless PCM L16 encoder with a wav container.""" container = 'wav' def __init__(self): FFMpegEncoder.__init__(self, ['audio/l16'], sample_format='s16be') class FFMpegMp3Encoder(FFMpegEncoder): """Mp3 encoder. Setting 'bitrate' to 0 causes VBR encoding to be chosen and 'qscale' to be used instead, otherwise 'bitrate' is expressed in kilobits. See also https://trac.ffmpeg.org/wiki/Encode/MP3. """ container = 'mp3' encoder = 'libmp3lame' def __init__(self): super().__init__(['audio/mp3', 'audio/mpeg']) self.bitrate = 256 self.qscale = 2 def extra_args(self): if self.bitrate != 0: return f'-b:a {self.bitrate}k' else: return f'-qscale:a {self.qscale}' class FFMpegOpusEncoder(FFMpegEncoder): """Opus encoder. See also https://wiki.xiph.org/Opus_Recommended_Settings. """ container = 'opus' encoder = 'libopus' def __init__(self): super().__init__(['audio/opus', 'audio/x-opus']) self.bitrate = 128 def extra_args(self): return f'-b:a {self.bitrate}k' class FFMpegVorbisEncoder(FFMpegEncoder): """Vorbis encoder. Setting 'bitrate' to 0 causes VBR encoding to be chosen and 'qscale' to be used instead, otherwise 'bitrate' is expressed in kilobits. See also https://ffmpeg.org/ffmpeg-all.html#libvorbis. """ container = 'ogg' encoder = 'libvorbis' def __init__(self): super().__init__(['audio/vorbis', 'audio/x-vorbis']) self.bitrate = 256 self.qscale = 3.0 def extra_args(self): if self.bitrate != 0: return f'-b:a {self.bitrate}k' else: return f'-qscale:a {self.qscale}' ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1738574572.4963608 pa_dlna-0.16/pa_dlna/http_server.py0000644000000000000000000005006414750105354014312 0ustar00"""An asyncio HHTP 1.1 server serving DLNA devices requests.""" import os import asyncio import signal import urllib.parse import logging from http import HTTPStatus from .upnp.util import (AsyncioTasks, log_unhandled_exception, log_exception, HTTPRequestHandler) from .encoders import FFMpegEncoder, L16Encoder logger = logging.getLogger('http') # A stream with a throughput of 1 Mbps sends 2048 bytes every 15.6 msecs. HTTP_CHUNK_SIZE = 2048 async def kill_process(process): try: try: # First try with SIGTERM. process.terminate() await asyncio.wait_for(process.wait(), timeout=1.0) except asyncio.TimeoutError: pass finally: # Kill the process if the process is still alive. # And close transports of stdin, stdout, and stderr pipes, # otherwise we would get an exception on exit triggered by garbage # collection (a Python bug ?): # Exception ignored in: = 0 else signal.strsignal(-ret) logger.info(f'Exit status of parec process: {exit_status}') if exit_status in (0, 'Killed', 'Terminated'): await self.close() return await self.close(disable=True) @log_unhandled_exception(logger) async def run_encoder(self, encoder_cmd): renderer = self.session.renderer logger.info(f"{renderer.name}: {' '.join(encoder_cmd)}") exit_status = 0 self.encoder_proc = await asyncio.create_subprocess_exec( *encoder_cmd, stdin=self.pipe_reader, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE) self.queue.put_nowait(self.encoder_proc.stdout) self.session.stream_tasks.create_task( self.log_stderr('encoder', self.encoder_proc.stderr), name='encoder_stderr') ret = await self.encoder_proc.wait() self.encoder_proc = None self.stream_reader = None exit_status = ret if ret >= 0 else signal.strsignal(-ret) # ffmpeg exit code is 255 when the process is killed with SIGTERM. # See ffmpeg main() at https://gitlab.com/fflabs/ffmpeg/-/blob/ # 0279e727e99282dfa6c7019f468cb217543be243/fftools/ffmpeg.c#L4833 if (isinstance(renderer.encoder, FFMpegEncoder) and exit_status == 255): exit_status = 'Terminated' logger.info(f'Exit status of encoder process: {exit_status}') if exit_status in (0, 'Killed', 'Terminated'): return await self.close(disable=True) async def run(self): renderer = self.session.renderer logger.info(f'Start {renderer.name} stream process(es)') encoder = renderer.encoder try: if self.parec_proc is None: # Start the parec task. # An L16Encoder stream only runs the parec program. # Use a copy of parec_cmd. parec_cmd = renderer.control_point.parec_cmd[:] if self.no_encoder: coro = self.run_parec(encoder, parec_cmd) else: self.pipe_reader, stdout = os.pipe() coro = self.run_parec(encoder, parec_cmd, stdout) self.parec_task = self.session.stream_tasks.create_task(coro, name='parec') # Start the encoder task. if not self.no_encoder and self.encoder_proc is None: encoder_cmd = encoder.command self.encoder_task = self.session.stream_tasks.create_task( self.run_encoder(encoder_cmd), name='encoder') except Exception as e: log_exception(logger, f'{e!r}') await self.close(disable=True) class StreamSessions: """Handle multiple tracks. A track is processed with the stream data flowing through pipes established between stream subprocesses and the HTTP socket: parec process | encoder process | Track instance writing to HTTP socket A new session starts when 'track_count' is zero and ends upon a call to the close_session() method. Stopping a track terminates the encoder process but not the parec process. Two tracks may overlap within a given session, indeed this is the purpose of the 'SetNextAVTransportURI' UPnP soap action: the DLNA device uploads the next track while it is playing the end of the current track by emptying its buffer. This is implemented here by the Track.shutdown() coroutine running in a task. """ def __init__(self, renderer): self.renderer = renderer self.is_playing = False self.processes = None self.track = None self.track_count = 0 self.stream_tasks = AsyncioTasks() async def stop_track(self): self.is_playing = False if self.track is not None: try: self.track.stop() finally: self.track = None if self.processes is not None: await self.processes.close_encoder() async def close_session(self, shutdown_coro=False): self.is_playing = False self.track_count = 0 if self.track is not None: try: if not shutdown_coro: self.track.stop() else: await self.track.close() finally: self.track = None if self.processes is not None: await self.processes.close() self.processes = None async def start_track(self, writer): self.is_playing = True # Start the subprocesses. if self.processes is None: self.processes = StreamProcesses(self) await self.processes.run() # Get the reader from the last subprocess on the pipe chain. reader = await self.processes.get_stream_reader() self.track_count += 1 task_name = f'{self.renderer.name}-track-{self.track_count}' self.track = Track(self, writer, task_name) self.track.task = self.stream_tasks.create_task( self.track.run(reader), name=task_name) class HTTPServer: """HHTP server accepting connections only from 'allowed_ips'. Reference: Hypertext Transfer Protocol -- HTTP/1.1 - RFC 7230. """ def __init__(self, control_point, ip_address, port): self.control_point = control_point self.ip_address = ip_address self.port = port self.allowed_ips = set() loop = asyncio.get_running_loop() self.startup = loop.create_future() def allow_from(self, ip_addr): self.allowed_ips.add(ip_addr) @log_unhandled_exception(logger) async def client_connected(self, reader, writer): """Handle an HTTP GET request from a DLNA device. This is a callback scheduled as a task by asyncio. """ peername = writer.get_extra_info('peername') ip_source = peername[0] if ip_source not in self.allowed_ips: sockname = writer.get_extra_info('sockname') logger.warning(f'Discarded TCP connection from {ip_source} (not' f' allowed) received on {sockname[0]}') writer.close() return do_close = True try: handler = HTTPRequestHandler(reader, writer, peername) await handler.set_rfile() handler.handle_one_request() if not hasattr(handler, 'path'): content = handler.rfile.getvalue().decode() request = content.splitlines()[0] if content else '' logger.error(f'Invalid path in HTTP request from {ip_source}:' f' {request}') return # Start the stream in a new task if the GET request is valid and # the uri path matches one of the encoder's. # BaseHTTPRequestHandler has decoded the received bytes as # 'iso-8859-1' encoded, now unquote the uri path. uri_path = urllib.parse.unquote(handler.path) for renderer in self.control_point.renderers(): if not renderer.match(uri_path): continue if handler.request_version != 'HTTP/1.1': handler.send_error( HTTPStatus.HTTP_VERSION_NOT_SUPPORTED) await renderer.disable_root_device() break if renderer.stream_sessions.is_playing: handler.send_error(HTTPStatus.CONFLICT, f'Cannot start {renderer.name} stream' f' (already running)') break if renderer.nullsink is None: handler.send_error(HTTPStatus.CONFLICT, f'{renderer.name} temporarily disabled') break if handler.command == 'HEAD': await write_http_ok(writer, renderer) return # Ok, handle the request. await renderer.start_track(writer) # The track task has been started by the renderer's # StreamSessions instance. do_close = False return else: handler.send_error(HTTPStatus.NOT_FOUND, 'Cannot find a matching renderer') # Flush the error response. await writer.drain() finally: if do_close: try: writer.close() await writer.wait_closed() except ConnectionError: pass @log_unhandled_exception(logger) async def run(self): task_name = asyncio.current_task().get_name() try: aio_server = await asyncio.start_server(self.client_connected, self.ip_address, self.port) addrs = ', '.join(str(sock.getsockname()) for sock in aio_server.sockets) logger.info(f'{task_name} serve HTTP requests on {addrs}') async with aio_server: try: self.startup.set_result(None) await aio_server.serve_forever() finally: logger.info(f'{task_name} closed') except Exception as e: await self.control_point.close(f'{e!r}') raise ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1735810355.0901196 pa_dlna-0.16/pa_dlna/init.py0000644000000000000000000003466614735456463012737 0ustar00"""Utilities for starting an UPnPApplication.""" import sys import os import argparse import ipaddress import logging import asyncio import threading import struct import atexit import configparser from pathlib import Path try: import systemd as systemd_module from systemd import journal, daemon except ImportError: systemd_module = None try: import termios except ImportError: termios = None from . import __version__, MIN_LIBPULSE_VERSION, SYSTEMD_LOG_LEVEL from .config import DefaultConfig, UserConfig from .pulseaudio import APPS_TITLE, APPS_HEADER logger = logging.getLogger('init') def require_libpulse_version(version): from libpulse.libpulse import __version__ as libpulse_version if libpulse_version.startswith('v') or libpulse_version < version: sys.exit(f"Error: libpulse version '{version}' or more recent" f" is required.\n" f"The libpulse installed version is '{libpulse_version}'.") def disable_xonxoff(fd): """Disable XON/XOFF flow control on output.""" def restore_termios(): try: termios.tcsetattr(fd, termios.TCSANOW, old_attr) except termios.error as e: print(f'Error failing to restore termios: {e!r}', file=sys.stderr) if termios is not None and os.isatty(fd): try: old_attr = termios.tcgetattr(fd) new_attr = termios.tcgetattr(fd) new_attr[0] = new_attr[0] & ~termios.IXON termios.tcsetattr(fd, termios.TCSANOW, new_attr) logger.debug('Disabling XON/XOFF flow control on output') return restore_termios except termios.error: pass # Parsing arguments utilities. class FilterDebug: def filter(self, record): """Ignore DEBUG logging messages.""" if record.levelno != logging.DEBUG: return True def setup_logging(options, default_loglevel): root = logging.getLogger() root.setLevel(logging.DEBUG) options_systemd = options.get('systemd') if options_systemd and systemd_module is not None: handler = journal.JournalHandler(SYSLOG_IDENTIFIER='pa-dlna') formatter = logging.Formatter(fmt='%(message)s') else: handler = logging.StreamHandler() formatter = logging.Formatter( fmt='%(name)-7s %(levelname)-7s %(message)s') options_loglevel = options.get('loglevel') if options_systemd and not options_loglevel: handler.setLevel(SYSTEMD_LOG_LEVEL) else: loglevel = options_loglevel if options_loglevel else default_loglevel handler.setLevel(getattr(logging, loglevel.upper())) handler.setFormatter(formatter) root.addHandler(handler) if options['nolog_upnp']: logging.getLogger('upnp').addFilter(FilterDebug()) logging.getLogger('network').addFilter(FilterDebug()) if not options['log_aio']: logging.getLogger('asyncio').addFilter(FilterDebug()) # Add a file handler set at the debug level. if options['logfile'] is not None: logfile = os.path.expanduser(options['logfile']) try: logfile_hdler = logging.FileHandler(logfile, mode='w') except OSError as e: logging.error(f'cannot setup the log file: {e!r}') else: logfile_hdler.setLevel(logging.DEBUG) formatter = logging.Formatter( fmt='%(asctime)s %(name)-7s %(levelname)-7s %(message)s') logfile_hdler.setFormatter(formatter) root.addHandler(logfile_hdler) return logfile_hdler return None def get_applications(clients_uuids_path, parser): if not clients_uuids_path: return None path = Path(clients_uuids_path) path = path.expanduser() if path.is_file(): cfg_parser = CaseConfigParser(default_section=APPS_TITLE, delimiters=('->', ), empty_lines_in_values=False) try: with open(path) as f: cfg_parser.read_file(f) except configparser.Error as e: parser.error(f'ConfigParser error: {e}') except OSError as e: parser.error(e) # Use the default section of 'cfg_parser' to store the data in # the 'applications' dict. applications = cfg_parser.defaults() if not applications: sections = cfg_parser.sections() if sections: parser.error(f'Invalid default section header in {path}.\n' f"Instead of '[{sections[0]}]'" f" it must be: '[{APPS_TITLE}]'") return applications else: # Make sure that path is writable by writing the header. try: with open(path, 'w') as f: f.write(APPS_HEADER) return dict() except OSError as e: parser.error(f'{path} is not writable: {e!r}') def parse_args(doc, pa_dlna=True, argv=sys.argv[1:]): """Parse the command line. UPnP discovery is run on all the networks (except the loopbak interface 'lo') when the '--ip-addresses' and '--nics' command line arguments are not used or empty. Otherwise both arguments may be used indifferently or even jointly. """ def pack_B(ttl): try: ttl = int(ttl) return struct.pack('B', ttl) except (struct.error, ValueError) as e: parser.error(f"Bad 'ttl' argument: {e!r}") def mime_types(mtypes): mtypes = [y for y in (x.strip() for x in mtypes.split(',')) if y] if len(set(mtypes)) != len(mtypes): parser.error('The mime types in MIME-TYPES must be different') for mtype in mtypes: mtype_split = mtype.split('/') if len(mtype_split) != 2 or mtype_split[0] != 'audio': parser.error(f"'{mtype}' is not an audio mime type") return mtypes def ipv4_addresses(ip_addresses): ipv4_addrs = [] for addr in (x.strip() for x in ip_addresses.split(',')): if addr: try: ipaddress.IPv4Address(addr) except ValueError as e: parser.error(e) ipv4_addrs.append(addr) return ipv4_addrs parser = argparse.ArgumentParser(description=doc, epilog=' '.join(parse_args.__doc__.split('\n')[2:])) prog = 'pa-dlna' if pa_dlna else 'upnp-cmd' parser.prog = prog parser.add_argument('--version', '-v', action='version', version='%(prog)s: version ' + __version__) parser.add_argument('--ip-addresses', '-a', default='', type=ipv4_addresses, help='IP_ADDRESSES is a comma separated list of the' ' local IPv4 addresses of the networks where UPnP' " devices may be discovered (default: '%(default)s')") parser.add_argument('--nics', '-n', default='', help='NICS is a comma separated list of the names of' ' network interface controllers where UPnP devices' " may be discovered such as 'wlan0,enp5s0' for" " example (default: '%(default)s')") parser.add_argument('--msearch-interval', '-m', type=int, default=60, help='set the time interval in seconds between the' ' sending of the MSEARCH datagrams used for UPnP' ' device discovery (default: %(default)s)') parser.add_argument('--msearch-port', '-p', type=int, default=0, help='set the local UDP port for receiving MSEARCH' ' response messages from UPnP devices, a value of' " '0' means letting the operating system choose an" ' ephemeral port (default: %(default)s)') parser.add_argument('--ttl', type=pack_B, default=b'\x02', help='set the IP packets time to live to TTL' ' (default: 2)') if pa_dlna: parser.add_argument('--port', type=int, default=8080, help='set the TCP port on which the HTTP server' ' handles DLNA requests (default: %(default)s)') parser.add_argument('--dump-default', '-d', action='store_true', help='write to stdout (and exit) the default' ' built-in configuration') parser.add_argument('--dump-internal', '-i', action='store_true', help='write to stdout (and exit) the' ' configuration used internally by the program on' ' startup after the pa-dlna.conf user' ' configuration file has been parsed') parser.add_argument('--clients-uuids', metavar='PATH', help='PATH name of a file where are stored the' ' associations between client applications and' ' their DLNA device uuid' ) parser.add_argument('--loglevel', '-l', choices=('debug', 'info', 'warning', 'error'), help='set the log level of the stderr logging' ' console (default: info)') parser.add_argument('--systemd', action='store_true', help='run as a systemd service unit') parser.add_argument('--logfile', '-f', metavar='PATH', help='add a file logging handler set at ' "'debug' log level whose path name is PATH") parser.add_argument('--nolog-upnp', '-u', action='store_true', help="ignore UPnP log entries at 'debug' log level") parser.add_argument('--log-aio', '-y', action='store_true', help='do not ignore asyncio log entries at' " 'debug' log level; the default is to ignore those" ' verbose logs') if pa_dlna: parser.add_argument('--test-devices', '-t', metavar='MIME-TYPES', type=mime_types, default='', help='MIME-TYPES is a comma separated list of' ' distinct audio mime types. A DLNATestDevice is' ' instantiated for each one of these mime types' ' and registered as a virtual DLNA device. Mostly' ' for testing.') # Options as a dict. options = vars(parser.parse_args(argv)) dump_default = options.get('dump_default') dump_internal = options.get('dump_internal') if dump_default and dump_internal: parser.error(f"Cannot set both '--dump-default' and " f"'--dump-internal' arguments simultaneously") if dump_default or dump_internal: return options, None default_loglevel = 'info' if pa_dlna else 'error' logfile_hdler = setup_logging(options, default_loglevel) if options['logfile'] is not None and logfile_hdler is None: logging.shutdown() sys.exit(2) logger.info('pa-dlna version ' + __version__) logger.info('Python version ' + sys.version) options['nics'] = [nic for nic in (x.strip() for x in options['nics'].split(',')) if nic] logger.info(f'Options {options}') if 'clients_uuids' in options: options['applications'] = get_applications(options['clients_uuids'], parser) return options, logfile_hdler # Classes. class CaseConfigParser(configparser.ConfigParser): def optionxform(self, optionstr): return optionstr class ControlPointAbortError(Exception): pass class UPnPApplication: """An UPnP application.""" def __init__(self, **kwargs): for k, v in kwargs.items(): setattr(self, k, v) async def run_control_point(self): raise NotImplementedError def __str__(self): raise NotImplementedError # The main function. def padlna_main(clazz, doc, argv=sys.argv): def run_in_thread(coro): """Run the UPnP control point in a thread.""" cp_thread = threading.Thread(target=asyncio.run, args=[coro]) cp_thread.start() return cp_thread assert clazz.__name__ in ('AVControlPoint', 'UPnPControlCmd') pa_dlna = True if clazz.__name__ == 'AVControlPoint' else False if pa_dlna: require_libpulse_version(MIN_LIBPULSE_VERSION) # Parse the arguments. options, logfile_hdler = parse_args(doc, pa_dlna, argv[1:]) systemd = options.get('systemd') if systemd and systemd_module is None: raise RuntimeError('Cannot import the systemd module, the' " 'python-systemd' package is missing") # Instantiate the UPnPApplication. if pa_dlna: # Get the encoders configuration. try: if options['dump_default']: DefaultConfig().write(sys.stdout) sys.exit(0) config = UserConfig(systemd=systemd) if options['dump_internal']: config.print_internal_config() sys.exit(0) except Exception as e: logger.error(f'{e!r}') sys.exit(1) app = clazz(config=config, **options) else: app = clazz(**options) # Run the UPnPApplication instance. loglevel = SYSTEMD_LOG_LEVEL if systemd else logging.INFO logger.log(loglevel, f'Starting {app}') exit_code = 1 try: if pa_dlna: try: if systemd: daemon.notify('READY=1') else: fd = sys.stdin.fileno() restore_termios = disable_xonxoff(fd) if restore_termios is not None: atexit.register(restore_termios) exit_code = asyncio.run(app.run_control_point()) finally: if systemd: daemon.notify('STOPPING=1') else: # Run the control point of upnp-cmd in a thread. event = threading.Event() cp_thread = run_in_thread(app.run_control_point(event)) exit_code = app.run(cp_thread, event) finally: logger.log(loglevel, f'End of {app}') if logfile_hdler is not None: logfile_hdler.flush() logging.shutdown() sys.exit(exit_code) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1739632320.4132435 pa_dlna-0.16/pa_dlna/pa_dlna.py0000644000000000000000000010146414754127300013343 0ustar00"An UPnP control point forwarding PulseAudio/Pipewire streams to DLNA devices." import sys import shutil import asyncio import logging import re import hashlib import time from signal import SIGINT, SIGTERM from collections import namedtuple, UserList from . import SYSTEMD_LOG_LEVEL from .init import padlna_main, UPnPApplication, ControlPointAbortError from .pulseaudio import Pulse from .http_server import StreamSessions, HTTPServer from .encoders import select_encoder from .upnp import (UPnPControlPoint, UPnPDevice, UPnPClosedDeviceError, UPnPSoapFaultError, NL_INDENT, shorten, log_unhandled_exception, AsyncioTasks, QUEUE_CLOSED, xml_escape) logger = logging.getLogger('pa-dlna') AUDIO_URI_PREFIX = '/audio-content' MEDIARENDERER = 'urn:schemas-upnp-org:device:MediaRenderer:' AVTRANSPORT = 'urn:upnp-org:serviceId:AVTransport' RENDERINGCONTROL = 'urn:upnp-org:serviceId:RenderingControl' CONNECTIONMANAGER = 'urn:upnp-org:serviceId:ConnectionManager' IGNORED_SOAPFAULTS = {'701': 'Transition not available', '715': "Content 'BUSY'"} # Maximum time before starting a new session while waiting for the second # pulse event. NEW_SESSION_MAX_DELAY = 1 ISSUE_48_TIMER = 2 def get_udn(data): """Build an UPnP udn.""" # 'hexdigest' length is 40, we will use the first 32 characters. hexdigest = hashlib.sha1(data).hexdigest() p = 0 udn = ['uuid:'] for n in [8, 4, 4, 4, 12]: if p != 0: udn.append('-') udn.append(hexdigest[p:p+n]) p += n return ''.join(udn) def log_action(name, action, state, ignored=False, msg=None): txt = f"'{action}' " if ignored: txt += 'ignored ' txt += f'UPnP action [{name} device prev state: {state}]' if msg is not None: txt += NL_INDENT + msg logger.debug(txt) def normalize_xml(xml): """Convert a multi-lines xml string to one line, handling whitespaces. To support parsing by libexpat, see issue 29. """ lines = [] prev_end = None for line in xml.strip().splitlines(): line = line.strip() if not line: continue if prev_end and prev_end != '>': line = ' ' + line prev_end = line[-1] lines.append(line) return ''.join(lines) def shorten_udn(udn): span = 5 start = udn.find(':') + 1 if start != len('uuid:'): return udn[:13] return udn[start:start+span] + '...' + udn[len(udn)-span:] # Classes. class MetaData(namedtuple('MetaData', ['publisher', 'artist', 'title'])): def __str__(self): return shorten(repr(self), head_len=40, tail_len=40) class SoapSpacer: """Space out SOAP actions.""" def __init__(self, soap_minimum_interval): self.soap_minimum_interval = soap_minimum_interval self.next_soap_at = None async def wait(self): now = time.monotonic() if self.next_soap_at is not None: interval = self.next_soap_at - now if interval > 0: await asyncio.sleep(interval) now = time.monotonic() self.next_soap_at = now + self.soap_minimum_interval class Renderer: """A DLNA MediaRenderer. See the Standardized DCP (SDCP) specifications: 'AVTransport:3 Service' 'RenderingControl:3 Service' 'ConnectionManager:3 Service' """ def __init__(self, control_point, upnp_device, renderers_list): self.control_point = control_point self.upnp_device = upnp_device self.renderers_list = renderers_list self.root_device = renderers_list.root_device self.description = (f'{self.getattr("friendlyName")} - ' f'{shorten_udn(upnp_device.UDN)}') self.name = self.description self.root_device_name = (f'{self.root_device.modelName}-' f'{self.root_device.udn[-5:]}') self.curtask = None # Renderer.run() task self.closing = False self.nullsink = None # NullSink instance self.previous_idx = None # previous sink input index self.exit_metadata = None # sink input meta data at exit self.encoder = None self.mime_type = None self.protocol_info = None self.current_uri = None self.new_pulse_session = False self.suspended_state = False self.stream_sessions = StreamSessions(self) self.pulse_queue = asyncio.Queue() async def close(self): if not self.closing: self.closing = True level = (SYSTEMD_LOG_LEVEL if self.control_point.systemd else logging.INFO) logger.log(level, f"Closing '{self.name}' renderer") # Close the root device and all of its renderers. await self.renderers_list.close() # Handle the race condition where the Renderer.run() task # has been created but not yet started. if self.nullsink is not None: for task in self.control_point.cp_tasks: if task.get_name() == self.nullsink.sink.name: while True: if self.curtask is not None: break await asyncio.sleep(0.1) break if (self.curtask is not None and asyncio.current_task() != self.curtask): self.curtask.cancel() await self.pulse_unregister() await self.stream_sessions.close_session() def getattr(self, name): """Falling back to root device when upn_device attribute missing.""" try: return getattr(self.upnp_device, name) except AttributeError: return getattr(self.root_device, name) async def disable_for(self, *, period): """Disable the renderer for 'period' seconds.""" # Unload the null-sink module, sleep 'period' seconds and load a new # module. During the sleep period, the stream that was routed to this # null-sink is routed to the default sink instead of being silently # discarded by the null-sink. After loading the new null-sink module, # the renderer receives a 'change' pulse event and starts a new stream # session. await self.pulse_unregister() if period: logger.info(f'Wait {period} seconds before' f' re-enabling {self.name}') await asyncio.sleep(period) if not await self.pulse_register(): logger.error(f'Cannot load new null-sink module for {self.name}') await self.close() async def disable_root_device(self): """Close the renderer and disable its root device.""" await self.close() self.control_point.disable_root_device(self.root_device, name=self.root_device_name) async def pulse_unregister(self): if self.nullsink is not None: await self.control_point.pulse.unregister(self.nullsink) self.nullsink = None async def pulse_register(self): self.nullsink = await self.control_point.pulse.register(self) if self.nullsink is not None: return True else: await self.disable_root_device() return False def match(self, uri_path): return uri_path == f'{AUDIO_URI_PREFIX}/{self.upnp_device.UDN}' async def add_application(self): # Map the application name to the uuid. pulse = self.control_point.pulse client = await pulse.get_client(self.nullsink.sink_input) if client is not None: app_name = client.proplist.get('application.name') if app_name is not None: pulse.add_application(self, app_name, self.upnp_device.UDN) async def start_track(self, writer): await self.stream_sessions.start_track(writer) async def push_second_event_at(self, delay, event, sink, sink_input): """Push the first pulse event a second time to start a new session. Handle the case where the second pulse event is missing. """ await asyncio.sleep(delay) if self.new_pulse_session: self.pulse_queue.put_nowait((event, sink, sink_input)) def log_pulse_event(self, event, sink_input): sink_input_index = 'unknown' if sink_input is None: sink_input = self.nullsink.sink_input if sink_input is not None: sink_input_index = sink_input.index logger.debug(f"'{event}' pulse event [{self.name} " f'sink-input index {sink_input_index}]') def sink_input_meta(self, sink_input): if sink_input is None: return None proplist = sink_input.proplist publisher = proplist.get('application.name', '') artist = proplist.get('media.artist', '') title = proplist.get('media.title', '') if not self.encoder.track_metadata: title = publisher artist = '' return MetaData(publisher, artist, title) async def handle_action(self, action): """An action is either 'Stop' or an instance of MetaData. DLNA TransportStates are 'NO_MEDIA_PRESENT', 'STOPPED' or 'PLAYING', the other states are silently ignored. """ # Get the stream state. state = await self.get_transport_state() # Space out SOAP actions that start or stop a stream. if self.encoder.soap_minimum_interval != 0: await self.soap_spacer.wait() # Run an AVTransport action if needed. try: if state not in ('STOPPED', 'NO_MEDIA_PRESENT'): if isinstance(action, MetaData): if self.encoder.track_metadata: await self.set_nextavtransporturi(action, state) return elif action == 'Stop': index = self.get_sink_input_index() self.stream_sessions.stream_tasks.create_task( self.maybe_stop(index, state), name=f'maybe_stop {self.name}') return elif isinstance(action, MetaData): await self.set_avtransporturi(action, state) log_action(self.name, 'Play', state) await self.play() return except UPnPSoapFaultError as e: arg = e.args[0] if (hasattr(arg, 'errorCode') and arg.errorCode in IGNORED_SOAPFAULTS): error_msg = IGNORED_SOAPFAULTS[arg.errorCode] logger.warning(f"Ignoring SOAP error '{error_msg}'") else: raise log_action(self.name, action, state, ignored=True) async def handle_pulse_event(self): """Handle a PulseAudio event. An event is either 'new', 'change', 'remove' or 'exit'. An action is either 'Stop' or an instance of MetaData. """ if self.nullsink is None: # The Renderer instance is now temporarily disabled. return event, sink, sink_input = await self.pulse_queue.get() self.log_pulse_event(event, sink_input) # Note that, at each pulseaudio event, a new instance of sink and # sink_input is generated by the libpulse library. if (sink_input is not None and self.nullsink.sink_input is not None and sink_input.index != self.nullsink.sink_input.index): self.previous_idx = self.nullsink.sink_input.index # Process the event and set the new attributes values of nullsink. if event in ('remove', 'exit'): if self.nullsink.sink_input is not None: if event == 'exit': self.exit_metadata = self.sink_input_meta( self.nullsink.sink_input) await self.handle_action('Stop') return assert sink is not None and sink_input is not None try: cur_metadata = self.sink_input_meta(sink_input) if self.nullsink.sink_input is None: self.new_pulse_session = True # The reconnection of a sink-input after an exit is signaled # by only one 'change' event, while a new session is signaled # by two events in both PulseAudio and Pipewire, the track # meta data (if any) being only available in the second one. # # push_second_event_at() handles the case where the second # event is missing after a NEW_SESSION_MAX_DELAY delay. if cur_metadata == self.exit_metadata: self.exit_metadata = None else: self.control_point.cp_tasks.create_task( self.push_second_event_at(NEW_SESSION_MAX_DELAY, event, sink, sink_input), name='new_session_max_delay') return if self.new_pulse_session: self.new_pulse_session = False # So that the device may display at least some useful info. if not cur_metadata.title: cur_metadata = cur_metadata._replace( title=cur_metadata.publisher) await self.handle_action(cur_metadata) # A new track. elif 'media.title' in sink_input.proplist: prev_metadata = self.sink_input_meta(self.nullsink.sink_input) # Note that if self.encoder.track_metadata is false, then # cur_metadata.title == prev_metadata.title since 'title' # value is 'publisher' value. if (prev_metadata is not None and cur_metadata.title != prev_metadata.title): await self.handle_action(cur_metadata) finally: # If the Renderer instance is not temporarily disabled. if self.nullsink is not None: self.nullsink.sink = sink self.nullsink.sink_input = sink_input async def soap_action(self, serviceId, action, args={}): """Send a SOAP action. Return the dict {argumentName: out arg value} if successfull, otherwise an instance of the upnp.xml.SoapFault namedtuple defined by field names in ('errorCode', 'errorDescription'). """ if self.upnp_device.closed: raise UPnPSoapFaultError('UPnPRootDevice is closed') service = self.upnp_device.serviceList[serviceId] return await service.soap_action(action, args, log_debug=False) async def select_encoder(self, udn): """Select an encoder matching the DLNA device supported mime types.""" protocol_info = await self.soap_action(CONNECTIONMANAGER, 'GetProtocolInfo') res = select_encoder(self.control_point.config, self.name, protocol_info, udn) if res is None: logger.error(f'Cannot find an encoder matching the {self.name}' f' supported mime types') await self.disable_root_device() return False self.encoder, self.mime_type, self.protocol_info = res self.soap_spacer = SoapSpacer(self.encoder.soap_minimum_interval) return True def didl_lite_metadata(self, metadata): """Build de didl-lite xml string. The returned string is built with ../tools/build_didl_lite.py. """ didl_lite = ( f''' {xml_escape(metadata.title)} object.item.audioItem.musicTrack {xml_escape(metadata.publisher)} {xml_escape(metadata.artist)} {self.current_uri} ''' ) return normalize_xml(didl_lite) async def set_avtransporturi(self, metadata, state): await self.add_application() action = 'SetAVTransportURI' didl_lite = self.didl_lite_metadata(metadata) args = {'InstanceID': 0, 'CurrentURI': self.current_uri, 'CurrentURIMetaData': didl_lite } log_action(self.name, action, state, msg=didl_lite) logger.info(f'{metadata}' f'{NL_INDENT}URL: {self.current_uri}') await self.soap_action(AVTRANSPORT, action, args) async def set_nextavtransporturi(self, metadata, state): action = 'SetNextAVTransportURI' didl_lite = self.didl_lite_metadata(metadata) args = {'InstanceID': 0, 'NextURI': self.current_uri, 'NextURIMetaData': didl_lite } await self.stream_sessions.stop_track() log_action(self.name, action, state, msg=didl_lite) logger.info(f'{metadata}') logger.debug(f'URL: {self.current_uri}') await self.soap_action(AVTRANSPORT, action, args) async def get_transport_state(self): res = await self.soap_action(AVTRANSPORT, 'GetTransportInfo', {'InstanceID': 0}) state = res['CurrentTransportState'] return state async def play(self, speed=1): args = {'InstanceID': 0} args['Speed'] = speed await self.soap_action(AVTRANSPORT, 'Play', args) async def stop(self): args = {'InstanceID': 0} await self.soap_action(AVTRANSPORT, 'Stop', args) def get_sink_input_index(self): if (self.nullsink is not None and self.nullsink.sink_input is not None): return self.nullsink.sink_input.index @log_unhandled_exception(logger) async def maybe_stop(self, index, state): # Work around for issue #48: # KDE music players trigger (randomly) 'remove' events upon track # changes. await asyncio.sleep(ISSUE_48_TIMER) cur_index = self.get_sink_input_index() if cur_index is not None and cur_index != index: # A new track is being played. # Ignore the 'remove' event. self.log_pulse_event('remove ignored', self.nullsink.sink_input) return self.nullsink.sink_input = None # Let the HTTP 1.1 chunked transfer encoding handles the closing of # the stream. log_action(self.name, 'Closing-Stop', state) await self.stream_sessions.close_session() # Not really necessary. log_action(self.name, 'Stop', state) try: await self.stop() except UPnPSoapFaultError: pass def set_current_uri(self): self.current_uri = (f'http://{self.root_device.local_ipaddress}' f':{self.control_point.port}' f'{AUDIO_URI_PREFIX}/{self.upnp_device.UDN}') @log_unhandled_exception(logger) async def run(self): """Run the Renderer task.""" self.curtask = asyncio.current_task() try: if not await self.select_encoder(self.upnp_device.UDN): return self.set_current_uri() level = (SYSTEMD_LOG_LEVEL if self.control_point.systemd else logging.INFO) logger.log(level, f"New '{self.name}' renderer with {self.encoder}" f" handling '{self.mime_type}'") logger.info(f'{NL_INDENT}URL: {self.current_uri}') # Handle the case where pa-dlna is started after streaming has # started (no pulse event). pulse = self.control_point.pulse sink_input = await pulse.get_sink_input(self) # Work around the wireplumber bug (issue #15). if sink_input is None: sink_input = await pulse.find_sink_input(self.upnp_device.UDN) if sink_input is not None: # 'sink_input' is None if move_sink_input() fails. sink_input = await pulse.move_sink_input( sink_input, self.nullsink.sink) if sink_input is not None: logger.info(f"Streaming '{sink_input.name}' on {self.name}") self.nullsink.sink_input = sink_input cur_metadata = self.sink_input_meta(sink_input) if not cur_metadata.title: cur_metadata = cur_metadata._replace( title=cur_metadata.publisher) # Trigger 'SetAVTransportURI' and 'Play' soap actions. await self.handle_action(cur_metadata) # If running as a test of test_pa_dlna.py, break from the loop # when the 'test_end' asyncio future is done. Otherwise run for # ever. test_end = self.control_point.test_end while test_end is None or not test_end.done(): await self.handle_pulse_event() await self.close() except asyncio.CancelledError: pass except UPnPSoapFaultError as e: logger.error(f'{e!r}') await self.disable_root_device() except (OSError, UPnPClosedDeviceError, ControlPointAbortError) as e: logger.error(f'{e!r}') except Exception: await self.disable_root_device() raise finally: await self.close() class DLNATestDevice(Renderer): """Non UPnP Renderer to be used for testing.""" class RootDevice: LOOPBACK = '127.0.0.1' def __init__(self, mime_type, control_point): self.control_point = control_point # Needed by soap_action() in the test suite. self.mime_type = mime_type self.peer_ipaddress = self.LOOPBACK self.local_ipaddress = self.LOOPBACK match = re.match(r'audio/([^;]+)', mime_type) name = match.group(1) self.modelName = f'DLNATest_{name}' self.friendlyName = self.modelName self.UDN = get_udn(name.encode()) self.udn = self.UDN def close(self): logger.info(f"Close '{self.modelName}' root device") def __init__(self, control_point, mime_type): root_device = self.RootDevice(mime_type, control_point) renderers_list = RenderersList(control_point, root_device) renderers_list.append(self) super().__init__(control_point, root_device, renderers_list) control_point.root_devices[root_device] = renderers_list self.mime_type = mime_type async def play(self, speed=1): pass async def soap_action(self, serviceId, action, args='unused'): if action == 'GetProtocolInfo': # Use the 'mime_type' attribute of the root device instead of the # renderer as expected since this method is also used by tests at # pa_dlna.tests.test_pa_dlna.PatchGetNotificationTests. return {'Source': None, 'Sink': f'http-get:*:{self.root_device.mime_type}:*' } elif action == 'GetTransportInfo': state = ('PLAYING' if self.stream_sessions.is_playing else 'STOPPED') return {'CurrentTransportState': state} class RenderersList(UserList): """The list of all Renderers of a root device as a dict. This includes the root device if it is a MediaRenderer and all embedded devices that are MediaRenderer. """ def __init__(self, control_point, root_device): super().__init__() self.control_point = control_point self.root_device = root_device self.closed = False def build_list(self): # Build the list of renderers. for upnp_device in UPnPDevice.embedded_devices_generator( self.root_device): if re.match(rf'{MEDIARENDERER}(\d+)', upnp_device.deviceType): self.data.append(Renderer(self.control_point, upnp_device, self)) async def close(self): if not self.closed: self.closed = True for renderer in self.data: try: await renderer.close() except Exception as e: logger.error(f'Got exception closing {renderer.name}:' f' {e!r}') if self.root_device in self.control_point.root_devices: del self.control_point.root_devices[self.root_device] self.root_device.close() class AVControlPoint(UPnPApplication): """Control point with Content. Manage PulseAudio and the DLNA MediaRenderer devices. See section 6.6 of "UPnP AV Architecture:2". """ def __init__(self, **kwargs): super().__init__(**kwargs) self.closing = False self.root_devices = {} # dictionary {root_device: renderers_list} self.curtask = None # task running run_control_point() self.pulse = None # Pulse instance self.start_event = None self.upnp_control_point = None self.http_servers = {} # {IPv4 address: http server instance} self.register_sem = asyncio.Semaphore() self.cp_tasks = AsyncioTasks() # 'test_end' is meant to be used as an asyncio future by tests in # test_pa_dlna.py. self.test_end = None @log_unhandled_exception(logger) async def shutdown(self, end_event): try: await end_event.wait() await self.close('Got SIGINT or SIGTERM') finally: loop = asyncio.get_running_loop() for sig in (SIGINT, SIGTERM): loop.remove_signal_handler(sig) @log_unhandled_exception(logger) async def close(self, msg=None): # This coroutine may be run as a task by AVControlPoint.abort(). if not self.closing: self.closing = True # The semaphore prevents a race condition where a new Renderer # is awaiting the registration of a sink with pulseaudio while # the renderers are being closed here. In that case, # this sink would never be unregistered. async with self.register_sem: for renderers_list in list(self.root_devices.values()): await renderers_list.close() if self.pulse is not None: await self.pulse.close() if self.curtask != asyncio.current_task(): if sys.version_info[:2] >= (3, 9): self.curtask.cancel(msg) else: self.curtask.cancel() def abort(self, msg): """Abort the whole program from a non-main task.""" self.cp_tasks.create_task(self.close(msg), name='abort') raise ControlPointAbortError(msg) def disable_root_device(self, root_device, name=None): self.upnp_control_point.disable_root_device(root_device, name=name) async def register(self, renderer): """Load the null-sink module. If successfull, create the http_server if needed and create the renderer task. """ async with self.register_sem: if self.closing: return registered = await renderer.pulse_register() if registered: root_device = renderer.root_device http_server = await self.create_httpserver( root_device.local_ipaddress) http_server.allow_from(root_device.peer_ipaddress) # Create the renderer task. self.cp_tasks.create_task(renderer.run(), name=renderer.nullsink.sink.name) async def create_httpserver(self, ip_address): """Create the http_server task.""" if ip_address not in self.http_servers: http_server = HTTPServer(self, ip_address, self.port) self.cp_tasks.create_task(http_server.run(), name=f'http_server-{ip_address}') await http_server.startup self.http_servers[ip_address] = http_server return self.http_servers[ip_address] def renderers(self): """Generator yielding all the renderers.""" for renderers_list in self.root_devices.values(): for renderer in renderers_list: yield renderer async def handle_upnp_notifications(self): while True: notif, root_device = await ( self.upnp_control_point.get_notification()) if (notif, root_device) == QUEUE_CLOSED: logger.debug('UPnP queue is closed') return logger.info(f"Got '{notif}' notification for {root_device}") if (not hasattr(root_device, 'deviceType') or not hasattr(root_device, 'modelName')): logger.info(f"Ignore '{root_device}': " f'missing deviceType or modelName') self.disable_root_device(root_device) continue renderers_list = RenderersList(self, root_device) renderers_list.build_list() if not renderers_list: if not self.upnp_control_point.is_disabled(root_device): logger.info(f"Ignore '{root_device}':" f' no MediaRenderer found') self.disable_root_device(root_device) continue is_new_renderer_list = root_device not in self.root_devices if notif == 'alive': if self.upnp_control_point.is_disabled(root_device): logger.debug(f'Ignore disabled {root_device}') elif is_new_renderer_list: if root_device.local_ipaddress is not None: self.root_devices[root_device] = renderers_list for renderer in renderers_list: await self.register(renderer) elif notif == 'byebye': if not is_new_renderer_list: # Close the renderers_list. await self.root_devices[root_device].close() else: raise RuntimeError('Error: Unknown notification') @log_unhandled_exception(logger) async def run_control_point(self): try: self.curtask = asyncio.current_task() self.start_event = asyncio.Event() if not self.config.any_available(): raise RuntimeError('Error: No encoder is available') parec_pgm = shutil.which('parec') if parec_pgm is None: raise RuntimeError("Error: The pulseaudio 'parec'" ' program cannot be found') self.parec_cmd = [parec_pgm] # Add the signal handlers. end_event = asyncio.Event() self.cp_tasks.create_task(self.shutdown(end_event), name='shutdown') loop = asyncio.get_running_loop() for sig in (SIGINT, SIGTERM): loop.add_signal_handler(sig, end_event.set) # Run the UPnP control point. with UPnPControlPoint( self.ip_addresses, self.nics, self.msearch_interval, self.msearch_port, self.ttl) as self.upnp_control_point: # Create the Pulse task. self.pulse = Pulse(self) self.cp_tasks.create_task(self.pulse.run(), name='pulse') # Wait for the connection to PulseAudio to be ready. await self.start_event.wait() # Register the DLNATestDevices. for mtype in self.test_devices: rndr = DLNATestDevice(self, mtype) await self.register(rndr) # Handle UPnP notifications for ever. await self.handle_upnp_notifications() except asyncio.CancelledError as e: level = SYSTEMD_LOG_LEVEL if self.systemd else logging.INFO logger.log(level, f'Main task got: {e!r}') finally: await self.close() def __str__(self): return 'pa-dlna' # The main function. def main(): padlna_main(AVControlPoint, __doc__) if __name__ == '__main__': padlna_main(AVControlPoint, __doc__) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1740321670.3532872 pa_dlna-0.16/pa_dlna/pulseaudio.py0000644000000000000000000003410414756631606014126 0ustar00"""The pulseaudio interface.""" import asyncio import logging from pathlib import Path from libpulse.libpulse import (LibPulse, PA_SUBSCRIPTION_MASK_SINK_INPUT, LibPulseError, LibPulseOperationError, PA_INVALID_INDEX) from .upnp.util import NL_INDENT, log_unhandled_exception logger = logging.getLogger('pulse') APPS_TITLE = 'Application name -> uuid' APPS_HEADER = f"""# List of 'application name -> DLNA device uuid'. # # This list is generated by pa-dlna when run with the '--clients-uuids' # command line option. You may remove a line or comment it out (using the '#' # character as the line prefix) to remove the association between an # application and a device. # Default section header. # DO NOT change the next line. [{APPS_TITLE}] """ # Classes. class NullSink: """A connection between a sink_input and the null-sink of a Renderer. A NullSink is instantiated upon registering a Renderer instance. """ def __init__(self, sink): self.sink = sink # libpulse Sink instance self.sink_input = None # libpulse SinkInput instance class SinkInputEvent: def __init__(self, sink_input, event): self.type = event.type # 'sink' is the index of the sink associated with the sink-input. self.sink = sink_input.sink self.proplist = sink_input.proplist def __eq__(self, other): return (self.type == other.type and self.sink == other.sink and self.proplist == other.proplist) class Pulse: """Pulse monitors pulseaudio sink-input events.""" def __init__(self, av_control_point): self.av_control_point = av_control_point self.clients_uuids = av_control_point.clients_uuids self.applications = av_control_point.applications self.closing = False self.lib_pulse = None self.sink_input_events = {} async def close(self): if not self.closing: self.closing = True logger.info('Close pulse') await self.av_control_point.close() async def get_sink_by_module(self, renderer, module_index, module_name): """Get the sink matching a renderer from 'module_index'.""" for sink in await self.lib_pulse.pa_context_get_sink_info_list(): if sink.owner_module == module_index: logger.info(f'Load null-sink module {sink.name}' f"{NL_INDENT}description='{renderer.description}'") # The module name is registered by pulseaudio after being # modified in pa_namereg_register() by replacing invalid # characters with '_'. The invalid characters are defined in # is_valid_char(char c).See the pulseaudio code. if len(module_name) != len(sink.name): # Pulseaudio has added a '.n' suffix because there exists # another null-sink with the same name. await self.lib_pulse.pa_context_unload_module(module_index) renderer.control_point.abort( f'Two DLNA devices registered with the same name:' f'{NL_INDENT}{module_name}') # AVControlPoint.abort() raises an exception. assert False, 'Statement never reached' return sink async def register(self, renderer): """Load a null-sink module.""" if self.lib_pulse is None: return upnp_device = renderer.upnp_device module_name = f'{renderer.getattr("modelName")}-{upnp_device.UDN}' _description = renderer.description.replace(' ', r'\ ') module_index = await self.lib_pulse.pa_context_load_module( 'module-null-sink', f'sink_name="{module_name}" ' f'sink_properties=device.description=' f'"{_description}"') if module_index == PA_INVALID_INDEX: logger.error(f'Failed loading {module_name} pulseaudio module') return None sink = await self.get_sink_by_module(renderer, module_index, module_name) if sink is None: await self.lib_pulse.pa_context_unload_module(module_index) logger.error( f'Failed getting sink of {module_name} pulseaudio module') return None return NullSink(sink) async def unregister(self, nullsink): if self.lib_pulse is None: return logger.info(f'Unload null-sink module {nullsink.sink.name}') await self.lib_pulse.pa_context_unload_module( nullsink.sink.owner_module) async def get_sink_input(self, renderer): assert renderer.nullsink is not None sink_inputs = (await self.lib_pulse.pa_context_get_sink_input_info_list()) for sink_input in sink_inputs: if sink_input.sink == renderer.nullsink.sink.index: return sink_input return None async def get_renderer_sink(self, renderer): if renderer is not None and renderer.nullsink.sink is not None: try: return await self.lib_pulse.pa_context_get_sink_info_by_name( renderer.nullsink.sink.name) except LibPulseOperationError as e: logger.warning( f'Got exception at pulseaudio.get_renderer_sink(): {e!r}') def is_ignored_event(self, sink_input, event): index = event.index if index not in self.sink_input_events: self.sink_input_events[index] = SinkInputEvent(sink_input, event) return False else: if event.type == 'remove': del self.sink_input_events[index] else: last_event = self.sink_input_events[index] new_event = SinkInputEvent(sink_input, event) if new_event == last_event: return True # Ignore the event. else: self.sink_input_events[index] = new_event return False def find_previous_renderer(self, event): """Find the renderer that was last connected to this sink-input.""" for renderer in self.av_control_point.renderers(): if (renderer.nullsink is not None and renderer.nullsink.sink_input is not None and renderer.nullsink.sink_input.index == event.index): return renderer async def find_renderer(self, event): """Find the renderer now connected to this sink-input.""" notfound = (None, None) # Find the sink_input that has triggered the event. # Note that by the time this code is running, pulseaudio may have done # other changes. In other words, there may be inconsistencies between # the event and the sink_input and sink lists. sink_inputs = (await self.lib_pulse.pa_context_get_sink_input_info_list()) for sink_input in sink_inputs: if sink_input.index == event.index: # Ignore 'pulsesink probe' - seems to be used to query sink # formats (not for playback). if sink_input.name == 'pulsesink probe': return notfound # Find the corresponding sink when it is the null-sink of a # Renderer. for renderer in self.av_control_point.renderers(): if (renderer.nullsink is not None and renderer.nullsink.sink.index == sink_input.sink): return renderer, sink_input break return notfound async def dispatch_event(self, event): """Dispatch the event to a renderer. event.type is either 'new', 'change' or 'remove'. A new event.index is generated by pulseaudio for each 'new' event. The index of a 'remove' event refers to the index of a previous 'new' event. A 'new' event establishes an association between a sink-input and a sink. IMPORTANT: 'nullsink.sink' and 'nullsink.sink_input' are the renderer's instances built from one of the previous events. They are stale instances. 'sink' and 'sink_input' returned by find_renderer() and get_sink_by_name() are the current instances as set by pulseaudio. """ evt_type = event.type if evt_type == 'remove': renderer = self.find_previous_renderer(event) if renderer is not None: renderer.pulse_queue.put_nowait((evt_type, None, None)) return renderer, sink_input = await self.find_renderer(event) if renderer is not None: assert sink_input is not None # Ignore sound settings events. # See src/pulse/proplist.h in Pulseaudio source code. proplist = sink_input.proplist if (proplist and 'media.role' in proplist and proplist['media.role'] == 'event'): return # 'renderer.nullsink.sink' is the stale sink from the previous # event, we need to fetch the 'sink' correponding to this event. sink = await self.get_renderer_sink(renderer) if sink is not None: if (self.is_ignored_event(sink_input, event) and event.type not in ('new', 'remove')): # Ignore a SinkInputEvent with no changes from the # previous one (or if the previous one does not exist) and # the event type is `change`. pass elif sink_input.index == renderer.previous_idx: # Ignore event related to the previous sink-input. pass else: renderer.pulse_queue.put_nowait( (evt_type, sink, sink_input)) prev_renderer = self.find_previous_renderer(event) # The sink_input has been re-routed to another sink. if prev_renderer is not None and prev_renderer is not renderer: # Build our own 'exit' event (pulseaudio does not provide one) # for the sink that had been previously connected to this # sink_input. evt_type = 'exit' if event.index in self.sink_input_events: del self.sink_input_events[event.index] prev_renderer.pulse_queue.put_nowait((evt_type, None, None)) def add_application(self, renderer, name, uuid): if self.applications is None: return if name not in self.applications or self.applications[name] != uuid: logger.info(f"Adding new association '{name}' ->" f" uuid of '{renderer.name}'") self.applications[name] = uuid def write_applications(self): if not self.clients_uuids: return path = Path(self.clients_uuids) path = path.expanduser() try: with open(path, 'w') as f: f.write(APPS_HEADER) for k, val in self.applications.items(): indent = ' ' * max(20 - len(k), 1) f.write(f'{k}{indent}-> {val}\n') except Exception as e: logger.exception(f'Error writing {path}: {e!r}') async def get_client(self, sink_input): if sink_input is not None: try: return await self.lib_pulse.pa_context_get_client_info( sink_input.client) except LibPulseOperationError as e: logger.warning( f'Got exception at pulseaudio.get_client(): {e!r}') async def move_sink_input(self, sink_input, sink): try: await self.lib_pulse.pa_context_move_sink_input_by_index( sink_input.index, sink.index) return sink_input except LibPulseOperationError as e: logger.warning( f'Got exception at pulseaudio.move_sink_input(): {e!r}') async def find_sink_input(self, uuid): if self.applications is None: return lp = self.lib_pulse sink_inputs = await lp.pa_context_get_sink_input_info_list() for sink_input in sink_inputs: client = await self.get_client(sink_input) if client is None: continue app_name = client.proplist.get('application.name') if app_name is not None and self.applications.get(app_name) == uuid: return sink_input @log_unhandled_exception(logger) async def run(self): try: async with LibPulse('pa-dlna') as self.lib_pulse: # Only one instance of pa-dlna is allowed to run. n = len([client for client in await self.lib_pulse.pa_context_get_client_info_list() if client.name == 'pa-dlna']) if n > 1: logger.warning( 'There is already one instance of pa-dlna running') return await self.lib_pulse.log_server_info() # Start the iteration on sink-input events. await self.lib_pulse.pa_context_subscribe( PA_SUBSCRIPTION_MASK_SINK_INPUT) iterator = self.lib_pulse.get_events_iterator() self.av_control_point.start_event.set() async for event in iterator: await self.dispatch_event(event) # Wait until end of test. test_end = self.av_control_point.test_end if test_end is not None: await test_end except LibPulseError as e: logger.error(f'{e!r}') finally: self.write_applications() self.lib_pulse = None await self.close() ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1739375547.033665 pa_dlna-0.16/pa_dlna/tests/__init__.py0000644000000000000000000000414214753141673014651 0ustar00import os import sys import contextlib import logging import subprocess import unittest import functools import shutil import asyncio from ..upnp.tests import load_ordered_tests, find_in_logs, search_in_logs if sys.version_info >= (3, 9): functools_cache = functools.cache else: functools_cache = functools.lru_cache def _id(obj): return obj @functools_cache def requires_resources(resources): """Skip the test when one of the resource is not available. 'resources' is a string or a tuple instance (MUST be hashable). """ resources = [resources] if isinstance(resources, str) else resources for res in resources: try: if res == 'os.devnull': # Check that os.devnull is writable. with open(os.devnull, 'w'): pass elif res in ('curl', 'ffmpeg', 'upmpdcli', 'mpd'): path = shutil.which(res) if path is None: raise Exception elif res == 'libpulse': # Check that pulseaudio or pipewire-pulse is running. subprocess.run(['pactl', 'info'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True) else: # Otherwise check that the module can be imported. exec(f'import {res}') except Exception: return unittest.skip(f"'{res}' is not available") else: return _id async def skip_loop_iterations(count): """Skip 'count' loop iterations (cost: few msecs).""" for i in range(count): await asyncio.sleep(0) class BaseTestCase(unittest.TestCase): def setUp(self): # Redirect stderr to os.devnull. self.stack = contextlib.ExitStack() f = self.stack.enter_context(open(os.devnull, 'w')) self.stack.enter_context(contextlib.redirect_stderr(f)) def tearDown(self): self.stack.close() # Remove the root logger handler set up by init.setup_logging(). root = logging.getLogger() for hdl in root.handlers: root.removeHandler(hdl) ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1677139355.850765 pa_dlna-0.16/pa_dlna/tests/encoder.py0000755000000000000000000000000014375616634016555 2streams.pyustar00././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1734790678.0001316 pa_dlna-0.16/pa_dlna/tests/gs-16b-1c-44100hz.mp30000644000000000000000000037054114731547026015500 0ustar00ID3NTIT2GalwayTPE1Kevin MacLeodTSSELavf56.40.101ÿû@ÀInfo`ñ  "$'),.1368;=ACFHKMPRUWZ\_adfilnqsvx{}ƒ†ˆ‹’•—šœŸ¡¤¦©«®°³¶¸»½ÀÃÅÈËÍÐÒÕ×ÚÜßáäæéëîðóõøúýLavc56.60$ñ j]ÃÿûPÄI‘¬ùE‰4µá pšù hˆ…!æúœ—'SŸèIÞIÞNJùÈOÿüþò7ý]?ÔîsŸ"œì¯è@3×õÿß'ÿ©Ý0qn‘¨SœS¸@~ aU s½oº-st_¯¼‘L+‡*ÞúçÏóéLK”ùÜæŠ{ã9L€¡Í6[ÞÉ}åò9shyÿoÛ7~ÊLýš2¡y¸ùM¡£Ș€z)!®¶©¦êR޵èÞ†¯ôöɦº~޶O®ßVßJºÿûRÄ€J-± ˆ×É6!pšùú½îÌÚº²àˆ­w¥ ÏêÓ_šË¶ý÷Ëûßþâ_swË»Òí—Ù0bN`’ÞÄX91à—ÌïÛo·ÿ×ú§ÿõº¯óϯüá—)7&ˆ gÔTož#½T¨Ê¦ZïŽöï__·}ÓJ>"Øõ ÉE…¬F ‰”&FÕ€é1÷^–©Ú/ïéË÷J–­eÿÿ§Elû5_g?ݪUª‘6l2³¢çרÒÓ+_¢Ö—ú“ã¾ëÿÿRÝÌ·? ?_rÿûRÄ#€ A±ˆ×É6!¤p–ù­…‘ƒdŬ;š”ešï{[]vÒŸ_ßé‹/)ký ¢—ÿ÷ü®è—æÏ9À^þ-’óþ²Â–ÞµOG䵩Áj±9¹„B!ÿûRÄ5I³ „WÁA¶`Àpšø ÎØ„(BmàÓˆñù”înÝŸÆÞ–þ£Ô™%’­k…RHªË8ÀQP€ÝõvªÝV×íªè”ºÍró¾SåíÉïÖeñêüÚT‰jrÓcFøEe[›UòÿÿöòÍö™Š†ºÖ-ðÊ5œ¬XbVdŸaÝc@>Œ×UÓzé¼Ómõtü¿Ë“û?9׿Dü'å²òìÔ™NI"7 ÎC)òåß_©üßÛ¶þñ6‹/®¤²‚ ˜=:ÿûRÄFIm± „×Á$6!pšùå#Ð6s ̪A!Ñô̾¢f¶´véäU×——¿üþ»èàå".ò£æ•ó–h-«#hdÕ™ÿžæû–ýíÛùx¼õ‘Œ†>·ÇÅ2æ ¬2ä oÏm6¾¿ëô™˜ó‹ž_Ȭíoñƒ»µ¥7²bÔ2™ÄxȘ€Ã™6Ôö'.RW.ß¾÷x)L͆­÷´ËØÛQçœ ›¤ACD5jQ Ö‹³*]+]V™”Þ³&Rü·DÿûRÄY€I±³ „×Á86 Àpšù¾Œîÿ,è”Ì+ÜEÕé;†d  Dþ±Àª4@í]™‘)ßžš5{VÞDOU¿·¡Fµt~ÕîÉ{³Ý îDVmžvT–r(ˆ«"Ng[Я³+%6CtÕþ7¿™ñ÷´äjî:1¨¦â0"ÓBíaÑ@¾VUIÚè¶™·ÛÑu^yþùMùIçü‹ó4tS‘h'F\|ÒÅ2ÿûRÄi€JA¯Ä×Éd¶`àqø„L!dçu¶\ žT'èá/»l~øóüöb‘•©Ëe^9gb7Ê"&ÑL’â‡têúþvý©û³üÿëÈ©œ¿ÞŠ"i5‚A”­$“yḦdN”TLb–ùkÿ/¿âÒ*!ùº‹U®|ˆ¶‰HaÂ9ÙR ¸tÂb&«ªÙÝÛݹôd6}h½v¬¯òËý5³Ji²…òïÄ$%ÌÉL|F@”×”ä1Œä½cä].G<;³cfBÊgË„Ò7N›ÿûRÄq€Ê± „×É.6 À ¢ùº4|é^ªí +po§\ôÜÔö³3ïºÕ¶o¯¿é숔zÕ[¦ÎõÏ*©Ý ~iæ+»‘ÖPµ?r:±Úµ*Ìéöèmâýî×ߣזë!ÔÛ4iÄÔZaªØÓ+œA q€[ÖÔžÕÚžc_š´Ì_ª½ê~^Bùz­JHýÕ‡Yç¼é Ðtu¢ÑgÿëÌ\4=ë\±—HÓ=lóbm <\&X%wϪÿ÷m·í{ËõÎóÜÿûRÄ€Ê1³£„×ÁN6 Àqø½zæ³"áDZs„;•E D:6›<Ê\|ƒšÙ”ý- c—þÎÍ·d<©t«È.Õ½¬9Ö¢Jh>…kZe@Ö}¯5¬jÚ¾mVùþy#®™-?rúÉ›ø½òDdOEh¼Äl˜º`&k4Ë!X%ò•ð9Ï¿ëç¬øÓóžXU#¼a€ëD”†LùˆA ¤F‡lv̶ꕵ’mÖž#Ñ•À‘]8ŽZ5>ëpÙÈ cÙ0ˆ‰à¨ÿûRÄIÕµ …Á@¶ Àp¦ù±ª«dŠ|$ƒŒªëù¾~þ?úã¾æÕQ¦b/äê0y§‹(AÃI›¼»"¼Åœµ›­ÚÆSz£*ïµýdïå·ûÙ3÷Ôåi‘ée!ß³É×Z«Ò¨ˆbPƒ¥šÎê^úØÉZ³;åÕ+;ÚgkÕÃ÷j¹®ähÑp‘áyãÌ—8X2IÎ0 ­¤ «ÑÒ¨õœ´Jl”Tm3g¿´¹ߺ™.ú§oÕiŸß6½“k¥Hésƒz>eVl«‘ wÿûRÄ›€Je±„×ÉW¶`€ ¢¸ò2£•^ʵÞÇJzÿ÷®øþŸ.šb¿.å賑5Le&õ 7TJ.ïÛ§|’ëò.ù‹üýgèaH(Í:)D2&à>Öú½Ò«¹J¾Gtß1?õw÷¬u×ÛóRÑ­ÑôÓUÆ)22ôaEI‚ÒiPë8VEJC®c™T£UÖ»Z¿_øVëý÷³ŸìÿùD¤í‘„aH&ÔâõÒ½wÜòPË2,—Ò×6¤)ÌÌŒÕaKHÿûRÄ¥ =­ˆ×ÉZ6! qø ‹ D `$92É`HöK:=í½ŠºSíI~ýÿ~ü¹üý[kÏ1ñæ³ï'·§_Ïg½%ƒ—Ïì¸EÈö‘Td;\±&àN†âÜrVSÛ@H€6ö˜¶}÷ögN½ü¥2.|úü<´¼¬šB2›3£5 ií«?#)’4E#¤U9Øfpá8‹=æ<³:ìv |£ãز!±S**’ÀÖ§idæ{m{õwþ¥ýÿÖè¿ÂÿûRĪ€JÁ³£…Á;6!$pŽù>}ÙdñydX%¨Ænslƒ9xõ»sÉ_ûöiØ‹×ï/ß]Ìi^6ÀÙ^=tRÈ…¬_Õ5i¿í¯üþYçïêÿñËJxޤç&°…Pß<E¦#O<²œ½;=¶¾ÿ÷aûu·¬¶¤2á¼ †CM¢à˜vD$I‘¡鵯we»:ïWk¿L´Ì»â³ùÿaÉ}ž»;ßuÌþé*S÷ ÎLyzÿ¢þ_K+ÞJ’¢­ß‰(ª/`ÔÿûRĶHé³ c„wÁ;¶a p–øÌÏïnÉè¯UT×ÎÑjŠ÷µÑšé³z£mïÞçÒ™\éö+1—ÈjQ‹ÕBâråMÖ”'7µ‰ R¥wWŠùÓÿŸýþ^ÿ”u/A‰NÕ¤rî¨U¶í¢tqBòÃ$J´.’»ªžaêóó]Gé] O®ŠwzFúšýɹò«Tl£5k6¶hDG÷ f¢¸y1 ОD²þWžåÝÛ7µâGÑ%&ˆð NDX÷ È@€©"ÿûRÄÈ‚I³ „×Á5ápšùÈ7kÍUtt¢¥™^c²ÞÛ1Ð;©&A”à9€ˆÞ<†-8…–UB‡¬Žˆm¡€Žº¦8äF$2öR C˜MFŒS×:©n ÛÈJäøÖ&iÞŸ*Ⱦq“׬i³šu½Lö­naÓ—vZ¢oü³²¿çȺ·8JSym¿ÔdmFÄ#ìÚfa! @Æ1tÊ5#Bñ!WÛF"çñlÈçŸçðþá²\Z»VÕ]'^Ÿ`†³zâÿûRÄ߀I§ #„wÉz¶`€¡§Ù·*ÓÄá|,ä†! tÍ}›^æRúûsw~ý6¯èÇý(j͵7ÝŸ-ËGSUC²O»2í!ñˆR*/Õéu_ú[\ýÔ@͉kl¤J.ìøØ0Ñ¡élp°T`6¥`îçÑ79Ú­_}W9Ñ^ŠŽÔÌû-®IÑÜÜ«Yûk5gd\©i/3µYjš"C;-ÕêÇWAT«ÞŽŸZ6¦[“=}˜ÝÝgn~žÉ7GÖ^²%¡!D‰¥b“ÿûRÄêK³#„×Áƒ6 äp¦ùs×­²µÙžú¢"=÷~‹Ú¯œÉ´þêóսћڽyùŸ»›ƒ9&çnlو콦÷”Ù:ÊY=¤fJgîÿ|®_cÖñ½ØøOj ]íÃe“mÓÙœo‰TÒÍéæž¼€Ñ•e•ŸwÍêªé¸Ûÿê.ÿþþ.úù¿úãyé/çYéþ/„Žú›á‘ªã¦iT©hâ™j’÷N^ÿ®÷nô›Ú¸çççž¹ç¤J–ÖªÖ:yº ‘Ð*¡“‡QÿûRÄëÌų 7Á]¶ àq"øÂ"R¡  €Qý…Å¢¿½ùÁ:|FëùâÐ ‰¶¼ ¹0 KZšiܯᶨb?]÷ð·± `òIZë¨p P‡•Í®¥×ÝfäLÁ7d¬½½ Ò]#GMœñ¢lµh­Ô®ŠMRÔÛœDòh2)›¶´ÔóZ _Q’ÓZk¤´æ¦™¾I.ç˜"_IfŸÿÿæè˜¯ÿÿþÇ``0 C‘ÈÔcA£B‡ˆdѯ/ÿûRÄêK᱈×É}6`€¡§Ù˜ôlÍeÁTLNÕ–¨ –8°š( kH€d š¦Tè»PB…2¢}5™›®ß½'ú£÷´hÂUbÿëYZQï1à•y‚Lò™ç$K_VÄYë¥hs€C$(9UFtrCC[‚ DˆA&1˜ˆa‘LÀ¼2¤éÿûRÄè€ -±t iÂ!Ó5@˜ÕED0Ø~s2,lFrЂÒH‰—Q’ DÙæÄ<™ãs­G–ª•<£:+dÖ•Í<ŠÔ‚JZmf÷vÐÛϸŽN„¼ô àËxàU É  DÕ¿ÿ6¥¶šQ¨“e3"qÈäB3‘˜Á€@Ôb~ÊA‰Qs8˜AÍâÕñ\2A‚¡`ã¡èª'¢\%Œöœ3/• n_)ži¼¦p¾”r1‚4LÝ”lf‚F+r]Fåʬ­hÿûRÄÈ€!U¹ºf#)Ã5&·EFeÒš‹ç Czº×I‚Ô™ ÍKæìåʽ{lµ ÷Ò>×04­Ë‡ÿú7ãÍfS.‰Fe¦_PFSh”‹‰4XËÌ#‘­PÀ…/UÐØU)×D‚@v’ Ç£sQ𵤉ÄO›”Ÿ&°µr£§Mˆhþ;ES¥DTU‹‰÷Ÿl§¡~Ĩ± ­8ÊËû@µ_%„¢iSͺiÕÁ4†5Az[u‘,dÿ‰æ9®¯ê›ÿûRÄ5™o¸ö€e²ì÷ž°?1·7ûÿïsjêæç»ãg\ô²ZV‚½Ÿ ΄*¦j¬P¶MFãm¬ÌKZc´²Õ4ºWaÿw\™Ú2b2䢓ÁðD,mÑ3ʬÐä¥!PXT&z°H(u'¬d³_§ m9ºZ0ï&Ñ*:»ÎRýªªò¶jýŠ$aϱZˆü+TW‹ýý3oîÉ-µûÑ¢¨¯p›á?Šm…àL;?nC©)89ÊZ€@#<ÌóÿûRÄQ€’?Y,¤ÒÊ3›)…Ì0; J·¹A’®#/‹8 %Ê%ŒÂ`Yw „qù üДeã›'ðŒrFÇøÛ"dõYª›ŸÀz•âü'«¬ÝìºÕ¾³ß7ëϵKºË«]ÿ™®4óKšç…è—°—ú®éë ¬,Û¾ñðXÇ}µ\xý”S_À“uÅ PNc ³0P bë‰+ôÇËxù¸ÍÙ…¼ò2ýKॠT2¬ˆàR=èbLlQtb΢bµ¹ UÈÓÿûRÄ €<ýSM¤oÔ™)é¦XPeÖ°žàè´µQä×Ï.48~R–ÇIoaíHÎÉÕà 7óÌŠ´ ±¢ éH¤)r:Ò†ˆŽ(:`“†¨)´1AkÔ±»—Ô8zêp]¥–ºßБDùÔ­õ¸Ïá»¶« Èf%–ŠÑ¬-©™‚°]3•§½(Wµ|RÔå¬V½¾y[)íïÕA„¸eª}QvZŽ)U§Tj´¦˜é„ø¢Úñ¯"4B4SnKÅ åÜlÿûRÄ Ü£cìnáy%)å´–XlJªÝ†Ž×¤¯|&kW¦‚' ÜÅ1Qx¨W*Áœq¦lYö.†‡Ÿ¦«¹&p™b)2ˆ£Üæˆ×¯^Ø«ÇRXP¸3±Ý{n“$ËT!ˆT<ða#ZÂB uDã&yßkLX˜^.žp N³zÜIT{k«««É€!F€¯ Y:Ô©™ŠˆcBFš8¦jÆÔ›q~’Ió ‚0C(º2°¤ˆž¨y‰Ñ4ÔÕÉäžL3Lœ;ÊÎY³C2E’¦R•ŸiÒÖ–e³*š³_Ô·HÖ¥¤Ž§¤ªþ»ô*8ë6Ilµ54ÑvPŒ>ÿ²…e‹XPѠϽɔ÷­%?ÿûRÄ€ ì³\„K+óžPo®+‰$n¤2„bÆŽƒ£©(P=¡ƒI´{Uúh›6÷Ä÷X¼ãýùŽnǤ˜.ËÀ°ó¼ ópòÇ[#gÆ %ÀÁ%‹¯-¥Œ…m8<‘¾Ôç¦V¦e¹ƒœHXÁãh1]¸„÷·g§ÿQ®Íâ"G´Ò®<ÛH¼øº-'±ä^õ¥±ÇBD|ïÿÕ£õªQñîé¿Ö:Øî6`‚?¤U[&,')Ã\7S’Îü\ÿûRÄ 0Ûi¦í!F—ktñ$¢TüB¹FÑÑT³Ï”VG>‚‘z¢Y M>šõz<·îÿ©@´Ië©Iÿîd_¿¶Ö  4F+ xŽy%^/„ÌÁÞUySG+»tR Æ TtÅLfï¸æêjšÏTB¢(hßq±:nµ[ëbÛrÃL&³ñ!úOµŸÿE þÛ Õ ±háµ5­™Í…V¬Bããvc(ˆ"Ñô‡”k±Q‹%á$Aã·nìÎf­ ƒ w¬ ë¢Æn¤•ÿûRÄ l»c§¤©!A“-<Ä » ®ÝUEË s,&†Ýßú+Þ&@] ¨àÌp0­Þ¶-†³#@êU‹(”â6S¢”ׄpho<àËewxËf¤òü§cOiºÚÊ•bNÃú«R#Ž:§TÄÌJ*+߳̿ˆòi²Å§ê¤ÞTÿûRÄ>€Mua¸õ€L‘k÷ž°×}Üû«lDýCe‰ªPª0¬ %`Ð(à:Kÿü]€ hä­Ëã‹V…öó¼‡+Ÿ>eË×’#M¹ÊpnuuN–вç«ÖÚê¢:›_Q'uÜÕÉ ,4œºE”ÓŒÏQ ¯ÿÿÿÓB¼ªû«YZV …Ò™µ™…R>’‹Å‘&­0xêHL‰ìÈýt˜ááÔEbÅÛP¡ÎEt¹VÍÄŸó7yŒWu²b¡bÌhÕ×ÿþ³ÿÿûRÄ%€ 4½mæ<®!:‘lüõŠ”+¥W¶¯|ø±˜D-»¬h¤5……¶25…+Mi*ÜZ@´[Ÿb%wrõò«Ìªn I~“ØÔaN‘yíF6±6Î2ÉMÈÒ6 ( “ @n>0´Û¨!ÿ§ÿѰáI€)JA,&úÜåõñz©Ê¡o8Ž”!(j»â²äwhúñÿûRÄ€ „}e¼ôR,tô(>âZ®*žsm¦J bÊ>eÕ<° ”&³ "*~[ncéßZʆ‹&ÏÑ–ô(š@€%7/p4Ï †b Vöí>ŸdŒêd¨–ÝÒEbÛd€É‰áW“n ާ®Ï¬EÃÜqâ Ǥ˜õ·ëCiGkû2+üšEí2¦‹ÝJaùŠ„“•Þ°êe‡­0Õnž•0Ø¢‚Û ÈOJ9ÚZ:bSϼ6öT·Õ>J´ìtNàüÊó?êƒv¼I»à0Ì+YÉO«Í >ª*;õ/¶¹gOOù^ÔÚcÿÎðÏYj MQ¾Eߦ$fnf+3?T¦ãyUIEˆüy Ér{x5|Gáütk5¨L€šD&^ëõ¸BÍÝ*Öõ7úßèÍÿùSœÿOQm—õê»·Ù¥@ 82¢©˜ƒ©² ÿûPÄ[€ TÃW§¬ôDž-põž.\¹0îhDfX«1ïOÍè]…Ö3Ï2oäÌ ÇÒ «zˆŽNZ†¿ÕÔßCÖýhÿ»ýEÛ·úÉ6ÆÿO__± D$Ba(!@î#°å8£ÐƆ蘹˜ócÁ²ψQÑCøÙ¢>ˆƒëÛb Éj/°ú›§äÅ!Þt¥ý¶mE£ÿ³“åßý?þ_”÷uª*Ñr²(ÛD§ûQÇ%Š#%‘Ͻ†YDkë¾õ£t‹[ÖoŒáÔdÿûRÄg LóQ¬<©Gª´ö5Л’Žjü˜ª{è#©ŸÁÁ¾z‰›KÛd]íõþ¤?”oêê9Éuûûy"[»#Ý¡‡ÄgÏÕ›¶ÍBù*«±3nR#&»ï ëи€PµÏʃÌ}b&_žêI˜ô5 ’ùƒå¶¦o{$¹?íNÞó¾s"ÔU/p„@QsWüb ¦žÏµŽRþi…Ó­«ToƒXu½½O^ÜÊÀއ!’å1Ê;ið!]wÿûRÄr€ qm§´ô±I—í0Çf² FN5úr¯åù>¡ÁgHèÒ AŠ #¦èdÒ}@gÊw©‹¸^A„Èh^˜Å†æÇ]éU×€‡\"R ‚Þþlv .(QB3ë(AýÒ€†\¸òàûœÁŸ…Áõ;ðÇÿý¼¦A?<MäŒRÆUwãD?9R'Aa@”xŒÚ‹i…~ Ç—>(Òï”YÄð„ÉœÙ!Tœ wÒ§ÉЈ‹”Ê™|§7Ît²°Ê™p¯¹ÿX…¢WÓ¸sKÿûRÄ}€ ›P = G«å†! ܳ› 08`q3ˆ>œ¹ Å}¤Àµbлœãeg,Ôö…rÌùeó+E–~ „©ÑÀ3ZÊÆ~B¯ƒ$X…b¢€†!XŽô çVz(XÅ ‹4†~öý5$ûžüS"ôÞ?5Y’…ÛqÜçþ2–‚ý?=dü€%Ø?3ZÂÚœšÊÕõ«Ž~ 2½©óußYªIg"T޵:åThÃ_LókÒî‰g–×ucÝ&ûΦ6WÒýP¤›’$àÿûRÄŠ m1d¬$kÁ: lXó 0äQY5wÀ‘ iQ•y 9vF :ÿœfˆßŸS!?^à†ì Ù ,*ZúJŸ¢i¢¤Î¯c{û^Öú´ÜMwØË®yÿ_¶ kƒ9S.Û]iÍm®ÉÐF˜|犭hˆõXü'ۧ΃†«SŸßôaÍva×(¾^Šf¦_Àc·±·°?Ëu†yWZ½rõÓ-²ù²cN(Z-’Þmààê6Ü&$¯úG“‹·ãÿûRÄ‹ tƒ\L1 <¡+Ý„‰0‡òṫÏiN4af ôÙMýMõ8b”ßO»@wíªÄ‰E0ÀýÖµÇÿúk ‘Ü(QbÒ ’€êÒ·©Ø³¶Ü¡}£™eè‹v  mã†É.î™@É‘~Q¼‡ñ mlý‹·êoôVKºÐ+¢&8ÖB¡GÕèÎݦ 6ÁN‘%bŒ-(¢,´¦¹þÆ]sW¯ZT…ǯ³$ ¨uDÇ”¿KF çAŽƒdlz»eHa`üUºDダpQ&ãò Üê„í0˜ÆÙ(Â?ã«Z Lð®ž+Ö{’ëêrº?´ŽŽ§¤Ü•‚õF[”E=ÖÍá¥aÐ9!˜…¢PÙ:¢øå<ÏÛ÷øÐhI p`Rb²“vÿûRÄ¿ |]L$IÁN ꙇ•8Ú3ï@f&èù¯ûßÝZ‰Zټ鷃ÿîÎê œÖÃ?ô„: ¡´ G.ŠÐX›X€cp§Aë(BN¨>l¼Sµât:wâ#¯=e«ÄêÛr–ûg>ÿ/_Æ¢;Y%×á®Hv‡ò”-y³x«qjÞƒL¸rM`9ÚJuÀˆì¹•©'(é"í£R¨ 3æŒ-¼Ð-W' ió¸âª½\7ˆŠs‹-; Ò®g[x½§œOðê2ZÿûRÄÉ dÕo†,PñM,4ôÜ–ö%ú¹&óh@ ,”ì!2f3x¶-¬Ê¶Ç[I³¸#„…`Ñ¥¿n¬'»¿ ”MV)*«±›_M°,;ª´™‘VøüÒ"†©Ë·¿ÿý43[ýŸMDVG¥Ë8 ¡mœ)$ÀBJã¾Æ%ΫŒèB :ƒ¬ÈÅÜq ÖÛS钣Дz~ÇoïÛ¥%[û¼‰Þ{J휜1B†½épqг”VN×KÚ~ÁÍ ÿûRÄÓ€ emæ$KaRkµ„‰0p+JÒ5úx¼_’p¦cp_¹Ž´YÈÄH$¤Ô$z£9*ÏgŒÚˆðíõŒ0a„²vyí+1 »³É§wd"Ùéÿã}Ž÷Xý³/L@¤¢ GN»v”BŽIË þÅFãªÐe…¶ïËh÷|r#5Þ„^”eïhíyœ,fýÓ_3¹Ú×E_¶åð–ä2†Ÿ0 «¡ÁÒ§b#w+¿Ð躦(YÓØÿûRÄÝ€ ´N = ÁLìtô¡Üu 4p¹%ê¢%¤¶}K‘>?mIÛ§S‰R„Ñ6…˜I2¡ä$¤#•:'Á@0($`#D‹Eû€ï6òa5­æÀîM.µÕ•¸ç‘¢–#*¡öR"õ–Q$XtÒèzÅÕ¤cIË3;hð3Wdò9Üé&=¨ÁP"Ò¬HÜru Ó.ÚPà7®¶S=“~ט#”Å2])AjWJæ¶´ T:Œ ‡V×Ìq ÂÒÊŠ8PØPC»6{ÿûRÄëŒÔ‹P-a#A‹œk…¤™E¥ŽåÓûœP¸áiœô(j %eE¤ÛÎb'ªn6„âÄý¾Èà&]þ§Ó5Y¤ ·#hk§?!ð‹4øÏ­íu±4;®uÕ“¥CQù}ÿ2ÁW•[n,%.•* „€[’åYFÐñzKjºª&Ùl¥x‰S#ž©‘õ©5C--‚¨¯é%ŸÛXwVJYøéŒº Ý’•[5î´D\¥zúé[(í?û ÆíÂÀšLzÛVÅEÿûRÄä P…c,<ÄÁ‡ ,µ‡¤`TTJUžY5à)6S©Ñf¸Ç>£ìþ ^¦»8˫싴ÍÀвÂâȶ<04ecc+w i––qV'’"ìO|Žÿ¿ûþˆßû¿jx“ý·®,FêžiÉ]ˆT’[‡fɆÁ!mAþ¸Ãëµµ £C d¹8á¤ø».”Y°Üím! ÔÌÎÕæÜÚ¿Ý}æî§™d³* Íb•¤i6°-Q¸Ïñ^ÝMýø‹ÿoÖŒ—£‘íªÿûRÄç€ Ü³q‡¤i±uí4ô<ß®êÈ®­ÇÓš!è¸÷óT0†I*D·¸t–8i .Ò-ÈJÛ¥Ìry‚Ó‘&¾q‹µˆ¶§­¼øš¾|x k”ר¹;î!o%]XÖ°š¾å é%Ñ›ù¿Ûúˆ?÷ÿ[|g•ªÁ- {7ôQ, ¶­–¶£iK²l'š<ÔYL¬iJEW£Zhè(…Ó‚É$ À§[½Ìí[ž»Ô™çA&¥ŸfúÓUoç·™·óßÕþŸù¿ûþ™š±ÇÿûRÄç [L0«Y ì´ôˆüæFð₼.ÁrJŠ  <÷ÏB0\&daÊÕP—H¼±E%›»Ç.3—·iñÐ\G:Ašc²ÌÈäćRP|mm ðø;Ðym=4ÇöÁ,ž;ì»aÍ£×½ëäôq{mmŸy‘0¬Ÿ.guÇ,öûþkLún8ÅIüÿôÞïÿ±ìÌt‰‰–Žû'¡ÿÿÿÿÿÿÿç›ÉèÓ³ì8ÿÿÿÿM6mògsk.·9XÛDÆF qÙ±ÿûRÄé€ E“UL°«Ék'k5–0©Âm¡¦[ÆÆÎc,ÉÔg® &¨‘ÈÜ×é—SÇ6',§\ZèYo/¯z™Ö\tA\œ’X…-`±KF\êyöá©#e}Õ6®…Yq[ꛚ¹…›o·ÁÍkkAû½ûÆ1ÿÿüÖ¹µ±—µ¯ÿÿÿÿ5¦+¯ñ¯ •ƒÐPmT­.y Ô@FŠ (’u;‹F4ºÍ‘» AñdÔpÄ b¯_1D [º„1L Œ»Â"»•F¨‰•ÿûRÄå A+s´ö€2^²«'2°é¦žîÓ™Âþf½:¯›ëÿx‘¦e•u‹i)Qz+7g§W­-bÝj FÂá P©VX“Áœ[Ì1ˆ¦²¥ê¤géP}oèŠ1œN:B²Š{ð=µy!úòC¹•kÕl9D´ÈD…OX*‘p‚(FãŒÐ_kE‰¯fˆ˜P Ìõ•ŠÐ÷øõIgAP–ÚKÌ.v”¥Ú4“²ÍQÕ½q#}VšN*ØŽcóJ±ˆšÝí™$ãI²K ›žÿûRÄÆ]Ic¹‡€ vëw°€*d*¿Í±GúDŸIÂ|hg¡]:Ù<æ$J´Óâ±(`ÌK/­Îí¿ïæ~º "!f–þ‰åB–u¬µæÜN* ÈÒ(<é˜î£¦Ô•zU½ÁZ­¸úîäEû?RI(oç@ðüA#åfkŸ»þE ιADNˆ«zÞN‘§ÖŽnÚ9Š[‰ÑŽkYq )$Q¤’%ÔUŸh"û±%hvÅ’ØÀk8²Sç:Ò™)¢8çšÿûRħ€ H‰W,=#Aym°õîêµÑñõ˜ç(Q•Û˜ËÚxX“%AƒË¸4¶™±'ù –jw¾!ùõVÑYÁKmâV½É<æVàý¶Æm)"ӉơA¥E™µõkɬ»K+jÔ»‚PD²íÁ[Y¨Ñ­s!û9þ¡ÝóÃÊßÏbCõ¨ŠÝ€rT¸LþB©_³2ÿL³žu¯ú+…?H©6›n5j¹làZ„Uƒ e°Ý‰‚@œ¦y/ó…f+÷@ÙˆVÿûRÄ¥ ´¿]¬1ÁŒíôöþoéá¡°G»¥î½"„lê¡ ñ¡³®~ê)¹I¹–tnºé=¦”šN¶)Th™V¤-¨Ë^¥ €SNP7¼m‘”4t#°²¤>ì­„ÆÊî‚ÅŠ -bCþÇí`5<‡—aê ¿ýÔÁ3+{Ó’(ú·)Ý®¸ÁéOÜ-ý ËtÊ)†uRÊ2ÂST!uDÜ­Š( ;×EEÙÛ•j–ËäÑ™P"¤ ”(áílÁQüÒ2Ç.‚c°ÿûRÄž€ DN,½ Qe“mðĉ>‚$ åñP±tÌI*UR™Ú9ϰ–4¡hÝ?ûnv¯7'ÙÚÿ®«{ßµdåIcöÿÏÿó²¥ïƒÅ®¥ mXÀ¨ÿÿÿÿõSÜÇù‘©£ѫ°ÿÿú€ Û$#p%ÔÆ?ÒyePÜçkRORç+ ‚ƒáè\œ[åa¡:Ÿ©”ˆ.•ÚD/h¦­ròÎý*š)Kï¿ý7ÿÿ%‰ŽCBý‰P©&€FA±Ü$!îÿûRÄ¢€ }[TôZ2«§0°yˆ>QWbµP¡BÁ†zäü¤a¸Â›µ7ÌN˜IhˆØUW{\u‰‹†ßE#ÂuéR4…™Sv}M›*ß_êY›`[IÍ6Lô¤xÅ 6¢VÌŽ¶'Ç­a#+ѯBÙþúÄÈrFúr1åÂñƒ­Ó‡”âÄ7É!‹½*6Õž4j*ÀÏöïLWÉlÑP ‚$QFQ}8{é V#à£%(ðÑ’x—;—IÿûRÄŒ€ <»i€@Ží0ôŒ¨?m*s3^Ù´æRÕôfÞÐg_’ˆ•&íBÚm«¥£¹+ÈuúhQ¯Óžâß‘R¶Õ ñáH %:†8€ÜÂÆx¢Ž—ÞHMæfèLAÍ .;±¨—{ '§@š"¼n¥ªoÒ çV*ó¥trk,rÖ6ŠhšÑÖ«ÄüŒ¾÷Û’’:ÅQˆ€Ýbú>èY#äbÙ]xø±IÇÇͰ"DŽhî_àÂÙŠ ѹ´ï²7]´´˜-doÕåüÈÿûRÄ™€ t‰k¬$cFl4ó p°Ÿ²>šþŸ}[AöZÏg%mÛdÁ )ÎXšÊ h®OV[5?ËôÑ;çµëºÛRµwÈD›ºoßp“¯úw\BF€ƒ,b£GD и/ËB„Ó?eó~]˜]c1Ô\ÿûRIJ 4i_§°¥Á:ltôœð-`Päž?‡òØ<ðÞ£µSCR|"rÃÙ™(wøØüh»¥ÍaºgNDm­•~óM¯zôÖ¹µÅüt݇iŸ&1'B'jÖžz›kg7{â+»¯]ÛQ¨=-ÿÿÿÿ77¸‹9[ýïI¡04—ÿÿÿÖHR”ƒ8v¤ ­ƒº›ˆÉèZìJ&Øt„,jIƒY;X^AcÛiT»1ùEcCߤètˆC6¯µHwÇ”uÐÿûRÄ¿€ HÑR °kF™+*²pô“…ŸµÖ½2®Ÿ+¢‰.—s@ x[EâNP&z¬ÛÉl÷X|qGÉ]gnׯВ 4@! Ô¶Åäg­ËFÉ3¶mUš¿ÔAdôkÃ:Ë~BM`+.ú]\â_ïÝSÝ‘¾¨tì‘V6ïóZOâŽ$Tr`M3qI5¶•ðÖ‹RGCIa‹ää²ä"ÊáqCß=º^”uNÝMý,êk¦Ö”ešBWóÖJû¤T,½¯ÿu‘Õ 3P š.^Y‰ÿûRÄË€ù…XY…€<*G²yó94„!¹¦p+ƨ”Ê=¨Q®WmYQ€a¿+]Ƕõ Õãþ¿¯§ïVU.½+ýFÈèÒèšAo«»¾Éó®Õê*@`´ž¤È4ÄÔzŠ ª×&€Áaf(Le3Ð3=꾉 _|Å÷Ó{oš_Rõ%Ë5lcùp¹û'?k÷µë»å{~µÿ~þŸª?Z„m}ëéÔ‰Z•(¡`"vý/Ó„Jžø”øøÒ ›E‘\!¦StÛõSwS÷}C_}šîG½ïòýßóæ¨yû³«û} ,@Sc@„)˜¡-PåI*sb¹îË4'G (uüÎÛϵ‹X§v è+³hŸTé©Ñ÷üÿûRÄÁ€ \÷]§¤KAE¥«(ô8Àµ\_ôªWïÖÕ½„Ì{Ð’Õýÿ@`Œw†r™dŹ m;XÜ:ü|,ªØ 80LjBDQPqu P—Z°ÔªG9Á¸Ò£;èªÎœ´§ëÈíüˆDmÅ9L¿Üœ5u_û\›wY¿ŒA’™Šø$jÀª;Ž“Mã–·2KAýn å¥6% H|ÖĈ$Çh{)>8ƒD ›I€m#J,ä¼jð`Õ9¢ÍŠYŒ®¢lÿûRÄÍ DË_¬$GÁIj鄉(WÑ@ù4ó"#lÃ%SvÙúíòõº¸f™ôö0û†öè}â⣞sÿ0ˉ»;qæ¢ÖÿÿþÿºÿØÏàåœ ;ÿÿÿæ¶Ðk‘3«YclÈ ³å–LD“°t<O@Ñšæ7žZ%•ÒXÈäø†™óÀ@•†­.JÆA¤%‘Û"ù».@µ…£©)JSÏ…ø>´‡Ò_Ñ»Çëeˆ)ÒÄ/­‡ã¬RÌ9wªå_»96‡×ÿûRÄØ€ ôÉYG¤LÁM™«*žPÝú0Ó_›3Iõ·Û³ÑÚÓµ…›_¦ëò½=|çmغӽ0=L¨ÇoÿÑ7,P&Qî$ç©e…™8_¶Å\øQ=lO¤”¬*rÇwÊÜ(~X×[𶫼מòˆ6L:½¯ Œ8r4ÌÙ¯ß$ä{wí'qj¸NÒñÎx DJjæ°X` A_ý¥™‚NÇU®ÌÕ^b—È£S2^‡ÉN4PÔ QÏiÝZÜ ÿûRÄåIS9…€j®«0ÀÛéÿ¨*#¨­È>‚!ÙµZôY±@˜D%ÜntZbaÁdŽ®4ByY†idlp5¹»J¤àÚ‘`€J1å…I˜ˆy—CLUËÜóð×®ÑP˜…Jù CnC¦kÐMåÝ£XI¢ 'Ê€ QÈJ™p9¹aÑ"åEÒÑQH¶¸»L×IêÃ5I* ql<БÃô’B+uó9Ò_íèÐ ¹éˆœ”ˆ\ÚŸ#õ€B€ ®H µêxÚ{c$K® Æ‹"Æ u¥±Ý“ú¡ úl…¬ÝÖVÄf†UÊÇzÑ~ºÐ˜0a±DÄÁ3ç§Ï5ê[sâÊ5Óç9ÛQaFšIJر©]hIø$-C/¾è—-ŸÍCÿûRijŠtKa§¤ÃAA+hô0@’7gå'úÝœÆcÊ¥ œ š6$ ’oXÊ›çÐÕÝMöûW£‹Léüˆ­±Ÿ@P@h5x(=Eþ‹¸ìÙ¥A <ì_Cû¤k¯ ¡ëÏ3xzé¹,.Ï´ÂO,’;›žBˆZDÀPƒ€…ǺÔ8>^…¿u¾m£o.ó—­&~Š€¯2:®üžrd°:Ó–,ǯå7.ZˆÃ&i ušŸ¥ëoÃÎŒ Z"¬4ÙjöJ©¢âÿûRÄ¿ Yi‡¤GñG ©…—±'•¥nÍÝ$Sųj,%Ht”Âÿþðõ yÓ tÃVíˆó{3Åú§RµðëÍ2—ÚY>õ¬ÈX”…æQ ý6&%A#Ô‘â¡ã\ùס®û œ ×î>œ|¹@ðJaª¹&’ŸÞ»ª‰dvŒ‡õvŽ8‹@”NÁ…Å……¦ÏZ¾¥0‹ö1§A6¨ýS.%ÍÿûRÄÞ Pß]‡°©Bì4ô‰`Ö‡õ,Á7!Ë‘s2Ó¨{f+õl‚ˆñ$hÂçÔÉ’A¹ÍvÂBÃ!sä#×G ݀܇-å8‡æžóá~‰½s€ ha‹d§ûÄs†8•ë°BtQj.qq*Wü¿îNeD ìñÃŽ.BÔ@ùð†ƒø[„<ê%˹Iñ^t}´‡®ÃP䢫¹d¤ÁÀíé+´ä;z¡Î– ŽH&xyUa¢!uáªWü|‚rĈ«ÿûRÄê€ Áa‡¤Iáhꡆøú ß–Ù$­µ<"Ô¶fºh»x2Ù_Lw¡ ìd MgÊM+÷-eZò1Û>«¯xéٵƋ÷¿Ö‘û¶>µ‘‚¤’µÅƒ‚ª…¶ uŠÎÖ3j¯$,0zÕ<Œ^”(XÿÔÚðå·UÉ®¶Þ] aðy“”B0Þ PšÖ fO%ú«ºiÝM-¥®z!ÀÏU°ñëTde…‰R& ‚îQ!ZƒŽ0£Ä€çTyAÇZBæ.,·L6ÿûRÄè€ Ô‡]‡¤KA¤(ìô8¯`³ÿ[ª¢<#M6Óm¤âI¤€9i‡ˆ*¢ò¡‡ªÿºfµƒæ’?PíôÑ'mg;·1fDÅÍs?4X£Ï†ˆÓIbî u¢­u&êïü p´(Û}G¡­HqÙZk*ä» <™å]*R>>ÂZ‹Ór²¡Ä¶K;'3‘•ÝSxÜéæ&^é¨Ðý‘›@ó¤guö;åS î¶£Í 8ÝLPIèÆšh¿:U:092+@ñÿûRĆøe‡˜o‹®tó1Üvé®ê][¸í( ­­^•Å:8QôŸQ¦L¤‚mꥥIªlï­S¢TÒ!+§›^ŒŠ& ò;sLa‡ (ŽŽÚ¢¼åAÊÚúÙÇ \“–?R[°WCڟߥý=ÏÑ”pÇ:VÙQõ€#Ž’À£¬xŒEVaèÈÞ.2)Œ•†1Ub¼}æ©V"f»”ËöÀ÷LVKêK A’[ Í"ŒéÖ0 Šl¡†˜øøc”l÷ÿöPë’ú¦±:1ÿûRÄç ¸ss§¥a}‘®ôö,¶`„‡,qÁ× Q$ÅÈ›KÝT8ÝMC”ãJq+æ1Ô^£qêtsXŸ£ÚÄ kûësÿeꋺU»o»Þˆâ:.4¤N%Ô'—¹í¬ÈaêeI[çõ-é¨ü$(ŠÚ}éÂÇ0b'?È2‰ êÉLľL¯äB³”¯b7QÉš®ìÇmƒ‹[,ìi¼àEŠØövÚ:r ÙZªcóc^q&#Ž[D#±JY ¬¸v»[ÒA>‘‚îžEÒÿûRÄæ  ›\ì°IÁm–ídö ®w€>ôS‘€J€r*Mº³Ø%õ‡à"`DLX& •6Ï’—}f˹«Ìúõ\°êÇ¡5¸kÔdÂ(0·9§Ö­ ȶb`y5ja VÈ6Ác<~ä74 ¤¤@Ú¨KÉS6,æPyu*‘ ¢œi8¡²ªQe“Ý0|¿‚ªz¹¹†$È(¶å £á¼ÖXW.¿k‡æ¶ô- dÌ’fC.×Dã„4¨èœ LœƒuQv8hy L[ÿ÷ÿûPÄç€ ì™Zì°iÁ–“k]†8w·GìX .ÈÛ øî!„¯g,©ÆbPsº =‹=t¢eãÉMØÕ—B¢‘±gkÈTÌ¿Èç‘6Ç ¨†ý&#?fc™[½ûþê“FS¨tp÷‰_ѽötlŸÕ£­¯bèsÅcmdL^€Fdª˜5ÃÛ°¨‰G\ŒÃÚF˜Nl‡W¶Ið|qÁ8ŒÒµ¢ýØ×R·í¥Bˆûà­$XÒ94àVPÈTÕÞ©œ¦7G]ÿH¦@è¹Óárka¹^xxÃÀ†¾ŠÏy7õZãUøÚëɦ“o,š{àçïí³Úñ«ˆÛŸ[ƒ]ßǬ‘ñMÿ¿¬gtøŸÓ[õ¶ñvy³$¶cJèóK%™Y¤rˆ¾óLÑäLâ&˜HðXŠ›£çýœ¥ÄÿûRÄ߀ ”Ác§¬vaŠ뽇¬üí?­ÜE²ÙhDr´%¥G‰aœl#зƒñ•rOY_+K‡ÂÀK wÝ‚@°0ØÍl¹‰‘yÃsC™¥$™¦$cc‰Wù¹ñýéd­×©¹¹óF0œÙ鬵9|}±”÷½Jˆ‡êÒòè˜ú4­ˆef“ÍÕU6ßîœê¾qLûÿý#b¢Õdó¶]³×˜O{I 1 ‹¨Vh±#ØP.JÔò¨ü?´ÊÆ»d0”<wa­`ÿûRÄÝ€ ¿]¬= â.¦©Ê´ðéHÈEŠ™Ý™H¨bª¢;ä+ùŒcoeZÙôæXHMq(K-cÔãbA6¦•"•3[}K-ûÜå(¥M J^*2Ì—‚Α‰¼³Ž'‘K<“HZ"iœ2‘Yä ˜x?8ž¹¬#Rc]áÉøu ‡©g=ínûtÛT?WM?ÿžÑÝ…ßÿÿ§UmZ®ýÿý¬6ÈuÉITVÁÚAA¶»6$…!øáêCa³o­GiqU ƒáÿûRĽ1}q¸õ€ l­3žPËàR ,z£ÕDŸÎ{“gK\Èý¯æKßfÊàíàqv¨QE£J€ò*<(`ìJÐ.|  è •ÖÞ†òJ P¦!eRÑŒ‚ÎÅ‘0œáÉÐŒØ_6]p:5[§QGbÀ AÌn×n¬ M»ë½É¢(Hr#€Ð«µ±‡2dŠþ’ `#“¿E8IööÌéSÞWjæPÐÉÐ¥.T¢fJñ]Sbª/Ã÷K •CËd±tÿûRÄ € 4Ik¬0ÃI“npö ®„ñÃEyÔœaYxÜÕ¢>Î좼N1=eØÊŒvžC;²¸j”8“;ºö/XTµ‘ô;ôM»+Uµ¯9KýFañ©±R1šj„áj´‘Çœd! ¬oqäü´¹ëB´Œi¬.SƄՄsa§!(Ð5ØâaîW èvÐÚY3™Ïr©œ¬½jÛ-8j fôÉÿÞžû|}–¨ëF¢9dkó7#!i! -d—ÞF  ¸¥¼ÿûRÄ› °{[L°Ã±¤ki„‰è%ÀêÔY=4_§§rÂÔÈq;èYÂÛÛšòHl•™àénݯ ß1šÕEËÿ¸Hâ½V.Õy5M[}iÑAó?O§m­+èw»è…ÎÿþfJôVz…-÷eHª £’nÀ¢à~‡Q:S~aªM÷2CÊ»Ÿ±îþÊú¹ï Æ*bØþîòxÖ­<q6k.;5C*ˆeõ³ëª¾¿ï´Þ–þ1,Ø1¶±|飩sbÃ:.cB¾ZÿûRÄ” }k]G°LÁ™-kôó 0”£S$Œ¢s“¨H„èx³’E£¹•=S¨¹xß!ðcØ& œ‡ü-Õgˆ»µLë¢ö×™M¡íRPR´‹”—}þ”d;Y^¿Úõ†ô XʨMUqšmÞ¶%\n3!‹·ê'©4ê)Ü*ä 3Á\CØŒ”û¿¼‚Oß&„Ä{M˜ÚÖß׿æÊyÓÉßÙìJ)‘hp]«5æçÂûP~…XAP º3ðÊ×cATPµÀ£’…ÿûRÄŒ€ )#_§¤«e—¬tñ–$ªˆ8 i2²îð®XŠa£w†$*mf¤RJhl¶wØ,¢ÌYx–Oô}Š/}57:*d*ãðÒL»ÿù'ò·E¬ 0ã5¼Ë‰Œ?R¢E(‰0«Ølfuc>O1 Žc8-‘'W4m¯Cuœ)­]&È(e³æsì»Ä*ß( U¥ª9Ÿõº!×–¹u,IJ-ìnÞÏB´h}©lÆO¼0.–3%]æw-ƒ•ЖÒéÔs§Cfc†Xx5ÿûRÄ‘ $Ñe§ŒO¡u‘jI‡˜¸ iå„üu|ûÝwR’òŸ6ÔÀÝi¿>$E€Ù;[ȳ@õñwr}Ÿö¡hË"o†~Oz 7iQäl” (eÙ|ÿKDH1$I… P2” ÷œ=oM hÛç¹1 ÃM3٠뻣Ô”Õ u)¤D0ôš¿S*Uqæ¥O¢ ×ÿöY§¦ŽµkîP@“H`¥§ÝD­=#-@•¤áïêñ![JörÍj@ò8öFb"ÒÈ9¨úbžìËpŽŸÿûRÄ— XÙWL0¥ÁC“ª™† ¸Qm ‚Âïg3ŒBÛýê¨çRèÐÏîôý5ƒTs4ímÇ3#ÑLtPtU‚¡òri%NMòáôëí6œ&9Ãîâû M¨a¾`—qLï&‚ÕZé¨^*Ñ ¥Õÿÿ_éd{'=ÈÒ]*ÿûRÄ£€ Téa§¤©aCªa‡”¸²Ú$OÖ¡— HÙ(F(&D|T%0ÌØPÜíX^‡Ïú†Å弞†QðÝ¿ˆÕ bù÷Ç’“~xØ@`@‹ R]œSÊëªû©ýÿ¤T”÷P.bj_T'ÒçFIEV*<Œ(q?@´u8_ÂuyhÁäUK*]ž¤qJÎpt÷Æì—¨qE(. }qMc¤´ßJ?ý»Õíô¬­ ,®ÔšÈC¢ »˜’Qvî(#ŒQ¥—ÿûRဠ0g礥á?“ìtô(¦ ÍÍÿžëi.ñ^­ä˜4çzÌy®V¦&«ªzeãïb̚ΥV`³|>½ÿ²ÿÞ gQôRaª«11ê½õJçÕøxÈz56üÍ<¯¦Ûš)yåa,³¢âÏ#ìaIÁF´\zFÞç¤zDÙ}>jV õ½âÔ‰šk pZ³cýg×ï×0÷Yýà}î“úÿ¿¿ýÿ¶ioÙ¯ãtÿýýï[ßÿÙ¾ÒA€ø`UÆ-ŽÿûRļ€ ]Œ$eÁ7–+dôˆ¸æXE!‘;€§œÄª…Ö(á>…æF6ŽÑaö©¨\v¦÷›×Fjtw¯v\Y-‹–%a”•K½“TÇ«ÿÿR¾ªKÛBPô±¶Ê \£|R¶dÆ¥ª«‡©f) h©îÔÇ#Cjïð¼ÚȘ”:þ:””9¯Yé¿sõçSŠr£øv%¾59´6¶H‘9e‹wúý›ú*€ý•Ûk„„ÈF˜:D‚:Ô¬§dé5+4*èp¼ò §iLÓ¾ÿûRĵ€ d‰`Ý„M“­´ö <›Ë•:çÓ`ByÇ·îh€¾"n©úuqRô^€KÔÒBÆF=¢[Ûgß”éYÿÿwù >x" ämA'€c´ÌÖX˜Ä@ d†b\‚«ûbUP¢›l†±²Ž¾…JÐj¶ZíoSLå¾þ‡,pQ{ó,öY&`&P‘UÚt©T晡îÿúTí»Œ6ôÔ¾’õ$Vã` ¡àô-Àé]¤ÿ}XâEÅxŽ8c êWd5ÓÞæÿûRÄÀ <‰]L½ A9“«é† x Úv  é¢ÓÇÕ _P¢qÚ5NØŠ¼éÇ}ð²žElˆÎv¯÷ÿoûœ`¨ÀìÁ´DûÀŒë_m¬Føp;!ˆI22´5Ž5Nxɵ»3šÄ-ЇÂñ/º ^ƶY̵W9ÇB†NŸ°û}Wµ°fòQ+PHÛÁ‹Òxm1fû Hïûêê$è©ótå«VyÇ®tßµQ rÑ ?ŽRìÌr!M,VT0ï*Ê͉ kõêvÿûRÄÍ€ ø‰e¬=cár‘k)Œ°pG]iPãÁYvFS]H-¢Ü@{^¥,ß]*ër·•E¸y¦‘F¡*èúVžÉuÓ]·!’ýÄMÙ$J[ eAN‡ ‹Y)„Ë“2™™žÝבzីG~^ÓèyÖ£ ãZœži³2»Ý^æúW”¤—š¼z¸DÄ‹8ˆsp|鬇9ñM)Þe·´9ud6l0P»"V*™~q<‹0má6#Ç?OÖAÑ”¨|DÿûRÄÑ ÃYL± Áz¬ôö,¤‘â…5Ž´Ýš m b´mÊd³5½Uvd,¨«e®š.4H140)"i]O'{ãT(ÙûÙMe¿­ê¹ÍÒ%Ê a¶™ÊA…ÁÐ~‡˜j"–fšRIG øu–©Ä錒6:å\\‘„ YÙ$I!ü[ŒÚ/væÞûæÆ]=ÔÒ³ Éš.ê!Ñ,1}ŒmUÿÑÈÛÿ×EV—Di%Fš8—Æ‘‚¯˜FJ\@ySý™Né'ÜÿûRÄÏ€ ÈÅe§µR¡f”.tÄ>ºóÖì~g}’ÈöÓej£aTáôÄ3c„@Á±RJ{žYŠ’B›ýO÷v2k }Ú  *’¤„„‹Ráƒ6Ò¸²£`êe‹8Oy­Ë|*sÓ»UBLq/r°…ÄmB²™Ì¼µ†ógWÿE¢kP6މÏó>çTªfQ‹Uzo¾Ãwµ»µwdKjJ©D8'a$…ª ÓBqò•:Þ]Ê2„ HøáŽ6=õ1W±ã„,“Ky` •®ûÿûRÄÑ€ ŒÇk§¤Íáfle„ pô—LSïЄò kv‘·þ×ùµy­ X;› €Z’Âbè?p™„¢xäÅ„hË&E˜À¦4c¶¢-+ýšA*6» Ýý‰ÿêü‰ô?ÿÚ½ŸöÿR÷£³Q÷ù¿Ô³çÈS!€iË ƒÝ[’ÈI° Ø}rA²Dí~~åX•W:šÃ8iùäQ ßRo÷i}œºº§UÓèÿ×úì{~´^úÌû*+öü¥~…w€ÿûRÄÌ€ 0›qç¤CáI™,u„ˆ¸©³Ó³ nK$Íd,™¡$õY³ 4A#jPŸ‹3,IUŒ5…«I>öXI’XÄ €)Æx ÌÛª‚·d~?z•¯v~`Z®;:ÜQ9á‘Pã­çÒFßZ@!g‘Ê©)|ðÕTf”U(å›[Ø`](ÒJˆ žeƒâ¤@ !§â‹: äÂ/§„Ľת5…žoÑ1ŠÍ¡BìÏäû®šYì{³!ï÷êÖëK±*‹Q¼’T…q^ºÐ,ù¢@ÿûRÄØ€ T_e¬<£A=,¬´ñ•ÈQ“ÃV$äF€T$J8ão€Þ\ÙÏ·ŠtŒ5¶×4R?æBî9)–,hžùƒ>O^œZ ‘瞦ùþÅ>ңѤ\7¨aƒ`ù2þ)oº.40¥š˜îA{0|x]n@µÔ @8FY"TÜÂÛƒÌø¹Œjq÷bMÍó†Z‰ðà †F¡´¶§€š$ I6¤„&G#åB±XH! •¥sÖZ*é~'±5ÿïç «öíMm­EB J£p”ÖøÞ·#³{Èäöíõïÿÿ·úÔ xoSØÛVý«§õ¢Š -“WŒöd cÒï~ãuYæêÈ¡úYCó«AýažYw_¨PäU#û ÔCÄÖ8àB/J„ptŠªÔ&­*>ç*±hº6¿è=z*ÿûRÄÊ€ Œƒu†pq:käö¸Ä¾GÏ•~Æ’kh/­DÆ”Eär'KŽXŒƒµ áÔ²ªT¡PÑ>—T×nì7™”s3ZæT½«ÕQÑâºÔ3–×it1zïUÁiÖ³µA¯¶Ô,èŒïŽfûKûòþŸ÷ÿ(ÿëÿþ¨8Iðte¾Øä÷¸tõ1"4­±\kƒdÈP!†áÞÍÎÜДŽnlÖUâµdVž~0ãã“‚K­%æ$-QÀ<ÅÊdºŠ†BxBVÿûRÄÛ e]G„YQ¤¬ôó êC³5l-ke¥ß·ßëÿðͯÅ=¿OÕÁ2׿B‘¹SåÀ ˜q l r!0òا~Ô©†Y§—Ö–Eè5‡Ú| (ϑƥ_àÜš3rçzt»ß=¼„VoŽí„Ô1žb°³/1je2áÑ# JJ“5¿OÿýHíG”¡ÐÉUQÜù¥;qfŒZ¾áI ½ä˜Èªõ¥]ù€¬›SnÏm&½-þ7óº×âsEmû=ºæÿûRÄå ìíUL ´Á¤,,´ö»#ƒÅ™^îù‡Shzbֻ̻¶2{ÛTõ§lôm¿ìŸûþNÑ!'ÈI{ €a'Áñ À“²¶„Å‚xÚ¨>ðêJ»¯².7ñW†nÔÔšUõ9”~vŒ9ú|-õΫ]«ÔðécÃ;ïR £;üÒY–hVaEqÕP•iètœ÷‹›˜By§»W„åL¥*Ñí >ª¼91[‘$AEQ#Å;BÓÉó;.5;8+ÚUdÿûRÄÞ€ É_[§ SA¿&ê©„–˜B841bSöÅIÀ¨öD„4yr¥Ï:¯úêô`Àa‰ø¼K؇fs Ohì8¾-çÔ|³i÷Ýš>äÁÌxóÚLCðyἊ^1•íãÛèekE4BH¹¼†l‚X¹m3*&ºË—kt²K 70i\¢Â—<ÒÜ\—åWu^"~»wþ¯ &ÜL ”ò™Àœô±V²¹©”£"xÔ1}FÄòѲ´óP:•£”ÆŠêÌVBr]X²µ8ù#E«Z hĪºÜ›íž»kf—³«v±‹WÐ €Š-Þ:s'S=úR.€“0—<Á0aÕ†.{é»%Û¢²2*2„8YªF›%Ë[WÏõ)[úÝ*ÿûRÄê€%c¹‡€ >Ž¬×ž_E§VMZÃ:5ýWz¢Êmý«P56áOUÏH'€K–¬‡É7p"í{Lz¤³°Å÷¬~™»ë6IZÝç3™ë“aKóRˆ{5lÖçNŽÖБpÓ#PáSÁ°íàÈu—ÖÔÚ^²ûVÿÕ¢kÃPé§\«Â“¦kO¨Â‰±Ú–-ÃÍDB¢„DzS£˜E:ÆJ2ÿLÕ›ì×I1’2¥|3 fÔæÿ|£±Jh¢õD5ªÝzBlÿûRÄÌ ØScŒ$ÃLŽì5†à©r EX£ó~Í»U€ÉÙ[e¢ÜõPy%–_÷ñÌE7E,V2žé6úΔDŸŠlR£ f › ÷¨òý°ë\¬cYÙì^ÆúY–¸[mŸ[~©¿Ítýr#pc÷¿€ªªíöV§MÚi¨º%L8Ô§Õ[‹¡Ð #7áA¨¢Ý†r§Y Ýê$vÞÃÀøÉ\¸UÉ)¨‹eȹ7{ï¤â5”GGiõ*¶fô)¢l¶yÞè§iô3íþÿûRÄÙ tç_¬0EÁH“«i† 8Óö½«YNû¤E|•Qu •Dœ9CCÉæ-X‚!‰ªð¯aq ÷Ÿ:…^UÌBÄZÇ…›ÓBa¥ì®Yz[H=QK¿Ò'!hV¥Ú¤êÓ¤Z£È×êØ±k`¨¨&¹÷0 *U YÌUAI“^¡˜šPëˆ^ÐÉ ôá‚W¿s‚Ü`ˆ$ ˜yèUòÇ!Ž¡iÄppA³YË­U¾Wú1ŸÔV̹½}]÷úh[º¶oú«_ý3ÄÿûRÄä È·TM$Qž,,%„–'Òîî@€s"Û­×´ :M˜:D ÒW»|Û#’¸iPJx»¬âW ÑLJRÁËãL Ú%cظ #ÝF„€ŠD°@Àx•Ía“0LYR^b˜˜}GT²M)éíW®0uI¦Ç®åµbžNÜá! yœ+P^ê@¯Ô¾{²š³˜jЪËÓŸlî¤AM~°¦KuÿûRÄæ€ 5[´ó€™1+k2Ð-õ µ‡ÄÏ2.,;‘êsj´»k}:»þ¯›¦6 ‘Œ]“nà¹GçóZzðFQâK „¡0¬ m½WÑZU§ÇL#[»«†ü~¯ßÄŒên§?Ê>ž‹ÿ_õ~sÚÍDùd¯5HŠj«eY’Ö%^ÚÁf‚&ÑSÔ¼µ›¨R©aQ¢þj¥†“5ë¶X(øØð¡O£J-.g¶&ó%~³Ñá’§œ¡]N¿Cÿ(õd_öXOÏzÿûRÄÄ€ME_Y‡€E“«ë°€zÆQf$vÆ>É­ïvëÓ1vöˆE!Õ¢¯xÜ50÷¾‚˜Ðøï€‡œø”aÔF*9ykåU*õŠ8({5çÎ¥/ñõTˆ¡“-œT4Cé ÿ-ëÑ'æÿîê 3[-5ȺàHËé¾v¹'—U( ‘ o%³¿Z²—â °úorDÆÀ¸ÕKà"ÕìŠ8ÓY,hTݽâ«§ú×]ûÉÿàvò>oFjuõg©@+øŸg¢É%2ÿûRĬ€ é_§­NÁV•ì4ñª¸^›·H©WŸáŽjë·}HáHVÆï¨SÃίRe¯œb”¹V›¡læ$«Ò¥úô=¿éT¦ÅZMÍÚpÜî×kÿ¨ƒeBRf708ÊR³gBÁƒQ/“]|²1ßH¼…›´…E;CëRQs¢üÍŒw¹Ÿû£òk|zév÷—Þ¦tßÿïШԄ/<,qo¾zÚï#EH„=F{?’!¨-C ØÅ‡#çÝýùfœ®¾åÿûRÄ´ à³N­<«ÁK -0ñªþÕ’_NÿÑ{ŒRî|‰õ+ÍŸ”.m\0£…-ö ßÿÿ¿ÿÒ>t¸qQA‚a%ˆ¯ù[¡É\ûßñ—Éé颯i1„ÂÖNîýŸg¬hžûÜ}þþíã?ÃïmÏ&ž±€ .hˆã¢Dëþñ^E«!WàŠnïġ̃˜ò-ovº<>ÿâmäZ,aý7‡Áp¶µŒó=–~ÆŸ‰&ÇAoϬò¶UµäJEÿûRÄÁ 8×R̼éÁL•ëôö¼ªâiIZוƒpÈu ¢t%Hµ†GèMשLyÔ=,ëvv}ÕäÔiäZM;“ª9žzX©“ÛAæu£>ùx ¢xÅgïÅÌ=­oÜjíp-—j›pë< i¬'²8{‰º˜† Z¢OR…H‘¹JñÉ¥T†°$lAQ^°?ŠQµŠ¹w^sIŽˆÜQãíÙ&8µ$ÈÊåyUÌ:݇0{ޡ׬aæ7u®y—æÿûRÄã @aL0Iˆk]–(×Ï ÿ²îÛžÂTÝœ¶]®ÅÜg–ÕÇ¢BCE©cR5‚¬ bòTí]¥2‡‡?\T™GåD¤×÷äñoÿÖãÛÇÞŸ_TmÔñ­ ø½h(”¬_†IЯMu§d~ª ’›#2B€ hT§p(ËP+/Y@ÚÀó´†HÞÀ‡‡¨i&s’òñ!Ôo¬ïH~^Cfs]¬1-KgW«·ªÆ:õÌЕÔÍ©Ÿ5ôûw…]Á‹kÿûRÄç ˆÉcL0éa{môÃŽ¤üæŸ9ÿ²0M jµöëÿÏûúW¿J{ßÒп­ŸBÎÿÿýþ¾÷»ÏÌÚ´,Å®-ÿÿÿÿþãï"c[¯Å÷ŸñVÖ…¥§ÿÿÿÅC@ ŒQH‹Rº‹"®ÖS…†±y§ÕîÆ¡Éiþõ.“¬Ù„1ÑN=+Sv»MĵpŸŽöá»þÞÒ¨GQdÃì+6MƒâB‡œB»–þÊê5*ÂAý/X°mäl@T2²=LÂ}ôBÿûRÄç€ ðËg§ ±!|í¶ž€™i°ÿ´±õ·R¾V'ÙÌÙGü÷xì kв¹hŸêi‘Äï©„[‰f†©/Ù¥ G×ÿýÿ·öðÑDn²i%\ ¤ÒÒ»=j‡)™Ç®M%–¿úypëCV˜¼p¸Ïõö#F6½q‘ýYQGþ†õWBÜ¥)l­zï|5ÿïåH][¾´£o©ç?® ã³ÌY®±½áHîF¸<4' G(PšÖé1ú—»0¡‰—·Ö6I/3¡\ZÿûRÄæM‘X¹‡€K’+k°æŒS‡|ó]ÆÒ÷pßš;?Óÿÿïèfýì³ÑBÁ4*]UÈ뎩ŠL…Å\¸xh¢’Ê ”´*ͬV´Ø# 4§ŽGÒרõ~ùu$›[–ÿ…‹]úPáfÝÔçîKžÜêT²ý9f¯GR¯ÅE€ddÄnmšÅWu$@k- ð²y8C»$N»r…øþCMšbñ°›öÍ•í.öû!œ5³è„RΪrn¸T¸ºf³ÿÿ¯ÛÿûRÄÉ $‘VÌ= AFœ-tô‰|F?Ö¬û’²[m5liémPåW1°º9‚‡Œ´s&úfQæ Û^Ö]ñOD­!Z³&à‰t;i±Âof)–OKÔÓgz#šû™8ŠL‹°á×zŽØvê+J¿öc1ÿ±j¿îˆ@--_5ö®óFD{r_i ð¢ÀtqRˆ¡JbÎiJV¨Y¨jÒ¡PÖëE$}3¾õþþzš05åس½N÷vž"„¥µæÙê`²ªLÄâ`–hb ‡›y¼‡ùMl‡ÝÔPšÿûRÄ¥ T§g¼ô!¡+ë¨õŠ,#1)lØÝŸù°_ Α¬,ÕŒíÿ]×37 WÖ5$úR‹â¸j1óT'¿­x`M$·2ÜÊímí_·õ„ÿM{_¯þßóÿÄo±N°~‰kÕËNj¤YK5vÊšôf +tjQÞGd"+ËõnÝVÔŠª+9ÍUNJiJúÆÃ̺ߚvö§·«Zÿï^Þ“Íÿèô÷-êºÌ©®p<™W¦*—!Xš/™¤ç=‡ÿûRÄ€ €÷cG±K±½+¬ôôŽIü¦>œUÇaįpkZQ·3µ2øëûÓ.Ø¿ÞO‘zåi`|·²’…L&ÿúL±+6Àðéb€†Úf«ñçÛÙA?¬Ÿÿ·«Ä¬Ï³­>ϹI”Åu¦@4Ѝ,‚’IQ†š RŸF¢Hì¹9·Â^mòžíZ—ûdŒ—ºb4Ÿ„`&éÞ;`Ñnþ—ëæ>ß" ÚuÞ®Q}>ÛýMøøƒ„™z¬~¯W@]>Ç‘½ ly2WÎj·â½ºÎ9ý=—o§ýK{ÿ»ÿîú• ñucŒ<¬‰ÀfDZ™^ìã|E‘œnof¶{–.n&cê;HÑ`6›ïõ-"!å©Á‹ù¦3šçîŠ ìó•{ßàÿÿÿÙ\h"ƒygš"›ôQf@Ê’™8VR5¦Ò—Gß |4ÝÇ©H’/ ÿûRÄ’€ ñ[§ ö!WiÙ‡©8†“A(6á-(€«\hT{…ÜÔpoQ²‘0$%㦥Òh³(2š\+LÌé|h2MNŒÊëyLFÎ: IÅ$P>{×ݦ¦A©˜5t[ÿþ´æê¢q–‘‰ ’Oÿû:ÿC234rRtïÿÿÿ†•0)/Ø› TyÊ´ÐØ¶8ÔÊü,ÓÉv¢êS¨ùä~žµ‡“ŸÈã;?þKBî%iSëÙ¥ÏA$ÿûRÄ‚€ »aœõ€!GªƒÈ»d S<ä&•X® 5YÔ>ÆÜ µèWeÝO¿I_Ÿ}-S÷Ù·±“p‚µy²¯íV–é3ãd–ÿŒŽ†[}x’éBújßPÆ“qI?"„¸ïmÀÐLja™âUÁ·¸p°Š&yóðu&s3«‚®çîê·:>MZ»Ûaº]h,·†ù÷™Åéÿïwû„C… W¢Â±HãxÑÙúöw§"äÚwB<ác=ć¹Îþ×ÖmÿûRÄ xÃe§¥I¡Bª‰† è>bͬ‹Ä\ñìî¯Ó@þA¸iD~:+¿Ÿ¼žÛ‘v$ÔqÜRþu•5ÀkmhšD§bàRŽåâº)U'…²¡í‹/JY“ Ìñ¨TV †,˼ÒêÚ¥ô‰ËVÙìTÔÈ£¬RõÎþ§öÒ¶]¨Šöü=ÿÍßÿûþ²½¥ÀƒP%÷ðŠ«Y“ÖaŽ P·ÌÍ óˆ_¸÷FShøŠ{h™ F4EYTÌtRÔLìîûJ]}A{ûwýß­5ÿûRÄš€ è·U, °ÁD–êY‡¡hwÛ]öõ·M<½“îIÕkGÿRX2`¨aI΢5F*ºA (IÓõע˜éÜ…ÖwêÝü ‘/çìX©]r ¾™Rù—ç°†£ÚQÊ6ç~ßNÿºõ«þˆ™^=êÍG [‘ÁDÀô¤ ÅÄT¢@òqiÒò=æ_|ûê¡NæÆëy& NyÝØöÕâ°úz>ºˆl©3hö\]E­,pï‘UÿýžžCªò­-ÿûRĨ iAq¦X±Qžê¥†•ࣘŽf„,H P¼þCìôG½¹ êþïéÛ®‰>®ÏüÖ‡D]w:ö§Ž&ó¿×ÖîCÅÖã®î¢¹Úvì‚5…Úý×i_¶ïœ€Áöõ £(—µ!g¦z+ 8:¯Û"Åè9¥0ùvZíÊÁ@„(ò,·q8w®~X·P#nƼvHA.c Ø[º¾žûQàÁ‚g†Z,×1ô<ê]bòU'ÿÿõÕ40Q¢MÜ^ Ä‚y(ÈIŒª>Qzñ|ùÛ‘­üÿûRIJ LùW§°ôA”ìôÆžL½¦Ò˜a=ÖkqPäÖ–f÷þ žEÍvꩆνí镌Söê•wò¥î™|̳¦º9ýé $€ HÁ òV ?Ž”âˆ‚>.ñŽFfÚ6â•$Ôdq¹”0ˆT«"UQ˜tÏMk¸~ª/Æ]B‘sÝÿr 7.æ‡U2 HaNÕÿÝÿ÷9ý '&HDÞ`MHÁPÜÔþäèΊOíyÝ8µ.·†`mí$Z¨èä5¾A^Cì&$%¡·doðÿûRľ‚‰ä©R°tÁHëTó"ëOM?·û·Ú¢!J­ [KÿíîdVðl:ʪhN@@CIåÙ ¡üvš †òââVjbWiB[‰^*¦€’¹û*Cü@gøùþ£ïþOn›Ž©¿ý¾¬ßJœÿÿô_úíüïêä\¥ÉÀ–¥*½É[ê‘7*æ)M”yìù,¹=N‚DÄ¢!\¯ò#ü6ßH—×Î£ŠžknîZAJ¤©N…™êóÔ4VFlÌ·‰Ç-Ÿïm.徎CÑëÿûRÄË€ @©e¦vI”ëhó"õ[½ ×oq)ƒ“n^ªÆ˜Å<,'&3Ò-rb+¦ï†M¢Ä&–¶.¿7µ£ªüx_ü>7«žk”.ŠjÔà$¼åÏ&G§ª¯î¢—FjãèkkBîÚõ(}H‰lŒ³ YIo¹7:¿zª)B2rfšD¶QÂ% Ä%(ð‘›“-õ‚|œ£lK¶%žMxðcÞìCLÒ5›ºŸ7ÜÎû5²ô‘èè? &jj€LIOÊ¢mó³~¯þƒýõîóÅ‘4#óÆ¢ÿûRÄ× T÷i&,­1J«¬4ôLCÍ·kw7õ €0 ¤î ’z#F¹ç9Ôª;ÁýG÷W'žñ,.i£ð b0(&ÒJÌɺ@”ú½D´º…qÄ;óß÷ÿVÿWÿ’ÿó¥Mýºî³I†¸s…ïA¿}€¡T”J#hÂ))°V±¥Ôì(FHÓñO ¸²ì )‡ò«Â;Dhì9ÌHIb¡(­^T§a¨àÄ9—fbw<‡¡·nüØ€†=zÀÍ$%F³¸QË ÿûRÄâ T¥cçµvav¡-0Ç©.Y ,oíþ?×ìí±” Nå®iñëþiý¼Ö¥+|ú|ÿ¿¯mæÚóoxÖ^V<;D’±ó¼bÏöùÿÿ¯ÿÿÿÞb¬òÇ¥«|â%¾à†º ÀV@  êÅ$2šx=—Ü‹ÃTìÝ"O®­nÁ«Bê´hꨥWfòŠ3bZ¡ŽéH·ÜqÏ]K)±: ç4(¢?9õ*et†S\HÅ9P- ÇcöÇ8†n@D¥ÿûRÄç€ Äãk§¨X±z+jöž VfBáaBŠqTÿÏ|Dа_k<ø2ƒeçåî¦ °Mæ*›¦ÍÿþUJè¹]oFƒÊ˜fIÆ«ý¾õ$’eF“ÂÆO†"$*H‹…#k#?yzL– ÄF7®”‹S©ú‹Ó™)í¼0Øì[T}çaÑ:—rôÝR¿ùD§‘ÖŽx ) „ÂZ-dñ…ŽÒÀ„¼oà[Àd%õôåªFŸÿý ­ÅwAØF^oïùã² ÿûRÄçqy_¹—€ FŒ¬·°°Z˜&Jë–üË lB;˜Û¿Kˆ,ËžI»Y²¢r?ûƒ²¸Úh¨g£t±(Gq2ŽÒò:2zLz0ïñàÌP¦î"Õ[Ì‹=Ö$êäZao¼„.šº¢5_û;ÑhÕIÕôÐÒz·þýÿ#ë^”»SÚŸ“Èê#R G#, €:`ò/èÀºH‰«ôÁ„0rÌš=nuÖÕ}ši*s ³ÇMdôʺ±ÿ¾ÖDÛ%ÖüÚw¡lßvJiÁÑÿûRÄÊ @}uç°a¡G•.´ö<Ñ•[OíÆÈõ =8õ’ÑEzûµdA£ÀD"R™¹ )c?0ÃS ˜ǰŠNÔ‚s¹KL76èXòa„ÔÛfa¾"±Œ±&®UFëßDxµ¼rÓµÍ_×—N1ý¿ÿlcâOØÌÙ ýu9¾Q’J’öns3•‰fŠŠµÒA?N¶å÷'÷Íú9Õ>í2;³½c¿ôÕqŒ&‹ôE gò?ÿûRÄÖ HaL0ÂÁe«n0ó–R#`–& -œq´V²=€®†¶‘j¹+ ›õ<ä(UW>`JƒÑIm(ÄrëâXj‚BiIÓxx9‘£æ,s½=¾'X9ˆI¢òŠs¥Ÿ *éÍôg÷’”jÜE«§*Ù©±–î„ÙZo˜îæÄíÞw¦LÉål‡ïÑ¿0³è†âŽ£Ký£î˜Û„Õ®ý³¿g £ÉõRgË~wïÆÖ3fYO—ÜÀ†PpÏTÖ@ä0îÿûRÄÞ é5o‡œS1T¦ké†¸ŽŠÇˆÈ‰Âîw( c‘Tƒ#É1Ä %¸X3FfO²?XµI_&ÆÓ”mrºGšŠDëM¤ŽQ×cÛÓ_§RI,C5˜>*0åé»NkM^Ò픵/êÊMæf_PœÎ)Zzî÷eº“Mq×ê¶ÛZPáEú÷ëüÌÌÓ¶}fffgWÝÓF¸—ÿÿÿØj0ˆ` ¸ž™*¸FZ!ÔŒq‰iNÜ$¬bÉâTˆÄuI3âo~îD±9X\èÿûRÄå€ u3cTò€´¨j«0ð±ÈL¸À|ÂáÑÊž:‚v4gW5hy²ª³ß?Ù°²Õ!d›¡ØÉ"ù‡èÙm ¢Þnœ¦iÕËr™ž•_^¦ KÉnYTZ\­TŠ®x˜Ó ÏH’ ¥WÐUÔ·´Õg…H,îUi =Ù+’gêý bma‡ÀÖólï –äðí²M ¿%’ßBͽ?CU^äÍ!Ï“·mëÔ°Æ5¹xd$`Pè-R À •F÷C±<¶ÒwªÖdºÃš$1ØdïoWG*!ÄhêÚíq1DÜjGå÷C̽Ìúi‹×“œÝoÿ¨…1ôïARH•jËpãeÒàŸ™UÆ,-ei4 ‰Ú4^â®W©Ò%®Eál?¥*g‡A:ˆ ÚU+ÿ`«Íûš­+V‰g¾ÙÆù“¯ÐïÿÉýIͽ)Tª Q mÁĵg™ÿÿûRĬŠLuc,0ÂÁGŽ«1–¸qœÆƒt®ØÚ°ª!ºÙŠnšÂËËòñƒ (ëœî}õsšÔò¿ÒaýÆÂZl–š½…’Šžì±êÿ²•þ¾¤ÞuÊ{Ý3>¦ vÛk‡Z‰ï¢ãSHd2h˜r<þFft¸y}:•°fÌÜÕotÔD.·Ä±5¼½|€Ñ?_ÃnŸÿÃ0à4±Tm¦9¸·Á2‹É††õlæŽ0¿ÍêØ>²aÂxìïâi“u?S $¸ú -HïÕþï®)ñúËÓW8Û¦¯ÿÿÿÿÿÿÿãá`þ6&‚H¨’+¹jCS([E(”åBÿûRÄÌ ô©UL0¥Eì¤ô•6 _4úWJ/ºãaÉdÔ^ÍÀQp¸@"å{Y••VÛÜÒnŽw¨0 Ç,\cB—«‘SlŠ/§ãª½›û‹«Í;¼Q¨S·bœ]Æ+aI)Ò4"¾)Um «¡ó(n 1!†T ¤L¥"xº—Ôð®†qØ)œ´êâš•ž¬“'î®Å•½¢89% fH˜TkNƒS-åŸÚ£Ò²ÿ@€K À&Ù‚ÌŠ´Cˆ  …qœpj+Ök§–öçœIÿûRÄÙ€ pçWU„ \3,ë0°9TgºXÝ(Ã{h÷õzu(Í=‘õ‡IѳÅÓyWx«â¤@$‡´…ÐèqŒþÊ€ìÙ Ñrƒ(ÑIá¾eHGzfC?2 ‘¦¯5ĬæE%;HÍFŸ´ª"–GÉ¢ú­sFu#1ŒÊ¢…ºsWÈ­$[cEZÊXtXÅr?ÃEŸ¦«D‚eÈ# 9F¾ýà oä3 ¶n~™ä/p“X/÷UäcFB:K‚߸—[ úÞŸiûýx©¶ÿûRÄ Ls]½‚€A–éÁ´”ø/EÊv^šj<Õ¤\죉½Bͳÿ[*¨UÌ€RÍhÒƒúΖcVŸ€0t (~MBåÞð$7ÇFÎiñª f¢¦;Ýcgb3YæÉW¾wF¶´£LÎÍ;m[)Qß}Å^#þå?£Zûµ{½²*†¦Ö„fE"€b©àÀÙ³O¼;'g¯$NG+…JÞxg1Þ5í2R뎑`þhäáðþ`Á9€ŽVñØftÝÄc٣ెçê—QàD!>8{ª7;]½Îj:ÇaçœaÇßßõÏó~V?Î|–x˜æÿÿÿÿÿÿù ü><ÓáDõ*NúÛ瘂é"‡ vX]X^ ËŠÝhæÕgsÌ$nXаª-#ßQÂP»ÇÉÞùj°)&^¹Zd™xçS$^cÔQ¯ŠX‡Â€Èhx°”4~ußþx«‘ܺª* l RZ_—GÇ1×»/D%lÍ”>%_5 ÿûRÄÚ XÃ[¬°CáJ™*ö°pà£%#Eb.‚®Ec|Ìó"‘¼~桇w3Zgêçý³¬8^ƒaqù®—ËüüP_O}Z?ÿhÅ’µ­#ê™P]Ô^UM–ç”aqľã°Õ‘?¼Yz2ÿÑ eàà|m9únö¥KG¨!H&FãVµS´à±wˆ ååêQõ:#ÑÐ5G=³wyÏôÕIàkQðËeÝ»[ÈO«Ô‘÷¶H‰f¼lŽã5Ý.ÉêEd¥V†vÉs¸ªþ¿8ÊÿûRÄå]•WY•€ Q”­³˜`([Þ ’<(㚬¦Ï,Dª¬óËÙ4¢2’Þ²r Ãì+X*†>caai{ ­Šmʨ™°ÅÇ„ŽóêùPA»ójŸmÇÑbeX¨°V‘Wް´Ó›htn{O¿ÿØ/^Æ=FÓ`0ŠÞI ”PçQÃÏ<–?Z‡¯ÏC2i™w%¶‚ˆaw¥Íš×\ú땉¦^Y\Ù¤P!ïíÖÎgZ±@r÷Ûn×úJšT½,Èöw§¶çY~«ÿûRÄË Íg§ŒQAJ,‡¤xñ±ÉB¯ÿ ¬(‚€)9xçDÔÕ®*G³ ×:ªY¬Š5n¢hi ¨Ø{M©GºUΞ\;œb£v}×t1Í_§IalûgXÀ\Gb Ô,¢‹:Ý¢Øñߺ‚¦ûÏ,çï{UPˆ MÑ¢s1Ê« ÃO´¨ŒÄkç1jÍÒ©ZÃr(·ä r0K38.ŒEð|O¨ã6Ô›)J õ©3×õîϺ«åÆ`ÁáÒC‹rUKì Í/ÿûRÄ× @gáp?n4ñP n;ÔŸ AOY¸å¬¨›f=*i/\9&Áú‰È¤>‡óO+ ¾Ú´¸þú{gšŠÖ ¤zlÅ*s†T·™ÜÌ-jµõQ1 E# èGGTAc Z?âßþ_ù“Ñâl9µ¡?a¯’ G"ª2[‰:Pµ 'EBôgDœT©$!X¦Ð–-V¥ÕÜÉ+®z¼éhÏÍÎD²>ü¨„»ª]óZRP‹šGöÓ?ÏoFõÍpoZ êŠ 9é…CBÿûRÄè Ù_g¬´Qz¬u‡¨xÊý’n½t³ ‹‹„€vK¸j¢j( ŠÚu¢ðÇ¥V…©š„hË-Àû¹´ßSÔœ{?»ã01QÅ÷rÙVÊ‚)Õ¨FJ2]n²¶Ýî„Ï‹jã}M”TYÍnÄx§«¯RX„€EÁT“u¨ËJgHq-Y‚:ýØÆ)Câ±™ ´¡Ê^ˆLi1$ê²Jœw[L³VÿbcUKìŠÉcŸ°ÝÚÉ`|µ´öóŽ‡Ê²"y¡ô 1¶HÿûRÄç ¸½]LŽ}%¬u…•x.@ªÒè2`±']ÜvÖÍyä’ù˜j Ê9œFËëI'@“#æuCèx© ®é)é-æîN kmG©YÕ ¢R2/²ëq"ª³½}*ßߦkwÿÍ¢nÖÙJÅÍ¿ì3%—ège 4¡É  D¨LXÓG‘y`ÚåO º+k óU¡Æ…QP)F¥0¦sÒÛcƒ}•§†È~›NE¶¡T½g7Ÿs4æ¡ }^‘îZÉÚ•þKvöFËSȸ¾XâÿûRÄæ€ ˜éy¦)±kltõ– Ì)¯¡æEA–!@&“€ÄŠh.B߇dc»xé#Ôï¬eÃU&¡!•Eª*Ü6²Ó†£³ÍQÿó´+':"Ô?ݧRyÓµîÏç+1u×ûÙ•t&¾Íe;º®ŸtPxm…\ÒH@“n –lòÆšsÌïÁ•å9Cøv}¡oçõUž-ßÝ f,FVqØb&)©(;2«tsN¬ÖÒCž­£ü÷“Õ ¿}Q…%˜ô=ý}Ûoÿ‘Ò—ÿûRÄè€ ü½_L=CÑw(«µ–– ;b×/Q‘!Ç%„&P„¶RŠƒ…B‡¾RGH;ezÜseZr"Tx»£Ÿ£IMt¹yòŒq1Ù½#Z§¢5þrüﲪ!8*IÔéOžÛìs=÷<ÅýÍ.4ƒw¢ß¦T*²Ášì¨ƒ"ïS;:Óp”³NÀ˜´Ä€'‹”r)Åï9ÑÕÉêè…K)CÑ’¥èêÈýÑêh9žaüïšRÚ3¿9™¨&9ÚÛùe~Âz—ÿûRÄç€ ÈÙk§™Rq{ki…ŠjVÿcšŠ,ÖM¥Jxa³HÞ«Ñ¶Ë >i"ÕO”,ß±*Df7=0ä-æùÖ‹¦û,NÜ5¥1L3¤M*‰ìÍr—5ô]{þÿTª‚àMor»¿­{®Òg§_ÿêÄ GÉ_VìaÁëF­Ã*cÀ³BÁé¯7jbCEv²Ð²²Û 8Ϊã’FPCÞ¥¹¬¶¶û¢±Œº¿ŠßÞý-åêÁJþífàu?mwÿûRÄç [L‰R‘u'li‡¨:àã_X¶ín ³“ÿ¦Ì…1xŠÊq'2×FªÊàÕ¬uN2 ò³2oR/¼ä_å„s<Gfîí1|WJÎÎNdz²L1Í6·ç1¸/ª]šçúÿùž—·z˜Jâlû¶íïEÈ®@£ÆÔ„€Ë™Ü’ •;”•æcü‚¡…8¸‘ÄÕFz¯}9Z¸[i¼WÑ&uÆlÌôÔ8²,LÿýW5™ 1­œêJCªmþÊØ]µxëÿûPÄç€ ¼Ó[¬<ãÁlšj%¥žX²«}M°`×VúAn4l‰8ÚB(´ðNCH]›anèXj$§’•I &WÀ¡9U—¼GëöÀ€ÆGd’Ç¡ÎÝ <èU®iŸ3“9žù¯è'¬—SÿÿÿÿYgφ©­Ü^m·½©#Ј Å!Ƕ&HÓô½ 'XDkâ‡äp"<(6U R÷Š*ÔÿÿûË×V²–œÿÙ· ˆ%|rèÔ€C ¢à©HC×mj„RU™¬i@´¦»eÁ™g ¿»Š€¶–"¥™¨Ž9›L±|¿«f£NŸÏ´ƒ¬ ž¼U(]cH™¡¦LóáZÿûRÄÝ€ äku§½ñH‹ìôò®D¿Øß{ù PVIÆI ¹Ûuõ”µ» X²Œº±8†"P1©.x„°åÁ†Û¦Ì{Ÿ8J Î}XÙ®rîšrYJ=úñ!-hª÷“Ú\©×Ù\’­Mö'\nq§ó:iQ2Ò§+e†ƒäÔâäsˆXÚeEQ?!î´¤TíW»7#÷`ÅG>s½'«¤†‚¯‰-öDúÅ]’Hý P.W/66q¾êSø6·ŸÿûRÄê€ ˆg[­=ƒán‹îtð¾Æž$#cT¶Lœ 0£&›Œ€K=UɆÚsyÆÍhsâô—hà¸ÓÑO¶Ïºç ßjŸ"Û÷›”bd9¶3d¤–M& $ Q¤eevŠ©Hjd‘]?m~§×_I„!ên¥0ÄÎ?Y€@É·jWkr6£ ľû 2N“ð†ãF$8²(e­·t?K@bœ áFê0üS¢Š5ÛánV4(5Ô¬n‰M×4-îp¢YIºfÑëïþ7hðfÿûRÄè€ £kç´t¡yì}†)4Üù®u¼ïW¯–š¥qH±ýwõ­bÛ¥3÷ñŸ}j™ÇÎíàÇ’5a}Ó­þëñz_ÓWô÷߃­oýîØÖoL|nÚùø¬ÙŸÒ%³xõj¤,‚!œÎ…âøB£¨zÄwVúXŠÅN°+%giáà}Ùó{bÁ€žÇ¼©N±¶.¥ÅpmB‚„­>Æw™±s!°ÿR63Ä´¿Óßé‘BÍÏckׯï}[ÿ<­pÎöO‹oÿûRÄç Ø_c§•r¡{ªê°ï9‰UÊ‘_h²Z|_ügÿóóý>&‹Gñ«mâZÃÀKju²\£²eÿ÷.z@,%2øÂö¦|í°9TŠýN¾6¶ž$`ºÄsF§wSÍ]4®ÔVî´œÈ¿Øæ¤’³U¯tO 2Ò\Õ0±sî¡|Qª×³ÿÛŠ««§ÿï‰KÔÊ(þmÔ²¸%¥ô©0/á8¶}¾¶¥˜±†<¤U|ýÌhQ-Nõ=[ ê' Šƒf†ö¼˜»“ÿûRÄæM…]¹‡€ f*«#0ðnæ©È^-N´hòW)Žå¦Þš  À°é¤Ú­Cáyi‘ŠÔ=Åš cZÒ@N¥•íó^$ërHVƒGKžUJyÛaV5Sžÿû(4¯.bZk—Œ»gím5,BJ#·6Z€LLG#î:ôcC‰wáÔ³C>£Ò¹™‹Gz€ŒŠÎ X}Š kx EEÚñ0EZú˜y]ls“Wú¿¸“!8†Sô5u- âÿûRĦJ@U݆€F*%†! §d¦¦-´h)¶lêyS­ZV¯ìÇF* ”Z™b¾SúD!¦¢D‡Rþµ™™5š),\cV”0°ð[°Ïý3X“þÏÍ-º.®Žñà´%=ZÜl€„áp8Ö³©¬ä§FX‹!"°±ÀÉ`GWÇ2ãÜ´¼ÒWS ´O×€WˆõEhJøpÝÞ¤ú‘”bÿYT¦’©ˆ›CRí5Ú¥ )&Üi@Åm8–Ìz1 †—¹wÃr ˆÿûRIJ økQ'¤MN‹ë4ô¡#ÖlªÀ…ÃU[¿‡_K¥mtµø‡cD ds«`ã­kż>.fhÚ‚~²FUAµÕÿù° ‰M@ŒÁ;šJ eáÝU‡DX90?`A*7G³‡iÌæÒ‚Ô(Çö&ÖnŽ:­5óò€,º%¡!ìYgNüSŸ¨LlU2t5ù³cÀš‘œË¨œ«¢©d‚×Aš $Å+”Dl‰qݨ%ûpó\Ø%S®$¶%Ÿ!‡|aÖG»»O™Í¿Lw¦ÏÛýFº”‰Š8"qЦä0ZǤƵSM'﹋ÿûRÄë ÑwUŒ°GÉy ê!¦ Aùõ’c7\ÊÑ•r» ] ÂÝa™À M°ñ ew,Yÿd0Ÿ©-\}A«Mz @‚ã3'îÆ(Ñh»¶:fŠ\(1ÉhµºÍ”¿«þŸRüƒ"ªKäŸz ¤\rWE5t„W$‘$Í_BòvFB•C ¾ôäS22›ä \Û^ᨥ‰ZÆŸ:¯ÅéIéuÌ\ÐÅp”e¸ìL<€2¦]NM/aº߈²¶¨ ìÔŸíœíÿûRÄæ€ ke硇ktõŽ\)¿y¿»[À%6œJím‘ëv¶Ù"Aâª|01H2G%‘uå~È>Äi„ BSuY Þì CÙètˉyÕžZ 4Ô--WR6GR‹GW2·ÒOËsRªñ®iÕÇÆ'œ·!¢ÐÓHb³6£2ÝNXÉý§­•Ü*PÖÆ[Mcêæ¸öÎþš3SW&*ÙÕºõ°»–ÏÏòÇ?¯½eÛË>sÿí¼õ̹û¸TÐÿnýJ!…QU‚P;¤»ÿûRÄç ÜO,0Éxktö |ÆÉPõ’J]U°½BTÈë9 èTPÈRÔ €€?#'ÉÇGAåIdÓRIClù™¹=g‡‡D%çz@ °Ü´ù¶µëÄîå…ådóѼ’o¸ù­ÆIè®È{⵿ÿÿq á2O§a÷Õß3ßmýÎñ«&çoD¾ Ì4ÚoùŸÿPLâJš2›Ešó’Ï ù}òlsX#¦ ·ŠaDú4جõ.oõ¬V™‡®Î)þ±#'ÿûRÄæ€ h_[ô÷€"œ¦k·1€d*–2.xe eÿ¿ò7Ùy›¿Gjº?ý&Yêõµ×eцøµÜÆ,sÕ~—ç]åI®šG:¬eÙz–ZžÏZò‡×ëP´û(¾‘-µbhá7…­9²bݲEnA¦Y_ÿþ™oÜ€€™+X©Ë$ÊóÓ‹Rƒ Idï•€™ƒ-¥KtÞ­«F1Î{¡òHÎw¯·:}Í&QÝ/Ñz?Ó{yd…)úÜVwÿû¿_4­ÿûRÄÕ[Q9…€?(C¶ð¤Ä’Ü  «ª£nÇTÁÐõ¼'Ux|z™µL :]Y 3GYŠÒGŠÔ¯îòȪl×$KœÏM볡Bê1ÚŠ¿uŠ{òKÿmI– Äzr“dˆÅc™úNb¤ßÂŽlGq ƒ‰ i4M„&—wD# úó =ˆ8X©^k.·;rÅêæèîmŒKÙš82¢ˆRÄl"‚©´_S7¥ €¢mmžt°>"pûƒŠ³Kâ6¦³hÿûRÄ®€ ¼³W§¬RáKhõ¦(¸¶°¬ëq·•?M‰_åB›MÜL†ÓÅ‘cÑûvZ¸«È#v=Þ%öýQƒñzóMkL¹p«Ã¢x¸JÙü¨26Ô-8’$‘Š?60k""Ò±ù] s¹Ä‹b£Œ‰½÷?y_kÒÿô‡¬ÆñȦ,æ% Ûÿý÷Š{¿~þ&¡Þ ]¤@… ”±s‰¶ÞªÏ÷{uªR$Š‚"H ‚FU-sir²|Yþ€†§(¶íÿûRļ ̽KL°ãáI—¨µ—œxKêCñÆC¹d8ÔÏ‹a’õÎG…µÂ>¨Î¯U3­çå"/hÆ«9 ÆxÊË»c:³÷ûÓù4¿‹î¢f›áµÙüõJ¸A‰{ýçxvÄ:Zšùů¬îßÿÿ;øÕñï}ýãÿÿÿÿÿÿø”¾#8Tàz[°}×£NÌ‹ƒ Š,¤» OF<”.”O+Ä’iØ]F”|'Ì%`˜á.P&›ŽóÂøò*ba ľdãÔØŠ³4Ò2ÿûRÄÉ€ 4¿C¬˜²Av+vžð%Í“Lܼ\>~lÊ27Q»"‹š5”‚ o4Oe$y76En‚Ö¤®ºÐj¨jw6NbŸîÌªËæôèAÐ5d¼“̨ÿÿÖ™¢Ý·Ô‚;L‚¢ mÇ¿þÿ¬ÕdHÉI¶ÜŽ4ÚD£Q¢Q¾e°•«¹ÊF%3a§¸Óuæ¦Õ¶ügOœÕƒªp~]ë,û‡Y¶y¦Ø1'I¹B¦0ò%`DÃ×¶úݾu˜ú{ˆ1+Hòý?Õ1L@“?5¤ðw¿œß6ÿûRÄË€ùgS™‡€k°ëW4Л8÷ø¿kg§… sÏéŸO¿ãËœoĉJ¼g5Æbü}ïtÿÿÿßñ÷¯›Ä§yF…x$SCF„쉶æÔí D)Èß–bàg™NJæ1-žÊˆFÍ‚§£ä}¦lãã“"Ë»5WrŽFªë”™•଄E–)ÈÇ•,PMѨYv¤oB¿ÿcÇa©“½êE°ÛnLeÈT¹gj3¶*±óh++¡[Ë!¸ª7™^ÿ»ò˜i÷uÿûRÄEu{¹‡€anóž^³á„”&I"h/º¦¸GÐìY%tWd¶‡iºÎ›?ž•ECŒ#9uO*Ië‹qüùt[›O€×³+qRᕊǫ¨À‘^Ï®í`›h×íl²¡‹….zAF¯¶QTCÆTÎî,kMCÿº¥W·ÿïÛeYÖÆäº˜DT"¹ÓåþÙ&ÛŒ2\íQ”-—{j&ÌWËY¥Žb7p4»¯Õ¹dü&XËK8|ìXІ´¡µ0jÈÿûRÄt€ Àqe§¤mAB‹kñ—°4úí°§ÿûötP‹–ÏH $e•LPÄ Ó˜à°'¡ÛEUÅЃ™!¢:¾ÑMw~Î4önu´2ûSÇd¿8¡Ñ1C¦¬ýVz³+¦s¥lÙ¾û+·ÿð »ÒÚšý©YSÑ·$º'¡:Áð“…§§`¡Ká‰d¥ÇêDÀè•{ý° [¿ó·Á 6ø¼ÊhIJÛß0˹DX\‘à6§ñGWì¿î_¢Î£¼ƒ­½Kë À›ŠP ‚ÉéÿûRÄ Ôm¦<ë1I¬4ö$¼gçþÄ[lÐFÀ9Û¡ÈÓVX¾}kvù´5«Ý´g7ãïÿn¥«˜³ABHªê,Z¦_D8OY/½? ‹uù£C†ð.Õ ”ÕˆƒÇ¡‰¼ðÓÅ“ƒj;¨ÐÑ”¸¶°{Dÿªw ™–^éûôÚ€výjý2_ízÿçPÞîeFÜço=‹>ùyô] D*Hxh²öN*Cê1¹£bÆÆ9ƒí«;µáërÈÓfRÊ:Wúc ô¤—ýËwIµÿûRÄŒ€ ¡y¦ pñFêé— ¸ŽÛ½ÇÜîëÏ$ O•2UJzêsÄŠT нdcÚÊÕÿÿ¸ŠåxÌÔ2 TÝ–ËvÛÝ­` ÚBo¬¨HU"Çf n¿e @ fE¦Î‰ÚDK’Z9Í÷òx=.ŠÈJvR>v'NMa*{„X°êÄÏ[¬7·^XºÕ§Šã& Éï&wwQé>w|ƼLGd~ã$úô¦­¿MfôÆ-·Ú« ¸ïYó[?4Í3ï›ZúÍ7ÿûRÄ™ ÏRí°mACªæ´`[ïQ÷ï>¿ÿ¤8’žã†Z!?BÁÉ É‚9æí¥2'Å Fà!•‡€DcÐz/夯—H“šE…‰™KƒÜ$ç ^qöqÐ奯æc7º\ñI›a†ñÿdâhÿ ” Ä“jwÔK[óÿãpí'âI±(¤ÀwîVÌÿþyÉ ¢g2@ÛÉÆk‘ük?ýÕYJq§öÖZ"ª¸[ÚwiZ|Ä=n#­Ý‹ePÿûRĦ€9?U¹§€Z©ª6°Y¡9ãJVVrL9–0ËiÓ¸% ¼4 E‚¢­3XæÎ¼€£|õL¯ÕÊk$^­/Íÿú=»B4%9"TÉòh8!Ms¡ZRi ؤD„bÛ®š>Á†Év-7_êK1c’–ÜUg¿?]S˜¾Íf½{·o…ÎÀ³ÀlI¨…¿[þïÿ¹»M:q¨ItÿûRÄŽ mI- ´AKnv˜€’wÝ»¿·åÿÿÿÿÿÿunoÉHဓr¿ÿH‰ YÌ#BlLN9¦Ã1K-©b†9K1B<N‹m['R¬­\ä³BKÞý(B9Žúþiau”q4s̓»©\"Ä9ö)ù’­èïÿÓúÝ+]ug—eG$–9Ì0¦K‰qH;Œɱ1F{Mk­!Ò0ÓÛyä´µÉ~1’ÂRPؘ*Y‹Ï„¸™  ?µ›ÕíúÊ‘BvþÿûRÄš€Õua¹„€H믰`ö«RÔÛUò@b‡C4@À^dùŠŠGÂb1Qމ¨Í^#8ÃÕb'ƒÃ„F¬ðj«#¶Q:c$©3â…bºÓ"³RiTLû¿W·OÿµØ¥wkµÏð@lÇWÐ:;ìîz+=îSù·ÖŸÂúÉQ( ìÒë3P†Äu’ÓeˆmÌw€d^60±å†ƒ`å#„’À˺ôÑ}®¯ÿý´T»·d ;Q,2GlúŒr‹Žo+ÿûRÄ„ lwoç¤gaF걦 °‘úN¼TV 8s½t\ªñÕˆõlêò«©P”¬öcîÍçH²¥¯ý@ À©Ä±Ã¯«{ƒ>¦Uÿú(N¢.GZCŒd$Ú‰–BƒV´,6^' ZÕ»CJQdØJÐ/ $b3Zù$£hŠs=iÂC={¯ü¦+ý‡´à“aÐPªùýHv(¶ Ï>Û—ÿôV¾üð3%‰§%ŽÒ  $±Ds¸L¯Ñ@çÖ¬1ˆ•rGQÀÙv*CJd\#=ÿûRÄ€ TsU,˜lÁDm<ô\s.†Œ,*·&mŠ[H*9ákÓÍ0æšë[“Œñíô,¯)µg¾Íu@º<2ßbˆÕ+t'Ùe(P€¢C­¹g:ˆGõ›VSÉ+òÏyÈ^Á*º„AƒÍr'Š%$œUHeaäði‚Òp¼Ëš‡øÄ<Öý€T¦5<&Gy„Gcç‡QùqÊ¥çWá‹ÇqtçƒÞš4æM˧ۧ‰ìßáƒÇ…cRÊ ú¨·¥[Å J aÔäØò4#NÁx-hl8¹ÜX^Å‚^PFŠ‚Õ¿´hñoYqòµ·Q£R·õ®éºâ4o îà½ù‹OÿÿXÿÿÿûRÄ´€ \©o´Å€2f'ê'4Àþ~2ö¯i‹_ÿþÿÿÿÿÿÿÿÆ5_%íþ6öB>OwФ$U#|Ї’ôh†^*Fo®m aˆ­›í±|mSV+Yë.•ýw&+žp=ßNúwCʽ3l5y¡‰Æ©îlË…vU&—â©Àf¨R»¿÷¯Ãÿ©0‚›åŽÛTDäpþŸÃzŒSÄ–ôHLᦢìÃFÅ«Ikù@J¯D{gv1u·bp`#Ð)ʘéÕ[’멺QM?ìܧ©Ô6ÿûRÄœ5Y]¹—€ Li´À.£¯±‹KÒ#IkJ4£ôjžoãÀ)€xp4)FX¨–]e?Hõ>_àì§ÂX8* ExĤsÚ}mY ¦u»ý:ooø²+éšôK)5it{w¶%Ðä4d5f"¤,24¨å0.Øh7¬¶jÄ.íÄ‚O°š€­¡€ÀMLhâšíbÑ-ŽÖÅ0¨ú}š>Áf½¿Ñ¬_.(ݱ  ƒ³ |Ì€ðÿûR〠`M, MA7 ©e”Œð–;œ™åMy¿»à%xWT=µõ±IÁ…MÅàÉåuc•PôQUäs–4ÑãÆZ¯ÿÿÿAÉžJëk©CJ €LÑkÊY61¨4´o£¹#,ñ*a@AÈÞvö¸¡.΂g’3¿Ï* [ÞɈ>—_(<Áð"”"\Nô£âàùÿ§ÿýï8Ñň ç’ß›'€­FÐåš‘™TÚ‹‹V2n/u!“[Í}ó±ôíÿûRÄ‘ 8?_ç±$áHk4ÄlðÆýŒûɘ ±¡¼L—‡B ¨"PjÃcâi ™‡m,{í³üJÕ¯IµÒÞè.'‰jÍÀاø|š¨G쵋V±pB©´–i‰$Ьq¸),(L:±G¤6l"®B5“bħ«¶wLÚ5|­OêߥŸž­@D hrSì²62ú)!Ì'A÷I[Ã%)Ph-C_ó eäQج%‘µrÇÆña¡Î<,tlTˆ±3þÏÿûRÄJ€ySŒ$kF*á—˜xÈTL-ÿÿô)Ÿ­Â;u-ªPn‘v¯‹‡ø‰}/F¾õ<¡ã1-ÆÏØ’üWšYiÔ(D€¬|€yŸؤl2º' ¥b™PÍIhåáZÖx4á8ÑŽ„, "d H â@¨ðO4Bª1ê£ÔJDHÓA!£ŽlÜ‹EkðÑ4h¢GôþĈëˆÄ5*ñykÜí²Å”S¶\n1O)\¥Z“5¨*¿§œÙ„Ên|ä*ÆÿûRĨ LQg¬1áM‰­}†$ëM úè"1j³Ù!ØÛ$©Xª¤©š·Òð4gbX3Ì»ÎäÙ³F`01ÐC‡e”›~Eó¹b'•gâ/Ò€ äqǰ³$É€¨|•êŸemgxíŒ'Ö0r‘m"Œ÷BÔ~tJpTHPaÃd“e\H½ [nq/ý†ÍØÌ¡ tw„ :À6"Š*œàRc9Üaô ­•òiµhù(±‘KžyÀ»ø_Š1®æ]zñUÿûRijIƒ^L°ÍéJ—«U¤(5±ñh¡îŠ(Š ¸(Z­â| ïW6ßJ|±ÿïôÓ<Àš«Á¯ÎvìÉ‘öL×aP7o_}¹Lž DT&n±%“)¬ÎXs:”làG—´tJ0XðùBS#ŠI8ú]q5 ‘ú]ã³o{E*E©§Gý5dÍý:h™DkJÖ+@寋)aÚÔŽkGŠ`pO¼.«ŒT7¼†€ÿ¼‘Öìó|cˆ[VGЉ¹öÌ9¨ˆƒª".é–!mrÿûRÄš€ (IU =h:ê­·­ž…SþŸ³˜ 媲K-° W Ê”’òâshÑ+) ÿÇå-mWljÿc¥y‘®ÌÁ½¾zcèZFípe]Œ‘S‹ ˜p„D²Í$MkEçXF ÿÿÿÿVû«·} Q»¿–KÓC]× æêj¯7Öð=E#mÀAðfI=›Bwi3HíI{¥µ‹$™[VQh+LÎ×â@@‘b''ÞªEÐ-,ö‹±“63þ_wû?ìévժõ·#ŒÏ…ª¿¡¸ÿûRĬ€ DqS-,®G‘éå§¡(ù îÑ••ÜA¢}Ur  ÝÓv3ºŸk¦7!jù$É ƒEZ@¸…Û±O:>X ð^1õ!ÉÿÿÿÿÿbBµÛ5ŽKdb) Âø}í ôÚ.é©ÑåžÖÈ37`°Ï·žƒ»~æöBÿ× 0ç×ǵªŸ¤tUR…-Ç) ݬçûÿ÷hö¦õtU=6Ûm®WµºË]‚ƒ(j>˜Ê~@ù$c¢ñ_@(¢Ö]o0ÿKÒžfTŸÿûRĸ€ ,s§±qO,1„•>?”í­‘X^ÀÓ Ò^‰¦= ZM:¹hy½{||cržù‘miÔG×··ñ&¾ïŠ1½ÌvU§Wßþ¸Öw½|âšÖ!Cyô÷oþ¾³½Óÿéõ©±‘–4ºÏøÿÿÿÿÿÿÿÿÀ¤hµ¦áf‘ag2IH.HÕÓqù»ËÞ¼;‡åQ.Jy”]ŠR|‰Õ@PþFX@JØ ˆ©C„AãAÒ yi蘫XËO2i¯û¹ßýüp”5QÙQŒÿûPÄÀ Èoi¬%ñ?’.6ž°m%U0@¡I 9€^ %ÕMFWì2^ÊiÒ@ðf(¸z÷œ&•¬Í&‡ÇkUb ª¡Q@ºš€.Yv«¼9Ž'­/c”õ¡úÿ¾SZÙ5jWHD„I.ÖATãŒìEвHØš¨(H-¨hÜ<,—;°Ÿ‚ö6çùÂ߆Eô ‘\ÄçÈò†aq€gnÑUd¹°•ߎÿúÒ‡"³Äì8ü8À0Ú‰ãi"Jh©ùÖÿûRÄÑ€1w{¹‡C*k°`|x°œH/‘¦ÑËíì‡î¾®ÿáMÓmðìØ®ª b¦çÝ/qh½•!£‡FRƒ±¨-¡ (A1ÿþÚ¿éÖ…“cmý›l„ 0Hëx‘ˆGªÝÂ, /o\±Ë P¡Ûb¹’¹ìGÜ×´rt¯è2bÏ\h•AUY.—3GTç¿§W«[íoùÅ¡=+Õ77ÍÄÙ‰jåút“S±ANízBÉ™ha;}Ë…VçÃ-œÿûRĺ `CO­á áM®|ö æ9Î ¥¦—pH¬xIAKÎ .8^SúµVÝYD rT rçïû’Â[ŽmcMÂù"¬ÔÚÕ0Ya8ü@FC´­Œé.A+à¿&=Æ0a:Á4P¨äwÅ”®Æ9ëi<ÞO~0Äò6ÜÊ%~Ýñ›?Ùè«•WM¶= ×$òÙ,‚©C‡‡n&âêô‡)Ñt.GyÒ?ÒÌßJ?íÖSeM©jÄ¥Ú`r^A£áUÖ…“ÅyÿûRÄÄ€ \ƒ]¬0Á±?jµ¦ ¬ÌY’ýã?ÿâBmÒ8ÆYZqHÚ}|„@Ôúª#;2‚êv9ÕP‰îFwL†s‹)Gè:mϾé´AÛ»&89w{k£­` *Ž‚hçá€Ä àúeÞÐÿÿÿó8õSà@¬¦ àÉr¬™3do#«J£•Ö†å2Þ|G…±Ð–¼±ƒ¸zZ Dn,Ñú"¥Ç‚ÁËžròDG§æ¢.˜„G‰*ODz¹áTKÿûRÄÑ <“Y¬0caK’*5¦ ¸:$! I‰Sð¾sñ¶v|~úJ/}b"çph8&ì¦<¯ß·´ÌÉ¿Ë_ jmu7H:b¤ãÛ[9 @k?ÿ`+û#ƒ³q¡ZÔìs |6éж}°‘âhê&à19–+!æÝÓ'W²R,÷•ŽïïÎm5ÛEÔ•ŽÖG¶ÉÒ]ÆíLõ› epž–¡ßK ÍsÊ-“ {AÒÖºmŠÀ¥LˆR‰Ð:ʸpm-fÿ¹ôs> 1krÉÔÆ9¥¯b…ÁŽ^û`’H"›Ãh|ˆ–¸Ä¦|`\…“T£±ñbeRU&rF˜ŠÁÓÎ ‰C`P–*é—ž×ìÄ«íÅÞ•˜ôþÏô4ˆö±‹^2†Y+<LrN;ƒ3‘ä’$ÑsdÕ‚ƒ§Œ¡ XíðjÿûRÄã€MT¬0Ñɯ",%†Pì’\%Ž´U¤o¨ìÉeõW­,ù¿L.ó 6Ñ0‰CÕ '’¹ÝN+ûÈú¿ìâ›\F$ˆ© #CŽ©aÁc‚s‚Ø(u‰«$ß$?6neEtB&TH tÙ`(ÿûRÄ ˆ]e§°cNŠ­4ö t S˜ð…*ÑcÛpýÊO^Ç©µ¡÷ûýw¡](Uý±U;8вPÍqCPÆ *JÏÉu×Pàƒ`ÃâêCI—Hj•¨«Í¥„ͱâ%˜5a]"·ÕQÀé¡§ ÊÓöm¦Îhã—ï ª)@16Î…ä.õ÷¢(ÁåÄHÔ$4t*U¬çKYtâÊÓ5aTJÕÑx ÕSAqqÆHeµ)•söwÐþJÿÛÞÇ3¯éº&Ž õÿûRÄÌ U 0ÃA‘ë1† x™ðÈdLJKº óq`®y’ÎPX褜™¦ÕšpË)âb­Li®t9ñ4¦)ÕY¾ Š’›“†9Ûmo‹XËû3%Í l|Ƽe3šÌ´¾ß¾=cmÿûFE?µáÄ’ù´! Ãô±8ÓdÚ @E9~;[Χ5jy¸ð4œU”2ºÝEèŠ4íb岚cNŒšœ'ÅO .>ø4q€@F’ÃÎIÜþ'ÐL(áb]è93äª þ€ D¨YmB„Ÿ÷r©²ÝÆüL  I µõ05Y;'~Ý ˜ô. ìN8‚!ç: pM†Æìt%±é%ê—q$_f>ýD÷Pç±r£êϧþ€¿åñ´VíôÜ¥{ì½|÷¯¶iw! $ÑÀµâ¯K ý!% Q\õQñY\D[”ßc´±ï«;¯1qÌ¥"Q®ÍÒÕ"¥YÜí¯KïZÿûRÄÄ ˆqa½“Kˆ*)§˜yñÙ/Óåuÿþ_;¢¸!"tZ>˜¯Æ âh£rMÇÝš}iùBØžzŽÚ‡»Ye¬ò ª÷oaÊ¼Ü Èæ»¯C»!YjìÚ'öM×ä+þéZÙþÛ¡­¹„ÈîrÐ@¤L.ÀD•oSun€`g¥˜ÁÎQéÖèÇó`ØJŒÂ"ÎM„᣶¾1;ŸccmÆRrôzÄ4µ-›¢‘0Æ'aV±‡9 QÇ.ª~í2AÿûRÄÎ |K-¼aÁG›h…Ä ð ›Va¯Œô<tB4ŠQ§ã®­(AHô @REÇe"ÜJϽ‡Ñ¨Ò‘ ôÎ)B]ð_±æÿèæX3BE3G´ÌøYwo Êµ…¯"{„ÛYñSçëwú?ìä÷V¥°é×ZŒŠûú¹}Ûî¿™Òkù˜ê&ok½½öùû»þ Ÿ¥…I0kÈ’&ÏÐÿÿ¨$^šÖevDEªŽÒJ}“ez¿Uk4WNGô9Æ+ù”+úøMƒ©G¼:±´¼8~SC Ȫ!ÆŸIÝã_lcÿÿôš¬‘F±èþË½Š§ZõkÒ¸ÚD–¥,!+ñ#çµß*À~=qÒr»GÞÊ DÿûRÄè ŸM,0cÁ”l¾žJ#ÒÆ”/\À^Ù.ÓzŠ,À‰å†XD4ÔJÒj;³èëýenùŸý´å4ö´LzN+#";FB0×Ò QãÌäSµí§®«9\€X©¥÷†æ>*Áp\éƒpÛ¯‰!‰é}ÍoPÛ«ÕRÌÏ9#®V¢è_þÏÿ÷9²;Ú Uf–»G¯þÛâ¤'§šƉ³V!8¦%D’ÉØö$©Å:‚Š=G®åp¢k[R‡‹pf˜H6HÛÿûRÄမg]9…€;‡ï?žÀ !™¡Š!¡{nÕýÛÑ£ÿ¢´-ÏøÚAHJ#’ŽÀ.‹oKÚèæÉ ÜI‘©U1Ĭbå×wõå­|—¥iˆF+;Ü–®vzŸ´igºj_ëEêÖçl£ëÔÙfjò¶$YÓ¯ZÀHHó(щ°§ßQ`™$ ­ô®\åøƒ±²{5©¶Ò„Í ø-Ù§'—rÒç’ê¤÷¶:‡ 0ˆ2,÷ÖKë¹]÷™FâµÚÏ×ÙúÿûRÄÍ€ o{§°g±O”ìõ‡Œ¼ºBvnë„­lŽŒÞòþ‰ •;*`°«Q”j†ÇÊzhÛ?˜±® kÄ·BKeÑÑ%0–û¢ˆ88LÑäž{Ç´TÈæ“jªÚÃWˆEÎ,‚$a7±å“Ú‘ÃÜ(*0‚è¶6.õ®õôפÿÖÌKÃL½C,lƒYÞ<õ3]±2£w6Ó´ˆ"”Æ-]ÐÛÏ[Jƒ„b&q¿D$pŠ’%\jÅ©Âë=a5L¸Q1ïo¢­*¡ÊŠÿûRÄÙ (uqç°PaN”«u–¸MÌïfIM¼ØeÕ›[{.o@ÚUGQê·Fà½Gâ ýįNÑî ªd‘cxÛ2þc­ù®öÍùd›®â­ÞkÓý.ú¾ªy²ÜÓõ¹¡'1*À\1 Þˆ(QÁ/6j~êeDy‡D43‘ë4y*ÒŒ²nè–’£Ü›eæ4“üB"}Úo¢²…/C™¤œ¶¯µ¢ô®lŠw-,lnvŠÈçºR>Œõ®.Í äXò9]C“ÇÿûRÄä€ HQ,OA–kõ‡”<ÏÄøÎåô¯[?{ƯKÄÖ½)‰bËk[Û;¾³_|ïê‘ï}çÚ5¾-ßüÓ8¦"kT§¦ÿÓØ°mýkœÿÿÿñÿøÞ~ï¸þñ5˜þ7@ ®Œ†ã»X¢‚œçO8yE\§_(æâãõ'øHóŽ—‘?ëÃ+·¼j¤™* ¤ÑÖ|i‚åciL–#*ÓG ±$ß©©ÿÙ÷Ðä'VHŒÅõ笲7+h„ø4§7š”¦ÑS"¶Þñ7ÿûRÄæ€ ˜‘e§œpáì¾°€˜nVe;¡ÁX”æ¼ã›wjûˆ1 ;Ÿñš cõÉj;iØl´–"Xàð¿»Mÿÿ•}'ËYr"ÓïE"\¯^6¥²$˜iØZ®°é 4ž!ÕÕ˜Š#úÑf7œ P43 ¸[A²¯Qb䉆ÁÖ°ªJ$ÃvK©ÏA’-”¥ÈíUϱ•WW—ϼ¥Hf¤!gG…uY$‰×¢ÑRÖOQ!+¤UgÃe£ºmCj-ðå‡_J|$x/< ©ÿûRÄæu_ù—€ H ¬°`Ïâ| M©Óáƒþ±IKÛ3×êÛñ¯¡@ðÖÞ)R¯sú«jA @v|!È>Sk¬ûëoëJsƒÄ qÂðŒÍ¡fKyíc/Z£ØÚ9K1”ÊZ&ÌTpé½§‰’KÂd’¾h$(’¶4HGÛ{ígÛÝè&“’Ù5RX붉)#&:C5ÉG¯#w…§{ðÕaÉc»Yr¼J(F3R@ŽÐO%’¢‘(ñ>[~V˜Ø>Âzj¯ÿûRÄÈ€ l‡s§ŒÐñMkõ– p “-$ÇYãB9£ÐºkW‡1Îe½qÐq¿Cº¶²#L–^Êágqº%· ®•77>LB›Ÿ™Žûu'ocžÛ\àµIr†#¡µ¼@+[›Ú½U’Xƒ >išAÔ`¬7-è}7 Ù˜ëH‡ÑˆÚ z2q¹¢bj;Ha¸†A4;c.ÚÌlÙˆšñQM¾7g@®ÊbÝOþþÉK¤‹•š›ÝÿûRÄÓ h[{ç°g1K+"°PÜË]óõ̹Ðk rG¿¨Ùºmίí¦ÔÚwGa–~yå#k ‰„àºÜ¶@l«ÿëÿú!y­î¾¿Új;ÄqÎ ä+I¡ÁPôܽh¹O#èýÜŠ³2U'GÍx& ¥.^pÐе²RâqJ}¶"‚b¤‡EG©òþU,% Ê︻ªî¢žhIF® Éuw›¥G's‘äQ«qóµÃÑÆÚ9vÉ{ÝôXn\B©öÔõ[w7ÿíóÿûRÄÞY‹]¹•€ V,ª£2°±÷wÍïÒr“(pa6DlvÛ¶×i´Ölí–Ö³“×ОŽ-jn8Í‘¼‚ë–¥*ÓW‘îÎÕQ¢)@vÆÒë O3sý˜è™*¯j¤xJçQY³µ³±?´xû‰³r^#;S„ueb²ËëÚ¦1ôàñäMÀ‰ w·ù¶u_ŒWyÜ}ûßúêµÖý­óþ>¿×ÿÒÏŸ…Û9k`ög^.sõª~9iÙ €@DY F"…ê]NÿûRÄ£€!‹W9•€ d¦.·0ðV1r/ibpŒmgGŸéåõ4fÖ£Ì8SŒ )FD¬ÀˆIª— .wŒ¨x¸7UE„ò^´:ÙÊYX*ÜŸ)àm¹iêÁ0æ†ÖÅYßgrÍóQõ¯­y![ÒÙùµ­ñÿøÏÿøQ·¸Q³m|ë[Ö«ª×ü|üÿîZš/׆þpâ@Șùh7•TQ„ª¯ñ“áu$À¦YJퟕCV]ÕpÜÚ{ºš À03ONÊä2°JzÙ&k²z )ÿûRÄhQQe¸÷€d¬í'0À tªn…XË…‚ä|9hú«9O2¾?mã(V­ùve™˜8ËRkÝiËBä?ÿ4½Ü}ý¾tµ×*ìäÎ|¿–Xä^›ZÛg&g?òr“9N—ëy_g#aÌ빬Úì諜6S+ÿÕ5Èeö³0i…¡Õ!æi)ÜšÐ2V³ñ™8/­Ê%>%ž’J£…¼œ°XyI]¤Ì]jVjøùv—GýjFå/ ¸¾a§?®Ógf¿9¹Jý]ýlÿûRÄ, Id–䬳?€eæ¾ÿ|ß&z½ÇµÉ˹z¼â•Q)Žoó³o˜ÿö }·³žì¯¼ …‰Šì¬*XXïÿ÷ÿürPÎaÙU™™™™m»m®Û[ðy44rè!¡œ´)²¼ùPU? ˆH aª£ !‡A–r™Å$×ÇõæUD­~««@óµ2GNÅ4w!aìïñþ‘ÔËòÂ$›ŠAö¬ÞßÜ}q¿wuõsÿÿûÅiÔÛßp|^©¤Ê˜T4vTå‘ËÄÿûRÄ @ƒsüõ€!i+%§©àò^A Áü{:PEkŠz–çÎR&T‚%’[GÁI{ÉΆ½®už2žFÆÌï¶ðÆ|µÆ¢e¬ê_ijÙÇb”2»`W:†d±³ÏAÚªuœpó´çh À\^3ø-Õ—CvâòŒæ]’†°›D4ë"É»wµoï$}LÀƒàZ«²fá¯v2h`ÜÇ ß;SÊ™z@M1¸wÂÔßþ¡a„­»þNàLÒpzáÖ媷€°†Ó–L!@ô些¥ÿûRÄ€ 4_e¬1!AK­¼ö¼ʉÈ.o d„)5#·<ØÕüT{åŸÅË °T±Q¨ˆËèa †w‡P¿¨;ÿ×ýìb°WmÿHª0*4äÖÞá’„hS(à$“£ôÐ ÿ𶄈̶dzæsj29em:Q¬Ö2_&êl´PÑ™éD¢ƒ‹4h°èM´ÎÅ‘_Ï{5ÖßJúz}=ýÕ54ƒNí¶ËˆN¿è´ÊEgó* [ ’ÅDùÚ…PÁJ;ÿûRÄ Xwmì$i¡O n´ô‰ÞAº•‰uÊ+{† "hP2æ°ñÆ)ur¢²/5s“ùßÝ~“÷Óéºû~`']RÇm¤Ys8ðfÈ[ ia`L"I%a0 ©ó §³Tþ/F¸‰ 1$ÀÕ¯P€ÁâÈ:ƒÉލñ4¹åÔª%1Iü4ûÔkÿÝõü×ܵ%—Im¶ÇŒEëâäaø ­z‹½Þ¨D 3q:#÷l«f›:ÃÅm&qðÐb44{”Z©uA— ÿûRÄ€ |[kæ$Ρ& êÁ‡¡= 4iÅñÊ«ÚÛµÿéã{ÐÇ7Ö( ¢¯x¡ô –G±×ÁÕ¨l4™;% CK€,86àu,Öù.ÓpÄ$|°N8&q½þ}óe+¿oêÀäYŸ¯¾ïe=ªQA¥W5¦Fj5ˆžÃN®H¦Š¨þÅ[[ýª{N§3|ËÞnUº—–êædhÛ)jDŠ¡œ@%‰g÷ ˜y•‹!œ¦Ac… ßþêé«é¡úQP°¢ÛÿûRÄ-€ t—i§¤iáJ l}‡° h‘Åb1'Ê„°¨ùÄëGh¨y˜«ný×›ÄvüƒŽK÷ýÐH©"fÑ0Ññ1ÁpXùûì¦ÑSm¥ªîÛÿëò_é¯óÕÕÀ›ö%±É•¬Ö¹&ŒÂWlNf†s%–^%Œ ‘•˜ª¥ä¦ÅFXC9FžÎ¢é“°qÑŠBÃÉÅĨ¡p¸Ç­kdñÎ…þ÷;]ßÿGX¨.´«¬+˜) R!uo,,•”7ÅbH¹ÿûRÄ8 4m]¬0nMjñ‡Œ¸Vþ›Uºö³!#§CfyØE~½§Ê àU¨ñ./ÆñZ‚UX¡Zj`÷Ì;WÿëÏViU¥6±BŒ'8:˜Øœaj!zyW2uòH+`…=VKg™bA‘r0ñèHõ€Na£BÇÒTi¦Ej[¯ý®Ùö›û›Jý®c{)AWµçl„†‡ ²Õ”ôV§Q@%Ç0I2P|‹¢H™ILƒwK K’¤n]"iFÏCN¹× @H&ÿûRÄC€ à?S,=!Kl4ôŒümD^DðªP=´~+ך"ÍŸÿþÊ2K.ésP!iS‰ô‰»7‹ÕÖ{â­>ÓĬ‰ÁIÈ2KEQ£Z˜}ĸ–¢š‡qrÀ «C&CÈU!Ãïš1UÛJ³deJ£Ü %" ÚE˜ÑŽb5¯Ç¶\б™,§N8@4'œŽ^“$ß‚We‚º1ÞPHéBüñ˜þlxó“CR³¦«ÚmæžÕttÒÕýZ•þ•}µsÿûRÄP€ \QW¬$IJéÝ” ©G~&)z#oÞÙ”¦7`HÅ#ò—…™(ekáÁ”@Éÿ hLí€E ¬*MM&3RWÉ×É ·ÀFM(2ÅÐ-·ÙýwbµýoP*BXd’gðêËøü]ŸYæ++ˆÑÃPÜ&è}å"ˆÄ‹¨òyf‚‘„jF _£H¹l0âjø÷O/ ×é¼±‚’nÁõº$ ‹db„Q_p¹SûÙÿ eŒìû¿þ—ÿú ¹ÿûRÄ_€ QS5ƒ­ëg2€œ²ÒœT%µ#B­Úí¬·9Ùn´{"hx—v¼‡ïYÏJ¶[kzÍúb¤·ê“8 pÁ°®° #…„ ûE+Ðú–,Ò×÷!Ÿ¦2¦½ÌNÝÈ3û–Ï=¹›QA…!@€#"‘Ìf#—ÉÿRõÏ/zBÆ‚ÄcVžàÿûRÄ_ Üusœó1J‰+ý¤Pv~”²ˆj *)¨0Èh LxeGÍ9¹×È1ålSY$»}u4KŒ}?Û³èfôÕõ ÆŽMßPL¨2x¼°“%EÍàU°ÃÏ0ÀZg“ÖrÂ¥}†ºZ¯QÔvãu툉K „­&DÜ¢ž©*ܘ4³­tèEzc«êÿõ=éÐ$÷¼B ÃdúFä‰w“ó‡k,H¤ñï‡k¡K€·qÔ>êU”Ì2&‘ÿ!¹„°¹‹¾Gµ»U—ÕÿûRÄl $]WŒ0hÁO )Õ¼¤0Ï;Ü£!îé(Û[q  ±Û·/º+;VßÿÑýo¨Ò€ e i i}—óE}V‘‡)¬=€ÐÛ­K¨Ð'"F_(?ò!•¥Bn§ª£Öʵ­?]‹3uäÚµ8„â"ª#{¹¼ˆÃ´jÿþÿªå¤ÒÉ,©a¸Œ‘¶H‘ˆz]*‚„ ” ’ä²P]b¥TGULnñÁq£ÞPtÁeN™cúÁªÖ1ت˜ˆá{ÿûRÄw€ h¿s§°PqN©¥´™ÐP]â¨*"Z?ªÕrÈÇIX8$bk@r-'²$A]¹¥qn¹Õ\¥+Z_bä^¦6õòÁÕnèÒ†d´bÈó×ÔZ4óô‘91˜¶½©4¸ gÖ@I<|ßÁü»c$ÿ‹U%¥OõU€,Ǭ^˜Ü ’`"©oX| ±õl¾“1FÈY˜"£D0¦-‹žª¦Ú5z&ö½çu“Ý£¯Eÿ™²ˆn¨Nh¶ ϧ¥5697¥ÿûRÄ‚‹”au§±ñ^.4ôŽç–QÎËTn«B2.2Εl 4Ñ úÚ 07´ûamôòñ¿ÔO„Âÿþ¼þ€ €æAªúQƒ&#¼ØŒEŽ¿Ò6a.{w@ꦀe° ¤°ÿ! >¢¬¶ì[eK•XÍ'>r›\‡>Þgé÷d.)>ÈF*-\{ž‰ž)U€Ñ”±H,™b Î6%™5 æ,;CA-ç žûʪ•"j5&AL”äDsG »éÿûRÄ…‚Äÿ@®«—o>úЏîm•í±[&ëc)~)·«¶%’Ëský †5Òm÷ï‡K»NþR=²Ê€# ž}ø|-ªð§Uqñðâí%!úÆsAüHáBSõ—–^fÇϽmI:.п±ÊZÎr¿ÞóüÕ¿PK~Ã÷c·ÿM· 9Ž´­úëþtJù:ßNcTÎ÷þ_ÿûRÄ4 ˜{K¼ÁÉ“ êu§˜8ßk“Àht "Ôq vvcˆ b¶Ræ£TÓM—£Ü"—çµ ™¢?à# ð&¶Ôûúì>gÆL" –0LF(x'¨|ô.¥¦Íæ,ÖœcpPt¢†ji»ëõEaWÈ 󜖚XeUeI,ÎÖƒï#¹°Ð-…ò(Μ ‡#]† ãXˆŸêz4Íátú Ù·¤ÿ«ïMLL<ð°&G“Àí¤™Sr¬$ñNKîôøÿ¬ˆPëŒÿûRÄ) \‹sç¤iqK‹n<ôŒ¾>Cád¬“PŽª‰ ’FÚ!.É‚QÄ@^€£±0­\rA p ‘ Ú¶š±2]‰Hˆá CÏ€CÁ†ûÁ“ékM—Y¦—QsLy‹ÅN,ÿ•ÿÿÑ~fôæîªZYfÇ[¿Ö&h fá]æÇ·¥ŽŒšz暤Ý( :xÛnvC;8‘¹8Å(*Pˆœ&p³Ó‘–mrP,¾ÖkGäÜк…ÅG0 =¹B椘íì’ JðvÈÞÿûRÄ0 ì‡gç°aáG l=†< ¯—ë&²åÈXpk~w¸á6cšŽ6ogÖ`<šz’•©cŒOØ&œ¬¼ÇôGÕ½õ=ïnŠVª4ÕHHu·]t‘¢ÉƒÂAàôvLFz©3- 7W[qy©ˆ†f8ºÈ}¯ ‚æn<*¦Ð¡r)AˆÇ¼óÀlë®Ds(0=tîKš´£í -mê›?_¥@ÏNð÷5Û“Xðì "@kHYÑ=¤™†í;¦V 1 ­)!šÿûRÄ= Œacç°eaJ‰­¼ö$4‚n²`²àÁ8œ¤NEqDÏŠZ¥s雽‹¤!ÿJÜ–;Û«uk8r5¡öÿå€J?Î*ж*N¡V{Enaí®'¯à©®åg±$¤FùT¬ äZQò€Èa ’T‚IÑkÂiñËÜIÌ>\çQ])³á¼:r€l4="°›1⋲™°Ñ抆·gQ H<Ä$¥É£­ÒêPåŸ<)"álíA5ŽÍÀò8’'Q,bÿûRÄG€ øµ]'°ØA<“ìq† h ²30IñùÞF½ô¼™øÊWRÀ*w6l ·geDC¢0ðSy„4QŒ6<ùÀ ƒ¥‚«<±ìƒ’ÓãGåÖM”#õ;õ m_N€ P@L]Òµ‹‰^N©× j®53ûÌãzÓ0ÅÊx°y›NêL ¢H% †¥ÄE}b DÿûPÄF 0CYŒ0ÃAJªa† (óë2ØÚüõ^© yŠŠîÛʲwRª4¤Õ0Àn  ?µÑ®Âr/ˆlt¬4œÏ⊰ Í d/P¤=9™´&7³Ì¸zäyÐc`G¥Y¨Nª•XôBm.ñ¬,ÿöZ¤€Q4µõÚ„ë5Tlm*ž‹8´½=" Ù¬r QPËÌ''Õvç^K!DJèÌò'ê©2L¨v¢ ÐGR„]x䮡©ÿüWw«N¶ÄK]ÌMÒ]ŠÿûRÄQ `Tǰh1Ž+pôŒüƒä—pˆ‘Œ|bñ4°^•¡/&²pRؽª«ˆeLçß•s2ÎpÁí R^@Ä³Ž ˜>ó•hC]FŸóJ¿Ml¤ Mǧ Ò|úh gÿûRÄs€A™J¹„€b­l?°:À„I5m<å˹•[¦Ã®ÚßÓaÓtþʉÉT9æ¼wÿæÍsžr77Z‘E#ÆÈšß¹ïÛý:"ÔÞu¶v;‡$Ö£µ½]Ä2Ov  ’›]3åšeàºã)rÆ%j9= F¤‘º°ü¾^öAº3û=<|ÏÍ}‡ÿ¿žùögÆ .¸ ºÐÁÌ7Bã¦3æW»Ó·cóZ)ÕS^¶êÀÀÌæÈîñ®–Z@FEˆÞL…©GíÉêÿÔϪú~ç~C¹4ÒFÛi[Ç{x…¶¡t2T+ë L‰Ò¦‹H!I>¢ÝÈj°Ö[#4ccÿè5Ö€,ᙓ1è¾³ÏõÁˆ}‹ÓrÜúUÿúê}ȳõ)`(D‘x º£-´z=¥Rè¥ZĪÁiz–Ö&1Õ Šc±¬hj"TÆ¿ê_[ ]ÿûRÄC€ l»[§˜nÑp¢i1–ôVFz,©;ÞýLŠÌÖE5fN¨†õ^ª+p¿º·q¦j¬D0w©¾¥ é%¶M$Í&´±0¥h.ê4#§ÚS+=yé"Ë›2ÔˆÛZ«},¬ùp£ÔÔ³ÅÕ±Fk͈Èk"RŽ´7rvER'¢òB%žp ´ˆlqç)ÿx^•--SçPY©6mS=ŠJ¡Ç­¬ àp,´¿R—m< Æ%=&VX ‘ÁÐE&Q.õ ˆ“ô ÿûRÄI€ ¿S§¤ï!…—§¥¤•à¡Bô;pÍ•¡²9žŽÏ¹™¬ÕŽyZþåV:¦î8 Cõ?âÙ\5NI…ïI5ÐCþ®ú7¾ÁQ·ª ˆw°„3…£°u³UÕ+óµé}b„•F÷+Ç«Hû¼BÃÓˆ‚ª·á­²Qg₎z %…V*ålY6É*‡ÿúÿþŸñ³Î|À(Ó?ð[™¼© §Á7Þ¡·Ùö[ÖkÐ*$#^× ¤ÁQæQYLS±A8À?Ö´iO[Õ½x”x»¾°ÐI4Úš9‹Wk±Ö¬B1Äæ¡¡Î8á½¼C,ß'.l%3S%—C—dÖÎGÀ´Ï—Ù2.prWctÌh›¸X±+ÿûRÄM  I@Õ¤€]³.wÐM:zÝB=v9ÉqÞ9ýtÛtÔ€ó$G9,<_è]þJ’å‘@¾F%Ô¤7ýÕeû:H—ÍMÐ0DÝÿÿUÿù|€8Îøž È1Æ ó¦hÕ©J£zÒ’|>K˜¹9Jã!w •B†£càÚBÒ$øÙÓ{½å´ö†S^î°âų •QíÝ:¼r¤ÚXwñ_ÁjrU2¹Ü@„\CáÄ$(À4NŠJ‹ô_KåEàÒè Œ™¸(‹K 8.ÇDψœ[¬D§«„ƒRm‡N°ïN«=ó¿òë@ŠGö©€ŠIÓ(:P¢a‘`f“4ö¾b&¿Y›½0ºWiAÉ9*À¾~-úÓc ò_œ$ÿûRÄA€ ,Qs‡¤eñNê´Ð|8“°6“ ˆLïëK_Æ©–ÿû½Xë³ï}>Eí Á`a–ìXÔ¥‚™Km³ÂÓË;oŒu„?”«ë¢Oð8y-Y «ÐMÇôÛÁB!f1M.VêZ*ÛeÍ•:©ûývv¯¿V§ìcoêÙô‹… JmÀÁBÅ~B&Z«iõoLE`¡¨nÁ¨³—Àäh~ÒÍ-¦ß¹N‘½!!ß"˜ªÆÇž£Â'VÑ¥ÿûRÄM ds[¬¤®Hë½”(b /J?ÿÿþ?ÿǪ Ñ¢ª&äŽ w¿1VÊë7¯7tömø£căœÿüàâO]ªÐø':¸Öºü$®D^˜5$ig†öI^‹¶tZ@*"6@ªH–6«?þ/°4cÍ6Z¢B\’C[8…05̆–a¨È$V$ê”6'8˜{ªO¢dV@$À)œµ¯L¤9àðW‰ÿ÷HB·BÁcÓhPÒ¥’دô>Ÿ¶Ê~¯ÿúFÕ±@,€SÿûRÄX l‹u§¤LñOª·(ŸGöJµ"ÎM­–NË­ëä’øˆÎ¢Ãâ`8*[ ’­G7ÑÕÐÌR¾™{¿½£fä]ZÝ¿kªþsÿgýE¡fŽ(D!œ{‡22›rÉ$rÝ?¶Ù( €²ÂÁôô^ŠPr©V°¨e•¢Që*B4Ûƒ… *'D! N1#am“â:‘E$õPîHP"ÞH=f‡:™p‹<]b¹i{‡˜;•ô8Ïb¼öòÆž SÛÀtµµŠÿï_ÿûRÄb h§Yµ’€"l'-70ðúgwú§¼êU3c¸®u9?ÿÿÿÅþ©­çýù˜”êµl—n=ÿùRJx”ˆª S™mô`Æa¤ RãÇØ“=ga1 ³Ñ¸xš[“—|˜²þ½²ënÃÓ\¢ÅÆgšÈ›‡]Í5®ikÞ¡±âÅ…ŸñêPH agÄ¢P ¼K‰@H¹ƒ  Â~0@@«4–†GHÚŸå(Ôg¥« æMéÕ_)» OµiŒ>´˜_â]\ø²ƒTÿû?scÀä$9¤’‚nÊ4ÛÈÕü‰Ã£§y ua‹*¥Øj†24k]×d! Ô¨ŒèÁgôˆ{+ÝPÈ?Onõݶ§Øûn›lñuo¼Çý®G÷¢£ÿûRÄT h¿eŒ$©qJ+5–´Yi[‰ä›L¬Pëý6;É·–˜8´-]G¾;t¡×"ƒgÛ•T±ý>\¡-£"Æv}^dõÑW zÚ÷RòÆKåz¦ÃSwüÿý¸L" ŽH‘…^sʉdÁDD‡°¦˜ c@B§O©.#Ž@Õ$Α®ŽÆØŽÏÆC®C!æö½ýú¿zIJoEÏØJ å *"¿îwÿÿ¶ý˜Qšy2¨"iÒ/=$S½CvÿûRÄ_ 8Ùc¬0åñLjµ¦t´ õŠ7´4í4¶x™ƒ£ÈÙ]šÌj? ´Eês¶Ô¨b!=£%ó7¡—¢´QÿþñR‹Å‘¹JjAX˜ÑK¾ŽQL–¦A–†¨E5$œ•×!Â:@gvf Þ„ócúfýòâ;SÔ·.JØ^j&¢P%=Œµ_ölû-*…·ÛÜÞš5bµ¦ãh†0GHœäjÆ4yéV(ç»4À€vgC¨öLï˜y‰VÊ-V~Æ7Ê–ÿûRÄj€ HuI¤NIký† ¼NŠk¦õK2j¯ÇªeFбGˆ>¥ÿþ1ÂÁyä´~l˜£w‚¤4ÒVÄ ‚>„Å‘;jÃFëa—SNLؘ~¼ù",,îŽR@w0}"ó×}ñÿi‹ÙqG‹ AâU¡—¼aèÿÿÿúò>ئ–U[Rv2°ˆˆÔ#!c!aÙànD¿UÍÕšåÅáêÌò@“aâ ð Žà Öaå(]¤îÞóB@ì4Æ($Ô™î4„ÿûRÄv D³i§¤LñP*v´€Ï¸ÖN¢ß¿a>Í4§¶Q9{UÈ9g{{kj~·S*MÜ~æ&×¶ªjc‡}>"žÕëk8ÿÿÿÿß¿—ÿ¾+Þãö}®rÒP9FBI<×_—Ÿ7{!}êJ¢ÑΩIZt™´s¿Ù+[=_³_'oãÏïó˜Ðpúvl4hT„]Ò[»ÚZ÷šŒÝUkl­H%‹óÔª ;fÀ>ŒV¦âˆ(ð‚ò2]bX ÿûRÄ€€•‘[¹•€ B«³°`ab™9îÅ"Øä†Hö…$L0,Ñ0²‡C';n;khÞÂ$þ·;ÆŽ)<ßF¥ép/E*jUØ)BÒ –AL&ˆæåÏlؤ˜/>/ÚÎD´t¢V @vå ¸:*$©¢Í µ2Ç\¬qöÍñÉGÙ¡[£Ôíh¶Šß·&^j©þÕ¼ ,œ?GÐ"z[r‡d4VDéCt$í›BÔŒ‡K .=2.žCÿûRÄk€ 4c]'¤eHŠlq†,8öi:–i Ki]Yx¬ñ%(0˜¾ÿgÆco¸ë˜½ì(À4@¾ Rq\"‡¡øiržŽ´ˆ)i- $˜´Ž`ä[÷4‰Fªyƒ*`¢ Ñtš%o"ˆª."|×™$ýžÁ©±‹Fô–±ØÑZ„$œˆ°Åñð°¸`!+âÅeèXARÒ›˜R‡·qˆÉ¼"´5@ež¢Â2Â`\ªÎ“¶<ÀI9èxadžo»g»ÿûRÄw€ |IY,0ÁN*ðö hú~ueVmL¹Š;U <–5˜ÍU‡ec!eEZN<:6«Œ¡C2²41D=šgÃÌsi&Å Ëùç…¤.äh·pj%«³Ow±õ!¿ P‹”¡1º 0ID#2Œaíyû̬®3q©Äîõ@aoÉÖ]!Œ­›øSF#ä¢!9à‰˜k±BåH ×#–^b¬Ÿ#ÅUÿÿ_K-£Y~<4@+çkx,Ê’]ŒÆÿûRÄ€ LcWH°aÁDj1† °2#£FõÜÔ ~2,2ކ 0èÕš©[p›9gäƒ ‰¦Å <²¦T†ŠÊ™gÖí[e–ÿê¤u4ýýTTª4«ÖÕ¶ëcn ¨\!ïà#x`äIžË•,æ°Û3Z5ÇÓ”B sFE%0ÚábXЬ܂²Ui›/¼bs8R¸LJY=*2¸-+ÕæDƒ… ºË{L¶s1Áê>ϰ¹Ý1ã½GnË—ø?'&¿“3ÿûRÄ€ iSŒ$ÈaC©¦°0múïõòfôž™šOÒÕ·L¾ÏÅZwsŒ·‘õð"óÌæ’9$eG-™Èå“Fó3U*qB©žŸ{ž9 íˆVgN,ü)÷ŸóhÖ$ç‘Åáˆðˆë1•§6øl2"1iû¶ÛÍ©¬E²ºæÏW%‡˜œ‡æfDFªfI6&5Y¾[3­3oX½Œ» –ÇûÓŸ;;‘ÎLÌ›&8×°Ü-}`ƒök]Ý®LÖk3,¥>r§ëŠiu˜¹a$³Åýu(I+ÿûRÄš€í=IY–h+k÷0À{y­GlÖg,‚Âúy kJ-µ•Úî1Œ­ï|Õé<3—vÃíÈç-bÍ<¯,Ð1hþEÃÈ–²ûõ¶·‘Õ(¬£“kݸßïê—¥0É‚4ÐlËßÓ²òoOJú×À‡Œ}gvøôôÿÿ ; 6[oë>Ÿý|ýïÝ)ÿ÷ˆå,(Ñg¾«OÿÿÿÿÿÞ;óE*€ÄøYŠe¹96pˆx˜À€q ›ŠT‡`hä}w2¿N}M 깇F»Ù×ß©šÆV œèüžªÎNÿ][ŠO|:¤>©!!Uû ù*³`@-WÜ^«Û¯gbËÿ8õ|ÐeËCW!S;þÐNFz7Ãlÿ’1¬+H$ñt m_ÿûRÄD€ ŒÛW'¤K|1*i„•©–Žµ×˜~Cyö¿3Q7œ[ÿëz®”ïõµ—ïíÜþ½Uv‘*—˜ÕÈ Ò“{uD@Iru²•BІ٥˜PÕé¶p«¥/µIÐ8´r¼ á,°¥.:;ŸÓÝ?J·5=*ZUÞ¢?CfUuµí¯#ººI£7XIÀ©q¢AsÀ5IIIÊ£xZJc¶Š©"'•Ò` v}úõSç2¤™‹EÑê~ûRôC˜ŠÊÐvvp} J×MÕÿûRÄH (ÙUL,KH›*ôô‰¨õ/¶ÖêÿÐoûÛúþÍéD•§.Áàr¶¢Ã 8‰D‚’N(8ŽHôF’¿œA WJ^3z) HÁÈdµ› Kâ ²xî_ùÎôñËÄ–ÀTD£ïbêz±—·>±TIþßT‡k L'¨ÙsœÂp¸ƒÂI ’TôA©ÿŒU°8ÂÜࢷ #½Š¬i¬Yxã^…«×º)k«œf"³paAÞ«fT :s‰Ës}ÕªÁ–Dnßï×ܽÿûÿûRÄP€ P}]¦ÎÑ=›(Á‡xÑx¡Wª€1FÖÆêPã-Ÿƒ/G!¹ñò^Jé…ÅÞÌݲ#xÝÝãïw<\_÷á">9w¾~]þ+ôÞ]âÝþ’ïÑ¥%ÅÞÐ\ø PPÉtIw}ôIyx‚+Ã#‡¤HgOÿ€˜ˆïÁÜ3¨a3 ŒŠÊ®f~Mn‰ÇcÖãˆëL‚+ò…†/B•¾;)$ª'LÓÑ•6`@å[DÔÔ°}5¤—¨'J?a[lÊYf&†Ò‚Ѱ7ÿûRÄ]€ ?Tµ„ _.íÿ0ŒA•fãÓ¼ŽÉhÞ²Þ¨wä2òþ}©rª Éáïvÿ^xÇ*¿ÉÞÔUeâç­{–ïwö½± ù¾®÷ù¨F™!èë¤Qc…›ÿô* E¡I µ¡2³V8îÃÐs†ïÓS9.ÄôÄn’zM4Vƒ^V™Úk7ê§7á¯Eº=nC$Pw\5$õ·ª½µ»*uÓµ0‘Ú:Óœ*DtK’fIs†Ye‘7 ÕâQ&Ñš@¤[ fÍ HÚYÿûRÄ7 `ug„LŠ®½†œ«&öd¡açô¯=Ëv·Yæ•* &téfÆ;V4SØTë"[vh(ø+fíÔÏ V7;ôËõU€ ñÊ(a‚M¬XUL–gð >ä0\[ªh\]Ø© AÐêq¨ÝYTb……ÝæãWÝ•;6D+8f’EZ $iH¶i䎯íö¿ÿÑRkÕ^îÐÌWIð'c\%pAŒÒvE#QvŽö)›x¢~„Øœ½i)Ü¡t&Ïû‹%¶NJò¦½ã¼OÚ¯Jëx®•ÿú-¥ž ]¿]u¶HÒmùáûÌÁ'V7ÌŠÉSÝÕK`ƒ•Vɀʄ ºŸ¤& aæ ò£wx€¶KP`>{Ïo{ªmŠvæ©?ûˆ^Ú æf@ H_å<#˜DÅgËMô ‡–›'#·º©AxÆKs7’[èÄ^Uý¦aùaA’÷ŠŸ<”rh—îsÿûRÄn€ Py¦ mq? .ôĈî¢ÕfRªQð¦¹WeèÉi@-Ç­ÉL"‹ Š »‹¡XB)V¨#FÑ-œ[ãqäf¨üZh Í„¯ôÒE^ (`p¥qè.ã‡%?³Æc¨Þä#ÿëÛg¹øAbd̨$Àe…耆‚u%âD,þ)g^X—f$ þ¦i,‚½_4SÛI'T‚޶÷€n/{IR¸#Çe6øÅ'1ý@¾ÝÔÅžÐ5•ø˜gÛ{[”ì1ÿûRÄ T™O,0eAH«5”Œð´Ž2^µ+‡‹Y,¡uÀê'Xž? ÒЭމÂ!-ÀtOÊ»EÒŸ5—î¾Ï£üŽÑcæÖÂËÙ\ùKÚ8»’ã4ª#e:e†g-Ððã»*Õ¬ü´ ‘¼M/Öuœ¹|°çåøˆy\CÌ»Cl©M,JoþľÌ*¼*t\e®Üò5’pùÊWÿÿÿÒ®ÇrlrÄ ‚!lšMmqÄ4™uJe‰Fèљ҉š)ňÿûRÄŠ€ …UŒ°cNˆm|ô™LÜf¢I•$³c^àLJP“È 8•‹rÐWOi¾ËÎi•Ap‹,1Ý ¯ÿb¼¾ }I0è¢ÅPq‚ظ8—õ”ªìÃÅrrÁî—Å£á1…´Û‰ã.pÀ´`N‰äæšw™HpA 4NÀÄzJ ­K?WwCùðC;u.Õ%ŽÿãŒ'þš"7¨_Ëó†Üÿ^°ÃIeòX~``´þ©"uÆ)¥œ5;0lCFx¡1M±kh}Ó “ÿûRĘ ˆ‰yç„tqGí<ö)ÄA ƒ<éìA]IÛdʽ²²Ð¿i¶bãw_±Š™<ó ÒkÄâ?aîyôCM‰êw…œž¡•YNƒL¤N‘ í¶m§ÃŒ­ÿqä¹ HDPôÄ>e2Iàþš]%“' B‘&›—”¶½K\–ÉŠg¹“ïXÝ™nOÍ”¡ðf9ìù /F|lï™m^T#7r…-=]~ƯaÙ@ªÀ¦{ œ­5"çJ`Ü’ÿûRÄ£ hu[‡°iB'ëÕ†9hàþÙOÉ0äÔ˜[™-·0 9`AŽ LžL Åž=·+U9UzϳQ§eâ– ÚbãÿqÏÿ¹5¹ ¢ rîÝ Úÿ+ˆÜß*±Å@~zH¹a9ÞÕ©oSÕšßñ]õü”ÖêV|û_ϪA­31×…ë—Ò¾GüøÃòUi©ª=eÁ¨AâëЊ+ˆßóµF-Ї\ÑGZM6Þú?ˆh(ØyþxÌ?¤4=áñ•–_«¤ÿûRÄ•€ „¿eŒ0ÃAEí=†0*Þ(Š$b¬k:%¥Ë99gb‘eT#9 MèÔJ"Æ­Å¢bJ¥–§®­J­lº7©{ÜVŸ×_Û^=öµKû¯o‰®e Çbª"Re;a*#À}$KGº’º„ê‚#H$*0†ñÝ"th¤®õ—gê…•«7!g³^°ˆIC»Wi®AléR^m(3ü¿ô]ßÕ½AtÅÑ=jì”þ¶rŽ$\{qçÍ 8w´Ðb²ÛÆLÕØ»š0†$¾ÿûRÄœ€ ¤Ñk¬0gÁ‘±ïôô•7Ä2© àÄÑ¢Ép±L(ΉóÚóÖš¦#(£Ø‡)Ž…z¹,¨40Ô©cM#LFVu'vBãÇd5…ýþšc[ïEÐKD¿» nÎ]HržÏË'ÐK•ò©¡6¾IŽsqc;³Ñ -ça…R&l´T©Òð, 7„ß8àæøj«]¹g«²Ö3Ï'“I8Nö©ˆ¬n½iùJ´ÞçcÁ¤îô=Ì®žêf™^·5÷¡oúÓñk­ÿûRÄ™€ [w§¤Kq¬+-q†þ­õ\¤tD1Ù¹*Fèà‰u[>Ê%Òý0‹i4 âä¥G’׬Þ5^p8;•,T@Ó\¿T9(U˜(¹Ç H×¥ôn~¬¨`°tqõåö—Õ_ã?÷ñLÝís¨ý.ó ² h :Øi¢Qi:2ÍSzEK‰È_ÐÒ¢2ô7#ÚÎNÞΫ{šÝ0Gš±D:ÜÁÓJ:c?GMøD= ?õ´ùÒÃZž†»³¹U¤„ÿûRÄ e[[L¤ï««k©‡”¸4,‡«ª56§»×*ÿ#^±âªÓU<Ç#nsšú4BÖ'Íx^Ùé¡2v d¸`¢m¿ŒJššW)¼•³‰D8Ý2…J¡õ/I¯’„M^^´½$LçP¾ße^æ*z²©Ï)VAæª[Vè88/g¾¥æ±sJSOê8_Òÿï«ôBn52'dÕk1ý 9Êû^ö‘f²h0¶n#ýEÓîD˜VV„i—Äì» ¿†œ6F±ÿûRăµ]o§V¹Å+ki–à&¤ÐLþT2:ÅMyþðiFñj4í$©Å%L{¯³ðtý/§w dmŒµ ²ŸÔK)Ê6bots-"åÎ|ôËÝÏöÓWø²ü_GùOU^¨¿wrìãTþ_šAÇÍöÐ  £»zQ6†ˆJpÝ×FÄ]èz§øÒÚáD(ÎNQºëÕWvoßMÓA¹Îë›Û+;˜ËY×9‹ïÀösÎÚ­ÕM îÛsݳƒtèïé¿^ù‘ú 4DÞS7DÿûRÄm€•kY¬¬³É¦«k)–ðªÕ÷&÷S>N0G†* “qXf|;x¡-nÃÑi}Âz­ç—±`_-$6³ÊŸ‰Ú OøkÌŒqè8Nˆ.µ¡Gm:&"> ®º·«¸­öøÝØ,I[¢kwú?Æ~7¯¸Å,ŒÌòà†«t°Lë@öß@ $¶Ÿ‚6¦(®Ûê&«ß™€ Ï›Á’Œ‰Â9¥¢Sº$2å@1O,†^ÆXÚÙöUuL¡„ø»^µlÔ©!-IYs 8ÚÿûRÄX õ[YM0®Á¯+kiƒ)ÐŒ@°ä)Ç×;§ïô•ü™êšM<³*ºÞìýIß¾N±ÖéRB Ci™h¹)^BJcUðúV0é@ŒjН£ ‰Ó4…}ãú€wÚ7r‡]®-Y2º<6Lr&õn{¡ NToTTy)jò&ÿOÌý«æ´c!:5¸fÊž4x?¶P4áP%[Âl§|ÄNȘ†‡+#¨Z×èLŽ<þízéOP Ø£.èReb›OICØä9̰F1,ÿûRÄL ù7o§¡Qq¤+,dóÖ}2¦_É™Óõ74"òmçéÝtoœ[ÐîÕ;J£)vTÏZwÊÖã!“BÐag%ö¤Y)Ç(‘¤ã‰:áŒ[ ˜'¼K\+ G쀊3^‚ãÖgõÊŒc[áiê©EÒѰù{Oœ—Z̦Ëx>ltÝ*ÏGÌ5Ì£ç7¨Zy½›ÿÿVù_É´þÿ3þsÑLkºÞ›ªö¤ÎA«ÊQª,Ì`iÁÅô$¯ZCËóøH Ä¿eÕ~Äí]³Rï4 5]ÿo×þ>Ë}-ý ÓÆ¦&ìÑ8sU²àÎ •|IÞÖ; wÝ\ãqé bÌq}Ž(`cáÿûRÄ? í;i‡ óq}§ë)„Ð"¤!F­œöy¨}Y†¬êJ÷ËÕŽøJ|J<Â/Á™Y±7äËTkß0×rݸc6MÌ{ÔöwsÓ FÌÃýõoÛÑßÔÓX^'[3§ä·öý5™/Ø ûcIX&w ™ks0dÌÍWa›¶âµLí*º‚6á`é=¾n_vu ,ò£»9ÌóÓSa\)*<×*ßfy»¿²:†eÍšlÉÌ®»ß×åYùz±Ü »Jj—¨èYþ É V´•«Àa úyŒ>A:3‡NŠXU¶ujÅì.ÿûRÄ?€ Tì-OJ¢l¤ö(¾5ï—FK)þ¾ÚåQ«õú š+šFÄ䉜²KµI”Ùw× &HÃAèR×ÐF6 É†µZüM•OA46¯´v¼ŽeD†fQ•à¸H*T°¡¾%£¡Š¼×ˆœtÛêß7ïõ- ±Ü•'-æX‰”»JÇn`"€‰ÊÕé~‰“g™Œ–v† õQkáÁ zD¼…—Œèª+£€n¼»ü+fQÉ,ð/ïuëj››ÿûRÄF ÑSL$®@•©”àõý íøqûzþšžŒÝ䉆wPë*㸘¥#VU¤LJÇ.»Å¨ I“±¶Š^d¹<½&}1Ô‰(êa.[ ì÷(¹7‡–_mW[ËÕÅV·eÛ©q=–ûà=–Žvféᙡ‚, ’À;ÄW™•è³’ªxމ6¾~™Æ»×j7ðw^COy|-´ë ϯÈ,·,;l¶£m§\e·×®a8ëöõµ@,$ô+Uj›Õü§JŒÉwØÍÿûRÄS€ L‘Pì,oAy§kdõ©÷Úç´œè«~ÈÈ*pPŽ‚d Rðq#HÿoXŠOQuô5@bV‡b­.8?©ÿq?}‘Ä€õñ ˜® {ncæ9¹ë9/Gílë¡+À&y„ê¨E§µ?ÿÊ·×½'91Ì®ÒMÒ8RÀ(ä–bpRÆ82*Bñ&¬¤ÖMÐô94Rèeþwx4õ"é}|ù` # œ;»‘î…Ô@ÉW_G24¹ºótw(g¡º{Ïoû|K-¶ÿûRÄY õG[G¡OÙ„§+èô)ò†öeãˆzÜ,ö¼÷è_õ&–ü˜]çˆe ¤(B2‚d"÷Ýh­@J 0€Õs¦ žee.Òå3Þj¹üyÓd¼¡Î@óúVûšÈ&¼º=ÕÎyÇH€.t£ÐuÔö{,ßý¾#¿ÆºÜ¿ ØiŸZ ,„œþ´Ô’‰n¸”‰°d6 ʃRÐGS©.𪎑IÛW6š«ñÚÈô“m{Nkòéâ…;NC­j‘jh•¾{H‹lšXÅÞF'+wM¿ÿûRÄV€ qKc&)ñu'-ôÄ*fúõÇóuèç ÉO`(+eÞü¯¦8Ø#Û´C&Ñ“‹9—æ–#^ b ru£vâu¨P9 Ø»ik²Ýê(Ï,^J“¨´…kúõjŠí}Ïö£GÔ9¥S¾Õ;û_Mç²ÝOVõÇX³ÚÿX“'"̰|b¨(Qr{uÂ’zít¬œ¦@Â?^VH¹b³‡]-½PµõZ>"Þ̲mµiÀ ¼ï«oÅ]ÕÝ=¬‚@Z]»ÿûRÄT u7SLPAP¦«äõ vüÕéSÛšù8D¼\þb,¶Ù¦ì@‹ÇQ 4lj© ibiZ­|1åÓ¹"5â. s7155¸6ºbáÑ>ug\¼X³3^­Œ½WSwÿþgÿù7Õ”iŰ•Õ@Ĩã.4Ýq¸ æh‘èG –´]Ô¤± ÆÍ;™rB¤:¹¢ÉÂufMÍvL¨÷DRê‡QV ×—Ó_ÅlnŠŸœÐáQzúú2ôâZϰëömÕ `ÿûRÄV 9k¦!Qq?"m´ÃŠv®ß„©YIî%„0Ôâí…zcáŒyFG±.ñU[gtq–I{vvcr¤õ,ª8Æê‰`ô²å{btíoß±oºµ=iß§žYî³”»é4’Š#-Ç#X˜è2â(º¥YÅ—òQAc.É}YÄåµ±í¥‰¼©ÏÛGŽ Ú~ºþT»¿þbÀÔ0,’ ³¿’ôF¨œê=jv¼*&ýÁÖ&” 52Øà-_y ¥‡%j`!ÿûRÄ_€ }SL<åÁ.ítÄœöýŽaä^ùL_u,¬ÕF õ~æ{i —§×ÒX‘õÛûM :MÔ ?•:äV ™¥…ôµûPI…èÍëÈ_GnÄÜ‚iÿ~J ®e‹•±Z}Ï&sµSC™¤e”ÆCŠ1÷9|™ØÞD ¦Š¯S%S­C2ïÙ›aëßßüŸ¦K÷ÌWÆÕ³dà A°X`èBˆÅ[E×)Ùª¬ð& arך;.ª¾y¥$´#g¬ïÜœÿûRÄm€ ÑLì0îcžªå‡©Öýu'6¤—ú“jÔ+q0.,Ýüsýõ T~c¥–Ø—?Üø8 z¿òô×è(,»ð ¤×§Žþzn:V®«„ø.OìD!×gH“Ñv{ÈÞì¶Obï„ÂE–{¢ÜDÑ®&k´ kìçШP—ÜÂ2#*î…¤4–u£-ôØLÌ¥´©ù ©ÇjDú|¾ýýaÊr‘ÖJ²xF Ò‘"00g¤a;#œ>J$ðQšÓ#¡°ØåÿûRÄv€ Fl¬ó‰³§ë$õ'/Ñ× Z{÷kJ^imyÊL2…þw|\‘È/I(ÇÒ–UnTVuåf"¥”j8yz©&‰Q*’ T‚:õ¶&¶×ú/µ›»­ŽÉÈ3¢Ìq9‰…ó¦Y 1tP†Xní·ˆ¢”p—Aa&ç]—.K¹Ù›W›—N­b>—.rÑž±Õ\Heç~"t{SÜ̾73JËÌÅ¡<’Ô£Ëì« ú YýZ'°µ©5®~êmóžÕTÏR³…ÿûRÄjqGLì!¶‰´§*ôõ¶˜z™WMªË œ2ëUÿZD¨Ði„ãh´ è'ŽÀ¬‘”¸®íW(4o1•1!ÀÄ Ë^ñ>º·ýÖúÎ<•´¾Pn¾cIx×øžþ–»q[kϼ}^߬C‰BÚµò?×È«Àw)|¥[¬§Ïÿþ&ßHôCP`(Î ¯ÿ©[á Õl”¥:~ž‡êž4DB±ýÌ4˜|Y~}Ê¿¹Êgî;L±Hõëoÿœ–fU±p:eˆ•@éÿA¾(Ì"±ÔìÿûRÄW€ 99g§ ¾q³§,hö5¾ú#XòR*&Õ›+]í·ìÿêú‰˵¢êI™Ë倨@: ¼gü­ À%¿×ƒLK a®%éu¹ õyn+€³‚Õl¡hAÞÇôˆÂ¶M¾¿›«Þ9ºÑkM’{­Õ¸ÌS )€ûß–Ic „wÀ”I¾ˆ®õÇÙô}©iÛVŒÒDY"¤ÄšÔ¶ÕKìë–´ ÿôpz³–J ƒ Ì"·gtšâ(ÌÇdî”H¨²1¬ûuL.DÕ#þßÚ±óp¥Cn°ðœ7–¢ù.p¶!èTtJí•u‘Ú³JPR7"Z²Ñö©7EV6¡`‡WDwvvuR¤ÿûRÄE€ ÔÏJl öI¨«dóÖKßþz9pD‡fè”H]Ë_ô¾ßÿõtô¿DZö¼µÇªWôù7IŠÃN!4PЪ.§½šêÝdKüS„6]/g)@oLˆÞÛÏ"= ¢v•gS?|Ü©)Œî¥ßÑ2°ü…md"¯Ü®º..ƒöRJ>© BÒƒµ´Å¡æ>6$c©™˜jß­«¤lCu)±`Iúkþæ¨5d …fÝŠ"Ý&«Â¥çûoö!}ÿI„ÍGco2§6›ÿÿûRÄO dÓaG™N±Y§+$ô'™ý×w\¡ Æ¿º¸Äè"”‹–3ŸMzèa ½Î%¸vqZ›ŒÔ½båz{=a;Á ,iIF•h>¡T§B3ÒÓÙ sŽEWÎó…Ħ)Çõ¢/"×§]véÿþQ¾–÷Ê5Qp*lA1 éˆYP‰Þ`¦L&/#õ>†Â©#Xƒ­MMÖ½º[ë|Îl0ùí…ëx¨}¸‡UεrëyáB Ù•ÖÆf¢ù®ô7?P¾?çÏÿûRÄX ±7FLµVAr'i „*Ðÿþ¤E¾Ÿô.ƒ)€–ëš7ú®V1°ûÏÇŽ™¯$¦§hPÐÚ‘j¹JKŽŠæ,£ùX!šX£™®Í@&ÎRRšìí%Îd«íM ‰Èǵyê¦@×½ ÿ·ëÿQͳ¨©èxëè7µ¿èKàPÆp&À†¹*ÌÔ*¾ >Å£<Xpá^Ë×z¯ƒbVé5«˜gc)þ]­3ù`¿ÿ4û½¨ë9ÊTVJ\\k»&qúЪmÛkv ûäúíÓôýÿûRÄY ¡9PìµPr£(—ªÈT·G“ÿÿU%ФA^Ø,$)Z¯¼ì/¤„ñOR¡òã2ìvÅÚ¢HBB)!W¥“L Y|õ;•{0& ‘Ï9Èt¹Ñxã*¢Ú”¼áˆ©CßJ²ÿÿþCúÿÊ¿#ÿ÷°*¿ÇÄÒ±4澫$ðħqÉì>’KN°a km¾Â÷΀íBmv=§¾¡ÍÝSúß5@| ¢/&Åæw>nÄñ±R”ÔÍ›R‚ÔÚÛþÿÿç?Kª´nñÿûRÄZ 7UGåLAv'*éƒ5ÑÏê[ø¼£E¸|+<+ÉñlÅ~Ч¥ÔN%@G¨¼]BÝçO&¥íõ›6e–#RÑ|àªó;"jÚOc„µ5¦™ôY‘QÕ˜a{%^êY××ÿþ™ï«þ` bâNŒ“Ò$3ŒÓC ³ô‚íN°¶×DŒ…¸B0Ò·çöè¸×î(Å5±Old fUŽ&5˜{~TBrÙŒvU$EÔA›uôyÄ(`—Uj¡óµÿÿîU¾ÿîQñuÕ@¤-ŽÿûRÄ] 5gF ®±g&«dô©æÑN‰lÐY¨QŽã(€#Ö&ôøÂE LøŠèrât {M|^^ªœÿœ'w5 3)yE˜èísVªTïÿ ¸­«Øÿ÷×Q·&î%e^°ï¬GÀÆ4ÓBûGÞhn)•3ø V‘¨àâΓѯtÍr ];&âþ/eÉÄÞèy–§¨UN:ê‰*E›tÑÒØPlýÕ©úÿ•·îZ•$Fmãe0‹4ò<`tªèâ‡j¼•Áæ#4ÿûRÄa€ ‘O¬ PAK"hÉ•èQÅ/mbr×_éÖÎ>¦ZÕ¶qÔø/)t’jÞî¥Ó4fCïò"M}ý—ÿõ±² ³/è3U`õ5²À:ÈÀáÀ| Ÿ ËbA5,%'’ë hOÜ-Ùe‚ΰ2%êhz ‚~«J™YÈ.×í^®ƒ¦´ÄûÕLQÓo}”;øØ•‰†ŒœÉªì鮂d1 &…lªtÍv˜{T¬T?ç,>Í)»>Óx±CоoêªzpJê/êÿûRÄk€ a§±£ñCë$ÁžVí'îÀ±ÓsTæs_ã±ú:Rqûÿý?/44âÔVù 9MRM4èN‘È­$ìÆÀ˜H<¯RÔcÁ`Ø&I¥ã.9ª®¯`¬K!yó7kê 3 ¡—Ѫ#—õ_õfÿ·drßþÿýþcìù‡…E­M€šYTCÆ¢ ( ð7¸DgãMª {`mUÒ C»(µg°ëß 83¦è,ý=žÈ,š)‘«6IIUŒB?”ÕÿÔÓ×foMöRÇ#ÿûRÄv€ ðÓY' SqD§*¨ö(³}ûÿœªËAz HÌ¿@¦Dh2ÛjÂUÄÖ>®Ü¾©@e@ºôSü'ÄÈaT¸üºæÂÆ/'G™O•OÜùÖÎmÂx¾§Õ9”?öÓmAlÔN¿ôÿÿGú%kÖ^k|ª`@ £’z&_xQø*n[M3™¹„B; á«zHÀÍGÖµõž ürŽ{dFP 5\q•z ©,Þÿ¢ž_ßß1Ý€aôØäÿê~AϸY¿ÿÔŠ±ÿûRÄ„ u9Hì1®P'ktôª ±- ÖÒ`ô´Ë'Q èj²X°¢®¯BÐ*9jÒØÅ0iå\Y•4ö2i_Â&ÆÃG&¥Ü騇ö™™Úœ®¨ôzŽ5R(¶4úÑ·Íÿ¯KŠ›l—}q;7Emé`’¸­‰0*‹Âx‰Lĸòv/JEªVÚŒ¢š›KgS©ºM ·E6ï¬mV–Úž®N1ýìi¤šªY‚lµ}oô‹î´[èëu¨v«AzZÿêÿ:•|¡5r6å» õpÎÂjÅe0 }áŸËŠ‚J2’9Ö[Öÿwx¡m¤%¿këV ½”Ýû¨˜‘m~Ýf ÎÍç##(P4{aíÑ/’Òu—ù4ˆ1H ¨ °(‚(Å“\lžiªAý Y¹µ¡Dç A©£] e‰ÔÁ€I2FwÿÏgZ/F”8—ÿÿûRÄ•€ yY' ñq/èÉ„µÐÑ]Ýë“‹—E¾Þ¾.7Þ‹Vhè#%ÖŠÓl±2Š/ízF¦dÎ & cÈaZÖ~b²6c5ó§¶&î>jEs˜¤ÀPp¶µý¨Iœ|õTZZÓ㨻Ûý~ª’2A Ûíà© AF?EZ£„ aO²5a¾(U“¡/2ޝü`bF\¢÷kéñàó¦z{ÂY臿÷µ¶å­Õs¥‚ü×êgô¿òòõv˜„ÐãÈÿûRÄ£€ äÑSL,ï!C(”©ÐL¨i$þL!fr&S±Pv’Ó\ƃ½@ÃÝÓoi‡;M‘©¹¨Ç˜ËFŽéIZ…C}ªû»r¤Ïë=*ÊÍŠ®³ßý]ä×h0 $ň…\Ç$±¦Jí¸:Ä•MTÀä:˜R ï‘©ñ·ëXŸ\pÛ¨ÓÓ_×ëÛÜ5IKöS†ÍF<ó ÐŸ¬ýóLYÙ¾vÌÆ‚?¶¿™Ûÿå¥ýRj†"µ:ƒÃ0€aòI†Epª¼r˜Â€ˆ ÿûRı€ ¼ÍYG˜î±>)ôó¢ 8XíN!,³n×%»ÝÔ•‚C¿›ðk_¨ûÖ¢Îãº;1SèbN¸ 2”Oè¤.wæî©ÃÙÿgõ»o’ÿÿQI¸Äh³%&¥ tþC`1ÂL¯í«¼ã[ãôêM9Dÿß×Èaæ¥í;ÿ’JþuÈZŽ8釼FÒL–!Ç_ËfRÛêøO™ÿ_ÿò¿­ïù ¿ÿ]TÈ$²zø=+oì¹Ñ \f~†ÝÉa*Là–H2ã˜_,¨üÿûRÄÀ (ÑB̼çÁh£(§è âYr,n¿ÍfÎ"³'پaEMöÍÌu€ý+]oÿÖ'š»ÝqÁ€áßwò¾Ðm€ˆ*;&d% ä³¢‹…)dÜP`2#Q]sòªåòùP–½ýlΨ¿uO_µ3¦ÊsšˆÔ8û<^-;?~ÿÿòOÓþP¿ÿÒ@GPˆ_Œø=LF9Šñ"u$žmlU¢/­]i­÷ i\óªdŒ²©$çúW³È5ÝÿûRÄÈ€ 4ÑB,¼öAT',´õž6£Ð ¥È‹šã6‘)ÿ&_MNÛ*(úÿÿÿó¿uݯË=jÑÿ¤@ìC¤2W‡¤à¹-´ûÄñ|-½TeX|Aî”ÚI‹ëiå¬c9²'V“ú÷óídB¬@EÏdšŸþ¹ça\jÁ¨œ¾ïä¿­Õ@\™p24§Â)4çK¯\‚?Æ©§©¾%™RH—Í^¡èx¾H°|1{zïYÿû­c‚ˆµ«ëZø×¾JáÓ—Íkh£vôÿûRÄÏ q9U¬(ðM§*´Ì©m,º ^½?ÿÿõÿýäl[lDÂ4”ó²UІÈz+“ŒÇ²Ãdäi*›"Ú˜Ž´«Ì/ªê솠gÑËÕPê j±ûgA}å׬÷Bq»3ær÷Ø6¥á E”ÓþÿãœZ¸éŸÚÅù|c(γ«HZúz%‘¶söá®§mË­\ï1ª"‡­Ó¶ÚñùM[(ÉÍkQøW’¥ž‚O1E75\G ˜ŽÇŽORG›2˜Ä˜¾ôÕÕ×ÑUÿûRÄÕ€ Ù9U§áLA>š)–ª M»iOù„X] °±Æu‡¥åc¸M®V±ÎÀp,M…jF0 %–phÈù²âEI¡(z½o½¯P£Ù#‰­mKXu2”œSjEñýûkÖ‚Q±Ò¼wà&~ªd©Z–ðp'‘¼ŽÆÂKbj’Y¸ä‚GÂMábñ®˜ üº*G¥{2aºû)# Ù*ê ï š¢´&µ¢ÛÀØ}kUÍc ($NE;k×(³ºÿûRÄà Å5F̽O‰K«ôó².YJ¯Ú¨Òæ/ÌÊá˜l ÿJÅ’±U©Šfñ¸1éö›G]b¡†¨Ûk:L 1XsÁv£Aç¶G¿ì¥@¨bY&ýÏ8xcý¿ï!K{ýF†f¼>Ï–sÿ7 "˜F€8A•Ú‚.¼Û“gQ&²ß°´BÏ^Õ#ÝÜ\íËáÊÛ`….۽¿ÿׂFï¬UöX÷ÛžZÏd%|º—Ykß“öÜ^ÇzèÇÏU( o?Oü`cÿûRÄé€ Ù7Y'´õ1_è©”µÐ§ý*x©TŸŸw˜¦ErBYú ºD*’a¶HdˆNÁ`TB#‰eX¥á?à$"Ç= Ó0vçw€{í©œÎNGÇΗ…¡§%*¦(ÆK²°W=Þ¯Nª°âžoJæ#!],—&¢ëÊ¿ÿò&û™Sè|ò¤‘ùoÚ)8Ðbd±Ì•ò/,Çü6BrÃeþÀ){?(˜õæ¿JÌSÐèÿü`ž9e‘Zb†ÂÃø‹²]÷­:Ì ÅÑ­µ/ÿûRÄè Ù5Q&4öù4š(I”*e <{hÌ5ÿþ{é¢ô¦Œ¥” ïU(º¢ O¿^ ¬þ¹ ~t)ç£ÒO ì¦ÉPIj=Èû, Ô—mBŸí¯êm¹ö1JÚÎ)1êEÜãÝç­J Õhb’1“QOu½L×uù3}|öûvùļ1ÒBÒ‰2”€ Xg»C¦ŽMT±Bù˜ér¥”ÖÞˆ­ËT¹ë¹á2!ÞÆ“9ÇþeU%DOÙf-ï<;™™öè];§ÑŽšÿûRÄìyG@ì=OÁ‰'jäÇ«"ŇidïõðÏ¢7,XZf$ÀQ‚LàlOC=Œ–× EHÐ÷$ÊT/ lIökÚ‹!:)ædß(I¶­ó …C¿üŘLk%N2…Œ2’Õ«¯}5žšäÞMeì‘ú² tÕIÇ(-³ÝAìi\‡%丶¿E(ZU¨J–ƒÜtFqƱ„Ýhá_÷—Â^ÙŽûÒ/Ý~‚[l´×—fÄÇ7b­^ìÇ"©¶<ÖvG9P®o=UŽ´,d~ëîÿûRÄß ­9a§¡±qj'jõ†©ø’J”¦ÞÌ#¬ Ð@ ,2ÿTì¹g¹î´ÝˆœbÝYl¡å> *á?36g•©ko5è4ÃJ÷1Qóª‰#¶†è³61 ZÔïöduK:¬…゜™ÈV­ozŒWéÿDú7­cãG|nbÎ=´ÔO)I2r€HÄ&fU8ïc7I~K‹Â]w=òïéßÞ÷q¦5o”žšÏÊ…ï3gEÕˆ#MÛí‘…öµìß0jfŸþ@èü»´5£`ÿûRÄá ˜ÑH­™Ve§-4ö)ÖëÅ›YJ6‚l'“ ò|#%b”‡.Ñ+¥;¨r$*YƒP q…›'kU­ÆÄq¦DÔ}·xÌ™îÔS'ëhoVûš€Ë!¯"{bô–™Ûêoÿû¶)vÑúÐÆ>ýJ`4“Ž ë@hŒ¶Ï#Ó =pÎUo¶‰q÷«êvÇXj9J:ÄD*()TRÒëÈÒÛ)4ZÊwd Á)Òá35AžÉ#h„[3¦‡Õ\ÓÃ$§2>Ÿ©¿M~¬ÿûRÄç€ ù7c‡¨¹1‰&éi§ªuZþåMGcè¸b‡À`eS7¡ºÝ¶å@vÚöìTÅ¢>Ùíæo_ühgÿçßvºÿûRÄê ½3GMHñgª)„©Òg»”y¯ÖŸÑhg¡ËD O.âÏÕ|£OüG[È;­×+N˜Îf‰¼˜ÔS f¹RH[=¾”õ77½PZç/þ×”Sy¢1 2 ”7ÈßoTED×›¢P.Þ¯–Õ_ų×.”™WõUvÐLé•áƒØí±'eü~ãô”72𤙔¢H„VI93úÞüƒ7\™Ë(µ1‘# ˜eç#Ó=Ï”ß_ü²¦žö[š3¡™9'¦þ·ÿûRÄè õ5CL%Nt¦ªðò‹Vok²/ݤƒA"iµÂÅ–¼=+k·fƒw$©¦QÐ\Ò½v±¯~¹sEëó¦‘êY·•_.ì@É1g§9‰^ù‹ 5Ÿ[u‘r€U3ÔÍ;e?ù_ÐæNü»Yr”•®û¨–¨ë‰À=‚!@p« eõë·‹¦ol1Lebvú·œ¦j~ÎDÞ­UB!ÚQWor,¶h:M ò3¸š†ZBò; Ñtô s—}Ë‚S¾¯úaÄõnÕ%AI­1:ý¾ÔRfêFƒéÒZ™ênæ ;Ÿj–µóvnÿûRÄç€KÉ7o§µT1r¦í´öŠîGÿX:Á =ÜGš«;-Õû…Ç‹’NÊ]l‘Càðlþrõ¬¥P‹‡ $ñ o¾—¬š¯ êM4fL°êËQ’•©IfI14Ù%¦‰“V~·˜ }©ºæåEïm¬—®¯vI®êú.B;ÖVÐ%eÛq]_ÖçÖkCï5MT‘RE ‰‡ÄGט­£Ýê Nï¿û|Å5[=ˆ—wiUÿ`,cÞ½Ñ_´Ý»&…þŸôý?|ÿûRÄè 9i§±Nù…¦éµ–ª0©¬÷’tC_ûˆ-ßT©ôÃ!Øn|®·9¨"Žb0)0tˆ¢RwM; YÙï «ÎDº_ü`ÝU©´1L ùÿe@`žL÷¼÷eB2»††½Ö«UÆN{ûüä”o§]™ÈL+!IN¢H ™'ÊÓ€¥Z=ŒnÙwFƒœbÁG^ή׺èÊuÅf¥s=µ—}ÀôõüýÅÔ­ÛÓè#Îîzûù´ˆfOñý¨Ñ´8E‰c4$™gÿÿûRÄå ”ñM¬1®¡&©)¦žp@aÚ¥á Ã÷it(7´“†3?" ÷~ )õ¨Ò+ǧ#Õ tsKR%èçšWùÏ}Ü“ø™õø¦5cCy¾õ¸w»fñI"7‘³_š¶¾½íš) OÚ­½¬Ç‚i]ý7ÿê>§Ú:>ã÷Ò -’…PDŠE7ú;ŽAè/Z‚!ñBªhn]tÝ—–PçA\ñŽwo[.)4MÒ&#c¦æb` åÄÍT‚-37[¡‡‚¢CžR:ÿûRÄä€ ¡3M¬¬îA‡¦i)…©áÊ@Á8.5/8Kñ/öÿf/£0á%§ƒE„ª2%—ìj3$†ïC}¯95²¯@H×:â~i%‹’nÆÆ‹0YI!îl󕜩 ¬@Môóš™Ý ÅB?S'¯Gj ¦§FÌÊYN­¦wk’ôí½ ™Þ5ÔZÙf}>¤"ÌO •ré¶WI.ŽE.Rᤚ§S`=òZ‡bÐîáöòØÆµÂüD94…{x<óŒò$sÐðÁ÷²ößBÿûRÄç àßW§¥j±‡¢©)‡øUsDnõíÿ"í¯ü¡ßt²ÕÖ„Ïj†áúí¶‡"’(”!Ñh.J5†Årî¤]ô™Ô> µZæî¿>^Äi.j!“(…Ë/S+¡–?‹}~Ï)éITºÌ…§}½+úÎü­/c'Í»Ò*Žk¨n(ä‰@Øh¡ŠÔ""9©rȶ¨zªaÛiH®T¾«ȨÛ窖Óê/—±6z)´“˜P×,ô}]o»ï›£ÈôÏ_ý?ÿûRÄä€ ÑGM4ñ“¢èÝ–ª1ÿ×óLõVBæ@Ò¶î–…n(“ ÈC!^LNÀìSFW¿R(Ò·\ I8ýT¤»:©úTIÙÖ·ý×ü‹¯Ók0 ©¿)£Òìkž¦µ‡+*Ó9©V×èGÿïÿ›ô¸ò*ŽkèV·#Ž@l˲ôMÖ‹›ao>WËñð«RôÃþï> 9ôvËt–` 1»´þÉ%Ìz®ë6ÐJ´«†Ýëߪ£GvïcšÍÖ qV¼öúø|QÕ²­š”…³ÿûRÄÜ€ q9MM=NÁ^§-´õïÍ:ªLí ˜£&jµBò‰@%ßË*C‘ÈbYVƒ)[lraÛÉ©2 [µ3ŒÅß(û`”¼…ì¶\\Nµ‹ÙdícäFù|Ý5&v¢{ÕÞ@#XõɿЄïê;ýhmë r¹+­À’:Ž¡B€É?SébدnŠ‹é‡kîyÞTy檌\XØaåå}ò܈O«ÌìÄäIùçʉñîåß6‘z{NµªWÆŠ"Ìõ9ÝôΓ²þèÿûRÄà€ ±7o§…P1[&ítõö]ÿc­c±ó[IQ¤ ƒoQ!;E(Ðq“ ŠTë\ûˆ¿Âg¯»üXkX-y”û÷êÍŠy÷9J›Sj”9ý¿|·Ù”²,ÓF€Ò¨Õ5ìæùatõ–N™M}âÔÕ å“Ò÷—­gÉáëZ|[Rº'­´Ø¶ÈZ¯¹^Æ QO2Œµšl£#*xŒ “VR$RÓ™”~]’Ì.F¹¹Ñ$[&z¥X«= @ÀEœ•(íü\L¿þæ «ÿLÿûRÄè 9k§´³ñT(U§©ØÙ;ˆ’/éIÙXv¹#­±)¬h®‰¬2åBVæn! ô_¢HÆTƵtÈçIÎ^¸)­„oÓŽ”šÈ}Å}U+bPÒíÿ~Ò¢ ØâÖc%|nˆ‚Q›XæDéqæ¯ÿž<ê~HÒUÄЃdÀÑÝv -±&[z×Z@øömÍ €´‹ ½A[µOÞs§Œ™îäå{TMí/Z ãÌ3gB¥…˜ëVÓÙÚîT«P¥9„}—þ¥ÿþ¬sý—IÿûRÄé€ ©5g§­QñCžkpñž”…6Ô6ä$B¤Üƒ²Æp˜–(fTËmÉøë–|*ðM¨Ç»T.Öwljƒ,&°=fÝzWrM¨c‚ÞK¢l6Mj¶{rà çë¡û»ôïÿ(ßÿ¹ñ÷Þö0ÂW­Ú•@ÆÙi 0!9Ù43gíJ]Ѱ «•ÍLo ¡ô¬¤eÃGº¯xyiŸ&îHËP*q“tc˜õ@peÑ—µ j›;©š½A±¶£ÿÊÿù=kÿûRÄì Á5CM5Py",4ô®N‚qˆFÍo‹@q׳³så.Ô² ¿RO²îÔE'æÑ_§Ân›§g’‰1íú«uÔÇP&9äÏ;¦œ4DP7znµ51J ëzÖ¦˜Laå÷sÖ†}w_öªÿ<ÍLg•/Tú±Ûi–ái@;LSÜ:ùC!˜jer¡yˆ{d¨$¼µVZEÙ¯¶ ²pK¨{1ªØ|xJ°»GÒ¢¡3š°eJIxú 3Yúñ7m¡YŠ×4EaŒ×kïñ{}ôts‘Au’»7Ôwöÿ”ûº¢ÿûRÄå põW‡µRq©§iõ–ªh÷œTÕ-¥ =mJºlL.$8×¹m$*ä|R"@rX©„8x«ÙjôŽËõMòߎ`¦& žm¯V¡$7Ñj(:5ÝŽ!–‹N²—§ïÏçef@Ûz¯üf<{iÛå¦9´uÍÙ¨*]3ûê*rkèr&ë­@¼„t8ê¯O$G-uÜõ=̋ɫ´ïnæ7IÈÐ}« 3JhôQd3Áá#ˆ)hŸR¹‰ó¢h@@h\6‚Øðé…5»6:Ì”Œ©j;Z”´ÂjoQ¤eÈË#šÌ–bÿûRÄè Ñ7E¬,ïi¦é4õžØj:i¯\\TäD—¹)¦9õµ•)Ú£oÓþcý[Ñ4¤¢Ü•±1¿nÄÔ1& ¢Á š>pVÊ‚’CÑÁD°‡ËÇ®àÐ_¼«ªÅÌAXÍO2‹µvÅ—c$]V²k_ö2¡’„Ͳº¢²Üñ0ëËæêØ³¿ÿœÝ'=5V¹g]®R”–8_H¢-ÏÝ‹VcaÙ^pÄ„–¢SQEé–o ¼*eÎâ\Ì+„‘ç¥÷™ß6<²žaÒlÝÿûRÄã }SG­²‘¡§,ôø*nŒw¡4÷Íûr†=´;ZˆÅÝ}÷ùCûýÞü¨¿e ¦š0Hh!¦1¯¶¶Æ…¥ooT)¢" Gor¾‚Ýf™œÝì§f.èT ƪXÆì³Ìá¥ODµ—}+rÌ•äôÿöýßú Q 5`ÛÐh‚a²pª,}ìi›z+YŒÐOW»Á6†"l„«Zôÿ4šÂéjªW¾š…†Î¡›»¡Ç(hKnßD¸Å«¡-œ–AdÛ÷Ó÷üïøþŸiµÿûRÄÚ€ M7YGµRÑm'm4Æž–ØÁy§´I@ŽÇÃTlͲ´7¶ŸWòŠ9ž™µW*¨1·äV;D³=E«Š€|C½é<ÄåɽÜtä&æj-Ì€¶ï¦ý}ô™s*ßÃ}·j†œÎ÷;w¦¨¶ÿó %~oj Ý 0Á»Ö¥¤À‘’…fÌIÇQ›SXFãxÕÎ ›A¡3¸+ª­<È|d~Ê¡¤c­¾N»)%u±ÅÃðÁ›­-ûlË]{%œEH:5ѶÝÿûRÄÙ€ ´óG,½OA'*¨õ jõ*u]Ü>u™8™9­‚HcжÂèßrgs u8¡CS*X›"넃*--³VƒLÐ*ѹ63]›­õ§g‰„Bþ¯ó^"ŒСYBè㥎 bgtækŽŽýBP=€U=Gè®ðñ'S]tV´)ðŽ7'rž!LûAåW¡)Q¥Ó(/¦8f”H°;ÃÍ(¿eñxT·O¡Ì¤<êæ?ÿz4]L¤èu¢¸J-mÿ÷Ÿÿ¨çBµº,ÊèÂÿûRÄä€ ù5M¬%N›&é5…ž8Š`uîmsì9 ²˜æ9ª<ÏG ð™E¢‡Ãs×/H2ÖR)ž’u—-~”yɃ‡~,cICèʘߟïù@ —¶Ûïù½lw–ìÜ‹!L¢)ÿó úþS7ÙÚô:³Q‡”¾õW¸tžAhRr“§7g²‘XÎȺl¼bÌUªGöx“¾'ÿ_®‰¯ð÷\3f—ûß̃²'Ú„™)bŠ\¥"†ªL’¢øæì¬2ÇS¤Å˜Æ” ç¾_?¾Kó?ÿûRÄã ñGL°ATìtô^Ø£}];´Ò·®ý)½l2S%mΨÆ9ÁDJpçQòÙ•sh"Àa!1}vå­«\ûwý-ùùp§‡O½x€O¸Û©œÈ<­‡§è@f3i‹ý e=•÷ZgÈiÖú¶ÕÑÿÿ·Ó×u¤Ö4u¶û©¶šë­ÀT›ä¼$Ý*Îçp嬻\£ÌËŒýàî@2_†aõT°§Ð–‡ †vt ‚Å¿º$©y[]¨Ç»ÞŸ®”òöþÛz_mŒt,ÞÿûRÄê E7E¬,¯Iy§+tõ‰þÑfÞ¶ X†&Šfi|G˜†¼s”È<Ìø‰_\ÃÉìb* "òµ2})2ŠOŽº¨ @1DçÔ.+(²EÓîT‹1Å(q"Ù*z›DR·8ÆeåU .xÈsO¤Š§÷Èûú·½û_BC¶¿ù¿ª–ÿ¨²(ê©@‘D¢ xêôR„·¢á5èçíå0¡'±YìÛ^¡C+0üÿ°;È4Tì9£ÔGvÓç«>AÑ©¦Î'€s×·ý¿ÿ¨ÿïÿûRÄç€ -9Q'¤úqx§l´ó7FÕª®&#r^f Ö²€PJg[×!Ïy!˜‡¤ý ý3#˜*dÀFÃÇ­"ù+œ“ï“2S-¶€2ùÊiq†¥37èÞ®®¨é¹]Ë!ª‡ @mÚbçsr}z~ù'Ø­£©rGçK³·ò¤M%Ž1•¶s­Cú¡—LP}jƒA|Kqª­Vök‰zzãéýÇ¥˜™³®ÙàR/ó5ï"W•ï;8­ìŽÆs*F ~þ­ÿýÿÑlÿûRÄå€ ‘9q§•Qq¦§,töªnU56a*XL)k9y )ÇAþA¯ÄÄaÞe"eYŽYÎË©éù#·¬9؈ðÌ4CŠ~ü¶IºµN5@i¾wÒ‘u¢”¡ÏcÑA-“=«ø¼—êßåóËU9ÃÙýWMéh2¢lÀ„$<‹b IKÊ…ß.'äÃ?g1t±:Í•®ÿ(>ԕИ벪˜TF¿-›õ&ÒÞË1ÝBÔVY!Œjg¢·ÿùÏû”oZ£|ÿûRÄä 9o§´ð±…'ju‡©Ù8÷û—Djˆ@mY8jÛ.‡ÙÝ¥*ž“³Ø‚fŠ*H8|#v‹ÖÚI1ÃGeÜâ§N—zJ¬ÈaÓ[*#ô©hyS.ù*&IR±(„$,’ñV½ºY—þš‡h‘–¥„ïr )mû¨M–≰F‰é¬(̫ԹšÄä\šL&Ò‚¥@ñ|·O\M›-M†ýé›ÒÖ¶ò`œº ù çñÂJ%=7üë…Hš™¦òNWsÆÅ•j¯Ð‰èoîûJ.ûÿûRÄå i9KM=NÁš¦é5¡ªÁâExïX‰I¥¡JœŠ7ò9"ºægk¤óÆsÄÀ©ðÿKZóG½(Œ‰¹>Š$*ü‘ÿcY£ $sYöuÕ‡ï]_êÊJ¥ßs,J:8nö}›Ucn÷ÿTC¿Šò'„*Zöó†| Ji w8â°ÜNÜO-åLsÝ^x.â.´º5•*¯‡j”·Q©jÀň`}ÑÜÓjBœ U•ÑÍFóg‰…O¿û\Tëky³PÆ@$7ºO7Z&Ìwªÿe={'ÿûRÄæ %We¦(uñ~¢©5¦– ùņVßH´Dj4Ì“ IÄËRÅ¿—¶Ðäf?Z¤—€Lºçi¢9¾ÌߪžïzÀ,<Ã…Ó¦]Ã?ô‡õöê?ç¨2Qó¿èa1úw²¬\î>Wþ 'fL,“1sDUØÐM$A•†X¤ß,b¬~%ótïE׌6RCj´ß|ã Õàþš@¸Îf΃Ná'v¨«Èú-Z„ÈûiÉ3Þé4še3fÝ?ÿÿ oœ^ÊÿûRÄç€ Ù_§´òñxª¬tõöî„„Ä­"ÌÉÌŰFƒ&Ç.Ühg´2—fI)Ξ=02i\áˆ!£sšçÈð‹”Ÿ‡lçBO.gއâs›cuš½†9޾†}Œ² í½ˆUÕXö˜¿ó_ÿùÅ©ÙçQ‘"ôÿ-žîªk`rŠ«ÖQ»Pû©Qò†åÜ=bä¨q'e(’‘¦æQ­zäJÅŒø,ñG&1SµCרâRs(Ê¥OA(,fèÛØ¾PÖ9ŽÿûRÄç •3a§´ô1w(µ…–X1y;Õ’ÉäMôO”íÿämô;*nÿ¨š©#@¾¹|/“«çrãõÖà#É? r‰#;ŒXî€M4Áí_ÑAp¾kÚˆú<¸r§}såè”w?$y¶ÀX‰˜Ã köÏÿúBß}\è§Ø4×…R*nk`R?Ùaò:J3…f)¢‡©PÚ©çeÆ3c5¦´ykº'Gú€?Œ¸òòN»­öËh‚eñ¶:rßw@¸qÔþ/¹õi§ÿûRÄç€ ;G¬@²áš&éµ—ž%z§ò9ÌŒÿBÏW_nÈÀ°»Q#èÃÈôsBSD-NõÚ€„`"ixG_™ ~íJî߈|ïdÓüƒŽª¸ªdµµ•0ï5ÀXû^[M ©fZMí²(:zN×^i‘€l{QHNœNþ# ©ÿ“ÿý‘|ùÈÖCq0r¤Sí`VD±ÍQmS ’U… ZQ+Ôâ(ðaŠSÕÖrfi\iÑ ø1õkãÙ}¬µÛ6µÌd&gÿûRÄဠ!9Q¬5T'm´õ~f«Íü\LÞ3ð𔬥:ÏãF¸:. ÷Þ‹’Àœêa…À¶E@ ‹µ¦v¥€WžéÃ~ÀíJ1”®yÁRá««»·Ê6¾5XÜ0¯c&%[²6Ú+³J¶Uï¦ÆP‰.cäTvÁ×çü#£sJ±W,›ÀcA]uIÇ J=Ø X$¡h¼´ÁÓÝ^ðq¦XR‘/"¶ÂCIÎ|ÀDñàÖ\÷Ê\DZ¢hÅS"ZÜÈÂêµÜëÝ5šM¥ÿûRÄÞ€ é;c‡¬óy{&éµ·©Ð±æ\ÂðÄ[cØz_z_¿–ÎüDþtnAªÉ‰¥dr¦nãNNÀu¡·bW¾ÇtI(±Ç kÞ[øð{üøˆ¢m—}½TCµ‘Î=ëZ¡ nö;íó‡U1÷¢, ˜ËV!z_ÑYÿö¹KWû¦ƒ"aâ©v…)FÐ)#F`ë¬xÊzeÖft0t"ÎÄ¢H‡!tj»ÎØmKÄΠ|C8¯õí?ÿ^ðq4ArŒÍsеe7ÿí?}û¢ÿûRÄÙ€ ¼Ñe§¡S1nëpñªÆ E_GÿÓÿó¯ÿ¹ùwÈd`6†ßCbnp[,^1©k£?Ð ÿȤ½*ïFkl|*Xb,œé‹ÂÆÅ­Íž– ƾ´9©†GÿPÓ__j´Â uýç*¡5_½Ëí7÷ˆ­ IÙ°6&¤©°ºH”ÖÌåݵrl'‘Á”%_§©2·}f²ÉÖh³õ7Òú¾‰ÔA¬#².ægV‰¥%¡tËìA"ªË††Ö\ÕQOB3£Õ¿ÿûRÄÞ€ ˜ÑY†=lqs&©µ—©ÜóšßóFÚ]:7©S#æ)Úœ×p¬r×cfYГÓ†ê¸CœÄ⯦gK§jDj§wdPÚ©¬ëíxkf¦gÙÓaµ,oQ#àSïÿìO)Úê÷RJrùë÷þºÿÿÑOú¾·2ý5j}°–Ijj@_RF¬ÀÄž'‘ ÑbZŒO·YHwóù¿¾'™ö­“cMzl5„Ông#.xÿÙÏ(€ÈïÓúN/ÿ3vV Õ÷ÿ˜ÔzŸ®Óz'ÿûRÄ߀ ™5QŒ<ïyV¦i5¥0,Êmg±ô( nµÄPa9€Ñ‘3'Dž/R¥YZã±6ÀpŒ/‘zƒì¿ÆõõZµÃËC´ÝŸ~Oï¼ñíòícÕXÆ$1·îíýO9îÿ>„ ê1…ÙPÊ$þ·nŸ¶ ¶Uœ:ÌmÄÍ?è²øÔ‚j5ill%u‰Ñ£ˆÙ*bé‰=JkKYÑäârYx¬¡¤è<ÿ!qª"*S ²Ñ¢8§öûÝ‹%žsèÎ3R#ÿûRÄè ý5]§Àõqb&ìtõµö@ˆ˜á±’›Ÿ¯ÿÒ´¨gÔ»Íd4ƒ,‹cÝÔÞ¾ ;¶$Wã… ¹(NÚu)—Ý2…²WÓëøÇ™¦´ýBåè¥ob=”Ä6Û+úê=cHÚjd6¹Ðá’¼¿{þŽìÚ6z°‚ãvšz?•âVU'ž%É I‰BùË"Ú¯iÖS¢fsB têç¨D ƒ…¦ÒcDP½{€ýË;ÿ=œÌ¥@zßW¯FÚz3¿Ls¡ÿûRÄå€ 7e¦5Vq¢'è¨õa`~cèÇg-]ßýD-óŒjؘñÐHðš¿þçvÞ‰£Ž(äžÑœÏ•l,¯˜VôÚÙúʪ 6Ëk•2iƒ ©ôµ5t§\#ùŸºGÕlmµ{tJc3›VûVÖ9rr„B6ªRÏ”Ø<-´o=W¸h ‚*¦©aBŽœ[SM«„-ê±=q춨ZcE&¾“ñ Rˆ ,ƒªNHiÔúŠÜ•T¶sniá‹ûT^v×;^¹ÿûRÄß Q7OFaLY}'ì0õ¿C·Ûþ{ý´ÑÏÆÍ*Sn„¼}hí8 Æ, “s…á+1´nÝŽ±™rÔÌùl#Êq}"ã³A“uÃÍÖÏ>¿¬mk3³hüŠNŽË}ôÔA¾ßd ¹ÕÚïÿ'ÿó¿¢;QÚ`èØc£<ÜH@nü pœ¶‡~9OÓÐêž³ [ ñ‚£SûÊe‘[)m‘³{X·gµ± _cѨïU@ô8mujùÔ%ÿ¶QÔA‚†RSP­ÿtÿûRÄÜ q;[‡¤î±t¦í´ó–5ßÍ£|Œ/‰¾ß›Bg=ÚÉ M“PY)r¼”?RÙamf²-ÔüÈ{]+v>¤Ä'kåFû³Û £ÚÙÉJˆBVÿí«ÓK= ©&8í_ÿfž©¦Ê¦K„ˆŠûùßbÛXŒ–X‘ÀU†Z(Ä‘ÄtžÇCTÚ㋺Lƒ•\VSö͸zË!*L{;›® UêršÎèÇ2‹ÄoOêÅ HK%6bÊpP†ÄïG z»U"õ;ÿm†WÿûRÄÝ€ ?OG¨uÙZ¦étö–¡Üº”L¤×J@rf*A¸m¼4dk¼ÐR4òMÃ²Š• ;ä«|N+Æ”±ö¥±ˆiäN^ŠÏQ ón¯]•Ôt<´Ïùüa‘¾‰» ;?¿Ø¿Õ¿ÆÅŸa†”h6Ä/9]6@@‚iPÝNÊ‹„øÂ¥TÄ7 ˜ ŽÆÐt`;@´ê^ÄÎf€,öy7Ã'š}¿Th!ânA»*!Î<Á›¯J#Óó’§<<÷«þè‡ÿÿ4iùïÚÿûRÄ〠Á7AM1O F¢i4ü« æ_ 'la·wmD <Ìœ„àÅ?¨Ÿ‡¯"ËúZmGkŸ>úÝçíaýZ2AP>XÏúÇ߀;>ÝO+㮕—“C—Æ¥óÍ Ç^ؾ#™|qZ`mÔȹgÿßÇñ[üW£„Ó5:ÃlÍ ŒGœÆvNm qãe0Ö…°¡J8Îiƒñ"ž®^)|2¹òôš¨Í[ñ…ÍÝÐ7ìïY€sÈ)Π浡Z :_‚YÆYÕÙ ž^‰ûux,ÿûRÄé€ II§±Nai¢¨õ…£>oü›}·ú¨Ã×9ö}œp`ÆF©{irï –Zd°™;ŽrðJ•Eñ $£q T]±‡µÁyrpÚA$‹§€ ÄR Piÿ¾MÐ{VÒ D·Óµ‘ªbÖ—Ïï;yÚþ¿ÿê[å· aÉJwU[ ™‚Zjw­Hbœ¥ŽF3³Wx:êZÍ@ö_·0§)AÖƒa!ÇÓ?[«Nòm+£fàhcmïs iû~ ýµüqÿí¦Tc|¤ÿûRÄé ù9A¬¬ï¸§k0õ£_Çê졪Š^¯ýn[­ °ÍL–ФƳS¤†j *¼RhœËã%­wª˜ë²jذÇ.Íú‰º ƃ¦‘T‘ àÈY«RÛÕL‰_dCßi\µšçéõ7ÿï –½™ÊÒ&Ñå•+ö6à P4€Šå¸Ü))L¹jÕíÀˆž´Óf#¶©^›øÈ¢ktÓDYÜÔÝEÚÖØ€åZŠÆI³¨ÓzB…1¢š©²ÖùÔT>‹úI²žÿûRÄà ÁGc§´óq\§,´ôV´>¢ÿÿ£ÿ_l™v_3Vú7#Ö0¥ÐñI´¡ÈR Ä$‘1§ „¢{Ñ8C[#Yñ$¡k؆£¾iWgý:Súo´àÔ_5ríÑkš"T ᣽rïÔì0&uFcÔ•+…3Ö…V½{ÿÿSŸèë틨ªsêrÿñ–(ìA!â‘b3Q0R$Œ”™ï!¯\b…f¡"òÏ·™‰ÖÇE'¡ª´‚îû'%Œ³CfD y)ªg«EUrÓ©úÿûRÄã -9Q¬´òA‚'¬´Åµ×„Ú×mtèÿÿÊô›Ž \I&h€`&“ÁA)4›(5Ñ,Ê*…³Iˆ;/µîÔ1Ð3 F«9¿ñ˜7=”Ó×!çœõ™Iuj—8G\ãfµ cahví—{~qßþÙ%‘<Ê>’ç£{ê ìÊ’†Èš±fÞU ¥”IîÌU­‰L7°!…4$ÓÎÌŠŽ:¨í妤ÃÚDÆ¡õ1§ÑÒÀ@ß7˜!{íó‰" ZÿûPÄä Ù9I¦j ‰y',´Æª¦K5É©ú¢ÿVÌÈ Pm#Ò@‰Ce])ˬ ²‹á8¶.·^\ I‡Ê´à©Ê‘X¦q·r¦l—¨f5yß“·SMïá|{œy!uJ£Ä¡3}MÜÙD@Ä„gúÿòŸ¶ÄÕNYXM†ã0˜'ìhA¾ú*a\ý•Ÿ2¶tˆ¡ŠÔ-Zbã@¸±ÐSúb©9 ŠÎ-7:ÈabmZæ.ëöY˜9½§Qw6¨á²„Ԇ曙YIªÊ}ÿûRÄß µ7k¦4óqv¦©´œ)þªWU¬ˆ‚ “Îù”¦±†Û}TÉèG‰"~”T’]U J쎦JŒcö”^ÿMx¼Vy•GÙ L ¶8λ;«Dp(¯Wúæ ºmû“1‡üí_ÿ¶ˆñ«÷x}ª Û¡–?¬ædàç46¼˜4BA–Ö³º9ȦF]£*Þ‘Ù¬mI¿—‚áˆV¹½ úÛ]JO-rQ; Eíþÿ'ÿØ÷(ÉÆ;Uè§êq×ÿú°Ð‡î¶0ÿûRÄß GLµNÁ?"+ôÇ©Žøé!ñ°ªl¤ÜbD“KÃ`N¡<|l”n½ZÛ£>§!!{ÂãRL¢SfFóâ(‹ºn?‹yôÍUI|è—¶æ“F®ºÔ¢ðC÷»W³ `GÌâHf ª’3¶j5!²ÔTÝOÔÕ¦ŠˆËSØáäÛ§+Y`b4õG0‡†$ãÌÑå«0ØäüÀÆÏ$]_ p¥•Ê·ªz»Ï´ Ƨ*Qæ|È£“ùæÀ‚‹t.·;gÿûRÄæ€ Q]§•¯1K¢k0ö(¾Au)ØõŸimZBtF¡ú˜|<æÏÿžßOÓ*4ú5òMÿ.TÒ°’ G[¡­_–… t•¨ËÐtÄÇ‚¬»'[ˆD™Nf‹ø7ñi"™1«ÿ  ï5î®ÝÒ¹ wm~Û „ Îç“ØÇÚxú×ÿýK|âʧ1î’„4Ÿçèþªrëpv5ÜÂe8¢eFÇÊÔ­kÎ׋óâ„àTbº#"9uÿxO3TŠç;V1V‚™­YÖHˆ)mZ÷ÿûRÄé€ ™;W‡„ú9ʧ(ôÌ5èÐ@oI:ë>ê;¤ëD)c·R*uWèÿ=õ6­ZÝ4¹5°:Éí$õrj5Ô[T¡Lð±Ø!*2TU÷jÓ0§ôµqÆÓíŸÒXþ{F‰š©¶„à‚ õº÷­õ5ÈÁQûÊB¡yÀ^ˆcåôüïÿò­ò6Û´ñѧhÿôÕwm¨R$㈠Öt±˜ÆñŸŒSmÕ”zè‹z埥·œÏ›¼¬I“Y£7µZ΋]i¢¦¬îÏ*ñ’wTÁ_,ÿûRÄÛ€ Ý9<­µVÁa'+ôô*+†‹Èì‰é:è±¼; Âö—R;¿”î_8¢Ý•‚EºÜA‚VCï\,]2œ"bÎL\f6±t1ó (r˜oú‰ïœòz!Û"€<צýèTÔhÔ!aê<%ÞÁ\ˆÛë×éú~™ÿWÛòjÈ` °áÀEA{>…³f²4åtµ ^QàôPç–E&c"<š;œwü¨8ÝìëM‹À^ŠÌ;5Y@¸•ZŒLÒ;±¶7OÿÿÿûRÄÙ€ …;e‡±®±p',pöªnü§ó.my¥Ð7bëSk Q†¢h‘x^¢yO-¯/®„¾Ü‰¡šW-5Jûˆ[;(-gœqGúb@)-c”œÕSf‘2‰BI~Ûjј˜ì¬ÊÍ2‹ìF…cŒõ;øϨ:ê „\†àØ)¶ùµ‹p+q…Ffþn1– »$ì·™ã©Ø]–ïTÊX¾žœØÁ´6°º2½h-²íÝ‘! ÙLµô^µ*¹×•j‰ÄÃ-ÿú:ÍÓOYÀ×cõUÿûRÄÛ Ñi§µu1W&ëtœ)‚Eh¨Ö,jµ&¢äµ‚ØjK$ ÉÔ=y±±m} ;” [n­"\¤5\feÅ|^9’Wçÿ ¶‚ÇÆFΰq­R5ù¨‡‰§±¯*@aBÄÅX0žuªßéM? &ÿþRºýU§P€ü⻪zEGmGã¬!ée@d0îzÍ‹qtee/™· "äUj¿ÏE€îs=¨©2¤@‚Ç®ÉE…$Ó_:‹sÖDÍê¿îßÿȬÿûRÄ〠a7K¦aLK™ì4Å&vûÏèíXH-h1&;èäº2÷íé‘K9©¤ân‡d'zl[Ž×„˜œ ïwZì·õ• l™›¬·2S’¡²=M #Ò-IEäв£”ÒI÷£@àÁEªs,_øsâÈQöÐnÛhMµòaÜ=LF8^¿9x§3j­¹2saâ1º×Ë1Èk^Ø|RúHë¼HÆéc*”Ÿ`”"Ò?Z †•z³×jœ= ÷õÿÿüÆùŠ«4ÚÿûRÄê€ ±9E­@õo¦ªôô)òÇåM i~2¥»C[-ÆÒc&2µ f|¹mlNœ¤¹ãjêä`J€†è†äN.£·^¡)ËêZgt·¬j •RúŒSR–ëAÖD N`šh!¢’ÙR Îè²öj;_bLõ?¹Ÿô磞[m ¯k Ë—#ÃßÚ-ÈÐe[@†K+‹Ÿ/táÃßmvÛ°BÑ•XœÉã%ÞD©ÔcÁX˜d‘Ŷ†“=ÉeðÑîÔn—¡ý=OS®?ºº0ÿûRÄç€ ?-PÁš(õƒ5Ô^¯Ÿ¶¿ÿþW÷oò¥×B—fì­FÓÐAÀ¥N£s¸ng<`r‡¨‡Ÿê¯ ˆWƒ>c)múlù@ Æ™ÍC^¸\1·íù}?nø>jôÓõû|Rëêc[D±/F-Wxf`ET啸ƞf5–ê"ú&Цå °R´îDi¨>sz„O(cNÑ]®2Ó겤:Î0 ˜ÉR,hëjdN.›&ë1 j´ ÝŽ\È2ž²´tÞ¾aúÿ^ÿûRÄç€ ½=W‡¡Pù¦§+ôöª7M¯Ö¥nX>]Ûþ…VhVT,·hÍ‘Öö¨‰Ó¤üpŽàýƒÐ†–%Ëím±+®÷α³Z¬{Oê9ÛC¶ì˜MKG;êœxÎh¥Ö´K)Hs®ƒò~¿ÿã?'ö¨ãn»ÿÒUf…@%D”l” ¸¡V!ˆæ4YÝ·ÜÕ’¡ Œ›¯e;•1*¢Î$¸Ì&fLšL¥èüàõh ¢{¦fŠúÁ8Aflš(;)Û:ÈXæ5Žj²ÕØ€6uèÿêÿûRÄဠ5;W¦aLQI'l0õžVÿþº:þ„®TÒÖh †ûtBé@$?P‡Ƶ"hCÀ^åQ¤YdGÀ﹦ï/ˆÝÍi6ïè*{SME†¿;î@Åà[E,D\fl^Z‰@ [˜ôý_ÿü¿îŸÐšê*Vë I¦ëqQ šEܱfb0)X4‡¼>‚)ŽÖtصá´V ð=ÉælrŠÚuÛfþ€®má^~y0úšb,#Õûz’G×}/©‚®“©z»-%ÿÿûRÄé€ 9qæ)³1]§-¼ö–®«ûT§²*&Ók{ŒØ·|ü¶¦w®Õ“c^[.L.r=Y—†ãžÕqé>Þ!>±áÏ[DúûÃP–Ÿ¹åž«\&)nèÛüŒ€ïVû©£2FyîPïðFû`œpø3F¿g2§Æíˆ<Í#ïO²UA Ãè¶ØœÕ–½xa30¥J§4bt¼4 QtèõÓ}Z,ÊtRZ¯ø*DÌÚÞv˜*iþŸô3þ¯vs©(¯u{ÿûRÄç€ )7eçµU1X')ôÉ© !U#dÂÛjeË#l&Î’†Š©M Äý—©ý05‡•ýT‰œ/õæ!¯®åÔø¶¶¼y}¾‘;[ÞØ‡…p›ÑA%§»vI2Æ5ÑgOR á…2Xõíªµ˜. "ضê×û¨€óÓá6XóºÃ£n,jABöKi ¨ü0U@°.ä|o¶‘ª¸äVQog1ýÏð*O{%³ñË©fÞú)Â1†2:;-ÑPáÐ~9m[þÆÿô˜ÿûRÄé€ É5W§±¶‘FçÕ§©èaÖ8'É2ôåš N4 ¢ "<ô¢‰¹Å±>ˆp¯¢¼K™°Ëd}Váé¯èbSÚôÿúouÆ|ë+¿>×+zpÓ‚ÓûOË™÷Ö¥?¢7Ý#@ÿ_ú oßÐŒQÉ“V^W}©q—$m1„-"e%"2(ݘíÌŠÚ¢ØO<íQ«¿¾ø.{ñ­­nžð¨þ·¹ažšwšÝ&¨Æ˜'DþoQ™|ë-׳©nôdìÎ$–4KB5’ÿûRÄë€ ©7G­4ïÁvš,4õµöÄ‹Dièi;¯[‹Œ¯)§L©;º}ŸÝ·-È Èð.„àÀ]u”2ˉq2Bø7@X2E6 GÖC4ss%"= jßMÖcwz°bœú®g:xùwkÐæC XI8Ó—?¶Ú¿ÿç”-ò’é`ª€ïC(wìØu†âd Öy.È$CQЦÌ5Àɰ{—ò™ÂÊ× …ëÀ_ã¢Ï¼@T”A¡`ÕÉôçÉqëÉáènÖ²¿çM ïjÍÑ™‚ëÿûRÄè ÑG¬¬ïq¢«4õ—NNšþŸÿèßFý¨XÎå-­‚ØE¸Ü2™Ðî£?0?×ù'¼ MŽUâp5¬ã•w‡ ŒÓZ‡}Dsý×FæÄpݦ%]9÷‹‚•§’k“êê€Ó«´Ý?9¿þ脟CYQ¼¢*j—ÿhv&ã‰@S#ŠƒÐ6?d¯ƒXäL.¢‰ÏýN ë<¦ãü§lªÞ ªÝ!ÔOè…üýË/ÝÔ¹ä„ù†Ç^kTÞ˜z}óú¿_íßAK3ÿûRÄè€ å;a§ÅZyg¤i´š) =¾_Ak<¤Lé•Ön`u|ç8nõÀ É£À©Ð©ñ–É–{–šªdŒ£~CÑZØß³9³œÕʆ$­.jë4Et9Ïez9Æ ”ËÎûÑF™ç1‡3˜î…ÜÁxeÞɲý?ÿÈ](6ê—ÿ`Rä‰A²ÄRl%Ç…4«¹;=$¶ø7¹ó …iË·_P½6…žI«z€€æ&d&k>· ƒcÑjudÈöA\)Zf­ÿoÿêLÿQµ{ÿûRÄâ 9e§¡S1_&ë´Ì)›Üº{»Ž[£0&¥pú<¡׬¿‘k¬ÃÛ%ë±=ÕcPíì]lpÉãPUÞGò–挒ö}Ô n­Ñ»³U²˜€Ü4<¡ù8åt<3¬Ë›ßêKÑ×S³/j¦£˜åÑŠ}Ä¿@ÕX¦PE`Dµ¨0\]Ÿi¿$Ä…µ½ë†j '‚ðþ]Ìõkþ}@|ùî%»tª2ú˽iT¦SØwÑ -d.¹Ë„Ðå÷CµÔ$%§»ùÿûRÄç€ ?k¦-±ù^¦ê´œ©.Þ·,!`q¨²6œø[œh’×ç89à·~×`$:šä–-ݬÛÜe’ÆSGœ8b=Ûõ4¨Ð-ќ뚭¤ˆ˜XTÖßÿú’=NߨU§-€¼q+²nÏ He­†âeáׄ™zepß<‰_O‹ÛI€ëÇ3c»ZH¦QÒaˆPÕ˧ђ'šnP'9û×®´†Ö•I¾z´Xàü,*%zÿûRÄä€ ˜ÑOçµU!‹¢§5·©àÁ…¿t‹w[CÕ¹¤H¯«ÌaS@—„r£XÆÎ 5Bp­ÅSgpv]ñÎöwÚ¦À^½Ü`c{þ™´yp¼1œe£CeÖ¶×,—ÛǡþšÄ²ë~s°0aªUgmjò@nºø²Íˆ÷±L[§ ®E!þ@{£%‰Ù·ßX]Žj²)"Æx\+áS-B»Ny¢£}âó€ùgó?×6é«Ö%ß…ÂZG´¦)o-ófQ°iK×ìŠÿûRÄæ€ ˆÓ3-$¶At¢¨µ“*ЄAyoîþ m€B·P TÄ@åȲ״õ3ÆùÐ}«¼ÔÃÏSY¯Æ7ª³ÿ»½»Ì~V £ÚyÈgæf@ÛhX«?e˜å JbbSm_ëG»7z('3eÅX‚úªMÙ`-†mÖg¨xÍCOÌÜæT›Œõ”ƒ^}êÊ•ÊoÈ„òß ’äOã?ø ‡xˆbªÛ$E,ÞCdˆá||n¿îÖŠyîøùOjæ) öÿûRÄç€ ”ÓY§¢±|*ôñÆ Ú¹–ÿ”Nk°V¨õ‰€¾e8¥Ö<#ÙDÁ;ŽCOK E¡¬Õóµ»Õ;$ ‚Ÿõt—pÇoûÝþqúrQe:åW¯+ .[Áʯ‰Wq­%5‰¾Èx5)ŸÓРVÓàÝ¿šRû¨VÆõ±€¾r“ÓHLÔL&r °ýkÚÿ¢ìZ™\?ÌI«³ª%©©•%³ïUÿYúú°Í[Æ“ì0É%¨ï Zåå©m]ë_xÿ* ƒ'Ժ㙚«i ùöÌÿûRÄç€ °Ñ=­=OÁ&õ¶6С†HÔSü1|*4õ¶fe°­Ð*§'·ê–HÚ €`žãð°³2Þ`\'39ͼd>¨•õÙkoQdÃnß'”ËñÜ,!õ*x˜éݼÀ&Ä ×íº¨ÉçRÖ¥*émLffìKíZ‹¿ÁÁ<ÿ@œ¢ÕYh›Q€F‚¨…Æ1¸§M¹JuÏi$Â¥YÐ?Á+îcrI1&#¹Õ q;åT—_¡N©ç²¦ÌÔœ/fSSo>ä#À&!4‚ÓnènbÞÿûRÄç ”ÑU§®±uœê4ö®§–è”8©3R€!H1ÊÀ@£9ÑñŸ¬i÷õ¯½Ð ~WI>c+á2õ1¼„+{\E»T}¥sÎ¥¯È•\¥r™çÑ©æ>Ü‘¦~?ŽøZO7†²ë߯» I =0Áf.FíÎÔµd°E "D4ýdK¨%çˆ<­j=*k¥i"žok¹Óèê{0JË”Q£x·³È3öþuk$Hõ‘ׇ»Li‡o¯¯‹ôr ª³WmafnqÿûRÄè ÐÓ9¬­ï¡s*ôõµ¾4hô“Ø ¶à‘-é#¸#¼· j¥™2•éÔÜT›Œdˆ•&…Ÿ‰eG:KK Ò v}ÝÙrá3ÿ²›ê,ä-³q€ÀbÐÚëøZ¨êjâÙŠY˜Œ.çÞÑ*Œ÷‚Å!…BO›=€— @ÁÁïKƒ«C6ËËØ•³Ù~¶†³E†¹ŒÝ#,S`5q>z “=Kh;²Â*«qÕx?Íøô–Dóü~×<î«~aó\w˜ÊÿûRÄè å;§¡O©uš&u„­àéjWûð(p"ÀYfLÀ̵‡™øƒaè)ã“¿ðä ç°C˜J§Õ¯OÓÊ {xE»„Ö°Ò9ÛŨš®mT¥;ú£+aå–r(¦qØ:FŸ¿¯ªù}šÔLÓçe.å_戜t˜£(Ũl€§`]žË-?mÞEŒ·*¹À±qÀ6DÃ[O,Ãܵ¿°CfÁÆî;F M á¶Qs#éî³SªÁüÕIº«o­½Ózû ´ÝËÃqÿûRÄç€ œÑ+-=A~šg´ô¡ô³Ê[Oëè’ ©$T5é•tBé†ß‡Ò*ú̳»b>µVD Yvw,~û.=@EÏ}%gæØhãÊ,Ð/8u’ÃKÜÕiï<Ù÷-ëç•o©‹¦ß–’Ì.—©ƒ¾Î3Ô0ì5¨Õ앰ʤRxÅ¡™·C8r¤AÌ&/eù©”ä¶å¯Ä%(s_”³Ë©²ïÙÁZ¹£³—Uª{šµ'uTßê;Þ«—Ý<_TÿûRÄç ˜Ñ&¬½nÁ%e¬øw§ÏKw6Ù¸ @)Á*ý4H™|©ë‚à†Í,i}Á@ÀŒ.eLÕƒLÔ¥æ<¯Ï æQÔiœ´;È9sÚõaˆ€tvÞ·G]0Ëý@ÉU3¶9Ñ»«ý·3sņ »c×úöUÒgÊ”èzÉ¡Özò1†õ‰Ã4Ð ÂX<² tƒ¦•²):‹*ôšB(¤ce‚ì»Y¼kÎÍú¸¹ëÿ~ÍÕ¶»ª'³˜]‘œQÚŽ#ÿûRÄæ€ `Ñ7¬­¯!å©—­Ø‹Ýïz@S!8Žp0UåïyDf¼Z uBéÇ3šqFe—3“S꼯•zÌ»ƒ¯rÒÚ¸NœáLÎìÿ ìµÖ¦:I ¶Ï_ñ¼Ï¾£¸{[dAåæv-•·í 3B@„ȃ ¹ÖzÙšìIøš†5˜^£Æ™$fœ×Sç Ä|]„Y§ì­rb¶»æÜZ¿$ª·ÏÕd̦Kݬo8_—O/}ëǨ«¦]Æ”2ÿmX›#ÁÿûRÄç ÔÓ&,íkÁv%%¬øàI !XÏ(8’Ë$}ÔnûFi]G^»fKÁdžuô–PMAõ«Àz·¼vØÕ‡D‘E9ÇU'b°7›Ò¥(@8\Ë&Fëÿßÿþ´½*CÅÔKTÈxàãÈ>šUì„K` „PÀD—nSfoËK‚Ýw ùžjÉrÊš©æDä²ÂÝNÎÆ§nЭ$ ê¿xÌÐB¢5tJ©çŠóJ€˜EHH´¶K}bèÿž£ž¤· þÕÿûRÄæ‚ (Ï$­°±Af$ɬùŒR0©Á~§wX¿^—â0×­ºQ}®ÖfÐ$†Öi—«•3Rzµ#¯ÕÏ=~£çù©bu»FŸ¶RKVÿêÑAWN¢éë…¡b²oPŒÿù8¼¯Õsz1±•*¤Ü`Èf él«m@pKzåE‰‹Q:âˆýnù^ý[€ûU=ÀJ¿VC¶¦Ýáë_;†HsùÏãýÕƒ°T&ÇÕ½WQª*Z›_yS RÿûRÄëKìÓ$Í=qsš$é–¢È8±htJ €fNÅæC!T eõf°Kû ߪÏë£]%ûV¸vÌFI/­qµÕiþäôŒÙÈ?™*$3ˆ³Àá’V x×§!Ü2÷S[M7;ÿø®ªµy’>œÿVš tÕÊ„…„œQÙøa¯ hàvËYš—LMù3XíI,Îõ˜Uº ¼©T Öd‚)öœt=»|¤2;jUÓ)Í)Nø‘דj¯öb£e…„…Âd•¯ÝÿûRÄê€ ¤Ñ'-=Adé—¥øÿÿÿÿÿw¸%.!ºaÇ  µ^ù {#ƒ!¨#„ì÷šWÊáùS÷¨®ž]V©†lðyÍZÒäÛÕÔK-vhºE-8÷BÔÔ쵚æ»îÔ¨ EuºÝ\À™jÒ¤U<€JaL(o ƒ¼ïä¥~Ei"±›mÙH»"·$‹*¥¹“Ÿ•¸¼þïÙÅ}˳.(‰6ݽ”b iMn˜nÇ3*€ïéÒͱÈq—¥tj¢MÿûRÄç€ œÑ)M½Anš$™ øQHP ˜™©”˜˜Ðý#¶ÔYóÇ)µnŠ^îPØaj®mPæyLöj;CUVá‡:ÝœïG°Žñ4­¢àÄó“jÏâ` :c©dñÿqU²&Âñi\¯vÛ{$£E"@ rÔÀ0‹­#ÜØµb.õ41äy›þa)¡y1g0¨^ÅÏ:¢÷'}e!œó&³<5ìAjݲãenš‰™m{¶¾:­3ÉF;ëû©ÿûRÄéL¯#-­VmšdI¥–دßmÿþ‹ýà£òú;â AƒšÃc’4 ÅZ1R©Œ¼jÕ•ëæTlÙ…ñãhÍ9êˬÍO™$Ö³¶ò•¦™$™ Æ£Õ“(N c® êf?Tÿÿ5¼ù"-¿ßÿÿÿÿú€A¼žg@bÇ×ë…4Éa«®c¥JÄÚ$H—ñà¤mU]‘åb±C£؈Ùtå¿z÷ÅoNôWžW!Þ¥M8¹ÁŠhî†RÓU6£Zÿ*}®ÿûRÄè tÓ"ͬVÁv•ä ·­è©™:›ÿ­ŸÿÿÿM`@œ2HÖLŽ5¹@_vÌú3G¦#OŠA¬8Q‰†<»XJï ªVËxcYmVë&1Òkg\ÚÏ%ª'O-G ÙŒfÐ’KýLúÐUq¸“ÿÿÿÿÿwûÚ6 8a·¸è¦@à[æú{] TRËÒÕÈÀc \üÈs§ŽBùöóíÌußoÈk^ÝŽô¬SH4wÑ~`#åoeT‘‚CLû‘ýýwÿíÿôÿ«ÿûRÄé€ LÑ%MArAoš$e—¡è¬ðàÕˆ”†P8‹B‚wRºÚA”‰®:q[ZØàGÄí ›|ôVN+ô‘_æ±Â>ŸÃÒR›óQÿVÓÏ=uÿešu–ʨzYßϳùÙàÇi>ßÀŸwïû[ñÝÿú¾:+ ŠPF\ØË…#!Údí‰Ôwenëï-j°h,ëÄÑõ‰G2г7Ò[rîržãâ$g±,¡×i­f´Ô÷âX"UiÚv ¢ý–žµ¢nÝY1ÿûRÄèLlÏ!-<ïA^¤%¦*ÐC‘­íoý]ÿÿþšFå–e`§©k€ lÔ’ûÆÖ "ô«£½6¾U£îJ.Tï~`HÇä*L;q#ű(2 3Ê IÒéq‚<3?ó÷oÿÐO¢¥GüÉò“2b øËalFçqګ1LÑ©'¹mëÛ‡ÎãWù¸Ð8ßê53O—5Þw wàÕç½xwÁi‘ÛBÓØ(ç£"žÛ7D/ºÿùj5TWôþžoÖÿÿûRÄè€ 8Ó"ÌìKŒšdi– Øënê =©Ì¨ñlŒ,ÙóTªù8Æ<) :GÔ¾n› dœÍ¹û˜XÕǬÁýX©j›>jÖYó{¡LÉì<L#I ¦@:'d]ѵ[¿õûµF·ÿ|û)Èÿ÷'ÿþˆêD“Í N€à„<â³ë.•wá°S8,Rr@±Iè®SùcA½\ðZ7ë†q[úâ.¼ 2åxpï=OmÔD>j|«J99 ÞŸ×û©ÍÿûRÄèL¨Ñ-´¸Edi¤ ØU?ÿý@N@𵄦%§5uüÆ¥LþºÝ-ZV¬:q 4:j¾®&SKZô! Í‡6GSÛ€zÝÌsÊOaÕm²#Ô?iñ¢J‰|å+[ëÿNïÿÿOéé×eL,P‚36‹ 1’2h yG!·~âN ‡L¨;‹½ÓOKórôÀÔÊUÍÂ&ß–—ÚÅ)FM ©I6~Ã>§¯ùúë®5ßUö¬“þá·¾¥¿³™©ÿûRÄê ÑMú‰š#™£7@ ÇÕý‘û,heœ †€8(" ²W»BŸ7Bý÷õm9"¤NZ2\¬ž· p}5l"dùrkë¯ëV®eÞðÌ×c=·PàYKþ§†×ÛÄ\ù'Tr»:{ÿÿÿ«ÿÿc(åª`#XZ# -P·ïãÔó4– §¢ƒA(HÒõm+éã5FjÿK5º¬cúLÌݸ:üêûú+´r¯ë$¦tïwöÿÓØÝ oÿÿv×ÿûRÄæ€ ,!LégÁzc™©.H"•„d Ü£S6‚ß áO¸'oT)lÍ>’! 5fžÍÝ"]dÖy"À‘L´F²%zFj_ˆ•go9KhOñ¸2\«ê#7ÐÛúsŸöuû‘ßä¿ÿû^¥¢BÐ(ˆµe0T (4(i¨bÒ–T ~%qÙ{0‹­À[Q~‘Õ^A¶±ßþ¼9iÉê¾5¾@»y¹¢K«Ty ¸¡ënü‡ýIÿûöÿÿÿ¼]GÿûRÄè€L ÏMÉAm£é§±Ø¦ZbáI˜Ó©g¾NÕ4Ì6Ƽa ÓÔÓÜx*[6›¤;[$âœjNzë;YiêÓ>õëtÓ±:äRîÐØu®k$NR‡êKú¿Zšçvjô/ú¼¿Ië;Y7CrBL@p 6Õ£PSÜé[³fû¥¸bH»RKbRö>\ßcÉ߇®a*µqêÓ5¡œyLÆwêü~ÃÂÂQÇû}hå¹1då)úþ¥/^ÎÆÝýlBòóÿûRÄè D!M½z’£Ý¨>X@Ä>AA÷æ 1žØÜ¸¬©‹5W¬Ä5Y—':ÍfÁýµSœ9ÞSwpaw5Ä+ÞC75”︗8nN-ïfã¹›%§Iôü†m+~¿Üõ û?E2îÏþŽôVÚ(ÒÍ câèÒù¢Mü1A›ÑZa¦!Ê6ºãrŒé«ÏõÈz3<”ÿ…u›ÿ´eÐ1q¥ Ê ½1EÇ>I ­ý¼·Ú¯Ñ¿èÿâ”Vé$0¸*ÿûPÄé€ Ø'­=nÁˆ‘£I¸2€›[A„™Ú5!¸Å ÷Û6íHøµ2R‡g,Zw¥¢<žÈ¥Ð#F<â*_{-|ŽZèÇbåRæW€xEØóúßušE·ãuc§Qvú_iWÿÒ¿^ÆŠ*$AP˜Ø&<™l 0‘Ö_î 7¦xÝdÇ9¦Ìår/tŒ£ðªaތਭÈQJ¸Ðv.ì@müVUEÓv¼ä˜‘O{;òÝjÖÿ-e|[×÷HSª©«l& G´æ‘ÖÒ‹ ÷9Û²R…ä·Tÿ;‘¿ØúhvÿûhÕÜö.¶³uAFˆ»¦ :H@"þE¢qöùö§Â½a–,!ÖßKoòÎ6lë o>ý xsH±ð³¢KVAf™I¡”WJÔ>Ç!dpmHתCÿìõt·þþÄéoïöVˆ :ÌfÿûRÄíK8-EAaã§±ØÀH ¢$Ñ祰¸4²,౫ãYqº?}—=î6åúÌ×Lt­¹bTß:`¬»»—Õ§÷-ÍOv8­/S›ÜárI(mÌäwH)&¨êßU} O½Ö ”rÕ¥@Rã`tR€q¨ðSØè·XÀŽ[¤í ‘€ÕÄ4q,ÝìKzMOH±a›²‘Šî<ênÐ yC•Q§i©<ÓÔ…޵Œ{E¢Û&,úyçF… 1m}]•ÛOÚÿûRÄò è‹-PAy‘ce¬-ØMa³KA‹‰Dflj|]WùëƒâÔªRJY Ê“¦aÍhüµb’®ëó–qû©ÇߦÛö-Ø­JØä¨e‰*–šz‚ì€ÐM)(÷%DCú=^ÏõSõÿ»u*oz #¨ÜD]1*‹û„½2÷†‡ v0ò ˆ‡Úl»‰›Lg¶K¨ƒGÒÇS¦P¹ý0r¿²v6Ïé@ÉÒ‰ÉAmªoÝ›3nåÚñÓ©êRÿ+÷ÿÊ#ZB¥ÿûRÄí Pí,vA‹ã§­ØdzW,ƒR[ÍM™ÆžÌ®ÆÝ‡L ÒP'*û7Ç~îJE®£7KVî©Ö÷¤§E-)«Áõ#¨«˜>åÅMûsƒ¡ô&,ºÖåÿÿÛ]Œ½´¡‚­.@Q§À„ (’„Tm¬½ÿ’[ÇÌX1ªcÁ].%®÷omäÚ=ðÝ©·˜'ä7 SçÿìÖ62™ `¡ý¯Úý·ßÖ¿}%«mW{+ßOªÀMeÐ H®5°ÿûRÄì  M½NÁe‘£©¦ÈMÕlÎûaÔV5bÕ—¢0@3dŠ›”;dŽ×É.^@ÅyÊÏ?”Si [7»ßPÎôVJca…Ša´T¦onöÇ>äR‡"åý½:2¶”B[ÂÈBfsa¢K£ËÀ68è3Þ³èŽ58§Q±€NjöîÔòxxrÅo]ž¢ç¼œ‡I÷Wr0Ç…¦ÄäE€à駆¯Eæø÷h*›µŒ{eP=“Ô…¯yÂÿûˆ]²µ-z‹qGRÄÿûRÄê‚ \•Ìécp‘£¥è€ yx‹ÿUÑj³®œ3k<Ûl‘«&ÝY´&Ü»ºUX† 6Î]ïc©fhÙ€KiÞßÙC³« NËwT±É±Ö¯¶uÆ*îò›;ý}XŒÅ‰.CcU)@ÙæÏP)Ø”Âb*½­1·r\¯ƒÄã@žñOÌ£´Æ[R\ú„†šs‡,öÖïæÍexŽ›Ð/Í $äqç=;S!3ê ÂÚfÑ¢¦vwÏîè@ѨÒáñRPtÚoÿûRÄí <M¼ÎÁl£e¬,0N :°óloÌ=˜ŽêHÓ‰€m¨Ö²‰«.†ÊD#ë4:kz™¿p*=‹T«IžÉ@ú*NÍêM*Sf9C즛Ç%ÌÂú[ÚijZn†ÓÔ-jdÀJ|ø40!ÊË$ü¼Í…Ô‚d¹Ýí†>‡Pé+q`Ñý#ï ÿ¾à„ÇÚ¬I¸~ ©ñôÖıSTw){öãR™ýÔ-öïpžÛ}ôF¹V­×‚é4’!(nOjÃÁÿûRÄð‚L”iͽÁf‘£¥Š ¹í{ G¥v=ΧÁŽÈ µÊ/·û¥+Oómœ>œèl·¦a\5f¬EÝûM& þç½Ì”Í &m÷ï†1é¾9÷ Ãâ@à@×î µûÊÿÓŠQ;¬®ÔÌ@§xÈË=Œã·œÌ Pþ²Ôa\Æ”e‰ cx!Ô-á/{¤–¢- H,’Qyƒˆr’Ãè_EÁŠsSé¸üÿûRÄï ¤Í=nÁ~c — øBã‘XÁÚ ±9é]~Ù¹&'† #ýjuS,Ü‘+6aúõ%÷‘ÆÄ•/©3t?ÿ·îšÆ‹4A¥ÏÿÿÿÙr')aÿÿÿH€6‘ØÛI J7(ÑST4Ú•4¡KÜíU ØkžÑKY™©¬¢Ú–N ÂJ.âgA-)Gz¡Ù¼[úIðÜ´žñêÜ…JS6½pƒÔ® ‰çdÎŽúôÆ$KbÐ{2)ô×ÿûRÄî€ ä‹-µÁ«#æ´°¢œgµíLüü¤ffµ`zãýküoãï?_5¡ ”Ëjÿÿô*‚[A3 ø ¤öYÍ •…ÇE›‰}AC¡DdB 7[ĉp_ÞÂrôi“I„‹ -V¹±mÕzXs.’q"1#ò­‹™óÎ-î /ãî]Vóc[×½ñõ÷¯ã‹²" °?†à}Ë®’Š,æYú•u„a5éÿ•C¿ýÌZê™ÝÿûRÄç€Õ+¹¦€N ¥·4ðüÛÿþöI›¦ ò“î²µT ðáΑÌÐU3»³ ZÓ,L «)ð…¯˜†' Ì#@äÌ…:.g"†’·Á½E$y ì$jÅS Q&#Æû*’)ˆÎ^ž²)µXy½+·‰´åL„[¶,n÷×ÞÚY/=),zêø®© ?þßÍ©_05ûÏ4¨§÷~îcµ­d¸)|ÖQ‡Œ Á†KÀ©\³mrKZê‚ OyðMGÿûRĨà¿!9·€`"g72ðañ½'ŠÅÃã´j=ü*’j45€¨mnŽ˜'2,Œ†H86T¢ŠOjе¥ˆ¨'ÿïÎûêgUÀõÊHಱ†Ô¦ ˜¨2¨ŠËðº[un*æ¢^bAhºË!Š 1u Ê¢ñU›¥ U{7a3ÁSÎýážåü¥Nà˜AadÙ#zïzÖâû#Íiöýµj HØÌ Ìa ÁÆL1aX`èz!$½0¼Y«@nî“ã­d‘ÓÿûRÄr‚ i$]³t¤eÃ`Ê]>ïýdRGZÌ1T³“.K…ú¿O…ëô’V.fâ° 8«êQùF™­Ù·²£ß¡q3à˜L &u1•f6gªd$IFçŸé÷eí‡Y"É뇞¿ÍׯÝJŠŸ§o_z9·Uà1‚.‚ ´°© &Dª .xÐ]Ö­&S]ï,.MµÍCOïð§ÿïÿSØÕ×L‚3tƦŠ"*Bi„ ªRvŸÈ-Þ܉ÔôÿûRÄq€ lÓ-˜pˆŒc¥Ì°hömWøúr…[õ²ð¡Õ\Iôƒ3‘ÀÊùÚ8¬ˆóȲ)üë6p®â²3!xس´v¯èWþÿ³‹îúÔ@”*•jˆ×æ,•‰ .†äQ´€ ˆöá‘å!•Ä]–½E^rvc5Pgp'¨¦˜8 ì€jÍ},›lùÇÇÃC°vÖ´‘V9À@BŠž¡rˆ`¯0e켚µ(=ÄÚ@ ߤš7B¨©¢ôÐÌÍš—4݉†n¨n¢Ç¹l–5–zÃDAû¼°mó)£×ÿûRÄl€ Pµ/¬0i¡;¤©„ àmú™Áð~™Œï7÷¯i÷þ€/>¾é°_£]—QM)af ÎÜ:Ið .‰u]§­FÅÖ6Yö¹ hlÀ”€ð‰Qä† ”Yѧž8“T¡ˆAF¾?zÎß÷ÿÙíëRh¯û¤, +€‚Öš ÎÓÒA{Ì(Z«s'­qe©wfÑñ«Øda6¸ïR!ŽšZ¬X©ÌŒpz9$²‚é`³—3¼áÿaÿþ¯&ŽÑ5ÒfÉÿûRÄy€ {#L nÉEˆäe– °¶6>m³½ðµ°-®*ÙomŸ ½3K;œÏ¢µÉœyœgSçf)‡*ÌÊæUpèÀÈRµƒ ÛEæuÚE"¸ô ZY@¾ÝÌK.RMïvÒ€!Üsàe×U'>ÐcËÊzÕé”y$…*}bÜøŽ®†¹Ñn.ÍÜÂ8ïuÜÏâ_NLMþÐu“l7AgïþËÍú¿ÝÿöÐ @ã±´’©Ð»B¤_“ÖdÓS[kùˆ:Šsà†2wåAÿûRÄŠ€ /3ìóRäñßjúvÍ2œ\Úˆ+L†Ìª ‘ZårÀŽp˜t)!Áí¤èº®èÁqžJ:R8ã[Ÿ¼c[öžìyÜßéP I§ßH'ÑÀ¡RC´‹—OJŠh²ër¡w¿»ì¬è±Î£+áë±bÐ<ã…è=ÊáÜ‘‡;Hè}}[C›šùü˜Ú‹ýÿýÌ;õï}ÿ}’6‘ ­ÿûRÄ¥ ,g,˜nÁC¤õ„ØÕ É“v°x>3²íÖÝŠFèâ²»Id~,ïú4O/Rc-vœÃñÚ-&dÜ^’í$arA4¹Ñ ÁæRÛ™F§¯n¿ÿgÿÿSƒiIànû–HŒ%’P.‰¡â²Î9iv7¯¤üs˜Ìj&ÉK˜*ÞÌ:rc~½JÆòD›Õþ«<ÕÊUQ~q»+;?©þŸ|~N_ç9ˆ«Q¿¾ês3Q±òå—5Æ;`Pã2äõ¸PÆÄ,*©Fg*¡C—ÓÚpâ“æí–}F>þ¤ÿûRÄÀ tmU„€h%'w0ðäá.i¾ ŸJ&GªF¾Ò»ï}*œedp6B²ª4…ªËÅ¥À ð~ÖÅ3ØŽ”-³Ú‡@F§³eI15:x¤PÁrNhÁ“˜,`ûç­hºè¼Þ³ÃØö€–ÄÝÛ³ÿÿþšZ‘mháƒCWGU¢7Ûl¤t8’ƒ£óç¨Ù 7gf6‘¨!‰7R~çJTM˹g·O©˜Å–µí 6L`\ÉàK6z¯B_Wôÿÿýl&ÿûRħ ±9¼ô!N•䥇ŒycDÈÛQ;R¢B¡¸Ê›_@[òñѲàû·ž?ÑÌœ“Ú±Jœ† 7\˜«ðŒ«²E)ôÕžéU¶/îú”Ÿ—wòGÃ'¦k8”£%õto ¹-²G+H€RÞ98zþ6¡Z­lÊ5õДlÍÔ¡ŠÚ?*tÉ?[c÷¨¹kylþ{½šP±:h‚V$föô2Ð7J -öÊ,+ùöõÍÿ³­ Tþi0ÛcAì‹À½"Ú®JxûEa¶öVÿûRİ€ (i#,0eG¤e† pBoÞ;ä'¿uÝäsûk­·?Ç‚„%e‡…ÖD*‘íLÀ¼ñòÄPB¢U*B`¨Y#Sëÿÿÿ¶Õ'@ÁJ¶ãAˆîŸ%cúPÓÀ±ÑÊf^aîØkFh¸ƒ­Ž‹˜:‘sŒ c'0¤|üËÌéË"Þ QbEî–2夣Œ1¬,«ÈÙkuþ¿Ÿ´ÏÕ41*@`@  ’Ó5¿N–Áwìv¨ø ";bd ÷JÝ54®ÂeµÍÿûRĽ @Ù'¬0gÁF“%´ó-ĬA´eãîÌS"Ï)2"Vªó$‡œí:Sè»Zõ^xj¯(“2Ú5¢µvÛ*³¨¡m×cE,ëÅ®zNDÚ$²Gˆº—89\²ô[ ó‹•W†f_Õ†»‰/s6RX*A &pÌA˜y1!çN_Ÿ—š9<à0aŒ<À6¥M"–!Ïÿg·ÿöþNÚJ Ž˜(Òɲ‚/ÃQË·bì³lù‚­ðŠDIcVß­#n;3£}n;ÿûRÄÉ „!,0ÃAYãU¦ x»nþžpl™|;×ÚÊQè"vˆJÜB(²½n„@¨…÷R¿êÙõö± <—(*TÍ@_踰¦h”DŒõ’z)#Æ¡Ü/ÁB¹•…›~òão' FÚU[Ó‡‰Œ—$(\ÀÔ"‘9 ÑT ñt,À„«C–†èŸ~ÄûÛè¿Wå·^꜈`ú&[é”ðd·1ª­Š‘®e8,D& ¤8r-Qd‘ͦ㦚*ñ‚BeÈ…N°ÿûRÄÑ€ ˆÕ-0eÁU¤i—Œ8\ZÈåÅZ‘j­k"Ä÷EQå ž¿ÿýÿÝ2A €›l–"’Ž„ ZôÍ›âήŸJu.™å íö’¼«ýbë@™”•mJèý¿]y(ÑáBë˜P gkò(Û/Nm€E¾-ˆ³<Ì*Zɶå–Í'3õÎì­¿Ÿ©ûXeÝu ¦©¾Äѧ PÊö™¥ì³ÙúßPž¿ïý,¼ÈÞÅ>eüÏÏ"b˰¹?ŒìM!—™ÄHÆ8ÿûRÄÔ€ |i,19ŒãY—˜ щk›±´ÙA´Z—Us3ôjp!âI“žä»™Yú{¬jŒÍwDy_J}¾†¤f‡þµXâ=_Þºÿþ+ÕjEJÆA “p•ž3,FïÂM^Ù©Hä,Õ*Øïè`B‘Œ†çÂrz3FC„xGßÎe¢pé™L×/ËnÿÔØ’6§åMÈ­ŽAžj‡|©µœ-+Dm‰òä,¯ÿåxgJžÏü ßïMDF#X"i¸ân•ÈÿûRÄå DL°ÅA[c%–xI_"be4õÈxDß›>ïc†¯G9.ޤÔàÃ.¦Jî ŸùV¿,ÁÒÑCÊ,¡íT‘ÛÑÑ)ÿÔ›Þ4:!œš‹ iƒ‚G‚v²¦gX‰{¬Hø²0j2)$—‹ëXöË×v¾~bdÍ¥üÎô!Åôó~‘üãÂåhw_['ÂÊdqîp­¹¤ý³Ë-ÛŒqºí)“WÖå‘rrùÿÔû}á«hać”`i‡L hóÕ¦8‘8²ÿûRÄî€ Ñ© $gÉ –d)…ˆ° wÖýõY²¢=H…5 #yÍ bK± ]‘'6AãÖ"\±Ö îÅ$ Üý(j†¹E޲UëuÎ"‡Wža²ÄY…$ÅåH*o‚Ö‘žÂ*Xtž¾XP%- ;‡#c_Û¶Â9Î{ΈèSÕTÝõ£Oy”Èì› ®G½–ÍØÚ—+ÞÛ=„Q*i?emc¶Jurë]¯v© ¬xv:†Eƒ2X†!€ %ÚP’tJÿûRÄø á¯L°eÉ, £hô°‹ÒêwºÅ1%|u"Uöp·a¦Î\èêvò£ÿÙÕMüëdùGÊ´‡«5&ÿr1‘NF»×…Û/ÆÛçÚ™}?éÊxQ•$‘=4cŒýZ7ûWÉí6ï %4¬U(©Â@£-†UGªKuiò±5nÆZ™¡u®güç1ýÝË~SƦÉç®Lz”çу[OQϯ¬³,ò3®‹5²Ÿ™ô$ˆâœnN¤MÿŸù³”3EnŽõÝPZûh5 ÀÞÎaÿûRÄù)«,¼gÉKˆâXðÐÃ{³Çb7Û’Et«ŽW3ñí¯ÿ†½“»UÂȨˆ™‰Ó:™hüÈŽD¨é™"Ü„œ€hÕLó*æ¥LFƒ‹“¼¨äÿZdfcB‚â3d#F˜¸* P‚Pt|^ª)’{Á•Å8ô¥áµÅÞêt¨?iç\*¥ð¯ô•Èû_=J‘{ʦ!ÛÝ©gžæ³šn¦k—ñåÝÿb,ïþéaùw/æPíÿÎè á QÚ2)Ž1C ®<‡ÿûRÄô‚ ù± '˜Nɧ"ñ ñ+jä-Maëà0§Nþd^­3YÿÖ±ÉÞug̸qW?á×;$Ñ’)d» Sf\Ž0òsÍ–\Ïÿ)÷³´ÖjjÂhAÆÊ‚e!¸ñ1Ä ¡bí#B 2Iz](æ¿’Û…4ŠE¹h@§ Z[€™yËr2ŽJï삳ÿLýVM“j)Ô+£¢¿r3+Z^‰:3=]MVF³ä]eFt}!©î»i‘naQ:̆8‰‘ÅÿûRÄî •« ,wÉW5b°90>FØÄJ‰·w.1˜Þc4ŠK3ɵZ&È*楫TTE/]]ߦó-êè ,´mhšiWÑY„Ú¨§¥n_ôdÔ[UÞ^Ìûn›ëc‡•¤‰S¨’ˆ(aŠr  QL *Ûiƒ1\ĤH›Ue©$ÔZVJ>£Èè7U7ë²áꦻfׇ¿”,äˆÎ§Ÿ$~q󗲯rÉxoÌŒô/_»Þ€~ÈÒШÇjVY„ÿûRÄòƒ y±L¼cÉp¶¡ð–ø”“g‡áÊUš}HÜó‰XYyÔ24½'ü‹ÎRïäf}ŸïÊå êùýôÔ¿Oý:FI!!mø{eÉ”Û+'çÓ8jÉJž½N Ê™gܸtϦfW÷_ßdÜöؘ<] G@<z©Uìö$ sª±šu ‚Ïe))¶M%©¯õ©5è³ÂãQŸë2ñTS«8 ܔś+3nkŒØ›-&¦Dÿ+é~a4ožPe¶mÌ™D˜I¶5B@ÁÿûRÄì‚LÕ« ÇŒ¯I‰6¡I„°Ž(Ì‚H/`³3 é§«¹êŠÄ[&§I•ëFìi‘øŒ-Ä,·gŒEæVò6›žgeNsb÷~,å™".²ð•E0©Ÿu¢!»=þý›gÕüÜ‹ll¶‡‹éÍ{îŒX÷+*™VŒP",;šÎ£mTž ͺԕ篖÷ÍQùÇŸ>4ïG˪Ëܶ·¯é; +ËÔhžÁ±ÁSnú¢ðéý¯R$Mê‰dC™Y‚A„E R‚ó8íRÿ8ÿûRÄæ É©F„yÉ5ae…Œ±§Õ©¬ ’hºñí¯o¿Ûò4\fu@VFš²ÿÂH9dGŒéó£©(YM›sÊ‹ „}šð*9øs)þ¯t¹Z´b\Aˆ¨˜Œ(@½Õvd?Áµ#ÿáVGö›jšî:× â º˜oÀ±ÇPhË©­û’7”ÔÖYdAJ™Bˆfý¶u.´X¾¾fßù^ëž¾–Ÿïúâf/†¦Ú•ŒÅê†Ñy‚´ìXpXŽDaH²j[ÿûRÄé ± &„yÉm6aXКø&jjæ÷ˆHý}aÏ5Î)z[çxÛGXýò>ûä¾wZ§>]‘ƒ3Ë£€¦à£G&,›2‚" Ç#£®B9¬û4ëøó ¸fþ˜Š¤ þ]ÎYrÀ‡IG2–˜õð”a4jXõôÕcf<5v1í?¶ñœþ©œL°Y³å•šô_,®¼å3ëZ™³Ñ®„E(#9ÊLÉ‹œYè‰Õž-÷ݵ÷ö“õ®**v³. ÃÀ$H ^DÿûRÄë I±%„WɃµ¡XðŽùL1ÀEŠš•jg“Q66¤LLŒÝê³j_¯‹F¿œcßÊŸÈÔ¥FFSÒ1 >s‚ V€†A+™)“å_ÈÙû{ùß<»iØœ{dišƒóÝ×B‰*P’f#þÖ  …ZÕ³8·©Ÿe¦ùI,F²=—2z“9ÿž´rD…$Œúÿ9Ÿ2?D;æ‹—,…Å/ÿa7ý{i+iuve+,ŠEa‚(Ç:pXae(“Šùu_Öô¾%KÓyÿûRÄïÌI±…É}6 Àðšù£ø_ã8Ïη_¢åáve4.ùÔ.Éè]o9šº—ç'«1Ò»œ?RÒ¼4T?óf¯Ú²ÿq쟙ð»ÄÏ?‹aKtÜõãôÚP¬nåram­-´m"IV›J(’lm2RUäÂ.¢Å£ù">-H´£]«óÿ?>¯M;neEeõm´j¼O~¦r;ªQ´lŒbº} g£!­2ºYFtvµµ¢ÍgÆÓAÌ-H‡vr‡ØTuEEBA‡J/ ÑA ÿûRÄì ¥µ'„×Á{¶ Ñšù ûÿY¢¼3>‘œù£i™›°‰ŒÏDa2ÀJì͘ oZ:*ÉÕÏÌK,žÐãypzHèÈ‘ƒŠcÜä,ÁN ¸í–ÿûRÄïËÁ±¥„±Áª6`€±¦ø¾ì‘<‹+›ºŸëF†wß“_ùkJ®‹ïmý{32µ˜r˜ÃºÔ’ºÐ5ÝlC£ÙÙJšÉ—iÑÞÿÈ­[?ý—gêfM­nÅ”¨i’PUÐYϪ,5U€” pª˜s£še&›z»%o£R)–²å\„9žó:̲DÚúêÉTÊæ–Ss†GÂ3f3Rn•zÕeÄoÿ6ñ6U=Ž«J¸QïeŒBDô–%ƒÇp>A'EÂAÿûRÄè‚ÊU³„×Á–6 ”Т;«²,›6“gøPrÀôï5úï4ì‚®š8Oûpyò×w=ññ*¶“L4mn:ᣔ\éJ•RœëM/ëVF¿zÙ”sޏr挲i(ô ’&°ú¼‹‚³õ ¸ú;ÏÓ§]: ·7Zª®Ýj×{­Y´Z1þ—¢6ò×,ÛSMÚ¯¢Ô¨ŒÊJ)Š«v+Û‚—fÖèsÈL˵¤%7î|š3QÿoÛu½Ç(㦜³‰–u¾ ÿûPÄêÊÉ«Ɇ¶ €ÑùÆ0ðöÉ‚’¨•øqi# ˜«_}_g¶ÏéO¹*zë´|¹<³óñ>ÂõdåZX¾#Uífˆr.þ ÿþÛ-¥ás,’ÔU€¨XRƒ1RpØ1‘×>®”Í&˜ÊçïT1N^zÑŸ­íG¦œÿ=6#c…å[D¶©.Ds¯o™2böƒä‚5‘ÖTýÕºÊ?|¬rÚUÉÊ‘ÜÈŒåcÙÖÏ´Îôn÷]<‹=4tÝEcÍr˜6DY0ÿûRÄë€KI³ %Á6 €Ð¢8¬Å¡8½¦\pÀ¹•fJè̬š£{ïÞn¦Ù.G&rùZÿ‘Ïw%W=ÖM)Ã'è¦D=àžl¶°eY?—™ÏùÌÒû¡‘áÙÔ'QÈaah£8ê ÑDLS%ÑZNµ èZ™¨YÕ³ªêU^ËEú¯Ì¬ôÿü«tþXhJgÉOü悸‹‹]Þfz3°.™¥kÕ,Ô©Û¦ ‰Û¸—ÈÔó÷=œ®Pusìêmc±$I°Ú3MˆÍÿûRÄê€ ¡³ˆ×Á6µadpŽù¢d±BAµSÕĬ<¦™‹¦è$ô÷µ6©jí} dKÝ•é¥?ó¢ÒíѽȈ­P“×T©Î¹Q³ŒUô:Õ dÕ'<Ò/·Ý÷æu¾äævjëU5$Lã%DË$Uœ4If¢ÄC˜FP88Æ´æeÙ4Z¶½PÆÛ3³ùÉhå׋ï£3+$ù‘Ç5edòÈf;cE ¡MsL˜œ¿ _|Ë®cˆj¸¦n\Ç…›„a¡éˆ”•†Q¥¨ŒÿûRÄïMy± ;AC6! Žù4Åïd®×»};N?çWKÖöNŸÛK"óÛµÓóÎU'f;õÞ¯þàÙ²)¢¾C¤|ág›Z}9ý^ýuÏîþήük/«­Ã[f 5%—ž“—q¨Ìv ŒB6 VJN{-Qþ¨ŠŽîÌg»ào"¾§G„N.Lº#ü{ÍÇ4JYPùB@6R‡÷ q ÓU6ó}ôµZE4wññÍñiSw¥ñÚհ류{˜N%,YrÇ`ÒÿûRÄîƒÍ©³;Ir6 ÀÑù„8C.8Â!GFFUªÔy—×[™ìê«WwõºÝS›7¥zžM>¿Ÿ8µŽ}?V%×LL!LŠíå éöš™ö¯+ÓËøW¨ûfe꣙÷æGõòÔA ›dÌ£c—!H•¢çy#h…ÂjF¢¤É*ÑnÉm­YÙU“Wó}a½sôåüï ޶â¬Ôzác.ÔŽÁÌèÕþ_ÿ_>wðÑ»](ý6iÇ}Í&Zb9‘Ç6ɼš„¡f-ÿûRÄç€Jù³Á€6 ”¡§Ù´ÅH¦’´T´™ Ó¦›¥vU+×Z^•ÿvÛßÊ«ÞÌ9Ö×Î67Yâ0û²™7$bœh¹6ŒL@„°ýØ·ç™Ï “Ë,Éîm~öW\3ZWe75U.É…ö'èT¢È§4,Ž!mäŒU2häµú++{ýìÚý6|©ý:¿¾žýÝ«ô›ÜÕ{’·¿³4ŽÕJ©ÝÅÑ÷[+Z›“½4ÿíëq²Û;´i‰ÛlÌ6ή­ËáµÉªH¢‘H ÃñÿûRÄéƒÌ9±É‚5`€¡§¡qä»íZ½¹¦=“ïíÐôÖêm®c¢37ϱÉ}ŒÌM›56‹‘hû%:õÈAcˆGðèlò–{Žî­Y„ª!™.fž‘s¨‰ñ¹Âçöz’M¨K0ÎÈ®™F¹8Uþ×%-5˜.@ª @ÉÀC(›1oÑ'˜ŠÉ±ŸÔæâ"çý%(?×Ñy̽ÚÙÌÙ‡$g‰üL%Éœ¨Ho|‘p°ùot¯»n+WMaÖ:áòt¢­]ǃÿûRÄæJ ± „×ɳ6 @ѧi°¨c£YÕnÛ<Ö˜è™ú²Ì÷L»ÿÒf™¿®ÝŸùô®„ÿÍwùÊwv9•瞌kÙ¡£‚çÃbÐ+Ú{T,ÉþJgïþ¥eÊÐÏ”SÓÆnÏJèƒ]Õ²Òè !a7¶I2ÃhYõ C„ç«-uÙÚè}ìwZÚ­Vº¿×{?Ï¿mÓSÌE³^Ìã”{• îÇdyŽ ÓÍ3ßW»S§½ÚmÙ®—ÿcλ>›»ªÊ6v‘”"I=R I’tAäÿûRÄåÉÙ±ˆ×É¢¶ @¡§¡§XaJ@åÎ|×~ëC¿Ýêsi™SìöLÜíô»oèNF_œ·S¬ó¦æm·u" ™]zE-Ô©Ù­!úy8÷²HY—ñ.gÏ÷åÃë-¯kËRò¿5³¦ES""›+®ÂÃd‚"ôB@•=Yš»²jÝ÷lÞ×ùË>.æÿ'’N~¼Œé‘2…ÜΑ÷=n“#6ˆA¢Ùè‰ùtɪtÕ\ÜÄBôvg±Ô¶+BðÇ ‰U‡B¡‹2ÿûRÄçJe¯ …ɉ6`”¡šø@âQC‹±·G:f娌ëw{o¢Ùý\æ½YVûêïúN·Xé½üáß¾pÜëJGš‘|?>L‰`ÕG3Õ ÙÜ¢÷Êy;7â³s®4Ϫ‡£§ ß((š@ÌPáäkQ P°½I™ !¹Šc­)VMZ³Yýþ»’>'<¯ÙÆåù¿2*´fÌÊŽ£ 8fÈŒCè˜)%ãHݲ={­÷Ú1îoeY¦ìÓk$r˜¢a©-# é‘xðRÿûRÄê˹±×É—¶`@¡§¡P.fÎzª³"U:–ÖlËŸ¾égÝø‘{æró‹dŸK"Ë/Ú)Þÿ×øó™ÒIËöÒƒbmŒ– ÃÆ¢ àT°ÿûRÄæÊ‘±£…ÉŠ6 ”q›ÙTD{5fÑLë»æëŸú£RËZiÙ_jöšš2—–Ý…Óÿ”ÓîŠr)î§A9ÊšKÕ3þ ; çL®__EçŸÿqð÷½'íêWØY®=ˆò2M&‰¢m©&ÜhÃ#gÈ£"!ŬÖ{=µ~Ýgœ®•Kë·ögO~ý[î›aðœŽ$ä—;ö#V>‘㤙G#Nçܱt¦Wþey >röy7f¹h¹fÝÝÝ#´~£’ƒ%…°…ylL-ÿûRÄè€Jñ±#„×Éš¶ ”¡§0@©`—š·WÞ§Qé®>ìjLüÄ̪õ[{Vsý‰žè¾_û$ÏݬYSÅ¥Q· œÔó•àtévXV|çKIôá—™—¾>ÿÛ¯§}wçêöG#£G?GG,A"j[U@BOF²ÍfF~úWíkÑò½MýWÉ. Ñu+ÚJ/”„dCˆóJ;2³Œ6|ÙÎ|Ê]t-«÷yÏÚZµ«y*ÙjYtñ¦ß3ài4(°³ô$µ½þ×ÿûRÄçʹµ„×Áƒµà ¡§ÙujdÓí52>dóÒvŽvÏÊrX|UmÛ¹êuŠu“êª/zN»K¢$ã‘ÝåÓê^îî&û«žçþ+Dîîv¨¸¨†×‘ÏÃ*YcqÄ‹b‚ †¸p@ÐožêHP@‘sê¯v»Ñ×í©³¶\ú4½ ™»;´Nõ2ÈYiƒÝ¢F<¬ÔÉÐ%ˆ‹-ˆŽ.8Ñ'•™Æÿ.?Ö–îØSÞö¼‚niÝ3»‘_S§ˆr¸YàxÿûRÄé€ËµµŒÞÁz6 €¡› zÝ™õ:ªÛ¶t÷cŸÌê—ä¤yè÷/,ë±þU¿ü»›‘ýoÈŒ¸±[#D@‡Ù*óS Œ•5÷#á•Þ*Yúåêk¾Ö4˜›žËK ¥•¬1–YöàÉØádpq3FÕ€tɧWÝVè³=äRù3ï®®LÌå#ì¡b€œ3Ä©ÈÁ¡‡( Ôüµö“ÅK]¥ß•]Z_-Ö½Q÷W˽s[ŒW«…ÑâWE’#Àt2ƒÈ*ƒÿûRÄéJyµ ƒ„×Áž¶`@°¡ø¬…Y€€š´[Ù®Ó¶ZÞ¶÷¨¤]< E ÏÏ©&EœeSÀPî eHlÆ4\•³B§;ôuÄWµ£Ü,usÿé½;XéøEdík8mœY„Ž£ ±Ö&=„ÀÙ†T‡6-tšÎçê½ôfz²¶{æ:ý˜´~¾¬ÎÌÓe³×ÓóÔ©cé*OShÞämcdЧ[Zï¿—ùšDU¿é}æ\7sæ6µÎMTHŒÅ(·»ß¦=Ð(/ÿûRÄéK ³„×Á6 @¡šù– Âð:ë zÑYš»2Ù6¦ž·£›®¸¨òè(ürþnš‡„jHtNÌù‘OÐO`°Š$Û3Ç"½ÇƒËÓßyM³;<4O{¹HÆî’M ô¤A¨äŽe* *sfÓdZí¾uç-l»JyDín}NxÃ"MfOR3dÎܪ‹PˆÆm„ah,à2ˆ@AÖËcwi‰TeœSɵ³ßµåʤ¬3JEªÝ?¨ÜX<>ÙBŽÿûRÄè€Ë±±¥Éuµà” ¢yÀ! ’ëj«´ÅÚ·%Ìyâ© Œºh/)OH³=’g‘¢ŒzU²Õ¿¬z5SÇž­UIIUz}Ô¢óð÷ÇÇüDkÝ~¨¿÷OuÅ_gŠv±–ÅSÙẈnðA$‚bÕ™V<Æ?Ss­5-?«wµ^Û÷Ý骶¿­Û™érÎÐådæfpÓÒuÕÈýÃ0D¾nFFî¯ $úsU»‘÷™^ýÏÞ®êUó':„*rñ¦UY†Øš‹+VB’„$eÿûRÄé̱¥ ÞÉQ6 àpšùB0 3m Öö{$Å­û}½³wÞYÜþFGÉ—Ó´Y¿¤¼“¨&‰É( Q›Fv]ËÍÉGd]ÊGù“ä~îÕ^µîDø£_bžº!ôDŒ(È&i莱2@+¾rõMésŠÓn¶ž÷ÿïnù]¯9”æO5óÏøôýÔKžwñ}åTÊ*žw$å¦üÅôö2?8MiIìgÓGÿoÞ×Ìú^j1'V0b²h'ÃŽ| 8™43ÏÿûRÄìÌ1µ¥7Áz¶   ¢:±ÌéS¯eG3­žÎýëeÏ;ä½Ëûñ¡NþgžœÉˆ¦Ôk+,ôuZf]i±›Í.EÈG“S„r«‘g>•ÿ‰ñgïþå{(j²”S„¹¸cLâDb¦y²®ÕÛXŒy]@Æ""¢*"­sz&ìã)Dk7Eõ²ñ™zÊŽêlѱÔÜ ÉÍ#³§í¢)&ç¨x×ßÔó¬Jó;wmüsýjXÛÔ­ÖìÆ”¡…±å¦!œ–q&ãÿûRÄêÌ%± = J6 àpšøìL06fWT2Šš5µjºíÕ&;)‹ïgDלr"=ôÓÛ‘oÎur¦Ä?K£´~O•þ¬›w¤¦¼(iÃZ]Í{LÊC-úS럻¾é[íïf!u§KB» E3 $™¡¬‘ e v¯Chi®Ê¦+;VË»YyˆÆëjÑ Fê÷öÓuj%ö¾…äzúêö?m+P×S‡_.æi!ìDgn|*dG|Ûý‹m·Öù—÷(ÇÅ3Î%ˆÁ)YÿûRÄîËùµ¥ ÛA6  q§h†˜Ié@óÄÀG¶¯fc©7M5¦Uøwâè{üмR\Rþ‡jæv`-ù2FHì¤he'9nWqårÎ åõµ§Ã÷hÍÙä9Œ¼ª´‹ðyI'x™åê'ddgkL¶§£1«û"%O?W3/Þ­úŸShFÙјÑ”ÙŠ@¹ 3b»26ÙY ·ÈE:#š!ƒø¡eÇÝîÿ û ~”ᓯgn“d©^ºö£bÌÑ+×4® ÿûRÄêÌ9³…Ás¶`€¡›ØG¶«ª]îçwMŽ~úSojRýs~Ö¤Ù«}¤°Ÿk+J¨…¾Kó{ –‘žÜ„y¥¦ÛÁáï4´ÉôJðÙŠå-âyOæ^íKa7Ýj^a»d¨+Mœ.T²$âG£wK*ÍDvÓEÊ›Ýîí¶‡*ÑŒ’ç#O;^SÎ7cÛÏòùðcÌç´Iéiˆˆá£ñ!L˜1fÔâ>Èÿ2¬PÉ/ð‰ þêò£^qŒ2ype­’hÉ-¤º$Dň^0òÿûRÄéL)³¥ ÞÁQ¶ àpšøÉr `6ÀtÓ›kkt:–]šÅ1S…Ö<Îqþ™Ï™d^jÈÒbËjðÜã“P’3åŸ|ìì}ù‹ñêaó)ûÞýÇÎÝ›çioòò]šÿ‡ÊCWN:ÑÀÓàÄFb.c¡mZ-›g:eó,¦é:šUW²{¿[wìÝæ·7Ÿs,¾:!äÛÕ(M–¿l*ŠG•Õ+$ rbkåùGí?äþB¬Í¹ùû~ôöÁì!]¥S;MDɼÿûRÄìËŵ£…7Áx¶`”¡§ÚÙȬ´P꘤£O©Ík²*ôU[‚ÅsÞµykþ¯ÑÏaYßG “/#Kº8Ú4}jˆ®‹KÒr£ír¦¥g²«û'g÷{½NU°œqHE°™ÈF8˜ÑPa¦*rié·=]ëlþa¶E§S•³nÇÞ­vo|³IÏ>ùI3"œïg£?v™*ºCVU·Vhf1šÈq¼Ý3Ï58¥ì})îVþÊ9Ý{Œ^Õ!¤×I3’²r&b©(ªÿûRÄëƒÌÕµ >Áo6`€ šxŽ2„ù@ÑwÄu]ëlú÷Jïµóþ‰{W«~÷¯¾ùIÝî~uµè§S#¡V„²W±¤t’ýs»,·é3+~¿=÷?ÿxþ`ú—ÔsÖž¨Â Ö>ÈôOYBq‚Gµ @tê31³RÕѨ®Ô7Ú·¼ê«çhö³7ççìsn™ô´ïڈċ/Ó——ܦðêõNÉ'©EVŸ"XkÒÿùyûî[*·äÞ¤$©6)QUFùô§Äê¤dUÿûRÄèÌ9µ¥ žÁO¶ Àp–yÊDjQp ”×™ZVeït~sßJ5žØÞú²{´þõó!ÜçÅ3<ˆ Ñ¡hQŒHÛf@ 2™'…ƒ–Ân¹AüòÓö™÷C:6i† è¹dt±|€Ã'ª @œŒeœÕF̱Æ#^ÇílíÈ«e"ŒßUG‘œ]9JäµÈzÍY£‘b‚CÑÌÇU!hC-0ˆ^ Ñ>s%·êòÿt)Ö´ ÞcV££$(Ê$X»E†iRòÿûRÄëL¹µ =V¶ Ôqøm, ;SZº¶ý_5×Fm¨Ó5yÝ“zs+}e©ý(~¥ù¥Èç{ççqá.ärR—.5r³–}%ËÊÇSÎsí*ä^å—wþ³%(ÞmusÛS~f e¶,N»P,TÛg¨£b„nˆ‡£*íw«tšfÿNÛv½ï[.ægzK½ò¤úN™O¼Cœù£|4P]2ùyÒЊ*dpŒÉ¼ÎðÌþœ,²¤¶y67ý,Ø”ö›N×—øY @óùă ÿûRÄëÌUµ¥ >Ñc5à” šúÑY +6Ïu¶îæ7y¹÷«»"7׎Pvä_ò{QÊk…›Ðlî@açqÍDøC4ÂhB)“¼±Hä“öÖí±vÿõý†>1âݡϩDòÑä Kh&$“*0 žr\Û.ý¶”¬Ä響µsùé~î‹‘e«y{m2obÉ Pú,õs¨îÔ)'¦eb,Êp–;6 /Ôö¶;£úÉÆî ³M³V-4{6ä+\ê‘¥J 6 ÿûRÄëÌ!³7Áy¶` ¡§h‚äíÈ”Xµ( A9¬b.§ºo¡Þµ¦Óד%ÚYŸùÑø¡µð7d>Žby×=›—Íʾ‰MÕ]0EŠÂh²†Žr"iY 31e„ÏP²Aj‚Œ6•ui#C¸7a©ëš¸–ºc©.¥°ªÔJÓ˜‡<888pÿûRÄìM± = O¶`Ôpšøp+Hˆ³J¥©½]ŽÛ÷tG;öe£éSW}è–kö®”Åu½ö?"*ÇaU‰S<Ò‚‚©ŽZã$XP²C“+–QyÿJßòÈŠt3JÀÑÂFu‚Êá0d%¥1ò«,ÊmEd£­k§d5·ÐÒÊt7ê¼e4›»åé=”ä3ÝØ‘±å(ÍèÅvÕ4w¼9j›4ÄW?×]ÕGU=wÖýî·]=Wh«WVW¡ò-Øá1£x‡‹ÿûRÄëƒÌÙµ7Áo6 € ¢80> Š¡ÁÔ¦ÌîÓuŸ;&L‹î¦g)nw]v~VÙꃽNlljÂß_ï«E¯7E÷6’±$UZ'(õëÃZO¿3qÍÅi7UÄÎ÷\*C‹I&î7sU s# áã„!CBb òÂ×UhR;×}+Ùë}öEýº}sªµSi¿DZí_ü¹u M-Ë’KÔS=ÚÅÓ=³§¯otžJö¬ÈÉûO9 ËPÈÔŽD1S·c„Q¯ 0Á"ˆÿûRÄèËu³Á€6`”¡§à ëÐæt©öõØÞok#ѨúÓFìÎÎc¬ç¯ô«#ðþÖ.BoÉ„{eO”Ý]âÓç_9ªy¬³5?HecËrÿoï}ucë*·Óš'Ò¾ãRçØ@PDbPr ÖŽ"'FFØõ F•£ïyÎú z`¥ÞLœj<\z ûcÜçt85©±IÝÏZý>¯gGÄ%Ûvª±iþܽÕzó¯ÍÇ=U%¯ÏSÅ3érçCô… ,Â"‰»«e†ᄨ|ƒZàØÿûRÄèË͵¥Ñs5à€ ¢8ñA,D Xý›gEVs_Z_6»j¦QÏ]®·Ú…¼oç§6“ÏgòO,á*ÿl2×b2Cé~9rœ'G%’G;ù.|¹É><že/(7sìcù’ñHç•Ø9 3QȤ‹ ÑCˆ ±p•"7»ÕöYgØï1›gZ¦ÔÚOÕe'kféfÎï½.Ï룩Qδ-RU¤¬ï»Q–y±fî¿VS©Kæk^Ⱦ'½ºòûµ8­éÙI͈ †Ü]‘:Œ†ÿûRÄèK±Œß v6 €¡§Ù4Y5€v­GÑÑ‘鶬¨É3F¹«Ùf'Ýg¿v«¡ôOZ'u¹¹£NKŸ”” aŠfòyvreéÛIHW-4kù̷Ιäd¸ß¾~Õéýeg, ö5”š`t`MÉ‚t&BF A @Ï¥ó—J™M4é×NË9~Î×çé9ä¹A9äš2s¸&Ì©ÂQŒœ“¤ Ñ;DÕκè{¿âs¶ÛøÖÝÈ1çk¢³ÜÒàÑàƒˆ;œAÖÿûRÄëLyµ¥Áƒ6  q›hݬꬷÚ~›Ùû¾w>Œµ”Ý6gþ‹îû1¹ÅåJyò2.™G'3“oÑâD±™œ¬É÷«yK3å½çÒ(›Ôi8l(1×zг0èа š2©8ŒJ]±ª ßgT_=Sºªn þy· ”$ÎÞˆŠÐèµ™<ÿôçÕ}9ºÛ3¡zXð'/4™é4òhH @Öç£:]³¼þïÛÝêXŒ¾~_øE_äŒ9ålhMäI@¤7+ DL æ/´e¤?"~ÿö×ûšìs;(ž*(=5¥Ž8Ãùô ±‘ EºRÿûRÄêËõ³ÁO6`Ôqø£{7Fõ¦Èuè鞎¦ùÒB™Jÿ~|ý޹—äS1¤Ú LªZççPªÙ’úè†/rÂÌÁ’N`›5Ró6Ãÿvžsãs,þœZuû×–Ud®ÆöIdG„@˜Ì¡›£UV« ò}/ø^ºÃª¾Ùö±·˜F¶°Âg°” 8’ÿûRÄçJ³„×Á¡6 @¡¦ùè<¸ BhÏ‚CnêôT£u­•§3vé–SÏ^=õ‚šÔ÷þ^Ïç´tŒË+Šò‘O æO?:œú}[êð¬óuN)±{Ô$,0Šqe‡Vpð7°êwE9ºSªg½]O›vek·;Eºwž­õtMô¬Ûþv9ÞD>=6£ôÒ¦C)‘GoÐŽù‘7ÊœòÍÞo“yö–e½·Ì¶=÷G£S˜h^ &7†=3h™ €Ç¤dÿûRÄæÊ屈×Ál¶ €¡›¡Šìc¢êï©”oCY tuvnfý•4ó)Ÿoò+eÚeM½Ì¯ò4~™¶r­ó mÈ!f¤Ù*,K+§îl§2GS¦½Ê‘ê»rÙ×OmܪÆR@vMF92©§¨ÑØø—È!`& ÑR›_U­õ½ë5ÝkOMìÏT]Ö‰õfþsiù_¸‡ÄæuŽX©H»ƒŠe!ÿ´67&6Ô¯¿™¦_äe6Ÿ8wÛ:G á…UMáå0¨$ÿûRÄê€Lů ×ÁIµààp¢ù`‡ 9$¢‡Lî«cUïÏg}h×ý/‹U~Ÿ†+ƒËæñH„Z¦q‘æFBîBBb>fPˆdˆÙ2æP4Jô,ÞU×û›þWfkçeÑ‹QywÅ‹ë8ºÒ/ €ƒ“^@GUg¢=´nÌœç½eMU­;?;§tß;LOÅϼ!ÌÜÓ4$À×j¯l_2• v!7îj9ÏV=d„‰¤*-•aÖùŽcˆ†×¨¨»êµaЧìãÊQ´Í,ÌÿûRÄìÌ1³¥ ÞÁ™5à@¡§iòjÍÝ»§5)MŸÚzºjÝ?z%¾Ýn×§lÅ’ÅŸpD12ð¦hGò4Poÿ0Ó>™÷ÑÍ ÿ2¿fsµˆ»úþŸJ¶îqñbµœ‚¯>ªm5$–ä2^cEÄ(ÿûRÄæK…±Œß Rµ € ¦ùСH6t­ë{ÏV[UN9çÓ¥o­—´Ë²»›Ñlü"ò”“ï÷$ù?óΡÂZEœÊD†×Ë-.HÕàsy'ÿœÔ·¹ïŸ¯o×~ç¹O'mQ`LVæZU@ æŒE qÀJu1éDtkì‰GïÑU«º@½ôOË,‰oôsN²AeÄl(¬Œdo„PD‘È€ÈÚç }&¥šRWæ÷§ß‹v¯O5—„l¯cˆ¤XñìXÅd! &ªóÿûRÄëÍѵ¥!;‘E¶ ÀqùìÌÇÎVm{¹Ë3Ú¯­hžûöíÌø^ZúÞÓ=’_ÏsBõR†æÀ©4†¡Æ#ÒÔUJ‹YÔС­SðûŹw™šõŽ//9%¸bÌNVé´‰6Vœz1ôœHÕëÎsb–UB2UµXlÉcY”Nó/~޹—@„I2„Í_Œ×Gæø ;8Œ»\nŸ ™Z ˆÐð° ÈÔ¤°Š>ÚÔ×QWÏÌÇ_zíx‹¶­9ŒQ†î@ÐÚkS`pNáÙÿûRÄé‚Ë‘± >É€6  ¡› "ïŒU„,P «¬ÕÙU7sfíÙÿ¾è…o¥m¿gF^öê¥÷ºæ¯ºÌeªò‘sn _;ÞrYlg#:!g-:æDmù÷[þ×+<ü~ÎB6¢èuª‰‰BÄ A[R$ê³Ö‹²ìušÇ¥OþhÊYk̰}N‰êÖVOŠeƒLËŽ ‘ù™£M¡0ìq±¶qÓšùIÑ?È×õU+ËŒ}Ô'4£é;’**Ñ"FXBOE Û\úzÿûRÄéÊÕ±„×ɘ¶ @¡§h´»µì¹»{lw§þ³ÿÞžÚÖº;%®ÿ^اçÊO;Zš&”¸Va}‹É‰eÉœŽ{ÞùÄâÿÆÿìœßMïòô¶Ütã9[´@æ8×)$,â‚H$Ræ ÆÇÇÃ{æšÿ·Ì]Ç0÷Ìç“”ÏI5󹜙VûŇý!gr¦Mš[†'Ë:$(!4ZdæK›‘@ÍËîÀŠfž­H¼‹ö™lt®í¯’…%p}-‚ÐŒ"³eX+rj,)ÿûPÄè€Ë©±¥Ém¶  ¡ù—8B *@ÝV‡¡æ»%´5èÍýûNWB{ëÉ»þ•D¶¾îzÕQüµ¤‡Elé‚#©].º¢¼ìÌé;ú¢'g•«ÿñüí¿öƒ;øíeAß"‹Û’¬»¶“5‰„* u'²VÕMiœîëm:æý¦û-YczUz1÷¶ßë[|¹“”"¾_¦“RYA1©¸k<Ô’Ö&Ÿš‘‘Ä^å—¬&ÌÍ­éÝëq׫Ö*û˜q¥Øm_ÃÿûRÄé€Ë]µ…7Á\¶ Ôq›ÙÁ¸”ƒtÚŽuŒgéc:¸Ü÷SùÿiþiÁ^\s ¦ÉÌêy !°¤wsŒ¥žº›º^ÖšO›¹–Šÿ‹‰ÿëUþŸi¤\dÞJ=DžTˆÌYOc.¨xxH$Dâ‚¡ù£¥uÓYÊ«umÝUÿGïnm“MéÏÓ.õë¿™gRæ” AŒäÿœÉ|ºh…k!"ù6¹9Å«Ó;ç—.ÅïÜݤU=@´ÛÈ:…ÓE™²–f5†Ý# ÿûRÄî€M•³7Éd6 ìqù”°±9´æDw6¬ÇÒn¦û$Þ½öG×öžÍÎÒݽ}Ü­sŸÈ‡µ7Wsô6R»’Ï@ëŽU‹QŸQNÅãù­¥gÒ÷/éÈ}{MÕùZÆZÒ“ëD ÊÍs‡XvrKIޑР{=¶?9ýµ¶½çˆ PÔä»S•ùçæ¿ ¥¹¨ª²Ï´É˜s³l3#0Mr‘0vºŸÙÍwùþ7ãg6ù‘' Æ@ŠØE q8Ð$&ÈÿûRÄéËɳ¥ ÞÁw6 € ¢y“¾ÈôvÛsØÅ]w¥ýc¯ú^ûýÑŽ~–m¾–M§8ˆ^æÃ[H^[´zr·¹–²[£qÊÉXÚç¤Ó??=FïÎåySé3±´çL­r@Ú“’ŽÅ,"fu’–5˜Î¨×FÒ‹j_]ÎÏý½¶lÅGoËPÿ´Ù‹k¹RË=Ë$ËÊœ#Þ»á ×í‹èfO3G)PúìkÙèvDô?ϲØg”ºóÏ$sØ7rÊTÑT›[ c ÿûRÄé€Ì=µ¥ =z6  ¡›Ù®‰ åÑÌx5’÷óúÕ)tæÖ–zòü¿û}î¦3.q1™%a‰ØÆO`%«@ÔÙÍX¬%ïf`ÝŠYÎ,îýü!ýoØÔç ÞIDá»UŠ€•vŠaWHJv¦LFs]‘ÍÙ5SRùŽô«Ê½3)ÖÖ‘pVe!L²ì>}<œ’›OsPvƒi¾eå!Ä4®©{:² ¢;^\¼ˆËþä¹R…_òÙÿxeM†>Ú$Ï@èH£`(u“º© ÿûRÄçJ©± ƒ„×É‚6` q§Øàô€ÕV¢GtWfG2”¡Cx:‚_0WX.p94”E;…ee¥32㉋"«¸ˆHº»¹Níx‰¿þÑ*&.êZoUŽ6zñÖl©f|ˆŒ™&QL‘PŠÁµÀ ˜'áæÑÜź=Ð2 L×7F>ó%.Êf¢Î\â5 eqgõC8oïå-î­eR¾êÒžÑM¯¤›Jî.]¢«˜ÿ™‰ÿoOú¨6Ý-ÕE•†Iè6EŤy'ÿûRÄêÌU±¥ = D6 Àpšù#Ì$¡RÅÉ|6x Ží“B,µ,•]­wô3K{òú°ÿå=|¾hç˜ÑqL²Ñ”^Lj¢&‚$l¹ƒ(WP¿šûÿ7­÷Y²[¥Ì…öóÝdN_&£5è®*†édk)ètùùŽœÍ´«zè«W—®gé2›fM¾<×1$›g³% „”(4;A‘3RFb6Ëžé"}e)fR[ž^5)âd²²òÅ”½!4|¡RÔ“Íš0ˆhDªÿûRÄîLå³¥ ÛA†¶ ” ¢x­,OT;nó4yÝUòX÷œÛ#ÜŠ—:_ë^ì…uùó|ÍÁ¡CÓ…Hš&ul¸~ú•#Xtñ¼ál^s#¿sÈŸ•ó=Bã”ú˜ÊÍC šÍêH÷h¸¨P«Í‰ÒPe˜Ý*¦îÛ¥Ÿß9Öß¡{xº}–Y¹þˆ‡ù÷eȸ ‘:…¨Å3-º3gcgädX­|ëý·ÿåü4Þi/iÍH³›NNUâ‘‘‡ìàˆÿûRÄç€Ì…±É86a pšøcUÎDÍVdêÞ®ìus³nözó"3ÏiH¿×þŸ!<,ÒSΑ;–džg+“ÉRÅ›‚v44*æPÒKÊÁ\q®[³M ÊÉMôôBû꩜Êó”öšØË¦Ž-ÂÄѱIe#"B…ÉÌ™"‰fU(æÑÓ»zÏÌ沈ù[×^R~Yu#Ÿj¢é&Þ[±æ? ‘ˆœâÁ°0žSôd6 è³?ÿÏþzˆøÞ'P•NE¢¤ÖòOYÒ‘5‡<µÂQvÿûRÄìÌ=³…7É‹6 €¡¦ø¨{«™v£ê—ꪛ¢+ô5§mVôοÝúu³]Y"÷…t§©BE¹áZªòAAyù—¹'–fy)ØS"ÌóÿÌ¢Nó<>.««Ö1’ªCqVqƒü‹Á¶#5eTqR¯ÕlʯGwG1-Í÷ÔÝ»fÏ®rì¦w‹¯ÑO$\É™™âtÀ0J™€Àb` îH€4ÁvT+¿›Š½ð»ñüïÏý§4m^v5,j¥iC Š<€¨£‰ ˜±a0ÿûRÄç€ m± #„×ɧ¶`T¡§0˜A:dF´ùûk¢WQ&Z3;t²Cˆý¼Ëºô\?"¸äÉüô쩌æÓÒ*Zž ¯ø¾9·‹[ŠH®}â/ž«ª¢:–…[c’.$·¢³ìD¨±Cèaqéa0|=V€od2z3[>‡N­²ÂÐæÃ̺òËÎúù÷!›³Mº‘òª£&ޤíGK˜Kª¨G;…¶™Ûøßùžyš«­G£Ì›Bµ8„qDòS©,q"áPTÿûRÄæ€J ± „×É6  ¡§Ø‚€ < 7êUSMîëCç:9º×ùʽVÚ·£º=~¯LË¿·äEK^¯åœ©=+…€ÂÈ@ø¹ö>æu!Â÷<û9“eý¦eÓÍ¥ÿ¥·Õ÷/ì–ˆ•LßújJt‚“„áH‹ƒÓ].FD`²1û¡´W»Y¨æì«ÞõÑÙÛjmάœçß\»ñææÍbÚp¤áÙeé9–dF«±þ[g>çÞÛ•>h†eÿ¦I>|Îs_þÿ-ÛÄÆÇg¸ô£v™äñ‰TÿûRÄê‚Ì µ¥Ár¶ € ¢8¢ÁÓÐH@ ©•˜È©¥WU£+ܲ¨ÓÕK–¼ø­VfóyY'1!LA˜ÉÄAxBlÎÆ{&[¯_åOûÛ~ÿNϳ¬ÔÕSŒýãë}j,È2vPË 3˜Ø ½q'ž8Ý'¾¼w¥qû71Uiµ×ëFfxs­Ýj®åžnî #h$jiÊbDœk– f™U‰2™·›™jÑN ÿ·Üʹ¿ód«‹ v±§ O\¢¦.i$ÿûRÄêÌ9¯¥Él6 €¡›Ø XçmïSÕbVÓΛDt`¶¿¹Ôñ#žm Ìe>%”:òª×}™¢¼³Î5´ù!õû}ºÿûÿñó·È¾ò×Þ·ëÎäùÔÒ£×W+E3HÐL,¡`‡¥Ì صµÑ'çU]kçv6iŽ·K½jÙßÛOUèÿ1‘¾Vëd>Rsûm ™ô]ˆs‡R2ùõBÃ|å…›ý)^i[™'¥·RoÜâ¼"¶|N*¯ªê¥dYTB ÿûRÄé€KѵŒÛAz6   š¸!$i$ABL n‹¥ÝmÒš{×Õ<ó_¡æ„ý5|  É‘;¬†Ó"¹µE©ÞÀM“’Ì™îFL§sþ›3Ï1 ø£E\xµQxz# €ëÄ„ÿûRÄé ±¤×Éz6  šyŒ¾¿8Äz·®Œrß™sŠÄëG_Ò…ÜîZ5¨}3+Ûj\îŽÇ)ë!õ—œæêêxè‹LµkNS%-9Œ=®ìºîrS¶;*[¿7)«Ë15 5«1L RBä$²˜âÅ¢jPtú&¬µÙ=ÍDnˆ¾yÔ_oÖó*ó®wÛæW{¬“í¥¬¶åœË=Ö5ŽbxˆáK&Íaùw;”cýÿ±ùU{pJãS¹:SE_#Êi"bt¬’DQ3IÿûRÄéÌ!µ >ÁI5ààpšø‚4 âd!8„B#!LBÅ!<*aÿ! ¢üXTB„!>.! a„øqO‚rBÍÈ!_ÇðÜ´ÌÍj*,,vÍ5 E4@Ø[(¨r*H©€ ÕLAME3.99.5UUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUA5e–T6V²ÇIe–d­e–[,²¡Š  a¤²ÿûRÄíÌa±Éq¶ €¡ùË2V²Ê–ËßÍ™ÿÿÿù‘Û222ÿÿÿÿÿÿÿîÕ4ìípßÿý"DP`cˆÕU*LAME3.99.5ªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªÿûRÄëLµ¥ ;Ak¶Ÿ@¢øªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªÿûRÄËÊE¤rA„ÞÈ4€ªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªª././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1740309475.3557448 pa_dlna-0.16/pa_dlna/tests/libpulse.py0000644000000000000000000001462114756601743014736 0ustar00import sys import re import importlib import contextlib import asyncio import collections.abc from unittest import mock from . import skip_loop_iterations # The following values are right but only needed to have the pulseaudio module # import the current module as a stub. PA_SUBSCRIPTION_MASK_SINK_INPUT = 4 PA_INVALID_INDEX = -1 SKIP_LOOP_ITERATIONS = 30 @contextlib.contextmanager def use_libpulse_stubs(modules): """Patch 'modules' with stubs defined in this module. The first module in 'modules' is patched first. """ def recurse_import(modules): if len(modules): module = modules.pop(0) with mock.patch.dict('sys.modules', {module: importlib.import_module(module)}): return recurse_import(modules) + [sys.modules[module]] else: return [] for module in modules: if module in sys.modules: del sys.modules[module] for module in ('libpulse', 'libpulse.libpulse'): if module in sys.modules: del sys.modules[module] importlib.invalidate_caches() with mock.patch.dict('sys.modules', {'libpulse': sys.modules[__name__], 'libpulse.libpulse': sys.modules[__name__] }): yield tuple(reversed(recurse_import(modules.copy()))) for module in modules: assert module not in sys.modules class LibPulseError(Exception): pass class LibPulseClosedError(LibPulseError): pass class LibPulseStateError(LibPulseError): pass class LibPulseOperationError(LibPulseError): pass class Event: def __init__(self, event, proplist={'media.role': 'music'}): assert event in ('new', 'change', 'remove') self.type = event self.proplist = proplist self.index = None class EventIterator: """Pulse events asynchronous iterator.""" def __init__(self, lib_pulse): self.lib_pulse = lib_pulse def __aiter__(self): return self async def __anext__(self): while True: has_event = False for sink_input in self.lib_pulse.sink_inputs: event = sink_input.get_event() if event is not None: has_event = True return event # Allow the processing of the event. await skip_loop_iterations(SKIP_LOOP_ITERATIONS) if not has_event: # The sink_inputs don't have any more events. raise StopAsyncIteration class SinkInput: index = 0 def __init__(self, name, events): assert isinstance(events, collections.abc.Sequence) self.name = name self.events = events self.sink = None self.index = SinkInput.index SinkInput.index += 1 def get_event(self): if len(self.events): event = self.events.pop(0) self.proplist = event.proplist return event def __str__(self): return self.name class Sink: index = 0 def __init__(self, name, owner_module=None): self.name = name self.owner_module = owner_module self.sink_input = None self.index = Sink.index Sink.index += 1 def __str__(self): return self.name class LibPulse(): """LibPulse stub.""" sink_inputs = None sink_input_index = 0 do_raise_once = False def __init__(self, name): assert self.sink_inputs is not None, ('missing call to' ' LibPulse.add_sink_inputs()') self.raise_once() Sink.index = 0 Event.index = 0 self.module_index = 0 default_sink = Sink('auto-null') # The pulseaudio default sink. self.sinks = [default_sink] @classmethod def add_sink_inputs(cls, sink_inputs): """Extend the list of sink_inputs. This class method MUST be called BEFORE the instantiation of LibPulse. The first sink_input in the list (if any) is associated with the sink loaded by the following call to LibPulse.pa_context_load_module(). """ cls.sink_inputs = sink_inputs for sink_input in sink_inputs: index = cls.sink_input_index sink_input.index = index for event in sink_input.events: event.index = index cls.sink_input_index += 1 async def pa_context_load_module(self, module, args): assert module == 'module-null-sink' args = dict(re.findall(r"(?P\w+)=\"(?P[^\"]*)\"", args)) sink_name = args['sink_name'].strip("\"") for sink in self.sinks: if sink.name == sink_name: sink_name = sink_name + '.1' index = self.module_index sink = Sink(sink_name, owner_module=index) # Link this sink to the first sink_input. if len(LibPulse.sink_inputs): LibPulse.sink_inputs[0].sink = sink.index self.sinks.append(sink) self.module_index += 1 return index async def pa_context_unload_module(self, index): for i, sink in enumerate(list(self.sinks)): if sink.owner_module == index: self.sinks.pop(i) break async def pa_context_get_sink_info_list(self): return list(sink for sink in self.sinks) async def pa_context_get_sink_input_info_list(self): return list(sink_input for sink_input in LibPulse.sink_inputs) async def pa_context_get_sink_info_by_name(self, name): for sink in self.sinks: if sink.name == name: return sink async def pa_context_subscribe(self, mask): assert mask == PA_SUBSCRIPTION_MASK_SINK_INPUT async def pa_context_get_client_info(self, index): if self.sink_inputs: return self.sink_inputs[0].client async def pa_context_get_client_info_list(self): return [] async def log_server_info(self): return def get_events_iterator(self): return EventIterator(self) def raise_once(self): if self.do_raise_once: LibPulse.do_raise_once = False raise LibPulseStateError async def __aenter__(self): return self async def __aexit__(self, exc_type, exc_value, traceback): LibPulse.sink_inputs = None LibPulse.sink_input_index = 0 LibPulse.do_raise_once = False ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1677141993.6578732 pa_dlna-0.16/pa_dlna/tests/parec.py0000755000000000000000000000000014375623752016227 2streams.pyustar00././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1725626079.3197393 pa_dlna-0.16/pa_dlna/tests/streams.py0000755000000000000000000002775414666573337014622 0ustar00"""The parec.py (or encoder.py) script used for testing.""" import sys import os import io import asyncio import socket import time import tempfile import contextlib from unittest import mock from .libpulse import use_libpulse_stubs from ..http_server import HTTPServer from ..encoders import select_encoder, Encoder from ..config import UserConfig BLKSIZE = 2 ** 12 # 4096 ADEN_ARABIE = (b"J'avais vingt ans. Je ne laisserai personne dire que c'est" b' le plus bel age de la vie.') PAREC_PATH_ENV = 'PA_DLNA_PAREC_PATH' ENCODER_PATH_ENV = 'PA_DLNA_ENCODER_PATH' STDIN_FILENO = 0 STDOUT_FILENO = 1 # Use the patched pulseaudio and pa_dlna modules to avoid importing libpulse # that is not required for running the test. with use_libpulse_stubs(['pa_dlna.pulseaudio', 'pa_dlna.pa_dlna']) as modules: pulseaudio, pa_dlna = modules @contextlib.contextmanager def unix_socket_path(socket_path_env): path = tempfile.mktemp(prefix="test_http_", suffix='.sock', dir=os.path.curdir) path = os.path.abspath(path) with mock.patch.dict('os.environ', {socket_path_env: path}): yield path try: os.unlink(path) except OSError: pass async def run_curl(url, http_version='http1.1', extra_args=[]): curl_cmd = ['curl', '--silent', '--show-error', f'--{http_version}'] curl_cmd.extend(extra_args) curl_cmd.append(url) proc = await asyncio.create_subprocess_exec(*curl_cmd, stdin=asyncio.subprocess.DEVNULL, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE) stdout, stderr = await proc.communicate() if stderr and 0: print(f'CURL stderr: {stderr.decode().strip()}') return proc.returncode, len(stdout.decode()) async def new_renderer(mime_type): renderer = Renderer(ControlPoint(), mime_type) await renderer.setup() return renderer def set_control_point(control_point): control_point.parec_cmd = [sys.executable, '-m', 'pa_dlna.tests.parec'] # The following patches do: # - Make encoders available whether they are installed or not. # - Ignore the local pa_dlna.conf when it exists. with mock.patch.object(Encoder, 'available') as available,\ mock.patch('builtins.open', mock.mock_open()) as m_open: available.return_value = True m_open.side_effect = FileNotFoundError() control_point.config = UserConfig() async def play_track(mime_type, transactions, wait_for_completion=True, logs=None): if wait_for_completion: loop = asyncio.get_running_loop() completed = loop.create_future() else: completed = None env_path = PAREC_PATH_ENV if 'l16' in mime_type else ENCODER_PATH_ENV with unix_socket_path(env_path) as sock_path: renderer = await new_renderer(mime_type) # Start the http server. control_point = renderer.control_point http_server = _HTTPServer(control_point, renderer.root_device.local_ipaddress, control_point.port) http_server.allow_from(renderer.root_device.peer_ipaddress) http_server_t = asyncio.create_task(http_server.run(), name='http_server') # Start the AF_UNIX socket server. server = UnixSocketServer(sock_path, transactions, completed) server_t = asyncio.create_task(server.run(), name='socket server') # Start curl. await http_server.startup await server.ready_fut curl_task = asyncio.create_task(run_curl(renderer.current_uri), name='curl') # Wait for the last chunk of data to be written to the pipe read by # Track.write_track(). if completed is not None: try: await asyncio.wait_for(completed, timeout=1) except asyncio.TimeoutError: print(f'***** server.stage: {server.stage}', file=sys.stderr) print(f'***** http_server.stage: {http_server.stage}', file=sys.stderr) if logs is not None: print('\n'.join(l for l in logs.output if ':asyncio:' not in l), file=sys.stderr) raise return curl_task, renderer class UnixSocketServer: """Accept connections on an AF_UNIX socket.""" def __init__(self, path, transactions, completed): self.path = path self.transactions = transactions self.completed = completed self.stage = 'init' loop = asyncio.get_running_loop() self.ready_fut = loop.create_future() async def client_connected(self, reader, writer): """Handle request/expect transactions. The first element of 'transactions' is either: - 'ignore' - 'dont_sleep' - 'FFMpegEncoder' - an Exception class name The following elements are the number of bytes to write to stdout. """ self.stage = 'connected' first = self.transactions[0] self.stage = 'before first command' assert (first in ('ignore', 'dont_sleep', 'FFMpegEncoder') or isinstance(eval(first + '()'), Exception)) writer.write(first.encode()) resp = await reader.read(1024) assert resp == b'Ok' self.stage = 'before count loop' for count in self.transactions[1:]: assert isinstance(count, int) self.stage = 'before count write' writer.write(str(count).encode()) await writer.drain() self.stage = 'before count read' await reader.read(1024) self.stage = 'after count loop' try: writer.close() await writer.wait_closed() except ConnectionError: pass self.stage = 'end connection' if self.completed is not None: self.completed.set_result(True) async def run(self): try: aio_server = await asyncio.start_unix_server( self.client_connected, self.path) async with aio_server: self.ready_fut.set_result(True) await aio_server.serve_forever() except Exception as e: try: self.ready_fut.set_result(True) except asyncio.InvalidStateError: pass raise class _HTTPServer(HTTPServer): def __init__(self, control_point, ip_address, port): super().__init__(control_point, ip_address, port) self.stage = 'init' async def client_connected(self, reader, writer): self.stage = 'connected' try: return await super().client_connected(reader, writer) finally: self.stage = 'end connection' async def run(self): try: aio_server = await asyncio.start_server(self.client_connected, self.ip_address, self.port) async with aio_server: self.startup.set_result(True) await aio_server.serve_forever() except Exception as e: try: self.startup.set_result(True) except asyncio.InvalidStateError: pass raise class Sink: monitor_source_name = 'monitor source name' class NullSink: sink = Sink() class Renderer(pa_dlna.DLNATestDevice): def __init__(self, control_point, mime_type): super().__init__(control_point, mime_type) self.nullsink = NullSink() self.set_current_uri() async def setup(self): await self.select_encoder(self.root_device.udn) if self.encoder is not None: self.encoder.command = [sys.executable, '-m', 'pa_dlna.tests.encoder'] async def disable_for(self, *, period): pass async def disable_root_device(self): pass class ControlPoint(pa_dlna.AVControlPoint): def __init__(self): self.port = 8080 self.root_devices = {} set_control_point(self) def abort(self, msg): pass async def close(self, msg=None): pass ### The parec_py and encoder_py functions. ### def get_blk(): """Return BLKSIZE bytes.""" hunk = ADEN_ARABIE * (BLKSIZE // len(ADEN_ARABIE) + 1) hunk = hunk[:BLKSIZE] assert len(hunk) == BLKSIZE return hunk def handle_first_command(sock): return_code = 0 do_sleep = True resp = b'Ok' exception = None command = sock.recv(1024) command = command.decode() if command == 'ignore': pass elif command == 'dont_sleep': do_sleep = False elif command == 'FFMpegEncoder': return_code = 255 do_sleep = False else: try: obj = eval(command + '()') except NameError: resp = b'NameError' else: if isinstance(obj, Exception): exception = obj else: resp = b'Unknown' sock.sendall(resp) if exception is not None: raise exception return return_code, do_sleep def parec_py(): print('parec stub starting', file=sys.stderr) return_code = 0 hunk = get_blk() stdout = io.BufferedWriter(io.FileIO(STDOUT_FILENO, mode='w')) try: socket_path = os.environ.get(PAREC_PATH_ENV) if socket_path is None: while True: stdout.write(hunk) else: with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock: sock.connect(socket_path) return_code, do_sleep = handle_first_command(sock) while True: # Get the 'count' value. bcount = sock.recv(1024) if not bcount: return count = int(bcount) assert count % BLKSIZE == 0 # Write 'count' bytes. for i in range(count // BLKSIZE): stdout.write(hunk) stdout.flush() # Write the 'count' value. sock.sendall(bcount) except Exception as e: print(f'parec stub error: {e!r}', file=sys.stderr) return_code = 1 finally: stdout.close() return return_code def encoder_py(): """Write for ever 'count' bytes read from stdin to stdout.""" print('encoder stub starting', file=sys.stderr) stdin = io.BufferedReader(io.FileIO(STDIN_FILENO, mode='r')) stdout = io.BufferedWriter(io.FileIO(STDOUT_FILENO, mode='w')) try: socket_path = os.environ[ENCODER_PATH_ENV] with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock: sock.connect(socket_path) return_code, do_sleep = handle_first_command(sock) while True: # Get the 'count' value. bcount = sock.recv(1024) if not bcount: break count = int(bcount) # Write 'count' bytes. data = stdin.read(count) stdout.write(data) stdout.flush() # Write the 'count' value. sock.sendall(bcount) # Sleep to let the Track.run() task be cancelled by Track.stop(). if do_sleep: time.sleep(10) except Exception as e: print(f'encoder stub error: {e!r}', file=sys.stderr) return_code = 1 finally: stdin.close() stdout.close() print(f'encoder stub return_code: {return_code}', file=sys.stderr) return return_code def main(): processes = { 'parec.py': parec_py, 'encoder.py': encoder_py } proc_name = os.path.basename(sys.argv[0]) return processes[proc_name]() if __name__ == '__main__': sys.exit(main()) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1734864562.9350698 pa_dlna-0.16/pa_dlna/tests/test_config.py0000644000000000000000000003242014731767263015423 0ustar00"""Encoders configuration test cases.""" import io from unittest import mock from contextlib import redirect_stdout from configparser import ParsingError # Load the tests in the order they are declared. from . import load_ordered_tests as load_tests from . import BaseTestCase, requires_resources from ..config import UserConfig from ..encoders import select_encoder UDN = 'uuid:ffffffff-ffff-ffff-ffff-ffffffffffff' class Encoder: def __init__(self): self.selection = ['TestEncoder'] self.args = None self.option = 1 @property def available(self): if hasattr(self, '_available'): return self._available return True def set_args(self): raise NotImplementedError class StandAloneEncoder(Encoder): def __init__(self): super().__init__() class TestEncoder(StandAloneEncoder): def __init__(self): StandAloneEncoder.__init__(self) def set_args(self): self.args = f'command line: {self.option}' class encoders_module: def __init__(self, root=Encoder, encoder=TestEncoder): self.ROOT_ENCODER = root self.TestEncoder = encoder @requires_resources('os.devnull') class DefaultConfig(BaseTestCase): """Default configuration tests.""" def test_invalid_section(self): name = 'InvalidEncoder' class _Encoder(Encoder): def __init__(self): super().__init__() self.selection = [name] with mock.patch('pa_dlna.config.encoders_module', new=encoders_module(root=_Encoder)),\ self.assertRaises(ParsingError) as cm: from ..config import DefaultConfig DefaultConfig() self.assertEqual(cm.exception.args[0], f"'{name}' is not a valid class name") @requires_resources(('os.devnull', 'ffmpeg')) class UserConfigTests(BaseTestCase): """User configuration tests.""" def test_invalid_value(self): value = 'string' pa_dlna_conf = f""" [TestEncoder] option = {value} """ with mock.patch('pa_dlna.config.encoders_module', new=encoders_module()),\ mock.patch('builtins.open', mock.mock_open( read_data=pa_dlna_conf)),\ self.assertRaises(ParsingError) as cm: UserConfig() self.assertRegex(cm.exception.args[0], f"TestEncoder.option: invalid .*'{value}'") def test_option_negative_value(self): value = -1 pa_dlna_conf = f""" [TestEncoder] option = {value} """ with mock.patch('pa_dlna.config.encoders_module', new=encoders_module()),\ mock.patch('builtins.open', mock.mock_open( read_data=pa_dlna_conf)),\ self.assertRaises(ParsingError) as cm: UserConfig() self.assertRegex(cm.exception.args[0], f'TestEncoder.option: {value} is negative') def test_invalid_option(self): pa_dlna_conf = """ [TestEncoder] invalid = 1 """ with mock.patch('pa_dlna.config.encoders_module', new=encoders_module()),\ mock.patch('builtins.open', mock.mock_open( read_data=pa_dlna_conf)),\ self.assertRaises(ParsingError) as cm: UserConfig() self.assertEqual(cm.exception.args[0], "Unknown option 'TestEncoder.invalid'") def test_default_conf(self): with mock.patch('pa_dlna.config.encoders_module', new=encoders_module()),\ mock.patch('builtins.open', mock.mock_open()) as m_open: m_open.side_effect = FileNotFoundError() cfg = UserConfig() self.assertEqual(cfg.encoders['TestEncoder'].__dict__, {'args': 'command line: 1', 'option': 1}) def test_user_conf(self): pa_dlna_conf = """ [TestEncoder] option = 2 """ with mock.patch('pa_dlna.config.encoders_module', new=encoders_module()),\ mock.patch('builtins.open', mock.mock_open( read_data=pa_dlna_conf)): cfg = UserConfig() self.assertEqual(cfg.encoders['TestEncoder'].__dict__, {'args': 'command line: 2', 'option': 2}) def test_customize_args_option(self): pa_dlna_conf = """ [FFMpegMp3Encoder] bitrate = 320 args = foo """ with mock.patch('builtins.open', mock.mock_open( read_data=pa_dlna_conf)): cfg = UserConfig() self.assertEqual(cfg.encoders['FFMpegMp3Encoder'].args, 'foo') def test_command_qscale(self): pa_dlna_conf = """ [FFMpegMp3Encoder] bitrate = 0 qscale = 2 """ with mock.patch('builtins.open', mock.mock_open( read_data=pa_dlna_conf)): cfg = UserConfig() arg = '-qscale:a' command = cfg.encoders['FFMpegMp3Encoder'].command self.assertTrue(arg in command) index = command.index(arg) self.assertEqual(command[index+1], '2') def test_default_sample_formats(self): configs = ( ('FFMpegMp3Encoder', 's16le'), ('FFMpegL16WavEncoder', 's16be'), ('L16Encoder', 's16be'), ) for encoder, format in configs: pa_dlna_conf = f'[{encoder}]' with self.subTest(pa_dlna_conf=pa_dlna_conf, format=format),\ mock.patch('builtins.open', mock.mock_open( read_data=pa_dlna_conf)): cfg = UserConfig() self.assertEqual(cfg.encoders[encoder].sample_format, format) def test_mp3_sample_format(self): pa_dlna_conf = """ [FFMpegMp3Encoder] sample_format = s32le """ with mock.patch('builtins.open', mock.mock_open( read_data=pa_dlna_conf)): cfg = UserConfig() self.assertEqual(cfg.encoders['FFMpegMp3Encoder'].sample_format, 's32le') def test_l16_sample_format(self): pa_dlna_conf = """ [L16Encoder] """ with mock.patch('builtins.open', mock.mock_open( read_data=pa_dlna_conf)): cfg = UserConfig() self.assertEqual(cfg.encoders['L16Encoder'].sample_format, 's16be') def test_l16_udn_sample_format(self): pa_dlna_conf = """ [L16Encoder.uuid:9ab0c000] """ with mock.patch('builtins.open', mock.mock_open( read_data=pa_dlna_conf)): cfg = UserConfig() self.assertEqual(cfg.udns['uuid:9ab0c000'].sample_format, 's16be') def test_not_available(self): class UnAvailableEncoder(StandAloneEncoder): def __init__(self): super().__init__() self._available = False def set_args(self): pass with mock.patch('pa_dlna.config.encoders_module', new=encoders_module(encoder=UnAvailableEncoder)),\ mock.patch('builtins.open', mock.mock_open()) as m_open,\ redirect_stdout(io.StringIO()) as output: m_open.side_effect = FileNotFoundError() cfg = UserConfig() cfg.print_internal_config() self.assertEqual(cfg.encoders, {}) self.assertIn('No encoder is available\n', output.getvalue()) def test_invalid_section(self): pa_dlna_conf = """ [TestEncoder.] """ with mock.patch('pa_dlna.config.encoders_module', new=encoders_module()),\ mock.patch('builtins.open', mock.mock_open( read_data=pa_dlna_conf)),\ self.assertRaises(ParsingError) as cm: UserConfig() self.assertEqual(cm.exception.args[0], "'TestEncoder.' is not a valid section") def test_not_exists(self): pa_dlna_conf = """ [DEFAULT] selection = UnknownEncoder [UnknownEncoder] """ with mock.patch('pa_dlna.config.encoders_module', new=encoders_module()),\ mock.patch('builtins.open', mock.mock_open( read_data=pa_dlna_conf)),\ self.assertRaises(ParsingError) as cm: UserConfig() self.assertEqual(cm.exception.args[0] , "'UnknownEncoder' encoder does not exist") def test_invalid_encoder(self): pa_dlna_conf = """ [DEFAULT] selection = UnknownEncoder """ with mock.patch('pa_dlna.config.encoders_module', new=encoders_module()),\ mock.patch('builtins.open', mock.mock_open( read_data=pa_dlna_conf)),\ self.assertRaises(ParsingError) as cm: UserConfig() self.assertEqual(cm.exception.args[0], "'UnknownEncoder' in the" ' selection is not a valid encoder') def test_udn_section(self): pa_dlna_conf = f""" [TestEncoder.{UDN}] """ with mock.patch('pa_dlna.config.encoders_module', new=encoders_module()),\ mock.patch('builtins.open', mock.mock_open( read_data=pa_dlna_conf)),\ redirect_stdout(io.StringIO()) as output: UserConfig().print_internal_config() self.assertIn(f"{{'{UDN}': {{'_encoder': 'TestEncoder'", output.getvalue()) def test_update_args_option(self): pa_dlna_conf = """ [DEFAULT] selection = FFMpegMp3Encoder, [FFMpegMp3Encoder] bitrate = 320 """ with mock.patch('builtins.open', mock.mock_open( read_data=pa_dlna_conf)): cfg = UserConfig() self.assertIn('-b:a 320k', cfg.encoders['FFMpegMp3Encoder'].args) def test_udn_update_args_option(self): pa_dlna_conf = f""" [FFMpegMp3Encoder.{UDN}] bitrate = 320 """ with mock.patch('builtins.open', mock.mock_open( read_data=pa_dlna_conf)): cfg = UserConfig() self.assertIn('-b:a 320k', cfg.udns[UDN].args) @requires_resources('os.devnull') class Encoders(BaseTestCase): """Encoders tests.""" def l16_mime_type(self, mime_type, rate=0, channels=0, udn=None): pinfo = {'Sink': f'http-get:*:{mime_type}:DLNA.ORG_PN=LPCM'} config = UserConfig() if udn is not None: self.assertEqual(config.encoders, {}) self.assertIn(udn, config.udns) else: # Set the attributes of the L16Encoder instance. l16 = config.encoders['L16Encoder'] l16.rate = rate l16.channels = channels res = select_encoder(config, 'Renderer name', pinfo, udn) if res is not None: encoder, mtype, protocol_info = res self.assertEqual(encoder.__class__.__name__, 'L16Encoder') self.assertEqual(mtype, mime_type) self.assertEqual(protocol_info, f'http-get:*:{mime_type}:DLNA.ORG_PN=LPCM') return res def test_select_L16(self): rate_channels = [(44100, 2), (44100, 1), (88200, 2)] mime_types = ['audio/L16;channels={channels};rate={rate}', 'audio/l16;rate={rate};channels={channels}'] for rate, channels in rate_channels: for mtype in mime_types: mtype = mtype.format(rate=rate, channels=channels) with self.subTest(mtype=mtype),\ mock.patch('builtins.open', mock.mock_open()) as m_open: m_open.side_effect = FileNotFoundError() self.l16_mime_type(mtype, rate, channels) def test_select_udn(self): pa_dlna_conf = f""" [DEFAULT] selection = [L16Encoder.{UDN}] """ with mock.patch('builtins.open', mock.mock_open( read_data=pa_dlna_conf)): res = self.l16_mime_type('audio/L16;channels=2;rate=44100', udn=UDN) self.assertNotEqual(res, None) def test_bad_mtype(self): pa_dlna_conf = f""" [DEFAULT] selection = [L16Encoder.{UDN}] """ mime_types = [ 'audio/L16;channels=2;rate=88200', # 88200 is invalid param 'audio/FOO;channels=2;rate=44100', # not L16 mime type 'audio/L16;channels=2;rate=FOO' # wrong param value ] for mtype in mime_types: with self.subTest(mtype=mtype),\ mock.patch('builtins.open', mock.mock_open( read_data=pa_dlna_conf)): res = self.l16_mime_type(mtype, udn=UDN) self.assertEqual(res, None) if __name__ == '__main__': unittest.main(verbosity=2) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1729706227.7023277 pa_dlna-0.16/pa_dlna/tests/test_http_server.py0000644000000000000000000003343514706234364016523 0ustar00"""Http server test cases.""" import re import asyncio import logging from unittest import IsolatedAsyncioTestCase, mock # Load the tests in the order they are declared. from . import load_ordered_tests as load_tests from . import requires_resources, find_in_logs, search_in_logs from .streams import (BLKSIZE, run_curl, new_renderer, play_track, Renderer, ControlPoint) from ..encoders import FFMpegEncoder, L16Encoder from ..http_server import HTTPServer, Track, HTTPRequestHandler async def start_http_server(allow_from=True): renderer = await new_renderer('audio/mp3') # Start the http server. control_point = renderer.control_point http_server = HTTPServer(control_point, renderer.root_device.local_ipaddress, control_point.port) if allow_from: http_server.allow_from(renderer.root_device.peer_ipaddress) asyncio.create_task(http_server.run(), name='http_server') await http_server.startup # Tests using that function fail randomly on GitLab and Python 3.11 with # the curl error: # CURLE_COULDNT_CONNECT (7) Failed to connect() to host or proxy. # When the http_server.startup future is done, the Server._start_serving() # method of asyncio's base_events module has added the socket to the # asyncio loop, but the http server is not yet ready to accept # connections. Therefore we wait some loop iterations. for i in range(10): await asyncio.sleep(0) return renderer @requires_resources('curl') class Http_Server(IsolatedAsyncioTestCase): """Http server test cases.""" def skip_if_curl_cannot_connect(self, returncode): # Curl fails to connect to the http server under the following # conditions: # - only when run with coverage.py # - only on Python 3.11 # - on GitLab CI/CD if returncode == 7: self.skipTest('CURLE_COULDNT_CONNECT (7) Failed to connect() to' ' host or proxy') async def test_play_mp3(self): with self.assertLogs(level=logging.DEBUG) as m_logs: transactions = ['ignore', 16 * BLKSIZE] curl_task, renderer = await play_track('audio/mp3', transactions, logs=m_logs) assert not isinstance(renderer.encoder, FFMpegEncoder) await renderer.stream_sessions.stop_track() await renderer.stream_sessions.processes.close() returncode, length = await curl_task self.assertEqual(returncode, 0) self.assertEqual(length, sum(transactions[1:])) # Issue #5: # 'The parec command line length keeps increasing at each new track'. self.assertEqual(renderer.control_point.parec_cmd, ControlPoint().parec_cmd) async def test_play_aiff(self): with self.assertLogs(level=logging.DEBUG) as m_logs: transactions = ['ignore', 16 * BLKSIZE] curl_task, renderer = await play_track('audio/aiff', transactions, logs=m_logs) assert isinstance(renderer.encoder, FFMpegEncoder) await renderer.stream_sessions.stop_track() await renderer.stream_sessions.processes.close() returncode, length = await curl_task self.assertEqual(returncode, 0) self.assertEqual(length, sum(transactions[1:])) async def test_play_aiff_255(self): # Test that an FFMpegEncoder encoder exiting with an exit_status of # 255 is reported as 'Terminated'. with self.assertLogs(level=logging.DEBUG) as m_logs: transactions = ['FFMpegEncoder', 16 * BLKSIZE] curl_task, renderer = await play_track('audio/aiff', transactions, logs=m_logs) assert isinstance(renderer.encoder, FFMpegEncoder) await renderer.stream_sessions.processes.encoder_task await renderer.stream_sessions.processes.close() returncode, length = await curl_task self.assertEqual(returncode, 0) self.assertEqual(length, sum(transactions[1:])) self.assertTrue(find_in_logs(m_logs.output, 'http', 'Exit status of encoder process: Terminated')) self.assertTrue(find_in_logs(m_logs.output, 'encoder', 'encoder stub return_code: 255')) async def test_play_l16(self): # Test playing track with no encoder. with self.assertLogs(level=logging.DEBUG) as m_logs: mime_type = 'audio/l16;rate=44100;channels=2' transactions = ['ignore', 16 * BLKSIZE] curl_task, renderer = await play_track(mime_type, transactions, logs=m_logs) assert isinstance(renderer.encoder, L16Encoder) await renderer.stream_sessions.processes.parec_task await renderer.stream_sessions.processes.close() returncode, length = await curl_task self.assertEqual(returncode, 0) self.assertEqual(length, sum(transactions[1:])) async def test_close_session(self): with self.assertLogs(level=logging.DEBUG) as m_logs: transactions = ['ignore', 16 * BLKSIZE] curl_task, renderer = await play_track('audio/mp3', transactions, logs=m_logs) await renderer.stream_sessions.close_session() returncode, length = await curl_task self.assertEqual(returncode, 0) self.assertEqual(length, sum(transactions[1:])) async def test_partial_read(self): # Check use of IncompleteReadError in Track.write_track(). with self.assertLogs(level=logging.DEBUG) as m_logs: data_size = 16 * BLKSIZE + 1 transactions = ['dont_sleep', data_size] curl_task, renderer = await play_track('audio/mp3', transactions, logs=m_logs) await renderer.stream_sessions.processes.encoder_task await renderer.stream_sessions.processes.close() returncode, length = await curl_task self.assertEqual(returncode, 0) self.assertEqual(length, sum(transactions[1:])) async def test_ConnectionError(self): with mock.patch.object(Track, 'write_track') as wtrack,\ self.assertLogs(level=logging.DEBUG) as m_logs: wtrack.side_effect = ConnectionError() curl_task, renderer = await play_track('audio/mp3', ['ignore', BLKSIZE], wait_for_completion=False, logs=m_logs) returncode, length = await curl_task self.assertEqual(returncode, 0) self.assertEqual(length, 0) self.assertTrue(search_in_logs(m_logs.output, 'http', re.compile('HTTP socket is closed: ConnectionError'))) async def test_Exception(self): with mock.patch.object(Track, 'write_track') as wtrack,\ self.assertLogs(level=logging.INFO) as m_logs: wtrack.side_effect = RuntimeError() curl_task, renderer = await play_track('audio/mp3', ['ignore', BLKSIZE], wait_for_completion=False, logs=m_logs) returncode, length = await curl_task # Sleep to let the logger in the log_unhandled_exception decorator # log the exception before asyncio termination. Otherwise the # log message is printed on stderr after the unittest test has # terminated. Using asyncSetUp() and asyncTearDown() does not help. await asyncio.sleep(0.5) self.assertEqual(returncode, 0) self.assertEqual(length, 0) self.assertTrue(search_in_logs(m_logs.output, 'http', re.compile(r'RuntimeError\(\)'))) async def test_disable_with_encoder(self): with mock.patch.object(Renderer, 'disable_root_device') as disable,\ self.assertLogs(level=logging.DEBUG) as m_logs: curl_task, renderer = await play_track('audio/mp3', ['OSError'], logs=m_logs) returncode, length = await curl_task await renderer.stream_sessions.processes.encoder_task disable.assert_called_once() self.assertEqual(returncode, 0) self.assertEqual(length, 0) self.assertTrue(find_in_logs(m_logs.output, 'http', 'Exit status of encoder process: 1')) async def test_disable_with_parec(self): with mock.patch.object(Renderer, 'disable_root_device') as disable,\ self.assertLogs(level=logging.DEBUG) as m_logs: mime_type = 'audio/l16;rate=44100;channels=2' curl_task, renderer = await play_track(mime_type, ['OSError'], logs=m_logs) await renderer.stream_sessions.processes.parec_task await renderer.stream_sessions.processes.close() returncode, length = await curl_task disable.assert_called_once() self.assertEqual(returncode, 0) self.assertEqual(length, 0) self.assertTrue(find_in_logs(m_logs.output, 'http', 'Exit status of parec process: 1')) async def test_not_allowed(self): with self.assertLogs(level=logging.INFO) as m_logs: renderer = await start_http_server(allow_from=False) # Start curl. curl_task = asyncio.create_task(run_curl(renderer.current_uri)) returncode, length = await asyncio.wait_for(curl_task, timeout=1) self.assertNotEqual(returncode, 0) self.assertEqual(length, 0) self.assertTrue(search_in_logs(m_logs.output, 'http', re.compile('Discarded.*not allowed'))) async def test_renderer_not_found(self): with self.assertLogs(level=logging.INFO) as m_logs: renderer = await start_http_server() # Start curl. curl_task = asyncio.create_task(run_curl( renderer.current_uri + 'fff')) returncode, length = await asyncio.wait_for(curl_task, timeout=1) self.assertEqual(returncode, 0) self.assertNotEqual(length, 0) self.assertTrue(search_in_logs(m_logs.output, 'util', re.compile('Cannot find a matching renderer'))) async def test_http_version(self): with self.assertLogs(level=logging.INFO) as m_logs: renderer = await start_http_server() # Start curl. curl_task = asyncio.create_task(run_curl(renderer.current_uri, http_version='http1.0')) returncode, length = await asyncio.wait_for(curl_task, timeout=1) self.assertEqual(returncode, 0) self.assertNotEqual(length, 0) self.assertTrue(search_in_logs(m_logs.output, 'util', re.compile('HTTP Version Not Supported'))) async def test_is_playing(self): with self.assertLogs(level=logging.INFO) as m_logs: renderer = await start_http_server() renderer.stream_sessions.is_playing = True # Start curl. curl_task = asyncio.create_task(run_curl(renderer.current_uri)) returncode, length = await asyncio.wait_for(curl_task, timeout=1) self.skip_if_curl_cannot_connect(returncode) self.assertEqual(returncode, 0) self.assertNotEqual(length, 0) self.assertTrue(search_in_logs(m_logs.output, 'util', re.compile('Cannot start DLNATest.* stream .already running'))) async def test_None_nullsink(self): with self.assertLogs(level=logging.INFO) as m_logs: renderer = await start_http_server() renderer.nullsink = None # Start curl. curl_task = asyncio.create_task(run_curl(renderer.current_uri)) returncode, length = await asyncio.wait_for(curl_task, timeout=1) self.skip_if_curl_cannot_connect(returncode) self.assertEqual(returncode, 0) self.assertNotEqual(length, 0) self.assertTrue(search_in_logs(m_logs.output, 'util', re.compile('DLNATest.* temporarily disabled'))) async def test_no_path_in_request(self): with mock.patch.object(HTTPRequestHandler, 'handle_one_request'),\ self.assertLogs(level=logging.DEBUG) as m_logs: renderer = await start_http_server() # Start curl. curl_task = asyncio.create_task(run_curl(renderer.current_uri)) returncode, length = await asyncio.wait_for(curl_task, timeout=1) self.skip_if_curl_cannot_connect(returncode) # curl: (52) Empty reply from server. # See https://curl.se/libcurl/c/libcurl-errors.html self.assertEqual(returncode, 52) self.assertEqual(length, 0) self.assertTrue(search_in_logs(m_logs.output, 'http', re.compile('Invalid path in HTTP request'))) async def test_HEAD_method(self): with self.assertLogs(level=logging.INFO) as m_logs: renderer = await start_http_server() # Start curl. curl_task = asyncio.create_task(run_curl(renderer.current_uri, extra_args=['--head'])) returncode, length = await asyncio.wait_for(curl_task, timeout=1) self.assertEqual(returncode, 0) self.assertNotEqual(length, 0) self.assertTrue(find_in_logs(m_logs.output, 'util', 'HTTP/1.1 HEAD request from 127.0.0.1')) if __name__ == '__main__': unittest.main(verbosity=2) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1735744189.8836317 pa_dlna-0.16/pa_dlna/tests/test_init.py0000644000000000000000000003305614735255276015127 0ustar00"""Command line test cases.""" import sys import os import io import struct import ipaddress import logging import unittest import tempfile import pathlib try: import termios import pty except ImportError: pass from contextlib import redirect_stdout, redirect_stderr from unittest import mock # Load the tests in the order they are declared. from . import load_ordered_tests as load_tests from . import requires_resources, BaseTestCase from ..init import parse_args, padlna_main, UPnPApplication, disable_xonxoff from ..encoders import Encoder from ..config import user_config_pathname # b'\x13' is the Stop character . # b'\x11' is the Start character . XON_XOFF_BYTES_ARRAY = b'A \x13 \x11 B\n' def echo_xonxoff_array(): txt = sys.stdin.readline() print(txt, end='') # When XON/XOFF is enabled, the Stop and Start characters are not passed # as input and the following condition evaluates to True. if txt.encode() != XON_XOFF_BYTES_ARRAY: sys.exit(1) @requires_resources('os.devnull') class Init(BaseTestCase): def test_python_version(self): import sys import importlib import pa_dlna from .. import MIN_PYTHON_VERSION version = (MIN_PYTHON_VERSION[0], MIN_PYTHON_VERSION[1] - 1) try: with mock.patch.object(sys, 'version_info', version),\ redirect_stderr(io.StringIO()) as output,\ self.assertRaises(SystemExit) as cm: pa_dlna = importlib.reload(pa_dlna) self.assertEqual(cm.exception.args[0], 1) self.assertRegex(output.getvalue(), f'^error.*{MIN_PYTHON_VERSION}') finally: pa_dlna = importlib.reload(pa_dlna) @requires_resources('pty') def test_disable_xonxoff(self): """Spawn a process to test disable_xonxoff().""" pid, master_fd = pty.fork() if pid == 0: module = 'pa_dlna.tests.test_init' argv = [sys.executable, '-c', f'import {module}; {module}.echo_xonxoff_array()'] os.execl(argv[0], *argv) # Note that the pty module imports the tty module that imports the # termios module. So it is safe to assume that if pty is available, # then termios is also available and 'restore_termios' is not None. restore_termios = disable_xonxoff(master_fd) old_attr = None try: # No ECHO and do not map NL to CR NL on output. old_attr = termios.tcgetattr(master_fd) new_attr = termios.tcgetattr(master_fd) new_attr[1] = new_attr[1] & ~termios.ONLCR new_attr[3] = new_attr[3] & ~termios.ECHO termios.tcsetattr(master_fd, termios.TCSANOW, new_attr) os.write(master_fd, XON_XOFF_BYTES_ARRAY) data = os.read(master_fd, 1024) status = os.waitpid(pid, 0)[1] self.assertEqual(data, XON_XOFF_BYTES_ARRAY) self.assertEqual(status, 0) finally: if old_attr is not None: termios.tcsetattr(master_fd, termios.TCSANOW, old_attr) if restore_termios: restore_termios() os.close(master_fd) def test_libpulse_version(self): from ..init import require_libpulse_version min_version = '9999' with self.assertRaises(SystemExit) as cm: require_libpulse_version(min_version) self.assertRegex(cm.exception.args[0], f"Error: libpulse version '{min_version}' .* required") @requires_resources('os.devnull') class Argv(BaseTestCase): """Command line tests.""" def test_no_args(self): options, _ = parse_args(self.__doc__, argv=[]) self.assertEqual(options, {'dump_default': False, 'dump_internal': False, 'ip_addresses': [], 'log_aio': False, 'logfile': None, 'loglevel': None, 'msearch_interval': 60, 'msearch_port': 0, 'nics': [], 'nolog_upnp': False, 'port': 8080, 'systemd': False, 'test_devices': [], 'ttl': b'\x02', 'clients_uuids': None, 'applications': None}) def test_ip_addresses(self): options, _ = parse_args(self.__doc__, argv=['--ip-addresses', '192.168.0.1']) self.assertEqual(options['ip_addresses'], ['192.168.0.1']) def test_invalid_ip_addresses(self): with self.assertRaises(SystemExit) as cm: options, _ = parse_args(self.__doc__, argv=['--ip-addresses', '192.168.0.999']) self.assertEqual(cm.exception.args[0], 2) self.assertTrue(isinstance(cm.exception.__context__, ipaddress.AddressValueError)) def test_ttl(self): options, _ = parse_args(self.__doc__, argv=['--ttl', '255']) self.assertEqual(options['ttl'], b'\xff') def test_invalid_ttl(self): with self.assertRaises(SystemExit) as cm: options, _ = parse_args(self.__doc__, argv=['--ttl', '256']) self.assertEqual(cm.exception.args[0], 2) self.assertTrue(isinstance(cm.exception.__context__, struct.error)) def test_mtypes(self): options, _ = parse_args(self.__doc__, argv=['--test-devices', ',,audio/mp3,,audio/mpeg']) self.assertEqual(options['test_devices'], ['audio/mp3', 'audio/mpeg']) def test_same_mtypes(self): with self.assertRaises(SystemExit) as cm: options, _ = parse_args(self.__doc__, argv=['--test-devices', 'audio/mp3, audio/mp3']) self.assertEqual(cm.exception.args[0], 2) def test_invalid_mtypes(self): with self.assertRaises(SystemExit) as cm: options, _ = parse_args(self.__doc__, argv=['--test-devices', 'foo/mp3']) self.assertEqual(cm.exception.args[0], 2) def test_two_dumps(self): with self.assertRaises(SystemExit) as cm: options, _ = parse_args(self.__doc__, argv=['--dump-default', '--dump-internal']) self.assertEqual(cm.exception.args[0], 2) def test_not_writable(self): with mock.patch('builtins.open', mock.mock_open()) as m_open,\ redirect_stderr(io.StringIO()) as output,\ self.assertRaises(SystemExit) as cm: m_open.side_effect = OSError options, _ = parse_args(self.__doc__, argv=['--clients-uuids', 'foo']) m_open.assert_called_once() self.assertEqual(cm.exception.args[0], 2) self.assertRegex(output.getvalue(), 'pa-dlna: error: foo is not writable: OSError()') def test_not_readable(self): if os.geteuid() == 0: self.skipTest('cannot run test as root') with tempfile.NamedTemporaryFile() as f,\ redirect_stderr(io.StringIO()) as output,\ self.assertRaises(SystemExit) as cm: path = pathlib.Path(f.name) path.chmod(0o222) options, _ = parse_args(self.__doc__, argv=['--clients-uuids', str(path)]) self.assertEqual(cm.exception.args[0], 2) self.assertRegex(output.getvalue(), r'pa-dlna: error: \[Errno 13] Permission denied') def test_invalid_header(self): with tempfile.NamedTemporaryFile(mode='w') as f,\ redirect_stderr(io.StringIO()) as output,\ self.assertRaises(SystemExit) as cm: f.write('[foo]\n') f.flush() options, _ = parse_args(self.__doc__, argv=['--clients-uuids', f.name]) self.assertEqual(cm.exception.args[0], 2) self.assertRegex(output.getvalue(), 'pa-dlna: error: Invalid default section header') def test_no_header(self): with tempfile.NamedTemporaryFile(mode='w') as f,\ redirect_stderr(io.StringIO()) as output,\ self.assertRaises(SystemExit) as cm: f.write('[foo\n') f.flush() options, _ = parse_args(self.__doc__, argv=['--clients-uuids', f.name]) self.assertEqual(cm.exception.args[0], 2) self.assertRegex(output.getvalue(), 'ConfigParser error: File contains no section headers') def test_log_options(self): with mock.patch('pa_dlna.init.setup_logging') as setup_logging: options, _ = parse_args(self.__doc__, argv=['--nolog-upnp', '--log-aio']) self.assertEqual(options['nolog_upnp'], True) self.assertEqual(options['log_aio'], True) setup_logging.assert_called_once() def test_logfile(self): with mock.patch('builtins.open', mock.mock_open()) as m: options, logfile_hdler = parse_args( self.__doc__, argv=['--logfile', '/dummy/file/name']) m.assert_called_once() self.assertEqual(logfile_hdler.level, logging.DEBUG) def test_failed_logfile(self): error_msg = 'Test cannot open logfile' with mock.patch('builtins.open', mock.mock_open()) as m_open,\ self.assertLogs(level=logging.ERROR) as m_logs,\ self.assertRaises(SystemExit) as cm: m_open.side_effect = OSError(error_msg) options, logfile_hdler = parse_args( self.__doc__, argv=['--logfile', '/dummy/file/name']) self.assertEqual(cm.exception.args[0], 2) m_open.assert_called_once() self.assertRegex(m_logs.output[-1], f'OSError.*{error_msg}') @requires_resources('os.devnull') class Main(BaseTestCase): """padlna_main() tests.""" def test_main(self): clazz = mock.MagicMock() coro = mock.AsyncMock() exit_code = 'foo' clazz.__name__ = 'AVControlPoint' app = clazz() app.run_control_point = coro coro.return_value = exit_code with mock.patch('pa_dlna.init.UserConfig') as cfg,\ self.assertLogs() as logs,\ self.assertRaises(SystemExit) as cm: padlna_main(clazz, self.__doc__, argv=['pa-dlna']) self.assertEqual(cm.exception.args[0], exit_code) cfg.assert_called_once() self.assertEqual(f'INFO:init:End of {app}', logs.output[-1]) app.run_control_point.assert_called_once() coro.assert_awaited() def test_PermissionError(self): clazz = mock.MagicMock() clazz.__name__ = 'AVControlPoint' with self.assertLogs() as logs,\ mock.patch('builtins.open', mock.mock_open()) as m_open,\ self.assertRaises(SystemExit) as cm: m_open.side_effect = PermissionError() padlna_main(clazz, self.__doc__, argv=['pa-dlna']) self.assertEqual(cm.exception.args[0], 1) self.assertEqual(logs.output[-1], 'ERROR:init:PermissionError()') def test_upnp_cmd(self): clazz = mock.MagicMock() coro = mock.AsyncMock() exit_code = 'foo' clazz.__name__ = 'UPnPControlCmd' app = clazz() app.run_control_point = coro app.run.return_value = exit_code with self.assertLogs() as logs,\ self.assertRaises(SystemExit) as cm: padlna_main(clazz, self.__doc__, argv=['upnp-cmd']) self.assertEqual(cm.exception.args[0], exit_code) self.assertEqual(f'INFO:init:End of {app}', logs.output[-1]) app.run_control_point.assert_called_once() app.run.assert_called_once() def test_upnpapplication(self): app = UPnPApplication(logfile='foo') self.assertEqual(app.logfile, 'foo') def test_defaultconfig(self): clazz = mock.MagicMock() clazz.__name__ = 'AVControlPoint' with redirect_stdout(io.StringIO()) as output,\ self.assertRaises(SystemExit) as cm: padlna_main(clazz, self.__doc__, argv=['pa-dlna', '--dump-default']) self.assertEqual(cm.exception.args[0], 0) doc = '# ' + Encoder.__doc__.split('\n')[0] self.assertEqual(output.getvalue().split('\n')[0], doc) def test_internalconfig(self): pa_dlna_conf = """ [DEFAULT] selection = L16Encoder """ clazz = mock.MagicMock() clazz.__name__ = 'AVControlPoint' with mock.patch('builtins.open', mock.mock_open( read_data=pa_dlna_conf)) as conf,\ redirect_stdout(io.StringIO()) as output,\ self.assertRaises(SystemExit) as cm: padlna_main(clazz, self.__doc__, argv=['pa-dlna', '--dump-internal']) self.assertEqual(cm.exception.args[0], 0) conf.assert_called_once_with(user_config_pathname()) self.assertIn("'L16Encoder': {'_mime_types': ['audio/l16']", output.getvalue()) if __name__ == '__main__': unittest.main(verbosity=2) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1739735953.8622842 pa_dlna-0.16/pa_dlna/tests/test_libpulse.py0000644000000000000000000000771014754441622015772 0ustar00"""Testing calls to libpulse.libpulse.LibPulse methods.""" import asyncio import logging import re import uuid from unittest import IsolatedAsyncioTestCase # Load the tests in the order they are declared. from . import load_ordered_tests as load_tests from . import requires_resources, search_in_logs from ..pulseaudio import Pulse, NullSink logger = logging.getLogger('libpulse tests') class SinkInput: def __init__(self, index=None, client=None): self.index = index self.client = client class Sink: def __init__(self, index=None, name=None): self.index = index self.name = name class Renderer: def __init__(self, sink): self.nullsink = NullSink(sink) class ControlPoint: def __init__(self): self.clients_uuids = None self.applications = None self.start_event = asyncio.Event() loop = asyncio.get_running_loop() self.test_end = loop.create_future() async def close(self): pass async def dispatch_event(self, event): pass @requires_resources('libpulse') class LibPulseTests(IsolatedAsyncioTestCase): async def asyncSetUp(self): self.control_point = ControlPoint() self.pulse = Pulse(self.control_point) asyncio.create_task(self.pulse.run()) # Wait for the connection to PulseAudio/Pipewire to be ready. await self.control_point.start_event.wait() async def asyncTearDown(self): # Terminate the self.pulse.run() asyncio task. self.control_point.test_end.set_result(True) async def get_invalid_index(self, pulse_object): # Get the list of the current 'pulse_object'. list_method = getattr(self.pulse.lib_pulse, f'pa_context_get_{pulse_object}_info_list') members = await list_method() logger.debug(f'{pulse_object}s ' f'{dict((el.name, el.index) for el in members)}') # Find the last pulse_object index. indexes = [member.index for member in members] max_index = max(indexes) if indexes else 0 logger.debug(f'max_index: {max_index}') return max_index + 10 async def test_get_client(self): with self.assertLogs(level=logging.DEBUG) as m_logs: invalid_index = await self.get_invalid_index('client') # Use an invalid client index. sink_input = SinkInput(client=invalid_index) client = await self.pulse.get_client(sink_input) self.assertEqual(client, None) self.assertTrue(search_in_logs(m_logs.output, 'pulse', re.compile(r'LibPulseOperationError'))) async def test_move_sink_input(self): with self.assertLogs(level=logging.DEBUG) as m_logs: invalid_index = await self.get_invalid_index('sink_input') # Use an invalid sink_input index. sink_input = SinkInput(index=invalid_index) sink = Sink(index=0) sink_input = await self.pulse.move_sink_input(sink_input, sink) self.assertEqual(sink_input, None) self.assertTrue(search_in_logs(m_logs.output, 'pulse', re.compile(r'PA_OPERATION_RUNNING'))) async def test_get_renderer_sink(self): with self.assertLogs(level=logging.DEBUG) as m_logs: # Use an invalid sink name. sinks = await self.pulse.lib_pulse.pa_context_get_sink_info_list() names = [sink.name for sink in sinks] while True: name = str(uuid.uuid4()) if name not in names: break logger.debug(f'Sink name: {name}') sink = Sink(name=name) renderer = Renderer(sink) sink = await self.pulse.get_renderer_sink(renderer) self.assertEqual(sink, None) self.assertTrue(search_in_logs(m_logs.output, 'pulse', re.compile(r'LibPulseOperationError'))) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1738766067.8553998 pa_dlna-0.16/pa_dlna/tests/test_pa_dlna.py0000644000000000000000000010305314750673364015554 0ustar00"""pa_dlna test cases.""" import re import sys import asyncio import signal import time import shutil import logging import tempfile from unittest import IsolatedAsyncioTestCase, mock # Load the tests in the order they are declared. from . import load_ordered_tests as load_tests from . import find_in_logs, search_in_logs from .streams import set_control_point as _set_control_point from .libpulse import use_libpulse_stubs, LibPulse from .libpulse import SinkInput as LibPulseSinkInput from ..init import ControlPointAbortError from ..encoders import Encoder from ..upnp.upnp import (UPnPRootDevice, QUEUE_CLOSED, UPnPControlPoint, UPnPSoapFaultError) from ..upnp.tests import min_python_version from ..upnp.xml import SoapFault # Use the patched pulseaudio and pa_dlna modules to avoid importing libpulse # that is not required for running the test. with use_libpulse_stubs(['pa_dlna.pulseaudio', 'pa_dlna.pa_dlna']) as modules: pulseaudio, pa_dlna = modules AVControlPoint = pa_dlna.AVControlPoint Renderer = pa_dlna.Renderer RenderersList = pa_dlna.RenderersList PROPLIST = { 'application.name': 'Strawberry', 'media.artist': 'Ziggy Stardust', 'media.title': 'Amarok', } async def wait_for(awaitable, timeout=2): """Work around of the asyncio.wait_for() bug, new in Python 3.9. Bug summary: In some cases asyncio.wait_for() does not raise TimeoutError although the future has been cancelled after the timeout. """ bug = sys.version_info > (3, 8) if bug: start = time.monotonic() res = await asyncio.wait_for(awaitable, timeout=timeout) if bug and time.monotonic() - start >= timeout - 0.1: raise asyncio.TimeoutError('*** asyncio.wait_for() BUG:' ' failed to raise TimeoutError') return res def get_control_point(sink_inputs): upnp_control_point = UPnPControlPoint(nics=[], msearch_interval=60) control_point = AVControlPoint(nics=['lo'], port=8080, clients_uuids=None, applications=None, systemd=False) control_point.upnp_control_point = upnp_control_point # LibPulse must be instantiated after the call to the # add_sink_inputs() class method. LibPulse.add_sink_inputs(sink_inputs) control_point.pulse = pulseaudio.Pulse(control_point) control_point.pulse.lib_pulse = LibPulse('pa-dlna') return upnp_control_point, control_point def set_control_point(control_point): _set_control_point(control_point) loop = asyncio.get_running_loop() control_point.test_end = loop.create_future() def set_no_encoder(control_point): set_control_point(control_point) control_point.config.encoders = {} class RootDevice(UPnPRootDevice): def __init__(self, upnp_control_point, mime_type='audio/mp3', device_type=True): self.mime_type = mime_type match = re.match(r'audio/([^;]+)', mime_type) name = match.group(1) self.modelName = f'RootDevice_{name}' self.friendlyName = self.modelName self.UDN = pa_dlna.get_udn(name.encode()) self.udn = self.UDN assert device_type in (None, True, False) if device_type: self.deviceType = f'{pa_dlna.MEDIARENDERER}1' elif device_type is False: self.deviceType = 'some device type' loopback = '127.0.0.1' super().__init__(upnp_control_point, self.udn, loopback, loopback, None, 3600) class Sink: pass class SinkInput: def __init__(self, index=0, proplist=None): self.index = index self.client = 0 self.proplist = proplist if proplist is not None else PROPLIST.copy() class PaDlnaTestCase(IsolatedAsyncioTestCase): async def run_control_point(self, handle_pulse_event, set_control_point=set_control_point, test_devices=[], has_parec=True): _which = shutil.which def which(arg): if arg == 'parec': return True if has_parec else None else: return _which(arg) # When 'test_end' is done, the task running # control_point.run_control_point() is cancelled by the Pulse task # closing the AVControlPoint instance 'control_point'. with mock.patch.object(Renderer, 'handle_pulse_event', handle_pulse_event),\ mock.patch.object(shutil, 'which', which),\ self.assertLogs(level=logging.DEBUG) as m_logs: control_point = AVControlPoint(ip_addresses=[], nics='lo', port=8080, ttl=2, msearch_interval=60, msearch_port=0, clients_uuids=None, applications=None, systemd=False, test_devices=test_devices) set_control_point(control_point) LibPulse.add_sink_inputs([]) try: return_code = await wait_for( control_point.run_control_point()) except asyncio.TimeoutError: logs = ('\n'.join(l for l in m_logs.output if ':asyncio:' not in l)) logs = None if not logs else '\n' + logs self.fail(f'TimeoutError with logs: {logs}') return return_code, m_logs class DLNAControlPoint(PaDlnaTestCase): """The control point test cases.""" async def test_no_encoder(self): async def handle_pulse_event(renderer): await asyncio.sleep(0) return_code, logs = await self.run_control_point(handle_pulse_event, test_devices=['audio/mp3'], set_control_point=set_no_encoder) self.assertTrue(isinstance(return_code, RuntimeError)) self.assertTrue(search_in_logs(logs.output, 'pa-dlna', re.compile('No encoder is available'))) async def test_no_parec(self): async def handle_pulse_event(renderer): await asyncio.sleep(0) return_code, logs = await self.run_control_point(handle_pulse_event, test_devices=['audio/mp3'], has_parec=False) self.assertTrue(isinstance(return_code, RuntimeError)) self.assertTrue(search_in_logs(logs.output, 'pa-dlna', re.compile("'parec' program cannot be found"))) @min_python_version((3, 9)) async def test_cancelled(self): async def handle_pulse_event(renderer): renderer.control_point.curtask.cancel('foo') await asyncio.sleep(0) return_code, logs = await self.run_control_point(handle_pulse_event, test_devices=['audio/mp3']) self.assertTrue(return_code == None) self.assertTrue(find_in_logs(logs.output, 'pa-dlna', "Main task got: CancelledError('foo')")) @min_python_version((3, 9)) async def test_exception_renderer_close(self): async def handle_pulse_event(renderer): renderer.control_point.curtask.cancel('foo') await asyncio.sleep(0) async def close(self): raise OSError('foo') with mock.patch.object(Renderer, 'close', close): return_code, logs = await self.run_control_point( handle_pulse_event, test_devices=['audio/mp3']) self.assertTrue(return_code == None) self.assertTrue(find_in_logs(logs.output, 'pa-dlna', "Main task got: CancelledError('foo')")) self.assertTrue(search_in_logs(logs.output, 'pa-dlna', re.compile(r"Got exception closing DLNATest_\S+ - \S+" fr" OSError\('foo'\)"))) self.assertTrue(search_in_logs(logs.output, 'pa-dlna', re.compile(r'Close \S+ root device'))) async def test_abort(self): async def handle_pulse_event(renderer): await asyncio.sleep(0) # Avoid infinite loop. return_code, logs = await self.run_control_point(handle_pulse_event, test_devices=['audio/mp3', 'audio/mp3']) self.assertTrue(type(return_code), ControlPointAbortError) self.assertTrue(search_in_logs(logs.output, 'pa-dlna', re.compile('Two DLNA devices registered with the same name'))) @min_python_version((3, 9)) async def test_SIGINT(self): async def handle_pulse_event(renderer): signal.raise_signal(signal.SIGINT) await asyncio.sleep(0) # Avoid infinite loop. return_code, logs = await self.run_control_point(handle_pulse_event, test_devices=['audio/mp3']) self.assertTrue(return_code == None) self.assertTrue(search_in_logs(logs.output, 'pa-dlna', re.compile('Got SIGINT or SIGTERM'))) class DLNARenderer(PaDlnaTestCase): """The renderer test cases using run_control_point().""" async def test_register_renderer(self): async def handle_pulse_event(renderer): renderer.control_point.test_end.set_result(True) raise OSError('foo') return_code, logs = await self.run_control_point(handle_pulse_event, test_devices=['audio/mp3']) self.assertTrue(return_code is None, msg=f'return_code: {return_code}') _logs = '\n'.join(l for l in logs.output if ':ASYNCIO:' not in l) self.assertTrue(find_in_logs(logs.output, 'pa-dlna', "OSError('foo')"), msg=_logs) # Print the logs if the assertion fails. self.assertTrue(search_in_logs(logs.output, 'pa-dlna', re.compile("New 'DLNATest_.*' renderer with Mp3Encoder"))) async def test_unknown_encoder(self): async def handle_pulse_event(renderer): await asyncio.sleep(0) # Never reached def disable(control_point, root_device, name=None): logger = logging.getLogger('foo') logger.warning(f'Disable the {name} device permanently') control_point.test_end.set_result(True) with mock.patch.object(AVControlPoint, 'disable_root_device', disable): return_code, logs = await self.run_control_point( handle_pulse_event, test_devices=['audio/foo']) self.assertEqual(return_code, None) self.assertTrue(search_in_logs(logs.output, 'foo', re.compile('Disable the DLNATest_.* device permanently'))) async def test_bad_encoder_unload_module(self): async def handle_pulse_event(renderer): await asyncio.sleep(0) # Never reached def disable(control_point, root_device, name=None): # Do not close renderers in AVControlPoint.close(). control_point.root_devices = {} control_point.test_end.set_result(True) # Check that the 'module-null-sink' module of a renderer whose encoder # is not found, is unloaded. with mock.patch.object(AVControlPoint, 'disable_root_device', disable): return_code, logs = await self.run_control_point( handle_pulse_event, test_devices=['audio/foo']) self.assertEqual(return_code, None) self.assertTrue(search_in_logs(logs.output, 'pulse', re.compile('Unload null-sink module DLNATest_foo'))) class PatchGetNotificationTests(IsolatedAsyncioTestCase): """Test cases using patch_get_notification().""" def setUp(self): self.upnp_control_point, self.control_point = get_control_point([]) async def patch_get_notification(self, notifications=[], alive_count=0): async def handle_pulse_event(renderer): # Wrapper around Renderer.handle_pulse_event to trigger the # 'test_end' future after 'alive_count' calls to this method from # new renderers. nonlocal handle_pulse_event_called handle_pulse_event_called += 1 if handle_pulse_event_called == alive_count: renderer.control_point.test_end.set_result(True) await _handle_pulse_event(renderer) _handle_pulse_event = Renderer.handle_pulse_event handle_pulse_event_called = 0 set_control_point(self.control_point) with mock.patch.object(self.upnp_control_point, 'get_notification') as get_notif,\ mock.patch.object(Renderer, 'soap_action', pa_dlna.DLNATestDevice.soap_action),\ mock.patch.object(Renderer, 'handle_pulse_event', handle_pulse_event),\ self.assertLogs(level=logging.DEBUG) as m_logs: notifications.append(QUEUE_CLOSED) get_notif.side_effect = notifications await self.control_point.handle_upnp_notifications() if alive_count != 0: try: await wait_for(self.control_point.test_end) except asyncio.TimeoutError: logs = ('\n'.join(l for l in m_logs.output if ':asyncio:' not in l)) logs = None if not logs else '\n' + logs self.fail(f'TimeoutError with logs: {logs}') return m_logs async def test_alive(self): root_device = RootDevice(self.upnp_control_point) logs = await self.patch_get_notification([('alive', root_device)], alive_count=1) self.assertEqual(len(self.control_point.root_devices), 1) renderer = list(self.control_point.root_devices.values())[0][0] self.assertEqual(renderer.root_device, root_device) self.assertTrue(search_in_logs(logs.output, 'pa-dlna', re.compile("New 'RootDevice_mp3.*' renderer with Mp3Encoder"))) async def test_missing_deviceType(self): root_device = RootDevice(self.upnp_control_point, device_type=None) logs = await self.patch_get_notification([('alive', root_device)], alive_count=0) self.assertEqual(len(self.control_point.root_devices), 0) self.assertTrue(search_in_logs(logs.output, 'pa-dlna', re.compile('missing deviceType'))) self.assertTrue(search_in_logs(logs.output, 'upnp', re.compile('Disable the UPnPRootDevice .* permanently'))) async def test_not_MediaRenderer(self): root_device = RootDevice(self.upnp_control_point, device_type=False) logs = await self.patch_get_notification([('alive', root_device)], alive_count=0) self.assertEqual(len(self.control_point.root_devices), 0) self.assertTrue(search_in_logs(logs.output, 'pa-dlna', re.compile('no MediaRenderer'))) self.assertTrue(search_in_logs(logs.output, 'upnp', re.compile('Disable the UPnPRootDevice .* permanently'))) async def test_byebye(self): root_device = RootDevice(self.upnp_control_point) mpeg_root_device = RootDevice(self.upnp_control_point, mime_type='audio/mpeg') # Using two 'byebye' notifications to emulate the behavior of the root # device that sends one after having been closed by Renderer.close(). logs = await self.patch_get_notification([('alive', root_device), ('byebye', root_device), ('byebye', root_device), ('alive', mpeg_root_device) ], alive_count=2) self.assertEqual(len(self.control_point.root_devices), 1) self.assertTrue(search_in_logs(logs.output, 'pa-dlna', re.compile("Got 'byebye' notification"))) self.assertTrue(search_in_logs(logs.output, 'pa-dlna', re.compile(r"Closing 'RootDevice_mp3 - \S+'"))) self.assertTrue(search_in_logs(logs.output, 'pulse', re.compile('Unload null-sink module RootDevice_mp3'))) async def test_disabled_root_device(self): root_device = RootDevice(self.upnp_control_point) mpeg_root_device = RootDevice(self.upnp_control_point, mime_type='audio/mpeg') # Capture the logs (and ignore them) to avoid them being printed on # stderr. with self.assertLogs(level=logging.DEBUG) as m_logs: self.control_point.disable_root_device(root_device) logs = await self.patch_get_notification([('alive', root_device), ('alive', mpeg_root_device) ], alive_count=1) self.assertEqual(len(self.control_point.root_devices), 1) self.assertTrue(search_in_logs(logs.output, 'pa-dlna', re.compile('Ignore disabled UPnPRootDevice'))) class PulseEventContext: """The context set before running handle_pulse_event() tests. The context is made of 'renderer', 'sink' and 'sink_input'. 'sink' and 'sink_input' are either both None or both not None. """ def __init__(self, sink=None, prev_sink_input_index = None, sink_input_index=None, sink_input_proplist=None, clients_uuids=None, applications={}): assert ((sink is None and sink_input_index is None) or (sink is not None and sink_input_index is not None)) # Build the renderer. upnp_control_point = UPnPControlPoint(nics=[], msearch_interval=60) control_point = AVControlPoint(clients_uuids=clients_uuids, applications=applications, systemd=False) control_point.pulse = pulseaudio.Pulse(control_point) LibPulse.add_sink_inputs([]) control_point.pulse.lib_pulse = LibPulse('pa-dlna') control_point.upnp_control_point = upnp_control_point _set_control_point(control_point) root_device = RootDevice(upnp_control_point) renderers_list = RenderersList(control_point, root_device) # Note that self.renderer is not appended to renderers_list as this is # not needed. self.renderer = Renderer(control_point, root_device, renderers_list) # Set the value of Renderer.nullsink. prev_sink = Sink() nullsink = pulseaudio.NullSink(prev_sink) if prev_sink_input_index is not None: nullsink.sink_input = SinkInput(prev_sink_input_index) self.renderer.nullsink = nullsink # Build the sink. self.sink = sink # Build the sink_input. self.sink_input = (SinkInput(sink_input_index, sink_input_proplist) if sink_input_index is not None else None) class PatchSoapActionTests(IsolatedAsyncioTestCase): """Test cases using patch_soap_action().""" @staticmethod async def select_encoder(ctx): if ctx.renderer.encoder is None: await ctx.renderer.select_encoder(ctx.renderer.root_device.udn) else: ctx.renderer.encoder.soap_minimum_interval = 0 async def patch_soap_action(self, event, ctx, transport_state='STOPPED', track_metadata=True, timeout=0, soap_minimum_interval=None): async def soap_action(renderer, serviceId, action, args={}): if action == 'GetProtocolInfo': return {'Source': None, 'Sink': 'http-get:*:audio/mp3:*' } elif action == 'GetTransportInfo': return {'CurrentTransportState': transport_state} else: result.append((serviceId, action, args)) result = [] with mock.patch.object(Renderer, 'soap_action', soap_action),\ self.assertLogs(level=logging.DEBUG) as m_logs: # Select the encoder: Renderer.sink_input_meta() needs # to read the Renderer.encoder.track_metadata attribute. await self.select_encoder(ctx) renderer = ctx.renderer renderer.encoder.track_metadata = track_metadata if soap_minimum_interval is not None: renderer.encoder.soap_minimum_interval = soap_minimum_interval renderer.soap_spacer.next_soap_at = (time.monotonic() + soap_minimum_interval) renderer.pulse_queue.put_nowait((event, ctx.sink, ctx.sink_input)) await renderer.handle_pulse_event() # Sleep to get the last 'Stop' SOAP action. if timeout: await asyncio.sleep(timeout) return result, m_logs async def test_remove_event(self): index = 999 ctx = PulseEventContext(prev_sink_input_index=index) self.assertEqual(ctx.sink, None) self.assertTrue(ctx.renderer.nullsink.sink_input is not None) self.assertEqual(ctx.sink_input, None) with mock.patch.object(pa_dlna, 'ISSUE_48_TIMER', 0): result, logs = await self.patch_soap_action('remove', ctx, timeout=0.2, transport_state='PLAYING') self.assertEqual(len(result), 1) self.assertEqual(ctx.renderer.nullsink.sink_input, None) self.assertTrue(search_in_logs(logs.output, 'pa-dlna', re.compile(f"'remove' pulse event .* index {index}"))) self.assertTrue(search_in_logs(logs.output, 'pa-dlna', re.compile( "'Closing-Stop' UPnP action .* device prev state: PLAYING"))) async def test_ignore_remove_event(self): index = 999 ctx = PulseEventContext(prev_sink_input_index=index) self.assertEqual(ctx.sink, None) self.assertTrue(ctx.renderer.nullsink.sink_input is not None) self.assertEqual(ctx.sink_input, None) with mock.patch.object(pa_dlna, 'ISSUE_48_TIMER', 0.01),\ self.assertLogs(level=logging.DEBUG) as m_logs: result, logs = await self.patch_soap_action('remove', ctx, transport_state='PLAYING') ctx.renderer.nullsink.sink_input.index = 1000 await asyncio.sleep(0.5) logs = logs.output + m_logs.output self.assertEqual(len(result), 0) self.assertTrue(search_in_logs(logs, 'pa-dlna', re.compile(f"'remove' pulse event .* index {index}"))) self.assertTrue(search_in_logs(logs, 'pa-dlna', re.compile( "'remove ignored' .* index 1000"))) async def test_exit_metadata(self): ctx = PulseEventContext(prev_sink_input_index=0) self.assertEqual(ctx.sink, None) self.assertTrue(ctx.renderer.nullsink.sink_input is not None) self.assertEqual(ctx.sink_input, None) with mock.patch.object(pa_dlna, 'ISSUE_48_TIMER', 0): await self.patch_soap_action('exit', ctx, timeout=0.2, transport_state='PLAYING') self.assertTrue(ctx.renderer.exit_metadata is not None) ctx.sink = Sink() ctx.sink_input = SinkInput(1) result, logs = await self.patch_soap_action('change', ctx) self.assertEqual(ctx.renderer.exit_metadata, None) self.assertEqual(len(result), 2) self.assertEqual(result[0][1], 'SetAVTransportURI') self.assertEqual(result[1][1], 'Play') async def test_first_track(self): ctx = PulseEventContext(sink=Sink(), sink_input_index=0) self.assertEqual(ctx.renderer.nullsink.sink_input, None) await self.patch_soap_action('new', ctx) result, logs = await self.patch_soap_action('change', ctx) self.assertTrue(ctx.renderer.nullsink.sink is ctx.sink) self.assertTrue(ctx.renderer.nullsink.sink_input is ctx.sink_input) self.assertEqual(len(result), 2) self.assertEqual(result[0][1], 'SetAVTransportURI') self.assertEqual(result[1][1], 'Play') self.assertTrue(search_in_logs(logs.output, 'pa-dlna', re.compile(r"MetaData\(.*artist='Ziggy Stardust'"))) async def test_clients_uuids(self): class Client: def __init__(self, proplist): self.proplist = proplist client = Client(PROPLIST) # Add a first entry to the 'clients_uuids' file. with tempfile.NamedTemporaryFile() as f: ctx = PulseEventContext(sink=Sink(), sink_input_index=0, clients_uuids=f.name) control_point = ctx.renderer.control_point ctx.sink_input.client = client control_point.pulse.lib_pulse.sink_inputs.append(ctx.sink_input) await self.patch_soap_action('new', ctx) result, logs = await self.patch_soap_action('change', ctx) self.assertEqual(result[0][1], 'SetAVTransportURI') self.assertTrue(search_in_logs(logs.output, 'pulse', re.compile("Adding new association 'Strawberry' -> uuid"))) applications = ctx.renderer.control_point.applications self.assertTrue('Strawberry' in applications) self.assertTrue(applications['Strawberry'].startswith('uuid:')) async def test_next_track(self): index = 999 proplist = PROPLIST.copy() proplist['media.title'] = 'Sticky Fingers' ctx = PulseEventContext(sink=Sink(), prev_sink_input_index=0, sink_input_index=index, sink_input_proplist=proplist) self.assertTrue(ctx.renderer.nullsink.sink_input is not None) result, logs = await self.patch_soap_action('change', ctx, transport_state='PLAYING') self.assertTrue(ctx.renderer.nullsink.sink is ctx.sink) self.assertTrue(ctx.renderer.nullsink.sink_input is ctx.sink_input) self.assertEqual(len(result), 1) self.assertEqual(result[0][1], 'SetNextAVTransportURI') self.assertTrue(search_in_logs(logs.output, 'pa-dlna', re.compile(f'change.* event .* sink-input index {index}'))) self.assertTrue(search_in_logs(logs.output, 'pa-dlna', re.compile(r"MetaData\(.* title='Sticky Fingers'\)"))) async def test_no_title(self): # Test that an empty 'title' is replaced by the 'publisher'. ctx = PulseEventContext(sink=Sink(), sink_input_index=0) self.assertEqual(ctx.renderer.nullsink.sink_input, None) await self.patch_soap_action('new', ctx) proplist = PROPLIST.copy() application_name = 'foo' proplist['application.name'] = application_name proplist['media.title'] = '' ctx.sink_input.proplist = proplist result, logs = await self.patch_soap_action('change', ctx) self.assertEqual(len(result), 2) self.assertEqual(result[0][1], 'SetAVTransportURI') self.assertEqual(result[1][1], 'Play') self.assertTrue(search_in_logs(logs.output, 'pa-dlna', re.compile(fr"MetaData\(.*, title='{application_name}'\)"))) async def test_no_track_metadata(self): # Ignore change event when: # - not new_session # - renderer.encoder.track_metadata is false ctx = PulseEventContext(sink=Sink(), prev_sink_input_index=0, sink_input_index=1) self.assertTrue(ctx.renderer.nullsink.sink_input is not None) # A dummy 'change' event to select the encoder. await self.patch_soap_action('change', ctx, transport_state='PLAYING') ctx.renderer.encoder.track_metadata = False sink_input_meta = ctx.renderer.sink_input_meta proplist = PROPLIST.copy() proplist['media.title'] = 'Sticky Fingers' ctx.renderer.nullsink.sink_input.proplist = proplist # See the comment in the code. self.assertTrue(sink_input_meta(ctx.sink_input) == sink_input_meta(ctx.renderer.nullsink.sink_input)) result, logs = await self.patch_soap_action('change', ctx, transport_state='PLAYING') self.assertEqual(len(result), 0) async def test_change_same_metadata(self): # Ignore change event when: # - not new_session # - no change in metadata ctx = PulseEventContext(sink=Sink(), prev_sink_input_index=0, sink_input_index=1) self.assertTrue(ctx.renderer.nullsink.sink_input is not None) result, logs = await self.patch_soap_action('change', ctx, transport_state='PLAYING') self.assertEqual(len(result), 0) async def test_new_session_max_delay(self): # Test that after Renderer.new_pulse_session is set to True, if the # second event is missing, the first event event is pushed again by # the 'new_session_max_delay' task. async def soap_action(renderer, serviceId, action, args={}): if action == 'GetProtocolInfo': return {'Source': None, 'Sink': 'http-get:*:audio/mp3:*' } elif action == 'GetTransportInfo': return {'CurrentTransportState': 'STOPPED'} else: result.append((serviceId, action, args)) ctx = PulseEventContext(sink=Sink(), sink_input_index=0) self.assertEqual(ctx.renderer.nullsink.sink_input, None) with mock.patch.object(pa_dlna, 'NEW_SESSION_MAX_DELAY', 0) as delay: await self.patch_soap_action('new', ctx) self.assertTrue(ctx.renderer.new_pulse_session) await asyncio.sleep(0) # Handle the event pushed by the 'new_session_max_delay' task. result = [] with mock.patch.object(Renderer, 'soap_action', soap_action),\ self.assertLogs(level=logging.DEBUG) as logs: await ctx.renderer.handle_pulse_event() self.assertEqual(len(result), 2) self.assertEqual(result[0][1], 'SetAVTransportURI') self.assertEqual(result[1][1], 'Play') self.assertTrue(search_in_logs(logs.output, 'pa-dlna', re.compile(r"MetaData\(.*artist='Ziggy Stardust'"))) async def test_soap_minimum_interval(self): ctx = PulseEventContext(sink=Sink(), sink_input_index=0) with mock.patch.object(asyncio, 'sleep') as sleep: await self.patch_soap_action('new', ctx) result, logs = await self.patch_soap_action('change', ctx, soap_minimum_interval=5) sleep.assert_called_once() async def test_soap_fault(self): ctx = PulseEventContext(sink=Sink(), sink_input_index=0) self.assertEqual(ctx.renderer.nullsink.sink_input, None) with mock.patch.object(Renderer, 'play') as play: play.side_effect = UPnPSoapFaultError(SoapFault('701')) await self.patch_soap_action('new', ctx) result, logs = await self.patch_soap_action('change', ctx) play.assert_called_once() self.assertTrue(search_in_logs(logs.output, 'pa-dlna', re.compile("Ignoring SOAP error 'Transition not available'"))) async def test_start_streaming(self): # Test that streaming starts when pa-dlna is started while the track # is already playing. sink_input_name = 'Orcas Ibericas' sink_input = LibPulseSinkInput(sink_input_name, []) sink_input.proplist = PROPLIST upnp_control_point, control_point = get_control_point([sink_input]) set_control_point(control_point) # Ensure that Renderer.run() does not run the loop over calls to # handle_pulse_event(). control_point.test_end.set_result(True) root_device = RootDevice(upnp_control_point) renderers_list = RenderersList(control_point, root_device) # Note that renderer is not appended to renderers_list as this is # not needed. renderer = Renderer(control_point, root_device, renderers_list) renderer.encoder = Encoder() with mock.patch.object(Renderer, 'handle_action') as handle_action,\ mock.patch.object(Renderer, 'select_encoder') as select_encoder,\ self.assertLogs(level=logging.DEBUG) as m_logs: select_encoder.return_value = True await renderer.pulse_register() await renderer.run() handle_action.assert_called_once_with( renderer.sink_input_meta(sink_input)) self.assertTrue(search_in_logs(m_logs.output, 'pa-dlna', re.compile(f"Streaming '{sink_input_name}'"))) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1740321703.4164207 pa_dlna-0.16/pa_dlna/tests/test_pulseaudio.py0000644000000000000000000003215014756631647016333 0ustar00"""Pulseaudio test cases.""" import re import asyncio import contextlib import tempfile import logging from unittest import IsolatedAsyncioTestCase, mock # Load the tests in the order they are declared. from . import load_ordered_tests as load_tests from . import find_in_logs, search_in_logs from .streams import pulseaudio, pa_dlna from .libpulse import (SinkInput, Event, LibPulseClosedError, LibPulse, LibPulseError, PA_SUBSCRIPTION_MASK_SINK_INPUT, PA_INVALID_INDEX) from ..pa_dlna import ControlPointAbortError class Renderer(pa_dlna.DLNATestDevice): def __init__(self, control_point, mime_type, results=None): super().__init__(control_point, mime_type) self.results = results async def run(self): while True: event_tuple = await self.pulse_queue.get() if self.results is not None: self.results.append(event_tuple) evt, sink, sink_input = event_tuple if evt == 'new': self.nullsink.sink = sink self.nullsink.sink_input = sink_input class ControlPoint(pa_dlna.AVControlPoint): def __init__(self, clients_uuids=None, applications=None): self.clients_uuids = clients_uuids self.applications = applications self.start_event = asyncio.Event() self.root_devices = {} def abort(self, msg): raise ControlPointAbortError(msg) async def close(self): pass class Pulseaudio(IsolatedAsyncioTestCase): """Pulseaudio test cases.""" def setUp(self): # The Pulse instance to test. self.control_point = ControlPoint() self.pulse = pulseaudio.Pulse(self.control_point) def new_renderer(self, mime_type, results): renderer = Renderer(self.control_point, f'audio/{mime_type}', results) asyncio.create_task(renderer.run()) return renderer async def test_run_pulse(self): with self.assertLogs(level=logging.DEBUG) as m_logs: LibPulse.add_sink_inputs([]) await self.pulse.run() self.assertTrue(find_in_logs(m_logs.output, 'pulse', 'Close pulse')) async def test_dispatch_event(self): results = [] renderer = self.new_renderer('mp3', results) with self.assertLogs(level=logging.DEBUG) as m_logs: sink_input = SinkInput('source', [Event('new')]) LibPulse.add_sink_inputs([sink_input]) async with LibPulse('pa-dlna') as self.pulse.lib_pulse: renderer.nullsink = await self.pulse.register(renderer) await self.pulse.lib_pulse.pa_context_subscribe( PA_SUBSCRIPTION_MASK_SINK_INPUT) iterator = self.pulse.lib_pulse.get_events_iterator() async for event in iterator: await self.pulse.dispatch_event(event) await asyncio.sleep(0) self.assertTrue(results[0] == ('new', renderer.nullsink.sink, sink_input)) async def test_clients_uuids(self): class Client: def __init__(self, proplist): self.proplist = proplist uuid = object() client = Client({'application.name': 'Strawberry'}) applications = {'Strawberry': uuid} control_point = ControlPoint(applications=applications) pulse = pulseaudio.Pulse(control_point) sink_input = SinkInput('sink-input with a client', []) sink_input.client = client LibPulse.add_sink_inputs([sink_input]) async with LibPulse('pa-dlna') as pulse.lib_pulse: result = await pulse.find_sink_input(uuid) self.assertTrue(result is sink_input) def test_write_applications(self): with tempfile.NamedTemporaryFile(mode='w+') as f: control_point = ControlPoint(clients_uuids=f.name) pulse = pulseaudio.Pulse(control_point) pulse.applications = {'Strawberry': 'uuid'} pulse.write_applications() content = f.read() self.assertTrue('Strawberry -> uuid' in content) async def test_ignore_prev_sink_input(self): results = [] renderer = self.new_renderer('mp3', results) proplist = {'media.role': 'video'} with self.assertLogs(level=logging.DEBUG) as m_logs: sink_input = SinkInput('source', [Event('new'), Event('change'), Event('change', proplist=proplist)]) LibPulse.add_sink_inputs([sink_input]) async with LibPulse('pa-dlna') as self.pulse.lib_pulse: renderer.nullsink = await self.pulse.register(renderer) await self.pulse.lib_pulse.pa_context_subscribe( PA_SUBSCRIPTION_MASK_SINK_INPUT) iterator = self.pulse.lib_pulse.get_events_iterator() count = 0 async for event in iterator: await self.pulse.dispatch_event(event) # Do not dispatch the second Event. renderer.previous_idx = 0 if count == 0 else None count += 1 await asyncio.sleep(0) self.assertTrue(len(results) == 2) self.assertTrue(results[0] == ('new', renderer.nullsink.sink, sink_input)) self.assertTrue(results[1] == ('change', renderer.nullsink.sink, sink_input)) self.assertTrue(sink_input.proplist is proplist) async def test_ignore_sound_setting(self): results = [] renderer = self.new_renderer('mp3', results) proplist_event = {'media.role': 'event'} proplist_video = {'media.role': 'video'} with self.assertLogs(level=logging.DEBUG) as m_logs: sink_input = SinkInput('source', [Event('new'), Event('change', proplist=proplist_event), Event('change', proplist=proplist_video)]) LibPulse.add_sink_inputs([sink_input]) async with LibPulse('pa-dlna') as self.pulse.lib_pulse: renderer.nullsink = await self.pulse.register(renderer) await self.pulse.lib_pulse.pa_context_subscribe( PA_SUBSCRIPTION_MASK_SINK_INPUT) iterator = self.pulse.lib_pulse.get_events_iterator() count = 0 async for event in iterator: await self.pulse.dispatch_event(event) # Do not dispatch the second Event. renderer.previous_idx = 0 if count == 0 else None count += 1 await asyncio.sleep(0) self.assertTrue(len(results) == 2) self.assertTrue(results[0] == ('new', renderer.nullsink.sink, sink_input)) self.assertTrue(results[1] == ('change', renderer.nullsink.sink, sink_input)) self.assertTrue(sink_input.proplist is proplist_video) async def test_connect_raise_once(self): with self.assertLogs(level=logging.INFO) as m_logs: LibPulse.add_sink_inputs([SinkInput('source', [Event('new')])]) LibPulse.do_raise_once = True await self.pulse.run() self.assertTrue(search_in_logs(m_logs.output, 'pulse', re.compile(r'LibPulseStateError()'))) self.assertTrue(find_in_logs(m_logs.output, 'pulse', 'Close pulse')) async def test_disconnected(self): with mock.patch.object(self.pulse, 'dispatch_event') as dispatch,\ self.assertLogs(level=logging.INFO) as m_logs: LibPulse.add_sink_inputs([SinkInput('source', [Event('new')])]) dispatch.side_effect = LibPulseClosedError() await self.pulse.run() self.assertTrue(search_in_logs(m_logs.output, 'pulse', re.compile(r'LibPulseClosedError'))) self.assertTrue(find_in_logs(m_logs.output, 'pulse', 'Close pulse')) async def test_register(self): renderer = Renderer(self.control_point, 'audio/mp3') with self.assertLogs(level=logging.DEBUG) as m_logs: LibPulse.add_sink_inputs([]) async with LibPulse('pa-dlna') as self.pulse.lib_pulse: sink = await self.pulse.register(renderer) self.assertTrue(str(self.pulse.lib_pulse.sinks[1]).startswith( 'DLNATest_mp3-uuid:')) await self.pulse.unregister(sink) self.assertTrue(search_in_logs(m_logs.output, 'pulse', re.compile('Load null-sink module' ' DLNATest_mp3-uuid:.*\n.*description='))) async def test_bad_register(self): renderer = Renderer(self.control_point, 'audio/mp3') with self.assertLogs(level=logging.DEBUG) as m_logs: LibPulse.add_sink_inputs([]) async with LibPulse('pa-dlna') as self.pulse.lib_pulse: with mock.patch.object(self.pulse.lib_pulse, 'pa_context_load_module') as load: load.side_effect = [PA_INVALID_INDEX] sink = await self.pulse.register(renderer) self.assertTrue(sink is None) self.assertTrue(search_in_logs(m_logs.output, 'pulse', re.compile('Failed loading DLNATest_mp3-uuid:'))) async def test_bad_get_sink_by_module(self): renderer = Renderer(self.control_point, 'audio/mp3') with self.assertLogs(level=logging.DEBUG) as m_logs: LibPulse.add_sink_inputs([]) async with LibPulse('pa-dlna') as self.pulse.lib_pulse: with mock.patch.object(self.pulse.lib_pulse, 'pa_context_load_module') as load: load.side_effect = [999] await self.pulse.register(renderer) self.assertTrue(search_in_logs(m_logs.output, 'pulse', re.compile('Failed getting sink of DLNATest_mp3-uuid:'))) async def test_register_twice(self): renderer = Renderer(self.control_point, 'audio/mp3') with self.assertLogs(level=logging.DEBUG) as m_logs: LibPulse.add_sink_inputs([]) async with LibPulse('pa-dlna') as self.pulse.lib_pulse: with self.assertRaises(ControlPointAbortError) as cm: await self.pulse.register(renderer) await self.pulse.register(renderer) self.assertTrue(cm.exception.args[0].startswith( 'Two DLNA devices registered with the same name')) async def test_remove_event(self): results = [] renderer = self.new_renderer('mp3', results) with self.assertLogs(level=logging.DEBUG) as m_logs: sink_input = SinkInput('source', [Event('new'), Event('remove')]) LibPulse.add_sink_inputs([sink_input]) async with LibPulse('pa-dlna') as self.pulse.lib_pulse: renderer.nullsink = await self.pulse.register(renderer) await self.pulse.lib_pulse.pa_context_subscribe( PA_SUBSCRIPTION_MASK_SINK_INPUT) iterator = self.pulse.lib_pulse.get_events_iterator() async for event in iterator: await self.pulse.dispatch_event(event) await asyncio.sleep(0) self.assertTrue(results[0] == ('new', renderer.nullsink.sink, sink_input)) self.assertTrue(results[1] == ('remove', None, None)) async def test_exit_event(self): results = [] mp3_renderer = self.new_renderer('mp3', results) mpeg_renderer = self.new_renderer('mpeg', results) with self.assertLogs(level=logging.DEBUG) as m_logs: sink_input = SinkInput('source', [Event('new'), Event('new')]) LibPulse.add_sink_inputs([sink_input]) async with LibPulse('pa-dlna') as self.pulse.lib_pulse: mp3_renderer.nullsink = await self.pulse.register( mp3_renderer) await self.pulse.lib_pulse.pa_context_subscribe( PA_SUBSCRIPTION_MASK_SINK_INPUT) iterator = self.pulse.lib_pulse.get_events_iterator() async for event in iterator: await self.pulse.dispatch_event(event) await asyncio.sleep(0) if mpeg_renderer.nullsink is None: mpeg_renderer.nullsink = await self.pulse.register( mpeg_renderer) self.assertTrue(results[0] == ('new', mp3_renderer.nullsink.sink, sink_input)) self.assertTrue(results[1] == ('new', mpeg_renderer.nullsink.sink, sink_input)) self.assertTrue(results[2] == ('exit', None, None)) if __name__ == '__main__': unittest.main(verbosity=2) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1739632320.4165769 pa_dlna-0.16/pa_dlna/tests/test_tracks.py0000644000000000000000000004075714754127300015444 0ustar00"""Tests that stream tracks to upmpdcli and mpd.""" import sys import os import time import asyncio import tempfile import pathlib import subprocess from textwrap import dedent from signal import SIGINT, SIGTERM from contextlib import asynccontextmanager, AsyncExitStack from unittest import IsolatedAsyncioTestCase try: from libpulse import libpulse except ImportError: libpulse = None from . import requires_resources from ..init import parse_args from ..config import UserConfig from ..pa_dlna import AVControlPoint import logging logger = logging.getLogger('tsample') TRACK_TIMEOUT = 20 DEFAULT_ENCODER = 'L16Encoder' # Courtesy of https://espressif-docs.readthedocs-hosted.com/projects/esp-adf/ # en/latest/design-guide/audio-samples.html. # A 16 seconds track: Duration: 00:00:15.88, start: 0.025057, bitrate: 64 kb/s TRACK_16 = pathlib.Path(__file__).parent / 'gs-16b-1c-44100hz.mp3' # Map values to their name. if libpulse is not None: SINK_STATES = dict((eval(f'libpulse.{state}'), state) for state in ('PA_SINK_IDLE', 'PA_SINK_INIT', 'PA_SINK_INVALID_STATE', 'PA_SINK_RUNNING', 'PA_SINK_SUSPENDED', 'PA_SINK_UNLINKED')) class TrackRuntimeError(Exception): pass @asynccontextmanager async def create_config_home(encoder, sink_name): "Yield temporary directory to be used as the value of XDG_CONFIG_HOME" with tempfile.TemporaryDirectory(dir='.') as tmpdirname: # Create the minimum set of mpd files. config_home = pathlib.Path(tmpdirname).absolute() mpd_path = config_home / 'mpd' mpd_path.mkdir() state_path = mpd_path / 'state' with open(state_path, 'w'): pass sticker_path = mpd_path / 'sticker.sql' with open(sticker_path, 'w'): pass mpd_conf = mpd_path / 'mpd.conf' with open(mpd_conf, 'w') as f: f.write(dedent(f'''\ state_file "{state_path}" sticker_file "{sticker_path}" audio_output {{ type "pulse" name "My Pulse Output" sink "{sink_name}" }} ''')) # Create the pa-dlna configuration file. padlna_path = config_home / 'pa-dlna' padlna_path.mkdir() pa_dlna_conf = padlna_path / 'pa-dlna.conf' with open(pa_dlna_conf, 'w') as f: f.write(dedent(f'''\ [DEFAULT] selection = {encoder}, ''')) yield str(config_home) @asynccontextmanager async def run_control_point(config_home, loglevel): async def cp_connected(): # Wait for the connection to LibPulse. while cp.pulse is None or cp.pulse.lib_pulse is None: await asyncio.sleep(0) logger.debug('Connected to libpulse') return cp argv = ['--nics', 'lo', '--loglevel', loglevel] options, _ = parse_args('pa-dlna sample tests', argv=argv) # Override any existing pa-dlna user configuration with no user # configuration. _environ = os.environ.copy() try: os.environ.update({'XDG_CONFIG_HOME': config_home}) config = UserConfig() cp = AVControlPoint(config=config, **options) asyncio.create_task(cp.run_control_point()) finally: os.environ.clear() os.environ.update(_environ) try: yield await asyncio.wait_for(cp_connected(), 5) except TimeoutError: raise TrackRuntimeError('Cannot connect to libpulse') from None finally: await cp.close() async def proc_terminate(proc, signal=None, timeout=0.2): async def _terminate(funcname, delay): start = time.monotonic() if funcname == 'send_signal': proc.send_signal(signal) else: getattr(proc, funcname)() await asyncio.sleep(0) while proc.returncode is None and time.monotonic() - start < delay: await asyncio.sleep(0) if proc.returncode is None: if signal is not None: await _terminate('send_signal', timeout) else: await _terminate('terminate', timeout) if proc.returncode is None: await _terminate('kill', 0) @asynccontextmanager async def proc_run(cmd, *args, env=None): logger.debug(f"Run command '{cmd} {' '.join(args)}'") environ = None if env is not None: environ = os.environ.copy() environ.update(env) proc = await asyncio.create_subprocess_exec( cmd, *args, env=environ, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE) try: stderr = await asyncio.wait_for(proc.stderr.readline(), 5) except TimeoutError: raise TrackRuntimeError( f"'{cmd!r}' failure to output first stderr line") from None yield proc if cmd == 'upmpdcli': await proc_terminate(proc, signal=SIGINT) else: await proc_terminate(proc) await proc.wait() if logger.getEffectiveLevel() == logging.DEBUG: logger.debug(f'[{cmd!r} exited with {proc.returncode}]') stdout = await proc.stdout.read() if stdout: logger.debug(f'[stdout]\n{stdout.decode()}') stderr += await proc.stderr.read() if stderr: logger.debug(f'[stderr]\n{stderr.decode()}') async def get_sink_state(lib_pulse, sink): for _sink in await lib_pulse.pa_context_get_sink_info_list(): if _sink.index == sink.index: return SINK_STATES[_sink.state] else: raise TrackRuntimeError(f"'Cannot find sink '{sink.name}'") async def sink_is_running(lib_pulse, sink): # Loop for ever. while True: state = await get_sink_state(lib_pulse, sink) if state == 'PA_SINK_RUNNING': return True await asyncio.sleep(0) async def http_transfer_end(renderer): # Wait for the termination of the HTTP 1.1 chunked transfer encoding. while True: if renderer.stream_sessions.track_count == 0: return await asyncio.sleep(0.1) class UpmpdcliMpd: """Set up the environment to play tracks with upmpdcli and mpd. 'upmpdcli' is a DLNA Media Renderer implementation that forwards audio to 'mpd' and 'mpd' is configured to output audio to a pulse sink. The UpmpdcliMpd instance starts both processes, creates the pulse sink used by 'mpd' and gets the upmpdcli pa-dlna renderer. UpmpdcliMpd must be instantiated in an 'async with' statement. """ def __init__(self, encoder=DEFAULT_ENCODER, mpd_sink_name='MPD-sink', loglevel='error'): self.encoder = encoder self.mpd_sink_name = mpd_sink_name self.loglevel = loglevel self.mpd_sink = None self.control_point = None self.lib_pulse = None self.renderer = None self.closed = False self.curtask = asyncio.current_task() self.exit_stack = AsyncExitStack() async def shutdown(self, end_event): # Run by the 'shutdown' task. await end_event.wait() await self.close('Got SIGINT or SIGTERM') async def close(self, msg=None): if self.closed: return self.closed = True try: # Close the UPnP control point to avoid annoying logs from the # _ssdp_notify task. # This will close the AVControlPoint instance. if (self.control_point is not None and self.control_point.upnp_control_point is not None): self.control_point.upnp_control_point.close() await self.exit_stack.aclose() finally: if self.curtask != asyncio.current_task(): if sys.version_info[:2] >= (3, 9): self.curtask.cancel(msg) else: self.curtask.cancel() loop = asyncio.get_running_loop() for sig in (SIGINT, SIGTERM): loop.remove_signal_handler(sig) @asynccontextmanager async def create_sink(self): # Refuse to create the sink if it already exists. for sink in await self.lib_pulse.pa_context_get_sink_info_list(): if sink.name == self.mpd_sink_name: raise TrackRuntimeError( dedent(f"""\ The '{sink.name}' sink already exists. To remove this sink run the command 'pactl list sinks' to get the of the 'Owner Module' of '{sink.name}' and unload this module with the command 'pactl unload-module '""" )) logger.debug(f"Create sink '{self.mpd_sink_name}'") module_index = await self.lib_pulse.pa_context_load_module( 'module-null-sink', f'sink_name="{self.mpd_sink_name}" ' f'sink_properties=device.description="{self.mpd_sink_name}"') try: for sink in await self.lib_pulse.pa_context_get_sink_info_list(): if sink.owner_module == module_index: yield sink break else: raise TrackRuntimeError( f"Cannot find sink '{self.mpd_sink_name}'") finally: await self.lib_pulse.pa_context_unload_module(module_index) logger.debug(f'Unload null-sink module of {self.mpd_sink_name}') async def get_renderer(self): renderer = None while renderer is None: for rndrer in self.control_point.renderers(): if rndrer.name.startswith('UpMpd-'): renderer = rndrer break await asyncio.sleep(0) while renderer.encoder is None: await asyncio.sleep(0) # Make sure the control_point is idle. for i in range(10): await asyncio.sleep(0) logger.debug(f'Found renderer {renderer.name}') return renderer async def start_track(self, track_path): # ffmpeg plays a track to the sink of the upmpdcli renderer. renderer_sink = self.renderer.nullsink.sink args = ['-hide_banner', '-nostats', '-i', str(track_path), '-f', 'pulse', '-device', str(renderer_sink.index), track_path.stem] track_proc = await self.exit_stack.enter_async_context( proc_run('ffmpeg', *args)) # Wait for the MPD sink to be running. try: await asyncio.wait_for(sink_is_running(self.lib_pulse, self.mpd_sink), TRACK_TIMEOUT) except TimeoutError: try: state = await get_sink_state(self.lib_pulse, self.mpd_sink) logger.error(f"MPD sink state is '{state}'" f" after {TRACK_TIMEOUT} seconds") finally: await self.stop_track(track_proc) return return track_proc async def stop_track(self, track_proc): # Stop the stream. await proc_terminate(track_proc) # The timeout value depends on ISSUE_48_TIMER value. # It has been increased by ISSUE_48_TIMER after the issue #48 fix. timeout = 7 try: await asyncio.wait_for(http_transfer_end(self.renderer), timeout) except TimeoutError: logger.error(f'Http transfer still running {timeout} seconds ' f'after the audio stream source has been terminated') async def __aenter__(self): try: # Run the AVControlPoint. config_home = await self.exit_stack.enter_async_context( create_config_home(self.encoder, self.mpd_sink_name)) self.control_point = await self.exit_stack.enter_async_context( run_control_point(config_home, self.loglevel)) # Add the signal handlers (overridding the AVControlPoint # signal handlers). end_event = asyncio.Event() asyncio.create_task(self.shutdown(end_event), name='shutdown') loop = asyncio.get_running_loop() for sig in (SIGINT, SIGTERM): loop.add_signal_handler(sig, end_event.set) self.lib_pulse = self.control_point.pulse.lib_pulse logger.debug(f"XDG_CONFIG_HOME is '{config_home}'") # Create 'MPD-sink'. self.mpd_sink = await self.exit_stack.enter_async_context( self.create_sink()) # Start the mpd and upmpdcli processes. await self.exit_stack.enter_async_context( proc_run('mpd', '--no-daemon', env={'XDG_CONFIG_HOME': config_home})) await self.exit_stack.enter_async_context( proc_run('upmpdcli', '-i', 'lo')) # Get the pa-dlna Renderer instance of the upmpdcli DLNA device. try: self.renderer = await asyncio.wait_for(self.get_renderer(), 5) except TimeoutError: raise TrackRuntimeError( 'Cannot find the upmpdcli Renderer instance') from None return self except Exception as e: await self.exit_stack.aclose() if isinstance(e, TrackRuntimeError): sys.exit(f'*** error: {e}') else: raise async def __aexit__(self, exc_type, exc_value, traceback): await self.close() @requires_resources(('libpulse', 'ffmpeg', 'upmpdcli', 'mpd')) class PlayTracks(IsolatedAsyncioTestCase): async def play_track(self, upmpdcli): proc = None cancelled = False try: proc = await upmpdcli.start_track(TRACK_16) self.assertTrue(proc is not None) lib_pulse = upmpdcli.lib_pulse mpd_state = await get_sink_state(lib_pulse, upmpdcli.mpd_sink) self.assertEqual(mpd_state, 'PA_SINK_RUNNING') renderer_sink = upmpdcli.renderer.nullsink.sink renderer_state = await get_sink_state(lib_pulse, renderer_sink) self.assertEqual(renderer_state, 'PA_SINK_RUNNING') state = await upmpdcli.renderer.get_transport_state() self.assertEqual(state, 'PLAYING') except asyncio.CancelledError as e: logger.info(f'Got {e!r}') cancelled = True finally: if proc is not None: await upmpdcli.stop_track(proc) if cancelled: self.fail('The test has been cancelled') async def test_play_track_aac(self): async with UpmpdcliMpd(encoder='FFMpegAacEncoder') as upmpdcli: await self.play_track(upmpdcli) async def test_play_track_l16(self): async with UpmpdcliMpd(encoder='L16Encoder') as upmpdcli: await self.play_track(upmpdcli) async def main(): encoder = DEFAULT_ENCODER if len(sys.argv) == 2: encoder = sys.argv[1] async with UpmpdcliMpd(encoder=encoder, loglevel='debug') as upmpdcli: logger.info(f"Using '{encoder}' encoder") proc = None try: proc = await upmpdcli.start_track(TRACK_16) if proc is None: return lib_pulse = upmpdcli.lib_pulse mpd_state = await get_sink_state(lib_pulse, upmpdcli.mpd_sink) logger.info(f'MPD sink state: {mpd_state}') renderer_sink = upmpdcli.renderer.nullsink.sink renderer_state = await get_sink_state(lib_pulse, renderer_sink) logger.info(f'upmpdcli sink state: {renderer_state}') # Get the upmpdcli MediaRenderer state using a # 'GetTransportInfo' soap action. state = await upmpdcli.renderer.get_transport_state() logger.info(f'upmpdcli MediaRenderer state: {state}') except asyncio.CancelledError as e: logger.info(f'Got {e!r}') finally: if proc is not None: await upmpdcli.stop_track(proc) if __name__ == '__main__': asyncio.run(main()) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1730456841.1353462 pa_dlna-0.16/pa_dlna/upnp/__init__.py0000644000000000000000000000111614711126411014452 0ustar00# UPnP exceptions. class UPnPError(Exception): pass # TEST_LOGLEVEL is below logging.DEBUG and is only used by the test suite. TEST_LOGLEVEL = 5 # All exported objects. from .upnp import (UPnPClosedDeviceError, UPnPInvalidSoapError, UPnPSoapFaultError, UPnPControlPoint, UPnPRootDevice, UPnPDevice, UPnPService, QUEUE_CLOSED) from .network import ipaddr_from_nics from .util import (NL_INDENT, shorten, AsyncioTasks, log_exception, log_unhandled_exception) from .xml import UPnPXMLError, pformat_xml, xml_escape ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1737987650.3763506 pa_dlna-0.16/pa_dlna/upnp/network.py0000644000000000000000000005052714745713102014424 0ustar00"""Networking utilities.""" import asyncio import socket import struct import time import re import io import logging import urllib.parse import psutil from ipaddress import IPv4Interface, IPv4Address from . import UPnPError, TEST_LOGLEVEL from .util import log_exception logger = logging.getLogger('network') MCAST_GROUP = '239.255.255.250' MCAST_PORT = 1900 MCAST_ADDR = (MCAST_GROUP, MCAST_PORT) UPNP_ROOTDEVICE = 'upnp:rootdevice' MSEARCH_COUNT= 3 # number of MSEARCH requests each time MSEARCH_INTERVAL = 0.2 # sent at seconds intervals MX = 2 # seconds to delay response MSEARCH = '\r\n'.join([ f'M-SEARCH * HTTP/1.1', f'HOST: {MCAST_GROUP}:{MCAST_PORT}', f'MAN: "ssdp:discover"', f'ST: {UPNP_ROOTDEVICE}', f'MX: {MX}', f'', f'', ]) # Chunked transfer encoding. HTTP_CHUNK_SIZE = 512 SEP = b'\r\n' CHUNK_EXT = b';' HEXDIGITS = re.compile(b'[0-9a-fA-F]+') class ChunkState: PARSE_CHUNKED_SIZE = 0 PARSE_CHUNKED_CHUNK = 1 PARSE_CHUNKED_CHUNK_EOF = 2 PARSE_MAYBE_TRAILERS = 3 PARSE_TRAILERS = 4 class UPnPInvalidSsdpError(UPnPError): pass class UPnPInvalidHttpError(UPnPError): pass # Networking helper functions. def ipaddr_from_nics(nics, skip_loopback=False, as_string=True): """Yield the IPv4 addresses of NICS in the UP state. Use all existing network interface when 'nics' is empty, except the loopback interface when 'skip_loopback' is true. """ # Get the IP addresses of each NIC in the UP state. all_nics = {} nics_stats = psutil.net_if_stats() for nic, val in psutil.net_if_addrs().items(): if nic in nics_stats and nics_stats[nic].isup: all_nics[nic] = val for nic in filter(lambda x: not nics and (not skip_loopback or x != 'lo') or x in nics, all_nics): for addr in filter(lambda x: x.family == socket.AF_INET, all_nics[nic]): if addr.netmask is not None: ip_addr = IPv4Interface(f'{addr.address}/{addr.netmask}') if ip_addr.network.prefixlen != 32: yield addr.address if as_string else ip_addr else: yield addr.address if as_string else IPv4Address(addr.address) def http_header_as_dict(header): """Return the http header as a dict.""" def normalize(args): """Return a normalized (key, value) tuple.""" return args[0].strip().upper(), args[1].strip() # RFC 2616 (obsoleted) section 4.2: Header fields can be extended over # multiple lines by preceding each extra line with at least one SP or HT. # But see RFC 7230 section 3.2.4: A server that receives an obs-fold ... # [may] replace each received obs-fold with one or more SP octets. compacted = '' for line in header: sep = '' if not compacted or line.startswith((' ', '\t')) else '\n' compacted = sep.join((compacted, line)) try: return dict(normalize(line.split(':', maxsplit=1)) for line in compacted.splitlines()) except (ValueError, IndexError): raise UPnPInvalidSsdpError(f'malformed HTTP header:\n{header}') def check_ssdp_header(header, is_msearch): """Check the SSDP header.""" def exist(keys): for key in keys: if key not in header: raise UPnPInvalidSsdpError( f'missing "{key}" field in SSDP notify:\n{header}') # Check the presence of some required keys. if is_msearch: exist(('ST', 'LOCATION', 'USN')) else: exist(('NT', 'NTS', 'USN')) if header['NTS'] in ('ssdp:alive', 'ssdp:update'): exist(('LOCATION',)) def parse_ssdp(datagram, peer_ipaddress, is_msearch): """Return None when ignoring the SSDP, otherwise return a dict.""" req_line = 'HTTP/1.1 200 OK' if is_msearch else 'NOTIFY * HTTP/1.1' # Ignore non 'notify' and non 'msearch' SSDPs. header = datagram.decode().splitlines() start_line = header[:1] if not start_line or start_line[0].strip() != req_line: if start_line: logger.log(TEST_LOGLEVEL, f"Ignore '{start_line[0].strip()}' request") return None # Parse the HTTP header as a dict. try: header = http_header_as_dict(header[1:]) check_ssdp_header(header, is_msearch) except UPnPInvalidSsdpError as e: logger.warning(f'Error from {peer_ipaddress}: {e}') return None # Ignore non root device responses. _type = header['ST'] if is_msearch else header['NT'] if _type != UPNP_ROOTDEVICE: logger.log(TEST_LOGLEVEL, f"Ignore '{_type}': non root device") return None return header async def msearch(ip, protocol, msearch_count=MSEARCH_COUNT, msearch_interval=MSEARCH_INTERVAL, mx=MX): """Implement the SSDP search protocol on the 'ip' network interface. Return the list of received (data, peer_addr, local_addr). """ expire = time.monotonic() + mx for i in range(msearch_count): await asyncio.sleep(msearch_interval) if not protocol.closed(): protocol.send_datagram(MSEARCH) else: break logger.debug(f'Sent {i + 1} M-SEARCH datagrams to {MCAST_ADDR} from {ip}') if not protocol.closed(): remain = expire - time.monotonic() if remain > 0: await asyncio.sleep(expire - time.monotonic()) return protocol.get_result() async def send_mcast(ip, port, ttl=2, coro=msearch): """Send multicast datagrams. 'coro' is a coroutine *function* and when invoked, the coroutine is awaited with the 'protocol' end point as parameter for sending and receiving datagrams. """ # Create the socket. sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) sock.setblocking(False) try: # Prevent multicast datagrams to be looped back to ourself. sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_LOOP, 0) try: sock.bind((ip, port)) except OSError as e: # Just log the exception, the associated network interface may # be reconnected later. logger.debug(f'Cannot bind to IP address {ip}: {e!r}') return # Start the server. transport = None try: loop = asyncio.get_running_loop() transport, protocol = await loop.create_datagram_endpoint( lambda: MsearchServerProtocol(ip), sock=sock) # Prepare the socket for sending from the network # interface of 'ip'. sock.setsockopt(socket.SOL_IP, socket.IP_MULTICAST_IF, socket.inet_aton(ip)) sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, ttl) return await coro(ip, protocol) finally: if transport is not None: transport.close() finally: # Needed when OSError is raised upon binding the socket. sock.close() def trim_bytes_to_string(source, size=80): text = source.decode("ascii", "surrogateescape") trailer = '...' if len(text) > size else '' return text[:size] + trailer async def parse_chunked_body(reader, url='', http_chunk_size=HTTP_CHUNK_SIZE): """Parse a chunked encoding body. This is mostly code from aiohttp.htt_parser.HttpPayloadParser after fixing issue https://github.com/aio-libs/aiohttp/issues/10355. """ state = ChunkState.PARSE_CHUNKED_SIZE chunk_size = 0 chunk_tail = b'' body = io.BytesIO() # RFC 2616 https://datatracker.ietf.org/doc/html/rfc2616#section-3.6.1. while True: chunk = await reader.read(http_chunk_size) if chunk == b'': if state not in (ChunkState.PARSE_MAYBE_TRAILERS, ChunkState.PARSE_TRAILERS): logger.warning(f"Missing last-chunk from '{url}'") if chunk_tail: tail = trim_bytes_to_string(chunk_tail) logger.warning(f'Trailing chunk from {url}: {tail}') break if chunk_tail: chunk = chunk_tail + chunk chunk_tail = b'' while chunk: # Read next chunk size. if state == ChunkState.PARSE_CHUNKED_SIZE: pos = chunk.find(SEP) if pos >= 0: # Strip chunk-extensions. i = chunk.find(CHUNK_EXT, 0, pos) size_b = chunk[:i] if i >= 0 else chunk[:pos] size_b = size_b.strip() if not re.fullmatch(HEXDIGITS, size_b): size = trim_bytes_to_string(chunk[:pos]) raise UPnPInvalidHttpError( f'Not a chunk size: {size!r} from {url}') size = int(size_b, 16) chunk = chunk[pos+len(SEP):] if size == 0: state = ChunkState.PARSE_MAYBE_TRAILERS else: state = ChunkState.PARSE_CHUNKED_CHUNK chunk_size = size else: chunk_tail = chunk break # Read the chunk. if state == ChunkState.PARSE_CHUNKED_CHUNK: required = chunk_size chunk_size = max(required - len(chunk), 0) body.write(chunk[:required]) if chunk_size: break chunk = chunk[required:] state = ChunkState.PARSE_CHUNKED_CHUNK_EOF # Toss the CRLF at the end of the chunk. if state == ChunkState.PARSE_CHUNKED_CHUNK_EOF: if chunk[:len(SEP)] == SEP: state = ChunkState.PARSE_CHUNKED_SIZE chunk = chunk[len(SEP):] else: length = len(chunk) if length and chunk[0] != SEP[0] or length > 1: chunk = trim_bytes_to_string(chunk) raise UPnPInvalidHttpError( f'Missing CRLF at chunk end: {chunk!r} from {url}') # Get the CRLF or the missing LF in the next chunk. chunk_tail = chunk break # If stream does not contain trailer, after 0\r\n # we should get another \r\n otherwise # trailers needs to be skipped until \r\n\r\n. if state == ChunkState.PARSE_MAYBE_TRAILERS: head = chunk[:len(SEP)] if head == SEP: # End of stream. break # Both CR and LF, or only LF may not be received yet. It is # expected that CRLF or LF will be shown at the very first # byte next time, otherwise trailers should come. The last # CRLF which marks the end of response might not be # contained in the same TCP segment which delivered the # size indicator. if not head or head == SEP[0]: chunk_tail = head break state = ChunkState.PARSE_TRAILERS # Read and discard trailer up to the CRLF terminator if state == ChunkState.PARSE_TRAILERS: pos = chunk.find(SEP) if pos >= 0: chunk = chunk[pos+len(SEP):] state = ChunkState.PARSE_MAYBE_TRAILERS else: chunk_tail = chunk break return body.getvalue() async def http_query(method, url, header='', body=''): """An HTTP 1.0 GET or POST request.""" assert method in ('GET', 'POST') writer = None try: urlobj = urllib.parse.urlsplit(url) host = urlobj.hostname port = urlobj.port if urlobj.port is not None else 80 reader, writer = await asyncio.open_connection(host, port) # Send the request. request = urlobj._replace(scheme='')._replace(netloc='').geturl() query = ( f"{method} {request or '/'} HTTP/1.0\r\n" f"Host: {host}:{port}\r\n" ) query = query + header + '\r\n' writer.write(query.encode('latin-1')) writer.write(body.encode()) # Parse the http header. header = [] while True: line = await reader.readline() if not line: break line = line.decode('latin1').rstrip() if line: header.append(line) else: break if not header: raise UPnPInvalidHttpError(f'Empty http header from {host}') header_dict = http_header_as_dict(header[1:]) transfer_encoding = header_dict.get('TRANSFER-ENCODING') if transfer_encoding is not None: if transfer_encoding.lower() == 'chunked': body = await parse_chunked_body(reader, url=url) return header, body, host else: logger.error(f"HTTP 1.0 does not support '{transfer_encoding}'" f" Transfer-Encoding") return header, b'', host content_length = header_dict.get('CONTENT-LENGTH') if content_length is not None: content_length = int(content_length) if content_length == 0: logger.warning(f'Got content_length = 0 from {url}') return header, b'', host body = await reader.read() # Check that we have received the whole body. if content_length is not None: if len(body) != content_length: raise UPnPInvalidHttpError(f'Content-Length and actual length' f' mismatch ({content_length} != {len(body)})' f' from {host}') if not body: logger.warning(f'Got empty body from {url}') return header, body, host finally: if writer is not None: try: writer.close() await writer.wait_closed() except ConnectionError: pass async def http_get(url): """An HTTP 1.0 GET request.""" header, body, host = await http_query('GET', url) line = header[0] if re.match(r'HTTP/1\.(0|1) 200 ', line) is None: raise UPnPInvalidHttpError(f"Header={header}, Body={body}" f" from {host}") return body async def http_soap(url, header, body): """HTTP 1.0 POST request used to submit a SOAP action.""" header, body, host = await http_query('POST', url, header, body) line = header[0] if re.match(r'HTTP/1\.(0|1) 200 ', line) is not None: is_fault = False # HTTP/1.0 500 Internal Server Error. elif re.match(r'HTTP/1\.(0|1) 500 ', line) is not None: is_fault = True else: raise UPnPInvalidHttpError(f"Header={header}, Body={body}" f" from {host}") return is_fault, body # Classes. class Notify: """Implement the SSDP advertisement protocol. See section 21.10 Sending and Receiving in "Network Programming Volume 1, Third Edition" Stevens et al. See also section 5.10.2 Receiving IP Multicast Datagrams in "An Advanced 4.4BSD Interprocess Communication Tutorial". """ def __init__(self, process_datagram, ip_addresses): self.process_datagram = process_datagram self.failed_memberships = set() # Create the socket. self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) self.sock.setblocking(False) self.manage_membership(ip_addresses) # Future used by the test suite. loop = asyncio.get_running_loop() self.startup = loop.create_future() def close(self): self.sock.close() def manage_membership(self, new_ips, stale_ips=None): def member(ip, option): msg = ('member of' if option == socket.IP_ADD_MEMBERSHIP else 'dropped from') try: mreq = struct.pack('4s4s', socket.inet_aton(MCAST_GROUP), socket.inet_aton(ip)) self.sock.setsockopt(socket.IPPROTO_IP, option, mreq) logger.debug(f'SSDP notify: {ip} {msg} multicast group' f' {MCAST_GROUP}') if (option == socket.IP_ADD_MEMBERSHIP and ip in self.failed_memberships): self.failed_memberships.remove(ip) except OSError as e: # Log the warning only once. if (option == socket.IP_ADD_MEMBERSHIP and ip not in self.failed_memberships): logger.warning(f'SSDP notify: {ip} cannot be {msg}' f' {MCAST_GROUP}: {e!r}') self.failed_memberships.add(ip) return False return True for ip in new_ips: member(ip, socket.IP_ADD_MEMBERSHIP) if stale_ips is not None: for ip in stale_ips: member(ip, socket.IP_DROP_MEMBERSHIP) async def run(self): # Allow other processes to bind to the same multicast group and port. self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) # Bind to the multicast (group, port). # Binding to (INADDR_ANY, port) would also work, except # that in that case the socket would also receive the datagrams # destined to (any other address, MCAST_PORT). self.sock.bind(MCAST_ADDR) # Start the server. transport = None try: loop = asyncio.get_running_loop() on_con_lost = loop.create_future() transport, protocol = await loop.create_datagram_endpoint( lambda: NotifyServerProtocol(self.process_datagram, on_con_lost), sock=self.sock) self.startup.set_result(None) await on_con_lost logger.debug("Future 'on_con_lost' is done.") finally: # Drop multicast group membership for all IP addresses. self.manage_membership(set()) if transport is not None: transport.close() logger.info('End of the SSDP notify task') # Network protocols. class MsearchServerProtocol: """The MSEARCH asyncio server.""" def __init__(self, ip): self.ip = ip self.transport = None self._result = [] # list of received (data, peer_addr, local_addr) self._closed = None def connection_made(self, transport): self.transport = transport self._closed = False def datagram_received(self, data, peer_addr): local_addr = self.transport.get_extra_info('sockname') self._result.append((data, peer_addr[0], local_addr[0])) def error_received(self, exc): logger.warning(f'Error received on {self.ip} by' f' MsearchServerProtocol: {exc}') self.transport.abort() def connection_lost(self, exc): if exc: logger.debug(f'Connection lost on {self.ip} by' f' MsearchServerProtocol: {exc!r}') self._closed = True def send_datagram(self, message): try: self.transport.sendto(message.encode(), MCAST_ADDR) except Exception as e: self.error_received(e) def get_result(self): return self._result def closed(self): return self._closed class NotifyServerProtocol: """The NOTIFY asyncio server.""" def __init__(self, process_datagram, on_con_lost): self.process_datagram = process_datagram self.on_con_lost = on_con_lost def connection_made(self, transport): pass def datagram_received(self, data, addr): try: self.process_datagram(data, addr[0], None) except Exception as exc: if not self.on_con_lost.done(): self.on_con_lost.set_result(True) self.error_received(exc) def error_received(self, exc): log_exception(logger, f'Error received by NotifyServerProtocol: {exc!r}') def connection_lost(self, exc): if exc: logger.warning(f'Connection lost by NotifyServerProtocol: {exc!r}') ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1714557737.8100407 pa_dlna-0.16/pa_dlna/upnp/tests/__init__.py0000644000000000000000000001110514614411452015617 0ustar00import sys import socket import asyncio import unittest from unittest import mock from ..upnp import UPnPControlPoint from ..network import MCAST_ADDR MSEARCH_PORT = 9999 SSDP_NOTIFY = '\r\n'.join([ 'NOTIFY * HTTP/1.1', 'Host: 239.255.255.250:1900', 'Content-Length: 0', 'Location: {url}', 'Cache-Control: max-age={max_age}', 'Server: Linux', 'NT: upnp:rootdevice', '{nts}', 'USN: {udn}::upnp:rootdevice', '', '', ]) HOST = '127.0.0.1' HTTP_PORT = 9999 URL = f'http://{HOST}:{HTTP_PORT}/MediaRenderer/desc.xml' UDN = 'uuid:ffffffff-ffff-ffff-ffff-ffffffffffff' SSDP_PARAMS = { 'url': URL, 'max_age': '1800', 'udn': UDN } SSDP_ALIVE = SSDP_NOTIFY.format(nts='NTS: ssdp:alive', **SSDP_PARAMS) def min_python_version(sys_version): return unittest.skipIf(sys.version_info < sys_version, f'Python version {sys_version} or higher required') def bind_mcast_address(): """Decorator raising SkipTest if MCAST_ADDR is already in use.""" skip = False reason = None with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock: try: sock.bind(MCAST_ADDR) except OSError as e: if e.args[0] == 98: skip = True reason = e.args[1] return unittest.skipIf(skip, f'{MCAST_ADDR}: {reason}') def load_ordered_tests(loader, standard_tests, pattern): """Keep the tests in the order they were declared in the class. Thanks to https://stackoverflow.com/a/62073640 """ ordered_cases = [] for test_suite in standard_tests: ordered = [] for test_case in test_suite: test_case_type = type(test_case) method_name = test_case._testMethodName testMethod = getattr(test_case, method_name) line = testMethod.__code__.co_firstlineno ordered.append( (line, test_case_type, method_name) ) ordered.sort() for line, case_type, name in ordered: ordered_cases.append(case_type(name)) return unittest.TestSuite(ordered_cases) def find_in_logs(logs, logger, msg): """Return True if 'msg' from 'logger' is in 'logs'.""" for log in (log.split(':', maxsplit=2) for log in logs): if len(log) == 3 and log[1] == logger and log[2] == msg: return True return False def search_in_logs(logs, logger, matcher): """Return True if the matcher's pattern is found in a message in 'logs'.""" for log in (log.split(':', maxsplit=2) for log in logs): if (len(log) == 3 and log[1] == logger and matcher.search(log[2]) is not None): return True return False async def loopback_datagrams(datagrams, patch_method=None, setup=None): """Loopback datagrams to UPnPControlPoint._process_ssdp. datagrams Either a coroutine that sends datagrams or a list of datagrams to be broadcasted to the UPnP multicast address. patch_method The name of a method of the UPnPControlPoint instance to patch. setup A coroutine to be awaited for before sending the datagrams. """ async def send_datagrams(ip, protocol): # 'protocol' is the protocol of the MsearchServerProtocol instance. for datagram in datagrams: protocol.send_datagram(datagram) async def is_called(mock): while True: await asyncio.sleep(0) if mock.called: return True if asyncio.iscoroutinefunction(datagrams): coro = datagrams else: coro = send_datagrams control_point = UPnPControlPoint(nics=['lo'], msearch_interval=3600) with mock.patch.object(control_point, '_ssdp_msearch') as ssdp_msearch: if patch_method is not None: patcher = mock.patch.object(control_point, patch_method) method = patcher.start() # Prevent the msearch task to run UPnPControlPoint._ssdp_msearch. ssdp_msearch.side_effect = [None] if setup is not None: await setup(control_point) control_point.open() await control_point._notify.startup # 'coro' is a coroutine *function*. await control_point.msearch_once(coro, port=MSEARCH_PORT) if patch_method is not None: try: await asyncio.wait_for(is_called(method), 1) except asyncio.TimeoutError: raise AssertionError( f'{patch_method}() not called') from None return control_point ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1737816590.3656516 pa_dlna-0.16/pa_dlna/upnp/tests/device_resps.py0000644000000000000000000001053414745175016016550 0ustar00"""This module collects UPnP device responses.""" def device_description(friendly_name='Friendly Name', icons='', devices=''): return f""" 10 DMR-1.50 urn:schemas-upnp-org:device:MediaRenderer:1 {friendly_name} Some model name uuid:ffffffff-ffff-ffff-ffff-ffffffffffff {icons} urn:schemas-upnp-org:service:AVTransport:1 urn:upnp-org:serviceId:AVTransport /AVTransport/desc.xml /AVTransport/ctrl /AVTransport/event urn:schemas-upnp-org:service:RenderingControl:1 urn:upnp-org:serviceId:RenderingControl /RenderingControl/desc.xml /RenderingControl/ctrl /RenderingControl/event urn:schemas-upnp-org:service:ConnectionManager:1 urn:upnp-org:serviceId:ConnectionManager /ConnectionManager/desc.xml /ConnectionManager/ctrl /ConnectionManager/event {devices} """ def scpd(state_variable=''): return f''' GetProtocolInfo Source out SourceProtocolInfo Sink out SinkProtocolInfo SourceProtocolInfo string SinkProtocolInfo string CurrentPlayMode string NORMAL NORMAL NumberOfTracks ui4 0 1 {state_variable} ''' def soap_response(response): return f""" {response} """ def soap_fault(): return """ s:Client UPnPError 401 Invalid Action """ ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1737988202.635117 pa_dlna-0.16/pa_dlna/upnp/tests/test_network.py0000644000000000000000000005024614745714153016632 0ustar00"""Network test cases.""" import re import asyncio import psutil import logging import socket from collections import namedtuple from ipaddress import IPv4Address from unittest import TestCase, IsolatedAsyncioTestCase, mock from ..network import ipaddr_from_nics # Load the tests in the order they are declared. from . import load_ordered_tests as load_tests from . import (find_in_logs, search_in_logs, loopback_datagrams, URL, MSEARCH_PORT, HOST, HTTP_PORT, SSDP_PARAMS, SSDP_NOTIFY, SSDP_ALIVE) from .. import TEST_LOGLEVEL from ..network import (send_mcast, msearch, http_get, UPnPInvalidHttpError, http_soap, Notify, parse_chunked_body) from ..util import HTTPRequestHandler ST_ROOT_DEVICE = 'ST: upnp:rootdevice' SSDP_MSEARCH = '\r\n'.join([ 'HTTP/1.1 200 OK', ('Location: ' + URL), 'Cache-Control: max-age=1800', 'Content-Length: 0', 'Server: Linux', 'EXT:', '{st}', 'USN: uuid:ffffffff-ffff-ffff-ffff-ffffffffffff::upnp:rootdevice', '', '', ]) MSEARCH_RESPONSE = SSDP_MSEARCH.format(st=ST_ROOT_DEVICE) NOT_FOUND_REASON = 'A dummy reason' NOT_FOUND = '\r\n'.join([ f'HTTP/1.1 404 {NOT_FOUND_REASON}', 'Content-Length: 0', '', '', ]) # A shortened psutil address. snicaddr = namedtuple('snicaddr', ['address', 'netmask', 'family'], defaults=[socket.AF_INET]) snicstats = namedtuple('snicstats', ['isup']) NICS_STAT = {'lo': snicstats(True)} class HTTPServer: def __init__(self, body, content_length=None, transfer_encoding=None, start_line=None): self.body = body.encode() self.body_length = len(self.body) header = ['HTTP/1.1 200 OK' if start_line is None else start_line] if content_length is not None: header.append(f'Content-Length: {content_length}') if transfer_encoding is not None: header.append(f'Transfer-Encoding: {transfer_encoding}') header.extend(['', '']) self.header = '\r\n'.join(header).encode('latin-1') loop = asyncio.get_running_loop() self.startup = loop.create_future() async def client_connected(self, reader, writer): """Handle an HTTP GET request and return the response.""" peername = writer.get_extra_info('peername') try: handler = HTTPRequestHandler(reader, writer, peername) await handler.set_rfile() handler.handle_one_request() # Write the response. writer.write(self.header) if self.body_length: writer.write(self.body) finally: await writer.drain() try: writer.close() await writer.wait_closed() except ConnectionError: pass async def run(self): aio_server = await asyncio.start_server(self.client_connected, HOST, HTTP_PORT) async with aio_server: self.startup.set_result(None) await aio_server.serve_forever() class AddrFromNics(TestCase): """network.ipaddr_from_nics() tests.""" def test_cfg_skip_loopback_true(self): # Test that '127.0.0.1' is returned when 'lo' is configured, even # though 'skip_loopback' is true. nics_addr = {'lo': [snicaddr('127.0.0.1', '255.0.0.0')]} with mock.patch.object(psutil,'net_if_addrs') as net_if_addrs,\ mock.patch.object(psutil, 'net_if_stats') as net_if_stats: net_if_addrs.return_value = nics_addr net_if_stats.return_value = NICS_STAT result = list(ipaddr_from_nics(['lo'], skip_loopback=True)) self.assertEqual(result, ['127.0.0.1']) def test_cfg_skip_loopback_false(self): # Test that '127.0.0.1' is returned when 'lo' is not configured # and 'skip_loopback' is false. nics_addr = {'lo': [snicaddr('127.0.0.1', '255.0.0.0')]} with mock.patch.object(psutil,'net_if_addrs') as net_if_addrs,\ mock.patch.object(psutil, 'net_if_stats') as net_if_stats: net_if_stats.return_value = NICS_STAT net_if_addrs.return_value = nics_addr result = list(ipaddr_from_nics(['lo'], skip_loopback=False)) self.assertEqual(result, ['127.0.0.1']) def test_nocfg_skip_loopback_true(self): # Test that '127.0.0.1' is ignored when 'lo' is not configured # and 'skip_loopback' is true. nics_addr = {'lo': [snicaddr('127.0.0.1', '255.0.0.0')]} with mock.patch.object(psutil,'net_if_addrs') as net_if_addrs,\ mock.patch.object(psutil, 'net_if_stats') as net_if_stats: net_if_stats.return_value = NICS_STAT net_if_addrs.return_value = nics_addr result = list(ipaddr_from_nics([], skip_loopback=True)) self.assertEqual(result, []) def test_nocfg_skip_loopback_false(self): # Test that '127.0.0.1' is returned when 'lo' is not configured # and 'skip_loopback' is false. nics_addr = {'lo': [snicaddr('127.0.0.1', '255.0.0.0')]} with mock.patch.object(psutil,'net_if_addrs') as net_if_addrs,\ mock.patch.object(psutil, 'net_if_stats') as net_if_stats: net_if_addrs.return_value = nics_addr net_if_stats.return_value = NICS_STAT result = list(ipaddr_from_nics([], skip_loopback=False)) self.assertEqual(result, ['127.0.0.1']) def test_no_netmask(self): nics_addr = {'lo': [snicaddr('127.0.0.1', None)]} with mock.patch.object(psutil,'net_if_addrs') as net_if_addrs,\ mock.patch.object(psutil, 'net_if_stats') as net_if_stats: net_if_addrs.return_value = nics_addr net_if_stats.return_value = NICS_STAT result = list(ipaddr_from_nics(['lo'], as_string=False)) self.assertEqual(result, [IPv4Address('127.0.0.1')]) class SSDP_notify(IsolatedAsyncioTestCase): """SSDP notify test cases. These tests use the fact that multicast datagrams sent to the loopback interface are looped back to the notify task. """ async def test_ssdp_notify(self): with self.assertLogs(level=TEST_LOGLEVEL) as m_logs: await loopback_datagrams([SSDP_ALIVE], patch_method='_create_root_device') self.assertTrue(find_in_logs(m_logs.output, 'upnp', SSDP_ALIVE)) async def test_membership_OSError(self): with self.assertLogs(level=logging.DEBUG) as m_logs: try: notify = Notify(None, set()) notify.manage_membership(set(['256.0.0.0'])) finally: notify.sock.close() self.assertTrue(search_in_logs(m_logs.output, 'network', re.compile(r'256\.0\.0\.0 cannot be member of 239.255.255.250'))) async def test_failed_memberships(self): try: ip = '127.0.0.1' notify = Notify(None, set()) with self.assertLogs(level=logging.DEBUG) as m_logs: with mock.patch.object(notify, 'sock') as sock: sock.setsockopt.side_effect = OSError() notify.manage_membership(set([ip])) self.assertEqual(notify.failed_memberships, {ip}) notify.manage_membership(set([ip])) self.assertEqual(notify.failed_memberships, set()) finally: notify.sock.close() async def test_notify_OSError(self): async def setup(control_point): patcher = mock.patch.object(control_point, '_process_ssdp') proc_ssdp = patcher.start() proc_ssdp.side_effect = OSError(err_msg) err_msg = 'Exception raised by _process_ssdp' with self.assertLogs(level=logging.DEBUG) as m_logs: control_point = await loopback_datagrams([SSDP_ALIVE], setup=setup) # Wait until completion of the notify task. try: for task in control_point._upnp_tasks: if task.get_name() == 'ssdp notify': break else: raise AssertionError(f'SSDP notify task not found') await asyncio.wait_for(task, 1) except asyncio.CancelledError: pass except asyncio.TimeoutError: self.fail('Notify task did not terminate as expected') self.assertTrue(search_in_logs(m_logs.output, 'network', re.compile('on_con_lost.*done'))) self.assertTrue(search_in_logs(m_logs.output, 'network', re.compile(fr'OSError\({err_msg!r}\)'))) async def test_invalid_field(self): field = 'invalid NTS field' with self.assertLogs(level=logging.DEBUG) as m_logs: await loopback_datagrams( [SSDP_NOTIFY.format(nts=field, **SSDP_PARAMS), SSDP_ALIVE], patch_method='_create_root_device') self.assertTrue(search_in_logs(m_logs.output, 'network', re.compile(f'malformed HTTP header:\n.*{field}'))) async def test_no_NTS_field(self): not_nts = 'FOO: dummy field name' with self.assertLogs(level=logging.DEBUG) as m_logs: await loopback_datagrams( [SSDP_NOTIFY.format(nts=not_nts, **SSDP_PARAMS), SSDP_ALIVE], patch_method='_create_root_device') self.assertTrue(search_in_logs(m_logs.output, 'network', re.compile(f'missing "NTS" field'))) class SSDP_msearch(IsolatedAsyncioTestCase): """SSDP msearch test cases.""" @staticmethod def _sendto_coro(datagram): """Return a coroutine to send a datagram using a socket. The datagram is received by the MsearchServerProtocol instance. """ async def _get_result(protocol): return protocol.get_result() async def send_datagram(ip, protocol): with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock: sock.setblocking(False) sock.sendto(datagram.encode(), (HOST, MSEARCH_PORT)) # With python 3.12 the tests calling this function fail unless # we give back control twice to the asyncio loop. await asyncio.sleep(0) await asyncio.sleep(0) try: return await asyncio.wait_for(_get_result(protocol), 1) except asyncio.TimeoutError: raise AssertionError ('The sent datagram has not been' ' received') return send_datagram async def test_ssdp_msearch(self): async def _msearch(ip, protocol): await msearch(ip, protocol, msearch_count=1, msearch_interval=0, mx=0) with self.assertLogs(level=logging.DEBUG) as m_logs: await loopback_datagrams(_msearch) self.assertTrue(search_in_logs(m_logs.output, 'network', re.compile( r"Sent 1 M-SEARCH datagrams to \('239\.255\.255\.250', 1900\)"))) async def test_ssdp_socket_msearch(self): coro = self._sendto_coro(MSEARCH_RESPONSE) with self.assertLogs(level=TEST_LOGLEVEL) as m_logs: await loopback_datagrams(coro, patch_method='_create_root_device') self.assertTrue(find_in_logs(m_logs.output, 'upnp', MSEARCH_RESPONSE)) async def test_bad_start_line(self): coro = self._sendto_coro(NOT_FOUND) with self.assertLogs(level=TEST_LOGLEVEL) as m_logs: await loopback_datagrams(coro) self.assertTrue(search_in_logs(m_logs.output, 'network', re.compile(f"Ignore '{NOT_FOUND.splitlines()[0]}' request"))) async def test_not_root_device(self): device = 'urn:schemas-upnp-org:device:MediaServer:1' st = f'ST: {device}' coro = self._sendto_coro(SSDP_MSEARCH.format(st=st)) with self.assertLogs(level=TEST_LOGLEVEL) as m_logs: await loopback_datagrams(coro) self.assertTrue(search_in_logs(m_logs.output, 'network', re.compile(f"Ignore '{device}': non root device"))) async def test_invalid_ip(self): with self.assertLogs(level=logging.DEBUG) as m_logs: await send_mcast('256.0.0.0', 0) self.assertTrue(search_in_logs(m_logs.output, 'network', re.compile(r'Cannot bind.*256\.0\.0\.0'))) class HttpQuery(IsolatedAsyncioTestCase): """Http test cases.""" @staticmethod async def _loopback_get(body, content_length=None, transfer_encoding=None, start_line=None): """Start the http server and send the query.""" http_server = HTTPServer(body, content_length, transfer_encoding, start_line) asyncio.create_task(http_server.run()) await http_server.startup return await asyncio.wait_for(http_get(URL), 1) @staticmethod async def _loopback_soap(body, start_line=None): """Start the http server and send the query.""" soap_body = 'The soap action' soap_header = f'Content-length: {len(soap_body.encode())}\r\n' http_server = HTTPServer(body, start_line=start_line) asyncio.create_task(http_server.run()) await http_server.startup return await asyncio.wait_for(http_soap(URL, soap_header, soap_body), 1) async def test_http_get(self): body = 'Some content.' received_body = await self._loopback_get(body) self.assertEqual(body, received_body.decode()) async def test_zero_length(self): body = 'Some content.' with self.assertLogs(level=logging.DEBUG) as m_logs: received_body = await self._loopback_get(body, content_length=0) self.assertTrue(search_in_logs(m_logs.output, 'network', re.compile('Got content_length = 0'))) self.assertEqual(received_body, b'') async def test_empty_body(self): body = '' with self.assertLogs(level=logging.DEBUG) as m_logs: received_body = await self._loopback_get(body) self.assertTrue(search_in_logs(m_logs.output, 'network', re.compile('Got empty body'))) self.assertEqual(received_body, b'') async def test_length_mismatch(self): body = 'Some content.' with self.assertRaises(UPnPInvalidHttpError) as cm: received_body = await self._loopback_get(body, content_length=1) self.assertIn(f'mismatch (1 != {len(body)})', cm.exception.args[0]) async def test_bad_http_version(self): body = 'Some content.' start_line = 'HTTP/2.0 200 OK' with self.assertRaises(UPnPInvalidHttpError) as cm: received_body = await self._loopback_get(body, start_line=start_line) self.assertIn(start_line, cm.exception.args[0]) async def test_transfer_encoding(self): with self.assertLogs(level=logging.DEBUG) as m_logs: body = 'Some content.' received_body = await self._loopback_get(body, transfer_encoding='deflate') self.assertTrue(search_in_logs(m_logs.output, 'network', re.compile(r"not support.*Transfer-Encoding"))) async def test_http_soap(self): body = 'soap response' is_fault, received_body = await self._loopback_soap(body) self.assertEqual(body, received_body.decode()) self.assertFalse(is_fault) async def test_soap_fault(self): body = 'soap response' is_fault, received_body = await self._loopback_soap(body, start_line='HTTP/1.0 500 Internal Server Error') self.assertEqual(body, received_body.decode()) self.assertTrue(is_fault) async def test_bad_soap(self): body = 'soap response' start_line = 'HTTP/2.0 200 OK' with self.assertRaises(UPnPInvalidHttpError) as cm: is_fault, received_body = await self._loopback_soap(body, start_line=start_line) self.assertIn(start_line, cm.exception.args[0]) async def test_chunked_body(self): body = '4\r\nabcd\r\n0\r\n\r\n' with self.assertLogs(level=logging.DEBUG) as m_logs: received_body = await self._loopback_get(body, transfer_encoding='chunked') self.assertEqual(received_body, b'abcd') class HttpChunkedParser(IsolatedAsyncioTestCase): class Reader: def __init__(self, buffer, lines, trimed): # Measure the length of the first 'lines' CRLF lines and # trim the last 'trimed' bytes. first_split = b''.join(buffer.splitlines(keepends=True)[:lines]) self.split = len(first_split) - trimed self.buffer = buffer async def read(self, unused): # Read blocks of 'self.split' size. part = self.buffer[:self.split] self.buffer = self.buffer[self.split:] return part async def test_chunked_split(self): body = b'4\r\nwxyz\r\n0\r\n\r\n' for lines, trimed in ((2, 0), (2, 1), (2, 2), (3, 0), (3, 1), (3, 2), (4, 1), (4, 2), ): with self.subTest(lines=lines, trimed=trimed): reader = self.Reader(body, lines, trimed) received_body = await parse_chunked_body(reader) self.assertEqual(received_body, b'wxyz') async def test_chunked_bad_split(self): body = b'4\r\nwxyzX\n0\r\n\r\n' for lines, trimed in ((2, 2), (2, 1)): with self.subTest(lines=lines, trimed=trimed): with self.assertRaises(UPnPInvalidHttpError) as cm: reader = self.Reader(body, lines, trimed) received_body = await parse_chunked_body(reader) self.assertIn('Missing CRLF', cm.exception.args[0]) async def test_chunked_split_trailers(self): body = (b'4\r\nwxyz\r\n0\r\n' b'Content-MD5: 912ec803b2ce49e4a541068d495ab570\r\n\r\n') for lines, trimed in ((4, 0), (5, 1), (4, 34), (4, 46)): with self.subTest(lines=lines, trimed=trimed): reader = self.Reader(body, lines, trimed) received_body = await parse_chunked_body(reader) self.assertEqual(received_body, b'wxyz') async def test_chunked_missing_CRLF(self): body = b'4\r\nwxyz\rX0\r\n\r\n' with self.assertRaises(UPnPInvalidHttpError) as cm: reader = self.Reader(body, 3, 0) received_body = await parse_chunked_body(reader) self.assertIn('Missing CRLF', cm.exception.args[0]) async def test_chunked_bad_size(self): body = b'blah\r\n0\r\n\r\n' with self.assertRaises(UPnPInvalidHttpError) as cm: reader = self.Reader(body, 3, 0) received_body = await parse_chunked_body(reader) self.assertIn('Not a chunk size', cm.exception.args[0]) async def test_chunked_last_chunk(self): body = b'4\r\nwxyz\r\n' with self.assertLogs(level=logging.DEBUG) as m_logs: reader = self.Reader(body, 3, 0) received_body = await parse_chunked_body(reader, url='foo') self.assertTrue(search_in_logs(m_logs.output, 'network', re.compile('Missing last-chunk.*foo'))) async def test_chunked_trailing(self): body = b'4\r\nwxyz\r\n0\r\nWXYZ' with self.assertLogs(level=logging.DEBUG) as m_logs: reader = self.Reader(body, 4, 0) received_body = await parse_chunked_body(reader, url='foo') self.assertTrue(search_in_logs(m_logs.output, 'network', re.compile('Trailing chunk.*WXYZ'))) if __name__ == '__main__': unittest.main(verbosity=2) ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1737452031.674742 pa_dlna-0.16/pa_dlna/upnp/tests/test_upnp.py0000644000000000000000000006004214743665000016110 0ustar00"""UPnP test cases.""" import re import asyncio import psutil import logging import urllib from unittest import mock, IsolatedAsyncioTestCase # Load the tests in the order they are declared. from . import load_ordered_tests as load_tests from . import (loopback_datagrams, find_in_logs, search_in_logs, UDN, HOST, HTTP_PORT, SSDP_NOTIFY, SSDP_PARAMS, SSDP_ALIVE, URL, bind_mcast_address) from .test_network import snicaddr, snicstats from .device_resps import device_description, scpd, soap_response, soap_fault from ..util import HTTPRequestHandler, shorten from ..upnp import (UPnPControlPoint, UPnPRootDevice, UPnPDevice, UPnPService, UPnPSoapFaultError, UPnPClosedDeviceError) from ..xml import UPnPXMLError SSDP_BYEBYE = SSDP_NOTIFY.format(nts='NTS: ssdp:byebye', **SSDP_PARAMS) SSDP_UPDATE = SSDP_NOTIFY.format(nts='NTS: ssdp:update', **SSDP_PARAMS) CONNECTIONMANAGER = 'urn:upnp-org:serviceId:ConnectionManager' class HTTPServer: def __init__(self, soap_response, icons, devices): self.soap_response = soap_response self.icons = icons self.devices = devices loop = asyncio.get_running_loop() self.startup = loop.create_future() def get_response(self, uri_path): header = ['HTTP/1.1 200 OK'] if uri_path == '/MediaRenderer/desc.xml': body = device_description(icons=self.icons, devices=self.devices) else: for service in ('AVTransport', 'RenderingControl', 'ConnectionManager'): if uri_path == f'/{service}/desc.xml': body = scpd() break elif uri_path == f'/{service}/ctrl': body = self.soap_response if 'Fault>' in body: header = ['HTTP/1.1 500 Internal Server Error'] break else: raise AssertionError(f'Unknown uri_path: {uri_path}') self.body = body.encode() header.extend([('Content-Length: ' + str(len(self.body))), '', '']) self.header = '\r\n'.join(header).encode('latin-1') async def client_connected(self, reader, writer): """Handle an HTTP GET request and return the response.""" peername = writer.get_extra_info('peername') try: handler = HTTPRequestHandler(reader, writer, peername) await handler.set_rfile() handler.handle_one_request() if not hasattr(handler, 'path'): return uri_path = urllib.parse.unquote(handler.path) self.get_response(uri_path) # Write the response. writer.write(self.header) writer.write(self.body) except asyncio.CancelledError: pass finally: await writer.drain() try: writer.close() await writer.wait_closed() except ConnectionError: pass async def run(self): aio_server = await asyncio.start_server(self.client_connected, HOST, HTTP_PORT) async with aio_server: self.startup.set_result(None) await aio_server.serve_forever() async def start_http_server(soap_response=None, icons='', devices=''): http_server = HTTPServer(soap_response, icons, devices) task = asyncio.create_task(http_server.run()) await http_server.startup return task class ControlPoint(IsolatedAsyncioTestCase): """Control Point test cases.""" @staticmethod @bind_mcast_address() async def _run_until_patch(datagrams, setup=None, patch_method='_put_notification'): await start_http_server() return await loopback_datagrams(datagrams, setup=setup, patch_method=patch_method) async def test_alive(self): with self.assertLogs(level=logging.DEBUG) as m_logs: await self._run_until_patch([SSDP_ALIVE]) self.assertTrue(find_in_logs(m_logs.output, 'upnp', 'New UPnP services: AVTransport, RenderingControl,' ' ConnectionManager')) self.assertTrue(search_in_logs(m_logs.output, 'upnp', re.compile('UPnPRootDevice uuid:fffff.* has been created'))) async def test_update(self): with self.assertLogs(level=logging.DEBUG) as m_logs: await self._run_until_patch([SSDP_UPDATE, SSDP_ALIVE]) self.assertTrue(find_in_logs(m_logs.output, 'upnp', f'Ignore not supported ssdp:update notification from {HOST}')) async def test_bad_nts(self): nts_field = 'ssdp:FOO' nts = f'NTS: {nts_field}' ssdp_bad_nts = SSDP_NOTIFY.format(nts=nts, **SSDP_PARAMS) with self.assertLogs(level=logging.DEBUG) as m_logs: await self._run_until_patch([ssdp_bad_nts, SSDP_ALIVE]) self.assertTrue(search_in_logs(m_logs.output, 'upnp', re.compile(f"Unknown NTS field '{nts_field}'"))) async def test_byebye(self): async def setup(control_point): root_device = mock.MagicMock() root_device.udn = UDN root_device.__str__.side_effect = [device_name] control_point._devices[UDN] = root_device device_name = '_Some root device name_' with self.assertLogs(level=logging.DEBUG) as m_logs: await self._run_until_patch([SSDP_BYEBYE], setup=setup) self.assertTrue(find_in_logs(m_logs.output, 'upnp', f'{device_name} has been deleted')) async def test_faulty_device(self): async def setup(control_point): control_point._faulty_devices.add(udn) udn = 'uuid:ffffffff-ffff-ffff-ffff-000000000000' ssdp_params = { 'url': URL, 'max_age': '1800', 'nts': 'NTS: ssdp:alive', 'udn': udn } ssdp_alive = SSDP_NOTIFY.format(**ssdp_params) with self.assertLogs(level=logging.DEBUG) as m_logs: control_point = await self._run_until_patch( [ssdp_alive, SSDP_ALIVE], setup=setup, patch_method='_create_root_device') control_point._create_root_device.assert_called_once() async def test_remove_device(self): class RootDevice: def __init__(self, udn): self.udn = udn def close(self): pass def __str__(self): return shorten(udn) async def setup(control_point): control_point._devices[udn] = root_device control_point._remove_root_device(root_device, exc=OSError()) udn = 'uuid:ffffffff-ffff-ffff-ffff-000000000000' root_device = RootDevice(udn) ssdp_params = { 'url': URL, 'max_age': '1800', 'nts': 'NTS: ssdp:alive', 'udn': udn } ssdp_alive = SSDP_NOTIFY.format(**ssdp_params) with self.assertLogs(level=logging.DEBUG) as m_logs: control_point = await self._run_until_patch( [ssdp_alive, SSDP_ALIVE], setup=setup, patch_method='_create_root_device') self.assertTrue(control_point.is_disabled(root_device)) self.assertTrue(find_in_logs(m_logs.output, 'upnp', f'Disable the {shorten(udn)} UPnP device permanently')) async def test_bad_max_age(self): max_age = 'FOO' ssdp_params = { 'url': URL, 'max_age': f'{max_age}', 'nts': 'NTS: ssdp:alive', 'udn': UDN } ssdp_alive = SSDP_NOTIFY.format(**ssdp_params) with self.assertLogs(level=logging.DEBUG) as m_logs: await self._run_until_patch([ssdp_alive, SSDP_ALIVE]) self.assertTrue(search_in_logs(m_logs.output, 'upnp', re.compile(f'Invalid CACHE-CONTROL field.*\n.*max-age={max_age}'))) async def test_refresh(self): ssdp_params = { 'url': URL, 'nts': 'NTS: ssdp:alive', 'udn': UDN } ssdp_alive_first = SSDP_NOTIFY.format(max_age=10, **ssdp_params) ssdp_alive_second = SSDP_NOTIFY.format(max_age=20, **ssdp_params) with self.assertLogs(level=logging.DEBUG) as m_logs: await self._run_until_patch([ssdp_alive_first, ssdp_alive_second]) self.assertTrue(search_in_logs(m_logs.output, 'upnp', re.compile('Refresh with max-age=20'))) async def test_close(self): async def is_called(mock): while True: await asyncio.sleep(0) if mock.called: return True control_point = UPnPControlPoint(nics=['lo'], msearch_interval=3600) with mock.patch.object(UPnPControlPoint, 'msearch_once') as msearch,\ self.assertLogs(level=logging.DEBUG) as m_logs: msearch.side_effect = OSError('FOO') control_point.open() await asyncio.wait_for(is_called(msearch), 1) self.assertTrue(search_in_logs(m_logs.output, 'upnp', re.compile(r"OSError\('FOO'\)"))) self.assertTrue(find_in_logs(m_logs.output, 'upnp', 'Close UPnPControlPoint')) @bind_mcast_address() async def test_local_ip_address(self): header = { 'LOCATION': URL } control_point = UPnPControlPoint(nics=['lo'], msearch_interval=3600) with mock.patch.object(control_point, '_put_notification'),\ self.assertLogs(level=logging.DEBUG) as m_logs: await start_http_server() # The SSDP notification. # Using '192.168.0.1' instead of HOST to prevent the root device # local_ipaddress to be found by matching the network of 'lo'. control_point._create_root_device(header, UDN, '192.168.0.1', False, None) root_device = control_point._devices[UDN] self.assertEqual(root_device.local_ipaddress, None) # Wait for the HTTP request to complete, otherwise Python 3.11 # complains with 'Error on transport creation for incoming # connection'. while root_device.urlbase is None: await asyncio.sleep(0) # The SSDP msearch provides the local_ipaddress. control_point._create_root_device(header, UDN, HOST, True, HOST) self.assertEqual(root_device.local_ipaddress, HOST) def test_update_ip_addresses(self): init_nics = { 'eth0': [snicaddr('192.168.0.1', '255.255.255.0')], 'eth1': [snicaddr('192.168.1.1', '255.255.255.0')], } next_nics = { 'eth0': [snicaddr('192.168.0.1', '255.255.255.0')], 'wlan2': [snicaddr('192.168.2.1', '255.255.255.0')], } nics_stat = { 'eth0': snicstats(True), 'eth1': snicstats(True), 'wlan2': snicstats(True), } with mock.patch.object(psutil,'net_if_addrs') as net_if_addrs,\ mock.patch.object(psutil, 'net_if_stats') as net_if_stats: net_if_addrs.side_effect = [init_nics, init_nics, next_nics, next_nics] net_if_stats.side_effect = [nics_stat, nics_stat, nics_stat, nics_stat] control_point = UPnPControlPoint(ip_addresses=['192.168.1.1'], nics=['eth0', 'wlan2']) new_ips, stale_ips = control_point._update_ip_addresses() self.assertEqual(new_ips, {'192.168.2.1'}) self.assertEqual(stale_ips, {'192.168.1.1'}) @bind_mcast_address() async def test_stale_ip_address(self): ip = '192.168.0.1' nics = {'eth0': [snicaddr(ip, '255.255.255.0')]} nics_stat = {'eth0': snicstats(True)} next_nics_stat = {'eth0': snicstats(False)} with mock.patch.object(psutil,'net_if_addrs') as net_if_addrs,\ mock.patch.object(psutil, 'net_if_stats') as net_if_stats: net_if_addrs.side_effect = [nics, nics] net_if_stats.side_effect = [nics_stat, next_nics_stat] control_point = UPnPControlPoint(nics=['eth0']) header = { 'LOCATION': URL } with mock.patch.object(control_point, '_put_notification'),\ self.assertLogs(level=logging.DEBUG) as m_logs: await start_http_server() control_point._create_root_device(header, UDN, ip, True, ip) # Wait for the HTTP request to complete (see # test_local_ip_address). root_device = control_point._devices[UDN] while root_device.urlbase is None: await asyncio.sleep(0) self.assertTrue(UDN in control_point._devices) await control_point.msearch_once(None, 0, do_msearch=False) self.assertTrue(UDN not in control_point._devices) @bind_mcast_address() class RootDevice(IsolatedAsyncioTestCase): """Root device test cases.""" def setUp(self): self.control_point = UPnPControlPoint(nics=['lo'], msearch_interval=3600) self.root_device = UPnPRootDevice(self.control_point, UDN, HOST, HOST, URL, 1800) async def test_OSError(self): exc = OSError('FOO') with self.assertLogs(level=logging.DEBUG) as m_logs,\ mock.patch.object(self.root_device, '_parse_description') as parse: parse.side_effect = exc await start_http_server() await self.root_device._run() self.assertTrue(find_in_logs(m_logs.output, 'upnp', f'UPnPRootDevice._run(): {exc!r}')) self.assertTrue(search_in_logs(m_logs.output, 'upnp', re.compile('Disable the UPnPRootDevice .* device permanently'))) async def test_missing_device(self): with mock.patch('pa_dlna.upnp.upnp.xml_of_subelement') as subelement,\ self.assertLogs(level=logging.INFO) as m_logs: subelement.side_effect = [None] await start_http_server() await self.root_device._run() self.assertTrue(search_in_logs(m_logs.output, 'upnp', re.compile("Missing 'device' subelement in root" ' device description'))) async def test_age_device(self): with self.assertLogs(level=logging.DEBUG) as m_logs: # A max_age value of 0 means no aging. Set this value of 0.1 to # pass the test. max_age = 0.1 self.root_device = UPnPRootDevice(self.control_point, UDN, HOST, HOST, URL, max_age) await start_http_server() await self.root_device._run() self.assertTrue(find_in_logs(m_logs.output, 'upnp', f'Aging expired on UPnPRootDevice {shorten(UDN)}')) self.assertTrue(find_in_logs(m_logs.output, 'upnp', f'UPnPRootDevice {shorten(UDN)} has been created' f' with max-age={max_age}')) async def test_soap_action(self): response = soap_response( f""" """) with mock.patch.object(self.root_device, '_age_root_device') as age,\ self.assertLogs(level=logging.DEBUG) as m_logs: # Make the UPnPRootDevice._run() coroutine terminate. age.side_effect = [None] await start_http_server(response) await self.root_device._run() service = self.root_device.serviceList[CONNECTIONMANAGER] self.assertTrue(isinstance(service, UPnPService)) response = await service.soap_action('GetProtocolInfo', {}, log_debug=True) self.assertEqual(response, {'Source': None, 'Sink': None}) async def test_soap_closed(self): with mock.patch.object(self.root_device, '_age_root_device') as age,\ self.assertRaises(UPnPClosedDeviceError) as cm,\ self.assertLogs(level=logging.DEBUG) as m_logs: # Make the UPnPRootDevice._run() coroutine terminate. age.side_effect = [None] await start_http_server() await self.root_device._run() self.root_device.close() service = self.root_device.serviceList[CONNECTIONMANAGER] response = await service.soap_action('GetProtocolInfo', {}) async def test_soap_fault(self): response = soap_fault() with mock.patch.object(self.root_device, '_age_root_device') as age,\ self.assertRaises(UPnPSoapFaultError) as cm,\ self.assertLogs(level=logging.DEBUG) as m_logs: # Make the UPnPRootDevice._run() coroutine terminate. age.side_effect = [None] await start_http_server(response) await self.root_device._run() service = self.root_device.serviceList[CONNECTIONMANAGER] response = await service.soap_action('GetProtocolInfo', {}, log_debug=True) self.assertEqual(cm.exception.args[0]._asdict(), {'errorCode': '401', 'errorDescription': 'Invalid Action'}) async def test_icons(self): # Testing a valid iconList and an invalid one. for element in ('', '0'): with self.subTest(element=element): # Reset self.root_device on each iteration. self.setUp() icons = f""" {element} image/jpeg 48 48 24 /Icons/48x48.jpg """ with mock.patch.object( self.root_device, '_age_root_device') as age,\ self.assertLogs(level=logging.DEBUG) as m_logs: # Make the UPnPRootDevice._run() coroutine terminate. age.side_effect = [None] task = await start_http_server(icons=icons) await self.root_device._run() task.cancel() self.assertEqual(self.root_device.iconList[0]._asdict(), {'mimetype': 'image/jpeg', 'width': '48', 'height': '48', 'depth': '24' , 'url': '/Icons/48x48.jpg'}) async def test_icons_namespace(self): icons = """ image/jpeg 48 48 24 /Icons/48x48.jpg """ with mock.patch.object(self.root_device, '_age_root_device') as age,\ self.assertLogs(level=logging.INFO) as m_logs: # Make the UPnPRootDevice._run() coroutine terminate. age.side_effect = [None] await start_http_server(icons=icons) await self.root_device._run() self.assertTrue(search_in_logs(m_logs.output, 'upnp', re.compile("UPnPXMLError.*Found " "'{urn:schemas-yamaha-com:device-1-0}icon' instead of" " '{urn:schemas-upnp-org:device-1-0}icon'"))) async def test_icons_missing(self): icons = """ 48 48 24 /Icons/48x48.jpg """ with mock.patch.object(self.root_device, '_age_root_device') as age,\ self.assertLogs(level=logging.DEBUG) as m_logs: # Make the UPnPRootDevice._run() coroutine terminate. age.side_effect = [None] await start_http_server(icons=icons) await self.root_device._run() self.assertTrue(search_in_logs(m_logs.output, 'upnp', re.compile("Missing required subelement of 'icon' in" " device description"))) async def test_devices(self): device_type = 'urn:schemas-upnp-org:device:MediaRenderer:1' device_name = 'Embedded device name' # Root device udn set by device_description() in the device_resps # module. root_device_udn = 'uuid:ffffffff-ffff-ffff-ffff-ffffffffffff' udn = 'uuid:embedded-ffff-ffff-ffff-ffffffffffff' # Testing a valid deviceList and xml namespaces with same prefix # within the same scope. for nested in ('', ''): with self.subTest(nested=nested): # Reset self.root_device on each iteration. self.setUp() devices = f""" {device_type} {udn} {device_name} {nested} """ with mock.patch.object(self.root_device, '_age_root_device') as age,\ self.assertLogs(level=logging.DEBUG) as m_logs: # Make the UPnPRootDevice._run() coroutine terminate. age.side_effect = [None] task = await start_http_server(devices=devices) await self.root_device._run() task.cancel() embedded = self.root_device.deviceList[0] self.assertEqual(embedded.friendlyName, device_name) self.assertEqual(embedded.UDN, udn) self.assertTrue(not hasattr(embedded, 'udn')) self.assertEqual(self.root_device.UDN, root_device_udn) all_devices = list( UPnPDevice.embedded_devices_generator(self.root_device)) self.assertEqual(all_devices, [self.root_device, embedded]) async def test_empty_device_list(self): empty_list = '' newline_list = '\n' for devices in (empty_list, newline_list): with self.subTest(devices=devices): with mock.patch.object( self.root_device, '_age_root_device') as age,\ self.assertLogs(level=logging.DEBUG) as m_logs: # Make the UPnPRootDevice._run() coroutine terminate. age.side_effect = [None] task = await start_http_server(devices=devices) await self.root_device._run() task.cancel() self.assertTrue(isinstance(self.root_device.deviceList, list)) self.assertTrue(len(self.root_device.deviceList) == 0) def tearDown(self): self.control_point.close() if __name__ == '__main__': unittest.main(verbosity=2) ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1729706227.735661 pa_dlna-0.16/pa_dlna/upnp/tests/test_util.py0000644000000000000000000000712214706234364016107 0ustar00"""Util test cases.""" import asyncio import logging import re from unittest import TestCase from http import HTTPStatus # Load the tests in the order they are declared. from . import load_ordered_tests as load_tests from . import search_in_logs from ..network import http_get, UPnPInvalidHttpError from ..util import (shorten, log_unhandled_exception, AsyncioTasks, HTTPRequestHandler) logger = logging.getLogger('test') HOST = '127.0.0.1' PORT = 9999 URL = f'http://{HOST}:{PORT}/' class HTTPServer: """HTTP server responding with status code: 404 (Not Found).""" def __init__(self, message, exception=None): self.message = message self.exception = exception loop = asyncio.get_running_loop() self.startup = loop.create_future() async def client_connected(self, reader, writer): peername = writer.get_extra_info('peername') try: handler = HTTPRequestHandler(reader, writer, peername) await handler.set_rfile() handler.handle_one_request() handler.send_error(HTTPStatus.NOT_FOUND, self.message) finally: await writer.drain() try: writer.close() await writer.wait_closed() except ConnectionError: pass @log_unhandled_exception(logger) async def run(self): try: aio_server = await asyncio.start_server(self.client_connected, HOST, PORT) async with aio_server: self.startup.set_result(None) await aio_server.serve_forever() finally: if self.exception: raise self.exception class Util(TestCase): """Util test cases.""" @staticmethod async def _loopback_get(message, exception=None): http_server = HTTPServer(message, exception) asyncio.create_task(http_server.run()) await http_server.startup await asyncio.wait_for(http_get(URL), 1) def test_shorten(self): tests = [ ('123456789abcdef', '123...def'), ('123456789', '123456789'), ('123', '123'), ] for text, expected in tests: with self.subTest(text=text, expected=expected): self.assertEqual(shorten(text, head_len=3, tail_len=3), expected) def test_log_unhandled_exception(self): with self.assertRaises(UPnPInvalidHttpError),\ self.assertLogs(level=logging.ERROR) as m_logs: asyncio.run(self._loopback_get('foo', OSError())) self.assertTrue(search_in_logs(m_logs.output, 'test', re.compile(r'Exception .* HTTPServer.run\(\):\n *OSError'))) def test_asyncio_tasks(self): async def coro(): http_server = HTTPServer('foo') tasks.create_task(http_server.run(), name=task_name) self.assertEqual(list(t.get_name() for t in tasks), [task_name]) task_name = 'http server' tasks = AsyncioTasks() asyncio.run(coro()) self.assertEqual(list(t.get_name() for t in tasks), []) def test_http_logs(self): message = 'Le temps des cerises' with self.assertRaises(UPnPInvalidHttpError),\ self.assertLogs(level=logging.ERROR) as m_logs: asyncio.run(self._loopback_get(message)) self.assertTrue(search_in_logs(m_logs.output, 'util', re.compile(message))) if __name__ == '__main__': unittest.main(verbosity=2) ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1674982585.682591 pa_dlna-0.16/pa_dlna/upnp/tests/test_xml.py0000644000000000000000000001172114365432272015731 0ustar00"""XML test cases.""" import re import logging from unittest import TestCase # Load the tests in the order they are declared. from . import load_ordered_tests as load_tests from . import search_in_logs from .device_resps import (device_description, scpd, soap_response, soap_fault) from ..xml import (xml_of_subelement, dict_to_xml, pformat_xml, upnp_org_etree, scpd_actionlist, scpd_servicestatetable, parse_soap_response, UPnPXMLError, parse_soap_fault, UPnPNamespace) class XML(TestCase): """XML test cases.""" def test_subelement(self): friendly_name = 'Some friendly name' description = device_description(friendly_name=friendly_name) device = xml_of_subelement(description, 'device') self.assertIn(friendly_name, xml_of_subelement(device, 'friendlyName')) def test_missing_subelement(self): description = device_description() device = xml_of_subelement(description, 'device') self.assertEqual(xml_of_subelement(device, 'FOO'), None) def test_dict_to_xml(self): expect = '<testing escape characters>' elements = dict_to_xml({ 'foo': '', 'bar': 123, }) text = [ '', elements, '' ] output = pformat_xml('\n'.join(text)) self.assertIn(expect, output) def test_action_list(self): expect = {'GetProtocolInfo': {'Source': {'direction': 'out', 'relatedStateVariable': 'SourceProtocolInfo'}, 'Sink': {'direction': 'out', 'relatedStateVariable': 'SinkProtocolInfo'}} } etree, namespace = upnp_org_etree(scpd()) actions = scpd_actionlist(etree, namespace) self.assertEqual(actions, expect) def test_service_state(self): state_variable = """ FOO string """ expect = {'SourceProtocolInfo': {'sendEvents': 'yes', 'dataType': 'string'}, 'SinkProtocolInfo': {'sendEvents': 'yes', 'dataType': 'string'}, 'CurrentPlayMode': {'sendEvents': 'no', 'dataType': 'string', 'allowedValueList': ['NORMAL'], 'defaultValue': 'NORMAL'}, 'NumberOfTracks': {'sendEvents': 'no', 'dataType': 'ui4', 'allowedValueRange': {'minimum': '0', 'maximum': '1'}}, 'FOO': {'sendEvents': 'yes', 'dataType': 'string'}, } with self.assertLogs(level=logging.WARNING) as m_logs: etree, namespace = upnp_org_etree( scpd(state_variable=state_variable)) service_states = scpd_servicestatetable(etree, namespace) self.assertTrue(search_in_logs(m_logs.output, 'xml', re.compile(" 'FOO'.*attribute.*not supported"))) self.assertEqual(service_states, expect) def test_soap_response(self): expect = {'CurrentTransportState': 'PLAYING'} action = 'GetTransportInfo' response = f""" PLAYING """ xml_string = soap_response(response) result = parse_soap_response(xml_string, action) self.assertEqual(result, expect) def test_empty_response(self): action = 'GetTransportInfo' response = f""" """ with self.assertRaises(UPnPXMLError) as cm: xml_string = soap_response(response) parse_soap_response(xml_string, action) self.assertIn(f"No '{action}Response' element", cm.exception.args[0]) def test_soap_fault(self): expect = {'errorCode': '401', 'errorDescription': 'Invalid Action'} xml_string = soap_fault() fault = parse_soap_fault(xml_string) self.assertEqual(fault._asdict(), expect) def test_UPnPNamespace(self): with self.assertRaises(UPnPXMLError): UPnPNamespace(soap_fault(), 'urn:schemas-FOO-org:') if __name__ == '__main__': unittest.main(verbosity=2) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1735995777.1101804 pa_dlna-0.16/pa_dlna/upnp/upnp.py0000644000000000000000000007560014736230601013712 0ustar00"""A basic UPnP Control Point asyncio library. The library does not have any external dependency. Here is an example of using the Control Point. Allow it a few seconds to discover an UPnP device on the 'enp0s31f6' ethernet interface. >>> import asyncio >>> import upnp >>> >>> async def main(nics): ... with upnp.UPnPControlPoint(nics=nics) as control_point: ... notification, root_device = await control_point.get_notification() ... print(f" Got '{notification}' from {root_device.peer_ipaddress}") ... print(f' deviceType: {root_device.deviceType}') ... print(f' friendlyName: {root_device.friendlyName}') ... for service in root_device.serviceList.values(): ... print(f' serviceId: {service.serviceId}') ... >>> try: ... asyncio.run(main(['enp0s31f6'])) ... except KeyboardInterrupt: ... pass ... Got 'alive' from 192.168.0.212 deviceType: urn:schemas-upnp-org:device:MediaRenderer:1 friendlyName: Yamaha RN402D serviceId: urn:upnp-org:serviceId:AVTransport serviceId: urn:upnp-org:serviceId:RenderingControl serviceId: urn:upnp-org:serviceId:ConnectionManager >>> The API is made of the methods and attibutes of the UPnPControlPoint, UPnPRootDevice, UPnPDevice and UPnPService classes. See "UPnP Device Architecture 2.0". Not implemented: - The extended data types in the service xml description - see "2.5.1 Defining and processing extended data types" in UPnP 2.0. - SOAP
elements are ignored - see "3.1.1 SOAP Profile" in UPnP 2.0. - Unicast and multicast eventing are not implemented. """ import asyncio import sys import logging import time import collections import urllib.parse from ipaddress import IPv4Interface, IPv4Address from signal import SIGINT, SIGTERM, strsignal from . import UPnPError, TEST_LOGLEVEL from .util import NL_INDENT, shorten, log_unhandled_exception, AsyncioTasks from .network import (ipaddr_from_nics, parse_ssdp, msearch, send_mcast, Notify, http_get, http_soap) from .xml import (upnp_org_etree, build_etree, xml_of_subelement, findall_childless, scpd_actionlist, scpd_servicestatetable, dict_to_xml, parse_soap_response, parse_soap_fault, UPnPXMLError) logger = logging.getLogger('upnp') QUEUE_CLOSED = ('closed', None) ICON_ELEMENTS = ('mimetype', 'width', 'height', 'depth', 'url') SERVICEID_PREFIX = 'urn:upnp-org:serviceId:' class UPnPClosedControlPointError(UPnPError): pass class UPnPClosedDeviceError(UPnPError): pass class UPnPInvalidSoapError(UPnPError): pass class UPnPSoapFaultError(UPnPError): pass # Components of an UPnP root device. Icon = collections.namedtuple('Icon', ICON_ELEMENTS) class UPnPElement: """An UPnP device or service.""" def __init__(self, parent_device, root_device): self.parent_device = parent_device self.root_device = root_device @property def closed(self): return self.root_device._closed class UPnPService(UPnPElement): """An UPnP service. Attributes: parent_device the UPnPDevice instance providing this service root_device the UPnPRootDevice instance serviceType UPnP service type serviceId Service identifier description the device xml descrition as a string actionList dict {action name: arguments} where arguments is a dict indexed by the argument name with a value that is another dict whose keys are in. ('direction', 'relatedStateVariable') serviceStateTable dict {variable name: params} where params is a dict with keys in ('sendEvents', 'multicast', 'dataType', 'defaultValue', 'allowedValueList', 'allowedValueRange'). The value of 'allowedValueList' is a list. The value of 'allowedValueRange' is a dict with keys in ('minimum', 'maximum', 'step'). Properties: closed True if the root device is closed Methods: soap_action coroutine - send a SOAP action """ def __init__(self, parent_device, root_device, attributes): super().__init__(parent_device, root_device) # Set the attributes found in the 'service' element of the device # description. for k, v in attributes.items(): setattr(self, k, v) urlbase = root_device.urlbase self.SCPDURL = urllib.parse.urljoin(urlbase, self.SCPDURL) self.controlURL = urllib.parse.urljoin(urlbase, self.controlURL) if self.eventSubURL is not None: self.eventSubURL = urllib.parse.urljoin(urlbase, self.eventSubURL) self.actionList = {} self.serviceStateTable = {} self.description = None async def soap_action(self, action, args, log_debug=True): """Send a SOAP action. 'action' action name 'args' dict {argument name: value} the dict keys MUST be in the same order as specified in the service description (SCPD) that is available from the device (UPnP 2.0), raises UPnPInvalidSoapError otherwise Return the dict {argumentName: out arg value} if successfull, otherwise raise an UPnPSoapFaultError exception with the instance of the upnp.xml.SoapFault namedtuple defined by field names in ('errorCode', 'errorDescription'). """ if self.closed: raise UPnPClosedDeviceError( f"Error while requesting '{action}' SOAP action:" f'{NL_INDENT}{self.root_device} is closed') # Validate action and args. if action not in self.actionList: raise UPnPInvalidSoapError(f"action '{action}' not in actionList" f" of '{self.serviceId}'") arguments = self.actionList[action] if list(args) != list(name for name in arguments if arguments[name]['direction'] == 'in'): raise UPnPInvalidSoapError(f'argument mismatch in action' f" '{action}' of '{self.serviceId}'") # Build header and body. body = ( f'\n' f'\n' f' \n' f' \n' f' {dict_to_xml(args)}\n' f' \n' f' \n' f'' ) body = ''.join(line.strip() for line in body.splitlines()) header = ( f'Content-length: {len(body.encode())}\r\n' f'Content-type: text/xml; charset="utf-8"\r\n' f'Soapaction: "{self.serviceType}#{action}"\r\n' ) # Send the soap action. is_fault, body = await http_soap(self.controlURL, header, body) # The specification of the serviceType format is # 'urn:schemas-upnp-org:device:serviceType:ver'. serviceType = self.serviceType.split(':')[-2] # Handle the response. body = body.decode() if is_fault: fault = parse_soap_fault(body) logger.warning(f"soap_action('{action}', '{serviceType}') =" f' {fault}') raise UPnPSoapFaultError(fault) response = parse_soap_response(body, action) if log_debug: logger.debug(f'soap_action({action}, {serviceType}, {args}) =' f' {response}') return response async def _run(self): description = await http_get(self.SCPDURL) self.description = description.decode() # Parse the actionList. scpd, namespace = upnp_org_etree(self.description) self.actionList = scpd_actionlist(scpd, namespace) # Parse the serviceStateTable. self.serviceStateTable = scpd_servicestatetable(scpd, namespace) # Start the eventing task. # Not implemented. return self def __str__(self): return (self.serviceId[len(SERVICEID_PREFIX):] if self.serviceId.startswith(SERVICEID_PREFIX) else self.serviceId) class UPnPDevice(UPnPElement): """An UPnP device. Attributes: parent_device the parent UPnPDevice instance or the root device root_device the UPnPRootDevice instance description the device xml description as a string urlbase the url used to retrieve the description of the root device or the 'URLBase' element (deprecated from UPnP 1.1 onwards) All the subelements of the 'device' element in the xml description are attributes of the UPnPDevice instance: 'deviceType', 'friendlyName', 'manufacturer', 'UDN', etc... (see the specification). Note that a RootDevice has both an 'UDN' attribute and an 'udn' one that hold the same value. Their value is the value (text) of the element except for: serviceList dict {serviceId value: UPnPService instance} deviceList list of embedded devices iconList list of instances of the Icon namedtuple; use 'urlbase' and the (relative) 'url' attribute of the namedtuple to retrieve the icon. Properties: closed True if the root device is closed """ def __init__(self, parent_device, root_device): super().__init__(parent_device, root_device) self.description = None self.urlbase = None if root_device is not None: self.urlbase = root_device.urlbase self.serviceList = {} self.deviceList = [] self.iconList = [] def _create_icons(self, icons, namespace): if icons is None: return for element in icons: if element.tag != f'{namespace!r}icon': raise UPnPXMLError(f"Found '{element.tag}' instead" f" of '{namespace!r}icon'") d = dict((k, val) for (k, val) in findall_childless(element, namespace).items() if k in ICON_ELEMENTS) if all(d.get(tag) for tag in ICON_ELEMENTS): self.iconList.append(Icon(**d)) else: logger.warning("Missing required subelement of 'icon' in" ' device description') async def _create_services(self, services, namespace): """Create each UPnPService instance with its attributes. And await until its xml description has been parsed and the soap task started. 'services' is an etree element. """ if services is None: return service_ids = [] for element in services: if element.tag != f'{namespace!r}service': raise UPnPXMLError(f"Found '{element.tag}' instead" f" of '{namespace!r}service'") d = findall_childless(element, namespace) if not d: raise UPnPXMLError("Empty 'service' element") if 'serviceId' not in d: raise UPnPXMLError("Missing 'serviceId' element") serviceId = d['serviceId'] self.serviceList[serviceId] = await ( UPnPService(self, self.root_device, d)._run()) # The specification of the serviceId format is # 'urn:upnp-org:serviceId:service'. service = serviceId.split(':')[-1] service_ids.append(service) return service_ids async def _create_devices(self, devices, namespace): """Instantiate the embedded UPnPDevice(s).""" if devices is None: return for element in devices: if element.tag != f'{namespace!r}device': raise UPnPXMLError(f"Found '{element.tag}' instead" f" of '{namespace!r}device'") d = findall_childless(element, namespace) if not d: raise UPnPXMLError("Empty 'device' element") description = build_etree(element) embedded = await (UPnPDevice(self, self.root_device)._parse_description(description)) self.deviceList.append(embedded) @staticmethod def embedded_devices_generator(device): """Recursive generator yielding the embedded devices.""" yield device for dev in device.deviceList: yield from UPnPDevice.embedded_devices_generator(dev) async def _parse_description(self, description): """Parse the xml 'description'. Recursively instantiate the tree of embedded devices and their services. When this method returns, each UPnPService instance has parsed its description and started a task to handle soap requests. """ self.description = description device_etree, namespace = upnp_org_etree(description) # Add the childless elements of the device element as instance # attributes of the UPnPDevice instance. for k, v in findall_childless(device_etree, namespace).items(): if k not in ('serviceList', 'deviceList', 'iconList'): setattr(self, k, v) if not hasattr(self, 'deviceType'): raise UPnPXMLError("Missing 'deviceType' element") # The specification of the deviceType format is # 'urn:schemas-upnp-org:device:deviceType:ver'. self.device_type = self.deviceType.split(':')[-2] if not isinstance(self, UPnPRootDevice): logger.info(f'New {self.device_type} embedded device') icons = device_etree.find(f'{namespace!r}iconList') self._create_icons(icons, namespace) services = device_etree.find(f'{namespace!r}serviceList') services = await self._create_services(services, namespace) if services: logger.info(f"New UPnP services: {', '.join(services)}") # Recursion here: _create_devices() calls _parse_description() devices = device_etree.find(f'{namespace!r}deviceList') await self._create_devices(devices, namespace) return self def __str__(self): return f'{shorten(self.UDN)}' class UPnPRootDevice(UPnPDevice): """An UPnP root device. An UPnP root device is also an UPnPDevice, see the UPnPDevice __doc__ for the other attributes and methods available. Attributes: udn Unique Device Name peer_ipaddress IP address of the UPnP device local_ipaddress IP address of the local network interface receiving msearch response datagrams location 'Location' field value in the header of the notify or msearch SSDP Methods: close Close the root device """ def __init__(self, control_point, udn, peer_ipaddress, local_ipaddress, location, max_age): super().__init__(self, self) self._control_point = control_point # UPnPControlPoint instance self.udn = udn self.peer_ipaddress = peer_ipaddress self.local_ipaddress = local_ipaddress self.location = location self._curtask = None # UPnPRootDevice._run() task self._set_valid_until(max_age) self._max_age = max_age self._closed = False def close(self, exc=None): if not self._closed: self._closed = True logger.info(f'Close {self}') if (self._curtask is not None and asyncio.current_task() != self._curtask): self._curtask.cancel() self._control_point._remove_root_device(self, exc=exc) def _set_valid_until(self, max_age): # The '_valid_until' attribute is the monotonic date when the root # device and its services and embedded devices become disabled. # '_valid_until' None means no aging is performed. if max_age: self._valid_until = time.monotonic() + max_age else: self._valid_until = None def _get_timeleft(self): if self._valid_until is not None: return self._valid_until - time.monotonic() return None async def _age_root_device(self): # Age the root device using SSDP alive notifications. while True: timeleft = self._get_timeleft() # Missing or invalid 'CACHE-CONTROL' field in SSDP. # Wait for a change in _valid_until. if not timeleft: await asyncio.sleep(60) elif timeleft > 0: await asyncio.sleep(timeleft) else: logger.warning(f'Aging expired on {self}') self.close() break @log_unhandled_exception(logger) async def _run(self): self._curtask = asyncio.current_task() try: description = await http_get(self.location) description = description.decode() if not description: raise UPnPError(f"The 'description' of {self} is empty") # Find the 'URLBase' subelement (UPnP version 1.1). root, namespace = upnp_org_etree(description) element = root.find(f'{namespace!r}URLBase') self.urlbase = (element.text if element is not None else self.location) device_description = xml_of_subelement(description, 'device') if device_description is None: raise UPnPXMLError("Missing 'device' subelement in root" ' device description') await self._parse_description(device_description) logger.info( f'New {self.device_type} root device at {self.peer_ipaddress}' f' with UDN:' + NL_INDENT + f'{self.udn}') self._control_point._put_notification('alive', self) logger.debug(f'{self} has been created' f' with max-age={self._max_age}') await self._age_root_device() except asyncio.CancelledError: self.close() except OSError as e: logger.error(f'UPnPRootDevice._run(): {e!r}') self.close(exc=e) except Exception as e: self.close(exc=e) raise def __str__(self): """Return a short representation of udn.""" return f'UPnPRootDevice {shorten(self.udn)}' # UPnP control point. class UPnPControlPoint: """An UPnP control point. Attributes: ip_configured List of the IPv4 addresses of the networks where UPnP devices may be discovered. nics List of the network interfaces where UPnP devices may be discovered. ip_monitored List of the IPv4 addresses currently monitored by UPnP discovery. msearch_interval The time interval in seconds between the sending of the MSEARCH datagrams used for device discovery. msearch_port The local UDP port for receiving MSEARCH response messages from UPnP devices, a value of '0' means letting the operating system choose an ephemeral port. ttl The IP packets time to live. Methods: open Coroutine - start the UPnP Control Point. close Close the UPnP Control Point. disable_root_device Disable permanently a root device. is_disabled Return True if the root device is disabled. get_notification Coroutine - return a notification and the corresponding UPnPRootDevice instance. __enter__ UPnPControlPoint supports the context manager protocol. __close__ """ def __init__(self, ip_addresses=[], nics=[], msearch_interval=60, msearch_port=0, ttl=2): self.ip_configured = ip_addresses self.nics = nics self.msearch_interval = msearch_interval self.msearch_port = msearch_port self.ttl = ttl # Get the list of active IPv4 addresses used for UPnP discovery. self.ip_monitored = set() self._update_ip_addresses() self._closed = False self._notify = None self._upnp_queue = asyncio.Queue() self._devices = {} # {udn: UPnPRootDevice} self._faulty_devices = set() # set of the udn of root devices # permanently disabled self._last_msearch = time.monotonic() self._msearch_task = None self._notify_task = None self._upnp_tasks = AsyncioTasks() def open(self): """Start the UPnP Control Point.""" # Start the msearch task. self._msearch_task = self._upnp_tasks.create_task( self._ssdp_msearch(), name='ssdp msearch') # Start the notify task. self._notify = Notify(self._process_ssdp, self.ip_monitored) self._notify_task = self._upnp_tasks.create_task(self._ssdp_notify(), name='ssdp notify') def close(self): """Close the UPnP Control Point.""" if not self._closed: self._closed = True self._put_notification(*QUEUE_CLOSED) if self._msearch_task is not None: self._msearch_task.cancel() if self._notify_task is not None: self._notify_task.cancel() self._notify.close() for root_device in list(self._devices.values()): root_device.close() logger.info('Close UPnPControlPoint') def disable_root_device(self, root_device, name=None): """Disable permanently a root device.""" udn = root_device.udn if udn in self._faulty_devices: return self._faulty_devices.add(udn) root_device.close() devname = f'{root_device} UPnP' if name is None else f'{name} DLNA' logger.warning(f'Disable the {devname} device permanently') def is_disabled(self, root_device): return root_device.udn in self._faulty_devices async def get_notification(self): """Return the tuple ('alive' or 'byebye', UPnPRootDevice instance). A coroutine waiting on the queue gets the QUEUE_CLOSED tuple when the control point is closing. When the control point is closed, the method raises UPnPClosedControlPointError. """ if self._closed: raise UPnPClosedControlPointError else: return await self._upnp_queue.get() def _put_notification(self, kind, root_device): self._upnp_queue.put_nowait((kind, root_device)) def _skip_log_max_age(self, max_age, root_device, is_msearch): # Avoid cluttering the logs when the aging refresh occurs within 5 # seconds of the last one. if not max_age: if is_msearch: previous = self._last_msearch self._last_msearch = time.monotonic() if not is_msearch or self._last_msearch - previous > 5: return False else: timeleft = root_device._get_timeleft() if timeleft is not None and max_age - timeleft > 5: return False return True def _create_root_device(self, header, udn, peer_ipaddress, is_msearch, local_ipaddress): # Get the max-age. # No aging if 'max_age' in (None, 0). max_age = None cache = header.get('CACHE-CONTROL') if cache is not None: age = 'max-age=' try: max_age = int(cache[cache.index(age)+len(age):]) except ValueError: logger.warning( f'Invalid CACHE-CONTROL field in' f' SSDP notify from {peer_ipaddress}:\n{header}') return if udn not in self._devices: # Instantiate the UPnPDevice and start its task. if local_ipaddress is None: ip_addr = IPv4Address(peer_ipaddress) for obj in ipaddr_from_nics(self.nics, as_string=False): if (isinstance(obj, IPv4Interface) and ip_addr in obj.network): local_ipaddress = str(obj.ip) break else: logger.info(f'{peer_ipaddress} does not belong to one' f' of the known network interfaces') root_device = UPnPRootDevice(self, udn, peer_ipaddress, local_ipaddress, header['LOCATION'], max_age) self._upnp_tasks.create_task(root_device._run(), name=str(root_device)) self._devices[udn] = root_device else: root_device = self._devices[udn] # The root device had been created from reception of a notify # SSDP, this handles the reception of an msearch SSDP so update # 'local_ipaddress' if not already done. if (not root_device.closed and local_ipaddress is not None and root_device.local_ipaddress is None): root_device.local_ipaddress = local_ipaddress self._put_notification('alive', root_device) if not self._skip_log_max_age(max_age, root_device, is_msearch): msg = ('msearch response' if is_msearch else 'notify advertisement') logger.debug(f'Got {msg} from {peer_ipaddress}') logger.debug(f'Refresh with max-age={max_age}' f' for {root_device}') # Refresh the aging time. root_device._set_valid_until(max_age) def _remove_root_device(self, root_device, exc=None): udn = root_device.udn if udn in self._devices: del self._devices[udn] root_device.close() logger.debug(f'{root_device} has been deleted') self._put_notification('byebye', root_device) if exc is not None: self.disable_root_device(root_device) def _update_ip_addresses(self): """Set the new IPv4 addresses set.""" current_ips = set() if self.ip_configured: all_ips = list(ipaddr_from_nics(nics=[])) for ip in self.ip_configured: if ip in all_ips: current_ips.add(ip) if self.nics or not self.ip_configured: for ip in ipaddr_from_nics(nics=self.nics, skip_loopback=True): current_ips.add(ip) new_ips = current_ips.difference(self.ip_monitored) if new_ips: logger.info(f'Start UPnP discovery on new IPs {new_ips}') stale_ips = self.ip_monitored.difference(current_ips) if stale_ips: logger.info(f'Stop UPnP discovery on stale IPs {stale_ips}') self.ip_monitored = current_ips return new_ips, stale_ips def _process_ssdp(self, datagram, peer_ipaddress, local_ipaddress): """Process the received datagrams.""" if (local_ipaddress is not None and local_ipaddress not in self.ip_monitored): logger.warning( f'Ignore msearch SSDP received on {local_ipaddress}') return logger.log(TEST_LOGLEVEL, datagram.decode()) # 'is_msearch' is True when processing a msearch response, # otherwise it is a notify advertisement. is_msearch = True if local_ipaddress is not None else False header = parse_ssdp(datagram, peer_ipaddress, is_msearch) if header is None: return if is_msearch or (header['NTS'] == 'ssdp:alive'): udn = header['USN'].split('::')[0] if udn not in self._faulty_devices: self._create_root_device(header, udn, peer_ipaddress, is_msearch, local_ipaddress) else: nts = header['NTS'] if nts == 'ssdp:byebye': udn = header['USN'].split('::')[0] root_device = self._devices.get(udn) if root_device is not None: self._remove_root_device(root_device) elif nts == 'ssdp:update': logger.warning(f'Ignore not supported {nts} notification' f' from {peer_ipaddress}') else: logger.warning(f"Unknown NTS field '{nts}' in SSDP notify" ' from {peer_ipaddress}') async def msearch_once(self, coro, port, do_msearch=True): new_ips, stale_ips = self._update_ip_addresses() for ip in stale_ips: for root_device in self._devices.values(): if ip == root_device.local_ipaddress: root_device.close() break # Update the notify task with the new and stale sets. if self._notify is not None: self._notify.manage_membership(new_ips, stale_ips) if not do_msearch and not new_ips: return for ip_addr in self.ip_monitored: # 'coro' is a coroutine *function*. result = await send_mcast(ip_addr, port, ttl=self.ttl, coro=coro) if result: for (data, peer_addr, local_addr) in result: self._process_ssdp(data, peer_addr, local_addr) else: logger.debug(f'No response on {ip_addr} to all' f' M-SEARCH messages, next try in' f' {self.msearch_interval} seconds') @log_unhandled_exception(logger) async def _ssdp_msearch(self, coro=msearch): """Send msearch multicast SSDPs and process unicast responses.""" try: last_time = -1 while True: do_msearch = False cur_time = time.monotonic() if (last_time == -1 or cur_time - last_time >= self.msearch_interval): do_msearch = True last_time = cur_time await self.msearch_once(coro, self.msearch_port, do_msearch=do_msearch) await asyncio.sleep(1) except asyncio.CancelledError: pass finally: self.close() @log_unhandled_exception(logger) async def _ssdp_notify(self): """Listen to SSDP notifications.""" try: await self._notify.run() except asyncio.CancelledError: pass finally: self.close() def __enter__(self): self.open() return self def __exit__(self, exc_type, exc_value, traceback): self.close() ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1729706227.7389944 pa_dlna-0.16/pa_dlna/upnp/util.py0000644000000000000000000000677214706234364013720 0ustar00"""Utilities.""" import io import asyncio import functools import logging import pprint import http.server logger = logging.getLogger('util') NL_INDENT = '\n ' def shorten(txt, head_len=10, tail_len=5): if len(txt) <= head_len + 3 + tail_len: return txt return txt[:head_len] + '...' + txt[len(txt)-tail_len:] def log_exception(logger, expt_msg): log_backtrace = any(handler.level == logging.DEBUG for handler in logging.getLogger().handlers) if log_backtrace: logger.exception(expt_msg) else: logger.error(f"{expt_msg}{NL_INDENT}" f"Run this program at the 'debug' log level to print the" " exception backtrace") def log_unhandled_exception(logger): """A decorator logging exceptions occuring in a coroutine. Its purpose is to ensure that a task may not trigger unhandled exceptions in code that is running in the last except clause or in code running in the last finally clause. Otherwise the exception shows up only when the event loop terminates making the problem difficult to resolve. """ def decorator(coro): @functools.wraps(coro) async def wrapper(*args, **kwargs): try: return await coro(*args, **kwargs) except Exception as e: task_name = asyncio.current_task().get_name() expt_msg = (f"Exception in task '{task_name}' - function" f' {coro.__qualname__}():{NL_INDENT}{e!r}') log_exception(logger, expt_msg) return e return wrapper return decorator class AsyncioTasks: """Save references to tasks, to avoid tasks being garbage collected. See Python github PR 29163 and the corresponding Python issues. """ def __init__(self): self._tasks = set() def create_task(self, coro, name): task = asyncio.create_task(coro, name=name) self._tasks.add(task) task.add_done_callback(lambda t: self._tasks.remove(t)) return task def __iter__(self): for t in self._tasks: yield t class HTTPRequestHandler(http.server.BaseHTTPRequestHandler): def __init__(self, reader, writer, peername): self._reader = reader self.wfile = writer self.client_address = peername # BaseHTTPRequestHandler invokes self.wfile.flush(). def flush(): pass setattr(writer, 'flush', flush) async def set_rfile(self): # Read the full HTTP request from the asyncio StreamReader into a # BytesIO. request = [] while True: line = await self._reader.readline() request.append(line) if line in (b'\r\n', b'\n', b''): break self.rfile = io.BytesIO(b''.join(request)) def log_message(self, format, *args): # Overriding log_message() that logs the errors. logger.error("%s - %s" % (self.client_address[0], format % args)) def log_request(self, method): logger.info(f'{self.request_version} {method} request from ' f'{self.client_address[0]}') logger.debug(f"uri path: '{self.path}'") logger.debug(f'Request headers:\n' f"{pprint.pformat(dict(self.headers.items()))}") def do_GET(self): self.log_request('GET') def do_POST(self): self.log_request('POST') def do_HEAD(self): self.log_request('HEAD') ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1735985262.640204 pa_dlna-0.16/pa_dlna/upnp/xml.py0000644000000000000000000001664014736204157013536 0ustar00"""XML utilities.""" import sys import io import functools import logging import collections import xml.etree.ElementTree as ET from . import UPnPError if sys.version_info >= (3, 9): functools_cache = functools.cache ET_indent = ET.indent else: functools_cache = functools.lru_cache ET_indent = (lambda x: None) logger = logging.getLogger('xml') UPNP_NAMESPACE_BEG = 'urn:schemas-upnp-org:' ENVELOPE_NAMESPACE_BEG = "http://schemas.xmlsoap.org/soap/envelope" RESP_NAMESPACE_BEG = "urn:schemas-upnp-org:service:" CTRL_NAMESPACE_BEG = "urn:schemas-upnp-org:control" class UPnPXMLError(UPnPError): pass # XML helper functions. @functools_cache def namespace_as_dict(xml): return dict(reversed(elem) for (event, elem) in ET.iterparse( io.StringIO(xml), events=['start-ns'])) def upnp_org_etree(xml): """Return the element tree and UPnP namespace from an xml string.""" upnp_namespace = UPnPNamespace(xml, UPNP_NAMESPACE_BEG) return ET.fromstring(xml), upnp_namespace def build_etree(element): """Build an element tree to a bytes sequence and return it as a string.""" etree = ET.ElementTree(element) with io.BytesIO() as output: etree.write(output, encoding='utf-8', xml_declaration=True) return output.getvalue().decode() def xml_of_subelement(xml, tag): """Return the first 'tag' subelement as an xml string.""" # Find the 'tag' subelement. root, namespace = upnp_org_etree(xml) element = root.find(f'{namespace!r}{tag}') if element is None: return None return build_etree(element) def findall_childless(etree, namespace): """Return the dictionary {tag: text} of all chidless subelements.""" d = {} ns_len = len(f'{namespace!r}') for e in etree.findall(f'.{namespace!r}*'): if e.tag and len(list(e)) == 0: tag = e.tag[ns_len:] d[tag] = e.text return d def scpd_actionlist(scpd, namespace): """Parse the scpd element for 'actionList'.""" result = {} actionList = scpd.find(f'{namespace!r}actionList') if actionList is not None: for action in actionList: action_name = args = None for e in action: if e.tag == f'{namespace!r}name': action_name = e.text elif e.tag == f'{namespace!r}argumentList': args = {} for argument in e: d = findall_childless(argument, namespace) name = d['name'] del d['name'] args[name] = d # Silently ignore malformed actions. if action_name is not None and args is not None: result[action_name] = args return result def scpd_servicestatetable(scpd, namespace): """Parse the scpd element for 'serviceStateTable'.""" result = {} table = scpd.find(f'{namespace!r}serviceStateTable') if table is not None: for variable in table: varname = None params = {} has_type_attr = False for attr in ('sendEvents', 'multicast'): val = variable.attrib.get(attr) if val is not None: params[attr] = val for e in variable: if e.tag == f'{namespace!r}name': varname = e.text elif e.tag == f'{namespace!r}dataType': if 'type' in e.attrib: has_type_attr = True params['dataType'] = e.text elif e.tag == f'{namespace!r}defaultValue': params['defaultValue'] = e.text elif e.tag == f'{namespace!r}allowedValueList': allowed_list = [] for allowed in e: allowed_list.append(allowed.text) params['allowedValueList'] = allowed_list elif e.tag == f'{namespace!r}allowedValueRange': ns_len = len(f'{namespace!r}') val_range = {} for limit in e: if limit.tag in (f'{namespace!r}{x}' for x in ('minimum', 'maximum', 'step')): tag = limit.tag[ns_len:] val_range[tag] = limit.text params['allowedValueRange'] = val_range if varname is not None: if has_type_attr: logger.warning(f" '{varname}': 'type'" ' attribute of not supported') result[varname] = params return result def xml_escape(txt): txt = txt.replace('&', '&') txt = txt.replace('<', '<') txt = txt.replace('>', '>') txt = txt.replace("\"", '"') txt = txt.replace('\'', ''') return txt def dict_to_xml(arguments): """Build an xml string from a dict.""" xml = [] for tag, element in arguments.items(): # Escape control chars in xml elements. if isinstance(element, str): element = xml_escape(element) xml.append(f'<{tag}>{element}') return '\n'.join(xml) def parse_soap_response(xml, action): # Find the 'Body' subelement. env_namespace = UPnPNamespace(xml, ENVELOPE_NAMESPACE_BEG) root = ET.fromstring(xml) body = root.find(f'{env_namespace!r}Body') if body is None: raise UPnPXMLError("No 'Body' element in SOAP response") # Find the response subelement. resp_namespace = UPnPNamespace(xml, RESP_NAMESPACE_BEG) resp = body.find(f'{resp_namespace!r}{action}Response') if resp is None: raise UPnPXMLError(f"No '{action}Response' element in" f' SOAP response') return dict((e.tag, e.text) for e in resp) def parse_soap_fault(xml): # Find the 'Fault' subelement. env_namespace = UPnPNamespace(xml, ENVELOPE_NAMESPACE_BEG) root = ET.fromstring(xml) fault = root.find(f'.//{env_namespace!r}Fault') if fault is None: raise UPnPXMLError("No 'Fault' element in SOAP fault response") # Find the 'UPnPError' subelement. ctrl_namespace = UPnPNamespace(xml, CTRL_NAMESPACE_BEG) error = fault.find(f'.//{ctrl_namespace!r}UPnPError') if error is None: raise UPnPXMLError("No 'UPnPError' element in SOAP fault response") ns_len = len(f'{ctrl_namespace!r}') d = dict((e.tag[ns_len:], e.text) for e in error) return SoapFault(**d) def pformat_xml(xml): """Pretty format an UPnP xml string.""" root, namespace = upnp_org_etree(xml) ET.register_namespace('', str(namespace)) tree = ET.ElementTree(root) ET_indent(tree) with io.StringIO() as out: tree.write(out, encoding='unicode') return out.getvalue() # Helper classes. SoapFault = collections.namedtuple('SoapFault', 'errorCode errorDescription', defaults=['Not specified']) class UPnPNamespace: """A namespace uri.""" def __init__(self, xml, prefix): """The first namespace in 'xml' starting with 'prefix'.""" ns = namespace_as_dict(xml) for uri in ns: if uri.startswith(prefix): self.uri = uri return raise UPnPXMLError(f'No namespace starting with {prefix}') def __str__(self): return self.uri def __repr__(self): return f'{{{self.uri}}}' if self.uri else '' ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1729706227.7389944 pa_dlna-0.16/pa_dlna/upnp_cmd.py0000644000000000000000000004742414706234364013565 0ustar00"""Command line tool for introspection and control of UPnP devices.""" import io import cmd import pprint import functools import logging import asyncio import threading import textwrap import traceback from .init import padlna_main, UPnPApplication from .upnp import (UPnPControlPoint, UPnPSoapFaultError, UPnPClosedDeviceError, pformat_xml, log_unhandled_exception, QUEUE_CLOSED) logger = logging.getLogger('upnpcmd') pprint_pprint = functools.partial(pprint.pprint, sort_dicts=False) class MissingElementError(Exception): pass # Utilities. def _dedent(txt): """A dedent that does not use the first line to compute the margin. And that removes lines only made of space characters. """ lines = txt.splitlines() return lines[0] + '\n' + textwrap.dedent('\n'.join(l for l in lines[1:] if l == '' or l.strip())) class DoMethod: # The implementation of generic do_* methods. def __init__(self, func, arg, doc=None): self.func = func self.arg = arg if doc is not None: self.__doc__ = doc def __call__(self, unused): return self.func(self.arg) def build_commands_from(instance, obj, exclude=()): """Build do_* commands from 'obj'.""" for key, value in vars(obj).items(): if key.startswith('_') or key in exclude: continue funcname = f'do_{key}' if not hasattr(instance, funcname): setattr(instance, funcname, DoMethod(print, value, f"Print the value of '{key}'.")) def check_required(obj, attributes): """Check that all in 'attributes' are attributes of 'obj'.""" for name in attributes: if not hasattr(obj, name): msg = '' if hasattr(obj, 'peer_ipaddress'): msg = f' at {obj.peer_ipaddress}' raise MissingElementError(f"Missing '{name}' xml element in" f" description of '{str(obj)}'{msg}") def device_name(dev): attr = 'friendlyName' return getattr(dev, attr) if hasattr(dev, attr) else str(dev) def comma_split(txt): """Split 'txt' on commas handling backslash escaped commas.""" escaped = None result = [] for line in txt.split(','): if escaped: line = escaped + line escaped = None if line.endswith('\\'): escaped = line[:-1] + ',' else: result.append(line.strip()) return result def pprint_soap(response): """Pretty print an soap response.""" if not response: print('SOAP response OK') return for arg, value in response.items(): # A comma separated list becomes a Python list. if isinstance(value, str): splitted = comma_split(value) if len(splitted) > 1: response[arg] = splitted else: try: response[arg] = int(value) except ValueError: pass print('SOAP response:') pprint_pprint(response) # Class(es). class _Cmd(cmd.Cmd): def __init__(self): super().__init__() def select_device(self, devices, idx): """Select a device in a list and print some device attributes.""" if not devices: print('*** No device') return try: for dev in devices: check_required(dev, ('deviceType', 'UDN')) except MissingElementError as e: print(f'*** {e.args[0]}') return idx = 0 if idx == '' else idx try: idx = int(idx) dev = devices[idx] print('Selected device:') print(' friendlyName:', device_name(dev)) print(' deviceType:', dev.deviceType) print(' UDN:', dev.UDN) print() except Exception as e: print(f'*** {e!r}') else: return dev def do_EOF(self, unused): """Quit the application.""" print() return self.do_quit(unused) def get_names(self): return dir(self) def do_help(self, arg): if not arg: self.stdout.write(_dedent(self.__class__.__doc__)) super().do_help(arg) do_help.__doc__ = cmd.Cmd.do_help.__doc__ def get_help(self): """Return the help as a string.""" _stdout = self.stdout try: with io.StringIO() as out: self.stdout = out self.do_help(None) return out.getvalue() finally: self.stdout = _stdout def emptyline(self): """Do not run the last command.""" def onecmd(self, line): try: return super().onecmd(line) except Exception: traceback.print_exc(limit=-10) def cmdloop(self): super().cmdloop(intro=self.get_help()) class ActionCommand(cmd.Cmd): """Cmd interpreter used to prompt for an argument.""" def __init__(self, argument): super().__init__() self.prompt = f' {argument} = ' self.result = None def complete(self, text, state): return None def cmdloop(self, intro=None): # Disable history. try: if self.use_rawinput and self.completekey: try: import readline readline.set_auto_history(False) except ImportError: pass super().cmdloop(intro) finally: if self.use_rawinput and self.completekey: try: import readline readline.set_auto_history(True) except ImportError: pass def onecmd(self, line): line = line.strip() if line: self.result = line return True class UPnPServiceCmd(_Cmd): """Use the 'previous' command to return to the device. Enter an action command to be prompted for the value of each of its arguments. Type to abort entering those values. """ def __init__(self, upnp_service, loop): super().__init__() self.upnp_service = upnp_service self.loop = loop build_commands_from(self, upnp_service) # Build the do_* action commands. self.doc_header = 'Main commands:' self.undoc_header = 'Action commands:' for action in self.upnp_service.actionList: funcname = f'do_{action}' setattr(self, funcname, DoMethod(self.soap_action, action)) self.prompt = f'[{str(self.upnp_service)}] ' self.quit = False def do_quit(self, unused): """Quit the application.""" # Tell cmdloop() to return True. self.quit = True # Stop the current interpreter and return to the previous one. return True def do_previous(self, unused): """Return to the device.""" return True def help_parent_device(self): print('Shortened UDN of the parent device.') def do_description(self, unused): """Print the xml 'description'.""" print(pformat_xml(self.upnp_service.description)) def help_root_device(self): print('Shortened UDN of the root device.') def help_actionList(self): print(_dedent("""Print an action or list actions. With a numeric argument such as 'actionList DEPTH': When DEPTH is 1, print the list of the actions. When DEPTH is 2, print the list of the actions with their arguments. When DEPTH is 3, print the list of the actions with their arguments and the values of 'direction' and 'relatedStateVariable' for each argument. With no argument, it is the same as 'actionList 1'. With the action name as argument, print the full description of the action. Completion is enabled on the action names. """)) def complete_actionList(self, text, line, begidx, endidx): return [a for a in self.upnp_service.actionList if a.startswith(text)] def do_actionList(self, arg): depth = None if arg == '': depth = 1 else: try: depth = int(arg) if depth <= 0 or depth > 3: print('*** Depth must be > 0 and < 4') return except ValueError: pass if depth is not None: pprint_pprint(self.upnp_service.actionList, depth=depth) else: try: action = {arg: self.upnp_service.actionList[arg]} pprint_pprint(action) except KeyError: print(f"*** '{arg}' is not an action") def help_serviceStateTable(self): print(_dedent("""Print a stateVariable or list the stateVariables. With a numeric argument such as 'serviceStateTable DEPTH': When DEPTH is 1, print the list of the stateVariables. When DEPTH is 2, print the list of the stateVariables with their parameters. When DEPTH is 3, print also the list of the 'allowedValueList' or 'allowedValuerange' parameter if any. With no argument, it is the same as 'serviceStateTable 1'. With the stateVariable name as argument, print the full description of the stateVariable. Completion is enabled on the stateVariable names. """)) def complete_serviceStateTable(self, text, line, begidx, endidx): return [s for s in self.upnp_service.serviceStateTable if s.startswith(text)] def do_serviceStateTable(self, arg): depth = None if arg == '': depth = 1 else: try: depth = int(arg) if depth <= 0 or depth > 3: print('*** Depth must be > 0 and < 4') return except ValueError: pass if depth is not None: pprint_pprint(self.upnp_service.serviceStateTable, depth=depth) else: try: action = {arg: self.upnp_service.serviceStateTable[arg]} pprint_pprint(action) except KeyError: print(f"*** '{arg}' is not an action") def soap_action(self, action): """Invoke a soap action on asyncio event loop from this thread.""" if self.loop is None or self.loop.is_closed(): print('*** The control point is closed') return args = {} for arg, params in self.upnp_service.actionList[action].items(): if params['direction'] == 'in': cmd = ActionCommand(arg) cmd.cmdloop() if cmd.result == 'EOF': print('*** Action interrupted') return args[arg] = cmd.result future = asyncio.run_coroutine_threadsafe( self.upnp_service.soap_action(action, args), self.loop) try: response = future.result() pprint_soap(response) except UPnPSoapFaultError as e: print(f'*** Fault {e.args[0]}') except UPnPClosedDeviceError: print(f'*** {self.upnp_service.root_device} is closed') except Exception as e: print(f'*** Got exception {e!r}') def cmdloop(self): super().cmdloop() # Tell the previous interpreter to just quit when self.quit is True. return self.quit class UPnPDeviceCmd(_Cmd): """Use the 'embedded' or 'service' command to select an embedded device or service. Use the 'previous' command to return to the previous device or to the control point. """ def __init__(self, upnp_device, loop): super().__init__() self.upnp_device = upnp_device self.loop = loop build_commands_from(self, upnp_device, exclude=('deviceList', 'serviceList')) self.prompt = f'[{device_name(upnp_device)}] ' self.quit = False def do_quit(self, unused): """Quit the application.""" # Tell cmdloop() to return True. self.quit = True # Stop the current interpreter and return to the previous one. return True def do_embedded_list(self, unused): """List the embedded UPnP devices""" print([device_name(dev) for dev in self.upnp_device.deviceList]) def help_embedded(self): print(_dedent(f"""Select an embedded device. Use the command 'embedded IDX' to select the device at index IDX (starting at zero) in the list printed by the 'embedded_list' command. With no argument, do this for the device at index 0. """)) def do_embedded(self, idx): selected = self.select_device(self.upnp_device.deviceList, idx) if selected is not None: interpreter = UPnPDeviceCmd(selected, self.loop) if interpreter.cmdloop(): return self.do_quit(None) def do_service_list(self, unused): """List the services.""" print([str(serv) for serv in self.upnp_device.serviceList.values()]) def complete_service(self, text, line, begidx, endidx): return [s for s in (str(serv) for serv in self.upnp_device.serviceList.values()) if s.startswith(text)] def help_service(self): print(_dedent("""Select a service. Use the command 'service NAME' to select the service named NAME. Completion is enabled on the service names. """)) def do_service(self, arg): services = list(self.upnp_device.serviceList.values()) if not services: print('*** No service') return try: for serv in services: check_required(serv, ('serviceType', 'serviceId')) except MissingElementError as e: print(f'*** {e.args[0]}') return for serv in services: str_serv = str(serv) if str_serv == arg: print('Selected service:') print(' serviceId:', str_serv) print(' serviceType:', serv.serviceType) print() break else: print(f"*** Unkown service '{arg}'") return interpreter = UPnPServiceCmd(serv, self.loop) if interpreter.cmdloop(): return self.do_quit(None) def help_previous(self): if self.upnp_device.parent_device is self.upnp_device.root_device: print('Return to the control point.') else: print('Return to the previous device.') def do_previous(self, unused): return True def help_peer_ipaddress(self): print('Print the IP address of the UPnP device.') def help_parent_device(self): print('Shortened UDN of the parent device.') def do_description(self, unused): """Print the xml 'description'.""" print(pformat_xml(self.upnp_device.description)) def do_iconList(self, unused): """Print the value of 'iconList'.""" device = self.upnp_device if hasattr(device, 'iconList'): pprint_pprint(device.iconList, indent=2) else: print('None') def help_root_device(self): print('Shortened UDN of the root device.') def cmdloop(self): super().cmdloop() # Tell the previous interpreter to just quit when self.quit is True. return self.quit class UPnPControlCmd(UPnPApplication, _Cmd): """Interactive interface to an UPnP control point List available commands with 'help' or '?'. List detailed help with 'help COMMAND'. Use tab completion and command history when the readline Python module is available. Type 'quit' or here or at each sub-menu to quit the session. Use the 'device' command to select a device among the discovered devices. """ def __init__(self, **kwargs): super().__init__(**kwargs) super(UPnPApplication, self).__init__() # Control point attributes. self.loop = None self.control_point = None self.cp_thread = None self.devices = set() # Cmd attributes. self.prompt = '[Control Point] ' def do_quit(self, unused): """Quit the application.""" self.close() return True def do_device_list(self, unused): """List the discovered UPnP devices.""" print([device_name(dev) for dev in self.devices]) def help_device(self): print(_dedent(f"""Select a discovered device. Use the command 'device IDX' to select the device at index IDX (starting at zero) in the list printed by the 'device_list' command. With no argument, do this for the device at index 0. """)) def do_device(self, idx): dev_list = list(self.devices) selected = self.select_device(dev_list, idx) if selected is not None: interpreter = UPnPDeviceCmd(selected, self.loop) if interpreter.cmdloop(): self.close() return True def help_ip_configured (self): print(_dedent("""Print the list of the configured IPv4 addresses of the networks where UPnP devices may be discovered. All addresses may be monitored when both 'ip_configured' and 'nics' are empty. """)) def help_nics(self): print(_dedent("""Print the list of the network interfaces where UPnP devices may be discovered. All IPv4 addresses may be monitored when both 'ip_configured' and 'nics' are empty. """)) def help_ip_monitored(self): print(_dedent("""Print the list of the IPv4 addresses currently monitored by UPnP discovery. """)) def help_ttl(self): print('Print the IP packets time to live.') def close(self): if self.control_point is not None: if threading.current_thread() is not self.cp_thread: if self.loop is not None and not self.loop.is_closed(): self.loop.call_soon_threadsafe(self.control_point.close) self.cp_thread.join(timeout=5) else: self.control_point.close() def run(self, cp_thread, event): self.cp_thread = cp_thread event.wait() build_commands_from(self, self.control_point) try: self.cmdloop() except KeyboardInterrupt as e: print(f'Got {e!r}') self.close() return 1 @log_unhandled_exception(logger) async def run_control_point(self, event): self.loop = asyncio.get_running_loop() try: # Run the UPnP control point. with UPnPControlPoint( self.ip_addresses, self.nics, self.msearch_interval, self.msearch_port, self.ttl) as self.control_point: event.set() while True: notif, root_device = (await self.control_point.get_notification()) if (notif, root_device) == QUEUE_CLOSED: logger.debug('UPnP queue is closed') break logger.info(f"Got '{notif}' notification for " f' {root_device}') if notif == 'alive': self.devices.add(root_device) elif root_device in self.devices: self.devices.remove(root_device) except asyncio.CancelledError: pass finally: self.close() def __str__(self): return 'upnp-cmd' # The main function. def main(): padlna_main(UPnPControlCmd, __doc__) if __name__ == '__main__': padlna_main(UPnPControlCmd, __doc__) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1740303213.3137124 pa_dlna-0.16/pyproject.toml0000644000000000000000000000174714756565555012740 0ustar00[build-system] requires = ["flit_core >=3.2,<4"] build-backend = "flit_core.buildapi" [project] name = "pa-dlna" readme = "README.rst" requires-python = ">=3.8,<4" license = {file = "LICENSE"} authors = [{name = "Xavier de Gaye", email = "xdegaye@gmail.com"}] keywords = ["pulseaudio", "pipewire", "DLNA", "UPnP", "asyncio"] classifiers = [ "Development Status :: 2 - Pre-Alpha", "Framework :: AsyncIO", "License :: OSI Approved :: MIT License", "Operating System :: Unix", "Programming Language :: Python :: 3", "Topic :: Multimedia :: Sound/Audio :: Players", ] dependencies = [ "psutil", "libpulse >=0.7", ] dynamic = ["version", "description"] [project.urls] Documentation = "https://pa-dlna.readthedocs.io/en/stable/" Source = "https://gitlab.com/xdegaye/pa-dlna" Changelog = "https://pa-dlna.readthedocs.io/en/stable/history.html" [project.scripts] pa-dlna = "pa_dlna.pa_dlna:main" upnp-cmd = "pa_dlna.upnp_cmd:main" [tool.flit.module] name = "pa_dlna" ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1735744189.8836317 pa_dlna-0.16/systemd/pa-dlna.service0000644000000000000000000000257014735255276014376 0ustar00# When enabled, the pa-dlna service unit is started automatically after the # pulseaudio or pipewire service unit is started. It will also stop when the # pulseaudio or pipewire service unit stops. However it will stop when the # pulseaudio or pipewire service unit is restarted but it will not start. # # Both pa-dlna and pulseaudio service units are of 'Type=notify'. This means # that pa-dlna will only start after pulseaudio has notified systemd that it # is ready and pa-dlna may connect successfully to libpulse. # # However the pipewire service unit is of 'Type=simple'. In that case and if # pa-dlna fails to start with the error: # LibPulseStateError(('PA_CONTEXT_FAILED', 'Connection refused')) # add a delay to the pa-dlna start up sequence with the directive: # ExecStartPre=/bin/sleep 1 # # Any pa-dlna option may be added to the 'ExecStart' directive, for example to # restrict the allowed NICs or IP addresses (recommended) or to change the # log level. # The '--systemd' option is required. # # The 'python-systemd' package is required. [Unit] Description=Pa-dlna Service Documentation=https://pa-dlna.readthedocs.io/en/stable/ After=pipewire-session-manager.service pulseaudio.service [Service] Type=notify ExecStart=/usr/bin/pa-dlna --systemd Slice=session.slice NoNewPrivileges=yes UMask=0077 [Install] WantedBy=pipewire-session-manager.service pulseaudio.service ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1667661560.3930001 pa_dlna-0.16/tools/__init__.py0000644000000000000000000000000014331477370013234 0ustar00././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1667661560.3930001 pa_dlna-0.16/tools/build_didl_lite.py0000644000000000000000000000302214331477370014614 0ustar00"""Build a didl_lite URIMetaData string using python-didl-lite. python-didl-lite: https://github.com/StevenLooman/python-didl-lite. """ import sys import xml.etree.ElementTree as ET # The defusedxml package is imported by didl-lite, but it is not needed here. class defusedxml: ElementTree = None sys.modules['defusedxml'] = defusedxml sys.modules['defusedxml.ElementTree'] = defusedxml from didl_lite import didl_lite def to_xml_string(*objects): """Convert items to DIDL-Lite XML string with indentation.""" root_el = ET.Element("DIDL-Lite", {}) root_el.attrib["xmlns"] = didl_lite.NAMESPACES["didl_lite"] root_el.attrib["xmlns:dc"] = didl_lite.NAMESPACES["dc"] root_el.attrib["xmlns:upnp"] = didl_lite.NAMESPACES["upnp"] for didl_object in objects: didl_object_el = didl_object.to_xml() root_el.append(didl_object_el) # This is the original code from didl_lite with this line added to indent # the returned string. ET.indent(root_el) return ET.tostring(root_el) def main(): resource = didl_lite.Resource('{self.current_uri}', '{self.protocol_info}') items = [ didl_lite.MusicTrack( id='0', parent_id='0', restricted='0', title='{metadata.title}', artist='{metadata.artist}', publisher='{metadata.publisher}', resources=[resource], ), ] xml = to_xml_string(*items).decode() print(xml) if __name__ == '__main__': main() ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1698237774.4908383 pa_dlna-0.16/tools/docker-pipewire.sh0000644000000000000000000000064014516206516014557 0ustar00#! /bin/bash # Start pipewire-pulse and run the test suite. export XDG_RUNTIME_DIR=/tmp pipewire & echo "*** pipewire started" pipewire-pulse & echo "*** pipewire-pulse started" # wireplumber needs an X11 server. export DISPLAY=:0.0 Xvfb -screen $DISPLAY 1920x1080x24 & echo "*** Xvfb started" wireplumber & echo "*** wireplumber started" sleep 1 pw-cli help pactl info python3 -m unittest --verbose --failfast ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1672649132.6740093 pa_dlna-0.16/tools/gendoc_default_config.py0000644000000000000000000000337614354514655016013 0ustar00"""Generate the default-config file in the documentation.""" import sys from subprocess import Popen, PIPE DEFAULT_CONFIG = 'docs/source/default-config.rst' INDENT = ' ' MAX_LENGTH = 82 HEADER = """\ .. File generated by tools/gendoc_default_config.py. DO NOT EDIT THIS FILE DIRECTLY. .. _default_config: Built-in Default Configuration ============================== As printed by the command ``pa-dlna --dump-default``. :: """ def wrap(line, max=MAX_LENGTH): """This generator recursively wraps 'line', yielding its parts. Splitting 'line' at word boundaries. """ length = len(line) if length <= max: if length: yield line.rstrip() return if line[max-1].isspace() or line[max].isspace(): length = max else: idx = line[:max].rfind(' ') if idx != -1: length = idx else: length = len(line) yield line[:length].rstrip() yield from wrap(line[length:].lstrip()) def main(): try: with open(DEFAULT_CONFIG, 'w') as f: f.write(HEADER) with Popen([sys.executable, '-m', 'pa_dlna.pa_dlna', '--dump-default'], stdout=PIPE) as proc: for line in proc.stdout: line = line.decode() line = line.replace('\t', ' ').rstrip() if line: for part in wrap(line): f.write(INDENT + part + '\n') else: f.write('\n') except FileNotFoundError as e: print(f'{e!r}: {DEFAULT_CONFIG}', file=sys.stderr) sys.exit(1) print(f"A new '{DEFAULT_CONFIG}' file has been written.") if __name__ == '__main__': main() ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1734184752.5240605 pa_dlna-0.16/tools/set_devpt_version_name.py0000644000000000000000000000364114727307461016257 0ustar00"""Update the version using 'git describe'.""" import sys import re import subprocess from packaging import version as pkg_version INIT_FILE = 'pa_dlna/__init__.py' def normalize(version, do_print=False): "Check and normalize 'version' as conform to PEP 440." try: v = pkg_version.Version(version) if do_print: v_dict = { 'release': v.release, 'pre': v.pre, 'post': v.post, 'dev': v.dev, 'local': v.local, } for name, value in v_dict.items(): print(f'{name}: {value}', file=sys.stderr) return v except pkg_version.InvalidVersion as e: print(e, file=sys.stderr) sys.exit(1) def main(): """Set the development version name in INIT_FILE. The 'git describe' command outputs: release'-'number_of_commits_since_last_tag'-g'short_commit_sha After all the characters after the last '-' in the output have been striped, the version is normalized (made conform to PEP 440) as: release'.post'number_of_commits_since_last_tag For example '0.14.post3'. """ version = subprocess.check_output(['git', 'describe']) version = version.decode().strip() version = normalize(version.rsplit('-', maxsplit=1)[0], do_print=True) if version.post is None: print(f'*** Error:\n Cannot set a development version name at release' f' {version}.\n It must be followed by at least one commit.', file=sys.stderr) sys.exit(1) with open(INIT_FILE) as f: txt = f.read() regexp = re.compile(r"(__version__\s+=\s+)'([^']+)'") new_txt = regexp.sub(rf"\1'{version}'", txt) with open(INIT_FILE, 'w') as f: f.write(new_txt) print(f"{INIT_FILE} has been updated with: __version__ = '{version}'", file=sys.stderr) if __name__ == '__main__': main() pa_dlna-0.16/PKG-INFO0000644000000000000000000001554000000000000011027 0ustar00Metadata-Version: 2.3 Name: pa-dlna Version: 0.16 Summary: Forward pulseaudio streams to DLNA devices. Keywords: pulseaudio,pipewire,DLNA,UPnP,asyncio Author-email: Xavier de Gaye Requires-Python: >=3.8,<4 Description-Content-Type: text/x-rst Classifier: Development Status :: 2 - Pre-Alpha Classifier: Framework :: AsyncIO Classifier: License :: OSI Approved :: MIT License Classifier: Operating System :: Unix Classifier: Programming Language :: Python :: 3 Classifier: Topic :: Multimedia :: Sound/Audio :: Players Requires-Dist: psutil Requires-Dist: libpulse >=0.7 Project-URL: Changelog, https://pa-dlna.readthedocs.io/en/stable/history.html Project-URL: Documentation, https://pa-dlna.readthedocs.io/en/stable/ Project-URL: Source, https://gitlab.com/xdegaye/pa-dlna .. image:: images/coverage.png :alt: [pa-dlna test coverage] `pa-dlna`_ forwards audio streams to DLNA devices. A Python project based on `asyncio`_, that uses `ctypes`_ to interface with the ``libpulse`` library and supports the PulseAudio and PipeWire [#]_ sound servers. `pa-dlna`_ is composed of the following components: * The ``pa-dlna`` program forwards PulseAudio streams to DLNA devices. * The ``upnp-cmd`` is an interactive command line tool for introspection and control of UPnP devices [#]_. * The UPnP Python sub-package is used by both commands. The documentation is hosted at `Read the Docs`_: - The `stable documentation`_ of the last released version. - The `latest documentation`_ of the current GitLab development version. To access the documentation as a pdf document one must click on the icon at the down-right corner of any page. It allows to switch between stable and latest versions and to select the corresponding pdf document. Requirements ------------ Python version 3.8 or more recent. psutil """""" The UPnP sub-package and therefore the ``upnp-cmd`` and ``pa-dlna`` commands depend on the `psutil`_ Python package. This package is available in most distributions as ``python3-psutil`` or ``python-psutil``. It will be installed by ``pip`` as a dependency of ``pa-dlna`` if not already installed as a package of the distribution. libpulse """""""" `libpulse`_ is a Python asyncio interface to the Pulseaudio and Pipewire ``libpulse`` library. It was a sub-package of ``pa-dlna`` and has become a full-fledged package on PyPi. It will be installed by ``pip`` as a dependency of ``pa-dlna``. parec """"" `pa-dlna`_ uses the pulseaudio ``parec`` program [#]_. Depending on the linux distribution it may be already installed as a dependency of pulseaudio or of pipewire-pulse. If not, then the package that owns ``parec`` must be installed. On archlinux the package name is ``libpulse``, on debian it is `pulseaudio-utils`_. systemd """"""" The `python-systemd`_ package is required to run the pa-dlna systemd service unit. Encoders """""""" No other dependency is required by `pa-dlna`_ when the DLNA devices support raw PCM L16 (:rfc:`2586`) [#]_. Optionally, encoders compatible with the audio mime types supported by the devices may be used. ``pa-dlna`` currently supports the `ffmpeg`_ (mp3, wav, aiff, flac, opus, vorbis, aac), the `flac`_ and the `lame`_ (mp3) encoders. The list of supported encoders, whether they are available on this host and their options, is printed by the command that prints the default configuration:: $ pa-dlna --dump-default pavucontrol """"""""""" Optionally, one may install the ``pavucontrol`` package for easier management of associations between sound sources and DLNA devices. Installation ------------ pipewire as a pulseaudio sound server """"""""""""""""""""""""""""""""""""" The ``pipewire``, ``pipewire-pulse`` and ``wireplumber`` packages must be installed and the corresponding programs started. If you are switching from pulseaudio, make sure to remove ``/etc/pulse/client.conf`` or to comment out the setting of ``default-server`` in this file as pulseaudio and pipewire do not use the same unix socket path name. The ``parec`` 's package includes the ``pactl`` program. One may check that the installation of pipewire as a pulseaudio sound server is successfull by running the command:: $ pactl info pa-dlna """"""" Install ``pa-dlna`` with pip:: $ python -m pip install pa-dlna Configuration ------------- A ``pa-dlna.conf`` user configuration file overriding the default configuration may be used to: * Change the preferred encoders ordered list used to select an encoder. * Configure encoder options. * Set an encoder for a given device and configure the options for this device. * Configure the *sample_format*, *rate* and *channels* parameters of the ``parec`` program used to forward PulseAudio streams, for a specific device, for an encoder type or for all devices. See the `configuration`_ section of the pa-dlna documentation. .. _pa-dlna: https://gitlab.com/xdegaye/pa-dlna .. _asyncio: https://docs.python.org/3/library/asyncio.html .. _ctypes: https://docs.python.org/3/library/ctypes.html .. _pulseaudio-utils: https://packages.debian.org/bookworm/pulseaudio-utils .. _pa-dlna issue 15: https://gitlab.com/xdegaye/pa-dlna/-/issues/15 .. _Wireplumber issue 511: https://gitlab.freedesktop.org/pipewire/wireplumber/-/issues/511 .. _Read the Docs: https://about.readthedocs.com/ .. _stable documentation: https://pa-dlna.readthedocs.io/en/stable/ .. _latest documentation: https://pa-dlna.readthedocs.io/en/latest/ .. _psutil: https://pypi.org/project/psutil/ .. _ConnectionManager:3 Service: http://upnp.org/specs/av/UPnP-av-ConnectionManager-v3-Service.pdf .. _ffmpeg: https://www.ffmpeg.org/ffmpeg.html .. _flac: https://xiph.org/flac/ .. _lame: https://lame.sourceforge.io/ .. _configuration: https://pa-dlna.readthedocs.io/en/stable/configuration.html .. _pipewire-pulse: https://docs.pipewire.org/page_man_pipewire_pulse_1.html .. _libpulse: https://pypi.org/project/libpulse/ .. _pa-dlna command: https://pa-dlna.readthedocs.io/en/stable/pa-dlna.html .. _python-systemd: https://www.freedesktop.org/software/systemd/python-systemd/ .. [#] When using PipeWire with the Wireplumber session manager, ``pa-dlna`` must be started before the audio streams that are routed to DLNA devices. Re-starting those audio streams fixes the problem. See `pa-dlna issue 15`_ and `Wireplumber issue 511`_. A workaround may be used with the ``--clients-uuids`` command line option, see the `pa-dlna command`_ documentation. .. [#] The ``pa-dlna`` and ``upnp-cmd`` programs can be run simultaneously. .. [#] The ``parec`` program also uses the ``libpulse`` library which is included in ``parec`` 's package or is installed as a dependency. Note also that this package includes the ``pactl`` and ``pacmd`` programs. .. [#] DLNA devices must support the HTTP GET transfer protocol and must support HTTP 1.1 as specified by Annex A.1 of the `ConnectionManager:3 Service`_ UPnP specification.