Compare commits

...

87 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
milaq
803964ecb4 Unify station id generation 2019-08-19 13:57:35 +02:00
milaq
5205ec4e7c Werkzeug: Do not log every request when not using debug mode 2019-08-19 13:42:19 +02:00
milaq
dd28e64c61 vTuner: Unify 'to_xml' to get a root element for every function 2019-08-19 13:35:25 +02:00
milaq
244759e408 Use upstream proposed XML API import and naming 2019-08-18 14:19:54 +02:00
milaq
31545662da Minor changes to packaging info 2019-08-18 14:14:53 +02:00
milaq
333fc2012f Unify version and user agent
Also use more detailed user agent as proposed by the Radiobrowser API
2019-08-18 14:11:38 +02:00
milaq
895a5aa37f Add ability to enable debug logging by commandline argument 2019-08-18 13:51:07 +02:00
milaq
16387a3f50 Rework directory handling
This greatly improves loading times and takes some load off the APIs.

 * Add generic directory class to also hold item count
 * Radiobrowser: Get station count directly from API
 * Optionally show broken stations and their count
 * Remove minimum station bitrate to not filter away some listings
 * Improve code wording
 * Log API requests
2019-08-18 13:50:38 +02:00
milaq
ed6baa692e Radiobrowser: Limit search results 2019-08-17 23:59:50 +02:00
milaq
ce32358149 Radiobrowser: Reduce default station limit
Let's assume nobody wants to get 1000 stations by default.
Also takes some load off the Radiobrowser API.
2019-08-17 23:56:51 +02:00
milaq
4accb0bef1 Paging: Improve sanity checks and add in 'DirCount' support
Some AVRs want to know the folder item count before entering it.
2019-08-17 23:52:55 +02:00
milaq
533d4a041d paging: set complete item count for folders
allows proper paging for yamaha AVRs
2019-08-17 20:23:40 +02:00
milaq
b1dca83e51 update readme and prep for prerelease rollout 2019-08-17 14:51:36 +02:00
milaq
6eba2c8ec2 remove disjointed todo 2019-08-16 15:06:34 +02:00
milaq
940c5cb446 unify acquisition for paging elements 2019-08-16 15:05:40 +02:00
milaq
0e29afa077 Server: Removed unused yaml import 2019-08-14 16:11:19 +02:00
milaq
c3866440fa Radiobrowser: Fix handling of request errors 2019-08-14 16:11:19 +02:00
milaq
1316bb65d0 Decouple 'My Stations' feature from server module
Also add in check whether to show the respective link or just
a disclaimer on the landing page.
2019-08-14 16:11:17 +02:00
milaq
92756a7144 Fix up station parameters
Correct wording for bandwidth and bookmark.

Also add 'Relia' station reliability parameter.
Use hardcoded mean value of 3 for now (seems to be 1-5).
2019-08-14 14:50:02 +02:00
milaq
63b87dfa95 Do not send a proper XML header on init token response
The vTuner API doesn't do it and some AVRs have problems with it.
Thanks to @AlfredJ91 for the heads-up.
2019-08-14 14:29:54 +02:00
milaq
9e1bb5e17b Append bogus parameter to every target url
To work around the issue that a few AVRs blindly append parameters
with an ampersand.

