├── tests ├── __init__.py ├── test_extension.py └── test_somafm.py ├── setup.py ├── mopidy_somafm ├── ext.conf ├── __init__.py ├── backend.py └── somafm.py ├── .gitignore ├── MANIFEST.in ├── pyproject.toml ├── tox.ini ├── LICENSE ├── .circleci └── config.yml ├── setup.cfg ├── README.rst └── CHANGES.rst /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup() 4 | -------------------------------------------------------------------------------- /mopidy_somafm/ext.conf: -------------------------------------------------------------------------------- 1 | [somafm] 2 | enabled = true 3 | encoding = mp3 4 | quality = fast 5 | dj_as_artist = true 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | /.coverage 3 | /.mypy_cache/ 4 | /.pytest_cache/ 5 | /.tox/ 6 | /*.egg-info 7 | /build/ 8 | /dist/ 9 | /MANIFEST 10 | 11 | # 12 | channels.xml 13 | /venv/ -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include *.py 2 | include *.rst 3 | include .mailmap 4 | include LICENSE 5 | include MANIFEST.in 6 | include pyproject.toml 7 | include tox.ini 8 | 9 | recursive-include .circleci * 10 | recursive-include .github * 11 | 12 | include mopidy_*/ext.conf 13 | 14 | recursive-include tests *.py 15 | recursive-include tests/data * 16 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools >= 30.3.0", "wheel"] 3 | 4 | 5 | [tool.black] 6 | target-version = ["py37", "py38"] 7 | line-length = 80 8 | 9 | 10 | [tool.isort] 11 | multi_line_output = 3 12 | include_trailing_comma = true 13 | force_grid_wrap = 0 14 | use_parentheses = true 15 | line_length = 88 16 | known_tests = "tests" 17 | sections = "FUTURE,STDLIB,THIRDPARTY,FIRSTPARTY,TESTS,LOCALFOLDER" 18 | -------------------------------------------------------------------------------- /tests/test_extension.py: -------------------------------------------------------------------------------- 1 | from mopidy_somafm import Extension 2 | 3 | 4 | def test_get_default_config(): 5 | ext = Extension() 6 | 7 | config = ext.get_default_config() 8 | 9 | assert "[somafm]" in config 10 | assert "enabled = true" in config 11 | 12 | 13 | def test_get_config_schema(): 14 | ext = Extension() 15 | 16 | schema = ext.get_config_schema() 17 | 18 | assert "quality" in schema 19 | assert "encoding" in schema 20 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py37, py38, black, check-manifest, flake8 3 | 4 | [testenv] 5 | sitepackages = true 6 | deps = .[test] 7 | commands = 8 | python -m pytest \ 9 | --basetemp={envtmpdir} \ 10 | --cov=mopidy_somafm --cov-report=term-missing \ 11 | {posargs} 12 | 13 | [testenv:black] 14 | deps = .[lint] 15 | commands = python -m black --check . 16 | 17 | [testenv:check-manifest] 18 | deps = .[lint] 19 | commands = python -m check_manifest 20 | 21 | [testenv:flake8] 22 | deps = .[lint] 23 | commands = python -m flake8 --show-source --statistics 24 | -------------------------------------------------------------------------------- /mopidy_somafm/__init__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import pathlib 3 | 4 | import pkg_resources 5 | 6 | from mopidy import config, ext 7 | 8 | __version__ = pkg_resources.get_distribution("Mopidy-SomaFM").version 9 | 10 | logger = logging.getLogger(__name__) 11 | 12 | 13 | class Extension(ext.Extension): 14 | 15 | dist_name = "Mopidy-SomaFM" 16 | ext_name = "somafm" 17 | version = __version__ 18 | 19 | def get_default_config(self): 20 | return config.read(pathlib.Path(__file__).parent / "ext.conf") 21 | 22 | def get_config_schema(self): 23 | schema = super().get_config_schema() 24 | schema["encoding"] = config.String(choices=("aac", "mp3", "aacp")) 25 | schema["quality"] = config.String( 26 | choices=("highest", "fast", "slow", "firewall") 27 | ) 28 | schema["dj_as_artist"] = config.Boolean() 29 | return schema 30 | 31 | def setup(self, registry): 32 | from .backend import SomaFMBackend 33 | 34 | registry.add("backend", SomaFMBackend) 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2019 Alexandre Petitjean 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | 3 | orbs: 4 | codecov: codecov/codecov@1.0.5 5 | 6 | workflows: 7 | version: 2 8 | test: 9 | jobs: 10 | - py38 11 | - py37 12 | - black 13 | - check-manifest 14 | - flake8 15 | 16 | jobs: 17 | py38: &test-template 18 | docker: 19 | - image: mopidy/ci-python:3.8 20 | steps: 21 | - checkout 22 | - restore_cache: 23 | name: Restoring tox cache 24 | key: tox-v1-{{ .Environment.CIRCLE_JOB }}-{{ checksum "setup.cfg" }} 25 | - run: 26 | name: Run tests 27 | command: | 28 | tox -e $CIRCLE_JOB -- \ 29 | --junit-xml=test-results/pytest/results.xml \ 30 | --cov-report=xml 31 | - save_cache: 32 | name: Saving tox cache 33 | key: tox-v1-{{ .Environment.CIRCLE_JOB }}-{{ checksum "setup.cfg" }} 34 | paths: 35 | - ./.tox 36 | - ~/.cache/pip 37 | - codecov/upload: 38 | file: coverage.xml 39 | - store_test_results: 40 | path: test-results 41 | 42 | py37: 43 | <<: *test-template 44 | docker: 45 | - image: mopidy/ci-python:3.7 46 | 47 | black: *test-template 48 | 49 | check-manifest: *test-template 50 | 51 | flake8: *test-template 52 | -------------------------------------------------------------------------------- /tests/test_somafm.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from mopidy_somafm.somafm import SomaFMClient 4 | 5 | 6 | class SomaFMClientTest(unittest.TestCase): 7 | def test_refresh(self): 8 | sfmc = SomaFMClient() 9 | sfmc.refresh("mp3", "fast") 10 | 11 | self.assertIsNotNone(sfmc.channels) 12 | self.assertNotEqual(len(sfmc.channels), 0) 13 | 14 | def test_refresh_firewall(self): 15 | sfmc = SomaFMClient() 16 | sfmc.refresh("mp3", "firewall") 17 | 18 | self.assertIsNotNone(sfmc.channels) 19 | self.assertNotEqual(len(sfmc.channels), 0) 20 | 21 | def test_refresh_no_channels(self): 22 | sfmc = SomaFMClient() 23 | sfmc.CHANNELS_URI = "" 24 | sfmc.refresh("mp3", "fast") 25 | 26 | self.assertDictEqual(sfmc.channels, {}) 27 | self.assertEqual(len(sfmc.channels), 0) 28 | 29 | def test_downloadContent(self): 30 | url = "http://api.somafm.com/channels.xml" 31 | sfmc = SomaFMClient() 32 | data = sfmc._downloadContent(url) 33 | self.assertNotEqual(len(data), 0) 34 | 35 | def test_extractStreamUrlFromPls(self): 36 | url = "http://somafm.com/groovesalad.pls" 37 | sfmc = SomaFMClient() 38 | data = sfmc.extractStreamUrlFromPls(url) 39 | self.assertNotEqual(len(data), 0) 40 | self.assertNotEqual(data, url) 41 | 42 | def test_extractStreamUrlFromPls_unknown(self): 43 | url = "http://somafm.com/noneazerty.pls" 44 | sfmc = SomaFMClient() 45 | data = sfmc.extractStreamUrlFromPls(url) 46 | self.assertNotEqual(len(data), 0) 47 | self.assertEqual(data, url) 48 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = Mopidy-SomaFM 3 | version = 2.0.2 4 | url = https://github.com/AlexandrePTJ/mopidy-somafm 5 | author = Alexandre Petitjean 6 | author_email = alpetitjean@gmail.com 7 | license = Apache License, Version 2.0 8 | license_file = LICENSE 9 | description = SomaFM extension for Mopidy 10 | long_description = file: README.rst 11 | classifiers = 12 | Environment :: No Input/Output (Daemon) 13 | Intended Audience :: End Users/Desktop 14 | License :: OSI Approved :: Apache Software License 15 | Operating System :: OS Independent 16 | Programming Language :: Python :: 3 17 | Programming Language :: Python :: 3.7 18 | Programming Language :: Python :: 3.8 19 | Topic :: Multimedia :: Sound/Audio :: Players 20 | 21 | 22 | [options] 23 | zip_safe = False 24 | include_package_data = True 25 | packages = find: 26 | python_requires = >= 3.7 27 | install_requires = 28 | Mopidy >= 3.0 29 | Pykka >= 2.0.1 30 | requests >= 2.0.0 31 | setuptools 32 | 33 | 34 | [options.extras_require] 35 | lint = 36 | black 37 | check-manifest 38 | flake8 39 | flake8-bugbear 40 | flake8-import-order 41 | isort[pyproject] 42 | release = 43 | twine 44 | wheel 45 | test = 46 | pytest 47 | pytest-cov 48 | dev = 49 | %(lint)s 50 | %(release)s 51 | %(test)s 52 | 53 | 54 | [options.packages.find] 55 | exclude = 56 | tests 57 | tests.* 58 | 59 | 60 | [options.entry_points] 61 | mopidy.ext = 62 | somafm = mopidy_somafm:Extension 63 | 64 | 65 | [flake8] 66 | application-import-names = mopidy_somafm, tests 67 | max-line-length = 80 68 | exclude = .git, .tox, build 69 | select = 70 | # Regular flake8 rules 71 | C, E, F, W 72 | # flake8-bugbear rules 73 | B 74 | # B950: line too long (soft speed limit) 75 | B950 76 | # pep8-naming rules 77 | N 78 | ignore = 79 | # E203: whitespace before ':' (not PEP8 compliant) 80 | E203 81 | # E501: line too long (replaced by B950) 82 | E501 83 | # W503: line break before binary operator (not PEP8 compliant) 84 | W503 85 | # B305: .next() is not a thing on Python 3 (used by playback controller) 86 | B305 87 | 88 | 89 | [wheel] 90 | universal = 1 91 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ************* 2 | Mopidy-SomaFM 3 | ************* 4 | 5 | .. image:: https://img.shields.io/pypi/v/Mopidy-SomaFM 6 | :target: https://pypi.org/project/Mopidy-SomaFM/ 7 | :alt: Latest PyPI version 8 | 9 | .. image:: https://img.shields.io/circleci/build/gh/AlexandrePTJ/mopidy-somafm 10 | :target: https://circleci.com/gh/AlexandrePTJ/mopidy-somafm 11 | :alt: CircleCI build status 12 | 13 | .. image:: https://img.shields.io/codecov/c/gh/AlexandrePTJ/mopidy-somafm 14 | :target: https://codecov.io/gh/AlexandrePTJ/mopidy-somafm 15 | :alt: Test coverage 16 | 17 | SomaFM extension for Mopidy 18 | 19 | 20 | Installation 21 | ============ 22 | 23 | 24 | Debian/Ubuntu 25 | ------------- 26 | 27 | Install by running:: 28 | 29 | python3 -m pip install Mopidy-SomaFM 30 | 31 | Or, if available, install the Debian/Ubuntu package from 32 | `apt.mopidy.com `_. 33 | 34 | 35 | Configuration 36 | ============= 37 | 38 | Before starting Mopidy, you must add configuration for 39 | Mopidy-SomaFM to your Mopidy configuration file:: 40 | 41 | [somafm] 42 | encoding = aac 43 | quality = highest 44 | 45 | - ``encoding`` must be either ``aac``, ``mp3`` or ``aacp`` 46 | - ``quality`` must be one of ``highest``, ``fast``, ``slow``, ``firewall`` 47 | 48 | If the preferred quality is not available for a channel, the extension will fallback 49 | to ``fast``. And afterwards if the preferred encoding is not available for that 50 | quality, it will fallback to using ``mp3``. 51 | It seems that all channels support the combination ``fast`` + ``mp3`` 52 | 53 | You can also choose to use the channel DJ as the reported track artist (default behavior):: 54 | 55 | [somafm] 56 | dj_as_artist = true 57 | 58 | 59 | Project resources 60 | ================= 61 | 62 | - `Source code `_ 63 | - `Issue tracker `_ 64 | - `Changelog `_ 65 | 66 | 67 | Credits 68 | ======= 69 | 70 | - Original author: `Alexandre Petitjean `__ 71 | - Current maintainer: `Alexandre Petitjean `__ 72 | - `Contributors `_ 73 | -------------------------------------------------------------------------------- /CHANGES.rst: -------------------------------------------------------------------------------- 1 | Changelog 2 | ========= 3 | 4 | 5 | v2.0.2 (2021-02-10) 6 | ------------------- 7 | 8 | - #40 Fix exception on URI parsing 9 | 10 | 11 | v2.0.1 (2021-01-07) 12 | ------------------- 13 | 14 | - #37 Fix image display (Thanks to @dreamlayers and @morithil) 15 | 16 | 17 | v2.0.0 (2020-03-11) 18 | ------------------- 19 | 20 | - #36 Ready for Mopidy 3.0 21 | 22 | 23 | v2.0.0rc1 (2019-12-04) 24 | ---------------------- 25 | 26 | - #32 Migrate to Python 3.7 27 | 28 | 29 | v1.1.0 (2017-10-14) 30 | ------------------- 31 | 32 | - #24: Graceful fallback 33 | - #28: Various fix (DJ as artist, station ordering) 34 | 35 | 36 | v1.0.1 (2016-01-19) 37 | ------------------- 38 | 39 | - Use httpclient helper from Mopidy >= 1.1 40 | 41 | 42 | v0.8.0 (2015-11-09) 43 | ------------------- 44 | 45 | - #20: Replace HTTP with HTTPS for channels.xml 46 | 47 | 48 | v0.7.1 (2015-01-04) 49 | ------------------- 50 | 51 | - #11: Add Low Bitrate encoding (aacp) 52 | 53 | 54 | v0.7.0 (2014-07-29) 55 | ------------------- 56 | 57 | - #10: Remove playlists provider 58 | 59 | 60 | v0.6.0 (2014-03-15) 61 | ------------------- 62 | 63 | - Directly show PLS in browser 64 | - Add precision about 'quality' and 'encoding' couple 65 | 66 | 67 | v0.5.1 (2014-03-09) 68 | ------------------- 69 | 70 | - Fix doc typo 71 | 72 | 73 | v0.5.0 (2014-03-03) 74 | ------------------- 75 | 76 | - #5: Select prefered quality and format from config 77 | - Add tests and Travis-CI support 78 | 79 | 80 | v0.4.0 (2014-02-16) 81 | ------------------- 82 | 83 | - Add browse support for LibraryController 84 | 85 | 86 | v0.3.1 (2014-01-30) 87 | ------------------- 88 | 89 | - #3: Correct wrong subclassing 90 | 91 | 92 | v0.3.0 (2014-01-29) 93 | ------------------- 94 | 95 | - Require Mopidy >= 0.18 96 | - Add proxy support for downloading SomaFM content 97 | - #1: handle 'requests' exceptions 98 | - Use builtin Mopidy's .pls support 99 | - Internal code cleanup 100 | 101 | 102 | v0.2.0 (2013-09-22) 103 | ------------------- 104 | 105 | - PLS files are downloaded to local temp directory 106 | - Implement library::lookup to allow adding tracks from playlist uri 107 | 108 | 109 | v0.1.1 (2013-09-14) 110 | ------------------- 111 | 112 | - Update Licence information 113 | 114 | 115 | v0.1.0 (2013-09-13) 116 | ------------------- 117 | 118 | - Initial release 119 | - Create SomaFM extension for Mopidy 120 | -------------------------------------------------------------------------------- /mopidy_somafm/backend.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import mopidy_somafm 4 | import pykka 5 | import requests 6 | import configparser 7 | import random 8 | from mopidy import backend, httpclient 9 | from mopidy.models import Album, Artist, Image, Ref, Track 10 | 11 | from .somafm import SomaFMClient, extract_somafm_channel_name_from_uri 12 | 13 | logger = logging.getLogger(__name__) 14 | 15 | 16 | class SomaFMBackend(pykka.ThreadingActor, backend.Backend): 17 | def __init__(self, config, audio): 18 | super().__init__() 19 | 20 | user_agent = "{}/{}".format( 21 | mopidy_somafm.Extension.dist_name, mopidy_somafm.__version__ 22 | ) 23 | 24 | self.somafm = SomaFMClient(config["proxy"], user_agent) 25 | self.library = SomaFMLibraryProvider(backend=self) 26 | self.playback = SomaFMPlayback( 27 | audio=audio, 28 | backend=self, 29 | proxy_config=config["proxy"], 30 | user_agent=user_agent, 31 | ) 32 | 33 | self.uri_schemes = ["somafm"] 34 | self.quality = config["somafm"]["quality"] 35 | self.encoding = config["somafm"]["encoding"] 36 | self.dj_as_artist = config["somafm"]["dj_as_artist"] 37 | 38 | def on_start(self): 39 | self.somafm.refresh(self.encoding, self.quality) 40 | 41 | 42 | class SomaFMLibraryProvider(backend.LibraryProvider): 43 | 44 | root_directory = Ref.directory(uri="somafm:root", name="SomaFM") 45 | 46 | def lookup(self, uri): 47 | # Whatever the uri, it will always contains one track 48 | # which is a url to a pls 49 | channel_name = extract_somafm_channel_name_from_uri(uri) 50 | if channel_name is None: 51 | return None 52 | 53 | channel_data = self.backend.somafm.channels[channel_name] 54 | 55 | # Artists 56 | if self.backend.dj_as_artist: 57 | artist = Artist(name=channel_data["dj"]) 58 | else: 59 | artist = Artist() 60 | 61 | # Build album (idem as playlist, but with more metada) 62 | album = Album( 63 | artists=[artist], 64 | name=channel_data["title"], 65 | ) 66 | 67 | track = Track( 68 | artists=[artist], 69 | album=album, 70 | last_modified=channel_data["updated"], 71 | comment=channel_data["description"], 72 | genre=channel_data["genre"], 73 | name=channel_data["title"], 74 | uri="somafm:channel:/%s" % (channel_name), 75 | ) 76 | 77 | return [track] 78 | 79 | def browse(self, uri): 80 | 81 | if uri != "somafm:root": 82 | return [] 83 | 84 | result = [] 85 | for channel in self.backend.somafm.channels: 86 | result.append( 87 | Ref.track( 88 | uri="somafm:channel:/%s" % (channel), 89 | name=self.backend.somafm.channels[channel]["title"], 90 | ) 91 | ) 92 | 93 | result.sort(key=lambda ref: ref.name.lower()) 94 | return result 95 | 96 | def get_images(self, uris): 97 | 98 | images = {} 99 | 100 | for uri in uris: 101 | channel_name = extract_somafm_channel_name_from_uri(uri) 102 | if channel_name is not None: 103 | image = Image(uri=self.backend.somafm.images[channel_name]) 104 | images[uri] = [image] 105 | 106 | return images 107 | 108 | 109 | class SomaFMPlayback(backend.PlaybackProvider): 110 | def __init__(self, audio, backend, proxy_config=None, user_agent=None): 111 | super().__init__(audio=audio, backend=backend) 112 | 113 | # Build requests session 114 | self.session = requests.Session() 115 | if proxy_config is not None: 116 | proxy = httpclient.format_proxy(proxy_config) 117 | self.session.proxies.update({"http": proxy, "https": proxy}) 118 | 119 | full_user_agent = httpclient.format_user_agent(user_agent) 120 | self.session.headers.update({"user-agent": full_user_agent}) 121 | 122 | def translate_uri(self, uri): 123 | try: 124 | channel_name = extract_somafm_channel_name_from_uri(uri) 125 | if channel_name is None: 126 | return None 127 | 128 | channel_data = self.backend.somafm.channels.get(channel_name) 129 | 130 | r = self.session.get(channel_data["pls"]) 131 | if r.status_code != 200: 132 | return None 133 | 134 | pls = configparser.ConfigParser() 135 | pls.read_string(r.text) 136 | playlist = pls["playlist"] 137 | num = int(playlist["numberofentries"]) 138 | return playlist["File" + str(random.randint(1, num))] 139 | 140 | except Exception: 141 | return None 142 | -------------------------------------------------------------------------------- /mopidy_somafm/somafm.py: -------------------------------------------------------------------------------- 1 | import collections 2 | import logging 3 | import re 4 | from urllib.parse import urlsplit 5 | 6 | import requests 7 | 8 | from mopidy import httpclient 9 | 10 | try: 11 | import xml.etree.cElementTree as ET 12 | except ImportError: 13 | import xml.etree.ElementTree as ET 14 | 15 | 16 | logger = logging.getLogger(__name__) 17 | 18 | # 19 | # Channels are playlist and Album 20 | # PLS are tracks 21 | # PLS contents for internal use 22 | # 23 | 24 | 25 | def extract_somafm_channel_name_from_uri(uri): 26 | 27 | if not uri.startswith("somafm:"): 28 | return None 29 | 30 | channel_separator = uri.find("/") 31 | if channel_separator == -1: 32 | return None 33 | 34 | return uri[channel_separator + 1 :] 35 | 36 | 37 | class SomaFMClient: 38 | 39 | CHANNELS_URI = "https://api.somafm.com/channels.xml" 40 | 41 | # All channels seem to have this combination of quality/encoding available 42 | FALLBACK_QUALITY = "fast" 43 | FALLBACK_ENCODING = "mp3" 44 | 45 | channels = {} 46 | images = {} 47 | 48 | def __init__(self, proxy_config=None, user_agent=None): 49 | super().__init__() 50 | 51 | # Build requests session 52 | self.session = requests.Session() 53 | if proxy_config is not None: 54 | proxy = httpclient.format_proxy(proxy_config) 55 | self.session.proxies.update({"http": proxy, "https": proxy}) 56 | 57 | full_user_agent = httpclient.format_user_agent(user_agent) 58 | self.session.headers.update({"user-agent": full_user_agent}) 59 | 60 | def refresh(self, encoding, quality): 61 | # clean previous data 62 | self.channels = {} 63 | 64 | # download channels xml file 65 | channels_content = self._downloadContent(self.CHANNELS_URI) 66 | if channels_content is None: 67 | logger.error("Cannot fetch %s" % (self.CHANNELS_URI)) 68 | return 69 | 70 | # parse XML 71 | root = ET.fromstring(channels_content) 72 | 73 | for child_channel in root: 74 | 75 | pls_id = child_channel.attrib["id"] 76 | channel_data = {} 77 | channel_all_pls = collections.defaultdict(dict) 78 | 79 | for child_detail in child_channel: 80 | 81 | key = child_detail.tag 82 | val = child_detail.text 83 | 84 | if key in ["title", "image", "dj", "genre", "description"]: 85 | channel_data[key] = val 86 | elif key == "updated": 87 | channel_data["updated"] = int(val) 88 | elif "pls" in key: 89 | pls_quality = key[:-3] 90 | pls_format = child_detail.attrib["format"] 91 | 92 | channel_all_pls[pls_quality][pls_format] = val 93 | 94 | # firewall playlist are fastpls+mp3 but with fw path 95 | if pls_quality == "fast" and pls_format == "mp3": 96 | r1 = urlsplit(val) 97 | channel_all_pls["firewall"][ 98 | "mp3" 99 | ] = "{}://{}/{}".format( 100 | r1.scheme, r1.netloc, "fw" + r1.path 101 | ) 102 | 103 | channel_pls = self._choose_pls(channel_all_pls, encoding, quality) 104 | 105 | if channel_pls is not None: 106 | channel_data["pls"] = channel_pls 107 | self.channels[pls_id] = channel_data 108 | self.images[pls_id] = channel_data["image"] 109 | 110 | logger.info("Loaded %i SomaFM channels" % (len(self.channels))) 111 | 112 | def extractStreamUrlFromPls(self, pls_uri): 113 | pls_content = self._downloadContent(pls_uri) 114 | if pls_content is None: 115 | logger.error("Cannot fetch %s" % (pls_uri)) 116 | return pls_uri 117 | 118 | # try to find FileX= 119 | try: 120 | m = re.search(r"^(File\d)=(?P\S+)", pls_content, re.M) 121 | if m: 122 | return m.group("stream_url") 123 | else: 124 | return pls_uri 125 | except BaseException: 126 | return pls_uri 127 | 128 | def _choose_pls(self, all_pls, encoding, quality): 129 | if not all_pls: 130 | return None 131 | 132 | if quality in all_pls: 133 | quality_pls = all_pls[quality] 134 | elif self.FALLBACK_QUALITY in all_pls: 135 | quality_pls = all_pls[self.FALLBACK_QUALITY] 136 | else: 137 | quality_pls = all_pls[next(iter(all_pls))] 138 | 139 | if not quality_pls: 140 | return None 141 | 142 | if encoding in quality_pls: 143 | pls = quality_pls[encoding] 144 | elif self.FALLBACK_ENCODING in all_pls: 145 | pls = quality_pls[self.FALLBACK_ENCODING] 146 | else: 147 | pls = quality_pls[next(iter(quality_pls))] 148 | 149 | return pls 150 | 151 | def _downloadContent(self, url): 152 | try: 153 | r = self.session.get(url) 154 | logger.debug("Get %s : %i", url, r.status_code) 155 | 156 | if r.status_code != 200: 157 | logger.error( 158 | "SomaFM: %s is not reachable [http code:%i]", 159 | url, 160 | r.status_code, 161 | ) 162 | return None 163 | 164 | except requests.exceptions.RequestException as e: 165 | logger.error("SomaFM RequestException: %s", e) 166 | except requests.exceptions.ConnectionError as e: 167 | logger.error("SomaFM ConnectionError: %s", e) 168 | except requests.exceptions.URLRequired as e: 169 | logger.error("SomaFM URLRequired: %s", e) 170 | except requests.exceptions.TooManyRedirects as e: 171 | logger.error("SomaFM TooManyRedirects: %s", e) 172 | except Exception as e: 173 | logger.error("SomaFM exception: %s", e) 174 | else: 175 | return r.text 176 | 177 | return None 178 | --------------------------------------------------------------------------------