From 2f78083c7fe1876525e28858ebec2866b1f450b6 Mon Sep 17 00:00:00 2001 From: raymond531 Date: Sat, 14 Mar 2026 20:38:41 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E6=8F=92=E4=BB=B6=E5=8D=A0?= =?UTF-8?q?=E7=94=A8=E7=A9=BA=E9=97=B4=E9=99=90=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- plugins.v2/package.v2.json | 16 ++ plugins.v2/tvfirstwatch/__init__.py | 234 +++++++++++++++++++--------- 2 files changed, 175 insertions(+), 75 deletions(-) create mode 100644 plugins.v2/package.v2.json diff --git a/plugins.v2/package.v2.json b/plugins.v2/package.v2.json new file mode 100644 index 0000000..54de119 --- /dev/null +++ b/plugins.v2/package.v2.json @@ -0,0 +1,16 @@ +{ + "TvFirstWatch": { + "name": "首播试看", + "description": "定时抓取RSS,只下载电视剧前N集(首播试看),支持空间限制和预估大小。", + "labels": "订阅,RSS", + "version": "1.2", + "icon": "rss.png", + "author": "Raymond38324", + "level": 2, + "history": { + "v1.2": "新增预估大小配置,当RSS无种子大小时使用", + "v1.1": "新增空间限制功能,下载前检查剩余空间", + "v1.0": "首次发布:支持RSS订阅电视剧首播试看" + } + } +} \ No newline at end of file diff --git a/plugins.v2/tvfirstwatch/__init__.py b/plugins.v2/tvfirstwatch/__init__.py index 8385db7..c9abb26 100644 --- a/plugins.v2/tvfirstwatch/__init__.py +++ b/plugins.v2/tvfirstwatch/__init__.py @@ -24,8 +24,6 @@ from app.schemas.types import MediaType, EventType lock = Lock() -# ─── 集数正则(与独立脚本保持一致) ───────────────────────────────────── - EPISODE_PATTERNS = [ re.compile(r"[Ss]\d{1,2}[Ee](\d{1,3})", re.IGNORECASE), re.compile(r"\bEP?\.?(\d{1,3})\b", re.IGNORECASE), @@ -41,41 +39,31 @@ TV_HINTS = re.compile( class TvFirstWatch(_PluginBase): - """ - 电视剧首播试看自动下载 - - 定时抓取多个 RSS 源,仅下载电视剧前 N 集(默认 1-2 集), - 通过 MoviePilot DownloadChain 触发下载(支持洗版、刮削)。 - """ - - # ── 插件元信息 ────────────────────────────────────────────────────── plugin_name = "首播试看" plugin_desc = "定时抓取 RSS,只下载剧集前 N 集(首播试看),防重复推送。" plugin_icon = "rss.png" - plugin_version = "1.0" + plugin_version = "1.2" plugin_author = "Raymond38324" author_url = "https://github.com/Raymond38324" plugin_config_prefix = "tvfirstwatch_" plugin_order = 25 auth_level = 2 - # ── 私有变量 ───────────────────────────────────────────────────────── _scheduler: Optional[BackgroundScheduler] = None _downloadchain: Optional[DownloadChain] = None _history_path: Optional[Path] = None - # ── 配置属性 ───────────────────────────────────────────────────────── _enabled: bool = False _onlyonce: bool = False _notify: bool = False - _cron: str = "*/30 * * * *" # cron 轮询周期 - _rss_urls: str = "" # 每行一个 RSS URL(含 Cookie 则用 "|" 分隔) - _max_episode: int = 2 # 最大集号 + _cron: str = "*/30 * * * *" + _rss_urls: str = "" + _max_episode: int = 2 _whitelist: str = "1080p,2160p,4K,HEVC,H.265" _blacklist: str = "720p,CAM,HDTS" - _save_path: str = "" # 下载保存路径(留空 MP 默认) - - # ───────────────────────────────────────────────────────────────────── + _save_path: str = "" + _max_storage_gb: int = 0 + _default_size_gb: float = 2.0 def init_plugin(self, config: dict = None) -> None: self._downloadchain = DownloadChain() @@ -91,8 +79,9 @@ class TvFirstWatch(_PluginBase): self._whitelist = config.get("whitelist", "") self._blacklist = config.get("blacklist", "") self._save_path = config.get("save_path", "") + self._max_storage_gb = int(config.get("max_storage_gb", 0)) + self._default_size_gb = float(config.get("default_size_gb", 2.0)) - # 数据目录 self._history_path = self.get_data_path() / "history.json" if self._onlyonce: @@ -107,7 +96,6 @@ class TvFirstWatch(_PluginBase): if self._scheduler.get_jobs(): self._scheduler.start() - # 关闭一次性开关并保存 self._onlyonce = False self.__update_config() @@ -132,11 +120,16 @@ class TvFirstWatch(_PluginBase): "endpoint": self._clear_history, "methods": ["GET"], "summary": "清空首播试看下载历史", - } + }, + { + "path": "/storage_status", + "endpoint": self._storage_status, + "methods": ["GET"], + "summary": "获取存储空间使用情况", + }, ] def get_service(self) -> List[Dict[str, Any]]: - """注册定时任务。""" if self._enabled and self._cron: return [ { @@ -151,7 +144,6 @@ class TvFirstWatch(_PluginBase): @eventmanager.register(EventType.PluginAction) def _plugin_action(self, event): - """处理远程命令。""" if not self._enabled: return event_data = event.event_data @@ -167,14 +159,11 @@ class TvFirstWatch(_PluginBase): self._scheduler.shutdown() self._scheduler = None - # ─── 配置页面 ───────────────────────────────────────────────────────── - def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: return [ { "component": "VForm", "content": [ - # 第一行:开关 { "component": "VRow", "content": [ @@ -183,29 +172,43 @@ class TvFirstWatch(_PluginBase): _col(4, _switch("onlyonce", "立即运行一次")), ], }, - # 第二行:执行周期 + 最大集号 { "component": "VRow", "content": [ _col( - 8, + 4, _textfield( "cron", "执行周期(Cron)", - placeholder="5位cron,如 */30 * * * *", + placeholder="*/30 * * * *", ), ), _col( - 4, + 2, _textfield( "max_episode", "最大集号", - placeholder="默认 2(即下载 EP01-02)", + placeholder="默认 2", + ), + ), + _col( + 3, + _textfield( + "max_storage_gb", + "空间上限(GB)", + placeholder="0=不限制", + ), + ), + _col( + 3, + _textfield( + "default_size_gb", + "预估大小(GB)", + placeholder="RSS无大小默认值", ), ), ], }, - # 第三行:RSS 地址 { "component": "VRow", "content": [ @@ -227,7 +230,6 @@ class TvFirstWatch(_PluginBase): ), ], }, - # 第四行:白名单 / 黑名单 { "component": "VRow", "content": [ @@ -249,7 +251,6 @@ class TvFirstWatch(_PluginBase): ), ], }, - # 第五行:保存路径 { "component": "VRow", "content": [ @@ -263,7 +264,6 @@ class TvFirstWatch(_PluginBase): ), ], }, - # 说明 { "component": "VRow", "content": [ @@ -275,9 +275,9 @@ class TvFirstWatch(_PluginBase): "type": "info", "variant": "tonal", "text": ( - "仅下载识别到集号 ≤ 最大集号 的电视剧资源," - "下载记录保存在插件数据目录 history.json," - "点击「清空历史」API 可重置。" + "仅下载集号 ≤ 最大集号的电视剧。" + "空间上限:设置后超出将停止下载,0表示不限制。" + "预估大小:当RSS不包含种子大小时使用此值计算空间。" ), }, }, @@ -296,17 +296,34 @@ class TvFirstWatch(_PluginBase): "whitelist": "1080p,2160p,4K,HEVC,H.265", "blacklist": "720p,CAM,HDTS", "save_path": "", + "max_storage_gb": 0, + "default_size_gb": 2.0, } def get_page(self) -> List[dict]: - """详情页:展示最近下载记录。""" history = self._load_history() + total_bytes = self._calculate_total_size(history) + total_gb = total_bytes / (1024**3) + max_gb = self._max_storage_gb + usage_percent = (total_gb / max_gb * 100) if max_gb > 0 else 0 + if not history: return [ { "component": "div", "props": {"class": "text-center pa-4"}, - "content": [{"component": "p", "text": "暂无下载记录"}], + "content": [ + {"component": "p", "text": "暂无下载记录"}, + { + "component": "p", + "text": f"已用空间: {total_gb:.2f} GB" + + ( + f" / {max_gb} GB ({usage_percent:.1f}%)" + if max_gb > 0 + else "" + ), + }, + ], } ] @@ -314,12 +331,14 @@ class TvFirstWatch(_PluginBase): for key, meta in sorted( history.items(), key=lambda x: x[1].get("added_at", ""), reverse=True ): + size_str = meta.get("size_str", "-") rows.append( { "component": "tr", "content": [ {"component": "td", "text": meta.get("series_name", key)}, {"component": "td", "text": str(meta.get("episode", ""))}, + {"component": "td", "text": size_str}, {"component": "td", "text": meta.get("source", "")}, {"component": "td", "text": meta.get("added_at", "")}, ], @@ -327,6 +346,22 @@ class TvFirstWatch(_PluginBase): ) return [ + { + "component": "div", + "props": {"class": "mb-4 pa-2"}, + "content": [ + { + "component": "p", + "props": {"class": "text-h6"}, + "text": f"已用空间: {total_gb:.2f} GB" + + ( + f" / {max_gb} GB ({usage_percent:.1f}%)" + if max_gb > 0 + else "" + ), + }, + ], + }, { "component": "VTable", "props": {"hover": True}, @@ -339,6 +374,7 @@ class TvFirstWatch(_PluginBase): "content": [ {"component": "th", "text": "剧名"}, {"component": "th", "text": "集号"}, + {"component": "th", "text": "大小"}, {"component": "th", "text": "来源"}, {"component": "th", "text": "下载时间"}, ], @@ -347,13 +383,10 @@ class TvFirstWatch(_PluginBase): }, {"component": "tbody", "content": rows}, ], - } + }, ] - # ─── 核心逻辑 ───────────────────────────────────────────────────────── - def _check_all_feeds(self) -> None: - """轮询所有 RSS 源(定时任务入口)。""" if not self._rss_urls: logger.warning("[首播试看] 未配置任何 RSS 地址,跳过。") return @@ -366,11 +399,6 @@ class TvFirstWatch(_PluginBase): logger.error("[首播试看] 处理 RSS 源出错: %s — %s", line, exc) def _parse_feed_line(self, line: str) -> Tuple[str, dict]: - """ - 解析 RSS 行格式: - URL - URL|Cookie: xxx - """ parts = line.split("|", 1) url = parts[0].strip() headers = { @@ -382,7 +410,6 @@ class TvFirstWatch(_PluginBase): } if len(parts) == 2: cookie_part = parts[1].strip() - # 支持 "Cookie: xxx" 或直接是 cookie 字符串 if cookie_part.lower().startswith("cookie:"): headers["Cookie"] = cookie_part[7:].strip() else: @@ -420,18 +447,15 @@ class TvFirstWatch(_PluginBase): if not title: return - # 1. 是否为电视剧 if not self._is_tv(entry): logger.debug("[首播试看][跳过-非TV] %s", title) return - # 2. 提取集数 episodes = _extract_episodes(title) if not episodes: logger.debug("[首播试看][跳过-无集数] %s", title) return - # 3. 集数范围 eps_in_range = [ep for ep in episodes if ep <= self._max_episode] if not eps_in_range: logger.info( @@ -442,13 +466,11 @@ class TvFirstWatch(_PluginBase): ) return - # 4. 关键字过滤 ok, reason = self._keyword_filter(title) if not ok: logger.info("[首播试看][跳过-关键字] %s | %s", title, reason) return - # 5. 去重检查 series_name = _guess_series_name(title) with lock: history = self._load_history() @@ -466,18 +488,35 @@ class TvFirstWatch(_PluginBase): ) return - # 6. 触发下载 + torrent_size, is_estimated = self._get_torrent_size(entry) + size_label = "预估" if is_estimated else "实际" + if self._max_storage_gb > 0: + current_total = self._calculate_total_size(history) + max_bytes = self._max_storage_gb * (1024**3) + if current_total + torrent_size > max_bytes: + logger.warning( + "[首播试看][跳过-空间不足] 已用 %.2f GB + 新增 %.2f GB(%s) > 上限 %d GB", + current_total / (1024**3), + torrent_size / (1024**3), + size_label, + self._max_storage_gb, + ) + return + logger.info( - "[首播试看][下载] %s | 剧名=%s | 集号=%s | 来源=%s", + "[首播试看][下载] %s | 剧名=%s | 集号=%s | 大小=%.2f GB(%s) | 来源=%s", title, series_name, new_eps, + torrent_size / (1024**3), + size_label, source, ) success = self._do_download(entry, title, series_name) if success: now = datetime.datetime.now().isoformat(timespec="seconds") + size_str = self._format_size(torrent_size, is_estimated) for ep in new_eps: key = _make_key(series_name, ep) history[key] = { @@ -486,6 +525,9 @@ class TvFirstWatch(_PluginBase): "title": title, "source": source, "added_at": now, + "size": torrent_size, + "size_str": size_str, + "is_estimated": is_estimated, } self._save_history(history) @@ -494,12 +536,50 @@ class TvFirstWatch(_PluginBase): f"📺 首播试看已推送下载\n" f"剧名:{series_name}\n" f"集号:{new_eps}\n" + f"大小:{size_str}\n" f"标题:{title}" ) + def _get_torrent_size(self, entry) -> Tuple[int, bool]: + """ + 获取种子大小。 + 返回: (大小字节数, 是否为预估大小) + """ + try: + if hasattr(entry, "enclosures") and entry.enclosures: + for enc in entry.enclosures: + length = enc.get("length") + if length: + return int(length), False + if hasattr(entry, "content_length"): + return int(entry.content_length), False + except Exception: + pass + default_bytes = int(self._default_size_gb * (1024**3)) + return default_bytes, True + + @staticmethod + def _format_size(size_bytes: int, is_estimated: bool = False) -> str: + label = "(预估)" if is_estimated else "" + if size_bytes == 0: + return "未知" + label + elif size_bytes < 1024: + return f"{size_bytes} B{label}" + elif size_bytes < 1024**2: + return f"{size_bytes / 1024:.1f} KB{label}" + elif size_bytes < 1024**3: + return f"{size_bytes / (1024**2):.1f} MB{label}" + else: + return f"{size_bytes / (1024**3):.2f} GB{label}" + + @staticmethod + def _calculate_total_size(history: dict) -> int: + total = 0 + for meta in history.values(): + total += meta.get("size", 0) + return total + def _do_download(self, entry, title: str, series_name: str) -> bool: - """通过 MoviePilot DownloadChain 触发下载。""" - # 构造 TorrentInfo torrent_url = "" for enc in getattr(entry, "enclosures", []): if enc.get("type", "").startswith("application/"): @@ -530,7 +610,7 @@ class TvFirstWatch(_PluginBase): ) try: - did, msg = self._downloadchain.download_single( + did = self._downloadchain.download_single( context=context, torrent_file=None, save_path=self._save_path or None, @@ -539,16 +619,12 @@ class TvFirstWatch(_PluginBase): logger.info("[首播试看] ✅ DownloadChain 推送成功: %s", title) return True else: - logger.error( - "[首播试看] ❌ DownloadChain 推送失败 [%s]: %s", title, msg - ) + logger.error("[首播试看] ❌ DownloadChain 推送失败 [%s]", title) return False except Exception as exc: logger.error("[首播试看] ❌ 下载异常 [%s]: %s", title, exc) return False - # ─── 辅助:关键字过滤 ───────────────────────────────────────────────── - def _keyword_filter(self, title: str) -> Tuple[bool, str]: title_lower = title.lower() for kw in [k.strip() for k in self._blacklist.split(",") if k.strip()]: @@ -575,8 +651,6 @@ class TvFirstWatch(_PluginBase): return False return bool(TV_HINTS.search(entry.get("title", ""))) - # ─── 历史记录操作 ───────────────────────────────────────────────────── - def _load_history(self) -> dict: if self._history_path and self._history_path.exists(): try: @@ -596,13 +670,27 @@ class TvFirstWatch(_PluginBase): return _make_key(series_name, episode) in history def _clear_history(self, token: str = "") -> dict: - """API:清空下载历史。""" if token != settings.API_TOKEN: return {"success": False, "message": "认证失败"} self._save_history({}) logger.info("[首播试看] 下载历史已清空。") return {"success": True, "message": "历史已清空"} + def _storage_status(self, token: str = "") -> dict: + if token != settings.API_TOKEN: + return {"success": False, "message": "认证失败"} + history = self._load_history() + total_bytes = self._calculate_total_size(history) + total_gb = total_bytes / (1024**3) + count = len(history) + return { + "success": True, + "total_bytes": total_bytes, + "total_gb": round(total_gb, 2), + "max_gb": self._max_storage_gb, + "count": count, + } + def __update_config(self) -> None: self.update_config( { @@ -615,13 +703,12 @@ class TvFirstWatch(_PluginBase): "whitelist": self._whitelist, "blacklist": self._blacklist, "save_path": self._save_path, + "max_storage_gb": self._max_storage_gb, + "default_size_gb": self._default_size_gb, } ) -# ─── 模块级工具函数 ─────────────────────────────────────────────────────── - - def _extract_episodes(title: str) -> List[int]: found: set[int] = set() for pat in EPISODE_PATTERNS: @@ -652,9 +739,6 @@ def _make_key(series_name: str, episode: int) -> str: return f"{norm}__ep{episode:03d}" -# ─── Vuetify 组件快捷函数 ───────────────────────────────────────────────── - - def _col(md: int, *children) -> dict: return { "component": "VCol",