# -*- coding: utf-8 -*- """B 站收藏夹接口的返回模型。""" from __future__ import annotations from dataclasses import dataclass from typing import Any, Mapping @dataclass(frozen=True) class FavId: """``fav/resource/ids`` 的一条资源。""" aid: int type: int bvid: str | None = None @dataclass(frozen=True) class FavResource: """``fav/resource/infos`` 的一条视频元数据。""" aid: int bvid: str | None title: str up_mid: int | None up_name: str | None pubdate: int duration: int | None cover: str | None attr: int fav_time: int | None pages: int | None = None @property def invalid(self) -> bool: # 实测 attr=9 表示"已失效视频"。 return self.attr == 9 @classmethod def from_api(cls, item: Mapping[str, Any]) -> "FavResource": aid = item.get("id") if isinstance(aid, bool) or not isinstance(aid, int): raise ValueError("infos item.id 不是整数") upper = item.get("upper") or {} if not isinstance(upper, Mapping): upper = {} up_mid = upper.get("mid") if isinstance(up_mid, bool) or not isinstance(up_mid, int): up_mid = None raw_duration = item.get("duration") duration = raw_duration if isinstance(raw_duration, int) and not isinstance( raw_duration, bool ) else None raw_pages = item.get("page") pages = raw_pages if ( isinstance(raw_pages, int) and not isinstance(raw_pages, bool) and raw_pages > 0 ) else None raw_attr = item.get("attr", 0) attr = raw_attr if isinstance(raw_attr, int) and not isinstance(raw_attr, bool) else 0 fav_time = item.get("fav_time") if isinstance(fav_time, bool) or not isinstance(fav_time, int) or fav_time <= 0: fav_time = None bvid = item.get("bvid") or item.get("bv_id") if not isinstance(bvid, str) or not bvid: bvid = None return cls( aid=aid, bvid=bvid, title=str(item.get("title") or ""), up_mid=up_mid, up_name=str(upper.get("name") or "") or None, pubdate=int(item.get("pubtime") or item.get("ctime") or 0), duration=duration, cover=str(item.get("cover") or "") or None, attr=attr, fav_time=fav_time, pages=pages, ) @dataclass(frozen=True) class FolderInfo: media_id: int title: str media_count: int @dataclass(frozen=True) class InfosResult: requested: tuple[int, ...] items: dict[int, FavResource] missing: tuple[int, ...] @dataclass(frozen=True) class ViewStat: """``x/web-interface/view`` 的七项统计(含 UP 元数据)。""" aid: int bvid: str | None title: str view: int like: int favorite: int coin: int share: int reply: int danmaku: int up_mid: int | None = None up_name: str | None = None up_face: str | None = None def metrics(self) -> dict[str, int]: return { "view": self.view, "like": self.like, "favorite": self.favorite, "coin": self.coin, "share": self.share, "reply": self.reply, "danmaku": self.danmaku, } @classmethod def from_api(cls, item: Mapping[str, Any]) -> "ViewStat": aid = item.get("aid") if isinstance(aid, bool) or not isinstance(aid, int): raise ValueError("view data.aid 不是整数") stat = item.get("stat") if not isinstance(stat, Mapping): raise ValueError("view data.stat 不是对象") metrics: dict[str, int] = {} for key in ("view", "like", "favorite", "coin", "share", "reply", "danmaku"): value = stat.get(key) if isinstance(value, bool) or not isinstance(value, int) or value < 0: raise ValueError(f"view stat.{key} 不是非负整数") metrics[key] = value bvid = item.get("bvid") if not isinstance(bvid, str) or not bvid: bvid = None owner = item.get("owner") or {} if not isinstance(owner, Mapping): owner = {} raw_mid = owner.get("mid") up_mid = ( raw_mid if isinstance(raw_mid, int) and not isinstance(raw_mid, bool) else None ) up_name = owner.get("name") if not isinstance(up_name, str) or not up_name: up_name = None up_face = owner.get("face") if not isinstance(up_face, str) or not up_face: up_face = None return cls( aid=aid, bvid=bvid, title=str(item.get("title") or ""), up_mid=up_mid, up_name=up_name, up_face=up_face, **metrics, ) @dataclass(frozen=True) class VideoDetail: """``x/web-interface/view`` 的完整元数据 + 七项统计。 比 :class:`ViewStat` 多出 UP、时长、分 P 数、封面等字段,供手动入库建库使用。 """ aid: int bvid: str | None title: str up_mid: int | None up_name: str | None pubdate: int duration: int pages: int | None cover: str | None stat: ViewStat up_face: str | None = None def metrics(self) -> dict[str, int]: return self.stat.metrics() @classmethod def from_api(cls, item: Mapping[str, Any]) -> "VideoDetail": stat = ViewStat.from_api(item) raw_duration = item.get("duration") duration = ( raw_duration if isinstance(raw_duration, int) and not isinstance(raw_duration, bool) and raw_duration >= 0 else 0 ) pages: int | None = None raw_pages = item.get("pages") if isinstance(raw_pages, list) and raw_pages: pages = len(raw_pages) else: raw_videos = item.get("videos") if ( isinstance(raw_videos, int) and not isinstance(raw_videos, bool) and raw_videos > 0 ): pages = raw_videos raw_pubdate = item.get("pubdate") or item.get("ctime") pubdate = ( raw_pubdate if isinstance(raw_pubdate, int) and not isinstance(raw_pubdate, bool) else 0 ) cover = item.get("pic") if not isinstance(cover, str) or not cover: cover = None return cls( aid=stat.aid, bvid=stat.bvid, title=stat.title, up_mid=stat.up_mid, up_name=stat.up_name, pubdate=pubdate, duration=duration, pages=pages, cover=cover, stat=stat, up_face=stat.up_face, ) @dataclass(frozen=True) class FanStat: mid: int follower: int following: int @classmethod def from_api(cls, item: Mapping[str, Any]) -> "FanStat": mid = item.get("mid") follower = item.get("follower") following = item.get("following", 0) if isinstance(mid, bool) or not isinstance(mid, int): raise ValueError("relation/stat data.mid 不是整数") if isinstance(follower, bool) or not isinstance(follower, int) or follower < 0: raise ValueError("relation/stat data.follower 不是非负整数") if isinstance(following, bool) or not isinstance(following, int) or following < 0: raise ValueError("relation/stat data.following 不是非负整数") return cls(mid=mid, follower=follower, following=following) @dataclass(frozen=True) class LoginState: is_login: bool mid: int | None uname: str | None code: int message: str