Compare commits

...
Sign in to create a new pull request.

53 commits

Author SHA1 Message Date
Micha LaQua
f349a2686c
Merge pull request #77 from tomtastic/patch-1
Fix API endpoint for get_station_by_id
2020-12-03 13:41:47 +01:00
Micha LaQua
22475e9c3a
Merge pull request #70 from Arduous/patch-1
Update README.md ycast works with a Marantz SR5009
2020-12-03 13:35:08 +01:00
Micha LaQua
1d65195926
Merge pull request #73 from Arduous/master
radiobrowser.py: remove inadequate print statement.
2020-12-03 13:34:38 +01:00
Tom Matthews
25ecc9b668
Fix API endpoint for get_station_by_id
Fixes https://github.com/milaq/YCast/issues/76
2020-11-29 11:20:17 +00:00
Samuel Progin
595dea2fd5
radiobrowser.py: remove inadequate print statement.
The print statement is removed as:
- It is not present in other very similar methods
- It clutters the log
2020-10-24 17:28:38 +02:00
Samuel Progin
a37b86f638
Update README.md ycast works with a Marantz SR5009
Thank you.
2020-10-20 17:50:36 +02:00
milaq
e01d0af1a7 bump version 2020-10-10 17:07:17 +02:00
milaq
9ea3f3c3c9 Merge branch 'master' of github.com:milaq/YCast into master 2020-10-10 16:38:35 +02:00
milaq
f39b65e759 Add dynamic directory displayname
Also capitalize genres and all capitalize languages

Co-authored-by: Jonathan Rawle <git@jonathanrawle.co.uk>
2020-10-10 16:36:44 +02:00
Micha LaQua
00ba3ace66
Merge pull request #55 from mbroz/master
Add Denon DNP-720AE to confirmed devices
2020-10-10 16:03:28 +02:00
Micha LaQua
e799b92ed6
Merge branch 'master' into master 2020-10-10 16:02:46 +02:00
Milan Broz
ab2ac64cc0 Add Denon DNP-720AE to confirmed devices 2020-10-10 16:02:04 +02:00
Micha LaQua
f920d9f286
Merge pull request #60 from 463/patch-1
Add Denon DNP-730AE to confirmed devices
2020-10-10 16:00:19 +02:00
milaq
e893948485 Add Yamaha CRX-N560/MCR-N560 to supported devices
Thanks to @ra666ack for reporting
2020-10-10 15:58:45 +02:00
milaq
dc6bcf19d4 Add instructions for Grundig radios
Co-authored-by: Jakub Janeczko <jjaneczk@gmail.com>
2020-10-10 15:54:12 +02:00
Jakub Janeczko
9cbd4eb254 server: Some devices use capital I in {start,end}Items 2020-10-10 15:48:36 +02:00
Jakub Janeczko
04c40f2b5f add support for navXML.asp and FavXML.asp queries
Some devices use hardcoded links for station list and favourites.
2020-10-10 15:46:41 +02:00
Guido Schmitz
ee5d6ffdb1 Add POST method for Denon Remote App 2020-10-10 15:43:16 +02:00
milaq
0a2aaa163d Radiobrowser: Migrate to new API
Also fix up attributes and countrycode mapping as outlined in migration suggestions at https://api.radio-browser.info/

Co-authored-by: Thomas Endt <thomas.endt@uipath.com>
Co-authored-by: Jonathan Rawle <git@jonathanrawle.co.uk>
2020-10-10 15:20:22 +02:00
463
ae0e3b6c8c
Update README.md
Denon DNP-730AE is working well too
2020-06-30 16:26:47 +02:00
Milan Broz
d40cf3d894 Add Denon DNP-720AE to confirmed devices 2020-06-05 19:29:49 +02:00
milaq
1c296ec089 Add more tested and supported devices 2020-05-16 10:09:39 +02:00
milaq
d0c87277a9 Add more confirmed devices and revise sort order 2020-04-28 23:58:36 +02:00
Jakub Janeczko
544fe5d981 Readme: Add RX-V500D to supported devices
Also add a note about home routers.
2020-02-24 01:19:14 +01:00
milaq
85ee2d6797 Add Denon DRA-100 to confirmed devices 2020-02-24 00:47:03 +01:00
milaq
e17dff463a readme: confirm more devices
Also use underscore instead of an 'x' for model number wildcards
to increase readability.
2020-01-12 16:23:49 +01:00
milaq
b89ac21546 Nginx example: Don't try to resolve ipv6 localhost 2020-01-12 16:07:27 +01:00
milaq
ceb71ab00d Add request host checking to redirects
Notifies the user even without debug logging and makes troubleshooting easier.
2019-09-04 20:52:34 +02:00
milaq
9ce1e19ea3 Add Yamaha RX-Vx77 series to tested devices 2019-09-04 20:52:29 +02:00
milaq
1b4ba071b7 Disable station tracking by default
Station tracking depends on redirects which some AVRs don't do in a discernible manner.
We enable this again when we found out what makes redirects work for the affected models.
2019-08-27 19:37:57 +02:00
milaq
987951e43f Don't use redirect but direct response when handling upstream API calls 2019-08-27 00:29:53 +02:00
milaq
a0000eec95 Only fetch additional info for stations if requested
E.g.: We don't need the playable stream URL for fetching icons
2019-08-26 21:07:23 +02:00
milaq
f2f4c7a908 Radiobrowser: Add support for fetching playable station URL
We now get the playable station URL directly from the Radiobrowser API.
This fixes the issue with playlists in the 'url' attribute.

