chore: publish stable media assets bundle
Some checks failed
release / release (push) Failing after 19s
Some checks failed
release / release (push) Failing after 19s
Add CI validation, zip packaging, and vlatest release publishing for user-facing media-assets.zip downloads. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
53
.gitea/workflows/release.yml
Normal file
53
.gitea/workflows/release.yml
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
name: release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
lfs: true
|
||||||
|
|
||||||
|
- name: Pull Git LFS
|
||||||
|
run: |
|
||||||
|
git lfs install
|
||||||
|
git lfs pull
|
||||||
|
|
||||||
|
- name: Validate source assets
|
||||||
|
run: python3 scripts/validate_bundle.py .
|
||||||
|
|
||||||
|
- name: Build stable zip
|
||||||
|
run: |
|
||||||
|
rm -rf dist
|
||||||
|
mkdir -p dist/media-assets
|
||||||
|
cp README.md manifest.json dist/media-assets/
|
||||||
|
cp -r music fonts watermark dist/media-assets/
|
||||||
|
cd dist
|
||||||
|
zip -r media-assets.zip media-assets
|
||||||
|
|
||||||
|
- name: Validate packaged zip content
|
||||||
|
run: |
|
||||||
|
rm -rf dist/verify
|
||||||
|
mkdir -p dist/verify
|
||||||
|
unzip -q dist/media-assets.zip -d dist/verify
|
||||||
|
python3 scripts/validate_bundle.py dist/verify/media-assets
|
||||||
|
|
||||||
|
- name: Publish stable release asset
|
||||||
|
env:
|
||||||
|
GITEA_SERVER_URL: https://git.jc2009.com
|
||||||
|
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
GITEA_REPOSITORY: client-commons/media-assets
|
||||||
|
GITEA_SHA: ${{ github.sha }}
|
||||||
|
run: |
|
||||||
|
python3 scripts/publish_gitea_release_asset.py \
|
||||||
|
--tag vlatest \
|
||||||
|
--title "media-assets latest" \
|
||||||
|
--file dist/media-assets.zip \
|
||||||
|
--asset-name media-assets.zip
|
||||||
34
README.md
34
README.md
@@ -2,7 +2,28 @@
|
|||||||
|
|
||||||
匠厂(深圳匠厂科技)跨项目共享媒体资产库。
|
匠厂(深圳匠厂科技)跨项目共享媒体资产库。
|
||||||
|
|
||||||
本仓库只维护**轻量媒体资源**与**工具下载 manifest**,供各技能(skill)的录制脚本使用。
|
本仓库是 **media-assets 源仓库**,只维护轻量媒体资源与工具下载 manifest,供各技能(skill)的录制脚本使用。
|
||||||
|
|
||||||
|
## 用户下载方式
|
||||||
|
|
||||||
|
**不要下载 Gitea 源码 ZIP。** 源码 ZIP 中的 Git LFS 文件可能是 pointer,无法直接作为背景音乐或字体使用。
|
||||||
|
|
||||||
|
用户端应下载 CI 生成的稳定包:
|
||||||
|
|
||||||
|
https://git.jc2009.com/client-commons/media-assets/releases/download/vlatest/media-assets.zip
|
||||||
|
|
||||||
|
### 稳定包内容
|
||||||
|
|
||||||
|
`media-assets.zip` 包含:
|
||||||
|
|
||||||
|
- `music/`
|
||||||
|
- `fonts/`
|
||||||
|
- `watermark/`
|
||||||
|
- `manifest.json`
|
||||||
|
|
||||||
|
`media-assets.zip` **不包含** `ffmpeg.exe` / `ffprobe.exe` 等二进制工具。
|
||||||
|
|
||||||
|
ffmpeg 由 `jiangchang-platform-kit` 按 `manifest.json` 里的 `tools.ffmpeg` 下载源另行下载、解压并缓存到用户数据目录。
|
||||||
|
|
||||||
## 仓库内容
|
## 仓库内容
|
||||||
|
|
||||||
@@ -44,13 +65,13 @@ D:\jiangchang-data\shared\media-assets\bin\win32-x64\
|
|||||||
说明:
|
说明:
|
||||||
|
|
||||||
- 用户**不需要**手工安装 ffmpeg,也**不需要**依赖系统 PATH。
|
- 用户**不需要**手工安装 ffmpeg,也**不需要**依赖系统 PATH。
|
||||||
- 用户电脑**没有 Git** 也可以工作:kit 会通过 HTTP ZIP 下载 media-assets 与 ffmpeg essentials 包。
|
- 用户电脑**没有 Git** 也可以工作:kit 会通过 HTTP 下载稳定 `media-assets.zip` 与 ffmpeg essentials 包。
|
||||||
- Windows x64 默认使用 **ffmpeg-release-essentials**(非 full build)。
|
- Windows x64 默认使用 **ffmpeg-release-essentials**(非 full build)。
|
||||||
- `manifest.json` 中配置了国内镜像与 gyan.dev 官方源;kit 会按顺序尝试。
|
- `manifest.json` 中配置了国内镜像与 gyan.dev 官方源;kit 会按顺序尝试。
|
||||||
|
|
||||||
## 背景音乐
|
## 背景音乐
|
||||||
|
|
||||||
背景音乐仍从本仓库的 `music/` 目录读取。各技能可通过 `jiangchang_skill_core.media_assets` 解析路径并选取音频文件。
|
背景音乐从稳定包或本仓库的 `music/` 目录读取。各技能可通过 `jiangchang_skill_core.media_assets` 解析路径并选取音频文件。
|
||||||
|
|
||||||
## 使用方式
|
## 使用方式
|
||||||
|
|
||||||
@@ -62,7 +83,12 @@ MEDIA_ASSETS_ROOT = os.environ.get("MEDIA_ASSETS_ROOT", r"D:\OpenClaw\client-com
|
|||||||
|
|
||||||
未设置环境变量时,kit 默认使用 `{JIANGCHANG_DATA_ROOT}/shared/media-assets`(例如 `D:\jiangchang-data\shared\media-assets`)。
|
未设置环境变量时,kit 默认使用 `{JIANGCHANG_DATA_ROOT}/shared/media-assets`(例如 `D:\jiangchang-data\shared\media-assets`)。
|
||||||
|
|
||||||
|
## 开发者说明
|
||||||
|
|
||||||
|
- MP3、字体、图片等媒体文件通过 **Git LFS** 管理,clone 后请执行 `git lfs pull`。
|
||||||
|
- 更新音乐后,推送 `main` 前请确认本地 mp3 不是 LFS pointer(可用 `python scripts/validate_bundle.py .` 校验)。
|
||||||
|
- `main` 分支 push 后,CI 会覆盖 `vlatest` release 下的 `media-assets.zip`,永远只保留一个稳定包。
|
||||||
|
|
||||||
## 注意
|
## 注意
|
||||||
|
|
||||||
- MP3、字体、图片等媒体文件通过 **Git LFS** 管理,clone 前请确认已安装 `git lfs`。
|
|
||||||
- 请勿将版权不明的音乐文件上传至本库。
|
- 请勿将版权不明的音乐文件上传至本库。
|
||||||
|
|||||||
241
scripts/publish_gitea_release_asset.py
Normal file
241
scripts/publish_gitea_release_asset.py
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Publish or replace a fixed release asset on Gitea."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import mimetypes
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def _fail(message: str, *, status: int | None = None, body: str | None = None) -> None:
|
||||||
|
print(f"ERROR: {message}", file=sys.stderr)
|
||||||
|
if status is not None:
|
||||||
|
print(f"HTTP status: {status}", file=sys.stderr)
|
||||||
|
if body:
|
||||||
|
print(f"Response body: {body}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def _request(
|
||||||
|
method: str,
|
||||||
|
url: str,
|
||||||
|
token: str,
|
||||||
|
*,
|
||||||
|
data: bytes | None = None,
|
||||||
|
headers: dict[str, str] | None = None,
|
||||||
|
content_type: str | None = None,
|
||||||
|
) -> tuple[int, str]:
|
||||||
|
req_headers = {"Authorization": f"token {token}"}
|
||||||
|
if headers:
|
||||||
|
req_headers.update(headers)
|
||||||
|
if content_type:
|
||||||
|
req_headers["Content-Type"] = content_type
|
||||||
|
|
||||||
|
request = urllib.request.Request(url, data=data, headers=req_headers, method=method)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(request) as response:
|
||||||
|
body = response.read().decode("utf-8", errors="replace")
|
||||||
|
return response.status, body
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
body = exc.read().decode("utf-8", errors="replace")
|
||||||
|
return exc.code, body
|
||||||
|
|
||||||
|
|
||||||
|
def _api_base(server_url: str, repository: str) -> str:
|
||||||
|
base = server_url.rstrip("/")
|
||||||
|
owner, repo = repository.split("/", 1)
|
||||||
|
return f"{base}/api/v1/repos/{owner}/{repo}"
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_json(body: str) -> dict | list:
|
||||||
|
if not body.strip():
|
||||||
|
return {}
|
||||||
|
return json.loads(body)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_release_by_tag(api_base: str, token: str, tag: str) -> dict | None:
|
||||||
|
url = f"{api_base}/releases/tags/{urllib.parse.quote(tag)}"
|
||||||
|
status, body = _request("GET", url, token)
|
||||||
|
if status == 404:
|
||||||
|
return None
|
||||||
|
if status >= 400:
|
||||||
|
_fail(f"failed to query release for tag {tag}", status=status, body=body)
|
||||||
|
return _parse_json(body) # type: ignore[return-value]
|
||||||
|
|
||||||
|
|
||||||
|
def _create_release(
|
||||||
|
api_base: str,
|
||||||
|
token: str,
|
||||||
|
*,
|
||||||
|
tag: str,
|
||||||
|
title: str,
|
||||||
|
target_commitish: str | None,
|
||||||
|
) -> dict:
|
||||||
|
payload: dict[str, object] = {
|
||||||
|
"tag_name": tag,
|
||||||
|
"name": title,
|
||||||
|
"body": "Stable media-assets bundle generated by CI.",
|
||||||
|
"draft": False,
|
||||||
|
"prerelease": False,
|
||||||
|
}
|
||||||
|
if target_commitish:
|
||||||
|
payload["target_commitish"] = target_commitish
|
||||||
|
|
||||||
|
url = f"{api_base}/releases"
|
||||||
|
status, body = _request(
|
||||||
|
"POST",
|
||||||
|
url,
|
||||||
|
token,
|
||||||
|
data=json.dumps(payload).encode("utf-8"),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
if status >= 400:
|
||||||
|
hint = ""
|
||||||
|
if "tag" in body.lower() or status in (404, 422):
|
||||||
|
hint = " 请先创建或允许 CI 推送固定 tag vlatest"
|
||||||
|
_fail(
|
||||||
|
f"failed to create release for tag {tag}.{hint}",
|
||||||
|
status=status,
|
||||||
|
body=body,
|
||||||
|
)
|
||||||
|
return _parse_json(body) # type: ignore[return-value]
|
||||||
|
|
||||||
|
|
||||||
|
def _list_release_assets(api_base: str, token: str, release_id: int) -> list[dict]:
|
||||||
|
url = f"{api_base}/releases/{release_id}/assets"
|
||||||
|
status, body = _request("GET", url, token)
|
||||||
|
if status >= 400:
|
||||||
|
_fail(f"failed to list assets for release {release_id}", status=status, body=body)
|
||||||
|
assets = _parse_json(body)
|
||||||
|
if not isinstance(assets, list):
|
||||||
|
_fail("unexpected assets response shape", body=body)
|
||||||
|
return assets
|
||||||
|
|
||||||
|
|
||||||
|
def _delete_asset(api_base: str, token: str, asset_id: int) -> None:
|
||||||
|
url = f"{api_base}/releases/assets/{asset_id}"
|
||||||
|
status, body = _request("DELETE", url, token)
|
||||||
|
if status >= 400:
|
||||||
|
_fail(f"failed to delete asset {asset_id}", status=status, body=body)
|
||||||
|
|
||||||
|
|
||||||
|
def _upload_asset(
|
||||||
|
api_base: str,
|
||||||
|
token: str,
|
||||||
|
release_id: int,
|
||||||
|
*,
|
||||||
|
asset_name: str,
|
||||||
|
file_path: Path,
|
||||||
|
) -> dict:
|
||||||
|
boundary = "----jiangchang-media-assets-boundary"
|
||||||
|
file_bytes = file_path.read_bytes()
|
||||||
|
content_type = mimetypes.guess_type(asset_name)[0] or "application/octet-stream"
|
||||||
|
|
||||||
|
parts: list[bytes] = []
|
||||||
|
parts.append(f"--{boundary}\r\n".encode("utf-8"))
|
||||||
|
parts.append(
|
||||||
|
(
|
||||||
|
f'Content-Disposition: form-data; name="attachment"; filename="{asset_name}"\r\n'
|
||||||
|
f"Content-Type: {content_type}\r\n\r\n"
|
||||||
|
).encode("utf-8")
|
||||||
|
)
|
||||||
|
parts.append(file_bytes)
|
||||||
|
parts.append(b"\r\n")
|
||||||
|
parts.append(f"--{boundary}--\r\n".encode("utf-8"))
|
||||||
|
payload = b"".join(parts)
|
||||||
|
|
||||||
|
query = urllib.parse.urlencode({"name": asset_name})
|
||||||
|
url = f"{api_base}/releases/{release_id}/assets?{query}"
|
||||||
|
status, body = _request(
|
||||||
|
"POST",
|
||||||
|
url,
|
||||||
|
token,
|
||||||
|
data=payload,
|
||||||
|
content_type=f"multipart/form-data; boundary={boundary}",
|
||||||
|
)
|
||||||
|
if status >= 400:
|
||||||
|
_fail(
|
||||||
|
f"failed to upload asset {asset_name} to release {release_id}",
|
||||||
|
status=status,
|
||||||
|
body=body,
|
||||||
|
)
|
||||||
|
return _parse_json(body) # type: ignore[return-value]
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
parser = argparse.ArgumentParser(description="Publish a fixed Gitea release asset.")
|
||||||
|
parser.add_argument("--tag", required=True, help="Release tag, e.g. vlatest")
|
||||||
|
parser.add_argument("--title", required=True, help="Release title")
|
||||||
|
parser.add_argument("--file", required=True, help="Local asset file path")
|
||||||
|
parser.add_argument("--asset-name", required=True, help="Asset name in release")
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
|
server_url = os.environ.get("GITEA_SERVER_URL", "").strip()
|
||||||
|
token = os.environ.get("GITEA_TOKEN", "").strip()
|
||||||
|
repository = os.environ.get("GITEA_REPOSITORY", "").strip()
|
||||||
|
target_commitish = os.environ.get("GITEA_SHA", "").strip() or None
|
||||||
|
|
||||||
|
if not server_url:
|
||||||
|
_fail("missing environment variable GITEA_SERVER_URL")
|
||||||
|
if not token:
|
||||||
|
_fail("missing environment variable GITEA_TOKEN")
|
||||||
|
if not repository or "/" not in repository:
|
||||||
|
_fail("missing or invalid environment variable GITEA_REPOSITORY")
|
||||||
|
|
||||||
|
file_path = Path(args.file)
|
||||||
|
if not file_path.is_file():
|
||||||
|
_fail(f"asset file not found: {file_path}")
|
||||||
|
|
||||||
|
api_base = _api_base(server_url, repository)
|
||||||
|
release = _get_release_by_tag(api_base, token, args.tag)
|
||||||
|
if release is None:
|
||||||
|
print(f"Release for tag {args.tag} not found; creating...")
|
||||||
|
release = _create_release(
|
||||||
|
api_base,
|
||||||
|
token,
|
||||||
|
tag=args.tag,
|
||||||
|
title=args.title,
|
||||||
|
target_commitish=target_commitish,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
print(f"Reusing existing release for tag {args.tag} (id={release['id']})")
|
||||||
|
|
||||||
|
release_id = int(release["id"])
|
||||||
|
assets = _list_release_assets(api_base, token, release_id)
|
||||||
|
for asset in assets:
|
||||||
|
if asset.get("name") == args.asset_name:
|
||||||
|
asset_id = int(asset["id"])
|
||||||
|
print(f"Deleting existing asset {args.asset_name} (id={asset_id})...")
|
||||||
|
_delete_asset(api_base, token, asset_id)
|
||||||
|
|
||||||
|
print(f"Uploading {file_path} as {args.asset_name}...")
|
||||||
|
uploaded = _upload_asset(
|
||||||
|
api_base,
|
||||||
|
token,
|
||||||
|
release_id,
|
||||||
|
asset_name=args.asset_name,
|
||||||
|
file_path=file_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
download_url = uploaded.get("browser_download_url") or uploaded.get("download_url")
|
||||||
|
if not download_url:
|
||||||
|
owner, repo = repository.split("/", 1)
|
||||||
|
download_url = (
|
||||||
|
f"{server_url.rstrip('/')}/{owner}/{repo}/releases/download/"
|
||||||
|
f"{args.tag}/{urllib.parse.quote(args.asset_name)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"OK: uploaded release asset {args.asset_name}")
|
||||||
|
print(f"Download URL: {download_url}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
104
scripts/validate_bundle.py
Normal file
104
scripts/validate_bundle.py
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Validate a media-assets bundle directory before packaging or release."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
LFS_POINTER_PREFIX = b"version https://git-lfs.github.com/spec/v1"
|
||||||
|
MIN_MP3_BYTES = 100 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
def _fail(message: str) -> None:
|
||||||
|
print(f"ERROR: {message}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_lfs_pointer(path: Path) -> bool:
|
||||||
|
with path.open("rb") as handle:
|
||||||
|
header = handle.read(256)
|
||||||
|
return header.startswith(LFS_POINTER_PREFIX)
|
||||||
|
|
||||||
|
|
||||||
|
def _require_path(root: Path, relative: str, *, must_be_dir: bool = False) -> Path:
|
||||||
|
target = root / relative
|
||||||
|
if not target.exists():
|
||||||
|
_fail(f"missing required path: {relative}")
|
||||||
|
if must_be_dir and not target.is_dir():
|
||||||
|
_fail(f"expected directory: {relative}")
|
||||||
|
if not must_be_dir and not target.is_file():
|
||||||
|
_fail(f"expected file: {relative}")
|
||||||
|
return target
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_manifest(manifest_path: Path) -> None:
|
||||||
|
try:
|
||||||
|
data = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
_fail(f"manifest.json is not valid JSON: {exc}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
win32 = data["tools"]["ffmpeg"]["win32-x64"]
|
||||||
|
urls = win32["urls"]
|
||||||
|
archive_paths = win32["archive_paths"]
|
||||||
|
except (KeyError, TypeError) as exc:
|
||||||
|
_fail(f"manifest.json missing required tools.ffmpeg.win32-x64 fields: {exc}")
|
||||||
|
|
||||||
|
if not isinstance(urls, list) or not urls:
|
||||||
|
_fail("manifest.json tools.ffmpeg.win32-x64.urls must be a non-empty array")
|
||||||
|
|
||||||
|
for key in ("ffmpeg", "ffprobe"):
|
||||||
|
if key not in archive_paths or not archive_paths[key]:
|
||||||
|
_fail(f"manifest.json missing tools.ffmpeg.win32-x64.archive_paths.{key}")
|
||||||
|
|
||||||
|
|
||||||
|
def validate_bundle(root: Path) -> list[tuple[Path, int]]:
|
||||||
|
root = root.resolve()
|
||||||
|
if not root.is_dir():
|
||||||
|
_fail(f"bundle root is not a directory: {root}")
|
||||||
|
|
||||||
|
_require_path(root, "README.md")
|
||||||
|
manifest_path = _require_path(root, "manifest.json")
|
||||||
|
_require_path(root, "music", must_be_dir=True)
|
||||||
|
_require_path(root, "fonts", must_be_dir=True)
|
||||||
|
_require_path(root, "watermark", must_be_dir=True)
|
||||||
|
|
||||||
|
mp3_files = sorted(root.joinpath("music").rglob("*.mp3"))
|
||||||
|
if not mp3_files:
|
||||||
|
_fail("music/ must contain at least one .mp3 file")
|
||||||
|
|
||||||
|
mp3_stats: list[tuple[Path, int]] = []
|
||||||
|
for mp3_path in mp3_files:
|
||||||
|
size = mp3_path.stat().st_size
|
||||||
|
if _is_lfs_pointer(mp3_path):
|
||||||
|
rel = mp3_path.relative_to(root)
|
||||||
|
_fail(f"{rel} is a Git LFS pointer, not a real mp3 file")
|
||||||
|
if size <= MIN_MP3_BYTES:
|
||||||
|
rel = mp3_path.relative_to(root)
|
||||||
|
_fail(f"{rel} is too small ({size} bytes); expected > {MIN_MP3_BYTES} bytes")
|
||||||
|
mp3_stats.append((mp3_path.relative_to(root), size))
|
||||||
|
|
||||||
|
_validate_manifest(manifest_path)
|
||||||
|
return mp3_stats
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
args = argv if argv is not None else sys.argv[1:]
|
||||||
|
if len(args) != 1:
|
||||||
|
print("Usage: python scripts/validate_bundle.py <bundle-root>", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
root = Path(args[0])
|
||||||
|
mp3_stats = validate_bundle(root)
|
||||||
|
|
||||||
|
print("OK: media-assets bundle validation passed")
|
||||||
|
print(f"mp3 count: {len(mp3_stats)}")
|
||||||
|
for rel_path, size in mp3_stats:
|
||||||
|
print(f" - {rel_path.as_posix()}: {size} bytes")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
Reference in New Issue
Block a user