This also allows us to get rid of the search logic workaround.
Furthermore the vTuner API also hacks around the issue this way.
So this seems like the correct way to handle that even if it looks ugly.
2019-08-12 20:12:12 +02:00
milaq
4a2ecd9821 add support for start and howmany request arguments
seems like some AVRs use these for paging.
also unify request extraction logic on the way.
2019-08-12 20:12:01 +02:00
milaq
4fb591a18b vtuner pages: always calculate element count 2019-08-12 14:59:51 +02:00
milaq
bfa203d160 added minimum version check to main 2019-08-12 14:16:07 +02:00
milaq
787bf0a6e9 add more confirmed working devices
thanks to @pafodie and @vooba for reporting
2019-08-12 14:06:30 +02:00
milaq
83ad5733ed strip https in vtuner station urls 2019-07-21 20:07:26 +02:00
milaq
e8e5451dc7 fix radiobrowser name sorting order 2019-07-21 19:54:13 +02:00
milaq
ae9f168e8b do not limit country and genre results 2019-07-21 19:53:43 +02:00
milaq
1e9cc568f4 order stations in genres, countries and search results by name 2019-07-21 19:40:41 +02:00
milaq
1123f3e55b implement vtuner paging
should(tm) now be compatible with older avrs which do not get the whole listing at once
2019-07-17 15:44:35 +02:00
milaq
3114e5b226 add python requests as requirement 2019-07-17 15:15:31 +02:00
milaq
925cb30e77 more python packaging preparations 2019-07-10 14:58:14 +02:00
milaq
b79dfebbea obey line character limit and get rid of warnings
for the line width, 80 is ancient. we use 120.
2019-07-10 14:06:09 +02:00
milaq
b0c29f0582 major rework of backend features
* use flask for easier url handling and tidyness
 * create radio-browser.info and vtuner api classes
 * add support for more vtuner logic (logos, info messages, search, buttons, etc.)
 * use radio-browser.info index and search
 * prepare for python packaging
2019-07-09 18:44:56 +02:00
16 changed files with 991 additions and 149 deletions

3
.gitignore vendored
View file

@ -1,3 +1,6 @@
build
dist
*.egg-info
.idea
*.iml
*.pyc

14
LICENSE.txt Normal file
View file

@ -0,0 +1,14 @@
Copyright (C) 2019 Micha LaQua
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.

2
MANIFEST.in Normal file
View file

@ -0,0 +1,2 @@
include README.md
include LICENCE.txt

131
README.md
View file

