diff --git a/.github/actions/install-dependencies/action.yml b/.github/actions/install-dependencies/action.yml index a86119622d..9b928c7dc1 100644 --- a/.github/actions/install-dependencies/action.yml +++ b/.github/actions/install-dependencies/action.yml @@ -25,11 +25,11 @@ runs: install: >- mingw-w64-i686-autotools mingw-w64-i686-faad2 mingw-w64-i686-ffmpeg mingw-w64-i686-flac mingw-w64-i686-fluidsynth mingw-w64-i686-gcc - mingw-w64-i686-gtk2 mingw-w64-i686-lame mingw-w64-i686-libbs2b - mingw-w64-i686-libcdio-paranoia mingw-w64-i686-libcue - mingw-w64-i686-libmodplug mingw-w64-i686-libopenmpt - mingw-w64-i686-libsamplerate mingw-w64-i686-libsidplayfp - mingw-w64-i686-libsoxr mingw-w64-i686-libvorbis mingw-w64-i686-meson - mingw-w64-i686-mpg123 mingw-w64-i686-neon mingw-w64-i686-opusfile - mingw-w64-i686-pkg-config mingw-w64-i686-qt5-base mingw-w64-i686-SDL2 - mingw-w64-i686-wavpack + mingw-w64-i686-gtk2 mingw-w64-i686-json-glib mingw-w64-i686-lame + mingw-w64-i686-libbs2b mingw-w64-i686-libcdio-paranoia + mingw-w64-i686-libcue mingw-w64-i686-libmodplug + mingw-w64-i686-libopenmpt mingw-w64-i686-libsamplerate + mingw-w64-i686-libsidplayfp mingw-w64-i686-libsoxr + mingw-w64-i686-libvorbis mingw-w64-i686-meson mingw-w64-i686-mpg123 + mingw-w64-i686-neon mingw-w64-i686-opusfile mingw-w64-i686-pkg-config + mingw-w64-i686-qt5-base mingw-w64-i686-SDL2 mingw-w64-i686-wavpack diff --git a/.github/actions/install-dependencies/install-dependencies.sh b/.github/actions/install-dependencies/install-dependencies.sh index 686480651e..ec2266fbbe 100755 --- a/.github/actions/install-dependencies/install-dependencies.sh +++ b/.github/actions/install-dependencies/install-dependencies.sh @@ -20,9 +20,10 @@ ubuntu_packages='gettext libadplug-dev libasound2-dev libavformat-dev libbinio-dev libbs2b-dev libcddb2-dev libcdio-cdda-dev libcue-dev libcurl4-gnutls-dev libdbus-glib-1-dev libfaad-dev libflac-dev libfluidsynth-dev libgl1-mesa-dev - libjack-jackd2-dev liblircclient-dev libmms-dev libmodplug-dev - libmp3lame-dev libmpg123-dev libneon27-gnutls-dev libnotify-dev - libopenmpt-dev libopusfile-dev libpulse-dev libqt5opengl5-dev + libjack-jackd2-dev libjson-glib-dev liblircclient-dev + libmms-dev libmodplug-dev libmp3lame-dev libmpg123-dev + libneon27-gnutls-dev libnotify-dev libopenmpt-dev + libopusfile-dev libpulse-dev libqt5opengl5-dev libqt5x11extras5-dev libsamplerate0-dev libsdl2-dev libsidplayfp-dev libsndfile1-dev libsndio-dev libsoxr-dev libvorbis-dev libwavpack-dev libxml2-dev qtbase5-dev diff --git a/acinclude.m4 b/acinclude.m4 index 07cb6f8a22..d455db2bef 100644 --- a/acinclude.m4 +++ b/acinclude.m4 @@ -207,6 +207,10 @@ fi AC_SUBST(USE_GTK) +if test $USE_GTK = yes ; then + PKG_CHECK_MODULES(JSON_GLIB, json-glib-1.0 >= 1.0) +fi + if test $HAVE_MSWINDOWS = yes ; then PKG_CHECK_MODULES(GIO, gio-2.0 >= 2.32) else diff --git a/configure.ac b/configure.ac index 9f21c17c7a..c37c9af8ea 100644 --- a/configure.ac +++ b/configure.ac @@ -76,13 +76,13 @@ TRANSPORT_PLUGINS="gio" if test "x$USE_GTK" = "xyes" ; then EFFECT_PLUGINS="$EFFECT_PLUGINS ladspa" - GENERAL_PLUGINS="$GENERAL_PLUGINS albumart playlist-manager search-tool statusicon" + GENERAL_PLUGINS="$GENERAL_PLUGINS albumart lyrics playlist-manager search-tool statusicon" GENERAL_PLUGINS="$GENERAL_PLUGINS gtkui skins" VISUALIZATION_PLUGINS="$VISUALIZATION_PLUGINS blur_scope cairo-spectrum" fi if test "x$USE_QT" = "xyes" ; then - GENERAL_PLUGINS="$GENERAL_PLUGINS albumart-qt lyricwiki-qt playlist-manager-qt search-tool-qt song-info-qt statusicon-qt" + GENERAL_PLUGINS="$GENERAL_PLUGINS albumart-qt lyrics-qt playlist-manager-qt search-tool-qt song-info-qt statusicon-qt" GENERAL_PLUGINS="$GENERAL_PLUGINS qtui skins-qt" VISUALIZATION_PLUGINS="$VISUALIZATION_PLUGINS blur_scope-qt qt-spectrum vumeter-qt" fi @@ -829,6 +829,7 @@ echo " Ampache browser (requires Qt): $have_ampache" echo " Delete Files: $USE_GTK_OR_QT" echo " libnotify OSD: $have_notify" echo " Linux Infrared Remote Control (LIRC): $have_lirc" +echo " Lyrics Viewer: yes" echo " MPRIS 2 Server: $have_mpris2" echo " Scrobbler 2.0: $have_scrobbler2" echo " Song Change: $have_songchange" @@ -859,7 +860,6 @@ if test "x$USE_QT" = "xyes" ; then echo " Winamp Classic Interface: yes" echo " Album Art: yes" echo " Blur Scope: yes" - echo " Lyrics Viewer: yes" echo " OpenGL Spectrum Analyzer: $have_qtglspectrum" echo " Playlist Manager: yes" echo " Search Tool: yes" diff --git a/extra.mk.in b/extra.mk.in index c5eec0a258..c702945a20 100644 --- a/extra.mk.in +++ b/extra.mk.in @@ -52,6 +52,8 @@ GTK_CFLAGS ?= @GTK_CFLAGS@ GTK_LIBS ?= @GTK_LIBS@ JACK_CFLAGS ?= @JACK_CFLAGS@ JACK_LIBS ?= @JACK_LIBS@ +JSON_GLIB_CFLAGS ?= @JSON_GLIB_CFLAGS@ +JSON_GLIB_LIBS ?= @JSON_GLIB_LIBS@ LIBFLAC_LIBS ?= @LIBFLAC_LIBS@ LIBFLAC_CFLAGS ?= @LIBFLAC_CFLAGS@ MMS_CFLAGS ?= @MMS_CFLAGS@ diff --git a/meson.build b/meson.build index e206f940e6..2ae728b900 100644 --- a/meson.build +++ b/meson.build @@ -295,6 +295,7 @@ if meson.version().version_compare('>= 0.53') 'Delete Files': conf.has('USE_GTK_OR_QT'), 'Libnotify OSD': get_variable('have_notify', false), 'Linux Infrared Remote Control (LIRC)': get_variable('have_lirc', false), + 'Lyrics Viewer': get_variable('have_lyrics', false), 'MPRIS 2 Server': get_variable('have_mpris2', false), 'Scrobbler 2.0': get_variable('have_scrobbler2', false), 'Song Change': get_option('songchange'), @@ -307,7 +308,6 @@ if meson.version().version_compare('>= 0.53') 'Winamp Classic Interface': true, 'Album Art': true, 'Blur Scope': true, - 'Lyrics Viewer': get_variable('have_lyrics', false), 'OpenGL Spectrum Analyzer': get_variable('have_qtglspectrum', false), 'Playlist Manager': true, 'Search Tool': true, diff --git a/src/lyrics-qt/Makefile b/src/lyrics-qt/Makefile new file mode 100644 index 0000000000..c0a23386fb --- /dev/null +++ b/src/lyrics-qt/Makefile @@ -0,0 +1,18 @@ +PLUGIN = lyrics-qt${PLUGIN_SUFFIX} + +SRCS = ../lyrics/chart_lyrics_provider.cc \ + ../lyrics/file_provider.cc \ + ../lyrics/lyrics-qt.cc \ + ../lyrics/lyrics_ovh_provider.cc \ + ../lyrics/utils.cc + +include ../../buildsys.mk +include ../../extra.mk + +plugindir := ${plugindir}/${GENERAL_PLUGIN_DIR} + +LD = ${CXX} + +CFLAGS += ${PLUGIN_CFLAGS} +CPPFLAGS += ${PLUGIN_CPPFLAGS} ${QT_CFLAGS} ${GLIB_CFLAGS} ${XML_CFLAGS} -I../.. +LIBS += ${QT_LIBS} ${GLIB_LIBS} ${XML_LIBS} diff --git a/src/lyrics/Makefile b/src/lyrics/Makefile new file mode 100644 index 0000000000..e4cd81cc55 --- /dev/null +++ b/src/lyrics/Makefile @@ -0,0 +1,18 @@ +PLUGIN = lyrics${PLUGIN_SUFFIX} + +SRCS = chart_lyrics_provider.cc \ + file_provider.cc \ + lyrics.cc \ + lyrics_ovh_provider.cc \ + utils.cc + +include ../../buildsys.mk +include ../../extra.mk + +plugindir := ${plugindir}/${GENERAL_PLUGIN_DIR} + +LD = ${CXX} + +CFLAGS += ${PLUGIN_CFLAGS} +CPPFLAGS += ${PLUGIN_CPPFLAGS} ${GTK_CFLAGS} ${GLIB_CFLAGS} ${JSON_GLIB_CFLAGS} ${XML_CFLAGS} -I../.. +LIBS += ${GTK_LIBS} ${GLIB_LIBS} ${JSON_GLIB_LIBS} ${XML_LIBS} -laudgui diff --git a/src/lyrics/chart_lyrics_provider.cc b/src/lyrics/chart_lyrics_provider.cc new file mode 100644 index 0000000000..16157cee99 --- /dev/null +++ b/src/lyrics/chart_lyrics_provider.cc @@ -0,0 +1,195 @@ +/* + * Copyright (c) 2024 Thomas Lange + * + * Permission to use, copy, modify, and/or distribute this software for any + * purpose with or without fee is hereby granted, provided that the above + * copyright notice and this permission notice appear in all copies. + * + * THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR + * IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, + * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, + * STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING + * IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + */ + +#include "lyrics.h" + +void ChartLyricsProvider::reset_lyric_metadata () +{ + m_lyric_id = -1; + m_lyric_checksum = String (); + m_lyric_url = String (); + m_lyrics = String (); +} + +String ChartLyricsProvider::match_uri (LyricsState state) +{ + auto artist = str_copy (state.artist); + artist = str_encode_percent (artist, -1); + + auto title = str_copy (state.title); + title = str_encode_percent (title, -1); + + return String (str_concat ({m_base_url, "/SearchLyric?artist=", artist, "&song=", title})); +} + +bool ChartLyricsProvider::has_match (LyricsState state, xmlNodePtr node) +{ + String lyric_id, checksum, url, artist, title; + + for (xmlNodePtr cur_node = node->xmlChildrenNode; cur_node; cur_node = cur_node->next) + { + if (cur_node->type != XML_ELEMENT_NODE) + continue; + + xmlChar * content = xmlNodeGetContent (cur_node); + + if (xmlStrEqual (cur_node->name, (xmlChar *) "LyricId")) + lyric_id = String ((const char *) content); + else if (xmlStrEqual (cur_node->name, (xmlChar *) "LyricChecksum")) + checksum = String ((const char *) content); + else if (xmlStrEqual (cur_node->name, (xmlChar *) "SongUrl")) + url = String ((const char *) content); + else if (xmlStrEqual (cur_node->name, (xmlChar *) "Artist")) + artist = String ((const char *) content); + else if (xmlStrEqual (cur_node->name, (xmlChar *) "Song")) + title = String ((const char *) content); + + xmlFree (content); + } + + if (lyric_id && checksum && artist && title) // url is optional + { + int id = str_to_int (lyric_id); + + if (id > 0 && + ! strcmp_nocase (artist, state.artist) && + ! strcmp_nocase (title, state.title)) + { + m_lyric_id = id; + m_lyric_checksum = checksum; + m_lyric_url = url; + + return true; + } + } + + return false; +} + +bool ChartLyricsProvider::match (LyricsState state) +{ + reset_lyric_metadata (); + + auto handle_result_cb = [=] (const char * uri, const Index & buf) { + if (! buf.len ()) + { + update_lyrics_window_error (str_printf (_("Unable to fetch %s"), uri)); + return; + } + + xmlDocPtr doc = xmlReadMemory (buf.begin (), buf.len (), nullptr, nullptr, 0); + if (! doc) + { + update_lyrics_window_error (str_printf (_("Unable to parse %s"), uri)); + return; + } + + xmlNodePtr root = xmlDocGetRootElement (doc); + + for (xmlNodePtr cur_node = root->xmlChildrenNode; cur_node; cur_node = cur_node->next) + { + if (cur_node->type != XML_ELEMENT_NODE) + continue; + + if (has_match (state, cur_node)) + break; + } + + xmlFreeDoc (doc); + + fetch (state); + }; + + vfs_async_file_get_contents (match_uri (state), handle_result_cb); + update_lyrics_window_message (state, _("Looking for lyrics ...")); + + return true; +} + +String ChartLyricsProvider::fetch_uri (LyricsState state) +{ + if (m_lyric_id <= 0 || ! m_lyric_checksum) + return String (); + + auto id = int_to_str (m_lyric_id); + auto checksum = str_copy (m_lyric_checksum); + checksum = str_encode_percent (checksum, -1); + + return String (str_concat ({m_base_url, "/GetLyric?lyricId=", id, "&lyricCheckSum=", checksum})); +} + +void ChartLyricsProvider::fetch (LyricsState state) +{ + String _fetch_uri = fetch_uri (state); + if (! _fetch_uri) + { + update_lyrics_window_notfound (state); + return; + } + + auto handle_result_cb = [=] (const char * uri, const Index & buf) { + if (! buf.len ()) + { + update_lyrics_window_error (str_printf (_("Unable to fetch %s"), uri)); + return; + } + + xmlDocPtr doc = xmlReadMemory (buf.begin (), buf.len (), nullptr, nullptr, 0); + if (! doc) + { + update_lyrics_window_error (str_printf (_("Unable to parse %s"), uri)); + return; + } + + xmlNodePtr root = xmlDocGetRootElement (doc); + + for (xmlNodePtr cur_node = root->xmlChildrenNode; cur_node; cur_node = cur_node->next) + { + if (cur_node->type == XML_ELEMENT_NODE && + xmlStrEqual (cur_node->name, (xmlChar *) "Lyric")) + { + xmlChar * content = xmlNodeGetContent (cur_node); + m_lyrics = String ((const char *) content); + xmlFree (content); + break; + } + } + + xmlFreeDoc (doc); + + LyricsState new_state = g_state; + new_state.lyrics = String (); + + if (! m_lyrics || ! m_lyrics[0]) + { + update_lyrics_window_notfound (new_state); + return; + } + + new_state.lyrics = m_lyrics; + new_state.source = LyricsState::Source::ChartLyrics; + + update_lyrics_window (new_state.title, new_state.artist, new_state.lyrics); + persist_state (new_state); + }; + + vfs_async_file_get_contents (_fetch_uri, handle_result_cb); + update_lyrics_window_message (state, _("Looking for lyrics ...")); +} diff --git a/src/lyrics/file_provider.cc b/src/lyrics/file_provider.cc new file mode 100644 index 0000000000..972777eaad --- /dev/null +++ b/src/lyrics/file_provider.cc @@ -0,0 +1,159 @@ +/* + * Copyright (c) 2010-2019 Ariadne Conill + * + * Permission to use, copy, modify, and/or distribute this software for any + * purpose with or without fee is hereby granted, provided that the above + * copyright notice and this permission notice appear in all copies. + * + * THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR + * IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, + * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, + * STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING + * IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + */ + +#include +#include + +#include "lyrics.h" +#include "preferences.h" + +void FileProvider::cache (LyricsState state) +{ + auto uri = cache_uri_for_entry (state); + if (! uri) + return; + + bool exists = VFSFile::test_file (uri, VFS_IS_REGULAR); + if (exists) + return; + + AUDINFO ("Add to cache: %s\n", (const char *) uri); + VFSFile::write_file (uri, state.lyrics, strlen (state.lyrics)); +} + +#ifdef S_IRGRP +#define DIRMODE (S_IRWXU | S_IRGRP | S_IXGRP | S_IROTH | S_IXOTH) +#else +#define DIRMODE (S_IRWXU) +#endif + +String FileProvider::cache_uri_for_entry (LyricsState state) +{ + if (! state.artist) + return String (); + + auto user_dir = aud_get_path (AudPath::UserDir); + StringBuf base_path = filename_build ({user_dir, "lyrics"}); + StringBuf artist_path = filename_build ({base_path, state.artist}); + + if (aud_get_bool (CFG_SECTION, "enable-cache")) + { + if (g_mkdir_with_parents (artist_path, DIRMODE) < 0) + AUDERR ("Failed to create '%s': %s\n", (const char *) artist_path, strerror (errno)); + } + + StringBuf title_path = str_concat ({filename_build({artist_path, state.title}), ".lrc"}); + + return String (filename_to_uri (title_path)); +} + +String FileProvider::local_uri_for_entry (LyricsState state) +{ + if (strcmp (uri_get_scheme (state.filename), "file")) + return String (); + + // it's a local file: convert our URI to a local path + StringBuf filename = uri_to_filename (state.filename); + + // strip off the extension + char * ext = strrchr ((char *) filename, '.'); + if (! ext) + return String (); + * ext = '\0'; + + // combine the mangled filename and '.lrc' extension + return String (filename_to_uri (str_concat ({filename, ".lrc"}))); +} + +void FileProvider::fetch (LyricsState state) +{ + String path = local_uri_for_entry (state); + if (! path) + return; + + auto data = VFSFile::read_file (path, VFS_APPEND_NULL); + if (! data.len ()) + return; + + state.lyrics = String (data.begin ()); + state.source = LyricsState::Source::Local; + + update_lyrics_window (state.title, state.artist, state.lyrics); + persist_state (state); +} + +void FileProvider::cache_fetch (LyricsState state) +{ + String path = cache_uri_for_entry (state); + if (! path) + return; + + auto data = VFSFile::read_file (path, VFS_APPEND_NULL); + if (! data.len ()) + return; + + state.lyrics = String (data.begin ()); + state.source = LyricsState::Source::Local; + + update_lyrics_window (state.title, state.artist, state.lyrics); + persist_state (state); +} + +bool FileProvider::match (LyricsState state) +{ + String path = local_uri_for_entry (state); + if (! path) + return false; + + AUDINFO ("Checking for local lyric file: '%s'\n", (const char *) path); + + bool exists = VFSFile::test_file (path, VFS_IS_REGULAR); + if (exists) + { + fetch (state); + return true; + } + + path = cache_uri_for_entry (state); + if (! path) + return false; + + AUDINFO ("Checking for cache lyric file: '%s'\n", (const char *) path); + + exists = VFSFile::test_file (path, VFS_IS_REGULAR); + if (exists) + cache_fetch (state); + + return exists; +} + +void FileProvider::save (LyricsState state) +{ + if (! state.lyrics) + return; + + String path = local_uri_for_entry (state); + if (! path) + return; + + AUDINFO ("Saving lyrics to local file: '%s'\n", (const char *) path); + + VFSFile::write_file (path, state.lyrics, strlen (state.lyrics)); +} diff --git a/src/lyrics/lyrics-qt.cc b/src/lyrics/lyrics-qt.cc new file mode 100644 index 0000000000..c08d17a421 --- /dev/null +++ b/src/lyrics/lyrics-qt.cc @@ -0,0 +1,304 @@ +/* + * Copyright (c) 2010-2019 Ariadne Conill + * + * Permission to use, copy, modify, and/or distribute this software for any + * purpose with or without fee is hereby granted, provided that the above + * copyright notice and this permission notice appear in all copies. + * + * THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR + * IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, + * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, + * STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING + * IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + */ + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +#include "lyrics.h" +#include "preferences.h" + +class TextEdit : public QTextEdit +{ +public: + TextEdit (QWidget * parent = nullptr) : QTextEdit (parent) {} + +protected: + void contextMenuEvent (QContextMenuEvent * event); +}; + +class LyricsQt : public GeneralPlugin +{ +public: + static const char * const defaults[]; + static const PluginPreferences prefs; + static const PreferencesWidget widgets[]; + static constexpr PluginInfo info = { + N_("Lyrics"), + PACKAGE, + nullptr, // about + & prefs, + PluginQtOnly + }; + + constexpr LyricsQt () : GeneralPlugin (info, false) {} + + bool init (); + void * get_qt_widget (); +}; + +EXPORT LyricsQt aud_plugin_instance; + +const char * const LyricsQt::defaults[] = { + "remote-source", "lyrics.ovh", + "enable-file-provider", "TRUE", + "enable-cache", "TRUE", + "split-title-on-chars", "FALSE", + "split-on-chars", "-", + "truncate-fields-on-chars", "FALSE", + "truncate-on-chars", "|", + "use-embedded", "TRUE", + nullptr +}; + +const PreferencesWidget LyricsQt::widgets[] = { + WidgetLabel (N_("General")), + WidgetCheck (N_("Split title into artist and title on chars"), + WidgetBool (CFG_SECTION, "split-title-on-chars")), + WidgetTable ({{split_elements}}, WIDGET_CHILD), + WidgetLabel (N_("Sources")), + WidgetCheck (N_("Use embedded lyrics (from Lyrics tag)"), + WidgetBool (CFG_SECTION, "use-embedded")), + WidgetCombo (N_("Fetch lyrics from internet:"), + WidgetString (CFG_SECTION, "remote-source"), + {{remote_sources}}), + WidgetCheck (N_("Store fetched lyrics in local cache"), + WidgetBool (CFG_SECTION, "enable-cache")), + WidgetLabel (N_("Local Storage")), + WidgetCheck (N_("Load lyric files (.lrc) from local storage"), + WidgetBool (CFG_SECTION, "enable-file-provider")) +}; + +const PluginPreferences LyricsQt::prefs = {{widgets}}; + +FileProvider file_provider; +ChartLyricsProvider chart_lyrics_provider; +LyricsOVHProvider lyrics_ovh_provider; + +LyricsState g_state; + +static QTextEdit * textedit; + +bool LyricsQt::init () +{ + aud_config_set_defaults (CFG_SECTION, defaults); + return true; +} + +void update_lyrics_window (const char * title, const char * artist, const char * lyrics) +{ + if (! textedit) + return; + + textedit->document ()->clear (); + + QTextCursor cursor (textedit->document ()); + cursor.insertHtml (QString ("") + QString (title) + QString ("")); + + if (artist) + cursor.insertHtml (QString ("
") + QString (artist) + QString ("")); + + cursor.insertHtml ("

