From f0c73a0e7114ba644959cf0de696523bbccc469d Mon Sep 17 00:00:00 2001
From: XC-Pro <19840+xc-pro@noreply.example.org>
Date: Fri, 14 Aug 2026 11:27:10 +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 | 6801 +++++++++++++++++++++++++++++++++++++++
py/资源管理老.py | 7695 ++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 14496 insertions(+)
create mode 100644 py/资源管理备用.py
create mode 100644 py/资源管理老.py
diff --git a/py/资源管理备用.py b/py/资源管理备用.py
new file mode 100644
index 0000000..a9d8585
--- /dev/null
+++ b/py/资源管理备用.py
@@ -0,0 +1,6801 @@
+# 资源管理.py - 智能多策略封面提取版(含在线直播、在线电台、短视频、画廊功能、删除模式、网页浏览器、PDF/EPUB漫画阅读)
+# 说明:MP3/FLAC/M4A/AAC/OGG/WAV 多策略提取内置封面,优先使用本地同名图片
+# 新增:PDF/EPUB漫画阅读支持,统一缓存目录,支持任意文件夹读取
+# 新增:动作/工具/书签功能(移植自4webview.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漫画支持(需要Java类) ====================
+try:
+ from java import jclass
+ HAS_JAVA = True
+except ImportError:
+ HAS_JAVA = False
+ print("⚠️ Java类不可用,PDF/EPUB功能将受限")
+
+# ==================== 电视直播封面图片 ====================
+TV_COVER = "https://p2.ssl.qhimgs1.com/bdr/460__/t045d2cbb68401612b2.png"
+
+# ==================== 缓存路径配置 ====================
+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
+
+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("✅ 在线直播UA已修复 - 每个直播源可独立配置UA,播放时自动生效")
+print("✅ 连点删除功能已启用 - 播放界面连续点击同一首歌3次可删除")
+print("✅ 删除模式支持删除文件夹")
+print("✅ 缓存图片目录已添加 .nomedia 文件,防止相册扫描")
+print("✅ PDF/EPUB漫画阅读已集成 - 支持任意文件夹读取")
+print("✅ 动作/工具/书签功能已集成 - 网格排版")
+
+
+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": "rihou",
+ "type": "txt",
+ "ua": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
+ },
+ {
+ "id": "综合直播",
+ "name": "✨ 综合直播",
+ "url": "https://ds65.tv1288.xyz",
+ "cover": TV_COVER,
+ "remarks": "综合直播",
+ "type": "m3u",
+ "ua": "bingcha/1.1 (mianfeifenxiang)"
+ },
+ {
+ "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)]
+ print(f"[PDF已缓存] {pdf_name}, 前{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}[^>]*>(.*?){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'
]*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 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 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
+
+ if 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 _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_file_item(self, f):
+ file_size = self._get_file_size_str(f['path']) if not f['is_dir'] else ""
+
+ if f['is_dir']:
+ vod_pic = self.file_icons['folder']
+ vod_name = f"📁 {f['name']}"
+ remarks = f'🗑️ 点击删除整个文件夹'
+ if file_size:
+ remarks += f' ({file_size})'
+ return {
+ 'vod_id': f'delete_folder_{self.b64u_encode(f["path"])}',
+ 'vod_name': vod_name,
+ 'vod_pic': vod_pic,
+ 'vod_remarks': remarks,
+ 'vod_remarks_color': '#F44336',
+ 'style': {'type': 'list'},
+ 'vod_player': '书',
+ 'vod_tag': 'delete_folder'
+ }
+
+ if ComicReader.is_supported(f['name']):
+ cover_url = ComicReader.get_cover_url(f['path'])
+ return {
+ 'vod_id': f'delete_file_{self.b64u_encode(f["path"])}',
+ 'vod_name': f"📚 {f['name']}",
+ 'vod_pic': cover_url or ComicReader._get_default_comic_icon(f['ext']),
+ 'vod_remarks': f'🗑️ 点击删除 - {file_size}',
+ 'vod_remarks_color': '#F44336',
+ 'style': {'type': 'list'},
+ 'vod_player': '书'
+ }
+
+ if self.is_image_file(f['ext']) or f['ext'].lower() in ['heic', 'heif']:
+ vod_pic = f"file://{f['path']}"
+ vod_name = f"🖼 {f['name']}"
+ 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:
+ vod_pic = cover_url
+ else:
+ color = self.default_colors[hash(f['name']) % len(self.default_colors)]
+ first_char = f['name'][0] if f['name'] else "🎵"
+ vod_pic = self._generate_colored_icon(color, first_char)
+ vod_name = f"{f['name']}"
+ elif self.is_media_file(f['ext']):
+ vod_pic = self.file_icons['video']
+ vod_name = f"🎬 {f['name']}"
+ else:
+ vod_pic = self.get_file_icon_url(f['ext'])
+ vod_name = f"{f['name']}"
+
+ return {
+ 'vod_id': f'delete_file_{self.b64u_encode(f["path"])}',
+ 'vod_name': vod_name,
+ 'vod_pic': vod_pic,
+ 'vod_remarks': f'🗑️ 点击删除 - {file_size}',
+ 'vod_remarks_color': '#F44336',
+ 'style': {'type': 'list'},
+ 'vod_player': '书'
+ }
+
+ def get_file_icon_url(self, ext):
+ if ext in self.image_exts:
+ return self.file_icons['image']
+ elif ext in self.audio_exts:
+ return self.file_icons['audio']
+ elif ext in self.media_exts:
+ return self.file_icons['video']
+ elif ext in self.comic_exts:
+ return "https://img.icons8.com/color/96/000000/comic-book.png"
+ elif ext in self.list_exts:
+ return self.file_icons['list']
+ elif ext in self.db_exts:
+ return self.file_icons['database']
+ elif ext == 'php':
+ return self.file_icons['php']
+ elif ext == 'py':
+ return self.file_icons['python']
+ elif ext == 'zip':
+ return self.file_icons['zip']
+ elif ext in self.archive_exts:
+ return self.file_icons['archive']
+ else:
+ return self.file_icons['file']
+
+ def _get_file_size_str(self, file_path):
+ try:
+ size = os.path.getsize(file_path)
+ if size > 1024 * 1024:
+ return f"{size / (1024 * 1024):.1f} MB"
+ elif size > 1024:
+ return f"{size / 1024:.1f} KB"
+ else:
+ return f"{size} B"
+ except:
+ return ""
+
+ 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
+ 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 _is_delete_mode_active_in_path(self, path):
+ if not self.delete_mode_enabled or not self.delete_mode_dir:
+ return False
+ current_norm = os.path.normpath(path)
+ active_norm = os.path.normpath(self.delete_mode_dir)
+ return current_norm == active_norm or current_norm.startswith(active_norm + os.sep)
+
+ 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 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)
+
+ # 处理 web_action_browser 的 action
+ 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.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 == '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'
+ ]
+ 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.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):
+ return self.detailContent([self.LIST_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
+
+ if 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.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': 'list'},
+ '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': 'list'},
+ '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': 'list'}}
+ 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': 'list'}}
+ 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': 'list'}}
+ 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': 'list'}}
+ 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': 'list'}}
+ if f['ext'] == 'zip':
+ return {'vod_id': f['path'], 'vod_name': f"🗜️ {f['name']}", 'vod_pic': self.file_icons['zip'], 'vod_remarks': '', 'style': {'type': 'list'}}
+ 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': 'list'}}
+ 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': 'list'}}
+ 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': 'list'}}
+ if f['ext'] == 'txt':
+ is_live = any(kw in f['name'].lower() for kw in self.live_keywords)
+ if 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': 'list'}}
+ 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': 'list'}, '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': 'list'}}
+ return {'vod_id': f['path'], 'vod_name': f"📁 {f['name']}", 'vod_pic': self.file_icons['file'], 'vod_remarks': '', 'style': {'type': 'list'}}
+
+ 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 == "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')):
+ current_path = tid if os.path.exists(tid) else None
+
+ if extend and isinstance(extend, dict):
+ action = extend.get("action", "")
+ if action == "clear_comic_cache":
+ return self._clear_comic_cache_content()
+ elif action == "clear_cover_cache":
+ return self._clear_cover_cache_content()
+ elif action == "clear_lyrics_cache":
+ return self._clear_lyrics_cache_content()
+ elif action == "clear_radio_cover_cache":
+ return self._clear_radio_cover_cache_content()
+
+ delete_action = extend.get("delete_mode", "")
+ if delete_action == "on":
+ if current_path and os.path.isdir(current_path):
+ self._enable_delete_mode(current_path)
+ return self._get_category_content_with_delete_mode(tid, pg, filter, extend, current_path)
+ else:
+ return {
+ 'list': [{
+ 'vod_id': 'delete_mode_error',
+ 'vod_name': '❌ 无法在当前目录开启删除模式',
+ 'vod_pic': self._generate_colored_icon("#F44336", "✗"),
+ 'vod_remarks': '请确保当前是有效文件夹',
+ 'style': {'type': 'list'},
+ 'vod_player': '书'
+ }],
+ 'page': pg,
+ 'pagecount': 1
+ }
+ elif delete_action == "off":
+ self._disable_delete_mode()
+ if current_path and os.path.isdir(current_path):
+ return self._get_category_content_with_delete_mode(tid, pg, filter, extend, current_path)
+ 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 delete_action == "empty":
+ return self._empty_trash()
+
+ if not current_path or not os.path.isdir(current_path):
+ return {'list': [], 'page': pg, 'pagecount': 1}
+
+ return self._get_category_content_with_delete_mode(tid, pg, filter, extend, current_path)
+
+ def _get_category_content_with_delete_mode(self, tid, pg, filter, extend, current_path):
+ pg = int(pg) if pg else 1
+
+ is_delete_mode = self._is_delete_mode_active_in_path(current_path)
+
+ filter_value = None
+ if extend and isinstance(extend, dict):
+ 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] != '全部':
+ filter_value = extend[key]
+ break
+
+ if not filter_value and 'digit_row' in extend and extend['digit_row'] and extend['digit_row'] != '全部':
+ filter_value = extend['digit_row']
+
+ 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['match_score'], 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 is_delete_mode:
+ status_item = {
+ 'vod_id': 'delete_mode_status',
+ 'vod_name': '🔴 删除模式已开启(点击此条关闭)',
+ 'vod_pic': self.delete_icon,
+ 'vod_remarks': '⚠️ 点击文件/文件夹将移动到回收站,点击此条关闭',
+ 'vod_remarks_color': '#F44336',
+ 'style': {'type': 'list'},
+ 'vod_player': '书',
+ 'vod_tag': 'system_status'
+ }
+ vlist.append(status_item)
+
+ for f in page_files:
+ if f['is_dir']:
+ if is_delete_mode:
+ item = self._create_delete_file_item(f)
+ else:
+ item = self._create_file_item_with_flags(f)
+ if item:
+ vlist.append(item)
+ else:
+ if is_delete_mode:
+ item = self._create_delete_file_item(f)
+ else:
+ item = self._create_file_item_with_flags(f)
+ if item:
+ vlist.append(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)
+
+ 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 _resolve_path(self, tid):
+ if tid.startswith('root_'):
+ try:
+ idx = int(tid[5:])
+ return self.root_paths[idx] if idx < len(self.root_paths) else None
+ except:
+ return None
+ elif tid.startswith(self.FOLDER_PREFIX):
+ return self.b64u_decode(tid[len(self.FOLDER_PREFIX):])
+ return tid if os.path.exists(tid) else None
+
+ 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': 'list'}
+ }
+
+ 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}' if size_mb else f'{f["ext"].upper()}漫画',
+ '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': 'list'},
+ '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': 'list'}
+ }
+
+ 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': 'list'}
+ }
+
+ 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': 'list'}
+ }
+
+ 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': 'list'}
+ }
+
+ 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': 'list'}
+ }
+
+ 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': 'list'}
+ }
+
+ 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': 'list'}
+ }
+
+ 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': 'live_m3u',
+ 'style': {'type': 'list'}
+ }
+
+ 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': 'list'}
+ }
+
+ 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 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': 'list'}
+ }
+ 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': 'list'},
+ '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': 'list'}
+ }
+
+ 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': 'list'}
+ }
+
+ 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': 'list'}
+ }
+ else:
+ return {
+ 'vod_id': f['path'],
+ 'vod_name': f"📁 {f['name']}",
+ 'vod_pic': self.file_icons['file'],
+ 'vod_remarks': '无扩展名',
+ 'vod_tag': 'unknown',
+ 'style': {'type': 'list'}
+ }
+
+ def action(self, action_str):
+ # 先让 web_action_browser 处理
+ result = self.web_action_browser.handle_action(action_str)
+ if result:
+ # 检查是否是 OPEN_URL 的 action 格式
+ if isinstance(result, dict) and 'action' in result:
+ return result
+ # 如果返回的是 {'list': [...]} 格式,返回给显示
+ if isinstance(result, dict) and 'list' in result:
+ return result
+ # 如果返回的是字符串,显示提示
+ if isinstance(result, str):
+ return {
+ 'list': [{
+ 'vod_id': 'action_result',
+ 'vod_name': result,
+ 'vod_pic': self._generate_colored_icon("#4CAF50", "✓"),
+ 'vod_remarks': '操作完成',
+ 'style': {'type': 'list'},
+ 'vod_player': '书'
+ }],
+ 'page': 1,
+ 'pagecount': 1
+ }
+ return result
+
+ # 原有的处理逻辑
+ 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_str)
+ obj = {"action": action_str}
+ else:
+ obj = action_str
+
+ act = None
+ if isinstance(obj, dict):
+ act = obj.get('actionId', obj.get('action', ''))
+ value = obj.get('value', '')
+ else:
+ act = str(obj) if obj else ''
+ value = ''
+
+ # 处理单项输入(网址输入框)
+ if act == '单项输入':
+ if isinstance(value, dict) and "text" in value:
+ url = value["text"].strip()
+ if url:
+ if not url.startswith(('http://', 'https://')):
+ url = 'https://' + url
+ return self.web_action_browser._open_url(url)
+ elif isinstance(value, str) and value and value.strip():
+ url = value.strip()
+ if not url.startswith(('http://', 'https://')):
+ url = 'https://' + url
+ return self.web_action_browser._open_url(url)
+ else:
+ return {
+ 'actionId': '单项输入',
+ 'id': 'text',
+ 'type': 'input',
+ 'title': '🌐 访问网址',
+ 'tip': '请输入网址,例如:www.baidu.com',
+ 'value': '',
+ 'msg': '请输入网址'
+ }
+
+ # 处理 OPEN_URL
+ if act == 'OPEN_URL':
+ url = obj.get('url', '')
+ if url:
+ return self.web_action_browser._open_url(url)
+
+ # 处理直接传入的网址字符串
+ if isinstance(action_str, str) and action_str.startswith(('http://', 'https://')):
+ return self.web_action_browser._open_url(action_str)
+
+ # 默认返回输入框
+ return {
+ 'actionId': '单项输入',
+ 'id': 'text',
+ 'type': 'input',
+ 'title': '🌐 访问网址',
+ 'tip': '请输入网址',
+ 'value': '',
+ 'msg': '请输入网址'
+ }
+
+ except Exception as e:
+ print(f"❌ action 处理错误: {e}")
+ return {
+ 'actionId': '单项输入',
+ 'id': 'text',
+ 'type': 'input',
+ 'title': '🌐 访问网址',
+ 'tip': '请输入网址',
+ 'value': '',
+ 'msg': '请输入网址'
+ }
+
+ def _handle_webview(self, url, name=""):
+ print(f"🌐 打开网页: {url}")
+
+ if not name:
+ from urllib.parse import urlparse
+ parsed = urlparse(url)
+ name = parsed.netloc.replace('www.', '') if parsed.netloc else '网页'
+
+ return self.web_action_browser._open_url(url)
+
+ def destroy(self):
+ self.shutdown()
\ No newline at end of file
diff --git a/py/资源管理老.py b/py/资源管理老.py
new file mode 100644
index 0000000..519208a
--- /dev/null
+++ b/py/资源管理老.py
@@ -0,0 +1,7695 @@
+# 资源管理.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 = "http://pic.rmb.bdstatic.com/bjh/240603/8e7bf2d9440058101b0fc89167eba9b44449.jpeg"
+
+# ==================== 缓存路径配置 ====================
+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_MAGNET_ITEMS = 700
+MAX_PLAY_URLS = 300
+
+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("✅ 所有文件/文件夹项已改为网格排列")
+
+# ==================== XLS/XLSX 磁力文件解析器 ====================
+
+class XLSXMagnetParser:
+ """XLSX磁力链接文件解析器 - 纯二进制版(不依赖openpyxl)"""
+
+ @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)
+ magnets = set(magnet_pattern.findall(all_text))
+
+ ed2k_pattern = re.compile(r'ed2k://[^\s\'"<>《》\|]+(?:\|[^\s\'"<>《》]+)*', re.I)
+ ed2ks = set(ed2k_pattern.findall(all_text))
+
+ 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})
+
+ for ed2k in ed2ks:
+ 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 items:
+ print(f"✅ XLSX解析完成: {os.path.basename(file_path)}, 共 {len(items)} 条磁力/电驴链接")
+ 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)
+ magnets = set(magnet_pattern_simple.findall(all_text))
+
+ ed2k_pattern_simple = re.compile(r'ed2k://[^\s\'"<>]+', re.I)
+ ed2ks = set(ed2k_pattern_simple.findall(all_text))
+
+ for magnet in list(magnets)[:MAX_MAGNET_ITEMS]:
+ 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_MAGNET_ITEMS:
+ break
+
+ for ed2k in list(ed2ks)[:MAX_MAGNET_ITEMS - len(items)]:
+ 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_MAGNET_ITEMS:
+ break
+
+ if items:
+ print(f"✅ 二进制解析完成: {os.path.basename(file_path)}, 共 {len(items)} 条")
+ 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(MAX_MAGNET_FILE_SIZE)
+ content = content.replace('\r\n', ' ').replace('\n', ' ').replace('\r', ' ')
+
+ magnet_pattern = re.compile(r'(magnet:\?[^\s\'"<>]+)', re.I)
+ magnets = set(magnet_pattern.findall(content))
+
+ ed2k_pattern = re.compile(r'(ed2k://[^\s\'"<>]+)', re.I)
+ ed2ks = set(ed2k_pattern.findall(content))
+
+ for magnet in list(magnets)[:MAX_MAGNET_ITEMS]:
+ 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_MAGNET_ITEMS:
+ break
+
+ for ed2k in list(ed2ks)[:MAX_MAGNET_ITEMS - len(items)]:
+ name = MagnetLinkParser._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_MAGNET_ITEMS:
+ break
+
+ if items:
+ print(f"✅ TXT解析完成: {os.path.basename(file_path)}, 共 {len(items)} 条磁力/电驴链接")
+ 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}[^>]*>(.*?){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'{tag}>', '\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'current-program[^>]*>.*?
]*>([^<]+)',
+ 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'next-program[^>]*>.*?
]*>([^<]+)',
+ 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 _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'''
'''
+ 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
+ }
+ numid = f"{random.randint(1, 999):03d}"
+ return {
+ 'vod_id': json.dumps(config, ensure_ascii=False),
+ 'vod_name': name,
+ 'vod_pic': f"https://picsum.photos/200/300?random={int(time.time())}{numid}",
+ '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']
+ }
+ numid = f"{random.randint(1, 999):03d}"
+ 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']:
+ item['vod_pic'] = f"https://picsum.photos/200/300?random={numid}"
+ 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': ''
+ }
+ numid = f"{random.randint(1, 999):03d}"
+ return {
+ 'vod_id': json.dumps(config, ensure_ascii=False),
+ 'vod_name': name,
+ 'vod_pic': f"https://picsum.photos/200/300?random={int(time.time())}{numid}",
+ 'vod_remarks': '',
+ 'vod_tag': 'action',
+ 'style': {'type': 'grid', 'ratio': 0.75}
+ }
+
+ def _get_action_list(self):
+ 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('✏️ 修改书签名', '修改书签名', '修改书签名', '格式: 原书签|新名称|新封面图'),
+ ]
+
+ def _get_tool_list(self):
+ tools = [
+ ('📖 PDF阅读器', f'http://localhost:{self.port}/PDF阅读器.php'),
+ ('手绘画板', 'http://tool.mkblog.cn/whiteboard/'),
+ ('🎮 喜刷刷', f'http://localhost:{self.port}/html/妹子.html'),
+ ('🎵 裤佬音乐', f'http://localhost:{self.port}/html/裤佬音乐.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': '🎯 动作'},
+ {'type_id': 'web_tool', 'type_name': '🛠 工具'},
+ {'type_id': 'web_bookmarks', 'type_name': f'🔖 书签({bookmark_count})'},
+ ]
+ }
+
+ 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": "https://img2.baidu.com/it/u=170858330,816048848&fm=253&fmt=auto&app=138&f=JPG?w=500&h=500"},
+ {"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/200/300?random={random.randint(1, 999)}"
+ 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://img1.baidu.com/it/u=1692434982,1366527976&fm=253&fmt=auto&app=138&f=PNG?w=500&h=500',
+ 'video': 'https://img1.baidu.com/it/u=613721211,3483160181&fm=253&fmt=auto&app=138&f=JPEG?w=888&h=500',
+ 'video_playlist': 'https://img.icons8.com/color/96/000000/playlist.png',
+ '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=1556491300,3894580537&fm=253&fmt=auto&app=138&f=JPEG?w=500&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://img.icons8.com/color/96/000000/internet.png',
+ }
+
+ 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':
+ items = MagnetLinkParser.parse_magnet_from_txt(file_path)
+ if items:
+ return items
+
+ 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)
+ lines = content.split('\n')
+
+ for line in lines[:MAX_MAGNET_LINES]:
+ line = line.strip()
+ if not line or line.startswith('#'):
+ continue
+
+ if ',' in line:
+ parts = line.split(',', 1)
+ name = parts[0].strip()
+ magnet = magnet_pattern.search(parts[1])
+ if magnet:
+ items.append({'name': name, 'url': magnet.group(1)})
+ continue
+ ed2k = ed2k_pattern.search(parts[1])
+ if ed2k:
+ items.append({'name': name, 'url': ed2k.group(1)})
+ continue
+ else:
+ magnet = magnet_pattern.search(line)
+ if magnet:
+ name_match = re.search(r'dn=([^&]+)', magnet.group(1))
+ if name_match:
+ name = urllib.parse.unquote(name_match.group(1))
+ else:
+ name = f"磁力链接{len(items)+1}"
+ items.append({'name': name, 'url': magnet.group(1)})
+ continue
+
+ ed2k = ed2k_pattern.search(line)
+ if ed2k:
+ name_match = re.search(r'/([^/]+)(?:\.\w+)?$', ed2k.group(1))
+ if name_match:
+ name = name_match.group(1)
+ else:
+ name = f"电驴链接{len(items)+1}"
+ items.append({'name': name, 'url': ed2k.group(1)})
+ continue
+
+ if len(items) >= MAX_MAGNET_ITEMS:
+ break
+ except Exception as e:
+ self.log(f"解析磁力文件失败: {e}")
+
+ return items
+
+ 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': []}
+
+ ext = self.get_file_ext(file_path)
+ items = []
+
+ 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''''''
+ 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 == '单项输入':
+ 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