chore: bump version to 0.7.1

This commit is contained in:
ethan.chen
2025-05-27 14:16:48 +08:00
commit 63eda91fd6
103 changed files with 15868 additions and 0 deletions

View File

@@ -0,0 +1,9 @@
'''
Date: 2025-05-06 09:25:16
LastEditors: 陈子健
LastEditTime: 2025-05-26 17:14:27
FilePath: /mac-lyric-vue/backend/modules/__init__.py
'''
"""
lyroc 后端模块包
"""

View File

@@ -0,0 +1,118 @@
"""
Apple Music 控制和状态获取模块
负责与 Apple Music 应用交互的所有功能
"""
import subprocess
import logging
logger = logging.getLogger(__name__)
def run_applescript(script: str):
"""运行 AppleScript 并返回结果"""
result = subprocess.run([
"osascript", "-e", script
], capture_output=True, text=True)
return result.stdout.strip()
def get_music_status():
"""获取 Apple Music 播放状态"""
script = '''
tell application "Music"
if it is running then
set playerState to (get player state) as string
if playerState is "stopped" then
return "stopped"
else
set trackName to name of current track
set artistName to artist of current track
set pos to player position
set dur to duration of current track
return playerState & "|||" & trackName & "|||" & artistName & "|||" & (pos as text) & "|||" & (dur as text)
end if
else
return "notrunning"
end if
end tell
'''
try:
out = run_applescript(script)
if out is None:
logger.error("AppleScript执行失败返回了None")
return {"status": "error", "error": "AppleScript execution failed"}
if out == "notrunning":
return {"status": "notrunning"}
if out == "stopped":
return {"status": "stopped"}
try:
player_state, track_name, artist, pos, dur = out.split("|||")
return {
"status": player_state,
"track_name": track_name,
"artist": artist,
"position": float(pos),
"duration": float(dur)
}
except Exception as e:
return {"status": "error", "error": str(e), "raw": out}
except Exception as e:
logger.error(f"获取音乐状态时发生异常: {e}")
return {"status": "error", "error": str(e)}
def control_music(action, position=None):
"""控制Apple Music播放"""
try:
if action == "playpause":
script = '''
tell application "Music"
if it is running then
playpause
return "ok"
else
return "notrunning"
end if
end tell
'''
elif action == "previous":
script = '''
tell application "Music"
if it is running then
previous track
return "ok"
else
return "notrunning"
end if
end tell
'''
elif action == "next":
script = '''
tell application "Music"
if it is running then
next track
return "ok"
else
return "notrunning"
end if
end tell
'''
elif action == "seek" and position is not None:
script = f'''
tell application "Music"
if it is running then
set player position to {position}
return "ok"
else
return "notrunning"
end if
end tell
'''
else:
return {"status": "error", "message": "未知的命令"}
result = run_applescript(script)
if result == "ok":
return {"status": "success"}
else:
return {"status": "error", "message": result}
except Exception as e:
return {"status": "error", "message": str(e)}

View File

