diff --git a/plugins/lrclib_lyrics/__init__.py b/plugins/lrclib_lyrics/__init__.py new file mode 100644 index 00000000..f7eaac2a --- /dev/null +++ b/plugins/lrclib_lyrics/__init__.py @@ -0,0 +1,207 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2024 Giorgio Fontanive (twodoorcoupe) +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. + +PLUGIN_NAME = "Lrclib Lyrics" +PLUGIN_AUTHOR = "Giorgio Fontanive" +PLUGIN_DESCRIPTION = """ +Fetches lyrics from lrclib.net + +Also allows to export lyrics to an .lrc file or import them from one. +""" +PLUGIN_VERSION = "0.1" +PLUGIN_API_VERSIONS = ["2.12"] +PLUGIN_LICENSE = "GPL-2.0" +PLUGIN_LICENSE_URL = "https://www.gnu.org/licenses/gpl-2.0.html" + +import os +import re +from functools import partial + +from picard import config, log +from picard.file import register_file_post_save_processor, register_file_post_addition_to_track_processor +from picard.track import Track +from picard.ui.itemviews import BaseAction, register_track_action +from picard.ui.options import OptionsPage, register_options_page +from picard.webservice import ratecontrol + +from .option_lrclib_lyrics import Ui_OptionLrclibLyrics + + +URL = "https://lrclib.net/api/get" +REQUESTS_DELAY = 100 + +# Options +ADD_UNSYNCED_LYRICS = "add_unsynced_lyrics" +ADD_SYNCED_LYRICS = "add_synced_lyrics" +NEVER_REPLACE_LYRICS = "never_replace_lyrics" +LRC_FILENAME = "exported_lrc_filename" +EXPORT_LRC = "exported_lrc" +NEVER_REPLACE_LRC = "never_replace_lrc" + +lyrics_cache = {} +synced_lyrics_pattern = re.compile(r"(\[\d\d:\d\d\.\d\d\d]|<\d\d:\d\d\.\d\d\d>)") +tags_pattern = re.compile(r"%(\w+)%") +extra_file_variables = { + "filepath": lambda file: file, + "folderpath": lambda file: os.path.dirname(file), # pylint: disable=unnecessary-lambda + "filename": lambda file: os.path.splitext(os.path.basename(file))[0], + "filename_ext": lambda file: os.path.basename(file), # pylint: disable=unnecessary-lambda + "directory": lambda file: os.path.basename(os.path.dirname(file)) +} + + +def get_lyrics(track, file): + album = track.album + metadata = file.metadata + if not (config.setting[ADD_UNSYNCED_LYRICS] or config.setting[ADD_SYNCED_LYRICS]): + return + if not (metadata.get("title") and metadata.get("artist")): + log.debug(f"Skipping fetching lyrics for track in {album} as both title and artist are required") + return + if config.setting[NEVER_REPLACE_LYRICS] and metadata.get("lyrics"): + log.debug(f"Skipping fetching lyrics for {metadata['title']} as lyrics are already embedded") + return + args = { + "track_name": metadata["title"], + "artist_name": metadata["artist"], + } + if metadata.get("album"): + args["album_name"] = metadata["album"] + handler = partial(response_handler, metadata) + album.tagger.webservice.get_url( + method="GET", + handler=handler, + parse_response_type='json', + url=URL, + unencoded_queryargs=args + ) + + +def response_handler(metadata, document, reply, error): + if document and not error: + unsynced_lyrics = document.get("plainLyrics") + synced_lyrics = document.get("syncedLyrics") + if unsynced_lyrics: + lyrics_cache[metadata["title"]] = unsynced_lyrics + if ((not config.setting[ADD_UNSYNCED_LYRICS]) or + (config.setting[NEVER_REPLACE_LYRICS] and metadata.get("lyrics"))): + return + metadata["lyrics"] = unsynced_lyrics + if synced_lyrics: + lyrics_cache[metadata["title"]] = synced_lyrics + # Support for the syncedlyrics tag is not available yet + # if (not config.setting[ADD_SYNCED_LYRICS] or + # (config.setting[NEVER_REPLACE_LYRICS] and metadata.get("syncedlyrics"))): + # return + # metadata["syncedlyrics"] = syncedlyrics + else: + log.debug(f"Could not fetch lyrics for {metadata['title']}") + + +def get_lrc_file_name(file): + filename = f"{tags_pattern.sub('{}', config.setting[LRC_FILENAME])}" + tags = tags_pattern.findall(config.setting[LRC_FILENAME]) + values = [] + for tag in tags: + if tag in extra_file_variables: + values.append(extra_file_variables[tag](file.filename)) + else: + values.append(file.metadata.get(tag, f"%{tag}%")) + return filename.format(*values) + + +def export_lrc_file(file): + if config.setting[EXPORT_LRC]: + metadata = file.metadata + # If no lyrics were downloaded, try to export the lyrics already embedded + lyrics = lyrics_cache.pop(metadata["title"], metadata.get("lyrics")) + if lyrics: + filename = get_lrc_file_name(file) + if config.setting[NEVER_REPLACE_LRC] and os.path.exists(filename): + return + try: + with open(filename, 'w') as file: + file.write(lyrics) + log.debug(f"Created new lyrics file at {filename}") + except OSError: + log.debug(f"Could not create the lrc file for {metadata['title']}") + else: + log.debug(f"Could not export any lyrics for {metadata['title']}") + + +class ImportLrc(BaseAction): + NAME = 'Import lyrics from lrc files' + + def callback(self, objs): + for track in objs: + if isinstance(track, Track): + file = track.files[0] + filename = get_lrc_file_name(file) + try: + with open(filename, 'r') as lyrics_file: + lyrics = lyrics_file.read() + if synced_lyrics_pattern.search(lyrics): + # Support for syncedlyrics is not available yet + # file.metadata["syncedlyrics"] = lyrics + pass + else: + file.metadata["lyrics"] = lyrics + except FileNotFoundError: + log.debug(f"Could not find matching lrc file for {file.metadata['title']}") + + +class LrclibLyricsOptions(OptionsPage): + + NAME = "lrclib_lyrics" + TITLE = "Lrclib Lyrics" + PARENT = "plugins" + + __default_naming = "%filename%.lrc" + + options = [ + config.BoolOption("setting", ADD_UNSYNCED_LYRICS, True), + config.BoolOption("setting", ADD_SYNCED_LYRICS, False), + config.BoolOption("setting", NEVER_REPLACE_LYRICS, False), + config.TextOption("setting", LRC_FILENAME, __default_naming), + config.BoolOption("setting", EXPORT_LRC, False), + config.BoolOption("setting", NEVER_REPLACE_LRC, False), + ] + + def __init__(self, parent=None): + super(LrclibLyricsOptions, self).__init__(parent) + self.ui = Ui_OptionLrclibLyrics() + self.ui.setupUi(self) + + def load(self): + self.ui.lyrics.setChecked(config.setting[ADD_UNSYNCED_LYRICS]) + self.ui.syncedlyrics.setChecked(config.setting[ADD_SYNCED_LYRICS]) + self.ui.replace_embedded.setChecked(config.setting[NEVER_REPLACE_LYRICS]) + self.ui.lrc_name.setText(config.setting[LRC_FILENAME]) + self.ui.export_lyrics.setChecked(config.setting[EXPORT_LRC]) + self.ui.replace_exported.setChecked(config.setting[NEVER_REPLACE_LRC]) + + def save(self): + config.setting[ADD_UNSYNCED_LYRICS] = self.ui.lyrics.isChecked() + config.setting[ADD_SYNCED_LYRICS] = self.ui.syncedlyrics.isChecked() + config.setting[NEVER_REPLACE_LYRICS] = self.ui.replace_embedded.isChecked() + config.setting[LRC_FILENAME] = self.ui.lrc_name.text() + config.setting[EXPORT_LRC] = self.ui.export_lyrics.isChecked() + config.setting[NEVER_REPLACE_LRC] = self.ui.replace_exported.isChecked() + + +ratecontrol.set_minimum_delay_for_url(URL, REQUESTS_DELAY) +register_file_post_addition_to_track_processor(get_lyrics) +register_file_post_save_processor(export_lrc_file) +register_track_action(ImportLrc()) +register_options_page(LrclibLyricsOptions) diff --git a/plugins/lrclib_lyrics/option_lrclib_lyrics.py b/plugins/lrclib_lyrics/option_lrclib_lyrics.py new file mode 100644 index 00000000..41de26d2 --- /dev/null +++ b/plugins/lrclib_lyrics/option_lrclib_lyrics.py @@ -0,0 +1,69 @@ +# -*- coding: utf-8 -*- + +# Form implementation generated from reading ui file 'plugins/lrclib_lyrics/option_lrclib_lyrics.ui' +# +# Created by: PyQt5 UI code generator 5.15.10 +# +# WARNING: Any manual changes made to this file will be lost when pyuic5 is +# run again. Do not edit this file unless you know what you are doing. + + +from PyQt5 import QtCore, QtWidgets + + +class Ui_OptionLrclibLyrics(object): + def setupUi(self, OptionLrclibLyrics): + OptionLrclibLyrics.setObjectName("OptionLrclibLyrics") + OptionLrclibLyrics.resize(432, 368) + self.verticalLayout = QtWidgets.QVBoxLayout(OptionLrclibLyrics) + self.verticalLayout.setObjectName("verticalLayout") + self.groupBox = QtWidgets.QGroupBox(OptionLrclibLyrics) + self.groupBox.setObjectName("groupBox") + self.verticalLayout_2 = QtWidgets.QVBoxLayout(self.groupBox) + self.verticalLayout_2.setObjectName("verticalLayout_2") + self.lyrics = QtWidgets.QCheckBox(self.groupBox) + self.lyrics.setObjectName("lyrics") + self.verticalLayout_2.addWidget(self.lyrics) + self.syncedlyrics = QtWidgets.QCheckBox(self.groupBox) + self.syncedlyrics.setEnabled(False) + self.syncedlyrics.setCheckable(False) + self.syncedlyrics.setObjectName("syncedlyrics") + self.verticalLayout_2.addWidget(self.syncedlyrics) + self.replace_embedded = QtWidgets.QCheckBox(self.groupBox) + self.replace_embedded.setObjectName("replace_embedded") + self.verticalLayout_2.addWidget(self.replace_embedded) + self.verticalLayout.addWidget(self.groupBox) + self.groupBox_2 = QtWidgets.QGroupBox(OptionLrclibLyrics) + self.groupBox_2.setObjectName("groupBox_2") + self.verticalLayout_3 = QtWidgets.QVBoxLayout(self.groupBox_2) + self.verticalLayout_3.setObjectName("verticalLayout_3") + self.label = QtWidgets.QLabel(self.groupBox_2) + self.label.setObjectName("label") + self.verticalLayout_3.addWidget(self.label) + self.lrc_name = QtWidgets.QLineEdit(self.groupBox_2) + self.lrc_name.setObjectName("lrc_name") + self.verticalLayout_3.addWidget(self.lrc_name) + self.export_lyrics = QtWidgets.QCheckBox(self.groupBox_2) + self.export_lyrics.setObjectName("export_lyrics") + self.verticalLayout_3.addWidget(self.export_lyrics) + self.replace_exported = QtWidgets.QCheckBox(self.groupBox_2) + self.replace_exported.setObjectName("replace_exported") + self.verticalLayout_3.addWidget(self.replace_exported) + self.verticalLayout.addWidget(self.groupBox_2) + spacerItem = QtWidgets.QSpacerItem(20, 23, QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Expanding) + self.verticalLayout.addItem(spacerItem) + + self.retranslateUi(OptionLrclibLyrics) + QtCore.QMetaObject.connectSlotsByName(OptionLrclibLyrics) + + def retranslateUi(self, OptionLrclibLyrics): + _translate = QtCore.QCoreApplication.translate + OptionLrclibLyrics.setWindowTitle(_translate("OptionLrclibLyrics", "Form")) + self.groupBox.setTitle(_translate("OptionLrclibLyrics", "Embedded Lyrics Options")) + self.lyrics.setText(_translate("OptionLrclibLyrics", "Download and embed unsynced lyrics")) + self.syncedlyrics.setText(_translate("OptionLrclibLyrics", "Download and embed synced lyrics")) + self.replace_embedded.setText(_translate("OptionLrclibLyrics", "Never replace any embedded lyrics if already present")) + self.groupBox_2.setTitle(_translate("OptionLrclibLyrics", "Lrc Files Options")) + self.label.setText(_translate("OptionLrclibLyrics", "Use the following name pattern for lrc files:")) + self.export_lyrics.setText(_translate("OptionLrclibLyrics", "Export lyrics to lrc file when saving (priority to synced lyrics)")) + self.replace_exported.setText(_translate("OptionLrclibLyrics", "Never replace lrc files if already present")) diff --git a/plugins/lrclib_lyrics/option_lrclib_lyrics.ui b/plugins/lrclib_lyrics/option_lrclib_lyrics.ui new file mode 100644 index 00000000..29779740 --- /dev/null +++ b/plugins/lrclib_lyrics/option_lrclib_lyrics.ui @@ -0,0 +1,103 @@ + + + OptionLrclibLyrics + + + + 0 + 0 + 432 + 368 + + + + Form + + + + + + Embedded Lyrics Options + + + + + + Download and embed unsynced lyrics + + + + + + + false + + + Download and embed synced lyrics + + + false + + + + + + + Never replace any embedded lyrics if already present + + + + + + + + + + Lrc Files Options + + + + + + Use the following name pattern for lrc files: + + + + + + + + + + Export lyrics to lrc file when saving (priority to synced lyrics) + + + + + + + Never replace lrc files if already present + + + + + + + + + + Qt::Orientation::Vertical + + + + 20 + 23 + + + + + + + + +