@ -2,81 +2,110 @@
# 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.
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)
* Yamaha R-N500
* Onkyo TX-NR414
* 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
* 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`
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.
1) Create your initial `stations.yml` and put it in the same directory as `ycast.py`. The config follows a basic YAML structure (see below).
2) 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:
### 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:
* 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`)
3) Run `ycast.py`.
### stations.yml
```
Category one name:
First awesome station name: first.awesome/station/URL
Second awesome station name: second.awesome/station/URL
Category two name:
Third awesome station name: third.awesome/station/URL
Fourth awesome station name: fourth.awesome/station/URL
```
You can also have a look at the provided [example](examples/stations.yml.example) to better understand the configuration.
* Grundig radios: `grundig.vtuner.com`, `grundig.radiosetup.com` (and optionally `grundig2.vtuner.com` and `grundig2.radiosetup.com`)
## Web server configuration
### 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`
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`.
@ -85,21 +114,39 @@ You can redirect all traffic destined for the original request URL (e.g. `radioy
* `/setupapp`
* `/ycast`
__Attention__: Do not rewrite the request transparently. YCast expects the complete URL (i.e. including `/ycast` or `/setupapp`). It also need an intact `Host` header; so if you're proxying YCast you need to pass the original header on. For Nginx, this can be accomplished with `proxy_set_header Host $host;`.
__Attention__: Do not rewrite the requests transparently. YCast expects the complete URL (i.e. including `/ycast` or `/setupapp`). It also need an intact `Host` header; so if you're proxying YCast you need to pass the original header on. For Nginx, this can be accomplished with `proxy_set_header Host $host;`.
In case you are using (or plan on using) Nginx to proxy requests, have a look at [this example](examples/nginx-ycast.conf.example).
This can be used together with [this systemd service example](examples/ycast.service.example) for a fully functional deployment.
#### With WSGI
You can also setup a proper WSGI server. See the [official Flask documentation](https://flask.palletsprojects.com/en/1.1.x/deploying/).
### 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).
```
Category one name:
First awesome station name: first.awesome/station/URL
Second awesome station name: second.awesome/station/URL
Category two name:
Third awesome station name: third.awesome/station/URL
Fourth awesome station name: fourth.awesome/station/URL
```
You can also have a look at the provided [example](examples/stations.yml.example) to better understand the configuration.
## Firewall rules
* The server running YCast does __not__ need internet access.
* 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
YCast was a quick and dirty project to lay the foundation for having a self hosted vTuner emulation.
It is a barebone service at the moment. It provides your AVR with the basic info it needs to play internet radio stations.
Maybe this will change in the future, maybe not.
For now just station names and URLs; no web-based management interface, no coverart, no cute kittens, no fancy stuff.
* 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.
* 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

@ -6,7 +6,7 @@ After=network.target
Type=simple
User=ycast
Group=ycast
ExecStart=/opt/ycast/ycast.py -l 127.0.0.1 -p 8010
ExecStart=/usr/bin/python3 -m ycast -l 127.0.0.1 -p 8010
[Install]
WantedBy=multi-user.target

45
setup.py Normal file
View file

@ -0,0 +1,45 @@
from setuptools import setup, find_packages
import ycast
with open("README.md", "r") as fh:
long_description = fh.read()
setup(
name='ycast',
version=ycast.__version__,
author='milaq',
author_email='micha.laqua@gmail.com',
description='Self hosted vTuner internet radio service emulation',
long_description=long_description,
long_description_content_type="text/markdown",
url='https://github.com/milaq/YCast',
license='GPLv3',
classifiers=[
'Development Status :: 4 - Beta',
'Intended Audience :: Developers',
'Intended Audience :: System Administrators',
'License :: OSI Approved :: GNU General Public License v3 (GPLv3)',
'Natural Language :: English',
'Operating System :: POSIX :: Linux',
'Programming Language :: Python :: 3',
'Topic :: Multimedia :: Sound/Audio',
'Topic :: Software Development :: Libraries :: Python Modules'
],
keywords=[
'ycast',
'streaming',
'vtuner',
'internet radio',
'music',
'radio',
'shoutcast',
'avr',
'emulation',
'yamaha',
'onkyo',
'denon'
],
install_requires=['requests', 'flask', 'PyYAML', 'Pillow'],
packages=find_packages(exclude=['contrib', 'docs', 'tests'])
)

105
ycast.py
View file

@ -1,105 +0,0 @@
#!/usr/bin/env python3
import os
import sys
import argparse
from http.server import BaseHTTPRequestHandler, HTTPServer
import xml.etree.cElementTree as etree
import yaml
YCAST_LOCATION = 'ycast'
stations = {}
def get_stations():
global stations
ycast_dir = os.path.dirname(os.path.realpath(__file__))
try:
with open(ycast_dir + '/stations.yml', 'r') as f:
stations = yaml.load(f)
except FileNotFoundError:
print("ERROR: Station configuration not found. Please supply a proper stations.yml.")
sys.exit(1)
def text_to_url(text):
return text.replace(' ', '%20')
def url_to_text(url):
return url.replace('%20', ' ')
class YCastServer(BaseHTTPRequestHandler):
def do_GET(self):
get_stations()
self.address = 'http://' + self.headers['Host']
if 'loginXML.asp?token=0' in self.path:
self.send_xml('<EncryptedToken>0000000000000000</EncryptedToken>')
elif self.path == '/' \
or self.path == '/' + YCAST_LOCATION \
or self.path == '/' + YCAST_LOCATION + '/'\
or self.path.startswith('/setupapp'):
xml = self.create_root()
for category in sorted(stations, key=str.lower):
self.add_dir(xml, category,
self.address + '/' + YCAST_LOCATION + '/' + text_to_url(category))
self.send_xml(etree.tostring(xml).decode('utf-8'))
elif self.path.startswith('/' + YCAST_LOCATION + '/'):
category = url_to_text(self.path[len(YCAST_LOCATION) + 2:].partition('?')[0])
if category not in stations:
self.send_error(404)
return
xml = self.create_root()
for station in sorted(stations[category], key=str.lower):
self.add_station(xml, station, stations[category][station])
self.send_xml(etree.tostring(xml).decode('utf-8'))
else:
self.send_error(404)
def send_xml(self, content):
xml_data = '<?xml version="1.0" encoding="UTF-8" standalone="yes" ?>'
xml_data += content
self.send_response(200)
self.send_header('Content-type', 'text/html')
self.send_header('Content-length', len(xml_data))
self.end_headers()
self.wfile.write(bytes(xml_data, 'utf-8'))
def create_root(self):
return etree.Element('ListOfItems')
def add_dir(self, root, name, dest):
item = etree.SubElement(root, 'Item')
etree.SubElement(item, 'ItemType').text = 'Dir'
etree.SubElement(item, 'Title').text = name
etree.SubElement(item, 'UrlDir').text = dest
return item
def add_station(self, root, name, url):
item = etree.SubElement(root, 'Item')
etree.SubElement(item, 'ItemType').text = 'Station'
etree.SubElement(item, 'StationName').text = name
etree.SubElement(item, 'StationUrl').text = url
return item
parser = argparse.ArgumentParser(description='vTuner API emulation')
parser.add_argument('-l', action='store', dest='address', help='Listen address', default='0.0.0.0')
parser.add_argument('-p', action='store', dest='port', type=int, help='Listen port', default=80)
arguments = parser.parse_args()
get_stations()
try:
server = HTTPServer((arguments.address, arguments.port), YCastServer)
except PermissionError:
print("ERROR: No permission to create socket. Are you trying to use ports below 1024 without elevated rights?")
sys.exit(1)
print('YCast server listening on %s:%s' % (arguments.address, arguments.port))
try:
server.serve_forever()
except KeyboardInterrupt:
pass
print('YCast server shutting down')
server.server_close()

1
ycast/__init__.py Normal file
View file

@ -0,0 +1 @@
__version__ = '1.1.0'

34
ycast/__main__.py Executable file
View file

@ -0,0 +1,34 @@
#!/usr/bin/env python3
import argparse
import logging
import sys
from ycast import __version__
from ycast import server
logging.basicConfig(format='%(asctime)s %(levelname)s: %(message)s', datefmt='%Y-%m-%d %H:%M:%S', level=logging.INFO)
def launch_server():
parser = argparse.ArgumentParser(description='vTuner API emulation')
parser.add_argument('-c', action='store', dest='config', help='Station configuration', default=None)
parser.add_argument('-l', action='store', dest='address', help='Listen address', default='0.0.0.0')
parser.add_argument('-p', action='store', dest='port', type=int, help='Listen port', default=80)
parser.add_argument('-d', action='store_true', dest='debug', help='Enable debug logging')
arguments = parser.parse_args()
logging.info("YCast (%s) server starting", __version__)
if arguments.debug:
logging.getLogger().setLevel(logging.DEBUG)
logging.debug("Debug logging enabled")
else:
logging.getLogger('werkzeug').setLevel(logging.WARNING)
server.run(arguments.config, arguments.address, arguments.port)
if __name__ == "__main__":
if sys.version_info[0] < 3:
logging.error("Unsupported Python version (Python %s). Minimum required version is Python 3.",
sys.version_info[0])
sys.exit(1)
launch_server()

52
ycast/generic.py Normal file
View file

@ -0,0 +1,52 @@
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):
self.name = name
self.item_count = item_count
if displayname:
self.displayname = displayname
else:
self.displayname = name
def generate_stationid_with_prefix(uid, prefix):
if not prefix or len(prefix) != 2:
logging.error("Invalid station prefix length (must be 2)")
return None
if not uid:
logging.error("Missing station id for full station id generation")
return None
return str(prefix) + '_' + str(uid)
def get_stationid_prefix(uid):
if len(uid) < 4:
logging.error("Could not extract stationid (Invalid station id length)")
return None
return uid[:2]
def get_stationid_without_prefix(uid):
if len(uid) < 4:
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

