Files
biliup-next/src/biliup_next/modules/collection/providers/bilibili_collection.py

153 lines
6.7 KiB
Python

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