Does not work if played station tracking would be disabled.
It _could_ work but we would need to create an additional API request for every single listed station, even the ones not enqueued for playing.
This would then be way slower and put extra strain on the Radiobrowser API.
2019-08-26 20:39:36 +02:00
milaq
87c7753fee Station icons: Basic station icon caching
Storage path for the station icon cache is: ~/.ycast/cache/icons
2019-08-25 19:57:24 +02:00
milaq
4b45aa58d0 Station icons: Keep aspect ratio when scaling 2019-08-25 19:23:37 +02:00
milaq
72a8df3ed9 Add initial support for station icon proxying and conversion
Station icons now get converted to a JPEG with a maxmimum dimension of 290 on icon request.
Although we need to implement caching and aspect ratio keeping, this should fix issues with incompatible station icons and HTTPS icon URLs.

Adds a new package dependency: Pillow
2019-08-25 17:02:41 +02:00
milaq
f1a9885e53 Also track individually requested stations 2019-08-25 16:29:53 +02:00
milaq
5fe7d7cb67 Improve response items for AVR on error 2019-08-25 15:30:41 +02:00
milaq
e373e1e150 vTuner: Add ability to set 'NoDataCache' element
Some AVRs use this to indicate that the page should not be cached. E.g. for top listings
2019-08-25 14:47:36 +02:00
milaq
6d6eb6c392 Remove "Previous" elements until we know that all AVRs have support for it
Every AVR should have a "back" button anyways.
Maybe some companion apps rely on it. Need to test that still.
2019-08-25 14:12:50 +02:00
milaq
c6f4fe1691 Custom stations: Revisit checksum generation
Some AVRs do not allow an ID larger than 15 characters. Thanks to Marc for pointing that out.
Use a MD5 hash (128), XOR fold it (64) and cut it to size (48). This way we _should_ still have enough uniqueness and can generate a checksum which fits the limits.
2019-08-25 13:42:31 +02:00
milaq
19e0ff8649 Add ability to fetch single station element by hardcoded vTuner URL
Some AVRs fetch the station info by calling statxml.asp with the station ID parameter.
It seems like they expect a single station element in a vTuner compatible page.

This should not be confused with the streamurl acquisition proxying implemented in 7c3161aff9.
2019-08-22 23:16:40 +02:00
milaq
70d5ff072b Also set itemcount for Radiobrowser landing page
The vTuner API handles it also this way for a minimum folder depth of 1.
2019-08-22 20:42:55 +02:00
milaq
3c80bd2500 My Stations: Change the default config filename 2019-08-22 20:40:49 +02:00
milaq
2349e06a2e Add custom station ID generation
Generate IDs by hashing the station name and URL.
These IDs should be sufficiently unique.

We _may_ run into performance issues with a very large definition, but it should be fine for a sane amount of custom stations.
Revisit once we implement a custom station database.
2019-08-22 20:38:56 +02:00
milaq
7c3161aff9 Make YCast aware of played streams
Every stream URL is may now be fetched from the YCast server via redirection.
This allows for future integration of a "recently played" functionality and an availability check.
2019-08-22 20:30:29 +02:00
milaq
8504097e15 Independent hardcoded vTuner URL handling
All 'setupapp' calls are handled independently.
Allows for easier distinction between hardcoded vTuner URLs and our
own API calls.
Also may prevent client crashes because of an invalid response with a
landing page.
2019-08-22 01:31:13 +02:00
milaq
67ac5bd70a Handle station search globally
At least Denon AVRs don't require the API to return a 'Search' element
but use a hardcoded path for the search function.