@@ -0,0 +1,219 @@
"""
后台任务模块
负责创建和管理定期检查Apple Music状态并推送更新的后台任务
"""
import time
import asyncio
import logging
from .apple_music import get_music_status
from .lyrics import get_lyrics_data
from .websocket_manager import ConnectionManager
logger = logging.getLogger(__name__)
# 存储后台任务引用
background_tasks = set()
async def fetch_and_update_lyrics(status, manager):
"""异步获取歌词并发送更新"""
try:
# 异步获取歌词
logger.info(f"正在获取歌词...")
lyrics = get_lyrics_data(status)
logger.info(f"歌词: {lyrics}")
# 获取到歌词后,再发送一个歌词更新消息
if lyrics and isinstance(lyrics, dict): # 确保lyrics是一个非空的字典
lyrics_update = {
"type": "lyric_update", # 使用不同的消息类型
"timestamp": time.time(),
"lyrics": lyrics
}
await manager.broadcast(lyrics_update)
logger.info("歌词获取完成,已发送更新")
except Exception as e:
logger.error(f"获取歌词并发送更新时出错: {e}")
async def update_music_info(manager: ConnectionManager):
"""定期检查音乐状态并向所有客户端推送更新"""
logger.info("后台任务update_music_info开始运行...")
# 记录上一次状态
last_position = -1
last_track_name = None
last_lyrics = None
last_playback_status = None # 记录上一次的播放状态
# 配置更新间隔
check_interval = 0.05 # 内部检查间隔仍保持较短
# 设定重试次数
consecutive_errors = 0
max_consecutive_errors = 10
while True:
try:
current_time = time.time()
# 获取当前状态
status = get_music_status()
if status is None:
logger.error("获取音乐状态失败返回None")
await asyncio.sleep(1)
continue
current_status = status.get("status", "unknown")
# 错误状态处理
if current_status == "error":
logger.error(f"获取音乐状态返回错误: {status.get('error', 'Unknown error')}")
await asyncio.sleep(1)
continue
# 状态变化处理
if current_status != last_playback_status and last_playback_status is not None:
logger.info(f"播放状态变化: {last_playback_status} -> {current_status}")
# 确定消息类型
message_type = "status_change"
if current_status == "playing":
message_type = "playback_resumed"
elif current_status == "paused":
message_type = "playback_paused"
elif current_status == "stopped":
message_type = "playback_stopped"
elif current_status == "notrunning":
message_type = "app_not_running"
# 向客户端通知状态变化
status_update = {
"type": message_type,
"timestamp": current_time,
"status": status
}
await manager.broadcast(status_update)
last_playback_status = current_status
consecutive_errors = 0
await asyncio.sleep(check_interval)
continue
# 如果没有状态变化,但是还没有记录过状态,进行初始化
if last_playback_status is None:
last_playback_status = current_status
# 如果应用未运行或已停止,无需进一步处理
if current_status in ["stopped", "notrunning"]:
await asyncio.sleep(check_interval)
continue
# 保存当前播放状态用于下次比较
last_playback_status = current_status
# 如果已停止,无需进一步处理
if current_status in ["stopped", "notrunning"]:
await asyncio.sleep(check_interval)
continue
# 以下是正常播放状态下的处理
position = status.get("position", -1)
track_name = status.get("track_name")
# 安全检查:如果缺少关键数据,跳过本次循环
if track_name is None:
logger.debug("歌曲名称为空,跳过本次更新")
await asyncio.sleep(check_interval)
continue
# 检查是否需要发送更新
track_changed = track_name != last_track_name
# 如果歌曲变化,获取新歌词
if track_changed:
try:
# 立即发送歌曲变化通知(不含歌词)
logger.info(f"歌曲变化: {last_track_name} -> {track_name}")
# 先发送一个不含歌词的track_change消息
initial_update = {
"type": "track_change",
"timestamp": current_time,
"status": status,
"lyrics": None # 不包含歌词
}
await manager.broadcast(initial_update)
# 更新歌曲名称记录
last_track_name = track_name
last_position = position
# 重置错误计数
consecutive_errors = 0
# 启动一个独立的异步任务来获取歌词并后续发送更新
# 这样主循环可以继续运行,不会阻塞
asyncio.create_task(fetch_and_update_lyrics(status, manager))
except Exception as e:
logger.error(f"处理歌曲变化时出错: {e}")
await asyncio.sleep(0.5)
# 检查歌词是否需要更新(播放位置变化明显)
elif abs(last_position - position) > 0.5: # 如果播放位置变化超过0.5秒
try:
lyrics = get_lyrics_data(status)
# 只有当歌词内容变化时才发送更新
current_lyric = lyrics.get("current_lyric", "") if lyrics and isinstance(lyrics, dict) else ""
current_lyric_time = lyrics.get("current_lyric_time", "") if lyrics and isinstance(lyrics, dict) else ""
last_current_lyric_time = last_lyrics.get("current_lyric_time", "") if last_lyrics and isinstance(last_lyrics, dict) else ""
if current_lyric_time != last_current_lyric_time:
if current_lyric is None:
current_lyric = ""
logger.debug(f"歌词更新: {current_lyric[:20]}...")
combined_data = {
"type": "lyric_change",
"timestamp": current_time,
"status": status,
"lyrics": lyrics
}
await manager.broadcast(combined_data)
last_lyrics = lyrics
last_position = position
# 重置错误计数
consecutive_errors = 0
except Exception as e:
logger.error(f"处理歌词更新时出错: {e}")
await asyncio.sleep(0.5)
# 更新上一次位置
if track_changed or (abs(last_position - position) > 0.5):
last_position = position
# 等待下一次检查
await asyncio.sleep(check_interval)
except Exception as e:
logger.error(f"更新音乐信息时出错: {e}")
consecutive_errors += 1
if consecutive_errors >= max_consecutive_errors:
logger.error(f"连续错误次数超过{max_consecutive_errors},退出后台任务")
break
await asyncio.sleep(1) # 出错时等待1秒再重试
def start_background_task(manager: ConnectionManager):
"""启动后台任务并添加到跟踪集合中"""
task = asyncio.create_task(update_music_info(manager))
background_tasks.add(task)
task.add_done_callback(background_tasks.discard)
return task
def cancel_all_tasks():
"""取消所有后台任务"""
for task in background_tasks:
task.cancel()

