From 28ed7a85a8cbf3173f17e8ca9f7c8a7d5a7c98ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C5=99emysl=20Eric=20Janouch?= Date: Sun, 18 Sep 2022 09:15:23 +0200 Subject: [PATCH] Implement lyrics lookup There is now a generic mechanism for loading lyrics, or any other arbitrary content related to songs. --- CMakeLists.txt | 3 +- NEWS | 8 + README.adoc | 2 +- config.h.in | 3 + info/10-azlyrics.pl | 43 ++++ nncmpp.c | 482 +++++++++++++++++++++++++++++++++++++++++--- 6 files changed, 507 insertions(+), 34 deletions(-) create mode 100755 info/10-azlyrics.pl 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) diff --git a/NEWS b/NEWS index 4c48e72..2e2b9ea 100644 --- a/NEWS +++ b/NEWS @@ -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 +# 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>/; s///g; s/\s+$//gs; + + $found = 1; + s/<\/?b>/\x01/g; s/<\/?i>/\x02/g; s/
/\n/; s/<.+?>//g; + s/<//g; s/"/"/g; s/'/'/g; s/&/&/g; + print; + } + close($curl) or die $?; +} + +print "No lyrics have been found.\n" unless $found; diff --git a/nncmpp.c b/nncmpp.c index 4a65287..1ef2146 100644 --- a/nncmpp.c +++ b/nncmpp.c @@ -75,10 +75,11 @@ enum #define HAVE_LIBERTY #include "line-editor.c" -#include +#include #include -#include +#include #include +#include // 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) { @@ -164,6 +179,18 @@ latin1_to_utf8 (const char *latin1) return str_steal (&converted); } +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) { @@ -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) { - 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"); + (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) + { + 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));