Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added local lyrics with .lrc and Sync for lyrics #47

Merged
merged 2 commits into from
Dec 6, 2024
Merged
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
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
44 changes: 42 additions & 2 deletions lyrics/track.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ 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
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
114 changes: 108 additions & 6 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,23 +171,123 @@ 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_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 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.

Returns:
Tuple of (lyrics_list, timestamps_list, source)
timestamps_list will be None for non-LRC sources
'''

# Clean up track name for searching
search_name = track_name.strip(' -')

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

if os.path.isfile(lrc_path):
with open(lrc_path, 'r', encoding='utf-8') as file:
lrc_lines = file.read().splitlines()

# Parse LRC lines with timestamps
timed_lyrics = []
for line in lrc_lines:
results = parse_lrc_line(line)
timed_lyrics.extend(results) # Add all timestamp-lyric pairs

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

# Keep track of unique lyrics while preserving order
seen_lyrics = {} # text -> timestamp
lyrics_list = []
timestamps_list = []

for timestamp, text in timed_lyrics:
if text not in seen_lyrics:
seen_lyrics[text] = timestamp
lyrics_list.append(text)
timestamps_list.append(timestamp)

return (lyrics_list, timestamps_list, 'lrc')

# Check regular lyrics cache
filepath = get_filename(track_name)

if not os.path.isdir(CACHE_PATH):
Expand All @@ -197,7 +299,7 @@ def get_lyrics(track_name: str, source: str = 'any', cache: bool = True) -> Tupl
# cache lyrics exist
with open(filepath) as file:
lyrics_lines = file.read().splitlines()
return lyrics_lines, 'cache'
return (lyrics_lines, None, 'cache')

search_url = url + query(track_name)
html = get_html(search_url)
Expand Down
80 changes: 68 additions & 12 deletions lyrics/window.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
from lyrics import __version__

import curses

from lyrics import util
import time

class Key:
def __init__(self):
Expand Down Expand Up @@ -143,8 +144,10 @@ def __init__(self, stdscr, player, timeout):
self.text_padding = 5
self.keys = Key()
self.find_position = 0
self.timeout = timeout

self.timeout = 100 # Fixed timeout for smoother updates
self.last_highlight = -1 # Track last highlighted line
self.last_update = 0 # Track last display update time

curses.use_default_colors()
self.stdscr.timeout(self.timeout)
self.set_up()
Expand Down Expand Up @@ -349,20 +352,66 @@ def update_track(self, show_source=False):
else:
text = self.player.track.get_text()

pad_height = max(self.height, self.player.track.length) + 2
pad_width = max(self.width, self.player.track.width) + 2

self.scroll_pad.resize(pad_height, pad_width)
self.scroll_pad.addstr(text)
self.set_offset()

if not text:
return

lines = text.split('\n')
if not lines:
return

# Align all lines according to current alignment
aligned_lines = util.align(lines, self.width - 2, self.player.track.alignment)

# Adjust pad size if needed
pad_height = max(self.height, len(aligned_lines)) + 2
pad_width = max(self.width, max(len(line) for line in aligned_lines)) + 2
try:
self.scroll_pad.resize(pad_height, pad_width)
except curses.error:
pass

# Get current playback position
try:
current_time = float(self.player.get_time_pos())
except (ValueError, TypeError):
current_time = 0

# Update display with highlighting
for i, line in enumerate(aligned_lines):
attrs = curses.A_NORMAL

# Handle LRC highlighting
if self.player.track.timestamps and self.player.track.source == 'lrc':
current_line = -1
for j, ts in enumerate(self.player.track.timestamps):
if ts <= current_time:
current_line = j
else:
break

if i == current_line:
attrs = curses.A_REVERSE
if self.last_highlight != i:
self.current_pos = max(0, i - 2)
self.last_highlight = i

# Add line to pad
try:
self.scroll_pad.addstr(i, self.pad_offset, line, attrs)
except curses.error:
pass

# Show source if requested
if show_source:
self.stdscr.addstr(self.height - 1, 1,
f" Source: {self.player.track.source}", curses.A_REVERSE)

self.set_titlebar()
self.set_statusbar()
self.set_offset()
def main(self):
key = ''

last_lrc_update = time.time()

while key != self.keys.binds['quit']:
key = self.stdscr.getch()

Expand All @@ -372,6 +421,13 @@ def main(self):
if self.player.update():
self.current_pos = 0
self.update_track()
# Update display every 100ms for LRC files
elif (self.player.running and
self.player.track.timestamps and
self.player.track.source == 'lrc' and
time.time() - last_lrc_update >= 0.1):
self.update_track()
last_lrc_update = time.time()

if self.player.running:
self.keys.input(self, key)
Expand Down