Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion .vscode/launch.json
Original file line number Diff line number Diff line change
@@ -1,14 +1,20 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Python Debugger: Attach using Process Id",
"type": "debugpy",
"request": "attach",
"processId": "${command:pickProcess}"
},
{
"name": "Python: File",
"type": "debugpy",
"request": "launch",
"program": "${workspaceFolder}/lyrics/lyrics_in_terminal.py",
"console": "integratedTerminal",
"env": {
"PYTHONPATH": "${workspaceFolder}/lyrics:${PYTHONPATH}"
"PYTHONPATH": "${workspaceFolder}:${PYTHONPATH}"
},
"justMyCode": true
},
Expand Down
4 changes: 2 additions & 2 deletions lyrics/__init__.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
from pathlib import Path

CACHE_PATH = Path.home().joinpath('.cache', 'lyrics')
CACHE_PATH = Path.home().joinpath('.cache', 'lyrics_dev')

CONFIG_PATH = Path.home().joinpath('.config', 'lyrics-in-terminal', 'lyrics.cfg')

__version__ = '1.7.0'
__version__ = '1.8.0-dev'

if not CONFIG_PATH.exists():
from shutil import copy
Expand Down
2 changes: 2 additions & 0 deletions lyrics/lyrics_in_terminal.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

import sys
import curses
import traceback


def ErrorHandler(func):
Expand All @@ -20,6 +21,7 @@ def wrapper(*args, **kwargs):
print('Please increase terminal window size!')
except Exception as err:
print('Unexpected exception occurred.', sys.exc_info(), err)
traceback.print_exc()

return wrapper

Expand Down
16 changes: 16 additions & 0 deletions lyrics/player.py
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,22 @@ def update(self):

return False

def get_time_pos(self):
"""Get current playback position in seconds."""
try:
if not self.running or not self.player_interface:
return 0

# Get position in microseconds
position = self.player_interface.Get(
'org.mpris.MediaPlayer2.Player', 'Position')

# Convert to seconds
return float(position) / 1000000

except Exception as e:
return 0

def refresh(self, cycle_source=False, source=None, cache=True):
''' Re-fetches lyrics from procided source
source -> source name ('google' or 'azlyrics')
Expand Down
46 changes: 43 additions & 3 deletions lyrics/track.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,11 @@ def __init__(self,
self.width = width
self.length = 0
self.lyrics = []
self.timestamps = [] # List of timestamps corresponding to lyrics lines
self.source = None
self.album = None
self.trackid = None
self.sources = ['google', 'azlyrics', 'genius']
self.sources = ['lrc', 'lrclib', 'google', 'azlyrics', 'genius']

def __str__(self):
''' trackname in format "{artist} - {title}"
Expand Down Expand Up @@ -73,13 +74,52 @@ def get_lyrics(self, source, cycle_source=False, cache=True):
else:
source = 'any'

self.lyrics, self.source = util.get_lyrics(self.track_name, source, cache=cache)
self.width = len(max(self.lyrics, key=len))
result = util.get_lyrics(self.track_name, source, cache=cache)
if len(result) == 3: # New format with timestamps
self.lyrics, self.timestamps, self.source = result
else: # Old format compatibility
self.lyrics, self.source = result
self.timestamps = None

self.width = len(max(self.lyrics, key=len)) if self.lyrics else 0
self.length = len(self.lyrics)

def set_lyrics_with_timestamps(self, lyrics_list, timestamps_list):
"""Set lyrics and their corresponding timestamps.

Args:
lyrics_list: List of lyrics lines
timestamps_list: List of timestamps (in seconds) for each line
"""
self.lyrics = lyrics_list
self.timestamps = timestamps_list
self.length = len(self.lyrics)
if self.lyrics:
self.reset_width()

def refresh_lyrics(self, source='any', cache=True, cycle_source=False):
''' refresh lyrics from source
'''
if cycle_source and self.source in self.sources:
i = self.sources.index(self.source) + 1
source = self.sources[i % len(self.sources)]

result = util.get_lyrics(self.track_name, source, cache=cache)
if len(result) == 3: # New format with timestamps
self.lyrics, self.timestamps, self.source = result
else: # Old format compatibility
self.lyrics, self.source = result
self.timestamps = None

self.width = len(max(self.lyrics, key=len)) if self.lyrics else 0
self.length = len(self.lyrics)

def get_text(self, wrap=False, width=0):
''' returns lyrics text seperated by '\\n'
'''
if not self.lyrics:
return ''

if wrap:
lyrics=util.wrap_text(self.lyrics, width)
else:
Expand Down
175 changes: 160 additions & 15 deletions lyrics/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
import tempfile
import re
import requests
from pathlib import Path
import sys


url = 'https://www.google.com/search?q='
Expand Down Expand Up @@ -169,35 +171,177 @@ def parse_google(html: str) -> List[str] | None:
return lyrics_lines


def get_filename(track_name):
def parse_lrc_line(line: str) -> List[Tuple[float, str]]:
"""Parse a single LRC line and return list of (timestamp, lyrics) pairs.

Args:
line: A line from LRC file, possibly with multiple timestamps
e.g. "[00:12.34][00:15.67]Lyrics text"

Returns:
List of tuples (timestamp in seconds, lyrics text)
"""
if not line or not line.startswith('['):
return []

try:
# Find all timestamps in the line
timestamps = []
lyrics_text = line

while lyrics_text.startswith('['):
bracket_end = lyrics_text.find(']')
if bracket_end == -1:
break

timestamp_str = lyrics_text[1:bracket_end] # "00:12.34"

