Porting to picard.webservice

I’m trying to make a plugin that gathers synced lyrics for my music collection (if anyone knows of an already existing plugin that does this please let me know!). I’ve found an already existing python script which I was hoping to simply port as a plugin, but it’s built using requests and I can’t for the love of me understand how I’d go about changing it.

the (i think) only script that needs changing is base.py though some other scripts which interact with this one may of course also need changing.

import sys
import os

plugin_dir = os.path.dirname(os.path.abspath(__file__))
syncedlyrics_dir = os.path.join(plugin_dir, "requests")
sys.path.append(plugin_dir)

import requests
from typing import Optional


class LRCProvider:
    """
    Base class for all of the synced (LRC format) lyrics providers.
    """

    session = requests.Session()

    def __init__(self) -> None:
        self.session.headers.update(
            {
                "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36"
            }
        )

    def get_lrc_by_id(self, track_id: str) -> Optional[str]:
        """
        Returns the synced lyrics of the song in [LRC](https://en.wikipedia.org/wiki/LRC_(file_format)) format if found.

        ### Arguments
        - track_id: The ID of the track defined in the provider database. e.g. Spotify/Deezer track ID
        """
        raise NotImplementedError

    def get_lrc(self, search_term: str) -> Optional[str]:
        """
        Returns the synced lyrics of the song in [LRC](https://en.wikipedia.org/wiki/LRC_(file_format)) format if found.
        """
        raise NotImplementedError

The bridge between syncedlyrics and picard that I (chatGPT) have managed to write if that would be interesting:

import sys
import os

import picard
from picard.metadata import register_track_metadata_processor

plugin_dir = os.path.dirname(os.path.abspath(__file__))
syncedlyrics_dir = os.path.join(plugin_dir, "syncedlyrics")
sys.path.append(plugin_dir)

from syncedlyrics import search

class SyncedLyricsPlugin(picard.plugins.Plugin):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)

    def add_synced_lyrics_tag(self, metadata):
        # Search for synchronized lyrics using syncedlyrics module
        lyrics = search(f"{metadata['title']} {metadata['artist']}")  # Switched artist and track
        
        if lyrics:
            # Add synchronized lyrics tag
            metadata['syncedlyrics'] = lyrics

class SyncedLyricsTrackMetadataProcessor(picard.plugins.TrackMetadataProcessor):
    def __init__(self):
        super().__init__()

    def process_metadata(self, metadata, track, release):
        plugin = SyncedLyricsPlugin()
        plugin.add_synced_lyrics_tag(metadata)
        return metadata

picard.plugins.register_file_processor(SyncedLyricsPlugin)

If you think I’m stupid, I probably am because this is my first ever time doing anything in python (and programming in general) so any and all tips is greatly appreciated.

2 Likes

I think ChatGPT got a bit confused, this is as close as I could get to what you are looking for:

from picard.metadata import register_track_metadata_processor
from syncedlyrics import search

def add_lyrics(tagger, metadata, track, release):
        lyrics = search(f"{metadata['title']} {metadata['artist']}") 
        if lyrics:
            metadata['lyrics'] = lyrics

register_track_metadata_processor(add_lyrics)

I took a peek at syncedlyrics and the search function returns a string in LRC format.

The problem is that I don’t think Picard maps the synced-lyrics tag. So when you do metadata['syncedlyrics'], you just create a custom tag (unless I’m wrong).

There is, however, a lyrics tag in the mapping. With the code above you would get the lyrics with some timestamps in the middle, so it’s definitely not what you are looking for.

BTW, I assumed you have already installed syncedlyrics with pip since you included it in the code.

I’ll look at it again later when I have more time.

P.S. I found this post from a few years ago where the same thing was asked. It’s linked to this issue.

Thanks for your feedback! You’re probably right in that I should use the lyrics tag instead of syncedlyrics. I simply chose to use that tag since I already had an unsyncedlyrics tag on a couple of my songs.

I have now tried to manually add the Lyrics tag with the string that the search function outputs and the lyrics show up like they should in my player, so the format of said output shouldn’t be a problem.

The real problem I’m encountering is that the python picard uses is independent of any systemwide python installs, and any packages you install with pip. I solved adding syncedlyrics by adding it to directly to the plugin directory and adding it to path using:

import sys
import os

plugin_dir = os.path.dirname(os.path.abspath(__file__))
syncedlyrics_dir = os.path.join(plugin_dir, "syncedlyrics")
sys.path.append(plugin_dir)

This seems to work, but I got a similar issue when I noticed picard doesn’t come packaged with the requests module, instead wanting you to use their picard.webservice. I think I’ll have to rewrite the parts of syncedlyrics that use requests (only base.py to my knowledge) to instead use picard.webservice, but the lack of proper documentation on it put a stop on that. I could also try to add the requests module manually like I did with syncedlyrics, but I’d prefer to do it correctly.

I didn’t even think about not being able to import the syncedlyrics module, that’s my bad!

