Compare commits

..

14 Commits

Author SHA1 Message Date
02ba383ab0 update readme 2026-01-09 12:04:33 +11:00
20c341de98 update readme 2026-01-09 12:02:35 +11:00
5fbc51fba7 add readme 2026-01-09 11:57:55 +11:00
a09a007787 add snapo server file 2026-01-09 11:56:17 +11:00
f3f2fbd42b first commit 2026-01-09 11:32:39 +11:00
Eric van Blokland
62bf24b9b5 Changes for V5 api compatibility 2024-12-24 16:06:32 +01:00
Mariusz
7b19c20269 Merge pull request #18 from Emrvb/master
Refactored library and radionet class
2023-03-06 18:14:53 +01:00
Eric van Blokland
9c2c78c373 Removed lingering logging statement and fixed caching issue causing tests to fail 2021-09-28 17:32:12 +02:00
Eric van Blokland
55aab87f4e Code cleanup 2021-09-28 17:25:37 +02:00
Eric van Blokland
9382eb2ff4 Support for track uri with station slug instead of id 2021-09-28 17:15:24 +02:00
Eric van Blokland
5f40f6ccad Fixed some oddities with language setting
- Strip whitespaces from setting
- Accept "en" as alternative for "net"
- Log warning instead of error
2021-09-28 16:25:36 +02:00
Eric van Blokland
34e4892975 Fixed error caused by unrecognized favorite not yielding result in search 2021-09-28 16:18:29 +02:00
Eric van Blokland
0df2ad24b7 Fixed setup.py version metadata regex (2/2) 2021-09-10 17:40:13 +02:00
Eric van Blokland
424173da27 Added image support 2021-09-09 20:27:41 +02:00
9 changed files with 576 additions and 308 deletions

49
README.md Normal file
View File

@@ -0,0 +1,49 @@
# Mopidy Music Server Configuration
This repository contains the configuration and setup for the local Mopidy instance. Mopidy acts as the primary music logic server (Radio, Local Files, Extensions) feeding into the Snapcast multi-room audio system.
This system ses snapweb which needs to be set to MainAudio for Piper Voice play http://192.168.20.13:1780/
## 🎵 System Architecture
* **Core:** Mopidy (Python-based music server).
* **Output:** Pipes audio to Snapserver via a Named Pipe (`/tmp/snapfifo`).
* **Frontend:** Iris Web Interface (Port 6680).
* **Extensions:** `mopidy-radionet`, `mopidy-local`, etc.
## 📂 Key Files
* **`mopidy.conf`**: The master configuration file. Controls enabled plugins, file paths, and GStreamer audio pipelines.
* **`requirements.txt`** (Optional): Python pip dependencies for installed plugins.
## ⚙️ Configuration Highlights
### Audio Output (Snapcast Link)
To ensure compatibility with Snapserver, the audio output is forced to **48kHz Stereo 16-bit** and written to the pipe:
```ini
[audio]
output = audioresample ! audioconvert ! audio/x-raw,rate=48000,channels=2,format=S16LE ! wavenc ! filesink location=/tmp/snapfifo
```
## 🚀 Management Commands
**Restart Service:**
```bash
sudo systemctl restart mopidy
```
**Check Status & Logs:**
```bash
sudo systemctl status mopidy
sudo journalctl -u mopidy -f
```
**Rescan Local Library:**
```bash
sudo mopidy local scan
```
**Config Location:**
* `/etc/mopidy/mopidy.conf` (System Service)
* `~/.config/mopidy/mopidy.conf` (User Mode)

View File

