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/") @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/") @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/") @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"])