But there is a way to avoid all this hassle. You could run syncedlyrics CLI from the python code and have it download the LRC file in the same location as the music file.

Then you could have the code run another python script which uses Mutagen to set the SYLT tag (synced lyrics), since Picard doesn’t have it mapped yet. It probably is simpler to code than explain it.

Anyways, this is a feature I’m interested in, so I’m willing to help you more if you’d like.

1 Like

I’ll believe you if you think that’d be easier, but I have a few questions about how it would work. Ideally it should all happen automatically.

  • I made a simple script (import mutagen, nothing else) and Picard tells me the plugin isn’t compatible with the current version, so would it still be a plugin or would it be something I run externally?

  • Ideally there shouldn’t be any files left other than the music files. I hope the .lrc files can be deleted automatically once mutagen is done with them?

  • Do you know if Picard can read or show the SYLT tag whatsoever? From reading the issue you linked previously it seems as a no, but I don’t want that answer so I want a second opinion.

Something I’d be interested in also.

I’m pretty sure I’d want to DL all the LRC files, tag the music files, but leave the LRC files in the same directory with the music file as that is the most likely supported way across different music players for future compatibility.

2 Likes

Better lyrics support would indeed be great, with both support for synced and unsynced lyrcis. The current lyrcis tag as supported by Picard is really about unsyced lyrics. There are a couple of things I think should be done in Picard:

  • Add a separate syncedlyrics tag with proper mapping
  • Support moving and renaming .lrc files alongside the music file
  • Investigate proper mapping for Vorbis / FLAC tags. Currently Picard uses LYRICS, but I have more commonly seen UNSYNCEDLYRICS being used. Not sure about synced lyrics.

I have personally not come across actual files which actually came with synced lyrics, except for some artificial examples. Is there any online source or download shop that commonly supports synced lyrics?

7 Likes

You should be able to have Picard write this tag with by setting ~id3:SYLT in the file.metadata in a plugin or by writing the _id3:SYLT variable in a tagger script.

But the syntax does not allow to read the data. So only way right now would be in a plugin to use the mutagen library directly to read the tag from the file again :frowning:

2 Likes

Considering what outsidecontext said, you don’t need another script importing mutagen. You also don’t need another script importing syncedlyrics, since that can be run through the CLI with something like the subprocess python module.

You can easily add an option to choose whether to delete the lrc file or not.

I’m currently trying to get this to work, but I think ID3 uses a different method of encoding synchronized lyrics than LRC. So you can’t just paste the lyrics into the SYLT field. BTW, this is what I’m working with so far:

from picard.file import register_file_post_save_processor
from picard import log
from os import path
import subprocess

def file_post_save_processor(file):
    lyrics_path = path.splitext(file.filename)[0] + ".lrc"
    arguments = ["syncedlyrics", "-o", lyrics_path, f'"{file.metadata['title']} {file.metadata['artist']}"']
    subprocess.run(arguments)
    try:
        lyrics = open(lyrics_path)
        file.metadata['~id3:SYLT'] = lyrics.read()
        log.info("Synced lyrics saved for %s", file.metadata['title'])
        log.debug("Lyrics: %s", file.metadata['~id3:SYLT'])
    except FileNotFoundError:
        log.info("Synced lyrics not found for %s", file.metadata['title'])

register_file_post_save_processor(file_post_save_processor)

When I’ll have more time I’ll come back to this and see if I can get a “translation” working.

2 Likes

Does this plugin actually work for you? I tried running it myself and only got errors. I had to change f'"{file.metadata['title']} {file.metadata['artist']}"' into f'"{file.metadata["title"]} {file.metadata["artist"]}"' to get it to run at all. And even then it failed to download the .lrc file despite being able to download it in a separate python window using the exact same method, ie

import subprocess
arguments = ['syncedlyrics', '-v', '-o', 'D:\\Music\\Bear Ghost\\Haunt, The Cartoon Heart\\01 Haunt, The Cartoon Heart.lrc', 'Haunt, The Cartoon Heart Bear Ghost']
subprocess.run(arguments)

(To get the arguments I added the line log.info("Running %s", arguments) and copied the output, using the first best song I knew had synced lyrics.)

By further modifying the subprocess.run() line I made the errors from syncedlyrics readable:

result = subprocess.run(["syncedlyrics", "-v", "-o", lyrics_path, f'"{file.metadata["title"]} {file.metadata["artist"]}"'], capture_output=True, text=True)
log.warning(result.stderr)

(I don’t know why but I couldn’t add extra arguments with the arguments variable, so I replaced it)
This gives the error: PermissionError: [WinError 5] Access is denied: '.syncedlyrics' which to me just doesn’t make sense. I’ve checked and C:\users\user\.syncedlyrics should have read-write permissions by everyone.

As for the translation, wouldn’t it still be easier to use mutagen? They’ve already done the work of translating .lrc to SYLT after all.

1 Like

