From bf1a76c3387687701ee91c0dfcc181d10cca6537 Mon Sep 17 00:00:00 2001 From: technosf <535060+technosf@users.noreply.github.com> Date: Thu, 26 Sep 2024 12:13:04 -0700 Subject: [PATCH 1/4] Updated meson function call --- po/meson.build | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/po/meson.build b/po/meson.build index 011a4ae..e3a09fa 100644 --- a/po/meson.build +++ b/po/meson.build @@ -1,7 +1,7 @@ # Install main translations i18n.gettext (meson.project_name (), args: [ - '--directory=' + meson.source_root (), + '--directory=' + meson.project_source_root(), '--from-code=UTF-8', '-cTRANSLATORS' ], From a26358d2e9c427964f704f7d93288d51299a854d Mon Sep 17 00:00:00 2001 From: technosf <535060+technosf@users.noreply.github.com> Date: Thu, 10 Oct 2024 14:46:14 -0700 Subject: [PATCH 2/4] Added SRV lookup, JSON look up and randomizaton --- src/Services/RadioBrowser.vala | 177 ++++++++++++++++++++++----------- 1 file changed, 118 insertions(+), 59 deletions(-) diff --git a/src/Services/RadioBrowser.vala b/src/Services/RadioBrowser.vala index 05ca141..9262eb0 100644 --- a/src/Services/RadioBrowser.vala +++ b/src/Services/RadioBrowser.vala @@ -4,42 +4,70 @@ */ - using Gee; /** * @namespace Tuner.RadioBrowser * @brief Interface to radio-browser.info API and servers + * + * This namespace provides functionality to interact with the radio-browser.info API, + * including searching for stations, retrieving station information, and managing user actions + * such as voting and tracking listens. */ namespace Tuner.RadioBrowser { + private const string SRV_SERVICE = "api"; + private const string SRV_PROTOCOL = "tcp"; + private const string SRV_DOMAIN = "radio-browser.info"; + private const string ALL_API = "https://all.api.radio-browser.info"; + /** * @class Station * @brief Station data subset returned from radio-browser API + * + * This class represents a radio station with its properties as returned by the radio-browser API. */ public class Station : Object { + /** @brief Unique identifier for the station */ public string stationuuid { get; set; } + /** @brief Name of the station */ public string name { get; set; } + /** @brief Resolved URL of the station's stream */ public string url_resolved { get; set; } + /** @brief Country where the station is located */ public string country { get; set; } + /** @brief Country code of the station's location */ public string countrycode { get; set; } + /** @brief URL of the station's favicon */ public string favicon { get; set; } + /** @brief Number of clicks/listens for the station */ public uint clickcount { get; set; } + /** @brief URL of the station's homepage */ public string homepage { get; set; } + /** @brief Audio codec used by the station */ public string codec { get; set; } + /** @brief Bitrate of the station's stream */ public int bitrate { get; set; } } /** * @struct SearchParams * @brief Parameters for searching radio stations + * + * This struct defines the parameters used for searching radio stations. */ public struct SearchParams { + /** @brief Text to search for in station names */ string text; + /** @brief List of tags to filter stations */ ArrayList tags; + /** @brief List of station UUIDs to retrieve */ ArrayList uuids; + /** @brief Country code to filter stations */ string countrycode; + /** @brief Sort order for the search results */ SortOrder order; + /** @brief Whether to reverse the sort order */ bool reverse; } @@ -120,20 +148,14 @@ namespace Tuner.RadioBrowser { } } - - // TODO: Fetch list of servers via DNS query of SRV record for _api._tcp.radio-browser.info - private const string[] DEFAULT_STATION_SERVERS = { - "de1.api.radio-browser.info", - }; - - - /** * @class Tag * @brief Represents a tag associated with radio stations */ public class Tag : Object { + /** @brief Name of the tag */ public string name { get; set; } + /** @brief Number of stations associated with this tag */ public uint stationcount { get; set; } } @@ -147,29 +169,15 @@ namespace Tuner.RadioBrowser { return a == b; } - /** - * @brief Random sort function for strings - * @param a First string to compare - * @param b Second string to compare - * @return Random integer between -1 and 1 - */ - public int RandomSortFunc (string a, string b) { - return Random.int_range (-1, 1); - } - /** * @class Client * @brief RadioBrowser API Client + * + * This class provides methods to interact with the RadioBrowser API, including + * searching for stations, retrieving station information, and managing user actions. */ public class Client : Object { private string current_server; - private ArrayList randomized_servers; - - - ~Client() { - debug ("RadioBrowser Client - Destruct"); - } - /** * @brief Constructor for RadioBrowser Client @@ -178,23 +186,25 @@ namespace Tuner.RadioBrowser { public Client() throws DataError { Object(); - string[] servers; + ArrayList servers; string _servers = GLib.Environment.get_variable ("TUNER_API"); if ( _servers != null ){ - servers = _servers.split(":"); + servers = new Gee.ArrayList.wrap(_servers.split(":")); } else { - servers = DEFAULT_STATION_SERVERS; + //servers = DEFAULT_STATION_SERVERS; + servers = get_api_servers(); + } + + if ( servers.size == 0 ) { + throw new DataError.NO_CONNECTION ("Unable to resolve API servers for radio-browser.info"); } - randomized_servers = new ArrayList.wrap (servers, EqualCompareString); - randomized_servers.sort (RandomSortFunc); + var chosen_server = Random.int_range(0, servers.size); - current_server = @"https://$(randomized_servers[0])"; + current_server = @"https://$(servers[chosen_server])"; debug (@"RadioBrowser Client - Chosen radio-browser.info server: $current_server"); - // TODO: Implement server rotation on error } - /** * @brief Track a station listen event * @param stationuuid UUID of the station being listened to @@ -202,19 +212,10 @@ namespace Tuner.RadioBrowser { public void track (string stationuuid) { debug (@"sending listening event for station $stationuuid"); uint status_code; - //var resource = @"json/url/$stationuuid"; - //var message = new Soup.Message ("GET", @"$current_server/$resource"); - try { - //var resp = _session.send (message); - HttpClient.GET (@"$current_server/json/url/$stationuuid", out status_code); - // resp.close (); - } catch(GLib.Error e) { - debug ("failed to track()"); - } + HttpClient.GET (@"$current_server/json/url/$stationuuid", out status_code); debug (@"response: $(status_code)"); } - /** * @brief Vote for a station * @param stationuuid UUID of the station being voted for @@ -222,17 +223,10 @@ namespace Tuner.RadioBrowser { public void vote (string stationuuid) { debug (@"sending vote event for station $stationuuid"); uint status_code; - - try { - HttpClient.GET(@"$current_server/json/vote/$stationuuid", out status_code); - - } catch(GLib.Error e) { - debug("failed to vote()"); - } + HttpClient.GET(@"$current_server/json/vote/$stationuuid", out status_code); debug (@"response: $(status_code)"); } - /** * @brief Get stations from a specific API resource * @param resource API resource path @@ -268,7 +262,6 @@ namespace Tuner.RadioBrowser { return new ArrayList(); } - /** * @brief Search for stations based on given parameters * @param params Search parameters @@ -317,7 +310,6 @@ namespace Tuner.RadioBrowser { return get_stations (resource); } - /** * @brief Get a station by its UUID * @param uuid UUID of the station to retrieve @@ -333,7 +325,6 @@ namespace Tuner.RadioBrowser { return result[0]; } - /** * @brief Get all available tags * @return ArrayList of Tag objects @@ -367,14 +358,12 @@ namespace Tuner.RadioBrowser { return new ArrayList(); } - /** */ private Station jnode_to_station (Json.Node node) { return Json.gobject_deserialize (typeof (Station), node) as Station; } - /** */ private ArrayList jarray_to_stations (Json.Array data) { @@ -388,14 +377,12 @@ namespace Tuner.RadioBrowser { return stations; } - /** */ private Tag jnode_to_tag (Json.Node node) { return Json.gobject_deserialize (typeof (Tag), node) as Tag; } - /** */ private ArrayList jarray_to_tags (Json.Array data) { @@ -409,5 +396,77 @@ namespace Tuner.RadioBrowser { return tags; } + /** + * @brief Get all radio-browser.info API servers + * + * Gets server list from + * + * @since 1.5.4 + * @return ArrayList of strings containing the resolved hostnames + * @throw DataError if unable to resolve DNS records + */ + private ArrayList get_api_servers() throws DataError { + + var results = new ArrayList(); + + try + /* + DNS SRV record lookup + */ + { + var srv_targets = GLib.Resolver.get_default(). + lookup_service( SRV_SERVICE, SRV_PROTOCOL, SRV_DOMAIN, null ); + foreach (var target in srv_targets) { + results.add(target.get_hostname()); + } + } catch (GLib.Error e) { + @warning(@"Unable to resolve SRV records: $(e.message)"); + } + + if (results.is_empty) + /* + JSON API server lookup as SRV record lookup failed + */ + { + + try { + uint status_code; + var stream = HttpClient.GET(@"$ALL_API/json/servers", out status_code); + + debug (@"response from $(ALL_API)/json/servers: $(status_code)"); + + if (status_code == 200) { + + Json.Node root_node; + + try { + var parser = new Json.Parser(); + parser.load_from_stream (stream); + root_node = parser.get_root (); + } catch (Error e) { + throw new DataError.PARSE_DATA (@"unable to parse JSON response: $(e.message)"); + } + + if (root_node != null && root_node.get_node_type() == Json.NodeType.ARRAY) { + + root_node.get_array().foreach_element((array, index_, element_node) => { + var object = element_node.get_object(); + if (object != null) { + var name = object.get_string_member("name"); + if (name != null && !results.contains (name)) { + results.add(name); + } + } + }); + + } + } + } catch (Error e) { + warning("Failed to parse API ServersJSON: $(e.message)"); + } + } + + return results; + } } } \ No newline at end of file From d83cf362ad2d470074c03257c9248e2334952c81 Mon Sep 17 00:00:00 2001 From: technosf <535060+technosf@users.noreply.github.com> Date: Thu, 10 Oct 2024 15:12:43 -0700 Subject: [PATCH 3/4] Cleaning up and prep for 1.5.4 release --- .gitignore | 3 +- DEVELOP.md | 47 +++++ README.md | 36 +--- data/com.github.louis77.tuner.appdata.xml.in | 12 ++ meson.build | 2 +- src/Services/Favicon.vala | 8 +- src/Services/HttpClient.vala | 46 ++++- src/Widgets/StationBox.vala | 2 +- tuner.mm | 198 +++++++++++++++++++ 9 files changed, 309 insertions(+), 45 deletions(-) create mode 100644 DEVELOP.md create mode 100644 tuner.mm diff --git a/.gitignore b/.gitignore index 871c25d..a7c3573 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,5 @@ build-aux *~ .vscode .buildconfig -.flatpak-builder \ No newline at end of file +.flatpak-builder +code.sh diff --git a/DEVELOP.md b/DEVELOP.md new file mode 100644 index 0000000..c0b57f6 --- /dev/null +++ b/DEVELOP.md @@ -0,0 +1,47 @@ +# ![icon](docs/logo_01.png) Develop, Build and Contribute to Tuner [![License: GPL v3](https://img.shields.io/badge/License-GPL%20v3-blue.svg)](http://www.gnu.org/licenses/gpl-3.0) + + +Discover and Listen to your favourite internet radio stations. + +## Overview + +Tuner is hosted on Github, and linked to Flathub so as releases are pushed Flathub will automatically update its repositary. It is writen in [Vala](https://vala.dev/), a C#/Java/JavaFX-like language with a self-hosting compiler that generates C code and uses the GObject type system and wrapping a number of GTK libraries. It uses [Meson](https://mesonbuild.com/) as its build system. + + + +### Dependencies + +```bash +granite +gstreamer-1.0 +gstreamer-player-1.0 +gtk+-3.0 +json-glib-1.0 +libgee-0.8 +libsoup-3.0 +meson +vala +``` + +### Building + +Make sure you have the dependencies installed: + +```bash +sudo apt install git valac meson +sudo apt install libgtk-3-dev libgee-0.8-dev libgranite-dev libgstreamer1.0-dev libgstreamer-plugins-bad1.0-dev libsoup-3.0-dev libjson-glib-dev +``` + +Clone the repo and drop into the Tuner directory. Configure Meson for development debug build, build Tuner with Ninja, and run the result: + +```bash +meson setup --buildtype=debug builddir +ninja -C builddir +./builddir/com.github.louis77.tuner +``` + + +```bash +meson configure -Dprefix=/usr +sudo ninja install +``` diff --git a/README.md b/README.md index 766c5c7..b4aa859 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ Things I need help with: ### Flathub -Tuner is available on Flathub, but there are some known bugs: +Tuner is available asa Flatpak on Flathub: https://flathub.org/apps/details/com.github.louis77.tuner ### elementary OS @@ -83,7 +83,7 @@ While I hacked on this App, I discovered so many cool and new stations, which ma ## Features -- Uses radio-browser.info catalog +- Uses [radio-browser.info](https://www.radio-browser.info/) radio station catalog - Presets various selection of stations (random, top, trending, genres) - Save favourite stations - Sends a click count to radio-browser.info on station click @@ -102,38 +102,18 @@ While I hacked on this App, I discovered so many cool and new stations, which ma * `TUNER_API` - a `:` separated list of API servers to read from, e.g. * `export TUNER_API="de1.api.radio-browser.info:nl1.api.radio-browser.info"; com.github.louis77.tuner` -## Dependencies - -```bash -granite -gtk+-3.0 -gstreamer-1.0 -gstreamer-player-1.0 -libsoup-3.0 -json-glib-1.0 -libgee-0.8 -meson -vala -``` -## Building +## Build, Maintance and Development of Tuner -Make sure you have the dependencies installed: +Building, developing and maintianing **Tuner** is detailed seperately and in detail in the [DEVELOP](DEVELOP.md) markdown. -```bash -sudo apt install git valac meson -sudo apt install libgtk-3-dev libgee-0.8-dev libgranite-dev libgstreamer1.0-dev libgstreamer-plugins-bad1.0-dev libsoup-3.0-dev libjson-glib-dev -``` -Then clone this repo and build it locally: +## Support + +Feature request, observations and Issues can be documented with tickets on [Github](https://github.com/louis77/tuner/issues) -```bash -meson build && cd build -meson configure -Dprefix=/usr -sudo ninja install -``` -## Known Issues +### Known Issues - If AAC/AAC+ streams don't play (found on Elementary OS 6) install the following dependency: diff --git a/data/com.github.louis77.tuner.appdata.xml.in b/data/com.github.louis77.tuner.appdata.xml.in index 20f4f1e..524c798 100644 --- a/data/com.github.louis77.tuner.appdata.xml.in +++ b/data/com.github.louis77.tuner.appdata.xml.in @@ -62,6 +62,18 @@ + + +

