cmus外部歌词

写在前面

使用pythoncurses库进行终端歌词显示

原理是持续使用cmus-remote -Q查询当前音乐状态,然后进行歌词的时间轴匹配

Github 地址: ctlyrics

显示效果

歌词的获取

设置目标歌曲

首先将要查找的歌曲存入songs_list.txt文件,最终保存的格式为名称 - 歌手,从文件中读取目标歌曲便于组织和管理歌曲

结果举例:

島みやえい子 - 宇宙の花
潘辰 - 流泪
如果这都不算爱 - 张学友
Brilliant days - moumoon
女恶魔人op

其中前两首存在问题,矫正后如下:

宇宙の花 - 島みやえい子
流泪 - 潘辰
如果这都不算爱 - 张学友
Brilliant days - moumoon
女恶魔人op

相邻两行的去重操作(仅限 vim)::g/^\(.*\)$\n\1/d

获取目标歌曲流程:

流程图 get songs from directory

具体程序:

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=半生缘

search result

可以得出,获取结果可以使用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提取出来即可,可以获取链接(用于进一步获取目标)、名称(用于可能的展示)

使用BeautifulSoupselect进行提取:

soup = BeautifulSoup(response.text, 'html.parser')
search_results = soup.select('ul.mul li a')

默认进入第一个链接/music/557628382.html

result of lrc

<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文件即可

使用BeautifulSoupselect_one进行提取:

soup = BeautifulSoup(response.text, 'html.parser')
lyrics = soup.select_one('textarea.layui-textarea')

流程:

流程图 get lyrics

完整程序:

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

歌词的显示

流程:
流程图 display lyrics

首先使用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)

comment: