From e84309ec9dbf7d33737e83d244ef58dbdcdb2f3f Mon Sep 17 00:00:00 2001 From: XC-Pro <19840+xc-pro@noreply.example.org> Date: Fri, 14 Aug 2026 11:26:58 +0200 Subject: [PATCH] =?UTF-8?q?=E4=B8=8A=E4=BC=A0=E6=96=87=E4=BB=B6=E8=87=B3?= =?UTF-8?q?=E3=80=8Cpy=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- py/周六影库.py | 528 +++ py/悦听.py | 392 ++ py/源没有图标央视网.py | 570 +++ py/粤漫.py | 409 ++ py/资源管理.py | 8198 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 10097 insertions(+) create mode 100644 py/周六影库.py create mode 100644 py/悦听.py create mode 100644 py/源没有图标央视网.py create mode 100644 py/粤漫.py create mode 100644 py/资源管理.py diff --git a/py/周六影库.py b/py/周六影库.py new file mode 100644 index 0000000..ab24644 --- /dev/null +++ b/py/周六影库.py @@ -0,0 +1,528 @@ +#coding=utf-8 +#!/usr/bin/python +""" +影视仓 (CatVod) Python 爬虫源 - 58影视 (58hu.com) +=================================================== + + +网站结构 (苹果CMS): + 分类页: /index.php/vod/type/id/{id}.html + 筛选: /index.php/vod/show/class/{类型}/id/{id}/year/{年份}/page/{页码}.html + 详情页: /index.php/vod/detail/id/{id}.html + 播放页: /index.php/vod/play/id/{id}/sid/{sid}/nid/{nid}.html + 搜索: /index.php/vod/search.html?wd={keyword} + 搜索分页: /index.php/vod/search/page/{page}/wd/{keyword}.html + +播放解析: + player_data.url -> 第三方链接 (腾讯/优酷/爱奇艺等) + 通过 jxcb.58hu.com 解析接口获取真实播放地址 + playerconfig.js 中定义了所有线路的解析地址 + +分类ID: + 1=电影, 2=连续剧, 3=综艺, 4=动漫, 5=短剧 + 6=动作片, 7=喜剧片, 8=爱情片, 9=科幻片, 10=恐怖片, 11=剧情片, 12=战争片 + 13=国产剧, 14=港台剧, 15=日韩剧, 16=欧美剧, 17=海外剧 +""" + +import re +import sys +import json +import time +import random +from urllib.parse import quote, urljoin + +sys.path.append("..") + +try: + from pyquery import PyQuery as pq +except ImportError: + pass + +from base.spider import Spider + + +class Spider(Spider): + + host = "https://www.58hu.com" + + headers = { + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", + "User-Agent": ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/125.0.0.0 Safari/537.36" + ), + "Referer": host, + } + + # 播放器配置 (来自 playerconfig.js) + # 所有线路的解析接口 + parse_url = "https://jxcb.58hu.com/?url=" + parse_url2 = "https://qcbl.xundog.com/?url=" + + # 电影子分类 + movie_sub = [ + {"n": "全部", "v": ""}, + {"n": "动作片", "v": "6"}, + {"n": "喜剧片", "v": "7"}, + {"n": "爱情片", "v": "8"}, + {"n": "科幻片", "v": "9"}, + {"n": "恐怖片", "v": "10"}, + {"n": "剧情片", "v": "11"}, + {"n": "战争片", "v": "12"}, + {"n": "纪录片", "v": "22"}, + ] + + # 连续剧子分类 + tv_sub = [ + {"n": "全部", "v": ""}, + {"n": "国产剧", "v": "13"}, + {"n": "港台剧", "v": "14"}, + {"n": "日韩剧", "v": "15"}, + {"n": "欧美剧", "v": "16"}, + {"n": "海外剧", "v": "17"}, + ] + + # 年份选项 + years = [{"n": "全部", "v": ""}] + for _y in range(2026, 2014, -1): + years.append({"n": str(_y), "v": str(_y)}) + + # 类型标签 (用于 class 筛选) + tags = [ + {"n": "全部", "v": ""}, + {"n": "喜剧", "v": "喜剧"}, + {"n": "爱情", "v": "爱情"}, + {"n": "恐怖", "v": "恐怖"}, + {"n": "动作", "v": "动作"}, + {"n": "科幻", "v": "科幻"}, + {"n": "剧情", "v": "剧情"}, + {"n": "战争", "v": "战争"}, + {"n": "犯罪", "v": "犯罪"}, + {"n": "奇幻", "v": "奇幻"}, + {"n": "悬疑", "v": "悬疑"}, + {"n": "动画", "v": "动画"}, + ] + + def init(self, extend=""): + pass + + def getName(self): + return "58影视" + + def destroy(self): + pass + + def action(self, action): + return None + + def isVideoFormat(self, url): + return bool(re.search(r"\.(m3u8|mp4|flv|avi)(\?|$)", url)) + + def manualVideoCheck(self): + return False + + # -------------------------------------------------------- + # 首页分类 + 筛选配置 + # -------------------------------------------------------- + def homeContent(self, filter): + html = self.fetch(self.host, headers=self.headers).text + data = self.getpq(html) + + classes = [] + # 从导航栏提取分类 + for a in data("a[href*='/vod/type/']").items(): + href = a.attr("href") + if href: + m = re.search(r"id/(\d+)", href) + if m: + classes.append({ + "type_name": a.text().strip(), + "type_id": m.group(1) + }) + + # 去重 + seen = set() + unique_classes = [] + for c in classes: + if c["type_id"] not in seen and c["type_name"]: + seen.add(c["type_id"]) + unique_classes.append(c) + + result = {"class": unique_classes[:8]} + + # 筛选配置 + if filter: + result["filters"] = {} + # 电影: 子分类 + 类型标签 + 年份 + result["filters"]["1"] = [ + {"key": "cateId", "name": "分类", "value": self.movie_sub}, + {"key": "class", "name": "类型", "value": self.tags}, + {"key": "year", "name": "年份", "value": self.years}, + ] + # 连续剧: 子分类 + 年份 + result["filters"]["2"] = [ + {"key": "cateId", "name": "分类", "value": self.tv_sub}, + {"key": "year", "name": "年份", "value": self.years}, + ] + # 综艺: 年份 + result["filters"]["3"] = [ + {"key": "year", "name": "年份", "value": self.years}, + ] + # 动漫: 年份 + result["filters"]["4"] = [ + {"key": "year", "name": "年份", "value": self.years}, + ] + # 短剧: 年份 + result["filters"]["5"] = [ + {"key": "year", "name": "年份", "value": self.years}, + ] + + return result + + # -------------------------------------------------------- + # 首页推荐 + # -------------------------------------------------------- + def homeVideoContent(self): + html = self.fetch(self.host, headers=self.headers).text + data = self.getpq(html) + videos = [] + # 首页结构: div.box-title (标题) + 兄弟 div.layout-box (影片列表) + for bt in data(".box-title").items(): + h2 = bt.find("h2") + title = h2.text().strip() if h2 else "" + if "热播榜" in title: + continue + # 找紧跟的兄弟 div.layout-box + for sib in bt.next_all().items(): + if sib.is_("div") and sib.hasClass("layout-box"): + for item in sib.find("li.pic-list-hover").items(): + v = self.parse_card(item) + if v: + videos.append(v) + break + return {"list": videos} + + # -------------------------------------------------------- + # 分类列表 (支持筛选) + # -------------------------------------------------------- + def categoryContent(self, tid, pg, filter, extend): + # 构建筛选URL + # 格式: /index.php/vod/show/class/{类型}/id/{id}/year/{年份}/page/{页码}.html + cate_id = tid + cls = "" + year = "" + + if extend: + if extend.get("cateId"): + cate_id = extend["cateId"] + if extend.get("class"): + cls = extend["class"] + if extend.get("year"): + year = extend["year"] + + url_parts = [f"{self.host}/index.php/vod/show"] + if cls: + url_parts.append(f"class/{quote(cls)}") + url_parts.append(f"id/{cate_id}") + if year: + url_parts.append(f"year/{year}") + url_parts.append(f"page/{pg}.html") + url = "/".join(url_parts) + + html = self.fetch(url, headers=self.headers).text + data = self.getpq(html) + + videos = self.getlist(data(".border-box, .public-r, .box")) + + # 解析分页 + pagecount = 9999 + page_text = data(".page").text() + m = re.search(r"(\d+)/(\d+)", page_text) + if m: + pagecount = int(m.group(2)) + + return { + "list": videos, + "page": int(pg), + "pagecount": pagecount, + "limit": 90, + "total": 999999 + } + + # -------------------------------------------------------- + # 详情 + # -------------------------------------------------------- + def detailContent(self, ids): + url = f"{self.host}/index.php/vod/detail/id/{ids[0]}.html" + html = self.fetch(url, headers=self.headers).text + data = self.getpq(html) + + vod = { + "vod_id": ids[0], + "vod_name": data("h1").text().strip() or data("title").text().split("-")[0].strip(), + "vod_pic": data("img[data-original]").attr("data-original") or data("img.lazyload").attr("data-original") or "", + "type_name": "", + "vod_year": "", + "vod_area": "", + "vod_remarks": "", + "vod_actor": "", + "vod_director": "", + "vod_content": "" + } + + # 解析详情信息 + info_div = data(".video-info") + if info_div: + for div in info_div("div").items(): + text = div.text().strip() + if text.startswith("类型:"): + vod["type_name"] = text.replace("类型:", "").strip() + elif text.startswith("年代:"): + vod["vod_year"] = text.replace("年代:", "").strip() + elif text.startswith("国家地区:"): + vod["vod_area"] = text.replace("国家地区:", "").strip() + elif text.startswith("状态:"): + vod["vod_remarks"] = text.replace("状态:", "").strip() + elif text.startswith("主演:"): + vod["vod_actor"] = text.replace("主演:", "").strip() + elif text.startswith("导演:"): + vod["vod_director"] = text.replace("导演:", "").strip() + elif text.startswith("简介:"): + vod["vod_content"] = text.replace("简介:", "").strip() + + # 解析播放源 + tab_links = data("a.gico") + tab_names = [a.text().strip() for a in tab_links.items() if a.text().strip()] + + playlists = data("ul.fade-in") + if not playlists: + playlists = data("ul.playlist") + + play_from = [] + play_url = [] + + for i in range(len(playlists)): + name = f"线路{i + 1}" + if i < len(tab_names): + name = tab_names[i] + play_from.append(name) + + episodes = [] + ul = playlists.eq(i) + for li in ul("li a").items(): + title = li.text().strip() + href = li.attr("href") + if title and href: + m = re.search(r"sid/(\d+)/nid/(\d+)", href) + if m: + sid, nid = m.group(1), m.group(2) + episodes.append(f"{title}${ids[0]}/{sid}/{nid}") + + play_url.append("#".join(episodes)) + + vod["vod_play_from"] = "$$$".join(play_from) + vod["vod_play_url"] = "$$$".join(play_url) + + return {"list": [vod]} + + # -------------------------------------------------------- + # 搜索 + # -------------------------------------------------------- + def searchContent(self, key, quick, pg="1"): + if str(pg) == "1": + url = f"{self.host}/index.php/vod/search.html?wd={quote(key)}" + else: + url = f"{self.host}/index.php/vod/search/page/{pg}/wd/{quote(key)}.html" + + html = self.fetch(url, headers=self.headers).text + data = self.getpq(html) + videos = [] + + # 搜索结果在 h2.font16 a 中 (ul.pic-list.box-news-list > li > h2 > a) + for h2 in data("h2.font16").items(): + a = h2.find("a[href*='/vod/detail/']") + if not a: + continue + href = a.attr("href") + if not href: + continue + + m = re.search(r"id/(\d+)", href) + if not m: + continue + + vid = m.group(1) + title = a.text().strip() + if not title: + continue + + # 图片在 li 层级 (h2 的祖父元素) + pic = "" + li = h2.closest("li") + if li: + img = li.find("img") + if img: + pic = img.attr("data-original") or img.attr("src") or "" + + # 提取状态信息 (li > div > p 第一个) + remarks = "" + if li: + p = li.find("p.text-muted") + if p: + remarks = p.text().strip() + + videos.append({ + "vod_id": vid, + "vod_name": title, + "vod_pic": pic, + "vod_remarks": remarks + }) + + return {"list": videos, "page": pg} + + # -------------------------------------------------------- + # 播放解析 + # -------------------------------------------------------- + def playerContent(self, flag, id, vipFlags): + """ + flag: 播放源名称 + id: videoId/sid/nid + """ + parts = str(id).split("/") + if len(parts) != 3: + return {"parse": 1, "url": "", "header": self.headers} + + video_id, sid, nid = parts + url = f"{self.host}/index.php/vod/play/id/{video_id}/sid/{sid}/nid/{nid}.html" + + html = self.fetch(url, headers=self.headers).text + + play_url = "" + source_from = "" + + # 从 player_data JSON 提取 + m = re.search(r'var\s+player_data\s*=\s*(\{[^<]+\})', html) + if m: + try: + pd = json.loads(m.group(1)) + play_url = pd.get("url", "").replace("\\/", "/") + source_from = pd.get("from", "") + except (json.JSONDecodeError, KeyError): + pass + + # 备用: 从 iframe 提取 + if not play_url: + m = re.search(r']+src="([^"]+)"', html) + if m: + play_url = m.group(1).replace("\\/", "/") + + if not play_url: + return {"parse": 1, "url": f"{self.host}{url}", "header": self.headers} + + # 如果已经是直链格式 + if self.isVideoFormat(play_url): + return { + "parse": 0, + "playUrl": "", + "url": play_url, + "header": self.headers + } + + # 第三方平台链接 -> 通过解析接口获取真实地址 + # 解析接口: jxcb.58hu.com/?url= (来自 playerconfig.js) + # 该接口返回 MizhiPlayerART 播放器页面,内含 AES 解密逻辑 + # 影视仓无法直接执行 JS,所以返回 parse=1 让影视仓处理 + jx_url = self.parse_url + play_url + + return { + "parse": 1, + "playUrl": "", + "url": jx_url, + "header": { + "User-Agent": self.headers["User-Agent"], + "Referer": self.host, + } + } + + # -------------------------------------------------------- + # 本地代理 + # -------------------------------------------------------- + def localProxy(self, param): + return None + + # -------------------------------------------------------- + # 工具方法 + # -------------------------------------------------------- + def getlist(self, data): + """从容器中提取视频列表""" + videos = [] + + # 方式1: box-title + layout-box (首页结构) + for bt in data(".box-title").items(): + h2 = bt.find("h2") + title = h2.text().strip() if h2 else "" + if "热播榜" in title: + continue + for sib in bt.next_all().items(): + if sib.is_("div") and sib.hasClass("layout-box"): + for item in sib.find("li.pic-list-hover").items(): + v = self.parse_card(item) + if v: + videos.append(v) + break + + # 方式2: 直接在容器中查找 (分类列表页) + if not videos: + for item in data.find("li.pic-list-hover").items(): + v = self.parse_card(item) + if v: + videos.append(v) + + return videos + + def parse_card(self, item): + """解析单个影片卡片""" + try: + a = item.find("a[href*='/vod/detail/']") + if not a: + return None + + href = a.attr("href") + title = a.attr("title") or a.text().strip() + + if not href or not title: + return None + + m = re.search(r"id/(\d+)", href) + if not m: + return None + + vid = m.group(1) + + img = item.find("img") + pic = img.attr("data-original") or img.attr("src") or "" + + score = item.find("span.score").text().strip() + status = item.find("span.titles").text().strip() + + remarks = score + if status: + remarks = f"{score} {status}".strip() if score else status + + return { + "vod_id": vid, + "vod_name": title, + "vod_pic": pic, + "vod_remarks": remarks + } + except Exception: + return None + + def getpq(self, data): + """创建 PyQuery 对象""" + try: + return pq(data) + except Exception: + return pq(data.encode("utf-8")) diff --git a/py/悦听.py b/py/悦听.py new file mode 100644 index 0000000..a9d0e96 --- /dev/null +++ b/py/悦听.py @@ -0,0 +1,392 @@ +# coding=utf-8 +#!/usr/bin/env python3 +"""TVBox / 影视仓 Python 源: 悦听吧有声书。""" + +import base64 +import hashlib +import html as html_lib +import json +import random +import re +import sys +import time +from urllib.parse import unquote, urljoin + +import requests + +sys.path.append('..') +try: + from base.spider import Spider as BaseSpider +except ImportError: + class BaseSpider(object): + pass + + +class Spider(BaseSpider): + site = 'http://www.yuetingba.cn' + _assl_marker = 'xMiP5W1DHBxC5PwQ5oj5QfRn0tsT5UBk' + _assl_key = 'le95G3hnFDJsBE+1/v9eYw==' + _assl_iv = 'IvswQFEUdKYf+d1wKpYLTg==' + + def __init__(self): + try: + super().__init__() + except TypeError: + pass + self.session = requests.Session() + self.session.headers.update({ + 'User-Agent': ( + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) ' + 'AppleWebKit/537.36 (KHTML, like Gecko) ' + 'Chrome/120.0.0.0 Safari/537.36' + ), + 'Referer': self.site + '/', + }) + self.cateManual = { + '玄幻': '1', '都市': '4', '历史': '2', '名著': '6', + '女频': '7', '科幻': '5', '武侠': '3', '评书': 'a', '社科': '8', + } + self._runtime_cache = {} + + def init(self, extend=''): + pass + + def getName(self): + return '悦听吧' + + def isVideoFormat(self, url): + pass + + def manualVideoCheck(self): + pass + + def homeContent(self, filter): + return { + 'class': [ + {'type_id': value, 'type_name': name} + for name, value in self.cateManual.items() + ], + 'filters': {}, 'list': [], 'parse': 0, 'jx': 0, + } + + def homeVideoContent(self): + result = {'list': [], 'parse': 0, 'jx': 0} + try: + result['list'] = self._parse_books(self._get('/').text)[:30] + except Exception as error: + print('homeVideoContent error: %s' % error) + return result + + def categoryContent(self, tid, pg, filter, extend): + page = self._page_number(pg) + result = {'list': [], 'parse': 0, 'jx': 0, 'page': page} + try: + page_html = self._get('/book/%s/%s' % (tid, page)).text + result['list'] = self._parse_books(page_html) + total_match = re.search(r'共\s*(\d+)\s*条', page_html) + total = int(total_match.group(1)) if total_match else len(result['list']) + result['total'] = total + result['limit'] = 10 + result['pagecount'] = max(page, (total + 9) // 10) + except Exception as error: + print('categoryContent error: %s' % error) + result.update({'total': 0, 'limit': 10, 'pagecount': page}) + return result + + def detailContent(self, ids): + result = {'list': [], 'parse': 0, 'jx': 0} + book_id = ids[0] if ids else '' + if not book_id: + return result + book_id = self._book_id(book_id) + try: + first_html = self._get('/book/detail/%s/0' % book_id).text + title = self._first_text(first_html, r']*>(.*?)') + if not title: + title = self._first_text(first_html, r'book-detail-title[^>]*>(.*?)') + pic_match = re.search( + r'
.*?]+src=["\']([^"\']+)', + first_html, re.S, + ) + pic = urljoin(self.site, pic_match.group(1)) if pic_match else '' + author = self._field(first_html, '作\s*者') + category = self._field(first_html, '分\s*类') + speaker = self._field(first_html, '演\s*播') + status = self._field(first_html, '状\s*态') + count = self._field(first_html, '集\s*数') + desc_match = re.search( + r'内容简介:.*?text-desc-content[^>]*>\s*

(.*?)

', + first_html, re.S, + ) + desc = self._clean(desc_match.group(1)) if desc_match else '' + + offsets = sorted({ + int(value) for value in re.findall( + r'/book/detail/%s/(\d+)' % re.escape(book_id), first_html + ) + }) or [0] + episodes = self._parse_episodes(first_html) + seen = {episode_id for _, episode_id in episodes} + for offset in offsets: + if offset == 0: + continue + page_html = self._get('/book/detail/%s/%s' % (book_id, offset)).text + for episode in self._parse_episodes(page_html): + if episode[1] not in seen: + episodes.append(episode) + seen.add(episode[1]) + + vod = { + 'vod_id': book_id, + 'vod_name': title, + 'vod_pic': pic, + 'type_name': category, + 'vod_year': '', + 'vod_area': '', + 'vod_remarks': ('%s · %s集' % (status, count)).strip(' ·'), + 'vod_actor': speaker, + 'vod_director': author, + 'vod_content': desc, + 'vod_play_from': '悦听吧', + 'vod_play_url': '#'.join( + '%s$%s' % (self._play_name(name), episode_id) + for name, episode_id in episodes + ), + } + result['list'].append(vod) + except Exception as error: + print('detailContent error: %s' % error) + return result + + def playerContent(self, flag, id, vipFlags): + try: + audio_url = self._resolve_audio(id) + return { + 'parse': 0, + 'url': audio_url, + 'jx': 0, + 'header': { + 'User-Agent': self.session.headers['User-Agent'], + 'Referer': self.site + '/', + }, + } + except Exception as error: + print('playerContent error: %s' % error) + return {'parse': 1, 'url': id, 'jx': 0, 'header': {}} + + def searchContent(self, key, quick, pg='1'): + page = self._page_number(pg) + result = {'list': [], 'parse': 0, 'jx': 0, 'page': page} + try: + response = self._get('/Search', params={ + 'type': '1', 'name': key, 'pageIndex': page, + }) + result['list'] = self._parse_books(response.text) + total_match = re.search(r'共\s*(\d+)\s*条', response.text) + total = int(total_match.group(1)) if total_match else len(result['list']) + result.update({ + 'total': total, + 'limit': 10, + 'pagecount': max(page, (total + 9) // 10), + }) + except Exception as error: + print('searchContent error: %s' % error) + result.update({'total': 0, 'limit': 10, 'pagecount': page}) + return result + + def localProxy(self, params): + return [200, 'audio/mpeg', {}, ''] + + def _get(self, path, **kwargs): + url = path if path.startswith('http') else urljoin(self.site + '/', path.lstrip('/')) + response = self.session.get(url, timeout=20, **kwargs) + response.raise_for_status() + response.encoding = response.apparent_encoding or 'utf-8' + return response + + def _parse_books(self, page_html): + videos = [] + seen = set() + pattern = re.compile( + r']+class="[^"]*section-box-list-item[^"]*"[^>]*>(.*?)' + r'(?=]+class="[^"]*section-box-list-item||
]*>\s*]*>(.*?)', block, re.S + ) + image = re.search(r']+src=["\']([^"\']+)["\']', block) + if not link or not title: + continue + book_id = link.group(1) + if book_id in seen: + continue + seen.add(book_id) + author_speaker = [self._clean(value) for value in re.findall( + r']+title=["\']([^"\']+)["\']', block + )] + remark = ' / '.join(author_speaker[:2]) + videos.append({ + 'vod_id': book_id, + 'vod_name': self._clean(title.group(1)), + 'vod_pic': urljoin(self.site, image.group(1)) if image else '', + 'vod_remarks': remark, + }) + return videos + + def _parse_episodes(self, page_html): + episodes = [] + for episode_id, block in re.findall( + r'
(.*?)
\s*
', + page_html, re.S | re.I, + ): + titles = re.findall(r']+title="([^"]+)"', block, re.S) + name = self._clean(titles[-1]) if titles else episode_id + episodes.append((name, episode_id)) + return episodes + + def _resolve_audio(self, episode_id): + data = self._get( + '/api/app/docs-listen/%s/ting-with-efi' % episode_id + ).json() + book_id = data['bookId'] + ting_no = int(data.get('tingNo') or 1) + offset = ((ting_no - 1) // 200) * 200 + runtime = self._book_runtime(book_id, offset) + + plain_id = data['id'].replace('-', '') + compact_time = re.sub(r'[-:T. ]', '', data['creationTime']).ljust(20, '0') + key = bytes( + (ord(plain_id[index]) + int(compact_time[index % 20])) & 0xff + for index in range(len(plain_id)) + ) + iv = bytes( + (ord(plain_id[index]) + int(compact_time[index - 1])) & 0xff + for index in range(20, 4, -1) + ) + source_path = self._aes_decrypt(data['efi'], key, iv).decode('utf-8').strip() + filename = unquote(source_path.rstrip('/').split('/')[-1]) + server = self._pick_server(runtime['servers'], book_id) + + path = source_path + if '_p' in server['Name']: + path = '/%s_%s/%s' % (runtime['py'], book_id, filename) + elif '_b' in server['Name']: + path = '/myfiles/host/listen/booksdir/%s_%s/%s' % ( + runtime['py'], book_id, filename, + ) + + expires = int(time.time()) + 600 + signature = hashlib.md5( + ('%s|%s|%s' % (filename, expires, self._assl_marker)).encode('utf-8') + ).hexdigest() + host = '%s://%s:%s' % (server['Scheme'], server['Value'], server['Port']) + return '%s%s?token=%s&expire=%s' % (host, path, signature, expires) + + def _book_runtime(self, book_id, offset): + cached = self._runtime_cache.get(book_id) + if cached and time.time() - cached['cached_at'] < 1800: + return cached + page_html = self._get('/book/detail/%s/%s' % (book_id, offset)).text + assl = self._js_var(page_html, 'assl').replace(self._assl_marker, '') + servers = json.loads(self._aes_decrypt( + assl, base64.b64decode(self._assl_key), base64.b64decode(self._assl_iv) + ).decode('utf-8')) + runtime = { + 'servers': servers, + 'py': self._js_var(page_html, 'py'), + 'cached_at': time.time(), + } + self._runtime_cache[book_id] = runtime + return runtime + + @staticmethod + def _pick_server(servers, book_id): + available = [ + item for item in servers + if str(item.get('AsType')) == '1' and item.get('Type') == 'A' + ] + group = book_id.split('-')[4] + dedicated = [ + item for item in available + if item.get('BookIds') and group in item['BookIds'] + ] + if dedicated: + available = dedicated + else: + available = [ + item for item in available + if int(item.get('Ratio') or 0) > 0 + and not str(item.get('BookIds') or '').strip() + ] + if not available: + raise ValueError('没有可用的音频服务器') + weights = [max(0, int(item.get('Ratio') or 0)) for item in available] + return random.choices(available, weights=weights or None, k=1)[0] + + @staticmethod + def _aes_decrypt(cipher_text, key, iv): + encrypted = base64.b64decode(re.sub(r'\s+', '', cipher_text)) + try: + from Crypto.Cipher import AES + padded = AES.new(key, AES.MODE_CBC, iv).decrypt(encrypted) + except ImportError: + try: + from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes + except ImportError: + raise RuntimeError('需要 pycryptodome 或 cryptography 才能解析音频') + decryptor = Cipher(algorithms.AES(key), modes.CBC(iv)).decryptor() + padded = decryptor.update(encrypted) + decryptor.finalize() + padding_size = padded[-1] + if padding_size < 1 or padding_size > 16: + raise ValueError('AES 填充无效') + return padded[:-padding_size] + + @staticmethod + def _js_var(page_html, name): + match = re.search( + r'\b(?:var|let|const)\s+%s\s*=\s*["\']([^"\']*)["\']' % re.escape(name), + page_html, + ) + if not match: + raise ValueError('页面缺少参数: %s' % name) + return html_lib.unescape(match.group(1)) + + @staticmethod + def _field(page_html, label): + match = re.search( + r'text-desc-title[^>]*>\s*%s:?\s*\s*' + r']*text-desc-content[^>]*>(.*?)' % label, + page_html, re.S, + ) + return Spider._clean(match.group(1)) if match else '' + + @staticmethod + def _first_text(page_html, pattern): + match = re.search(pattern, page_html, re.S) + return Spider._clean(match.group(1)) if match else '' + + @staticmethod + def _clean(value): + value = re.sub(r'', '\n', value or '', flags=re.I) + value = re.sub(r'<[^>]+>', '', value) + value = html_lib.unescape(value) + return re.sub(r'[ \t\r\f\v]+', ' ', value).strip() + + @staticmethod + def _page_number(value): + try: + return max(1, int(value or 1)) + except (TypeError, ValueError): + return 1 + + @staticmethod + def _book_id(value): + match = re.search(r'/book/detail/([^/]+)/', value or '') + return match.group(1) if match else value + + @staticmethod + def _play_name(name): + return (name or '').replace('$', '¥').replace('#', '﹟') diff --git a/py/源没有图标央视网.py b/py/源没有图标央视网.py new file mode 100644 index 0000000..637d7f4 --- /dev/null +++ b/py/源没有图标央视网.py @@ -0,0 +1,570 @@ +import sys +import re +import json +import time +import random +import struct +import requests +from base64 import b64encode, b64decode +from urllib.parse import urlencode +from urllib3 import disable_warnings +from base.spider import Spider + +disable_warnings() + + +class Spider(Spider): + # ==================== 加密常量 ==================== + DELTA = 0x9e3779b9 + ROUNDS = 16 + LOG_ROUNDS = 4 + SALT_LEN = 2 + ZERO_LEN = 7 + TEA_CKEY = bytes.fromhex('59b2f7cf725ef43c34fdd7c123411ed3') + GUARD_TEA_KEY = bytes.fromhex('110DBEC10C23E7D2E56A1CAD6914EF1B') + + XOR_KEY = [0x84, 0x2E, 0xED, 0x08, 0xF0, 0x66, 0xE6, 0xEA, + 0x48, 0xB4, 0xCA, 0xA9, 0x91, 0xED, 0x6F, 0xF3] + GUARD_XOR_KEY = [0xB3, 0xC9, 0x53, 0xA0, 0x69, 0x13, 0xAD, 0x4D] + + STANDARD_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=' + CUSTOM_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-=' + + # ==================== 频道数据 ==================== + # {key: [cnlid, livepid, defn, display_name]} + CHANNELS = { + 'cctv1': ['2024078201', '600001859', 'fhd', 'CCTV-1'], + 'cctv2': ['2024075401', '600001800', 'fhd', 'CCTV-2'], + 'cctv3': ['2024068501', '600001801', 'fhd', 'CCTV-3'], + 'cctv4': ['2029797101', '600001814', 'fhd', 'CCTV-4'], + 'cctv5': ['2024078401', '600001818', 'fhd', 'CCTV-5'], + 'cctv5p': ['2024078001', '600001817', 'fhd', 'CCTV-5+'], + 'cctv6': ['2013693901', '600108442', 'fhd', 'CCTV-6'], + 'cctv7': ['2024072001', '600004092', 'fhd', 'CCTV-7'], + 'cctv8': ['2029793001', '600001803', 'fhd', 'CCTV-8'], + 'cctv9': ['2024078601', '600004078', 'fhd', 'CCTV-9'], + 'cctv10': ['2024078701', '600001805', 'fhd', 'CCTV-10'], + 'cctv11': ['2027248701', '600001806', 'fhd', 'CCTV-11'], + 'cctv12': ['2027248801', '600001807', 'fhd', 'CCTV-12'], + 'cctv13': ['2029797201', '600001811', 'fhd', 'CCTV-13'], + 'cctv14': ['2027248901', '600001809', 'fhd', 'CCTV-14'], + 'cctv15': ['2027249001', '600001815', 'fhd', 'CCTV-15'], + 'cctv16': ['2027249101', '600098637', 'fhd', 'CCTV-16'], + 'cctv164k': ['2027249301', '600099502', 'fhd', 'CCTV-16(4K)'], + 'cctv17': ['2027249401', '600001810', 'fhd', 'CCTV-17'], + 'cctv4k': ['2029810301', '600002264', 'fhd', 'CCTV-4K'], + 'cctv8k': ['2026774101', '600156816', 'fhd', 'CCTV-8K'], + 'cgtn': ['2024181701', '600014550', 'fhd', 'CGTN'], + 'cgtnfy': ['2024181801', '600084704', 'fhd', 'CGTN法语'], + 'cgtney': ['2024181901', '600084758', 'fhd', 'CGTN俄语'], + 'cgtnalby': ['2024182001', '600084782', 'fhd', 'CGTN阿拉伯语'], + 'cgtnxby': ['2024182101', '600084744', 'fhd', 'CGTN西班牙语'], + 'cgtnwyjl': ['2024182301', '600084781', 'fhd', 'CGTN外语纪录'], + 'cctvfyjc': ['2025637103', '600099658', 'shd', '风云剧场'], + 'cctvdyjc': ['2026874203', '600099655', 'shd', '第一剧场'], + 'cctvhjjc': ['2026874303', '600099620', 'shd', '怀旧剧场'], + 'cctvsjdl': ['2026874403', '600099637', 'shd', '世界地理'], + 'cctvfyyy': ['2026874503', '600099660', 'shd', '风云音乐'], + 'cctvbqkj': ['2026874603', '600099649', 'shd', '兵器科技'], + 'cctvfyzq': ['2026966203', '600099636', 'shd', '风云足球'], + 'cctvgeqwq': ['2026874703', '600099659', 'shd', '高尔夫·网球'], + 'cctvnxss': ['2026874803', '600099650', 'shd', '女性时尚'], + 'cctvyswhjp': ['2026874903', '600099653', 'shd', '央视文化精品'], + 'cctvystq': ['2026875003', '600099652', 'shd', '央视台球'], + 'cctvdszn': ['2026875103', '600099656', 'shd', '电视指南'], + 'cctvwsjk': ['2025637003', '600099651', 'shd', '卫生健康'], + 'bjws': ['2024052703', '600002309', 'fhd', '北京卫视'], + 'jsws': ['2024171103', '600002521', 'fhd', '江苏卫视'], + 'dfws': ['2024054503', '600002483', 'fhd', '东方卫视'], + 'zjws': ['2024054703', '600002520', 'fhd', '浙江卫视'], + 'hnws': ['2024054803', '600002475', 'fhd', '湖南卫视'], + 'hbws': ['2024171203', '600002508', 'fhd', '湖北卫视'], + 'gdws': ['2024060903', '600002485', 'fhd', '广东卫视'], + 'gxws': ['2024060703', '600002509', 'fhd', '广西卫视'], + 'hljws': ['2029797003', '600002498', 'fhd', '黑龙江卫视'], + 'hnws2': ['2024055603', '600002506', 'fhd', '海南卫视'], + 'cqws': ['2024061103', '600002531', 'fhd', '重庆卫视'], + 'szws': ['2024061303', '600002481', 'fhd', '深圳卫视'], + 'scws': ['2024061403', '600002516', 'fhd', '四川卫视'], + 'henanws': ['2029797303', '600002525', 'fhd', '河南卫视'], + 'fjdnhz': ['2024061503', '600002484', 'fhd', '福建东南卫视'], + 'gzhws': ['2024061603', '600002490', 'fhd', '贵州卫视'], + 'jxws': ['2024061703', '600002503', 'fhd', '江西卫视'], + 'lnws': ['2024171303', '600002505', 'fhd', '辽宁卫视'], + 'ahws': ['2024171403', '600002532', 'fhd', '安徽卫视'], + 'hbws2': ['2024171503', '600002493', 'fhd', '河北卫视'], + 'sdws': ['2029787903', '600002513', 'fhd', '山东卫视'], + 'tjws': ['2019927003', '600152137', 'fhd', '天津卫视'], + 'jlws': ['2025561503', '600190405', 'fhd', '吉林卫视'], + 'shanxiws': ['2029795103', '600190400', 'fhd', '陕西卫视'], + 'nxws': ['2025608503', '600190737', 'fhd', '宁夏卫视'], + 'nmgws': ['2025561203', '600190401', 'fhd', '内蒙古卫视'], + 'ynws': ['2025561303', '600190402', 'fhd', '云南卫视'], + 'shanxiws2': ['2025560803', '600190407', 'fhd', '山西卫视'], + 'qhws': ['2025559103', '600190406', 'fhd', '青海卫视'], + 'xzws': ['2025558003', '600190403', 'fhd', '西藏卫视'], + 'xjws': ['2019927403', '600152138', 'fhd', '新疆卫视'], + 'cetv1': ['2022823801', '600171827', 'fhd', '中国教育电视台'], + 'gxpd': ['2029360403', '600213139', 'fhd', '国学频道'], + } + + # ==================== 分类定义 ==================== + CATS = [ + { + 'name': '央视', + 'ids': [ + 'cctv1', 'cctv2', 'cctv3', 'cctv4', 'cctv5', 'cctv5p', + 'cctv6', 'cctv7', 'cctv8', 'cctv9', 'cctv10', 'cctv11', + 'cctv12', 'cctv13', 'cctv14', 'cctv15', 'cctv16', 'cctv164k', + 'cctv17', 'cctv4k', 'cctv8k', + ] + }, + { + 'name': 'CGTN', + 'ids': ['cgtn', 'cgtnfy', 'cgtney', 'cgtnalby', 'cgtnxby', 'cgtnwyjl'] + }, + { + 'name': '央视付费', + 'ids': [ + 'cctvfyjc', 'cctvdyjc', 'cctvhjjc', 'cctvsjdl', 'cctvfyyy', + 'cctvbqkj', 'cctvfyzq', 'cctvgeqwq', 'cctvnxss', 'cctvyswhjp', + 'cctvystq', 'cctvdszn', 'cctvwsjk', + ] + }, + { + 'name': '卫视', + 'ids': [ + 'bjws', 'jsws', 'dfws', 'zjws', 'hnws', 'hbws', 'gdws', + 'gxws', 'hljws', 'hnws2', 'cqws', 'szws', 'scws', 'henanws', + 'fjdnhz', 'gzhws', 'jxws', 'lnws', 'ahws', 'hbws2', 'sdws', + 'tjws', 'jlws', 'shanxiws', 'nxws', 'nmgws', 'ynws', + 'shanxiws2', 'qhws', 'xzws', 'xjws', + ] + }, + { + 'name': '其他', + 'ids': ['cetv1', 'gxpd'] + }, + ] + + # ==================== 初始化 ==================== + + def init(self, extend=""): + self.guid = self._gen_guid() + self.session = requests.Session() + self.session.verify = False + self.session.headers.update({ + 'User-Agent': 'qqlive', + 'Connection': 'Keep-Alive', + 'Accept': 'application/json', + }) + + def getName(self): + return "央视频" + + # ==================== 蜘蛛壳接口 ==================== + + def homeContent(self, filter): + classes = [ + {'type_id': str(i), 'type_name': c['name']} + for i, c in enumerate(self.CATS) + ] + return {'class': classes} + + def categoryContent(self, tid, pg, filter, extend): + cat = self.CATS[int(tid)] + vod_list = [] + for cid in cat['ids']: + ch = self.CHANNELS.get(cid) + if ch: + vod_list.append({ + 'vod_id': cid, + 'vod_name': ch[3], + 'vod_pic': '', + 'vod_remarks': ch[2].upper(), + }) + return { + 'page': 1, + 'pagecount': 1, + 'limit': len(vod_list), + 'total': len(vod_list), + 'list': vod_list, + } + + def detailContent(self, ids): + cid = ids[0] + ch = self.CHANNELS.get(cid) + if not ch: + return {'list': []} + vod = { + 'vod_id': cid, + 'vod_name': ch[3], + 'vod_pic': '', + 'vod_play_from': '央视频', + 'vod_play_url': f'直播${cid}', + 'vod_content': f'{ch[3]} 高清直播', + } + return {'list': [vod]} + + def playerContent(self, flag, id, vipFlags): + ch = self.CHANNELS.get(id) + if not ch: + return {'parse': 0, 'url': '', 'header': ''} + play_url = self._get_play_url(ch[0], ch[1], ch[2]) + if play_url: + return {'parse': 0, 'url': play_url, 'header': ''} + return {'parse': 0, 'url': '', 'header': ''} + + # ==================== 播放地址获取 ==================== + + def _get_play_url(self, cnlid, livepid, defn): + try: + self.guid = self._gen_guid() + ck = self._gen_ckey(cnlid) + flowid = self._gen_flowid() + + params = { + "atime": "120", + "livepid": livepid, + "cnlid": cnlid, + "appVer": "V8.22.1035.3031", + "app_version": "300090", + "caplv": "1", + "cmd": "2", + "defn": defn, + "device": "iPhone", + "encryptVer": "4.2", + "getpreviewinfo": "0", + "hevclv": "33", + "lang": "zh-Hans_JP", + "livequeue": "0", + "logintype": "1", + "nettype": "1", + "newnettype": "1", + "newplatform": "4330403", + "platform": "4330403", + "sdtfrom": "v3021", + "spacode": "23", + "spaudio": "1", + "spdemuxer": "6", + "spdrm": "2", + "spdynamicrange": "7", + "spflv": "1", + "spflvaudio": "1", + "sphdrfps": "60", + "sphttps": "0", + "spvcode": "MSgzMDoyMTYwLDYwOjIxNjB8MzA6MjE2MCw2MDoyMTYwKTsyKDMwOjIxNjAsNjA6MjE2MHwzMDoyMTYwLDYwOjIxNjAp", + "spvideo": "4", + "stream": "1", + "system": "1", + "sysver": "ios18.2.1", + "uhd_flag": "4", + "cKey": ck['ckey'], + "guid": self.guid, + "fntick": ck['params']['Timestamp'], + "flowid": flowid, + "playbacktime": "0", + } + + resp = self.session.get( + "https://bkliveinfo.ysp.cctv.cn", + params=params, + timeout=15, + ) + data = resp.json() + if data.get('iretcode') == 0 and data.get('playurl'): + return data['playurl'] + except Exception as e: + print(f"[央视频] 获取播放地址失败: {e}") + return None + + # ==================== GUID / FlowID ==================== + + def _gen_guid(self): + return '{:08x}{:04x}{:04x}{:04x}{:012x}'.format( + random.randint(0, 0xffffffff), + random.randint(0, 0xffff), + random.randint(0, 0xffff), + random.randint(0, 0xffff), + random.randint(0, 0xffffffffffff), + ) + + def _gen_flowid(self): + p = [ + random.randint(0, 0xffff), random.randint(0, 0xffff), + random.randint(0, 0xffff), + random.randint(0, 0x0fff) | 0x4000, + random.randint(0, 0x3fff) | 0x8000, + random.randint(0, 0xffff), random.randint(0, 0xffff), + random.randint(0, 0xffff), + ] + return '{:04X}{:04X}-{:04X}-{:04X}-{:04X}-{:04X}{:04X}{:04X}_4330403'.format(*p) + + # ==================== 签名 ==================== + + def _calc_sig(self, buf): + s = 0 + for b in buf: + s = (0x83 * s + (b & 0xFF)) & 0x7FFFFFFF + return s + + # ==================== 自定义 Base64 ==================== + + def _b64enc(self, data): + enc = b64encode(data).decode() + return enc.translate(str.maketrans(self.STANDARD_ALPHABET, self.CUSTOM_ALPHABET)).rstrip('=') + + def _b64dec(self, text): + text = text.rstrip('=') + if len(text) % 4: + text += '=' * (4 - len(text) % 4) + return b64decode(text.translate(str.maketrans(self.CUSTOM_ALPHABET, self.STANDARD_ALPHABET))) + + # ==================== XOR ==================== + + def _xor(self, arr): + return [arr[i] ^ self.XOR_KEY[i & 0xF] for i in range(len(arr))] + + # ==================== TEA ==================== + + def _tea_enc(self, data, key): + if len(data) < 8: + data = data.ljust(8, b'\0') + y, z = struct.unpack('>II', data[:8]) + k = struct.unpack('>4I', key[:16]) + s = 0 + for _ in range(self.ROUNDS): + s = (s + self.DELTA) & 0xFFFFFFFF + y = (y + (((z << 4) + k[0]) ^ (z + s) ^ ((z >> 5) + k[1]))) & 0xFFFFFFFF + z = (z + (((y << 4) + k[2]) ^ (y + s) ^ ((y >> 5) + k[3]))) & 0xFFFFFFFF + return struct.pack('>II', y, z) + + def _tea_dec(self, data, key): + y, z = struct.unpack('>II', data[:8]) + k = struct.unpack('>4I', key[:16]) + s = (self.DELTA << self.LOG_ROUNDS) & 0xFFFFFFFF + for _ in range(self.ROUNDS): + z = (z - (((y << 4) + k[2]) ^ (y + s) ^ ((y >> 5) + k[3]))) & 0xFFFFFFFF + y = (y - (((z << 4) + k[0]) ^ (z + s) ^ ((z >> 5) + k[1]))) & 0xFFFFFFFF + s = (s - self.DELTA) & 0xFFFFFFFF + return struct.pack('>II', y, z) + + # ==================== CBC 加密 ==================== + + def _cbc_enc(self, p_in, n_len, p_key): + pad_salt_zero = n_len + 1 + self.SALT_LEN + self.ZERO_LEN + n_pad = pad_salt_zero % 8 + if n_pad: + n_pad = 8 - n_pad + + out = b'' + src = [0] * 8 + src[0] = (random.randint(0, 255) & 0xF8) | n_pad + si = 1 + + while n_pad: + src[si] = random.randint(0, 255) + si += 1 + n_pad -= 1 + + iv_p = [0] * 8 + iv_c = [0] * 8 + + # salt + i = 0 + while i < self.SALT_LEN: + if si < 8: + src[si] = random.randint(0, 255) + si += 1 + i += 1 + if si == 8: + for j in range(8): + src[j] ^= iv_c[j] + tb = list(self._tea_enc(bytes(src), p_key)) + for j in range(8): + tb[j] ^= iv_p[j] + iv_p = list(src) + iv_c = list(tb) + out += bytes(tb) + si = 0 + + # body + pi = 0 + while n_len: + if si < 8: + src[si] = p_in[pi] + pi += 1 + si += 1 + n_len -= 1 + if si == 8: + for j in range(8): + src[j] ^= iv_c[j] + tb = list(self._tea_enc(bytes(src), p_key)) + for j in range(8): + tb[j] ^= iv_p[j] + iv_p = list(src) + iv_c = list(tb) + out += bytes(tb) + si = 0 + + # zero + i = 0 + while i < self.ZERO_LEN: + if si < 8: + src[si] = 0 + si += 1 + i += 1 + if si == 8: + for j in range(8): + src[j] ^= iv_c[j] + tb = list(self._tea_enc(bytes(src), p_key)) + for j in range(8): + tb[j] ^= iv_p[j] + iv_p = list(src) + iv_c = list(tb) + out += bytes(tb) + si = 0 + + # last + if si > 0: + for j in range(si, 8): + src[j] = 0 + for j in range(8): + src[j] ^= iv_c[j] + tb = list(self._tea_enc(bytes(src), p_key)) + for j in range(8): + tb[j] ^= iv_p[j] + out += bytes(tb) + + return out + + # ==================== CBC 解密 ==================== + + def _cbc_dec(self, p_in, n_len, p_key): + if n_len % 8 != 0 or n_len < 16: + return None + dest = list(self._tea_dec(p_in[:8], p_key)) + n_pad = dest[0] & 0x07 + out_len = n_len - 1 - n_pad - self.SALT_LEN - self.ZERO_LEN + if out_len < 0: + return None + + iv_pre = [0] * 8 + iv_cur = list(p_in[:8]) + off = 8 + di = 1 + n_pad + + # skip salt + sc = 1 + while sc <= self.SALT_LEN: + if di < 8: + di += 1 + sc += 1 + elif di == 8: + iv_pre = list(iv_cur) + if off + 8 > n_len: + return None + iv_cur = list(p_in[off:off + 8]) + for j in range(8): + dest[j] ^= iv_cur[j] + dest = list(self._tea_dec(bytes(dest), p_key)) + off += 8 + di = 0 + + # copy plain + plain = [] + rem = out_len + while rem > 0: + if di < 8: + plain.append(dest[di] ^ iv_pre[di]) + di += 1 + rem -= 1 + elif di == 8: + iv_pre = list(iv_cur) + if off + 8 > n_len: + return None + iv_cur = list(p_in[off:off + 8]) + for j in range(8): + dest[j] ^= iv_cur[j] + dest = list(self._tea_dec(bytes(dest), p_key)) + off += 8 + di = 0 + return bytes(plain) + + # ==================== Guard Time ==================== + + def _last5(self, v): + v = str(v) + return v[-5:] if len(v) >= 5 else '' + + def _gen_guard_time(self, ts, guid): + body = struct.pack('>I', ts) + for part in [self._last5(guid), self._last5('null'), self._last5('null'), '-1']: + pb = part.encode() + body += struct.pack('>H', len(pb)) + pb + + plain = struct.pack('>H', len(body)) + body + chk = self._calc_sig(list(plain)) + + enc = self._cbc_enc(plain, len(plain), self.GUARD_TEA_KEY) + struct.pack('>I', chk) + el = list(enc) + for i in range(len(el)): + el[i] ^= self.GUARD_XOR_KEY[i & 7] + return bytes(el).hex().upper() + + # ==================== CKey ==================== + + def _encrypt_ckey(self, data): + chk = self._calc_sig(list(data)) + enc = self._cbc_enc(data, len(data), self.TEA_CKEY) + struct.pack('>I', chk) + return '--01' + self._b64enc(bytes(self._xor(list(enc)))) + + def _build_pkt(self, params): + d = b'' + d += bytes.fromhex('0000004200000004000004d2') # 12-byte header + d += struct.pack('>I', params['Platform']) + d += struct.pack('>I', 0) # sig placeholder + d += struct.pack('>I', params['Timestamp']) + + for k in ['Sdtfrom', 'randFlag', 'appVer', 'vid', 'guid']: + v = params[k].encode() + d += struct.pack('>H', len(v)) + v + + d += struct.pack('>I', 1) # part1 + d += struct.pack('>I', 1) # isDlna + + for v in [b'2622783A', b'nil']: + d += struct.pack('>H', len(v)) + v + + uuid4 = params['uuid4'].encode() + d += struct.pack('>H', len(uuid4)) + uuid4 + + d += struct.pack('>H', 3) + b'nil' # bundleID1 + + for v in [b'v0.1.000', b'com.cctv.yangshipin.app.iphone', + b'4330403', b'ex_json_bus', b'ex_json_vs']: + d += struct.pack('>H', len(v)) + v + + cgt = params['ck_guard_time'].encode() + d += struct.pack('>H', len(cgt)) + cgt + + buf = struct.pack('>H', len(d)) + d + sig = self._calc_sig(list(buf)) + return buf[:18] + struct.pack('>I', sig) + buf[22:] + + def _gen_ckey(self, cnlid): + ts = int(time.time()) + cgt = self._gen_guard_time(ts, self.guid) + params = { + 'Platform': 4330403, + 'Timestamp': ts, + 'Sdtfrom': 'dcgh', + 'vid': cnlid, + 'guid': self.guid, + 'appVer': 'V8.22.1035.3031', + 'randFlag': '_zj1A5Gh6QYcxWjIUGos2w==', + 'uuid4': '57eab0c4-2c58-44c6-8ae9-dd2757525dc5', + 'ck_guard_time': cgt, + } + pkt = self._build_pkt(params) + return {'ckey': self._encrypt_ckey(pkt), 'params': params} \ No newline at end of file diff --git a/py/粤漫.py b/py/粤漫.py new file mode 100644 index 0000000..c4c2e70 --- /dev/null +++ b/py/粤漫.py @@ -0,0 +1,409 @@ +# -*- coding: utf-8 -*- +import re +import socket +import threading +import time +import requests +import html as _html +from base.spider import Spider +from Crypto.Cipher import AES +from urllib3 import disable_warnings +from http.server import BaseHTTPRequestHandler, HTTPServer + +disable_warnings() + +HOST = "https://www.ymvid.com" + +# 全局变量 +_GLOBAL_M3U8_DATA = "" +_LOCAL_PORT = 0 +_TS_BASE_URL = "" # 🔥 新增:用于动态记录当前视频切片的基础域名/路径 + +# 心跳全局配置管理 +_HEARTBEAT_CONFIG = { + "active": False, + "seriesId": "", + "playTime": "", + "cookie": "", + "ua": "", + "referer": "", + "browser_code": "" +} + + +# ==================== 后台守护线程:伪造持续心跳 ==================== +def heartbeat_worker(): + """ 每隔 60 秒自动帮播放器上报一次状态,防止服务端拉黑或污染切片 """ + global _HEARTBEAT_CONFIG + while True: + if _HEARTBEAT_CONFIG["active"]: + try: + url = f"{HOST}/allocate/check_play_status" + headers = { + 'Accept': 'application/json, text/javascript, */*; q=0.01', + 'X-Client-Type': 'web', + 'Browser-Code': _HEARTBEAT_CONFIG["browser_code"], + 'Content-Type': 'application/json;charset=UTF-8', + 'User-Agent': _HEARTBEAT_CONFIG["ua"], + 'Referer': _HEARTBEAT_CONFIG["referer"], + 'Cookie': _HEARTBEAT_CONFIG["cookie"], + 'Origin': HOST + } + payload = { + "playTime": _HEARTBEAT_CONFIG["playTime"], + "seriesId": _HEARTBEAT_CONFIG["seriesId"] + } + requests.post(url, json=payload, headers=headers, verify=False, timeout=5) + except Exception: + pass + time.sleep(60) + + +# ==================== 本地精准分流 M3U8 服务器 ==================== +class M3U8Handler(BaseHTTPRequestHandler): + def do_GET(self): + global _GLOBAL_M3U8_DATA, _TS_BASE_URL + + # 🔥 修复核心:只有显式请求 play.m3u8 时才吐出索引文本 + if "play.m3u8" in self.path: + if _GLOBAL_M3U8_DATA: + self.send_response(200) + self.send_header("Content-Type", "application/vnd.apple.mpegurl") + self.send_header("Connection", "close") + self.send_header("Access-Control-Allow-Origin", "*") + encoded = _GLOBAL_M3U8_DATA.encode("utf-8") + self.send_header("Content-Length", str(len(encoded))) + self.end_headers() + self.wfile.write(encoded) + else: + self.send_response(404) + self.end_headers() + + # 🔥 修复核心:播放器请求真实的视频切片时,将其 302 重定向到远程真实视频服务器 + else: + # 清洗出切片文件名 + ts_file = self.path.split('?')[0].lstrip('/') + if ts_file and _TS_BASE_URL: + # 拼接成真实的远程 TS 切片完整地址 + remote_ts_url = f"{_TS_BASE_URL}/{ts_file}" + self.send_response(302) + self.send_header("Location", remote_ts_url) + self.end_headers() + else: + self.send_response(404) + self.end_headers() + + def log_message(self, format, *args): + pass + + +def get_free_port(): + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.bind(('127.0.0.1', 0)) + port = s.getsockname()[1] + s.close() + return port + + +def start_local_server(): + global _LOCAL_PORT + _LOCAL_PORT = get_free_port() + server = HTTPServer(('127.0.0.1', _LOCAL_PORT), M3U8Handler) + t = threading.Thread(target=server.serve_forever, daemon=True) + t.start() + + t_heartbeat = threading.Thread(target=heartbeat_worker, daemon=True) + t_heartbeat.start() + + +start_local_server() + + +class Spider(Spider): + + def getName(self): + return "粤漫之家" + + def init(self, extend=""): + pass + + # ==================== 分类 ==================== + def homeContent(self, filter): + classes = [ + {"type_id": "c0-s0-v0-l0-t0-y0/time_desc", "type_name": "全部(国/粤)"}, + {"type_id": "c1-s0-v0-l0-t0-y0/time_desc", "type_name": "粤语"}, + {"type_id": "c2-s0-v0-l0-t0-y0/time_desc", "type_name": "国语"}, + {"type_id": "c1-s0-v2-l0-t0-y0/time_desc", "type_name": "粤语·剧场版"}, + ] + return {"class": classes} + + # ==================== 列表 ==================== + def categoryContent(self, tid, pg, filter, extend): + curr_pg = int(pg) + url = f"{HOST}/list/{curr_pg}/{tid}" if curr_pg > 1 else f"{HOST}/list/1/{tid}" + + res = requests.get(url, verify=False, timeout=10, headers={ + "User-Agent": "Mozilla/5.0" + }) + html = res.text + + pattern = re.compile( + r']*el-col-adapt[^>]*>.*?' + r'href="[^"]*/play/(\d+)"[^>]*>.*?' + r']*src="([^"]+)"[^>]*alt="([^"]*)"[^>]*>.*?' + r'(?:]*tips[^>]*>([^<]*))?.*?' + r'
\s*\s*', + re.S + ) + + vod_list = [] + seen = set() + + for m in pattern.finditer(html): + vid = m.group(1) + pic = m.group(2) + name = m.group(3) + remark = m.group(4) + + if vid in seen: + continue + seen.add(vid) + + vod_list.append({ + "vod_id": vid, + "vod_name": name.strip() if name else "未知", + "vod_pic": pic.strip() if pic else "", + "vod_remarks": remark.strip() if remark else "" + }) + + has_next = bool(re.search(r'class="next"|下一页', html)) + + return { + "page": curr_pg, + "pagecount": curr_pg + (1 if has_next else 0), + "limit": 18, + "total": 999, + "list": vod_list + } + + # ==================== 详情 ==================== + def detailContent(self, ids): + vid = ids[0] + url = f"{HOST}/play/{vid}" + + res = requests.get(url, verify=False, timeout=10, headers={ + "User-Agent": "Mozilla/5.0", + "Referer": HOST + }) + html = res.text + + name_match = re.search(r']*>([^<]+)', html) + vod_name = name_match.group(1).strip() if name_match else "未知影片" + + content_match = re.search(r'class="intro-detailh"[^>]*>(.*?)', html, re.S) + vod_content = re.sub(r'<[^>]+>', '', content_match.group(1)).strip() if content_match else "暂无简介" + + ep_pattern = re.compile( + r']*href="[^"]*/play/(\d+)/(\d+)"[^>]*>.*?]*>([^<]+)', + re.S + ) + + play_urls = [] + seen = set() + + for m in ep_pattern.finditer(html): + vid = m.group(1) + sid = m.group(2) + ep_name = m.group(3).strip() + + if sid in seen: + continue + seen.add(sid) + + play_urls.append(f"{ep_name}$/{vid}/{sid}") + + return { + "list": [{ + "vod_id": vid, + "vod_name": vod_name, + "vod_pic": "", + "vod_play_from": "默认线路", + "vod_play_url": "#".join(play_urls), + "vod_content": vod_content + }] + } + + # ==================== 播放 ==================== + def playerContent(self, flag, id, vipFlags): + global _GLOBAL_M3U8_DATA, _LOCAL_PORT, _HEARTBEAT_CONFIG, _TS_BASE_URL + parts = id.strip("/").split("/") + if len(parts) != 2: + return {"parse": 0, "url": "", "header": {}, "msg": f"id格式错误: {id}"} + + vid, sid = parts[0], parts[1] + page_url = f"{HOST}/play/{vid}/{sid}" + + UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" + BROWSER_CODE = "23eba08b88d695ab91292e6358b7db400db021e4ed10799cdd6aafae222c1d6f23b80da12bc0871aa114baef1ac684e6" + MAGIC_COOKIE = f"lang=zh-CN; browser-code={BROWSER_CODE}" + + sess = requests.Session() + + try: + r1 = sess.get(page_url, verify=False, timeout=10, headers={"User-Agent": UA, "Cookie": MAGIC_COOKIE}) + html = r1.text + except Exception as e: + return {"parse": 0, "url": "", "header": {}, "msg": f"播放页请求失败: {e}"} + + play_time_match = re.search(r'playTime\s*[:=]\s*["\']([^"\']+)["\']', html) + detected_play_time = play_time_match.group(1) if play_time_match else "d6dd31bfdcd3466f832c506db420a0e3" + + enc_match = re.search(r'value\s*=\s*"([^"]+)"', html) + if not enc_match: + return {"parse": 0, "url": "", "header": {}, "msg": "enc未找到"} + enc = enc_match.group(1).strip() + + try: + key_bytes = "AVSI6788^765idue".encode("utf-8") + cipher = AES.new(key_bytes, AES.MODE_ECB) + ct_bytes = bytes.fromhex(enc) + pt_bytes = cipher.decrypt(ct_bytes) + + pad_len = pt_bytes[-1] + if 1 <= pad_len <= 16 and all(x == pad_len for x in pt_bytes[-pad_len:]): + pt_bytes = pt_bytes[:-pad_len] + + if b"?t=" in pt_bytes: + p_parts = pt_bytes.split(b"?t=", 1) + base_path = p_parts[0].decode("utf-8", errors="ignore").strip().rstrip("/") + t_raw_str = p_parts[1].decode("utf-8", errors="ignore").strip() + t_val = "".join(c for c in t_raw_str if c.isalnum()) + else: + dec_text = pt_bytes.decode("utf-8", errors="ignore").strip() + p = dec_text.split("?t=") + base_path = p[0].strip().rstrip("/") + t_val = "".join(c for c in p[1] if c.isalnum()) if len(p) > 1 else "" + except Exception as e: + return {"parse": 0, "url": "", "header": {}, "msg": f"解密环节底层异常: {e}"} + + if not base_path.startswith("/"): + base_path = "/" + base_path + + alloc = f"{HOST}{base_path}/{sid}?t={t_val}&vId={vid}" + + headers = { + "User-Agent": UA, + "Referer": page_url, + "Origin": HOST, + "Cookie": MAGIC_COOKIE + } + + try: + r2 = sess.get(alloc, headers=headers, verify=False, timeout=10) + body = r2.text.strip() + except Exception as e: + return {"parse": 0, "url": "", "header": {}, "msg": f"分配接口请求失败: {e}"} + + def decrypt_second_layer(hex_data): + try: + cipher_inner = AES.new(key_bytes, AES.MODE_ECB) + ct_inner = bytes.fromhex(hex_data.strip()) + pt_inner = cipher_inner.decrypt(ct_inner) + pad = pt_inner[-1] + if 1 <= pad <= 16: + pt_inner = pt_inner[:-pad] + return pt_inner.decode("utf-8", errors="ignore").strip() + except Exception: + return "" + + m3u8_content = "" + if body.startswith("#EXTM3U"): + m3u8_content = body + elif re.match(r"^[a-fA-F0-9]+$", body): + m3u8_content = decrypt_second_layer(body) + + if not m3u8_content.startswith("#EXTM3U"): + return {"parse": 0, "url": "", "header": {}, "msg": f"未成功解析出M3U8列表结构"} + + # 🔥 新增:动态捕获当前流媒体切片的 Base 域名基础路径,供本地重定向使用 + # 针对不带域名的相对路径切片,补齐远程服务器前缀 + ts_url_match = re.search(r'(https?://[^/\s]+(?:/[^/\s]+)*)/[^/\s]+\.ts', m3u8_content) + if ts_url_match: + _TS_BASE_URL = ts_url_match.group(1) + else: + # 如果 M3U8 里本身全是相对路径,则默认以分配接口所在目录为基准 + _TS_BASE_URL = alloc.rsplit('/', 1)[0] + + # 激活并注入心跳 + _HEARTBEAT_CONFIG["seriesId"] = str(sid) + _HEARTBEAT_CONFIG["playTime"] = detected_play_time + _HEARTBEAT_CONFIG["cookie"] = MAGIC_COOKIE + _HEARTBEAT_CONFIG["ua"] = UA + _HEARTBEAT_CONFIG["referer"] = page_url + _HEARTBEAT_CONFIG["browser_code"] = BROWSER_CODE + _HEARTBEAT_CONFIG["active"] = True + + _GLOBAL_M3U8_DATA = m3u8_content + local_play_url = f"http://127.0.0.1:{_LOCAL_PORT}/play.m3u8" + + return { + "parse": 0, + "play": 1, + "url": local_play_url, + "header": { + "User-Agent": UA, + "Referer": HOST + } + } + + # ==================== 搜索 ==================== + def searchContent(self, key, quick, pg=1): + curr_pg = int(pg) + url = f"{HOST}/search?p={curr_pg}&keyword={key}" if curr_pg > 1 else f"{HOST}/search?keyword={key}" + + res = requests.get(url, verify=False, timeout=10, headers={ + "User-Agent": "Mozilla/5.0", + "Referer": HOST + }) + html = res.text + + pattern = re.compile( + r']*item-row[^>]*>.*?' + r']*href="[^"]*/play/(\d+)"[^>]*>.*?' + r']*src="([^"]+)"[^>]*alt="([^"]*)".*?>.*?' + r']*item-title[^>]*>.*?]*>([^<]*)', + re.S + ) + + vod_list = [] + seen = set() + + for m in pattern.finditer(html): + vid, pic, alt, title = m.groups() + if vid in seen: + continue + seen.add(vid) + + # ✅ 关键修复:解码 HTML 实体 + 去标签 + raw_name = alt or title or '' + raw_name = _html.unescape(raw_name) # < → < + raw_name = re.sub(r'<[^>]+>', '', raw_name) # 去 + name = raw_name.strip() or '未知' + + vod_list.append({ + "vod_id": vid, + "vod_name": name, + "vod_pic": pic.strip(), + "vod_remarks": "" + }) + + has_next = bool(re.search(r'下一页|class="next"|class="page-next"', html)) + + return { + "page": curr_pg, + "pagecount": curr_pg + (1 if has_next else 0), + "limit": len(vod_list), + "total": 999, + "list": vod_list + } + \ No newline at end of file diff --git a/py/资源管理.py b/py/资源管理.py new file mode 100644 index 0000000..5f5c8c0 --- /dev/null +++ b/py/资源管理.py @@ -0,0 +1,8198 @@ +# 资源管理.py - 智能多策略封面提取版(含在线直播、在线电台、短视频、画廊功能、删除模式、网页浏览器、PDF/EPUB漫画阅读、游戏大厅、磁力链接解析) +# 修复:XLS/XLSX磁力链接解析(支持 .xls 和 .xlsx) +# 修复:TXT电驴链接解析 - 增强正则匹配 +# 修复:选择模式下字母/数字筛选后光标选中文件错位问题 - 最终修复版 +# 修改:所有文件/文件夹项改为网格排列(grid),与管理.py保持一致 +# 新增:磁力链接设置(可调整每次读取数量)- 持久化存储 +# 新增:磁力链接读取模式(顺序/随机切换)- 随机模式从全部链接中随机抽取 +# 修复:动作/工具/书签使用随机图片 + +import sys +import re +import json +import os +import base64 +import hashlib +import time +import urllib.parse +import sqlite3 +import glob +import zlib +import xml.etree.ElementTree as ET +import random +import struct +import threading +import shutil +from concurrent.futures import ThreadPoolExecutor, as_completed +from pathlib import Path +from base.spider import Spider +import requests +from requests.adapters import HTTPAdapter +from urllib3.util.retry import Retry + +# ==================== PDF/EPUB漫画支持 ==================== +try: + from java import jclass + HAS_JAVA = True +except ImportError: + HAS_JAVA = False + print("⚠️ Java类不可用,PDF/EPUB功能将受限") + +# ==================== 电视直播封面图片 ==================== +TV_COVER = "https://t7.baidu.com/it/u=2649979413,3280207332&fm=193" + +# ==================== 缓存路径配置 ==================== +COVER_CACHE_DIR = '/storage/emulated/0/tmp/covers/' +LYRICS_CACHE_DIR = '/storage/emulated/0/tmp/lyrics/' +RADIO_COVER_CACHE_DIR = '/storage/emulated/0/tmp/radio_covers/' +COMIC_CACHE_DIR = '/storage/emulated/0/tmp/comic_covers/' +COMIC_PREVIEW_DIR = '/storage/emulated/0/tmp/comic_previews/' +RADIO_SCAN_RECORD_FILE = '/storage/emulated/0/tmp/radio_scan_record.json' +COVER_SCAN_RECORD_FILE = '/storage/emulated/0/tmp/cover_scan_record.json' + +LIVE_PROGRAM_CACHE_DURATION = 300 +DB_COMPAT_MODE = True +MAX_DB_RESULTS = 50000 + +# 磁力链接解析配置 +MAX_MAGNET_FILE_SIZE = 50 * 1024 * 1024 +MAX_MAGNET_LINES = 50000 +MAX_XLSX_READ_SIZE = 30 * 1024 * 1024 +MAX_PLAY_URLS = 300 + +# ==================== 磁力配置持久化 ==================== +MAGNET_CONFIG_FILE = '/storage/emulated/0/tmp/magnet_config.json' +MAGNET_READ_MODE_ORDER = 'order' +MAGNET_READ_MODE_RANDOM = 'random' +DEFAULT_MAGNET_READ_MODE = MAGNET_READ_MODE_ORDER + + +class MagnetConfigManager: + """磁力链接配置管理器 - 持久化存储""" + + @staticmethod + def load_config(): + """加载磁力配置""" + default_config = { + 'max_items': 700, + 'read_mode': DEFAULT_MAGNET_READ_MODE + } + try: + if os.path.exists(MAGNET_CONFIG_FILE): + with open(MAGNET_CONFIG_FILE, 'r', encoding='utf-8') as f: + config = json.load(f) + if 'max_items' not in config: + config['max_items'] = default_config['max_items'] + if 'read_mode' not in config: + config['read_mode'] = default_config['read_mode'] + return config + return default_config + except: + return default_config + + @staticmethod + def save_config(config): + """保存磁力配置""" + try: + os.makedirs(os.path.dirname(MAGNET_CONFIG_FILE), exist_ok=True) + with open(MAGNET_CONFIG_FILE, 'w', encoding='utf-8') as f: + json.dump(config, f, ensure_ascii=False, indent=2) + return True + except: + return False + + @staticmethod + def set_max_items(count): + """设置最大读取数量""" + config = MagnetConfigManager.load_config() + config['max_items'] = count + return MagnetConfigManager.save_config(config) + + @staticmethod + def get_max_items(): + """获取最大读取数量""" + config = MagnetConfigManager.load_config() + return config.get('max_items', 700) + + @staticmethod + def set_read_mode(mode): + """设置读取模式""" + config = MagnetConfigManager.load_config() + config['read_mode'] = mode + return MagnetConfigManager.save_config(config) + + @staticmethod + def get_read_mode(): + """获取读取模式""" + config = MagnetConfigManager.load_config() + return config.get('read_mode', DEFAULT_MAGNET_READ_MODE) + + @staticmethod + def get_read_mode_display(): + """获取读取模式的显示名称""" + mode = MagnetConfigManager.get_read_mode() + if mode == MAGNET_READ_MODE_RANDOM: + return "🎲 随机" + else: + return "📋 顺序" + + +# 初始化磁力配置 +MAX_MAGNET_ITEMS = MagnetConfigManager.get_max_items() +MAGNET_READ_MODE = MagnetConfigManager.get_read_mode() + +print(f"✅ 当前磁力链接最大读取数量: {MAX_MAGNET_ITEMS}") +print(f"✅ 当前磁力链接读取模式: {MagnetConfigManager.get_read_mode_display()}") + +ROOT_PATHS = [ + '/storage/emulated/0/', + '/storage/emulated/0/Movies/', + '/storage/emulated/0/Music/', + '/storage/emulated/0/Download/KuwoMusic/music/', + '/storage/emulated/0/Download/', + '/storage/emulated/0/DCIM/Camera/', + '/storage/emulated/0/Pictures/', + '/storage/emulated/0/Books/', + '/storage/emulated/0/VodPlus/wwwroot/lz/', + '/storage/emulated/0/tmp/' +] + +PATH_TO_CHINESE = { + '/storage/emulated/0/': '根目录', + '/storage/emulated/0/Movies/': '电影', + '/storage/emulated/0/Music/': '音乐', + '/storage/emulated/0/Download/KuwoMusic/music/': '酷我音乐', + '/storage/emulated/0/Download/': '下载', + '/storage/emulated/0/DCIM/Camera/': '相机', + '/storage/emulated/0/Pictures/': '图片', + '/storage/emulated/0/Books/': '小说', + '/storage/emulated/0/VodPlus/wwwroot/lz/': '老张', + '/storage/emulated/0/tmp/': '缓存文件夹' +} + +print("✅ 本地资源管理加载成功") +print("✅ XLS/XLSX磁力解析已修复(支持 .xls 和 .xlsx)") +print("✅ TXT电驴链接解析已修复 - 增强正则匹配") +print("✅ 所有文件/文件夹项已改为网格排列") +print(f"✅ 当前磁力链接最大读取数量: {MAX_MAGNET_ITEMS}") +print(f"✅ 当前磁力链接读取模式: {MagnetConfigManager.get_read_mode_display()}") + + +# ==================== XLS/XLSX 磁力文件解析器(支持随机抽取) ==================== + +class XLSXMagnetParser: + """XLSX磁力链接文件解析器 - 纯二进制版(支持随机抽取)""" + + @staticmethod + def parse_magnet_from_xlsx(file_path): + items = [] + try: + import zipfile + all_text = "" + with zipfile.ZipFile(file_path, 'r') as zf: + for name in zf.namelist(): + if name.endswith('.xml'): + try: + content = zf.read(name).decode('utf-8', errors='ignore') + all_text += content + except: + pass + if not all_text: + with open(file_path, 'rb') as f: + all_text = f.read(MAX_XLSX_READ_SIZE).decode('utf-8', errors='ignore') + + magnet_pattern = re.compile(r'magnet:\?[^\s\'"<>《》]+', re.I) + ed2k_pattern = re.compile(r'ed2k://[^\s\'"<>《》\|]+(?:\|[^\s\'"<>《》]+)*', re.I) + + all_magnets = list(set(magnet_pattern.findall(all_text))) + all_ed2ks = list(set(ed2k_pattern.findall(all_text))) + + read_mode = MagnetConfigManager.get_read_mode() + max_items = MagnetConfigManager.get_max_items() + + magnets = [] + ed2ks = [] + + if read_mode == MAGNET_READ_MODE_RANDOM: + # 随机模式:从全部磁力链接中随机抽取 max_items 条 + if len(all_magnets) >= max_items: + magnets = random.sample(all_magnets, max_items) + else: + magnets = all_magnets[:] + random.shuffle(magnets) + remaining = max_items - len(magnets) + if len(all_ed2ks) >= remaining: + ed2ks = random.sample(all_ed2ks, remaining) + else: + ed2ks = all_ed2ks[:] + random.shuffle(ed2ks) + else: + # 顺序模式:取前 max_items 条 + magnets = all_magnets[:max_items] + remaining = max_items - len(magnets) + if remaining > 0: + ed2ks = all_ed2ks[:remaining] + + for magnet in magnets: + name_match = re.search(r'dn=([^&]+)', magnet) + if name_match: + name = urllib.parse.unquote(name_match.group(1)) + else: + name = f"磁力链接{len(items)+1}" + name = name.replace('\n', ' ').replace('\r', ' ').strip() + items.append({'name': name, 'url': magnet}) + if len(items) >= max_items: + break + + remaining = max_items - len(items) + for ed2k in ed2ks[:remaining]: + name_match = re.search(r'/([^/|]+)(?:\||$)', ed2k) + if name_match: + name = name_match.group(1) + else: + name = f"电驴链接{len(items)+1}" + name = name.replace('\n', ' ').replace('\r', ' ').strip() + items.append({'name': name, 'url': ed2k}) + if len(items) >= max_items: + break + + if items: + mode_display = "随机抽取" if read_mode == MAGNET_READ_MODE_RANDOM else "顺序" + print(f"✅ XLSX解析完成: {os.path.basename(file_path)}, 共 {len(items)} 条 (模式: {mode_display})") + except Exception as e: + print(f"❌ XLSX解析失败: {e}") + return items + + @staticmethod + def _parse_binary(file_path): + items = [] + try: + with open(file_path, 'rb') as f: + content = f.read(MAX_XLSX_READ_SIZE) + all_text = content.decode('utf-8', errors='ignore') + if len(all_text) < 100: + all_text = content.decode('latin-1', errors='ignore') + + magnet_pattern_simple = re.compile(r'magnet:\?[^\s\'"<>]+', re.I) + ed2k_pattern_simple = re.compile(r'ed2k://[^\s\'"<>]+', re.I) + + all_magnets = list(set(magnet_pattern_simple.findall(all_text))) + all_ed2ks = list(set(ed2k_pattern_simple.findall(all_text))) + + read_mode = MagnetConfigManager.get_read_mode() + max_items = MagnetConfigManager.get_max_items() + + magnets = [] + ed2ks = [] + + if read_mode == MAGNET_READ_MODE_RANDOM: + if len(all_magnets) >= max_items: + magnets = random.sample(all_magnets, max_items) + else: + magnets = all_magnets[:] + random.shuffle(magnets) + remaining = max_items - len(magnets) + if len(all_ed2ks) >= remaining: + ed2ks = random.sample(all_ed2ks, remaining) + else: + ed2ks = all_ed2ks[:] + random.shuffle(ed2ks) + else: + magnets = all_magnets[:max_items] + remaining = max_items - len(magnets) + if remaining > 0: + ed2ks = all_ed2ks[:remaining] + + for magnet in magnets: + name_match = re.search(r'dn=([^&]+)', magnet) + if name_match: + name = urllib.parse.unquote(name_match.group(1)) + else: + xt_match = re.search(r'xt=urn:btih:([^&]+)', magnet) + if xt_match: + name = f"磁力_{xt_match.group(1)[:16]}" + else: + name = f"磁力链接{len(items)+1}" + name = name.replace('\n', ' ').replace('\r', ' ').strip() + name = re.sub(r'[<>:"/\\|?*]', '_', name) + if len(name) > 80: + name = name[:77] + '...' + items.append({'name': name, 'url': magnet}) + if len(items) >= max_items: + break + + remaining = max_items - len(items) + for ed2k in ed2ks[:remaining]: + name = XLSXMagnetParser._extract_ed2k_name(ed2k) + if not name or len(name) < 1: + name = f"电驴链接{len(items)+1}" + name = name.replace('\n', ' ').replace('\r', ' ').strip() + if len(name) > 80: + name = name[:77] + '...' + items.append({'name': name, 'url': ed2k}) + if len(items) >= max_items: + break + + if items: + mode_display = "随机抽取" if read_mode == MAGNET_READ_MODE_RANDOM else "顺序" + print(f"✅ 二进制解析完成: {os.path.basename(file_path)}, 共 {len(items)} 条 (模式: {mode_display})") + except Exception as e: + print(f"❌ 二进制解析失败: {e}") + return items + + @staticmethod + def _extract_ed2k_name(ed2k): + name = None + file_match = re.search(r'\|file\|([^|]+)\|', ed2k, re.I) + if file_match: + name = file_match.group(1) + else: + last_slash = ed2k.rfind('/') + if last_slash != -1: + temp = ed2k[last_slash + 1:] + pipe_pos = temp.find('|') + if pipe_pos != -1: + name = temp[:pipe_pos] + else: + name = temp + if name: + name = name.replace('\n', ' ').replace('\r', ' ').strip() + name = urllib.parse.unquote(name) + name = re.sub(r'[<>:"/\\|?*]', '_', name) + if len(name) > 80: + name = name[:77] + '...' + return name + + +# ==================== 磁力/TXT链接解析器(增强正则匹配 + 随机抽取) ==================== + +class MagnetLinkParser: + """磁力链接和电驴链接解析器(支持TXT文件,增强正则匹配)""" + + @staticmethod + def parse_magnet_from_txt(file_path): + items = [] + try: + with open(file_path, 'r', encoding='utf-8', errors='ignore') as f: + content = f.read() + + # 更宽松的正则 - 匹配磁力链接直到遇到空格或换行或引号 + magnet_pattern = re.compile(r'magnet:\?[^\s\n\r\'"<>《》]+', re.I) + ed2k_pattern = re.compile(r'ed2k://[^\s\n\r\'"<>《》]+', re.I) + + all_magnets = list(set(magnet_pattern.findall(content))) + all_ed2ks = list(set(ed2k_pattern.findall(content))) + + read_mode = MagnetConfigManager.get_read_mode() + max_items = MagnetConfigManager.get_max_items() + + magnets = [] + ed2ks = [] + + if read_mode == MAGNET_READ_MODE_RANDOM: + if len(all_magnets) >= max_items: + magnets = random.sample(all_magnets, max_items) + else: + magnets = all_magnets[:] + random.shuffle(magnets) + remaining = max_items - len(magnets) + if len(all_ed2ks) >= remaining: + ed2ks = random.sample(all_ed2ks, remaining) + else: + ed2ks = all_ed2ks[:] + random.shuffle(ed2ks) + else: + magnets = all_magnets[:max_items] + remaining = max_items - len(magnets) + if remaining > 0: + ed2ks = all_ed2ks[:remaining] + + for magnet in magnets: + name_match = re.search(r'dn=([^&]+)', magnet) + if name_match: + name = urllib.parse.unquote(name_match.group(1)) + else: + xt_match = re.search(r'xt=urn:btih:([^&]+)', magnet) + if xt_match: + name = f"磁力_{xt_match.group(1)[:16]}" + else: + name = f"磁力链接{len(items)+1}" + name = name.replace('\n', ' ').replace('\r', ' ').strip() + name = re.sub(r'[<>:"/\\|?*]', '_', name) + if len(name) > 80: + name = name[:77] + '...' + items.append({'name': name, 'url': magnet}) + if len(items) >= max_items: + break + + remaining = max_items - len(items) + for ed2k in ed2ks[:remaining]: + name_match = re.search(r'/([^/|]+)(?:\||$)', ed2k) + if name_match: + name = name_match.group(1) + else: + name = f"电驴链接{len(items)+1}" + name = name.replace('\n', ' ').replace('\r', ' ').strip() + if len(name) > 80: + name = name[:77] + '...' + items.append({'name': name, 'url': ed2k}) + if len(items) >= max_items: + break + + if items: + mode_display = "随机抽取" if read_mode == MAGNET_READ_MODE_RANDOM else "顺序" + print(f"✅ TXT解析完成: {os.path.basename(file_path)}, 共 {len(items)} 条 (模式: {mode_display})") + else: + # 如果没解析到,尝试用更宽松的逐行方式 + print(f"⚠️ 未解析到磁力链接,尝试逐行解析: {os.path.basename(file_path)}") + items = MagnetLinkParser._parse_line_by_line(file_path, max_items, read_mode) + except Exception as e: + print(f"❌ TXT解析失败: {e}") + return items + + @staticmethod + def _parse_line_by_line(file_path, max_items, read_mode): + """逐行解析备用方法""" + items = [] + try: + with open(file_path, 'r', encoding='utf-8', errors='ignore') as f: + lines = f.readlines() + + all_magnets = [] + all_ed2ks = [] + + for line in lines: + line = line.strip() + if not line: + continue + # 磁力链接 + mag_match = re.search(r'magnet:\?[^\s\n\r\'"<>《》]+', line, re.I) + if mag_match: + all_magnets.append(mag_match.group(0)) + continue + # 电驴链接 + ed_match = re.search(r'ed2k://[^\s\n\r\'"<>《》]+', line, re.I) + if ed_match: + all_ed2ks.append(ed_match.group(0)) + continue + + # 去重 + all_magnets = list(set(all_magnets)) + all_ed2ks = list(set(all_ed2ks)) + + magnets = [] + ed2ks = [] + + if read_mode == MAGNET_READ_MODE_RANDOM: + if len(all_magnets) >= max_items: + magnets = random.sample(all_magnets, max_items) + else: + magnets = all_magnets[:] + random.shuffle(magnets) + remaining = max_items - len(magnets) + if len(all_ed2ks) >= remaining: + ed2ks = random.sample(all_ed2ks, remaining) + else: + ed2ks = all_ed2ks[:] + random.shuffle(ed2ks) + else: + magnets = all_magnets[:max_items] + remaining = max_items - len(magnets) + if remaining > 0: + ed2ks = all_ed2ks[:remaining] + + for magnet in magnets: + name_match = re.search(r'dn=([^&]+)', magnet) + if name_match: + name = urllib.parse.unquote(name_match.group(1)) + else: + xt_match = re.search(r'xt=urn:btih:([^&]+)', magnet) + if xt_match: + name = f"磁力_{xt_match.group(1)[:16]}" + else: + name = f"磁力链接{len(items)+1}" + name = name.replace('\n', ' ').replace('\r', ' ').strip() + name = re.sub(r'[<>:"/\\|?*]', '_', name) + if len(name) > 80: + name = name[:77] + '...' + items.append({'name': name, 'url': magnet}) + if len(items) >= max_items: + break + + remaining = max_items - len(items) + for ed2k in ed2ks[:remaining]: + name_match = re.search(r'/([^/|]+)(?:\||$)', ed2k) + if name_match: + name = name_match.group(1) + else: + name = f"电驴链接{len(items)+1}" + name = name.replace('\n', ' ').replace('\r', ' ').strip() + if len(name) > 80: + name = name[:77] + '...' + items.append({'name': name, 'url': ed2k}) + if len(items) >= max_items: + break + + if items: + mode_display = "随机抽取" if read_mode == MAGNET_READ_MODE_RANDOM else "顺序" + print(f"✅ 逐行解析完成: {os.path.basename(file_path)}, 共 {len(items)} 条 (模式: {mode_display})") + except Exception as e: + print(f"❌ 逐行解析失败: {e}") + return items + + @staticmethod + def _extract_ed2k_name(ed2k): + name = None + file_match = re.search(r'\|file\|([^|]+)\|', ed2k, re.I) + if file_match: + name = file_match.group(1) + else: + last_slash = ed2k.rfind('/') + if last_slash != -1: + temp = ed2k[last_slash + 1:] + pipe_pos = temp.find('|') + if pipe_pos != -1: + name = temp[:pipe_pos] + else: + name = temp + if name: + name = name.replace('\n', ' ').replace('\r', ' ').strip() + name = urllib.parse.unquote(name) + name = re.sub(r'[<>:"/\\|?*]', '_', name) + if len(name) > 80: + name = name[:77] + '...' + return name + + @staticmethod + def is_magnet_txt_file(file_path): + try: + with open(file_path, 'r', encoding='utf-8', errors='ignore') as f: + preview = f.read(8192) + if re.search(r'magnet:\?', preview, re.I): + return True + if re.search(r'ed2k://', preview, re.I): + return True + return False + except: + return False + + +# ==================== 在线直播源配置 ==================== +ONLINE_LIVE_SOURCES = [ + { + "id": "migu_live", + "name": "❤️咪咕直播", + "url": "https://gh-proxy.org/https://raw.githubusercontent.com/develop202/migu_video/refs/heads/main/interface.txt", + "cover": TV_COVER, + "remarks": "❤️咪咕直播", + "type": "m3u", + "playerType": 2, + "ua": "com.android.chrome/3.7.0 (Linux;Android 15)", + "referer": "https://www.miguvideo.com/" + }, + { + "id": "gongdian_live", + "name": "💛宫殿直播", + "url": "https://gongdian.top/tv/iptv", + "cover": TV_COVER, + "remarks": "💛宫殿直播源", + "type": "m3u", + "playerType": 2, + "ua": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", + "referer": "https://gongdian.top/" + }, + { + "id": "simple_live", + "name": "💚简单直播", + "url": "http://gh-proxy.org/raw.githubusercontent.com/Supprise0901/TVBox_live/main/live.txt", + "cover": TV_COVER, + "remarks": "💚简单直播", + "type": "txt", + "ua": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" + }, + { + "id": "Kimentanm", + "name": "💙Kimentanm", + "url": "https://gh.llkk.cc/https://raw.githubusercontent.com/Kimentanm/aptv/master/m3u/iptv.m3u", + "cover": TV_COVER, + "remarks": "💙Kimentanm", + "type": "m3u", + "ua": "AptvPlayer-UA" + }, + { + "id": "游魂", + "name": "💜游魂", + "url": "https://www.iyouhun.com/tv/zb", + "cover": TV_COVER, + "remarks": "💜游魂直播", + "type": "txt", + "ua": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" + }, + { + "id": "rihou", + "name": "❤️日后", + "url": "http://rihou.cc:555/gggg.nzk", + "cover": TV_COVER, + "remarks": "❤️日后直播", + "type": "txt", + "ua": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" + }, + { + "id": "易发TV", + "name": "💛易发TV直播", + "url": "https://gh-proxy.org/https://raw.githubusercontent.com/fafa002/yf2025/refs/heads/main/yiyifafa.txt", + "cover": TV_COVER, + "remarks": "易发直播", + "type": "txt", + "ua": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" + }, + { + "id": "天下TV", + "name": "🗺天下TV", + "url": "https://ilook.epg.one/5CM5SY98BF24PL/2", + "cover": TV_COVER, + "remarks": "🗺天下直播", + "type": "m3u", + "ua": "User-Agent : com.android.chrome/5.1.6 (Linux;Android 15) AndroidXMedia3/1.10.0" + }, + { + "id": "香雨直播", + "name": "☔香雨TV直播", + "url": "https://wget.la/https://raw.githubusercontent.com/ajqubbs/zhiboyuan/main/%E9%A6%99%E9%9B%A8%E7%9B%B4%E6%92%AD.txt", + "cover": TV_COVER, + "remarks": "☔香雨直播", + "type": "txt", + "ua": "User-Agent : com.android.chrome/5.1.6 (Linux;Android 15) AndroidXMedia3/1.10.0" + }, + { + "id": "三线直播", + "name": "❤️三线TV", + "url": "https://raw.githubusercontent.com/mzky/checklist/refs/heads/master/itvlist.m3u", + "cover": TV_COVER, + "remarks": "❤️三线直播", + "type": "m3u", + "ua": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" + }, + { + "id": "suxuang", + "name": "💚suxuang", + "url": "https://gh-proxy.org/https://raw.githubusercontent.com/suxuang/myIPTV/main/ipv4.m3u", + "cover": TV_COVER, + "remarks": "suxuang", + "type": "m3u", + "ua": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" + }, + { + "id": "kulao_tv", + "name": "👖 裤佬TV直播", + "url": "https://gh-proxy.org/https://raw.githubusercontent.com/Jsnzkpg/Jsnzkpg/Jsnzkpg/Jsnzkpg1.m3u", + "cover": TV_COVER, + "remarks": "裤佬TV直播源", + "type": "m3u", + "playerType": 2, + "ua": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" + } +] + +LIVE_CATEGORY_ID = "online_live" +LIVE_CATEGORY_NAME = "📺 电视直播" +LIVE_CACHE_DURATION = 600 + +COMMON_HEADERS_LIST = [ + { + "name": "Chrome浏览器", + "headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + "Accept": "*/*", + "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", + "Connection": "keep-alive" + } + }, + { + "name": "Firefox浏览器", + "headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:120.0) Gecko/20100101 Firefox/120.0", + "Accept": "*/*", + "Accept-Language": "zh-CN,zh;q=0.8,en-US;q=0.5,en;q=0.3", + "Connection": "keep-alive" + } + }, + { + "name": "okhttp/3", + "headers": { + "User-Agent": "okhttp/3.12.11", + "Accept": "*/*", + "Connection": "Keep-Alive" + } + }, + { + "name": "手机浏览器", + "headers": { + "User-Agent": "Mozilla/5.0 (Linux; Android 11; SM-G991B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36", + "Accept": "*/*", + "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", + "Connection": "keep-alive" + } + } +] + +DOMAIN_SPECIFIC_HEADERS = { + "miguvideo.com": [ + { + "name": "咪咕专用-Android Chrome", + "headers": { + "User-Agent": "com.android.chrome/3.7.0 (Linux;Android 15)", + "Accept": "*/*", + "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", + "Connection": "keep-alive", + "Referer": "https://www.miguvideo.com/" + } + } + ], + "gongdian.top": [ + { + "name": "宫殿直播专用", + "headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", + "Accept": "*/*", + "Referer": "https://gongdian.top/", + "Connection": "keep-alive" + } + } + ], + "rihou.cc": [ + { + "name": "日后源专用-Chrome", + "headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + "Referer": "https://rihou.cc:555/", + "Accept": "*/*", + "Connection": "keep-alive" + } + } + ] +} + + +class ComicReader: + SUPPORTED_EXTS = ['pdf', 'epub'] + EPUB_TEXT_THRESHOLD = 10 + FIRST_BATCH_PAGES = 200 + + _render_executor = None + + @classmethod + def get_render_executor(cls): + if cls._render_executor is None: + import multiprocessing + cpu_count = multiprocessing.cpu_count() + thread_count = min(max(cpu_count * 12, 24), 48) + cls._render_executor = ThreadPoolExecutor(max_workers=thread_count, thread_name_prefix="PDFRender") + print(f"[PDF渲染线程池] 已启动 {thread_count} 个线程") + return cls._render_executor + + @classmethod + def shutdown_render_executor(cls): + if cls._render_executor: + cls._render_executor.shutdown(wait=False) + cls._render_executor = None + + @staticmethod + def is_supported(filename): + ext = filename.split('.')[-1].lower() if '.' in filename else '' + return ext in ComicReader.SUPPORTED_EXTS + + @staticmethod + def get_comic_preview_dir(file_path): + file_hash = hashlib.md5(file_path.encode()).hexdigest()[:16] + preview_dir = os.path.join(COMIC_PREVIEW_DIR, file_hash) + try: + if not os.path.exists(preview_dir): + os.makedirs(preview_dir, exist_ok=True) + return preview_dir + except: + os.makedirs(COMIC_PREVIEW_DIR, exist_ok=True) + return COMIC_PREVIEW_DIR + + @staticmethod + def _generate_pdf_cover(pdf_path, cover_path, max_width=400): + if not HAS_JAVA: + return False + try: + File = jclass("java.io.File") + ParcelFileDescriptor = jclass("android.os.ParcelFileDescriptor") + PdfRenderer = jclass("android.graphics.pdf.PdfRenderer") + Bitmap = jclass("android.graphics.Bitmap") + CompressFormat = jclass("android.graphics.Bitmap$CompressFormat") + fd = ParcelFileDescriptor.open(File(pdf_path), ParcelFileDescriptor.MODE_READ_ONLY) + renderer = PdfRenderer(fd) + if renderer.getPageCount() > 0: + page = renderer.openPage(0) + width = page.getWidth() + height = page.getHeight() + if width > max_width: + scale = max_width / width + width = max_width + height = int(height * scale) + bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + page.render(bitmap, None, None, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY) + fos = jclass("java.io.FileOutputStream")(cover_path) + bitmap.compress(CompressFormat.PNG, 85, fos) + fos.flush() + fos.close() + page.close() + renderer.close() + fd.close() + return True + except Exception as e: + print(f"[PDF封面生成失败] {e}") + return False + + @staticmethod + def _generate_epub_cover(epub_path, cover_path): + try: + import zipfile + with zipfile.ZipFile(epub_path, 'r') as zf: + cover_names = ['cover', 'Cover', 'COVER', 'titlepage', 'TitlePage'] + img_files = [] + for name in zf.namelist(): + lname = name.lower() + if any(lname.endswith(ext) for ext in ['.png', '.jpg', '.jpeg', '.webp']): + base = os.path.basename(lname) + is_cover = any(cn.lower() in base for cn in cover_names) or 'cover' in lname + if is_cover: + img_files.insert(0, name) + else: + img_files.append(name) + img_files.sort(key=lambda x: x.lower()) + if img_files: + img_data = zf.read(img_files[0]) + with open(cover_path, 'wb') as f: + f.write(img_data) + return True + except Exception as e: + print(f"[EPUB封面生成失败] {e}") + return False + + @staticmethod + def get_cover_url(file_path): + if not os.path.exists(file_path): + return None + ext = file_path.split('.')[-1].lower() + file_hash = hashlib.md5(file_path.encode()).hexdigest() + os.makedirs(COMIC_CACHE_DIR, exist_ok=True) + cache_exts = ['.jpg', '.jpeg', '.png', '.webp'] + cache_file = None + for cext in cache_exts: + test_path = os.path.join(COMIC_CACHE_DIR, f"{file_hash}{cext}") + if os.path.exists(test_path): + cache_file = test_path + break + if cache_file: + return f"file://{cache_file}" + cache_file = os.path.join(COMIC_CACHE_DIR, f"{file_hash}.jpg") + success = False + if ext == 'pdf': + success = ComicReader._generate_pdf_cover(file_path, cache_file) + elif ext == 'epub': + success = ComicReader._generate_epub_cover(file_path, cache_file) + if success and os.path.exists(cache_file): + return f"file://{cache_file}" + return ComicReader._get_default_comic_icon(ext) + + @staticmethod + def _get_default_comic_icon(ext): + if ext == 'pdf': + return "https://img.icons8.com/color/96/000000/pdf-2.png" + else: + return "https://img.icons8.com/color/96/000000/epub.png" + + @staticmethod + def get_pdf_page_count(pdf_path): + if not HAS_JAVA: + return 0 + try: + File = jclass("java.io.File") + ParcelFileDescriptor = jclass("android.os.ParcelFileDescriptor") + PdfRenderer = jclass("android.graphics.pdf.PdfRenderer") + fd = ParcelFileDescriptor.open(File(pdf_path), ParcelFileDescriptor.MODE_READ_ONLY) + renderer = PdfRenderer(fd) + page_count = renderer.getPageCount() + renderer.close() + fd.close() + return page_count + except Exception as e: + print(f"[PDF页数获取失败] {e}") + return 0 + + @staticmethod + def render_pdf_page(pdf_path, page_num, output_path, max_width=800): + if not HAS_JAVA: + return False + try: + File = jclass("java.io.File") + ParcelFileDescriptor = jclass("android.os.ParcelFileDescriptor") + PdfRenderer = jclass("android.graphics.pdf.PdfRenderer") + Bitmap = jclass("android.graphics.Bitmap") + CompressFormat = jclass("android.graphics.Bitmap$CompressFormat") + fd = ParcelFileDescriptor.open(File(pdf_path), ParcelFileDescriptor.MODE_READ_ONLY) + renderer = PdfRenderer(fd) + if page_num < renderer.getPageCount(): + page = renderer.openPage(page_num) + width = page.getWidth() + height = page.getHeight() + if width > max_width: + scale = max_width / width + width = max_width + height = int(height * scale) + bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + page.render(bitmap, None, None, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY) + fos = jclass("java.io.FileOutputStream")(output_path) + bitmap.compress(CompressFormat.JPEG, 75, fos) + fos.flush() + fos.close() + page.close() + renderer.close() + fd.close() + return True + except Exception as e: + print(f"[PDF单页渲染失败] page {page_num}: {e}") + return False + + @staticmethod + def render_pdf_first_batch(pdf_path, pdf_name): + if not HAS_JAVA: + return 0, [] + try: + page_count = ComicReader.get_pdf_page_count(pdf_path) + if page_count == 0: + return 0, [] + preview_dir = ComicReader.get_comic_preview_dir(pdf_path) + all_cached = True + for i in range(min(page_count, 200)): + out_path = os.path.join(preview_dir, f"page_{i:04d}.jpg") + if not os.path.exists(out_path): + all_cached = False + break + if all_cached: + img_urls = [] + for i in range(page_count): + out_path = os.path.join(preview_dir, f"page_{i:04d}.jpg") + if os.path.exists(out_path): + img_urls.append(f"file://{out_path}") + print(f"[PDF已缓存] {pdf_name}, 全部{len(img_urls)}页秒开") + return len(img_urls), img_urls + render_limit = min(ComicReader.FIRST_BATCH_PAGES, page_count) + tasks = [] + for i in range(render_limit): + out_path = os.path.join(preview_dir, f"page_{i:04d}.jpg") + if not os.path.exists(out_path): + tasks.append((i, out_path)) + if not tasks: + img_urls = [f"file://{os.path.join(preview_dir, f'page_{i:04d}.jpg')}" for i in range(render_limit)] + return render_limit, img_urls + print(f"[PDF并发渲染] {pdf_name}, 开始并发渲染前{len(tasks)}/{render_limit}页") + start_time = time.time() + executor = ComicReader.get_render_executor() + futures = {} + for i, out_path in tasks: + future = executor.submit(ComicReader.render_pdf_page, pdf_path, i, out_path) + futures[future] = (i, out_path) + timeout = max(30, render_limit * 0.3) + completed = 0 + failed = 0 + for future in futures: + try: + future.result(timeout=timeout) + completed += 1 + except Exception as e: + i, out_path = futures[future] + print(f"[PDF渲染超时] page {i}: {e}") + ComicReader.render_pdf_page(pdf_path, i, out_path) + failed += 1 + elapsed = time.time() - start_time + print(f"[PDF并发渲染完成] {pdf_name}, 成功{completed}页, 失败{failed}页, 耗时{elapsed:.1f}秒") + img_urls = [] + for i in range(render_limit): + out_path = os.path.join(preview_dir, f"page_{i:04d}.jpg") + if os.path.exists(out_path): + img_urls.append(f"file://{out_path}") + else: + ComicReader.render_pdf_page(pdf_path, i, out_path) + img_urls.append(f"file://{out_path}") + if page_count > render_limit: + ComicReader._lazy_render_remaining_pages_concurrent(pdf_path, pdf_name, render_limit, page_count, preview_dir) + return len(img_urls), img_urls + except Exception as e: + print(f"[PDF渲染失败] {e}") + return 0, [] + + @staticmethod + def _lazy_render_remaining_pages_concurrent(pdf_path, pdf_name, start_page, total_pages, preview_dir): + def render_remaining_concurrent(): + remaining = total_pages - start_page + if remaining <= 0: + return + print(f"[PDF后台渲染] {pdf_name}, 开始后台渲染剩余{remaining}页") + start_time = time.time() + executor = ComicReader.get_render_executor() + futures = {} + for i in range(start_page, total_pages): + out_path = os.path.join(preview_dir, f"page_{i:04d}.jpg") + if not os.path.exists(out_path): + future = executor.submit(ComicReader.render_pdf_page, pdf_path, i, out_path) + futures[future] = (i, out_path) + for future in futures: + try: + future.result(timeout=300) + except Exception as e: + i, out_path = futures[future] + print(f"[PDF后台渲染失败] page {i}: {e}") + elapsed = time.time() - start_time + print(f"[PDF后台渲染完成] {pdf_name}, 剩余{remaining}页, 耗时{elapsed:.1f}秒") + background_thread = threading.Thread(target=render_remaining_concurrent, daemon=True) + background_thread.start() + + @staticmethod + def extract_epub_all_images(epub_path, epub_name): + preview_dir = ComicReader.get_comic_preview_dir(epub_path) + existing_images = [f for f in os.listdir(preview_dir) if f.endswith(('.jpg','.jpeg','.webp','.gif'))] + if existing_images: + existing_images.sort() + img_urls = [f"file://{os.path.join(preview_dir, img)}" for img in existing_images] + print(f"[EPUB已缓存] {epub_name}, {len(img_urls)}张图片") + return len(img_urls), img_urls, preview_dir + img_urls = [] + try: + import zipfile + with zipfile.ZipFile(epub_path, 'r') as zf: + image_exts = ('.png', '.jpg', '.jpeg', '.webp', '.gif') + img_files = [] + cover_names = ['cover', 'Cover', 'COVER', 'titlepage'] + for name in zf.namelist(): + lname = name.lower() + if any(lname.endswith(ext) for ext in image_exts): + base = os.path.basename(lname) + is_cover = any(cn.lower() in base for cn in cover_names) or 'cover' in lname + if is_cover: + img_files.insert(0, name) + else: + img_files.append(name) + img_files.sort(key=lambda x: x.lower()) + executor = ComicReader.get_render_executor() + futures = [] + for idx, img_name in enumerate(img_files): + ext = os.path.splitext(img_name)[1].lower() + if not ext or ext not in image_exts: + ext = '.jpg' + out_filename = f"page_{idx:04d}{ext}" + out_path = os.path.join(preview_dir, out_filename) + if not os.path.exists(out_path): + future = executor.submit(ComicReader._extract_single_epub_image, epub_path, img_name, out_path) + futures.append((idx, future, out_path)) + else: + img_urls.append(f"file://{out_path}") + for idx, future, out_path in futures: + try: + future.result(timeout=10) + img_urls.append(f"file://{out_path}") + except Exception as e: + print(f"[EPUB图片提取失败]: {e}") + print(f"[EPUB提取完成] {epub_name}, {len(img_urls)}张图片") + return len(img_urls), img_urls, preview_dir + except Exception as e: + print(f"[EPUB提取失败] {e}") + return 0, [], None + + @staticmethod + def _extract_single_epub_image(epub_path, img_name, out_path): + try: + import zipfile + with zipfile.ZipFile(epub_path, 'r') as zf: + data = zf.read(img_name) + with open(out_path, 'wb') as f: + f.write(data) + return True + except: + return False + + @staticmethod + def extract_epub_chapters(epub_path): + chapters = [] + try: + import zipfile + with zipfile.ZipFile(epub_path, 'r') as zf: + if "META-INF/container.xml" not in zf.namelist(): + return [] + container = zf.read("META-INF/container.xml") + root = ET.fromstring(container) + ns = {'ns': 'urn:oasis:names:tc:opendocument:xmlns:container'} + rootfile_path = root.find('.//ns:rootfile', ns).get('full-path') + opf_data = zf.read(rootfile_path) + opf_root = ET.fromstring(opf_data) + opf_ns = {'opf': 'http://www.idpf.org/2007/opf'} + items = {} + for item in opf_root.findall('.//opf:item', opf_ns): + items[item.get('id')] = { + 'href': item.get('href'), + 'media_type': item.get('media-type', '') + } + spine = opf_root.find('.//opf:spine', opf_ns) + if spine is None: + return [] + base_dir = os.path.dirname(rootfile_path) + chapter_idx = 0 + for itemref in spine.findall('opf:itemref', opf_ns): + idref = itemref.get('idref') + if idref not in items: + continue + item_info = items[idref] + media_type = item_info['media_type'] + if media_type not in ('application/xhtml+xml', 'text/html', 'application/xml'): + continue + href = item_info['href'] + import posixpath + full_path = posixpath.join(base_dir, href) if base_dir else href + if full_path not in zf.namelist(): + continue + raw = zf.read(full_path) + try: + content = raw.decode('utf-8') + except: + content = raw.decode('latin-1', errors='ignore') + title = None + for h_tag in ['h1', 'h2', 'h3', 'h4', 'h5', 'h6']: + m = re.search(rf'<{h_tag}[^>]*>(.*?)', content, re.IGNORECASE | re.DOTALL) + if m: + raw_title = re.sub(r'<[^>]+>', '', m.group(1)).strip() + raw_title = re.sub(r'^\d+\s*', '', raw_title) + if raw_title: + title = raw_title + break + if not title: + m = re.search(r']*>(.*?)', content, re.IGNORECASE | re.DOTALL) + if m: + raw_title = re.sub(r'<[^>]+>', '', m.group(1)).strip() + raw_title = re.sub(r'^\d+\s*', '', raw_title) + title = raw_title + if not title: + title = f"第{chapter_idx + 1}章" + text = content + text = re.sub(r']*>.*?', '', text, flags=re.DOTALL | re.IGNORECASE) + text = re.sub(r']*>.*?', '', text, flags=re.DOTALL | re.IGNORECASE) + block_tags = ['p', 'div', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'pre', 'blockquote'] + for tag in block_tags: + text = re.sub(rf'<{tag}[^>]*>', '\n\n', text, flags=re.IGNORECASE) + text = re.sub(rf'', '\n', text, flags=re.IGNORECASE) + text = re.sub(r'', '\n', text, flags=re.IGNORECASE) + text = re.sub(r'<[^>]+>', ' ', text) + text = text.replace(' ', ' ').replace('&', '&') + text = text.replace('<', '<').replace('>', '>') + text = re.sub(r'[ \t]+', ' ', text) + text = re.sub(r'\n{3,}', '\n\n', text) + text = text.strip() + if text: + chapters.append({ + 'title': title, + 'content': text, + 'index': chapter_idx + }) + chapter_idx += 1 + return chapters + except Exception as e: + print(f"[EPUB章节提取失败] {e}") + return [] + + @staticmethod + def get_epub_image_count(epub_path): + try: + import zipfile + with zipfile.ZipFile(epub_path, 'r') as zf: + image_exts = ('.png', '.jpg', '.jpeg', '.webp', '.gif') + return sum(1 for name in zf.namelist() if name.lower().endswith(image_exts)) + except: + return 0 + + @staticmethod + def get_comic_detail(file_path, filename): + ext = filename.split('.')[-1].lower() if '.' in filename else '' + if ext == 'pdf': + if not HAS_JAVA: + return {"list": [{ + "vod_id": file_path, + "vod_name": os.path.splitext(filename)[0], + "vod_pic": ComicReader._get_default_comic_icon(ext), + "vod_remarks": "需要Java支持", + "vod_actor": "PDF" + }]} + page_count, img_urls = ComicReader.render_pdf_first_batch(file_path, filename) + cover_url = ComicReader.get_cover_url(file_path) + if img_urls: + play_url = "pics://" + "&&".join(img_urls) + total_pages = ComicReader.get_pdf_page_count(file_path) + if page_count >= total_pages: + remarks = f"{page_count}页 | PDF (已全部缓存)" + else: + remarks = f"{page_count}/{total_pages}页 | 后台继续缓存..." + return { + "list": [{ + "vod_id": f"comic_pdf_{hashlib.md5(file_path.encode()).hexdigest()}", + "vod_name": os.path.splitext(filename)[0], + "vod_pic": cover_url, + "vod_play_from": "PDF漫画", + "vod_play_url": play_url, + "vod_remarks": remarks, + "vod_actor": "漫画", + "vod_player": "画" + }] + } + elif ext == 'epub': + img_count = ComicReader.get_epub_image_count(file_path) + if img_count >= ComicReader.EPUB_TEXT_THRESHOLD: + page_count, img_urls, _ = ComicReader.extract_epub_all_images(file_path, filename) + if img_urls: + play_url = "pics://" + "&&".join(img_urls) + cover_url = ComicReader.get_cover_url(file_path) + return { + "list": [{ + "vod_id": f"comic_epub_{hashlib.md5(file_path.encode()).hexdigest()}", + "vod_name": os.path.splitext(filename)[0], + "vod_pic": cover_url or img_urls[0], + "vod_play_from": "EPUB漫画", + "vod_play_url": play_url, + "vod_remarks": f"{page_count}页 | EPUB(图片)", + "vod_actor": "漫画", + "vod_player": "画" + }] + } + else: + chapters = ComicReader.extract_epub_chapters(file_path) + if chapters: + encoded_path = ComicReader._b64u_encode(file_path) + novel_id = f"novel://{encoded_path}" + play_url_parts = [] + for idx, ch in enumerate(chapters): + title = ch['title'].replace('$', ' ').replace('#', ' ') + play_url_parts.append(f"{title}${novel_id}?chapter={idx}") + play_url = "#".join(play_url_parts) + cover_url = ComicReader.get_cover_url(file_path) + return { + "list": [{ + "vod_id": novel_id, + "vod_name": os.path.splitext(filename)[0], + "vod_pic": cover_url or ComicReader._get_default_comic_icon(ext), + "vod_play_from": "EPUB文本", + "vod_play_url": play_url, + "vod_remarks": f"{len(chapters)}章 | EPUB", + "vod_actor": "小说", + "vod_player": "书" + }] + } + else: + page_count, img_urls, _ = ComicReader.extract_epub_all_images(file_path, filename) + if img_urls: + play_url = "pics://" + "&&".join(img_urls) + cover_url = ComicReader.get_cover_url(file_path) + return { + "list": [{ + "vod_id": f"comic_epub_{hashlib.md5(file_path.encode()).hexdigest()}", + "vod_name": os.path.splitext(filename)[0], + "vod_pic": cover_url or img_urls[0], + "vod_play_from": "EPUB漫画", + "vod_play_url": play_url, + "vod_remarks": f"{page_count}页 | EPUB", + "vod_actor": "漫画", + "vod_player": "画" + }] + } + return {"list": []} + + @staticmethod + def _b64u_encode(data): + if isinstance(data, str): + data = data.encode('utf-8') + encoded = base64.b64encode(data).decode('ascii') + return encoded.replace('+', '-').replace('/', '_').rstrip('=') + + @staticmethod + def _b64u_decode(data): + data = data.replace('-', '+').replace('_', '/') + pad = len(data) % 4 + if pad: + data += '=' * (4 - pad) + return base64.b64decode(data).decode('utf-8') + + @staticmethod + def clear_all_cache(): + cover_count = 0 + cover_size = 0 + preview_count = 0 + preview_size = 0 + if os.path.exists(COMIC_CACHE_DIR): + for filename in os.listdir(COMIC_CACHE_DIR): + if filename == '.nomedia': + continue + file_path = os.path.join(COMIC_CACHE_DIR, filename) + if os.path.isfile(file_path): + cover_size += os.path.getsize(file_path) + os.remove(file_path) + cover_count += 1 + if os.path.exists(COMIC_PREVIEW_DIR): + for root, dirs, files in os.walk(COMIC_PREVIEW_DIR): + for file in files: + if file == '.nomedia': + continue + file_path = os.path.join(root, file) + try: + preview_size += os.path.getsize(file_path) + os.remove(file_path) + preview_count += 1 + except: + pass + total_size = cover_size + preview_size + total_count = cover_count + preview_count + if total_size > 1024 * 1024: + size_str = f"{total_size / (1024 * 1024):.2f} MB" + elif total_size > 1024: + size_str = f"{total_size / 1024:.2f} KB" + else: + size_str = f"{total_size} B" + return total_count, size_str + + +class RadioProgramFetcher: + _cache = {} + _cache_time = {} + + @classmethod + def get_current_program(cls, radio_id): + cache_key = f"program_{radio_id}" + current_time = time.time() + if cache_key in cls._cache and current_time - cls._cache_time.get(cache_key, 0) < LIVE_PROGRAM_CACHE_DURATION: + return cls._cache[cache_key] + try: + url = f"http://www.qingting.fm/radios/{radio_id}" + headers = { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", + "Referer": "http://www.qingting.fm/", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8" + } + response = requests.get(url, headers=headers, timeout=10) + response.encoding = 'utf-8' + html = response.text + program_info = cls._parse_current_program(html, radio_id) + if program_info: + cls._cache[cache_key] = program_info + cls._cache_time[cache_key] = current_time + return program_info + return None + except Exception as e: + print(f"获取电台节目信息失败 {radio_id}: {e}") + return None + + @classmethod + def _parse_current_program(cls, html, radio_id): + program = {'current': '正在播出', 'next': '即将播出', 'current_time': '', 'next_time': ''} + try: + current_patterns = [ + r'正在播放[::]\s*<[^>]*>([^<]+)]*>.*?]*>([^<]+)', + r'节目[::]\s*([^<>\n]+)', + r'"programName"\s*:\s*"([^"]+)"', + r'
]*>([^<]+)
', + ] + for pattern in current_patterns: + match = re.search(pattern, html, re.IGNORECASE | re.DOTALL) + if match: + program['current'] = match.group(1).strip() + break + time_patterns = [ + r'(\d{1,2}:\d{2})\s*[-~]\s*(\d{1,2}:\d{2})', + r'(\d{1,2}:\d{2})\s*至\s*(\d{1,2}:\d{2})', + ] + for pattern in time_patterns: + match = re.search(pattern, html) + if match: + program['current_time'] = f"{match.group(1)}-{match.group(2)}" + break + next_patterns = [ + r'即将播放[::]\s*<[^>]*>([^<]+)]*>.*?]*>([^<]+)', + r'下一节目[::]\s*([^<>\n]+)', + ] + for pattern in next_patterns: + match = re.search(pattern, html, re.IGNORECASE | re.DOTALL) + if match: + program['next'] = match.group(1).strip() + break + for key in ['current', 'next']: + program[key] = re.sub(r'<[^>]+>', '', program[key]) + program[key] = re.sub(r'\s+', ' ', program[key]).strip() + if len(program[key]) > 50: + program[key] = program[key][:47] + '...' + if program['current'] == '正在播出' or len(program['current']) < 2: + program['current'] = cls._get_time_based_program(radio_id) + return program + except Exception as e: + print(f"解析节目信息失败: {e}") + return None + + @classmethod + def _get_time_based_program(cls, radio_id): + hour = time.localtime().tm_hour + if 6 <= hour < 9: + return "早安时段" + elif 9 <= hour < 12: + return "上午时段" + elif 12 <= hour < 14: + return "午间时段" + elif 14 <= hour < 18: + return "下午时段" + elif 18 <= hour < 20: + return "晚间时段" + elif 20 <= hour < 23: + return "黄金时段" + else: + return "深夜时段" + + +class DatabaseReader: + def __init__(self): + self.cache = {} + self.cache_time = {} + self.cache_duration = 600 + + def read_sqlite(self, db_path, limit=50000): + cache_key = f"{db_path}_{os.path.getmtime(db_path)}_{limit}" + current_time = time.time() + if cache_key in self.cache and current_time - self.cache_time.get(cache_key, 0) < self.cache_duration: + return self.cache[cache_key] + if not os.path.exists(db_path) or not os.access(db_path, os.R_OK): + return [] + out = [] + try: + conn = sqlite3.connect(db_path) + conn.row_factory = sqlite3.Row + cursor = conn.cursor() + cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE 'android_%'") + tables = cursor.fetchall() + skip_tables = ['android_metadata', 'db_config', 'meta', 'crawl_state', 'sqlite_sequence'] + for table in tables: + table_name = table[0] + if table_name in skip_tables: + continue + items = self.parse_table(cursor, conn, table_name, limit) + if items: + out.extend(items) + if len(out) >= limit: + out = out[:limit] + break + conn.close() + except Exception as e: + print(f"数据库读取错误: {e}") + return [] + self.cache[cache_key] = out + self.cache_time[cache_key] = current_time + return out + + def parse_table(self, cursor, conn, table, limit): + res = [] + try: + cursor.execute(f"PRAGMA table_info(`{table}`)") + cols = cursor.fetchall() + col_names = [col[1] for col in cols] + title_field = self.find_best_match(col_names, ['vod_name', 'name', 'title']) + url_field = self.find_best_match(col_names, ['play_url', 'vod_play_url', 'vod_url', 'url']) + pic_field = self.find_best_match(col_names, ['image', 'vod_pic', 'pic']) + remarks_field = self.find_best_match(col_names, ['vod_remarks', 'remarks']) + if not title_field or not url_field: + return [] + cursor.execute(f"SELECT * FROM `{table}` WHERE `{url_field}` IS NOT NULL AND `{url_field}` != '' LIMIT {limit}") + rows = cursor.fetchall() + for row in rows: + row_dict = dict(row) + play_url_raw = str(row_dict.get(url_field, '')).strip() + if not play_url_raw: + continue + title = str(row_dict.get(title_field, '未命名')).strip() + item = { + 'name': title, + 'url': '' if '$$$' in play_url_raw or '#' in play_url_raw else play_url_raw, + 'play_url': play_url_raw if '$$$' in play_url_raw or '#' in play_url_raw else '', + 'pic': row_dict.get(pic_field, '') if pic_field else '', + 'remarks': row_dict.get(remarks_field, '') if remarks_field else '', + } + res.append(item) + except Exception as e: + print(f"解析表 {table} 错误: {e}") + return res + + def find_best_match(self, column_names, candidates): + for cand in candidates: + for col in column_names: + if col.lower() == cand.lower(): + return col + for cand in candidates: + for col in column_names: + if cand.lower() in col.lower(): + return col + return None + + +class NovelParser: + @staticmethod + def parse_txt_novel(file_path): + chapters = [] + try: + with open(file_path, 'r', encoding='utf-8', errors='ignore') as f: + content = f.read() + patterns = [ + r'第[一二三四五六七八九十百千万0-9]+章\s*[^\n]{0,50}', + r'第[一二三四五六七八九十百千万0-9]+节\s*[^\n]{0,50}', + r'序章\s*[^\n]{0,50}|楔子\s*[^\n]{0,50}|尾声\s*[^\n]{0,50}', + r'正文\s+第[一二三四五六七八九十百千万0-9]+章', + r'Chapter\s+[0-9]+[.:]?\s*[^\n]{0,50}' + ] + matches = [] + for p in patterns: + for m in re.finditer(p, content, re.MULTILINE): + title = m.group().strip() + if title and len(title) > 2 and not title.strip().isdigit(): + matches.append((m.start(), title)) + matches = list(set(matches)) + matches.sort(key=lambda x: x[0]) + if matches: + for i, (pos, title) in enumerate(matches): + start = pos + end = matches[i + 1][0] if i + 1 < len(matches) else len(content) + chap_content = content[start:end].strip() + chapters.append({ + 'title': title, + 'content': chap_content, + 'index': i + }) + else: + chapters.append({ + 'title': os.path.splitext(os.path.basename(file_path))[0], + 'content': content, + 'index': 0 + }) + except Exception as e: + print(f"解析小说失败: {e}") + return chapters + + +class CoverScanRecord: + @staticmethod + def load_record(): + try: + if os.path.exists(COVER_SCAN_RECORD_FILE): + with open(COVER_SCAN_RECORD_FILE, 'r', encoding='utf-8') as f: + return json.load(f) + return {} + except: + return {} + + @staticmethod + def save_record(record): + try: + os.makedirs(os.path.dirname(COVER_SCAN_RECORD_FILE), exist_ok=True) + with open(COVER_SCAN_RECORD_FILE, 'w', encoding='utf-8') as f: + json.dump(record, f, ensure_ascii=False, indent=2) + except: + pass + + @staticmethod + def is_cover_cached(audio_path): + record = CoverScanRecord.load_record() + file_hash = hashlib.md5(audio_path.encode()).hexdigest() + return file_hash in record + + @staticmethod + def mark_cover_cached(audio_path): + record = CoverScanRecord.load_record() + file_hash = hashlib.md5(audio_path.encode()).hexdigest() + record[file_hash] = { + 'path': audio_path, + 'time': time.time(), + 'mtime': os.path.getmtime(audio_path) if os.path.exists(audio_path) else 0 + } + CoverScanRecord.save_record(record) + + @staticmethod + def clear_record(): + try: + if os.path.exists(COVER_SCAN_RECORD_FILE): + os.remove(COVER_SCAN_RECORD_FILE) + return True + except: + return False + + +class RadioCoverRecord: + @staticmethod + def load_record(): + try: + if os.path.exists(RADIO_SCAN_RECORD_FILE): + with open(RADIO_SCAN_RECORD_FILE, 'r', encoding='utf-8') as f: + return json.load(f) + return {} + except: + return {} + + @staticmethod + def save_record(record): + try: + os.makedirs(os.path.dirname(RADIO_SCAN_RECORD_FILE), exist_ok=True) + with open(RADIO_SCAN_RECORD_FILE, 'w', encoding='utf-8') as f: + json.dump(record, f, ensure_ascii=False, indent=2) + except: + pass + + @staticmethod + def is_cached(radio_id): + record = RadioCoverRecord.load_record() + return str(radio_id) in record + + @staticmethod + def mark_cached(radio_id): + record = RadioCoverRecord.load_record() + record[str(radio_id)] = {'id': radio_id, 'time': time.time()} + RadioCoverRecord.save_record(record) + + @staticmethod + def clear_record(): + try: + if os.path.exists(RADIO_SCAN_RECORD_FILE): + os.remove(RADIO_SCAN_RECORD_FILE) + return True + except: + return False + + +class LyricsCacheManager: + @staticmethod + def get_cache_dir(): + try: + os.makedirs(LYRICS_CACHE_DIR, exist_ok=True) + return LYRICS_CACHE_DIR + except: + alt_dir = "/storage/emulated/0/tmp/lyrics_backup/" + os.makedirs(alt_dir, exist_ok=True) + return alt_dir + + @staticmethod + def get_safe_filename(song_name, artist_name=""): + if artist_name and artist_name not in song_name: + base_name = f"{song_name}_{artist_name}" + else: + base_name = song_name + illegal_chars = r'[<>:"/\\|?*]' + safe_name = re.sub(illegal_chars, '_', base_name) + if len(safe_name) > 100: + safe_name = safe_name[:100] + return f"{safe_name}.lrc" + + @staticmethod + def is_lyrics_cached(song_name, artist_name=""): + cache_dir = LyricsCacheManager.get_cache_dir() + filename = LyricsCacheManager.get_safe_filename(song_name, artist_name) + filepath = os.path.join(cache_dir, filename) + return os.path.exists(filepath) + + @staticmethod + def save_lyrics(song_name, artist_name, lyrics_content): + if not lyrics_content or len(lyrics_content) < 50: + return False + try: + cache_dir = LyricsCacheManager.get_cache_dir() + filename = LyricsCacheManager.get_safe_filename(song_name, artist_name) + filepath = os.path.join(cache_dir, filename) + header = f"# 歌曲: {song_name}\n# 歌手: {artist_name}\n# 缓存时间: {time.strftime('%Y-%m-%d %H:%M:%S')}\n# ====================================\n\n" + with open(filepath, 'w', encoding='utf-8') as f: + f.write(header + lyrics_content) + return True + except: + return False + + @staticmethod + def load_lyrics(song_name, artist_name=""): + try: + cache_dir = LyricsCacheManager.get_cache_dir() + filename = LyricsCacheManager.get_safe_filename(song_name, artist_name) + filepath = os.path.join(cache_dir, filename) + if os.path.exists(filepath): + with open(filepath, 'r', encoding='utf-8') as f: + content = f.read() + lines = content.split('\n') + lyrics_lines = [] + skip_meta = False + for line in lines: + if line.startswith('# ===================================='): + skip_meta = True + continue + if skip_meta: + lyrics_lines.append(line) + result = '\n'.join(lyrics_lines) + if result and len(result) > 50: + return result + return None + except: + return None + + @staticmethod + def delete_lyrics_cache(song_name=None, artist_name=""): + cache_dir = LyricsCacheManager.get_cache_dir() + deleted_count = 0 + deleted_size = 0 + try: + if song_name: + filename = LyricsCacheManager.get_safe_filename(song_name, artist_name) + filepath = os.path.join(cache_dir, filename) + if os.path.exists(filepath): + file_size = os.path.getsize(filepath) + os.remove(filepath) + deleted_count = 1 + deleted_size = file_size + else: + if os.path.exists(cache_dir): + for filename in os.listdir(cache_dir): + if filename.endswith('.lrc'): + filepath = os.path.join(cache_dir, filename) + file_size = os.path.getsize(filepath) + os.remove(filepath) + deleted_count += 1 + deleted_size += file_size + except: + pass + return deleted_count, deleted_size + + +class UltraFastCoverExtractor: + @staticmethod + def _compress_image(image_data, max_size=(300, 300), quality=65): + try: + from PIL import Image + import io + img = Image.open(io.BytesIO(image_data)) + if img.mode in ('RGBA', 'LA', 'P'): + img = img.convert('RGB') + img.thumbnail(max_size, Image.Resampling.LANCZOS) + buffer = io.BytesIO() + img.save(buffer, format='JPEG', quality=quality, optimize=True) + return buffer.getvalue() + except: + return image_data + + @staticmethod + def _search_raw_image(file_path, max_size=5*1024*1024): + try: + file_size = os.path.getsize(file_path) + search_size = min(file_size, 10 * 1024 * 1024) + with open(file_path, 'rb') as f: + data = f.read(search_size) + jpeg_pos = data.find(b'\xff\xd8') + if jpeg_pos != -1: + end_pos = data.find(b'\xff\xd9', jpeg_pos + 2) + if end_pos != -1 and end_pos > jpeg_pos: + image_data = data[jpeg_pos:end_pos+2] + if 500 < len(image_data) < max_size: + if len(image_data) > 1024 * 1024: + image_data = UltraFastCoverExtractor._compress_image(image_data) + return f"data:image/jpeg;base64,{base64.b64encode(image_data).decode()}" + png_pos = data.find(b'\x89PNG\r\n\x1a\n') + if png_pos != -1: + end_pos = data.find(b'IEND', png_pos) + if end_pos != -1: + image_data = data[png_pos:end_pos+8] + if 100 < len(image_data) < max_size: + return f"data:image/png;base64,{base64.b64encode(image_data).decode()}" + if file_size > 5 * 1024 * 1024: + with open(file_path, 'rb') as f: + f.seek(-2 * 1024 * 1024, 2) + tail_data = f.read(2 * 1024 * 1024) + jpeg_pos = tail_data.find(b'\xff\xd8') + if jpeg_pos != -1: + end_pos = tail_data.find(b'\xff\xd9', jpeg_pos + 2) + if end_pos != -1 and end_pos > jpeg_pos: + image_data = tail_data[jpeg_pos:end_pos+2] + if 500 < len(image_data) < max_size: + if len(image_data) > 1024 * 1024: + image_data = UltraFastCoverExtractor._compress_image(image_data) + return f"data:image/jpeg;base64,{base64.b64encode(image_data).decode()}" + return None + except: + return None + + @staticmethod + def extract_mp3_cover(file_path): + try: + with open(file_path, 'rb') as f: + header = f.read(10) + if header.startswith(b'ID3'): + tag_size = ((header[6] & 0x7F) << 21) | ((header[7] & 0x7F) << 14) | \ + ((header[8] & 0x7F) << 7) | (header[9] & 0x7F) + read_size = min(tag_size + 10, 5 * 1024 * 1024) + f.seek(10) + tag_data = f.read(read_size) + cover = UltraFastCoverExtractor._find_apic_in_id3(tag_data) + if cover: + return cover + return UltraFastCoverExtractor._search_raw_image(file_path) + except: + return None + + @staticmethod + def _find_apic_in_id3(data): + pos = 0 + data_len = len(data) + while pos + 10 <= data_len: + frame_id = data[pos:pos+4] + if frame_id == b'APIC': + frame_size = struct.unpack('>I', data[pos+4:pos+8])[0] + if frame_size > 5 * 1024 * 1024: + pos += 10 + frame_size + continue + frame_data_pos = pos + 10 + if frame_data_pos + frame_size > data_len: + break + apic_data = data[frame_data_pos:frame_data_pos+frame_size] + idx = 1 + mime_end = apic_data.find(b'\x00', idx) + if mime_end == -1: + mime_end = len(apic_data) + idx = mime_end + 1 + idx += 1 + desc_end = apic_data.find(b'\x00', idx) + if desc_end == -1: + desc_end = len(apic_data) + idx = desc_end + 1 + image_data = apic_data[idx:] + if image_data and len(image_data) > 100: + if image_data[0] == 0xFF and image_data[1] == 0xD8: + mime = 'image/jpeg' + elif image_data[0:8] == b'\x89PNG\r\n\x1a\n': + mime = 'image/png' + else: + mime = 'image/jpeg' + if len(image_data) > 1024 * 1024: + image_data = UltraFastCoverExtractor._compress_image(image_data) + mime = 'image/jpeg' + return f"data:{mime};base64,{base64.b64encode(image_data).decode()}" + frame_size = struct.unpack('>I', data[pos+4:pos+8])[0] + pos += 10 + frame_size + return None + + @staticmethod + def extract_flac_cover(file_path): + try: + file_size = os.path.getsize(file_path) + with open(file_path, 'rb') as f: + header = f.read(4) + if header != b'fLaC': + return None + read_size = min(file_size - 4, 5 * 1024 * 1024) + f.seek(4) + data = f.read(read_size) + pos = 0 + data_len = len(data) + last_block = False + while not last_block and pos + 4 <= data_len: + header_byte = data[pos] + last_block = (header_byte & 0x80) != 0 + block_type = header_byte & 0x7F + block_size = (data[pos+1] << 16) | (data[pos+2] << 8) | data[pos+3] + pos += 4 + if block_type == 6: + if pos + block_size > data_len: + with open(file_path, 'rb') as f: + f.seek(pos) + picture_data = f.read(block_size) + else: + picture_data = data[pos:pos+block_size] + if len(picture_data) < 20: + pos += block_size + continue + pic_idx = 4 + if pic_idx + 4 > len(picture_data): + pos += block_size + continue + mime_len = struct.unpack('>I', picture_data[pic_idx:pic_idx+4])[0] + pic_idx += 4 + if mime_len > 100: + pos += block_size + continue + pic_idx += mime_len + if pic_idx + 4 > len(picture_data): + pos += block_size + continue + desc_len = struct.unpack('>I', picture_data[pic_idx:pic_idx+4])[0] + pic_idx += 4 + pic_idx += desc_len + pic_idx += 16 + if pic_idx + 4 > len(picture_data): + pos += block_size + continue + img_len = struct.unpack('>I', picture_data[pic_idx:pic_idx+4])[0] + pic_idx += 4 + if img_len > 5 * 1024 * 1024: + pos += block_size + continue + if pic_idx + img_len <= len(picture_data): + image_data = picture_data[pic_idx:pic_idx+img_len] + else: + with open(file_path, 'rb') as f: + f.seek(pos + pic_idx) + image_data = f.read(img_len) + if image_data and len(image_data) > 100: + if image_data[0] == 0xFF and image_data[1] == 0xD8: + mime = 'image/jpeg' + elif image_data[0:8] == b'\x89PNG\r\n\x1a\n': + mime = 'image/png' + else: + mime = 'image/jpeg' + if len(image_data) > 1024 * 1024: + image_data = UltraFastCoverExtractor._compress_image(image_data) + mime = 'image/jpeg' + return f"data:{mime};base64,{base64.b64encode(image_data).decode()}" + pos += block_size + return UltraFastCoverExtractor._search_raw_image(file_path) + except: + return None + + @staticmethod + def extract_m4a_cover(file_path): + try: + with open(file_path, 'rb') as f: + data = f.read(5 * 1024 * 1024) + jpeg_pos = data.find(b'\xff\xd8') + if jpeg_pos != -1: + end_pos = data.find(b'\xff\xd9', jpeg_pos + 2) + if end_pos != -1 and end_pos > jpeg_pos: + image_data = data[jpeg_pos:end_pos+2] + if 1000 < len(image_data) < 5 * 1024 * 1024: + if len(image_data) > 1024 * 1024: + image_data = UltraFastCoverExtractor._compress_image(image_data) + return f"data:image/jpeg;base64,{base64.b64encode(image_data).decode()}" + png_pos = data.find(b'\x89PNG\r\n\x1a\n') + if png_pos != -1: + end_pos = data.find(b'IEND', png_pos) + if end_pos != -1: + image_data = data[png_pos:end_pos+8] + if 100 < len(image_data) < 5 * 1024 * 1024: + return f"data:image/png;base64,{base64.b64encode(image_data).decode()}" + pos = 0 + data_len = len(data) + while pos + 8 <= data_len: + atom_size = struct.unpack('>I', data[pos:pos+4])[0] + if atom_size == 0 or atom_size > data_len - pos: + break + atom_type = data[pos+4:pos+8] + if atom_type == b'meta': + cover = UltraFastCoverExtractor._parse_meta_atom(data, pos, atom_size) + if cover: + return cover + pos += atom_size + return UltraFastCoverExtractor._search_raw_image(file_path) + except: + return None + + @staticmethod + def _parse_meta_atom(data, start, atom_size): + try: + meta_pos = start + 8 + meta_end = min(start + atom_size, len(data)) + while meta_pos + 8 <= meta_end: + child_size = struct.unpack('>I', data[meta_pos:meta_pos+4])[0] + child_type = data[meta_pos+4:meta_pos+8] + if child_type == b'ilst': + ilst_pos = meta_pos + 8 + ilst_end = min(meta_pos + child_size, meta_end) + while ilst_pos + 8 <= ilst_end: + item_size = struct.unpack('>I', data[ilst_pos:ilst_pos+4])[0] + item_type = data[ilst_pos+4:ilst_pos+8] + if item_type == b'covr': + covr_pos = ilst_pos + 8 + if covr_pos + 8 <= ilst_end: + data_size = struct.unpack('>I', data[covr_pos:covr_pos+4])[0] + data_type = data[covr_pos+4:covr_pos+8] + if data_type == b'data': + img_start = covr_pos + 16 + img_len = data_size - 16 + if img_start + img_len <= len(data): + image_data = data[img_start:img_start+img_len] + if image_data and len(image_data) > 100: + mime = 'image/jpeg' if image_data[0:2] == b'\xff\xd8' else 'image/png' + if len(image_data) > 1024 * 1024: + image_data = UltraFastCoverExtractor._compress_image(image_data) + mime = 'image/jpeg' + return f"data:{mime};base64,{base64.b64encode(image_data).decode()}" + ilst_pos += item_size + meta_pos += child_size + return None + except: + return None + + +# ==================== 动作/工具/书签浏览器 ==================== +class WebActionBrowser: + def __init__(self): + self.port = 8901 + self.bookmark_file = '/storage/emulated/0/tmp/web_bookmarks.json' + self.bookmarks = [] + self.default_colors = ["#FF6B6B", "#4ECDC4", "#FFD93D", "#6BCB77", "#9D65C9", + "#FF8C42", "#A2D729", "#FF6B8B", "#45B7D1", "#96CEB4"] + self._load_bookmarks() + + def _get_random_pic(self, seed=None): + if seed is None: + seed = random.randint(1, 999999) + return f"https://bing.img.run/rand.php?r={seed}" + + def _is_valid_image_url(self, url): + if not url: + return False + image_exts = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.ico', '.svg'] + if url.startswith('data:image/'): + return True + if url.startswith('file://'): + file_path = url[7:] + if os.path.exists(file_path) and os.path.isfile(file_path): + ext = os.path.splitext(file_path)[1].lower() + return ext in image_exts + return False + if url.startswith(('http://', 'https://')): + url_lower = url.lower() + for ext in image_exts: + if url_lower.endswith(ext): + return True + return True + return False + + def _normalize_cover_url(self, cover): + if not cover or not cover.strip(): + return '' + cover = cover.strip() + if cover.startswith('data:image/'): + return cover + if cover.startswith('/') or (len(cover) > 2 and cover[1] == ':'): + if os.path.exists(cover) and os.path.isfile(cover): + return f"file://{cover}" + if cover.startswith('file://'): + file_path = cover[7:] + if os.path.exists(file_path) and os.path.isfile(file_path): + return cover + return '' + if cover.startswith(('http://', 'https://')): + return cover + return '' + + def _load_bookmarks(self): + try: + if os.path.exists(self.bookmark_file): + with open(self.bookmark_file, 'r', encoding='utf-8') as f: + self.bookmarks = json.load(f) + for bm in self.bookmarks: + if 'cover' not in bm: + bm['cover'] = '' + else: + self.bookmarks = [] + except: + self.bookmarks = [] + + def _save_bookmarks(self): + try: + os.makedirs(os.path.dirname(self.bookmark_file), exist_ok=True) + with open(self.bookmark_file, 'w', encoding='utf-8') as f: + json.dump(self.bookmarks, f, ensure_ascii=False, indent=2) + except: + pass + + def _generate_colored_icon(self, color, text): + svg = f''' + + + {text} + ''' + return f"data:image/svg+xml;base64,{base64.b64encode(svg.encode()).decode()}" + + def _open_url_action(self, url, title=""): + if not url: + return None + if not url.startswith(('http://', 'https://')): + url = 'https://' + url + if not title: + title = url.replace('https://', '').replace('http://', '').split('/')[0][:30] + return { + 'action': { + 'actionId': 'OPEN_URL', + 'type': 'browser', + 'title': title, + 'url': url + } + } + + def _create_url_item(self, name, url): + config = { + 'actionId': 'OPEN_URL', + 'type': 'browser', + 'title': name, + 'url': url + } + seed = random.randint(1, 999999) + return { + 'vod_id': json.dumps(config, ensure_ascii=False), + 'vod_name': name, + 'vod_pic': self._get_random_pic(seed), + 'vod_remarks': '点击打开', + 'vod_tag': 'action', + 'style': {'type': 'grid', 'ratio': 0.75} + } + + def _create_bookmark_item(self, bm): + config = { + 'actionId': 'OPEN_URL', + 'type': 'browser', + 'title': bm['title'], + 'url': bm['url'] + } + item = { + 'vod_id': json.dumps(config, ensure_ascii=False), + 'vod_name': f"🔖 {bm['title']}", + 'vod_pic': bm.get('cover', ''), + 'vod_remarks': bm['url'][:50], + 'vod_tag': 'action', + 'style': {'type': 'grid', 'ratio': 0.75} + } + if not item['vod_pic'] or not self._is_valid_image_url(item['vod_pic']): + seed = random.randint(1, 999999) + item['vod_pic'] = self._get_random_pic(seed) + return item + + def _create_action_item_with_input(self, name, action_id, input_title, input_tip): + config = { + 'actionId': action_id, + 'id': 'text', + 'type': 'input', + 'title': input_title, + 'tip': input_tip, + 'value': '' + } + seed = random.randint(1, 999999) + return { + 'vod_id': json.dumps(config, ensure_ascii=False), + 'vod_name': name, + 'vod_pic': self._get_random_pic(seed), + 'vod_remarks': '', + 'vod_tag': 'action', + 'style': {'type': 'grid', 'ratio': 0.75} + } + + def _get_action_list(self): + current_count = MagnetConfigManager.get_max_items() + current_mode_display = MagnetConfigManager.get_read_mode_display() + + return [ + self._create_action_item_with_input('🌐 访问网址', '访问网址', '网址输入', '输入网址,如 baidu.com'), + self._create_action_item_with_input('🔖 添加书签', '添加书签', '添加书签', '格式: 名称@网址@封面图'), + self._create_action_item_with_input('🗑️ 删除书签', '删除书签', '删除书签', '输入书签名或网址'), + self._create_action_item_with_input('✏️ 修改书签名', '修改书签名', '修改书签名', '格式: 原书签|新名称|新封面图'), + self._create_action_item_with_input('🧲 磁力链接设置', '磁力链接设置', '磁力链接设置', f'当前每次读取 {current_count} 条\n请输入新数量 (10-5000)'), + self._create_action_item_with_input(f'🔄 磁力读取模式 [{current_mode_display}]', '磁力读取模式', '磁力读取模式', f'当前模式: {current_mode_display}\n点击切换为 {"🎲 随机" if current_mode_display == "📋 顺序" else "📋 顺序"}模式'), + ] + + def _get_tool_list(self): + tools = [ + ('📖 PDF阅读器', f'http://localhost:{self.port}/PDF阅读器.php'), + ('📖 漫画阅读器', f'http://localhost:{self.port}/漫画阅读器.php'), + ('日记本', f'http://localhost:{self.port}/html/日记本.html'), + ('🎮 喜刷刷', f'http://localhost:{self.port}/html/妹子.html'), + ('🔑 本地解密', f'http://localhost:{self.port}/jm/lgjm.html'), + ('💢 直播', f'http://localhost:{self.port}/html/index.html'), + ('🎮 网页小游戏', 'https://www.yikm.net/nes?tag=9'), + ('刘明野的工具箱', 'https://tools.liumingye.cn/'), + ] + return [self._create_url_item(name, url) for name, url in tools] + + def _get_bookmark_vod_list(self): + if not self.bookmarks: + return { + 'list': [self._create_action_item_with_input('📭 暂无书签,点击添加', '添加书签', '添加书签', '格式: 名称@网址@封面图')], + 'page': 1, + 'pagecount': 1, + 'limit': 1, + 'total': 1 + } + items = [self._create_bookmark_item(bm) for bm in reversed(self.bookmarks)] + return { + 'list': items, + 'page': 1, + 'pagecount': 1, + 'limit': len(items), + 'total': len(items) + } + + def get_home_content(self): + bookmark_count = len(self.bookmarks) + return { + 'class': [ + {'type_id': 'web_action', 'type_name': '🎯 动作', 'pic': self._get_random_pic(1001)}, + {'type_id': 'web_tool', 'type_name': '🛠 工具', 'pic': self._get_random_pic(1002)}, + {'type_id': 'web_bookmarks', 'type_name': f'🔖 书签({bookmark_count})', 'pic': self._get_random_pic(1003)}, + ] + } + + def get_category_content(self, tid, pg): + if int(pg) > 1: + return {'list': [], 'page': pg, 'pagecount': 1} + if tid == 'web_bookmarks': + return self._get_bookmark_vod_list() + if tid == 'web_tool': + return {'list': self._get_tool_list(), 'page': pg, 'pagecount': 1} + if tid == 'web_action': + return {'list': self._get_action_list(), 'page': pg, 'pagecount': 1} + return {'list': [], 'page': pg, 'pagecount': 1} + + def _create_input_response(self, action_id, title, tip): + return { + 'actionId': '单项输入', + 'id': 'text', + 'type': 'input', + 'title': title, + 'tip': tip, + 'value': '', + 'msg': '请输入' + } + + def _create_result_vod(self, message, success=True): + color = "#4CAF50" if success else "#F44336" + icon = "✓" if success else "✗" + return { + 'list': [{ + 'vod_id': 'action_result', + 'vod_name': message, + 'vod_pic': self._generate_colored_icon(color, icon), + 'vod_remarks': '操作完成' if success else '操作失败', + 'style': {'type': 'list'}, + 'vod_player': '书' + }], + 'page': 1, + 'pagecount': 1, + 'limit': 1, + 'total': 1 + } + + def handle_action(self, action_obj): + try: + if isinstance(action_obj, str): + obj = json.loads(action_obj) + else: + obj = action_obj + action_id = obj.get('actionId', '') + value = obj.get('value', '') + input_text = '' + if isinstance(value, dict): + input_text = value.get('text', '').strip() + elif isinstance(value, str): + input_text = value.strip() + if action_id == 'OPEN_URL': + url = obj.get('url', '') + if url: + return self._open_url_action(url, obj.get('title', '')) + return None + except: + return None + + +# ==================== 游戏大厅 ==================== +class GameHall: + def __init__(self): + self.host = "https://www.yikm.net" + self.default_headers = {'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'} + + def get_game_list(self): + return [ + {"name": "原神启动!", "url": "https://ys.mihoyo.com/cloud/m/", "pic": "http://bkimg.cdn.bcebos.com/pic/b3fb43166d224f4a20a4a07a33a287529822720e7b06"}, + {"name": "星穹铁道", "url": "https://sr.mihoyo.com/cloud/m/#/", "pic": "https://i0.hdslb.com/bfs/archive/797c2063ea2ff6d4da0eb2d0fc2e6af84823fe1c.jpg"}, + {"name": "cs1.6", "url": "https://cs.yikm.net/", "pic": "http://i0.hdslb.com/bfs/archive/5215e07057f2cb16ae1d0ee3152574e8a5ce86df.jpg"}, + {"name": "好游快爆", "url": "https://m.3839.com/wap.html", "pic": "https://img2.baidu.com/it/u=3047577263,831634823&fm=253&fmt=auto&app=120&f=JPEG?w=800&h=800"}, + {"name": "TapTap", "url": "https://www.taptap.cn/", "pic": "https://i-1.win1img.com/2023/7/19/5521a1a6-26a7-4925-ba25-9f1d9322e9b4.png"}, + {"name": "网易云游戏", "url": "https://cg.163.com/#/game/recommend", "pic": "https://f7.baidu.com/it/u=1780904728,147993274&fm=222&app=106&f=PNG"}, + {"name": "抖音", "url": "https://www.douyin.com/?is_from_mobile_home=1", "pic": "https://img.izhida.com/img/68639897f9e2b45.jpg"}, + {"name": "永劫无间", "url": "https://cloudgame.ds.163.com/yjwj", "pic": "https://img2.baidu.com/it/u=3935699145,3148359625&fm=253&fmt=auto&app=138&f=JPEG?w=800&h=800"}, + {"name": "梦幻西游", "url": "https://xyh5.163.com/game/", "pic": "https://olimg.3dmgame.com/uploads/images/raiders/20211115/1636967876_896503.jpg"}, + {"name": "赛尔号", "url": "https://s.61.com/", "pic": "https://miaobi-lite.cdn.bcebos.com/miaobi/5mao/b%27LV8xNzM2MzYwNzQ5LjYyNTExNzg%3D%27/0.png"}, + {"name": "4399小游戏", "url": "https://h.4399.com/", "pic": "https://bkimg.cdn.bcebos.com/pic/2fdda3cc7cd98d1001e9211b6874af0e7bec54e73acc"}, + {"name": "一千个小游戏", "url": "https://fuun.fun/", "pic": "https://gips1.baidu.com/it/u=880356554,2373818629&fm=3074&app=3074&f=JPEG?w=1080&h=1410&type=normal&func="}, + {"name": "小霸王游戏机", "url": "https://www.yikm.net", "pic": "https://i0.hdslb.com/bfs/archive/5b87d08955493c3cfa64d09198dfc096af296da3.jpg"}, + {"name": "X的世界", "url": "https://bloxd.io", "pic": "https://img0.baidu.com/it/u=421321986,2018594644&fm=253&fmt=auto&app=138&f=JPEG?w=359&h=500"}, + {"name": "红色警戒2", "url": "https://ra2web.com/", "pic": "https://q5.itc.cn/images01/20250302/69c16a5e881f426e99108d7d729dc077.jpeg"}, + {"name": "贪吃蛇", "url": "http://slither.io/", "pic": "https://miaobi-lite.bj.bcebos.com/miaobi/5mao/b%276LSq5ZCD6JuH5ri45oiPXzE3MzI5MDM5NTEuODkzOTE4Mw%3D%3D%27/0.png"}, + {"name": "斗地主(人机)", "url": "https://www.haiwaiqipai.com/games/doudizhus/index.html", "pic": "https://i-1-333ttt.upimgt.com/2025/10/17/6bfe96a4-cf6b-4d24-a983-b21d5920825b.png"}, + {"name": "五子棋", "url": "https://wuziqi.hongton.com", "pic": "http://miaobi-lite.cdn.bcebos.com/miaobi/5mao/b%275LqU5a2Q5qOL5Zu%2B54mHXzE3MzM1MDQ3OTIuMzAzMjI0%27/0.png"}, + {"name": "中国象棋", "url": "https://game.haiyong.site/xiangqi/", "pic": "http://img1.baidu.com/it/u=2872582931,481942876&fm=253&fmt=auto&app=138&f=JPEG?w=800&h=1067"}, + {"name": "俄罗斯方块", "url": "https://v2fy.com/game/tetris/", "pic": "https://wx4.sinaimg.cn/mw690/80f256c3gy1hqfguyrc8lj20m814rafc.jpg"}, + ] + + def _build_game_action_item(self, name, url, pic=''): + config = {'actionId': 'OPEN_URL', 'type': 'browser', 'title': name, 'url': url} + if not pic: + pic = f"https://picsum.photos/seed/{random.randint(1, 999)}/200/300" + return { + 'vod_id': json.dumps(config, ensure_ascii=False), + 'vod_name': name, + 'vod_pic': pic, + 'vod_remarks': '点击开始游戏', + 'vod_tag': 'action', + 'style': {'type': 'grid', 'ratio': 0.75} + } + + def _request(self, url, headers=None, timeout=10): + import urllib.request + if headers is None: + headers = self.default_headers + try: + req = urllib.request.Request(url, headers=headers) + with urllib.request.urlopen(req, timeout=timeout) as resp: + return resp.read().decode('utf-8', errors='ignore') + except Exception as e: + print(f"游戏大厅请求失败: {url}, {e}") + return '' + + def _parse_game_list(self, html, pg): + pattern = r'
.*?]*src="([^"]+)".*?

]*>([^<]+)' + matches = re.findall(pattern, html, re.DOTALL) + videos = [] + for pic, path, name in matches: + game_url = path if path.startswith('http') else self.host + path + videos.append(self._build_game_action_item(name.strip(), game_url, pic)) + if not videos: + pattern2 = r'href="([^"]+)"[^>]*>([^<]+).*?src="([^"]+)"' + matches2 = re.findall(pattern2, html, re.DOTALL) + for path, name, pic in matches2: + if '/nes' in path or '/play' in path: + game_url = path if path.startswith('http') else self.host + path + videos.append(self._build_game_action_item(name.strip(), game_url, pic)) + has_next_page = len(videos) >= 20 + return { + 'list': videos, + 'page': pg, + 'pagecount': pg + 1 if has_next_page else pg, + 'limit': len(videos), + 'total': len(videos) + } + + def get_category_content(self, tid, pg, extend): + pg = int(pg) if pg else 1 + if tid != 'game_hall': + return None + platform = 'custom' + if extend and isinstance(extend, dict): + platform = extend.get('platform', 'custom') + if platform == 'custom': + if pg != 1: + return {'list': [], 'page': pg, 'pagecount': 1} + videos = [] + for it in self.get_game_list(): + videos.append(self._build_game_action_item(it['name'], it['url'], it['pic'])) + total = len(videos) + return { + 'list': videos, + 'page': pg, + 'pagecount': 1, + 'limit': total, + 'total': total + } + url_map = { + 'JAVA': 'net/nes?e=8&tag=', + 'fc': '/nes?tag=0&e=0&page=', + 'sfc': '/nes?tag=&e=5&page=', + 'arcade': '/nes?tag=9&e=&page=', + 'gba': '/nes?tag=&e=2&page=', + 'nds': '/nes?tag=&e=7&page=', + 'md': '/nes?tag=&e=3&page=', + 'dos': '/nes?tag=&e=6&page=', + } + if platform in url_map: + target_url = self.host + url_map[platform] + str(pg) + html = self._request(target_url, headers=self.default_headers) + if html: + return self._parse_game_list(html, pg) + return {'list': [], 'page': pg, 'pagecount': 1} + + +# ==================== 字母/数字筛选辅助方法 ==================== +def _get_chinese_pinyin_initial(chinese_char): + pinyin_map = { + '阿': 'A', '啊': 'A', '爱': 'A', '安': 'A', '按': 'A', '暗': 'A', '奥': 'A', + '八': 'B', '巴': 'B', '把': 'B', '白': 'B', '百': 'B', '班': 'B', '般': 'B', + '版': 'B', '办': 'B', '半': 'B', '帮': 'B', '包': 'B', '宝': 'B', '保': 'B', + '报': 'B', '北': 'B', '被': 'B', '本': 'B', '比': 'B', '必': 'B', '边': 'B', + '变': 'B', '便': 'B', '别': 'B', '冰': 'B', '并': 'B', '波': 'B', '伯': 'B', + '博': 'B', '不': 'B', '部': 'B', '吧': 'B', '拜': 'B', '班': 'B', '颁': 'B', + '才': 'C', '财': 'C', '采': 'C', '彩': 'C', '参': 'C', '残': 'C', '草': 'C', + '策': 'C', '层': 'C', '曾': 'C', '查': 'C', '产': 'C', '长': 'C', '常': 'C', + '场': 'C', '唱': 'C', '超': 'C', '朝': 'C', '车': 'C', '陈': 'C', '成': 'C', + '城': 'C', '程': 'C', '吃': 'C', '持': 'C', '充': 'C', '出': 'C', '初': 'C', + '除': 'C', '处': 'C', '川': 'C', '传': 'C', '船': 'C', '窗': 'C', '床': 'C', + '创': 'C', '春': 'C', '此': 'C', '从': 'C', '村': 'C', '存': 'C', '错': 'C', + '打': 'D', '大': 'D', '代': 'D', '带': 'D', '但': 'D', '当': 'D', '党': 'D', + '到': 'D', '道': 'D', '得': 'D', '的': 'D', '等': 'D', '邓': 'D', '低': 'D', + '底': 'D', '地': 'D', '第': 'D', '点': 'D', '电': 'D', '店': 'D', '定': 'D', + '东': 'D', '冬': 'D', '懂': 'D', '动': 'D', '都': 'D', '读': 'D', '度': 'D', + '段': 'D', '断': 'D', '队': 'D', '对': 'D', '多': 'D', '董': 'D', '杜': 'D', + '儿': 'E', '而': 'E', '耳': 'E', '二': 'E', '额': 'E', '俄': 'E', '恩': 'E', + '发': 'F', '法': 'F', '反': 'F', '方': 'F', '房': 'F', '放': 'F', '非': 'F', + '飞': 'F', '费': 'F', '分': 'F', '芬': 'F', '丰': 'F', '风': 'F', '冯': 'F', + '佛': 'F', '夫': 'F', '服': 'F', '福': 'F', '父': 'F', '付': 'F', '负': 'F', + '富': 'F', '范': 'F', '傅': 'F', + '改': 'G', '盖': 'G', '干': 'G', '高': 'G', '搞': 'G', '哥': 'G', '歌': 'G', + '格': 'G', '个': 'G', '给': 'G', '跟': 'G', '更': 'G', '工': 'G', '公': 'G', + '功': 'G', '共': 'G', '够': 'G', '故': 'G', '顾': 'G', '关': 'G', '观': 'G', + '管': 'G', '光': 'G', '广': 'G', '归': 'G', '规': 'G', '国': 'G', '果': 'G', + '过': 'G', '郭': 'G', '龚': 'G', + '还': 'H', '海': 'H', '含': 'H', '汉': 'H', '好': 'H', '号': 'H', '喝': 'H', + '和': 'H', '河': 'H', '何': 'H', '合': 'H', '很': 'H', '红': 'H', '后': 'H', + '胡': 'H', '花': 'H', '话': 'H', '黄': 'H', '回': 'H', '会': 'H', '活': 'H', + '火': 'H', '或': 'H', '韩': 'H', '郝': 'H', + '机': 'J', '鸡': 'J', '基': 'J', '及': 'J', '级': 'J', '即': 'J', '己': 'J', + '几': 'J', '家': 'J', '加': 'J', '佳': 'J', '假': 'J', '间': 'J', '见': 'J', + '建': 'J', '将': 'J', '江': 'J', '蒋': 'J', '交': 'J', '教': 'J', '叫': 'J', + '接': 'J', '街': 'J', '节': 'J', '结': 'J', '姐': 'J', '解': 'J', '界': 'J', + '今': 'J', '金': 'J', '尽': 'J', '进': 'J', '经': 'J', '精': 'J', '景': 'J', + '净': 'J', '九': 'J', '久': 'J', '就': 'J', '旧': 'J', '局': 'J', '举': 'J', + '句': 'J', '具': 'J', '俱': 'J', '决': 'J', '觉': 'J', '军': 'J', '开': 'J', + '看': 'K', '康': 'K', '考': 'K', '科': 'K', '可': 'K', '克': 'K', '刻': 'K', + '客': 'K', '空': 'K', '孔': 'K', '口': 'K', '哭': 'K', '苦': 'K', '快': 'K', + '块': 'K', '款': 'K', '狂': 'K', '况': 'K', '困': 'K', '扩': 'K', '柯': 'K', + '拉': 'L', '来': 'L', '蓝': 'L', '老': 'L', '乐': 'L', '雷': 'L', '类': 'L', + '冷': 'L', '离': 'L', '李': 'L', '里': 'L', '理': 'L', '力': 'L', '立': 'L', + '丽': 'L', '利': 'L', '连': 'L', '联': 'L', '脸': 'L', '两': 'L', '亮': 'L', + '林': 'L', '刘': 'L', '流': 'L', '六': 'L', '龙': 'L', '路': 'L', '旅': 'L', + '绿': 'L', '乱': 'L', '论': 'L', '罗': 'L', '梁': 'L', + '妈': 'M', '马': 'M', '吗': 'M', '买': 'M', '满': 'M', '毛': 'M', '么': 'M', + '没': 'M', '美': 'M', '妹': 'M', '门': 'M', '们': 'M', '梦': 'M', '孟': 'M', + '迷': 'M', '米': 'M', '面': 'M', '民': 'M', '名': 'M', '明': 'M', '命': 'M', + '模': 'M', '么': 'M', '没': 'M', '莫': 'M', '木': 'M', '目': 'M', '母': 'M', + '那': 'N', '男': 'N', '南': 'N', '难': 'N', '脑': 'N', '内': 'N', '能': 'N', + '你': 'N', '年': 'N', '念': 'N', '娘': 'N', '鸟': 'N', '您': 'N', '牛': 'N', + '农': 'N', '女': 'N', '暖': 'N', '倪': 'N', '聂': 'N', + '欧': 'O', '偶': 'O', + '怕': 'P', '拍': 'P', '排': 'P', '派': 'P', '潘': 'P', '盘': 'P', '旁': 'P', + '胖': 'P', '跑': 'P', '朋': 'P', '彭': 'P', '朋': 'P', '碰': 'P', '批': 'P', + '皮': 'P', '片': 'P', '票': 'P', '品': 'P', '平': 'P', '评': 'P', '凭': 'P', + '苹': 'P', '破': 'P', '普': 'P', '庞': 'P', '裴': 'P', + '七': 'Q', '期': 'Q', '其': 'Q', '奇': 'Q', '起': 'Q', '气': 'Q', '千': 'Q', + '前': 'Q', '钱': 'Q', '强': 'Q', '亲': 'Q', '青': 'Q', '清': 'Q', '情': 'Q', + '请': 'Q', '秋': 'Q', '求': 'Q', '球': 'Q', '区': 'Q', '取': 'Q', '去': 'Q', + '全': 'Q', '权': 'Q', '却': 'Q', '确': 'Q', '秦': 'Q', '邱': 'Q', '齐': 'Q', + '然': 'R', '让': 'R', '人': 'R', '日': 'R', '容': 'R', '容': 'R', '如': 'R', + '入': 'R', '软': 'R', '任': 'R', '阮': 'R', '荣': 'R', '茹': 'R', + '三': 'S', '散': 'S', '色': 'S', '山': 'S', '上': 'S', '少': 'S', '社': 'S', + '身': 'S', '深': 'S', '神': 'S', '生': 'S', '声': 'S', '师': 'S', '十': 'S', + '时': 'S', '实': 'S', '食': 'S', '史': 'S', '使': 'S', '始': 'S', '式': 'S', + '是': 'S', '事': 'S', '势': 'S', '视': 'S', '试': 'S', '室': 'S', '是': 'S', + '收': 'S', '手': 'S', '首': 'S', '受': 'S', '书': 'S', '术': 'S', '数': 'S', + '树': 'S', '双': 'S', '水': 'S', '说': 'S', '司': 'S', '思': 'S', '斯': 'S', + '四': 'S', '送': 'S', '苏': 'S', '诉': 'S', '速': 'S', '算': 'S', '虽': 'S', + '随': 'S', '岁': 'S', '孙': 'S', '他': 'S', '它': 'S', '她': 'S', '沈': 'S', '宋': 'S', + '她': 'T', '他': 'T', '它': 'T', '太': 'T', '谈': 'T', '唐': 'T', '套': 'T', + '特': 'T', '提': 'T', '题': 'T', '体': 'T', '天': 'T', '田': 'T', '条': 'T', + '铁': 'T', '听': 'T', '厅': 'T', '通': 'T', '同': 'T', '头': 'T', '投': 'T', + '透': 'T', '突': 'T', '图': 'T', '土': 'T', '团': 'T', '推': 'T', '退': 'T', + '脱': 'T', '陶': 'T', '谭': 'T', + '外': 'W', '完': 'W', '玩': 'W', '晚': 'W', '万': 'W', '王': 'W', '往': 'W', + '为': 'W', '文': 'W', '问': 'W', '我': 'W', '无': 'W', '五': 'W', '午': 'W', + '武': 'W', '物': 'W', '务': 'W', '汪': 'W', '魏': 'W', '卫': 'W', + '西': 'X', '希': 'X', '习': 'X', '系': 'X', '下': 'X', '先': 'X', '现': 'X', + '相': 'X', '想': 'X', '向': 'X', '像': 'X', '小': 'X', '校': 'X', '笑': 'X', + '些': 'X', '心': 'X', '新': 'X', '信': 'X', '兴': 'X', '星': 'X', '行': 'X', + '形': 'X', '幸': 'X', '性': 'X', '休': 'X', '秀': 'X', '须': 'X', '需': 'X', + '许': 'X', '学': 'X', '雪': 'X', '血': 'X', '寻': 'X', '询': 'X', '徐': 'X', '谢': 'X', '萧': 'X', + '呀': 'Y', '言': 'Y', '颜': 'Y', '眼': 'Y', '演': 'Y', '验': 'Y', '阳': 'Y', + '杨': 'Y', '样': 'Y', '要': 'Y', '业': 'Y', '叶': 'Y', '页': 'Y', '夜': 'Y', + '一': 'Y', '医': 'Y', '依': 'Y', '已': 'Y', '以': 'Y', '意': 'Y', '因': 'Y', + '阴': 'Y', '音': 'Y', '银': 'Y', '引': 'Y', '印': 'Y', '应': 'Y', '英': 'Y', + '影': 'Y', '映': 'Y', '永': 'Y', '用': 'Y', '由': 'Y', '有': 'Y', '又': 'Y', + '于': 'Y', '余': 'Y', '鱼': 'Y', '语': 'Y', '玉': 'Y', '遇': 'Y', '元': 'Y', + '原': 'Y', '远': 'Y', '院': 'Y', '愿': 'Y', '约': 'Y', '月': 'Y', '乐': 'Y', + '云': 'Y', '运': 'Y', '袁': 'Y', + '再': 'Z', '在': 'Z', '咱': 'Z', '早': 'Z', '怎': 'Z', '增': 'Z', '扎': 'Z', + '摘': 'Z', '张': 'Z', '长': 'Z', '赵': 'Z', '这': 'Z', '真': 'Z', '正': 'Z', + '之': 'Z', '知': 'Z', '直': 'Z', '只': 'Z', '指': 'Z', '至': 'Z', '志': 'Z', + '治': 'Z', '中': 'Z', '终': 'Z', '钟': 'Z', '种': 'Z', '周': 'Z', '洲': 'Z', + '州': 'Z', '朱': 'Z', '诸': 'Z', '主': 'Z', '住': 'Z', '抓': 'Z', '转': 'Z', + '装': 'Z', '准': 'Z', '子': 'Z', '自': 'Z', '总': 'Z', '走': 'Z', '族': 'Z', + '组': 'Z', '祖': 'Z', '最': 'Z', '昨': 'Z', '左': 'Z', '作': 'Z', '坐': 'Z', + '做': 'Z', '郑': 'Z' + } + if chinese_char in pinyin_map: + return pinyin_map[chinese_char] + if '\u4e00' <= chinese_char <= '\u9fff': + return 'Z' + return chinese_char.upper() if chinese_char.isalpha() else '#' + + +# ==================== 主爬虫类 ==================== +class Spider(Spider): + def getName(self): + return "本地资源管理" + + def init(self, extend=""): + super().init(extend) + + self.headers = { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8", + "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", + "Accept-Encoding": "gzip, deflate, br", + "Connection": "keep-alive", + "Upgrade-Insecure-Requests": "1" + } + + self.root_paths = ROOT_PATHS + self.path_to_chinese = PATH_TO_CHINESE + + self.online_live_sources = ONLINE_LIVE_SOURCES + self.live_category_id = LIVE_CATEGORY_ID + self.live_category_name = LIVE_CATEGORY_NAME + self.live_cache = {} + self.live_cache_time = {} + self.live_cache_duration = LIVE_CACHE_DURATION + + self.radio_cache = {} + self.radio_cache_time = {} + + self.common_headers_list = COMMON_HEADERS_LIST + self.domain_specific_headers = DOMAIN_SPECIFIC_HEADERS + self.successful_headers_cache = {} + + self.default_colors = [ + "#FF6B6B", "#4ECDC4", "#FFD93D", "#6BCB77", "#9D65C9", + "#FF8C42", "#A2D729", "#FF6B8B", "#45B7D1", "#96CEB4" + ] + + self.media_exts = ['mp4', 'mkv', 'avi', 'rmvb', 'mov', 'wmv', 'flv', 'm4v', 'ts', 'm3u8'] + self.audio_exts = ['mp3', 'm4a', 'aac', 'flac', 'wav', 'ogg', 'wma', 'ape', 'm4b', 'm4p', 'opus'] + self.image_exts = ['jpg', 'jpeg', 'png', 'webp', 'gif', 'bmp', 'ico', 'svg', 'heic', 'heif'] + self.list_exts = ['m3u', 'txt', 'json', 'm3u8'] + self.lrc_exts = ['lrc', 'krc', 'qrc', 'yrc', 'trc'] + self.db_exts = ['db', 'sqlite', 'sqlite3', 'db3'] + self.magnet_exts = ['magnets', 'magnet', 'bt', 'torrent', 'mgt', 'xlsx', 'xls', 'csv'] + self.code_exts = ['php', 'py', 'js', 'css', 'html', 'htm', 'xml', 'sh', 'bash'] + self.archive_exts = ['zip', 'rar', '7z', 'tar', 'gz', 'bz2', 'xz'] + self.comic_exts = ['pdf', 'epub'] + + self.common_cover_names = ['cover', 'folder', 'album', 'front', 'back', 'disc', 'cd', '封面', '专辑', '文件夹'] + + self.max_audio_per_scan = 5000 + self.audio_scan_timeout = 10 + self.enable_online_lyrics = True + self.enable_online_poster = True + self.audio_cache_duration = 3600 + + self.QQ_OFFICIAL_SEARCH = "https://c.y.qq.com/splcloud/fcgi-bin/smartbox_new.fcg" + self.QQ_OFFICIAL_LYRIC = "https://c.y.qq.com/lyric/fcgi-bin/fcg_query_lyric_new.fcg" + + self.audio_list_cache = {} + self.audio_list_cache_time = {} + self.network_lyrics_cache = {} + self.network_cover_cache = {} + self.song_info_cache = {} + + self.audio_cover_cache = {} + self.cover_loading = {} + + self.video_cache = {} + self.video_cache_time = {} + + self.dir_cache = {} + self.dir_cache_time = {} + + self.debug_mode = False + + self.priority_audio_dirs = [ + '/storage/emulated/0/Music/', + '/storage/emulated/0/Download/KuwoMusic/music/', + '/storage/emulated/0/netease/cloudmusic/Music/', + '/storage/emulated/0/qqmusic/song/', + '/storage/emulated/0/MIUI/music/', + '/storage/emulated/0/Download/', + ] + + self.live_keywords = ['cctv', '卫视', '频道', '直播', '电视台', 'iptv', 'm3u8', 'live', '咪咕', '央卫', '香港', '台湾', '澳门', '体育', '新闻', '音乐', '综合', '抖音', 'douyin', 'video'] + self.novel_keywords = ['第', '章', '节', '卷', '部', '篇', '集', '小说', '故事', '作者'] + + self.DEFAULT_AUDIO_ICON = "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI5NiIgaGVpZ2h0PSI5NiIgdmlld0JveD0iMCAwIDk2IDk2Ij48cmVjdCB3aWR0aD0iOTYiIGhlaWdodD0iOTYiIGZpbGw9IiM1NTU1NTUiLz48dGV4dCB4PSI0OCIgeT0iNjAiIGZvbnQtc2l6ZT0iNDAiIHRleHQtYW5jaG9yPSJtaWRkbGUiIGZpbGw9IndoaXRlIiBmb250LWZhbWlseT0iQXJpYWwiPsKbPC90ZXh0Pjwvc3ZnPg==" + + self.file_icons = { + 'folder': 'https://img2.baidu.com/it/u=551272933,781657751&fm=253&fmt=auto&app=138&f=PNG?w=607&h=457', + 'video': 'https://img0.baidu.com/it/u=3801173794,2209343305&fm=253&fmt=auto&app=138&f=PNG?w=500&h=500', + 'video_playlist': 'http://img1.baidu.com/it/u=3509053159,582518618&fm=253&fmt=auto&app=138&f=PNG?w=500&h=500', + 'audio': self.DEFAULT_AUDIO_ICON, + 'audio_playlist': self.DEFAULT_AUDIO_ICON, + 'image': 'https://img.icons8.com/color/96/000000/image.png', + 'image_playlist': 'https://img.icons8.com/color/96/000000/image-gallery.png', + 'list': 'https://img.icons8.com/color/96/000000/list.png', + 'lrc': 'https://t9.baidu.com/it/u=1923945905,2024042027&fm=193', + 'database': 'https://5b0988e595225.cdn.sohucs.com/images/20190606/35b66fe2e2594fc2b7a5aeaa906d00c9.jpeg', + 'magnet': 'https://photo.xunpic.cn/xt/xiaoniu/00/03/28/pic-32864-E273D26F.png', + 'novel': 'https://5b0988e595225.cdn.sohucs.com/images/20191023/f3822273b017433a90e0ce2e179ec67a.png', + 'text': 'https://t8.baidu.com/it/u=511342002,1512031195&fm=193', + 'file': 'https://img2.baidu.com/it/u=607078189,89725949&fm=253&fmt=auto&app=138&f=PNG?w=558&h=500', + 'json': 'https://pic.cr173.com/up/2016-2/2016218111636.png', + 'music_note': self.DEFAULT_AUDIO_ICON, + 'lyrics': 'https://img.icons8.com/color/96/000000/audio-file.png', + 'cd': 'https://img.icons8.com/color/96/compact-disc.png', + 'song': 'https://img.icons8.com/color/96/song.png', + 'php': 'https://i-blog.csdnimg.cn/blog_migrate/8a0c9cc3844c9496e4ab5a0b525d1ecd.png', + 'python': 'https://img.php.cn/upload/article/000/000/007/e67e7cb33996074462cde1f541c19ab1.png', + 'zip': 'https://bkimg.cdn.bcebos.com/pic/9f2f070828381f3039c1fd27ac014c086f06f060', + 'archive': 'https://img.icons8.com/color/96/archive.png', + 'rar': 'https://bkimg.cdn.bcebos.com/pic/0b46f21fbe096b6300b323f204338744eaf8acee', + 'web': 'https://gd-hbimg.huaban.com/3f3cb2cb947738ed0476579cea4ed04e5e890d483fda-nUc1pe_fw658', + } + + self.TRANSPARENT_GIF = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7' + + self.V_DIR_PREFIX = 'vdir://' + self.V_ITEM_PREFIX = 'vitem://' + self.URL_B64U_PREFIX = 'b64u://' + self.V_ALL_PREFIX = 'vall://' + self.A_ALL_PREFIX = 'aall://' + self.FOLDER_PREFIX = 'folder://' + self.LIST_PREFIX = 'list://' + self.PICS_PREFIX = 'pics://' + self.MP3_PREFIX = 'mp3://' + self.CAMERA_ALL_PREFIX = 'camall://' + self.MAGNET_PREFIX = 'magnet://' + self.LIVE_PREFIX = 'live://' + self.NOVEL_PREFIX = 'novel://' + self.TEXT_PREFIX = 'text://' + self.WEBVIEW_PREFIX = 'webview://' + + self.lrc_cache = {} + self.m3u8_cache = {} + self.db_reader = DatabaseReader() + self.poster_cache = {} + self.word_lyrics_cache = {} + self.novel_path_cache = {} + self.novel_chapters_cache = {} + self.current_novel = {'encoded_path': None, 'file_path': None, 'chapters': []} + + self.preload_executor = ThreadPoolExecutor(max_workers=5, thread_name_prefix="CoverPreload") + + self.session = requests.Session() + retries = Retry(total=2, backoff_factor=0.5) + adapter = HTTPAdapter(max_retries=retries) + self.session.mount('http://', adapter) + self.session.mount('https://', adapter) + + self._init_cache_dirs() + self._init_comic_dirs() + + # 短视频API列表 + self.short_video_apis = [ + {"name": "🎬 小姐姐1", "url": "http://av.npcq.cn/pc.php"}, + {"name": "🎬 小姐姐2", "url": "https://diskgirl.com/get/get2.php"}, + {"name": "🎬 小姐姐3", "url": "https://www.xiaolufx.net/suiji/video.php?_t="}, + {"name": "🎬 小姐姐4", "url": "https://www.cunshao.com/666666/api/web.php"}, + {"name": "🎬 小姐姐5", "url": "http://api.yujn.cn/api/zzxjj.php"}, + {"name": "🎬 小姐姐6", "url": "https://www.cunshao.com/666666/api/pc.php"}, + {"name": "🎬 小姐姐7", "url": "https://v.api.aa1.cn/api/api-dy-girl/index.php?aa1=ajdu987hrjfw"}, + {"name": "🎬小姐姐8", "url": "https://api.ksse.cn/API/sp/sjxjj2.php"}, + {"name": "🎬小姐姐9", "url": "https://api.ksse.cn/API/sp/bs.php"}, + {"name": "🎬随机慢摇视频", "url": "https://api.bi71t5.cn/api/my.php"}, + {"name": "🎬 少妇视频", "url": "http://v.nrzj.vip/video.php?_t=0.9"}, + {"name": "🎬 高质量小姐姐", "url": "http://api.tinise.cn/api/xjjsp"}, + {"name": "🎬 抖音小姐姐", "url": "http://api.qemao.com/api/douyin/"}, + {"name": "🎬 完美身材", "url": "http://api.yujn.cn/api/wmsc.php?type=video"}, + {"name": "🎬 快手变装", "url": "http://api.yujn.cn/api/ksbianzhuang.php?type=video"}, + {"name": "🎬 抖音变装", "url": "http://api.yujn.cn/api/bianzhuang.php?"}, + {"name": "🤍 白丝视频", "url": "http://api.yujn.cn/api/baisis.php?type=video"}, + {"name": "👗 美女穿搭", "url": "http://api.yujn.cn/api/chuanda.php?type=video"}, + {"name": "🎲 随机小姐姐", "url": "http://api.yujn.cn/api/xjj.php?type=video"}, + {"name": "🖤 黑丝视频", "url": "http://api.yujn.cn/api/heisis.php?type=video"}, + {"name": "🎓 女大学生", "url": "https://api.yujn.cn/api/nvda.php?type=video"}, + {"name": "👁️ 抖音瞳瞳", "url": "https://api.yujn.cn/api/tongtong.php?type=video"}, + {"name": "💃 丝滑舞蹈", "url": "http://api.yujn.cn/api/shwd.php?type=video"}, + {"name": "🏮 古风类", "url": "http://api.yujn.cn/api/hanfu.php?type=video"}, + {"name": "🎧 慢摇系列", "url": "http://api.yujn.cn/api/manyao.php?type=video"}, + {"name": "👙 吊带系列", "url": "http://api.yujn.cn/api/diaodai.php?type=video"}, + {"name": "🌸 清纯系列", "url": "http://api.yujn.cn/api/qingchun.php?type=video"}, + {"name": "🎮 COS系列", "url": "http://api.yujn.cn/api/COS.php?type=video"}, + {"name": "🎀 萝莉系列", "url": "http://api.yujn.cn/api/luoli.php?type=video"}, + {"name": "🍬 甜妹系列", "url": "http://api.yujn.cn/api/tianmei.php?type=video"}, + ] + + self.video_cover_apis = [ + "https://api.71xk.com/api/picture/v1", + "https://api.6045833.xyz/wsmeinv", + "https://api.eyabc.cn/api/picture/beauty", + "http://api.lbbb.cc/api/baisi", + "http://api.lbbb.cc/api/heisi", + ] + + self.cached_radio_ids = set() + self._load_cached_radio_ids() + + self.gallery_apis = [ + {"name": "🎨 图源B", "url": "https://api-v2.cenguigui.cn/api/meizi/", "type": "random"}, + {"name": "🎨 图源C", "url": "https://api.bi71t5.cn/api/hs.php", "type": "random"}, + {"name": "🎨 图源D", "url": "http://api.iappht.vip/api/suijixiaojiejietupian", "type": "random"}, + {"name": "🎨 图源E", "url": "https://sucyan.top/api/tupian/jk.php", "type": "random"}, + {"name": "🎨 图源F", "url": "https://a.aa.cab/mn2.api", "type": "random"}, + {"name": "🎨 图源G", "url": "https://a.aa.cab/mn.api", "type": "random"}, + {"name": "🎨 图源H", "url": "https://a.aa.cab/cos.api", "type": "random"}, + {"name": "🎨 图源I", "url": "https://api.71xk.com/api/picture/v1", "type": "random"}, + {"name": "🎨 图源J", "url": "https://api.6045833.xyz/wsbizhi", "type": "random"}, + {"name": "🎨 图源k", "url": "http://api.lbbb.cc/api/heisi?r={time}", "type": "random"}, + {"name": "🎨 图源l", "url": "https://pic.ltywl.top/mn/pe.php?r={time}", "type": "random"}, + {"name": "🎨 图源m", "url": "https://api.6045833.xyz/meinv?r={time}", "type": "random"}, + {"name": "🎨 图源n", "url": "https://pic.ltywl.top/mn/api.php?r={time}", "type": "random"}, + {"name": "👗 丝袜美女", "url": "https://api.6045833.xyz/wsmeinv", "type": "random"}, + {"name": "🎀美女图片", "url": "http://ryapi.sbs/API/beauty.php", "type": "random"}, + {"name": "🌸 唯美图片", "url": "https://api-v2.cenguigui.cn/api/meizi/", "type": "random"}, + {"name": "🖼️ 漫画图库", "url": "https://pic.ltywl.top/mn/pe.php", "type": "random"}, + {"name": "🤍 白丝系列", "url": "http://api.lbbb.cc/api/baisi", "type": "random"}, + {"name": "🖤 黑丝系列", "url": "http://api.lbbb.cc/api/heisi", "type": "random"}, + {"name": "🎇桌面壁纸", "url": "https://api.xunjinlu.fun/api/img/index.php", "type": "random"}, + {"name": "🎊二次元", "url": "https://api.suyanw.cn/api/comic3.php", "type": "random"}, + {"name": "🌁简单壁纸", "url": "https://apis.uctb.cn/api/Moments", "type": "random"}, + {"name": "🦺东篱随机壁纸", "url": "https://tu.ltyuanfang.cn/api/fengjing.php", "type": "random"}, + {"name": "🌁多多壁纸", "url": "https://yydsys.top/bg.php", "type": "random"}, + {"name": "🌅必应每日一图", "url": "https://bing.img.run/rand.php", "type": "random"}, + ] + + self.game_hall = GameHall() + self.web_action_browser = WebActionBrowser() + + self.delete_mode_enabled = False + self.delete_mode_dir = None + self._pending_return_dir = None + self._current_page = 1 + + self.select_mode_enabled = False + self.select_mode_dir = None + self.selected_items = [] + self.current_select_index = 0 + self.copy_buffer = None + self.pending_rename_path = None + self.pending_rename_is_dir = False + + self._current_filter_key = None + + self.click_count = {} + self.click_timer = {} + + self.trash_dir = '/storage/emulated/0/tmp/trash/' + os.makedirs(self.trash_dir, exist_ok=True) + self.delete_icon = "https://img.icons8.com/color/96/000000/delete-forever.png" + self.select_icon = "https://img.icons8.com/color/96/000000/checked-checkbox.png" + + def _init_cache_dirs(self): + try: + tmp_dir = '/storage/emulated/0/tmp/' + os.makedirs(tmp_dir, exist_ok=True) + os.makedirs(COVER_CACHE_DIR, exist_ok=True) + os.makedirs(LYRICS_CACHE_DIR, exist_ok=True) + os.makedirs(RADIO_COVER_CACHE_DIR, exist_ok=True) + nomedia_dirs = [tmp_dir, COVER_CACHE_DIR, LYRICS_CACHE_DIR, RADIO_COVER_CACHE_DIR, self.trash_dir] + for dir_path in nomedia_dirs: + if os.path.exists(dir_path): + nomedia_path = os.path.join(dir_path, '.nomedia') + if not os.path.exists(nomedia_path): + with open(nomedia_path, 'w') as f: + f.write('# This file prevents media scanning in this directory\n') + print(f"✅ 已创建 .nomedia: {dir_path}") + except Exception as e: + print(f"⚠️ 创建 .nomedia 失败: {e}") + + def _init_comic_dirs(self): + try: + os.makedirs(COMIC_CACHE_DIR, exist_ok=True) + os.makedirs(COMIC_PREVIEW_DIR, exist_ok=True) + for dir_path in [COMIC_CACHE_DIR, COMIC_PREVIEW_DIR]: + nomedia_path = os.path.join(dir_path, '.nomedia') + if not os.path.exists(nomedia_path): + with open(nomedia_path, 'w') as f: + f.write('# This file prevents media scanning in this directory\n') + print("✅ 漫画缓存目录初始化完成") + except Exception as e: + print(f"⚠️ 漫画缓存目录初始化失败: {e}") + + def log(self, msg): + if self.debug_mode: + print(f"🔍 [DEBUG] {msg}") + + def b64u_encode(self, data): + if isinstance(data, str): + data = data.encode('utf-8') + encoded = base64.b64encode(data).decode('ascii') + return encoded.replace('+', '-').replace('/', '_').rstrip('=') + + def b64u_decode(self, data): + data = data.replace('-', '+').replace('_', '/') + pad = len(data) % 4 + if pad: + data += '=' * (4 - pad) + try: + return base64.b64decode(data).decode('utf-8') + except: + return '' + + def e64(self, text): + return base64.b64encode(text.encode("utf-8")).decode("utf-8") + + def d64(self, text): + return base64.b64decode(text.encode("utf-8")).decode("utf-8") + + def get_file_ext(self, filename): + idx = filename.rfind('.') + if idx == -1: + return '' + return filename[idx + 1:].lower() + + def is_media_file(self, ext): + return ext in self.media_exts + + def is_audio_file(self, ext): + return ext in self.audio_exts + + def is_image_file(self, ext): + return ext in self.image_exts + + def is_comic_file(self, ext): + return ext in self.comic_exts + + def is_list_file(self, ext): + return ext in self.list_exts + + def is_lrc_file(self, ext): + return ext in self.lrc_exts + + def is_db_file(self, ext): + return ext in self.db_exts + + def is_magnet_file(self, ext): + return ext in self.magnet_exts + + def is_code_file(self, ext): + return ext in self.code_exts + + def is_archive_file(self, ext): + return ext in self.archive_exts + + # ==================== 磁力链接解析方法 ==================== + + def _parse_magnet_file(self, file_path): + items = [] + ext = self.get_file_ext(file_path) + + if ext == 'xlsx' or ext == 'xls': + items = XLSXMagnetParser.parse_magnet_from_xlsx(file_path) + if items: + self.log(f"XLSX解析到 {len(items)} 条磁力/电驴链接") + return items + return items + + if ext == 'txt': + # 使用增强版TXT解析器 + items = MagnetLinkParser.parse_magnet_from_txt(file_path) + if items: + return items + # 备用解析 + return self._parse_magnet_txt_fallback(file_path) + + # 其他格式(无扩展名或自定义) + try: + with open(file_path, 'r', encoding='utf-8', errors='ignore') as f: + content = f.read() + + magnet_pattern = re.compile(r'magnet:\?[^\s\'"<>《》]+', re.I) + ed2k_pattern = re.compile(r'ed2k://[^\s\'"<>《》]+', re.I) + + all_magnets = list(set(magnet_pattern.findall(content))) + all_ed2ks = list(set(ed2k_pattern.findall(content))) + + read_mode = MagnetConfigManager.get_read_mode() + max_items = MagnetConfigManager.get_max_items() + + magnets = [] + ed2ks = [] + + if read_mode == MAGNET_READ_MODE_RANDOM: + if len(all_magnets) >= max_items: + magnets = random.sample(all_magnets, max_items) + else: + magnets = all_magnets[:] + random.shuffle(magnets) + remaining = max_items - len(magnets) + if len(all_ed2ks) >= remaining: + ed2ks = random.sample(all_ed2ks, remaining) + else: + ed2ks = all_ed2ks[:] + random.shuffle(ed2ks) + else: + magnets = all_magnets[:max_items] + remaining = max_items - len(magnets) + if remaining > 0: + ed2ks = all_ed2ks[:remaining] + + for magnet in magnets: + name_match = re.search(r'dn=([^&]+)', magnet) + if name_match: + name = urllib.parse.unquote(name_match.group(1)) + else: + xt_match = re.search(r'xt=urn:btih:([^&]+)', magnet) + if xt_match: + name = f"磁力_{xt_match.group(1)[:16]}" + else: + name = f"磁力链接{len(items)+1}" + name = name.replace('\n', ' ').replace('\r', ' ').strip() + name = re.sub(r'[<>:"/\\|?*]', '_', name) + if len(name) > 80: + name = name[:77] + '...' + items.append({'name': name, 'url': magnet}) + if len(items) >= max_items: + break + + remaining = max_items - len(items) + for ed2k in ed2ks[:remaining]: + name = self._extract_ed2k_name(ed2k) + if not name or len(name) < 1: + name = f"电驴链接{len(items)+1}" + name = name.replace('\n', ' ').replace('\r', ' ').strip() + if len(name) > 80: + name = name[:77] + '...' + items.append({'name': name, 'url': ed2k}) + if len(items) >= max_items: + break + + if items: + mode_display = "随机抽取" if read_mode == MAGNET_READ_MODE_RANDOM else "顺序" + self.log(f"磁力解析完成: {os.path.basename(file_path)}, 共 {len(items)} 条 (模式: {mode_display})") + except Exception as e: + self.log(f"解析磁力文件失败: {e}") + + return items + + def _parse_magnet_txt_fallback(self, file_path): + """备用TXT解析方法 - 更宽松的匹配""" + items = [] + try: + with open(file_path, 'r', encoding='utf-8', errors='ignore') as f: + content = f.read() + + # 更宽松的正则 + magnet_pattern = re.compile(r'magnet:\?[^\s\n\r\'"<>《》]+', re.I) + ed2k_pattern = re.compile(r'ed2k://[^\s\n\r\'"<>《》]+', re.I) + + all_magnets = list(set(magnet_pattern.findall(content))) + all_ed2ks = list(set(ed2k_pattern.findall(content))) + + read_mode = MagnetConfigManager.get_read_mode() + max_items = MagnetConfigManager.get_max_items() + + magnets = [] + ed2ks = [] + + if read_mode == MAGNET_READ_MODE_RANDOM: + if len(all_magnets) >= max_items: + magnets = random.sample(all_magnets, max_items) + else: + magnets = all_magnets[:] + random.shuffle(magnets) + remaining = max_items - len(magnets) + if len(all_ed2ks) >= remaining: + ed2ks = random.sample(all_ed2ks, remaining) + else: + ed2ks = all_ed2ks[:] + random.shuffle(ed2ks) + else: + magnets = all_magnets[:max_items] + remaining = max_items - len(magnets) + if remaining > 0: + ed2ks = all_ed2ks[:remaining] + + for magnet in magnets: + name_match = re.search(r'dn=([^&]+)', magnet) + if name_match: + name = urllib.parse.unquote(name_match.group(1)) + else: + xt_match = re.search(r'xt=urn:btih:([^&]+)', magnet) + if xt_match: + name = f"磁力_{xt_match.group(1)[:16]}" + else: + name = f"磁力链接{len(items)+1}" + name = name.replace('\n', ' ').replace('\r', ' ').strip() + name = re.sub(r'[<>:"/\\|?*]', '_', name) + if len(name) > 80: + name = name[:77] + '...' + items.append({'name': name, 'url': magnet}) + if len(items) >= max_items: + break + + remaining = max_items - len(items) + for ed2k in ed2ks[:remaining]: + name = self._extract_ed2k_name(ed2k) + if not name or len(name) < 1: + name = f"电驴链接{len(items)+1}" + name = name.replace('\n', ' ').replace('\r', ' ').strip() + if len(name) > 80: + name = name[:77] + '...' + items.append({'name': name, 'url': ed2k}) + if len(items) >= max_items: + break + + if items: + mode_display = "随机抽取" if read_mode == MAGNET_READ_MODE_RANDOM else "顺序" + print(f"✅ 备用解析完成: {os.path.basename(file_path)}, 共 {len(items)} 条 (模式: {mode_display})") + except Exception as e: + print(f"❌ 备用解析失败: {e}") + return items + + def _extract_ed2k_name(self, ed2k): + """提取ed2k链接的文件名""" + name = None + file_match = re.search(r'\|file\|([^|]+)\|', ed2k, re.I) + if file_match: + name = file_match.group(1) + else: + last_slash = ed2k.rfind('/') + if last_slash != -1: + temp = ed2k[last_slash + 1:] + pipe_pos = temp.find('|') + if pipe_pos != -1: + name = temp[:pipe_pos] + else: + name = temp + if name: + name = name.replace('\n', ' ').replace('\r', ' ').strip() + name = urllib.parse.unquote(name) + name = re.sub(r'[<>:"/\\|?*]', '_', name) + if len(name) > 80: + name = name[:77] + '...' + return name + + def _is_magnet_txt_file(self, file_path): + try: + with open(file_path, 'r', encoding='utf-8', errors='ignore') as f: + preview = f.read(8192) + if re.search(r'magnet:\?', preview, re.I): + return True + if re.search(r'ed2k://', preview, re.I): + return True + return False + except: + return False + + # ==================== 磁力详情处理方法 ==================== + + def _handle_magnet_detail(self, file_path, vod_id): + if not os.path.exists(file_path) or not os.path.isfile(file_path): + return {'list': []} + + items = self._parse_magnet_file(file_path) + + if items: + play_urls = [] + for item in items: + url = item.get('url', '') + if url and (url.startswith('magnet:') or url.startswith('ed2k://')): + name = item.get('name', '') + if not name: + name = f"链接{len(play_urls)+1}" + name = name.replace('$', ' ').replace('#', ' ').strip() + play_urls.append(f"{name}${url}") + + if play_urls: + if len(play_urls) > MAX_PLAY_URLS: + self.log(f"⚠️ 播放源过多 ({len(play_urls)}条),只显示前 {MAX_PLAY_URLS} 条") + play_urls = play_urls[:MAX_PLAY_URLS] + + return {'list': [{ + 'vod_id': vod_id, + 'vod_name': os.path.basename(file_path), + 'vod_pic': self.file_icons['magnet'], + 'vod_play_from': '磁力链接列表', + 'vod_play_url': '#'.join(play_urls), + 'vod_remarks': f'共{len(items)}条磁力/电驴链接', + 'style': {'type': 'list'} + }]} + + return {'list': [{ + 'vod_id': vod_id, + 'vod_name': os.path.basename(file_path), + 'vod_pic': self.file_icons['magnet'], + 'vod_play_from': '磁力链接', + 'vod_play_url': f"打开文件$file://{file_path}", + 'vod_remarks': '未解析到磁力/电驴链接,点击打开原始文件', + 'style': {'type': 'list'} + }]} + + # ==================== 原有方法继续 ==================== + + def _should_hide_file(self, filename, dir_path=None, audio_names=None): + name_lower = filename.lower() + name_without_ext = os.path.splitext(filename)[0] + is_image = any(name_lower.endswith(ext) for ext in ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp']) + is_lyric = any(name_lower.endswith(ext) for ext in ['.lrc', '.krc', '.qrc', '.yrc', '.trc']) + if not is_image and not is_lyric: + return False + if audio_names is not None: + for audio_name in audio_names: + if audio_name.lower() == name_without_ext.lower(): + return True + if name_without_ext.lower() in self.common_cover_names: + return True + return False + + def _get_cover_cache_dir(self): + return COVER_CACHE_DIR + + def _get_cached_cover_path(self, audio_path): + file_hash = hashlib.md5(audio_path.encode()).hexdigest() + cache_file1 = f"{COVER_CACHE_DIR}.{file_hash}.jpg" + cache_file2 = f"{COVER_CACHE_DIR}{file_hash}.jpg" + if os.path.exists(cache_file1): + return cache_file1 + if os.path.exists(cache_file2): + return cache_file2 + return None + + def _clear_cover_cache_content(self): + deleted_count = 0 + deleted_size = 0 + if os.path.exists(COVER_CACHE_DIR) and os.path.isdir(COVER_CACHE_DIR): + try: + for filename in os.listdir(COVER_CACHE_DIR): + if filename == '.nomedia': + continue + file_path = os.path.join(COVER_CACHE_DIR, filename) + if os.path.isfile(file_path): + file_size = os.path.getsize(file_path) + os.remove(file_path) + deleted_count += 1 + deleted_size += file_size + except: + pass + CoverScanRecord.clear_record() + self.audio_cover_cache.clear() + self.cover_loading.clear() + self.dir_cache.clear() + self.dir_cache_time.clear() + self.audio_list_cache.clear() + self.audio_list_cache_time.clear() + if deleted_size > 1024 * 1024: + size_str = f"{deleted_size / (1024 * 1024):.2f} MB" + elif deleted_size > 1024: + size_str = f"{deleted_size / 1024:.2f} KB" + else: + size_str = f"{deleted_size} B" + result_msg = f"✅ 已清除 {deleted_count} 个封面缓存文件\n释放空间: {size_str}\n扫描记录已重置\n\n请重新进入音乐目录刷新封面" + return { + 'list': [{ + 'vod_id': 'clear_result', + 'vod_name': result_msg, + 'vod_pic': self._generate_colored_icon("#4CAF50", "✓"), + 'vod_remarks': '清除完成', + 'style': {'type': 'list'}, + 'vod_player': '书' + }], + 'page': 1, + 'pagecount': 1, + 'limit': 1, + 'total': 1 + } + + def _clear_lyrics_cache_content(self): + deleted_count, deleted_size = LyricsCacheManager.delete_lyrics_cache() + self.network_lyrics_cache.clear() + self.lrc_cache.clear() + if deleted_size > 1024 * 1024: + size_str = f"{deleted_size / (1024 * 1024):.2f} MB" + elif deleted_size > 1024: + size_str = f"{deleted_size / 1024:.2f} KB" + else: + size_str = f"{deleted_size} B" + result_msg = f"✅ 已清除 {deleted_count} 个歌词缓存文件\n释放空间: {size_str}\n\n下次播放歌曲时会重新下载歌词" + return { + 'list': [{ + 'vod_id': 'clear_lyrics_result', + 'vod_name': result_msg, + 'vod_pic': self._generate_colored_icon("#2196F3", "🎵"), + 'vod_remarks': '清除完成', + 'style': {'type': 'list'}, + 'vod_player': '书' + }], + 'page': 1, + 'pagecount': 1, + 'limit': 1, + 'total': 1 + } + + def _clear_radio_cover_cache_content(self): + deleted_count = 0 + deleted_size = 0 + if os.path.exists(RADIO_COVER_CACHE_DIR) and os.path.isdir(RADIO_COVER_CACHE_DIR): + try: + for filename in os.listdir(RADIO_COVER_CACHE_DIR): + if filename == '.nomedia': + continue + file_path = os.path.join(RADIO_COVER_CACHE_DIR, filename) + if os.path.isfile(file_path): + file_size = os.path.getsize(file_path) + os.remove(file_path) + deleted_count += 1 + deleted_size += file_size + except: + pass + RadioCoverRecord.clear_record() + self.cached_radio_ids.clear() + if deleted_size > 1024 * 1024: + size_str = f"{deleted_size / (1024 * 1024):.2f} MB" + elif deleted_size > 1024: + size_str = f"{deleted_size / 1024:.2f} KB" + else: + size_str = f"{deleted_size} B" + result_msg = f"✅ 已清除 {deleted_count} 个电台封面缓存文件\n释放空间: {size_str}\n\n重新进入电台列表将重新下载封面" + return { + 'list': [{ + 'vod_id': 'clear_radio_result', + 'vod_name': result_msg, + 'vod_pic': self._generate_colored_icon("#FF9800", "📻"), + 'vod_remarks': '清除完成', + 'style': {'type': 'list'}, + 'vod_player': '书' + }], + 'page': 1, + 'pagecount': 1, + 'limit': 1, + 'total': 1 + } + + def _clear_comic_cache_content(self): + total_count, size_str = ComicReader.clear_all_cache() + result_msg = f"✅ 已清除漫画缓存\n\n📚 共清除 {total_count} 个文件\n💾 释放空间: {size_str}\n\n重新打开漫画时会重新生成" + return { + 'list': [{ + 'vod_id': 'clear_comic_result', + 'vod_name': result_msg, + 'vod_pic': self._generate_colored_icon("#9C27B0", "📚"), + 'vod_remarks': '清除完成', + 'style': {'type': 'list'}, + 'vod_player': '书' + }], + 'page': 1, + 'pagecount': 1, + 'limit': 1, + 'total': 1 + } + + def _get_radio_cached_cover_path(self, radio_id): + cache_file1 = f"{RADIO_COVER_CACHE_DIR}.{radio_id}.jpg" + cache_file2 = f"{RADIO_COVER_CACHE_DIR}{radio_id}.jpg" + if os.path.exists(cache_file1): + return cache_file1 + if os.path.exists(cache_file2): + return cache_file2 + return None + + def _cache_radio_cover(self, radio_id, image_url): + if not image_url: + return None + try: + cache_file = f"{RADIO_COVER_CACHE_DIR}.{radio_id}.jpg" + if os.path.exists(cache_file): + self.cached_radio_ids.add(str(radio_id)) + return cache_file + headers = { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", + "Referer": "http://www.qingting.fm/" + } + response = self.session.get(image_url, headers=headers, timeout=15) + if response.status_code == 200: + img_data = response.content + if len(img_data) > 500 * 1024: + img_data = UltraFastCoverExtractor._compress_image(img_data, max_size=(300, 300), quality=65) + with open(cache_file, 'wb') as f: + f.write(img_data) + self.cached_radio_ids.add(str(radio_id)) + RadioCoverRecord.mark_cached(radio_id) + return cache_file + return None + except: + return None + + def _load_cached_radio_ids(self): + try: + if os.path.exists(RADIO_COVER_CACHE_DIR): + for filename in os.listdir(RADIO_COVER_CACHE_DIR): + if filename.endswith('.jpg') and not filename == '.nomedia': + radio_id = filename.replace('.', '').replace('jpg', '') + if radio_id and radio_id.isdigit(): + self.cached_radio_ids.add(radio_id) + except: + pass + + def _refresh_cached_radio_ids(self): + self.cached_radio_ids.clear() + self._load_cached_radio_ids() + + def find_local_cover_image(self, audio_path): + audio_dir = os.path.dirname(audio_path) + audio_name = os.path.splitext(os.path.basename(audio_path))[0] + exact_names = [ + f"{audio_name}.jpg", f"{audio_name}.jpeg", f"{audio_name}.png", + f"{audio_name}.webp", f"{audio_name}.gif", + f"{audio_name}.JPG", f"{audio_name}.JPEG", f"{audio_name}.PNG" + ] + for cover_name in exact_names: + cover_path = os.path.join(audio_dir, cover_name) + if os.path.exists(cover_path) and os.path.isfile(cover_path): + return cover_path + return None + + def cache_cover_image(self, audio_path, source_path): + try: + file_hash = hashlib.md5(audio_path.encode()).hexdigest() + cache_file = f"{COVER_CACHE_DIR}.{file_hash}.jpg" + if os.path.exists(cache_file): + return cache_file + with open(source_path, 'rb') as f: + img_data = f.read() + if len(img_data) > 500 * 1024: + img_data = UltraFastCoverExtractor._compress_image(img_data, max_size=(300, 300), quality=65) + with open(cache_file, 'wb') as f: + f.write(img_data) + return cache_file + except: + return None + + def get_audio_cover_ultra_fast(self, file_path): + ext = self.get_file_ext(file_path) + cached_path = self._get_cached_cover_path(file_path) + if cached_path: + return f"file://{cached_path}" + if CoverScanRecord.is_cover_cached(file_path): + return self.DEFAULT_AUDIO_ICON + cover_path = self.find_local_cover_image(file_path) + if cover_path: + cached = self.cache_cover_image(file_path, cover_path) + if cached: + CoverScanRecord.mark_cover_cached(file_path) + return f"file://{cached}" + else: + CoverScanRecord.mark_cover_cached(file_path) + return f"file://{cover_path}" + try: + if ext == 'mp3': + cover_url = UltraFastCoverExtractor.extract_mp3_cover(file_path) + elif ext == 'flac': + cover_url = UltraFastCoverExtractor.extract_flac_cover(file_path) + elif ext in ['m4a', 'mp4', 'm4b', 'm4p', 'aac']: + cover_url = UltraFastCoverExtractor.extract_m4a_cover(file_path) + else: + cover_url = None + if cover_url and cover_url.startswith('data:image'): + match = re.match(r'data:image/(\w+);base64,(.+)', cover_url) + if match: + img_base64 = match.group(2) + img_data = base64.b64decode(img_base64) + if len(img_data) > 500 * 1024: + img_data = UltraFastCoverExtractor._compress_image(img_data, max_size=(300, 300), quality=65) + file_hash = hashlib.md5(file_path.encode()).hexdigest() + cache_file = f"{COVER_CACHE_DIR}.{file_hash}.jpg" + with open(cache_file, 'wb') as f: + f.write(img_data) + cover_url = f"file://{cache_file}" + CoverScanRecord.mark_cover_cached(file_path) + return cover_url + if cover_url: + CoverScanRecord.mark_cover_cached(file_path) + else: + CoverScanRecord.mark_cover_cached(file_path) + except: + CoverScanRecord.mark_cover_cached(file_path) + return cover_url or self.DEFAULT_AUDIO_ICON + + def preload_covers_batch(self, file_paths, max_count=500): + if not file_paths: + return + uncached_paths = [] + for file_path in file_paths: + if not CoverScanRecord.is_cover_cached(file_path): + uncached_paths.append(file_path) + if not uncached_paths: + return + uncached_paths = uncached_paths[:max_count] + def load_single(file_path): + self.get_audio_cover_ultra_fast(file_path) + for path in uncached_paths: + self.preload_executor.submit(load_single, path) + + def collect_audios_in_dir(self, dir_path): + try: + if not os.path.exists(dir_path) or not os.path.isdir(dir_path): + return [] + current_time = time.time() + if dir_path in self.audio_list_cache: + cache_time = self.audio_list_cache_time.get(dir_path, 0) + if current_time - cache_time < self.audio_cache_duration: + return self.audio_list_cache[dir_path] + audios = [] + start_time = current_time + try: + for name in os.listdir(dir_path): + if time.time() - start_time > self.audio_scan_timeout: + break + if name.startswith('.'): + continue + full_path = os.path.join(dir_path, name) + if os.path.isdir(full_path): + continue + ext = self.get_file_ext(name) + if ext in self.audio_exts: + try: + if os.access(full_path, os.R_OK): + audios.append({ + 'name': name, + 'path': full_path, + 'ext': ext, + 'mtime': os.path.getmtime(full_path) + }) + except: + pass + if len(audios) >= self.max_audio_per_scan: + break + except: + pass + audios.sort(key=lambda x: x['name']) + self.audio_list_cache[dir_path] = audios + self.audio_list_cache_time[dir_path] = current_time + return audios + except: + return [] + + def extract_song_info(self, filename): + name = os.path.splitext(filename)[0] + name = re.sub(r'^\d+\.\s*', '', name) + patterns_to_remove = [ + r'【.*?】', r'\[.*?\]', r'\{.*?\}', r'(.*?)', + r'-\s*(?:320k|128k|192k|HQ|SQ|无损|高品质|高音质)', + r'-\s*(?:Live|现场版|演唱会|歌词版|伴奏版)', + r'\s*\(feat\..*?\)', r'\s*\(Feat\..*?\)', + r'\s*ft\..*$', r'\s*Ft\..*$', + r'-\d{8,}-\d+$', + r'-\d+$', + ] + for pattern in patterns_to_remove: + name = re.sub(pattern, '', name, flags=re.IGNORECASE) + name = re.sub(r'\s+', ' ', name).strip() + artist = "" + song = name + for sep in [' - ', '-', '–', '—', ':', ':']: + if sep in name: + parts = name.split(sep, 1) + left = parts[0].strip() + right = parts[1].strip() + if len(left) < 30 and len(right) > 2: + artist, song = left, right + break + elif len(right) < 30 and len(left) > 2: + artist, song = right, left + break + if not artist and ' - ' in name: + parts = name.split(' - ', 1) + song, artist = parts[0].strip(), parts[1].strip() + if not artist: + feat_match = re.search(r'(.+?)\s*\(feat\.\s*(.+?)\)', name, re.IGNORECASE) + if feat_match: + song = feat_match.group(1).strip() + artist = feat_match.group(2).strip() + song = re.sub(r'[《》〈〉『』〔〕]', '', song).strip() + artist = re.sub(r'热门歌曲.*$', '', artist).strip() + artist = re.sub(r':.*$', '', artist).strip() + song = re.sub(r'-\d{8,}-\d+$', '', song) + song = re.sub(r'-\d+$', '', song) + if len(artist) > 30 and len(song) < 20: + common_artists = [ + 'G.E.M.', '邓紫棋', '周杰伦', '林俊杰', '陈奕迅', '蔡依林', '张惠妹', + '王菲', '那英', '孙燕姿', '梁静茹', '洋澜一', '海来阿木', '程响' + ] + for ca in common_artists: + if ca in artist: + song = artist.replace(ca, '').strip() + artist = ca + break + return artist, song + + def search_qq_song(self, song_name, artist_name=""): + song_name = re.sub(r'-\d{8,}-\d+$', '', song_name) + song_name = re.sub(r'-\d+$', '', song_name) + song_name = song_name.strip() + cache_key = f"qq_search_{song_name}_{artist_name}" + if cache_key in self.song_info_cache: + cache_time = self.song_info_cache.get(cache_key + "_time", 0) + if time.time() - cache_time < 3600: + return self.song_info_cache[cache_key] + try: + if artist_name and artist_name not in song_name: + keyword = f"{song_name} {artist_name}" + else: + keyword = song_name + keyword = keyword.strip() + url = f"{self.QQ_OFFICIAL_SEARCH}?is_xml=0&format=json&key={urllib.parse.quote(keyword)}" + headers = { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", + "Referer": "https://y.qq.com/", + "Accept": "application/json" + } + resp = self.session.get(url, headers=headers, timeout=10) + if resp.status_code == 200: + data = resp.json() + songs = data.get('data', {}).get('song', {}).get('itemlist', []) + if songs: + best_match = None + for song in songs: + song_artist = song.get('singer', '') + song_name_api = song.get('name', '') + if artist_name and artist_name in song_artist: + best_match = song + break + if song_name.lower() in song_name_api.lower(): + if not best_match: + best_match = song + if not best_match: + best_match = songs[0] + song_info = { + 'name': best_match.get('name', ''), + 'singer': best_match.get('singer', ''), + 'mid': best_match.get('mid', ''), + 'album': best_match.get('album', {}).get('name', '') if isinstance(best_match.get('album'), dict) else best_match.get('album', ''), + 'interval': best_match.get('interval', 0) + } + if song_info['mid']: + self.song_info_cache[cache_key] = song_info + self.song_info_cache[cache_key + "_time"] = time.time() + return song_info + except: + pass + return None + + def get_qq_song_lyrics(self, song_mid): + cache_key = f"qq_lyrics_{song_mid}" + if cache_key in self.network_lyrics_cache: + cache_time = self.network_lyrics_cache.get(cache_key + "_time", 0) + if time.time() - cache_time < 86400: + return self.network_lyrics_cache[cache_key] + try: + url = f"{self.QQ_OFFICIAL_LYRIC}?format=json&nobase64=0&songmid={song_mid}" + headers = { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", + "Referer": "https://y.qq.com/", + "Origin": "https://y.qq.com" + } + resp = self.session.get(url, headers=headers, timeout=10) + if resp.status_code == 200: + data = resp.json() + if data.get('retcode') == 0: + lyrics = data.get('lyric', '') + if lyrics: + lyrics = base64.b64decode(lyrics).decode('utf-8') + if lyrics and len(lyrics) > 50: + self.network_lyrics_cache[cache_key] = lyrics + self.network_lyrics_cache[cache_key + "_time"] = time.time() + return lyrics + except: + pass + return None + + def _get_local_lyrics(self, file_path): + audio_dir = os.path.dirname(file_path) + audio_name = os.path.splitext(os.path.basename(file_path))[0] + artist, song = self.extract_song_info(audio_name) + cached_lyrics = LyricsCacheManager.load_lyrics(song, artist) + if cached_lyrics: + return cached_lyrics + for lrc_ext in self.lrc_exts: + test_path = os.path.join(audio_dir, f"{audio_name}.{lrc_ext}") + if os.path.exists(test_path): + try: + with open(test_path, 'r', encoding='utf-8', errors='ignore') as f: + content = f.read() + if content and len(content) > 20: + return content + except: + pass + test_path = os.path.join(audio_dir, f"{audio_name}.{lrc_ext.upper()}") + if os.path.exists(test_path): + try: + with open(test_path, 'r', encoding='utf-8', errors='ignore') as f: + content = f.read() + if content and len(content) > 20: + return content + except: + pass + for subdir in ['Lyrics', 'lyrics', '歌词', 'LRC', 'lrc']: + lyrics_dir = os.path.join(audio_dir, subdir) + if os.path.exists(lyrics_dir) and os.path.isdir(lyrics_dir): + for name in os.listdir(lyrics_dir): + if name.startswith('.'): + continue + ext = self.get_file_ext(name) + if ext in self.lrc_exts: + lrc_name = os.path.splitext(name)[0] + if lrc_name == audio_name or lrc_name.lower() == audio_name.lower(): + full_path = os.path.join(lyrics_dir, name) + try: + with open(full_path, 'r', encoding='utf-8', errors='ignore') as f: + content = f.read() + if content and len(content) > 20: + return content + except: + pass + return None + + def _add_audio_info_fast(self, result, file_path): + filename = os.path.basename(file_path) + artist, song = self.extract_song_info(filename) + result["title"] = song or filename + result["artist"] = artist or "" + cover_url = self.get_audio_cover_ultra_fast(file_path) + if cover_url and cover_url != self.DEFAULT_AUDIO_ICON: + result["vod_pic"] = cover_url + lyrics = self._get_local_lyrics(file_path) + if lyrics: + result["lrc"] = lyrics + else: + if song and self.enable_online_lyrics: + try: + if LyricsCacheManager.is_lyrics_cached(song, artist): + cached_lyrics = LyricsCacheManager.load_lyrics(song, artist) + if cached_lyrics: + result["lrc"] = cached_lyrics + else: + song_info = self.search_qq_song(song, artist) + if song_info and song_info.get('mid'): + qq_lyrics = self.get_qq_song_lyrics(song_info['mid']) + if qq_lyrics: + result["lrc"] = qq_lyrics + LyricsCacheManager.save_lyrics(song, artist, qq_lyrics) + except: + pass + + def scan_directory(self, dir_path): + try: + if not os.path.exists(dir_path) or not os.path.isdir(dir_path): + return [] + audio_names = [] + all_items = [] + for name in os.listdir(dir_path): + if name.startswith('.') or name in ['.', '..']: + continue + full_path = os.path.join(dir_path, name) + is_dir = os.path.isdir(full_path) + ext = self.get_file_ext(name) + all_items.append({ + 'name': name, + 'path': full_path, + 'is_dir': is_dir, + 'ext': ext, + 'mtime': os.path.getmtime(full_path) if not is_dir else 0, + }) + if not is_dir and ext in self.audio_exts: + audio_names.append(os.path.splitext(name)[0]) + files = [] + for item in all_items: + name = item['name'] + is_dir = item['is_dir'] + if is_dir: + files.append(item) + continue + if self._should_hide_file(name, dir_path, audio_names): + continue + files.append(item) + files.sort(key=lambda x: (not x['is_dir'], x['name'].lower())) + return files + except: + return [] + + def _scan_directory_simple(self, dir_path): + try: + if not os.path.exists(dir_path) or not os.path.isdir(dir_path): + return [] + files = [] + for name in os.listdir(dir_path): + if name.startswith('.'): + continue + full_path = os.path.join(dir_path, name) + is_dir = os.path.isdir(full_path) + files.append({ + 'name': name, + 'path': full_path, + 'is_dir': is_dir, + 'ext': self.get_file_ext(name), + }) + files.sort(key=lambda x: (not x['is_dir'], x['name'].lower())) + return files + except: + return [] + + def _extract_filter_value(self, extend): + if not extend or not isinstance(extend, dict): + return None + letter_keys = ['letter_row1', 'letter_row2', 'letter_row3', 'letter_row4'] + for key in letter_keys: + if key in extend and extend[key] and extend[key] != '全部': + return extend[key] + if 'digit_row' in extend and extend['digit_row'] and extend['digit_row'] != '全部': + return extend['digit_row'] + return None + + def _apply_filter_to_files(self, files, filter_value): + if not filter_value or filter_value == '全部' or not files: + for f in files: + f.pop('match_score', None) + return files + for f in files: + name = f['name'] + f['match_score'] = 0 + if not name: + continue + first_char = name[0] + if filter_value == 'all_digits': + if first_char.isdigit(): + f['match_score'] = 1 + elif filter_value.isdigit(): + if first_char.isdigit() and first_char == filter_value: + f['match_score'] = 1 + else: + if first_char.upper() == filter_value.upper(): + f['match_score'] = 1 + elif '\u4e00' <= first_char <= '\u9fff': + pinyin_initial = _get_chinese_pinyin_initial(name[0]) + if pinyin_initial == filter_value.upper(): + f['match_score'] = 1 + filtered = [f for f in files if f.get('match_score', 0) == 1] + if filtered: + filtered.sort(key=lambda x: (-x.get('match_score', 0), x['name'].lower())) + return filtered + files.sort(key=lambda x: (-x.get('match_score', 0), x['name'].lower())) + return files + + def collect_videos_in_dir(self, dir_path): + files = self.scan_directory(dir_path) + return [f for f in files if not f['is_dir'] and f['ext'] in self.media_exts] + + def collect_images_in_dir(self, dir_path): + files = self.scan_directory(dir_path) + return [f for f in files if not f['is_dir'] and f['ext'] in self.image_exts] + + def get_file_icon(self, ext, is_dir=False): + if is_dir: + return '📁' + if ext in self.media_exts: + return '🎬' + if ext in self.audio_exts: + return '🎵' + if ext in self.image_exts: + return '🖼' + if ext in self.comic_exts: + return '📚' + if ext in self.list_exts: + return '📋' + if ext in self.lrc_exts: + return '📝' + if ext in self.db_exts: + return '🗄️' + if ext in self.magnet_exts: + return '🧲' + if ext == 'php': + return '🐘' + if ext == 'py': + return '🐍' + if ext in self.archive_exts: + return '🗜️' + return '📄' + + def is_playable_url(self, url): + u = str(url).lower().strip() + if not u: + return False + protocols = [ + 'http://', 'https://', 'rtmp://', 'rtsp://', 'udp://', 'rtp://', + 'file://', 'pics://', 'mp3://', 'magnet:', 'ed2k://', 'thunder://', 'ftp://', + 'vod://', 'bilibili://', 'youtube://', 'rtmps://', 'rtmpt://', 'hls://', + 'http-live://', 'https-live://', 'tvbus://', 'tvbox://', 'live://', 'novel://', 'text://' + ] + if any(u.startswith(p) for p in protocols): + return True + exts = [ + '.mp4', '.mkv', '.avi', '.rmvb', '.mov', '.wmv', '.flv', + '.m3u8', '.ts', '.mp3', '.m4a', '.aac', '.flac', '.wav', + '.webm', '.ogg', '.m4v', '.f4v', '.3gp', '.mpg', '.mpeg', + '.m3u', '.pls', '.asf', '.asx', '.wmx' + ] + if any(ext in u for ext in exts): + return True + patterns = [ + 'youtu.be/', 'youtube.com/', 'bilibili.com/', 'iqiyi.com/', + 'v.qq.com/', 'youku.com/', 'tudou.com/', 'mgtv.com/', + 'sohu.com/', 'acfun.cn/', 'douyin.com/', 'kuaishou.com/', + 'huya.com/', 'douyu.com/', 'twitch.tv/', 'live.' + ] + return any(p in u for p in patterns) + + def _generate_colored_icon(self, color, text): + svg = f''' + + + {text} + ''' + return f"data:image/svg+xml;base64,{base64.b64encode(svg.encode()).decode()}" + + def parse_json_file(self, file_path): + items = [] + try: + with open(file_path, 'r', encoding='utf-8', errors='ignore') as f: + content = f.read(150 * 1024 * 1024) + if content.startswith('\ufeff'): + content = content[1:] + data = json.loads(content) + if isinstance(data, list): + item_list = data + elif isinstance(data, dict): + if 'vod_play_url' in data: + return self._handle_vod_format(data, file_path) + if 'vod_play_from' in data and 'vod_play_url' in data: + return self._handle_multi_line_vod(data, file_path) + possible_keys = ['list', 'data', 'items', 'videos', 'vod', 'results', 'rows'] + item_list = None + for key in possible_keys: + if key in data and isinstance(data[key], list): + item_list = data[key] + break + if item_list is None and all(isinstance(v, dict) for v in data.values()): + item_list = list(data.values()) + if item_list is None: + item_list = [data] + else: + return items + for idx, item in enumerate(item_list): + if not isinstance(item, dict): + if isinstance(item, str) and self.is_playable_url(item): + items.append({ + 'name': f'链接{idx+1}', + 'url': item, + 'pic': '', + 'remarks': '' + }) + continue + name = self._extract_json_field(item, ['name', 'title', 'vod_name', 'video_name', 'show_name']) + if not name: + name = f"项目{idx+1}" + url = self._extract_json_field(item, ['url', 'link', 'play_url', 'video_url', 'vod_url', 'vod_play_url']) + if not url: + play_url_raw = self._extract_json_field(item, ['vod_play_url', 'play_url']) + if play_url_raw and ('$' in play_url_raw or '#' in play_url_raw): + episodes = self._parse_multi_episodes(play_url_raw, name) + for ep in episodes: + ep_item = { + 'name': ep['name'], + 'url': ep['url'], + 'pic': self._extract_json_field(item, ['pic', 'cover', 'image', 'vod_pic'], True), + 'remarks': self._extract_json_field(item, ['remarks', 'vod_remarks', 'note']) + } + items.append(ep_item) + continue + elif play_url_raw: + url = play_url_raw + if not url or not self.is_playable_url(url): + continue + pic = self._extract_json_field(item, ['pic', 'cover', 'image', 'vod_pic'], True) + remarks = self._extract_json_field(item, ['remarks', 'vod_remarks', 'note']) + items.append({ + 'name': name, + 'url': url, + 'pic': pic, + 'remarks': remarks + }) + except: + pass + return items + + def _handle_vod_format(self, data, file_path): + items = [] + name = data.get('vod_name') or data.get('name') or os.path.splitext(os.path.basename(file_path))[0] + play_url = data.get('vod_play_url', '') + pic = data.get('vod_pic') or data.get('pic') or '' + remarks = data.get('vod_remarks', '') + if play_url and ('$' in play_url or '#' in play_url): + episodes = self._parse_multi_episodes(play_url, name) + for ep in episodes: + items.append({ + 'name': ep['name'], + 'url': ep['url'], + 'pic': pic, + 'remarks': remarks + }) + else: + items.append({ + 'name': name, + 'url': play_url, + 'pic': pic, + 'remarks': remarks + }) + return items + + def _handle_multi_line_vod(self, data, file_path): + items = [] + vod_name = data.get('vod_name') or data.get('name') or os.path.splitext(os.path.basename(file_path))[0] + vod_pic = data.get('vod_pic') or data.get('pic') or '' + play_from = data.get('vod_play_from', '') + play_url = data.get('vod_play_url', '') + from_list = play_from.split('$$$') if play_from else ['默认线路'] + url_list = play_url.split('$$$') if play_url else [''] + for from_name, url_group in zip(from_list, url_list): + if url_group and ('$' in url_group or '#' in url_group): + episodes = self._parse_multi_episodes(url_group, f"{vod_name}[{from_name}]") + for ep in episodes: + items.append({ + 'name': ep['name'], + 'url': ep['url'], + 'pic': vod_pic, + 'remarks': f'线路:{from_name}' + }) + return items + + def _parse_multi_episodes(self, play_url_raw, base_name): + episodes = [] + groups = play_url_raw.split('$$$') + for group in groups: + if not group: + continue + parts = group.split('#') + for part in parts: + if not part: + continue + if '$' in part: + ep_name, ep_url = part.split('$', 1) + episodes.append({ + 'name': ep_name.strip(), + 'url': ep_url.strip() + }) + else: + episodes.append({ + 'name': f"{base_name} - 节目{len(episodes)+1}", + 'url': part.strip() + }) + return episodes + + def _extract_json_field(self, item, field_names, is_image=False): + for field in field_names: + if field in item and item[field]: + value = item[field] + if isinstance(value, dict): + for url_field in ['url', 'src', 'path', 'file']: + if url_field in value and value[url_field]: + return str(value[url_field]) + if is_image: + for img_field in ['large', 'medium', 'small', 'thumb']: + if img_field in value and value[img_field]: + return str(value[img_field]) + return str(value) + elif isinstance(value, list) and value: + if is_image: + first = value[0] + if isinstance(first, dict): + for url_field in ['url', 'src', 'path']: + if url_field in first and first[url_field]: + return str(first[url_field]) + return str(first) + else: + return str(first) + else: + for v in value: + if v and isinstance(v, str): + return v + return str(value[0]) if value else '' + else: + return str(value).strip() + return '' + + def parse_db_file(self, file_path): + return self.db_reader.read_sqlite(file_path, MAX_DB_RESULTS) + + def _get_domain_from_url(self, url): + try: + from urllib.parse import urlparse + domain = urlparse(url).netloc + return domain.split(':')[0] if ':' in domain else domain + except: + return "" + + def _fetch_with_auto_headers(self, url, source=None): + domain = self._get_domain_from_url(url) + if source: + custom_headers = {} + if source.get('ua'): + custom_headers['User-Agent'] = source['ua'] + if source.get('referer'): + custom_headers['Referer'] = source['referer'] + if source.get('origin'): + custom_headers['Origin'] = source['origin'] + if custom_headers: + custom_headers['Accept'] = '*/*' + custom_headers['Accept-Language'] = 'zh-CN,zh;q=0.9,en;q=0.8' + custom_headers['Connection'] = 'keep-alive' + custom_headers['Accept-Encoding'] = 'gzip, deflate' + try: + print(f"🌐 [UA生效] {source.get('name', '直播源')} 使用 UA: {custom_headers.get('User-Agent', '')[:50]}...") + resp = self.session.get(url, headers=custom_headers, timeout=15) + if resp.status_code == 200: + return resp.text + else: + print(f"⚠️ UA请求失败,状态码: {resp.status_code}") + except Exception as e: + print(f"❌ UA请求异常: {e}") + if domain in self.domain_specific_headers: + for headers_info in self.domain_specific_headers[domain]: + try: + print(f"🌐 [域名专用] {domain} 使用 {headers_info.get('name', '默认')}") + resp = self.session.get(url, headers=headers_info['headers'], timeout=15) + if resp.status_code == 200: + return resp.text + except: + continue + for headers_info in self.common_headers_list: + try: + resp = self.session.get(url, headers=headers_info['headers'], timeout=10) + if resp.status_code == 200: + print(f"🌐 [通用UA] 使用 {headers_info.get('name', '默认')} 成功") + return resp.text + except: + continue + try: + print(f"🌐 [默认UA] 使用默认浏览器UA") + resp = self.session.get(url, headers=self.headers, timeout=15) + if resp.status_code == 200: + return resp.text + except: + pass + print(f"❌ 所有UA尝试均失败: {url}") + return None + + def _get_live_programs(self, source): + source_id = source['id'] + current_time = time.time() + if source_id in self.live_cache and current_time - self.live_cache_time.get(source_id, 0) < self.live_cache_duration: + return self.live_cache[source_id] + content = self._fetch_with_auto_headers(source['url'], source) + if not content: + return [] + programs = self._parse_live_content(content, source) + if programs: + self.live_cache[source_id] = programs + self.live_cache_time[source_id] = current_time + return programs + + def _parse_live_content(self, content, source): + if source.get('type') == 'txt' or ',#genre#' in content: + return self._parse_txt_live(content) + elif content.strip().startswith(('{', '[')): + return self._parse_json_live(content) + else: + return self._parse_m3u_live(content) + + def _parse_m3u_live(self, content): + programs = [] + lines = content.split('\n') + current_name = None + for line in lines: + line = line.strip() + if not line: + continue + if line.startswith('#EXTINF:'): + name_match = re.search(r',(.+)$', line) or re.search(r'tvg-name="([^"]+)"', line) + current_name = name_match.group(1).strip() if name_match else None + elif line and not line.startswith('#') and current_name: + if self.is_playable_url(line): + programs.append({'name': current_name, 'url': line}) + current_name = None + return programs + + def _parse_txt_live(self, content): + programs = [] + lines = content.split('\n') + current_cat = None + for line in lines: + line = line.strip() + if not line or line.startswith('#'): + continue + if ',#genre#' in line: + current_cat = line.split(',')[0].strip() + continue + if ',' in line: + parts = line.split(',', 1) + name = parts[0].strip() + url = parts[1].strip() + if self.is_playable_url(url): + display_name = f"[{current_cat}] {name}" if current_cat else name + programs.append({'name': display_name, 'url': url}) + return programs + + def _parse_json_live(self, content): + programs = [] + try: + data = json.loads(content) + items = [] + if isinstance(data, dict): + for key in ['list', 'data', 'items', 'videos']: + if key in data and isinstance(data[key], list): + items = data[key] + break + if not items: + items = [data] + else: + items = data + for item in items: + if isinstance(item, dict): + name = item.get('name') or item.get('title') + url = item.get('url') or item.get('play_url') + if name and url and self.is_playable_url(url): + programs.append({'name': name, 'url': url}) + except: + pass + return programs + + def _is_txt_live_source(self, content, file_path=None): + if ',#genre#' in content.lower(): + return True + if file_path: + file_name_lower = os.path.basename(file_path).lower() + if any(kw in file_name_lower for kw in ['直播', 'live', '抖音', 'douyin', '视频', 'video']): + url_count = 0 + lines = content.split('\n')[:50] + for line in lines: + if 'http://' in line or 'https://' in line: + url_count += 1 + if url_count >= 2: + return True + url_count = 0 + lines = content.split('\n')[:50] + for line in lines: + if 'http://' in line or 'https://' in line: + url_count += 1 + if url_count >= 3: + return True + return False + + def _is_txt_novel(self, content, file_path=None): + chapter_patterns = [ + r'第[一二三四五六七八九十百千万0-9]+章', + r'第[一二三四五六七八九十百千万0-9]+节', + r'序章|楔子|尾声', + r'Chapter\s+\d+' + ] + preview = content[:5000] + chapter_count = 0 + for pattern in chapter_patterns: + matches = re.findall(pattern, preview) + chapter_count += len(matches) + if chapter_count >= 3: + return True + if file_path: + file_name_lower = os.path.basename(file_path).lower() + if any(kw in file_name_lower for kw in ['小说', 'novel', 'book', 'txt']): + url_count = len(re.findall(r'https?://', preview)) + if url_count < 5 and len(preview) > 2000: + return True + return False + + def _merge_split_urls(self, content): + lines = [line.rstrip('\r\n') for line in content.split('\n') if line.strip()] + merged_lines = [] + current_url = "" + i = 0 + while i < len(lines): + line = lines[i].strip() + if re.match(r'^\d+\s*$', line): + merged_lines.append(line) + i += 1 + continue + if line.startswith(('http://', 'https://')): + current_url = line + i += 1 + while i < len(lines): + next_line = lines[i].strip() + if re.match(r'^\d+\s*$', next_line) or next_line.startswith(('http://', 'https://')): + break + current_url += next_line + i += 1 + merged_lines.append(current_url) + current_url = "" + elif re.match(r'^\d+\s*[, ]', line): + merged_lines.append(line) + i += 1 + else: + merged_lines.append(line) + i += 1 + result_lines = [] + for line in merged_lines: + if 'http://' in line or 'https://' in line: + line = re.sub(r'(https?://[^\s]*?)\s+([^\s]+)', r'\1\2', line) + line = line.replace(' ', '').replace('\t', '').replace('\r', '').replace('\n', '') + result_lines.append(line) + return '\n'.join(result_lines) + + def _parse_txt_file(self, file_path): + items = [] + try: + with open(file_path, 'r', encoding='utf-8', errors='ignore') as f: + content = f.read() + content = self._merge_split_urls(content) + is_live = self._is_txt_live_source(content, file_path) + if not is_live and self._is_txt_novel(content, file_path): + return [] + lines = [] + for line in content.split('\n'): + line = line.strip() + if not line: + continue + lines.append(line) + current_cat = None + for line in lines: + if ',#genre#' in line: + current_cat = line.split(',')[0].strip() + continue + if line.startswith(('http://', 'https://')): + if self.is_playable_url(line): + name = current_cat if current_cat else f"视频{len(items)+1}" + items.append({'name': name, 'url': line}) + continue + if re.match(r'^\d+\s*[, ]', line): + url_match = re.search(r'[, ](https?://.+)', line) + if url_match: + url = url_match.group(1).strip() + url = re.sub(r'\s+', '', url) + name_match = re.match(r'^(\d+)\s*[, ]', line) + name = f"视频{name_match.group(1)}" if name_match else "视频" + if self.is_playable_url(url): + display_name = f"[{current_cat}] {name}" if current_cat else name + items.append({'name': display_name, 'url': url}) + continue + if ',' in line: + parts = line.split(',', 1) + name = parts[0].strip() + url = parts[1].strip() + url = re.sub(r'\s+', '', url) + if self.is_playable_url(url): + display_name = f"[{current_cat}] {name}" if current_cat else name + items.append({'name': display_name, 'url': url}) + seen = set() + unique_items = [] + for item in items: + key = f"{item['name']}|{item['url']}" + if key not in seen: + seen.add(key) + unique_items.append(item) + return unique_items + except: + return [] + + def _build_game_action_item(self, name, url, pic=''): + return self.game_hall._build_game_action_item(name, url, pic) + + def _fetch_game_html(self, url): + return self.game_hall._request(url, headers=self.game_hall.default_headers) + + def _parse_game_list_with_pagination(self, html, pg): + return self.game_hall._parse_game_list(html, pg) + + def homeContent(self, filter): + classes = [] + for i, path in enumerate(self.root_paths): + if os.path.exists(path): + name = self.path_to_chinese.get(path, os.path.basename(path.rstrip('/')) or f'目录{i}') + classes.append({"type_id": f"root_{i}", "type_name": name}) + classes.append({"type_id": "recent", "type_name": "最近添加"}) + classes.append({"type_id": self.live_category_id, "type_name": self.live_category_name}) + classes.append({"type_id": "online_radio", "type_name": "📻 网络电台"}) + classes.append({"type_id": "short_video", "type_name": "📱 短视频"}) + classes.append({"type_id": "gallery", "type_name": "🎨 画廊"}) + classes.append({"type_id": "game_hall", "type_name": "🎮 游戏大厅"}) + + web_home = self.web_action_browser.get_home_content() + for cls in web_home['class']: + classes.append(cls) + + alphabet_row1 = ['全部', 'A', 'B', 'C', 'D', 'E', 'F'] + alphabet_row2 = ['G', 'H', 'I', 'J', 'K', 'L', 'M'] + alphabet_row3 = ['N', 'O', 'P', 'Q', 'R', 'S', 'T'] + alphabet_row4 = ['U', 'V', 'W', 'X', 'Y', 'Z'] + + digit_row = [ + {"n": "🔢 全部数字", "v": "all_digits"}, + {"n": "0️⃣ 0", "v": "0"}, + {"n": "1️⃣ 1", "v": "1"}, + {"n": "2️⃣ 2", "v": "2"}, + {"n": "3️⃣ 3", "v": "3"}, + {"n": "4️⃣ 4", "v": "4"}, + {"n": "5️⃣ 5", "v": "5"}, + {"n": "6️⃣ 6", "v": "6"}, + {"n": "7️⃣ 7", "v": "7"}, + {"n": "8️⃣ 8", "v": "8"}, + {"n": "9️⃣ 9", "v": "9"} + ] + + filters = { + "online_radio": [ + { + "key": "category", + "name": "📻 电台分类", + "value": [ + {"n": "🎵 音乐电台", "v": "442"}, + {"n": "🚗 交通电台", "v": "429"}, + {"n": "📻 江苏电台", "v": "85"}, + {"n": "📻 广东电台", "v": "217"}, + {"n": "📻 浙江电台", "v": "99"}, + {"n": "📻 北京电台", "v": "3"}, + {"n": "📻 天津电台", "v": "5"}, + {"n": "📻 河北电台", "v": "7"}, + {"n": "📻 上海电台", "v": "83"}, + {"n": "📻 山西电台", "v": "19"}, + {"n": "📻 内蒙古电台", "v": "31"}, + {"n": "📻 辽宁电台", "v": "44"}, + {"n": "📻 吉林电台", "v": "59"}, + {"n": "📻 黑龙江电台", "v": "69"}, + {"n": "📻 安徽电台", "v": "111"}, + {"n": "📻 福建电台", "v": "129"}, + {"n": "📻 江西电台", "v": "139"}, + {"n": "📻 山东电台", "v": "151"}, + {"n": "📻 河南电台", "v": "169"}, + {"n": "📻 湖北电台", "v": "187"}, + {"n": "📻 湖南电台", "v": "202"}, + {"n": "📻 广西电台", "v": "239"}, + {"n": "📻 海南电台", "v": "254"}, + {"n": "📻 重庆电台", "v": "257"}, + {"n": "📻 四川电台", "v": "259"}, + {"n": "📻 贵州电台", "v": "281"}, + {"n": "📻 云南电台", "v": "291"}, + {"n": "📻 陕西电台", "v": "316"}, + {"n": "📻 甘肃电台", "v": "327"}, + {"n": "📻 宁夏电台", "v": "351"}, + {"n": "📻 新疆电台", "v": "357"}, + {"n": "📻 西藏电台", "v": "308"}, + {"n": "📻 青海电台", "v": "342"}, + {"n": "🎤 资讯电台", "v": "433"}, + {"n": "💰 经济电台", "v": "439"}, + {"n": "🎭 文艺电台", "v": "432"}, + {"n": "🏙️ 都市电台", "v": "441"}, + {"n": "⚽ 体育电台", "v": "430"}, + {"n": "🌐 双语电台", "v": "431"}, + {"n": "📰 综合电台", "v": "440"}, + {"n": "🏠 生活电台", "v": "438"}, + {"n": "✈️ 旅游电台", "v": "435"}, + {"n": "🎪 曲艺电台", "v": "436"}, + {"n": "🗣️ 方言电台", "v": "434"} + ] + } + ], + "game_hall": [ + { + "key": "platform", + "name": "🎮 游戏平台", + "value": [ + {"n": "🎯推荐游戏", "v": "custom"}, + {"n": "🕹FC游戏", "v": "fc"}, + {"n": "🕹SFC游戏", "v": "sfc"}, + {"n": "🕹街机游戏", "v": "arcade"}, + {"n": "🕹GBA游戏", "v": "gba"}, + {"n": "🕹NDS游戏", "v": "nds"}, + {"n": "🕹MD游戏", "v": "md"}, + {"n": "🕹DOS游戏", "v": "dos"}, + ] + } + ] + } + + for i in range(len(self.root_paths)): + root_key = f"root_{i}" + filters[root_key] = [ + { + "key": "select_mode", + "name": "✅ 选择模式", + "value": [ + {"n": "🟢 开启选择", "v": "on"}, + {"n": "🔴 关闭选择", "v": "off"}, + {"n": "⬆️ 上", "v": "up"}, + {"n": "⬇️ 下", "v": "down"}, + {"n": "☑️ 选中", "v": "select_current"}, + {"n": "❌ 取消", "v": "clear_current"}, + {"n": "📋 复制", "v": "copy"}, + {"n": "✂️ 剪切", "v": "move"}, + {"n": "📌 粘贴", "v": "paste"}, + {"n": "🔄 选择全部", "v": "select_all"}, + {"n": "✏️ 重命名", "v": "rename"} + ] + }, + { + "key": "manage_mode", + "name": "🗑️ 删除/缓存管理", + "value": [ + {"n": "🟢 开启删除", "v": "delete_on"}, + {"n": "🔴 关闭删除", "v": "delete_off"}, + {"n": "🗑️ 清空回收站", "v": "empty"}, + {"n": "📚 清除漫画缓存", "v": "clear_comic_cache"}, + {"n": "🖼️ 清除封面缓存", "v": "clear_cover_cache"}, + {"n": "📝 清除歌词缓存", "v": "clear_lyrics_cache"}, + {"n": "📻 清除电台封面", "v": "clear_radio_cover_cache"} + ] + }, + { + "key": "letter_row1", + "name": "🔤 字母筛选 A-G", + "value": [{"n": l, "v": l} for l in alphabet_row1] + }, + { + "key": "letter_row2", + "name": "🔤 字母筛选 H-N", + "value": [{"n": l, "v": l} for l in alphabet_row2] + }, + { + "key": "letter_row3", + "name": "🔤 字母筛选 O-T", + "value": [{"n": l, "v": l} for l in alphabet_row3] + }, + { + "key": "letter_row4", + "name": "🔤 字母筛选 U-Z", + "value": [{"n": l, "v": l} for l in alphabet_row4] + }, + { + "key": "digit_row", + "name": "🔢 数字筛选", + "value": digit_row + } + ] + + return {'class': classes, 'filters': filters} + + def _online_radio_content(self, category_id, pg): + pg = int(pg) if pg else 1 + radios = self._get_radios_by_category(category_id) + if not radios: + return {'list': [], 'page': pg, 'pagecount': 1} + vlist = [] + for radio in radios: + radio_id = str(radio['id']) + radio_name = radio['name'] + if radio_id in self.cached_radio_ids: + pic = f"file://{RADIO_COVER_CACHE_DIR}.{radio_id}.jpg" + else: + pic = radio.get('pic', '') + if pic and not RadioCoverRecord.is_cached(radio_id): + self.preload_executor.submit(self._cache_radio_cover, radio_id, pic) + remarks = radio.get('desc', '蜻蜓FM') + vlist.append({ + 'vod_id': radio_id, + 'vod_name': radio_name, + 'vod_pic': pic, + 'vod_remarks': remarks, + 'style': {'type': 'grid', 'ratio': 0.75}, + 'vod_player': '听' + }) + per_page = 30 + total = len(vlist) + start = (pg - 1) * per_page + end = min(start + per_page, total) + pagecount = (total + per_page - 1) // per_page if total > 0 else 1 + return { + 'list': vlist[start:end], + 'page': pg, + 'pagecount': pagecount, + 'limit': per_page, + 'total': total + } + + def _get_radios_by_category(self, category_id): + cache_key = f"radio_category_{category_id}" + current_time = time.time() + if cache_key in self.radio_cache and current_time - self.radio_cache_time.get(cache_key, 0) < 1800: + return self.radio_cache[cache_key] + file_cache_path = f"/storage/emulated/0/tmp/radio_list_{category_id}.json" + if os.path.exists(file_cache_path): + try: + mtime = os.path.getmtime(file_cache_path) + if current_time - mtime < 86400: + with open(file_cache_path, 'r', encoding='utf-8') as f: + all_radios = json.load(f) + self.radio_cache[cache_key] = all_radios + self.radio_cache_time[cache_key] = current_time + return all_radios + except: + pass + all_radios = [] + page = 1 + while True: + url = f"http://www.qingting.fm/radiopage/{category_id}/{page}" + try: + headers = { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", + "Referer": "http://www.qingting.fm/", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8" + } + response = self.session.get(url, headers=headers, timeout=15) + response.encoding = 'utf-8' + html = response.text + if '
]*href="/radios/(\d+)"[^>]*>.*?]*src="([^"]+)"[^>]*>.*?]*>([^<]+)' + matches = re.findall(pattern, html, re.DOTALL) + for radio_id, pic, name in matches: + name = name.strip() + if name and len(name) > 1: + if pic: + if pic.startswith('//'): + pic = 'http:' + pic + elif not pic.startswith('http'): + pic = 'http://www.qingting.fm' + pic + pic = pic.replace('/160/', '/300/').replace('/120/', '/300/') + radios.append({ + 'id': radio_id, + 'name': name, + 'pic': pic, + 'desc': "蜻蜓FM" + }) + return radios + + def _radio_detail_content(self, radio_id): + radio_id = str(radio_id) + if radio_id in self.cached_radio_ids: + radio_pic_url = f"file://{RADIO_COVER_CACHE_DIR}.{radio_id}.jpg" + else: + cached_cover = self._get_radio_cached_cover_path(radio_id) + if cached_cover: + self.cached_radio_ids.add(radio_id) + radio_pic_url = f"file://{cached_cover}" + else: + radio_pic_url = "" + radio_name = f"电台_{radio_id}" + for cache_key in self.radio_cache: + for radio in self.radio_cache[cache_key]: + if str(radio['id']) == radio_id: + radio_name = radio['name'] + break + if radio_name != f"电台_{radio_id}": + break + program_info = RadioProgramFetcher.get_current_program(radio_id) + play_url = f"http://lhttp.qingting.fm/live/{radio_id}/64k.mp3" + encoded_play_url = self.e64(f"0@@@@{play_url}") + program_text = "" + if program_info: + program_text = f"🎙️ 正在播放: {program_info.get('current', '加载中')}" + if program_info.get('current_time'): + program_text += f" ({program_info['current_time']})" + if program_info.get('next') and program_info['next'] != '即将播出': + program_text += f"\n⏩ 下一节目: {program_info['next']}" + else: + program_text = "🎙️ 正在播出" + vod = { + "vod_id": radio_id, + "vod_name": radio_name, + "vod_pic": radio_pic_url, + "vod_actor": "蜻蜓FM", + "vod_remarks": program_text, + "vod_content": program_text, + "vod_play_from": "蜻蜓FM", + "vod_play_url": f"播放${encoded_play_url}", + "style": {"type": "list"}, + "vod_player": "听" + } + return {"list": [vod]} + + def localProxy(self, param): + url = param.get("url", "") + if not url: + return None + url = urllib.parse.unquote(url) + if url.startswith('file://'): + file_path = url[7:] + if os.path.exists(file_path) and os.path.isfile(file_path): + try: + with open(file_path, 'rb') as f: + content = f.read() + content_type = 'application/octet-stream' + if file_path.endswith('.jpg') or file_path.endswith('.jpeg'): + content_type = 'image/jpeg' + elif file_path.endswith('.png'): + content_type = 'image/png' + elif file_path.endswith('.gif'): + content_type = 'image/gif' + elif file_path.endswith('.webp'): + content_type = 'image/webp' + return [200, content_type, content, {}] + except: + return [404, "text/plain", b"File not found", {}] + return [404, "text/plain", b"File not found", {}] + if param.get("type") == "img": + try: + response = self.session.get(url, headers={ + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", + "Referer": "http://www.qingting.fm/" + }, timeout=10) + if response.status_code == 200: + content_type = response.headers.get('Content-Type', 'image/jpeg') + return [200, content_type, response.content, {}] + except: + pass + return [404, "text/plain", b"Error", {}] + if url.startswith('http'): + try: + headers = { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", + "Referer": "https://www.douyin.com/", + "Accept": "video/mp4,video/*;q=0.9,*/*;q=0.8" + } + response = self.session.get(url, headers=headers, timeout=15) + if response.status_code == 200: + content_type = response.headers.get('Content-Type', 'video/mp4') + return [200, content_type, response.content, {}] + except: + pass + return None + + def _get_video_cover(self, api_name): + cover_apis = self.video_cover_apis + idx = hash(api_name) % len(cover_apis) + cover_api = cover_apis[idx] + return self._make_random_url(cover_api) + + def _make_random_url(self, api_url): + if '?' in api_url: + return f"{api_url}&_r={random.randint(1, 999999)}&_t={int(time.time())}" + else: + return f"{api_url}?_r={random.randint(1, 999999)}&_t={int(time.time())}" + + def _get_real_video_url(self, api_url): + cache_key = f"real_url_{api_url}" + if cache_key in self.video_cache: + cache_time = self.video_cache_time.get(cache_key, 0) + if time.time() - cache_time < 300: + return self.video_cache[cache_key] + try: + headers = { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8", + "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", + "Accept-Encoding": "gzip, deflate", + "Connection": "keep-alive", + "Upgrade-Insecure-Requests": "1", + "Referer": "https://www.douyin.com/" + } + resp = self.session.get(api_url, headers=headers, timeout=15, allow_redirects=True) + final_url = resp.url + if any(ext in final_url.lower() for ext in ['.mp4', '.m3u8', '.flv', '.mov']): + self.video_cache[cache_key] = final_url + self.video_cache_time[cache_key] = time.time() + return final_url + content_type = resp.headers.get('Content-Type', '') + if 'video' in content_type: + self.video_cache[cache_key] = api_url + self.video_cache_time[cache_key] = time.time() + return api_url + if resp.text: + patterns = [ + r'(https?://[^\s"\']+\.mp4[^\s"\']*)', + r'(https?://[^\s"\']+\.m3u8[^\s"\']*)', + r'(https?://[^\s"\']+\.flv[^\s"\']*)', + r'"url"\s*:\s*"([^"]+)"', + r'"video_url"\s*:\s*"([^"]+)"', + r'"play_url"\s*:\s*"([^"]+)"', + ] + for pattern in patterns: + match = re.search(pattern, resp.text, re.IGNORECASE) + if match: + video_url = match.group(1).replace('\\/', '/') + self.video_cache[cache_key] = video_url + self.video_cache_time[cache_key] = time.time() + return video_url + self.video_cache[cache_key] = api_url + self.video_cache_time[cache_key] = time.time() + return api_url + except: + return api_url + + def _get_video_list(self, api_url, count=30): + videos = [] + for i in range(count): + videos.append(self._make_random_url(api_url)) + return videos + + def _short_video_category_content(self, pg): + vlist = [] + for idx, api in enumerate(self.short_video_apis): + encoded_url = self.b64u_encode(api['url']) + cover_url = self._get_video_cover(api['name']) + vlist.append({ + 'vod_id': f"short_video_{encoded_url}", + 'vod_name': api['name'], + 'vod_pic': cover_url, + 'vod_remarks': '点击播放短视频', + 'style': {'type': 'grid', 'ratio': 0.75}, + 'vod_player': '短' + }) + return {'list': vlist, 'page': 1, 'pagecount': 1, 'limit': len(vlist), 'total': len(vlist)} + + def _short_video_detail(self, encoded_url): + try: + api_url = self.b64u_decode(encoded_url) + except: + return {'list': []} + api_name = "短视频源" + for api in self.short_video_apis: + if api['url'] == api_url: + api_name = api['name'] + break + cover_url = self._get_video_cover(api_name) + video_urls = self._get_video_list(api_url, 100) + play_urls = [] + for i, url in enumerate(video_urls): + play_urls.append(f"视频{i+1}${url}") + vod = { + 'vod_id': f"short_video_detail_{encoded_url}", + 'vod_name': f"{api_name} (100个视频)", + 'vod_pic': cover_url, + 'vod_play_from': '短视频播放', + 'vod_play_url': '#'.join(play_urls), + 'vod_remarks': f'共100个随机短视频', + 'style': {'type': 'list'}, + 'vod_player': '短' + } + return {'list': [vod]} + + def _handle_short_video_play(self, video_url): + real_url = self._get_real_video_url(video_url) + return { + "parse": 0, + "playUrl": "", + "url": real_url, + "header": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", + "Referer": "https://www.douyin.com/", + "Accept": "video/mp4,video/*;q=0.9,*/*;q=0.8" + }, + "vod_player": "短" + } + + def _live_category_content(self, pg): + vlist = [] + for idx, source in enumerate(self.online_live_sources): + encoded_id = self.b64u_encode(source['id']) + cover = source.get('cover', TV_COVER) + remarks = source.get('remarks', '直播源') + vlist.append({ + 'vod_id': self.LIVE_PREFIX + encoded_id, + 'vod_name': source['name'], + 'vod_pic': cover, + 'vod_remarks': remarks, + 'vod_tag': 'live_source', + 'style': {'type': 'grid', 'ratio': 0.75}, + 'type': 'live' + }) + return {'list': vlist, 'page': pg, 'pagecount': 1, 'limit': len(vlist), 'total': len(vlist)} + + def _live_source_detail(self, source_id): + source = next((s for s in self.online_live_sources if s['id'] == source_id), None) + if not source: + return {'list': []} + cover = source.get('cover', TV_COVER) + programs = self._get_live_programs(source) + if not programs: + return {'list': [{ + 'vod_id': self.LIVE_PREFIX + self.b64u_encode(source_id), + 'vod_name': source['name'], + 'vod_pic': cover, + 'vod_play_from': '直播源', + 'vod_play_url': '提示$无法获取直播源,请稍后重试', + 'vod_content': f"直播源: {source['url']}\n状态: 获取失败", + 'style': {'type': 'list'}, + 'type': 'live', + 'playerType': source.get('playerType', 2) + }]} + channels = {} + for p in programs: + name = p['name'] + clean_name = re.sub(r'^\[[^\]]+\]\s*', '', name) + clean_name = re.sub(r'\s*[\[\((]\s*\d+\s*[\]\))]\s*$', '', clean_name) + if clean_name not in channels: + channels[clean_name] = [] + channels[clean_name].append(p['url']) + max_lines = max(len(urls) for urls in channels.values()) + original_max_lines = max_lines + if max_lines > 1: + max_lines = 1 + ua = source.get('ua', '') + referer = source.get('referer', '') + ua_info = self.b64u_encode(json.dumps({'ua': ua, 'referer': referer})) + from_list = [] + url_list = [] + for line_idx in range(max_lines): + line_name = f"线路{line_idx + 1}" + channel_urls = [] + for channel_name, urls in channels.items(): + if line_idx < len(urls): + enhanced_url = urls[line_idx] + f"|UAINFO|{ua_info}" + channel_urls.append(f"{channel_name}${enhanced_url}") + if channel_urls: + from_list.append(line_name) + url_list.append('#'.join(channel_urls)) + if not from_list: + return {'list': [{ + 'vod_id': self.LIVE_PREFIX + self.b64u_encode(source_id), + 'vod_name': source['name'], + 'vod_pic': cover, + 'vod_play_from': '直播源', + 'vod_play_url': '提示$没有可用的线路', + 'vod_content': f"直播源: {source['url']}\n状态: 没有可用的线路", + 'style': {'type': 'list'}, + 'type': 'live', + 'playerType': source.get('playerType', 2) + }]} + current_date = time.strftime('%Y.%m.%d', time.localtime()) + total_channels = len(channels) + total_programs = sum(len(urls) for urls in channels.values()) + remarks = f'更新时间{current_date}' + if original_max_lines > 1: + remarks += f' (仅显示第1条线路)' + return {'list': [{ + 'vod_id': self.LIVE_PREFIX + self.b64u_encode(source_id), + 'vod_name': source['name'], + 'vod_pic': cover, + 'vod_play_from': '$$$'.join(from_list), + 'vod_play_url': '$$$'.join(url_list), + 'vod_remarks': remarks, + 'vod_content': f"共 {total_channels} 个频道,{total_programs} 条节目线路", + 'vod_style': {'type': 'live'}, + 'vod_type': 4, + 'vod_class': 'live', + 'type': 'live', + 'playerType': source.get('playerType', 2) + }]} + + def _recent_content(self, pg): + all_files = [] + for path in self.root_paths: + if os.path.exists(path): + self._scan_recent_files(path, all_files) + all_files.sort(key=lambda x: x['mtime'], reverse=True) + all_files = all_files[:200] + per_page = 50 + start = (pg - 1) * per_page + end = min(start + per_page, len(all_files)) + vlist = [] + for f in all_files[start:end]: + item = self._create_recent_item(f) + if item: + vlist.append(item) + return {'list': vlist, 'page': pg, 'pagecount': (len(all_files) + per_page - 1) // per_page, 'limit': per_page, 'total': len(all_files)} + + def _scan_recent_files(self, path, file_list, depth=0, max_depth=2): + if depth > max_depth: + return + try: + audio_names = [] + try: + for name in os.listdir(path): + if name.startswith('.'): + continue + full_path = os.path.join(path, name) + if not os.path.isdir(full_path): + ext = self.get_file_ext(name) + if ext in self.audio_exts: + audio_names.append(os.path.splitext(name)[0]) + except: + pass + for name in os.listdir(path): + if name.startswith('.'): + continue + full_path = os.path.join(path, name) + if os.path.isdir(full_path): + self._scan_recent_files(full_path, file_list, depth + 1, max_depth) + else: + ext = self.get_file_ext(name) + if self._should_hide_file(name, path, audio_names): + continue + if (self.is_media_file(ext) or self.is_audio_file(ext) or + self.is_image_file(ext) or self.is_list_file(ext) or + self.is_db_file(ext) or self.is_comic_file(ext) or + self.is_code_file(ext) or self.is_archive_file(ext) or + self.is_magnet_file(ext) or ext == 'txt'): + mtime = os.path.getmtime(full_path) + if time.time() - mtime < 7 * 24 * 3600: + file_list.append({'name': name, 'path': full_path, 'ext': ext, 'mtime': mtime}) + except: + pass + + def _create_recent_item(self, f): + if ComicReader.is_supported(f['name']): + cover_url = ComicReader.get_cover_url(f['path']) + size_bytes = os.path.getsize(f['path']) + size_mb = f"{size_bytes / 1024 / 1024:.1f}MB" if size_bytes > 0 else "" + return { + 'vod_id': f['path'], + 'vod_name': f"📚 {f['name']}", + 'vod_pic': cover_url or ComicReader._get_default_comic_icon(f['ext']), + 'vod_play_url': f"阅读${f['path']}", + 'vod_remarks': f"{f['ext'].upper()}漫画 | {size_mb}", + 'style': {'type': 'grid', 'ratio': 0.75}, + 'vod_player': '画' + } + if self.is_image_file(f['ext']) or f['ext'].lower() in ['heic', 'heif']: + return { + 'vod_id': self.URL_B64U_PREFIX + self.b64u_encode(self.PICS_PREFIX + "file://" + f['path']), + 'vod_name': f"🖼 {f['name']}", + 'vod_pic': f"file://{f['path']}", + 'vod_play_url': f"查看${self.PICS_PREFIX}file://{f['path']}", + 'vod_remarks': self._format_time(f['mtime']), + 'style': {'type': 'grid', 'ratio': 1}, + 'vod_player': '画' + } + if self.is_audio_file(f['ext']): + cover_url = self.get_audio_cover_ultra_fast(f['path']) + if cover_url and cover_url != self.DEFAULT_AUDIO_ICON and len(cover_url) > 50: + display_pic = cover_url + else: + color = self.default_colors[hash(f['name']) % len(self.default_colors)] + first_char = f['name'][0] if f['name'] else "🎵" + display_pic = self._generate_colored_icon(color, first_char) + has_lyrics = False + audio_dir = os.path.dirname(f['path']) + audio_name = os.path.splitext(f['name'])[0] + for lrc_ext in self.lrc_exts: + if os.path.exists(os.path.join(audio_dir, f"{audio_name}.{lrc_ext}")): + has_lyrics = True + break + remarks = self._format_time(f['mtime']) + if has_lyrics: + remarks += ' 📝' + return { + 'vod_id': self.URL_B64U_PREFIX + self.b64u_encode(self.MP3_PREFIX + f['path']), + 'vod_name': f"{f['name']}", + 'vod_pic': display_pic, + 'vod_play_url': f"播放${self.MP3_PREFIX + f['path']}", + 'vod_remarks': remarks, + 'style': {'type': 'grid', 'ratio': 1}, + 'vod_player': '听' + } + if self.is_media_file(f['ext']): + return { + 'vod_id': f['path'], + 'vod_name': f"🎬 {f['name']}", + 'vod_pic': self.file_icons['video'], + 'vod_remarks': self._format_time(f['mtime']), + 'style': {'type': 'grid', 'ratio': 1} + } + if self.is_lrc_file(f['ext']): + return { + 'vod_id': f['path'], + 'vod_name': f"📝 {f['name']}", + 'vod_pic': self.file_icons['lyrics'], + 'vod_remarks': self._format_time(f['mtime']), + 'style': {'type': 'grid', 'ratio': 1} + } + if f['ext'] == 'json': + return { + 'vod_id': self.LIST_PREFIX + self.b64u_encode(f['path']), + 'vod_name': f"📋 {f['name']}", + 'vod_pic': self.file_icons['json'], + 'vod_remarks': self._format_time(f['mtime']), + 'style': {'type': 'grid', 'ratio': 1} + } + if f['ext'] == 'php': + return { + 'vod_id': self.TEXT_PREFIX + self.b64u_encode(f['path']), + 'vod_name': f"🐘 {f['name']}", + 'vod_pic': self.file_icons['php'], + 'vod_remarks': self._format_time(f['mtime']), + 'style': {'type': 'grid', 'ratio': 1} + } + if f['ext'] == 'py': + return { + 'vod_id': self.TEXT_PREFIX + self.b64u_encode(f['path']), + 'vod_name': f"🐍 {f['name']}", + 'vod_pic': self.file_icons['python'], + 'vod_remarks': self._format_time(f['mtime']), + 'style': {'type': 'grid', 'ratio': 1} + } + if f['ext'] == 'zip': + return { + 'vod_id': f['path'], + 'vod_name': f"🗜️ {f['name']}", + 'vod_pic': self.file_icons['zip'], + 'vod_remarks': self._format_time(f['mtime']), + 'style': {'type': 'grid', 'ratio': 1} + } + if f['ext'] in ['rar', '7z', 'tar', 'gz', 'bz2', 'xz']: + icon_key = 'rar' if f['ext'] == 'rar' else 'archive' + return { + 'vod_id': f['path'], + 'vod_name': f"🗜️ {f['name']}", + 'vod_pic': self.file_icons[icon_key], + 'vod_remarks': self._format_time(f['mtime']), + 'style': {'type': 'grid', 'ratio': 1} + } + if f['ext'] in ['m3u', 'm3u8']: + colors = ["#FF6B6B", "#4ECDC4", "#FFD93D", "#6BCB77", "#9D65C9", "#FF8C42", "#A2D729", "#FF6B8B", "#45B7D1", "#96CEB4"] + color = colors[hash(f['name']) % len(colors)] + first_char = f['name'][0].upper() if f['name'] else "M" + icon_svg = self._generate_colored_icon(color, first_char) + return { + 'vod_id': self.LIST_PREFIX + self.b64u_encode(f['path']), + 'vod_name': f['name'], + 'vod_pic': icon_svg, + 'vod_remarks': self._format_time(f['mtime']), + 'style': {'type': 'grid', 'ratio': 1} + } + if self.is_db_file(f['ext']): + return { + 'vod_id': self.LIST_PREFIX + self.b64u_encode(f['path']), + 'vod_name': f"🗄️ {f['name']}", + 'vod_pic': self.file_icons['database'], + 'vod_remarks': self._format_time(f['mtime']), + 'style': {'type': 'grid', 'ratio': 1} + } + if self.is_magnet_file(f['ext']) or f['ext'] in ['xlsx', 'xls']: + return { + 'vod_id': self.MAGNET_PREFIX + self.b64u_encode(f['path']), + 'vod_name': f"🧲 {f['name']}", + 'vod_pic': self.file_icons['magnet'], + 'vod_remarks': self._format_time(f['mtime']), + 'style': {'type': 'grid', 'ratio': 1} + } + if f['ext'] == 'txt': + is_live = False + is_novel = False + url_count = 0 + try: + with open(f['path'], 'r', encoding='utf-8', errors='ignore') as ff: + preview = ff.read(4096) + is_live = self._is_txt_live_source(preview, f['path']) + if not is_live: + is_novel = self._is_txt_novel(preview, f['path']) + url_matches = re.findall(r'https?://[^\s\'"<>]+', preview) + url_count = len(url_matches) + except: + pass + + is_magnet_txt = self._is_magnet_txt_file(f['path']) + if is_magnet_txt: + return { + 'vod_id': self.MAGNET_PREFIX + self.b64u_encode(f['path']), + 'vod_name': f"🧲 {f['name']}", + 'vod_pic': self.file_icons['magnet'], + 'vod_remarks': self._format_time(f['mtime']), + 'style': {'type': 'grid', 'ratio': 1} + } + elif is_live or url_count >= 2: + colors = ["#FF6B6B", "#4ECDC4", "#FFD93D", "#6BCB77", "#9D65C9", "#FF8C42", "#A2D729", "#FF6B8B", "#45B7D1", "#96CEB4"] + color = colors[hash(f['name']) % len(colors)] + first_char = f['name'][0].upper() if f['name'] else "T" + icon_svg = self._generate_colored_icon(color, first_char) + return { + 'vod_id': self.LIST_PREFIX + self.b64u_encode(f['path']), + 'vod_name': f['name'], + 'vod_pic': icon_svg, + 'vod_remarks': self._format_time(f['mtime']), + 'style': {'type': 'grid', 'ratio': 1} + } + elif is_novel: + encoded = self.b64u_encode(f['path']) + novel_url = f"{self.NOVEL_PREFIX}{encoded}" + return { + 'vod_id': novel_url, + 'vod_name': f"📖 {f['name']}", + 'vod_pic': self.file_icons['novel'], + 'vod_remarks': self._format_time(f['mtime']), + 'style': {'type': 'grid', 'ratio': 1}, + 'vod_player': '书' + } + else: + return { + 'vod_id': self.TEXT_PREFIX + self.b64u_encode(f['path']), + 'vod_name': f"📄 {f['name']}", + 'vod_pic': self.file_icons['text'], + 'vod_remarks': self._format_time(f['mtime']), + 'style': {'type': 'grid', 'ratio': 1} + } + if f['ext'] in ['xml', 'html', 'htm', 'css', 'js', 'sh', 'bash']: + return { + 'vod_id': self.TEXT_PREFIX + self.b64u_encode(f['path']), + 'vod_name': f"📄 {f['name']}", + 'vod_pic': self.file_icons['text'], + 'vod_remarks': self._format_time(f['mtime']), + 'style': {'type': 'grid', 'ratio': 1} + } + return { + 'vod_id': f['path'], + 'vod_name': f"📁 {f['name']}", + 'vod_pic': self.file_icons['file'], + 'vod_remarks': self._format_time(f['mtime']), + 'style': {'type': 'grid', 'ratio': 1} + } + + def _format_time(self, timestamp): + diff = time.time() - timestamp + if diff < 3600: + return f"{int(diff/60)}分钟前" + elif diff < 86400: + return f"{int(diff/3600)}小时前" + else: + return time.strftime('%m-%d %H:%M', time.localtime(timestamp)) + + def _gallery_category_content(self, pg): + vlist = [] + for idx, api in enumerate(self.gallery_apis): + encoded_url = self.b64u_encode(api['url']) + if '?' in api['url']: + preview_url = f"{api['url']}&_r={random.randint(1, 999999)}&_t={int(time.time())}" + else: + preview_url = f"{api['url']}?_r={random.randint(1, 999999)}&_t={int(time.time())}" + vlist.append({ + 'vod_id': f"gallery_{api['type']}_{encoded_url}", + 'vod_name': api['name'], + 'vod_pic': preview_url, + 'vod_remarks': '点击查看50张', + 'style': {'type': 'grid', 'ratio': 0.75}, + 'vod_player': '画' + }) + return {'list': vlist, 'page': 1, 'pagecount': 1, 'limit': len(vlist), 'total': len(vlist)} + + def _gallery_detail(self, api_type, encoded_url): + try: + api_url = self.b64u_decode(encoded_url) + except: + return {'list': []} + api_name = "图库" + for api in self.gallery_apis: + if api['url'] == api_url: + api_name = api['name'] + break + images = [] + for i in range(50): + if '?' in api_url: + rand_url = f"{api_url}&_r={random.randint(1, 999999)}&_t={int(time.time())}" + else: + rand_url = f"{api_url}?_r={random.randint(1, 999999)}&_t={int(time.time())}" + images.append(rand_url) + pics_protocol = self.PICS_PREFIX + '&&'.join(images) + vod = { + 'vod_id': f"gallery_{api_type}_{encoded_url}", + 'vod_name': f"{api_name} (50张)", + 'vod_pic': images[0], + 'vod_play_from': '图片浏览', + 'vod_play_url': f"播放${pics_protocol}", + 'vod_remarks': '共50张图片', + 'style': {'type': 'list'}, + 'vod_player': '画' + } + return {'list': [vod]} + + # ==================== 选择模式相关方法 ==================== + + def _enable_select_mode(self, target_path): + self.select_mode_enabled = True + self.select_mode_dir = target_path + self.selected_items = [] + self.current_select_index = 0 + self._current_filter_key = None + return True + + def _disable_select_mode(self): + self.select_mode_enabled = False + self.select_mode_dir = None + self.selected_items = [] + self.pending_rename_path = None + self.pending_rename_is_dir = False + self.current_select_index = 0 + self._current_filter_key = None + return True + + def _clear_selected(self): + self.selected_items = [] + return True + + def _select_all(self, current_path): + files = self._scan_directory_simple(current_path) + for f in files: + if f['path'] not in self.selected_items: + self.selected_items.append(f['path']) + return True + + def _toggle_selection(self, file_path): + if file_path in self.selected_items: + self.selected_items.remove(file_path) + else: + self.selected_items.append(file_path) + return True + + def _move_up(self): + self.current_select_index -= 1 + if self.current_select_index < 0: + self.current_select_index = 0 + return True + + def _move_down(self, max_index): + self.current_select_index += 1 + if self.current_select_index >= max_index: + self.current_select_index = max_index - 1 if max_index > 0 else 0 + return True + + def _select_current_with_filtered(self, filtered_files): + if 0 <= self.current_select_index < len(filtered_files): + file_path = filtered_files[self.current_select_index]['path'] + self._toggle_selection(file_path) + return True + + def _clear_current_with_filtered(self, filtered_files): + if 0 <= self.current_select_index < len(filtered_files): + file_path = filtered_files[self.current_select_index]['path'] + if file_path in self.selected_items: + self.selected_items.remove(file_path) + return True + + def _copy_selected(self, current_dir): + if not self.selected_items: + return False, "没有选中任何文件" + self.copy_buffer = { + 'type': 'copy', + 'paths': self.selected_items.copy(), + 'source_dir': current_dir + } + return True, f"📋 已复制 {len(self.selected_items)} 个文件/文件夹" + + def _move_selected(self, current_dir): + if not self.selected_items: + return False, "没有选中任何文件" + self.copy_buffer = { + 'type': 'move', + 'paths': self.selected_items.copy(), + 'source_dir': current_dir + } + return True, f"✂️ 已标记移动 {len(self.selected_items)} 个文件/文件夹" + + def _paste_files(self, target_dir): + if not self.copy_buffer: + return False, "剪贴板为空,请先复制或移动文件" + source_paths = self.copy_buffer['paths'] + operation_type = self.copy_buffer.get('type', 'copy') + source_dir = self.copy_buffer.get('source_dir', '') + success_count = 0 + fail_count = 0 + errors = [] + for src_path in source_paths: + if not os.path.exists(src_path): + fail_count += 1 + errors.append(f"源文件不存在: {os.path.basename(src_path)}") + continue + src_name = os.path.basename(src_path) + dst_path = os.path.join(target_dir, src_name) + if os.path.exists(dst_path): + name, ext = os.path.splitext(src_name) + counter = 1 + while True: + new_name = f"{name}_{counter}{ext}" + new_dst = os.path.join(target_dir, new_name) + if not os.path.exists(new_dst): + dst_path = new_dst + break + counter += 1 + try: + if operation_type == 'move': + if os.path.isdir(src_path): + shutil.move(src_path, dst_path) + else: + shutil.move(src_path, dst_path) + self.dir_cache.pop(f"dir_{source_dir}", None) + self.dir_cache_time.pop(f"dir_{source_dir}", None) + self.audio_list_cache.pop(source_dir, None) + self.audio_list_cache_time.pop(source_dir, None) + else: + if os.path.isdir(src_path): + shutil.copytree(src_path, dst_path) + else: + shutil.copy2(src_path, dst_path) + success_count += 1 + except Exception as e: + fail_count += 1 + errors.append(f"操作失败 {src_name}: {str(e)}") + self.dir_cache.pop(f"dir_{target_dir}", None) + self.dir_cache_time.pop(f"dir_{target_dir}", None) + if operation_type == 'move' and success_count > 0: + self.copy_buffer = None + op_name = "移动" if operation_type == 'move' else "粘贴" + result_msg = f"📋 {op_name}完成\n✅ 成功: {success_count} 个\n❌ 失败: {fail_count} 个" + if errors: + result_msg += "\n\n" + "\n".join(errors[:3]) + return True, result_msg + + def _rename_file(self, file_path, new_name, is_dir=False): + if not os.path.exists(file_path): + return False, "文件或文件夹不存在" + old_dir = os.path.dirname(file_path) + old_name = os.path.basename(file_path) + if is_dir: + new_full_name = new_name + else: + ext = os.path.splitext(old_name)[1] + if '.' in new_name: + new_full_name = new_name + else: + new_full_name = new_name + ext + new_path = os.path.join(old_dir, new_full_name) + if os.path.exists(new_path): + return False, "目标文件或文件夹已存在" + try: + os.rename(file_path, new_path) + self.dir_cache.pop(f"dir_{old_dir}", None) + self.dir_cache_time.pop(f"dir_{old_dir}", None) + if is_dir: + self.dir_cache.pop(f"dir_{file_path}", None) + self.dir_cache_time.pop(f"dir_{file_path}", None) + self.audio_list_cache.pop(file_path, None) + self.audio_list_cache_time.pop(file_path, None) + if file_path in self.selected_items: + self.selected_items.remove(file_path) + self.selected_items.append(new_path) + if self.select_mode_dir == file_path: + self.select_mode_dir = new_path + return True, f"重命名成功: {old_name} -> {new_full_name}" + except Exception as e: + return False, f"重命名失败: {str(e)}" + + def _rename_current(self, current_path): + files = self._scan_directory_simple(current_path) + if 0 <= self.current_select_index < len(files): + file_path = files[self.current_select_index]['path'] + is_dir = os.path.isdir(file_path) + return True, "", file_path, is_dir + return False, "没有选中的文件", None, False + + def _get_select_result(self, message): + return { + 'list': [{ + 'vod_id': 'select_result', + 'vod_name': message, + 'vod_pic': self._generate_colored_icon("#4CAF50", "✓"), + 'vod_remarks': '操作完成', + 'style': {'type': 'list'}, + 'vod_player': '书' + }], + 'page': 1, + 'pagecount': 1, + 'limit': 1, + 'total': 1 + } + + def _get_select_error(self, message): + return { + 'list': [{ + 'vod_id': 'select_result', + 'vod_name': f'❌ {message}', + 'vod_pic': self._generate_colored_icon("#F44336", "✗"), + 'vod_remarks': '操作失败', + 'style': {'type': 'list'}, + 'vod_player': '书' + }], + 'page': 1, + 'pagecount': 1, + 'limit': 1, + 'total': 1 + } + + def _delete_to_trash(self, file_path): + try: + if not os.path.exists(file_path): + return False, "文件不存在" + file_name = os.path.basename(file_path) + unique_name = f"{int(time.time())}_{file_name}" + trash_path = os.path.join(self.trash_dir, unique_name) + os.rename(file_path, trash_path) + audio_dir = os.path.dirname(file_path) + audio_name = os.path.splitext(file_name)[0] + for lrc_ext in self.lrc_exts: + lrc_path = os.path.join(audio_dir, f"{audio_name}.{lrc_ext}") + if os.path.exists(lrc_path): + lrc_trash_name = f"{int(time.time())}_{audio_name}.{lrc_ext}" + lrc_trash_path = os.path.join(self.trash_dir, lrc_trash_name) + os.rename(lrc_path, lrc_trash_path) + lrc_path_upper = os.path.join(audio_dir, f"{audio_name}.{lrc_ext.upper()}") + if os.path.exists(lrc_path_upper): + lrc_trash_name = f"{int(time.time())}_{audio_name}.{lrc_ext.upper()}" + lrc_trash_path = os.path.join(self.trash_dir, lrc_trash_name) + os.rename(lrc_path_upper, lrc_trash_path) + cover_exts = ['jpg', 'jpeg', 'png', 'webp', 'gif'] + for cover_ext in cover_exts: + cover_path = os.path.join(audio_dir, f"{audio_name}.{cover_ext}") + if os.path.exists(cover_path): + cover_trash_name = f"{int(time.time())}_{audio_name}.{cover_ext}" + cover_trash_path = os.path.join(self.trash_dir, cover_trash_name) + os.rename(cover_path, cover_trash_path) + cover_path_upper = os.path.join(audio_dir, f"{audio_name}.{cover_ext.upper()}") + if os.path.exists(cover_path_upper): + cover_trash_name = f"{int(time.time())}_{audio_name}.{cover_ext.upper()}" + cover_trash_path = os.path.join(self.trash_dir, cover_trash_name) + os.rename(cover_path_upper, cover_trash_path) + file_hash = hashlib.md5(file_path.encode()).hexdigest() + cache_file = f"{COVER_CACHE_DIR}{file_hash}.jpg" + if os.path.exists(cache_file): + os.remove(cache_file) + cache_file_hidden = f"{COVER_CACHE_DIR}.{file_hash}.jpg" + if os.path.exists(cache_file_hidden): + os.remove(cache_file_hidden) + cover_record = CoverScanRecord.load_record() + if file_hash in cover_record: + del cover_record[file_hash] + CoverScanRecord.save_record(cover_record) + return True, f"已删除: {file_name} (及关联的封面和歌词)" + except Exception as e: + return False, f"删除失败: {e}" + + def _empty_trash(self): + deleted_count = 0 + deleted_size = 0 + if os.path.exists(self.trash_dir): + for item in os.listdir(self.trash_dir): + if item == '.nomedia': + continue + item_path = os.path.join(self.trash_dir, item) + try: + if os.path.isfile(item_path): + file_size = os.path.getsize(item_path) + deleted_size += file_size + deleted_count += 1 + elif os.path.isdir(item_path): + for root, dirs, files in os.walk(item_path): + for file in files: + file_path = os.path.join(root, file) + try: + deleted_size += os.path.getsize(file_path) + deleted_count += 1 + except: + pass + except: + pass + try: + shutil.rmtree(self.trash_dir) + os.makedirs(self.trash_dir, exist_ok=True) + nomedia_path = os.path.join(self.trash_dir, '.nomedia') + if not os.path.exists(nomedia_path): + with open(nomedia_path, 'w') as f: + f.write('# This file prevents media scanning in this directory\n') + except Exception as e: + for item in os.listdir(self.trash_dir): + if item == '.nomedia': + continue + item_path = os.path.join(self.trash_dir, item) + try: + if os.path.isfile(item_path): + os.remove(item_path) + elif os.path.isdir(item_path): + shutil.rmtree(item_path) + except: + pass + if deleted_size > 1024 * 1024: + size_str = f"{deleted_size / (1024 * 1024):.2f} MB" + elif deleted_size > 1024: + size_str = f"{deleted_size / 1024:.2f} KB" + else: + size_str = f"{deleted_size} B" + result_msg = f"🗑️ 已清空回收站\n\n✅ 删除 {deleted_count} 个文件/文件夹\n释放空间: {size_str}" + return { + 'list': [{ + 'vod_id': 'empty_trash_result', + 'vod_name': result_msg, + 'vod_pic': self._generate_colored_icon("#4CAF50", "✓"), + 'vod_remarks': '清空完成', + 'style': {'type': 'list'}, + 'vod_player': '书' + }], + 'page': 1, + 'pagecount': 1, + 'limit': 1, + 'total': 1 + } + + def _create_delete_result(self, message, success=True): + color = "#4CAF50" if success else "#F44336" + icon = "✓" if success else "✗" + return { + 'list': [{ + 'vod_id': 'delete_result', + 'vod_name': message, + 'vod_pic': self._generate_colored_icon(color, icon), + 'vod_remarks': '操作完成' if success else '操作失败', + 'style': {'type': 'list'}, + 'vod_player': '书' + }], + 'page': 1, + 'pagecount': 1, + 'limit': 1, + 'total': 1 + } + + def _create_delete_result_with_back(self, message, back_path, current_pg=1): + return { + 'list': [ + { + 'vod_id': 'delete_result', + 'vod_name': message, + 'vod_pic': self._generate_colored_icon("#4CAF50", "✓"), + 'vod_remarks': '删除成功,可以继续删除其他文件', + 'style': {'type': 'list'}, + 'vod_player': '书' + }, + { + 'vod_id': self.FOLDER_PREFIX + self.b64u_encode(back_path), + 'vod_name': '⬅️ 点击返回目录(删除模式保持开启)', + 'vod_pic': self.file_icons['folder'], + 'vod_remarks': f'返回后列表已刷新,删除模式仍然开启,当前第{current_pg}页', + 'style': {'type': 'list'}, + 'vod_player': '书' + } + ], + 'page': current_pg, + 'pagecount': 1, + 'limit': 2, + 'total': 2 + } + + def _enable_delete_mode(self, target_path): + self.delete_mode_enabled = True + self.delete_mode_dir = target_path + self.select_mode_enabled = False + self.select_mode_dir = None + self.selected_items = [] + return True + + def _disable_delete_mode(self): + self.delete_mode_enabled = False + self.delete_mode_dir = None + self._pending_return_dir = None + return True + + def _delete_folder_to_trash(self, folder_path): + try: + if not os.path.exists(folder_path): + return False, "文件夹不存在" + if not os.path.isdir(folder_path): + return False, "不是文件夹" + folder_name = os.path.basename(folder_path) + unique_name = f"{int(time.time())}_{folder_name}" + trash_path = os.path.join(self.trash_dir, unique_name) + shutil.move(folder_path, trash_path) + self.dir_cache.pop(f"dir_{folder_path}", None) + self.dir_cache_time.pop(f"dir_{folder_path}", None) + self.audio_list_cache.pop(folder_path, None) + self.audio_list_cache_time.pop(folder_path, None) + cache_keys_to_delete = [] + for key in self.dir_cache.keys(): + if key.startswith(f"dir_{folder_path}"): + cache_keys_to_delete.append(key) + for key in cache_keys_to_delete: + self.dir_cache.pop(key, None) + self.dir_cache_time.pop(key, None) + return True, f"已删除文件夹: {folder_name} (及其所有内容)" + except Exception as e: + return False, f"删除文件夹失败: {e}" + + # ==================== 选择模式界面(修复版)- 所有文件项改为网格 ==================== + + def _get_category_content_with_select_mode(self, tid, pg, filter, extend, current_path, filter_value=None): + pg = int(pg) if pg else 1 + raw_files = self._scan_directory_simple(current_path) + if filter_value: + filtered_files = self._apply_filter_to_files(raw_files, filter_value) + else: + filtered_files = raw_files + current_filter_key = filter_value or "no_filter" + if current_filter_key != getattr(self, '_current_filter_key', None): + print(f"[筛选变化] 旧筛选: {getattr(self, '_current_filter_key', None)} -> 新筛选: {current_filter_key}") + self.current_select_index = 0 + if filter_value and filter_value != '全部': + for idx, f in enumerate(filtered_files): + if f.get('match_score', 0) == 1: + self.current_select_index = idx + break + self._current_filter_key = current_filter_key + else: + if self.current_select_index >= len(filtered_files) and filtered_files: + self.current_select_index = len(filtered_files) - 1 + if self.current_select_index < 0 and filtered_files: + self.current_select_index = 0 + if not filtered_files: + if filter_value: + filter_name = "全部数字" if filter_value == 'all_digits' else (f"数字 {filter_value}" if filter_value.isdigit() else filter_value.upper()) + return { + 'list': [ + { + 'vod_id': 'select_mode_empty', + 'vod_name': f'🔍 筛选 "{filter_name}" 没有匹配的文件', + 'vod_pic': self._generate_colored_icon("#FF9800", "🔍"), + 'vod_remarks': f'当前目录中没有以 {filter_name} 开头的文件', + 'style': {'type': 'list'}, + 'vod_player': '书' + }, + { + 'vod_id': 'select_mode_status', + 'vod_name': '✅ 选择模式已开启 | 点击此条关闭', + 'vod_pic': self.select_icon, + 'vod_remarks': '请在筛选栏使用 ↑ ↓ 移动光标,✅ 选中当前文件', + 'vod_remarks_color': '#2196F3', + 'style': {'type': 'list'}, + 'vod_player': '书' + } + ], + 'page': pg, + 'pagecount': 1, + 'limit': 1, + 'total': 0 + } + else: + return { + 'list': [ + { + 'vod_id': 'select_mode_empty', + 'vod_name': '📭 当前目录为空', + 'vod_pic': self._generate_colored_icon("#FF9800", "📭"), + 'vod_remarks': '没有文件可以操作', + 'style': {'type': 'list'}, + 'vod_player': '书' + }, + { + 'vod_id': 'select_mode_status', + 'vod_name': '✅ 选择模式已开启 | 点击此条关闭', + 'vod_pic': self.select_icon, + 'vod_remarks': '请在筛选栏使用 ↑ ↓ 移动光标,✅ 选中当前文件', + 'vod_remarks_color': '#2196F3', + 'style': {'type': 'list'}, + 'vod_player': '书' + } + ], + 'page': pg, + 'pagecount': 1, + 'limit': 1, + 'total': 0 + } + total = len(filtered_files) + per_page = 500 + start = (pg - 1) * per_page + end = min(start + per_page, total) + page_files = filtered_files[start:end] + if self.current_select_index >= len(page_files) and page_files: + self.current_select_index = len(page_files) - 1 + if self.current_select_index < 0 and page_files: + self.current_select_index = 0 + vlist = [] + parent = os.path.dirname(current_path) + if parent and parent != current_path: + for i, root in enumerate(self.root_paths): + if os.path.normpath(parent) == os.path.normpath(root.rstrip('/')): + parent_id = f"root_{i}" + parent_name = self.path_to_chinese.get(root, os.path.basename(parent)) + break + else: + parent_id = self.FOLDER_PREFIX + self.b64u_encode(parent) + parent_name = os.path.basename(parent) + vlist.append({ + 'vod_id': parent_id, + 'vod_name': f'⬅️ 返回 {parent_name}', + 'vod_pic': self.file_icons['folder'], + 'vod_remarks': '', + 'style': {'type': 'list'} + }) + if filter_value: + filter_name = "全部数字" if filter_value == 'all_digits' else (f"数字 {filter_value}" if filter_value.isdigit() else filter_value.upper()) + matched_count = sum(1 for f in filtered_files if f.get('match_score', 0) == 1) + vlist.append({ + 'vod_id': 'filter_info', + 'vod_name': f'🔍 当前筛选: {filter_name} | 匹配 {matched_count} / {total} 个文件', + 'vod_pic': self._generate_colored_icon("#4CAF50", "🔍"), + 'vod_remarks': f'光标位置: {self.current_select_index + 1}/{len(page_files)} | 使用↑↓移动,☑️选中', + 'style': {'type': 'list'}, + 'vod_player': '书' + }) + selected_count = len(self.selected_items) + if self.copy_buffer: + if self.copy_buffer.get('type') == 'move': + clipboard_info = f"✂️ 待移动 {len(self.copy_buffer['paths'])} 个" + else: + clipboard_info = f"📋 待粘贴 {len(self.copy_buffer['paths'])} 个" + else: + clipboard_info = "📭 剪贴板为空" + vlist.append({ + 'vod_id': 'select_mode_status', + 'vod_name': f'✅ 已选中 {selected_count} 个 | 光标: {self.current_select_index + 1}/{len(page_files)}', + 'vod_pic': self.select_icon, + 'vod_remarks': f'{clipboard_info} | ☑️选中当前 ❌取消当前 ⭐全部选中 ✂️剪切 📌粘贴 ✏️重命名', + 'vod_remarks_color': '#2196F3', + 'style': {'type': 'list'}, + 'vod_player': '书' + }) + current_file_full = "" + if 0 <= self.current_select_index < len(page_files): + current_file_full = page_files[self.current_select_index]['name'] + if len(current_file_full) > 60: + current_file_full = current_file_full[:57] + "..." + vlist.append({ + 'vod_id': 'current_file_display', + 'vod_name': f'当前光标: ▶️【{current_file_full}】◀️', + 'vod_pic': self.TRANSPARENT_GIF, + 'vod_remarks': '↑ ↓ 移动光标,☑️ 选中当前文件', + 'vod_remarks_color': '#FF9800', + 'style': {'type': 'list'}, + 'vod_player': '书' + }) + for idx, f in enumerate(page_files): + is_selected = f['path'] in self.selected_items + is_cursor = (idx == self.current_select_index) + cursor_mark = "▶️" if is_cursor else " " + select_mark = "☑️" if is_selected else "⬜️" + if is_cursor: + display_name = f"{cursor_mark} {select_mark} 【{f['name']}】 ◀️" + else: + display_name = f"{cursor_mark} {select_mark} {f['name']}" + if f['is_dir']: + vlist.append({ + 'vod_id': f['path'], + 'vod_name': f"📁 {display_name}", + 'vod_pic': self.file_icons['folder'], + 'vod_remarks': '文件夹', + 'style': {'type': 'grid', 'ratio': 1} + }) + elif self.is_audio_file(f['ext']): + cover_url = self.get_audio_cover_ultra_fast(f['path']) + if cover_url and cover_url != self.DEFAULT_AUDIO_ICON and len(cover_url) > 50: + display_pic = cover_url + else: + color = self.default_colors[hash(f['name']) % len(self.default_colors)] + first_char = f['name'][0] if f['name'] else "🎵" + display_pic = self._generate_colored_icon(color, first_char) + vlist.append({ + 'vod_id': f['path'], + 'vod_name': f"{display_name}", + 'vod_pic': display_pic, + 'vod_play_url': f"播放${self.MP3_PREFIX + f['path']}", + 'vod_remarks': '音乐 | 点击播放', + 'style': {'type': 'grid', 'ratio': 1}, + 'vod_player': '听' + }) + elif self.is_image_file(f['ext']) or f['ext'].lower() in ['heic', 'heif']: + vlist.append({ + 'vod_id': f['path'], + 'vod_name': f"🖼 {display_name}", + 'vod_pic': f"file://{f['path']}", + 'vod_play_url': f"查看${self.PICS_PREFIX}file://{f['path']}", + 'vod_remarks': '图片 | 点击查看', + 'style': {'type': 'grid', 'ratio': 1}, + 'vod_player': '画' + }) + elif self.is_media_file(f['ext']): + vlist.append({ + 'vod_id': f['path'], + 'vod_name': f"🎬 {display_name}", + 'vod_pic': self.file_icons['video'], + 'vod_remarks': '视频 | 点击播放', + 'style': {'type': 'grid', 'ratio': 1} + }) + elif ComicReader.is_supported(f['name']): + cover_url = ComicReader.get_cover_url(f['path']) + vlist.append({ + 'vod_id': f['path'], + 'vod_name': f"📚 {display_name}", + 'vod_pic': cover_url or ComicReader._get_default_comic_icon(f['ext']), + 'vod_remarks': '漫画 | 点击阅读', + 'style': {'type': 'grid', 'ratio': 0.75}, + 'vod_player': '画' + }) + elif self.is_magnet_file(f['ext']) or f['ext'] in ['xlsx', 'xls']: + vlist.append({ + 'vod_id': self.MAGNET_PREFIX + self.b64u_encode(f['path']), + 'vod_name': f"🧲 {display_name}", + 'vod_pic': self.file_icons['magnet'], + 'vod_remarks': '磁力/电驴链接 | 点击查看', + 'style': {'type': 'grid', 'ratio': 1} + }) + else: + is_novel = False + if f['ext'] == 'txt': + try: + with open(f['path'], 'r', encoding='utf-8', errors='ignore') as ff: + preview = ff.read(4096) + is_novel = self._is_txt_novel(preview, f['path']) + except: + pass + if is_novel: + encoded = self.b64u_encode(f['path']) + novel_url = f"{self.NOVEL_PREFIX}{encoded}" + vlist.append({ + 'vod_id': novel_url, + 'vod_name': f"📖 {display_name}", + 'vod_pic': self.file_icons['novel'], + 'vod_remarks': '小说 | 点击阅读', + 'style': {'type': 'grid', 'ratio': 1}, + 'vod_player': '书' + }) + else: + vlist.append({ + 'vod_id': f['path'], + 'vod_name': f"📄 {display_name}", + 'vod_pic': self.file_icons['file'], + 'vod_remarks': f'{f["ext"].upper()}文件 | 点击打开' if f['ext'] else '文件 | 点击打开', + 'style': {'type': 'grid', 'ratio': 1} + }) + pagecount = (total + per_page - 1) // per_page if total > 0 else 1 + return { + 'list': vlist, + 'page': pg, + 'pagecount': pagecount, + 'limit': per_page, + 'total': total + } + + # ==================== 删除模式界面 - 所有文件项改为网格 ==================== + + def _get_category_content_with_delete_mode(self, tid, pg, filter, extend, current_path, filter_value=None): + pg = int(pg) if pg else 1 + files = self._scan_directory_simple(current_path) + if filter_value: + files = self._apply_filter_to_files(files, filter_value) + if not files: + if filter_value: + filter_name = "全部数字" if filter_value == 'all_digits' else (f"数字 {filter_value}" if filter_value.isdigit() else filter_value.upper()) + return { + 'list': [ + { + 'vod_id': 'delete_mode_empty', + 'vod_name': f'🔍 筛选 "{filter_name}" 没有匹配的文件', + 'vod_pic': self._generate_colored_icon("#FF9800", "🔍"), + 'vod_remarks': f'当前目录中没有以 {filter_name} 开头的文件', + 'style': {'type': 'list'}, + 'vod_player': '书' + }, + { + 'vod_id': self.FOLDER_PREFIX + self.b64u_encode(current_path), + 'vod_name': '⬅️ 点击返回上级目录', + 'vod_pic': self.file_icons['folder'], + 'vod_remarks': '返回后删除模式保持开启', + 'style': {'type': 'list'}, + 'vod_player': '书' + } + ], + 'page': pg, + 'pagecount': 1, + 'limit': 1, + 'total': 0 + } + else: + return { + 'list': [ + { + 'vod_id': 'delete_mode_empty', + 'vod_name': '📭 当前目录为空', + 'vod_pic': self._generate_colored_icon("#FF9800", "📭"), + 'vod_remarks': '没有文件或文件夹可以删除', + 'style': {'type': 'list'}, + 'vod_player': '书' + }, + { + 'vod_id': self.FOLDER_PREFIX + self.b64u_encode(current_path), + 'vod_name': '⬅️ 点击返回上级目录', + 'vod_pic': self.file_icons['folder'], + 'vod_remarks': '返回后删除模式保持开启', + 'style': {'type': 'list'}, + 'vod_player': '书' + } + ], + 'page': pg, + 'pagecount': 1, + 'limit': 1, + 'total': 0 + } + total = len(files) + per_page = 500 + start = (pg - 1) * per_page + end = min(start + per_page, total) + page_files = files[start:end] + vlist = [] + parent = os.path.dirname(current_path) + if parent and parent != current_path: + for i, root in enumerate(self.root_paths): + if os.path.normpath(parent) == os.path.normpath(root.rstrip('/')): + parent_id = f"root_{i}" + parent_name = self.path_to_chinese.get(root, os.path.basename(parent)) + break + else: + parent_id = self.FOLDER_PREFIX + self.b64u_encode(parent) + parent_name = os.path.basename(parent) + vlist.append({ + 'vod_id': parent_id, + 'vod_name': f'⬅️ 返回 {parent_name}', + 'vod_pic': self.file_icons['folder'], + 'vod_remarks': '', + 'style': {'type': 'list'} + }) + if filter_value: + filter_name = "全部数字" if filter_value == 'all_digits' else (f"数字 {filter_value}" if filter_value.isdigit() else filter_value.upper()) + vlist.append({ + 'vod_id': 'filter_info', + 'vod_name': f'🔍 当前筛选: {filter_name} | 匹配 {total} 个文件', + 'vod_pic': self._generate_colored_icon("#4CAF50", "🔍"), + 'vod_remarks': '删除模式 + 字母/数字筛选', + 'style': {'type': 'list'}, + 'vod_player': '书' + }) + vlist.append({ + 'vod_id': 'delete_mode_status', + 'vod_name': '🔴 删除模式已开启(点击此条关闭)', + 'vod_pic': self.delete_icon, + 'vod_remarks': '⚠️ 点击文件/文件夹将移动到回收站,点击此条关闭', + 'vod_remarks_color': '#F44336', + 'style': {'type': 'list'}, + 'vod_player': '书' + }) + for f in page_files: + if f['is_dir']: + vlist.append({ + 'vod_id': f'delete_folder_{self.b64u_encode(f["path"])}', + 'vod_name': f"📁 {f['name']}", + 'vod_pic': self.file_icons['folder'], + 'vod_remarks': '🗑️ 点击删除整个文件夹', + 'vod_remarks_color': '#F44336', + 'style': {'type': 'grid', 'ratio': 1}, + 'vod_player': '书' + }) + elif self.is_audio_file(f['ext']): + cover_url = self.get_audio_cover_ultra_fast(f['path']) + if cover_url and cover_url != self.DEFAULT_AUDIO_ICON and len(cover_url) > 50: + display_pic = cover_url + else: + color = self.default_colors[hash(f['name']) % len(self.default_colors)] + first_char = f['name'][0] if f['name'] else "🎵" + display_pic = self._generate_colored_icon(color, first_char) + vlist.append({ + 'vod_id': f'delete_file_{self.b64u_encode(f["path"])}', + 'vod_name': f"{f['name']}", + 'vod_pic': display_pic, + 'vod_remarks': '🗑️ 点击删除', + 'vod_remarks_color': '#F44336', + 'style': {'type': 'grid', 'ratio': 1}, + 'vod_player': '书' + }) + elif self.is_image_file(f['ext']) or f['ext'].lower() in ['heic', 'heif']: + vlist.append({ + 'vod_id': f'delete_file_{self.b64u_encode(f["path"])}', + 'vod_name': f"🖼 {f['name']}", + 'vod_pic': f"file://{f['path']}", + 'vod_remarks': '🗑️ 点击删除', + 'vod_remarks_color': '#F44336', + 'style': {'type': 'grid', 'ratio': 1}, + 'vod_player': '书' + }) + elif self.is_media_file(f['ext']): + vlist.append({ + 'vod_id': f'delete_file_{self.b64u_encode(f["path"])}', + 'vod_name': f"🎬 {f['name']}", + 'vod_pic': self.file_icons['video'], + 'vod_remarks': '🗑️ 点击删除', + 'vod_remarks_color': '#F44336', + 'style': {'type': 'grid', 'ratio': 1}, + 'vod_player': '书' + }) + else: + vlist.append({ + 'vod_id': f'delete_file_{self.b64u_encode(f["path"])}', + 'vod_name': f"📄 {f['name']}", + 'vod_pic': self.file_icons['file'], + 'vod_remarks': f'🗑️ 点击删除 - {f["ext"].upper()}' if f['ext'] else '🗑️ 点击删除', + 'vod_remarks_color': '#F44336', + 'style': {'type': 'grid', 'ratio': 1}, + 'vod_player': '书' + }) + pagecount = (total + per_page - 1) // per_page if total > 0 else 1 + return { + 'list': vlist, + 'page': pg, + 'pagecount': pagecount, + 'limit': per_page, + 'total': total + } + + # ==================== 普通模式界面 - 所有文件项改为网格 ==================== + + def _get_category_content_normal(self, tid, pg, filter, extend, current_path, filter_value=None): + pg = int(pg) if pg else 1 + cache_key = f"dir_{current_path}" + if cache_key in self.dir_cache and time.time() - self.dir_cache_time.get(cache_key, 0) < 3600: + files = self.dir_cache[cache_key] + else: + files = self.scan_directory(current_path) + self.dir_cache[cache_key] = files + self.dir_cache_time[cache_key] = time.time() + if filter_value and filter_value != '全部': + for f in files: + name = f['name'] + f['match_score'] = 0 + if not name: + continue + first_char = name[0] + if filter_value == 'all_digits': + if first_char.isdigit(): + f['match_score'] = 1 + elif filter_value.isdigit(): + if first_char.isdigit() and first_char == filter_value: + f['match_score'] = 1 + else: + if first_char.upper() == filter_value.upper(): + f['match_score'] = 1 + elif '\u4e00' <= first_char <= '\u9fff': + pinyin_initial = _get_chinese_pinyin_initial(name[0]) + if pinyin_initial == filter_value.upper(): + f['match_score'] = 1 + files.sort(key=lambda x: (-x.get('match_score', 0), not x['is_dir'], x['name'].lower())) + else: + files.sort(key=lambda x: (not x['is_dir'], x['name'].lower())) + total = len(files) + per_page = 500 + start = (pg - 1) * per_page + end = min(start + per_page, total) + page_files = files[start:end] + audio_paths = [] + comic_paths = [] + for f in page_files: + if not f['is_dir']: + if self.is_audio_file(f['ext']): + audio_paths.append(f['path']) + elif ComicReader.is_supported(f['name']): + comic_paths.append(f['path']) + if audio_paths: + self.preload_covers_batch(audio_paths, max_count=500) + for comic_path in comic_paths[:50]: + self.preload_executor.submit(ComicReader.get_cover_url, comic_path) + vlist = [] + parent_item = self._create_parent_item(current_path) + if parent_item: + vlist.append(parent_item) + if filter_value and filter_value != '全部': + if filter_value == 'all_digits': + filter_name = '全部数字' + icon_emoji = "❄️" + icon_color = "#FF9800" + elif filter_value.isdigit(): + filter_name = f'数字 {filter_value}' + icon_emoji = "❄️" + icon_color = "#FF9800" + else: + filter_name = filter_value.upper() + icon_emoji = "☀️" + icon_color = "#4CAF50" + matched_count = sum(1 for f in files if f.get('match_score', 0) == 1) + info_item = { + 'vod_id': 'filter_info', + 'vod_name': f'{icon_emoji} 筛选: {filter_name} | 匹配 {matched_count} / 共 {total} 个文件', + 'vod_pic': self._generate_colored_icon(icon_color, "🔍"), + 'vod_remarks': f'📌 匹配的排在前面', + 'style': {'type': 'list'}, + 'vod_player': '书' + } + vlist.insert(1 if parent_item else 0, info_item) + for f in page_files: + item = self._create_file_item_with_flags(f) + if item: + vlist.append(item) + pagecount = (total + per_page - 1) // per_page if total > 0 else 1 + return { + 'list': vlist, + 'page': pg, + 'pagecount': pagecount, + 'limit': per_page, + 'total': total + } + + def _create_parent_item(self, current_path): + parent = os.path.dirname(current_path) + for root in self.root_paths: + if os.path.normpath(current_path) == os.path.normpath(root.rstrip('/')): + return None + if not parent or parent == current_path: + return None + for i, root in enumerate(self.root_paths): + if os.path.normpath(parent) == os.path.normpath(root.rstrip('/')): + parent_id = f"root_{i}" + parent_name = self.path_to_chinese.get(root, os.path.basename(parent)) + break + else: + parent_id = self.FOLDER_PREFIX + self.b64u_encode(parent) + parent_name = os.path.basename(parent) + return { + 'vod_id': parent_id, + 'vod_name': f'⬅️ 返回 {parent_name}', + 'vod_pic': self.file_icons['folder'], + 'vod_remarks': '', + 'vod_tag': 'folder', + 'style': {'type': 'list'} + } + + def _create_file_item_with_flags(self, f): + icon = self.get_file_icon(f['ext'], f['is_dir']) + if f['is_dir']: + return { + 'vod_id': self.FOLDER_PREFIX + self.b64u_encode(f['path']), + 'vod_name': f"{icon} {f['name']}", + 'vod_pic': self.file_icons['folder'], + 'vod_remarks': '文件夹', + 'vod_tag': 'folder', + 'style': {'type': 'grid', 'ratio': 1} + } + if ComicReader.is_supported(f['name']): + cover_url = ComicReader.get_cover_url(f['path']) + size_bytes = os.path.getsize(f['path']) + size_mb = f"{size_bytes / 1024 / 1024:.1f}MB" if size_bytes > 0 else "" + remarks = f'{f["ext"].upper()}漫画' + if size_mb: + remarks += f' | {size_mb}' + self.preload_executor.submit(ComicReader.get_cover_url, f['path']) + return { + 'vod_id': f['path'], + 'vod_name': f"📚 {f['name']}", + 'vod_pic': cover_url or ComicReader._get_default_comic_icon(f['ext']), + 'vod_play_url': f"阅读${f['path']}", + 'vod_remarks': remarks, + 'vod_tag': 'comic', + 'style': {'type': 'grid', 'ratio': 0.75}, + 'vod_player': '画' + } + has_local_lyrics = False + has_local_cover = False + if self.is_audio_file(f['ext']): + audio_dir = os.path.dirname(f['path']) + audio_name = os.path.splitext(f['name'])[0] + for lrc_ext in self.lrc_exts: + if os.path.exists(os.path.join(audio_dir, f"{audio_name}.{lrc_ext}")): + has_local_lyrics = True + break + if os.path.exists(os.path.join(audio_dir, f"{audio_name}.{lrc_ext.upper()}")): + has_local_lyrics = True + break + exact_names = [ + f"{audio_name}.jpg", f"{audio_name}.jpeg", f"{audio_name}.png", + f"{audio_name}.webp", f"{audio_name}.gif", + f"{audio_name}.JPG", f"{audio_name}.JPEG", f"{audio_name}.PNG" + ] + for cover_name in exact_names: + cover_path = os.path.join(audio_dir, cover_name) + if os.path.exists(cover_path) and os.path.isfile(cover_path): + has_local_cover = True + break + if self.is_image_file(f['ext']) or f['ext'].lower() in ['heic', 'heif']: + return { + 'vod_id': f['path'], + 'vod_name': f"🖼 {f['name']}", + 'vod_pic': f"file://{f['path']}", + 'vod_play_url': f"查看${self.PICS_PREFIX}file://{f['path']}", + 'vod_remarks': '照片', + 'vod_tag': 'image', + 'style': {'type': 'grid', 'ratio': 1}, + 'vod_player': '画' + } + if self.is_audio_file(f['ext']): + cover_url = self.get_audio_cover_ultra_fast(f['path']) + if cover_url and cover_url != self.DEFAULT_AUDIO_ICON and len(cover_url) > 50: + display_pic = cover_url + else: + color = self.default_colors[hash(f['name']) % len(self.default_colors)] + first_char = f['name'][0] if f['name'] else "🎵" + display_pic = self._generate_colored_icon(color, first_char) + remarks = '音频' + if has_local_lyrics: + remarks += ' 📝' + if has_local_cover: + remarks += ' 🖼' + return { + 'vod_id': f['path'], + 'vod_name': f"{f['name']}", + 'vod_pic': display_pic, + 'vod_play_url': f"播放${self.MP3_PREFIX + f['path']}", + 'vod_remarks': remarks, + 'vod_tag': 'audio', + 'style': {'type': 'grid', 'ratio': 1}, + 'vod_player': '听' + } + if self.is_media_file(f['ext']): + return { + 'vod_id': f['path'], + 'vod_name': f"🎬 {f['name']}", + 'vod_pic': self.file_icons['video'], + 'vod_remarks': '视频', + 'vod_tag': 'video', + 'style': {'type': 'grid', 'ratio': 1} + } + if self.is_lrc_file(f['ext']): + return { + 'vod_id': f['path'], + 'vod_name': f"📝 {f['name']}", + 'vod_pic': self.file_icons['lyrics'], + 'vod_remarks': '歌词', + 'vod_tag': 'lrc', + 'style': {'type': 'grid', 'ratio': 1} + } + if f['ext'] == 'json': + return { + 'vod_id': self.LIST_PREFIX + self.b64u_encode(f['path']), + 'vod_name': f"📋 {f['name']}", + 'vod_pic': self.file_icons['json'], + 'vod_play_url': f"查看${self.LIST_PREFIX + self.b64u_encode(f['path'])}", + 'vod_remarks': 'JSON数据', + 'vod_tag': 'json', + 'style': {'type': 'grid', 'ratio': 1} + } + if f['ext'] == 'php': + return { + 'vod_id': self.TEXT_PREFIX + self.b64u_encode(f['path']), + 'vod_name': f"🐘 {f['name']}", + 'vod_pic': self.file_icons['php'], + 'vod_remarks': 'PHP文件', + 'vod_tag': 'php', + 'style': {'type': 'grid', 'ratio': 1} + } + if f['ext'] == 'py': + return { + 'vod_id': self.TEXT_PREFIX + self.b64u_encode(f['path']), + 'vod_name': f"🐍 {f['name']}", + 'vod_pic': self.file_icons['python'], + 'vod_remarks': 'Python文件', + 'vod_tag': 'python', + 'style': {'type': 'grid', 'ratio': 1} + } + if f['ext'] == 'zip': + return { + 'vod_id': f['path'], + 'vod_name': f"🗜️ {f['name']}", + 'vod_pic': self.file_icons['zip'], + 'vod_remarks': 'ZIP压缩包', + 'vod_tag': 'zip', + 'style': {'type': 'grid', 'ratio': 1} + } + if f['ext'] in ['rar', '7z', 'tar', 'gz', 'bz2', 'xz']: + icon_key = 'rar' if f['ext'] == 'rar' else 'archive' + return { + 'vod_id': f['path'], + 'vod_name': f"🗜️ {f['name']}", + 'vod_pic': self.file_icons[icon_key], + 'vod_remarks': f'{f["ext"].upper()}压缩包', + 'vod_tag': 'archive', + 'style': {'type': 'grid', 'ratio': 1} + } + if f['ext'] in ['m3u', 'm3u8']: + colors = ["#FF6B6B", "#4ECDC4", "#FFD93D", "#6BCB77", "#9D65C9", "#FF8C42", "#A2D729", "#FF6B8B", "#45B7D1", "#96CEB4"] + color = colors[hash(f['name']) % len(colors)] + first_char = f['name'][0].upper() if f['name'] else "M" + icon_svg = self._generate_colored_icon(color, first_char) + return { + 'vod_id': self.LIST_PREFIX + self.b64u_encode(f['path']), + 'vod_name': f['name'], + 'vod_pic': icon_svg, + 'vod_play_url': f"播放${self.LIST_PREFIX + self.b64u_encode(f['path'])}", + 'vod_remarks': '播放列表', + 'vod_tag': 'playlist', + 'style': {'type': 'grid', 'ratio': 1} + } + if self.is_db_file(f['ext']): + return { + 'vod_id': self.LIST_PREFIX + self.b64u_encode(f['path']), + 'vod_name': f"🗄️ {f['name']}", + 'vod_pic': self.file_icons['database'], + 'vod_remarks': '数据库', + 'vod_tag': 'database', + 'style': {'type': 'grid', 'ratio': 1} + } + if self.is_magnet_file(f['ext']) or f['ext'] in ['xlsx', 'xls']: + return { + 'vod_id': self.MAGNET_PREFIX + self.b64u_encode(f['path']), + 'vod_name': f"🧲 {f['name']}", + 'vod_pic': self.file_icons['magnet'], + 'vod_remarks': '磁力/电驴链接', + 'vod_tag': 'magnet', + 'style': {'type': 'grid', 'ratio': 1} + } + if f['ext'] == 'txt': + is_live_source = False + is_novel = False + url_count = 0 + content_preview = "" + try: + with open(f['path'], 'r', encoding='utf-8', errors='ignore') as ff: + content_preview = ff.read(4096) + is_live_source = self._is_txt_live_source(content_preview, f['path']) + if not is_live_source: + is_novel = self._is_txt_novel(content_preview, f['path']) + url_matches = re.findall(r'https?://[^\s\'"<>]+', content_preview) + url_count = len(url_matches) + except: + pass + + if self._is_magnet_txt_file(f['path']): + return { + 'vod_id': self.MAGNET_PREFIX + self.b64u_encode(f['path']), + 'vod_name': f"🧲 {f['name']}", + 'vod_pic': self.file_icons['magnet'], + 'vod_remarks': '磁力/电驴链接', + 'vod_tag': 'magnet', + 'style': {'type': 'grid', 'ratio': 1} + } + elif is_live_source or url_count >= 2: + colors = ["#FF6B6B", "#4ECDC4", "#FFD93D", "#6BCB77", "#9D65C9", "#FF8C42", "#A2D729", "#FF6B8B", "#45B7D1", "#96CEB4"] + color = colors[hash(f['name']) % len(colors)] + first_char = f['name'][0].upper() if f['name'] else "T" + icon_svg = self._generate_colored_icon(color, first_char) + return { + 'vod_id': self.LIST_PREFIX + self.b64u_encode(f['path']), + 'vod_name': f['name'], + 'vod_pic': icon_svg, + 'vod_play_url': f"播放${self.LIST_PREFIX + self.b64u_encode(f['path'])}", + 'vod_remarks': f'直播源 ({url_count}个链接)', + 'vod_tag': 'live_txt', + 'style': {'type': 'grid', 'ratio': 1} + } + elif is_novel: + encoded = self.b64u_encode(f['path']) + novel_url = f"{self.NOVEL_PREFIX}{encoded}" + return { + 'vod_id': novel_url, + 'vod_name': f"📖 {f['name']}", + 'vod_pic': self.file_icons['novel'], + 'vod_play_url': f"阅读${novel_url}", + 'vod_remarks': '小说', + 'vod_tag': 'novel', + 'style': {'type': 'grid', 'ratio': 1}, + 'vod_player': '书' + } + else: + encoded = self.b64u_encode(f['path']) + return { + 'vod_id': self.TEXT_PREFIX + encoded, + 'vod_name': f"📄 {f['name']}", + 'vod_pic': self.file_icons['text'], + 'vod_remarks': '文本文件', + 'vod_tag': 'text', + 'style': {'type': 'grid', 'ratio': 1} + } + if f['ext'] in ['xml', 'html', 'htm', 'css', 'js', 'sh', 'bash']: + return { + 'vod_id': self.TEXT_PREFIX + self.b64u_encode(f['path']), + 'vod_name': f"📄 {f['name']}", + 'vod_pic': self.file_icons['text'], + 'vod_remarks': '代码文件', + 'vod_tag': 'code', + 'style': {'type': 'grid', 'ratio': 1} + } + if f['ext']: + return { + 'vod_id': f['path'], + 'vod_name': f"📁 {f['name']}", + 'vod_pic': self.file_icons['file'], + 'vod_remarks': f'{f["ext"].upper()}文件', + 'vod_tag': 'unknown', + 'style': {'type': 'grid', 'ratio': 1} + } + else: + return { + 'vod_id': f['path'], + 'vod_name': f"📁 {f['name']}", + 'vod_pic': self.file_icons['file'], + 'vod_remarks': '无扩展名', + 'vod_tag': 'unknown', + 'style': {'type': 'grid', 'ratio': 1} + } + + # ==================== detailContent ==================== + + def detailContent(self, ids): + id_val = ids[0] + if id_val in ['web_action', 'web_tool', 'web_bookmarks']: + return self.web_action_browser.get_category_content(id_val, 1) + if id_val.startswith('{') and ('actionId' in id_val or '"action"' in id_val): + try: + result = self.web_action_browser.handle_action(id_val) + if result: + return result + except: + pass + if id_val.startswith('{') and '"actionId":' in id_val: + try: + action_config = json.loads(id_val) + if action_config.get('actionId') == '单项输入': + return {'list': []} + except: + pass + if id_val.endswith('.epub') or id_val.endswith('.pdf'): + if os.path.isfile(id_val) and ComicReader.is_supported(os.path.basename(id_val)): + result = ComicReader.get_comic_detail(id_val, os.path.basename(id_val)) + if result and result.get('list'): + return result + if id_val == 'select_mode_status': + self._disable_select_mode() + if self.select_mode_dir and os.path.isdir(self.select_mode_dir): + return self.categoryContent(self.select_mode_dir, self._current_page, None, None) + return {'list': []} + if id_val == 'delete_mode_status': + self._disable_delete_mode() + current_dir = getattr(self, '_pending_return_dir', None) + if current_dir and os.path.isdir(current_dir): + return { + 'list': [{ + 'vod_id': self.FOLDER_PREFIX + self.b64u_encode(current_dir), + 'vod_name': '🟢 删除模式已关闭,点击返回目录', + 'vod_pic': self._generate_colored_icon("#4CAF50", "✓"), + 'vod_remarks': '返回后删除模式已关闭', + 'style': {'type': 'list'}, + 'vod_player': '书' + }], + 'page': self._current_page, + 'pagecount': 1 + } + else: + return { + 'list': [{ + 'vod_id': 'delete_mode_closed', + 'vod_name': '🟢 删除模式已关闭', + 'vod_pic': self._generate_colored_icon("#4CAF50", "✓"), + 'vod_remarks': '现在点击文件正常打开,不会删除', + 'style': {'type': 'list'}, + 'vod_player': '书' + }], + 'page': self._current_page, + 'pagecount': 1 + } + system_status_ids = [ + 'delete_mode_status', 'filter_info', 'clear_result', + 'clear_lyrics_result', 'clear_radio_result', 'empty_trash_result', + 'delete_result', 'info', 'delete_mode_closed', 'clear_comic_result', 'action_result', + 'select_result', 'select_mode_empty', 'select_mode_status', 'select_mode_error', + 'current_file_display' + ] + if id_val in system_status_ids: + return {'list': [{ + 'vod_id': 'info', + 'vod_name': 'ℹ️ 系统状态提示', + 'vod_pic': self._generate_colored_icon("#2196F3", "ℹ️"), + 'vod_remarks': '这是状态提示信息,不可删除', + 'style': {'type': 'list'}, + 'vod_player': '书' + }]} + if id_val.startswith('delete_file_'): + encoded_path = id_val[len('delete_file_'):] + file_path = self.b64u_decode(encoded_path) + success, msg = self._delete_to_trash(file_path) + current_pg = self._current_page + if success: + dir_path = os.path.dirname(file_path) + self.dir_cache.pop(f"dir_{dir_path}", None) + self.dir_cache_time.pop(f"dir_{dir_path}", None) + self.audio_list_cache.pop(dir_path, None) + self.audio_list_cache_time.pop(dir_path, None) + self._pending_return_dir = dir_path + return self._create_delete_result_with_back(msg, dir_path, current_pg) + else: + return self._create_delete_result(msg, False) + if id_val.startswith('delete_folder_'): + encoded_path = id_val[len('delete_folder_'):] + folder_path = self.b64u_decode(encoded_path) + success, msg = self._delete_folder_to_trash(folder_path) + current_pg = self._current_page + if success: + parent_dir = os.path.dirname(folder_path) + self.dir_cache.pop(f"dir_{parent_dir}", None) + self.dir_cache_time.pop(f"dir_{parent_dir}", None) + self._pending_return_dir = parent_dir + return self._create_delete_result_with_back(msg, parent_dir, current_pg) + else: + return self._create_delete_result(msg, False) + if id_val.startswith("short_video_"): + encoded_url = id_val[len("short_video_"):] + return self._short_video_detail(encoded_url) + try: + radio_id = int(id_val) + if radio_id > 100: + return self._radio_detail_content(str(radio_id)) + except: + pass + if id_val.startswith("radio_play_"): + radio_id = id_val[len("radio_play_"):] + return self._radio_detail_content(radio_id) + if id_val.startswith("gallery_"): + parts = id_val.split('_', 2) + if len(parts) >= 3: + return self._gallery_detail(parts[1], parts[2]) + if id_val == 'add_custom_api': + return self._handle_add_custom_api() + if id_val.startswith(self.LIVE_PREFIX): + source_id = self.b64u_decode(id_val[len(self.LIVE_PREFIX):]) + return self._live_source_detail(source_id) + if id_val.startswith(self.NOVEL_PREFIX): + encoded = id_val[len(self.NOVEL_PREFIX):] + file_path = self.b64u_decode(encoded) + self.novel_path_cache[encoded] = file_path + vod_data = self._handle_novel_detail(file_path, id_val, encoded) + if vod_data and "list" in vod_data and len(vod_data["list"]) > 0: + vod_data["list"][0]["vod_player"] = "书" + return vod_data + if id_val.startswith(self.MAGNET_PREFIX): + file_path = self.b64u_decode(id_val[len(self.MAGNET_PREFIX):]) + return self._handle_magnet_detail(file_path, id_val) + if id_val.startswith(self.TEXT_PREFIX): + encoded = id_val[len(self.TEXT_PREFIX):] + file_path = self.b64u_decode(encoded) + vod_data = self._handle_text_detail(file_path, id_val) + if vod_data and "list" in vod_data and len(vod_data["list"]) > 0: + vod_data["list"][0]["vod_player"] = "书" + return vod_data + if id_val.startswith(self.FOLDER_PREFIX): + folder_path = self.b64u_decode(id_val[len(self.FOLDER_PREFIX):]) + if os.path.exists(folder_path) and os.path.isdir(folder_path): + return self.categoryContent(folder_path, 1, None, None) + return {'list': []} + if id_val.startswith(self.PICS_PREFIX + 'slideshow/'): + dir_path = self.b64u_decode(id_val[len(self.PICS_PREFIX + 'slideshow/'):]) + images = self.collect_images_in_dir(dir_path) + if not images: + return {'list': []} + pic_urls = [f"file://{img['path']}" for img in images] + vod = { + 'vod_id': id_val, + 'vod_name': f"🖼 图片连播 - {os.path.basename(dir_path)} ({len(images)}张)", + 'vod_pic': self.file_icons['image_playlist'], + 'vod_play_from': '图片浏览', + 'vod_play_url': f"浏览${self.PICS_PREFIX + '&&'.join(pic_urls)}", + 'style': {'type': 'list'}, + 'vod_player': '画' + } + return {'list': [vod]} + if id_val.startswith(self.URL_B64U_PREFIX): + decoded = self.b64u_decode(id_val[len(self.URL_B64U_PREFIX):]) + if decoded and decoded.startswith(self.PICS_PREFIX): + return self._handle_pics_detail(decoded, id_val) + if id_val.startswith(self.CAMERA_ALL_PREFIX): + dir_path = self.b64u_decode(id_val[len(self.CAMERA_ALL_PREFIX):]) + images = self.collect_images_in_dir(dir_path) + if not images: + return {'list': []} + pic_urls = [f"file://{img['path']}" for img in images] + vod = { + 'vod_id': id_val, + 'vod_name': f"🖼 相机照片 ({len(images)}张)", + 'vod_pic': self.file_icons['image_playlist'], + 'vod_play_from': '照片查看', + 'vod_play_url': f"浏览${self.PICS_PREFIX + '&&'.join(pic_urls)}", + 'style': {'type': 'list'}, + 'vod_player': '画' + } + return {'list': [vod]} + if id_val.startswith(self.LIST_PREFIX): + file_path = self.b64u_decode(id_val[len(self.LIST_PREFIX):]) + return self._handle_list_detail(file_path, id_val) + if id_val.startswith(self.A_ALL_PREFIX): + dir_path = self.b64u_decode(id_val[len(self.A_ALL_PREFIX):]) + return self._handle_audio_all_detail(dir_path, id_val) + if id_val.startswith(self.V_ALL_PREFIX): + dir_path = self.b64u_decode(id_val[len(self.V_ALL_PREFIX):]) + return self._handle_video_all_detail(dir_path, id_val) + if os.path.isfile(id_val) and ComicReader.is_supported(os.path.basename(id_val)): + return ComicReader.get_comic_detail(id_val, os.path.basename(id_val)) + if not os.path.exists(id_val): + return {'list': []} + if os.path.isdir(id_val): + return self.categoryContent(id_val, 1, None, None) + return self._handle_file_detail(id_val) + + def _handle_pics_detail(self, decoded, id_val): + pics_data = decoded[len(self.PICS_PREFIX):] + if '&&' in pics_data: + pic_urls = pics_data.split('&&') + return {'list': [{ + 'vod_id': id_val, + 'vod_name': f'图片相册 ({len(pic_urls)}张)', + 'vod_pic': pic_urls[0], + 'vod_play_from': '图片查看', + 'vod_play_url': f"浏览${self.PICS_PREFIX + '&&'.join(pic_urls)}", + 'style': {'type': 'list'}, + 'vod_player': '画' + }]} + else: + file_name = os.path.basename(pics_data.split('?')[0]) + if pics_data.startswith('file://'): + file_name = os.path.basename(pics_data[7:]) + return {'list': [{ + 'vod_id': id_val, + 'vod_name': file_name, + 'vod_pic': pics_data, + 'vod_play_from': '图片查看', + 'vod_play_url': f"查看${self.PICS_PREFIX + pics_data}", + 'style': {'type': 'list'}, + 'vod_player': '画' + }]} + + def _handle_add_custom_api(self): + return {'list': [{ + 'vod_id': 'add_custom_api_result', + 'vod_name': '📝 添加自定义API说明', + 'vod_pic': self.file_icons['text'], + 'vod_content': '请在 /storage/emulated/0/custom_video_apis.json 文件中添加自定义API\n\n格式示例:\n[\n {"name": "我的API", "url": "https://example.com/api"}\n]\n\n添加后重启即可生效。', + 'vod_remarks': '配置文件路径: /storage/emulated/0/custom_video_apis.json', + 'style': {'type': 'list'} + }]} + + def _handle_list_detail(self, file_path, vod_id): + if not os.path.exists(file_path) or not os.path.isfile(file_path): + return {'list': []} + ext = self.get_file_ext(file_path) + items = [] + if ext == 'json': + items = self.parse_json_file(file_path) + elif self.is_db_file(ext): + items = self.parse_db_file(file_path) + elif ext in ['m3u', 'm3u8']: + items = self._parse_m3u_file(file_path) + if len(items) > 5: + return self._format_live_source(items, file_path, vod_id, ext) + elif ext == 'txt': + items = self._parse_txt_file(file_path) + if not items: + return [] + if len(items) > 5: + return self._format_live_source(items, file_path, vod_id, ext) + if not items: + name = os.path.splitext(os.path.basename(file_path))[0] + return {'list': [self._create_fallback_vod(file_path, 'list', vod_id, name)]} + play_urls = self._build_play_urls(items) + if not play_urls: + return {'list': [self._create_fallback_vod(file_path, 'list', vod_id, os.path.splitext(os.path.basename(file_path))[0])]} + pic = items[0].get('pic', '') if items else '' + if not pic: + pic = self.file_icons['list'] + return {'list': [{ + 'vod_id': vod_id, + 'vod_name': os.path.basename(file_path), + 'vod_pic': pic, + 'vod_play_from': '播放列表', + 'vod_play_url': '#'.join(play_urls), + 'vod_remarks': f'共{len(items)}条', + 'style': {'type': 'list'} + }]} + + def _format_live_source(self, items, file_path, vod_id, ext): + channels = {} + for item in items: + name = item.get('name', '').strip() + url = item.get('url', '') + if not name or not url: + continue + clean_name = re.sub(r'^\[[^\]]+\]\s*', '', name) + clean_name = re.sub(r'\s*[\[\((]\s*\d+\s*[\]\))]\s*$', '', clean_name) + clean_name = re.sub(r'\s*[线|L|l]ine?\s*\d+$', '', clean_name, flags=re.I) + clean_name = clean_name.strip() + if clean_name not in channels: + channels[clean_name] = [] + channels[clean_name].append(url) + if not channels: + play_urls = self._build_play_urls(items) + pic = items[0].get('pic', '') if items else self.file_icons['list'] + return {'list': [{ + 'vod_id': vod_id, + 'vod_name': os.path.basename(file_path), + 'vod_pic': pic, + 'vod_play_from': '播放列表', + 'vod_play_url': '#'.join(play_urls), + 'vod_remarks': f'共{len(items)}条', + 'style': {'type': 'list'} + }]} + max_lines = max(len(urls) for urls in channels.values()) + original_max_lines = max_lines + if max_lines > 1: + max_lines = 1 + from_list = [] + url_list = [] + for line_idx in range(max_lines): + line_name = f"线路{line_idx + 1}" + channel_urls = [] + for channel_name, urls in channels.items(): + if line_idx < len(urls): + channel_urls.append(f"{channel_name}${urls[line_idx]}") + if channel_urls: + from_list.append(line_name) + url_list.append('#'.join(channel_urls)) + if not from_list: + play_urls = self._build_play_urls(items) + pic = items[0].get('pic', '') if items else self.file_icons['list'] + return {'list': [{ + 'vod_id': vod_id, + 'vod_name': os.path.basename(file_path), + 'vod_pic': pic, + 'vod_play_from': '播放列表', + 'vod_play_url': '#'.join(play_urls), + 'vod_remarks': f'共{len(items)}条', + 'style': {'type': 'list'} + }]} + colors = ["#FF6B6B", "#4ECDC4", "#FFD93D", "#6BCB77", "#9D65C9", "#FF8C42", "#A2D729", "#FF6B8B", "#45B7D1", "#96CEB4"] + color = colors[hash(os.path.basename(file_path)) % len(colors)] + first_char = os.path.basename(file_path)[0].upper() if os.path.basename(file_path) else "L" + icon_svg = self._generate_colored_icon(color, first_char) + current_date = time.strftime('%Y.%m.%d', time.localtime()) + total_channels = len(channels) + total_programs = sum(len(urls) for urls in channels.values()) + remarks = f'更新时间{current_date}' + if original_max_lines > 1: + remarks += f' (仅显示第1条线路)' + return {'list': [{ + 'vod_id': vod_id, + 'vod_name': os.path.basename(file_path), + 'vod_pic': icon_svg, + 'vod_play_from': '$$$'.join(from_list), + 'vod_play_url': '$$$'.join(url_list), + 'vod_remarks': remarks, + 'vod_content': f'共 {total_channels} 个频道,{total_programs} 条节目线路', + 'style': {'type': 'list'}, + 'type': 'live', + 'vod_type': 4, + 'vod_class': 'live', + 'vod_style': {'type': 'live'}, + 'playerType': 2 + }]} + + def _parse_m3u_file(self, file_path): + items = [] + try: + with open(file_path, 'r', encoding='utf-8', errors='ignore') as f: + lines = f.readlines() + current_name = None + for line in lines[:10000]: + line = line.strip() + if line.startswith('#EXTINF:'): + name_match = re.search(r',(.+)$', line) or re.search(r'tvg-name="([^"]+)"', line) + current_name = name_match.group(1).strip() if name_match else None + elif line and not line.startswith('#'): + if self.is_playable_url(line): + items.append({'name': current_name or f"线路{len(items)+1}", 'url': line}) + current_name = None + except: + pass + return items + + def _handle_novel_detail(self, file_path, vod_id, encoded): + if not os.path.isfile(file_path): + return {'list': []} + cache_key = f"chapters_{encoded}" + if cache_key in self.novel_chapters_cache: + chapters = self.novel_chapters_cache[cache_key] + else: + chapters = NovelParser.parse_txt_novel(file_path) + self.novel_chapters_cache[cache_key] = chapters + self.current_novel = {'encoded_path': encoded, 'file_path': file_path, 'chapters': chapters} + urls = [] + for i, c in enumerate(chapters): + title = c['title'] + if title and len(title) > 2 and not title.strip().isdigit(): + urls.append(f"{title}$chapter{i}") + return {'list': [{ + 'vod_id': vod_id, + 'vod_name': os.path.basename(file_path), + 'vod_pic': self.file_icons['novel'], + 'vod_play_from': '小说章节', + 'vod_play_url': '#'.join(urls), + 'vod_remarks': f'共{len(chapters)}章', + 'style': {'type': 'list'} + }]} + + def _handle_text_detail(self, file_path, vod_id): + if not os.path.isfile(file_path): + return {'list': []} + try: + with open(file_path, 'r', encoding='utf-8', errors='ignore') as f: + content = f.read() + return {'list': [{ + 'vod_id': vod_id, + 'vod_name': os.path.basename(file_path), + 'vod_pic': self.file_icons['text'], + 'vod_play_from': '文本阅读', + 'vod_play_url': f"阅读${vod_id}", + 'vod_content': content[:5000], + 'vod_remarks': f'共{len(content)}字', + 'style': {'type': 'list'} + }]} + except: + return {'list': []} + + def _create_fallback_vod(self, file_path, file_type, vod_id, name=None): + return { + 'vod_id': vod_id, + 'vod_name': os.path.basename(file_path), + 'vod_pic': self.file_icons[file_type], + 'vod_play_from': file_type, + 'vod_play_url': f"{name or os.path.splitext(os.path.basename(file_path))[0]}$file://{file_path}", + 'style': {'type': 'list'} + } + + def _build_play_urls(self, items): + play_urls = [] + for item in items: + url = item.get('url') or item.get('play_url', '') + if url: + name = item.get('name', '').strip() + if not name: + name = f"节目{len(play_urls)+1}" + name = re.sub(r'[#$]', '', name) + if '$$$' in url: + first_url = url.split('$$$')[0] + if '$' in first_url: + url_parts = first_url.split('$', 1) + if len(url_parts) == 2: + play_urls.append(f"{name}${url_parts[1]}") + else: + play_urls.append(f"{name}${first_url}") + else: + play_urls.append(f"{name}${first_url}") + else: + play_urls.append(f"{name}${url}") + return play_urls + + def _handle_audio_all_detail(self, dir_path, vod_id): + audios = self.collect_audios_in_dir(dir_path) + if not audios: + return {'list': []} + audios.sort(key=lambda x: x['name']) + play_urls = [] + for audio in audios: + name = os.path.splitext(audio['name'])[0] + if len(name) > 50: + name = name[:47] + '...' + play_urls.append(f"{name}${self.MP3_PREFIX + audio['path']}") + return {'list': [{ + 'vod_id': vod_id, + 'vod_name': f"🎵 {os.path.basename(dir_path)} ({len(audios)}首)", + 'vod_pic': self.DEFAULT_AUDIO_ICON, + 'vod_play_from': '本地音乐', + 'vod_play_url': '#'.join(play_urls), + 'vod_remarks': f'共{len(audios)}首', + 'style': {'type': 'list'}, + 'vod_player': '听' + }]} + + def _handle_video_all_detail(self, dir_path, vod_id): + videos = self.collect_videos_in_dir(dir_path) + if not videos: + return {'list': []} + play_urls = [f"{os.path.splitext(v['name'])[0]}$file://{v['path']}" for v in videos] + return {'list': [{ + 'vod_id': vod_id, + 'vod_name': f"🎬 视频播放列表 - {os.path.basename(dir_path)} ({len(videos)}集)", + 'vod_pic': self.file_icons['video_playlist'], + 'vod_play_from': '本地视频', + 'vod_play_url': '#'.join(play_urls), + 'vod_remarks': f'共{len(videos)}集', + 'style': {'type': 'list'} + }]} + + def _handle_file_detail(self, file_path): + name = os.path.basename(file_path) + ext = self.get_file_ext(name) + vod = {'vod_id': file_path, 'vod_name': name, 'vod_play_from': '本地播放', 'vod_play_url': '', 'style': {'type': 'list'}} + if self.is_audio_file(ext): + return self._handle_audio_file_detail(file_path, name, vod) + if ComicReader.is_supported(name): + return ComicReader.get_comic_detail(file_path, name) + if self.is_image_file(ext) or ext.lower() in ['heic', 'heif']: + dir_path = os.path.dirname(file_path) + all_images = self.collect_images_in_dir(dir_path) + if len(all_images) > 1: + clicked_index = -1 + for i, img in enumerate(all_images): + if img['path'] == file_path: + clicked_index = i + break + reordered_images = [] + if clicked_index >= 0: + for i in range(clicked_index, len(all_images)): + reordered_images.append(all_images[i]) + for i in range(0, clicked_index): + reordered_images.append(all_images[i]) + else: + reordered_images = all_images + pic_urls = [f"file://{img['path']}" for img in reordered_images] + vod.update({ + 'vod_play_url': f"浏览${self.PICS_PREFIX + '&&'.join(pic_urls)}", + 'vod_name': f"🖼 {name} (当前目录 {len(all_images)}张)", + 'vod_pic': f"file://{file_path}", + 'vod_play_from': '图片浏览', + 'vod_remarks': f'共{len(all_images)}张照片,循环播放', + 'vod_player': '画' + }) + else: + vod.update({ + 'vod_play_url': f"查看${self.PICS_PREFIX}file://{file_path}", + 'vod_pic': f"file://{file_path}", + 'vod_name': f"🖼️ {name}", + 'vod_player': '画' + }) + elif self.is_media_file(ext): + dir_path = os.path.dirname(file_path) + all_videos = self.collect_videos_in_dir(dir_path) + if len(all_videos) > 1: + clicked_index = -1 + for i, video in enumerate(all_videos): + if video['path'] == file_path: + clicked_index = i + break + reordered_videos = [] + if clicked_index >= 0: + for i in range(clicked_index, len(all_videos)): + reordered_videos.append(all_videos[i]) + for i in range(0, clicked_index): + reordered_videos.append(all_videos[i]) + else: + reordered_videos = all_videos + play_urls = [f"{os.path.splitext(v['name'])[0]}$file://{v['path']}" for v in reordered_videos] + vod.update({ + 'vod_play_url': '#'.join(play_urls), + 'vod_name': f"🎬 {name} (当前目录 {len(all_videos)}集)", + 'vod_pic': self.file_icons['video_playlist'], + 'vod_play_from': '本地视频', + 'vod_remarks': f'共{len(all_videos)}集,循环播放' + }) + else: + vod.update({ + 'vod_play_url': f"{os.path.splitext(name)[0]}$file://{file_path}", + 'vod_name': f"🎬 {name}", + 'vod_pic': self.file_icons['video'] + }) + elif self.is_list_file(ext) or self.is_db_file(ext) or self.is_magnet_file(ext) or ext in ['xlsx', 'xls']: + prefix = self.MAGNET_PREFIX if (self.is_magnet_file(ext) or ext in ['xlsx', 'xls']) else self.LIST_PREFIX + return self.detailContent([prefix + self.b64u_encode(file_path)]) + elif ext == 'php' or ext == 'py': + return self.detailContent([self.TEXT_PREFIX + self.b64u_encode(file_path)]) + elif ext == 'zip' or ext in ['rar', '7z', 'tar', 'gz', 'bz2', 'xz']: + vod.update({ + 'vod_play_url': f"{os.path.splitext(name)[0]}$file://{file_path}", + 'vod_name': f"🗜️ {name}", + 'vod_pic': self.file_icons['zip'] if ext == 'zip' else self.file_icons['archive'], + 'vod_remarks': f'{ext.upper()}压缩包' + }) + elif ext == 'txt': + preview = '' + try: + with open(file_path, 'r', encoding='utf-8', errors='ignore') as f: + preview = f.read(4096) + except: + pass + is_live = self._is_txt_live_source(preview, file_path) + is_novel = self._is_txt_novel(preview, file_path) if not is_live else False + is_magnet = self._is_magnet_txt_file(file_path) + + if is_magnet: + return self.detailContent([self.MAGNET_PREFIX + self.b64u_encode(file_path)]) + elif is_live: + return self.detailContent([self.LIST_PREFIX + self.b64u_encode(file_path)]) + elif is_novel: + return self.detailContent([self.NOVEL_PREFIX + self.b64u_encode(file_path)]) + else: + return self.detailContent([self.TEXT_PREFIX + self.b64u_encode(file_path)]) + else: + return {'list': [vod]} + return {'list': [vod]} + + def _handle_audio_file_detail(self, file_path, name, vod): + dir_path = os.path.dirname(file_path) + all_audios = self.collect_audios_in_dir(dir_path) + cover_url = self.get_audio_cover_ultra_fast(file_path) + if cover_url and cover_url != self.DEFAULT_AUDIO_ICON and len(cover_url) > 50: + display_pic = cover_url + else: + color = self.default_colors[hash(name) % len(self.default_colors)] + first_char = name[0] if name else "🎵" + display_pic = self._generate_colored_icon(color, first_char) + if len(all_audios) > 1: + clicked_index = -1 + for i, audio in enumerate(all_audios): + if audio['path'] == file_path: + clicked_index = i + break + reordered_audios = [] + if clicked_index >= 0: + reordered_audios.extend(all_audios[clicked_index:]) + reordered_audios.extend(all_audios[:clicked_index]) + else: + reordered_audios = all_audios + if len(reordered_audios) > 500: + reordered_audios = reordered_audios[:500] + play_urls = [] + for audio in reordered_audios: + audio_name = os.path.splitext(audio['name'])[0] + if len(audio_name) > 50: + audio_name = audio_name[:47] + '...' + play_urls.append(f"{audio_name}${self.MP3_PREFIX + audio['path']}") + vod.update({ + 'vod_play_url': '#'.join(play_urls), + 'vod_name': f"🎵 {name} (共{len(all_audios)}首)", + 'vod_pic': display_pic, + 'vod_play_from': '本地音乐', + 'vod_remarks': f'共{len(all_audios)}首', + 'vod_player': '听' + }) + else: + vod.update({ + 'vod_play_url': f"{os.path.splitext(name)[0]}${self.MP3_PREFIX + file_path}", + 'vod_name': f"🎵 {name}", + 'vod_pic': display_pic, + 'vod_player': '听' + }) + return {'list': [vod]} + + def playerContent(self, flag, id, vipFlags): + if id and id.startswith('ed2k://'): + return { + "parse": 0, + "playUrl": "", + "url": id, + "header": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", + "Accept": "*/*" + } + } + + if id.startswith('pics://'): + return {"parse": 0, "playUrl": "", "url": id, "header": {}, "vod_player": "画"} + if '|UAINFO|' in id: + parts = id.split('|UAINFO|') + real_url = parts[0] + ua_info_json = parts[1] + try: + ua_info = json.loads(self.b64u_decode(ua_info_json)) + custom_ua = ua_info.get('ua', '') + custom_referer = ua_info.get('referer', '') + headers = { + "User-Agent": custom_ua if custom_ua else "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", + "Accept": "*/*", + "Connection": "keep-alive" + } + if custom_referer: + headers["Referer"] = custom_referer + if custom_ua: + print(f"🎬 [直播播放UA生效] {custom_ua[:50]}...") + else: + print(f"🎬 [直播播放] 使用默认UA") + return { + "parse": 0, + "playUrl": "", + "url": real_url, + "header": headers + } + except Exception as e: + print(f"❌ 解析UA信息失败: {e}") + id = id.split('|UAINFO|')[0] + if id.startswith(self.MP3_PREFIX): + song_key = id + current_time = time.time() + if song_key in self.click_timer: + time_diff = current_time - self.click_timer[song_key] + if time_diff < 2: + self.click_count[song_key] = self.click_count.get(song_key, 0) + 1 + else: + self.click_count[song_key] = 1 + else: + self.click_count[song_key] = 1 + self.click_timer[song_key] = current_time + count = self.click_count.get(song_key, 1) + print(f"🎵 连点检测: 第{count}次点击 - {id[:80]}") + if count >= 3: + self.click_count[song_key] = 0 + if song_key in self.click_timer: + del self.click_timer[song_key] + if id.startswith(self.MP3_PREFIX): + file_path = id.replace(self.MP3_PREFIX, '') + if os.path.exists(file_path): + success, msg = self._delete_to_trash(file_path) + self.audio_list_cache.clear() + self.audio_list_cache_time.clear() + self.dir_cache.clear() + self.dir_cache_time.clear() + if success: + return { + "parse": 0, + "playUrl": "", + "url": "", + "header": {}, + "vod_player": "听" + } + if flag == '蜻蜓FM': + try: + raw = self.d64(id).split("@@@@")[-1] + url = raw.split("|||")[0] if "|||" in raw else raw + url = url.replace(r"\/", "/") + return { + "parse": 0, + "playUrl": "", + "url": url, + "header": { + "User-Agent": self.headers["User-Agent"], + "Referer": "http://www.qingting.fm/", + "Accept": "*/*" + }, + "vod_player": "听" + } + except: + return {"parse": 0, "playUrl": "", "url": "", "header": self.headers} + if flag == '短视频播放': + return self._handle_short_video_play(id) + if flag in ['PDF漫画', 'EPUB漫画']: + return {"parse": 0, "playUrl": "", "url": id, "header": {}, "vod_player": "画"} + if id.startswith(self.PICS_PREFIX): + return {"parse": 0, "playUrl": "", "url": id, "header": {}, "vod_player": "画"} + if id.startswith(self.TEXT_PREFIX): + encoded = id[len(self.TEXT_PREFIX):] + file_path = self.b64u_decode(encoded) + try: + with open(file_path, 'r', encoding='utf-8', errors='ignore') as f: + content = f.read() + data = {"title": os.path.basename(file_path), "content": content} + return { + "parse": 0, + "playUrl": "", + "url": self.TEXT_PREFIX + json.dumps(data, ensure_ascii=False), + "header": "", + "content": content, + "vod_player": "书" + } + except: + return {"parse": 0, "playUrl": "", "url": "", "header": ""} + if id.startswith(self.MP3_PREFIX): + return self._handle_mp3_play(id) + if id.startswith(self.NOVEL_PREFIX): + full_id = id[len(self.NOVEL_PREFIX):] + chapter_index = 0 + encoded_path = full_id + if '#chapter' in full_id: + parts = full_id.split('#chapter', 1) + encoded_path = parts[0] + try: + chapter_index = int(parts[1]) + except: + chapter_index = 0 + if encoded_path in self.novel_path_cache: + file_path = self.novel_path_cache[encoded_path] + else: + file_path = self.b64u_decode(encoded_path) + self.novel_path_cache[encoded_path] = file_path + try: + cache_key = f"chapters_{encoded_path}" + if cache_key in self.novel_chapters_cache: + chapters = self.novel_chapters_cache[cache_key] + else: + chapters = NovelParser.parse_txt_novel(file_path) + self.novel_chapters_cache[cache_key] = chapters + if chapters and 0 <= chapter_index < len(chapters): + title = chapters[chapter_index]['title'] + content = chapters[chapter_index]['content'] + else: + with open(file_path, 'r', encoding='utf-8', errors='ignore') as f: + content = f.read() + title = os.path.basename(file_path) + data = {"title": title, "content": content} + return { + "parse": 0, + "playUrl": "", + "url": "novel://" + json.dumps(data, ensure_ascii=False), + "header": "", + "content": content, + "vod_player": "书" + } + except: + return {"parse": 0, "playUrl": "", "url": "", "header": ""} + if id.startswith('chapter') and flag == '小说章节' and self.current_novel['chapters']: + try: + idx = int(id.replace('chapter', '')) + if 0 <= idx < len(self.current_novel['chapters']): + c = self.current_novel['chapters'][idx] + data = {"title": c['title'], "content": c['content']} + return { + "parse": 0, + "playUrl": "", + "url": "novel://" + json.dumps(data, ensure_ascii=False), + "header": "", + "vod_player": "书" + } + except: + pass + url = id + if '$' in url: + parts = url.split('$', 1) + if len(parts) == 2: + url = parts[1] + if url.startswith(('http://', 'https://', 'file://')): + pass + else: + try: + decoded = base64.b64decode(url).decode('utf-8') + if decoded.startswith(('http://', 'https://', 'file://')): + url = decoded + except: + pass + if 'dytt-' in url and '/share/' in url and not url.endswith('.m3u8'): + real_url = self._extract_real_m3u8_url(url) + if real_url: + url = real_url + headers = self._build_headers(flag, url) + result = {"parse": 0, "playUrl": "", "url": url, "header": headers} + if url.startswith('file://'): + file_path = url[7:] + if os.path.exists(file_path) and self.is_audio_file(self.get_file_ext(file_path)): + self._add_audio_info_fast(result, file_path) + if url.startswith('file://'): + ext = self.get_file_ext(url[7:]) + if self.is_image_file(ext) or ext.lower() in ['heic', 'heif'] or ComicReader.is_supported(url[7:]): + result["vod_player"] = "画" + return result + + def _handle_mp3_play(self, id): + file_path = id.replace(self.MP3_PREFIX, '') + if not os.path.exists(file_path): + test_paths = [file_path, '/storage/emulated/0/' + file_path.lstrip('/'), file_path.replace('//', '/')] + for test_path in test_paths: + if os.path.exists(test_path): + file_path = test_path + break + else: + return {"parse": 0, "playUrl": "", "url": "", "header": {}, "error": "文件不存在"} + if not os.access(file_path, os.R_OK): + return {"parse": 0, "playUrl": "", "url": "", "header": {}, "error": "文件无法读取"} + play_url = f"http://127.0.0.1:9978/file{file_path}" + result = {"parse": 0, "playUrl": "", "url": play_url, "header": {}, "vod_player": "听"} + if self.is_audio_file(self.get_file_ext(file_path)): + self._add_audio_info_fast(result, file_path) + return result + + def _extract_real_m3u8_url(self, page_url): + if page_url in self.m3u8_cache: + return self.m3u8_cache[page_url] + try: + from urllib.parse import urlparse + parsed = urlparse(page_url) + base_url = f"{parsed.scheme}://{parsed.netloc}" + headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", "Referer": base_url + "/"} + response = self.session.get(page_url, headers=headers, timeout=10) + if response.status_code != 200: + return None + html = response.text + patterns = [ + r'(https?://[^\s"\']+\.m3u8[^\s"\']*)', + r'(//[^\s"\']+\.m3u8[^\s"\']*)', + r'url["\']?\s*[:=]\s*["\']([^"\']+\.m3u8[^"\']*)["\']', + ] + for pattern in patterns: + matches = re.findall(pattern, html, re.IGNORECASE) + if matches: + url = matches[0] + if url.startswith('//'): + url = 'https:' + url + elif url.startswith('/'): + url = base_url + url + self.m3u8_cache[page_url] = url + return url + return None + except: + return None + + def _build_headers(self, flag, url): + from urllib.parse import urlparse + domain = urlparse(url).netloc + headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", "Accept": "*/*"} + if flag == 'migu_live': + headers.update({"User-Agent": "com.android.chrome/3.7.0 (Linux;Android 15)", "Referer": "https://www.miguvideo.com/"}) + elif flag == 'gongdian_live': + headers.update({"Referer": "https://gongdian.top/"}) + if 't.061899.xyz' in domain: + headers.update({"User-Agent": "okhttp/3.12.11", "Referer": "http://t.061899.xyz/"}) + elif 'rihou.cc' in domain: + headers.update({"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", "Referer": "https://rihou.cc:555/"}) + elif 'miguvideo.com' in domain: + headers.update({"User-Agent": "com.android.chrome/3.7.0 (Linux;Android 15)", "Referer": "https://www.miguvideo.com/"}) + elif 'gongdian.top' in domain: + headers.update({"Referer": "https://gongdian.top/"}) + elif domain: + headers["Referer"] = f"https://{domain}/" + return headers + + def searchContent(self, key, quick, pg="1"): + pg = int(pg) + results = [] + clean_key = re.sub(r'^[📁📂🎬🎵🖼📋📝🗄️🧲📄🖼️🎞️⬅️📚\s]+', '', key.lower()) + for path in self.root_paths: + if not os.path.exists(path): + continue + all_files = [] + self._scan_for_search(path, all_files) + for f in all_files: + if clean_key in f['name'].lower(): + item = self._create_search_item(f) + if item: + results.append(item) + results.sort(key=lambda x: (clean_key not in x['vod_name'].lower(), x['vod_name'])) + per_page = 50 + start = (pg - 1) * per_page + end = min(start + per_page, len(results)) + return {'list': results[start:end], 'page': pg, 'pagecount': (len(results) + per_page - 1) // per_page, 'limit': per_page, 'total': len(results)} + + def _scan_for_search(self, path, file_list, depth=0, max_depth=3): + if depth > max_depth: + return + try: + audio_names = [] + try: + for name in os.listdir(path): + if name.startswith('.'): + continue + full_path = os.path.join(path, name) + if not os.path.isdir(full_path): + ext = self.get_file_ext(name) + if ext in self.audio_exts: + audio_names.append(os.path.splitext(name)[0]) + except: + pass + for name in os.listdir(path): + if name.startswith('.'): + continue + full_path = os.path.join(path, name) + if os.path.isdir(full_path): + self._scan_for_search(full_path, file_list, depth + 1, max_depth) + else: + if self._should_hide_file(name, path, audio_names): + continue + file_list.append({'name': name, 'path': full_path, 'ext': self.get_file_ext(name)}) + except: + pass + + def _create_search_item(self, f): + if ComicReader.is_supported(f['name']): + cover_url = ComicReader.get_cover_url(f['path']) + return { + 'vod_id': f['path'], + 'vod_name': f"📚 {f['name']}", + 'vod_pic': cover_url or ComicReader._get_default_comic_icon(f['ext']), + 'vod_play_url': f"阅读${f['path']}", + 'vod_remarks': f'{f["ext"].upper()}漫画', + 'style': {'type': 'grid', 'ratio': 0.75}, + 'vod_player': '画' + } + if self.is_image_file(f['ext']) or f['ext'].lower() in ['heic', 'heif']: + return { + 'vod_id': self.URL_B64U_PREFIX + self.b64u_encode(self.PICS_PREFIX + "file://" + f['path']), + 'vod_name': f"🖼 {f['name']}", + 'vod_pic': f"file://{f['path']}", + 'vod_play_url': f"查看${self.PICS_PREFIX}file://{f['path']}", + 'vod_remarks': '', + 'style': {'type': 'grid', 'ratio': 1}, + 'vod_player': '画' + } + if self.is_audio_file(f['ext']): + cover_url = self.get_audio_cover_ultra_fast(f['path']) + if cover_url and cover_url != self.DEFAULT_AUDIO_ICON and len(cover_url) > 50: + display_pic = cover_url + else: + color = self.default_colors[hash(f['name']) % len(self.default_colors)] + first_char = f['name'][0] if f['name'] else "🎵" + display_pic = self._generate_colored_icon(color, first_char) + return { + 'vod_id': self.URL_B64U_PREFIX + self.b64u_encode(self.MP3_PREFIX + f['path']), + 'vod_name': f"{f['name']}", + 'vod_pic': display_pic, + 'vod_play_url': f"播放${self.MP3_PREFIX + f['path']}", + 'vod_remarks': '', + 'style': {'type': 'grid', 'ratio': 1}, + 'vod_player': '听' + } + if self.is_media_file(f['ext']): + return {'vod_id': f['path'], 'vod_name': f"🎬 {f['name']}", 'vod_pic': self.file_icons['video'], 'vod_remarks': '', 'style': {'type': 'grid', 'ratio': 1}} + if self.is_lrc_file(f['ext']): + return {'vod_id': f['path'], 'vod_name': f"📝 {f['name']}", 'vod_pic': self.file_icons['lyrics'], 'vod_remarks': '', 'style': {'type': 'grid', 'ratio': 1}} + if f['ext'] == 'json': + return {'vod_id': self.LIST_PREFIX + self.b64u_encode(f['path']), 'vod_name': f"📋 {f['name']}", 'vod_pic': self.file_icons['json'], 'vod_remarks': '', 'style': {'type': 'grid', 'ratio': 1}} + if f['ext'] == 'php': + return {'vod_id': self.TEXT_PREFIX + self.b64u_encode(f['path']), 'vod_name': f"🐘 {f['name']}", 'vod_pic': self.file_icons['php'], 'vod_remarks': '', 'style': {'type': 'grid', 'ratio': 1}} + if f['ext'] == 'py': + return {'vod_id': self.TEXT_PREFIX + self.b64u_encode(f['path']), 'vod_name': f"🐍 {f['name']}", 'vod_pic': self.file_icons['python'], 'vod_remarks': '', 'style': {'type': 'grid', 'ratio': 1}} + if f['ext'] == 'zip': + return {'vod_id': f['path'], 'vod_name': f"🗜️ {f['name']}", 'vod_pic': self.file_icons['zip'], 'vod_remarks': '', 'style': {'type': 'grid', 'ratio': 1}} + if f['ext'] in ['rar', '7z', 'tar', 'gz', 'bz2', 'xz']: + icon_key = 'rar' if f['ext'] == 'rar' else 'archive' + return {'vod_id': f['path'], 'vod_name': f"🗜️ {f['name']}", 'vod_pic': self.file_icons[icon_key], 'vod_remarks': '', 'style': {'type': 'grid', 'ratio': 1}} + if f['ext'] in ['m3u', 'm3u8']: + colors = ["#FF6B6B", "#4ECDC4", "#FFD93D", "#6BCB77", "#9D65C9", "#FF8C42", "#A2D729", "#FF6B8B", "#45B7D1", "#96CEB4"] + color = colors[hash(f['name']) % len(colors)] + first_char = f['name'][0].upper() if f['name'] else "M" + icon_svg = self._generate_colored_icon(color, first_char) + return {'vod_id': self.LIST_PREFIX + self.b64u_encode(f['path']), 'vod_name': f['name'], 'vod_pic': icon_svg, 'vod_remarks': '', 'style': {'type': 'grid', 'ratio': 1}} + if self.is_db_file(f['ext']): + return {'vod_id': self.LIST_PREFIX + self.b64u_encode(f['path']), 'vod_name': f"🗄️ {f['name']}", 'vod_pic': self.file_icons['database'], 'vod_remarks': '', 'style': {'type': 'grid', 'ratio': 1}} + if self.is_magnet_file(f['ext']) or f['ext'] in ['xlsx', 'xls']: + return {'vod_id': self.MAGNET_PREFIX + self.b64u_encode(f['path']), 'vod_name': f"🧲 {f['name']}", 'vod_pic': self.file_icons['magnet'], 'vod_remarks': '', 'style': {'type': 'grid', 'ratio': 1}} + if f['ext'] == 'txt': + is_live = any(kw in f['name'].lower() for kw in self.live_keywords) + is_magnet = self._is_magnet_txt_file(f['path']) + if is_magnet: + return {'vod_id': self.MAGNET_PREFIX + self.b64u_encode(f['path']), 'vod_name': f"🧲 {f['name']}", 'vod_pic': self.file_icons['magnet'], 'vod_remarks': '', 'style': {'type': 'grid', 'ratio': 1}} + elif is_live: + colors = ["#FF6B6B", "#4ECDC4", "#FFD93D", "#6BCB77", "#9D65C9", "#FF8C42", "#A2D729", "#FF6B8B", "#45B7D1", "#96CEB4"] + color = colors[hash(f['name']) % len(colors)] + first_char = f['name'][0].upper() if f['name'] else "T" + icon_svg = self._generate_colored_icon(color, first_char) + return {'vod_id': self.LIST_PREFIX + self.b64u_encode(f['path']), 'vod_name': f['name'], 'vod_pic': icon_svg, 'vod_remarks': '', 'style': {'type': 'grid', 'ratio': 1}} + else: + encoded = self.b64u_encode(f['path']) + return {'vod_id': f"{self.NOVEL_PREFIX}{encoded}", 'vod_name': f"📖 {f['name']}", 'vod_pic': self.file_icons['novel'], 'vod_remarks': '', 'style': {'type': 'grid', 'ratio': 1}, 'vod_player': '书'} + if f['ext'] in ['xml', 'html', 'htm', 'css', 'js', 'sh', 'bash']: + return {'vod_id': self.TEXT_PREFIX + self.b64u_encode(f['path']), 'vod_name': f"📄 {f['name']}", 'vod_pic': self.file_icons['text'], 'vod_remarks': '', 'style': {'type': 'grid', 'ratio': 1}} + return {'vod_id': f['path'], 'vod_name': f"📁 {f['name']}", 'vod_pic': self.file_icons['file'], 'vod_remarks': '', 'style': {'type': 'grid', 'ratio': 1}} + + def clear_audio_cache(self): + self.audio_list_cache.clear() + self.audio_list_cache_time.clear() + self.audio_cover_cache.clear() + keys_to_delete = [k for k in self.lrc_cache if k.startswith('fast_lrc_')] + for key in keys_to_delete: + del self.lrc_cache[key] + + def clear_network_cache(self): + self.network_lyrics_cache.clear() + self.network_cover_cache.clear() + self.song_info_cache.clear() + + def shutdown(self): + self.preload_executor.shutdown(wait=False) + ComicReader.shutdown_render_executor() + + def categoryContent(self, tid, pg, filter, extend): + pg = int(pg) if pg else 1 + self._current_page = pg + if tid in ['web_action', 'web_tool', 'web_bookmarks']: + return self.web_action_browser.get_category_content(tid, pg) + if tid == "game_hall": + return self.game_hall.get_category_content(tid, pg, extend) + if tid == "online_radio": + cat_id = extend.get("category", "442") if extend and isinstance(extend, dict) else "442" + return self._online_radio_content(cat_id, pg) + if tid == "short_video": + return self._short_video_category_content(pg) + if tid == "gallery": + return self._gallery_category_content(pg) + if tid.startswith("short_video_"): + return self._short_video_detail(tid[len("short_video_"):]) + if tid == self.live_category_id: + return self._live_category_content(pg) + if tid == 'recent': + return self._recent_content(pg) + current_path = None + if tid.startswith("root_"): + try: + idx = int(tid[5:]) + if idx < len(self.root_paths): + current_path = self.root_paths[idx] + except: + pass + elif tid.startswith(self.FOLDER_PREFIX): + current_path = self.b64u_decode(tid[len(self.FOLDER_PREFIX):]) + elif not tid.startswith(('online_', 'short_video', 'gallery', self.live_category_id, 'recent', 'web_action', 'web_tool', 'web_bookmarks', 'game_hall')): + current_path = tid if os.path.exists(tid) else None + filter_value = None + if extend and isinstance(extend, dict): + select_action = extend.get("select_mode", "") + if select_action: + print(f"选择模式操作: {select_action}") + if select_action == "on": + if current_path and os.path.isdir(current_path): + self.delete_mode_enabled = False + self.delete_mode_dir = None + self._enable_select_mode(current_path) + filter_value = self._extract_filter_value(extend) + return self._get_category_content_with_select_mode(tid, pg, filter, extend, current_path, filter_value) + else: + return { + 'list': [{ + 'vod_id': 'select_mode_error', + 'vod_name': '❌ 无法在当前目录开启选择模式', + 'vod_pic': self._generate_colored_icon("#F44336", "✗"), + 'vod_remarks': f'当前路径无效: {current_path}', + 'style': {'type': 'list'}, + 'vod_player': '书' + }], + 'page': pg, + 'pagecount': 1 + } + elif select_action == "off": + self._disable_select_mode() + if current_path and os.path.isdir(current_path): + return self._get_category_content_normal(tid, pg, filter, extend, current_path, filter_value) + else: + return { + 'list': [{ + 'vod_id': 'select_mode_closed', + 'vod_name': '🟢 选择模式已关闭', + 'vod_pic': self._generate_colored_icon("#4CAF50", "✓"), + 'vod_remarks': '现在点击文件正常打开', + 'style': {'type': 'list'}, + 'vod_player': '书' + }], + 'page': pg, + 'pagecount': 1 + } + elif select_action == "clear": + self._clear_selected() + if current_path and os.path.isdir(current_path): + filter_value = self._extract_filter_value(extend) + return self._get_category_content_with_select_mode(tid, pg, filter, extend, current_path, filter_value) + elif select_action == "clear_current": + if current_path and os.path.isdir(current_path): + filtered_files = self._scan_directory_simple(current_path) + if filter_value: + filtered_files = self._apply_filter_to_files(filtered_files, filter_value) + self._clear_current_with_filtered(filtered_files) + filter_value = self._extract_filter_value(extend) + return self._get_category_content_with_select_mode(tid, pg, filter, extend, current_path, filter_value) + elif select_action == "select_all": + if current_path and os.path.isdir(current_path): + files = self._scan_directory_simple(current_path) + for f in files: + if f['path'] not in self.selected_items: + self.selected_items.append(f['path']) + filter_value = self._extract_filter_value(extend) + return self._get_category_content_with_select_mode(tid, pg, filter, extend, current_path, filter_value) + elif select_action == "copy": + if current_path and os.path.isdir(current_path): + success, msg = self._copy_selected(current_path) + if success: + return self._get_select_result(msg) + else: + return self._get_select_error(msg) + elif select_action == "move": + if current_path and os.path.isdir(current_path): + success, msg = self._move_selected(current_path) + if success: + return self._get_select_result(msg) + else: + return self._get_select_error(msg) + elif select_action == "paste": + if current_path and os.path.isdir(current_path): + success, msg = self._paste_files(current_path) + if success: + self._clear_selected() + return self._get_select_result(msg) + else: + return self._get_select_error(msg) + elif select_action == "rename": + if current_path and os.path.isdir(current_path): + files = self._scan_directory_simple(current_path) + if 0 <= self.current_select_index < len(files): + file_path = files[self.current_select_index]['path'] + is_dir = os.path.isdir(file_path) + self.pending_rename_path = file_path + self.pending_rename_is_dir = is_dir + old_name = os.path.basename(file_path) + if is_dir: + old_base = old_name + tip_text = f"原文件夹: {old_name}\n请输入新文件夹名" + msg_text = "请输入新文件夹名" + title_text = "重命名文件夹" + else: + old_base = os.path.splitext(old_name)[0] + old_ext = os.path.splitext(old_name)[1] + tip_text = f"原文件: {old_name}\n请输入新文件名(扩展名 {old_ext} 会自动保留)" + msg_text = f"请输入新文件名(扩展名 {old_ext} 会自动保留)" + title_text = "重命名文件" + action_config = { + "actionId": "单项输入", + "id": "text", + "type": "input", + "title": title_text, + "tip": tip_text, + "value": old_base, + "msg": msg_text + } + return { + "page": 1, + "pagecount": 1, + "limit": 1, + "total": 1, + "list": [{ + "vod_id": json.dumps(action_config, ensure_ascii=False), + "vod_name": f"✏️ 重命名: {old_name}", + "vod_pic": self._generate_colored_icon("#4CAF50", "✏️"), + "vod_remarks": "点击后弹出输入框", + "vod_tag": "action", + "style": {"type": "list"} + }] + } + else: + return self._get_select_error("没有选中的文件") + else: + return self._get_select_error("路径无效") + elif select_action == "up": + if current_path and os.path.isdir(current_path): + self._move_up() + filter_value = self._extract_filter_value(extend) + return self._get_category_content_with_select_mode(tid, pg, filter, extend, current_path, filter_value) + elif select_action == "down": + if current_path and os.path.isdir(current_path): + filtered_files = self._scan_directory_simple(current_path) + if filter_value: + filtered_files = self._apply_filter_to_files(filtered_files, filter_value) + self._move_down(len(filtered_files)) + filter_value = self._extract_filter_value(extend) + return self._get_category_content_with_select_mode(tid, pg, filter, extend, current_path, filter_value) + elif select_action == "select_current": + if current_path and os.path.isdir(current_path): + filtered_files = self._scan_directory_simple(current_path) + if filter_value: + filtered_files = self._apply_filter_to_files(filtered_files, filter_value) + self._select_current_with_filtered(filtered_files) + filter_value = self._extract_filter_value(extend) + return self._get_category_content_with_select_mode(tid, pg, filter, extend, current_path, filter_value) + manage_action = extend.get("manage_mode", "") + if manage_action: + if manage_action == "delete_on": + if current_path and os.path.isdir(current_path): + self.select_mode_enabled = False + self.select_mode_dir = None + self.selected_items = [] + self._enable_delete_mode(current_path) + filter_value = self._extract_filter_value(extend) + return self._get_category_content_with_delete_mode(tid, pg, filter, extend, current_path, filter_value) + else: + return { + 'list': [{ + 'vod_id': 'delete_mode_error', + 'vod_name': '❌ 无法在当前目录开启删除模式', + 'vod_pic': self._generate_colored_icon("#F44336", "✗"), + 'vod_remarks': f'当前路径: {current_path}', + 'style': {'type': 'list'}, + 'vod_player': '书' + }], + 'page': pg, + 'pagecount': 1 + } + elif manage_action == "delete_off": + self._disable_delete_mode() + if current_path and os.path.isdir(current_path): + return self._get_category_content_normal(tid, pg, filter, extend, current_path, filter_value) + else: + return { + 'list': [{ + 'vod_id': 'delete_mode_closed', + 'vod_name': '🟢 删除模式已关闭', + 'vod_pic': self._generate_colored_icon("#4CAF50", "✓"), + 'vod_remarks': '现在点击文件正常打开,不会删除', + 'style': {'type': 'list'}, + 'vod_player': '书' + }], + 'page': pg, + 'pagecount': 1 + } + elif manage_action == "empty": + return self._empty_trash() + elif manage_action == "clear_comic_cache": + return self._clear_comic_cache_content() + elif manage_action == "clear_cover_cache": + return self._clear_cover_cache_content() + elif manage_action == "clear_lyrics_cache": + return self._clear_lyrics_cache_content() + elif manage_action == "clear_radio_cover_cache": + return self._clear_radio_cover_cache_content() + filter_value = self._extract_filter_value(extend) + if not current_path or not os.path.isdir(current_path): + return {'list': [], 'page': pg, 'pagecount': 1} + if self.select_mode_enabled and self.select_mode_dir == current_path: + return self._get_category_content_with_select_mode(tid, pg, filter, extend, current_path, filter_value) + elif self.delete_mode_enabled and self.delete_mode_dir == current_path: + return self._get_category_content_with_delete_mode(tid, pg, filter, extend, current_path, filter_value) + else: + return self._get_category_content_normal(tid, pg, filter, extend, current_path, filter_value) + + # ==================== action 接口 ==================== + + def action(self, action_str): + try: + if isinstance(action_str, str): + try: + obj = json.loads(action_str) + except: + if action_str.startswith(('http://', 'https://')): + return self.web_action_browser._open_url_action(action_str) + obj = {"action": action_str} + else: + obj = action_str + act = obj.get('action', '') or obj.get('actionId', '') + + # ========== 磁力链接设置 ========== + if act == '磁力链接设置': + input_text = obj.get('url', '') or obj.get('value', '') + if isinstance(input_text, dict): + input_text = input_text.get('text', '') + if input_text and input_text.strip(): + try: + new_count = int(input_text.strip()) + if 10 <= new_count <= 5000: + global MAX_MAGNET_ITEMS + MAX_MAGNET_ITEMS = new_count + MagnetConfigManager.set_max_items(new_count) + self.dir_cache.clear() + self.dir_cache_time.clear() + return { + 'list': [{ + 'vod_id': 'action_result', + 'vod_name': f'✅ 磁力链接数量已设置为: {new_count} 条', + 'vod_pic': self._generate_colored_icon("#4CAF50", "✓"), + 'vod_remarks': f'每次最多读取 {new_count} 条磁力链接\n已保存到配置文件,重启后仍然有效', + 'style': {'type': 'list'}, + 'vod_player': '书' + }], + 'page': 1, + 'pagecount': 1 + } + else: + return { + 'list': [{ + 'vod_id': 'action_result', + 'vod_name': '❌ 数量范围: 10 ~ 5000', + 'vod_pic': self._generate_colored_icon("#F44336", "✗"), + 'vod_remarks': '请输入 10-5000 之间的数字', + 'style': {'type': 'list'}, + 'vod_player': '书' + }], + 'page': 1, + 'pagecount': 1 + } + except ValueError: + return { + 'list': [{ + 'vod_id': 'action_result', + 'vod_name': '❌ 请输入有效的数字', + 'vod_pic': self._generate_colored_icon("#F44336", "✗"), + 'vod_remarks': f'当前值: {MagnetConfigManager.get_max_items()}', + 'style': {'type': 'list'}, + 'vod_player': '书' + }], + 'page': 1, + 'pagecount': 1 + } + else: + return { + 'actionId': '单项输入', + 'id': 'text', + 'type': 'input', + 'title': '🧲 磁力链接设置', + 'tip': f'当前每次读取 {MagnetConfigManager.get_max_items()} 条\n请输入新数量 (10-5000)', + 'value': str(MagnetConfigManager.get_max_items()), + 'msg': '请输入磁力链接最大读取数量' + } + + # ========== 磁力读取模式切换 ========== + if act == '磁力读取模式': + current_mode = MagnetConfigManager.get_read_mode() + if current_mode == MAGNET_READ_MODE_ORDER: + MagnetConfigManager.set_read_mode(MAGNET_READ_MODE_RANDOM) + new_mode_display = "🎲 随机" + mode_desc = "每次打开磁力文件时,从所有链接中随机抽取指定数量的链接" + else: + MagnetConfigManager.set_read_mode(MAGNET_READ_MODE_ORDER) + new_mode_display = "📋 顺序" + mode_desc = "每次打开磁力文件时,从开头顺序读取指定数量的链接" + + global MAGNET_READ_MODE + MAGNET_READ_MODE = MagnetConfigManager.get_read_mode() + + self.dir_cache.clear() + self.dir_cache_time.clear() + self.audio_list_cache.clear() + self.audio_list_cache_time.clear() + + return { + 'list': [{ + 'vod_id': 'action_result', + 'vod_name': f'✅ 磁力读取模式已切换为: {new_mode_display}', + 'vod_pic': self._generate_colored_icon("#4CAF50", "🔄"), + 'vod_remarks': f'{mode_desc}\n当前数量: {MagnetConfigManager.get_max_items()} 条\n已保存到配置文件,重启后仍然有效', + 'style': {'type': 'list'}, + 'vod_player': '书' + }], + 'page': 1, + 'pagecount': 1 + } + + # ========== 其他动作 ========== + if act == '单项输入': + if self.pending_rename_path: + new_name = obj.get('url', '') or obj.get('value', '') + if isinstance(new_name, dict): + new_name = new_name.get('text', '') + elif isinstance(new_name, str): + new_name = new_name.strip() + if new_name: + old_path = self.pending_rename_path + old_name = os.path.basename(old_path) + is_dir = self.pending_rename_is_dir + if is_dir: + new_full_name = new_name + else: + old_ext = os.path.splitext(old_name)[1] + new_full_name = new_name + old_ext + new_path = os.path.join(os.path.dirname(old_path), new_full_name) + if os.path.exists(new_path): + self.pending_rename_path = None + self.pending_rename_is_dir = False + return { + "action": { + "actionId": "toast", + "msg": f"❌ 目标已存在: {new_full_name}" + } + } + try: + os.rename(old_path, new_path) + old_dir = os.path.dirname(old_path) + self.dir_cache.pop(f"dir_{old_dir}", None) + self.dir_cache_time.pop(f"dir_{old_dir}", None) + if is_dir: + self.dir_cache.pop(f"dir_{old_path}", None) + self.dir_cache_time.pop(f"dir_{old_path}", None) + self.audio_list_cache.pop(old_path, None) + self.audio_list_cache_time.pop(old_path, None) + if old_path in self.selected_items: + self.selected_items.remove(old_path) + self.selected_items.append(new_path) + if self.select_mode_dir == old_path: + self.select_mode_dir = new_path + self.pending_rename_path = None + self.pending_rename_is_dir = False + type_text = "文件夹" if is_dir else "文件" + return { + "action": { + "actionId": "重命名成功", + "msg": f"✅ 重命名{type_text}成功: {old_name} → {new_full_name}" + } + } + except Exception as e: + self.pending_rename_path = None + self.pending_rename_is_dir = False + return { + "action": { + "actionId": "toast", + "msg": f"❌ 重命名失败: {str(e)}" + } + } + else: + self.pending_rename_path = None + self.pending_rename_is_dir = False + return { + "action": { + "actionId": "toast", + "msg": "❌ 名称不能为空" + } + } + else: + url = obj.get('url', '') or obj.get('value', '') + if isinstance(url, dict): + url = url.get('text', '') + if url and url.strip(): + if not url.startswith(('http://', 'https://')): + url = 'https://' + url + return self.web_action_browser._open_url_action(url, '打开的网页') + else: + tip = obj.get('tip', '请输入网址') + title = obj.get('title', '🌐 访问网址') + return { + 'actionId': '单项输入', + 'id': 'text', + 'type': 'input', + 'title': title, + 'tip': tip, + 'value': '', + 'msg': '请输入' + } + + if act == 'OPEN_URL': + url = obj.get('url', '') + if url: + return self.web_action_browser._open_url_action(url, obj.get('title', '')) + + if act == '访问网址': + url = obj.get('url', '') or obj.get('value', '') + if isinstance(url, dict): + url = url.get('text', '') + if url and url.strip(): + if not url.startswith(('http://', 'https://')): + url = 'https://' + url + return self.web_action_browser._open_url_action(url, '访问的网页') + else: + return { + 'actionId': '单项输入', + 'id': 'text', + 'type': 'input', + 'title': '🌐 访问网址', + 'tip': '输入网址,如 baidu.com', + 'value': '', + 'msg': '请输入网址' + } + + if act == '添加书签': + input_text = obj.get('url', '') or obj.get('value', '') + if isinstance(input_text, dict): + input_text = input_text.get('text', '') + if input_text and input_text.strip(): + parts = input_text.split('@') + if len(parts) >= 2: + title = parts[0].strip() + url = parts[1].strip() + cover = parts[2].strip() if len(parts) >= 3 else '' + for bm in self.web_action_browser.bookmarks: + if bm['url'] == url: + return { + 'list': [{ + 'vod_id': 'action_result', + 'vod_name': f'❌ 书签已存在\n\n📌 {bm["title"]}\n🔗 {bm["url"]}', + 'vod_pic': self._generate_colored_icon("#F44336", "✗"), + 'vod_remarks': '添加失败', + 'style': {'type': 'list'}, + 'vod_player': '书' + }], + 'page': 1, + 'pagecount': 1 + } + self.web_action_browser.bookmarks.append({ + 'id': hashlib.md5(url.encode()).hexdigest()[:8], + 'url': url, + 'title': title, + 'cover': cover, + 'add_time': time.time() + }) + self.web_action_browser._save_bookmarks() + return { + 'list': [{ + 'vod_id': 'action_result', + 'vod_name': f'✅ 添加成功!\n\n📌 {title}\n🔗 {url[:50]}...', + 'vod_pic': self._generate_colored_icon("#4CAF50", "✓"), + 'vod_remarks': '添加成功', + 'style': {'type': 'list'}, + 'vod_player': '书' + }], + 'page': 1, + 'pagecount': 1 + } + else: + return { + 'list': [{ + 'vod_id': 'action_result', + 'vod_name': '❌ 格式错误\n\n请使用: 名称@网址\n或 名称@网址@封面图', + 'vod_pic': self._generate_colored_icon("#F44336", "✗"), + 'vod_remarks': '添加失败', + 'style': {'type': 'list'}, + 'vod_player': '书' + }], + 'page': 1, + 'pagecount': 1 + } + else: + return { + 'actionId': '单项输入', + 'id': 'text', + 'type': 'input', + 'title': '📌 添加书签', + 'tip': '格式: 名称@网址\n或 名称@网址@封面图', + 'value': '', + 'msg': '请输入书签信息' + } + + if act == '删除书签': + input_text = obj.get('url', '') or obj.get('value', '') + if isinstance(input_text, dict): + input_text = input_text.get('text', '') + if input_text and input_text.strip(): + for bm in self.web_action_browser.bookmarks[:]: + if bm['title'] == input_text or bm['url'] == input_text or input_text in bm['url']: + title = bm['title'] + url = bm['url'] + self.web_action_browser.bookmarks.remove(bm) + self.web_action_browser._save_bookmarks() + return { + 'list': [{ + 'vod_id': 'action_result', + 'vod_name': f'✅ 删除成功!\n\n📌 {title}\n🔗 {url[:50]}...', + 'vod_pic': self._generate_colored_icon("#4CAF50", "✓"), + 'vod_remarks': '删除成功', + 'style': {'type': 'list'}, + 'vod_player': '书' + }], + 'page': 1, + 'pagecount': 1 + } + return { + 'list': [{ + 'vod_id': 'action_result', + 'vod_name': '❌ 未找到书签\n\n请输入正确的书签名或网址', + 'vod_pic': self._generate_colored_icon("#F44336", "✗"), + 'vod_remarks': '删除失败', + 'style': {'type': 'list'}, + 'vod_player': '书' + }], + 'page': 1, + 'pagecount': 1 + } + else: + return { + 'actionId': '单项输入', + 'id': 'text', + 'type': 'input', + 'title': '🗑️ 删除书签', + 'tip': '输入书签名或网址', + 'value': '', + 'msg': '请输入书签名' + } + + if act == '修改书签名': + input_text = obj.get('url', '') or obj.get('value', '') + if isinstance(input_text, dict): + input_text = input_text.get('text', '') + if input_text and input_text.strip(): + parts = input_text.split('|') + if len(parts) >= 2: + old = parts[0].strip() + new = parts[1].strip() + new_cover = parts[2].strip() if len(parts) >= 3 else "" + for bm in self.web_action_browser.bookmarks: + if bm['title'] == old or bm['url'] == old: + old_title = bm['title'] + old_url = bm['url'] + bm['title'] = new + if new_cover: + bm['cover'] = new_cover + self.web_action_browser._save_bookmarks() + return { + 'list': [{ + 'vod_id': 'action_result', + 'vod_name': f'✅ 修改成功!\n\n📌 {old_title} → {new}\n🔗 {old_url[:50]}...', + 'vod_pic': self._generate_colored_icon("#4CAF50", "✓"), + 'vod_remarks': '修改成功', + 'style': {'type': 'list'}, + 'vod_player': '书' + }], + 'page': 1, + 'pagecount': 1 + } + return { + 'list': [{ + 'vod_id': 'action_result', + 'vod_name': '❌ 未找到书签\n\n请输入正确的原书签名或网址', + 'vod_pic': self._generate_colored_icon("#F44336", "✗"), + 'vod_remarks': '修改失败', + 'style': {'type': 'list'}, + 'vod_player': '书' + }], + 'page': 1, + 'pagecount': 1 + } + else: + return { + 'list': [{ + 'vod_id': 'action_result', + 'vod_name': '❌ 格式错误\n\n请使用: 原书签|新名称\n或 原书签|新名称|新封面图', + 'vod_pic': self._generate_colored_icon("#F44336", "✗"), + 'vod_remarks': '修改失败', + 'style': {'type': 'list'}, + 'vod_player': '书' + }], + 'page': 1, + 'pagecount': 1 + } + else: + return { + 'actionId': '单项输入', + 'id': 'text', + 'type': 'input', + 'title': '✏️ 修改书签名', + 'tip': '格式: 原书签|新名称\n或 原书签|新名称|新封面图', + 'value': '', + 'msg': '请输入修改信息' + } + + return None + except Exception as e: + print(f"❌ action 错误: {e}") + import traceback + traceback.print_exc() + return None + + def destroy(self): + self.shutdown() \ No newline at end of file