Files
sg/py/资源管理老.py
2026-08-14 11:27:10 +02:00

7695 lines
351 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 资源管理.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'<title[^>]*>(.*?)</title>', 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'<script[^>]*>.*?</script>', '', text, flags=re.DOTALL | re.IGNORECASE)
text = re.sub(r'<style[^>]*>.*?</style>', '', 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'<br\s*/?>', '\n', text, flags=re.IGNORECASE)
text = re.sub(r'<[^>]+>', ' ', text)
text = text.replace('&nbsp;', ' ').replace('&amp;', '&')
text = text.replace('&lt;', '<').replace('&gt;', '>')
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[^>]*>.*?<span[^>]*>([^<]+)</span>',
r'节目[:]\s*([^<>\n]+)',
r'"programName"\s*:\s*"([^"]+)"',
r'<div class="program-name"[^>]*>([^<]+)</div>',
]
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[^>]*>.*?<span[^>]*>([^<]+)</span>',
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'''<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200" viewBox="0 0 200 200">
<rect width="200" height="200" rx="40" ry="40" fill="{color}"/>
<circle cx="100" cy="100" r="70" fill="white" opacity="0.3"/>
<text x="100" y="140" font-size="100" text-anchor="middle" fill="white" font-family="Arial" font-weight="bold">{text}</text>
</svg>'''
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'<div class="card-blog">.*?<img[^>]*src="([^"]+)".*?<h4><a href="([^"]+)"[^>]*>([^<]+)</a>'
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="([^"]+)"[^>]*>([^<]+)</a>.*?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'''<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200" viewBox="0 0 200 200">
<rect width="200" height="200" rx="40" ry="40" fill="{color}"/>
<circle cx="100" cy="100" r="70" fill="white" opacity="0.3"/>
<text x="100" y="140" font-size="100" text-anchor="middle" fill="white" font-family="Arial" font-weight="bold">{text}</text>
</svg>'''
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 '<div class="radio' not in html and 'class="radio-item"' not in html:
break
radios = self._parse_radio_page(html)
if not radios:
break
existing_ids = {r['id'] for r in all_radios}
for radio in radios:
if radio['id'] not in existing_ids:
all_radios.append(radio)
existing_ids.add(radio['id'])
if len(radios) < 12:
break
page += 1
time.sleep(0.3)
except:
break
try:
with open(file_cache_path, 'w', encoding='utf-8') as f:
json.dump(all_radios, f, ensure_ascii=False, indent=2)
except:
pass
self.radio_cache[cache_key] = all_radios
self.radio_cache_time[cache_key] = current_time
return all_radios
def _parse_radio_page(self, html):
radios = []
try:
from pyquery import PyQuery as pq
doc = pq(html)
items = doc(".contentSec .radio, .radio-list .radio-item")
for li in items.items():
a = li("a").eq(0)
href = a.attr("href") or ""
radio_id_match = re.search(r'/radios/(\d+)', href)
if not radio_id_match:
continue
radio_id = radio_id_match.group(1)
name = li("span").text() or a.attr("title") or a.text() or li(".name").text()
if not name:
continue
name = name.strip()
pic = li("img").attr("src") or ""
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/')
pic = pic.replace('//', '/').replace('http:/', 'http://')
desc = li(".descRadio, .desc, .radio-desc").text() or "直播中"
desc = desc.strip()
radios.append({
'id': radio_id,
'name': name,
'pic': pic,
'desc': desc
})
except ImportError:
pattern = r'<a[^>]*href="/radios/(\d+)"[^>]*>.*?<img[^>]*src="([^"]+)"[^>]*>.*?<span[^>]*>([^<]+)</span>'
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()