添加音频代理
This commit is contained in:
@@ -2,7 +2,7 @@ from __future__ import annotations
|
||||
|
||||
from app.config import settings
|
||||
|
||||
from . import avatar, beatmapset_ratings, cover, oauth, relationship, team, username # noqa: F401
|
||||
from . import audio_proxy, avatar, beatmapset_ratings, cover, oauth, relationship, team, username # noqa: F401
|
||||
from .router import router as private_router
|
||||
|
||||
if settings.enable_totp_verification:
|
||||
|
||||
65
app/router/private/audio_proxy.py
Normal file
65
app/router/private/audio_proxy.py
Normal file
@@ -0,0 +1,65 @@
|
||||
"""
|
||||
音频代理接口
|
||||
提供从osu!官方获取beatmapset音频预览的代理服务
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from app.dependencies.database import get_redis
|
||||
from app.service.audio_proxy_service import AudioProxyService, get_audio_proxy_service
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Path
|
||||
from fastapi.responses import Response
|
||||
from loguru import logger
|
||||
import redis.asyncio as redis
|
||||
|
||||
router = APIRouter(prefix="/audio", tags=["Audio Proxy"])
|
||||
|
||||
|
||||
async def get_audio_proxy_dependency(redis_client: Annotated[redis.Redis, Depends(get_redis)]) -> AudioProxyService:
|
||||
"""音频代理服务依赖注入"""
|
||||
return get_audio_proxy_service(redis_client)
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/beatmapset/{beatmapset_id}")
|
||||
async def get_beatmapset_audio(
|
||||
beatmapset_id: Annotated[int, Path(description="谱面集ID", ge=1)],
|
||||
audio_service: Annotated[AudioProxyService, Depends(get_audio_proxy_dependency)],
|
||||
):
|
||||
"""
|
||||
获取谱面集的音频预览
|
||||
|
||||
根据谱面集ID获取osu!官方的音频预览文件。
|
||||
音频文件会被缓存7天以提高响应速度。
|
||||
|
||||
参数:
|
||||
- beatmapset_id: 谱面集ID
|
||||
|
||||
返回:
|
||||
- 音频文件的二进制数据,Content-Type为audio/mpeg
|
||||
"""
|
||||
try:
|
||||
# 获取谱面集音频数据
|
||||
audio_data, content_type = await audio_service.get_beatmapset_audio(beatmapset_id)
|
||||
|
||||
# 返回音频响应
|
||||
return Response(
|
||||
content=audio_data,
|
||||
media_type=content_type,
|
||||
headers={
|
||||
"Cache-Control": "public, max-age=604800", # 7天缓存
|
||||
"Content-Length": str(len(audio_data)),
|
||||
"Content-Disposition": f"inline; filename=\"{beatmapset_id}.mp3\"",
|
||||
},
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
# 重新抛出HTTP异常
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error getting beatmapset audio: {e}")
|
||||
raise HTTPException(status_code=500, detail="Internal server error") from e
|
||||
@@ -5,3 +5,8 @@ from app.dependencies.rate_limit import LIMITERS
|
||||
from fastapi import APIRouter
|
||||
|
||||
router = APIRouter(prefix="/api/private", dependencies=LIMITERS)
|
||||
|
||||
# 导入并包含子路由
|
||||
from .audio_proxy import router as audio_proxy_router
|
||||
|
||||
router.include_router(audio_proxy_router)
|
||||
|
||||
130
app/service/audio_proxy_service.py
Normal file
130
app/service/audio_proxy_service.py
Normal file
@@ -0,0 +1,130 @@
|
||||
"""
|
||||
音频代理服务
|
||||
提供从osu!官方获取beatmapset音频预览并缓存的功能
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
from fastapi import HTTPException
|
||||
import httpx
|
||||
from loguru import logger
|
||||
import redis.asyncio as redis
|
||||
|
||||
|
||||
class AudioProxyService:
|
||||
"""音频代理服务"""
|
||||
|
||||
def __init__(self, redis_client: redis.Redis):
|
||||
self.redis = redis_client
|
||||
self.http_client = httpx.AsyncClient(timeout=30.0)
|
||||
self._cache_ttl = 7 * 24 * 60 * 60 # 7天缓存
|
||||
|
||||
async def close(self):
|
||||
"""关闭HTTP客户端"""
|
||||
await self.http_client.aclose()
|
||||
|
||||
def _get_beatmapset_cache_key(self, beatmapset_id: int) -> str:
|
||||
"""生成beatmapset音频缓存键"""
|
||||
return f"beatmapset_audio:{beatmapset_id}"
|
||||
|
||||
def _get_beatmapset_metadata_key(self, beatmapset_id: int) -> str:
|
||||
"""生成beatmapset音频元数据缓存键"""
|
||||
return f"beatmapset_audio_meta:{beatmapset_id}"
|
||||
|
||||
async def get_beatmapset_audio_from_cache(self, beatmapset_id: int) -> tuple[bytes, str] | None:
|
||||
"""从缓存获取beatmapset音频数据和内容类型"""
|
||||
try:
|
||||
cache_key = self._get_beatmapset_cache_key(beatmapset_id)
|
||||
metadata_key = self._get_beatmapset_metadata_key(beatmapset_id)
|
||||
|
||||
# 获取音频数据和元数据
|
||||
audio_data = await self.redis.get(cache_key)
|
||||
metadata = await self.redis.get(metadata_key)
|
||||
|
||||
if audio_data and metadata:
|
||||
logger.debug(f"Beatmapset audio cache hit for ID: {beatmapset_id}")
|
||||
# metadata 格式为 "content_type"
|
||||
return audio_data, metadata.decode()
|
||||
return None
|
||||
except (redis.RedisError, redis.ConnectionError) as e:
|
||||
logger.error(f"Error getting beatmapset audio from cache: {e}")
|
||||
return None
|
||||
|
||||
async def cache_beatmapset_audio(self, beatmapset_id: int, audio_data: bytes, content_type: str):
|
||||
"""缓存beatmapset音频数据"""
|
||||
try:
|
||||
cache_key = self._get_beatmapset_cache_key(beatmapset_id)
|
||||
metadata_key = self._get_beatmapset_metadata_key(beatmapset_id)
|
||||
|
||||
# 缓存音频数据和元数据
|
||||
await self.redis.setex(cache_key, self._cache_ttl, audio_data)
|
||||
await self.redis.setex(metadata_key, self._cache_ttl, content_type)
|
||||
|
||||
logger.debug(f"Cached beatmapset audio for ID: {beatmapset_id}, size: {len(audio_data)} bytes")
|
||||
except (redis.RedisError, redis.ConnectionError) as e:
|
||||
logger.error(f"Error caching beatmapset audio: {e}")
|
||||
|
||||
async def fetch_beatmapset_audio(self, beatmapset_id: int) -> tuple[bytes, str]:
|
||||
"""从osu!官方获取beatmapset音频预览"""
|
||||
try:
|
||||
# 构建 osu! 官方预览音频 URL
|
||||
preview_url = f"https://b.ppy.sh/preview/{beatmapset_id}.mp3"
|
||||
logger.info(f"Fetching beatmapset audio from: {preview_url}")
|
||||
|
||||
response = await self.http_client.get(preview_url)
|
||||
response.raise_for_status()
|
||||
|
||||
# osu!预览音频通常为mp3格式
|
||||
content_type = response.headers.get("content-type", "audio/mpeg")
|
||||
audio_data = response.content
|
||||
|
||||
# 检查文件大小限制(10MB,预览音频通常不会太大)
|
||||
max_size = 10 * 1024 * 1024 # 10MB
|
||||
if len(audio_data) > max_size:
|
||||
raise HTTPException(
|
||||
status_code=413, detail=f"Audio file too large: {len(audio_data)} bytes (max: {max_size})"
|
||||
)
|
||||
|
||||
if len(audio_data) == 0:
|
||||
raise HTTPException(status_code=404, detail="Audio preview not available for this beatmapset")
|
||||
|
||||
logger.info(f"Successfully fetched beatmapset audio: {len(audio_data)} bytes, type: {content_type}")
|
||||
return audio_data, content_type
|
||||
|
||||
except httpx.HTTPStatusError as e:
|
||||
logger.error(f"HTTP error fetching beatmapset audio for ID {beatmapset_id}: {e}")
|
||||
if e.response.status_code == 404:
|
||||
raise HTTPException(status_code=404, detail="Audio preview not found for this beatmapset") from e
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=e.response.status_code, detail=f"Failed to fetch audio: {e.response.status_code}"
|
||||
) from e
|
||||
except httpx.RequestError as e:
|
||||
logger.error(f"Request error fetching beatmapset audio for ID {beatmapset_id}: {e}")
|
||||
raise HTTPException(status_code=503, detail="Failed to connect to osu! servers") from e
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error fetching beatmapset audio for ID {beatmapset_id}: {e}")
|
||||
raise HTTPException(status_code=500, detail="Internal server error while fetching audio") from e
|
||||
|
||||
async def get_beatmapset_audio(self, beatmapset_id: int) -> tuple[bytes, str]:
|
||||
"""根据 beatmapset_id 获取音频预览"""
|
||||
# 先尝试从缓存获取
|
||||
cached_result = await self.get_beatmapset_audio_from_cache(beatmapset_id)
|
||||
if cached_result:
|
||||
return cached_result
|
||||
|
||||
# 缓存未命中,从osu!官方获取
|
||||
audio_data, content_type = await self.fetch_beatmapset_audio(beatmapset_id)
|
||||
|
||||
# 缓存新获取的音频数据
|
||||
await self.cache_beatmapset_audio(beatmapset_id, audio_data, content_type)
|
||||
|
||||
return audio_data, content_type
|
||||
|
||||
|
||||
|
||||
def get_audio_proxy_service(redis_client: redis.Redis) -> AudioProxyService:
|
||||
"""获取音频代理服务实例"""
|
||||
# 每次创建新实例,避免全局状态
|
||||
return AudioProxyService(redis_client)
|
||||
Reference in New Issue
Block a user