from __future__ import annotations import json import random import time from pathlib import Path from typing import Any from biliup_next.core.errors import ModuleError from biliup_next.core.models import Task from biliup_next.core.providers import ProviderManifest from biliup_next.infra.adapters.bilibili_api import BilibiliApiAdapter from biliup_next.infra.adapters.full_video_locator import resolve_full_video_bvid from biliup_next.infra.workspace_paths import resolve_task_work_dir class BilibiliCollectionProvider: def __init__(self, bilibili_api: BilibiliApiAdapter | None = None) -> None: self.bilibili_api = bilibili_api or BilibiliApiAdapter() self._section_cache: dict[int, int | None] = {} manifest = ProviderManifest( id="bilibili_collection", name="Bilibili Collection Provider", version="0.1.0", provider_type="collection_provider", entrypoint="biliup_next.modules.collection.providers.bilibili_collection:BilibiliCollectionProvider", capabilities=["collection"], enabled_by_default=True, ) def sync(self, task: Task, target: str, settings: dict[str, Any]) -> dict[str, object]: session_dir = resolve_task_work_dir(task) cookies = self.bilibili_api.load_cookies(Path(str(settings["cookies_file"]))) csrf = cookies.get("bili_jct") if not csrf: raise ModuleError(code="COOKIE_CSRF_MISSING", message="Cookie 缺少 bili_jct", retryable=False) session = self.bilibili_api.build_session( cookies=cookies, referer="https://member.bilibili.com/platform/upload-manager/distribution", ) if target == "a": season_id = int(settings["season_id_a"]) bvid = resolve_full_video_bvid(task.title, session_dir, settings) if not bvid: (session_dir / "collection_a_done.flag").touch() return {"status": "skipped", "reason": "full_video_bvid_not_found"} flag_path = session_dir / "collection_a_done.flag" else: season_id = int(settings["season_id_b"]) bvid_path = session_dir / "bvid.txt" if not bvid_path.exists(): raise ModuleError(code="COLLECTION_BVID_MISSING", message=f"缺少 bvid.txt: {session_dir}", retryable=True) bvid = bvid_path.read_text(encoding="utf-8").strip() flag_path = session_dir / "collection_b_done.flag" if season_id <= 0: flag_path.touch() return {"status": "skipped", "reason": "season_disabled"} section_id = self._resolve_section_id(session, season_id) if not section_id: raise ModuleError(code="COLLECTION_SECTION_NOT_FOUND", message=f"未找到合集 section: {season_id}", retryable=True) info = self._get_video_info(session, bvid) add_result = self._add_videos_batch(session, csrf, section_id, [info]) if add_result["status"] == "failed": raise ModuleError( code="COLLECTION_ADD_FAILED", message=str(add_result["message"]), retryable=True, details=add_result, ) flag_path.touch() if add_result["status"] == "added": append_key = "append_collection_a_new_to_end" if target == "a" else "append_collection_b_new_to_end" if settings.get(append_key, True): self._move_videos_to_section_end(session, csrf, section_id, [int(info["aid"])]) return {"status": add_result["status"], "target": target, "bvid": bvid, "season_id": season_id} def _resolve_section_id(self, session, season_id: int) -> int | None: # type: ignore[no-untyped-def] if season_id in self._section_cache: return self._section_cache[season_id] result = self.bilibili_api.list_seasons(session) if result.get("code") != 0: return None for season in result.get("data", {}).get("seasons", []): if season.get("season", {}).get("id") == season_id: sections = season.get("sections", {}).get("sections", []) section_id = sections[0]["id"] if sections else None self._section_cache[season_id] = section_id return section_id self._section_cache[season_id] = None return None def _get_video_info(self, session, bvid: str) -> dict[str, object]: # type: ignore[no-untyped-def] data = self.bilibili_api.get_video_view( session, bvid, error_code="COLLECTION_VIDEO_INFO_FAILED", error_message="获取视频信息失败", ) return {"aid": data["aid"], "cid": data["cid"], "title": data["title"], "charging_pay": 0} def _add_videos_batch(self, session, csrf: str, section_id: int, episodes: list[dict[str, object]]) -> dict[str, object]: # type: ignore[no-untyped-def] time.sleep(random.uniform(5.0, 10.0)) result = self.bilibili_api.add_section_episodes( session, csrf=csrf, section_id=section_id, episodes=episodes, ) if result.get("code") == 0: return {"status": "added"} if result.get("code") == 20080: return {"status": "already_exists", "message": result.get("message", "")} return {"status": "failed", "message": result.get("message", "unknown error"), "code": result.get("code")} def _move_videos_to_section_end(self, session, csrf: str, section_id: int, added_aids: list[int]) -> bool: # type: ignore[no-untyped-def] detail = self.bilibili_api.get_section_detail(session, section_id=section_id) if detail.get("code") != 0: return False section = detail.get("data", {}).get("section", {}) episodes = detail.get("data", {}).get("episodes", []) or [] if not episodes: return True target_aids = {int(aid) for aid in added_aids} existing = [] appended = [] for episode in episodes: item = {"id": episode.get("id")} if item["id"] is None: continue if episode.get("aid") in target_aids: appended.append(item) else: existing.append(item) ordered = existing + appended payload = { "section": { "id": section["id"], "seasonId": section["seasonId"], "title": section["title"], "type": section["type"], }, "sorts": [{"id": item["id"], "sort": index + 1} for index, item in enumerate(ordered)], } result = self.bilibili_api.edit_section(session, csrf=csrf, payload=payload) return result.get("code") == 0