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