mirror of
https://github.com/qwerfd2/Groove_Coaster_2_Server.git
synced 2025-12-21 19:20:11 +00:00
Ready for ex update
This commit is contained in:
6
7002.py
6
7002.py
@@ -14,8 +14,9 @@ from api.user import routes as user_routes
|
||||
from api.ranking import routes as rank_routes
|
||||
from api.shop import routes as shop_routes
|
||||
from api.play import routes as play_routes
|
||||
from api.batch import routes as batch_routes
|
||||
|
||||
from config import DEBUG, SSL_CERT, SSL_KEY, ROOT_FOLDER, ACTUAL_HOST, ACTUAL_PORT
|
||||
from config import DEBUG, SSL_CERT, SSL_KEY, ROOT_FOLDER, ACTUAL_HOST, ACTUAL_PORT, BATCH_DOWNLOAD_ENABLED
|
||||
|
||||
if (os.path.isfile('./files/dlc_4max.html')):
|
||||
get_4max_version_string()
|
||||
@@ -36,6 +37,9 @@ routes = []
|
||||
|
||||
routes = routes + user_routes + rank_routes + shop_routes + play_routes
|
||||
|
||||
if BATCH_DOWNLOAD_ENABLED:
|
||||
routes = routes + batch_routes
|
||||
|
||||
routes.append(Route("/{path:path}", serve_file))
|
||||
|
||||
app = Starlette(debug=DEBUG, routes=routes)
|
||||
|
||||
26
README.md
26
README.md
@@ -56,6 +56,16 @@ Edit `config.py`'s `MODEL`, `TUNEFILE`, and `SKIN` value to match the `pak`'s ti
|
||||
|
||||
Important: You must use the `common.zip` inside the `4max expansion` folder, not the `common.zip` in the root directory.
|
||||
|
||||
⚠️For players/servers already equipped with the 4MAX expansion, updating to the EX expansion means that your play results will be offsetted. While the server side can be corrected with a script in `various-tools`, your local save can't. The only way to fix this would be to clear your local and cloud save. Please beware.
|
||||
|
||||
<details>
|
||||
<summary>Details</summary>
|
||||
<br>
|
||||
|
||||
Because of a duplicate song, and 2 AC Alts being misplaced into 4MAX normal roster, the song list has changed order. As a result, your old play result would no longer match the song list. There is another solution - that is, to leave the 3 songs blank and hide them on both client and server side. However, since the remaining song list capacity is minimal (12 left), and the resulting code changes will pigeon-hole the 4max server code, resulting in discomfort in development later down the line. As a result, I chose to sacrifice the old user's save file. Sorry about that.
|
||||
|
||||
</details>
|
||||
|
||||
#### Updates
|
||||
|
||||
The `GC4MAX` expansion will receive updates to fix bugs. You can check the latest version by going to the ingame `shop` - `songs page` - `GC4MAX banner` after purchasing the expansion. A server restart is required to fetch the latest update.
|
||||
@@ -325,6 +335,16 @@ Since this game features tons of downloadable music and stage files that cannot
|
||||
|
||||
重要: 必须使用`4max expansion`文件夹里的`common.zip`,而不是根目录里的`common.zip`.
|
||||
|
||||
⚠️针对已安装游玩4MAX扩展包的服务器/用户,更新至EX扩展包会造成歌曲游玩记录偏移。服务器端可使用tools里的脚本进行更正,但是本地存档将无法更正。唯一解决方法为清空本地和云存档。敬请注意。
|
||||
|
||||
<details>
|
||||
<summary>细节</summary>
|
||||
<br>
|
||||
|
||||
由于一首歌曲重复,两首AC Alt误放至4MAX正常曲目中,歌曲列表发生了偏移。你的游玩记录会跟着偏移。其实有一个解决办法,就是把这三首位置留空,然后在本地文件和服务器端隐藏他们。不过,鉴于总曲目限制所剩无几(仅剩12首),以及所带来的代码更新会将4MAX服务器陷入自己的鸽子洞,造成开发的不愉快,我选择了牺牲老用户的存档。非常抱歉。
|
||||
|
||||
</details>
|
||||
|
||||
#### 更新
|
||||
|
||||
`GC4MAX` 扩展包会不定期接受bug修复更新。你可以在购买扩展包之后,通过游戏内的 `shop` - `songs 页面` - `GC4MAX 标题图片` 来查看最新的版本。为获取最新的版本更新,服务器应当不定期重启。
|
||||
@@ -463,6 +483,12 @@ PC用文本编辑器打开服务器文件夹的 `config.env`,将`IPV4`填写
|
||||
|
||||
下载网盘里`install packages`里的`apk`文件。安装。`安卓14+` 设备可能需要用`幸运破解器`重构APK (`Menu of Patches` - `Create Modified APK File` - `APK with changed permissions and activities` - 打开 `Removes integrity check and signature verification` 和 `Re-sign with original signature for android patch "Disable .apk Signature Verification"` 有人反馈中文路径如下:点 `破解菜单` 然后点 `已更改权限和活动项的APK文件` )。
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
(已获得授权,感谢@SaltNyaako)
|
||||
|
||||
此`apk`被修改过。若想自己修改,请看最后一段。
|
||||
|
||||
打开游戏的`obb`,密码是`eiprblFFv69R83J5`。提取全部文件。或者,从网盘下载`install packages/main.76.jp.co.taito.groovecoasterzero.obb`.
|
||||
|
||||
@@ -32,7 +32,7 @@ user = Table(
|
||||
Column("data", String, nullable=True),
|
||||
Column("save_id", String, nullable=True),
|
||||
Column("crc", Integer, nullable=True),
|
||||
Column("timestamp", DateTime, default=datetime.datetime.utcnow),
|
||||
Column("timestamp", DateTime, default=None),
|
||||
Column("coin_mp", Integer, default=1),
|
||||
)
|
||||
|
||||
|
||||
@@ -124,4 +124,10 @@ def inform_page(text, mode):
|
||||
elif mode == 3:
|
||||
mode = "/files/web/ttl_title.png"
|
||||
with open("files/inform.html", "r") as file:
|
||||
return file.read().format(text=text, img=mode)
|
||||
return file.read().format(text=text, img=mode)
|
||||
|
||||
def safe_int(val):
|
||||
try:
|
||||
return int(val)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
@@ -7,10 +7,10 @@ from sqlalchemy import select, update
|
||||
|
||||
from config import AUTHORIZATION_NEEDED, USE_REDIS_CACHE
|
||||
|
||||
import api.database
|
||||
from api.database import database, check_whitelist, check_blacklist, redis, result, daily_reward, user
|
||||
from api.crypt import decrypt_fields, encryptAES
|
||||
from api.templates import EXP_UNLOCKED_SONGS, TITLE_LISTS, SONG_LIST
|
||||
from api.misc import inform_page
|
||||
from api.misc import inform_page, safe_int
|
||||
|
||||
async def ranking(request: Request):
|
||||
decrypted_fields, _ = await decrypt_fields(request)
|
||||
@@ -19,7 +19,7 @@ async def ranking(request: Request):
|
||||
|
||||
should_serve = True
|
||||
if AUTHORIZATION_NEEDED:
|
||||
should_serve = await api.database.check_whitelist(decrypted_fields) and not await api.database.check_blacklist(decrypted_fields)
|
||||
should_serve = await check_whitelist(decrypted_fields) and not await check_blacklist(decrypted_fields)
|
||||
|
||||
if should_serve:
|
||||
device_id = decrypted_fields[b'vid'][0].decode()
|
||||
@@ -61,7 +61,7 @@ async def ranking_detail(request: Request):
|
||||
|
||||
should_serve = True
|
||||
if AUTHORIZATION_NEEDED:
|
||||
should_serve = await api.database.check_whitelist(decrypted_fields) and not await api.database.check_blacklist(decrypted_fields)
|
||||
should_serve = await check_whitelist(decrypted_fields) and not await check_blacklist(decrypted_fields)
|
||||
|
||||
if should_serve:
|
||||
device_id = decrypted_fields[b'vid'][0].decode()
|
||||
@@ -111,7 +111,7 @@ async def ranking_detail(request: Request):
|
||||
device_result = None
|
||||
if USE_REDIS_CACHE:
|
||||
cache_key = f"{song_id}-{mode}"
|
||||
cached = await api.database.redis.get(cache_key)
|
||||
cached = await redis.get(cache_key)
|
||||
else:
|
||||
cached = False
|
||||
|
||||
@@ -129,19 +129,19 @@ async def ranking_detail(request: Request):
|
||||
sorted_players = json.loads(cached)
|
||||
|
||||
else:
|
||||
query = select(api.database.result.c.vid, api.database.result.c.sid, api.database.result.c.mode, api.database.result.c.avatar, api.database.result.c.score)
|
||||
play_results = await api.database.database.fetch_all(query)
|
||||
query = select(result.c.vid, result.c.sid, result.c.mode, result.c.avatar, result.c.score)
|
||||
play_results = await database.fetch_all(query)
|
||||
|
||||
query = select(api.database.daily_reward.c.device_id, api.database.daily_reward.c.title, api.database.daily_reward.c.avatar)
|
||||
device_results_raw = await api.database.database.fetch_all(query)
|
||||
query = select(daily_reward.c.device_id, daily_reward.c.title, daily_reward.c.avatar)
|
||||
device_results_raw = await database.fetch_all(query)
|
||||
device_results = {row["device_id"]: {"title": row["title"], "avatar": row["avatar"]} for row in device_results_raw}
|
||||
|
||||
query = select(api.database.user.c.id, api.database.user.c.username, api.database.user.c.device_id)
|
||||
user_results_raw = await api.database.database.fetch_all(query)
|
||||
query = select(user.c.id, user.c.username, user.c.device_id)
|
||||
user_results_raw = await database.fetch_all(query)
|
||||
user_results = {row["id"]: {"username": row["username"], "device_id": row["device_id"]} for row in user_results_raw}
|
||||
|
||||
query = select(api.database.user).where(api.database.user.c.device_id == device_id)
|
||||
cur_user = await api.database.database.fetch_one(query)
|
||||
query = select(user).where(user.c.device_id == device_id)
|
||||
cur_user = await database.fetch_one(query)
|
||||
|
||||
player_scores = {}
|
||||
|
||||
@@ -174,7 +174,7 @@ async def ranking_detail(request: Request):
|
||||
|
||||
sorted_players = sorted(player_scores.items(), key=lambda x: x[1]["score"], reverse=True)
|
||||
if USE_REDIS_CACHE:
|
||||
await api.database.redis.set(cache_key, json.dumps(sorted_players), ex=300)
|
||||
await redis.set(cache_key, json.dumps(sorted_players), ex=300)
|
||||
|
||||
username = cur_user[1] if cur_user else f"Guest({device_id[-6:]})"
|
||||
|
||||
@@ -231,25 +231,25 @@ async def ranking_detail(request: Request):
|
||||
play_results = json.loads(cached)
|
||||
|
||||
else:
|
||||
query = select(api.database.result).where((api.database.result.c.id == song_id) & (api.database.result.c.mode == mode))
|
||||
play_results = await api.database.database.fetch_all(query)
|
||||
query = select(result).where((result.c.id == song_id) & (result.c.mode == mode))
|
||||
play_results = await database.fetch_all(query)
|
||||
play_results = sorted(play_results, key=lambda x: int(x[8]), reverse=True)
|
||||
if USE_REDIS_CACHE:
|
||||
await api.database.redis.set(cache_key, json.dumps(play_results), ex=300)
|
||||
await redis.set(cache_key, json.dumps(play_results), ex=300)
|
||||
|
||||
query = select(api.database.user).where(api.database.user.c.device_id == device_id)
|
||||
user_result = await api.database.database.fetch_one(query)
|
||||
query = select(user).where(user.c.device_id == device_id)
|
||||
user_result = await database.fetch_one(query)
|
||||
|
||||
query = select(api.database.daily_reward).where(api.database.daily_reward.c.device_id == device_id)
|
||||
device_result = await api.database.database.fetch_one(query)
|
||||
query = select(daily_reward).where(daily_reward.c.device_id == device_id)
|
||||
device_result = await database.fetch_one(query)
|
||||
|
||||
user_id = user_result[0] if user_result else None
|
||||
username = user_result[1] if user_result else f"Guest({device_id[-6:]})"
|
||||
play_record = None
|
||||
|
||||
if user_id:
|
||||
play_record = play_record = next(
|
||||
(record for record in play_results if record[3] not in (None, '') and int(record[3]) == user_id),
|
||||
play_record = next(
|
||||
(record for record in play_results if safe_int(record[3]) == user_id),
|
||||
None
|
||||
)
|
||||
|
||||
@@ -260,7 +260,7 @@ async def ranking_detail(request: Request):
|
||||
avatar_index = str(play_record[7]) if play_record else "1"
|
||||
user_score = play_record[8] if play_record else 0
|
||||
for rank, result_obj in enumerate(play_results, start=1):
|
||||
if user_result and result_obj[3] not in (None, '') and int(result_obj[3]) == user_id:
|
||||
if user_result and safe_int(result_obj[3]) == user_id:
|
||||
player_rank = rank
|
||||
break
|
||||
elif result_obj[1] == device_id and result_obj[3] in (None, ''):
|
||||
@@ -287,13 +287,13 @@ async def ranking_detail(request: Request):
|
||||
username = f"Guest({record[1][-6:]})"
|
||||
device_info = None
|
||||
if record[3]:
|
||||
query = select(api.database.user.c.username).where(api.database.user.c.id == record[3])
|
||||
user_data = await api.database.database.fetch_one(query)
|
||||
query = select(user.c.username).where(user.c.id == record[3])
|
||||
user_data = await database.fetch_one(query)
|
||||
if user_data:
|
||||
username = user_data["username"]
|
||||
|
||||
query = select(api.database.daily_reward.c.title).where(api.database.daily_reward.c.device_id == record[1])
|
||||
device_title = await api.database.database.fetch_one(query)
|
||||
query = select(daily_reward.c.title).where(daily_reward.c.device_id == record[1])
|
||||
device_title = await database.fetch_one(query)
|
||||
if device_title:
|
||||
device_info = device_title["title"]
|
||||
else:
|
||||
@@ -343,7 +343,7 @@ async def status(request: Request):
|
||||
|
||||
should_serve = True
|
||||
if AUTHORIZATION_NEEDED:
|
||||
should_serve = await api.database.check_whitelist(decrypted_fields) and not await api.database.check_blacklist(decrypted_fields)
|
||||
should_serve = await check_whitelist(decrypted_fields) and not await check_blacklist(decrypted_fields)
|
||||
|
||||
if should_serve:
|
||||
device_id = decrypted_fields[b'vid'][0].decode()
|
||||
@@ -352,19 +352,19 @@ async def status(request: Request):
|
||||
|
||||
if set_title:
|
||||
update_query = (
|
||||
update(api.database.daily_reward)
|
||||
.where(api.database.daily_reward.c.device_id == device_id)
|
||||
update(daily_reward)
|
||||
.where(daily_reward.c.device_id == device_id)
|
||||
.values(title=set_title)
|
||||
)
|
||||
await api.database.execute(update_query)
|
||||
await database.execute(update_query)
|
||||
|
||||
query = select(api.database.daily_reward).where(api.database.daily_reward.c.device_id == device_id)
|
||||
user_data = await api.database.database.fetch_one(query)
|
||||
query = select(daily_reward).where(daily_reward.c.device_id == device_id)
|
||||
user_data = await database.fetch_one(query)
|
||||
|
||||
user_name = f"Guest({device_id[-6:]})"
|
||||
if user_data:
|
||||
query = select(api.database.user.c.username).where(api.database.user.c.device_id == device_id)
|
||||
user_result = await api.database.database.fetch_one(query)
|
||||
query = select(user.c.username).where(user.c.device_id == device_id)
|
||||
user_result = await database.fetch_one(query)
|
||||
if user_result:
|
||||
user_name = user_result["username"]
|
||||
|
||||
@@ -438,7 +438,7 @@ async def set_title(request: Request):
|
||||
|
||||
should_serve = True
|
||||
if AUTHORIZATION_NEEDED:
|
||||
should_serve = await api.database.check_whitelist(decrypted_fields) and not await api.database.check_blacklist(decrypted_fields)
|
||||
should_serve = await check_whitelist(decrypted_fields) and not await check_blacklist(decrypted_fields)
|
||||
|
||||
if should_serve:
|
||||
device_id = decrypted_fields[b'vid'][0].decode()
|
||||
@@ -446,8 +446,8 @@ async def set_title(request: Request):
|
||||
title_id = decrypted_fields[b'title_id'][0].decode()
|
||||
current_title = 1
|
||||
|
||||
query = select(api.database.daily_reward.c.title).where(api.database.daily_reward.c.device_id == device_id)
|
||||
row = await api.database.database.fetch_one(query)
|
||||
query = select(daily_reward.c.title).where(daily_reward.c.device_id == device_id)
|
||||
row = await database.fetch_one(query)
|
||||
if row:
|
||||
current_title = row["title"]
|
||||
|
||||
@@ -480,7 +480,7 @@ async def mission(request: Request):
|
||||
|
||||
should_serve = True
|
||||
if AUTHORIZATION_NEEDED:
|
||||
should_serve = await api.database.check_whitelist(decrypted_fields) and not await api.database.check_blacklist(decrypted_fields)
|
||||
should_serve = await check_whitelist(decrypted_fields) and not await check_blacklist(decrypted_fields)
|
||||
|
||||
if should_serve:
|
||||
html = f"""<div class="f90 a_center pt50">Play Music to level up and unlock free songs!<br>Songs can only be unlocked when you play online.</div><div class='mission-list'>"""
|
||||
|
||||
15
api/shop.py
15
api/shop.py
@@ -91,7 +91,7 @@ async def web_shop(request: Request):
|
||||
if i not in my_stage and i not in EXCLUDE_STAGE_EXP:
|
||||
buttons_html += f"""
|
||||
<button style="width: 170px; height: 170px; margin: 10px; background-size: cover; background-image: url('/files/image/icon/shop/{i}.jpg');"
|
||||
onclick="window.location.href='wwic://web_shop_detail?&cnt_type={cnt_type}&cnt_id={i}'">
|
||||
onclick="window.location.href='wwic://web_shop_detail?&cnt_type={cnt_type}&cnt_id={i}&return_page={page}'">
|
||||
</button>
|
||||
"""
|
||||
inc += 1
|
||||
@@ -133,7 +133,7 @@ async def web_shop(request: Request):
|
||||
if i not in my_avatar and i not in EXCLUDE_STAGE_EXP:
|
||||
buttons_html += f"""
|
||||
<button style="width: 170px; height: 170px; margin: 10px; background-color: black; background-size: contain; background-repeat: no-repeat; background-position: center center; background-image: url('/files/image/icon/avatar/{i}.png');"
|
||||
onclick="window.location.href='wwic://web_shop_detail?&cnt_type={cnt_type}&cnt_id={i}'">
|
||||
onclick="window.location.href='wwic://web_shop_detail?&cnt_type={cnt_type}&cnt_id={i}&return_page={page}'">
|
||||
</button>
|
||||
"""
|
||||
inc += 1
|
||||
@@ -193,6 +193,10 @@ async def web_shop_detail(request: Request):
|
||||
cnt_type = decrypted_fields[b'cnt_type'][0].decode()
|
||||
cnt_id = int(decrypted_fields[b'cnt_id'][0].decode())
|
||||
device_id = decrypted_fields[b'vid'][0].decode()
|
||||
if b'return_page' in decrypted_fields:
|
||||
return_page = decrypted_fields[b'return_page'][0].decode()
|
||||
else:
|
||||
return_page = "1"
|
||||
|
||||
query = select(daily_reward.c.coin).where(daily_reward.c.device_id == device_id)
|
||||
result = await database.fetch_one(query)
|
||||
@@ -245,8 +249,9 @@ async def web_shop_detail(request: Request):
|
||||
elif cnt_id == -2:
|
||||
html = f"""
|
||||
<div class="text-content">
|
||||
<p>Brace the Ultimate - Extra - Challenge.</p>
|
||||
<p>170+ Arcade Extra difficulty charts await you.</p>
|
||||
<p>Are you looking for a bad time?</p>
|
||||
<p>If so, this is the Ultimate - Extra - Challenge.</p>
|
||||
<p>180+ Arcade Extra difficulty charts await you.</p>
|
||||
<p>You have been warned.</p>
|
||||
</div>
|
||||
|
||||
@@ -333,7 +338,7 @@ async def web_shop_detail(request: Request):
|
||||
<br>
|
||||
<div class="buttons" style="margin-top: 20px;">
|
||||
<a href="wwic://web_purchase_coin?cnt_type={cnt_type}&cnt_id={cnt_id}&num=1" class="bt_bg01" >Buy</a><br>
|
||||
<a href="wwic://web_shop?cnt_type={cnt_type}" class="bt_bg01" >Go Back</a>
|
||||
<a href="wwic://web_shop?cnt_type={cnt_type}&page={return_page}" class="bt_bg01" >Go Back</a>
|
||||
</div>
|
||||
"""
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import secrets
|
||||
from sqlalchemy import select, update, insert
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
from config import ROOT_FOLDER, START_COIN, AUTHORIZATION_NEEDED, HOST, PORT
|
||||
from config import ROOT_FOLDER, START_COIN, AUTHORIZATION_NEEDED, HOST, PORT, OVERRIDE_HOST
|
||||
|
||||
from api.misc import is_alphanumeric, inform_page, verify_password, hash_password, crc32_decimal, get_model_pak, get_tune_pak, get_skin_pak, get_m4a_path, get_stage_path, get_stage_zero
|
||||
from api.database import database, user, daily_reward, get_user_data, set_user_data, check_blacklist, check_whitelist
|
||||
@@ -331,7 +331,7 @@ async def start(request: Request):
|
||||
if not should_serve:
|
||||
return Response("""<response><code>403</code><message>Access denied.</message></response>""", media_type="application/xml")
|
||||
|
||||
host_string = "http://" + HOST + ":" + str(PORT) + "/"
|
||||
host_string = OVERRIDE_HOST if OVERRIDE_HOST is not None else ("http://" + HOST + ":" + str(PORT) + "/")
|
||||
device_id = decrypted_fields[b'vid'][0].decode()
|
||||
|
||||
for generator in [get_model_pak, get_tune_pak, get_skin_pak, get_m4a_path, get_stage_path]:
|
||||
|
||||
24
config.py
24
config.py
@@ -5,12 +5,15 @@ Do not change the name of this file.
|
||||
不要改动这个文件的名称。
|
||||
'''
|
||||
'''
|
||||
IP and port of the server.
|
||||
服务器的IP和端口。
|
||||
IP and port of the server FOR THE DOWNLOAD LINKS.
|
||||
下载链接:服务器的IP和端口。
|
||||
If you want to use a domain name, set it in OVERRIDE_HOST
|
||||
若想使用域名,请在OVERRIDE_HOST中设置。
|
||||
'''
|
||||
|
||||
HOST = "192.168.0.106"
|
||||
PORT = 9070
|
||||
PORT = 9068
|
||||
OVERRIDE_HOST = None
|
||||
|
||||
ACTUAL_HOST = "192.168.0.106"
|
||||
ACTUAL_PORT = 9068
|
||||
@@ -29,7 +32,7 @@ Datecode of the 3 pak files.
|
||||
'''
|
||||
|
||||
MODEL = "202504125800"
|
||||
TUNEFILE = "202504125800"
|
||||
TUNEFILE = "202507315816"
|
||||
SKIN = "202404191149"
|
||||
|
||||
'''
|
||||
@@ -42,6 +45,9 @@ ITEM_PRICE = 2
|
||||
COIN_REWARD = 1
|
||||
START_COIN = 10
|
||||
|
||||
FMAX_PRICE = 300
|
||||
EX_PRICE = 150
|
||||
|
||||
'''
|
||||
Only the whitelisted playerID can use the service. Blacklist has priority over whitelist.
|
||||
只有白名单的玩家ID才能使用服务。黑名单优先于白名单。
|
||||
@@ -56,11 +62,19 @@ SSL certificate path. If left blank, use HTTP.
|
||||
SSL_CERT = None
|
||||
SSL_KEY = None
|
||||
|
||||
'''
|
||||
是否开启批量下载功能
|
||||
Whether to enable batch download functionality.
|
||||
'''
|
||||
|
||||
BATCH_DOWNLOAD_ENABLED = True
|
||||
THREAD_COUNT = 3
|
||||
|
||||
'''
|
||||
Starlette default debug
|
||||
Starlette内置Debug
|
||||
'''
|
||||
|
||||
DEBUG = False
|
||||
DEBUG = True
|
||||
|
||||
ROOT_FOLDER = os.path.dirname(os.path.abspath(__file__))
|
||||
Reference in New Issue
Block a user