Maintanance release:

+
    +
  • Added API server lookup via SRV and backup via JSON, plus randomization
  • +
  • Added JSON validation for empty nodes
  • +
  • Refactored libsoup calls out to HttpClient.vala
  • +
  • Refactored loading of favicons to Favicon.vala
  • +
  • Turned off TLS checks for HTTP calls to avoid non loading of favicons with invalid certs
  • +
+
+

Maintanance release:

diff --git a/meson.build b/meson.build index 7c3fd73..412fce1 100644 --- a/meson.build +++ b/meson.build @@ -2,7 +2,7 @@ project ( 'com.github.louis77.tuner', 'vala', 'c', - version: '1.5.3' + version: '1.5.4' ) # if meson.get_compiler ('vala').get_id() == 'valac' diff --git a/src/Services/Favicon.vala b/src/Services/Favicon.vala index dbae786..2c5333f 100644 --- a/src/Services/Favicon.vala +++ b/src/Services/Favicon.vala @@ -25,9 +25,7 @@ */ public class Tuner.Favicon : GLib.Object { - // private static Image INTERNET_RADIO = Image().set_from_icon_name ("internet-radio", Gtk.IconSize.DIALOG); - //private static Image INTERNET_RADIO_SYMBOLIC = new Image.from_icon_name ("internet-radio-symbolic", Gtk.IconSize.DIALOG); - /** + /** * @brief Asynchronously load the favicon for a given station * * This method attempts to load the favicon from the cache first. If not found in the cache @@ -64,11 +62,9 @@ public class Tuner.Favicon : GLib.Object { return scaled_pixbuf; } catch (Error e) { - warning("Failed to process favicon: %s", e.message); + warning("Failed to process favicon %s: %s", station.favicon_url,e.message); } } return null; } - - } \ No newline at end of file diff --git a/src/Services/HttpClient.vala b/src/Services/HttpClient.vala index 1c60cb5..3dbcdfa 100644 --- a/src/Services/HttpClient.vala +++ b/src/Services/HttpClient.vala @@ -46,7 +46,7 @@ public class Tuner.HttpClient : Object { { _session = new Soup.Session(); _session.user_agent = @"$(Application.APP_ID)/$(Application.APP_VERSION)"; - _session.timeout = 3; + _session.timeout = 5; } return _session; } @@ -67,6 +67,14 @@ public class Tuner.HttpClient : Object { { status_code = 0; var msg = new Soup.Message("GET", url_string); + + /* + Ignore all TLS certificate errors + */ + msg.accept_certificate.connect ((msg, cert, errors) => { + return true; + }); + try { if (Uri.is_valid(url_string, NONE)) @@ -76,7 +84,7 @@ public class Tuner.HttpClient : Object { return inputStream; } } catch (Error e) { - warning ("GET - Couldn't render favicon: %s (%s)", + warning ("GET - Error accessing URL: %s (%s)", url_string ?? "unknown url", e.message); } @@ -98,14 +106,36 @@ public class Tuner.HttpClient : Object { public static async InputStream? GETasync(string url_string, out uint status_code) { status_code = 0; - var msg = new Soup.Message("GET", url_string); + try { - if (Uri.is_valid(url_string, NONE)) - { - var inputStream = yield getSession().send_async(msg, Priority.DEFAULT, null); - status_code = msg.status_code; - return inputStream; + /* + Ignore all URLs that are too short to be valid or dont validate + */ + if ( url_string != null + && url_string.length < 7 + && !Uri.is_valid(url_string, UriFlags.NONE)) { + warning("URL Check - Failed for URL: %s", url_string); + return null; } + } catch (GLib.UriError e) { + return null; + } + + var msg = new Soup.Message("GET", url_string); + + /* + Ignore all TLS certificate errors + */ + msg.accept_certificate.connect ((msg, cert, errors) => { + return true; + }); + + try { + + var inputStream = yield getSession().send_async(msg, Priority.DEFAULT, null); + status_code = msg.status_code; + return inputStream; + } catch (Error e) { warning ("GETasync - Couldn't render favicon: %s (%s)", url_string ?? "unknown url", diff --git a/src/Widgets/StationBox.vala b/src/Widgets/StationBox.vala index 56e0a36..f21ba78 100644 --- a/src/Widgets/StationBox.vala +++ b/src/Widgets/StationBox.vala @@ -40,7 +40,7 @@ public class Tuner.StationBox : Tuner.WelcomeButton { * Construct block for additional initialization */ construct { - warning (@"StationBox construct $(station.title)"); + debug (@"StationBox construct $(station.title)"); load_favicon(); diff --git a/tuner.mm b/tuner.mm new file mode 100644 index 0000000..7de7a43 --- /dev/null +++ b/tuner.mm @@ -0,0 +1,198 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From c8dd608003dc6c72a645f557e0a4247970e49210 Mon Sep 17 00:00:00 2001 From: technosf <535060+technosf@users.noreply.github.com> Date: Thu, 10 Oct 2024 16:03:01 -0700 Subject: [PATCH 4/4] Tweaking URL validation --- .gitignore | 1 + src/Services/HttpClient.vala | 77 ++++++++++++++++++++++++------------ 2 files changed, 53 insertions(+), 25 deletions(-) diff --git a/.gitignore b/.gitignore index a7c3573..4811f38 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ build-aux .buildconfig .flatpak-builder code.sh +favicon.ico diff --git a/src/Services/HttpClient.vala b/src/Services/HttpClient.vala index 3dbcdfa..d0c3f89 100644 --- a/src/Services/HttpClient.vala +++ b/src/Services/HttpClient.vala @@ -54,19 +54,24 @@ public class Tuner.HttpClient : Object { /** * @brief Perform a GET request to the specified URL * - * This method sends a GET request to the specified URL using the singleton - * Soup.Session instance. It returns the response body as an InputStream and - * outputs the status code of the response. - * * @param url_string The URL to send the GET request to * @param status_code Output parameter for the HTTP status code of the response - * @return InputStream containing the response body + * @return InputStream containing the response body, or null if the request failed * @throws Error if there's an error sending the request or receiving the response */ public static InputStream? GET(string url_string, out uint status_code) { status_code = 0; - var msg = new Soup.Message("GET", url_string); + + if (url_string == null || url_string.length < 4) // domains are at least 4 chars + { + warning("GET - Invalid URL: %s", url_string ?? "null"); + return null; + } + + string sanitized_url = ensure_https_prefix(url_string); + + var msg = new Soup.Message("GET", sanitized_url); /* Ignore all TLS certificate errors @@ -77,17 +82,19 @@ public class Tuner.HttpClient : Object { try { - if (Uri.is_valid(url_string, NONE)) + if (Uri.is_valid(sanitized_url, UriFlags.NONE)) { var inputStream = getSession().send(msg); status_code = msg.status_code; return inputStream; + } else { + debug("GET - Invalid URL format: %s", sanitized_url); } } catch (Error e) { - warning ("GET - Error accessing URL: %s (%s)", - url_string ?? "unknown url", - e.message); - } + warning("GET - Error accessing URL: %s (%s)", + sanitized_url, + e.message); + } return null; } @@ -95,10 +102,6 @@ public class Tuner.HttpClient : Object { /** * @brief Perform an asynchronous GET request to the specified URL * - * This method sends an asynchronous GET request to the specified URL using the singleton - * Soup.Session instance. It returns the response body as an InputStream and - * outputs the status code of the response. - * * @param url_string The URL to send the GET request to * @param status_code Output parameter for the HTTP status code of the response * @return InputStream containing the response body, or null if the request failed @@ -107,21 +110,25 @@ public class Tuner.HttpClient : Object { { status_code = 0; + if (url_string == null || url_string.length < 4 ) // domains are at least 4 chars + { + debug("GETasync - Invalid URL: %s", url_string ?? "null"); + return null; + } + + string sanitized_url = ensure_https_prefix(url_string); + try { - /* - Ignore all URLs that are too short to be valid or dont validate - */ - if ( url_string != null - && url_string.length < 7 - && !Uri.is_valid(url_string, UriFlags.NONE)) { - warning("URL Check - Failed for URL: %s", url_string); + if (!Uri.is_valid(sanitized_url, UriFlags.NONE)) { + debug("GETasync - Invalid URL format: %s", sanitized_url); return null; } } catch (GLib.UriError e) { + debug("GETasync - URI error: %s", e.message); return null; } - var msg = new Soup.Message("GET", url_string); + var msg = new Soup.Message("GET", sanitized_url); /* Ignore all TLS certificate errors @@ -137,11 +144,31 @@ public class Tuner.HttpClient : Object { return inputStream; } catch (Error e) { - warning ("GETasync - Couldn't render favicon: %s (%s)", - url_string ?? "unknown url", + warning("GETasync - Couldn't fetch resource: %s (%s)", + sanitized_url, e.message); } return null; } + + /** + * @brief Ensures that the given URL has an HTTPS prefix + * + * This method checks if the provided URL starts with either "http://" or "https://". + * If it doesn't have either prefix, it adds "https://" to the beginning of the URL. + * + * @param url The input URL string to be checked and potentially modified + * @return A string representing the URL with an HTTPS prefix + * + * @note This method does not validate the URL structure beyond checking for the protocol prefix + */ + private static string ensure_https_prefix(string url) { + // Check if the string starts with "http://" or "https://" + if (!url.has_prefix("http://") && !url.has_prefix("https://")) { + // If it doesn't, prefix the string with "https://" + return "https://" + url; + } + return url; + } } \ No newline at end of file