chore: bump version to 0.7.1
This commit is contained in:
3
backend/.env.example
Normal file
3
backend/.env.example
Normal file
@@ -0,0 +1,3 @@
|
||||
# 歌词服务器配置示例
|
||||
LYRICS_SERVER=http://123.57.93.143:28883
|
||||
LYRICS_AUTHORIZATION=fzt_tom
|
||||
7
backend/.gitignore
vendored
Normal file
7
backend/.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
__pycache__
|
||||
lyrics
|
||||
.DS_Store
|
||||
build
|
||||
dist
|
||||
.env
|
||||
venv
|
||||
10
backend/config.py
Normal file
10
backend/config.py
Normal file
@@ -0,0 +1,10 @@
|
||||
'''
|
||||
Date: 2025-04-24 16:32:55
|
||||
LastEditors: 陈子健
|
||||
LastEditTime: 2025-05-26 17:13:51
|
||||
FilePath: /mac-lyric-vue/backend/config.py
|
||||
'''
|
||||
import os
|
||||
|
||||
APP_SUPPORT_DIR = os.path.expanduser('~/Library/Application Support/lyroc')
|
||||
os.makedirs(APP_SUPPORT_DIR, exist_ok=True)
|
||||
86
backend/main.py
Normal file
86
backend/main.py
Normal file
@@ -0,0 +1,86 @@
|
||||
'''
|
||||
Date: 2025-05-06 09:26:51
|
||||
LastEditors: 陈子健
|
||||
LastEditTime: 2025-05-26 17:14:17
|
||||
FilePath: /mac-lyric-vue/backend/main.py
|
||||
'''
|
||||
"""
|
||||
lyroc 后端主程序
|
||||
负责初始化应用和启动服务器
|
||||
"""
|
||||
import uvicorn
|
||||
import os
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
# 导入自定义模块
|
||||
from modules.websocket_manager import ConnectionManager
|
||||
from modules.background_tasks import start_background_task, cancel_all_tasks, background_tasks
|
||||
from modules.routes import register_routes
|
||||
|
||||
# 配置日志
|
||||
logging.basicConfig(
|
||||
level=logging.DEBUG,
|
||||
format='%(asctime)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 应用支持目录和歌词目录在lyrics模块中已定义
|
||||
|
||||
# Lifespan 上下文管理器,替代 on_event
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
logger.info("正在启动API服务...")
|
||||
try:
|
||||
# 启动时创建后台任务
|
||||
start_background_task(manager)
|
||||
logger.info("后台任务已启动")
|
||||
except Exception as e:
|
||||
logger.error(f"启动后台任务失败: {e}")
|
||||
|
||||
yield # 应用正常运行
|
||||
|
||||
# 关闭时清理任务
|
||||
try:
|
||||
cancel_all_tasks()
|
||||
logger.info("后台任务已关闭")
|
||||
except Exception as e:
|
||||
logger.error(f"关闭后台任务失败: {e}")
|
||||
|
||||
# 创建FastAPI应用
|
||||
app = FastAPI(lifespan=lifespan)
|
||||
|
||||
# 确保在应用启动时直接启动任务(作为后备方案)
|
||||
@app.on_event("startup")
|
||||
async def startup_event():
|
||||
# 仅当通过 lifespan 未启动任务时才启动
|
||||
if not background_tasks:
|
||||
logger.info("通过startup_event启动后台任务...")
|
||||
try:
|
||||
start_background_task(manager)
|
||||
logger.info("后台任务通过startup_event成功启动")
|
||||
except Exception as e:
|
||||
logger.error(f"通过startup_event启动后台任务失败: {e}")
|
||||
|
||||
# 配置CORS
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# 创建WebSocket连接管理器
|
||||
manager = ConnectionManager()
|
||||
|
||||
# 注册API路由
|
||||
register_routes(app, manager)
|
||||
|
||||
if __name__ == "__main__":
|
||||
# 从环境变量获取端口号,如果未设置则默认使用5000
|
||||
port = int(os.environ.get("PORT", 5000))
|
||||
logger.info(f"启动后端服务器在端口: {port}")
|
||||
uvicorn.run("main:app", host="127.0.0.1", port=port, reload=True)
|
||||
9
backend/modules/__init__.py
Normal file
9
backend/modules/__init__.py
Normal 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 后端模块包
|
||||
"""
|
||||
118
backend/modules/apple_music.py
Normal file
118
backend/modules/apple_music.py
Normal 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)}
|
||||
219
backend/modules/background_tasks.py
Normal file
219
backend/modules/background_tasks.py
Normal 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
435
backend/modules/lyrics.py
Normal 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
205
backend/modules/routes.py
Normal 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连接已清理")
|
||||
53
backend/modules/websocket_manager.py
Normal file
53
backend/modules/websocket_manager.py
Normal 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)}")
|
||||
0
backend/pyproject.toml
Normal file
0
backend/pyproject.toml
Normal file
6
backend/requirements.txt
Normal file
6
backend/requirements.txt
Normal file
@@ -0,0 +1,6 @@
|
||||
fastapi>=0.95.0
|
||||
uvicorn[standard]>=0.22.0
|
||||
websockets>=10.4
|
||||
pydantic>=1.10.7
|
||||
requests>=2.31.0
|
||||
pycryptodome>=3.22.0
|
||||
Reference in New Issue
Block a user