from __future__ import annotations import json from pathlib import Path import re from biliup_next.core.models import ActionRecord, SessionBinding, TaskContext, utc_now_iso class SessionDeliveryService: def __init__(self, state: dict[str, object]): self.state = state self.repo = state["repo"] self.settings = state["settings"] def bind_task_full_video(self, task_id: str, full_video_bvid: str) -> dict[str, object]: task = self.repo.get_task(task_id) if task is None: return {"error": {"code": "TASK_NOT_FOUND", "message": f"task not found: {task_id}"}} bvid = self._normalize_bvid(full_video_bvid) if bvid is None: return {"error": {"code": "INVALID_BVID", "message": f"invalid bvid: {full_video_bvid}"}} now = utc_now_iso() context = self.repo.get_task_context(task_id) if context is None: context = TaskContext( id=None, task_id=task.id, session_key=f"task:{task.id}", streamer=None, room_id=None, source_title=task.title, segment_started_at=None, segment_duration_seconds=None, full_video_bvid=bvid, created_at=task.created_at, updated_at=now, ) full_video_bvid_path = self._persist_task_full_video_bvid(task, context, bvid, now=now) return { "task_id": task.id, "session_key": context.session_key, "full_video_bvid": bvid, "path": str(full_video_bvid_path), } def rebind_session_full_video(self, session_key: str, full_video_bvid: str) -> dict[str, object]: bvid = self._normalize_bvid(full_video_bvid) if bvid is None: return {"error": {"code": "INVALID_BVID", "message": f"invalid bvid: {full_video_bvid}"}} contexts = self.repo.list_task_contexts_by_session_key(session_key) if not contexts: return {"error": {"code": "SESSION_NOT_FOUND", "message": f"session not found: {session_key}"}} now = utc_now_iso() self.repo.update_session_full_video_bvid(session_key, bvid, now) updated_tasks: list[dict[str, object]] = [] for context in contexts: task = self.repo.get_task(context.task_id) if task is None: continue full_video_bvid_path = self._persist_task_full_video_bvid(task, context, bvid, now=now) updated_tasks.append({"task_id": task.id, "path": str(full_video_bvid_path)}) return { "session_key": session_key, "full_video_bvid": bvid, "updated_count": len(updated_tasks), "tasks": updated_tasks, } def merge_session(self, session_key: str, task_ids: list[str]) -> dict[str, object]: normalized_task_ids: list[str] = [] for raw in task_ids: task_id = str(raw).strip() if task_id and task_id not in normalized_task_ids: normalized_task_ids.append(task_id) if not normalized_task_ids: return {"error": {"code": "TASK_IDS_EMPTY", "message": "task_ids is empty"}} now = utc_now_iso() inherited_bvid = None existing_contexts = self.repo.list_task_contexts_by_session_key(session_key) for context in existing_contexts: if context.full_video_bvid: inherited_bvid = context.full_video_bvid break merged_tasks: list[dict[str, object]] = [] missing_tasks: list[str] = [] for task_id in normalized_task_ids: task = self.repo.get_task(task_id) if task is None: missing_tasks.append(task_id) continue context = self.repo.get_task_context(task_id) if context is None: context = TaskContext( id=None, task_id=task.id, session_key=session_key, streamer=None, room_id=None, source_title=task.title, segment_started_at=None, segment_duration_seconds=None, full_video_bvid=inherited_bvid, created_at=task.created_at, updated_at=now, ) else: context.session_key = session_key context.updated_at = now if inherited_bvid and not context.full_video_bvid: context.full_video_bvid = inherited_bvid self.repo.upsert_task_context(context) if context.full_video_bvid: full_video_bvid_path = self._persist_task_full_video_bvid(task, context, context.full_video_bvid, now=now) else: full_video_bvid_path = None payload = { "task_id": task.id, "session_key": session_key, "full_video_bvid": context.full_video_bvid, } if full_video_bvid_path is not None: payload["path"] = str(full_video_bvid_path) merged_tasks.append(payload) return { "session_key": session_key, "merged_count": len(merged_tasks), "tasks": merged_tasks, "missing_task_ids": missing_tasks, } def receive_full_video_webhook(self, payload: dict[str, object]) -> dict[str, object]: raw_bvid = str(payload.get("full_video_bvid") or payload.get("bvid") or "").strip() bvid = self._normalize_bvid(raw_bvid) if bvid is None: return {"error": {"code": "INVALID_BVID", "message": f"invalid bvid: {raw_bvid}"}} session_key = str(payload.get("session_key") or "").strip() or None source_title = str(payload.get("source_title") or "").strip() or None streamer = str(payload.get("streamer") or "").strip() or None room_id = str(payload.get("room_id") or "").strip() or None if session_key is None and source_title is None: return {"error": {"code": "SESSION_KEY_OR_SOURCE_TITLE_REQUIRED", "message": "session_key or source_title required"}} now = utc_now_iso() self.repo.upsert_session_binding( SessionBinding( id=None, session_key=session_key, source_title=source_title, streamer=streamer, room_id=room_id, full_video_bvid=bvid, created_at=now, updated_at=now, ) ) contexts = self.repo.list_task_contexts_by_session_key(session_key) if session_key else [] if not contexts and source_title: contexts = self.repo.list_task_contexts_by_source_title(source_title) updated_tasks: list[dict[str, object]] = [] for context in contexts: task = self.repo.get_task(context.task_id) if task is None: continue if session_key and (context.session_key.startswith("task:") or context.session_key != session_key): context.session_key = session_key full_video_bvid_path = self._persist_task_full_video_bvid(task, context, bvid, now=now) updated_tasks.append({"task_id": task.id, "path": str(full_video_bvid_path)}) self.repo.add_action_record( ActionRecord( id=None, task_id=None, action_name="webhook_full_video_uploaded", status="ok", summary=f"full video webhook received: {bvid}", details_json=json.dumps( { "session_key": session_key, "source_title": source_title, "streamer": streamer, "room_id": room_id, "updated_count": len(updated_tasks), }, ensure_ascii=False, ), created_at=now, ) ) return { "ok": True, "session_key": session_key, "source_title": source_title, "full_video_bvid": bvid, "updated_count": len(updated_tasks), "tasks": updated_tasks, } def _normalize_bvid(self, full_video_bvid: str) -> str | None: bvid = full_video_bvid.strip() if not re.fullmatch(r"BV[0-9A-Za-z]+", bvid): return None return bvid def _full_video_bvid_path(self, task_title: str) -> Path: session_dir = Path(str(self.settings["paths"]["session_dir"])) / task_title session_dir.mkdir(parents=True, exist_ok=True) return session_dir / "full_video_bvid.txt" def _upsert_session_binding_for_context(self, context: TaskContext, full_video_bvid: str, now: str) -> None: self.repo.upsert_session_binding( SessionBinding( id=None, session_key=context.session_key, source_title=context.source_title, streamer=context.streamer, room_id=context.room_id, full_video_bvid=full_video_bvid, created_at=now, updated_at=now, ) ) def _persist_task_full_video_bvid( self, task, context: TaskContext, full_video_bvid: str, *, now: str, ) -> Path: # type: ignore[no-untyped-def] context.full_video_bvid = full_video_bvid context.updated_at = now self.repo.upsert_task_context(context) self._upsert_session_binding_for_context(context, full_video_bvid, now) path = self._full_video_bvid_path(task.title) path.write_text(full_video_bvid, encoding="utf-8") return path