Browse Source

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
milaq 5 years ago
parent
commit
72a8df3ed9
6 changed files with 68 additions and 7 deletions
  1. 1 1
      README.md
  2. 1 1
      setup.py
  3. 2 1
      ycast/my_stations.py
  4. 27 1
      ycast/server.py
  5. 34 0
      ycast/station_icons.py
  6. 3 3
      ycast/vtuner.py

+ 1 - 1
README.md

@@ -60,6 +60,7 @@ Python packages:
  * `requests`
  * `flask`
  * `PyYAML`
+ * `Pillow`
  
 ## Usage
 
@@ -126,5 +127,4 @@ You can also have a look at the provided [example](examples/stations.yml.example
 
  * 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.
- * Some station logos are not compatible with the vTuner frontend.
  * The built-in bookmark function does not work at the moment. You need to manually add your favourite stations for now.

+ 1 - 1
setup.py

@@ -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'])
 )

+ 2 - 1
ycast/my_stations.py

@@ -17,9 +17,10 @@ class Station:
         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):

+ 27 - 1
ycast/server.py

@@ -1,17 +1,19 @@
 import logging
 
-from flask import Flask, request, url_for, redirect, abort
+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'
@@ -60,6 +62,7 @@ def get_stations_page(stations, request, tracked=True):
         vtuner_station = station.to_vtuner()
         if tracked:
             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
@@ -239,7 +242,30 @@ def get_station_info(tracked=True):
     vtuner_station = station.to_vtuner()
     if tracked:
         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)
+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_from_url(station.icon)
+    if not station_icon:
+        logging.error("Could not convert station icon for station with id '%s'", stationid)
+        abort(404)
+    response = make_response(station_icon)
+    response.headers.set('Content-Type', 'image/jpeg')
+    return response

+ 34 - 0
ycast/station_icons.py

@@ -0,0 +1,34 @@
+import logging
+import requests
+import io
+
+from PIL import Image
+
+import ycast.generic as generic
+from ycast import __version__
+
+MAX_SIZE = 290
+
+
+def get_icon_from_url(iconurl):
+    # TODO cache icons on disk
+    headers = {'User-Agent': generic.USER_AGENT + '/' + __version__}
+    try:
+        response = requests.get(iconurl, 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)", iconurl, response.status_code)
+        return None
+    try:
+        image = Image.open(io.BytesIO(response.content))
+        image = image.convert("RGB")
+        image = image.resize((MAX_SIZE, MAX_SIZE), Image.ANTIALIAS)  # TODO: keep aspect ratio
+        with io.BytesIO() as output_img:
+            image.save(output_img, format="JPEG")
+            image_conv = output_img.getvalue()
+    except Exception as e:
+        logging.error("Station icon conversion error (%s)", e)
+        return None
+    return image_conv

+ 3 - 3
ycast/vtuner.py

@@ -108,13 +108,13 @@ 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.trackurl = None
-        self.logo = logo
+        self.icon = icon
         self.genre = genre
         self.location = location
         self.mime = mime
@@ -134,7 +134,7 @@ class Station:
         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