87
ycast/my_stations.py Normal file
View file

@ -0,0 +1,87 @@
import logging
import hashlib
import yaml
import ycast.vtuner as vtuner
import ycast.generic as generic
ID_PREFIX = "MY"
config_file = 'stations.yml'
class Station:
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, self.icon, self.tag, None, None, None, None)
def set_config(config):
global config_file
if config:
config_file = config
if get_stations_yaml():
return True
else:
return False
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
def get_stations_yaml():
try:
with open(config_file, 'r') as f:
my_stations = yaml.safe_load(f)
except FileNotFoundError:
logging.error("Station configuration '%s' not found", config_file)
return None
except yaml.YAMLError as e:
logging.error("Station configuration format error: %s", e)
return None
return my_stations
def get_category_directories():
my_stations_yaml = get_stations_yaml()
categories = []
if my_stations_yaml:
for category in my_stations_yaml:
categories.append(generic.Directory(category, len(get_stations_by_category(category))))
return categories
def get_stations_by_category(category):
my_stations_yaml = get_stations_yaml()
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))
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]

157
ycast/radiobrowser.py Normal file
View file

@ -0,0 +1,157 @@
import requests
import logging
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"
def get_json_attr(json, attr):
try:
return json[attr]
except:
return None
class Station:
def __init__(self, station_json):
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.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')
self.bitrate = get_json_attr(station_json, 'bitrate')
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)
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 {}
if response.status_code != 200:
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/byuuid/' + str(uid))
if station_json and len(station_json):
return Station(station_json[0])
else:
return None
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:
stations.append(Station(station_json))
return stations
def get_country_directories():
country_directories = []
apicall = 'countries'
if not SHOW_BROKEN_STATIONS:
apicall += '?hidebroken=true'
countries_raw = request(apicall)
for country_raw in countries_raw:
if get_json_attr(country_raw, 'name') and get_json_attr(country_raw, 'stationcount') and \
int(get_json_attr(country_raw, 'stationcount')) > MINIMUM_COUNT_COUNTRY:
country_directories.append(generic.Directory(get_json_attr(country_raw, 'name'),
get_json_attr(country_raw, 'stationcount')))
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'
if not SHOW_BROKEN_STATIONS:
apicall += '?hidebroken=true'
genres_raw = request(apicall)
for genre_raw in genres_raw:
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()))
return genre_directories
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:
stations.append(Station(station_json))
return stations
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:
stations.append(Station(station_json))
return stations
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:
stations.append(Station(station_json))
return stations

