Compare commits

..

6 commits

Author SHA1 Message Date
milaq
82635eb9db Return the very first station for a single 'My Stations' fetch 2019-08-22 15:38:37 +02:00
milaq
fedca94507 Also set itemcount for Radiobrowser landing 2019-08-22 15:32:31 +02:00
milaq
cc8eb48a4b remove search element 2019-08-22 15:31:16 +02:00
milaq
195c6fdc5b Single station response seems to require a whole page
That's a random shot, but worth a try.
2019-08-21 00:22:19 +02:00
milaq
4f6be40da9 Handle error if single station request fails 2019-08-19 22:07:45 +02:00
milaq
b447eccd02 Add ability to fetch single station element by id
We don't know what's the correct format for the statxml.asp query.
So let's assume the API just returns a single station element.
2019-08-19 14:04:41 +02:00
10 changed files with 107 additions and 394 deletions

View file

@ -2,76 +2,42 @@
# YCast
[![PyPI latest version](https://img.shields.io/pypi/v/ycast?color=success)](https://pypi.org/project/ycast/) [![GitHub latest version](https://img.shields.io/github/v/release/milaq/YCast?color=success&label=github&sort=semver)](https://github.com/milaq/YCast/releases) [![Python version](https://img.shields.io/pypi/pyversions/ycast)](https://www.python.org/downloads/) [![License](https://img.shields.io/pypi/l/ycast)](https://www.gnu.org/licenses/gpl-3.0.en.html) [![GitHub issues](https://img.shields.io/github/issues/milaq/ycast)](https://github.com/milaq/YCast/issues)
[Get it via PyPI](https://pypi.org/project/ycast/)
[Download from GitHub](https://github.com/milaq/YCast/releases)
[Issue tracker](https://github.com/milaq/YCast/issues)
YCast is a self hosted replacement for the vTuner internet radio service which many AVRs use.
It emulates a vTuner backend to provide your AVR with the necessary information to play self defined categorized internet radio stations and listen to Radio stations listed in the [Community Radio Browser index](http://www.radio-browser.info).
YCast is for you if:
* You do not want to use a proprietary streaming service
* You are sick of loading delays and/or downtimes of the vTuner service
* You do not want to pay for a feature which was free before
* You are unsure about the continuation of the vTuner service
## Supported devices
Theoretically, YCast should work for **most AVRs which support vTuner**.
Most AVRs with network connectivity that were produced between 2011 and 2017 have vTuner support built-in.
Go ahead, test it with yours and kindly report the results back.
Any reported device helps the community to see which AVRs work properly and which may have issues.
Go ahead and test it with yours, and kindly report the result back :)
### Confirmed working
* Denon AVR-X_000 series (AVR-X1000, AVR-2000, AVR-X3000, AVR-X4000)
* Denon AVR-1912
* Denon AVR-X2200W
* Denon CEOL piccolo N5
* Denon CEOL N9
* Denon DNP-720AE
* Denon DNP-730AE
* Denon DRA-100
* Marantz Melody Media M-CR610
* Marantz NR1506
* Marantz NR1605
* Marantz NA6005
* Marantz NA8005
* Marantz SR5009
* Onkyo TX-NR414
* Onkyo TX-NR5009
* Onkyo TX-NR616
* Yamaha R-N301
* Yamaha RX-Vx73 series (RX-V373, RX-V473, RX-V573, RX-V673, RX-V773)
* Yamaha R-N500
* Yamaha RX-A810
* Yamaha RX-A820
* Yamaha RX-A830
* Yamaha CRX-N560/MCR-N560
* Yamaha RX-V_71 series with network connectivity (RX-V671, RX-V771)
* Yamaha RX-V_73 series with network connectivity (RX-V473, RX-V573, RX-V673, RX-V773)
* Yamaha RX-V_75 series (RX-V375, RX-V475, RX-V575, RX-V675, RX-V775)
* Yamaha RX-V_77 series (RX-V377, RX-V477, RX-V577, RX-V677, RX-V777)
* Yamaha RX-V3067
* Yamaha RX-V500D
* Onkyo TX-NR414
* Marantz Melody Media M-CR610
### Unconfirmed/Experimental
* Denon AVR-X_100W series (AVR-X1100W, AVR-2100W, AVR-X3100W, AVR-X4100W)
* Denon AVR-X_300W series (AVR-X1300W, AVR-2300W, AVR-X3300W)
* Yamaha RX-A1060
* Yamaha CX-A5000
* Yamaha RX-Vx75 series (RX-V375, RX-V475, RX-V575, RX-V675, RX-V775)
* Yamaha RX-Vx77 series (RX-V377, RX-V477, RX-V577, RX-V677, RX-V777)
* Yamaha RX-Vx79 series (RX-V379, RX-V479, RX-V579, RX-V679, RX-V779)
* Yamaha RX-Vx81 series (RX-V381, RX-V481, RX-V581, RX-V681, RX-V781)
* Yamaha RX-S600D
* Yamaha RX-S601D
* Yamaha WX-030
* Yamaha RX-A1060
* Yamaha RX-V2700
* Yamaha RX-V3800
* Yamaha RX-V_79 series (RX-V379, RX-V479, RX-V579, RX-V679, RX-V779)
* Yamaha RX-V_81 series (RX-V381, RX-V481, RX-V581, RX-V681, RX-V781)
* Yamaha WX-030
* Yamaha CX-A5000
## Dependencies:
Python version: `3`
@ -80,32 +46,31 @@ Python packages:
* `requests`
* `flask`
* `PyYAML`
* `Pillow`
## Usage
YCast really does not need much computing power nor bandwidth, i.e. you can run it on a low-spec RISC machine like a Raspberry Pi or a home router.
YCast really does not need much computing power nor bandwidth. It just serves the information to the AVR. The streaming
itself gets handled by the AVR directly, i.e. you can run it on a low-spec RISC machine like a Raspberry Pi.
### DNS entries
You need to create a manual entry in your DNS server (read 'Router' for most home users). The `*.vtuner.com` domain should point to the machine YCast is running on.
Specifically the following entries may be configured instead of a wildcard entry:
You need to create a manual entry in your DNS server (read 'Router' for most home users). `vtuner.com` should point to the machine YCast is running on. Alternatively, in case you only want to forward specific vendors, the following entries may be configured:
* Yamaha AVRs: `radioyamaha.vtuner.com` (and optionally `radioyamaha2.vtuner.com`)
* Onkyo AVRs: `onkyo.vtuner.com` (and optionally `onkyo2.vtuner.com`)
* Denon/Marantz AVRs: `denon.vtuner.com` (and optionally `denon2.vtuner.com`)
* Grundig radios: `grundig.vtuner.com`, `grundig.radiosetup.com` (and optionally `grundig2.vtuner.com` and `grundig2.radiosetup.com`)
### Running the server
#### With built-in webserver
You can run YCast by using the built-in development server of Flask (not recommended for production use, but should™ be enough for your private home use): `python -m ycast`
You can run YCast by using the built-in development server of Flask (not recommended for production use, but should(tm) be enough for your private home use): Just run the package: `python -m ycast`
While you can simply run YCast with root permissions listening on all interfaces on port 80, this may not be desired for various reasons.
You can change the listen address and port (via `-l` and `-p` respectively) if you are already running a HTTP server on the target machine and/or want to proxy or restrict YCast access.
You can change the listen address and port (via `-l` and `-p` respectively) if you are already running a HTTP server on the target machine
and/or want to proxy or restrict YCast access.
It is advised to use a proper webserver (e.g. Nginx) in front of YCast if you can.
Then, you also don't need to run YCast as root and can proxy the requests to YCast running on a higher port (>1024) listening only on `localhost`.
@ -125,7 +90,7 @@ You can also setup a proper WSGI server. See the [official Flask documentation](
### Custom stations
If you want to use the 'My Stations' feature, create a `stations.yml` and run YCast with the `-c` switch to specify the path to it. The config follows a basic YAML structure (see below).
If you want to use the 'My Stations' feature besides the global radio index, create a `stations.yml` and run YCast with the `-c` switch to specify the path to it. The config follows a basic YAML structure (see below).
```
Category one name:
@ -141,12 +106,13 @@ You can also have a look at the provided [example](examples/stations.yml.example
## Firewall rules
* Your AVR needs access to the internet.
* Your AVR needs access to the internet (i.e. to the station URLs you defined).
* Your AVR needs to reach port `80` of the machine running YCast.
* If you want to use Radiobrowser stations, the machine running YCast needs internet access.
## Caveats
* vTuner compatible AVRs don't do HTTPS. As such, YCast blindly rewrites every HTTPS station URL to HTTP. Most station
providers which utilize HTTPS for their stations also provide an HTTP stream. Thus, most HTTPS stations should work.
providers which utilize HTTPS for their stations also provide an HTTP stream. Thus, must HTTPS stations should work.
* Some station logos are not compatible with the vTuner frontend.
* The built-in bookmark function does not work at the moment. You need to manually add your favourite stations for now.

View file

@ -13,6 +13,6 @@ server {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_pass http://127.0.0.1:8010;
proxy_pass http://localhost:8010;
}
}

View file

@ -40,6 +40,6 @@ setup(
'onkyo',
'denon'
],
install_requires=['requests', 'flask', 'PyYAML', 'Pillow'],
install_requires=['requests', 'flask', 'PyYAML'],
packages=find_packages(exclude=['contrib', 'docs', 'tests'])
)

View file

@ -1 +1 @@
__version__ = '1.1.0'
__version__ = '1.0.0'

View file

@ -1,19 +1,12 @@
import logging
import os
USER_AGENT = 'YCast'
VAR_PATH = os.path.expanduser("~") + '/.ycast'
CACHE_PATH = VAR_PATH + '/cache'
class Directory:
def __init__(self, name, item_count, displayname=None):
def __init__(self, name, item_count):
self.name = name
self.item_count = item_count
if displayname:
self.displayname = displayname
else:
self.displayname = name
def generate_stationid_with_prefix(uid, prefix):
@ -38,15 +31,3 @@ def get_stationid_without_prefix(uid):
logging.error("Could not extract stationid (Invalid station id length)")
return None
return uid[3:]
def get_cache_path(cache_name):
cache_path = CACHE_PATH + '/' + cache_name
try:
os.makedirs(cache_path)
except FileExistsError:
pass
except PermissionError:
logging.error("Could not create cache folders (%s) because of access permissions", cache_path)
return None
return cache_path

View file

@ -1,5 +1,4 @@
import logging
import hashlib
import yaml
@ -8,19 +7,18 @@ import ycast.generic as generic
ID_PREFIX = "MY"
config_file = 'stations.yml'
config_file = 'my_stations.yml'
class Station:
def __init__(self, uid, name, url, category):
self.id = generic.generate_stationid_with_prefix(uid, ID_PREFIX)
def __init__(self, name, url, category):
self.id = generic.generate_stationid_with_prefix('000000', ID_PREFIX) # TODO: generate meaningful ID
self.name = name
self.url = url
self.tag = category
self.icon = None
def to_vtuner(self):
return vtuner.Station(self.id, self.name, self.tag, self.url, self.icon, self.tag, None, None, None, None)
return vtuner.Station(self.id, self.name, self.tag, self.url, None, self.tag, None, None, None, None)
def set_config(config):
@ -34,13 +32,8 @@ def set_config(config):
def get_station_by_id(uid):
my_stations_yaml = get_stations_yaml()
if my_stations_yaml:
for category in my_stations_yaml:
for station in get_stations_by_category(category):
if uid == generic.get_stationid_without_prefix(station.id):
return station
return None
# TODO: return correct station when custom station id generation is implemented, for now just return the very first one for testing
return get_stations_by_category(get_category_directories()[0].name)[0]
def get_stations_yaml():
@ -70,18 +63,5 @@ def get_stations_by_category(category):
stations = []
if my_stations_yaml and category in my_stations_yaml:
for station_name in my_stations_yaml[category]:
station_url = my_stations_yaml[category][station_name]
station_id = str(get_checksum(station_name + station_url)).upper()
stations.append(Station(station_id, station_name, station_url, category))
stations.append(Station(station_name, my_stations_yaml[category][station_name], category))
return stations
def get_checksum(feed, charlimit=12):
hash_feed = feed.encode()
hash_object = hashlib.md5(hash_feed)
digest = hash_object.digest()
xor_fold = bytearray(digest[:8])
for i, b in enumerate(digest[8:]):
xor_fold[i] ^= b
digest_xor_fold = ''.join(format(x, '02x') for x in bytes(xor_fold))
return digest_xor_fold[:charlimit]

View file

@ -5,10 +5,8 @@ from ycast import __version__
import ycast.vtuner as vtuner
import ycast.generic as generic
API_ENDPOINT = "http://all.api.radio-browser.info"
MINIMUM_COUNT_GENRE = 5
MINIMUM_COUNT_COUNTRY = 5
MINIMUM_COUNT_LANGUAGE = 5
DEFAULT_STATION_LIMIT = 200
SHOW_BROKEN_STATIONS = False
ID_PREFIX = "RB"
@ -23,12 +21,12 @@ def get_json_attr(json, attr):
class Station:
def __init__(self, station_json):
self.id = generic.generate_stationid_with_prefix(get_json_attr(station_json, 'stationuuid'), ID_PREFIX)
self.id = generic.generate_stationid_with_prefix(get_json_attr(station_json, 'id'), ID_PREFIX)
self.name = get_json_attr(station_json, 'name')
self.url = get_json_attr(station_json, 'url')
self.icon = get_json_attr(station_json, 'favicon')
self.tags = get_json_attr(station_json, 'tags').split(',')
self.countrycode = get_json_attr(station_json, 'countrycode')
self.country = get_json_attr(station_json, 'country')
self.language = get_json_attr(station_json, 'language')
self.votes = get_json_attr(station_json, 'votes')
self.codec = get_json_attr(station_json, 'codec')
@ -36,32 +34,21 @@ class Station:
def to_vtuner(self):
return vtuner.Station(self.id, self.name, ', '.join(self.tags), self.url, self.icon,
self.tags[0], self.countrycode, self.codec, self.bitrate, None)
def get_playable_url(self):
try:
playable_url_json = request('url/' + generic.get_stationid_without_prefix(self.id))[0]
self.url = playable_url_json['url']
except (IndexError, KeyError):
logging.error("Could not retrieve first playlist item for station with id '%s'", self.id)
self.tags[0], self.country, self.codec, self.bitrate, None)
def request(url):
logging.debug("Radiobrowser API request: %s", url)
headers = {'content-type': 'application/json', 'User-Agent': generic.USER_AGENT + '/' + __version__}
try:
response = requests.get(API_ENDPOINT + '/json/' + url, headers=headers)
except requests.exceptions.ConnectionError as err:
logging.error("Connection to Radiobrowser API failed (%s)", err)
return {}
response = requests.get('http://www.radio-browser.info/webservice/json/' + url, headers=headers)
if response.status_code != 200:
logging.error("Could not fetch data from Radiobrowser API (HTML status %s)", response.status_code)
logging.error("Could not fetch data from Radiobrowser (%s)", response.status_code)
return {}
return response.json()
def get_station_by_id(uid):
station_json = request('stations/byuuid/' + str(uid))
station_json = request('stations/byid/' + str(uid))
if station_json and len(station_json):
return Station(station_json[0])
else:
@ -72,7 +59,7 @@ def search(name, limit=DEFAULT_STATION_LIMIT):
stations = []
stations_json = request('stations/search?order=name&reverse=false&limit=' + str(limit) + '&name=' + str(name))
for station_json in stations_json:
if SHOW_BROKEN_STATIONS or get_json_attr(station_json, 'lastcheckok') == 1:
if SHOW_BROKEN_STATIONS or get_json_attr(station_json, 'lastcheckok') == '1':
stations.append(Station(station_json))
return stations
@ -91,21 +78,6 @@ def get_country_directories():
return country_directories
def get_language_directories():
language_directories = []
apicall = 'languages'
if not SHOW_BROKEN_STATIONS:
apicall += '?hidebroken=true'
languages_raw = request(apicall)
for language_raw in languages_raw:
if get_json_attr(language_raw, 'name') and get_json_attr(language_raw, 'stationcount') and \
int(get_json_attr(language_raw, 'stationcount')) > MINIMUM_COUNT_LANGUAGE:
language_directories.append(generic.Directory(get_json_attr(language_raw, 'name'),
get_json_attr(language_raw, 'stationcount'),
get_json_attr(language_raw, 'name').title()))
return language_directories
def get_genre_directories():
genre_directories = []
apicall = 'tags'
@ -116,8 +88,7 @@ def get_genre_directories():
if get_json_attr(genre_raw, 'name') and get_json_attr(genre_raw, 'stationcount') and \
int(get_json_attr(genre_raw, 'stationcount')) > MINIMUM_COUNT_GENRE:
genre_directories.append(generic.Directory(get_json_attr(genre_raw, 'name'),
get_json_attr(genre_raw, 'stationcount'),
get_json_attr(genre_raw, 'name').capitalize()))
get_json_attr(genre_raw, 'stationcount')))
return genre_directories
@ -125,16 +96,7 @@ def get_stations_by_country(country):
stations = []
stations_json = request('stations/search?order=name&reverse=false&countryExact=true&country=' + str(country))
for station_json in stations_json:
if SHOW_BROKEN_STATIONS or get_json_attr(station_json, 'lastcheckok') == 1:
stations.append(Station(station_json))
return stations
def get_stations_by_language(language):
stations = []
stations_json = request('stations/search?order=name&reverse=false&languageExact=true&language=' + str(language))
for station_json in stations_json:
if SHOW_BROKEN_STATIONS or get_json_attr(station_json, 'lastcheckok') == 1:
if SHOW_BROKEN_STATIONS or get_json_attr(station_json, 'lastcheckok') == '1':
stations.append(Station(station_json))
return stations
@ -143,7 +105,7 @@ def get_stations_by_genre(genre):
stations = []
stations_json = request('stations/search?order=name&reverse=false&tagExact=true&tag=' + str(genre))
for station_json in stations_json:
if SHOW_BROKEN_STATIONS or get_json_attr(station_json, 'lastcheckok') == 1:
if SHOW_BROKEN_STATIONS or get_json_attr(station_json, 'lastcheckok') == '1':
stations.append(Station(station_json))
return stations
@ -152,6 +114,6 @@ def get_stations_by_votes(limit=DEFAULT_STATION_LIMIT):
stations = []
stations_json = request('stations?order=votes&reverse=true&limit=' + str(limit))
for station_json in stations_json:
if SHOW_BROKEN_STATIONS or get_json_attr(station_json, 'lastcheckok') == 1:
if SHOW_BROKEN_STATIONS or get_json_attr(station_json, 'lastcheckok') == '1':
stations.append(Station(station_json))
return stations

View file

@ -1,28 +1,21 @@
import logging
import re
from flask import Flask, request, url_for, redirect, abort, make_response
from flask import Flask, request, url_for, abort
import ycast.vtuner as vtuner
import ycast.radiobrowser as radiobrowser
import ycast.my_stations as my_stations
import ycast.generic as generic
import ycast.station_icons as station_icons
PATH_ROOT = 'ycast'
PATH_PLAY = 'play'
PATH_STATION = 'station'
PATH_SEARCH = 'search'
PATH_ICON = 'icon'
PATH_MY_STATIONS = 'my_stations'
PATH_RADIOBROWSER = 'radiobrowser'
PATH_RADIOBROWSER_COUNTRY = 'country'
PATH_RADIOBROWSER_LANGUAGE = 'language'
PATH_RADIOBROWSER_GENRE = 'genre'
PATH_RADIOBROWSER_POPULAR = 'popular'
PATH_RADIOBROWSER_SEARCH = 'search'
station_tracking = False
my_stations_enabled = False
app = Flask(__name__)
@ -40,33 +33,26 @@ def check_my_stations_feature(config):
my_stations_enabled = my_stations.set_config(config)
def get_directories_page(subdir, directories, request):
def get_directories_page(subdir, directories, requestargs):
page = vtuner.Page()
if len(directories) == 0:
page.add(vtuner.Display("No entries found"))
page.set_count(1)
page.add(vtuner.Display("No entries found."))
return page
for directory in get_paged_elements(directories, request.args):
vtuner_directory = vtuner.Directory(directory.displayname,
url_for(subdir, _external=True, directory=directory.name),
for directory in get_paged_elements(directories, requestargs):
vtuner_directory = vtuner.Directory(directory.name, url_for(subdir, _external=True, directory=directory.name),
directory.item_count)
page.add(vtuner_directory)
page.set_count(len(directories))
return page
def get_stations_page(stations, request):
def get_stations_page(stations, requestargs):
page = vtuner.Page()
if len(stations) == 0:
page.add(vtuner.Display("No stations found"))
page.set_count(1)
page.add(vtuner.Display("No stations found."))
return page
for station in get_paged_elements(stations, request.args):
vtuner_station = station.to_vtuner()
if station_tracking:
vtuner_station.set_trackurl(request.host_url + PATH_ROOT + '/' + PATH_PLAY + '?id=' + vtuner_station.uid)
vtuner_station.icon = request.host_url + PATH_ROOT + '/' + PATH_ICON + '?id=' + vtuner_station.uid
page.add(vtuner_station)
for station in get_paged_elements(stations, requestargs):
page.add(station.to_vtuner())
page.set_count(len(stations))
return page
@ -74,8 +60,6 @@ def get_stations_page(stations, request):
def get_paged_elements(items, requestargs):
if requestargs.get('startitems'):
offset = int(requestargs.get('startitems')) - 1
elif requestargs.get('startItems'):
offset = int(requestargs.get('startItems')) - 1
elif requestargs.get('start'):
offset = int(requestargs.get('start')) - 1
else:
@ -85,8 +69,6 @@ def get_paged_elements(items, requestargs):
return []
if requestargs.get('enditems'):
limit = int(requestargs.get('enditems'))
elif requestargs.get('endItems'):
limit = int(requestargs.get('endItems'))
elif requestargs.get('start') and requestargs.get('howmany'):
limit = int(requestargs.get('start')) - 1 + int(requestargs.get('howmany'))
else:
@ -99,213 +81,112 @@ def get_paged_elements(items, requestargs):
return items[offset:limit]
def get_station_by_id(stationid, additional_info=False):
def get_station_by_id(stationid):
station_id_prefix = generic.get_stationid_prefix(stationid)
station = None
if station_id_prefix == my_stations.ID_PREFIX:
return my_stations.get_station_by_id(generic.get_stationid_without_prefix(stationid))
station = my_stations.get_station_by_id(generic.get_stationid_without_prefix(stationid))
elif station_id_prefix == radiobrowser.ID_PREFIX:
station = radiobrowser.get_station_by_id(generic.get_stationid_without_prefix(stationid))
if additional_info:
station.get_playable_url()
return station
return None
if station:
page = vtuner.Page()
page.add(station.to_vtuner())
page.set_count(1)
return page
else:
return None
def vtuner_redirect(url):
if request and request.host and not re.search("^[A-Za-z0-9]+\.vtuner\.com$", request.host):
logging.warning("You are not accessing a YCast redirect with a whitelisted host url (*.vtuner.com). "
"Some AVRs have problems with this. The requested host was: %s", request.host)
return redirect(url, code=302)
@app.route('/setupapp/<path:path>',
methods=['GET', 'POST'])
def upstream(path):
@app.route('/', defaults={'path': ''})
@app.route('/setupapp/<path:path>')
@app.route('/' + PATH_ROOT + '/', defaults={'path': ''})
def landing(path):
if request.args.get('token') == '0':
return vtuner.get_init_token()
if request.args.get('search'):
return station_search()
if 'statxml.asp' in path and request.args.get('id'):
return get_station_info()
if 'navXML.asp' in path:
return radiobrowser_landing()
if 'FavXML.asp' in path:
return my_stations_landing()
if 'loginXML.asp' in path:
return landing()
logging.error("Unhandled upstream query (/setupapp/%s)", path)
abort(404)
@app.route('/',
defaults={'path': ''},
methods=['GET', 'POST'])
@app.route('/' + PATH_ROOT + '/',
defaults={'path': ''},
methods=['GET', 'POST'])
def landing(path=''):
station = get_station_by_id(request.args.get('id'))
if station:
return station.to_string()
else:
logging.error("Could not get station with id '%s'", request.args.get('id'))
abort(404)
page = vtuner.Page()
page.add(vtuner.Directory('Radiobrowser', url_for('radiobrowser_landing', _external=True), 4))
page.add(vtuner.Directory('Radiobrowser', url_for('radiobrowser_landing', _external=True), 3))
if my_stations_enabled:
page.add(vtuner.Directory('My Stations', url_for('my_stations_landing', _external=True),
len(my_stations.get_category_directories())))
else:
page.add(vtuner.Display("'My Stations' feature not configured."))
page.set_count(1)
return page.to_string()
@app.route('/' + PATH_ROOT + '/' + PATH_MY_STATIONS + '/',
methods=['GET', 'POST'])
@app.route('/' + PATH_ROOT + '/' + PATH_MY_STATIONS + '/')
def my_stations_landing():
page = vtuner.Page()
page.add(vtuner.Previous(url_for("landing", _external=True)))
directories = my_stations.get_category_directories()
return get_directories_page('my_stations_category', directories, request).to_string()
return get_directories_page('my_stations_category', directories, request.args).to_string()
@app.route('/' + PATH_ROOT + '/' + PATH_MY_STATIONS + '/<directory>',
methods=['GET', 'POST'])
@app.route('/' + PATH_ROOT + '/' + PATH_MY_STATIONS + '/<directory>')
def my_stations_category(directory):
stations = my_stations.get_stations_by_category(directory)
return get_stations_page(stations, request).to_string()
return get_stations_page(stations, request.args).to_string()
@app.route('/' + PATH_ROOT + '/' + PATH_RADIOBROWSER + '/',
methods=['GET', 'POST'])
@app.route('/' + PATH_ROOT + '/' + PATH_RADIOBROWSER + '/')
def radiobrowser_landing():
page = vtuner.Page()
page.add(vtuner.Previous(url_for('landing', _external=True)))
page.add(vtuner.Directory('Genres', url_for('radiobrowser_genres', _external=True),
len(radiobrowser.get_genre_directories())))
page.add(vtuner.Directory('Countries', url_for('radiobrowser_countries', _external=True),
len(radiobrowser.get_country_directories())))
page.add(vtuner.Directory('Languages', url_for('radiobrowser_languages', _external=True),
len(radiobrowser.get_language_directories())))
page.add(vtuner.Directory('Most Popular', url_for('radiobrowser_popular', _external=True),
len(radiobrowser.get_stations_by_votes())))
page.set_count(4)
page.set_count(3)
return page.to_string()
@app.route('/' + PATH_ROOT + '/' + PATH_RADIOBROWSER + '/' + PATH_RADIOBROWSER_COUNTRY + '/',
methods=['GET', 'POST'])
@app.route('/' + PATH_ROOT + '/' + PATH_RADIOBROWSER + '/' + PATH_RADIOBROWSER_COUNTRY + '/')
def radiobrowser_countries():
directories = radiobrowser.get_country_directories()
return get_directories_page('radiobrowser_country_stations', directories, request).to_string()
return get_directories_page('radiobrowser_country_stations', directories, request.args).to_string()
@app.route('/' + PATH_ROOT + '/' + PATH_RADIOBROWSER + '/' + PATH_RADIOBROWSER_COUNTRY + '/<directory>',
methods=['GET', 'POST'])
@app.route('/' + PATH_ROOT + '/' + PATH_RADIOBROWSER + '/' + PATH_RADIOBROWSER_COUNTRY + '/<directory>')
def radiobrowser_country_stations(directory):
stations = radiobrowser.get_stations_by_country(directory)
return get_stations_page(stations, request).to_string()
return get_stations_page(stations, request.args).to_string()
@app.route('/' + PATH_ROOT + '/' + PATH_RADIOBROWSER + '/' + PATH_RADIOBROWSER_LANGUAGE + '/',
methods=['GET', 'POST'])
def radiobrowser_languages():
directories = radiobrowser.get_language_directories()
return get_directories_page('radiobrowser_language_stations', directories, request).to_string()
@app.route('/' + PATH_ROOT + '/' + PATH_RADIOBROWSER + '/' + PATH_RADIOBROWSER_LANGUAGE + '/<directory>',
methods=['GET', 'POST'])
def radiobrowser_language_stations(directory):
stations = radiobrowser.get_stations_by_language(directory)
return get_stations_page(stations, request).to_string()
@app.route('/' + PATH_ROOT + '/' + PATH_RADIOBROWSER + '/' + PATH_RADIOBROWSER_GENRE + '/',
methods=['GET', 'POST'])
@app.route('/' + PATH_ROOT + '/' + PATH_RADIOBROWSER + '/' + PATH_RADIOBROWSER_GENRE + '/')
def radiobrowser_genres():
directories = radiobrowser.get_genre_directories()
return get_directories_page('radiobrowser_genre_stations', directories, request).to_string()
return get_directories_page('radiobrowser_genre_stations', directories, request.args).to_string()
@app.route('/' + PATH_ROOT + '/' + PATH_RADIOBROWSER + '/' + PATH_RADIOBROWSER_GENRE + '/<directory>',
methods=['GET', 'POST'])
@app.route('/' + PATH_ROOT + '/' + PATH_RADIOBROWSER + '/' + PATH_RADIOBROWSER_GENRE + '/<directory>')
def radiobrowser_genre_stations(directory):
stations = radiobrowser.get_stations_by_genre(directory)
return get_stations_page(stations, request).to_string()
return get_stations_page(stations, request.args).to_string()
@app.route('/' + PATH_ROOT + '/' + PATH_RADIOBROWSER + '/' + PATH_RADIOBROWSER_POPULAR + '/',
methods=['GET', 'POST'])
@app.route('/' + PATH_ROOT + '/' + PATH_RADIOBROWSER + '/' + PATH_RADIOBROWSER_POPULAR + '/')
def radiobrowser_popular():
stations = radiobrowser.get_stations_by_votes()
return get_stations_page(stations, request).to_string()
return get_stations_page(stations, request.args).to_string()
@app.route('/' + PATH_ROOT + '/' + PATH_SEARCH + '/',
methods=['GET', 'POST'])
def station_search():
@app.route('/' + PATH_ROOT + '/' + PATH_RADIOBROWSER + '/' + PATH_RADIOBROWSER_SEARCH, defaults={'path': ''})
@app.route('/' + PATH_ROOT + '/' + PATH_RADIOBROWSER + '/' + PATH_RADIOBROWSER_SEARCH + '<path:path>')
def radiobrowser_search(path):
query = request.args.get('search')
if not query or len(query) < 3:
page = vtuner.Page()
page.add(vtuner.Display("Search query too short"))
page.set_count(1)
page.add(vtuner.Previous(url_for('landing', _external=True)))
page.add(vtuner.Display("Search query too short."))
return page.to_string()
else:
# TODO: we also need to include 'my station' elements
stations = radiobrowser.search(query)
return get_stations_page(stations, request).to_string()
@app.route('/' + PATH_ROOT + '/' + PATH_PLAY,
methods=['GET', 'POST'])
def get_stream_url():
stationid = request.args.get('id')
if not stationid:
logging.error("Stream URL without station ID requested")
abort(400)
station = get_station_by_id(stationid, additional_info=True)
if not station:
logging.error("Could not get station with id '%s'", stationid)
abort(404)
logging.debug("Station with ID '%s' requested", station.id)
return vtuner_redirect(station.url)
@app.route('/' + PATH_ROOT + '/' + PATH_STATION,
methods=['GET', 'POST'])
def get_station_info():
stationid = request.args.get('id')
if not stationid:
logging.error("Station info without station ID requested")
abort(400)
station = get_station_by_id(stationid, additional_info=(not station_tracking))
if not station:
logging.error("Could not get station with id '%s'", stationid)
page = vtuner.Page()
page.add(vtuner.Display("Station not found"))
page.set_count(1)
return page.to_string()
vtuner_station = station.to_vtuner()
if station_tracking:
vtuner_station.set_trackurl(request.host_url + PATH_ROOT + '/' + PATH_PLAY + '?id=' + vtuner_station.uid)
vtuner_station.icon = request.host_url + PATH_ROOT + '/' + PATH_ICON + '?id=' + vtuner_station.uid
page = vtuner.Page()
page.add(vtuner_station)
page.set_count(1)
return page.to_string()
@app.route('/' + PATH_ROOT + '/' + PATH_ICON,
methods=['GET', 'POST'])
def get_station_icon():
stationid = request.args.get('id')
if not stationid:
logging.error("Station icon without station ID requested")
abort(400)
station = get_station_by_id(stationid)
if not station:
logging.error("Could not get station with id '%s'", stationid)
abort(404)
if not hasattr(station, 'icon') or not station.icon:
logging.warning("No icon information found for station with id '%s'", stationid)
abort(404)
station_icon = station_icons.get_icon(station)
if not station_icon:
logging.error("Could not get station icon for station with id '%s'", stationid)
abort(404)
response = make_response(station_icon)
response.headers.set('Content-Type', 'image/jpeg')
return response
return get_stations_page(stations, request.args).to_string()

View file

@ -1,50 +0,0 @@
import logging
import requests
import io
import os
from PIL import Image
import ycast.generic as generic
from ycast import __version__
MAX_SIZE = 290
CACHE_NAME = 'icons'
def get_icon(station):
cache_path = generic.get_cache_path(CACHE_NAME)
if not cache_path:
return None
station_icon_file = cache_path + '/' + station.id
if not os.path.exists(station_icon_file):
logging.debug("Station icon cache miss. Fetching and converting station icon for station id '%s'", station.id)
headers = {'User-Agent': generic.USER_AGENT + '/' + __version__}
try:
response = requests.get(station.icon, headers=headers)
except requests.exceptions.ConnectionError as err:
logging.error("Connection to station icon URL failed (%s)", err)
return None
if response.status_code != 200:
logging.error("Could not get station icon data from %s (HTML status %s)", station.icon, response.status_code)
return None
try:
image = Image.open(io.BytesIO(response.content))
image = image.convert("RGB")
if image.size[0] > image.size[1]:
ratio = MAX_SIZE / image.size[0]
else:
ratio = MAX_SIZE / image.size[1]
image = image.resize((int(image.size[0] * ratio), int(image.size[1] * ratio)), Image.ANTIALIAS)
image.save(station_icon_file, format="JPEG")
except Exception as e:
logging.error("Station icon conversion error (%s)", e)
return None
try:
with open(station_icon_file, 'rb') as file:
image_conv = file.read()
except PermissionError:
logging.error("Could not access station icon file in cache (%s) because of access permissions",
station_icon_file)
return None
return image_conv

View file

@ -27,7 +27,6 @@ class Page:
def __init__(self):
self.items = []
self.count = -1
self.dontcache = False
def add(self, item):
self.items.append(item)
@ -38,8 +37,6 @@ class Page:
def to_xml(self):
xml = ET.Element('ListOfItems')
ET.SubElement(xml, 'ItemCount').text = str(self.count)
if self.dontcache:
ET.SubElement(xml, 'NoDataCache').text = 'Yes'
for item in self.items:
xml.append(item.to_xml())
return xml
@ -108,37 +105,33 @@ class Directory:
class Station:
def __init__(self, uid, name, description, url, icon, genre, location, mime, bitrate, bookmark):
def __init__(self, uid, name, description, url, logo, genre, location, mime, bitrate, bookmark):
self.uid = uid
self.name = name
self.description = description
self.url = strip_https(url)
self.trackurl = None
self.icon = icon
self.logo = logo
self.genre = genre
self.location = location
self.mime = mime
self.bitrate = bitrate
self.bookmark = bookmark
def set_trackurl(self, url):
self.trackurl = url
def to_xml(self):
item = ET.Element('Item')
ET.SubElement(item, 'ItemType').text = 'Station'
ET.SubElement(item, 'StationId').text = self.uid
ET.SubElement(item, 'StationName').text = self.name
if self.trackurl:
ET.SubElement(item, 'StationUrl').text = self.trackurl
else:
ET.SubElement(item, 'StationUrl').text = self.url
ET.SubElement(item, 'StationUrl').text = self.url
ET.SubElement(item, 'StationDesc').text = self.description
ET.SubElement(item, 'Logo').text = self.icon
ET.SubElement(item, 'Logo').text = self.logo
ET.SubElement(item, 'StationFormat').text = self.genre
ET.SubElement(item, 'StationLocation').text = self.location
ET.SubElement(item, 'StationBandWidth').text = str(self.bitrate)
ET.SubElement(item, 'StationBandWidth').text = self.bitrate
ET.SubElement(item, 'StationMime').text = self.mime
ET.SubElement(item, 'Relia').text = '3'
ET.SubElement(item, 'Bookmark').text = self.bookmark
return item
def to_string(self):
return XML_HEADER + ET.tostring(self.to_xml()).decode('utf-8')