合入音频支持http和https兼容,后端支持角色切换
This commit is contained in:
@@ -43,6 +43,26 @@ class DeviceService(DatabaseServiceBase):
|
||||
finally:
|
||||
await db_session.close()
|
||||
|
||||
async def list_device_ai_conversations(
|
||||
self,
|
||||
*,
|
||||
device_id: str,
|
||||
user_id: int,
|
||||
cursor: int | None,
|
||||
limit: int,
|
||||
) -> List[Mapping[str, Any]]:
|
||||
db_session = await self.get_session()
|
||||
try:
|
||||
dao = DeviceDAO(db_session)
|
||||
await dao.ensure_device_access(device_id=device_id, user_id=user_id)
|
||||
return await dao.list_device_ai_conversations(
|
||||
device_id=device_id,
|
||||
cursor=cursor,
|
||||
limit=limit,
|
||||
)
|
||||
finally:
|
||||
await db_session.close()
|
||||
|
||||
async def get_device_status(
|
||||
self,
|
||||
*,
|
||||
|
||||
174
talkingq-url/banban/service/device_audio_cache.py
Normal file
174
talkingq-url/banban/service/device_audio_cache.py
Normal file
@@ -0,0 +1,174 @@
|
||||
import asyncio
|
||||
import hashlib
|
||||
import mimetypes
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
from banban.service.message_audio_storage import (
|
||||
MessageAudioStorageError,
|
||||
MessageAudioStorageService,
|
||||
)
|
||||
from config import settings
|
||||
from utils.logger import session_logger
|
||||
|
||||
|
||||
_AUDIO_CONTENT_TYPE_TO_EXT = {
|
||||
"audio/mpeg": "mp3",
|
||||
"audio/mp3": "mp3",
|
||||
"audio/aac": "aac",
|
||||
"audio/wav": "wav",
|
||||
"audio/x-wav": "wav",
|
||||
"audio/x-m4a": "m4a",
|
||||
"audio/mp4": "m4a",
|
||||
"audio/webm": "webm",
|
||||
}
|
||||
|
||||
|
||||
class DeviceAudioCacheError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class DeviceAudioCacheService:
|
||||
def __init__(self, audio_storage: MessageAudioStorageService | None = None) -> None:
|
||||
self.audio_storage = audio_storage or MessageAudioStorageService()
|
||||
|
||||
def cache_dir(self) -> Path:
|
||||
return Path(settings.device_audio_cache_dir)
|
||||
|
||||
def public_base_url(self) -> str:
|
||||
configured = settings.device_audio_public_base_url.strip().rstrip("/")
|
||||
if configured:
|
||||
return configured
|
||||
return f"http://{settings.server_host}:{settings.server_port}/device-audio"
|
||||
|
||||
async def get_device_audio_url(
|
||||
self,
|
||||
file_key_or_url: str,
|
||||
*,
|
||||
device_id: str | None = None,
|
||||
) -> str:
|
||||
source = (file_key_or_url or "").strip()
|
||||
if not source:
|
||||
raise DeviceAudioCacheError("audio source is required")
|
||||
if source.startswith("http://"):
|
||||
return source
|
||||
|
||||
cache_key = hashlib.sha256(source.encode("utf-8")).hexdigest()
|
||||
cached = self._find_cached_file(cache_key)
|
||||
if cached is not None:
|
||||
return self._public_url(cached.name)
|
||||
|
||||
download_url = await self._resolve_download_url(source)
|
||||
content, content_type = await asyncio.to_thread(self._download_audio, download_url)
|
||||
extension = self._resolve_extension(source=source, content_type=content_type)
|
||||
target_path = self.cache_dir() / f"{cache_key}.{extension}"
|
||||
target_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
target_path.write_bytes(content)
|
||||
|
||||
session_logger.info(
|
||||
device_id or "",
|
||||
"device_audio",
|
||||
f"cached device audio: source={source} file={target_path}",
|
||||
)
|
||||
return self._public_url(target_path.name)
|
||||
|
||||
async def save_device_audio(
|
||||
self,
|
||||
content: bytes,
|
||||
*,
|
||||
device_id: str,
|
||||
extension: str = "mp3",
|
||||
) -> tuple[str, Path]:
|
||||
if not content:
|
||||
raise DeviceAudioCacheError("audio content is empty")
|
||||
|
||||
normalized_extension = extension.strip().lower().lstrip(".") or "mp3"
|
||||
if normalized_extension not in {"mp3", "aac", "m4a", "wav", "webm"}:
|
||||
normalized_extension = "mp3"
|
||||
|
||||
digest = hashlib.sha256(
|
||||
f"{device_id}:".encode("utf-8") + content
|
||||
).hexdigest()
|
||||
target_path = self.cache_dir() / f"{digest}.{normalized_extension}"
|
||||
target_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
await asyncio.to_thread(target_path.write_bytes, content)
|
||||
|
||||
session_logger.info(
|
||||
device_id,
|
||||
"device_audio",
|
||||
f"saved device audio cache: file={target_path}",
|
||||
)
|
||||
return self._public_url(target_path.name), target_path
|
||||
|
||||
async def _resolve_download_url(self, source: str) -> str:
|
||||
if source.startswith("https://"):
|
||||
if not self._is_allowed_https_source(source):
|
||||
raise DeviceAudioCacheError("unsupported https audio source")
|
||||
return source
|
||||
try:
|
||||
return await self.audio_storage.get_audio_url(source)
|
||||
except MessageAudioStorageError as exc:
|
||||
raise DeviceAudioCacheError(str(exc)) from exc
|
||||
|
||||
def _is_allowed_https_source(self, source: str) -> bool:
|
||||
parsed = urlparse(source)
|
||||
if not parsed.scheme == "https" or not parsed.netloc:
|
||||
return False
|
||||
|
||||
configured_base = settings.cos_public_base_url.strip()
|
||||
if configured_base:
|
||||
configured_host = urlparse(configured_base).netloc
|
||||
if configured_host and parsed.netloc == configured_host:
|
||||
return True
|
||||
|
||||
bucket = settings.cos_bucket_message.strip()
|
||||
region = settings.cos_region.strip()
|
||||
if bucket and region and parsed.netloc == f"{bucket}.cos.{region}.myqcloud.com":
|
||||
return True
|
||||
return False
|
||||
|
||||
def _download_audio(self, url: str) -> tuple[bytes, str | None]:
|
||||
request = Request(url, headers={"User-Agent": "banban-device-audio-cache/1.0"})
|
||||
with urlopen(request, timeout=15) as response:
|
||||
content_type = response.headers.get("Content-Type")
|
||||
content_length = response.headers.get("Content-Length")
|
||||
if content_length and int(content_length) > settings.device_audio_max_bytes:
|
||||
raise DeviceAudioCacheError("audio file is too large")
|
||||
content = response.read(settings.device_audio_max_bytes + 1)
|
||||
if len(content) > settings.device_audio_max_bytes:
|
||||
raise DeviceAudioCacheError("audio file is too large")
|
||||
if not content:
|
||||
raise DeviceAudioCacheError("audio file is empty")
|
||||
return content, content_type
|
||||
|
||||
def _find_cached_file(self, cache_key: str) -> Path | None:
|
||||
cache_dir = self.cache_dir()
|
||||
if not cache_dir.exists():
|
||||
return None
|
||||
matches = list(cache_dir.glob(f"{cache_key}.*"))
|
||||
if not matches:
|
||||
return None
|
||||
return matches[0]
|
||||
|
||||
def _resolve_extension(self, *, source: str, content_type: str | None) -> str:
|
||||
normalized_content_type = (content_type or "").split(";", 1)[0].strip().lower()
|
||||
if normalized_content_type in _AUDIO_CONTENT_TYPE_TO_EXT:
|
||||
return _AUDIO_CONTENT_TYPE_TO_EXT[normalized_content_type]
|
||||
|
||||
suffix = Path(urlparse(source).path).suffix.lower().lstrip(".")
|
||||
if suffix in {"mp3", "aac", "m4a", "wav", "webm"}:
|
||||
return suffix
|
||||
|
||||
guessed = mimetypes.guess_extension(normalized_content_type or "")
|
||||
if guessed:
|
||||
extension = guessed.lstrip(".")
|
||||
if extension in {"mp3", "aac", "m4a", "wav", "webm"}:
|
||||
return extension
|
||||
return "mp3"
|
||||
|
||||
def _public_url(self, filename: str) -> str:
|
||||
return f"{self.public_base_url()}/{filename}"
|
||||
|
||||
|
||||
device_audio_cache_service = DeviceAudioCacheService()
|
||||
@@ -125,8 +125,10 @@ class DeviceVoiceArchiveService(DatabaseServiceBase):
|
||||
),
|
||||
)
|
||||
|
||||
with open(prepared_audio.filepath, "rb") as archive_file:
|
||||
archive_audio_data = archive_file.read()
|
||||
archive_audio_data = await asyncio.to_thread(
|
||||
self._read_file_bytes,
|
||||
prepared_audio.filepath,
|
||||
)
|
||||
|
||||
session_logger.info(
|
||||
sender_device_id,
|
||||
@@ -245,7 +247,7 @@ class DeviceVoiceArchiveService(DatabaseServiceBase):
|
||||
finally:
|
||||
if prepared_audio and prepared_audio.archive_format == "mp3" and prepared_audio.filepath != local_audio_path:
|
||||
if os.path.exists(prepared_audio.filepath):
|
||||
os.remove(prepared_audio.filepath)
|
||||
await asyncio.to_thread(os.remove, prepared_audio.filepath)
|
||||
await db_session.close()
|
||||
|
||||
async def _prepare_archive_audio(
|
||||
@@ -255,8 +257,10 @@ class DeviceVoiceArchiveService(DatabaseServiceBase):
|
||||
archive_id: str,
|
||||
local_audio_path: str,
|
||||
) -> PreparedArchiveAudio | None:
|
||||
with open(local_audio_path, "rb") as local_file:
|
||||
local_audio_data = local_file.read()
|
||||
local_audio_data = await asyncio.to_thread(
|
||||
self._read_file_bytes,
|
||||
local_audio_path,
|
||||
)
|
||||
|
||||
source_format = detect_audio_format(local_audio_data)
|
||||
local_size = len(local_audio_data)
|
||||
@@ -288,8 +292,11 @@ class DeviceVoiceArchiveService(DatabaseServiceBase):
|
||||
mp3_path = f"{local_audio_path}.archive.mp3"
|
||||
try:
|
||||
if source_format == "wav":
|
||||
with open(wav_path, "wb") as wav_file:
|
||||
wav_file.write(local_audio_data)
|
||||
await asyncio.to_thread(
|
||||
self._write_file_bytes,
|
||||
wav_path,
|
||||
local_audio_data,
|
||||
)
|
||||
session_logger.info(
|
||||
sender_device_id,
|
||||
archive_id,
|
||||
@@ -302,8 +309,11 @@ class DeviceVoiceArchiveService(DatabaseServiceBase):
|
||||
channels=DEFAULT_CHANNELS,
|
||||
sample_width=DEFAULT_SAMPLE_WIDTH,
|
||||
)
|
||||
with open(wav_path, "wb") as wav_file:
|
||||
wav_file.write(wrapped_wav)
|
||||
await asyncio.to_thread(
|
||||
self._write_file_bytes,
|
||||
wav_path,
|
||||
wrapped_wav,
|
||||
)
|
||||
session_logger.info(
|
||||
sender_device_id,
|
||||
archive_id,
|
||||
@@ -320,7 +330,7 @@ class DeviceVoiceArchiveService(DatabaseServiceBase):
|
||||
source_path=wav_path,
|
||||
target_path=mp3_path,
|
||||
)
|
||||
mp3_size = os.path.getsize(mp3_path)
|
||||
mp3_size = await asyncio.to_thread(os.path.getsize, mp3_path)
|
||||
session_logger.info(
|
||||
sender_device_id,
|
||||
archive_id,
|
||||
@@ -339,7 +349,7 @@ class DeviceVoiceArchiveService(DatabaseServiceBase):
|
||||
)
|
||||
finally:
|
||||
if os.path.exists(wav_path):
|
||||
os.remove(wav_path)
|
||||
await asyncio.to_thread(os.remove, wav_path)
|
||||
|
||||
async def _convert_to_mp3(
|
||||
self,
|
||||
@@ -423,5 +433,13 @@ class DeviceVoiceArchiveService(DatabaseServiceBase):
|
||||
"child_name": row["child_name"],
|
||||
}
|
||||
|
||||
def _read_file_bytes(self, filepath: str) -> bytes:
|
||||
with open(filepath, "rb") as file:
|
||||
return file.read()
|
||||
|
||||
def _write_file_bytes(self, filepath: str, content: bytes) -> None:
|
||||
with open(filepath, "wb") as file:
|
||||
file.write(content)
|
||||
|
||||
|
||||
device_voice_archive_service = DeviceVoiceArchiveService()
|
||||
|
||||
@@ -7,6 +7,7 @@ from typing import Any
|
||||
from services.offline_audio_cache import offline_audio_cache
|
||||
from fastapi import HTTPException
|
||||
from services.database_service_base import DatabaseServiceBase
|
||||
from banban.service.device_audio_cache import device_audio_cache_service
|
||||
from banban.service.message_audio_storage import MessageAudioStorageService, MessageAudioStorageError
|
||||
from banban.service.binding import BindingService
|
||||
try:
|
||||
@@ -19,7 +20,6 @@ try:
|
||||
except ModuleNotFoundError:
|
||||
from banban.dao.im import ImDAO, DeviceIdentity, ConversationMessageCreateResult
|
||||
from banban.schemas.im import ChildConversationMessageItem, DeviceMessageCreateRequest, ParentChildMessageCreateRequest
|
||||
from handlers.audio_file_handler import message_audio_storage_service
|
||||
from utils.logger import session_logger
|
||||
|
||||
|
||||
@@ -152,6 +152,28 @@ async def present_message_item(
|
||||
return item
|
||||
|
||||
|
||||
async def present_device_message_item(
|
||||
row: Mapping[str, Any],
|
||||
*,
|
||||
device_id: str,
|
||||
) -> ChildConversationMessageItem:
|
||||
item = row_to_message_item(row)
|
||||
if item.content_type == 2 and item.media_file_key:
|
||||
try:
|
||||
item.media_file_key = await device_audio_cache_service.get_device_audio_url(
|
||||
item.media_file_key,
|
||||
device_id=device_id,
|
||||
)
|
||||
except Exception as exc:
|
||||
session_logger.error(
|
||||
device_id,
|
||||
"device_audio",
|
||||
f"failed to prepare device audio url: {exc}",
|
||||
exc_info=True,
|
||||
)
|
||||
return item
|
||||
|
||||
|
||||
class ImService(DatabaseServiceBase):
|
||||
def __init__(self):
|
||||
super().__init__(service_name="im_service")
|
||||
@@ -292,9 +314,12 @@ class ImService(DatabaseServiceBase):
|
||||
binding_service = BindingService()
|
||||
device = await binding_service.get_current_binding(parent_user_id)
|
||||
try:
|
||||
audio_url = await message_audio_storage_service.get_audio_url(stored_audio.file_key)
|
||||
audio_url = await device_audio_cache_service.get_device_audio_url(
|
||||
stored_audio.file_key,
|
||||
device_id=device.device_id,
|
||||
)
|
||||
except Exception:
|
||||
session_logger.error(device.device_id, "audio", f"failed to get audio url: {stored_audio.file_key}", exc_info=True)
|
||||
session_logger.error(device.device_id, "audio", f"failed to get device audio url: {stored_audio.file_key}", exc_info=True)
|
||||
audio_url = stored_audio.file_key
|
||||
await offline_audio_cache.add_audio_url(device.device_id, f"{audio_url}")
|
||||
except Exception:
|
||||
|
||||
@@ -109,7 +109,7 @@ class LocationService(DatabaseServiceBase):
|
||||
altitude_m: float | None = None,
|
||||
speed_mps: float | None = None,
|
||||
heading_deg: int | None = None,
|
||||
source: int | None = None,
|
||||
source: int | str | None = None,
|
||||
battery_pct: int | None = None,
|
||||
device_time: datetime | None = None,
|
||||
) -> Mapping[str, Any] | None:
|
||||
@@ -129,6 +129,8 @@ class LocationService(DatabaseServiceBase):
|
||||
battery_pct: int | None
|
||||
device_time: datetime
|
||||
|
||||
source_value = self._normalize_location_source(source)
|
||||
|
||||
db_session = await self.get_session()
|
||||
try:
|
||||
dao = LocationDAO(db_session)
|
||||
@@ -144,7 +146,7 @@ class LocationService(DatabaseServiceBase):
|
||||
altitude_m=altitude_m,
|
||||
speed_mps=speed_mps,
|
||||
heading_deg=heading_deg,
|
||||
source=source if source is not None else 0,
|
||||
source=source_value,
|
||||
battery_pct=battery_pct,
|
||||
device_time=device_time or datetime.now(),
|
||||
)
|
||||
@@ -156,5 +158,25 @@ class LocationService(DatabaseServiceBase):
|
||||
finally:
|
||||
await db_session.close()
|
||||
|
||||
@staticmethod
|
||||
def _normalize_location_source(source: int | str | None) -> int:
|
||||
if source is None:
|
||||
return 0
|
||||
if isinstance(source, int):
|
||||
return source
|
||||
text = str(source).strip().lower()
|
||||
if not text:
|
||||
return 0
|
||||
if text.isdigit():
|
||||
return int(text)
|
||||
return {
|
||||
"gps": 1,
|
||||
"wifi": 2,
|
||||
"cell": 3,
|
||||
"base_station": 3,
|
||||
"manual": 4,
|
||||
"mock": 9,
|
||||
}.get(text, 0)
|
||||
|
||||
# 创建全局 LocationService 实例
|
||||
location_service = LocationService()
|
||||
|
||||
Reference in New Issue
Block a user