feat: 支持客户端播放服务端音乐

This commit is contained in:
helloplhm-qwq 2024-02-04 11:51:52 +08:00
parent a8e4d8ac69
commit 339e5edf3d
No known key found for this signature in database
GPG Key ID: 6BE1B64B905567C7
4 changed files with 427 additions and 6 deletions

View File

@ -108,6 +108,11 @@ default = {
"mg": ["128k"],
}
},
"local_music": {
"desc": "服务器侧本地音乐相关配置,请确保你的带宽足够",
"audio_path": "./audio",
"temp_path": "./temp",
}
},
"security": {
"rate_limit": {
@ -153,7 +158,7 @@ default = {
"enable": True,
"length": 86400 * 7, # 七天
},
},
}
},
"module": {
"kg": {

362
common/localMusic.py Normal file
View File

@ -0,0 +1,362 @@
# ----------------------------------------
# - mode: python -
# - author: helloplhm-qwq -
# - name: localMusic.py -
# - project: lx-music-api-server -
# - license: MIT -
# ----------------------------------------
# This file is part of the "lx-music-api-server" project.
import platform
import subprocess
import sys
from PIL import Image
import aiohttp
from common.utils import createMD5, timeLengthFormat
from . import log, config
from pydub.utils import mediainfo
import ujson as json
import traceback
import mutagen
import os
logger = log.log('local_music_handler')
audios = []
map = {}
AUDIO_PATH = config.read_config("common.local_music.audio_path")
TEMP_PATH = config.read_config("common.local_music.temp_path")
FFMPEG_PATH = None
def convertCover(input_bytes):
if (input_bytes.startswith(b'\xff\xd8\xff\xe0')): # jpg object do not need convert
return input_bytes
temp = TEMP_PATH + '/' + createMD5(input_bytes) + '.img'
with open(temp, 'wb') as f:
f.write(input_bytes)
f.close()
img = Image.open(temp)
img = img.convert('RGB')
with open(temp + 'crt', 'wb') as f:
img.save(f, format='JPEG')
f.close()
data = None
with open(temp + 'crt', 'rb') as f:
data = f.read()
f.close()
try:
os.remove(temp)
except:
pass
try:
os.remove(temp + 'crt')
except:
pass
return data
def check_ffmpeg():
logger.info('正在检查ffmpeg')
devnull = open(os.devnull, 'w')
linux_bin_path = '/usr/bin/ffmpeg'
environ_ffpmeg_path = os.environ.get('FFMPEG_PATH')
if (platform.system() == 'Windows' or platform.system() == 'Cygwin'):
if (environ_ffpmeg_path and (not environ_ffpmeg_path.endswith('.exe'))):
environ_ffpmeg_path += '/ffmpeg.exe'
else:
if (environ_ffpmeg_path and os.path.isdir(environ_ffpmeg_path)):
environ_ffpmeg_path += '/ffmpeg'
if (environ_ffpmeg_path):
try:
subprocess.Popen([environ_ffpmeg_path, '-version'], stdout=devnull, stderr=devnull)
devnull.close()
return environ_ffpmeg_path
except:
pass
if (os.path.isfile(linux_bin_path)):
try:
subprocess.Popen([linux_bin_path, '-version'], stdout=devnull, stderr=devnull)
devnull.close()
return linux_bin_path
except:
pass
try:
subprocess.Popen(['ffmpeg', '-version'], stdout=devnull, stderr=devnull)
return 'ffmpeg'
except:
logger.warning('无法找到ffmpeg对于本地音乐的一些扩展功能无法使用如果您不需要请忽略本条提示')
logger.warning('如果您已经安装,请将 FFMPEG_PATH 环境变量设置为您的ffmpeg安装路径或者将其添加到PATH中')
return None
def getAudioCoverFromFFMpeg(path):
if (not FFMPEG_PATH):
return None
cmd = [FFMPEG_PATH, '-i', path, TEMP_PATH + '/_tmp.jpg']
popen = subprocess.Popen(cmd, stdout=sys.stdout, stderr=sys.stdout)
popen.wait()
if (os.path.exists(TEMP_PATH + '/_tmp.jpg')):
with open(TEMP_PATH + '/_tmp.jpg', 'rb') as f:
data = f.read()
f.close()
try:
os.remove(TEMP_PATH + '/_tmp.jpg')
except:
pass
return data
def readFileCheckCover(path):
with open(path, 'rb') as f: # read the first 1MB audio
data = f.read(1024 * 1024)
return b'image/' in data
def checkLyricValid(lyric_content):
if (lyric_content is None):
return False
if (lyric_content == ''):
return False
lines = lyric_content.split('\n')
for line in lines:
line = line.strip()
if (line == ''):
continue
if (line.startswith('[')):
continue
if (not line.startswith('[')):
return False
return True
def filterLyricLine(lyric_content: str) -> str:
lines = lyric_content.split('\n')
completed = []
for line in lines:
line = line.strip()
if (line.startswith('[')):
completed.append(line)
continue
return '\n'.join(completed)
def getAudioMeta(filepath):
if not os.path.exists(filepath):
return None
try:
audio = mutagen.File(filepath)
if not audio:
return None
logger.info(audio.items())
if (filepath.lower().endswith('.mp3')):
cover = audio.get('APIC:')
if (cover):
cover = convertCover(cover.data)
title = audio.get('TIT2')
artist = audio.get('TPE1')
album = audio.get('TALB')
lyric = audio.get('TLRC')
if (title):
title = title.text
if (artist):
artist = artist.text
if (album):
album = album.text
if (lyric):
lyric = lyric.text
else:
lyric = [None]
else:
cover = audio.get('cover')
if (cover):
cover = convertCover(cover[0])
else:
if (readFileCheckCover(filepath)):
cover = getAudioCoverFromFFMpeg(filepath)
else:
cover = None
title = audio.get('title')
artist = audio.get('artist')
album = audio.get('album')
lyric = audio.get('lyrics')
if (not lyric):
if (os.path.isfile(os.path.splitext(filepath)[0] + '.lrc')):
with open(os.path.splitext(filepath)[0] + '.lrc', 'r', encoding='utf-8') as f:
lyric = filterLyricLine(f.read())
if (not checkLyricValid(lyric)):
lyric = [None]
f.close()
else:
lyric = [None]
return {
"filepath": filepath,
"title": title[0] if title else '',
"artist": ''.join(artist) if artist else '',
"album": album[0] if album else '',
"cover_path": extractCover({
"filepath": filepath,
"cover": cover,
}, TEMP_PATH),
"lyrics": lyric[0],
'length': audio.info.length,
'format_length': timeLengthFormat(audio.info.length),
}
except:
logger.error(f"get audio meta error: {filepath}")
logger.error(traceback.format_exc())
return None
def checkAudioValid(path):
if not os.path.exists(path):
return False
try:
audio = mutagen.File(path)
if not audio:
return False
return True
except:
logger.error(f"check audio valid error: {path}")
logger.error(traceback.format_exc())
return False
def extractCover(audio_info, temp_path):
if (not audio_info['cover']):
return None
path = os.path.join(temp_path + '/' + createMD5(audio_info['filepath']) + '_cover.jpg')
with open(path, 'wb') as f:
f.write(audio_info['cover'])
return path
def findAudios():
available_exts = [
'mp3',
'wav',
'flac',
'ogg',
'm4a',
]
files = os.listdir(AUDIO_PATH)
if (files == []):
return []
audios = []
for file in files:
if (not file.endswith(tuple(available_exts))):
continue
path = os.path.join(AUDIO_PATH, file)
if (not checkAudioValid(path)):
continue
logger.info(f"found audio: {path}")
meta = getAudioMeta(path)
audios = audios + [meta]
return audios
def getAudioCover(filepath):
if not os.path.exists(filepath):
return None
try:
audio = mutagen.File(filepath)
if not audio:
return None
return convertCover(audio.get('APIC:').data)
except:
logger.error(f"get audio cover error: {filepath}")
logger.error(traceback.format_exc())
return None
def writeAudioCover(filepath):
s = getAudioCover(filepath)
path = os.path.join(TEMP_PATH + '/' + createMD5(filepath) + '_cover.jpg')
with open(path, 'wb') as f:
f.write(s)
f.close()
return path
def writeLocalCache(audios):
with open(TEMP_PATH + '/meta.json', 'w', encoding='utf-8') as f:
f.write(json.dumps({
"file_list": os.listdir(AUDIO_PATH),
"audios": audios
}, ensure_ascii = False, indent = 2))
f.close()
def dumpLocalCache():
try:
TEMP_PATH = config.read_config("common.local_music.temp_path")
with open(TEMP_PATH + '/meta.json', 'r', encoding='utf-8') as f:
d = json.loads(f.read())
return d
except:
return {
"file_list": [],
"audios": []
}
def initMain():
global FFMPEG_PATH
FFMPEG_PATH = check_ffmpeg()
logger.debug('找到的ffmpeg命令: ' + str(FFMPEG_PATH))
if (not os.path.exists(AUDIO_PATH)):
os.mkdir(AUDIO_PATH)
logger.info(f"创建本地音乐文件夹 {AUDIO_PATH}")
if (not os.path.exists(TEMP_PATH)):
os.mkdir(TEMP_PATH)
logger.info(f"创建本地音乐临时文件夹 {TEMP_PATH}")
global audios
cache = dumpLocalCache()
if (cache['file_list'] == os.listdir(AUDIO_PATH)):
audios = cache['audios']
else:
audios = findAudios()
writeLocalCache(audios)
for a in audios:
map[a['filepath']] = a
logger.info("初始化本地音乐成功")
logger.debug(f'本地音乐列表: {audios}')
logger.debug(f'本地音乐map: {map}')
async def generateAudioFileResonse(path):
try:
w = map[path]
return aiohttp.web.FileResponse(w['filepath'])
except:
return {
'code': 2,
'msg': '未找到文件',
'data': None
}, 404
async def generateAudioCoverResonse(path):
try:
w = map[path]
if (not os.path.exists(w['cover_path'])):
p = writeAudioCover(w['filepath'])
logger.debug(f"生成音乐封面文件 {w['cover_path']} 成功")
return aiohttp.web.FileResponse(p)
return aiohttp.web.FileResponse(w['cover_path'])
except:
logger.debug(traceback.format_exc())
return {
'code': 2,
'msg': '未找到封面',
'data': None
}, 404
async def generateAudioLyricResponse(path):
try:
w = map[path]
return w['lyrics']
except:
return {
'code': 2,
'msg': '未找到歌词',
'data': None
}, 404
def checkLocalMusic(path):
return {
'file': os.path.exists(path),
'cover': os.path.exists(map[path]['cover_path']),
'lyric': bool(map[path]['lyrics'])
}

View File

@ -64,8 +64,10 @@ def filterFileName(filename):
# 将不合法字符替换为下划线
return re.sub(illegal_chars, '_', filename)
def createMD5(s: str):
return handleCreateMD5(s.encode("utf-8")).hexdigest()
def createMD5(s: (str, bytes)):
if (isinstance(s, str)):
s = s.encode("utf-8")
return handleCreateMD5(s).hexdigest()
def readFile(path, mode = "text"):
try:

58
main.py
View File

@ -11,18 +11,20 @@
import sys
from common.utils import createBase64Decode
if ((sys.version_info.major == 3 and sys.version_info.minor < 6) or sys.version_info.major == 2):
print('Python版本过低请使用Python 3.6+ ')
sys.exit(1)
from common import config
from common import config, localMusic
from common import lxsecurity
from common import log
from common import Httpx
from common import variable
from common import scheduler
from common import lx_script
from aiohttp.web import Response
from aiohttp.web import Response, FileResponse, StreamResponse
import ujson as json
import threading
import traceback
@ -33,6 +35,12 @@ import time
import os
def handleResult(dic, status = 200) -> Response:
if (not isinstance(dic, dict)):
dic = {
'code': 0,
'msg': 'success',
'data': dic
}
return Response(body = json.dumps(dic, indent=2, ensure_ascii=False), content_type='application/json', status = status)
logger = log.log("main")
@ -99,7 +107,7 @@ async def handle_before_request(app, handler):
resp = handleResult(body, status)
else:
resp = Response(body = str(body), content_type='text/plain', status = status)
elif (not isinstance(resp, Response)):
elif (not isinstance(resp, (Response, FileResponse, StreamResponse))):
resp = Response(body = str(resp), content_type='text/plain', status = 200)
aiologger.info(f'{request.remote_addr + ("" if (request.remote == request.remote_addr) else f"|proxy@{request.remote}")} - {request.method} "{request.path}", {resp.status}')
return resp
@ -142,6 +150,48 @@ async def handle(request):
async def handle_404(request):
return handleResult({'code': 6, 'msg': '未找到您所请求的资源', 'data': None}, 404)
async def handle_local(request):
try:
query = dict(request.query)
data = query.get('q')
data = createBase64Decode(data.replace('-', '+').replace('_', '/'))
data = json.loads(data)
t = request.match_info.get('type')
data['t'] = t
except:
return handleResult({'code': 6, 'msg': '请求参数有错', 'data': None}, 404)
if (data['t'] == 'u'):
if (data['p'] in list(localMusic.map.keys())):
return await localMusic.generateAudioFileResonse(data['p'])
else:
return handleResult({'code': 6, 'msg': '未找到您所请求的资源', 'data': None}, 404)
if (data['t'] == 'l'):
if (data['p'] in list(localMusic.map.keys())):
return await localMusic.generateAudioLyricResponse(data['p'])
else:
return handleResult({'code': 6, 'msg': '未找到您所请求的资源', 'data': None}, 404)
if (data['t'] == 'p'):
if (data['p'] in list(localMusic.map.keys())):
return await localMusic.generateAudioCoverResonse(data['p'])
else:
return handleResult({'code': 6, 'msg': '未找到您所请求的资源', 'data': None}, 404)
if (data['t'] == 'c'):
if (not data['p'] in list(localMusic.map.keys())):
return {
'code': 0,
'msg': 'success',
'data': {
'file': False,
'cover': False,
'lyric': False
}
}
return {
'code': 0,
'msg': 'success',
'data': localMusic.checkLocalMusic(data['p'])
}
app = aiohttp.web.Application(middlewares=[handle_before_request])
# mainpage
app.router.add_get('/', main)
@ -149,6 +199,7 @@ app.router.add_get('/', main)
# api
app.router.add_get('/{method}/{source}/{songId}/{quality}', handle)
app.router.add_get('/{method}/{source}/{songId}', handle)
app.router.add_get('/local/{type}', handle_local)
if (config.read_config('common.allow_download_script')):
app.router.add_get('/script', lx_script.generate_script_response)
@ -225,6 +276,7 @@ async def run_app():
async def initMain():
await scheduler.run()
variable.aioSession = aiohttp.ClientSession(trust_env=True)
localMusic.initMain()
try:
await run_app()
logger.info("服务器启动成功请按下Ctrl + C停止")