435
backend/modules/lyrics.py Normal file
View File

@@ -0,0 +1,435 @@
"""
歌词处理模块
负责歌词的解析、格式化、存储和获取
"""
import os
import re
import logging
import requests
import sqlite3
from urllib.parse import quote
from Crypto.Cipher import AES
import base64
import random
import codecs
import json
logger = logging.getLogger(__name__)
# 配置应用支持目录
APP_SUPPORT_DIR = os.path.expanduser('~/Library/Application Support/lyroc')
os.makedirs(APP_SUPPORT_DIR, exist_ok=True)
# SQLite数据库配置
DB_PATH = os.path.join(APP_SUPPORT_DIR, 'lyroc.db')
def init_db():
"""初始化SQLite数据库"""
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
# 创建歌词表
cursor.execute('''
CREATE TABLE IF NOT EXISTS lyrics (
id INTEGER PRIMARY KEY AUTOINCREMENT,
track_name TEXT NOT NULL,
artist TEXT NOT NULL,
lyrics_content TEXT NOT NULL,
source TEXT DEFAULT 'netease',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(track_name, artist)
)
''')
conn.commit()
conn.close()
logger.debug(f"数据库初始化完成: {DB_PATH}")
# 初始化数据库
init_db()
def clear_lyrics_cache():
"""清空歌词缓存"""
global _lyrics_cache
_lyrics_cache = {
"track_name": None,
"artist": None,
"lyrics_text": None,
"source": None,
"deleted": False
}
# 歌词缓存
clear_lyrics_cache()
# 网易云音乐API配置
NETEASE_API_BASE = "https://music.163.com/weapi"
NETEASE_SEARCH_URL = f"{NETEASE_API_BASE}/search/get"
NETEASE_LYRIC_URL = f"{NETEASE_API_BASE}/song/lyric"
# 加密相关配置
MODULUS = '00e0b509f6259df8642dbc35662901477df22677ec152b5ff68ace615bb7b725152b3ab17a876aea8a5aa76d2e417629ec4ee341f56135fccf695280104e0312ecbda92557c93870114af6c9d05c4f7f0c3685b7a46bee255932575cce10b424d813cfe4875d3e82047b97ddef52741d546b8e289dc6935b3ece0462db0a22b8e7'
NONCE = '0CoJUm6Qyw8W8jud'
PUBKEY = '010001'
IV = '0102030405060708'
class LyricLine:
def __init__(self, time, text):
self.time = time # 时间(秒)
self.text = text # 歌词文本
def load_lyrics_from_db(track_name, artist):
"""从数据库加载歌词"""
try:
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
cursor.execute('''
SELECT lyrics_content FROM lyrics WHERE track_name = ? AND artist = ?
''', (track_name, artist))
result = cursor.fetchone()
conn.close()
if result:
return result[0]
except Exception as e:
logger.error(f"加载数据库歌词时出错: {e}", exc_info=True)
return None
def parse_time(time_str):
"""解析时间标记 [mm:ss.xxx]"""
try:
# 移除方括号
time_str = time_str.strip('[]')
# 分离分钟和秒
minutes, seconds = time_str.split(':')
# 转换为秒
total_seconds = float(minutes) * 60 + float(seconds)
return total_seconds
except:
return 0
def format_lrc_lyrics(lyrics_text, current_position=None):
"""格式化 LRC 歌词,找出当前、下一句和下下句歌词"""
if not lyrics_text:
return None, None, None, None
# 解析歌词行
lyric_lines = []
for line in lyrics_text.split('\n'):
line = line.strip()
if not line:
continue
# 查找所有时间标记
time_tags = re.findall(r'\[([0-9:.]+)\]', line)
if not time_tags:
continue
text = re.sub(r'\[[0-9:.]+\]', '', line).strip()
if not text:
continue
# 一行可能有多个时间标记
for time_tag in time_tags:
try:
time_seconds = parse_time(time_tag)
lyric_lines.append({"time": time_seconds, "text": text})
except Exception as e:
logger.error(f"解析时间标记出错: {time_tag}, 错误: {e}")
# 按时间排序
lyric_lines.sort(key=lambda x: x["time"])
# 如果没有有效歌词,返回空
if not lyric_lines:
return None, None, None, None
if not current_position:
# 如果没有提供当前位置,返回前三句歌词
current = lyric_lines[0]["text"] if len(lyric_lines) > 0 else None
next_ = lyric_lines[1]["text"] if len(lyric_lines) > 1 else None
next_next = lyric_lines[2]["text"] if len(lyric_lines) > 2 else None
return current, next_, next_next, lyric_lines[0]["time"]
# 找出当前歌词
current_index = -1
for i, line in enumerate(lyric_lines):
if line["time"] > current_position:
if i > 0:
current_index = i - 1
break
# 处理当前位置超过最后一行歌词的情况
if current_index == -1 and lyric_lines:
# 如果遍历完所有歌词都没找到大于当前位置的,说明当前位置超过了最后一行歌词
if current_position >= lyric_lines[-1]["time"]:
# 返回最后三句歌词,将最后一句作为当前歌词
last_index = len(lyric_lines) - 1
if last_index >= 2:
return lyric_lines[last_index]["text"], None, None, lyric_lines[last_index]["time"]
elif last_index == 1:
return lyric_lines[1]["text"], None, None, lyric_lines[1]["time"]
else:
return lyric_lines[0]["text"], None, None, lyric_lines[0]["time"]
# 如果找到了当前歌词
if current_index >= 0:
current = lyric_lines[current_index]["text"]
next_ = lyric_lines[current_index + 1]["text"] if current_index + 1 < len(lyric_lines) else None
next_next = lyric_lines[current_index + 2]["text"] if current_index + 2 < len(lyric_lines) else None
return current, next_, next_next, lyric_lines[current_index]["time"]
# 如果当前位置在所有歌词之前
if lyric_lines:
return None, lyric_lines[0]["text"], lyric_lines[1]["text"] if len(lyric_lines) > 1 else None, lyric_lines[0]["time"]
return None, None, None, None
def get_lyrics_data(status):
"""获取当前歌词(先获取状态,再从缓存或存储中获取歌词)"""
global _lyrics_cache
# 如果没有播放,清空缓存并返回空
if status.get("status") not in ["playing", "paused"]:
clear_lyrics_cache()
return {
"current_lyric_time": None,
"current_lyric": None,
"next_lyric": None,
"next_next_lyric": None,
"track_name": None,
"artist_name": None
}
track_name = status.get("track_name")
artist = status.get("artist")
position = status.get("position", 0)
# 检查是否切换了歌曲
if _lyrics_cache["track_name"] != track_name or _lyrics_cache["artist"] != artist:
logger.debug(f"歌曲切换,重新获取歌词: {track_name} - {artist}")
# 清空缓存
_lyrics_cache["track_name"] = track_name
_lyrics_cache["artist"] = artist
_lyrics_cache["lyrics_text"] = None
_lyrics_cache["deleted"] = False
# 1. 先尝试从数据库获取
lyrics_text = load_lyrics_from_db(track_name, artist)
if lyrics_text:
_lyrics_cache["lyrics_text"] = lyrics_text
_lyrics_cache["source"] = "db"
# 2. 如果本地没有且不是被删除的歌词尝试从网易云音乐API获取歌词
elif not _lyrics_cache["lyrics_text"] and not _lyrics_cache.get("deleted", False):
lyrics_text = search_lyrics(track_name, artist)
if lyrics_text:
# 保存到数据库
save_lyrics(track_name, artist, lyrics_text)
_lyrics_cache["lyrics_text"] = lyrics_text
_lyrics_cache["source"] = "netease"
# 如果有缓存的歌词,直接解析
if _lyrics_cache["lyrics_text"]:
if _lyrics_cache.get("deleted", False):
return {
"current_lyric_time": None,
"current_lyric": None,
"next_lyric": None,
"next_next_lyric": None,
}
# 根据播放位置解析当前歌词
current, next_, next_next, current_time = format_lrc_lyrics(_lyrics_cache["lyrics_text"], position)
if current or next_ or next_next:
return {
"current_lyric_time": current_time,
"current_lyric": current,
"next_lyric": next_,
"next_next_lyric": next_next,
"track_name": track_name,
"artist_name": artist,
"lyrics_source": _lyrics_cache["source"] # 标记歌词来源
}
# 都没找到返回空
return {
"current_lyric_time": None,
"current_lyric": None,
"next_lyric": None,
"next_next_lyric": None,
"track_name": track_name,
"artist_name": artist,
"lyrics_source": "none" # 标记歌词来源
}
def create_secret_key(size):
"""生成随机密钥"""
return ''.join(random.choice('abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789') for _ in range(size))
def aes_encrypt(text, key):
"""AES加密"""
pad = 16 - len(text) % 16
text = text + chr(pad) * pad
encryptor = AES.new(key.encode('utf-8'), AES.MODE_CBC, IV.encode('utf-8'))
encrypt_text = encryptor.encrypt(text.encode('utf-8'))
encrypt_text = base64.b64encode(encrypt_text).decode('utf-8')
return encrypt_text
def rsa_encrypt(text, pubkey, modulus):
"""RSA加密"""
text = text[::-1]
rs = pow(int(codecs.encode(text.encode('utf-8'), 'hex'), 16), int(pubkey, 16), int(modulus, 16))
return format(rs, 'x').zfill(256)
headers = {
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.114 Safari/537.36',
'Referer': 'https://music.163.com/',
'Origin': 'https://music.163.com',
'Content-Type': 'application/x-www-form-urlencoded'
}
def encrypt_request(text):
"""加密请求数据"""
secret_key = create_secret_key(16)
params = aes_encrypt(text, NONCE)
params = aes_encrypt(params, secret_key)
encSecKey = rsa_encrypt(secret_key, PUBKEY, MODULUS)
return {
'params': params,
'encSecKey': encSecKey
}
def get_lyrics_from_netease(track_name, artist_name):
# 构建搜索关键词
search_keyword = f"{track_name} {artist_name}"
# 搜索歌曲
data = {
's': search_keyword,
'type': 1, # 1: 单曲, 10: 专辑, 100: 歌手, 1000: 歌单
'limit': 5,
'offset': 0,
'hlpretag': '<span class="s-fc7">',
'hlposttag': '</span>',
'total': True,
'csrf_token': ''
}
encrypted_data = encrypt_request(json.dumps(data))
response = requests.post(NETEASE_SEARCH_URL, data=encrypted_data, headers=headers)
logger.debug(f"网易云音乐搜索响应: {response.text}")
response.raise_for_status()
result = response.json()
return result
def get_lyrics_from_id(id):
"""从网易云音乐获取歌词"""
# 获取歌词
data = {
'id': id,
'lv': 1, # 获取歌词
'kv': 1, # 获取翻译
'tv': -1, # 不获取罗马音
'csrf_token': ''
}
encrypted_data = encrypt_request(json.dumps(data))
response = requests.post(NETEASE_LYRIC_URL, data=encrypted_data, headers=headers)
response.raise_for_status()
result = response.json()
return result
def search_lyrics(track_name, artist_name):
"""从网易云音乐搜索并获取歌词"""
try:
result = get_lyrics_from_netease(track_name, artist_name)
if result['code'] == 200 and result['result']['songCount'] > 0:
song = result['result']['songs'][0]
id = song['id']
lrc_result = get_lyrics_from_id(id)
if lrc_result['code'] == 200 and 'lrc' in lrc_result:
logger.debug(f"网易云音乐歌词: {lrc_result['lrc']['lyric']}")
return lrc_result['lrc']['lyric']
return None
except Exception as e:
logger.error(f"从网易云音乐获取歌词时出错: {e}", exc_info=True)
return None
def format_lyrics(lyrics):
"""格式化歌词文本,保留时间信息"""
if not lyrics:
logger.debug("歌词内容为空")
return []
lyric_lines = []
for line in lyrics.split('\n'):
line = line.strip()
if not line:
continue
# 查找所有时间标记
time_tags = re.findall(r'\[([0-9:.]+)\]', line)
if not time_tags:
continue
text = re.sub(r'\[[0-9:.]+\]', '', line).strip()
if not text:
continue
# 可能一行有多个时间标记
for time_tag in time_tags:
try:
time_seconds = parse_time(time_tag)
lyric_lines.append(LyricLine(time_seconds, text))
except Exception as e:
logger.error(f"解析时间标记出错: {time_tag}, 错误: {e}")
# 按时间排序
sorted_lyrics = sorted(lyric_lines, key=lambda x: x.time)
logger.debug(f"解析完成,共 {len(sorted_lyrics)} 行歌词")
return sorted_lyrics
def save_lyrics(track_name, artist, lyrics):
"""保存歌词到本地文件,如果已存在则更新"""
try:
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
cursor.execute('''
INSERT INTO lyrics (track_name, artist, lyrics_content)
VALUES (?, ?, ?)
ON CONFLICT(track_name, artist)
DO UPDATE SET
lyrics_content = excluded.lyrics_content,
created_at = CURRENT_TIMESTAMP
''', (track_name, artist, lyrics))
conn.commit()
conn.close()
logger.debug(f"歌词已保存/更新到数据库: {track_name} - {artist}")
except Exception as e:
logger.error(f"保存歌词时出错: {e}", exc_info=True)
def delete_lyrics(track_name, artist):
"""从数据库中删除歌词"""
try:
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
cursor.execute('''
DELETE FROM lyrics WHERE track_name = ? AND artist = ?
''', (track_name, artist))
conn.commit()
deleted = cursor.rowcount > 0
conn.close()
if deleted:
logger.info(f"已删除歌词: {track_name} - {artist}")
# 标记这首歌的歌词已被删除,避免重新获取
_lyrics_cache["deleted"] = True
return True
else:
logger.warning(f"未找到要删除的歌词: {track_name} - {artist}")
return False
except Exception as e:
logger.error(f"删除歌词时出错: {e}", exc_info=True)
return False