try:
if ':' in timestamp_str:
minutes, seconds = timestamp_str.split(':')
total_seconds = float(minutes) * 60 + float(seconds)
timestamps.append(total_seconds)
except ValueError:
pass # Skip invalid timestamps

lyrics_text = lyrics_text[bracket_end + 1:]

lyrics_text = lyrics_text.strip()
if not lyrics_text or not timestamps:
return []

# Return a pair for each timestamp with the same lyrics
return [(ts, lyrics_text) for ts in timestamps]

except Exception as e:
return []


def get_liblrc(artist_name, track_name):
querystring = {"artist_name": artist_name, "track_name": track_name}
url = "https://lrclib.net/api/get"
response = requests.get(url, params=querystring)
return response.json()


def get_synced_lyrics(artist_name, track_name) -> Tuple[List[str] | None, List[str] | None]:
"""
Fetches synced lyrics from lrclib.net
Returns:
Tuple of (synced lyrics list, lyrics list)
"""
ly = get_liblrc(artist_name, track_name)

if ly.get("statusCode") == 404:
print(ly.get("message"))
return (None, None)

lyrics = None
synced_ly = None
if ly.get("instrumental", False):
return (None, None)

synced_ly = ly.get("syncedLyrics", None)
if synced_ly is None:
lyrics = ly.get("plainLyrics", None)
lyrics = lyrics.split('\n') if lyrics is not None else None
else:
return (synced_ly.split('\n'), None)

return (synced_ly, lyrics)


def get_filename(track_name, lrc=False):
'''returns name of cache file name from track name with correct format

Args:
track_name: track name in format "artist - title"
lrc: if True, look for .lrc file instead of plain lyrics
'''
# Clean up leading/trailing spaces and hyphens
filename = track_name.strip(' -')

# removing text in brackets [] ()
filename = re.sub(r'(\[.*\].*)|(\(.*\).*)', '', track_name).strip()
filename = re.sub(r'(\[.*\].*)|(\(.*\).*)', '', filename).strip()

# Remove spaces and special characters
filename = re.sub(r'\s|\/|\\|\.', '', filename)
return os.path.join(CACHE_PATH, filename)
# Add .lrc extension if needed
if lrc:
filename = filename + '.lrc'

# Build full path
full_path = os.path.join(CACHE_PATH, filename)
return full_path


def format_synced_lyrics(lyrics_lines: List[str]) -> Tuple[List[str], List[float], str]:
lyrics = []
for line in lyrics_lines:
results = parse_lrc_line(line)
lyrics.extend(results) # Add all timestamp-lyric pairs

# Sort by timestamp and remove duplicates
lyrics.sort(key=lambda x: x[0])

lyrics_list = []
timestamps_list = []

for timestamp, text in lyrics:
lyrics_list.append(text)
timestamps_list.append(timestamp)

return (lyrics_list, timestamps_list, 'lrc')


def get_lyrics(track_name: str, source: str = 'any', cache: bool = True) -> Tuple[List[str], str | None]:
''' returns tuple of list of strings with lines of lyrics and found source
def get_lyrics(track_name: str, source: str = 'any', cache: bool = True) -> Tuple[List[str], List[float] | None, str | None]:
''' returns tuple of list of strings with lines of lyrics, timestamps and found source
also reads/write to cache file | if cache=True

track_name -> track name in format "artist - title"
source -> source to fetch lyrics from ('google', 'azlyrics', 'genius', 'any')
cache -> bool | whether to check lyrics from cache or not.
'''
filepath = get_filename(track_name)

if not os.path.isdir(CACHE_PATH):
os.makedirs(CACHE_PATH)
Returns:
Tuple of (lyrics_list, timestamps_list, source)
timestamps_list will be None for non-LRC sources
'''

lyrics_lines = None
# If cache enabled, then return cached lyrics
if os.path.isfile(filepath) and cache:
# cache lyrics exist
with open(filepath) as file:
lyrics_lines = file.read().splitlines()
return lyrics_lines, 'cache'

if cache:
# First check for .lrc file
lrc_path = get_filename(track_name, lrc=True)

if (source == 'lrc' or source == 'any') and os.path.isfile(lrc_path):
with open(lrc_path, 'r', encoding='utf-8') as file:
lrc_lines = file.read().splitlines()
return format_synced_lyrics(lrc_lines)

# Check regular lyrics cache
filepath = get_filename(track_name)

if not os.path.isdir(CACHE_PATH):
os.makedirs(CACHE_PATH)

# If cache enabled, then return cached lyrics
if os.path.isfile(filepath) and cache:
# cache lyrics exist
with open(filepath) as file:
lyrics_lines = file.read().splitlines()
return (lyrics_lines, None, 'cache')

if source == 'lrclib' or source == 'lrc' or source == 'any':
artist, title = track_name.split(' - ', 1)
synced_lyrics, lyrics = get_synced_lyrics(artist, title)

if synced_lyrics is not None:
return format_synced_lyrics(synced_lyrics)
elif lyrics is not None:
lrc_path = get_filename(track_name, lrc=True)
with open(filepath, 'w') as file:
file.writelines(lyrics)
return (lyrics_lines, None, 'lrclib')

search_url = url + query(track_name)
html = get_html(search_url)
Expand Down Expand Up @@ -226,6 +370,7 @@ def get_lyrics(track_name: str, source: str = 'any', cache: bool = True) -> Tupl
# TODO: replace all html entities with ASCII instead of just &
text = map(lambda x: x.replace('&', '&') + '\n', lyrics_lines)

filepath = get_filename(track_name)
with open(filepath, 'w') as file:
file.writelines(text)

Expand Down
Loading