I think Picard on Windows comes with an older version of python, where the way in which I put the quotes is not allowed. I just assumed it was the same as Linux, but now I checked on Windows as well, sorry about that!

Well, this is weird. After changing the quotes like you suggested, it works just fine on Windows for me. I’m wondering, is python in your PATH? If you open cmd and type: python, does it enter the python interpreter or does it open the Windows store?
If it’s not, you should easily be able to rerun the installer and check the “Add to path” option like explained here. I tried using py -m syncedlyrics... in the code to make it usable in case you don’t have python in your PATH, but there’s a compatibility issue with the version of python Picard uses on Windows.

Yeah, you can import mutagen in a plugin, since it is also used by Picard. I think the problem you were having earlier where it said the plugin was not compatible was because you didn’t put the PLUGIN_API_VERSIONS constant.

I have actually tried to implement this, you can find the source code here and donwload the plugin here. It embeds the lyrics with mutagen, and I made it so it downloads the lyrics in parallel, so it’s a bit faster and doesn’t block Picard. Also, in the options page you can choose whether to keep or delete the .lrc file.
To show the embedded lyrics, you can right click on a track, go to plugins, then click “Show synchronized lyrics”. It will open a pop up window; I’m not convinced this is the best solution, but it’s the only one I could think of as of right now.

2 Likes

Yes, Picard on Windows uses Python 3.8 for maitaining compatibility with Windows 7. This will change with Picard 3.

I’m not fully sure I understand what was being discussed, but want to note that any system installed Python version and what Picard ships should be independent of each other. Also Picard itself does not ship a python.exe. So for manually running any script the globally installed python.exe would be used, when running from inside Picard it would be the Python version Picard ships (limited to the modules Picard bundles).

Which also means that running a Python command with subprocess.run would, if the Python package was globally installed, run that with the global Python.

How did you install / do you run Picard? Should you run the Microsoft Store version I would instead use the installer version.

I didn’t actually know mutagen implements this conversion. That’s actually pretty handy and simplifies things quite a bit.

3 Likes

Maybe it’s a problem only I have, but when I use py -m syncedlyrics <args> in subprocess.run in Windows, I get an error saying there’s a module trying to use python38.dll which is incompatible with the current version of python.

Can you do the whole translation with mutagen? I couldn’t find anything about it. In the way I did it, I still had to “extract” each line and timestamp from the lrc, then let mutagen do the rest. Thanks for the tip nevertheless!

Ah yes. Might actually happen if you added the Picard install directory to your PATH. If that’s the case Windows might want to load the DLL from there. Or maybe if the Picard directory is the working directory? Not really sure how, but you likely can manage to create such situation. Oh the fun of Windows DLL hell.

2 Likes

Ah I think I got a bit confused, my bad. I did some googling myself and couldn’t find anything about mutagen being able to do it automatically either.

I don’t know what’s different but this version works like expected, so no weird permission errors anymore (yay). I probably did something weird or missed something obvious when copying your code the first time.

I tried to save just barely under 400 songs at once to stress test it (most of them were instrumental though…) and I gotta say I’m impressed. But I noticed how I got some false positives, which is expected, and it’d be nice if I were able to also remove the SYLT tag from those songs. Also it skipped some (probably under 5%) songs that have synced lyrics available, but due to the low percentage I’m willing to just live with it. I thought it was worth mentioning since syncedelyrics managed to find them when I did it manually.

Also, it embeds the lyrics only for mp3 files
(plugin description)

I’m guessing this is because mp3 is the only container that supports the SYLT tag? That’s a shame if that’s the case, most of my collection is saved as flac and it seems as though my current player doesn’t support loose lrc files. Would it be too difficult to add an option to add the lyrics as lrc format to the lyrics tag instead? It’s probably not the correct way of doing it but it works for my own setup at least (Navidrome and Symfonium).

Thank you so much for coming here and writing the whole thing!

1 Like

Thanks for the feedback!

This is because I had to remove Musixmatch as a source since often it would give an empty response and make the syncedlyrics module throw an exception and somehow deadlock. I can probably come up with a way around this eventually.

I’m sure there is a way to do this for other formats, but at this point it might be better to just map the tag in Picard to make this easier for plugins. Once that’s done I’ll modify the plugin and submit it to the offical list.

edit:

I looked into how vorbis comments handle synchronized lyrics, and it isn’t as straight forward as id3. There are both LYRICS and UNSYNCEDLYRICS like outsidecontext suggested. The good news is, I saw that you can just paste the contents of the lrc file into LYRICS (which is mapped by Picard with the lyrics field) and it should work for most players. This explains why this worked:

So if you want to modify the plugin yourself, add this around where it checks if ithe extension is mp3:

if file.metadata["~extension"] == "flac":
    file.metadata['lyrics'] = lrc_syncedlyrics

Also add and file.metadata["~extension"] != "flac" to the if in line 86 so it doesn’t waste time with other format.

Ah this makes sense. It works great for now though, I’ll just download the missing ones manually for now.

Thanks for the help, I’ll go do that.