311
ycast/server.py Normal file
View file

@ -0,0 +1,311 @@
import logging
import re
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'
station_tracking = False
my_stations_enabled = False
app = Flask(__name__)
def run(config, address='0.0.0.0', port=8010):
try:
check_my_stations_feature(config)
app.run(host=address, port=port)
except PermissionError:
logging.error("No permission to create socket. Are you trying to use ports below 1024 without elevated rights?")
def check_my_stations_feature(config):
global my_stations_enabled
my_stations_enabled = my_stations.set_config(config)
def get_directories_page(subdir, directories, request):
page = vtuner.Page()
if len(directories) == 0:
page.add(vtuner.Display("No entries found"))
page.set_count(1)
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),
directory.item_count)
page.add(vtuner_directory)
page.set_count(len(directories))
return page
def get_stations_page(stations, request):
page = vtuner.Page()
if len(stations) == 0:
page.add(vtuner.Display("No stations found"))
page.set_count(1)
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)
page.set_count(len(stations))
return page
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:
offset = 0
if offset > len(items):
logging.warning("Paging offset larger than item count")
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:
limit = len(items)
if limit < offset:
logging.warning("Paging limit smaller than offset")
return []
if limit > len(items):
limit = len(items)
return items[offset:limit]
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:
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'])
def my_stations_landing():
directories = my_stations.get_category_directories()
return get_directories_page('my_stations_category', directories, request).to_string()
@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).to_string()
@app.route('/' + PATH_ROOT + '/' + PATH_RADIOBROWSER + '/',
methods=['GET', 'POST'])
def radiobrowser_landing():
page = vtuner.Page()
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)
return page.to_string()
@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).to_string()
@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).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'])
def radiobrowser_genres():
directories = radiobrowser.get_genre_directories()
return get_directories_page('radiobrowser_genre_stations', directories, request).to_string()
@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).to_string()
@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).to_string()
@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.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).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

