From 339e5edf3dee34152eef2568e2b89b311652dc02 Mon Sep 17 00:00:00 2001 From: helloplhm-qwq Date: Sun, 4 Feb 2024 11:51:52 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E5=AE=A2=E6=88=B7?= =?UTF-8?q?=E7=AB=AF=E6=92=AD=E6=94=BE=E6=9C=8D=E5=8A=A1=E7=AB=AF=E9=9F=B3?= =?UTF-8?q?=E4=B9=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- common/config.py | 7 +- common/localMusic.py | 362 +++++++++++++++++++++++++++++++++++++++++++ common/utils.py | 6 +- main.py | 58 ++++++- 4 files changed, 427 insertions(+), 6 deletions(-) create mode 100644 common/localMusic.py diff --git a/common/config.py b/common/config.py index 1ff630e..d11dbbe 100644 --- a/common/config.py +++ b/common/config.py @@ -108,6 +108,11 @@ class ConfigReadException(Exception): "mg": ["128k"], } }, + "local_music": { + "desc": "服务器侧本地音乐相关配置,请确保你的带宽足够", + "audio_path": "./audio", + "temp_path": "./temp", + } }, "security": { "rate_limit": { @@ -153,7 +158,7 @@ class ConfigReadException(Exception): "enable": True, "length": 86400 * 7, # 七天 }, - }, + } }, "module": { "kg": { diff --git a/common/localMusic.py b/common/localMusic.py new file mode 100644 index 0000000..a3323b9 --- /dev/null +++ b/common/localMusic.py @@ -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']) + } \ No newline at end of file diff --git a/common/utils.py b/common/utils.py index 1e1e5b2..778e6d4 100644 --- a/common/utils.py +++ b/common/utils.py @@ -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: diff --git a/main.py b/main.py index 12e8ba4..24cb3e9 100644 --- a/main.py +++ b/main.py @@ -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 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_request(request): 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 @@ async def handle_404(request): # 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停止")