Implement lyrics lookup

There is now a generic mechanism for loading lyrics,
or any other arbitrary content related to songs.
This commit is contained in:
Přemysl Eric Janouch 2022-09-18 09:15:23 +02:00
parent b6dd940720
commit 28ed7a85a8
Signed by: p
GPG Key ID: A0420B94F92B9493
6 changed files with 507 additions and 34 deletions

View File

@ -104,6 +104,7 @@ foreach (extra m)
endforeach () endforeach ()
# Generate a configuration file # Generate a configuration file
include (GNUInstallDirs)
configure_file (${PROJECT_SOURCE_DIR}/config.h.in configure_file (${PROJECT_SOURCE_DIR}/config.h.in
${PROJECT_BINARY_DIR}/config.h) ${PROJECT_BINARY_DIR}/config.h)
include_directories (${PROJECT_SOURCE_DIR} ${PROJECT_BINARY_DIR}) include_directories (${PROJECT_SOURCE_DIR} ${PROJECT_BINARY_DIR})
@ -123,10 +124,10 @@ target_link_libraries (${PROJECT_NAME} ${Unistring_LIBRARIES}
add_threads (${PROJECT_NAME}) add_threads (${PROJECT_NAME})
# Installation # Installation
include (GNUInstallDirs)
install (TARGETS ${PROJECT_NAME} DESTINATION ${CMAKE_INSTALL_BINDIR}) install (TARGETS ${PROJECT_NAME} DESTINATION ${CMAKE_INSTALL_BINDIR})
install (FILES LICENSE DESTINATION ${CMAKE_INSTALL_DOCDIR}) install (FILES LICENSE DESTINATION ${CMAKE_INSTALL_DOCDIR})
install (DIRECTORY contrib DESTINATION ${CMAKE_INSTALL_DATADIR}/${PROJECT_NAME}) install (DIRECTORY contrib DESTINATION ${CMAKE_INSTALL_DATADIR}/${PROJECT_NAME})
install (DIRECTORY info DESTINATION ${CMAKE_INSTALL_DATADIR}/${PROJECT_NAME})
# Generate documentation from text markup # Generate documentation from text markup
find_program (ASCIIDOCTOR_EXECUTABLE asciidoctor) find_program (ASCIIDOCTOR_EXECUTABLE asciidoctor)

8
NEWS
View File

@ -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) 2.0.0 (2022-09-03)
* Added an optional X11 user interface * Added an optional X11 user interface

View File

@ -40,7 +40,7 @@ Building
Build dependencies: CMake, pkg-config, asciidoctor or asciidoc, Build dependencies: CMake, pkg-config, asciidoctor or asciidoc,
liberty (included), termo (included) + liberty (included), termo (included) +
Runtime dependencies: ncursesw, libunistring, cURL + 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 $ git clone --recursive https://git.janouch.name/p/nncmpp.git
$ mkdir nncmpp/build $ mkdir nncmpp/build

View File

@ -4,6 +4,9 @@
#define PROGRAM_NAME "${PROJECT_NAME}" #define PROGRAM_NAME "${PROJECT_NAME}"
#define PROGRAM_VERSION "${PROJECT_VERSION}" #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 HAVE_RESIZETERM
#cmakedefine WITH_FFTW #cmakedefine WITH_FFTW
#cmakedefine WITH_PULSE #cmakedefine WITH_PULSE

43
info/10-azlyrics.pl Executable file
View File

@ -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/&lt;/</g; s/&gt;/>/g; s/&quot;/"/g; s/&apos;/'/g; s/&amp;/&/g;
print;
}
close($curl) or die $?;
}
print "No lyrics have been found.\n" unless $found;

482
nncmpp.c
View File