@@ -1,8 +1,6 @@
from __future__ import unicode_literals from __future__ import unicode_literals
import re import re
import time
import pykka import pykka
from mopidy import backend from mopidy import backend
@@ -29,8 +27,7 @@ class RadioNetBackend(pykka.ThreadingActor, backend.Backend):
self.uri_schemes = ["radionet"] self.uri_schemes = ["radionet"]
self.radionet.min_bitrate = int(config["radionet"]["min_bitrate"]) self.radionet.min_bitrate = int(config["radionet"]["min_bitrate"])
self.radionet.set_lang(str(config["radionet"]["language"])) self.radionet.set_lang(str(config["radionet"]["language"]).strip())
self.radionet.set_apikey(str(config["radionet"]["api_key"]))
self.radionet.set_favorites( self.radionet.set_favorites(
tuple( tuple(
file_ext.strip("'").lower() for file_ext in config["radionet"]["favorite_stations"] file_ext.strip("'").lower() for file_ext in config["radionet"]["favorite_stations"]
@@ -43,8 +40,9 @@ class RadioNetPlaybackProvider(backend.PlaybackProvider):
return True return True
def translate_uri(self, uri): def translate_uri(self, uri):
identifier = re.findall(r"^radionet:track:?([a-z0-9]+|\d+)?$", uri) identifier = re.findall(r"^radionet:track:?(.+)?$", uri)
if identifier: if identifier:
print(identifier)
return self.backend.radionet.get_stream_url(identifier[0]) return self.backend.radionet.get_stream_url(identifier[0])
return None return None

View File

@@ -4,7 +4,8 @@ import logging
import re import re
from mopidy import backend from mopidy import backend
from mopidy.models import Album, Artist, Ref, SearchResult, Track from mopidy.models import Album, Artist, Ref, SearchResult, Track, Image
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -20,21 +21,18 @@ class RadioNetLibraryProvider(backend.LibraryProvider):
if not uri.startswith("radionet:"): if not uri.startswith("radionet:"):
return None return None
variant, identifier, sorting, page = self.parse_uri(uri) variant, identifier, page = self.parse_uri(uri)
if variant == "station" or variant == "track": if variant == "station" or variant == "track":
identifier = int(identifier)
radio_data = self.backend.radionet.get_station_by_id(identifier) radio_data = self.backend.radionet.get_station_by_id(identifier)
artist = Artist(name=radio_data.name) artist = Artist(name=radio_data.name)
name = "" name = ""
if radio_data.description is not None: if radio_data.description is not None:
name = radio_data.description + " / " name = radio_data.description
name = ( name = (
name name
+ radio_data.continent
+ " / "
+ radio_data.country + radio_data.country
+ " - " + " - "
+ radio_data.city + radio_data.city
@@ -43,7 +41,7 @@ class RadioNetLibraryProvider(backend.LibraryProvider):
album = Album( album = Album(
artists=[artist], artists=[artist],
name=name, name=name,
uri="radionet:station:%s" % (identifier), uri="radionet:station:%s" % radio_data.id,
) )
track = Track( track = Track(
@@ -52,7 +50,7 @@ class RadioNetLibraryProvider(backend.LibraryProvider):
name=radio_data.name, name=radio_data.name,
genre=radio_data.genres, genre=radio_data.genres,
comment=radio_data.description, comment=radio_data.description,
uri="radionet:track:%s" % (identifier), uri="radionet:track:%s" % radio_data.id,
) )
return [track] return [track]
@@ -60,22 +58,39 @@ class RadioNetLibraryProvider(backend.LibraryProvider):
def browse(self, uri): def browse(self, uri):
category, page, value, sorting = self.parse_uri(uri) category, page, value = self.parse_uri(uri)
if category == "root": if category == "root":
return self._browse_root() return self._browse_root()
elif category in ["favorites", "topstations", "localstations"]: elif category in ["favorites", "local"]:
return self._browse_category(category, page) return self._browse_simple_category(category, page)
elif category in ["genres", "topics", "languages", "cities", "countries"]: elif category in ["genres", "topics", "languages", "cities", "countries"]:
return self._browse_sorted_category(category, value, sorting, page) result = self._browse_category(category, value, page)
return result
else: else:
logger.debug("Unknown URI: %s", uri) logger.debug("Unknown URI: %s", uri)
return [] return []
def get_images(self, uris):
images = {}
for uri in uris:
variant, identifier, page = self.parse_uri(uri)
station = self.backend.radionet.get_station_by_id(identifier)
if station:
images[uri] = []
if station.image_tiny:
images[uri].append(Image(uri=station.image_tiny, height=44, width=44))
if station.image_small:
images[uri].append(Image(uri=station.image_small, height=100, width=100))
if station.image_medium:
images[uri].append(Image(uri=station.image_medium, height=175, width=175))
if station.image_large:
images[uri].append(Image(uri=station.image_large, height=300, width=300))
return images
def _browse_root(self): def _browse_root(self):
directories = [ directories = [
self.ref_directory("radionet:topstations", "Top stations"), self.ref_directory("radionet:local", "Local stations"),
self.ref_directory("radionet:localstations", "Local stations"),
self.ref_directory("radionet:genres", "Genres"), self.ref_directory("radionet:genres", "Genres"),
self.ref_directory("radionet:topics", "Topics"), self.ref_directory("radionet:topics", "Topics"),
self.ref_directory("radionet:languages", "Languages"), self.ref_directory("radionet:languages", "Languages"),
@@ -86,22 +101,22 @@ class RadioNetLibraryProvider(backend.LibraryProvider):
directories.insert(0, self.ref_directory("radionet:favorites", "Favorites")) directories.insert(0, self.ref_directory("radionet:favorites", "Favorites"))
return directories return directories
def _browse_category(self, category, page): def _browse_simple_category(self, category, page):
result = [] result = []
if category == "favorites": if category == "favorites":
items = self._get_favorites() items = self._get_favorites()
if items: if items:
for item in items: for item in items:
result.append(self.station_to_ref(item)) result.append(self.station_to_ref(item))
elif category == "topstations": # elif category == "topstations":
items = self._get_topstations() # items = self._get_topstations()
if items: # if items:
for item in items: # for item in items:
result.append(self.station_to_ref(item)) # result.append(self.station_to_ref(item))
elif not page: elif not page:
pages = self._get_category_pages(category) pages = self._get_simple_category_pages(category)
if pages == 1: if pages == 1:
items = self._get_category(category, 1) items = self._get_simple_category(category, 1)
if items: if items:
for item in items: for item in items:
result.append(self.station_to_ref(item)) result.append(self.station_to_ref(item))
@@ -120,7 +135,7 @@ class RadioNetLibraryProvider(backend.LibraryProvider):
result.append(self.station_to_ref(item)) result.append(self.station_to_ref(item))
return result return result
def _browse_sorted_category(self, category, value, sorting, page): def _browse_category(self, category, value, page):
result = [] result = []
if not value: if not value:
@@ -129,25 +144,14 @@ class RadioNetLibraryProvider(backend.LibraryProvider):
for item in items: for item in items:
result.append( result.append(
self.ref_directory( self.ref_directory(
"radionet:{0}:{1}".format(category, item["systemEnglish"]), "radionet:{0}:{1}".format(category, item["slug"]),
item["localized"], item["name"],
) )
) )
elif not sorting or sorting not in ["rank", "az"]:
result.append(
self.ref_directory(
"radionet:{0}:{1}:rank".format(category, value), "By rank"
)
)
result.append(
self.ref_directory(
"radionet:{0}:{1}:az".format(category, value), "Alphabetical"
)
)
elif not page: elif not page:
pages = self._get_sorted_category_pages(category, value) pages = self._get_category_pages(category, value)
if pages == 1: if pages == 1:
items = self._get_sorted_category(category, value, sorting, 1) items = self._get_category(category, value, 1)
if items: if items:
for item in items: for item in items:
result.append(self.station_to_ref(item)) result.append(self.station_to_ref(item))
@@ -155,14 +159,14 @@ class RadioNetLibraryProvider(backend.LibraryProvider):
for index in range(pages): for index in range(pages):
result.append( result.append(
self.ref_directory( self.ref_directory(
"radionet:{0}:{1}:{2}:{3}".format( "radionet:{0}:{1}:{2}".format(
category, value, sorting, str(index + 1) category, value, str(index + 1)
), ),
str(index + 1), str(index + 1),
) )
) )
else: else:
items = self._get_sorted_category(category, value, sorting, page) items = self._get_category(category, value, int(page))
if items: if items:
for item in items: for item in items:
result.append(self.station_to_ref(item)) result.append(self.station_to_ref(item))
@@ -184,19 +188,19 @@ class RadioNetLibraryProvider(backend.LibraryProvider):
return self.backend.radionet.get_countries() return self.backend.radionet.get_countries()
def _get_topstations(self): def _get_topstations(self):
return self.backend.radionet.get_category("topstations", 1) return self.backend.radionet.get_simple_category("local", 1)
def _get_sorted_category(self, category, name, sorting, page): def _get_category(self, category, name, page):
return self.backend.radionet.get_sorted_category(category, name, sorting, page) return self.backend.radionet.get_category(category, name, page)
def _get_sorted_category_pages(self, category, name): def _get_category_pages(self, category, name):
return self.backend.radionet.get_sorted_category_pages(category, name) return self.backend.radionet.get_category_pages(category, name)
def _get_category(self, category, page): def _get_simple_category(self, category, page):
return self.backend.radionet.get_category(category, page) return self.backend.radionet.get_simple_category(category, page)
def _get_category_pages(self, category): def _get_simple_category_pages(self, category):
return self.backend.radionet.get_category_pages(category) return self.backend.radionet.get_simple_category_pages(category)
def _get_favorites(self): def _get_favorites(self):
return self.backend.radionet.get_favorites() return self.backend.radionet.get_favorites()
@@ -214,7 +218,7 @@ class RadioNetLibraryProvider(backend.LibraryProvider):
def station_to_ref(self, station): def station_to_ref(self, station):
return Ref.track( return Ref.track(
uri="radionet:station:%s" % (station.id), uri="radionet:station:%s" % station.id,
name=station.name, name=station.name,
) )
@@ -237,22 +241,20 @@ class RadioNetLibraryProvider(backend.LibraryProvider):
category = None category = None
value = None value = None
page = None page = None
sorting = None
result = re.findall( result = re.findall(
r"^radionet:(genres|topics|languages|cities|countries)(:([^:]+)(:(rank|az)(:([0-9]+))?)?)?$", r"^radionet:(genres|topics|languages|cities|countries)(:([^:]+)(:([0-9]+))?)?$",
uri, uri,
) )
if result: if result:
category = result[0][0] category = result[0][0]
value = result[0][2] value = result[0][2]
sorting = result[0][4] page = result[0][4]
page = result[0][6]
else: else:
result = re.findall( result = re.findall(
r"^radionet:(root|favorites|topstations|localstations|station|track)(:([0-9]+))?$", r"^radionet:(root|favorites|local|station|track)(:([0-9]+))?$",
uri, uri,
) )
@@ -260,4 +262,14 @@ class RadioNetLibraryProvider(backend.LibraryProvider):
category = result[0][0] category = result[0][0]
page = result[0][2] page = result[0][2]
return category, page, value, sorting else:
result = re.findall(
r"^radionet:(track|station):([^:]+)$",
uri,
)
if result:
category = result[0][0]
page = result[0][1]
return category, page, value

View File

@@ -2,15 +2,40 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from __future__ import unicode_literals from __future__ import unicode_literals
import json
import logging import logging
import re
import time import time
import urllib
import requests from urllib.request import urlopen, Request
from mopidy import httpclient
import math
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
REGIONS = {
'at': 'de-AT',
'au': 'en-AU',
'br': 'pt-BR',
'ca': 'en-CA',
'co': 'es-CO',
'de': 'de-DE',
'dk': 'da-DK',
'es': 'es-ES',
'fr': 'fr-FR',
'ie': 'en-IE',
'it': 'it-IT',
'mx': 'es-MX',
'nl': 'nl-NL',
'nz': 'en-NZ',
'pl': 'pl-PL',
'pt': 'pt-PT',
'se': 'sv-SE',
'uk': 'en-GB',
'us': 'en-US',
'za': 'en-ZA',
}
class Station(object): class Station(object):
id = None id = None
@@ -20,16 +45,19 @@ class Station(object):
genres = None genres = None
name = None name = None
stream_url = None stream_url = None
image = None image_tiny = None
image_small = None
image_medium = None
image_large = None
description = None description = None
playable = False playable = False
class RadioNetClient(object): class RadioNetClient(object):
base_url = "https://radio.net/" base_url = "https://prod.radio-api.net"
language = REGIONS['us']
user_agent = 'Radio.net - Web V5'
session = requests.Session()
api_prefix = None
min_bitrate = 96 min_bitrate = 96
max_top_stations = 100 max_top_stations = 100
station_bookmarks = None station_bookmarks = None
@@ -54,48 +82,26 @@ class RadioNetClient(object):
def __init__(self, proxy_config=None, user_agent=None): def __init__(self, proxy_config=None, user_agent=None):
super(RadioNetClient, self).__init__() super(RadioNetClient, self).__init__()
self.session = requests.Session()
if proxy_config is not None:
proxy = httpclient.format_proxy(proxy_config)
self.session.proxies.update({"http": proxy, "https": proxy})
full_user_agent = httpclient.format_user_agent(user_agent)
self.session.headers.update({"user-agent": full_user_agent})
self.session.headers.update({"cache-control": "no-cache"})
self.update_prefix()
def __del__(self):
self.session.close()
def set_lang(self, lang): def set_lang(self, lang):
langs = ["net", "de", "at", "fr", "pt", "es", "dk", "se", "it", "pl"] if lang in REGIONS:
if lang in langs: self.language = REGIONS[lang]
self.base_url = self.base_url.replace(".net", "." + lang)
else: else:
logging.error("Radio.net not supported language: %s", str(lang)) logging.warning("Radio.net not supported language: %s, defaulting to English", str(lang))
self.update_prefix()
def update_prefix(self):
tmp_str = self.session.get(self.base_url)
lang = self.base_url.split(".")[-1].replace("/", "")
self.api_prefix = "https://api.radio." + lang + "/info/v2"
def set_apikey(self, api_key):
self.api_key = api_key
def do_get(self, api_suffix, url_params=None): def do_get(self, api_suffix, url_params=None):
if self.api_prefix is None: try:
return None url = self.base_url + api_suffix
if url_params is not None:
url += "?" + urllib.parse.urlencode(url_params)
req = Request(url)
req.add_header('accept-language', self.language)
req.add_header('user-agent', self.user_agent)
response = urlopen(req).read()
except Exception as err:
logging.error(f'_open_url error: {err}')
response = None
if url_params is None: return json.loads(response)
url_params = {}
url_params["apikey"] = self.api_key
response = self.session.get(self.api_prefix + api_suffix, params=url_params)
return response
def get_cache(self, key): def get_cache(self, key):
if self.cache.get(key) is not None and self.cache[key].expired() is False: if self.cache.get(key) is not None and self.cache[key].expired() is False:
@@ -122,49 +128,25 @@ class RadioNetClient(object):
if cache is not None: if cache is not None:
return cache return cache
api_suffix = "/search/station" api_suffix = "/stations/details"
url_params = { url_params = {
"station": station_id, "stationIds": station_id,
} }
response = self.do_get(api_suffix, url_params) response = self.do_get(api_suffix, url_params)
if response.status_code != 200: if response is None or len(response) == 0:
logger.error( logger.error("Radio.net: Error on get station by id " + str(station_id))
"Radio.net: Error on get station by id "
+ str(station_id)
+ ". Error: "
+ response.text
)
return False return False
logger.debug("Radio.net: Done get top stations list") logger.debug("Radio.net: Done get top stations list")
json = response.json()
if not self.stations_by_id.get(json["id"]): json = response[0]
station = Station()
station.playable = True
else:
station = self.stations_by_id[json["id"]]
station.id = json["id"]
station.continent = json["continent"]
station.country = json["country"]
station.city = json["city"]
station.genres = ", ".join(json["genres"])
station.name = json["name"]
station.slug = json["subdomain"]
station.stream_url = self._get_stream_url(json["streamUrls"], self.min_bitrate)
station.image = json["logo100x100"]
station.description = json["shortDescription"]
if json["playable"] == "PLAYABLE":
station.playable = True
self.stations_by_id[station.id] = station station = self._get_station_from_search_result(json)
self.stations_by_slug[station.slug] = station
self.set_cache("station/" + str(station.id), station, 1440) self.set_cache(cache_key, station, 1440)
self.set_cache("station/" + station.slug, station, 1440)
return station return station
def _get_station_from_search_result(self, result): def _get_station_from_search_result(self, result):
@@ -175,40 +157,56 @@ class RadioNetClient(object):
station = self.stations_by_id[result["id"]] station = self.stations_by_id[result["id"]]
station.id = result["id"] station.id = result["id"]
if result["continent"] is not None:
station.continent = result["continent"]["value"]
else:
station.continent = ""
if result["country"] is not None: if "country" in result:
station.country = result["country"]["value"] station.country = result["country"]
else: else:
station.country = "" station.country = ""
if result["city"] is not None: if "city" in result:
station.city = result["city"]["value"] station.city = result["city"]
else: else:
station.city = "" station.city = ""
if result["name"] is not None: if "name" in result:
station.name = result["name"]["value"] station.name = result["name"]
else: else:
station.name = "" station.name = ""
if result["subdomain"] is not None: if "shortDescription" in result:
station.slug = result["subdomain"]["value"] station.description = result["shortDescription"]
else:
station.slug = ""
if result["shortDescription"] is not None:
station.description = result["shortDescription"]["value"]
else: else:
station.description = "" station.description = ""
station.image = result["logo100x100"] if "genres" in result:
station.genres = ", ".join(result["genres"])
else:
station.genres = ""
if "logo44x44" in result:
station.image_tiny = result["logo44x44"]
else:
station.image_tiny = ""
if "logo100x100" in result:
station.image_tiny = result["logo100x100"]
else:
station.image_tiny = ""
if "logo175x175" in result:
station.image_medium = result["logo175x175"]
else:
station.image_medium = ""
if "logo300x300" in result:
station.image_large = result["logo300x300"]
else:
station.image_large = ""
if "streams" in result:
station.stream_url = self._get_stream_url(result["streams"], self.min_bitrate)
self.stations_by_id[station.id] = station self.stations_by_id[station.id] = station
self.stations_by_slug[station.slug] = station
return station return station
def get_genres(self): def get_genres(self):
@@ -231,106 +229,100 @@ class RadioNetClient(object):
if cached is not None: if cached is not None:
return cached return cached
api_suffix = "/search/get" + key api_suffix = "/stations/tags"
response = self.do_get(api_suffix) response = self.do_get(api_suffix)
if response.status_code != 200: if response is None:
logger.error( logger.error("Radio.net: Error on get item list")
"Radio.net: Error on get item list "
+ str(api_suffix)
+ ". Error: "
+ response.text
)
return False return False
return self.set_cache(key, response.json(), 1440)
def get_sorted_category(self, category, name, sorting, page): return self.set_cache(key, self._filter_result(response, key, 0), 1440)
def _filter_result(self, data, tag, min_count_stations):
api_result = data.get(tag, [])
if api_result and min_count_stations:
# filter result by minimum count of stations
result = []
for item in api_result:
if item['count'] >= min_count_stations:
result.append(item)
return result
return api_result
def get_category(self, category, name, page=1):
results = [] results = []
for result in self._get_sorted_category(category, name, sorting, page): for result in self._get_category(category, name, page):
results.append(self._get_station_from_search_result(result)) results.append(self._get_station_from_search_result(result))
return results return results
def _get_sorted_category(self, category, name, sorting, page): def _get_category(self, category, name, page=1):
if sorting == "az": cache_key = category + "/" + name + "/" + str(page)
sorting = "STATION_NAME"
else:
sorting = "RANK"
cache_key = category + "/" + name + "/" + sorting + "/" + str(page)
cache = self.get_cache(cache_key) cache = self.get_cache(cache_key)
if cache is not None: if cache is not None:
return cache return cache
api_suffix = "/search/stationsby" + self.category_param_map[category] api_suffix = "/stations/by-tag"
url_params = { url_params = {
self.category_param_map[category]: name, "tagType": category,
"sorttype": sorting, "slug": name,
"sizeperpage": 50, "count": 50,
"pageindex": page, "offset": (page - 1) * 50,
} }
response = self.do_get(api_suffix, url_params) response = self.do_get(api_suffix, url_params)
if response.status_code != 200: if response is None:
logger.error( logger.error("Radio.net: Error on get station by " + str(category))
"Radio.net: Error on get station by "
+ str(category)
+ ". Error: "
+ response.text
)
return False return False
json = response.json() self.set_cache(category + "/" + name, int(math.ceil(response["totalCount"] / 50)), 10)
self.set_cache(category + "/" + name, int(json["numberPages"]), 10) return self.set_cache(cache_key, response["playables"], 10)
return self.set_cache(cache_key, json["categories"][0]["matches"], 10)
def get_category(self, category, page): def get_simple_category(self, category, page=1):
results = [] results = []
for result in self._get_category(category, page): for result in self._get_simple_category(category, page):
results.append(self._get_station_from_search_result(result)) results.append(self._get_station_from_search_result(result))
return results return results
def _get_category(self, category, page): def _get_simple_category(self, category, page=1):
cache_key = category + "/" + str(page) cache_key = category + "/" + str(page)
cache = self.get_cache(cache_key) cache = self.get_cache(cache_key)
if cache is not None: if cache is not None:
return cache return cache
api_suffix = "/search/" + category api_suffix = "/stations/" + category
url_params = {"sizeperpage": 50, "pageindex": page} url_params = {"count": 50, "offset": (page - 1) * 50}
response = self.do_get(api_suffix, url_params) response = self.do_get(api_suffix, url_params)
if response.status_code != 200: if response is None:
logger.error( logger.error("Radio.net: Error on get station by " + str(category))
"Radio.net: Error on get station by "
+ str(category)
+ ". Error: "
+ response.text
)
return False return False
json = response.json() self.set_cache(category, int(math.ceil(response["totalCount"] / 50)), 10)
self.set_cache(category, int(json["numberPages"]), 10) return self.set_cache(cache_key, response["playables"], 10)
return self.set_cache(cache_key, json["categories"][0]["matches"], 10)
def get_sorted_category_pages(self, category, name): def get_category_pages(self, category, name):
cache_key = category + "/" + name cache_key = category + "/" + name
cache = self.get_cache(cache_key) cache = self.get_cache(cache_key)
if cache is not None: if cache is not None:
return cache return cache
self.get_sorted_category(category, name, "rank", 1) self.get_category(category, name, 1)
return self.get_cache(cache_key) return self.get_cache(cache_key)
def get_category_pages(self, category): def get_simple_category_pages(self, category):
cache_key = category cache_key = category
cache = self.get_cache(cache_key) cache = self.get_cache(cache_key)
if cache is not None: if cache is not None:
return cache return cache
self.get_category(category, 1) self.get_simple_category(category, 1)
return self.get_cache(cache_key) return self.get_cache(cache_key)
@@ -346,26 +338,27 @@ class RadioNetClient(object):
favorite_stations = [] favorite_stations = []
for station_slug in self.favorites: for station_slug in self.favorites:
station = self.get_station_by_slug(station_slug) station = self.get_station_by_id(station_slug)
if station is False: if station is False:
api_suffix = "/search/stationsonly" api_suffix = "/stations/search"
url_params = { url_params = {
"query": station_slug, "query": station_slug,
"pageindex": 1, "count": 1,
"offset": 0,
} }
response = self.do_get(api_suffix, url_params) response = self.do_get(api_suffix, url_params)
if response.status_code != 200: if response is None:
logger.error("Radio.net: Search error " + response.text) logger.error("Radio.net: Search error")
else: else:
logger.debug("Radio.net: Done search") logger.debug("Radio.net: Done search")
json = response.json()
# take only the first match! if "playables" in response and len(response["playables"]) > 0:
station = self._get_station_from_search_result( # take only the first match!
json["categories"][0]["matches"][0] station = self._get_station_from_search_result(response["playables"][0])
) else:
logger.warning("Radio.net: No results for %s", station_slug)
if station and station.playable: if station and station.playable:
favorite_stations.append(station) favorite_stations.append(station)
@@ -377,29 +370,30 @@ class RadioNetClient(object):
def do_search(self, query_string, page_index=1, search_results=None): def do_search(self, query_string, page_index=1, search_results=None):
api_suffix = "/search/stationsonly" api_suffix = "/stations/search"
url_params = { url_params = {
"query": query_string, "query": query_string,
"sizeperpage": 50, "count": 50,
"pageindex": page_index, "offset": (page_index - 1) * 50,
} }
logger.info(url_params)
response = self.do_get(api_suffix, url_params) response = self.do_get(api_suffix, url_params)
if response.status_code != 200: if response is None:
logger.error("Radio.net: Search error " + response.text) logger.error("Radio.net: Search error")
else: else:
logger.debug("Radio.net: Done search") logger.info("Radio.net: Done search")
if search_results is None: if search_results is None:
search_results = [] search_results = []
json = response.json() for match in response["playables"]:
for match in json["categories"][0]["matches"]:
station = self._get_station_from_search_result(match) station = self._get_station_from_search_result(match)
if station and station.playable: if station and station.playable:
search_results.append(station) search_results.append(station)
number_pages = int(json["numberPages"]) number_pages = int(math.ceil(response["totalCount"] / 50))
if number_pages >= page_index: # Search is utterly broken, don't retrieve more than 10 pages
logger.info(page_index)
if number_pages > page_index and page_index < 10:
self.do_search(query_string, page_index + 1, search_results) self.do_search(query_string, page_index + 1, search_results)
else: else:
logger.info( logger.info(
@@ -417,12 +411,13 @@ class RadioNetClient(object):
stream_url = None stream_url = None
for stream in stream_json: for stream in stream_json:
if int(stream["bitRate"]) >= bit_rate and stream["streamStatus"] == "VALID": logger.info("Radio.net: Found stream URL " + stream["url"])
stream_url = stream["streamUrl"] if ("bitRate" in stream and int(stream["bitRate"]) >= bit_rate) and stream["status"] == "VALID":
stream_url = stream["url"]
break break
if stream_url is None and len(stream_json) > 0: if stream_url is None and len(stream_json) > 0:
stream_url = stream_json[0]["streamUrl"] stream_url = stream_json[0]["url"]
return stream_url return stream_url
@@ -430,7 +425,7 @@ class RadioNetClient(object):
class CacheItem(object): class CacheItem(object):
def __init__(self, value, expires=10): def __init__(self, value, expires=10):
self._value = value self._value = value
self._expires = expires = time.time() + expires * 60 self._expires = time.time() + expires * 60
def expired(self): def expired(self):
return self._expires < time.time() return self._expires < time.time()

View File

@@ -7,7 +7,7 @@ from setuptools import find_packages, setup
def get_version(filename): def get_version(filename):
with open(filename) as fh: with open(filename) as fh:
metadata = dict(re.findall("__([a-z]+)__ = \"([^']+)\"", fh.read())) metadata = dict(re.findall("__([a-z]+)__ = \"([^\"]+)\"", fh.read()))
return metadata['version'] return metadata['version']

238
snapserver.conf Normal file
View File

@@ -0,0 +1,238 @@
###############################################################################
# ______ #
# / _____) #
# ( (____ ____ _____ ____ ___ _____ ____ _ _ _____ ____ #
# \____ \ | _ \ (____ || _ \ /___)| ___ | / ___)| | | || ___ | / ___) #
# _____) )| | | |/ ___ || |_| ||___ || ____|| | \ V / | ____|| | #
# (______/ |_| |_|\_____|| __/ (___/ |_____)|_| \_/ |_____)|_| #
# |_| #
# #
# Snapserver config file #
# #
###############################################################################
# default values are commented
# uncomment and edit to change them
# Settings can be overwritten on command line with:
# "--<section>.<name>=<value>", e.g. --server.threads=4
# General server settings #####################################################
#
[server]
# Number of additional worker threads to use
# - For values < 0 the number of threads will be 2 (on single and dual cores)
# or 4 (for quad and more cores)
# - 0 will utilize just the processes main thread and might cause audio drops
# in case there are a couple of longer running tasks, such as encoding
# multiple audio streams
#threads = -1
# the pid file when running as daemon (-d or --daemon)
#pidfile = /var/run/snapserver/pid
# the user to run as when daemonized (-d or --daemon)
#user = snapserver
# the group to run as when daemonized (-d or --daemon)
#group = snapserver
# directory where persistent data is stored (server.json)
# if empty, data dir will be
# - "/var/lib/snapserver/" when running as daemon
# - "$HOME/.config/snapserver/" when not running as daemon
#datadir =
#
###############################################################################
# Secure Socket Layer #########################################################
#
[ssl]
# Certificate files are either specified by their full or relative path. Certificates with
# relative path are searched for in the current path and in "/etc/snapserver/certs"
# Certificate file in PEM format
#certificate =
# Private key file in PEM format
#certificate_key =
# Password for decryption of the certificate_key (only needed for encrypted certificate_key file)
#key_password =
# Verify client certificates
#verify_clients = false
# List of client CA certificate files, can be configured multiple times
#client_cert =
#client_cert =
#
###############################################################################
# HTTP RPC ####################################################################
#
[http]
# enable HTTP Json RPC (HTTP POST and websockets)
#enabled = true
# address to listen on, can be specified multiple times
# use "0.0.0.0" to bind to any IPv4 address or :: to bind to any IPv6 address
# or "127.0.0.1" or "::1" to bind to localhost IPv4 or IPv6, respectively
# use the address of a specific network interface to just listen to and accept
# connections from that interface
#bind_to_address = ::
# which port the server should listen to
#port = 1780
# enable HTTPS Json RPC (HTTPS POST and ssl websockets)
#ssl_enabled = false
# same as 'bind_to_address' but for SSL
#ssl_bind_to_address = ::
# same as 'port' but for SSL
#ssl_port = 1788
# serve a website from the doc_root location
# disabled if commented or empty
doc_root = /usr/share/snapserver/snapweb
# Hostname or IP under which clients can reach this host
# used to serve cached cover art
# use <hostname> as placeholder for your actual host name
#host = <hostname>
# Optional custom URL prefix for generated URLs where clients can reach
# cached album art, to e.g. match scheme behind a reverse proxy.
#url_prefix = https://<hostname>
#
###############################################################################
# TCP RPC #####################################################################
#
[tcp]
# enable TCP Json RPC
#enabled = true
# address to listen on, can be specified multiple times
# use "0.0.0.0" to bind to any IPv4 address or :: to bind to any IPv6 address
# or "127.0.0.1" or "::1" to bind to localhost IPv4 or IPv6, respectively
# use the address of a specific network interface to just listen to and accept
# connections from that interface
#bind_to_address = ::
# which port the server should listen to
#port = 1705
#
###############################################################################
# Stream settings #############################################################
#
[stream]
# address to listen on, can be specified multiple times
# use "0.0.0.0" to bind to any IPv4 address or :: to bind to any IPv6 address
# or "127.0.0.1" or "::1" to bind to localhost IPv4 or IPv6, respectively
# use the address of a specific network interface to just listen to and accept
# connections from that interface
#bind_to_address = ::
# which port the server should listen to
#port = 1704
# source URI of the PCM input stream, can be configured multiple times
# The following notation is used in this paragraph:
# <angle brackets>: the whole expression must be replaced with your specific setting
# [square brackets]: the whole expression is optional and can be left out
# [key=value]: if you leave this option out, "value" will be the default for "key"
#
# Format: TYPE://host/path?name=<name>[&codec=<codec>][&sampleformat=<sampleformat>][&chunk_ms=<chunk ms>][&controlscript=<control script filename>[&controlscriptparams=<control script command line arguments>]]
# parameters have the form "key=value", they are concatenated with an "&" character
# parameter "name" is mandatory for all sources, while codec, sampleformat and chunk_ms are optional
# and will override the default codec, sampleformat or chunk_ms settings
# Available types are:
# pipe: pipe:///<path/to/pipe>?name=<name>[&mode=create], mode can be "create" or "read"
# librespot: librespot:///<path/to/librespot>?name=<name>[&username=<my username>&password=<my password>][&devicename=Snapcast][&bitrate=320][&wd_timeout=7800][&volume=100][&onevent=""][&normalize=false][&autoplay=false][&params=<generic librepsot process arguments>]
# note that you need to have the librespot binary on your machine
# sampleformat will be set to "44100:16:2"
# file: file:///<path/to/PCM/file>?name=<name>
# process: process:///<path/to/process>?name=<name>[&wd_timeout=0][&log_stderr=false][&params=<process arguments>]
# airplay: airplay:///<path/to/airplay>?name=<name>[&port=5000]
# note that you need to have the airplay binary on your machine
# sampleformat will be set to "44100:16:2"
# tcp server: tcp://<listen IP, e.g. 127.0.0.1>:<port>?name=<name>[&mode=server]
# tcp client: tcp://<server IP, e.g. 127.0.0.1>:<port>?name=<name>&mode=client
# alsa: alsa:///?name=<name>&device=<alsa device>[&send_silence=false][&idle_threshold=100][&silence_threshold_percent=0.0]
# meta: meta:///<name of source#1>/<name of source#2>/.../<name of source#N>?name=<name>
#source = pipe:///tmp/snapfifo?name=default
source = meta:///Announcements/default?name=MainAudio
source = tcp://0.0.0.0:4953?name=Announcements&sampleformat=48000:16:2&codec=pcm
source = pipe:///run/snapserver/snapfifo?name=default
#source = spotify:///librespot?name=Spotify&username=guzzbinkles&password=Finn05092013&devicename=Snapcast&bitrate=320
#announcements
#source = tcp://0.0.0.0:4953?name=Announcements&sampleformat=22050:16:1&codec=pcm
#source = spotify:///librespot?name=Spotify&devicename=Snapcast&bitrate=320
# Plugin directory, containing scripts, referred by "controlscript"
#plugin_dir = /usr/share/snapserver/plug-ins
# Default sample format: <sample rate>:<bits per sample>:<channels>
#sampleformat = 48000:16:2
# Default transport codec
# (flac|ogg|opus|pcm)[:options]
# Start Snapserver with "--stream:codec=<codec>:?" to get codec specific options
#codec = flac
# Default source stream read chunk size [ms].
# The server will continously read this number of milliseconds from the source into buffer and pass this buffer to the encoder.
# The encoded buffer is sent to the clients. Some codecs have a higher latency and will need more data, e.g. Flac will need ~26ms chunks
#chunk_ms = 20
# Buffer [ms]
# The end-to-end latency, from capturing a sample on the server until the sample is played-out on the client
#buffer = 1000
# Send audio to muted clients
#send_to_muted = false
#
# Streaming client options ####################################################
#
[streaming_client]
# Volume assigned to new snapclients [percent]
# Defaults to 100 if unset
#initial_volume = 100
#
###############################################################################
# Logging options #############################################################
#
[logging]
# log sink [null,system,stdout,stderr,file:<filename>]
# when left empty: if running as daemon "system" else "stdout"
#sink =
# log filter <tag>:<level>[,<tag>:<level>]*
# with tag = * or <log tag> and level = [trace,debug,info,notice,warning,error,fatal]
#filter = *:info
#
###############################################################################

View File

@@ -6,19 +6,21 @@ from mopidy_radionet import backend
from mopidy_radionet.radionet import RadioNetClient from mopidy_radionet.radionet import RadioNetClient
from mopidy_radionet.library import RadioNetLibraryProvider from mopidy_radionet.library import RadioNetLibraryProvider
@pytest.fixture @pytest.fixture
def backend_mock(): def backend_mock():
backend_mock = mock.Mock(spec=backend.RadioNetBackend) backend_mock = mock.Mock(spec=backend.RadioNetBackend)
backend_mock.radionet = RadioNetClient(proxy_config=None) backend_mock.radionet = RadioNetClient(proxy_config=None)
backend_mock.library = RadioNetLibraryProvider(backend=backend_mock) backend_mock.library = RadioNetLibraryProvider(backend=backend_mock)
backend_mock.radionet.set_apikey('test')
backend_mock.radionet.set_favorites({'lush'}) backend_mock.radionet.set_favorites({'lush'})
return backend_mock return backend_mock
@pytest.fixture @pytest.fixture
def library(backend_mock): def library(backend_mock):
return backend_mock.library return backend_mock.library
@pytest.fixture @pytest.fixture
def radionet(backend_mock): def radionet(backend_mock):
return backend_mock.radionet return backend_mock.radionet

View File

@@ -1,13 +1,10 @@
from unittest import mock
def test_browse_root(library): def test_browse_root(library):
results = library.browse('radionet:root'); results = library.browse('radionet:root')
assert 8 == len(results) assert 7 == len(results)
def test_browse_localstations(library): def test_browse_localstations(library):
results = library.browse('radionet:localstations'); results = library.browse('radionet:local')
assert len(results) > 0 assert len(results) > 0
page_uri = results[0].uri if results is not None else None page_uri = results[0].uri if results is not None else None
@@ -18,28 +15,23 @@ def test_browse_localstations(library):
# assert len(results) > 0 # assert len(results) > 0
def test_browse_topstations(library):
results = library.browse('radionet:topstations');
assert len(results) > 0
def test_browse_genres(library): def test_browse_genres(library):
results = library.browse('radionet:genres'); genres = library.browse('radionet:genres')
assert len(results) > 0 assert len(genres) > 0
cat_uri = results[0].uri if results is not None else None cat_uri = genres[0].uri if genres is not None else None
assert cat_uri is not None assert cat_uri is not None
results = library.browse(cat_uri) pages = library.browse(cat_uri)
assert len(results) == 2 assert len(pages) == 7
sort_uri = results[0].uri if results is not None else None page_uri = pages[0].uri if pages is not None else None
assert sort_uri is not None assert page_uri is not None
results = library.browse(sort_uri) results = library.browse(page_uri)
assert len(results) > 0 assert len(results) > 0
page_uri = results[0].uri if results is not None else None page_uri = pages[len(pages) - 1].uri if results is not None else None
assert page_uri is not None assert page_uri is not None
results = library.browse(page_uri) results = library.browse(page_uri)
@@ -47,20 +39,14 @@ def test_browse_genres(library):
def test_browse_topics(library): def test_browse_topics(library):
results = library.browse('radionet:topics'); results = library.browse('radionet:topics')
assert len(results) > 0 assert len(results) > 2
cat_uri = results[0].uri if results is not None else None cat_uri = results[2].uri if results is not None else None
assert cat_uri is not None assert cat_uri is not None
results = library.browse(cat_uri) results = library.browse(cat_uri)
assert len(results) == 2 assert len(results) > 2
sort_uri = results[0].uri if results is not None else None
assert sort_uri is not None
results = library.browse(sort_uri)
assert len(results) > 0
page_uri = results[0].uri if results is not None else None page_uri = results[0].uri if results is not None else None
assert page_uri is not None assert page_uri is not None
@@ -71,20 +57,14 @@ def test_browse_topics(library):
def test_browse_languages(library): def test_browse_languages(library):
results = library.browse('radionet:languages'); results = library.browse('radionet:languages')
assert len(results) > 0 assert len(results) > 0
cat_uri = results[5].uri if results is not None else None cat_uri = results[5].uri if results is not None else None
assert cat_uri is not None assert cat_uri is not None
results = library.browse(cat_uri) results = library.browse(cat_uri)
assert len(results) == 2 assert len(results) == 11
sort_uri = results[0].uri if results is not None else None
assert sort_uri is not None
results = library.browse(sort_uri)
assert len(results) > 0
page_uri = results[0].uri if results is not None else None page_uri = results[0].uri if results is not None else None
assert page_uri is not None assert page_uri is not None
@@ -95,7 +75,7 @@ def test_browse_languages(library):
def test_browse_cities(library): def test_browse_cities(library):
results = library.browse('radionet:cities'); results = library.browse('radionet:cities')
assert len(results) > 0 assert len(results) > 0
cat_uri = results[0].uri if results is not None else None cat_uri = results[0].uri if results is not None else None
@@ -104,12 +84,6 @@ def test_browse_cities(library):
results = library.browse(cat_uri) results = library.browse(cat_uri)
assert len(results) == 2 assert len(results) == 2
sort_uri = results[0].uri if results is not None else None
assert sort_uri is not None
results = library.browse(sort_uri)
assert len(results) > 0
page_uri = results[0].uri if results is not None else None page_uri = results[0].uri if results is not None else None
assert page_uri is not None assert page_uri is not None
@@ -119,20 +93,14 @@ def test_browse_cities(library):
def test_browse_countries(library): def test_browse_countries(library):
results = library.browse('radionet:countries'); results = library.browse('radionet:countries')
assert len(results) > 0 assert len(results) > 0
cat_uri = results[0].uri if results is not None else None cat_uri = results[0].uri if results is not None else None
assert cat_uri is not None assert cat_uri is not None
results = library.browse(cat_uri) results = library.browse(cat_uri)
assert len(results) == 2 assert len(results) == 4
sort_uri = results[0].uri if results is not None else None
assert sort_uri is not None
results = library.browse(sort_uri)
assert len(results) > 0
page_uri = results[0].uri if results is not None else None page_uri = results[0].uri if results is not None else None
assert page_uri is not None assert page_uri is not None
@@ -143,27 +111,31 @@ def test_browse_countries(library):
def test_browse_favorites(library): def test_browse_favorites(library):
results = library.browse('radionet:favorites'); results = library.browse('radionet:favorites')
assert 1 == len(results) assert 1 == len(results)
def test_search(library): def test_search(library):
result = library.search({'any': ['radio ram']}) result = library.search({'any': ['katze']})
assert len(result.tracks) > 0 assert len(result.tracks) > 0
old_length = len(result.tracks) old_length = len(result.tracks)
result = library.search({'any': ['radio ram']}) result = library.search({'any': ['katze']})
assert len(result.tracks) == old_length assert len(result.tracks) == old_length
def test_lookup(library): def test_lookup(library):
results = library.browse('radionet:favorites')
results = library.browse('radionet:favorites');
assert 1 == len(results) assert 1 == len(results)
for result in results: for result in results:
assert library.lookup(result.uri) is not None assert library.lookup(result.uri) is not None
def test_track_by_slug(library):
results = library.lookup('radionet:track:dancefm')
assert 1 == len(results)
assert results[0].uri == 'radionet:track:dancefm'

View File

@@ -1,34 +1,36 @@
from unittest import mock
def test_get_genres(radionet): def test_get_genres(radionet):
genres = radionet.get_genres(); genres = radionet.get_genres()
assert len(genres) > 0 assert len(genres) > 0
def test_get_top_stations(radionet):
result = radionet.get_category('topstations', 1)
assert len(result) > 0
def test_get_local_stations(radionet): def test_get_local_stations(radionet):
result = radionet.get_category('localstations', 1) result = radionet.get_simple_category('local', 1)
assert len(result) > 0 assert len(result) > 0
def test_do_search(radionet): def test_do_search(radionet):
result = radionet.do_search("radio ram") result = radionet.do_search("\"radio ram\"")
assert len(result) > 0 assert len(result) > 0
assert result[0].stream_url is None assert result[0].stream_url is not None
assert radionet.get_stream_url(result[0].id) is not None assert radionet.get_stream_url(result[0].id) is not None
def test_get_favorites(radionet): def test_get_favorites(radionet):
test_favorites = ("Rock Antenne", "radio ram", "eska", "dancefm") radionet.cache = {}
test_favorites = ["rockantenne", "eska", "dancefm"]
radionet.set_favorites(test_favorites) radionet.set_favorites(test_favorites)
result = radionet.get_favorites() result = radionet.get_favorites()
assert len(result) == len(test_favorites) assert len(result) == len(test_favorites)
assert result[2].name == 'Eska' assert result[1].name == 'Eska'
def test_favorites_broken_slug(radionet):
radionet.cache = {}
test_favorites = ["radio 357"]
radionet.set_favorites(test_favorites)
result = radionet.get_favorites()
assert len(result) == 1