Files
2024-05-26 13:25:28 +08:00

631 lines
22 KiB
Python

import uuid
import os
import shutil
from flask import Flask, request, send_from_directory
from dynaconf import FlaskDynaconf
from flask_sqlalchemy import SQLAlchemy
from flask_security import Security, SQLAlchemyUserDatastore, auth_required, hash_password, current_user, login_user, verify_password, roles_accepted, logout_user
from flask_security.models import fsqla_v3 as fsqla
from flask_socketio import SocketIO, join_room, leave_room, emit
from flask_wtf import CSRFProtect
import gettext
import warnings
import signal
import sys
import pickle
from MusicQueue import MusicQueue
from Music import Music
import MusicManager
import Stardust
import LyricFormatter
from Extensions.Netease import NeteaseSession
# from Extensions.QQMusic import QQMusicSession
# from Extensions.Bilibili import BilibiliSession
from Extensions.Provider import SongStatus, AccountType, SessionProvider
app = Fla325-576-113sk("GalaxyMusicHall")
socketio = SocketIO(app, cors_allowed_origins='*')
# 启用配置文件读取
FlaskDynaconf(app, settings_files=[
"instance/config.toml", "instance/.secrets.toml"], environments=True)
# 喵化输出
if app.config["meowlize"]:
os.environ["LANGUAGE"] = "meow"
# 初始化gettext
gettext.install('main', localedir='./locales')
db = SQLAlchemy(app)
fsqla.FsModels.set_db_info(db)
# 初始化扩展会话
ncmSession = NeteaseSession()
class Role(db.Model, fsqla.FsRoleMixin):
pass
class User(db.Model, fsqla.FsUserMixin):
pass
user_datastore = SQLAlchemyUserDatastore(db, User, Role)
CSRFProtect(app)
app.security = Security(app, user_datastore)
queue = MusicQueue()
current = Music()
providerList = [ncmSession]
SOCKETIO_UPDATE_RESPONSE = {"status": 101, "msg": "该API使用SocketIO接口"}, 200
def unauthenticated(mechanisms=None, headers=None):
return {"status": 401, "msg": _("Not authenticated!")}, 401
def denied(mechanisms=None, headers=None):
return {"status": 403, "msg": _("Permission denied!")}, 403
app.security.unauthn_handler(unauthenticated)
app.security.unauthz_handler(denied)
def sigint_handler(signal, frame):
print(_("Quitting..."))
saveSession()
sys.exit(0)
signal.signal(signal.SIGINT, sigint_handler)
@app.route("/")
def test():
return {"status": 200, "msg": _("Hello, this is the API testing page of galaxy music hall."), "debug": app.debug}
@app.route("/ping", methods=["GET"])
def ping():
return {"message": _("Server is running"),
"instanceName": app.config.get("INSTANCE_NAME")}, 200
@app.route("/config", methods=["GET"])
@auth_required()
def getConfig():
return {"allowPlayerNeteaseLogin": app.config.get("ALLOW_PLAYER_NETEASE_LOGIN")}, 200
@app.route("/user/login", methods=["POST"])
def userLogin(): # SocketIO似乎不能写cookies
username = request.form.get("username")
if username is None:
return {"status": 403, "msg": _("Incorrect user or password!")}, 403
password = request.form.get("password")
try:
status = verify_password(
password, user_datastore.find_user(username=username).password)
if status is False:
return {"status": 403, "msg": _("Incorrect user or password!")}, 403
status = login_user(user_datastore.find_user(username=username), remember=True,
authn_via="password")
if status:
return {"status": 200, "msg": _("Login success.")}, 200
else:
return {"status": 400, "msg": _("Login failed, unknown exception.")}, 400
except AttributeError:
return {"status": 403, "msg": _("Incorrect user or password!")}, 403
except Exception as e:
print(e)
return {"status": 500, "msg": _("Login failed, internal server error.")}, 500
@app.route("/user/logout", methods=["POST"])
def userLogout():
try:
logout_user()
return {"status": 200, "msg": _("Logout success.")}
except:
return {"status": 500, "msg": _("Logout failed, internal server error.")}, 500
@socketio.on("userStatus")
def userStatus():
try:
stardust, nextCost = Stardust.smartread(
current_user.username, app.config["INITIAL_STARDUST"], app.config["INITIAL_STARDUST_COST"])
return {"status": current_user.is_authenticated,
"username": current_user.username,
"role": current_user.roles[0].name,
"stardust": stardust,
"nextCost": nextCost}
except Exception as e:
return {"status": False,
"username": None,
"role": None,
"stardust": -1,
"nextCost": 65536}
return SOCKETIO_UPDATE_RESPONSE
@app.route("/music/current", methods=["GET"])
@auth_required()
def getCurrentMusic():
return current.dict(), 200
@socketio.on("nextMusic")
def nextMusic():
global current
try:
current = queue.front()
if current.uuid == "KOBEBRYANT":
return
queue.pop()
updateQueue()
return {"status": 200}, 200
except:
current = Music()
updateQueue()
return {"status": 204, "msg": _("No music in queue.")}, 204
# @app.route("/music/queue", methods=["GET"])
@socketio.on("getQueue") # TODO: 设置未登录用户可见性
def getQueue():
socketio.emit("updateQueue", {
"current": current.dict(), "queue": queue.dict()})
def updateQueue():
emit("updateQueue", {"current": current.dict(),
"queue": queue.dict()}, broadcast=True, namespace="/")
saveSession()
def pushLimitCheck():
if app.config["ENABLE_PUSH"] is False:
return {"status": 403, "msg": _("Push is disabled!")}, 403
username = current_user.username
if not "admin" in current_user.roles:
count = queue.count(Music(owner=username))
if count >= app.config["USER_PUSHLIMIT"]:
return {"status": 420, "msg": _("You have reached the push limit!")}, 420
count = queue.size()
if count >= app.config["TOTAL_PUSHLIMIT"]:
return {"status": 429, "msg": _("The push queue is full!")}, 429
@socketio.on("pushMusic")
@auth_required()
def pushMusic(providerString, content): # TODO: 将下载改为异步API,允许查看进度 TODO: 修改musicURL逻辑
# 推送速率限制
if status := pushLimitCheck():
return status
# 阻止角色为"admin", "user"以外的用户推送
# SocketIO似乎不支持auth_required
if "admin" not in current_user.roles and "user" not in current_user.roles:
return {"status": 403, "msg": _("Permission denied!")}, 403
# 处理音乐提供者
if content is None:
return {"status": 400, "msg": _("Push parameter is empty!")}, 400
provider = MusicManager.judgeProvider(
providerString, content, providerList)
if provider is None:
return {"status": 400, "msg": _("Unknown source, please specify!")}, 400
# 由提供者提供服务
musicID = provider.parseURL(content)
musicStatus = provider.checkSong(musicID)
if musicStatus == SongStatus.SONG_NOTEXIST:
return {"status": 404, "msg": _("The music does not exist!")}, 404
if musicStatus == SongStatus.SONG_NOTAVAIL:
return {"status": 403, "msg": _("The music is not available!")}, 403
if musicStatus == SongStatus.SONG_ISVIP and provider.accountType != AccountType.ACCOUNT_VIP:
return {"status": 412, "msg": _("The music is VIP only, please contact administrator to login VIP account!")}, 403
musicName = provider.getSongName(musicID)
music = Music(owner=current_user.username, uuid=None, name=provider.getSongName(musicID), source=provider.providerName, imageURL=provider.getSongImage(
musicID), musicURL=provider.getSongURL(musicID), lyric=LyricFormatter.lrcFormat(provider.getSongLyric(musicID)))
# 检查是否已存在
if (not app.config["ALLOW_DUPLICATE_PUSH"]) and queue.find(Music(name=musicName)):
return {"status": 201, "msg": _("The music is already in the queue!")}, 201
# 下载歌曲
MusicManager.downloadMusic(music, app.config["CACHE_PATH"])
# 推送至队列
queue.push(music)
if not current.uuid:
nextMusic()
else:
updateQueue()
return {"status": 200, "msg": _("Successfully pushed the music.")}, 200
@socketio.on("deleteMusic")
@auth_required()
def deleteMusic(musicUUID):
# musicUUID = request.form.get("uuid")
if musicUUID == "KOBEBRYANT":
return {"status": 400, "msg": "Manba out!"}
music = queue.find(Music(uuid=musicUUID))
if not "admin" in current_user.roles:
# 用户删除部分
if music is None:
return {"status": 404, "msg": _("The music is not in the queue!")}, 404
if music.owner != current_user.username:
return {"status": 403, "msg": _("You are not the owner of the music!")}, 403
queue.delete(music)
updateQueue()
return {"status": 200, "msg": _("Successfully deleted the music.")}, 200
else:
# 管理员删除部分
if music is None:
return {"status": 404, "msg": _("The music is not in the queue!")}, 404
queue.delete(music)
updateQueue()
return {"status": 200, "msg": _("Successfully deleted the music.")}, 200
@socketio.on("elevateMusic")
@auth_required()
def elevateMusic(musicUUID):
# musicUUID = request.form.get("uuid")
music = queue.find(Music(uuid=musicUUID))
if musicUUID == "KOBEBRYANT":
return {"status": 400, "msg": "What can I say?"}
if not "admin" in current_user.roles:
# 用户提升部分
if music is None:
return {"status": 404, "msg": _("The music is not in the queue!")}, 404
if music.owner != current_user.username:
return {"status": 403, "msg": _("You are not the owner of the music!")}, 403
stardust, nextCost = Stardust.smartread(
current_user.username, app.config["INITIAL_STARDUST"], app.config["INITIAL_STARDUST_COST"])
if stardust < nextCost:
return {"status": 403, "msg": _("You do not have enough stardust to elevate the music!")}, 403
queue.elevate(music)
Stardust.update(current_user.username, stardust-nextCost,
nextCost+app.config["STARDUST_COST_INCREASE"])
updateQueue()
emit("updateUserStatus", userStatus())
return {"status": 200, "msg": _("Successfully elevated the music.")}, 200
else:
# 管理员提升部分
if music is None:
return {"status": 404, "msg": _("The music is not in the queue!")}, 404
queue.elevate(music)
updateQueue()
return {"status": 200, "msg": _("Successfully elevated the music.")}, 200
@app.route("/play/<url>")
@auth_required()
@roles_accepted("player")
def playMusic(url):
for i in app.config["ALLOWED_UPLOAD_MUSIC"]:
try:
os.stat(app.config["CACHE_PATH"]+"/"+url+"."+i)
return send_from_directory(app.config["CACHE_PATH"], url + "." + i)
except Exception as e:
pass
return {"data": None}
@app.route("/image/<url>")
@auth_required()
def getImage(url):
for i in ("jpg", "png"):
try:
os.stat(app.config["CACHE_PATH"]+"/"+url+"."+i)
return send_from_directory(app.config["CACHE_PATH"], url + "." + i)
except Exception as e:
print(e)
pass
return "No such file or directory!", 404
@app.route("/lyric/<url>")
@auth_required()
def getLyric(url):
return send_from_directory(app.config["CACHE_PATH"], url+".lrc")
@app.route("/download")
def downloadMusic():
url = request.args.get("uuid")
if url == "flag":
return "用户拿flag,用户坏!", 400
for i in app.config["ALLOWED_UPLOAD_MUSIC"]:
try:
os.stat(app.config["CACHE_PATH"] + url + "." + i)
return send_from_directory(app.config["CACHE_PATH"], url + "." + i)
except:
pass
path = app.config["CACHE_PATH"] + "/" + url
try:
with open(path, "r", encoding="utf-8") as f:
data = f.read()
if "flag" in data:
return "flag{****REDACTED****}"
else:
return data
except Exception as e:
print(e)
return "无法下载此音乐!", 400
@socketio.on("adminResetQueue")
@auth_required()
def adminResetQueue():
global current
return {"status": 403, "msg": "已禁用此功能!"}, 403
if not "admin" in current_user.roles:
return {"status": 403, "msg": _("Permission denied!")}, 403
queue.clear()
current = Music()
updateQueue()
return {"status": 200, "msg": _("Reset success!")}
@app.route("/upload", methods=["GET", "POST"])
@roles_accepted("user", "admin")
def uploadMusic(): # TODO: 修改musicURL逻辑
# 推送速率限制
if status := pushLimitCheck():
return status
# 上传处理
file = request.files.get("file")
if file is None or file.filename == "":
# TODO: 空文件判断
return
if ".php" in file.filename:
return {"status": 200, "msg": "我觉得银河音乐厅不是个php应用..."}
# 是音乐文件
if file.filename.endswith(tuple(app.config["ALLOWED_UPLOAD_MUSIC"])):
suffix = file.filename.split(".")[-1]
if queue.find(Music(name=file.filename)):
return {"status": 201, "msg": _("The music is already in the queue!")}, 201
musicUUID = str(uuid.uuid4())
uploadFile = os.path.join(
app.config["CACHE_PATH"], musicUUID+"."+suffix)
uploadAlbum = app.config["UPLOAD_MUSIC_IMAGE"]
try:
file.save(uploadFile)
except Exception as e:
return {"status": 500, "msg": _("Failed to save the file!")}, 500
try:
album = MusicManager.imagePayload(uploadFile)
if album is not None:
albumFileName = os.path.join(
app.config["CACHE_PATH"], musicUUID+".jpg")
with open(albumFileName, "wb") as albumFile:
albumFile.write(album)
uploadAlbum = "/api/image/" + musicUUID
except Exception as e:
print(e)
warnings.warn(_("Failed to read the album image!"))
music = Music(owner=current_user.username, uuid=musicUUID, name=file.filename, source="Upload",
imageURL=uploadAlbum, musicURL=uploadFile, lyric=LyricFormatter.lrcFormat(None))
# TODO: 为上传的音乐添加歌词
queue.push(music)
if not current.uuid:
nextMusic()
else:
updateQueue()
return {"status": 200, "msg": _("Successfully uploaded the music!")}, 200
# 是歌词文件
elif file.filename.endswith(tuple(app.config["ALLOWED_UPLOAD_LYRIC"])):
return {"status": 501, "msg": _("Lyric file is not supported yet!")}, 501
else:
return {"status": 400, "msg": _("Invalid file format!"), "allowed_format": app.config["ALLOWED_UPLOAD_MUSIC"]}, 400
@socketio.on("getBanner")
def getBanner():
return {"data": open(app.config["CACHE_PATH"]+"/banner.md").read()}
@socketio.on("adminPushMessage")
def broadcastMessage(message: str):
emit("broadcastMessage", {"title": "来自服务器的消息", "content": message,
"duration": 5000}, broadcast=True, namespace="/")
def loadBanner(reload=0, bannerfile=app.config["BANNER_FILE"]):
regenerateUUID()
try:
with open(bannerfile, encoding="utf-8") as bannerfile:
bannerContent = bannerfile.read()
bannerContent = str.replace(
bannerContent, r"{{instanceName}}", "InstanceName: "+app.config["INSTANCE_NAME"])
bannerContent = str.replace(
bannerContent, r"{{instanceUUID}}", "InstanceUUID: "+app.config["INSTANCE_UUID"])
with open(app.config["CACHE_PATH"]+"/banner.md", "w", encoding="utf-8") as banner:
banner.write(bannerContent)
if reload:
emit('updateBanner', getBanner(), broadcast=True)
return {"status": 200, "msg": _("Successfully reloaded the banner!")}, 200
except Exception as e:
print(e)
return {"status": 500, "msg": _("Failed to reload the banner!")}, 500
@socketio.on("adminUpdateBanner")
@app.route("/admin/updateBanner", methods=["POST"])
@roles_accepted("admin")
def updateBanner():
file = request.files.get("file")
file.save(app.config["CACHE_PATH"]+"/new_banner.md")
return loadBanner(reload=0, bannerfile=app.config["CACHE_PATH"]+"/new_banner.md")
@socketio.on("adminResetBanner")
def resetBanner():
return loadBanner(reload=0)
@socketio.on("adminDisablePush")
def disablePush():
if not "admin" in current_user.roles:
return {"status": 403, "msg": _("Permission denied!")}, 403
if app.config["ENABLE_PUSH"]:
app.config["ENABLE_PUSH"] = False
emit("updatePushStatus",
(app.config["ENABLE_PUSH"], 1), broadcast=True, namespace="/")
return {"status": 200, "msg": _("Push disabled.")}
else:
app.config["ENABLE_PUSH"] = True
emit("updatePushStatus",
(app.config["ENABLE_PUSH"], 1), broadcast=True, namespace="/")
return {"status": 200, "msg": _("Push enabled.")}
@socketio.on("extCredential")
def extCredential(providerString: str, content: str, key: str):
provider = MusicManager.judgeProvider(providerString, "", providerList)
if not provider:
return {"status": 400, "msg": _("Invalid provider!")}, 400
return provider.loginCredential(content, key)
@app.route("/ext/login", methods=["POST"])
@socketio.on("extLogin")
def extLogin(providerString: str) -> dict:
provider = MusicManager.judgeProvider(providerString, "", providerList)
if not provider:
return {"status": 400, "msg": _("Invalid provider!")}, 400
status = provider.login()
updateExtStatus()
return status
@socketio.on("extAnonymousLogin")
def extAnonymousLogin(providerString: str) -> dict:
provider = MusicManager.judgeProvider(providerString, "", providerList)
if not provider:
return {"status": 400, "msg": _("Invalid provider!")}, 400
status = provider.loginAnonymous()
updateExtStatus()
return status
@app.route("/ext/qrcode", methods=["GET"])
@socketio.on("extQRCode")
def getExtQRCode(providerString: str) -> dict:
provider = MusicManager.judgeProvider(providerString, "", providerList)
if not provider:
return {"status": 400, "msg": _("Invalid provider!")}, 400
qrcode = provider.qrcode()
return {"status": 200, "url": qrcode}
def getExtStatus(provider: SessionProvider):
if not provider:
return {"status": 400, "msg": _("Invalid provider!")}, 400
return provider.userStatus()
def updateExtStatus():
for i in providerList:
print(i.providerName, getExtStatus(i))
emit("updateExtStatus", (i.providerName, getExtStatus(i)))
@socketio.on("init")
def clientInit():
emit("updateBanner", getBanner())
emit("updatePushStatus", (app.config["ENABLE_PUSH"], 0))
if "admin" in current_user.roles:
updateExtStatus()
def regenerateUUID():
app.config["INSTANCE_UUID"] = str(uuid.uuid1())
def init():
with app.app_context():
# # 若不存在数据库就创建
print(_("Creating database if not present..."))
db.create_all()
db.session.commit()
regenerateUUID()
try:
if app.config["PRESERVE_CACHE"]:
os.stat(app.config["CACHE_PATH"])
else:
shutil.rmtree(app.config["CACHE_PATH"])
os.mkdir(app.config["CACHE_PATH"])
except FileNotFoundError:
os.mkdir(app.config["CACHE_PATH"])
except PermissionError:
raise Exception(_("Permission denied to access cache directory!"))
except:
raise Exception(_("Failed to rebuild the cache directory!"))
os.system("pwd")
shutil.copy("./resource/flag.lrc", app.config["CACHE_PATH"])
shutil.copy("./resource/flag.wav", app.config["CACHE_PATH"])
shutil.copy("./resource/flag.png", app.config["CACHE_PATH"])
shutil.copy("./resource/KOBEBRYANT.jpg", app.config["CACHE_PATH"])
if loadBanner(reload=0)[1] != 200:
raise Exception(_("Failed to reload the banner!"))
if app.config["SAVE_SESSION"]:
if app.config["PRESERVE_CACHE"]:
try:
for i in providerList:
with open(app.config["CACHE_PATH"]+"/"+i.providerName+".session", "r", encoding="utf-8") as sessionFile:
i.restoreSession(sessionFile.read())
with open(app.config["CACHE_PATH"]+"/queue.pickle", "rb") as queueFile:
queue.load(queueFile.read())
with open(app.config["CACHE_PATH"]+"/current.pickle", "rb") as currentFile:
current = pickle.loads(currentFile.read())
except FileNotFoundError:
warnings.warn(_("No session found, skipping..."))
except Exception as e:
print(e)
raise Exception(_("Failed to restore the session!"))
else:
warnings.warn(
_("SAVE_SESSION doesn't work without PRESERVE_CACHE!"))
print(_("Successfully initialized the application!"))
def saveSession():
if app.config["SAVE_SESSION"]:
if not app.config["PRESERVE_CACHE"]:
warnings.warn(
_("SAVE_SESSION doesn't work without PRESERVE_CACHE!"))
try:
# 保存音乐提供者session
for i in providerList:
sessionText = i.dumpSession()
with open(app.config["CACHE_PATH"]+"/"+i.providerName+".session", "w", encoding="utf-8") as sessionFile:
sessionFile.write(sessionText)
# 保存歌曲队列
with open(app.config["CACHE_PATH"]+"/queue.pickle", "wb") as queueFile:
queueFile.write(queue.dump())
# 保存当前播放
with open(app.config["CACHE_PATH"]+"/current.pickle", "wb") as currentFile:
currentFile.write(pickle.dumps(current))
except Exception as e:
print(e)
warnings.warn(_("Failed to save the session, operation aborted."))
init()
if __name__ == "__main__":
socketio.run(app, debug=app.config["DEBUG"],
host=app.config["HOST"], port=app.config["PORT"])