From 5d38a146a1232cbe34325bb2cb5509b4867c8a7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A8=D1=83=D1=80=D1=83=D0=BF=D0=BE=D0=B2=20=D0=98=D0=BB?= =?UTF-8?q?=D1=8C=D1=8F=20=D0=92=D0=B8=D0=BA=D1=82=D0=BE=D1=80=D0=BE=D0=B2?= =?UTF-8?q?=D0=B8=D1=87?= Date: Sat, 17 Jan 2026 14:24:05 +0300 Subject: [PATCH] lyrics fetcher --- Flow.py | 51 ++++++++++++- main.py | 20 ++++- src/Library.py | 1 + src/LibrarySerializer.py | 2 + src/local/LocalLibraryIndexer.py | 111 +++++++++++++++++++++++----- src/local/LocalPLaylistGenerator.py | 2 +- src/local/ParseLocal.py | 2 + src/lyrics/lyrics.py | 111 ++++++++++++++++++++++++++++ 8 files changed, 277 insertions(+), 23 deletions(-) create mode 100644 src/lyrics/lyrics.py diff --git a/Flow.py b/Flow.py index bcbdd16..4731f1c 100644 --- a/Flow.py +++ b/Flow.py @@ -1,7 +1,11 @@ +from tqdm import tqdm +import src.lyrics.lyrics as Lyrics + from Env import client_id, client_secret, local_library_path from Env import work_dir +from src.Library import * from src.LibrarySerializer import LibrarySaver, LibraryLoader from src.spotify.SpotifyWebAPI import fetch_all @@ -15,8 +19,8 @@ from src.StatGenerator import log_stats class Flow: - spotify_library = None - local_library = None + spotify_library : Library = None + local_library : Library = None @staticmethod def fetch_spotify(self): @@ -45,6 +49,49 @@ class Flow: library_parsed = parser.parse(work_dir / "local" / "local_library.json") LibrarySaver(library_parsed).save(work_dir / "local_library.json") + + def fetch_and_save_lyrics(self): + """ + Fetches and writes lyrics for tracks that don't have lyrics yet. + Displays stats and uses a progress bar. + """ + + # ---------- Step 1: Collect tracks without lyrics ---------- + tracks_missing_lyrics = [ + track for track in self.local_library.tracks + if len(track.artists) > 0 and not track.has_lyrics + ] + + total_tracks = len(self.local_library.tracks) + missing_count = len(tracks_missing_lyrics) + + print(f"[INFO] Total tracks in library: {total_tracks}") + print(f"[INFO] Tracks missing lyrics: {missing_count}") + + if missing_count == 0: + print("[INFO] No tracks need lyrics. Exiting.") + return + + # ---------- Step 2: Fetch and write lyrics with progress bar ---------- + for track in tqdm(tracks_missing_lyrics, desc="Fetching lyrics", unit="track"): + artist_name = track.artists[0].title + album_title = track.album.title if track.album else "Unknown Album" + print(f"\n[INFO] Processing: '{track.title}' by '{artist_name}' on '{album_title}'") + + lyrics_text = Lyrics.fetch_lyrics(artist_name, album_title, track.title, track.duration_ms) + + if lyrics_text: + print("[SUCCESS] Lyrics found, writing to file...") + file_path = local_library_path / track.local_path + try: + Lyrics.write_lyrics(file_path, lyrics_text) + print(f"[SUCCESS] Lyrics written for '{track.title}'") + except Exception as e: + print(f"[ERROR] Failed to write lyrics for '{track.title}': {e}") + else: + print(f"[WARNING] Lyrics not found for '{track.title}'") + + def load_libraries(self): self.spotify_library = LibraryLoader().load(work_dir / "spotify_library.json") self.local_library = LibraryLoader().load(work_dir / "local_library.json") diff --git a/main.py b/main.py index 5e395a4..8499c07 100644 --- a/main.py +++ b/main.py @@ -1,7 +1,21 @@ + import Flow -if __name__ == '__main__': +def test1(): + flow = Flow.Flow() + + # flow.fetch_local() + flow.parse_local_library() + flow.load_libraries() + + # flow.map_local_to_spotify() + # flow.log_stats() + + flow.fetch_and_save_lyrics() + + +def main(): flow = Flow.Flow() # flow.fetch_spotify() @@ -17,3 +31,7 @@ if __name__ == '__main__': flow.log_stats() print("asd") + + +test1() +# main() diff --git a/src/Library.py b/src/Library.py index 68e2c63..0de72ce 100644 --- a/src/Library.py +++ b/src/Library.py @@ -22,6 +22,7 @@ class Track: self.duration_ms : int = None self.added_at : datetime = None self.local_path : Path = None + self.has_lyrics : bool = False class Album: diff --git a/src/LibrarySerializer.py b/src/LibrarySerializer.py index e94a0f7..84f6a46 100644 --- a/src/LibrarySerializer.py +++ b/src/LibrarySerializer.py @@ -45,6 +45,7 @@ class LibrarySaver: "duration_ms": track.duration_ms, "added_at": _dt_to_str(track.added_at), "local_path": track.local_path, + "has_lyrics": track.has_lyrics, } @staticmethod @@ -119,6 +120,7 @@ class LibraryLoader: track.local_path = data.get("local_path") track.artists = [self.get_artist(artist_id) for artist_id in data.get("artists")] track.album = self.get_album(data.get("album")) + track.has_lyrics = data.get("has_lyrics") self.track_map[track.id] = track return track diff --git a/src/local/LocalLibraryIndexer.py b/src/local/LocalLibraryIndexer.py index d3a7ce2..1a8eaab 100644 --- a/src/local/LocalLibraryIndexer.py +++ b/src/local/LocalLibraryIndexer.py @@ -1,8 +1,8 @@ -import os import fnmatch import mutagen import eyed3 from mutagen.flac import FLAC +from mutagen import File as MutagenFile from src.helpers import * @@ -11,6 +11,52 @@ music_extensions = ('*.mp3', '*.flac', '*.wav', '*.aac', '*.ogg', '*.m4a') exclude_directories = ("*mary--*", "*example-word*") +from mutagen.id3 import ID3 +from mutagen.id3 import ID3NoHeaderError + + +def detect_lyrics(abs_path, ext): + ext = ext.lower() + + # ---------- MP3 ---------- + if ext == ".mp3": + try: + tags = ID3(abs_path) + except ID3NoHeaderError: + return False + + return bool( + tags.getall("SYLT") or + tags.getall("USLT") + ) + + # ---------- FLAC / OGG / OPUS ---------- + if ext in {".flac", ".ogg", ".opus"}: + try: + audio = MutagenFile(abs_path) + if not audio or not audio.tags: + return False + + for key in audio.tags.keys(): + k = key.upper() + if k in {"LYRICS", "LRC", "SYNCEDLYRICS"}: + if audio.tags[key]: + return True + except Exception: + return False + + # ---------- Fallback ---------- + try: + audio = MutagenFile(abs_path) + if audio and audio.tags: + for key in audio.tags.keys(): + if "LYRIC" in key.upper(): + return True + except Exception: + pass + + return False + def update_local_library(root_dir, output_dir): set_workdir(output_dir / "local") @@ -29,50 +75,77 @@ def update_local_library(root_dir, output_dir): return prev_id for dir_path, dir_names, filenames in os.walk(root_dir): - dir_names[:] = [d for d in dir_names if not any(fnmatch.fnmatch(d, exclude) for exclude in exclude_directories)] - filtered_filenames = [filename for filename in filenames if any(fnmatch.fnmatch(filename, ext) for ext in music_extensions)] + dir_names[:] = [ + d for d in dir_names + if not any(fnmatch.fnmatch(d, exclude) for exclude in exclude_directories) + ] + + filtered_filenames = [ + filename for filename in filenames + if any(fnmatch.fnmatch(filename, ext) for ext in music_extensions) + ] for filename in filtered_filenames: song_name, track_type = os.path.splitext(filename) relative_path = os.path.relpath(os.path.join(dir_path, filename), root_dir) song_id = get_new_song_id(song_id, relative_path) + new_library[song_id] = { "name": song_name, "path": relative_path, "type": track_type, "artist": "", - "album": "" + "album": "", + "duration": None } for track_id, track in new_library.items(): abs_path = os.path.join(root_dir, track['path']) - match track['type']: + + track_abs_path = os.path.join(root_dir, track["path"]) + track["has_lyrics"] = detect_lyrics(track_abs_path, track['type'].lower()) + + match track['type'].lower(): case ".mp3": mp3track = eyed3.load(abs_path) - if not mp3track: - print(f"Failed to load mp3 {abs_path} using just name of the file as track name") + if not mp3track or not mp3track.info: + print(f"Failed to load mp3 {abs_path}") track['name'] = track['path'] else: + track['duration'] = mp3track.info.time_secs + tag = mp3track.tag - if not tag: - print(f"Invalid mp3 header {abs_path} using just name of the file as track name") - track['name'] = track['path'] - else: + if tag: track['artist'] = tag.artist track['name'] = tag.title track['album'] = tag.album case ".flac": try: - metadata = FLAC(abs_path).tags - if 'artist' in metadata: - track['artist'] = " ".join(metadata['artist']) - if 'TITLE' in metadata: - track['name'] = " ".join(metadata['TITLE']) - if 'album' in metadata: - track['album'] = " ".join(metadata['album']) + audio = FLAC(abs_path) + + if audio.info: + track['duration'] = audio.info.length + + metadata = audio.tags + if metadata: + if 'artist' in metadata: + track['artist'] = " ".join(metadata['artist']) + if 'TITLE' in metadata: + track['name'] = " ".join(metadata['TITLE']) + if 'album' in metadata: + track['album'] = " ".join(metadata['album']) + except mutagen.MutagenError: - print(f"Invalid flac header {abs_path} using just name of the file as track name") + print(f"Invalid flac header {abs_path}") track['name'] = track['path'] + case _: + try: + audio = MutagenFile(abs_path) + if audio and audio.info: + track['duration'] = audio.info.length + except Exception: + pass + save_data(new_library, "local_library") diff --git a/src/local/LocalPLaylistGenerator.py b/src/local/LocalPLaylistGenerator.py index 0299498..1c0f1e1 100644 --- a/src/local/LocalPLaylistGenerator.py +++ b/src/local/LocalPLaylistGenerator.py @@ -1,7 +1,7 @@ from pathlib import Path -def generate_playlists(local_library, library_path, remote_playlists, link_pattern, output_dir): +def (): output_dir = Path(output_dir) output_playlists = [] diff --git a/src/local/ParseLocal.py b/src/local/ParseLocal.py index 084bbce..49b3f55 100644 --- a/src/local/ParseLocal.py +++ b/src/local/ParseLocal.py @@ -116,6 +116,8 @@ class LocalParser: track.artists = self.parse_artists(data.get("artist")) track.album = self.parse_album(data.get("album")) track.local_path = data.get("path") + track.duration_ms = data.get("duration") + track.has_lyrics = data.get("has_lyrics") self.track_map[track.id] = track self.library.tracks.append(track) diff --git a/src/lyrics/lyrics.py b/src/lyrics/lyrics.py new file mode 100644 index 0000000..fb427de --- /dev/null +++ b/src/lyrics/lyrics.py @@ -0,0 +1,111 @@ +import requests +from mutagen import File +from mutagen.id3 import ID3, USLT, ID3NoHeaderError +import os + +def fetch_lyrics(artist: str, album: str, track: str, duration: int = 0) -> str: + + skip_artists = [ + "shurupis", + "unknown" + ] + + skip_album = [ + "unknown" + ] + + if artist in skip_artists or album in skip_album: + return "" + + + url = "https://lrclib.net/api/get" + params = { + "artist_name": artist, + "track_name": track, + "album_name": album, + "duration": duration, + } + try: + resp = requests.get(url, params=params, timeout=10) + if resp.status_code != 200: + try: + err = resp.json() + print(err.get("error"), "-", err.get("message")) + except ValueError: + print("HTTPError", resp.status_code) + return "" + data = resp.json() + if "error" in data: + print(data.get("error"), "-", data.get("message")) + return "" + return data.get("syncedLyrics", "") + except requests.RequestException as e: + print(type(e).__name__, "-", str(e)) + return "" + except ValueError as e: + print(type(e).__name__, "-", str(e)) + return "" + + +def write_lyrics(file_path, lyrics_text, lang="eng"): + """ + Writes lyrics text into tags recognized by Navidrome. + Provides detailed logs and success/failure info. + """ + + ext = os.path.splitext(file_path)[1].lower() + print(f"[INFO] Processing file: {file_path} (extension: {ext})") + + try: + # ---------- MP3 ---------- + if ext == ".mp3": + print("[INFO] Detected MP3 file") + try: + tags = ID3(file_path) + print("[INFO] Existing ID3 tags loaded") + except ID3NoHeaderError: + tags = ID3() + print("[INFO] No ID3 header found, creating new tags") + + tags.delall("USLT") + print("[INFO] Removed existing USLT frames (lyrics)") + + tags.add( + USLT( + encoding=3, # UTF-8 + lang=lang, + desc="", + text=lyrics_text + ) + ) + tags.save(file_path) + print("[SUCCESS] Lyrics written to MP3 tags") + return + + # ---------- FLAC / OGG / OPUS ---------- + audio = File(file_path) + if audio is None: + print(f"[ERROR] Unsupported or unrecognized file format: {file_path}") + return + + if ext in {".flac", ".ogg", ".opus"}: + print(f"[INFO] Detected {ext.upper()} file") + audio["LRC"] = lyrics_text + audio["SYNCEDLYRICS"] = lyrics_text + audio.save() + print(f"[SUCCESS] Lyrics written to {ext.upper()} tags") + return + + # ---------- Fallback ---------- + if audio.tags is not None: + print("[INFO] Using fallback tag writing") + audio["LYRICS"] = lyrics_text + audio.save() + print("[SUCCESS] Lyrics written using fallback tags") + return + + print(f"[WARNING] No suitable tag frame found for {file_path}") + + except Exception as e: + print(f"[ERROR] Failed to write lyrics to {file_path}: {e}") +