Also, don't add a distinct menu item. Most AVRs don't display it
correctly either way.
2019-08-21 23:37:16 +02:00
milaq
fbbf585bf0 Readme: Add more info and candy 2019-08-21 12:33:03 +02:00
milaq
4cd99f2a9c Radiobrowser: Handle connection errors 2019-08-21 00:21:29 +02:00
milaq
048e479080 Readme: Fix wording and spelling
Also add potentially supported devices from Denon.
2019-08-19 18:41:46 +02:00
milaq
1a51bd195c Add Radiobrowser station language listing
Co-authored-by: Zenith-Nadir <Zenith-Nadir@users.noreply.github.com>
2019-08-19 15:00:48 +02:00
milaq
7869fc0ee3 Add Yamaha RX-x75 series to confirmed devices
Thanks to @eddhannay for the report.
2019-08-19 14:34:37 +02:00
10 changed files with 401 additions and 86 deletions

View file

@ -2,42 +2,76 @@
# 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 and test it with yours, and kindly report the result back :)
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.
### Confirmed working
* Yamaha RX-Vx73 series (RX-V373, RX-V473, RX-V573, RX-V673, RX-V773)
* 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 R-N500
* Yamaha RX-A810
* Yamaha RX-A820
* Yamaha RX-A830
* Onkyo TX-NR414
* Marantz Melody Media M-CR610
* 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
### Unconfirmed/Experimental
* 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)
* 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-S600D
* Yamaha RX-S601D
* Yamaha WX-030
* Yamaha RX-A1060
* Yamaha RX-V2700
* Yamaha RX-V3800
* Yamaha CX-A5000
* 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
## Dependencies:
Python version: `3`
@ -46,31 +80,32 @@ Python packages:
* `requests`
* `flask`
* `PyYAML`
* `Pillow`
## Usage
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.
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.
### DNS entries
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:
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:
* 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(tm) be enough for your private home use): Just run the package: `python -m ycast`
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`
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`.
@ -90,7 +125,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 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).
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).
```
Category one name:
@ -106,13 +141,12 @@ You can also have a look at the provided [example](examples/stations.yml.example
## Firewall rules
* Your AVR needs access to the internet (i.e. to the station URLs you defined).
* Your AVR needs access to the internet.
* 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, must HTTPS stations should work.
* Some station logos are not compatible with the vTuner frontend.
providers which utilize HTTPS for their stations also provide an HTTP stream. Thus, most HTTPS stations should work.
* 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://localhost:8010;
proxy_pass http://127.0.0.1:8010;
}
}

View file

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

View file

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

View file

@ -1,12 +1,19 @@
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):
def __init__(self, name, item_count, displayname=None):
self.name = name
self.item_count = item_count
if displayname:
self.displayname = displayname
else:
self.displayname = name
def generate_stationid_with_prefix(uid, prefix):
@ -31,3 +38,15 @@ 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,4 +1,5 @@
import logging
import hashlib
import yaml
@ -7,18 +8,19 @@ import ycast.generic as generic
ID_PREFIX = "MY"
config_file = 'my_stations.yml'
config_file = 'stations.yml'
class Station:
def __init__(self, name, url, category):
self.id = generic.generate_stationid_with_prefix('000000', ID_PREFIX) # TODO: generate meaningful ID
def __init__(self, uid, name, url, category):
self.id = generic.generate_stationid_with_prefix(uid, ID_PREFIX)
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, None, self.tag, None, None, None, None)
return vtuner.Station(self.id, self.name, self.tag, self.url, self.icon, self.tag, None, None, None, None)
def set_config(config):
@ -32,7 +34,13 @@ def set_config(config):
def get_station_by_id(uid):
return None # TODO: return correct station when custom station id generation is implemented
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
def get_stations_yaml():
@ -62,5 +70,18 @@ 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]:
stations.append(Station(station_name, my_stations_yaml[category][station_name], 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))
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,8 +5,10 @@ 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"
@ -21,12 +23,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, 'id'), ID_PREFIX)
self.id = generic.generate_stationid_with_prefix(get_json_attr(station_json, 'stationuuid'), 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.country = get_json_attr(station_json, 'country')
self.countrycode = get_json_attr(station_json, 'countrycode')
self.language = get_json_attr(station_json, 'language')
self.votes = get_json_attr(station_json, 'votes')
self.codec = get_json_attr(station_json, 'codec')
@ -34,21 +36,32 @@ class Station:
def to_vtuner(self):
return vtuner.Station(self.id, self.name, ', '.join(self.tags), self.url, self.icon,
self.tags[0], self.country, self.codec, self.bitrate, None)
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)
def request(url):
logging.debug("Radiobrowser API request: %s", url)
headers = {'content-type': 'application/json', 'User-Agent': generic.USER_AGENT + '/' + __version__}
response = requests.get('http://www.radio-browser.info/webservice/json/' + url, headers=headers)
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 {}
if response.status_code != 200:
logging.error("Could not fetch data from Radiobrowser (%s)", response.status_code)
logging.error("Could not fetch data from Radiobrowser API (HTML status %s)", response.status_code)
return {}
return response.json()
def get_station_by_id(uid):
station_json = request('stations/byid/' + str(uid))
station_json = request('stations/byuuid/' + str(uid))
if station_json and len(station_json):
return Station(station_json[0])
else:
@ -59,7 +72,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
@ -78,6 +91,21 @@ 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'
@ -88,7 +116,8 @@ 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, 'stationcount'),
get_json_attr(genre_raw, 'name').capitalize()))
return genre_directories
@ -96,7 +125,16 @@ 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':
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:
stations.append(Station(station_json))
return stations
@ -105,7 +143,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
@ -114,6 +152,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,20 +1,28 @@
import logging
import re
from flask import Flask, request, url_for
from flask import Flask, request, url_for, redirect, abort, make_response
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__)
@ -32,26 +40,33 @@ def check_my_stations_feature(config):
my_stations_enabled = my_stations.set_config(config)
def get_directories_page(subdir, directories, requestargs):
def get_directories_page(subdir, directories, request):
page = vtuner.Page()
if len(directories) == 0:
page.add(vtuner.Display("No entries found."))
page.add(vtuner.Display("No entries found"))
page.set_count(1)
return page
for directory in get_paged_elements(directories, requestargs):
vtuner_directory = vtuner.Directory(directory.name, url_for(subdir, _external=True, directory=directory.name),
for directory in get_paged_elements(directories, request.args):
vtuner_directory = vtuner.Directory(directory.displayname,
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, requestargs):
def get_stations_page(stations, request):
page = vtuner.Page()
if len(stations) == 0:
page.add(vtuner.Display("No stations found."))
page.add(vtuner.Display("No stations found"))
page.set_count(1)
return page
for station in get_paged_elements(stations, requestargs):
page.add(station.to_vtuner())
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)
page.set_count(len(stations))
return page
@ -59,6 +74,8 @@ def get_stations_page(stations, requestargs):
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:
@ -68,6 +85,8 @@ 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:
@ -80,12 +99,51 @@ def get_paged_elements(items, requestargs):
return items[offset:limit]
@app.route('/', defaults={'path': ''})
@app.route('/setupapp/<path:path>')
@app.route('/' + PATH_ROOT + '/', defaults={'path': ''})
def landing(path):
def get_station_by_id(stationid, additional_info=False):
station_id_prefix = generic.get_stationid_prefix(stationid)
if station_id_prefix == my_stations.ID_PREFIX:
return 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
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):
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=''):
page = vtuner.Page()
page.add(vtuner.Directory('Radiobrowser', url_for('radiobrowser_landing', _external=True), 4))
if my_stations_enabled:
@ -93,76 +151,161 @@ def landing(path):
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 + '/')
@app.route('/' + PATH_ROOT + '/' + PATH_MY_STATIONS + '/',
methods=['GET', 'POST'])
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.args).to_string()
return get_directories_page('my_stations_category', directories, request).to_string()
@app.route('/' + PATH_ROOT + '/' + PATH_MY_STATIONS + '/<directory>')
@app.route('/' + PATH_ROOT + '/' + PATH_MY_STATIONS + '/<directory>',
methods=['GET', 'POST'])
def my_stations_category(directory):
stations = my_stations.get_stations_by_category(directory)
return get_stations_page(stations, request.args).to_string()
return get_stations_page(stations, request).to_string()
@app.route('/' + PATH_ROOT + '/' + PATH_RADIOBROWSER + '/')
@app.route('/' + PATH_ROOT + '/' + PATH_RADIOBROWSER + '/',
methods=['GET', 'POST'])
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.add(vtuner.Search('Search', url_for('radiobrowser_search', _external=True, path='')))
page.set_count(4)
return page.to_string()
@app.route('/' + PATH_ROOT + '/' + PATH_RADIOBROWSER + '/' + PATH_RADIOBROWSER_COUNTRY + '/')
@app.route('/' + PATH_ROOT + '/' + PATH_RADIOBROWSER + '/' + PATH_RADIOBROWSER_COUNTRY + '/',
methods=['GET', 'POST'])
def radiobrowser_countries():
directories = radiobrowser.get_country_directories()
return get_directories_page('radiobrowser_country_stations', directories, request.args).to_string()
return get_directories_page('radiobrowser_country_stations', directories, request).to_string()
@app.route('/' + PATH_ROOT + '/' + PATH_RADIOBROWSER + '/' + PATH_RADIOBROWSER_COUNTRY + '/<directory>')
@app.route('/' + PATH_ROOT + '/' + PATH_RADIOBROWSER + '/' + PATH_RADIOBROWSER_COUNTRY + '/<directory>',
methods=['GET', 'POST'])
def radiobrowser_country_stations(directory):
stations = radiobrowser.get_stations_by_country(directory)
return get_stations_page(stations, request.args).to_string()
return get_stations_page(stations, request).to_string()
@app.route('/' + PATH_ROOT + '/' + PATH_RADIOBROWSER + '/' + PATH_RADIOBROWSER_GENRE + '/')
@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'])
def radiobrowser_genres():
directories = radiobrowser.get_genre_directories()
return get_directories_page('radiobrowser_genre_stations', directories, request.args).to_string()
return get_directories_page('radiobrowser_genre_stations', directories, request).to_string()
@app.route('/' + PATH_ROOT + '/' + PATH_RADIOBROWSER + '/' + PATH_RADIOBROWSER_GENRE + '/<directory>')
@app.route('/' + PATH_ROOT + '/' + PATH_RADIOBROWSER + '/' + PATH_RADIOBROWSER_GENRE + '/<directory>',
methods=['GET', 'POST'])
def radiobrowser_genre_stations(directory):
stations = radiobrowser.get_stations_by_genre(directory)
return get_stations_page(stations, request.args).to_string()
return get_stations_page(stations, request).to_string()
@app.route('/' + PATH_ROOT + '/' + PATH_RADIOBROWSER + '/' + PATH_RADIOBROWSER_POPULAR + '/')
@app.route('/' + PATH_ROOT + '/' + PATH_RADIOBROWSER + '/' + PATH_RADIOBROWSER_POPULAR + '/',
methods=['GET', 'POST'])
def radiobrowser_popular():
stations = radiobrowser.get_stations_by_votes()
return get_stations_page(stations, request.args).to_string()
return get_stations_page(stations, request).to_string()
@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):
@app.route('/' + PATH_ROOT + '/' + PATH_SEARCH + '/',
methods=['GET', 'POST'])
def station_search():
query = request.args.get('search')
if not query or len(query) < 3:
page = vtuner.Page()
page.add(vtuner.Previous(url_for('landing', _external=True)))
page.add(vtuner.Display("Search query too short."))
page.add(vtuner.Display("Search query too short"))
page.set_count(1)
return page.to_string()
else:
# TODO: we also need to include 'my station' elements
stations = radiobrowser.search(query)
return get_stations_page(stations, request.args).to_string()
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

50
ycast/station_icons.py Normal file
View file

@ -0,0 +1,50 @@
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,6 +27,7 @@ class Page:
def __init__(self):
self.items = []
self.count = -1
self.dontcache = False
def add(self, item):
self.items.append(item)
@ -37,6 +38,8 @@ 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
@ -105,29 +108,36 @@ class Directory:
class Station:
def __init__(self, uid, name, description, url, logo, genre, location, mime, bitrate, bookmark):
def __init__(self, uid, name, description, url, icon, genre, location, mime, bitrate, bookmark):
self.uid = uid
self.name = name
self.description = description
self.url = strip_https(url)
self.logo = logo
self.trackurl = None
self.icon = icon
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
ET.SubElement(item, 'StationUrl').text = self.url
if self.trackurl:
ET.SubElement(item, 'StationUrl').text = self.trackurl
else:
ET.SubElement(item, 'StationUrl').text = self.url
ET.SubElement(item, 'StationDesc').text = self.description
ET.SubElement(item, 'Logo').text = self.logo
ET.SubElement(item, 'Logo').text = self.icon
ET.SubElement(item, 'StationFormat').text = self.genre
ET.SubElement(item, 'StationLocation').text = self.location
ET.SubElement(item, 'StationBandWidth').text = self.bitrate
ET.SubElement(item, 'StationBandWidth').text = str(self.bitrate)
ET.SubElement(item, 'StationMime').text = self.mime
ET.SubElement(item, 'Relia').text = '3'
ET.SubElement(item, 'Bookmark').text = self.bookmark