/*****************************************************************************
* radio.c: Online Radio Browser services discovery module
*****************************************************************************
* Copyright (C) 2025 the VideoLAN team
*
* Authors: Zyad M. Ayad <zyad.moh.1011@gmail.com>
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation; either version 2.1 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 Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston MA 02110-1301, USA.
*****************************************************************************/
#ifdef HAVE_CONFIG_H
#include "config.h"
#endif
#include <vlc_common.h>
#include <vlc_plugin.h>
#include <vlc_services_discovery.h>
#include <vlc_stream.h>
#include <vlc_access.h>
#include <string.h>
#include <vlc_interrupt.h>
#include <ctype.h>
#define DEFAULT_BASE_URL "https://radio.videolan.org/"
#define MRL_PREFIX "radio://"
#define CSV_MAX_FIELDS 100
#define STATIONS_PAGE_SIZE 1000
typedef struct
{
vlc_thread_t thread;
vlc_interrupt_t *interrupt;
} services_discovery_sys_t;
typedef struct
{
char *base_url;
} access_sys_t;
typedef struct
{
stream_t *stream;
char *header_line;
char **header_fields;
size_t fields_count;
char *line;
char **fields;
} csv_parser;
/*
TODO: Handle CSV Fields that has '\n' inside quoted fields
Currently this module skips stations with such fields
Reason: we are using vlc_stream_ReadLine which reads until '\n'
*/
static int parse_csv_line( char *line, char **fields, size_t max_fields)
{
size_t field_count = 0;
char *p = line;
char *start = NULL;
enum
{
STATE_START,
STATE_IN_FIELD,
STATE_IN_QUOTED_FIELD
} state = STATE_START;
while (p && *p)
{
if (field_count >= max_fields)
return - 1; // Exceeded max fields
switch (state)
{
case STATE_START:
if (*p == '"')
{
state = STATE_IN_QUOTED_FIELD;
start = ++p; // Skip the opening quote
}
else if (*p == ',')
{
fields[field_count++] = p;
*p++ = '\0';
}
else
{
state = STATE_IN_FIELD;
start = p;
}
break;
case STATE_IN_FIELD:
{
char *comma = strchr(p, ',');
if (comma)
{
*comma = '\0';
fields[field_count++] = start;
p = comma + 1;
state = STATE_START;
}
else
{
fields[field_count++] = start;
p = NULL;
}
break;
}
case STATE_IN_QUOTED_FIELD:
{
char *quote = strchr(p, '"');
if (!quote)
return - 1;
if (*(quote + 1) == '"')
{
// Escaped quote
memmove(quote, quote + 1, strlen(quote));
p = quote + 1;
}
else
{
*quote = '\0';
fields[field_count++] = start;
p = quote + 1;
state = STATE_START;
if (*p == ',')
{
p++; // Move past comma
}
else if (*p == '\0')
{
p = NULL; // End of input
}
}
break;
}
default:
break;
}
}
return ( int)field_count;
}
static int csv_parser_read_line(csv_parser *parser)
{
free(parser->line);
parser->line = NULL;
/* read next line */
parser->line = vlc_stream_ReadLine(parser->stream);
if (!parser->line)
{
return 0; // End of stream
}
/* parse the line into fields */
int field_count = parse_csv_line(parser->line, parser->fields, parser->fields_count);
if (field_count < 0 || (size_t)field_count != parser->fields_count)
{
return - 1;
}
return 1;
}
static void csv_parser_free(csv_parser *parser)
{
if (parser->stream)
{
vlc_stream_Delete(parser->stream);
}
free(parser->header_line);
free(parser->line);
free(parser->fields);
free(parser->header_fields);
free(parser);
}
static int csv_parser_get_field_index(csv_parser *parser, const char *field_name)
{
if (!parser || !field_name || !parser->header_fields)
{
return - 1; // Invalid parser or field name
}
for (size_t i = 0; i < parser->fields_count; i++)
{
if (strcmp(parser->header_fields[i], field_name) == 0)
{
return i; // Return the field value
}
}
return - 1; // Field not found
}
static csv_parser *csv_parser_init( void *data, char *psz_url, int max_fields)
{
services_discovery_t *p_sd = (services_discovery_t *)data;
csv_parser *parser = malloc( sizeof(csv_parser));
if (!parser)
{
return NULL;
}
parser->stream = vlc_stream_NewURL(p_sd, psz_url);
if (!parser->stream)
{
free(parser);
return NULL;
}
parser->header_line = NULL;
parser->fields_count = max_fields;
parser->line = NULL;
parser->fields = malloc(max_fields * sizeof( char *));
if (!parser->fields)
{
vlc_stream_Delete(parser->stream);
free(parser);
return NULL;
}
parser->header_fields = malloc(max_fields * sizeof( char *));
if (!parser->header_fields)
{
free(parser->fields);
vlc_stream_Delete(parser->stream);
free(parser);
return NULL;
}
parser->header_line = vlc_stream_ReadLine(parser->stream);
if (!parser->header_line)
{
free(parser->fields);
free(parser->header_fields);
vlc_stream_Delete(parser->stream);
free(parser);
return NULL;
}
int header_fields_count = parse_csv_line(parser->header_line, parser->header_fields, ma x_fields);
if (header_fields_count < 0)
{
free(parser->fields);
free(parser->header_line);
free(parser->header_fields);
vlc_stream_Delete(parser->stream);
free(parser);
return NULL;
}
parser->fields_count = header_fields_count;
return parser;
}
/* Radio Browser's CSV API serializes missing optional values as "null". */
static bool FieldHasValue(csv_parser *parser, int index)
{
return index >= 0 && parser->fields[index] != NULL &&
parser->fields[index][0] != '\0' &&
strcmp(parser->fields[index], "null") != 0;
}
static void SetMetaFromField(input_item_t *item, csv_parser *parser, int index, vlc_meta_type_t type)
{
if (!FieldHasValue(parser, index))
return;
input_item_SetMeta(item, type, parser->fields[index]);
}
static void SetMetaExtraFromField(input_item_t *item, csv_parser *parser, int index, const char *name)
{
if (!FieldHasValue(parser, index))
return;
input_item_SetMetaExtra(item, name, parser->fields[index]);
}
static void *Run(void *data)
{
vlc_thread_set_name("vlc-radio");
services_discovery_t *p_sd = (services_discovery_t *)data;
services_discovery_sys_t *p_sys = p_sd->p_sys;
char *base_url = var_InheritString(p_sd, "radio-browser-baseurl");
if (!base_url)
return NULL;
char *countries_endpoint;
if (asprintf(&countries_endpoint, "%scsv/countries", base_url) < 0)
{
free(base_url);
return NULL;
}
free(base_url);
vlc_interrupt_set(p_sys->interrupt);
csv_parser *parser = csv_parser_init(p_sd, countries_endpoint, CSV_MAX_FIELDS);
free(countries_endpoint);
if (!parser)
{
msg_Err(p_sd, "Failed to initialize CSV parser for countries");
return NULL;
}
int name_index = csv_parser_get_field_index(parser, "name");
int iso_3166_1_index = csv_parser_get_field_index(parser, "iso_3166_1");
int stationcount_index = csv_parser_get_field_index(parser, "stationcount");
if (name_index < 0 || iso_3166_1_index < 0)
{
msg_Err(p_sd, "Missing required fields in country data");
csv_parser_free(parser);
return NULL;
}
int ret;
while ((ret = csv_parser_read_line(parser)))
{
if (vlc_killed())
break;
if (ret < 0)
{
continue;
}
if (strlen(parser->fields[name_index]) == 0 || strlen(parser->fields[iso_3166_1_index]) != 2)
{
continue;
}
char *mrl;
if (asprintf(&mrl, MRL_PREFIX "%s", parser->fields[iso_3166_1_index]) < 0)
{
continue;
}
input_item_t *country_node = input_item_NewDirectory(mrl, parser->fields[name_index], ITEM_NET);
free(mrl);
if (!country_node)
{
continue;
}
char flag[256];
snprintf(flag, sizeof(flag), "https://flags.videolan.org/%s/flat/64.png", parser->fields[iso_3166_1_index]);
input_item_SetMeta(country_node, vlc_meta_ArtworkURL, flag);
SetMetaExtraFromField(country_node, parser, stationcount_index, "stationcount");
services_discovery_AddItem(p_sd, country_node);
}
csv_parser_free(parser);
return NULL;
}
static int Open(vlc_object_t *p_this)
{
services_discovery_t *p_sd = (services_discovery_t *)p_this;
services_discovery_sys_t *p_sys = malloc(sizeof(*p_sys));
if (!p_sys)
return VLC_ENOMEM;
p_sd->p_sys = p_sys;
p_sys->interrupt = vlc_interrupt_create();
if (!p_sys->interrupt)
{
free(p_sys);
return VLC_ENOMEM;
}
p_sd->description = _("Radio");
if (vlc_clone(&p_sys->thread, Run, p_sd))
{
vlc_interrupt_destroy(p_sys->interrupt);
free(p_sys);
return VLC_EGENERIC;
}
return VLC_SUCCESS;
}
static void Close(vlc_object_t *p_this)
{
services_discovery_t *p_sd = (services_discovery_t *)p_this;
services_discovery_sys_t *p_sys = p_sd->p_sys;
vlc_interrupt_kill(p_sys->interrupt);
vlc_join(p_sys->thread, NULL);
vlc_interrupt_destroy(p_sys->interrupt);
free(p_sys);
}
static int ReadDirectory(stream_t *p_access, input_item_node_t *p_node)
{
access_sys_t *p_sys = p_access->p_sys;
if (!p_sys || !p_sys->base_url)
{
return VLC_EGENERIC;
}
// Make sure the location is TWO characters, because empty countrycode will cause this moudle to load "ALL" stations
if (strlen(p_access->psz_location) != 2)
{
return VLC_EGENERIC;
}
size_t offset = 0;
for (;;)
{
char *stations_endpoint;
if (asprintf(&stations_endpoint,
"%scsv/stations/bycountrycodeexact/%s?hidebroken=true&order=votes&reverse=true&limit=%zu&offset=%zu",
p_sys->base_url, p_access->psz_location,
(size_t)STATIONS_PAGE_SIZE, offset) < 0)
{
return VLC_ENOMEM;
}
csv_parser *parser = csv_parser_init(p_access, stations_endpoint, CSV_MAX_FIELDS);
free(stations_endpoint);
if (!parser)
{
return offset == 0 ? VLC_EGENERIC : VLC_SUCCESS;
}
int name_index = csv_parser_get_field_index(parser, "name");
int url_index = csv_parser_get_field_index(parser, "url");
int favicon_index = csv_parser_get_field_index(parser, "favicon");
int homepage_index = csv_parser_get_field_index(parser, "homepage");
int tags_index = csv_parser_get_field_index(parser, "tags");
int language_index = csv_parser_get_field_index(parser, "language");
int codec_index = csv_parser_get_field_index(parser, "codec");
int bitrate_index = csv_parser_get_field_index(parser, "bitrate");
int votes_index = csv_parser_get_field_index(parser, "votes");
int clickcount_index = csv_parser_get_field_index(parser, "clickcount");
int state_index = csv_parser_get_field_index(parser, "state");
int countrycode_index = csv_parser_get_field_index(parser, "countrycode");
if (name_index < 0 || url_index < 0)
{
msg_Err(p_access, "Missing required fields in station data");
csv_parser_free(parser);
return offset == 0 ? VLC_EGENERIC : VLC_SUCCESS;
}
size_t rows = 0;
bool killed = false;
int ret;
while ((ret = csv_parser_read_line(parser)))
{
if (vlc_killed())
{
killed = true;
break;
}
rows++;
if (ret < 0)
{
continue;
}
if (strlen(parser->fields[name_index]) == 0 || strlen(parser->fields[url_index]) == 0)
{
continue;
}
input_item_t *station_item = input_item_NewStream(parser->fields[url_index],
parser->fields[name_index],
INPUT_DURATION_INDEFINITE);
if (!station_item)
{
continue;
}
SetMetaFromField(station_item, parser, favicon_index, vlc_meta_ArtworkURL);
SetMetaFromField(station_item, parser, homepage_index, vlc_meta_URL);
SetMetaFromField(station_item, parser, tags_index, vlc_meta_Genre);
SetMetaFromField(station_item, parser, language_index, vlc_meta_Language);
SetMetaExtraFromField(station_item, parser, codec_index, "codec");
SetMetaExtraFromField(station_item, parser, bitrate_index, "bitrate");
SetMetaExtraFromField(station_item, parser, votes_index, "votes");
SetMetaExtraFromField(station_item, parser, clickcount_index, "clickcount");
SetMetaExtraFromField(station_item, parser, state_index, "state");
SetMetaExtraFromField(station_item, parser, countrycode_index, "countrycode");
input_item_node_AppendItem(p_node, station_item);
input_item_Release(station_item);
}
csv_parser_free(parser);
if (killed || rows < STATIONS_PAGE_SIZE)
{
break;
}
offset += rows;
}
return VLC_SUCCESS;
}
static int OpenAccess(vlc_object_t *p_this)
{
stream_t *p_access = (stream_t *)p_this;
access_sys_t *p_sys = malloc(sizeof(*p_sys));
if (!p_sys)
return VLC_ENOMEM;
p_sys->base_url = var_InheritString(p_access, "radio-browser-baseurl");
if (!p_sys->base_url)
{
free(p_sys);
return VLC_EGENERIC;
}
p_access->p_sys = p_sys;
p_access->pf_readdir = ReadDirectory;
p_access->pf_control = access_vaDirectoryControlHelper;
return VLC_SUCCESS;
}
static void CloseAccess(vlc_object_t *p_this)
{
stream_t *p_access = (stream_t *)p_this;
access_sys_t *p_sys = p_access->p_sys;
free(p_sys->base_url);
free(p_sys);
}
VLC_SD_PROBE_HELPER("radio", N_("Radio Browser"), SD_CAT_INTERNET)
#define BASE_URL_TEXT N_("Radio Browser Api Base URL")
#define BASE_URL_LONGTEXT N_("Enter the base URL for the Radio Browser API. The default is " DEFAULT_BASE_URL)
vlc_module_begin()
set_shortname("Radio")
set_description(N_("Radio Browser services discovery"))
set_subcategory(SUBCAT_PLAYLIST_SD)
set_capability("services_discovery", 0)
set_callbacks(Open, Close)
add_shortcut("radio")
add_string("radio-browser-baseurl", DEFAULT_BASE_URL, BASE_URL_TEXT,
BASE_URL_LONGTEXT)
add_submodule()
set_description(N_("Radio Browser access"))
set_subcategory(SUBCAT_INPUT_ACCESS)
set_capability("access", 0)
set_callbacks(OpenAccess, CloseAccess)
add_shortcut("radio")
VLC_SD_PROBE_SUBMODULE
vlc_module_end()
¤ Dauer der Verarbeitung: 0.3 Sekunden
(vorverarbeitet am 2026-11-04)
¤
*© Formatika GbR, Deutschland
|
|