- categories
- >
- Others
写在前面
使用python
的curses
库进行终端歌词显示
原理是持续使用cmus-remote -Q
查询当前音乐状态,然后进行歌词的时间轴匹配
Github 地址: ctlyrics
歌词的获取
设置目标歌曲
首先将要查找的歌曲存入songs_list.txt
文件,最终保存的格式为名称 - 歌手
,从文件中读取目标歌曲便于组织和管理歌曲
结果举例:
島みやえい子 - 宇宙の花
潘辰 - 流泪
如果这都不算爱 - 张学友
Brilliant days - moumoon
女恶魔人op
其中前两首存在问题,矫正后如下:
宇宙の花 - 島みやえい子
流泪 - 潘辰
如果这都不算爱 - 张学友
Brilliant days - moumoon
女恶魔人op
相邻两行的去重操作(仅限 vim)::g/^\(.*\)$\n\1/d
获取目标歌曲流程:
具体程序:
import os
def get_songs_from_directory(directory):
valid_extensions = ('.mp3', '.flac', '.wav')
songs = []
for filename in os.listdir(directory):
if not filename.endswith(valid_extensions):
continue
try:
if '-' in filename:
song_name, song_artist = filename.split('-', 1)
song_name = song_name.strip()
song_artist = (song_artist.rsplit('.', 1)[0]).strip()
else:
song_name = (filename.rsplit('.', 1)[0]).strip()
song_artist = ""
songs.append((song_name, song_artist))
except Exception as e:
print(f"Error processing {filename}: {e}")
continue
return songs
def save_songs_to_file(songs, file_path):
with open(file_path, 'w', encoding='utf-8') as file:
for song_name, song_artist in songs:
if(song_artist == ""):
file.write(f"{song_name}\n")
else:
file.write(f"{song_name} - {song_artist}\n")
if __name__ == "__main__":
music_dir = "/home/_warehouse/music/"
output_file = "songs_list.txt"
songs = get_songs_from_directory(music_dir)
save_songs_to_file(songs, output_file)
lrc 文件的获取
我是在无损音乐网获取的歌词https://www.sq0527.cn/
假设查找半生缘的歌词,点击搜索后发现网址变更如下:
https://www.sq0527.cn/search?ac=半生缘
可以得出,获取结果可以使用requests
直接访问https://www.sq0527.cn/search?ac=半生缘
,得到对应结果
接着处理搜索结果,理论上歌词大差不差,默认选择第一个作为目标
<ul class="mul" style="background:#fff;overflow:hidden;">
<li
style="line-height: 30px; height: 30px; float: left; width: 210px; margin: 0px 10px; white-space: nowrap; overflow: hidden; background-color: rgb(255, 255, 255);"
onmouseover="this.style.backgroundColor='#eee'"
onmouseout="this.style.backgroundColor='#FFF'"
>
<a href="/music/557628382.html" target="_blank" title=""
>莫文蔚 - <font color="red">半生缘</font> (我们在这里相遇)</a
>
</li>
<li
style="line-height: 30px; height: 30px; float: left; width: 210px; margin: 0px 10px; white-space: nowrap; overflow: hidden; background-color: rgb(255, 255, 255);"
onmouseover="this.style.backgroundColor='#eee'"
onmouseout="this.style.backgroundColor='#FFF'"
>
<a href="/music/2073816158.html" target="_blank" title=""
>莫文蔚 - <font color="red">半生缘</font></a
>
</li>
...
</ul>
只要将包裹在ul
下每个li
提取出来即可,可以获取链接(用于进一步获取目标)、名称(用于可能的展示)
使用BeautifulSoup
的select
进行提取:
soup = BeautifulSoup(response.text, 'html.parser')
search_results = soup.select('ul.mul li a')
默认进入第一个链接/music/557628382.html
<textarea placeholder="歌词" class="layui-textarea">
[00:00.00] 作词 : 李焯雄
[00:00.79] 作曲 : 华晨宇
[00:01.59] 编曲 : Ruth Ling
[00:02.38] 制作人 : 荒井十一
[00:03.18]
[00:15.05]后半从前半分裂
[00:22.43]人生是连环失窃
[00:29.72]你爱的不告而别
...
</textarea>
textarea
标签内即为歌词,保存为对应的lrc
文件即可
使用BeautifulSoup
的select_one
进行提取:
soup = BeautifulSoup(response.text, 'html.parser')
lyrics = soup.select_one('textarea.layui-textarea')
流程:
完整程序:
import os
import requests
from bs4 import BeautifulSoup
def search_song(song_name):
search_url = f'https://www.sq0527.cn/search?ac={song_name}'
response = requests.get(search_url)
if response.status_code != 200:
print(f"request failed: {response.status_code}")
return None
soup = BeautifulSoup(response.text, 'html.parser')
search_results = soup.select('ul.mul li a')
if not search_results:
print(f"can't find {song_name}")
return None
return search_results
def choose_search_result(search_results, limit=15, by_self=False):
limited_results = search_results[:limit]
for index, result in enumerate(limited_results, 1):
print(f"{index}. {result.text.strip()}")
if by_self:
choice = int(input("your choice: ")) - 1
if 0 <= choice < len(limited_results):
return limited_results[choice]['href']
else:
print("invalid choice!")
return None
else:
return limited_results[0]['href']
def get_lyrics(lyrics_url):
response = requests.get(lyrics_url)
if response.status_code != 200:
print(f"request failed, error code: {response.status_code}")
return None
soup = BeautifulSoup(response.text, 'html.parser')
lyrics = soup.select_one('textarea.layui-textarea')
return lyrics.text if lyrics else None
def save_lyrics(lyrics, filename, save_directory="./lyrics"):
if not os.path.exists(save_directory):
os.makedirs(save_directory)
file_path = os.path.join(save_directory, filename)
with open(file_path, 'w', encoding='utf-8') as file:
file.write(lyrics)
print(f"lyrics saved to {file_path}")
def get_songs_from_file(file_path):
songs = []
with open(file_path, 'r', encoding='utf-8') as file:
for line in file:
line = line.strip()
if ' - ' in line:
song_name = line.split(' - ', 1)[0]
else:
song_name = line
songs.append((song_name, line))
return songs
def log_error(file, message):
with open(file, 'a', encoding='utf-8') as file:
file.write(f"{message}\n")
if __name__ == "__main__":
songs_list = "songs_list.txt"
lyrics_dir = "./lyrics"
is_self = False
res_limit = 15
songs = get_songs_from_file(songs_list)
total_songs = len(songs)
index = 1
error_log = "error.txt"
for song_name, original_filename in songs:
os.system('clear')
print(f"\n({index}/{total_songs}) searching: {song_name}")
index += 1
search_results = search_song(song_name)
is_success = True
error_msg = song_name
if search_results:
selected_result = choose_search_result(search_results, res_limit, is_self)
if selected_result:
lyrics_url = f'https://www.sq0527.cn{selected_result}'
lyrics = get_lyrics(lyrics_url)
if lyrics:
save_lyrics(lyrics, f"{original_filename}.lrc", lyrics_dir)
else:
print("lyrics not found")
is_success = False
error_msg += "|lyrics not found"
else:
print("selected result not found")
is_success = False
error_msg += "|selected result not found"
else:
print("search results not found")
is_success = False
error_msg += "|search results not found"
if not is_success:
log_error(error_log, error_msg)
songs_list
定义了获取歌曲的文件lyrics_dir
定义了保存文件的目标is_self
定义了是否手动筛选歌词的搜索结果res_limit
定义了歌词搜索结果的展示数量
程序执行效果:
(1/881) searching: ALL KILL
1. T-ara - ALL KILL
2. 白永 - All-Kill
3. Rosie - All Kill
4. 忆唏 - ALL KILL
5. Zico/Crush - BERMUDA TRIANGLE
6. 智柠 - ALL KILL
7. 国民之子 - NEVER
8. Truedy - ALL KILL
9. 崔姨母/智贤贤 - ALL KILL
10. IU - 밤편지
11. 咖菲 - ALL KILL
12. MG_木九九九 - ALL KILL
13. 无可乐不乐 - ALL KILL
14. 4ROSE - All Kill
15. Alina铭/猫人 - All Kill
lyrics saved to ./lyrics/ALL KILL - T-ara.lrc
歌词的显示
流程:
首先使用cmus-remote -Q
获取当前音乐信息:
status paused
file /home/_warehouse/music/Brilliant days - moumoon.mp3
duration 246
position 159
set aaa_mode all
set continue true
set play_library true
set play_sorted false
set replaygain disabled
set replaygain_limit true
set replaygain_preamp 0.000000
set repeat false
set repeat_current false
set shuffle off
set softvol false
set vol_left 70
set vol_right 70
主要获取file
, position
其中file
用于歌词的查找,postion
用于歌词的定位
import re
import subprocess
def get_cmus_info():
try:
output = subprocess.check_output(['cmus-remote', '-Q']).decode('utf-8')
# get position
position_match = re.search(r'position (\d+)', output)
position = int(position_match.group(1)) if position_match else 0
# get duration
duration_match = re.search(r'duration (\d+)', output)
duration = int(duration_match.group(1)) if duration_match else 0
# get status
status_match = re.search(r'status (\w+)', output)
status = status_match.group(1) if status_match else 'stopped'
# get title and artist
song_match = re.search(r'/([^/]+?)(?:\s*-\s*([^/.]+))?\.', output)
title = song_match.group(1).strip() if song_match.group(1) else "unknown"
artist = song_match.group(2).strip() if song_match.group(2) else "unknown"
return {
"position": position,
"duration": duration,
"status": status,
"title": title,
"artist": artist
}
except subprocess.CalledProcessError:
return {
"position": 0,
"duration": 0,
"status": "unknown",
"title": "unknown",
"artist": "unknown"
}
lrc 文件的加载
然后查找对应的lrc
文件:
def load_lyrics_and_info(self, info):
current_title = info.get('title')
artist = info.get('artist')
if current_title == self.last_title:
return self.last_lyrics, self.last_total_lines
lyrics_path = find_lrc_file('lyrics', current_title, artist)
if lyrics_path:
self.last_lyrics = parse_lrc(lyrics_path)
self.last_total_lines = len(self.last_lyrics)
else:
self.last_lyrics = []
self.last_total_lines = 0
self.last_title = current_title
return self.last_lyrics, self.last_total_lines
其中info
信息即get_cmus_info()
得到,为了减少性能损耗,创建了LyricsCache
类,只有音乐不同于上一首时才遍历文件夹进行歌词的查找。以上代码只是LyricsCache
中的加载函数
歌词的查找:
def find_lrc_file(dir, title, artist=None):
if not title:
return None
title_pattern = re.compile(re.escape(title), re.IGNORECASE)
files = os.listdir(dir)
matching_files = [file for file in files if title_pattern.search(file) and file.endswith('.lrc')]
if len(matching_files) == 0:
return None
if len(matching_files) == 1:
return os.path.join(dir, matching_files[0])
if artist:
artist_pattern = re.compile(re.escape(artist), re.IGNORECASE)
for file in matching_files:
if artist_pattern.search(file):
return os.path.join(dir, file)
return os.path.join(dir, matching_files[0]) if matching_files else None
首先判断title
是否相同,如果相同则进一步比较artist
,如果一首歌存在多个演唱家的歌词,则返回最后一个结果
屏幕绘制
使用curses.newpad()
创建双缓冲的歌词显示,避免刷新闪烁
def display_lyrics(stdscr, info, lyrics, total_lines, offset):
current_line = 0
adjusted_position = info['position'] + offset
for i, (timestamp, _) in enumerate(lyrics):
if adjusted_position >= timestamp:
current_line = i
else:
break
height, width = stdscr.getmaxyx()
if width <= 0 or height <= 0:
return
start_line = max(0, current_line - height // 2)
end_line = min(total_lines, start_line + height)
pad = curses.newpad(total_lines + 1, width)
song_info = f"{info['title']} - {info['artist']} [{info['status']}] (Offset: {offset:.1f}s)"
pad.addstr(0, 0, song_info, curses.A_BOLD)
for i in range(end_line - start_line):
display_line = start_line + i
if display_line >= total_lines:
break
text = lyrics[display_line][1]
try:
if display_line == current_line:
pad.addstr(i + 1, 0, text, curses.color_pair(1) | curses.A_BOLD)
else:
pad.addstr(i + 1, 0, text)
except curses.error:
print(f"Failed to display line {i + 1}: {text}")
pad.refresh(0, 0, 0, 0, height - 1, width - 1)
- Valine
- LiveRe
- ChangYan