diff --git a/app/router/private/__init__.py b/app/router/private/__init__.py index 4402a47..297c6b4 100644 --- a/app/router/private/__init__.py +++ b/app/router/private/__init__.py @@ -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: diff --git a/app/router/private/audio_proxy.py b/app/router/private/audio_proxy.py new file mode 100644 index 0000000..eff812b --- /dev/null +++ b/app/router/private/audio_proxy.py @@ -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 diff --git a/app/router/private/router.py b/app/router/private/router.py index 556dbe0..6882bc9 100644 --- a/app/router/private/router.py +++ b/app/router/private/router.py @@ -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) diff --git a/app/service/audio_proxy_service.py b/app/service/audio_proxy_service.py new file mode 100644 index 0000000..c011fff --- /dev/null +++ b/app/service/audio_proxy_service.py @@ -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)