"""Platform lesson MP3s live under xiaozhi-server, keyed by org. Device paths have no org."""
|
|
from __future__ import annotations
|
|
import os
|
import re
|
from typing import Any, Dict, Iterable, List, Optional
|
|
DEFAULT_ORG_ID = "default_org"
|
LESSON_AUDIO_DIRNAME = "lesson_audio"
|
ALLOWED_PREFIX = "english_learning/"
|
_ORG_RE = re.compile(r"^[A-Za-z0-9_-]{1,64}$")
|
_REL_RE = re.compile(
|
r"^english_learning/(repeater|shadowing)/lessons/[A-Za-z0-9][A-Za-z0-9_./-]*\.mp3$"
|
)
|
|
|
def resolve_org_id(org_id: Optional[str]) -> str:
|
value = str(org_id or "").strip()
|
if not value:
|
return DEFAULT_ORG_ID
|
if not _ORG_RE.fullmatch(value):
|
return DEFAULT_ORG_ID
|
return value
|
|
|
def normalize_sd_name(name: str) -> str:
|
text = str(name or "").replace("\\", "/").strip()
|
if not text:
|
return ""
|
if not text.startswith("/"):
|
text = "/" + text
|
return text
|
|
|
def unique_lesson_names(items: Iterable[Any]) -> List[str]:
|
seen = set()
|
names: List[str] = []
|
for raw in items or []:
|
if isinstance(raw, dict):
|
name = normalize_sd_name(str(raw.get("name") or raw.get("path") or raw.get("url") or ""))
|
else:
|
name = normalize_sd_name(str(raw or ""))
|
if not name or name in seen:
|
continue
|
seen.add(name)
|
names.append(name)
|
return names
|
|
|
def missing_lesson_names(needed: Iterable[str], present: Iterable[str]) -> List[str]:
|
have = {normalize_sd_name(name) for name in present or []}
|
have.discard("")
|
return [name for name in unique_lesson_names(needed) if name not in have]
|
|
|
def lesson_audio_root(config: Optional[Dict[str, Any]] = None) -> str:
|
from config.config_loader import get_project_dir
|
|
data_dir = "data"
|
if isinstance(config, dict):
|
log_cfg = config.get("log") if isinstance(config.get("log"), dict) else {}
|
data_dir = str(log_cfg.get("data_dir") or data_dir)
|
root = data_dir if os.path.isabs(data_dir) else os.path.join(get_project_dir(), data_dir)
|
return os.path.join(root, LESSON_AUDIO_DIRNAME)
|
|
|
def org_audio_dir(config: Optional[Dict[str, Any]], org_id: Optional[str]) -> str:
|
return os.path.join(lesson_audio_root(config), resolve_org_id(org_id))
|
|
|
def sanitize_relative_path(rel: str) -> Optional[str]:
|
text = str(rel or "").replace("\\", "/").lstrip("/")
|
if not text or ".." in text.split("/"):
|
return None
|
if not _REL_RE.fullmatch(text):
|
return None
|
return text
|
|
|
def resolve_disk_file(
|
config: Optional[Dict[str, Any]], org_id: Optional[str], rel: str
|
) -> Optional[str]:
|
relative = sanitize_relative_path(rel)
|
if not relative:
|
return None
|
org = resolve_org_id(org_id)
|
primary = os.path.normpath(os.path.join(org_audio_dir(config, org), relative))
|
root = os.path.normpath(lesson_audio_root(config))
|
if not primary.startswith(root + os.sep) and primary != root:
|
return None
|
if os.path.isfile(primary):
|
return primary
|
if org != DEFAULT_ORG_ID:
|
fallback = os.path.normpath(os.path.join(org_audio_dir(config, DEFAULT_ORG_ID), relative))
|
if fallback.startswith(root + os.sep) and os.path.isfile(fallback):
|
return fallback
|
return None
|
|
|
def lesson_audio_public_root(config: Optional[Dict[str, Any]]) -> str:
|
server = (config or {}).get("server") if isinstance(config, dict) else {}
|
if not isinstance(server, dict):
|
server = {}
|
raw = str(server.get("lesson_audio_public_url") or "").strip()
|
port = int(server.get("http_port", 8003) or 8003)
|
if not raw or "你" in raw:
|
raw = f"http://{_local_ip()}:{port}/lesson-audio"
|
return raw.rstrip("/")
|
|
|
def _local_ip() -> str:
|
import socket
|
|
try:
|
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
sock.connect(("8.8.8.8", 80))
|
ip = sock.getsockname()[0]
|
sock.close()
|
return ip
|
except Exception:
|
return "127.0.0.1"
|
|
|
def lesson_audio_base_url(config: Optional[Dict[str, Any]], org_id: Optional[str]) -> str:
|
return f"{lesson_audio_public_root(config)}/{resolve_org_id(org_id)}"
|
|
|
_CLIP_RE = re.compile(r"^(\d+)\.mp3$", re.I)
|
|
|
def list_lesson_clips(
|
config: Optional[Dict[str, Any]],
|
org_id: Optional[str],
|
kind: str,
|
lesson_id: str,
|
lang: str,
|
) -> Optional[List[str]]:
|
"""SD paths for one lesson folder.
|
|
None means the folder is not on disk yet (caller may use the catalog).
|
A list, including empty, means the folder exists and those MP3s are the lesson.
|
"""
|
level, story = _split_lesson_id(lesson_id)
|
kind_name = str(kind or "").strip().lower()
|
language = str(lang or "").strip().lower()
|
if (
|
not level
|
or not story
|
or kind_name not in ("repeater", "shadowing")
|
or language not in ("cn", "en")
|
):
|
return None
|
rel_dir = f"english_learning/{kind_name}/lessons/{level}/{story}/{language}"
|
folder = _existing_lesson_dir(config, org_id, rel_dir)
|
if folder is None:
|
return None
|
numbered = []
|
try:
|
for entry in os.listdir(folder):
|
match = _CLIP_RE.match(entry)
|
if match:
|
numbered.append((int(match.group(1)), entry))
|
except OSError:
|
return None
|
numbered.sort()
|
return [f"/{rel_dir}/{name}" for _, name in numbered]
|
|
|
def _split_lesson_id(lesson_id: str) -> tuple:
|
from core.app_runtime.idiom_catalog import parse_lesson_id
|
|
return parse_lesson_id(lesson_id)
|
|
|
def _existing_lesson_dir(
|
config: Optional[Dict[str, Any]], org_id: Optional[str], rel_dir: str
|
) -> Optional[str]:
|
org = resolve_org_id(org_id)
|
primary = os.path.join(org_audio_dir(config, org), *rel_dir.split("/"))
|
if os.path.isdir(primary):
|
return primary
|
if org != DEFAULT_ORG_ID:
|
fallback = os.path.join(org_audio_dir(config, DEFAULT_ORG_ID), *rel_dir.split("/"))
|
if os.path.isdir(fallback):
|
return fallback
|
return None
|
|
|
def course_id_for(task_id: str, lesson_id: str) -> str:
|
lesson = re.sub(r"[^A-Za-z0-9_-]+", "-", str(lesson_id or "l1.001").strip()) or "l1-001"
|
kind = "rpt" if str(task_id or "").strip() == "repeater" else "shd"
|
course = f"{lesson}-{kind}"
|
return course[:31]
|
|
|
def conn_org_id(conn: Any, params: Optional[Dict[str, Any]] = None) -> str:
|
params = params or {}
|
raw = params.get("org_id") if params.get("org_id") is not None else params.get("orgId")
|
if raw:
|
return resolve_org_id(str(raw))
|
cfg = getattr(conn, "config", None) if conn is not None else None
|
runtime = cfg.get("app_runtime") if isinstance(cfg, dict) else None
|
if isinstance(runtime, dict) and runtime.get("lesson_org_id") is not None:
|
return resolve_org_id(str(runtime.get("lesson_org_id")))
|
return DEFAULT_ORG_ID
|
|
|
def ensure_org_dirs(config: Optional[Dict[str, Any]] = None) -> None:
|
os.makedirs(org_audio_dir(config, DEFAULT_ORG_ID), exist_ok=True)
|