diff --git a/package.v2.json b/package.v2.json index eccdbea..45c2748 100644 --- a/package.v2.json +++ b/package.v2.json @@ -629,5 +629,17 @@ "history": { "v2.0.0": "首次发布:可配置报告模块、历史记录页面、下载器通用化、存储路径可配置" } + }, + "TvFirstWatch": { + "name": "首播试看", + "description": "定时抓取RSS,只下载电视剧前N集,自动跳过合集和过大文件。", + "labels": "订阅,RSS", + "version": "1.0.0", + "icon": "rss.png", + "author": "Raymond38324", + "level": 2, + "history": { + "v1.0.0": "首次发布" + } } -} +} \ No newline at end of file diff --git a/plugins.v2/tvfirstwatch/__init__.py b/plugins.v2/tvfirstwatch/__init__.py new file mode 100644 index 0000000..46967dd --- /dev/null +++ b/plugins.v2/tvfirstwatch/__init__.py @@ -0,0 +1,844 @@ +import datetime +import json +import re +from pathlib import Path +from threading import Lock +from typing import Any, Dict, List, Optional, Tuple +from urllib.parse import urlparse + +import pytz +from apscheduler.schedulers.background import BackgroundScheduler +from apscheduler.triggers.cron import CronTrigger + +import feedparser +import requests + +from app.chain.download import DownloadChain +from app.core.config import settings +from app.core.context import Context, MediaInfo, TorrentInfo +from app.core.metainfo import MetaInfo +from app.log import logger +from app.plugins import _PluginBase +from app.core.event import eventmanager +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), + re.compile(r"第\s*(\d{1,3})\s*集"), + re.compile(r"[【\[((](\d{1,3})[】\]))]"), + re.compile(r"\b(\d{1,3})\s*of\s*\d+\b", re.IGNORECASE), +] + +TV_HINTS = re.compile( + r"\b(S\d{1,2}E\d{1,3}|EP?\d{1,3}|第\d+集|Season|Complete|HDTV|WEB-?DL)\b", + re.IGNORECASE, +) + +COMPLETE_HINTS = re.compile( + r"\b(Complete|全集|全季|Season\s*\d+\s*Complete|S\d+\s*Complete)\b", + re.IGNORECASE, +) + + +class TvFirstWatch(_PluginBase): + plugin_name = "首播试看" + plugin_desc = "定时抓取 RSS, 只下载剧集前 N 集(首播试看),防重复推送。" + plugin_icon = "rss.png" + plugin_version = "1.0.0" + 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 + _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 = "" + _max_storage_gb: int = 0 + _default_size_gb: float = 2.0 + _max_single_size_gb: float = 10.0 + + def init_plugin(self, config: dict = None) -> None: + self._downloadchain = DownloadChain() + self.stop_service() + + if config: + self._enabled = _to_bool(config.get("enabled", False), False) + self._onlyonce = _to_bool(config.get("onlyonce", False), False) + self._cron = config.get("cron", "*/30 * * * *") or "*/30 * * * *" + self._rss_urls = config.get("rss_urls", "") + self._max_episode = _to_int(config.get("max_episode", 2), 2) + self._whitelist = config.get("whitelist", "") + self._blacklist = config.get("blacklist", "") + self._save_path = config.get("save_path", "") + self._max_storage_gb = _to_int(config.get("max_storage_gb", 0), 0) + self._default_size_gb = _to_float(config.get("default_size_gb", 2.0), 2.0) + self._max_single_size_gb = _to_float( + config.get("max_single_size_gb", 10.0), 10.0 + ) + + self._history_path = self.get_data_path() / "history.json" + + if self._onlyonce: + self._scheduler = BackgroundScheduler(timezone=settings.TZ) + logger.info("[首播试看] 立即运行一次") + self._scheduler.add_job( + func=self._check_all_feeds, + trigger="date", + run_date=datetime.datetime.now(tz=pytz.timezone(settings.TZ)) + + datetime.timedelta(seconds=3), + ) + if self._scheduler.get_jobs(): + self._scheduler.start() + + self._onlyonce = False + self.__update_config() + + def get_state(self) -> bool: + return self._enabled + + def get_command(self) -> List[Dict[str, Any]]: + return [ + { + "cmd": "/tvfirst_check", + "event": EventType.PluginAction, + "desc": "首播试看立即检查", + "category": "订阅", + "data": {"action": "check_feeds"}, + } + ] + + def get_api(self) -> List[Dict[str, Any]]: + return [ + { + "path": "/clear_history", + "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 [ + { + "id": "TvFirstWatch", + "name": "首播试看轮询", + "trigger": CronTrigger.from_crontab(self._cron), + "func": self._check_all_feeds, + "kwargs": {}, + } + ] + return [] + + @eventmanager.register(EventType.PluginAction) + def _plugin_action(self, event): + if not self._enabled: + return + event_data = event.event_data + if not event_data or event_data.get("action") != "check_feeds": + return + logger.info("[首播试看] 收到远程命令,立即执行检查") + self._check_all_feeds() + + def stop_service(self) -> None: + if self._scheduler: + self._scheduler.remove_all_jobs() + if self._scheduler.running: + self._scheduler.shutdown() + self._scheduler = None + + def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: + return [ + { + "component": "VForm", + "content": [ + { + "component": "VRow", + "content": [ + _col(4, _switch("enabled", "启用插件")), + _col(4, _switch("onlyonce", "立即运行一次")), + ], + }, + { + "component": "VRow", + "content": [ + _col( + 4, + _textfield( + "cron", + "执行周期(Cron)", + placeholder="*/30 * * * *", + ), + ), + _col( + 2, + _textfield( + "max_episode", + "最大集号", + placeholder="默认 2", + ), + ), + _col( + 3, + _textfield( + "max_storage_gb", + "空间上限(GB)", + placeholder="0=不限制", + ), + ), + _col( + 3, + _textfield( + "max_single_size_gb", + "单集上限(GB)", + placeholder="超过跳过", + ), + ), + ], + }, + { + "component": "VRow", + "content": [ + _col( + 6, + _textfield( + "default_size_gb", + "预估大小(GB)", + placeholder="RSS无大小默认值", + ), + ), + _col( + 6, + _textfield( + "save_path", + "下载保存路径", + placeholder="留空使用MP默认", + ), + ), + ], + }, + { + "component": "VRow", + "content": [ + _col( + 12, + { + "component": "VTextarea", + "props": { + "model": "rss_urls", + "label": "RSS 地址", + "rows": 4, + "placeholder": ( + "每行一个地址,格式:\n" + "https://site/rss?passkey=xxx\n" + "# 需要Cookie:https://site/rss|Cookie: uid=1; pass=abc" + ), + }, + }, + ), + ], + }, + { + "component": "VRow", + "content": [ + _col( + 6, + _textfield( + "whitelist", + "白名单关键字(逗号分隔)", + placeholder="1080p,4K,HEVC", + ), + ), + _col( + 6, + _textfield( + "blacklist", + "黑名单关键字(逗号分隔)", + placeholder="720p,CAM", + ), + ), + ], + }, + { + "component": "VRow", + "content": [ + _col( + 12, + { + "component": "VAlert", + "props": { + "type": "info", + "variant": "tonal", + "text": ( + "仅下载集号≤最大集号的电视剧,自动跳过Complete/全集。" + "单集上限:超过此大小的种子将跳过(防合集)。" + "空间上限:超出将停止下载,0表示不限制。" + ), + }, + }, + ), + ], + }, + ], + } + ], { + "enabled": False, + "onlyonce": False, + "cron": "*/30 * * * *", + "rss_urls": "", + "max_episode": 2, + "whitelist": "1080p,2160p,4K,HEVC,H.265", + "blacklist": "720p,CAM,HDTS", + "save_path": "", + "max_storage_gb": 0, + "default_size_gb": 2.0, + "max_single_size_gb": 10.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 + count = len(history) + + header_content = [ + { + "component": "div", + "props": {"class": "d-flex justify-space-between align-center"}, + "content": [ + { + "component": "p", + "props": {"class": "text-h6 mb-0"}, + "text": f"已用空间: {total_gb:.2f} GB" + + ( + f" / {max_gb} GB ({usage_percent:.1f}%)" + if max_gb > 0 + else f" | 共 {count} 条记录" + ), + }, + { + "component": "VBtn", + "props": { + "color": "error", + "variant": "outlined", + "size": "small", + }, + "text": "清空历史", + "events": { + "click": { + "api": "plugin/TvFirstWatch/clear_history", + "method": "get", + "params": {"token": settings.API_TOKEN}, + } + }, + }, + ], + } + ] + + if not history: + return [ + { + "component": "div", + "props": {"class": "pa-4"}, + "content": header_content + + [ + { + "component": "p", + "text": "暂无下载记录", + "props": {"class": "text-center mt-4"}, + }, + ], + } + ] + + rows = [] + 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", "")}, + ], + } + ) + + return [ + { + "component": "div", + "props": {"class": "pa-2"}, + "content": header_content, + }, + { + "component": "VTable", + "props": {"hover": True}, + "content": [ + { + "component": "thead", + "content": [ + { + "component": "tr", + "content": [ + {"component": "th", "text": "剧名"}, + {"component": "th", "text": "集号"}, + {"component": "th", "text": "大小"}, + {"component": "th", "text": "来源"}, + {"component": "th", "text": "下载时间"}, + ], + } + ], + }, + {"component": "tbody", "content": rows}, + ], + }, + ] + + def _check_all_feeds(self) -> None: + if not self._rss_urls: + logger.warning("[首播试看] 未配置任何 RSS 地址,跳过。") + return + + lines = [l.strip() for l in self._rss_urls.splitlines() if l.strip()] + for line in lines: + try: + self._process_feed(line) + except Exception as exc: + logger.error("[首播试看] 处理 RSS 源出错: %s — %s", line, exc) + + def _parse_feed_line(self, line: str) -> Tuple[str, dict]: + parts = line.split("|", 1) + url = parts[0].strip() + headers = { + "User-Agent": ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/123.0.0.0 Safari/537.36" + ) + } + if len(parts) == 2: + cookie_part = parts[1].strip() + if cookie_part.lower().startswith("cookie:"): + headers["Cookie"] = cookie_part[7:].strip() + else: + headers["Cookie"] = cookie_part + return url, headers + + def _process_feed(self, line: str) -> None: + url, headers = self._parse_feed_line(line) + source = urlparse(url).netloc + logger.info("[首播试看] 抓取 RSS: %s", url) + + try: + resp = requests.get(url, headers=headers, timeout=30) + resp.raise_for_status() + except requests.RequestException as exc: + logger.error("[首播试看] RSS 请求失败 [%s]: %s", source, exc) + return + + parsed = feedparser.parse(resp.text) + if not parsed.entries: + logger.warning("[首播试看] RSS 无条目 [%s]", source) + return + + logger.info("[首播试看] [%s] 解析到 %d 个条目", source, len(parsed.entries)) + for entry in parsed.entries: + try: + self._process_entry(entry, source) + except Exception as exc: + logger.error( + "[首播试看] 处理条目异常 [%s]: %s", entry.get("title") or "", exc + ) + + def _process_entry(self, entry, source: str) -> None: + title = entry.get("title") or "" + if not title: + return + + if not self._is_tv(entry): + logger.debug("[首播试看][跳过-非TV] %s", title) + return + + if COMPLETE_HINTS.search(title): + logger.info("[首播试看][跳过-合集] %s | 检测到Complete/全集关键词", title) + return + + episodes = _extract_episodes(title) + if not episodes: + logger.debug("[首播试看][跳过-无集数] %s", title) + return + + eps_in_range = [ep for ep in episodes if ep <= self._max_episode] + if not eps_in_range: + logger.info( + "[首播试看][跳过-超限] %s | 识别集号=%s 限制≤%d", + title, + episodes, + self._max_episode, + ) + return + + ok, reason = self._keyword_filter(title) + if not ok: + logger.info("[首播试看][跳过-关键字] %s | %s", title, reason) + return + + series_name = _guess_series_name(title) + + torrent_size, is_estimated = self._get_torrent_size(entry) + size_label = "预估" if is_estimated else "实际" + size_gb = torrent_size / (1024**3) + + if self._max_single_size_gb > 0 and size_gb > self._max_single_size_gb: + logger.info( + "[首播试看][跳过-过大] %s | 大小 %.2f GB > 上限 %.1f GB (可能是合集)", + title, + size_gb, + self._max_single_size_gb, + ) + return + + with lock: + history = self._load_history() + new_eps = [ + ep + for ep in eps_in_range + if not self._is_downloaded(history, series_name, ep) + ] + if not new_eps: + logger.info( + "[首播试看][跳过-已下载] %s | 剧名=%s | 集号=%s", + title, + series_name, + eps_in_range, + ) + return + + 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), + size_gb, + size_label, + self._max_storage_gb, + ) + return + + logger.info( + "[首播试看][下载] %s | 剧名=%s | 集号=%s | 大小=%.2f GB(%s) | 来源=%s", + title, + series_name, + new_eps, + size_gb, + 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] = { + "series_name": series_name, + "episode": ep, + "title": title, + "source": source, + "added_at": now, + "size": torrent_size, + "size_str": size_str, + "is_estimated": is_estimated, + } + self._save_history(history) + + 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: + torrent_url = "" + for enc in getattr(entry, "enclosures", []): + enc_type = enc.get("type") or "" + if enc_type.startswith("application/"): + torrent_url = enc.get("href") or "" + break + if not torrent_url: + torrent_url = entry.get("link") or "" + + if not torrent_url: + logger.error("[首播试看] 条目缺少种子 URL: %s", title) + return False + + meta = MetaInfo(title=title) + if not meta.name: + meta.name = series_name + + mediainfo = self.chain.recognize_media(meta=meta) + if not mediainfo: + logger.warning("[首播试看] 未识别到媒体信息,使用基本信息: %s", title) + mediainfo = MediaInfo() + mediainfo.type = MediaType.TV + mediainfo.title = series_name + + torrent = TorrentInfo( + title=title, + enclosure=torrent_url, + page_url=entry.get("link", ""), + ) + + context = Context( + meta_info=meta, + media_info=mediainfo, + torrent_info=torrent, + ) + + try: + did = self._downloadchain.download_single( + context=context, + torrent_file=None, + save_path=self._save_path or None, + ) + if did: + logger.info("[首播试看] ✅ DownloadChain 推送成功: %s", title) + return True + else: + 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()]: + if kw.lower() in title_lower: + return False, f"命中黑名单「{kw}」" + wl = [k.strip() for k in self._whitelist.split(",") if k.strip()] + if wl and not any(k.lower() in title_lower for k in wl): + return False, f"未命中白名单 {wl}" + return True, "" + + @staticmethod + def _is_tv(entry) -> bool: + cat = "" + try: + if hasattr(entry, "tags") and entry.tags: + term = entry.tags[0].get("term") or "" + cat = term.lower() + elif hasattr(entry, "category"): + cat = (entry.category or "").lower() + except Exception: + pass + + tv_cats = ("tv", "series", "drama", "television", "综艺", "剧集", "连续剧") + movie_kw = ("movie", "film", "电影", "纪录片") + if any(k in cat for k in tv_cats): + return True + if any(k in cat for k in movie_kw): + return False + return bool(TV_HINTS.search(entry.get("title") or "")) + + def _load_history(self) -> dict: + if self._history_path and self._history_path.exists(): + try: + return json.loads(self._history_path.read_text(encoding="utf-8")) + except Exception: + pass + return {} + + def _save_history(self, history: dict) -> None: + if self._history_path: + self._history_path.write_text( + json.dumps(history, ensure_ascii=False, indent=2), encoding="utf-8" + ) + + @staticmethod + def _is_downloaded(history: dict, series_name: str, episode: int) -> bool: + return _make_key(series_name, episode) in history + + def _clear_history(self, token: str = "") -> dict: + 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( + { + "enabled": self._enabled, + "onlyonce": self._onlyonce, + "cron": self._cron, + "rss_urls": self._rss_urls, + "max_episode": self._max_episode, + "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, + "max_single_size_gb": self._max_single_size_gb, + } + ) + + +def _extract_episodes(title: str) -> List[int]: + found: set[int] = set() + for pat in EPISODE_PATTERNS: + for m in pat.finditer(title): + try: + ep = int(m.group(1)) + if 0 < ep < 1000: + found.add(ep) + except (IndexError, ValueError): + pass + return sorted(found) + + +def _guess_series_name(title: str) -> str: + name = re.split( + r"[\s._\-]*(?:[Ss]\d{1,2}[Ee]\d{1,3}|[Ee][Pp]?\d{1,3}|第\d+集|\d+of\d+|[\[(【]\d+[】\])])", + title, + maxsplit=1, + )[0] + name = re.sub(r"^\s*\[.*?\]\s*", "", name) + name = re.sub(r"\b(19|20)\d{2}\b", "", name) + name = re.sub(r"[\s._\-]+", " ", name).strip(" .-_") + return name or title + + +def _make_key(series_name: str, episode: int) -> str: + norm = re.sub(r"\W+", "", series_name.lower()) + return f"{norm}__ep{episode:03d}" + + +def _to_bool(value: Any, default: bool = False) -> bool: + if isinstance(value, bool): + return value + if value is None: + return default + if isinstance(value, (int, float)): + return value != 0 + if isinstance(value, str): + v = value.strip().lower() + if v in ("1", "true", "yes", "y", "on"): + return True + if v in ("0", "false", "no", "n", "off", ""): + return False + return default + + +def _to_int(value: Any, default: int) -> int: + try: + return int(value) + except (TypeError, ValueError): + return default + + +def _to_float(value: Any, default: float) -> float: + try: + return float(value) + except (TypeError, ValueError): + return default + + +def _col(md: int, *children) -> dict: + return { + "component": "VCol", + "props": {"cols": 12, "md": md}, + "content": list(children), + } + + +def _switch(model: str, label: str) -> dict: + return { + "component": "VSwitch", + "props": {"model": model, "label": label}, + } + + +def _textfield(model: str, label: str, placeholder: str = "") -> dict: + props: dict = {"model": model, "label": label} + if placeholder: + props["placeholder"] = placeholder + return {"component": "VTextField", "props": props} diff --git a/plugins.v2/tvfirstwatch/requirements.txt b/plugins.v2/tvfirstwatch/requirements.txt new file mode 100644 index 0000000..3244d21 --- /dev/null +++ b/plugins.v2/tvfirstwatch/requirements.txt @@ -0,0 +1 @@ +feedparser>=6.0.0 \ No newline at end of file