初始化提交
This commit is contained in:
@@ -0,0 +1,8 @@
|
|||||||
|
result
|
||||||
|
test*.*
|
||||||
|
# Ignore dynaconf secret files
|
||||||
|
.secrets.*
|
||||||
|
pretrained_models
|
||||||
|
2stems*.tar*
|
||||||
|
__pycache__
|
||||||
|
|
||||||
+19
@@ -0,0 +1,19 @@
|
|||||||
|
FROM archlinux:latest
|
||||||
|
|
||||||
|
# set mirror
|
||||||
|
RUN echo "Server = https://mirrors.tuna.tsinghua.edu.cn/archlinux/\$repo/os/\$arch" > /etc/pacman.d/mirrorlist
|
||||||
|
RUN pacman -Syu --noconfirm ffmpeg && yes | pacman -Scc
|
||||||
|
# copy project files
|
||||||
|
COPY . /app
|
||||||
|
WORKDIR /app
|
||||||
|
RUN pacman -U python39-3.9.19-1-x86_64.pkg.tar.zst --noconfirm
|
||||||
|
RUN python3.9 pipx.pyz --global install poetry -i https://pypi.tuna.tsinghua.edu.cn/simple \
|
||||||
|
&& rm -rf /root/.cache \
|
||||||
|
&& rm -rf /root/.local
|
||||||
|
RUN useradd -m -s /bin/bash gmh_convert && chown -R gmh_convert:gmh_convert /app
|
||||||
|
# install dependencies
|
||||||
|
USER gmh_convert
|
||||||
|
RUN POETRY_PYPI_MIRROR_URL=https://pypi.tuna.tsinghua.edu.cn/simple poetry install --no-cache
|
||||||
|
# run the app
|
||||||
|
EXPOSE 8000
|
||||||
|
CMD ENV_FOR_DYNACONF=production poetry run python main.py run
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# GalaxyMusicHall_Convert
|
||||||
|
|
||||||
|
[中文版](./README_zh.md)
|
||||||
|
|
||||||
|
# Introduction
|
||||||
|
|
||||||
|
A backend plugin for GalaxyMusicHall, which provides file converting and vocal reducing capability.
|
||||||
|
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
# 银河音乐厅V2_转换后端
|
||||||
|
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "下载spleeter预训练模型..."
|
||||||
|
if [ -e pretrained_models/2stems/checkpoint ]
|
||||||
|
echo "模型已存在,跳过下载..."
|
||||||
|
else
|
||||||
|
curl -LO https://github.com/deezer/spleeter/releases/download/v1.4.0/2stems-finetune.tar.gz
|
||||||
|
tar -xf 2stems-finetune.tar.gz
|
||||||
|
mkdir pretrained_models
|
||||||
|
mv 2stems-finetune/ pretrained_models/2stems
|
||||||
|
echo "模型下载完成"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "检查python3.9..."
|
||||||
|
if [-e python3.9*.tar*]
|
||||||
|
echo "python3.9已存在,跳过下载..."
|
||||||
|
else
|
||||||
|
echo "找不到python3.9,请手动构建python3.9的archlinux包!"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "检查pipx.npz..."
|
||||||
|
if [-e pipx.npz]
|
||||||
|
echo "pipx已存在,跳过下载..."
|
||||||
|
else
|
||||||
|
curl -LO https://github.com/pypa/pipx/releases/download/1.5.0/pipx.pyz
|
||||||
|
echo "pipx下载完成"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "构建docker镜像..."
|
||||||
|
docker build -t galaxymusichallv2_convert .
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
|
||||||
|
from dynaconf import Dynaconf
|
||||||
|
|
||||||
|
settings = Dynaconf(
|
||||||
|
envvar_prefix="GMHCONVERT",
|
||||||
|
settings_files=['settings.toml', '.secrets.toml'],
|
||||||
|
environments=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# `envvar_prefix` = export envvars with `export DYNACONF_FOO=bar`.
|
||||||
|
# `settings_files` = Load these files in the order.
|
||||||
@@ -0,0 +1,322 @@
|
|||||||
|
from typing import Annotated, Union
|
||||||
|
from fastapi import FastAPI, Header, status
|
||||||
|
from fastapi.responses import Response
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from enum import Enum
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
import jwt
|
||||||
|
import json
|
||||||
|
import base64
|
||||||
|
import click
|
||||||
|
import ffmpeg
|
||||||
|
import shutil
|
||||||
|
import logging
|
||||||
|
import uvicorn
|
||||||
|
import uuid
|
||||||
|
from config import settings
|
||||||
|
os.environ["TF_CPP_MIN_LOG_LEVEL"] = settings["TF_CPP_MIN_LOG_LEVEL"]
|
||||||
|
from spleeter.separator import Separator
|
||||||
|
|
||||||
|
|
||||||
|
class Payload(BaseModel):
|
||||||
|
filename: str
|
||||||
|
data: str # base64 encoded
|
||||||
|
actions: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
class TokenResponse(Enum):
|
||||||
|
TOKEN_OK = 0
|
||||||
|
TOKEN_SCHEME_ERROR = 1
|
||||||
|
TOKEN_EXPIRED = 2
|
||||||
|
TOKEN_INVALID = 3
|
||||||
|
TOKEN_DECODE_ERROR = 4
|
||||||
|
TOKEN_USED = 5
|
||||||
|
|
||||||
|
class ProcessStatus():
|
||||||
|
uuid = ""
|
||||||
|
name = ""
|
||||||
|
status = 0
|
||||||
|
total = 0
|
||||||
|
tempfiles = []
|
||||||
|
|
||||||
|
def __init__(self, uuid, name, total):
|
||||||
|
self.uuid = uuid
|
||||||
|
self.name = name
|
||||||
|
self.status = 0
|
||||||
|
self.total = total
|
||||||
|
|
||||||
|
def update(self):
|
||||||
|
self.status += 1
|
||||||
|
|
||||||
|
def update_temp_files(self, tempfile):
|
||||||
|
self.tempfiles.append(tempfile)
|
||||||
|
|
||||||
|
def is_done(self):
|
||||||
|
return self.status == self.total
|
||||||
|
|
||||||
|
def get_status(self):
|
||||||
|
return self.status, self.total
|
||||||
|
|
||||||
|
def __eq__(self, other):
|
||||||
|
return self.uuid == other.uuid or self.name == other.name
|
||||||
|
|
||||||
|
def __dict__(self):
|
||||||
|
return {"uuid": self.uuid, "name": self.name, "status": self.status, "total": self.total, "tempfiles": self.tempfiles}
|
||||||
|
|
||||||
|
|
||||||
|
queue_status = []
|
||||||
|
|
||||||
|
tempfiles = []
|
||||||
|
|
||||||
|
app = FastAPI(docs_url=None, redoc_url=None)
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"typ": "JWT",
|
||||||
|
"alg": "HS256"
|
||||||
|
}
|
||||||
|
|
||||||
|
usedJTI = set() # Avoid replay attacks
|
||||||
|
separator = Separator("spleeter:2stems")
|
||||||
|
separator_lock = False
|
||||||
|
LOGFORMAT = "[%(asctime)s] [%(levelname)s] <%(threadName)s> %(message)s"
|
||||||
|
logging.basicConfig(format=LOGFORMAT, level=settings.LOG_LEVEL)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_token(auth):
|
||||||
|
scheme = auth.split()[0]
|
||||||
|
if scheme != "Bearer":
|
||||||
|
return TokenResponse.TOKEN_SCHEME_ERROR
|
||||||
|
token = auth.split()[1]
|
||||||
|
try:
|
||||||
|
result = jwt.decode(token,
|
||||||
|
settings["JWT_SECRET"],
|
||||||
|
verify=True,
|
||||||
|
algorithms=["HS256"],
|
||||||
|
audience="galaxymusichall.convert")
|
||||||
|
if result["jti"] in usedJTI:
|
||||||
|
return TokenResponse.TOKEN_USED
|
||||||
|
usedJTI.add(result["jti"])
|
||||||
|
return TokenResponse.TOKEN_OK
|
||||||
|
except jwt.ExpiredSignatureError:
|
||||||
|
return TokenResponse.TOKEN_EXPIRED
|
||||||
|
except jwt.InvalidTokenError:
|
||||||
|
return TokenResponse.TOKEN_INVALID
|
||||||
|
except jwt.DecodeError:
|
||||||
|
return TokenResponse.TOKEN_DECODE_ERROR
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/clear")
|
||||||
|
def clear(authorization: Annotated[Union[str, None], Header()],
|
||||||
|
response: Response):
|
||||||
|
if verify := verify_token(authorization):
|
||||||
|
if verify == TokenResponse.TOKEN_OK:
|
||||||
|
pass
|
||||||
|
elif verify == TokenResponse.TOKEN_SCHEME_ERROR:
|
||||||
|
response.status_code = status.HTTP_400_BAD_REQUEST
|
||||||
|
return {"error": "Invalid authorization header, 'bearer' required"}
|
||||||
|
elif verify == TokenResponse.TOKEN_EXPIRED:
|
||||||
|
response.status_code = status.HTTP_403_FORBIDDEN
|
||||||
|
return {"error": "Token has expired"}
|
||||||
|
elif verify == TokenResponse.TOKEN_INVALID:
|
||||||
|
response.status_code = status.HTTP_403_FORBIDDEN
|
||||||
|
return {"error": "Invalid token"}
|
||||||
|
elif verify == TokenResponse.TOKEN_DECODE_ERROR:
|
||||||
|
response.status_code = status.HTTP_400_BAD_REQUEST
|
||||||
|
return {"error": "Token decoding failed"}
|
||||||
|
elif verify == TokenResponse.TOKEN_USED:
|
||||||
|
response.status_code = status.HTTP_403_FORBIDDEN
|
||||||
|
return {"error": "Token has been used"}
|
||||||
|
else:
|
||||||
|
response.status_code = status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||||
|
return {"error": "Uncaught error"}
|
||||||
|
for i in tempfiles:
|
||||||
|
try:
|
||||||
|
os.remove(i)
|
||||||
|
logging.info("删除文件: "+i)
|
||||||
|
except FileNotFoundError:
|
||||||
|
logging.warning("文件已被删除: "+i)
|
||||||
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
logging.error("删除文件失败: "+str(e))
|
||||||
|
response.status_code = status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||||
|
return {"error": "Failed to clear temporary files"}
|
||||||
|
tempfiles.clear()
|
||||||
|
return {"message": "Temporary files cleared"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/status")
|
||||||
|
def status(authorization: Annotated[Union[str, None], Header()]):
|
||||||
|
if verify := verify_token(authorization):
|
||||||
|
if verify == TokenResponse.TOKEN_OK:
|
||||||
|
pass
|
||||||
|
elif verify == TokenResponse.TOKEN_SCHEME_ERROR:
|
||||||
|
response.status_code = status.HTTP_400_BAD_REQUEST
|
||||||
|
return {"error": "Invalid authorization header, 'bearer' required"}
|
||||||
|
elif verify == TokenResponse.TOKEN_EXPIRED:
|
||||||
|
response.status_code = status.HTTP_403_FORBIDDEN
|
||||||
|
return {"error": "Token has expired"}
|
||||||
|
elif verify == TokenResponse.TOKEN_INVALID:
|
||||||
|
response.status_code = status.HTTP_403_FORBIDDEN
|
||||||
|
return {"error": "Invalid token"}
|
||||||
|
elif verify == TokenResponse.TOKEN_DECODE_ERROR:
|
||||||
|
response.status_code = status.HTTP_400_BAD_REQUEST
|
||||||
|
return {"error": "Token decoding failed"}
|
||||||
|
elif verify == TokenResponse.TOKEN_USED:
|
||||||
|
response.status_code = status.HTTP_403_FORBIDDEN
|
||||||
|
return {"error": "Token has been used"}
|
||||||
|
else:
|
||||||
|
response.status_code = status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||||
|
return {"error": "Uncaught error"}
|
||||||
|
return json.dumps([i.__dict__() for i in queue_status])
|
||||||
|
|
||||||
|
@app.post("/process")
|
||||||
|
def process(data: Payload,
|
||||||
|
authorization: Annotated[Union[str, None], Header()],
|
||||||
|
response: Response):
|
||||||
|
global separator_lock
|
||||||
|
#print(data.filename, data.actions, authorization)
|
||||||
|
logging.info(f"处理文件{data.filename},操作{data.actions}")
|
||||||
|
processUUID = str(uuid.uuid4())
|
||||||
|
process_status = ProcessStatus(uuid=processUUID, name=data.filename, total=len(data.actions))
|
||||||
|
queue_status.append(process_status)
|
||||||
|
if verify := verify_token(authorization):
|
||||||
|
if verify == TokenResponse.TOKEN_OK:
|
||||||
|
pass
|
||||||
|
elif verify == TokenResponse.TOKEN_SCHEME_ERROR:
|
||||||
|
response.status_code = status.HTTP_400_BAD_REQUEST
|
||||||
|
return {"error": "Invalid authorization header, 'bearer' required"}
|
||||||
|
elif verify == TokenResponse.TOKEN_EXPIRED:
|
||||||
|
response.status_code = status.HTTP_403_FORBIDDEN
|
||||||
|
return {"error": "Token has expired"}
|
||||||
|
elif verify == TokenResponse.TOKEN_INVALID:
|
||||||
|
response.status_code = status.HTTP_403_FORBIDDEN
|
||||||
|
return {"error": "Invalid token"}
|
||||||
|
elif verify == TokenResponse.TOKEN_DECODE_ERROR:
|
||||||
|
response.status_code = status.HTTP_400_BAD_REQUEST
|
||||||
|
return {"error": "Token decoding failed"}
|
||||||
|
elif verify == TokenResponse.TOKEN_USED:
|
||||||
|
response.status_code = status.HTTP_403_FORBIDDEN
|
||||||
|
return {"error": "Token has been used"}
|
||||||
|
else:
|
||||||
|
response.status_code = status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||||
|
return {"error": "Uncaught error"}
|
||||||
|
tempfilename = "/tmp/"+data.filename
|
||||||
|
try:
|
||||||
|
with open(tempfilename, "wb") as wrfile:
|
||||||
|
wrfile.write(base64.b64decode(data.data))
|
||||||
|
except FileExistsError:
|
||||||
|
response.status_code = status.HTTP_409_CONFLICT
|
||||||
|
return {"error": "File already exists"}
|
||||||
|
except Exception as e:
|
||||||
|
response.status_code = status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||||
|
logging.error("Unexpected exception:",e)
|
||||||
|
return {"error": "Uncaught error"}
|
||||||
|
current_name = tempfilename
|
||||||
|
process_status.update_temp_files(tempfilename)
|
||||||
|
basename = tempfilename[:tempfilename.rfind(".")]
|
||||||
|
try:
|
||||||
|
for i in data.actions:
|
||||||
|
logging.info(f"正在处理{process_status.get_status()}/{process_status.total}:{i}")
|
||||||
|
if i == "separate":
|
||||||
|
# TODO:性能优化,并发
|
||||||
|
while separator_lock:
|
||||||
|
logging.info("等待上一Spleeter任务完成...")
|
||||||
|
time.sleep(2)
|
||||||
|
separator_lock = True
|
||||||
|
current_name = separate(current_name, basename)
|
||||||
|
process_status.update_temp_files(basename+"/vocals.wav")
|
||||||
|
process_status.update_temp_files(current_name)
|
||||||
|
separator_lock = False
|
||||||
|
elif i == "audio":
|
||||||
|
current_name = audio(current_name, basename, target="aac")
|
||||||
|
process_status.update_temp_files(current_name)
|
||||||
|
elif "convert" in i:
|
||||||
|
if "->" not in i:
|
||||||
|
response.status_code = status.HTTP_400_BAD_REQUEST
|
||||||
|
return {"error": "操作格式错误", "hint": "使用'->'指定目标格式"}
|
||||||
|
current_name = convert(current_name, basename, target=i.split("->")[1])
|
||||||
|
process_status.update_temp_files(current_name)
|
||||||
|
else:
|
||||||
|
logging.warning("无效操作"+i+",跳过中...")
|
||||||
|
pass
|
||||||
|
process_status.update()
|
||||||
|
if current_name is None:
|
||||||
|
response.status_code = status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||||
|
return {"error": "处理失败", "status": process_status.get_status(), "required": process_status.total}
|
||||||
|
data = {"filename": current_name[current_name.rfind('/') + 1:],
|
||||||
|
"data": base64.b64encode(open(current_name, "rb").read()).decode()}
|
||||||
|
if process_status.is_done():
|
||||||
|
tempfiles.extend(process_status.tempfiles)
|
||||||
|
queue_status.remove(process_status)
|
||||||
|
else:
|
||||||
|
response.status_code = status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||||
|
return {"error": "未预期的错误:流水线在完成前就退出", "status": process_status.get_status(), "required": process_status.total}
|
||||||
|
return json.dumps(data)
|
||||||
|
except Exception as e:
|
||||||
|
logging.error("未预期的错误:" + str(e))
|
||||||
|
response.status_code = status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||||
|
return {"error": "Processing failed"}
|
||||||
|
|
||||||
|
|
||||||
|
def separate(filename, basename):
|
||||||
|
separator.separate_to_file(filename, "/tmp")
|
||||||
|
shutil.move(basename + "/accompaniment.wav", basename + ".wav")
|
||||||
|
return basename + ".wav"
|
||||||
|
|
||||||
|
|
||||||
|
def audio(filename, basename, target="aac"):
|
||||||
|
audio = ffmpeg.input(filename).audio
|
||||||
|
out = ffmpeg.output(audio, basename+"."+target)
|
||||||
|
out.overwrite_output().run()
|
||||||
|
return basename+"."+target
|
||||||
|
|
||||||
|
|
||||||
|
def convert(filename, basename, target="wav"):
|
||||||
|
audio = ffmpeg.input(filename)
|
||||||
|
out = ffmpeg.output(audio, basename+"."+target)
|
||||||
|
out.overwrite_output().run()
|
||||||
|
return basename+"."+target
|
||||||
|
|
||||||
|
|
||||||
|
@click.group()
|
||||||
|
def cli():
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@cli.command()
|
||||||
|
def secret():
|
||||||
|
"""显示JWT密钥"""
|
||||||
|
click.echo(settings["JWT_SECRET"])
|
||||||
|
|
||||||
|
|
||||||
|
@cli.command()
|
||||||
|
@click.argument("token")
|
||||||
|
def verify(token):
|
||||||
|
"""验证JWT token"""
|
||||||
|
try:
|
||||||
|
result = jwt.decode(token,
|
||||||
|
settings["JWT_SECRET"],
|
||||||
|
verify=True,
|
||||||
|
algorithms=["HS256"],
|
||||||
|
audience="galaxymusichall.convert")
|
||||||
|
click.echo(result)
|
||||||
|
except jwt.DecodeError as e:
|
||||||
|
click.echo("JWT token解码失败"+str(e))
|
||||||
|
except jwt.ExpiredSignatureError:
|
||||||
|
click.echo("token已过期。")
|
||||||
|
except jwt.InvalidTokenError:
|
||||||
|
click.echo("token无效。")
|
||||||
|
except Exception as e:
|
||||||
|
click.echo("未知错误:")
|
||||||
|
click.echo(e)
|
||||||
|
|
||||||
|
|
||||||
|
@cli.command()
|
||||||
|
def run():
|
||||||
|
"""启动转换服务器"""
|
||||||
|
uvicorn.run(app, host=settings.HOST, port=settings.PORT)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
cli()
|
||||||
Generated
+1899
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,26 @@
|
|||||||
|
[tool.poetry]
|
||||||
|
name = "galaxymusichallv2-convert"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = ""
|
||||||
|
authors = ["CattySteve <cattysteve89265@163.com>"]
|
||||||
|
readme = "README.md"
|
||||||
|
|
||||||
|
[tool.poetry.dependencies]
|
||||||
|
python = ">=3.9,<3.11"
|
||||||
|
|
||||||
|
|
||||||
|
spleeter = "^2.4.0"
|
||||||
|
fastapi = "0.99.0"
|
||||||
|
dynaconf = "^3.2.5"
|
||||||
|
click = "7.1.2"
|
||||||
|
uvicorn = "^0.29.0"
|
||||||
|
pyjwt = "^2.8.0"
|
||||||
|
python-multipart = "^0.0.9"
|
||||||
|
[build-system]
|
||||||
|
requires = ["poetry-core"]
|
||||||
|
build-backend = "poetry.core.masonry.api"
|
||||||
|
|
||||||
|
[[tool.poetry.source]]
|
||||||
|
name = "tsinghua"
|
||||||
|
url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple/"
|
||||||
|
priority = "primary"
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
[default]
|
||||||
|
JWT_AUDIENCE = "galaxymusichall.convert"
|
||||||
|
TF_CPP_MIN_LOG_LEVEL= "2"
|
||||||
|
|
||||||
|
[development]
|
||||||
|
HOST = "127.0.0.1"
|
||||||
|
PORT = 8000
|
||||||
|
LOG_LEVEL = 10
|
||||||
|
|
||||||
|
[production]
|
||||||
|
HOST = "0.0.0.0"
|
||||||
|
PORT = 8000
|
||||||
|
LOG_LEVEL = 30
|
||||||
Reference in New Issue
Block a user