144
ycast/vtuner.py Normal file
View file

@ -0,0 +1,144 @@
import xml.etree.ElementTree as ET
XML_HEADER = '<?xml version="1.0" encoding="UTF-8" standalone="yes" ?>'
def get_init_token():
return '<EncryptedToken>0000000000000000</EncryptedToken>'
def strip_https(url):
if url.startswith('https://'):
url = 'http://' + url[8:]
return url
def add_bogus_parameter(url):
"""
We need this bogus parameter because some (if not all) AVRs blindly append additional request parameters
with an ampersand. E.g.: '&mac=<REDACTED>&dlang=eng&fver=1.2&startitems=1&enditems=100'.
The original vTuner API hacks around that by adding a specific parameter or a bogus parameter like '?empty=' to
the target URL.
"""
return url + '?vtuner=true'
class Page:
def __init__(self):
self.items = []
self.count = -1
self.dontcache = False
def add(self, item):
self.items.append(item)
def set_count(self, count):
self.count = count
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
def to_string(self):
return XML_HEADER + ET.tostring(self.to_xml()).decode('utf-8')
class Previous:
def __init__(self, url):
self.url = url
def to_xml(self):
item = ET.Element('Item')
ET.SubElement(item, 'ItemType').text = 'Previous'
ET.SubElement(item, 'UrlPrevious').text = add_bogus_parameter(self.url)
ET.SubElement(item, 'UrlPreviousBackUp').text = add_bogus_parameter(self.url)
return item
class Display:
def __init__(self, text):
self.text = text
def to_xml(self):
item = ET.Element('Item')
ET.SubElement(item, 'ItemType').text = 'Display'
ET.SubElement(item, 'Display').text = self.text
return item
class Search:
def __init__(self, caption, url):
self.caption = caption
self.url = url
def to_xml(self):
item = ET.Element('Item')
ET.SubElement(item, 'ItemType').text = 'Search'
ET.SubElement(item, 'SearchURL').text = add_bogus_parameter(self.url)
ET.SubElement(item, 'SearchURLBackUp').text = add_bogus_parameter(self.url)
ET.SubElement(item, 'SearchCaption').text = self.caption
ET.SubElement(item, 'SearchTextbox').text = None
ET.SubElement(item, 'SearchButtonGo').text = "Search"
ET.SubElement(item, 'SearchButtonCancel').text = "Cancel"
return item
class Directory:
def __init__(self, title, destination, item_count=-1):
self.title = title
self.destination = destination
self.item_count = item_count
def to_xml(self):
item = ET.Element('Item')
ET.SubElement(item, 'ItemType').text = 'Dir'
ET.SubElement(item, 'Title').text = self.title
ET.SubElement(item, 'UrlDir').text = add_bogus_parameter(self.destination)
ET.SubElement(item, 'UrlDirBackUp').text = add_bogus_parameter(self.destination)
ET.SubElement(item, 'DirCount').text = str(self.item_count)
return item
def set_item_count(self, item_count):
self.item_count = item_count
class Station:
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.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
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.icon
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, 'StationMime').text = self.mime
ET.SubElement(item, 'Relia').text = '3'
ET.SubElement(item, 'Bookmark').text = self.bookmark
return item