"); + cursor.insertText (lyrics); +} + +bool try_parse_json (const Index & buf, const char * key, String & output) +{ + QByteArray json = QByteArray (buf.begin (), buf.len ()); + QJsonDocument doc = QJsonDocument::fromJson (json); + + if (doc.isNull () || ! doc.isObject ()) + return false; + + QJsonObject obj = doc.object (); + if (obj.contains (key)) + { + QString str = obj[key].toString (); + if (! str.isNull ()) + { + QByteArray raw_data = str.toLocal8Bit (); + output = String (raw_data.data ()); + } + } + + return true; +} + +static void lyrics_playback_began () +{ + // FIXME: Cancel previous VFS requests (not possible with current API) + + g_state.filename = aud_drct_get_filename (); + + Tuple tuple = aud_drct_get_tuple (); + g_state.title = tuple.get_str (Tuple::Title); + g_state.artist = tuple.get_str (Tuple::Artist); + g_state.lyrics = String (); + + if (aud_get_bool (CFG_SECTION, "split-title-on-chars")) + { + QString artist = QString (g_state.artist); + QString title = QString (g_state.title); + + QRegularExpression qre; + qre.setPattern (QString ("^(.*)\\s+[") + aud_get_str (CFG_SECTION, "split-on-chars") + "]\\s+(.*)$"); + + QRegularExpressionMatch qrematch = qre.match (title); + + if (qrematch.hasMatch ()) + { + artist = qrematch.captured (1); + title = qrematch.captured (2); + + if (aud_get_bool (CFG_SECTION, "truncate-fields-on-chars")) + { + qre.setPattern (QString ("^.*\\s+[") + aud_get_str (CFG_SECTION, "truncate-on-chars") + "]\\s+"); + artist.remove (qre); + + qre.setPattern (QString ("\\s+[") + aud_get_str (CFG_SECTION, "truncate-on-chars") + "]\\s+.*$"); + title.remove (qre); + } + + g_state.artist = String (); + g_state.title = String (); + g_state.artist = String (artist.toUtf8 ()); + g_state.title = String (title.toUtf8 ()); + } + } + + if (aud_get_bool (CFG_SECTION, "use-embedded")) + { + String embedded_lyrics = tuple.get_str (Tuple::Lyrics); + if (embedded_lyrics && embedded_lyrics[0]) + { + g_state.lyrics = embedded_lyrics; + g_state.source = LyricsState::Source::Embedded; + g_state.error = false; + update_lyrics_window (g_state.title, g_state.artist, g_state.lyrics); + return; + } + } + + if (! aud_get_bool (CFG_SECTION, "enable-file-provider") || ! file_provider.match (g_state)) + { + if (! g_state.artist || ! g_state.title) + { + update_lyrics_window_error (_("Missing title and/or artist.")); + return; + } + + auto rsrc = remote_source (); + if (rsrc) + { + rsrc->match (g_state); + return; + } + } + + if (! g_state.lyrics) + update_lyrics_window_notfound (g_state); +} + +static void lyrics_cleanup (QObject * object = nullptr) +{ + g_state.filename = String (); + g_state.title = String (); + g_state.artist = String (); + g_state.lyrics = String (); + + hook_dissociate ("tuple change", (HookFunction) lyrics_playback_began); + hook_dissociate ("playback ready", (HookFunction) lyrics_playback_began); + + textedit = nullptr; +} + +void * LyricsQt::get_qt_widget () +{ + textedit = new TextEdit; + textedit->setReadOnly (true); + +#ifdef Q_OS_MAC // Mac-specific font tweaks + textedit->document ()->setDefaultFont (QApplication::font ("QTipLabel")); +#endif + + hook_associate ("tuple change", (HookFunction) lyrics_playback_began, nullptr); + hook_associate ("playback ready", (HookFunction) lyrics_playback_began, nullptr); + + if (aud_drct_get_ready ()) + lyrics_playback_began (); + + QObject::connect (textedit, & QObject::destroyed, lyrics_cleanup); + + return textedit; +} + +void TextEdit::contextMenuEvent (QContextMenuEvent * event) +{ + if (! g_state.artist || ! g_state.title) + return QTextEdit::contextMenuEvent (event); + + LyricProvider * remote_provider = remote_source (); + + QMenu * menu = createStandardContextMenu (); + menu->addSeparator (); + + if (g_state.lyrics && g_state.source != LyricsState::Source::Local && ! g_state.error) + { + if (remote_provider) + { + String edit_uri = remote_provider->edit_uri (g_state); + + if (edit_uri && edit_uri[0]) + { + QAction * edit = menu->addAction (_("Edit Lyrics ...")); + QObject::connect (edit, & QAction::triggered, [edit_uri] () { + QDesktopServices::openUrl (QUrl ((const char *) edit_uri)); + }); + } + } + + QAction * save = menu->addAction (_("Save Locally")); + QObject::connect (save, & QAction::triggered, [] () { + file_provider.save (g_state); + }); + } + + if (g_state.source == LyricsState::Source::Local || g_state.error) + { + QAction * refresh = menu->addAction (_("Refresh")); + QObject::connect (refresh, & QAction::triggered, [remote_provider] () { + if (remote_provider) + remote_provider->match (g_state); + }); + } + + menu->exec (event->globalPos ()); + menu->deleteLater (); +} diff --git a/src/lyrics/lyrics.cc b/src/lyrics/lyrics.cc new file mode 100644 index 0000000000..f19d03f7e1 --- /dev/null +++ b/src/lyrics/lyrics.cc @@ -0,0 +1,375 @@ +/* + * Copyright (c) 2010-2019 Ariadne Conill + * Copyright (c) 2024 Thomas Lange + * + * Permission to use, copy, modify, and/or distribute this software for any + * purpose with or without fee is hereby granted, provided that the above + * copyright notice and this permission notice appear in all copies. + * + * THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR + * IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, + * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, + * STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING + * IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + */ + +#include + +#define AUD_GLIB_INTEGRATION +#include +#include +#include +#include +#include + +#include "lyrics.h" +#include "preferences.h" + +class Lyrics : public GeneralPlugin +{ +public: + static const char * const defaults[]; + static const PluginPreferences prefs; + static const PreferencesWidget widgets[]; + static constexpr PluginInfo info = { + N_("Lyrics"), + PACKAGE, + nullptr, // about + & prefs, + PluginGLibOnly + }; + + constexpr Lyrics () : GeneralPlugin (info, false) {} + + bool init (); + void * get_gtk_widget (); +}; + +EXPORT Lyrics aud_plugin_instance; + +const char * const Lyrics::defaults[] = { + "remote-source", "lyrics.ovh", + "enable-file-provider", "TRUE", + "enable-cache", "TRUE", + "split-title-on-chars", "FALSE", + "split-on-chars", "-", + "truncate-fields-on-chars", "FALSE", + "truncate-on-chars", "|", + "use-embedded", "TRUE", + nullptr +}; + +const PreferencesWidget Lyrics::widgets[] = { + WidgetLabel (N_("General")), + WidgetCheck (N_("Split title into artist and title on chars"), + WidgetBool (CFG_SECTION, "split-title-on-chars")), + WidgetTable ({{split_elements}}, WIDGET_CHILD), + WidgetLabel (N_("Sources")), + WidgetCheck (N_("Use embedded lyrics (from Lyrics tag)"), + WidgetBool (CFG_SECTION, "use-embedded")), + WidgetCombo (N_("Fetch lyrics from internet:"), + WidgetString (CFG_SECTION, "remote-source"), + {{remote_sources}}), + WidgetCheck (N_("Store fetched lyrics in local cache"), + WidgetBool (CFG_SECTION, "enable-cache")), + WidgetLabel (N_("Local Storage")), + WidgetCheck (N_("Load lyric files (.lrc) from local storage"), + WidgetBool (CFG_SECTION, "enable-file-provider")) +}; + +const PluginPreferences Lyrics::prefs = {{widgets}}; + +FileProvider file_provider; +ChartLyricsProvider chart_lyrics_provider; +LyricsOVHProvider lyrics_ovh_provider; + +LyricsState g_state; + +static GtkTextView * textview; +static GtkTextBuffer * textbuffer; + +bool Lyrics::init () +{ + aud_config_set_defaults (CFG_SECTION, defaults); + return true; +} + +void update_lyrics_window (const char * title, const char * artist, const char * lyrics) +{ + GtkTextIter iter; + + if (! textbuffer) + return; + + gtk_text_buffer_set_text (textbuffer, "", -1); + + gtk_text_buffer_get_start_iter (textbuffer, & iter); + + gtk_text_buffer_insert_with_tags_by_name (textbuffer, & iter, title, -1, + "weight_bold", "scale_large", nullptr); + + if (artist) + { + gtk_text_buffer_insert (textbuffer, & iter, "\n", -1); + gtk_text_buffer_insert_with_tags_by_name (textbuffer, & iter, artist, -1, + "style_italic", nullptr); + } + + gtk_text_buffer_insert (textbuffer, & iter, "\n\n", -1); + gtk_text_buffer_insert (textbuffer, & iter, lyrics, -1); + + gtk_text_buffer_get_start_iter (textbuffer, & iter); + gtk_text_view_scroll_to_iter (textview, & iter, 0, true, 0, 0); +} + +bool try_parse_json (const Index & buf, const char * key, String & output) +{ + JsonParser * parser = json_parser_new (); + + if (! json_parser_load_from_data (parser, buf.begin (), buf.len (), nullptr)) + { + g_object_unref (parser); + return false; + } + + JsonNode * root = json_parser_get_root (parser); + JsonReader * reader = json_reader_new (root); + + json_reader_read_member (reader, key); + output = String (json_reader_get_string_value (reader)); + json_reader_end_member (reader); + + g_object_unref (reader); + g_object_unref (parser); + + return true; +} + +static char * truncate_by_pattern (const char * input, const char * pattern) +{ + GRegex * regex = g_regex_new (pattern, G_REGEX_CASELESS, (GRegexMatchFlags) 0, nullptr); + char * result = g_regex_replace (regex, input, strlen (input), 0, "", (GRegexMatchFlags) 0, nullptr); + g_regex_unref (regex); + return result; +} + +static void split_title_and_truncate () +{ + StringBuf split_pattern = str_concat ({ + "^(.*)\\s+[", aud_get_str (CFG_SECTION, "split-on-chars"), "]\\s+(.*)$" + }); + + GMatchInfo * match_info; + GRegex * split_regex = g_regex_new (split_pattern, G_REGEX_CASELESS, (GRegexMatchFlags) 0, nullptr); + + if (g_regex_match (split_regex, g_state.title, (GRegexMatchFlags) 0, & match_info)) + { + CharPtr artist (g_match_info_fetch (match_info, 1)); + CharPtr title (g_match_info_fetch (match_info, 2)); + + if (aud_get_bool (CFG_SECTION, "truncate-fields-on-chars")) + { + StringBuf artist_pattern = str_concat ({ + "^.*\\s+[", aud_get_str (CFG_SECTION, "truncate-on-chars"), "]\\s+" + }); + + StringBuf title_pattern = str_concat ({ + "\\s+[", aud_get_str (CFG_SECTION, "truncate-on-chars"), "]\\s+.*$" + }); + + // TODO: Are these reassignments safe and not leaking memory? + artist = CharPtr (truncate_by_pattern (artist, artist_pattern)); + title = CharPtr (truncate_by_pattern (title, title_pattern)); + } + + g_state.artist = String (); + g_state.title = String (); + g_state.artist = String (artist); + g_state.title = String (title); + } + + g_match_info_free (match_info); + g_regex_unref (split_regex); +} + +static void lyrics_playback_began () +{ + // FIXME: Cancel previous VFS requests (not possible with current API) + + g_state.filename = aud_drct_get_filename (); + + Tuple tuple = aud_drct_get_tuple (); + g_state.title = tuple.get_str (Tuple::Title); + g_state.artist = tuple.get_str (Tuple::Artist); + g_state.lyrics = String (); + + if (aud_get_bool (CFG_SECTION, "split-title-on-chars")) + split_title_and_truncate (); + + if (aud_get_bool (CFG_SECTION, "use-embedded")) + { + String embedded_lyrics = tuple.get_str (Tuple::Lyrics); + if (embedded_lyrics && embedded_lyrics[0]) + { + g_state.lyrics = embedded_lyrics; + g_state.source = LyricsState::Source::Embedded; + g_state.error = false; + update_lyrics_window (g_state.title, g_state.artist, g_state.lyrics); + return; + } + } + + if (! aud_get_bool (CFG_SECTION, "enable-file-provider") || ! file_provider.match (g_state)) + { + if (! g_state.artist || ! g_state.title) + { + update_lyrics_window_error (_("Missing title and/or artist.")); + return; + } + + auto rsrc = remote_source (); + if (rsrc) + { + rsrc->match (g_state); + return; + } + } + + if (! g_state.lyrics) + update_lyrics_window_notfound (g_state); +} + +static void destroy_cb () +{ + g_state.filename = String (); + g_state.title = String (); + g_state.artist = String (); + g_state.lyrics = String (); + + hook_dissociate ("tuple change", (HookFunction) lyrics_playback_began); + hook_dissociate ("playback ready", (HookFunction) lyrics_playback_began); + + textview = nullptr; + textbuffer = nullptr; +} + +static void append_item_to_menu (GtkWidget * menu, const char * label, GCallback callback, void * data) +{ + GtkWidget * menu_item = (label != nullptr) + ? gtk_menu_item_new_with_label (label) + : gtk_separator_menu_item_new (); + + if (callback) + g_signal_connect (menu_item, "activate", callback, data); + + gtk_menu_shell_append ((GtkMenuShell *) menu, menu_item); + gtk_widget_show (menu_item); +} + +static void append_separator_to_menu (GtkWidget * menu) +{ + append_item_to_menu (menu, nullptr, nullptr, nullptr); +} + +static void edit_lyrics_cb (GtkMenuItem * menu_item, void * data) +{ + const char * edit_uri = aud::from_ptr (data); +#ifdef USE_GTK3 + gtk_show_uri_on_window (nullptr, edit_uri, GDK_CURRENT_TIME, nullptr); +#else + gtk_show_uri (nullptr, edit_uri, GDK_CURRENT_TIME, nullptr); +#endif +} + +static void save_locally_cb (GtkMenuItem * menu_item, void * data) +{ + file_provider.save (g_state); +} + +static void refresh_cb (GtkMenuItem * menu_item, void * data) +{ + LyricProvider * remote_provider = (LyricProvider *) data; + if (remote_provider) + remote_provider->match (g_state); +} + +static void populate_popup_cb (GtkTextView * text_view, GtkWidget * menu, void * data) +{ + if (! g_state.artist || ! g_state.title || ! GTK_IS_MENU (menu)) + return; + + LyricProvider * remote_provider = remote_source (); + + append_separator_to_menu (menu); + + if (g_state.lyrics && g_state.source != LyricsState::Source::Local && ! g_state.error) + { + if (remote_provider) + { + String edit_uri = remote_provider->edit_uri (g_state); + + // TODO: Is passing 'edit_uri' to 'edit_lyrics_cb' like this safe? + if (edit_uri && edit_uri[0]) + append_item_to_menu (menu, _("Edit Lyrics ..."), + (GCallback) edit_lyrics_cb, aud::to_ptr ((const char *) edit_uri)); + } + + append_item_to_menu (menu, _("Save Locally"), (GCallback) save_locally_cb, nullptr); + } + + if (g_state.source == LyricsState::Source::Local || g_state.error) + append_item_to_menu (menu, _("Refresh"), (GCallback) refresh_cb, remote_provider); +} + +static GtkWidget * build_widget () +{ + textview = (GtkTextView *) gtk_text_view_new (); + gtk_text_view_set_editable (textview, false); + gtk_text_view_set_cursor_visible (textview, false); + gtk_text_view_set_left_margin (textview, 4); + gtk_text_view_set_right_margin (textview, 4); + gtk_text_view_set_wrap_mode (textview, GTK_WRAP_WORD); + textbuffer = gtk_text_view_get_buffer (textview); + + GtkWidget * scrollview = gtk_scrolled_window_new (nullptr, nullptr); + gtk_scrolled_window_set_shadow_type ((GtkScrolledWindow *) scrollview, GTK_SHADOW_IN); + gtk_scrolled_window_set_policy ((GtkScrolledWindow *) scrollview, GTK_POLICY_AUTOMATIC, GTK_POLICY_AUTOMATIC); + GtkWidget * vbox = audgui_vbox_new (6); + + g_signal_connect (textview, "populate-popup", (GCallback) populate_popup_cb, nullptr); + + gtk_container_add ((GtkContainer *) scrollview, (GtkWidget *) textview); + gtk_box_pack_start ((GtkBox *) vbox, scrollview, true, true, 0); + + gtk_widget_show_all (vbox); + + gtk_text_buffer_create_tag (textbuffer, "weight_bold", "weight", PANGO_WEIGHT_BOLD, nullptr); + gtk_text_buffer_create_tag (textbuffer, "scale_large", "scale", PANGO_SCALE_LARGE, nullptr); + gtk_text_buffer_create_tag (textbuffer, "style_italic", "style", PANGO_STYLE_ITALIC, nullptr); + + GtkWidget * hbox = audgui_hbox_new (6); + gtk_box_pack_start ((GtkBox *) vbox, hbox, false, false, 0); + + return vbox; +} + +void * Lyrics::get_gtk_widget () +{ + GtkWidget * vbox = build_widget (); + + hook_associate ("tuple change", (HookFunction) lyrics_playback_began, nullptr); + hook_associate ("playback ready", (HookFunction) lyrics_playback_began, nullptr); + + if (aud_drct_get_ready ()) + lyrics_playback_began (); + + g_signal_connect (vbox, "destroy", (GCallback) destroy_cb, nullptr); + + return vbox; +} diff --git a/src/lyrics/lyrics.h b/src/lyrics/lyrics.h new file mode 100644 index 0000000000..42f2b27636 --- /dev/null +++ b/src/lyrics/lyrics.h @@ -0,0 +1,142 @@ +/* + * Copyright (c) 2010-2019 Ariadne Conill + * Copyright (c) 2024 Thomas Lange + * + * Permission to use, copy, modify, and/or distribute this software for any + * purpose with or without fee is hereby granted, provided that the above + * copyright notice and this permission notice appear in all copies. + * + * THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR + * IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, + * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, + * STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING + * IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + */ + +#ifndef AUDACIOUS_LYRICS_H +#define AUDACIOUS_LYRICS_H + +#include +#include + +#include +#include +#include +#include +#include + +struct LyricsState { + String filename; // of song file + String title, artist; + String lyrics; + + enum Source { + None, + Embedded, + Local, + LyricWiki, // TODO: Can this be safely removed? + LyricsOVH, + ChartLyrics + } source = None; + + bool error = false; +}; + + +// LyricProvider encapsulates an entire strategy for fetching lyrics, +// for example from chartlyrics.com, lyrics.ovh or local storage. +class LyricProvider +{ +public: + virtual bool match (LyricsState state) = 0; + virtual void fetch (LyricsState state) = 0; + virtual String edit_uri (LyricsState state) = 0; +}; + + +// FileProvider provides a strategy for fetching and saving lyrics +// in local files. It also manages the local lyrics cache. +class FileProvider : public LyricProvider +{ +public: + FileProvider () {}; + + bool match (LyricsState state) override; + void fetch (LyricsState state) override; + String edit_uri (LyricsState state) override { return String (); } + + void save (LyricsState state); + void cache (LyricsState state); + void cache_fetch (LyricsState state); + +private: + String local_uri_for_entry (LyricsState state); + String cache_uri_for_entry (LyricsState state); +}; + + +// ChartLyricsProvider provides a strategy for fetching lyrics using the API +// from chartlyrics.com. It uses the two-step approach since the endpoint +// "SearchLyricDirect" may sometimes return incorrect data. One example is +// "Metallica - Unforgiven II" which leads to the lyrics of "Unforgiven". +class ChartLyricsProvider : public LyricProvider +{ +public: + ChartLyricsProvider () {}; + + bool match (LyricsState state) override; + void fetch (LyricsState state) override; + String edit_uri (LyricsState state) override { return m_lyric_url; } + +private: + String match_uri (LyricsState state); + String fetch_uri (LyricsState state); + + void reset_lyric_metadata (); + bool has_match (LyricsState state, xmlNodePtr node); + + int m_lyric_id = -1; + String m_lyric_checksum, m_lyric_url, m_lyrics; + + const char * m_base_url = "http://api.chartlyrics.com/apiv1.asmx"; +}; + + +// LyricsOVHProvider provides a strategy for fetching lyrics using the +// lyrics.ovh search engine. +class LyricsOVHProvider : public LyricProvider +{ +public: + LyricsOVHProvider () {}; + + bool match (LyricsState state) override; + void fetch (LyricsState state) override; + String edit_uri (LyricsState state) override { return String (); } + +private: + const char * m_base_url = "https://api.lyrics.ovh"; +}; + + +extern FileProvider file_provider; +extern ChartLyricsProvider chart_lyrics_provider; +extern LyricsOVHProvider lyrics_ovh_provider; +LyricProvider * remote_source (); + +extern LyricsState g_state; +void persist_state (LyricsState state); + +void update_lyrics_window (const char * title, const char * artist, const char * lyrics); +void update_lyrics_window_message (LyricsState state, const char * message); +void update_lyrics_window_error (const char * message); +void update_lyrics_window_notfound (LyricsState state); + +bool try_parse_json (const Index & buf, const char * key, String & output); + +#endif // AUDACIOUS_LYRICS_H diff --git a/src/lyrics/lyrics_ovh_provider.cc b/src/lyrics/lyrics_ovh_provider.cc new file mode 100644 index 0000000000..6356fb8f7a --- /dev/null +++ b/src/lyrics/lyrics_ovh_provider.cc @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2010-2019 Ariadne Conill + * + * Permission to use, copy, modify, and/or distribute this software for any + * purpose with or without fee is hereby granted, provided that the above + * copyright notice and this permission notice appear in all copies. + * + * THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR + * IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, + * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, + * STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING + * IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + */ + +#include "lyrics.h" + +bool LyricsOVHProvider::match (LyricsState state) +{ + fetch (state); + return true; +} + +void LyricsOVHProvider::fetch (LyricsState state) +{ + auto handle_result_cb = [=] (const char * uri, const Index & buf) { + if (! buf.len ()) + { + update_lyrics_window_error (str_printf (_("Unable to fetch %s"), uri)); + return; + } + + String lyrics; + if (! try_parse_json (buf, "lyrics", lyrics)) + { + update_lyrics_window_error (str_printf (_("Unable to parse %s"), uri)); + return; + } + + LyricsState new_state = g_state; + new_state.lyrics = lyrics; + + if (! lyrics) + { + update_lyrics_window_notfound (new_state); + return; + } + + new_state.source = LyricsState::Source::LyricsOVH; + + update_lyrics_window (new_state.title, new_state.artist, new_state.lyrics); + persist_state (new_state); + }; + + auto artist = str_copy (state.artist); + artist = str_encode_percent (state.artist, -1); + + auto title = str_copy (state.title); + title = str_encode_percent (state.title, -1); + + auto fetch_uri = str_concat ({m_base_url, "/v1/", artist, "/", title}); + + vfs_async_file_get_contents (fetch_uri, handle_result_cb); + update_lyrics_window_message (state, _("Looking for lyrics ...")); +} diff --git a/src/lyrics/meson.build b/src/lyrics/meson.build new file mode 100644 index 0000000000..56abf16dda --- /dev/null +++ b/src/lyrics/meson.build @@ -0,0 +1,33 @@ +have_lyrics = xml_dep.found() + +lyrics_deps = [audacious_dep, glib_dep, xml_dep] +lyrics_src = [ + 'chart_lyrics_provider.cc', + 'file_provider.cc', + 'lyrics_ovh_provider.cc', + 'utils.cc' +] + +if have_lyrics + if conf.has('USE_QT') + shared_module('lyrics-qt', + ['lyrics-qt.cc'] + lyrics_src, + dependencies: [audqt_dep, qt_dep] + lyrics_deps, + name_prefix: '', + install: true, + install_dir: general_plugin_dir + ) + endif + + if conf.has('USE_GTK') + json_glib_dep = dependency('json-glib-1.0', version: '>= 1.0', required: true) + + shared_module('lyrics', + ['lyrics.cc'] + lyrics_src, + dependencies: [audgui_dep, gtk_dep, json_glib_dep] + lyrics_deps, + name_prefix: '', + install: true, + install_dir: general_plugin_dir + ) + endif +endif diff --git a/src/lyrics/preferences.h b/src/lyrics/preferences.h new file mode 100644 index 0000000000..fbd0f23224 --- /dev/null +++ b/src/lyrics/preferences.h @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2010-2019 Ariadne Conill + * + * Permission to use, copy, modify, and/or distribute this software for any + * purpose with or without fee is hereby granted, provided that the above + * copyright notice and this permission notice appear in all copies. + * + * THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR + * IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, + * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, + * STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING + * IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + */ + +#ifndef AUDACIOUS_LYRICS_PREFERENCES_H +#define AUDACIOUS_LYRICS_PREFERENCES_H + +#include +#include + +// Use former plugin name to stay backwards compatible +static constexpr const char * CFG_SECTION = "lyricwiki"; + +static const ComboItem remote_sources[] = { + ComboItem (N_("Nowhere"), "nowhere"), + ComboItem ("chartlyrics.com", "chartlyrics.com"), + ComboItem ("lyrics.ovh", "lyrics.ovh") +}; + +static const PreferencesWidget truncate_elements[] = { + WidgetLabel (N_("Artist is truncated at the start, Title -- at the end")), + WidgetEntry (N_("Chars to truncate on:"), WidgetString (CFG_SECTION, "truncate-on-chars")) +}; + +static const PreferencesWidget split_elements[] = { + WidgetLabel (N_("Chars are ORed in RegExp, surrounded by whitespace")), + WidgetEntry (N_("Chars to split on:"), WidgetString (CFG_SECTION, "split-on-chars")), + WidgetCheck (N_("Further truncate those on chars"), + WidgetBool (CFG_SECTION, "truncate-fields-on-chars")), + WidgetTable ({{truncate_elements}}, WIDGET_CHILD) +}; + +#endif // AUDACIOUS_LYRICS_PREFERENCES_H diff --git a/src/lyrics/utils.cc b/src/lyrics/utils.cc new file mode 100644 index 0000000000..348258ff88 --- /dev/null +++ b/src/lyrics/utils.cc @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2010-2019 Ariadne Conill + * + * Permission to use, copy, modify, and/or distribute this software for any + * purpose with or without fee is hereby granted, provided that the above + * copyright notice and this permission notice appear in all copies. + * + * THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR + * IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, + * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, + * STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING + * IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + */ + +#include "lyrics.h" +#include "preferences.h" + +LyricProvider * remote_source () +{ + auto source = aud_get_str (CFG_SECTION, "remote-source"); + + if (! strcmp (source, "chartlyrics.com")) + return & chart_lyrics_provider; + + if (! strcmp (source, "lyrics.ovh")) + return & lyrics_ovh_provider; + + return nullptr; +} + +void persist_state (LyricsState state) +{ + g_state = state; + g_state.error = false; + + if (g_state.source == LyricsState::Source::Local || ! aud_get_bool (CFG_SECTION, "enable-cache")) + return; + + file_provider.cache (state); +} + +void update_lyrics_window_message (LyricsState state, const char * message) +{ + update_lyrics_window (state.title, state.artist, message); +} + +void update_lyrics_window_error (const char * message) +{ + update_lyrics_window (_("Error"), nullptr, message); + g_state.error = true; +} + +void update_lyrics_window_notfound (LyricsState state) +{ + update_lyrics_window (state.title, state.artist, _("Lyrics could not be found.")); + g_state.error = true; +} diff --git a/src/lyricwiki-qt/Makefile b/src/lyricwiki-qt/Makefile deleted file mode 100644 index a17bc66d74..0000000000 --- a/src/lyricwiki-qt/Makefile +++ /dev/null @@ -1,14 +0,0 @@ -PLUGIN = lyricwiki-qt${PLUGIN_SUFFIX} - -SRCS = lyricwiki.cc - -include ../../buildsys.mk -include ../../extra.mk - -plugindir := ${plugindir}/${GENERAL_PLUGIN_DIR} - -LD = ${CXX} - -CFLAGS += ${PLUGIN_CFLAGS} -CPPFLAGS += ${PLUGIN_CPPFLAGS} ${QT_CFLAGS} ${GLIB_CFLAGS} ${XML_CFLAGS} -I../.. -LIBS += ${QT_LIBS} ${GLIB_LIBS} ${XML_LIBS} diff --git a/src/lyricwiki-qt/lyricwiki.cc b/src/lyricwiki-qt/lyricwiki.cc deleted file mode 100644 index 45e7d8a37f..0000000000 --- a/src/lyricwiki-qt/lyricwiki.cc +++ /dev/null @@ -1,816 +0,0 @@ -/* - * Copyright (c) 2010, 2014, 2019 Ariadne Conill - * - * Permission to use, copy, modify, and/or distribute this software for any - * purpose with or without fee is hereby granted, provided that the above - * copyright notice and this permission notice appear in all copies. - * - * THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR - * IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED - * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE - * DISCLAIMED. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, - * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES - * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR - * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) - * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, - * STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING - * IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE - * POSSIBILITY OF SUCH DAMAGE. - */ - -#include - -#include -#include -#include - -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include - -#define AUD_GLIB_INTEGRATION -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include - -struct LyricsState { - String filename; /* of song file */ - String title, artist; - String lyrics; - - enum Source { - None, - Embedded, - Local, - LyricWiki, - LyricsOVH, - ChartLyrics - } source = None; - - bool error = false; -}; - -static LyricsState g_state; - -class TextEdit : public QTextEdit -{ -public: - TextEdit (QWidget * parent = nullptr) : QTextEdit (parent) {} - -protected: - void contextMenuEvent (QContextMenuEvent * event); -}; - -class LyricWikiQt : public GeneralPlugin -{ -public: - static const char * const defaults[]; - static const PluginPreferences prefs; - static const PreferencesWidget widgets[]; - static constexpr PluginInfo info = { - N_("Lyrics"), - PACKAGE, - nullptr, // about - & prefs, - PluginQtOnly - }; - - constexpr LyricWikiQt () : GeneralPlugin (info, false) {} - - bool init (); - void * get_qt_widget (); -}; - -EXPORT LyricWikiQt aud_plugin_instance; - -const char * const LyricWikiQt::defaults[] = { - "remote-source", "lyrics.ovh", - "enable-file-provider", "TRUE", - "enable-cache", "TRUE", - "split-title-on-chars", "FALSE", - "split-on-chars", "-", - "truncate-fields-on-chars", "FALSE", - "truncate-on-chars", "|", - "use-embedded", "TRUE", - nullptr -}; - -static const ComboItem remote_sources[] = { - ComboItem(N_("Nowhere"), "nowhere"), - ComboItem("chartlyrics.com", "chartlyrics.com"), - ComboItem("lyrics.ovh", "lyrics.ovh") -}; - -static const PreferencesWidget truncate_elements[] = { - WidgetLabel(N_("Artist is truncated at the start, Title -- at the end")), - WidgetEntry(N_("Chars to truncate on:"), WidgetString ("lyricwiki", "truncate-on-chars")) -}; - -static const PreferencesWidget split_elements[] = { - WidgetLabel(N_("Chars are ORed in RegExp, surrounded by whitespace")), - WidgetEntry(N_("Chars to split on:"), WidgetString ("lyricwiki", "split-on-chars")), - WidgetCheck(N_("Further truncate those on chars"), - WidgetBool ("lyricwiki", "truncate-fields-on-chars")), - WidgetTable({{truncate_elements}}, WIDGET_CHILD) -}; - -const PreferencesWidget LyricWikiQt::widgets[] = { - WidgetLabel(N_("General")), - WidgetCheck(N_("Split title into artist and title on chars"), - WidgetBool ("lyricwiki", "split-title-on-chars")), - WidgetTable({{split_elements}}, WIDGET_CHILD), - WidgetLabel(N_("Sources")), - WidgetCheck(N_("Use embedded lyrics (from Lyrics tag)"), - WidgetBool ("lyricwiki", "use-embedded")), - WidgetCombo(N_("Fetch lyrics from internet:"), - WidgetString ("lyricwiki", "remote-source"), - {{remote_sources}}), - WidgetCheck(N_("Store fetched lyrics in local cache"), - WidgetBool ("lyricwiki", "enable-cache")), - WidgetLabel(N_("Local Storage")), - WidgetCheck(N_("Load lyric files (.lrc) from local storage"), - WidgetBool ("lyricwiki", "enable-file-provider")) -}; - -const PluginPreferences LyricWikiQt::prefs = {{widgets}}; - -bool LyricWikiQt::init () -{ - aud_config_set_defaults ("lyricwiki", defaults); - - return true; -} - -static void update_lyrics_window (const char * title, const char * artist, const char * lyrics); -static void update_lyrics_window_message (LyricsState state, const char * message); -static void update_lyrics_window_error (const char * message); -static void update_lyrics_window_notfound (LyricsState state); - -// LyricProvider encapsulates an entire strategy for fetching lyrics, -// for example from chartlyrics.com, lyrics.ovh or local storage. -class LyricProvider { -public: - virtual bool match (LyricsState state) = 0; - virtual void fetch (LyricsState state) = 0; - virtual String edit_uri (LyricsState state) = 0; -}; - -// FileProvider provides a strategy for fetching and saving lyrics -// in local files. It also manages the local lyrics cache. -class FileProvider : public LyricProvider { -public: - FileProvider() {}; - - bool match (LyricsState state) override; - void fetch (LyricsState state) override; - String edit_uri (LyricsState state) override { return String (); } - - void save (LyricsState state); - void cache (LyricsState state); - void cache_fetch (LyricsState state); - -private: - String local_uri_for_entry (LyricsState state); - String cache_uri_for_entry (LyricsState state); -}; - -static FileProvider file_provider; - -static void persist_state (LyricsState state) -{ - g_state = state; - g_state.error = false; - - if (g_state.source == LyricsState::Source::Local || ! aud_get_bool("lyricwiki", "enable-cache")) - return; - - file_provider.cache (state); -} - -void FileProvider::cache (LyricsState state) -{ - auto uri = cache_uri_for_entry (state); - if (! uri) - return; - - bool exists = VFSFile::test_file (uri, VFS_IS_REGULAR); - if (exists) - return; - - AUDINFO ("Add to cache: %s\n", (const char *) uri); - VFSFile::write_file (uri, state.lyrics, strlen (state.lyrics)); -} - -#ifdef S_IRGRP -#define DIRMODE (S_IRWXU | S_IRGRP | S_IXGRP | S_IROTH | S_IXOTH) -#else -#define DIRMODE (S_IRWXU) -#endif - -String FileProvider::cache_uri_for_entry (LyricsState state) -{ - if (! state.artist) - return String (); - - auto user_dir = aud_get_path (AudPath::UserDir); - StringBuf base_path = filename_build ({user_dir, "lyrics"}); - StringBuf artist_path = filename_build ({base_path, state.artist}); - - if (aud_get_bool ("lyricwiki", "enable-cache")) - { - if (g_mkdir_with_parents (artist_path, DIRMODE) < 0) - AUDERR ("Failed to create '%s': %s\n", (const char *) artist_path, strerror (errno)); - } - - StringBuf title_path = str_concat({filename_build({artist_path, state.title}), ".lrc"}); - - return String (filename_to_uri (title_path)); -} - -String FileProvider::local_uri_for_entry (LyricsState state) -{ - if (strcmp (uri_get_scheme (state.filename), "file")) - return String (); - - // it's a local file: convert our URI to a local path - StringBuf filename = uri_to_filename (state.filename); - - // strip off the extension - char * ext = strrchr((char *) filename, '.'); - if (! ext) - return String (); - *ext = '\0'; - - // combine the mangled filename and '.lrc' extension - return String (filename_to_uri (str_concat ({filename, ".lrc"}))); -} - -void FileProvider::fetch (LyricsState state) -{ - String path = local_uri_for_entry (state); - if (! path) - return; - - auto data = VFSFile::read_file (path, VFS_APPEND_NULL); - if (! data.len()) - return; - - state.lyrics = String (data.begin ()); - state.source = LyricsState::Source::Local; - - update_lyrics_window (state.title, state.artist, state.lyrics); - persist_state (state); -} - -void FileProvider::cache_fetch (LyricsState state) -{ - String path = cache_uri_for_entry (state); - if (! path) - return; - - auto data = VFSFile::read_file (path, VFS_APPEND_NULL); - if (! data.len()) - return; - - state.lyrics = String (data.begin ()); - state.source = LyricsState::Source::Local; - - update_lyrics_window (state.title, state.artist, state.lyrics); - persist_state (state); -} - -bool FileProvider::match (LyricsState state) -{ - String path = local_uri_for_entry (state); - if (! path) - return false; - - AUDINFO("Checking for local lyric file: '%s'\n", (const char *) path); - - bool exists = VFSFile::test_file(path, VFS_IS_REGULAR); - if (exists) - { - fetch (state); - return true; - } - - path = cache_uri_for_entry (state); - if (! path) - return false; - - AUDINFO("Checking for cache lyric file: '%s'\n", (const char *) path); - - exists = VFSFile::test_file(path, VFS_IS_REGULAR); - if (exists) - cache_fetch (state); - - return exists; -} - -void FileProvider::save (LyricsState state) -{ - if (! state.lyrics) - return; - - String path = local_uri_for_entry (state); - if (! path) - return; - - AUDINFO("Saving lyrics to local file: '%s'\n", (const char *) path); - - VFSFile::write_file (path, state.lyrics, strlen (state.lyrics)); -} - -// ChartLyricsProvider provides a strategy for fetching lyrics using the API -// from chartlyrics.com. It uses the two-step approach since the endpoint -// "SearchLyricDirect" may sometimes return incorrect data. One example is -// "Metallica - Unforgiven II" which leads to the lyrics of "Unforgiven". -class ChartLyricsProvider : public LyricProvider -{ -public: - ChartLyricsProvider () {}; - - bool match (LyricsState state) override; - void fetch (LyricsState state) override; - String edit_uri (LyricsState state) override { return m_lyric_url; } - -private: - String match_uri (LyricsState state); - String fetch_uri (LyricsState state); - - void reset_lyric_metadata (); - bool has_match (LyricsState state, xmlNodePtr node); - - int m_lyric_id = -1; - String m_lyric_checksum, m_lyric_url, m_lyrics; - - const char * m_base_url = "http://api.chartlyrics.com/apiv1.asmx"; -}; - -void ChartLyricsProvider::reset_lyric_metadata () -{ - m_lyric_id = -1; - m_lyric_checksum = String (); - m_lyric_url = String (); - m_lyrics = String (); -} - -String ChartLyricsProvider::match_uri (LyricsState state) -{ - auto artist = str_copy (state.artist); - artist = str_encode_percent (artist, -1); - - auto title = str_copy (state.title); - title = str_encode_percent (title, -1); - - return String (str_concat ({m_base_url, "/SearchLyric?artist=", artist, "&song=", title})); -} - -bool ChartLyricsProvider::has_match (LyricsState state, xmlNodePtr node) -{ - bool match_found = false; - String lyric_id, checksum, url, artist, title; - - for (xmlNodePtr cur_node = node->xmlChildrenNode; cur_node; cur_node = cur_node->next) - { - if (cur_node->type != XML_ELEMENT_NODE) - continue; - - xmlChar * content = xmlNodeGetContent (cur_node); - - if (xmlStrEqual (cur_node->name, (xmlChar *) "LyricId")) - lyric_id = String ((const char *) content); - else if (xmlStrEqual (cur_node->name, (xmlChar *) "LyricChecksum")) - checksum = String ((const char *) content); - else if (xmlStrEqual (cur_node->name, (xmlChar *) "SongUrl")) - url = String ((const char *) content); - else if (xmlStrEqual (cur_node->name, (xmlChar *) "Artist")) - artist = String ((const char *) content); - else if (xmlStrEqual (cur_node->name, (xmlChar *) "Song")) - title = String ((const char *) content); - - xmlFree (content); - } - - if (lyric_id && checksum && artist && title) // url is optional - { - int id = str_to_int (lyric_id); - - if (id > 0 && - ! strcmp_nocase (artist, state.artist) && - ! strcmp_nocase (title, state.title)) - { - m_lyric_id = id; - m_lyric_checksum = checksum; - m_lyric_url = url; - - match_found = true; - } - } - - return match_found; -} - -bool ChartLyricsProvider::match (LyricsState state) -{ - reset_lyric_metadata (); - - auto handle_result_cb = [=] (const char * uri, const Index & buf) { - if (! buf.len ()) - { - update_lyrics_window_error (str_printf (_("Unable to fetch %s"), uri)); - return; - } - - xmlDocPtr doc = xmlReadMemory (buf.begin (), buf.len (), nullptr, nullptr, 0); - if (! doc) - { - update_lyrics_window_error (str_printf (_("Unable to parse %s"), uri)); - return; - } - - xmlNodePtr root = xmlDocGetRootElement (doc); - - for (xmlNodePtr cur_node = root->xmlChildrenNode; cur_node; cur_node = cur_node->next) - { - if (cur_node->type != XML_ELEMENT_NODE) - continue; - - if (has_match (state, cur_node)) - break; - } - - xmlFreeDoc (doc); - - fetch (state); - }; - - vfs_async_file_get_contents (match_uri (state), handle_result_cb); - update_lyrics_window_message (state, _("Looking for lyrics ...")); - - return true; -} - -String ChartLyricsProvider::fetch_uri (LyricsState state) -{ - if (m_lyric_id <= 0 || ! m_lyric_checksum) - return String (); - - auto id = int_to_str (m_lyric_id); - auto checksum = str_copy (m_lyric_checksum); - checksum = str_encode_percent (checksum, -1); - - return String (str_concat ({m_base_url, "/GetLyric?lyricId=", id, "&lyricCheckSum=", checksum})); -} - -void ChartLyricsProvider::fetch (LyricsState state) -{ - String _fetch_uri = fetch_uri (state); - if (! _fetch_uri) - { - update_lyrics_window_notfound (state); - return; - } - - auto handle_result_cb = [=] (const char * uri, const Index & buf) { - if (! buf.len ()) - { - update_lyrics_window_error (str_printf (_("Unable to fetch %s"), uri)); - return; - } - - xmlDocPtr doc = xmlReadMemory (buf.begin (), buf.len (), nullptr, nullptr, 0); - if (! doc) - { - update_lyrics_window_error (str_printf (_("Unable to parse %s"), uri)); - return; - } - - xmlNodePtr root = xmlDocGetRootElement (doc); - - for (xmlNodePtr cur_node = root->xmlChildrenNode; cur_node; cur_node = cur_node->next) - { - if (cur_node->type == XML_ELEMENT_NODE && - xmlStrEqual (cur_node->name, (xmlChar *) "Lyric")) - { - xmlChar * content = xmlNodeGetContent (cur_node); - m_lyrics = String ((const char *) content); - xmlFree (content); - break; - } - } - - xmlFreeDoc (doc); - - LyricsState new_state = g_state; - new_state.lyrics = String (); - - if (! m_lyrics || ! m_lyrics[0]) - { - update_lyrics_window_notfound (new_state); - return; - } - - new_state.lyrics = m_lyrics; - new_state.source = LyricsState::Source::ChartLyrics; - - update_lyrics_window (new_state.title, new_state.artist, new_state.lyrics); - persist_state (new_state); - }; - - vfs_async_file_get_contents (_fetch_uri, handle_result_cb); - update_lyrics_window_message (state, _("Looking for lyrics ...")); -} - -static ChartLyricsProvider chart_lyrics_provider; - -// LyricsOVHProvider provides a strategy for fetching lyrics using the -// lyrics.ovh search engine. -class LyricsOVHProvider : public LyricProvider { -public: - LyricsOVHProvider() {}; - - bool match (LyricsState state) override; - void fetch (LyricsState state) override; - String edit_uri (LyricsState state) override { return String (); } -}; - -bool LyricsOVHProvider::match (LyricsState state) -{ - fetch (state); - return true; -} - -void LyricsOVHProvider::fetch (LyricsState state) -{ - auto handle_result_cb = [=] (const char *filename, const Index & buf) { - if (! buf.len ()) - { - update_lyrics_window_error(str_printf(_("Unable to fetch %s"), filename)); - return; - } - - QByteArray json = QByteArray (buf.begin (), buf.len ()); - QJsonDocument doc = QJsonDocument::fromJson (json); - - if (doc.isNull () || ! doc.isObject ()) - { - update_lyrics_window_error(str_printf(_("Unable to parse %s"), filename)); - return; - } - - LyricsState new_state = g_state; - new_state.lyrics = String (); - - auto obj = doc.object (); - if (obj.contains ("lyrics")) - { - auto str = obj["lyrics"].toString(); - if (! str.isNull ()) - { - auto raw_data = str.toLocal8Bit(); - new_state.lyrics = String (raw_data.data ()); - } - } - else - { - update_lyrics_window_notfound (new_state); - return; - } - - new_state.source = LyricsState::Source::LyricsOVH; - - update_lyrics_window (new_state.title, new_state.artist, new_state.lyrics); - persist_state (new_state); - }; - - auto artist = str_copy (state.artist); - artist = str_encode_percent (state.artist, -1); - - auto title = str_copy (state.title); - title = str_encode_percent (state.title, -1); - - auto uri = str_concat({"https://api.lyrics.ovh/v1/", artist, "/", title}); - - vfs_async_file_get_contents(uri, handle_result_cb); - update_lyrics_window_message (state, _("Looking for lyrics ...")); -} - -static LyricsOVHProvider lyrics_ovh_provider; - -static LyricProvider * remote_source () -{ - auto source = aud_get_str ("lyricwiki", "remote-source"); - - if (! strcmp (source, "chartlyrics.com")) - return &chart_lyrics_provider; - - if (! strcmp (source, "lyrics.ovh")) - return &lyrics_ovh_provider; - - return nullptr; -} - -static QTextEdit * textedit; - -static void update_lyrics_window_message (LyricsState state, const char * message) -{ - update_lyrics_window (state.title, state.artist, message); -} - -static void update_lyrics_window_error (const char * message) -{ - update_lyrics_window (_("Error"), nullptr, message); - g_state.error = true; -} - -static void update_lyrics_window_notfound (LyricsState state) -{ - update_lyrics_window (state.title, state.artist, _("Lyrics could not be found.")); - g_state.error = true; -} - -static void update_lyrics_window (const char * title, const char * artist, const char * lyrics) -{ - if (! textedit) - return; - - textedit->document ()->clear (); - - QTextCursor cursor (textedit->document ()); - cursor.insertHtml (QString ("") + QString (title) + QString ("")); - - if (artist) - cursor.insertHtml (QString ("
") + QString (artist) + QString ("")); - - cursor.insertHtml ("

