From 07e5009a4fe68b6fdf8128240a543c5fade0b514 Mon Sep 17 00:00:00 2001 From: CattySteve Date: Sun, 26 May 2024 13:25:28 +0800 Subject: [PATCH] =?UTF-8?q?=E5=88=9D=E5=A7=8B=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + Dockerfile | 15 + Extensions/Bilibili.py | 62 +++ Extensions/Netease.py | 160 ++++++++ Extensions/Provider.py | 65 +++ Extensions/QQMusic.py | 99 +++++ LyricFormatter.py | 33 ++ MusicManager.py | 61 +++ MusicQueue.py | 64 +++ Stardust.py | 18 + UserImport.py | 53 +++ instance/config.toml | 75 ++++ instance/data.db | Bin 0 -> 40960 bytes locales/meow/LC_MESSAGES/main.mo | Bin 0 -> 3359 bytes locales/meow/LC_MESSAGES/main.po | 159 ++++++++ locales/zh_CN/LC_MESSAGES/main.mo | Bin 0 -> 3233 bytes locales/zh_CN/LC_MESSAGES/main.po | 158 ++++++++ main.py | 630 ++++++++++++++++++++++++++++++ requirements_deployment.txt | 16 + 19 files changed, 1669 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 Extensions/Bilibili.py create mode 100644 Extensions/Netease.py create mode 100644 Extensions/Provider.py create mode 100644 Extensions/QQMusic.py create mode 100644 LyricFormatter.py create mode 100644 MusicManager.py create mode 100644 MusicQueue.py create mode 100644 Stardust.py create mode 100644 UserImport.py create mode 100644 instance/config.toml create mode 100644 instance/data.db create mode 100644 locales/meow/LC_MESSAGES/main.mo create mode 100644 locales/meow/LC_MESSAGES/main.po create mode 100644 locales/zh_CN/LC_MESSAGES/main.mo create mode 100644 locales/zh_CN/LC_MESSAGES/main.po create mode 100644 main.py create mode 100644 requirements_deployment.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bee8a64 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +__pycache__ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..54b2a33 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,15 @@ +FROM alpine:3.18 +MAINTAINER CattySteve cattysteve89265@163.com + + +RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.ustc.edu.cn/g' /etc/apk/repositories +RUN apk add python3 py3-pip gcc g++ python3-dev curl uuidgen +RUN echo flag{$(uuidgen)} > /flag +RUN pip config set global.index-url https://pypi.tuna.tsinghua.edu.cn/simple +COPY . /opt/galaxy +WORKDIR /opt/galaxy +RUN pip install -r ./requirements_deployment.txt +EXPOSE 5001 +CMD cd /opt/galaxy && python main.py +HEALTHCHECK --interval=30s --timeout=30s --start-period=30s \ + CMD curl http://localhost:5001 > /dev/null diff --git a/Extensions/Bilibili.py b/Extensions/Bilibili.py new file mode 100644 index 0000000..b3200aa --- /dev/null +++ b/Extensions/Bilibili.py @@ -0,0 +1,62 @@ +try: + from Provider import SessionProvider, SongStatus, AccountType +except: + from Extensions.Provider import SessionProvider, SongStatus, AccountType +import httpx +from functools import lru_cache +import re +from enum import Enum +from bilibili_api import user, login_func, sync, login, Credential +from pyzbar import pyzbar +from PIL import Image +import qrcode +import time + +class BilibiliSession(SessionProvider): + sessionKey = None + session = None + providerName = "Bilibili" + URLKeyword = ["b23.tv", "bilibili.com", "bilibili.tv"] + accountType = AccountType.ACCOUNT_NOLOGIN + + def login(self) -> dict: + status, self.session = login_func.check_qrcode_events(self.sessionKey) + print(status) + if status == login_func.QrCodeLoginEvents.SCAN: + return {"status": "400", "msg": "请扫描二维码"}, 400 + elif status == login_func.QrCodeLoginEvents.CONF: + return {"status": "400", "msg": "请在手机上确认登录"}, 400 + elif status == login_func.QrCodeLoginEvents.TIMEOUT: + return {"status": "400", "msg": "二维码已过期,请重新获取"}, 400 + elif status == login_func.QrCodeLoginEvents.DONE: + self.status.cache_clear() + print("success!") + return {"status": "200", "msg": "登录成功"}, 200 + else: + return {"status": "400", "msg": "未知错误"}, 400 + + @lru_cache(maxsize=8) + def status(self) -> dict: + return sync(user.get_self_info(self.session)) + + def userStatus(self) -> dict: + return self.status() + + def logout(self) -> dict: + return {"status": 201, "msg": "无法登出,直接换帐号就好了"}, 201 + + def qrcode(self): + _session = login.update_qrcode_data() + loginURL = _session["url"] + self.sessionKey = _session["qrcode_key"] + qrcode.make(loginURL).save("/tmp/qrcode.png") + return loginURL + +if __name__ == "__main__": + bisess = BilibiliSession() + print(bisess.qrcode()) + while(True): + if bisess.login()[1] == 200: + break + time.sleep(2) + print(bisess.userStatus()) diff --git a/Extensions/Netease.py b/Extensions/Netease.py new file mode 100644 index 0000000..2450753 --- /dev/null +++ b/Extensions/Netease.py @@ -0,0 +1,160 @@ +from pyncm import apis as ncm +import pyncm +try: + from Provider import SessionProvider, SongStatus, AccountType +except: + from Extensions.Provider import SessionProvider, SongStatus, AccountType +import httpx +from functools import lru_cache +import re +from enum import Enum + +class NeteaseSession(SessionProvider): + session = None + providerName = "Netease" + URLKeyword = ["music.163.com", "163cn.tv", "网易云", "netease"] + accountType = AccountType.ACCOUNT_NOLOGIN + + def updateAccountType(self) -> AccountType: + response = ncm.login.GetCurrentLoginStatus()["account"] + if not response: + self.accountType = AccountType.ACCOUNT_NOLOGIN + return + if response["paidFee"]: + self.accountType = AccountType.ACCOUNT_VIP + else: + self.accountType = AccountType.ACCOUNT_USER + + def login(self) -> dict: + self.status.cache_clear() + status = ncm.login.LoginQrcodeCheck(self.session) + if status["code"] == 803: + ncm.login.WriteLoginInfo(ncm.login.GetCurrentLoginStatus()) + self.updateAccountType() + return {"status": 200, "msg": _("Netease account logged in.")}, 200 + else: + return {"status": 408, "msg": _("Netease account login failed.")}, 408 + + def qrcode(self): + self.session = ncm.login.LoginQrcodeUnikey()["unikey"] + loginURL = f"https://music.163.com/login?codekey={self.session}" + #return self._qrcode(loginURL) + return loginURL + + def logout(self) -> dict: + self.status.cache_clear() + ncm.login.LoginLogout() + self.accountType = AccountType.ACCOUNT_NOLOGIN + return {"status": 200, "msg": _("Netease account logged out.")}, 200 + + @lru_cache(maxsize=8) + def status(self) -> dict: + return ncm.login.GetCurrentLoginStatus() + + def userStatus(self) -> dict: + try: + status = self.status() + if self.accountType == AccountType.ACCOUNT_NOLOGIN: + return {"status": 200, "accountType": self.accountType, "username": "未登录"} + if self.accountType == AccountType.ACCOUNT_ANONYMOUS: + return {"status": 200, "accountType": self.accountType, "username": "匿名用户"} + return {"status": 200, "accountType": self.accountType, "username": status["profile"]["nickname"]} + except Exception as e: + print(e) + return {"status": 500, "msg": _("Get status failed, internal server error!")} + + def loginAnonymous(self) -> dict: + self.status.cache_clear() + try: + ncm.login.LoginViaAnonymousAccount() + self.accountType = AccountType.ACCOUNT_ANONYMOUS + return {"status": 200, "msg": _("Netease anonymous account logged in.")}, 200 + except: + return {"status": 408, "msg": _("Netease anonymous account login failed, timed out.")}, 408 + + def checkURL(self, content: str) -> bool: + for i in self.URLKeyword: + if i in content: + return True + return False + + def parseURL(self, content: str) -> int or None: + uid = None + try: + if "music.163.com" in content: + uid = re.search(r"song\?id=[0-9]+",content).group() + uid = int(uid[8:]) + elif "网易云" in content: + uid = re.search(r"song\?id=[0-9]+",content).group() + uid = int(uid[8:]) + elif "163cn.tv" in content: + uid = httpx.get(content).headers.get("location") + uid = re.search(r"song\?id=[0-9]+",uid).group() + uid = int(uid[8:]) + elif type(eval(content)) == type(1): + uid = eval(content) + except Exception as e: + print(e) + return None + return uid + + def checkSong(self, content: int) -> SongStatus: + response = self._getSongStatus(content) + try: + if len(response["songs"]) == 0: + return SongStatus.SONG_NOTEXIST + if response["songs"][0]["fee"] == 1: + return SongStatus.SONG_ISVIP + return SongStatus.SONG_EXIST + except: + return SongStatus.SONG_NOTEXIST + return SongStatus.SONG_NOTEXIST + + def getSongURL(self, content: int) -> str: + response = ncm.track.GetTrackAudio(content) + return response["data"][0]["url"] + + def getSongImage(self, content: int) -> str: + response = self._getSongStatus(content) + return response["songs"][0]["al"]["picUrl"] + + def getSongName(self, content: int) -> str: + response = self._getSongStatus(content) + name = response["songs"][0]["name"] + "-" + for i in response["songs"][0]["ar"]: + name += i["name"] + name += "/" + return name[:-1] + + def getSongLyric(self, content: int) -> str: + #print(self._getSongStatus(content)) + response = ncm.track.GetTrackLyrics(content) + return response['lrc']['lyric'] + + def dumpSession(self) -> str: + return pyncm.DumpSessionAsString(pyncm.GetCurrentSession()) + + def restoreSession(self, session: str): + session = pyncm.LoadSessionFromString(session) + pyncm.SetCurrentSession(session) + + @lru_cache(maxsize=256) + def _getSongStatus(self, content: int) -> dict: + response = ncm.track.GetTrackDetail(content) + return response + +if __name__ == "__main__": + ncmsess = NeteaseSession() + print(ncmsess.parseURL("分享心华的单曲《一人行者》: https://y.music.163.com/m/song?id=526116050&userid=1578177131&dlt=0846 (来自@网易云音乐)")) + # print(ncmsess.loginAnonymous()) + # print(ncmsess.dumpSession()) + # print(ncmsess.checkSong(1828241226)) + print(ncmsess.getSongLyric(30953009)) + # print(ncmsess.checkSong(2014291207)) + # print(ncmsess.checkSong(1901371647)) + # print(ncmsess.checkSong(1828241226)) + # print(ncmsess.getSongName(1828241226)) + # print(ncmsess.getSongImage(1828241226)) + # print(ncmsess.getSongURL(1828241226)) + # print(ncmsess.status()) + # print(ncmsess._getSongStatus.cache_info()) diff --git a/Extensions/Provider.py b/Extensions/Provider.py new file mode 100644 index 0000000..aca86ec --- /dev/null +++ b/Extensions/Provider.py @@ -0,0 +1,65 @@ +import qrcode +from enum import Enum, StrEnum +from abc import abstractmethod, ABC + +class SongStatus(Enum): + SONG_EXIST = 1 + SONG_NOTEXIST = 0 + SONG_NOTAVAIL = 2 + SONG_ISVIP = 3 + +class AccountType(StrEnum): + ACCOUNT_NOLOGIN = "未登录" + ACCOUNT_ANONYMOUS = "匿名用户" + ACCOUNT_USER = "普通用户" + ACCOUNT_VIP = "VIP用户" + ACCOUNT_COOKIE = "Cookie登录" + def __json__(self): + return self.value + +class SessionProvider(): + tempPath = None + session = None + + @abstractmethod + def login(self): + pass + + @abstractmethod + def logout(self): + pass + + @abstractmethod + def qrcode(self): + pass + + def _qrcode(self, content): + raise DeprecationWarning("Please use string instead.") + if type(content)!=type("string"): + raise ValueError("QRCode content should be string") + img = qrcode.make(content) + return img + + @abstractmethod + def status(self): + pass + + @abstractmethod + def loginAnonymous(self): + pass + + @abstractmethod + def checkSong(self, content: int): + pass + + @abstractmethod + def parseURL(self, content: str): + pass + + @abstractmethod + def dumpSession(self): + pass + + @abstractmethod + def restoreSession(self, session: str): + pass diff --git a/Extensions/QQMusic.py b/Extensions/QQMusic.py new file mode 100644 index 0000000..e7ef645 --- /dev/null +++ b/Extensions/QQMusic.py @@ -0,0 +1,99 @@ +try: + from Provider import SessionProvider, SongStatus, AccountType +except: + from Extensions.Provider import SessionProvider, SongStatus, AccountType +from enum import Enum +import httpx +import re +from functools import lru_cache + +try: + print(_) +except: + _ = lambda x: x + +# 次奥,要手写API了 +class QQMusicSession(SessionProvider): + session = None + providerName = "QQMusic" + URL_Keyword = ["y.qq.com"] + accountType = AccountType.ACCOUNT_NOLOGIN + cookies = {} + def __init__(self): + print(_("WARN: This extension is deprecated due to the change of APIkey of y.qq.com!")) + + def loginAnonymous(self): + return {"status": 501, "msg": _("QQMusic does not support anonymous login.")}, 501 + + def loginCredential(self, content: str, key: str): + cookies = content + return {"status": 200, "msg": _("Cookies updated.")} + + def login(self) -> dict: + if cookies: + self.accountType = AccountType.ACCOUNT_COOKIE + return {"status": 200, "msg": _("QQMusic login succeeded.")} + else: + return {"status": 401, "msg": _("Cookie required!")}, 401 + + def logout(self): + self.accountType = AccountType.ACCOUNT_NOLOGIN + self.cookies = "" + return {"status": 200, "msg": _("QQMusic logout succeeded.")} + + def parseURL(self, content: str) -> str or None: + mid = None + try: + if "fcgi-bin/u?__" in content: + content = re.search(r"https://.*", content).group() + print(content) + mid = httpx.get(content).headers.get("location") + mid = re.search(r"songmid=(.+?)&", mid).group(1) + elif "ryqq/songDetail" in content: + mid = re.search(r"/[^/]+$", content).group()[1:] + except Exception as e: + print(e) + return None + return mid + + def checkURL(self, content: str) -> bool: + for i in self.URLKeyword: + if i in content: + return True + return False + + def getSongURL(self, content: str) -> str: + payload = r"https://u.y.qq.com/cgi-bin/musicu.fcg?format=json&data={%22req_0%22:{%22module%22:%22vkey.GetVkeyServer%22,%22method%22:%22CgiGetVkey%22,%22param%22:{%22filename%22:[%22PREFIXSONGMIDSONGMID.SUFFIX%22],%22guid%22:%2210000%22,%22songmid%22:[%22SONGMID%22],%22songtype%22:[0],%22uin%22:%220%22,%22loginflag%22:1,%22platform%22:%2220%22}},%22loginUin%22:%220%22,%22comm%22:{%22uin%22:%220%22,%22format%22:%22json%22,%22ct%22:24,%22cv%22:0}}" + payload = payload.replace("PREFIX", "M500") + payload = payload.replace("SONGMID", content) + payload = payload.replace("SUFFIX", "mp3") + data = httpx.get(payload, cookies=self.cookies) + print(payload) + print(data.json()) + print(a:=data.json()["req_0"]["data"]["midurlinfo"][0]["purl"]) + print(b:=data.json()["req_0"]["data"]["sip"][0]) + return b+a + + @lru_cache(maxsize=256) + def _getSongStatus(self, content:str) -> dict: + payload = r"https://u.y.qq.com/cgi-bin/musicu.fcg?format=json&data={%22req%22:{%22module%22:%22music.pf_song_detail_svr%22,%22method%22:%22get_song_detail_yqq%22,%22param%22:{%22songmid%22:%22SONGMID%22}},%22loginUin%22:%220%22,%22comm%22:{%22uin%22:%220%22,%22format%22:%22json%22,%22ct%22:24,%22cv%22:0}}" + payload = payload.replace("SONGMID", content) + data = httpx.get(payload,cookies=self.cookies ) + print(data.json()) + + def dumpSession(self) -> str: + return self.cookies + + def restoreSession(self, session: str): + self.cookies = session + self.login() + +if __name__ == "__main__": + cookies = "pac_uid=1_1599172402; iip=0; _qimei_fingerprint=a60e880d499fb09dfa3fc2e7b67f5cac; _qimei_q36=; _qimei_h38=5261d3497d310405f78852440900000df17a1e; RK=vx0t0LFmeo; ptcz=ad36d5ca2a0e7a6b7250c853a187f263446dfb2530128858c1c61c5fcc10875c; pgv_pvid=2920262853; eas_sid=d14770F6p8X5a1M6V2z6X8H9W1; qq_domain_video_guid_verify=c6c04001d0488e55; o_cookie=1599172402; suid=ek168787168502135101; _qimei_uuid42=18401162425100258014e7193532ca71547ede2d09; vversion_name=8.2.95; pgv_info=ssid=s4743344675; uin=1599172402; fqm_pvqid=d34c53e5-031d-4762-b0e1-10a3b13013b6; fqm_sessionid=12a505d3-2ba4-4fd8-b025-6d773e4153e6; ts_refer=cn.bing.com/; ts_uid=2582842080; ts_last=y.qq.com/n/ryqq/songDetail/002vbWwe3ZmffT; login_type=1; psrf_qqaccess_token=2BFFE211C6375E16CAE328C65066052A; qm_keyst=Q_H_L_63k3Naa-qgZKZwiv-Q6TaqMCUYqy0nrKuDW6p2NbmOYb0AYcnh1ttAqA9Az9tpu2D1FCrAkeJFQ; wxunionid=; euin=oK4qNK6lowvzoc**; psrf_qqrefresh_token=B75229616609EF6988510371BEF3EFA2; psrf_qqunionid=9A90D76C7D610B8DF336DC5F19EC8773; qqmusic_key=Q_H_L_63k3Naa-qgZKZwiv-Q6TaqMCUYqy0nrKuDW6p2NbmOYb0AYcnh1ttAqA9Az9tpu2D1FCrAkeJFQ; tmeLoginType=2; music_ignore_pskey=202306271436Hn@vBj; psrf_musickey_createtime=1714623437; wxrefresh_token=; psrf_access_token_expiresAt=1722399437; psrf_qqopenid=027F5F42CADC5D52F710B2936B81B0C5; wxopenid=" + qmsess = QQMusicSession() + smid = qmsess.parseURL("https://c6.y.qq.com/base/fcgi-bin/u?__=yCNzlNSNbaWD") + smid = qmsess.parseURL("https://y.qq.com/n/ryqq/songDetail/000lKV2G0PEkc3") + print(qmsess.loginCredential(cookies, "")) + print(qmsess.login()) + print(qmsess.getSongURL(smid)) + #print(qmsess._getSongStatus(smid)) diff --git a/LyricFormatter.py b/LyricFormatter.py new file mode 100644 index 0000000..71010c1 --- /dev/null +++ b/LyricFormatter.py @@ -0,0 +1,33 @@ +import re +import time +def lrcFormat(content: str) -> str: + lrcTuple = [] + result = "" + def strTimeToFloat(content: str) -> float: + t = re.findall(r"\d+", content) + if len(t) == 3: + return int(t[0])*60 + int(t[1]) + float(t[2])/(10**(len(t[2]))) + elif len(t) == 2: + return int(t[0])*60 + float(t[1]) + def floatToStrTime(content: float) -> str: + return f"{int(content//60):02d}:{int(content%60):02d}.{int(content*1000%1000):03d}" + # 读取歌词 + if content is None: + return "[0.0] 该歌曲暂无歌词\n" + for i in content.split('\n'): + a = re.findall(r"\[.*?\]",i) + timestamps = [strTimeToFloat(i[1:-1]) for i in a] + if len(timestamps) == 0: + continue + c = re.sub(r"\[.*?\]", "", i).strip() + for i in timestamps: + lrcTuple.append((i, c)) + lrcTuple.sort(key=lambda x:x[0]) + for i in lrcTuple: + result += "[" + str(i[0]) + "] " + i[1] + "\n" + return result + + +if __name__ == "__main__": + data = open("test.lrc", "r").read() + open("result.lrc", "w").write(lrcFormat(data)) diff --git a/MusicManager.py b/MusicManager.py new file mode 100644 index 0000000..4e53387 --- /dev/null +++ b/MusicManager.py @@ -0,0 +1,61 @@ +import httpx +from Music import Music +import uuid +from Extensions.Netease import NeteaseSession +from Extensions.Provider import SessionProvider +from mutagen import mp3, flac + +providers = [] + +def downloadMusic(music: Music, path: str) -> dict: + musicUUID = str(uuid.uuid4()) + filename = path + "/" + musicUUID + ".mp3" + imagefilename = path + "/" + musicUUID + ".jpg" + lyricfilename = path + "/" + musicUUID + ".lrc" + try: + with httpx.stream("GET", music.musicURL) as r: + with open(filename, "wb") as f: + for chunk in r.iter_bytes(): + f.write(chunk) + with httpx.stream("GET", music.imageURL) as r: + with open(imagefilename, "wb") as f: + for chunk in r.iter_bytes(): + f.write(chunk) + with open(lyricfilename, "wb") as f: + f.write(music.lyric.encode("utf-8")) # 仅测试用途 + except PermissionError: + return {"status": 500, "msg": _("File saving failed, permission denied!")}, 500 + except FileNotFoundError: + return {"status": 500, "msg": _("File saving failed, path doesn't exist!")}, 500 + except: + return {"status": 500, "msg": _("File saving failed, internal server error!")}, 500 + music.uuid = musicUUID + return {"status": 200, "msg": _("File saved."), "filename": filename, "uuid": musicUUID}, 200 + +def judgeProvider(provider: str, content: str, providerList: list) -> SessionProvider or None : + for i in providerList: + if provider == i.providerName: + return i + if i.checkURL(content): + return i + return None + +def imagePayload(music) -> bytes or None: + with open(music, "rb") as file: + if(music.endswith("mp3")): + target = mp3.MP3(file) + return target.tags['APIC:'].data + elif(music.endswith("flac")): + target = flac.FLAC(file) + return target.pictures[0].data + else: + raise AttributeError(_("Unsupported file format.")) + raise AttributeError(_("File not found.")) + +if __name__ == "__main__": + #ncmsession = NeteaseSession() + #print(judgeProvider(None, "https://music.163.com/song?id=114514", [ncmsession])) + #exit(0) + mu01 = Music("pig2014", None, "My Ordinary Life", "Netease", "https://p2.music.126.net/E_oSaVzztyW0yg-5IvklCg==/109951165802120160.jpg", "http://m701.music.126.net/20240402195141/3b62ce359fc591ca8857a2dd0b9c6909/jdymusic/obj/wo3DlMOGwrbDjj7DisKw/28531063631/77f4/653d/bdf8/a43f2583f10ce9f41046586a19a19c77.mp3") + print(downloadMusic(mu01, "/tmp/ttmp")) + #print(mu01.dict()) diff --git a/MusicQueue.py b/MusicQueue.py new file mode 100644 index 0000000..562b36d --- /dev/null +++ b/MusicQueue.py @@ -0,0 +1,64 @@ +import Music +import pickle + +class MusicQueue(): + queue = [] + + def __init__(self): + self.queue = [] + + def push(self, element: Music): + self.queue.append(element) + + def pop(self): + self.queue.pop(0) + + def front(self) -> Music: + return self.queue[0] + + def clear(self): + self.queue = [] + + def elevate(self, target: Music): + # elevate target to 1st place + self.queue.remove(target) + self.queue.insert(0, target) + + def find(self, target: Music) -> Music or None: + for i in self.queue: + if i == target: + return i + return None + + def count(self, target: Music): + count = 0 + for i in self.queue: + if i == target: + count += 1 + return count + + def findAndElevate(self, target: Music): + target = self.find(target) + if target is not None: + self.elevate(target) + + def delete(self, target: Music): + target = self.find(target) + if target is not None: + self.queue.remove(target) + + def size(self): + return len(self.queue) + + def dict(self): + return [i.dict() for i in self.queue] + + def __str__(self): + return str(self.queue) + + def dump(self): + return pickle.dumps(self.queue) + + def load(self, data: bytes): + self.queue = pickle.loads(data) + \ No newline at end of file diff --git a/Stardust.py b/Stardust.py new file mode 100644 index 0000000..89b909d --- /dev/null +++ b/Stardust.py @@ -0,0 +1,18 @@ +data = {} + +def create(username: str, stardust: int, nextCost: int): + data[username] = (stardust, nextCost) + +def read(username: str): + return data[username] + +def check(username: str): + return username in data + +def update(username: str, stardust: int, nextCost: int): + data[username] = (stardust, nextCost) + +def smartread(username: str, stardust: int, nextCost: int): + if username not in data: + create(username, stardust, nextCost) + return data[username] \ No newline at end of file diff --git a/UserImport.py b/UserImport.py new file mode 100644 index 0000000..0077478 --- /dev/null +++ b/UserImport.py @@ -0,0 +1,53 @@ +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 sys + + +app = Flask("GalaxyMusicHall") + + +FlaskDynaconf(app, settings_files=["instance/config.toml", "instance/.secrets.toml"], environments=True) + +db = SQLAlchemy(app) +fsqla.FsModels.set_db_info(db) + +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) + + +if __name__ == "__main__": + if len(sys.argv) > 1: + with app.app_context(): + print("初始化数据库...") + db.create_all() + app.security.datastore.find_or_create_role(name="admin") + app.security.datastore.find_or_create_role(name="user") + app.security.datastore.find_or_create_role(name="player") + with open(sys.argv[1], "r", encoding="utf-8") as file: + for i in file.readlines(): + username, password = i.strip().split(',') + print(username,password) + if not app.security.datastore.find_user(username=username): + app.security.datastore.create_user(username=username, email= username+"@null.com", password=hash_password(password)) + user = app.security.datastore.find_user(username=username) + app.security.datastore.add_role_to_user(user, "user") + + db.session.commit() + else: + print("数据库连接有效") + print("请在参数1指定.csv文件用于导入用户") + \ No newline at end of file diff --git a/instance/config.toml b/instance/config.toml new file mode 100644 index 0000000..740c327 --- /dev/null +++ b/instance/config.toml @@ -0,0 +1,75 @@ +[default] +# 指定密码哈希 +security_password_hash = "argon2" +# 设置cookie SameSite属性 +remember_cookie_samesite = "strict" +session_cookie_samesite = "strict" + +# 允许推送 +enable_push = true +# 设置用户推送限制 +user_pushlimit = 2 +# 设置总推送限制 +total_pushlimit = 60 +# 设置是否允许重复推送 +allow_duplicate_push = false + +# 实例名称,用于在前端展示 +instance_name = "银河音乐厅Next_v1.0.0" +# 缓存路径,存放下载的歌曲文件与封面等 +cache_path = "/tmp/galaxycache" +# 是否保留缓存,若为false,则每次启动时都会清空缓存 +preserve_cache = true +# 是否保存会话,上一选项为false时无效 +save_session = false + +# 喵化API的"msg"字段输出 +meowlize = false + +# 启用用户歌曲提升(消耗星尘) +# 若不启用,则下述三个参数无效 +allow_user_elevation = true +# 设置用户初始星尘数 +initial_stardust = 100 +# 设置初始星尘扣除数 +initial_stardust_cost = 10 +# 设置星尘扣除递增数 +stardust_cost_increase = 10 + +# 设置上传歌曲允许的后缀 +allowed_upload_music = ["mp3", "flac", "wav", "aac"] +# 设置上传歌词允许的后缀 +allowed_upload_lyric = ["lrc"] +# 设置上传歌曲的默认图标 +upload_music_image = "https://www.pngall.com/wp-content/uploads/2/Upload-PNG-Image.png" + +# 允许播放器端登录网易云帐号 +allow_player_netease_login = false + +# 以下为使Flask-Security工作必须的选项 +MAIL_DEFAULT_SENDER = "catty" +SECURITY_EMAIL_SENDER = "catty" +SECURITY_CSRF_PROTECT_MECHANISMS = "" +WTF_CSRF_CHECK_DEFAULT = false + +# 以下为使自定义登录API工作必须的选项 +SECURITY_LOGIN_URL = "/hiddenapi/login" +SECURITY_LOGOUT_URL = "/hiddenapi/logout" + +[development] +# 只用来测试 +debug = 1 +host = "127.0.0.1" +port = 5000 +cache_path = "/tmp/galaxycache" +banner_file = "./banner.md" +SQLALCHEMY_DATABASE_URI = "sqlite:///data.db" + +[production] +# 正式部署 +debug = 0 +host = "0.0.0.0" +port = 5000 +cache_path = "/tmp/galaxycache" +banner_file = "./banner.md" +SQLALCHEMY_DATABASE_URI = "sqlite:///data_prod.db" diff --git a/instance/data.db b/instance/data.db new file mode 100644 index 0000000000000000000000000000000000000000..1de40337f8c085153c4345725e3f0534bd76ac73 GIT binary patch literal 40960 zcmeI)PjBNy9|v$J-gcXytwE?N8~{JsN^P~f;J>jAs)V=eZL^!S+caHu_YzI)NozK7 zvW}BdZUqT(;le9;F3$^iPMo=Md!F+n4v&PmAr5c=Ar8zqZJO+MH!Ce&koZQjiAwU%s6A z*iA^iN#k~tc5A&Zjk_a@`PGW&Rz11uUN3p_NQS(W&+*_|7|6wqs<-AnP$35uUqTvw4NPxr=gxh!DBbhy2=7-k>LrC8phKEM=^_Ea#UOXQt=ho687c6wnX551Z`Du*MFg zcKY++U^4k(mRr0yG0@7g1g?W}uf>s!vk^uwRfKx`5M5P$##AOHafKmY;|fB*y_0D(V3ATPe28QeY)XT-S-zfq8#5$AL1 zErIm;|D}Hj(o3_iu`hfe009U<00Izz00bZa0SNqk1&$JNzAPMV@u#p?a^)d}uGft^ zwSA(grllK} zhkDfSSLmvJ%c^Q|Z&%q{xbM33e!01|`=N68Udd}3I|s#WUa8);$zaRCQ&A0t_ixSNswyh`yOEDD9a!gGlj>;-nx2?KvKmHKdIwR?*lN~gh zJzHKJYqmlS(36lH4YMU zwNfwK*>~xtxn8<&wUqQ)J)I33ZIVE(t0Zs~l{dCfS8Q7ie2oyh;ajG8MhnkK8a)61 zLXf_czL35d_HHad00Izz00bZa0SG_<0uX=z1R!v(0t;d}GwIfn$b%-`h{;|S%ehIn zE(X{CKMK-s>8WkpfQ;r;&? z29FIv00Izz00bZa0SG_<0uX=z1kO|7sqg;}uK&Llq#vZO@&Etlxf@V61Rwwb2tWV= z5P$##AOHafK;ZcWUKOt#5Aru0AV7RYTuev%8>VDm5tj$U{tc6c@Be?pM*w*KdY}Xd zKmY;|fB*y_009U<00Izz00f>zfWO*5X$;5o{eL=P)Wh`s|4E|+rsMxh-)E#>rJtnl upQV0?fdB*`009U<00Izz00bZa0SG|guPiVnikXZkPG#9wmd(UmPWT=FLop}- literal 0 HcmV?d00001 diff --git a/locales/meow/LC_MESSAGES/main.mo b/locales/meow/LC_MESSAGES/main.mo new file mode 100644 index 0000000000000000000000000000000000000000..5e833183a74ba031cd1b73c68d1820d8d035a2c8 GIT binary patch literal 3359 zcmb7^U2GIp6vxMJsox){i0G{nNt9htqhbZaLKocFue61TnwVktb~`#dcb$*Iz92=Q z1&S@9g;v0Bg(g4(i&WZLw)Tw}6XUBd8jU-%yNL#U(gz>>pPAWh7x{=co&NUTd+t5w z{Lh*B>HeDsB#gy)9>cTZ21$AeeD_BD!&r8cxBfi%2<8po9bg=M1~kAW;Ah~|;6?CN z@ON+_xa4L@dJtR#GVm?%ZZHKt3U-5JcMMzv&VY--AHf&E8*h=M`@omL`@wZS-v*ar zp77_Nf)w9rknAslPk=vw4E!5>99(d#H(w4ez*2!8{Gpz2AW3{~w?8Q5^Cg1j()zB;8by{Cwo|u)jXx^IM-kf^^>> zAl)lknZaTDZaD*{`X)2^FKkza4ax^)l+aSr19+&;596V6j|Yi? zXjykqztX!veMmh?17&pkNm?r_DvvQ!FrBMH#;Yk^L%30%G!atJ zH!!Sdlp97+YTzc94bJ3fR9KqHR1uFuW(Ag4*%Fp{#l5oIIlG$BQVC%h*S!8pM&-+( znvy_3o>$=|Yj72kkg^TJmkS3zw7A9LT$oI@ps{8uQQ5U>y1XRRM#vZnu&5kGIbsTl zEc8^h^dDm^Sw=gA*X0D%(y4?zku+i3;&lxcbW77vOR2>Zw#`!26zZ?yfY+*SRfCoL z*zTYR-Hy1DK@yIyv27*kO_*y?Ewv&WuY;&pnFcldW}V$Zyc;4-OlWFqDNCwu14M;p z%IGsWmQXY_w=P2t(g_^_amn8Ak-0u_ldS4oj-}8tZW?YCE;FGZ0oQ~TZ)b)n z>oLnPDJNg}xu=?R@jhB05QzmgaGhS?8rBqA^JZvWm~99*w?rBns+u{46{t7jN-VJ2 ziW`AeQNya58UxL|L!mQ)TGX?KZ48H+1NDu8+7NrAv8F!q?AwhE;i@{>Faxc+tQjha zTf^3_ud8DXq53de5v~tK>Q;qnYn#I@Ew95=i(B|C1X{VAs9|-XhIQ*Hk`;Azt5hW@ z^!Thz4NIoXcA-_(N9x0~!9O2dR<#C2#d{EFO(hYP$+wu#B~@9`USrXASvR=3a((OC z!17r;3a*XofiS*1G4yi{TfRv#t0d=8+8G77 z?{@|d)R&owLT7(| z>_Yy`NWO2Ey?@ZFJZvg;@$R2Em%q@91j?$GDuB1P?6fz7=0zy8PAQZ*hx|RPI{g`^ zrxPznZt@h;&3%2iux)2)dpb=PgBkB;s=(e+6uH8}uNR7Ne3&dy?X!c<(NRykvMLnH EzYtd_PXGV_ literal 0 HcmV?d00001 diff --git a/locales/meow/LC_MESSAGES/main.po b/locales/meow/LC_MESSAGES/main.po new file mode 100644 index 0000000..cdc1f7e --- /dev/null +++ b/locales/meow/LC_MESSAGES/main.po @@ -0,0 +1,159 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2024-04-02 20:45+0800\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: python\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" + +#: main.py:55 +msgid "Not authenticated!" +msgstr "请登录帐号喵!" + +#: main.py:58 +msgid "Permission denied!" +msgstr "权限不足喵!" + +#: main.py:65 +msgid "Hello, this is the API testing page of galaxy music hall." +msgstr "你好,这里是银河音乐厅的API测试页面喵。" + +#: main.py:69 +msgid "Server is running" +msgstr "服务器正在运行喵。" + +#: main.py:81 main.py:86 main.py:94 +msgid "Incorrect user or password!" +msgstr "用户名或密码错误喵!" + +#: main.py:90 +msgid "Login success." +msgstr "登录成功喵。" + +#: main.py:92 +msgid "Login failed, unknown exception." +msgstr "登录失败,未知异常喵!" + +#: main.py:119 +msgid "No music in queue." +msgstr "队列里是空的喵。" + +#: main.py:102 +msgid "Push parameter is empty!" +msgstr "推送参数是空的喵!" + +#: main.py:149 +msgid "Unsupported music provider!" +msgstr "不支持这个提供者喵!" + +#: main.py:154 +msgid "The music does not exist!" +msgstr "歌曲不存在喵!" + +#: main.py:156 +msgid "The music is not available!" +msgstr "歌曲不可用喵!" + +#: main.py:158 +msgid "The music is VIP only, please contact administrator to login VIP account!" +msgstr "歌曲是VIP音乐,请联系管理员登录VIP帐号喵!" + +#: main.py:164 main.py:233 +msgid "The music is already in the queue!" +msgstr "歌曲已经在队列里了喵!" + +#: main.py:169 +msgid "Successfully pushed the music." +msgstr "成功推送喵。" + +#: main.py:179 main.py:187 main.py:199 main.py:211 +msgid "The music is not in the queue!" +msgstr "歌曲不在队列里喵!" + +#: main.py:181 main.py:201 +msgid "You are not the owner of the music!" +msgstr "你不是歌曲的拥有者喵!" + +#: main.py:183 main.py:189 +msgid "Successfully deleted the music." +msgstr "成功删除喵。" + +#: main.py:203 +msgid "You do not have enough stardust to elevate the music!" +msgstr "你没有足够的星尘来提升歌曲喵!" + +#: main.py:207 main.py:213 +msgid "Successfully elevated the music." +msgstr "成功提升喵。" + +#: main.py:229 +msgid "No file selected!" +msgstr "没有选择文件喵!" + +#: main.py:231 +msgid "Invalid file format!" +msgstr "无效的文件格式喵!" + +#: main.py:239 +msgid "Failed to save the file!" +msgstr "无法保存文件喵!" + +#: main.py:242 +msgid "Successfully uploaded the music!" +msgstr "成功上传歌曲喵。" + +#: main.py:258 main.py:279 +msgid "Failed to reload the banner!" +msgstr "无法加载Banner喵!" + +#: main.py:277 +msgid "Permission denied to access cache directory!" +msgstr "没有访问缓存目录的权限喵!" + +#: MusicManager.py:23 +msgid "File saving failed, permission denied!" +msgstr "文件保存失败喵!(权限不够喵...)" + +#: MusicManager.py:25 +msgid "File saving failed, path doesn't exist!" +msgstr "文件保存失败喵!(路径不存在喵...)" + +#: MusicManager.py:27 +msgid "File saving failed, internal server error!" +msgstr "文件保存失败喵!(服务器出错了喵...)" + +#: MusicManager.py:29 +msgid "File saved." +msgstr "文件保存了喵。" + +#: Extensions/Netease.py:27 +msgid "Netease account logged in." +msgstr "网易云帐号登录成功喵。" + +#: Extensions/Netease.py:29 +msgid "Netease account login failed." +msgstr "网易云帐号登录失败喵。" + +#: Extensions/Netease.py:39 +msgid "Netease account logged out." +msgstr "网易云帐号登出了喵。" + +#: Extensions/Netease.py:48 +msgid "Netease anonymous account logged in." +msgstr "网易云匿名帐号登录成功喵。" + +#: Extensions/Netease.py:50 +msgid "Netease anonymous account login failed, timed out." +msgstr "网易云帐号登录失败喵。(超时了喵)" + diff --git a/locales/zh_CN/LC_MESSAGES/main.mo b/locales/zh_CN/LC_MESSAGES/main.mo new file mode 100644 index 0000000000000000000000000000000000000000..65d591cd5f829da981333e94df7ce055d5e991f7 GIT binary patch literal 3233 zcma)+`)?Fg6vxN6);GSz$1NI3l&xr@VnM@77ueWW+JfTahhg`2J32deokwARFrhpP zw6+aYT4-%6G^s>brP|iAwf>^}&b{Z{ z?>RF+ExWp3!FUwU19;Y5p(xLQZ(NCg7^|=H*PjCK#k?N85ln!OgBG|F`~Z9m{04j; z{0&?VuDn`N?gTf0415*51x$nYgPkDxodh2Q=fOw8AHk==E3Z+M+rekRW#FcOuYs#D zPX_bjAmw)kB>!)~hr#bb2L1^?1m1V8KVJjhhxs-z3R>U_a1Nw=eg?_^?_j?CIz@Q| z^XEX)=XLNwXw?Vag?Z+BpI-<30bGssKS0uH#SOkrb>Op@J0QjR7^J+v1u5Py;H{ui z;m>aa$?wTv{xV4Vyam$vJs`#Z3Z#ACfuxfMlHXs!`a>uh`Bi}wuLdOlHv=XEz6+9H zH%RB41gV}=Am#Uauzu@J{y8f^%5N=5_E*5i!FCYxNE;vr=|=;S$=cIzYBP#+8%;2% zHxNR>cBK*z4U|r~84s?qI4Bn7NduC}ad$uh)t35z2Hh8BRM1k&9eAkD_u!!+wFVg; zw5%M|ukR=$kzlRzv{g(qY;GE=&Ma=WbCYq?6m(v&t=u)G+HEW@xMi%g8Q-Z{_9gtdnba&x z69$WOL*wyEWi!`xu?lHv7Q-K99&U^>n_D(TNT~_VL@P_Ey1FyXl8&XtSevTrRZ7%| z3De{;n>omk2@}qiwL_RVHEOi0x)x`2Q`RcXq>7AcMM5(cujd$V8)AonbYnbaBc-aq z!E$0TZdp}IJ-4}Pai+#%!ZB>7i$nr4Yw)~;NjUaJZHr;ctcEbsN#R(RJ^o@&i-$oq zEs2EusKURj#dSzR%GQfOE^K(m;SNV|VKdc%#)hrMRO!|D?~+g(;l@ya#nc$e5!Xm$ zVWumU|2SjGvDzTKsV1S8NhRdTlnvizziY5yI);H-D$TyIt&XmzQGXp9{8p7!ty*e_ zxt%hU9dR*-6arty?4;;TD0iq-T5ub`gQ!=T0X2eVEm@H7)@UOWhMr!GiE!MG*)=(d*s8uaH)MBcJrIWbT zY~z;NT2>#fi?DT(x^T31eYmEkDbn2hB3zAxgU>>!g{#SGRvWJ0w1qNRS6jPY*Q$h> zDEU;gRN8J6Mnzq;E>cSVsjAf#8&Fid2ced93R&5Fr~PC~S2bfTi?yky#qAfiv}_Em zDfv-yt=tSn@ZE`{pR3uL?V4Soc*jP)$$t0HXkl*dCHc|4knIRobMwRQnE~(p1G)24 za#j8cRy%v$LnFWL+O7C@f7LzpY2mYR_+H3%y8HJP_e^+0-R_|=cW|P3bg+=gT*w~0 zhhuQo5a3RxQ9Q(XD{2`^DKRV%cPP@I`Ug!I6W`BOPVhZ z<^!|2+5IxTi*5A-FEw|~;|~A({DtIk2Fg^(Onaa1E==w7j-kk?&bcvf=wNZzLGSQK zrP3D%M%>P0xtZDgN3#?gy8<2Ltz>t|jfJ!G#baml*&`@o{=``*MH0$#NvDOv6{aTT z{Jn|dt`4_nx7YuPf4W>*;m750uU2p{03l z=1?v>>YeHGPMnjg@>f}-q1ti@eS^=T-!#-rndw5u(fs83{MoU5?|!#$z`rwme6eM& za;H0eE`PoUx%xt(2T{Y4nPT;1&~8hY%G}8SFYjo^>*_!k=VnghTDdPz6n5>Cwz*L< k4`lpINz}vRC{)Sp7Y@4OoggzR_Q`-ZJnpOGZ=;j`0geaHUjP6A literal 0 HcmV?d00001 diff --git a/locales/zh_CN/LC_MESSAGES/main.po b/locales/zh_CN/LC_MESSAGES/main.po new file mode 100644 index 0000000..2c84406 --- /dev/null +++ b/locales/zh_CN/LC_MESSAGES/main.po @@ -0,0 +1,158 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2024-04-02 20:45+0800\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: python\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" + +#: main.py:55 +msgid "Not authenticated!" +msgstr "请登录帐号!" + +#: main.py:58 +msgid "Permission denied!" +msgstr "权限不足!" + +#: main.py:65 +msgid "Hello, this is the API testing page of galaxy music hall." +msgstr "你好,这里是银河音乐厅的API测试页面。" + +#: main.py:69 +msgid "Server is running" +msgstr "服务器正在运行。" + +#: main.py:81 main.py:86 main.py:94 +msgid "Incorrect user or password!" +msgstr "用户名或密码错误!" + +#: main.py:90 +msgid "Login success." +msgstr "登录成功。" + +#: main.py:92 +msgid "Login failed, unknown exception." +msgstr "登录失败,未知异常!" + +#: main.py:119 +msgid "No music in queue." +msgstr "队列为空。" + +#: main.py:102 +msgid "Push parameter is empty!" +msgstr "推送参数为空!" + +#: main.py:149 +msgid "Unsupported music provider!" +msgstr "不支持这个提供者!" + +#: main.py:154 +msgid "The music does not exist!" +msgstr "歌曲不存在!" + +#: main.py:156 +msgid "The music is not available!" +msgstr "歌曲不可用!" + +#: main.py:158 +msgid "The music is VIP only, please contact administrator to login VIP account!" +msgstr "歌曲是VIP音乐,请联系管理员登录VIP帐号!" + +#: main.py:164 main.py:233 +msgid "The music is already in the queue!" +msgstr "歌曲已经在队列里了!" + +#: main.py:169 +msgid "Successfully pushed the music." +msgstr "成功推送。" + +#: main.py:179 main.py:187 main.py:199 main.py:211 +msgid "The music is not in the queue!" +msgstr "歌曲不在队列里!" + +#: main.py:181 main.py:201 +msgid "You are not the owner of the music!" +msgstr "你不是歌曲的拥有者!" + +#: main.py:183 main.py:189 +msgid "Successfully deleted the music." +msgstr "成功删除。" + +#: main.py:203 +msgid "You do not have enough stardust to elevate the music!" +msgstr "你没有足够的星尘来提升歌曲!" + +#: main.py:207 main.py:213 +msgid "Successfully elevated the music." +msgstr "成功提升。" + +#: main.py:229 +msgid "No file selected!" +msgstr "没有选择文件!" + +#: main.py:231 +msgid "Invalid file format!" +msgstr "无效的文件格式!" + +#: main.py:239 +msgid "Failed to save the file!" +msgstr "无法保存文件!" + +#: main.py:242 +msgid "Successfully uploaded the music!" +msgstr "成功上传歌曲。" + +#: main.py:258 main.py:279 +msgid "Failed to reload the banner!" +msgstr "无法加载Banner!" + +#: main.py:277 +msgid "Permission denied to access cache directory!" +msgstr "没有访问缓存目录的权限!" + +#: MusicManager.py:23 +msgid "File saving failed, permission denied!" +msgstr "文件保存失败!(权限不够)" + +#: MusicManager.py:25 +msgid "File saving failed, path doesn't exist!" +msgstr "文件保存失败!(路径不存在)" + +#: MusicManager.py:27 +msgid "File saving failed, internal server error!" +msgstr "文件保存失败!(内部服务器错误)" + +#: MusicManager.py:29 +msgid "File saved." +msgstr "文件保存成功。" + +#: Extensions/Netease.py:27 +msgid "Netease account logged in." +msgstr "网易云帐号登录成功。" + +#: Extensions/Netease.py:29 +msgid "Netease account login failed." +msgstr "网易云帐号登录失败。" + +#: Extensions/Netease.py:39 +msgid "Netease account logged out." +msgstr "网易云帐号登出了。" + +#: Extensions/Netease.py:48 +msgid "Netease anonymous account logged in." +msgstr "网易云匿名帐号登录成功。" + +#: Extensions/Netease.py:50 +msgid "Netease anonymous account login failed, timed out." +msgstr "网易云帐号登录失败。(请求超时)" diff --git a/main.py b/main.py new file mode 100644 index 0000000..bcd7415 --- /dev/null +++ b/main.py @@ -0,0 +1,630 @@ +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"]) diff --git a/requirements_deployment.txt b/requirements_deployment.txt new file mode 100644 index 0000000..b65d700 --- /dev/null +++ b/requirements_deployment.txt @@ -0,0 +1,16 @@ +dynaconf==3.2.5 +Flask==3.0.2 +Flask_Security_Too==5.4.3 +Flask_SocketIO==5.3.6 +flask_sqlalchemy==3.1.1 +flask_wtf==1.2.1 +httpx==0.27.0 +pyncm==1.6.15 +qrcode==7.4.2 +argon2-cffi==23.1.0 +argon2-cffi-bindings==21.2.0 +mutagen==1.47.0 +gunicorn==22.0 +gevent==24.2.1 +gevent-websocket==0.10.1 +#bilibili-api-python==16.2.0