205
backend/modules/routes.py Normal file
View File

@@ -0,0 +1,205 @@
"""
路由模块
包含所有API端点的定义和处理逻辑
"""
import asyncio
import time
import logging
from fastapi import WebSocket, WebSocketDisconnect
from pydantic import BaseModel
from .apple_music import get_music_status, control_music
from .lyrics import get_lyrics_data, delete_lyrics, get_lyrics_from_netease, get_lyrics_from_id, save_lyrics, clear_lyrics_cache
from .websocket_manager import ConnectionManager
logger = logging.getLogger(__name__)
# 音乐控制请求模型
class ControlRequest(BaseModel):
action: str # 'playpause', 'previous', 'next', 'seek'
position: float = None # 仅当 action=='seek' 时有效
# 手动搜索歌词请求模型
class SearchLyricsRequest(BaseModel):
track_name: str
artist: str
# 获取指定ID的歌词请求模型
class GetLyricsFromIdRequest(BaseModel):
id: int
track_name: str
artist: str
def register_routes(app, manager: ConnectionManager):
"""注册所有API路由到FastAPI应用"""
# 保留向下兼容的HTTP接口
@app.get("/status")
def status_endpoint():
"""获取 Apple Music 播放状态"""
return get_music_status()
@app.get("/lyrics")
def lyrics_endpoint():
"""获取当前歌词"""
status = get_music_status()
return get_lyrics_data(status)
@app.delete("/lyrics")
def delete_lyrics_endpoint(track_name: str, artist: str):
"""删除指定歌曲的歌词"""
success = delete_lyrics(track_name, artist)
if success:
return {"status": "success", "message": "歌词已删除"}
else:
return {"status": "error", "message": "未找到要删除的歌词"}
@app.post("/lyrics/search")
def search_lyrics_endpoint(request: SearchLyricsRequest):
"""手动搜索歌词"""
try:
# 搜索歌词
result = get_lyrics_from_netease(request.track_name, request.artist)
if result['code'] == 200 and result['result']['songCount'] > 0:
songs = result['result']['songs']
return {
"status": "success",
"songs": songs
}
else:
return {
"status": "error",
"message": "未找到歌词"
}
except Exception as e:
logger.error(f"搜索歌词时出错: {e}", exc_info=True)
return {
"status": "error",
"message": f"搜索歌词时出错: {str(e)}"
}
@app.post("/lyrics/getLyricsFromId")
def get_lyrics_from_id_endpoint(request: GetLyricsFromIdRequest):
"""获取指定ID的歌词"""
result = get_lyrics_from_id(request.id)
if result['code'] == 200 and 'lrc' in result:
logger.debug(f"网易云音乐歌词: {result['lrc']['lyric']}")
lyrics_text = result['lrc']['lyric']
# 保存到数据库
save_lyrics(request.track_name, request.artist, lyrics_text)
clear_lyrics_cache()
return {
"status": "success",
}
# 添加播放控制接口
@app.post("/control")
async def control_endpoint(request: ControlRequest):
"""控制Apple Music播放"""
result = control_music(request.action, request.position)
# 如果控制成功广播更新的状态给所有WebSocket客户端
if result["status"] == "success":
# 延迟一下让Apple Music有时间执行命令
await asyncio.sleep(0.1)
status = get_music_status()
lyrics = get_lyrics_data(status)
combined_data = {
"type": "position_update", # 添加消息类型
"timestamp": time.time(),
"status": status,
"lyrics": lyrics
}
# 异步广播更新
asyncio.create_task(manager.broadcast(combined_data))
return result
# WebSocket端点
@app.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket):
"""WebSocket连接处理合并推送歌词和状态"""
await manager.connect(websocket)
try:
# 连接建立后立即发送一次数据
status = get_music_status()
lyrics = get_lyrics_data(status)
logger.info(f"发送初始状态数据: 当前歌曲={status.get('track_name', '')}, 歌词可用={lyrics is not None}")
combined_data = {
"type": "track_change", # 确保添加消息类型
"timestamp": time.time(),
"status": status,
"lyrics": lyrics
}
await websocket.send_json(combined_data)
# 发送欢迎消息
await websocket.send_json({"type": "info", "message": "WebSocket连接已建立"})
# 连接状态标志,用于安全终止循环
is_connected = True
# 持续接收并处理客户端消息
while is_connected:
try:
# 接收客户端消息,设置超时避免无限阻塞
data = await asyncio.wait_for(websocket.receive_json(), timeout=1.0)
logger.info(f"收到WebSocket消息: {data}")
# 处理不同类型的消息
if isinstance(data, dict):
# 处理请求状态消息
if data.get("type") == "request_status":
logger.info("客户端请求状态更新")
status = get_music_status()
lyrics = get_lyrics_data(status)
update_data = {
"type": "track_change", # 确保添加消息类型
"timestamp": time.time(),
"status": status,
"lyrics": lyrics
}
await websocket.send_json(update_data)
# 处理ping消息
elif data.get("type") == "ping":
logger.debug("收到客户端ping")
await websocket.send_json({
"type": "pong",
"timestamp": time.time()
})
except WebSocketDisconnect:
# 连接已断开,退出循环
logger.info("WebSocket连接断开")
is_connected = False
break
except asyncio.TimeoutError:
# 接收超时,但连接可能仍然有效,继续循环
continue
except Exception as e:
# 其他错误,记录后继续(除非是连接断开)
if "disconnect" in str(e).lower() or "not connected" in str(e).lower():
logger.info(f"WebSocket连接可能已断开: {e}")
is_connected = False
break
else:
logger.error(f"处理WebSocket消息时出错: {e}")
# 继续监听下一条消息,不断开连接
continue
except WebSocketDisconnect:
logger.info("WebSocket连接断开 (外层异常)")
except Exception as e:
logger.error(f"WebSocket错误: {e}")
finally:
# 确保连接从管理器中移除
await manager.disconnect(websocket)
logger.info("WebSocket连接已清理")