@ -75,10 +75,11 @@ enum
#define HAVE_LIBERTY #define HAVE_LIBERTY
#include "line-editor.c" #include "line-editor.c"
#include <math.h> #include <dirent.h>
#include <locale.h> #include <locale.h>
#include <termios.h> #include <math.h>
#include <sys/ioctl.h> #include <sys/ioctl.h>
#include <termios.h>
// ncurses is notoriously retarded for input handling, we need something // ncurses is notoriously retarded for input handling, we need something
// different if only to receive mouse events reliably. // 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; 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 static bool
xstrtoul_map (const struct str_map *map, const char *key, unsigned long *out) 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); 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 static void
cstr_uncapitalize (char *s) 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; struct poller_curl_fd *fd;
if (!(fd = socket_data)) if (!(fd = socket_data))
{ {
set_cloexec (s);
fd = xmalloc (sizeof *fd); fd = xmalloc (sizeof *fd);
LIST_PREPEND (self->fds, fd); LIST_PREPEND (self->fds, fd);
@ -4089,68 +4118,456 @@ streams_tab_init (void)
// --- Info tab ---------------------------------------------------------------- // --- 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 static struct
{ {
struct tab super; ///< Parent class struct tab super; ///< Parent class
struct strv keys; ///< Data keys struct info_tab_item *items; ///< Items array
struct strv values; ///< Data values 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; 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 static struct layout
info_tab_on_item_layout (size_t item_index) info_tab_on_item_layout (size_t item_index)
{ {
const char *key = g_info_tab.keys.vector[item_index]; struct info_tab_item *item = &g_info_tab.items[item_index];
const char *value = g_info_tab.values.vector[item_index];
struct layout l = {}; 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); if (item->plugin)
app_push (&l, g.ui->label (A_BOLD, prefix)) app_push (&l, g.ui->label (A_BOLD, item->plugin->description));
->width = 8 * g.ui_hunit; else if (!item->text || !*item->text)
app_push (&l, g.ui->padding (0, 0.5, 1)); app_push (&l, g.ui->padding (0, 1, 1));
app_push_fill (&l, g.ui->label (0, value)); 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; 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 static void
info_tab_add (compact_map_t data, const char *field) info_tab_add (compact_map_t data, const char *field)
{ {
const char *value = compact_map_find (data, field); struct info_tab_item *item = info_tab_prepare ();
if (!value) value = ""; item->prefix = xstrdup (field);
item->text = xstrdup0 (compact_map_find (data, field));
strv_append (&g_info_tab.keys, field);
strv_append (&g_info_tab.values, value);
g_info_tab.super.item_count++;
} }
static void static void
info_tab_update (void) info_tab_update (void)
{ {
strv_reset (&g_info_tab.keys); while (g_info_tab.super.item_count)
strv_reset (&g_info_tab.values); info_tab_item_free (&g_info_tab.items[--g_info_tab.super.item_count]);
g_info_tab.super.item_count = 0;
compact_map_t map; compact_map_t map = item_list_get (&g.playlist, g.song);
if ((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"); (void) info_tab_prepare ();
info_tab_add (map, "Artist"); LIST_FOR_EACH (struct info_tab_plugin, plugin, g_info_tab.plugins)
info_tab_add (map, "Album"); info_tab_prepare ()->plugin = plugin;
info_tab_add (map, "Track"); }
info_tab_add (map, "Genre");
// Yes, it is "file", but this is also for display if (g_info_tab.plugin_pid != -1)
info_tab_add (map, "File"); {
(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 * static struct tab *
info_tab_init (void) info_tab_init (void)
{ {
g_info_tab.keys = strv_make (); g_info_tab.items =
g_info_tab.values = strv_make (); 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; struct tab *super = &g_info_tab.super;
tab_init (super, "Info"); tab_init (super, "Info");
super->on_action = info_tab_on_action;
super->on_item_layout = info_tab_on_item_layout; super->on_item_layout = info_tab_on_item_layout;
return super; return super;
} }
@ -5377,7 +5794,7 @@ tui_on_tty_readable (const struct pollfd *fd, void *user_data)
poller_timer_reset (&g.tk_timer); poller_timer_reset (&g.tk_timer);
termo_advisereadable (g.tk); termo_advisereadable (g.tk);
termo_key_t event; termo_key_t event = {};
int64_t event_ts = clock_msec (CLOCK_BEST); int64_t event_ts = clock_msec (CLOCK_BEST);
termo_result_t res; termo_result_t res;
while ((res = termo_getkey (g.tk, &event)) == TERMO_RES_KEY) 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); str_append_vprintf (&message, fmt, ap);
// Show it prettified to the user, then maybe log it elsewhere as well. // 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]); message.str[0] = toupper_ascii (message.str[0]);
app_show_message (xstrndup (message.str, quote_len), app_show_message (xstrndup (message.str, quote_len),
xstrdup (message.str + quote_len)); xstrdup (message.str + quote_len));