"); - cursor.insertText (lyrics); -} - -static void lyricwiki_playback_began () -{ - /* FIXME: cancel previous VFS requests (not possible with current API) */ - - g_state.filename = aud_drct_get_filename (); - - Tuple tuple = aud_drct_get_tuple (); - g_state.title = tuple.get_str (Tuple::Title); - g_state.artist = tuple.get_str (Tuple::Artist); - g_state.lyrics = String (); - - if (aud_get_bool ("lyricwiki", "split-title-on-chars")) - { - QString artist = QString (g_state.artist); - QString title = QString (g_state.title); - - QRegularExpression qre; - qre.setPattern (QString ("^(.*)\\s+[") + aud_get_str ("lyricwiki", "split-on-chars") + "]\\s+(.*)$"); - - QRegularExpressionMatch qrematch = qre.match (title); - - if (qrematch.hasMatch ()) - { - artist = qrematch.captured (1); - title = qrematch.captured (2); - - if (aud_get_bool ("lyricwiki", "truncate-fields-on-chars")) - { - qre.setPattern (QString ("^.*\\s+[") + aud_get_str ("lyricwiki", "truncate-on-chars") + "]\\s+"); - artist.remove (qre); - - qre.setPattern (QString ("\\s+[") + aud_get_str ("lyricwiki", "truncate-on-chars") + "]\\s+.*$"); - title.remove (qre); - } - - g_state.artist = String (); - g_state.title = String (); - g_state.artist = String (artist.toUtf8 ()); - g_state.title = String (title.toUtf8 ()); - } - } - - if (aud_get_bool ("lyricwiki", "use-embedded")) - { - String embedded_lyrics = tuple.get_str (Tuple::Lyrics); - if (embedded_lyrics && embedded_lyrics[0]) - { - g_state.lyrics = embedded_lyrics; - g_state.source = LyricsState::Source::Embedded; - g_state.error = false; - update_lyrics_window (g_state.title, g_state.artist, g_state.lyrics); - return; - } - } - - if (! aud_get_bool ("lyricwiki", "enable-file-provider") || ! file_provider.match (g_state)) - { - if (! g_state.artist || ! g_state.title) - { - update_lyrics_window_error (_("Missing title and/or artist.")); - return; - } - - auto rsrc = remote_source (); - if (rsrc) - { - rsrc->match (g_state); - return; - } - } - - if (! g_state.lyrics) - update_lyrics_window_notfound (g_state); -} - -static void lw_cleanup (QObject * object = nullptr) -{ - g_state.filename = String (); - g_state.title = String (); - g_state.artist = String (); - g_state.lyrics = String (); - - hook_dissociate ("tuple change", (HookFunction) lyricwiki_playback_began); - hook_dissociate ("playback ready", (HookFunction) lyricwiki_playback_began); - - textedit = nullptr; -} - -void * LyricWikiQt::get_qt_widget () -{ - textedit = new TextEdit; - textedit->setReadOnly (true); - -#ifdef Q_OS_MAC // Mac-specific font tweaks - textedit->document ()->setDefaultFont (QApplication::font ("QTipLabel")); -#endif - - hook_associate ("tuple change", (HookFunction) lyricwiki_playback_began, nullptr); - hook_associate ("playback ready", (HookFunction) lyricwiki_playback_began, nullptr); - - if (aud_drct_get_ready ()) - lyricwiki_playback_began (); - - QObject::connect (textedit, & QObject::destroyed, lw_cleanup); - - return textedit; -} - -void TextEdit::contextMenuEvent (QContextMenuEvent * event) -{ - if (! g_state.artist || ! g_state.title) - return QTextEdit::contextMenuEvent (event); - - LyricProvider * remote_provider = remote_source (); - - QMenu * menu = createStandardContextMenu (); - menu->addSeparator (); - - if (g_state.lyrics && g_state.source != LyricsState::Source::Local && ! g_state.error) - { - if (remote_provider) - { - String edit_uri = remote_provider->edit_uri (g_state); - - if (edit_uri && edit_uri[0]) - { - QAction * edit = menu->addAction (_("Edit Lyrics ...")); - QObject::connect (edit, & QAction::triggered, [edit_uri] () { - QDesktopServices::openUrl (QUrl ((const char *) edit_uri)); - }); - } - } - - QAction * save = menu->addAction (_("Save Locally")); - QObject::connect (save, & QAction::triggered, [] () { - file_provider.save (g_state); - }); - } - - if (g_state.source == LyricsState::Source::Local || g_state.error) - { - QAction * refresh = menu->addAction (_("Refresh")); - QObject::connect (refresh, & QAction::triggered, [remote_provider] () { - if (remote_provider) - remote_provider->match (g_state); - }); - } - - menu->exec (event->globalPos ()); - menu->deleteLater (); -} diff --git a/src/lyricwiki-qt/meson.build b/src/lyricwiki-qt/meson.build deleted file mode 100644 index 7039b16a42..0000000000 --- a/src/lyricwiki-qt/meson.build +++ /dev/null @@ -1,12 +0,0 @@ -have_lyrics = xml_dep.found() - - -if have_lyrics - shared_module('lyricwiki-qt', - 'lyricwiki.cc', - dependencies: [audacious_dep, qt_dep, glib_dep, xml_dep, audqt_dep], - name_prefix: '', - install: true, - install_dir: general_plugin_dir - ) -endif diff --git a/src/meson.build b/src/meson.build index 7843a19e09..a698820d77 100644 --- a/src/meson.build +++ b/src/meson.build @@ -84,6 +84,7 @@ endif # Qt/GTK-specific plugins if conf.has('USE_GTK_OR_QT') subdir('delete-files') + subdir('lyrics') subdir('skins-data') if get_option('notify') @@ -96,7 +97,6 @@ endif if conf.has('USE_QT') subdir('albumart-qt') subdir('blur_scope-qt') - subdir('lyricwiki-qt') subdir('playlist-manager-qt') subdir('qt-spectrum') subdir('qtui')