View File

@@ -0,0 +1,53 @@
"""
WebSocket连接管理模块
负责管理WebSocket连接和广播消息
"""
import asyncio
import logging
from typing import List
from fastapi import WebSocket
logger = logging.getLogger(__name__)
class ConnectionManager:
def __init__(self):
self.active_connections: List[WebSocket] = []
self._lock = asyncio.Lock() # 添加锁以保护连接列表
async def connect(self, websocket: WebSocket):
await websocket.accept()
async with self._lock:
# 检查连接是否已经存在,如果存在则不重复添加
if websocket not in self.active_connections:
self.active_connections.append(websocket)
logger.info(f"WebSocket客户端连接当前连接数: {len(self.active_connections)}")
async def disconnect(self, websocket: WebSocket):
async with self._lock:
# 安全移除连接,如果不存在则忽略
if websocket in self.active_connections:
self.active_connections.remove(websocket)
logger.info(f"WebSocket客户端断开当前连接数: {len(self.active_connections)}")
async def broadcast(self, data):
"""向所有连接的客户端广播数据"""
async with self._lock:
# 复制列表以避免在迭代时修改
connections = self.active_connections.copy()
# 在锁外处理发送操作
disconnect_list = []
for connection in connections:
try:
await connection.send_json(data)
except Exception as e:
logger.error(f"发送消息失败: {e}")
disconnect_list.append(connection)
# 移除断开的连接
if disconnect_list:
async with self._lock:
for conn in disconnect_list:
if conn in self.active_connections:
self.active_connections.remove(conn)
logger.info(f"移除断开的连接,剩余连接数: {len(self.active_connections)}")