Add asset proxy feature for resource URLs
Introduces asset proxy configuration and services to enable replacement of osu! resource URLs with custom domains. Updates API endpoints and caching services to process and rewrite resource URLs when asset proxy is enabled. Adds documentation and environment variables for asset proxy setup.
This commit is contained in:
@@ -117,3 +117,8 @@ STORAGE_SERVICE="local"
|
|||||||
# "s3_bucket_name": "your_s3_bucket_name",
|
# "s3_bucket_name": "your_s3_bucket_name",
|
||||||
# "s3_region_name": "us-east-1",
|
# "s3_region_name": "us-east-1",
|
||||||
# "s3_public_url_base": "https://your-custom
|
# "s3_public_url_base": "https://your-custom
|
||||||
|
|
||||||
|
# 启用资源代理功能
|
||||||
|
ENABLE_ASSET_PROXY=true
|
||||||
|
# 自定义资源域名
|
||||||
|
CUSTOM_ASSET_DOMAIN=assets-ppy.g0v0.top
|
||||||
@@ -49,6 +49,11 @@ docker-compose -f docker-compose-osurx.yml up -d
|
|||||||
|
|
||||||
参考[数据库迁移指南](https://github.com/GooGuTeam/g0v0-server/wiki/Migrate-Database)
|
参考[数据库迁移指南](https://github.com/GooGuTeam/g0v0-server/wiki/Migrate-Database)
|
||||||
|
|
||||||
|
## 资源文件反向代理
|
||||||
|
|
||||||
|
服务器支持资源文件反向代理功能,可以将 osu! 官方的资源链接(头像、谱面封面、音频等)替换为自定义域名。
|
||||||
|
|
||||||
|
|
||||||
## 许可证
|
## 许可证
|
||||||
|
|
||||||
MIT License
|
MIT License
|
||||||
|
|||||||
@@ -167,6 +167,13 @@ class Settings(BaseSettings):
|
|||||||
user_cache_max_preload_users: int = 200 # 最多预加载的用户数量
|
user_cache_max_preload_users: int = 200 # 最多预加载的用户数量
|
||||||
user_cache_concurrent_limit: int = 10 # 并发缓存用户的限制
|
user_cache_concurrent_limit: int = 10 # 并发缓存用户的限制
|
||||||
|
|
||||||
|
# 资源代理设置
|
||||||
|
enable_asset_proxy: bool = True # 启用资源代理功能
|
||||||
|
custom_asset_domain: str = "g0v0.top" # 自定义资源域名
|
||||||
|
asset_proxy_prefix: str = "assets-ppy" # assets.ppy.sh的自定义前缀
|
||||||
|
avatar_proxy_prefix: str = "a-ppy" # a.ppy.sh的自定义前缀
|
||||||
|
beatmap_proxy_prefix: str = "b-ppy" # b.ppy.sh的自定义前缀
|
||||||
|
|
||||||
# 反作弊设置
|
# 反作弊设置
|
||||||
suspicious_score_check: bool = True
|
suspicious_score_check: bool = True
|
||||||
banned_name: list[str] = [
|
banned_name: list[str] = [
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ class UserBase(UTCBaseModel, SQLModel):
|
|||||||
is_restricted: bool = False
|
is_restricted: bool = False
|
||||||
# blocks
|
# blocks
|
||||||
cover: UserProfileCover = Field(
|
cover: UserProfileCover = Field(
|
||||||
default=UserProfileCover(url="https://assets.ppy.sh/user-profile-covers/default.jpeg"),
|
default=UserProfileCover(url=""),
|
||||||
sa_column=Column(JSON),
|
sa_column=Column(JSON),
|
||||||
)
|
)
|
||||||
beatmap_playcounts_count: int = 0
|
beatmap_playcounts_count: int = 0
|
||||||
@@ -292,9 +292,9 @@ class UserResp(UserBase):
|
|||||||
redis = get_redis()
|
redis = get_redis()
|
||||||
u.is_online = await redis.exists(f"metadata:online:{obj.id}")
|
u.is_online = await redis.exists(f"metadata:online:{obj.id}")
|
||||||
u.cover_url = (
|
u.cover_url = (
|
||||||
obj.cover.get("url", "https://assets.ppy.sh/user-profile-covers/default.jpeg")
|
obj.cover.get("url", "")
|
||||||
if obj.cover
|
if obj.cover
|
||||||
else "https://assets.ppy.sh/user-profile-covers/default.jpeg"
|
else ""
|
||||||
)
|
)
|
||||||
|
|
||||||
if "friends" in include:
|
if "friends" in include:
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from app.dependencies.user import get_client_user, get_current_user
|
|||||||
from app.fetcher import Fetcher
|
from app.fetcher import Fetcher
|
||||||
from app.models.beatmap import SearchQueryModel
|
from app.models.beatmap import SearchQueryModel
|
||||||
from app.service.beatmap_download_service import BeatmapDownloadService
|
from app.service.beatmap_download_service import BeatmapDownloadService
|
||||||
|
from app.service.asset_proxy_helper import process_response_assets
|
||||||
|
|
||||||
from .router import router
|
from .router import router
|
||||||
|
|
||||||
@@ -96,9 +97,12 @@ async def search_beatmapset(
|
|||||||
try:
|
try:
|
||||||
sets = await fetcher.search_beatmapset(query, cursor, redis)
|
sets = await fetcher.search_beatmapset(query, cursor, redis)
|
||||||
background_tasks.add_task(_save_to_db, sets)
|
background_tasks.add_task(_save_to_db, sets)
|
||||||
|
|
||||||
|
# 处理资源代理
|
||||||
|
processed_sets = await process_response_assets(sets, request)
|
||||||
|
return processed_sets
|
||||||
except HTTPError as e:
|
except HTTPError as e:
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
return sets
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
@@ -110,13 +114,17 @@ async def search_beatmapset(
|
|||||||
)
|
)
|
||||||
async def lookup_beatmapset(
|
async def lookup_beatmapset(
|
||||||
db: Database,
|
db: Database,
|
||||||
|
request: Request,
|
||||||
beatmap_id: int = Query(description="谱面 ID"),
|
beatmap_id: int = Query(description="谱面 ID"),
|
||||||
current_user: User = Security(get_current_user, scopes=["public"]),
|
current_user: User = Security(get_current_user, scopes=["public"]),
|
||||||
fetcher: Fetcher = Depends(get_fetcher),
|
fetcher: Fetcher = Depends(get_fetcher),
|
||||||
):
|
):
|
||||||
beatmap = await Beatmap.get_or_fetch(db, fetcher, bid=beatmap_id)
|
beatmap = await Beatmap.get_or_fetch(db, fetcher, bid=beatmap_id)
|
||||||
resp = await BeatmapsetResp.from_db(beatmap.beatmapset, session=db, user=current_user)
|
resp = await BeatmapsetResp.from_db(beatmap.beatmapset, session=db, user=current_user)
|
||||||
return resp
|
|
||||||
|
# 处理资源代理
|
||||||
|
processed_resp = await process_response_assets(resp, request)
|
||||||
|
return processed_resp
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
|
|||||||
@@ -22,11 +22,12 @@ from app.log import logger
|
|||||||
from app.models.score import GameMode
|
from app.models.score import GameMode
|
||||||
from app.models.user import BeatmapsetType
|
from app.models.user import BeatmapsetType
|
||||||
from app.service.user_cache_service import get_user_cache_service
|
from app.service.user_cache_service import get_user_cache_service
|
||||||
|
from app.service.asset_proxy_helper import process_response_assets
|
||||||
from app.utils import utcnow
|
from app.utils import utcnow
|
||||||
|
|
||||||
from .router import router
|
from .router import router
|
||||||
|
|
||||||
from fastapi import BackgroundTasks, HTTPException, Path, Query, Security
|
from fastapi import BackgroundTasks, HTTPException, Path, Query, Request, Security
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlmodel import exists, false, select
|
from sqlmodel import exists, false, select
|
||||||
from sqlmodel.sql.expression import col
|
from sqlmodel.sql.expression import col
|
||||||
@@ -47,6 +48,7 @@ class BatchUserResponse(BaseModel):
|
|||||||
@router.get("/users/lookup/", response_model=BatchUserResponse, include_in_schema=False)
|
@router.get("/users/lookup/", response_model=BatchUserResponse, include_in_schema=False)
|
||||||
async def get_users(
|
async def get_users(
|
||||||
session: Database,
|
session: Database,
|
||||||
|
request: Request,
|
||||||
background_task: BackgroundTasks,
|
background_task: BackgroundTasks,
|
||||||
user_ids: list[int] = Query(default_factory=list, alias="ids[]", description="要查询的用户 ID 列表"),
|
user_ids: list[int] = Query(default_factory=list, alias="ids[]", description="要查询的用户 ID 列表"),
|
||||||
# current_user: User = Security(get_current_user, scopes=["public"]),
|
# current_user: User = Security(get_current_user, scopes=["public"]),
|
||||||
@@ -83,7 +85,10 @@ async def get_users(
|
|||||||
# 异步缓存,不阻塞响应
|
# 异步缓存,不阻塞响应
|
||||||
background_task.add_task(cache_service.cache_user, user_resp)
|
background_task.add_task(cache_service.cache_user, user_resp)
|
||||||
|
|
||||||
return BatchUserResponse(users=cached_users)
|
# 处理资源代理
|
||||||
|
response = BatchUserResponse(users=cached_users)
|
||||||
|
processed_response = await process_response_assets(response, request)
|
||||||
|
return processed_response
|
||||||
else:
|
else:
|
||||||
searched_users = (await session.exec(select(User).limit(50))).all()
|
searched_users = (await session.exec(select(User).limit(50))).all()
|
||||||
users = []
|
users = []
|
||||||
@@ -98,7 +103,10 @@ async def get_users(
|
|||||||
# 异步缓存
|
# 异步缓存
|
||||||
background_task.add_task(cache_service.cache_user, user_resp)
|
background_task.add_task(cache_service.cache_user, user_resp)
|
||||||
|
|
||||||
return BatchUserResponse(users=users)
|
# 处理资源代理
|
||||||
|
response = BatchUserResponse(users=users)
|
||||||
|
processed_response = await process_response_assets(response, request)
|
||||||
|
return processed_response
|
||||||
|
|
||||||
|
|
||||||
@router.get("/users/{user}/recent_activity", tags=["用户"], response_model=list[Event])
|
@router.get("/users/{user}/recent_activity", tags=["用户"], response_model=list[Event])
|
||||||
@@ -182,6 +190,7 @@ async def get_user_info_ruleset(
|
|||||||
async def get_user_info(
|
async def get_user_info(
|
||||||
background_task: BackgroundTasks,
|
background_task: BackgroundTasks,
|
||||||
session: Database,
|
session: Database,
|
||||||
|
request: Request,
|
||||||
user_id: str = Path(description="用户 ID 或用户名"),
|
user_id: str = Path(description="用户 ID 或用户名"),
|
||||||
# current_user: User = Security(get_current_user, scopes=["public"]),
|
# current_user: User = Security(get_current_user, scopes=["public"]),
|
||||||
):
|
):
|
||||||
@@ -193,7 +202,9 @@ async def get_user_info(
|
|||||||
user_id_int = int(user_id)
|
user_id_int = int(user_id)
|
||||||
cached_user = await cache_service.get_user_from_cache(user_id_int)
|
cached_user = await cache_service.get_user_from_cache(user_id_int)
|
||||||
if cached_user:
|
if cached_user:
|
||||||
return cached_user
|
# 处理资源代理
|
||||||
|
processed_user = await process_response_assets(cached_user, request)
|
||||||
|
return processed_user
|
||||||
|
|
||||||
searched_user = (
|
searched_user = (
|
||||||
await session.exec(
|
await session.exec(
|
||||||
@@ -214,7 +225,9 @@ async def get_user_info(
|
|||||||
# 异步缓存结果
|
# 异步缓存结果
|
||||||
background_task.add_task(cache_service.cache_user, user_resp)
|
background_task.add_task(cache_service.cache_user, user_resp)
|
||||||
|
|
||||||
return user_resp
|
# 处理资源代理
|
||||||
|
processed_user = await process_response_assets(user_resp, request)
|
||||||
|
return processed_user
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
|
|||||||
76
app/service/asset_proxy_helper.py
Normal file
76
app/service/asset_proxy_helper.py
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
"""
|
||||||
|
资源代理辅助函数和中间件
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
from fastapi import Request
|
||||||
|
from app.config import settings
|
||||||
|
from app.service.asset_proxy_service import get_asset_proxy_service
|
||||||
|
|
||||||
|
|
||||||
|
async def process_response_assets(data: Any, request: Request) -> Any:
|
||||||
|
"""
|
||||||
|
根据配置处理响应数据中的资源URL
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: API响应数据
|
||||||
|
request: FastAPI请求对象
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
处理后的数据
|
||||||
|
"""
|
||||||
|
if not settings.enable_asset_proxy:
|
||||||
|
return data
|
||||||
|
|
||||||
|
asset_service = get_asset_proxy_service()
|
||||||
|
|
||||||
|
# 仅URL替换模式
|
||||||
|
return await asset_service.replace_asset_urls(data)
|
||||||
|
|
||||||
|
|
||||||
|
def should_process_asset_proxy(path: str) -> bool:
|
||||||
|
"""
|
||||||
|
判断路径是否需要处理资源代理
|
||||||
|
"""
|
||||||
|
# 只对特定的API端点处理资源代理
|
||||||
|
asset_proxy_endpoints = [
|
||||||
|
"/api/v1/users/",
|
||||||
|
"/api/v2/users/",
|
||||||
|
"/api/v1/me/",
|
||||||
|
"/api/v2/me/",
|
||||||
|
"/api/v2/beatmapsets/search",
|
||||||
|
"/api/v2/beatmapsets/lookup",
|
||||||
|
"/api/v2/beatmaps/",
|
||||||
|
"/api/v1/beatmaps/",
|
||||||
|
"/api/v2/beatmapsets/",
|
||||||
|
# 可以根据需要添加更多端点
|
||||||
|
]
|
||||||
|
|
||||||
|
return any(path.startswith(endpoint) for endpoint in asset_proxy_endpoints)
|
||||||
|
|
||||||
|
|
||||||
|
# 响应处理装饰器
|
||||||
|
def asset_proxy_response(func):
|
||||||
|
"""
|
||||||
|
装饰器:自动处理响应中的资源URL
|
||||||
|
"""
|
||||||
|
async def wrapper(*args, **kwargs):
|
||||||
|
# 获取request对象
|
||||||
|
request = None
|
||||||
|
for arg in args:
|
||||||
|
if isinstance(arg, Request):
|
||||||
|
request = arg
|
||||||
|
break
|
||||||
|
|
||||||
|
# 执行原函数
|
||||||
|
result = await func(*args, **kwargs)
|
||||||
|
|
||||||
|
# 如果有request对象且启用了资源代理,则处理响应
|
||||||
|
if request and settings.enable_asset_proxy and should_process_asset_proxy(request.url.path):
|
||||||
|
result = await process_response_assets(result, request)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
return wrapper
|
||||||
92
app/service/asset_proxy_service.py
Normal file
92
app/service/asset_proxy_service.py
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
"""
|
||||||
|
资源文件代理服务
|
||||||
|
提供URL替换方案:将osu!官方资源URL替换为自定义域名
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from typing import Any
|
||||||
|
from app.config import settings
|
||||||
|
from app.log import logger
|
||||||
|
|
||||||
|
|
||||||
|
class AssetProxyService:
|
||||||
|
"""资源代理服务 - 仅URL替换模式"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
# 从配置获取自定义assets域名和前缀
|
||||||
|
self.custom_asset_domain = settings.custom_asset_domain
|
||||||
|
self.asset_proxy_prefix = settings.asset_proxy_prefix
|
||||||
|
self.avatar_proxy_prefix = settings.avatar_proxy_prefix
|
||||||
|
self.beatmap_proxy_prefix = settings.beatmap_proxy_prefix
|
||||||
|
|
||||||
|
async def replace_asset_urls(self, data: Any) -> Any:
|
||||||
|
"""
|
||||||
|
递归替换数据中的osu!资源URL为自定义域名
|
||||||
|
"""
|
||||||
|
# 处理Pydantic模型
|
||||||
|
if hasattr(data, 'model_dump'):
|
||||||
|
# 转换为字典,处理后再转换回模型
|
||||||
|
data_dict = data.model_dump()
|
||||||
|
processed_dict = await self.replace_asset_urls(data_dict)
|
||||||
|
# 尝试从字典重新创建模型
|
||||||
|
try:
|
||||||
|
return data.__class__(**processed_dict)
|
||||||
|
except Exception:
|
||||||
|
# 如果重新创建失败,返回字典
|
||||||
|
return processed_dict
|
||||||
|
elif isinstance(data, dict):
|
||||||
|
result = {}
|
||||||
|
for key, value in data.items():
|
||||||
|
result[key] = await self.replace_asset_urls(value)
|
||||||
|
return result
|
||||||
|
elif isinstance(data, list):
|
||||||
|
return [await self.replace_asset_urls(item) for item in data]
|
||||||
|
elif isinstance(data, str):
|
||||||
|
# 替换各种osu!资源域名
|
||||||
|
result = data
|
||||||
|
|
||||||
|
# 替换 assets.ppy.sh (用户头像、封面、奖章等)
|
||||||
|
result = re.sub(
|
||||||
|
r"https://assets\.ppy\.sh/",
|
||||||
|
f"https://{self.asset_proxy_prefix}.{self.custom_asset_domain}/",
|
||||||
|
result
|
||||||
|
)
|
||||||
|
|
||||||
|
# 替换 b.ppy.sh 预览音频 (保持//前缀)
|
||||||
|
result = re.sub(
|
||||||
|
r"//b\.ppy\.sh/",
|
||||||
|
f"//{self.beatmap_proxy_prefix}.{self.custom_asset_domain}/",
|
||||||
|
result
|
||||||
|
)
|
||||||
|
|
||||||
|
# 替换 https://b.ppy.sh 预览音频 (转换为//前缀)
|
||||||
|
result = re.sub(
|
||||||
|
r"https://b\.ppy\.sh/",
|
||||||
|
f"//{self.beatmap_proxy_prefix}.{self.custom_asset_domain}/",
|
||||||
|
result
|
||||||
|
)
|
||||||
|
|
||||||
|
# 替换 a.ppy.sh 头像
|
||||||
|
result = re.sub(
|
||||||
|
r"https://a\.ppy\.sh/",
|
||||||
|
f"https://{self.avatar_proxy_prefix}.{self.custom_asset_domain}/",
|
||||||
|
result
|
||||||
|
)
|
||||||
|
|
||||||
|
return result
|
||||||
|
else:
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
# 全局实例
|
||||||
|
_asset_proxy_service: AssetProxyService | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_asset_proxy_service() -> AssetProxyService:
|
||||||
|
"""获取资源代理服务实例"""
|
||||||
|
global _asset_proxy_service
|
||||||
|
if _asset_proxy_service is None:
|
||||||
|
_asset_proxy_service = AssetProxyService()
|
||||||
|
return _asset_proxy_service
|
||||||
@@ -15,6 +15,7 @@ from app.database.statistics import UserStatistics, UserStatisticsResp
|
|||||||
from app.log import logger
|
from app.log import logger
|
||||||
from app.models.score import GameMode
|
from app.models.score import GameMode
|
||||||
from app.utils import utcnow
|
from app.utils import utcnow
|
||||||
|
from app.service.asset_proxy_service import get_asset_proxy_service
|
||||||
|
|
||||||
from redis.asyncio import Redis
|
from redis.asyncio import Redis
|
||||||
from sqlmodel import col, select
|
from sqlmodel import col, select
|
||||||
@@ -283,6 +284,15 @@ class RankingCacheService:
|
|||||||
ranking_data = []
|
ranking_data = []
|
||||||
for statistics in statistics_data:
|
for statistics in statistics_data:
|
||||||
user_stats_resp = await UserStatisticsResp.from_db(statistics, session, None, include)
|
user_stats_resp = await UserStatisticsResp.from_db(statistics, session, None, include)
|
||||||
|
|
||||||
|
# 应用资源代理处理
|
||||||
|
if settings.enable_asset_proxy:
|
||||||
|
try:
|
||||||
|
asset_proxy_service = get_asset_proxy_service()
|
||||||
|
user_stats_resp = await asset_proxy_service.replace_asset_urls(user_stats_resp)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Asset proxy processing failed for ranking cache: {e}")
|
||||||
|
|
||||||
# 将 UserStatisticsResp 转换为字典,处理所有序列化问题
|
# 将 UserStatisticsResp 转换为字典,处理所有序列化问题
|
||||||
user_dict = json.loads(user_stats_resp.model_dump_json())
|
user_dict = json.loads(user_stats_resp.model_dump_json())
|
||||||
ranking_data.append(user_dict)
|
ranking_data.append(user_dict)
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from app.database.lazer_user import SEARCH_INCLUDED
|
|||||||
from app.database.score import ScoreResp
|
from app.database.score import ScoreResp
|
||||||
from app.log import logger
|
from app.log import logger
|
||||||
from app.models.score import GameMode
|
from app.models.score import GameMode
|
||||||
|
from app.service.asset_proxy_service import get_asset_proxy_service
|
||||||
|
|
||||||
from redis.asyncio import Redis
|
from redis.asyncio import Redis
|
||||||
from sqlmodel import col, select
|
from sqlmodel import col, select
|
||||||
@@ -298,6 +299,15 @@ class UserCacheService:
|
|||||||
"""缓存单个用户"""
|
"""缓存单个用户"""
|
||||||
try:
|
try:
|
||||||
user_resp = await UserResp.from_db(user, session, include=SEARCH_INCLUDED)
|
user_resp = await UserResp.from_db(user, session, include=SEARCH_INCLUDED)
|
||||||
|
|
||||||
|
# 应用资源代理处理
|
||||||
|
if settings.enable_asset_proxy:
|
||||||
|
try:
|
||||||
|
asset_proxy_service = get_asset_proxy_service()
|
||||||
|
user_resp = await asset_proxy_service.replace_asset_urls(user_resp)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Asset proxy processing failed for user cache {user.id}: {e}")
|
||||||
|
|
||||||
await self.cache_user(user_resp)
|
await self.cache_user(user_resp)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error caching single user {user.id}: {e}")
|
logger.error(f"Error caching single user {user.id}: {e}")
|
||||||
|
|||||||
5
main.py
5
main.py
@@ -66,6 +66,11 @@ async def lifespan(app: FastAPI):
|
|||||||
start_stats_scheduler() # 启动统计调度器
|
start_stats_scheduler() # 启动统计调度器
|
||||||
schedule_online_status_maintenance() # 启动在线状态维护任务
|
schedule_online_status_maintenance() # 启动在线状态维护任务
|
||||||
load_achievements()
|
load_achievements()
|
||||||
|
|
||||||
|
# 显示资源代理状态
|
||||||
|
if settings.enable_asset_proxy:
|
||||||
|
logger.info(f"Asset Proxy enabled - Domain: {settings.custom_asset_domain}")
|
||||||
|
|
||||||
# on shutdown
|
# on shutdown
|
||||||
yield
|
yield
|
||||||
bg_tasks.stop()
|
bg_tasks.stop()
|
||||||
|
|||||||
Reference in New Issue
Block a user