diff options
| -rw-r--r-- | CMakeLists.txt | 3 | ||||
| -rw-r--r-- | NEWS | 8 | ||||
| -rw-r--r-- | README.adoc | 2 | ||||
| -rw-r--r-- | config.h.in | 3 | ||||
| -rwxr-xr-x | info/10-azlyrics.pl | 43 | ||||
| -rw-r--r-- | nncmpp.c | 482 | 
6 files changed, 507 insertions, 34 deletions
| diff --git a/CMakeLists.txt b/CMakeLists.txt index 543e0c6..23256da 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -104,6 +104,7 @@ foreach (extra m)  endforeach ()  # Generate a configuration file +include (GNUInstallDirs)  configure_file (${PROJECT_SOURCE_DIR}/config.h.in  	${PROJECT_BINARY_DIR}/config.h)  include_directories (${PROJECT_SOURCE_DIR} ${PROJECT_BINARY_DIR}) @@ -123,10 +124,10 @@ target_link_libraries (${PROJECT_NAME} ${Unistring_LIBRARIES}  add_threads (${PROJECT_NAME})  # Installation -include (GNUInstallDirs)  install (TARGETS ${PROJECT_NAME} DESTINATION ${CMAKE_INSTALL_BINDIR})  install (FILES LICENSE DESTINATION ${CMAKE_INSTALL_DOCDIR})  install (DIRECTORY contrib DESTINATION ${CMAKE_INSTALL_DATADIR}/${PROJECT_NAME}) +install (DIRECTORY info DESTINATION ${CMAKE_INSTALL_DATADIR}/${PROJECT_NAME})  # Generate documentation from text markup  find_program (ASCIIDOCTOR_EXECUTABLE asciidoctor) @@ -1,3 +1,11 @@ +Unreleased + + * Added ability to look up song lyrics, +   using a new scriptable extension interface for the Info tab + + * Added Readline-like M-u, M-l, M-c editor bindings + +  2.0.0 (2022-09-03)   * Added an optional X11 user interface diff --git a/README.adoc b/README.adoc index d634d57..14d63a3 100644 --- a/README.adoc +++ b/README.adoc @@ -40,7 +40,7 @@ Building  Build dependencies: CMake, pkg-config, asciidoctor or asciidoc,                      liberty (included), termo (included) +  Runtime dependencies: ncursesw, libunistring, cURL + -Optional runtime dependencies: fftw3, libpulse, x11, xft +Optional runtime dependencies: fftw3, libpulse, x11, xft, Perl + cURL (lyrics)   $ git clone --recursive https://git.janouch.name/p/nncmpp.git   $ mkdir nncmpp/build diff --git a/config.h.in b/config.h.in index 6176df5..77296dd 100644 --- a/config.h.in +++ b/config.h.in @@ -4,6 +4,9 @@  #define PROGRAM_NAME "${PROJECT_NAME}"  #define PROGRAM_VERSION "${PROJECT_VERSION}" +// We use the XDG Base Directory Specification, but may be installed anywhere. +#define PROJECT_DATADIR "${CMAKE_INSTALL_FULL_DATADIR}" +  #cmakedefine HAVE_RESIZETERM  #cmakedefine WITH_FFTW  #cmakedefine WITH_PULSE diff --git a/info/10-azlyrics.pl b/info/10-azlyrics.pl new file mode 100755 index 0000000..4b88bda --- /dev/null +++ b/info/10-azlyrics.pl @@ -0,0 +1,43 @@ +#!/usr/bin/env perl +# 10-azlyrics.pl: nncmpp info plugin to fetch song lyrics on AZLyrics +# +# Copyright (c) 2022, Přemysl Eric Janouch <p@janouch.name> +# SPDX-License-Identifier: 0BSD +# +# Inspired by a similar ncmpc plugin. + +use warnings; +use strict; +use utf8; +use open ':std', ':utf8'; +unless (@ARGV) { +	print "Look up on AZLyrics\n"; +	exit; +} + +use Encode; +my ($title, $artist, $album) = map {decode_utf8($_)} @ARGV; + +# TODO: An upgrade would be transliteration with, e.g., Text::Unidecode. +use Unicode::Normalize; +$artist = lc(NFD($artist) =~ s/^the\s+//ir =~ s/[^A-Za-z0-9]//gr); +$title  = lc(NFD($title)  =~ s/\(.*?\)//gr =~ s/[^A-Za-z0-9]//gr); + +# TODO: Consider caching the results in a location like +# $XDG_CACHE_HOME/nncmpp/info/azlyrics/$artist-$title +my $found = 0; +if ($title ne '') { +	open(my $curl, '-|', 'curl', '-sA', 'nncmpp/2.0', +		"https://www.azlyrics.com/lyrics/$artist/$title.html") or die $!; +	while (<$curl>) { +		next unless /^<div>/ .. /^<\/div>/; s/<!--.*?-->//g; s/\s+$//gs; + +		$found = 1; +		s/<\/?b>/\x01/g; s/<\/?i>/\x02/g; s/<br>/\n/; s/<.+?>//g; +		s/</</g; s/>/>/g; s/"/"/g; s/'/'/g; s/&/&/g; +		print; +	} +	close($curl) or die $?; +} + +print "No lyrics have been found.\n" unless $found; @@ -75,10 +75,11 @@ enum  #define HAVE_LIBERTY  #include "line-editor.c" -#include <math.h> +#include <dirent.h>  #include <locale.h> -#include <termios.h> +#include <math.h>  #include <sys/ioctl.h> +#include <termios.h>  // ncurses is notoriously retarded for input handling, we need something  // different if only to receive mouse events reliably. @@ -130,6 +131,20 @@ clock_msec (clockid_t clock)  	return (int64_t) tp.tv_sec * 1000 + (int64_t) tp.tv_nsec / 1000000;  } +static void +shell_quote (const char *str, struct str *output) +{ +	// See SUSv3 Shell and Utilities, 2.2.3 Double-Quotes +	str_append_c (output, '"'); +	for (const char *p = str; *p; p++) +	{ +		if (strchr ("`$\"\\", *p)) +			str_append_c (output, '\\'); +		str_append_c (output, *p); +	} +	str_append_c (output, '"'); +} +  static bool  xstrtoul_map (const struct str_map *map, const char *key, unsigned long *out)  { @@ -165,6 +180,18 @@ latin1_to_utf8 (const char *latin1)  }  static void +str_enforce_utf8 (struct str *self) +{ +	if (!utf8_validate (self->str, self->len)) +	{ +		char *sanitized = latin1_to_utf8 (self->str); +		str_reset (self); +		str_append (self, sanitized); +		free (sanitized); +	} +} + +static void  cstr_uncapitalize (char *s)  {  	if (isupper (s[0]) && islower (s[1])) @@ -318,6 +345,8 @@ poller_curl_on_socket_action (CURL *easy, curl_socket_t s, int what,  	struct poller_curl_fd *fd;  	if (!(fd = socket_data))  	{ +		set_cloexec (s); +  		fd = xmalloc (sizeof *fd);  		LIST_PREPEND (self->fds, fd); @@ -4089,68 +4118,456 @@ streams_tab_init (void)  // --- Info tab ---------------------------------------------------------------- +struct info_tab_plugin +{ +	LIST_HEADER (struct info_tab_plugin) + +	char *path;                         ///< Filesystem path to plugin +	char *description;                  ///< What the plugin does +}; + +static struct info_tab_plugin * +info_tab_plugin_load (const char *path) +{ +	// Shell quoting is less annoying than process management. +	struct str escaped = str_make (); +	shell_quote (path, &escaped); +	FILE *fp = popen (escaped.str, "r"); +	str_free (&escaped); +	if (!fp) +	{ +		print_error ("%s: %s", path, strerror (errno)); +		return NULL; +	} + +	struct str description = str_make (); +	char buf[BUFSIZ]; +	size_t len; +	while ((len = fread (buf, 1, sizeof buf, fp)) == sizeof buf) +		str_append_data (&description, buf, len); +	str_append_data (&description, buf, len); +	if (pclose (fp)) +	{ +		str_free (&description); +		print_error ("%s: %s", path, strerror (errno)); +		return NULL; +	} + +	char *newline = strpbrk (description.str, "\r\n"); +	if (newline) +	{ +		description.len = newline - description.str; +		*newline = '\0'; +	} +	str_enforce_utf8 (&description); +	if (!description.len) +	{ +		str_free (&description); +		print_error ("%s: %s", path, "missing description"); +		return NULL; +	} + +	struct info_tab_plugin *plugin = xcalloc (1, sizeof *plugin); +	plugin->path = xstrdup (path); +	plugin->description = str_steal (&description); +	return plugin; +} + +static void +info_tab_plugin_load_dir (struct str_map *basename_to_path, const char *dirname) +{ +	DIR *dir = opendir (dirname); +	if (!dir) +	{ +		print_debug ("opendir: %s: %s", dirname, strerror (errno)); +		return; +	} + +	struct dirent *entry = NULL; +	while ((entry = readdir (dir))) +	{ +		struct stat st = {}; +		char *path = xstrdup_printf ("%s/%s", dirname, entry->d_name); +		if (stat (path, &st) || !S_ISREG (st.st_mode)) +		{ +			free (path); +			continue; +		} + +		// Empty files silently erase formerly found basenames. +		if (!st.st_size) +			cstr_set (&path, NULL); + +		str_map_set (basename_to_path, entry->d_name, path); +	} +	closedir (dir); +} + +static int +strv_sort_cb (const void *a, const void *b) +{ +	return strcmp (*(const char **) a, *(const char **) b); +} + +static struct info_tab_plugin * +info_tab_plugin_load_all (void) +{ +	struct str_map basename_to_path = str_map_make (free); +	struct strv paths = strv_make (); +	get_xdg_data_dirs (&paths); +	strv_append (&paths, PROJECT_DATADIR); +	for (size_t i = paths.len; i--; ) +	{ +		char *dirname = +			xstrdup_printf ("%s/" PROGRAM_NAME "/info", paths.vector[i]); +		info_tab_plugin_load_dir (&basename_to_path, dirname); +		free (dirname); +	} +	strv_free (&paths); + +	struct strv sorted = strv_make (); +	struct str_map_iter iter = str_map_iter_make (&basename_to_path); +	while (str_map_iter_next (&iter)) +		strv_append (&sorted, iter.link->key); +	qsort (sorted.vector, sorted.len, sizeof *sorted.vector, strv_sort_cb); + +	struct info_tab_plugin *result = NULL; +	for (size_t i = sorted.len; i--; ) +	{ +		const char *path = str_map_find (&basename_to_path, sorted.vector[i]); +		struct info_tab_plugin *plugin = info_tab_plugin_load (path); +		if (plugin) +			LIST_PREPEND (result, plugin); +	} +	str_map_free (&basename_to_path); +	strv_free (&sorted); +	return result; +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +struct info_tab_item +{ +	char *prefix;                       ///< Fixed-width prefix column or NULL +	char *text;                         ///< Text or NULL +	bool formatted;                     ///< Interpret inline formatting marks? +	struct info_tab_plugin *plugin;     ///< Activatable plugin +}; + +static void +info_tab_item_free (struct info_tab_item *self) +{ +	cstr_set (&self->prefix, NULL); +	cstr_set (&self->text, NULL); +} +  static struct  {  	struct tab super;                   ///< Parent class -	struct strv keys;                   ///< Data keys -	struct strv values;                 ///< Data values +	struct info_tab_item *items;        ///< Items array +	size_t items_alloc;                 ///< How many items are allocated + +	struct info_tab_plugin *plugins;    ///< Plugins + +	int plugin_songid;                  ///< Song ID or -1 +	pid_t plugin_pid;                   ///< Running plugin's process ID or -1 +	int plugin_stdout;                  ///< pid != -1: read end of stdout +	struct poller_fd plugin_event;      ///< pid != -1: stdout is readable +	struct str plugin_output;           ///< pid != -1: buffer, otherwise result  }  g_info_tab; +static chtype +info_tab_format_decode_toggle (char c) +{ +	switch (c) +	{ +	case '\x01': +		return A_BOLD; +	case '\x02': +		return A_ITALIC; +	default: +		return 0; +	} +} + +static void +info_tab_format (struct layout *l, const char *text) +{ +	chtype attrs = 0; +	for (const char *p = text; *p; p++) +	{ +		chtype toggled = info_tab_format_decode_toggle (*p); +		if (!toggled) +			continue; + +		if (p != text) +		{ +			char *slice = xstrndup (text, p - text); +			app_push (l, g.ui->label (attrs, slice)); +			free (slice); +		} + +		attrs ^= toggled; +		text = p + 1; +	} +	if (*text) +		app_push (l, g.ui->label (attrs, text)); +} +  static struct layout  info_tab_on_item_layout (size_t item_index)  { -	const char *key = g_info_tab.keys.vector[item_index]; -	const char *value = g_info_tab.values.vector[item_index]; +	struct info_tab_item *item = &g_info_tab.items[item_index];  	struct layout l = {}; +	if (item->prefix) +	{ +		char *prefix = xstrdup_printf ("%s:", item->prefix); +		app_push (&l, g.ui->label (A_BOLD, prefix)) +			->width = 8 * g.ui_hunit; +		app_push (&l, g.ui->padding (0, 0.5, 1)); +	} -	char *prefix = xstrdup_printf ("%s:", key); -	app_push (&l, g.ui->label (A_BOLD, prefix)) -		->width = 8 * g.ui_hunit; -	app_push (&l, g.ui->padding (0, 0.5, 1)); -	app_push_fill (&l, g.ui->label (0, value)); +	if (item->plugin) +		app_push (&l, g.ui->label (A_BOLD, item->plugin->description)); +	else if (!item->text || !*item->text) +		app_push (&l, g.ui->padding (0, 1, 1)); +	else if (item->formatted) +		info_tab_format (&l, item->text); +	else +		app_push (&l, g.ui->label (0, item->text)); + +	if (l.tail) +		l.tail->width = -1;  	return l;  } +static struct info_tab_item * +info_tab_prepare (void) +{ +	if (g_info_tab.super.item_count == g_info_tab.items_alloc) +		g_info_tab.items = xreallocarray (g_info_tab.items, +			sizeof *g_info_tab.items, (g_info_tab.items_alloc <<= 1)); + +	struct info_tab_item *item = +		&g_info_tab.items[g_info_tab.super.item_count++]; +	memset (item, 0, sizeof *item); +	return item; +} +  static void  info_tab_add (compact_map_t data, const char *field)  { -	const char *value = compact_map_find (data, field); -	if (!value) value = ""; - -	strv_append (&g_info_tab.keys, field); -	strv_append (&g_info_tab.values, value); -	g_info_tab.super.item_count++; +	struct info_tab_item *item = info_tab_prepare (); +	item->prefix = xstrdup (field); +	item->text = xstrdup0 (compact_map_find (data, field));  }  static void  info_tab_update (void)  { -	strv_reset (&g_info_tab.keys); -	strv_reset (&g_info_tab.values); -	g_info_tab.super.item_count = 0; +	while (g_info_tab.super.item_count) +		info_tab_item_free (&g_info_tab.items[--g_info_tab.super.item_count]); -	compact_map_t map; -	if ((map = item_list_get (&g.playlist, g.song))) +	compact_map_t map = item_list_get (&g.playlist, g.song); +	if (!map) +		return; + +	info_tab_add (map, "Title"); +	info_tab_add (map, "Artist"); +	info_tab_add (map, "Album"); +	info_tab_add (map, "Track"); +	info_tab_add (map, "Genre"); +	// We actually receive it as "file", but the key is also used for display +	info_tab_add (map, "File"); + +	if (g_info_tab.plugins) +	{ +		(void) info_tab_prepare (); +		LIST_FOR_EACH (struct info_tab_plugin, plugin, g_info_tab.plugins) +			info_tab_prepare ()->plugin = plugin; +	} + +	if (g_info_tab.plugin_pid != -1) +	{ +		(void) info_tab_prepare (); +		info_tab_prepare ()->text = xstrdup ("Processing..."); +		return; +	} + +	const char *songid = compact_map_find (map, "Id"); +	if (songid && atoi (songid) == g_info_tab.plugin_songid +	 && g_info_tab.plugin_output.len)  	{ -		info_tab_add (map, "Title"); -		info_tab_add (map, "Artist"); -		info_tab_add (map, "Album"); -		info_tab_add (map, "Track"); -		info_tab_add (map, "Genre"); -		// Yes, it is "file", but this is also for display -		info_tab_add (map, "File"); +		struct strv lines = strv_make (); +		cstr_split (g_info_tab.plugin_output.str, "\r\n", false, &lines); + +		(void) info_tab_prepare (); +		for (size_t i = 0; i < lines.len; i++) +		{ +			struct info_tab_item *item = info_tab_prepare (); +			item->formatted = true; +			item->text = lines.vector[i]; +		} +		free (lines.vector); +	} +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +static void +info_tab_plugin_abort (void) +{ +	if (g_info_tab.plugin_pid == -1) +		return; + +	// XXX: our methods of killing are very crude, we hope to improve; +	//   at least install a SIGCHLD handler to collect zombies +	(void) kill (-g_info_tab.plugin_pid, SIGTERM); + +	int status = 0; +	while (waitpid (g_info_tab.plugin_pid, &status, WNOHANG) == -1 +		&& errno == EINTR) +		; +	if (WIFEXITED (status) && WEXITSTATUS (status) != EXIT_SUCCESS) +		print_error ("plugin reported failure"); + +	g_info_tab.plugin_pid = -1; +	poller_fd_reset (&g_info_tab.plugin_event); +	xclose (g_info_tab.plugin_stdout); +	g_info_tab.plugin_stdout = -1; +} + +static void +info_tab_on_plugin_stdout (const struct pollfd *fd, void *user_data) +{ +	(void) user_data; + +	struct str *buf = &g_info_tab.plugin_output; +	switch (socket_io_try_read (fd->fd, buf)) +	{ +	case SOCKET_IO_OK: +		str_enforce_utf8 (buf); +		return; +	case SOCKET_IO_ERROR: +		print_error ("error reading from plugin: %s", strerror (errno)); +		// Fall-through +	case SOCKET_IO_EOF: +		info_tab_plugin_abort (); +		info_tab_update (); +		app_invalidate (); +	} +} + +static void +info_tab_plugin_run (struct info_tab_plugin *plugin, compact_map_t map) +{ +	info_tab_plugin_abort (); +	if (!map) +		return; + +	const char *songid = compact_map_find (map, "Id"); +	const char *title  = compact_map_find (map, "Title"); +	const char *artist = compact_map_find (map, "Artist"); +	const char *album  = compact_map_find (map, "Album"); +	if (!songid || !title || !artist) +	{ +		print_error ("unknown song title or artist"); +		return; +	} + +	int stdout_pipe[2]; +	if (pipe (stdout_pipe)) +	{ +		print_error ("%s: %s", "pipe", strerror (errno)); +		return; +	} + +	enum { READ, WRITE }; +	set_cloexec (stdout_pipe[READ]); +	set_cloexec (stdout_pipe[WRITE]); + +	const char *argv[] = +		{ xbasename (plugin->path), title, artist, album, NULL }; + +	pid_t child = fork (); +	switch (child) +	{ +	case -1: +		print_error ("%s: %s", "fork", strerror (errno)); +		xclose (stdout_pipe[READ]); +		xclose (stdout_pipe[WRITE]); +		return; +	case 0: +		if (setpgid (0, 0) == -1 || !freopen ("/dev/null", "r", stdin) +		 || dup2 (stdout_pipe[WRITE], STDOUT_FILENO) == -1 +		 || dup2 (stdout_pipe[WRITE], STDERR_FILENO) == -1) +			_exit (EXIT_FAILURE); + +		signal (SIGPIPE, SIG_DFL); + +		(void) execv (plugin->path, (char **) argv); +		fprintf (stderr, "%s\n", strerror (errno)); +		_exit (EXIT_FAILURE); +	default: +		// Resolve the race, even though it isn't critical for us +		(void) setpgid (child, child); + +		g_info_tab.plugin_songid = atoi (songid); +		g_info_tab.plugin_pid = child; +		set_blocking ((g_info_tab.plugin_stdout = stdout_pipe[READ]), false); +		xclose (stdout_pipe[WRITE]); + +		struct poller_fd *event = &g_info_tab.plugin_event; +		*event = poller_fd_make (&g.poller, g_info_tab.plugin_stdout); +		event->dispatcher = info_tab_on_plugin_stdout; +		str_reset (&g_info_tab.plugin_output); +		poller_fd_set (&g_info_tab.plugin_event, POLLIN); +	} +} + +static bool +info_tab_on_action (enum action action) +{ +	struct tab *tab = g.active_tab; +	if (tab->item_selected < 0 +	 || tab->item_selected >= (int) tab->item_count) +		return false; + +	struct info_tab_item *item = &g_info_tab.items[tab->item_selected]; +	if (!item->plugin) +		return false; + +	switch (action) +	{ +	case ACTION_DESCRIBE: +		app_show_message (xstrdup ("Path: "), xstrdup (item->plugin->path)); +		return true; +	case ACTION_CHOOSE: +		info_tab_plugin_run (item->plugin, item_list_get (&g.playlist, g.song)); +		info_tab_update (); +		app_invalidate (); +		return true; +	default: +		return false;  	}  }  static struct tab *  info_tab_init (void)  { -	g_info_tab.keys = strv_make (); -	g_info_tab.values = strv_make (); +	g_info_tab.items = +		xcalloc ((g_info_tab.items_alloc = 16), sizeof *g_info_tab.items); + +	g_info_tab.plugins = info_tab_plugin_load_all (); +	g_info_tab.plugin_songid = -1; +	g_info_tab.plugin_pid = -1; +	g_info_tab.plugin_stdout = -1; +	g_info_tab.plugin_output = str_make ();  	struct tab *super = &g_info_tab.super;  	tab_init (super, "Info"); +	super->on_action = info_tab_on_action;  	super->on_item_layout = info_tab_on_item_layout;  	return super;  } @@ -5377,7 +5794,7 @@ tui_on_tty_readable (const struct pollfd *fd, void *user_data)  	poller_timer_reset (&g.tk_timer);  	termo_advisereadable (g.tk); -	termo_key_t event; +	termo_key_t event = {};  	int64_t event_ts = clock_msec (CLOCK_BEST);  	termo_result_t res;  	while ((res = termo_getkey (g.tk, &event)) == TERMO_RES_KEY) @@ -6675,6 +7092,7 @@ app_log_handler (void *user_data, const char *quote, const char *fmt,  	str_append_vprintf (&message, fmt, ap);  	// Show it prettified to the user, then maybe log it elsewhere as well. +	// TODO: Review locale encoding vs UTF-8 in the entire program.  	message.str[0] = toupper_ascii (message.str[0]);  	app_show_message (xstrndup (message.str, quote_len),  		xstrdup (message.str + quote_len)); | 
