6801 lines
284 KiB
Python
6801 lines
284 KiB
Python
# 资源管理.py - 智能多策略封面提取版(含在线直播、在线电台、短视频、画廊功能、删除模式、网页浏览器、PDF/EPUB漫画阅读)
|
||
# 说明:MP3/FLAC/M4A/AAC/OGG/WAV 多策略提取内置封面,优先使用本地同名图片
|
||
# 新增:PDF/EPUB漫画阅读支持,统一缓存目录,支持任意文件夹读取
|
||
# 新增:动作/工具/书签功能(移植自4webview.py)- 网格排版,支持添加/删除/修改书签
|
||
|
||
import sys
|
||
import re
|
||
import json
|
||
import os
|
||
import base64
|
||
import hashlib
|
||
import time
|
||
import urllib.parse
|
||
import sqlite3
|
||
import glob
|
||
import zlib
|
||
import xml.etree.ElementTree as ET
|
||
import random
|
||
import struct
|
||
import threading
|
||
import shutil
|
||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||
from pathlib import Path
|
||
from base.spider import Spider
|
||
import requests
|
||
from requests.adapters import HTTPAdapter
|
||
from urllib3.util.retry import Retry
|
||
|
||
# ==================== PDF/EPUB漫画支持(需要Java类) ====================
|
||
try:
|
||
from java import jclass
|
||
HAS_JAVA = True
|
||
except ImportError:
|
||
HAS_JAVA = False
|
||
print("⚠️ Java类不可用,PDF/EPUB功能将受限")
|
||
|
||
# ==================== 电视直播封面图片 ====================
|
||
TV_COVER = "https://p2.ssl.qhimgs1.com/bdr/460__/t045d2cbb68401612b2.png"
|
||
|
||
# ==================== 缓存路径配置 ====================
|
||
COVER_CACHE_DIR = '/storage/emulated/0/tmp/covers/'
|
||
LYRICS_CACHE_DIR = '/storage/emulated/0/tmp/lyrics/'
|
||
RADIO_COVER_CACHE_DIR = '/storage/emulated/0/tmp/radio_covers/'
|
||
COMIC_CACHE_DIR = '/storage/emulated/0/tmp/comic_covers/'
|
||
COMIC_PREVIEW_DIR = '/storage/emulated/0/tmp/comic_previews/'
|
||
RADIO_SCAN_RECORD_FILE = '/storage/emulated/0/tmp/radio_scan_record.json'
|
||
COVER_SCAN_RECORD_FILE = '/storage/emulated/0/tmp/cover_scan_record.json'
|
||
|
||
LIVE_PROGRAM_CACHE_DURATION = 300
|
||
|
||
DB_COMPAT_MODE = True
|
||
MAX_DB_RESULTS = 50000
|
||
|
||
ROOT_PATHS = [
|
||
'/storage/emulated/0/',
|
||
'/storage/emulated/0/Movies/',
|
||
'/storage/emulated/0/Music/',
|
||
'/storage/emulated/0/Download/KuwoMusic/music/',
|
||
'/storage/emulated/0/Download/',
|
||
'/storage/emulated/0/DCIM/Camera/',
|
||
'/storage/emulated/0/Pictures/',
|
||
'/storage/emulated/0/Books/',
|
||
'/storage/emulated/0/VodPlus/wwwroot/lz/',
|
||
'/storage/emulated/0/tmp/'
|
||
]
|
||
|
||
PATH_TO_CHINESE = {
|
||
'/storage/emulated/0/': '根目录',
|
||
'/storage/emulated/0/Movies/': '电影',
|
||
'/storage/emulated/0/Music/': '音乐',
|
||
'/storage/emulated/0/Download/KuwoMusic/music/': '酷我音乐',
|
||
'/storage/emulated/0/Download/': '下载',
|
||
'/storage/emulated/0/DCIM/Camera/': '相机',
|
||
'/storage/emulated/0/Pictures/': '图片',
|
||
'/storage/emulated/0/Books/': '小说',
|
||
'/storage/emulated/0/VodPlus/wwwroot/lz/': '老张',
|
||
'/storage/emulated/0/tmp/': '缓存文件夹'
|
||
}
|
||
|
||
print("ℹ️ 本地资源管理加载成功 - 智能多策略封面提取版")
|
||
print("✅ 在线直播UA已修复 - 每个直播源可独立配置UA,播放时自动生效")
|
||
print("✅ 连点删除功能已启用 - 播放界面连续点击同一首歌3次可删除")
|
||
print("✅ 删除模式支持删除文件夹")
|
||
print("✅ 缓存图片目录已添加 .nomedia 文件,防止相册扫描")
|
||
print("✅ PDF/EPUB漫画阅读已集成 - 支持任意文件夹读取")
|
||
print("✅ 动作/工具/书签功能已集成 - 网格排版")
|
||
|
||
|
||
ONLINE_LIVE_SOURCES = [
|
||
{
|
||
"id": "migu_live",
|
||
"name": "📺 咪咕直播",
|
||
"url": "https://gh-proxy.org/https://raw.githubusercontent.com/develop202/migu_video/refs/heads/main/interface.txt",
|
||
"cover": TV_COVER,
|
||
"remarks": "央视/卫视直播",
|
||
"type": "m3u",
|
||
"playerType": 2,
|
||
"ua": "com.android.chrome/3.7.0 (Linux;Android 15)",
|
||
"referer": "https://www.miguvideo.com/"
|
||
},
|
||
{
|
||
"id": "gongdian_live",
|
||
"name": "🏛️ 宫殿直播",
|
||
"url": "https://gongdian.top/tv/iptv",
|
||
"cover": TV_COVER,
|
||
"remarks": "宫殿直播源",
|
||
"type": "m3u",
|
||
"playerType": 2,
|
||
"ua": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
||
"referer": "https://gongdian.top/"
|
||
},
|
||
{
|
||
"id": "simple_live",
|
||
"name": "✨ 简单直播",
|
||
"url": "http://gh-proxy.org/raw.githubusercontent.com/Supprise0901/TVBox_live/main/live.txt",
|
||
"cover": TV_COVER,
|
||
"remarks": "简单直播源",
|
||
"type": "txt",
|
||
"ua": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
|
||
},
|
||
{
|
||
"id": "Kimentanm",
|
||
"name": "💎 Kimentanm",
|
||
"url": "https://gh.llkk.cc/https://raw.githubusercontent.com/Kimentanm/aptv/master/m3u/iptv.m3u",
|
||
"cover": TV_COVER,
|
||
"remarks": "Kimentanm",
|
||
"type": "m3u",
|
||
"ua": "AptvPlayer-UA"
|
||
},
|
||
{
|
||
"id": "游魂",
|
||
"name": "💎 游魂",
|
||
"url": "https://www.iyouhun.com/tv/zb",
|
||
"cover": TV_COVER,
|
||
"remarks": "简单直播源",
|
||
"type": "txt",
|
||
"ua": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
|
||
},
|
||
{
|
||
"id": "rihou",
|
||
"name": "💎 日后",
|
||
"url": "http://rihou.cc:555/gggg.nzk",
|
||
"cover": TV_COVER,
|
||
"remarks": "rihou",
|
||
"type": "txt",
|
||
"ua": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
|
||
},
|
||
{
|
||
"id": "综合直播",
|
||
"name": "✨ 综合直播",
|
||
"url": "https://ds65.tv1288.xyz",
|
||
"cover": TV_COVER,
|
||
"remarks": "综合直播",
|
||
"type": "m3u",
|
||
"ua": "bingcha/1.1 (mianfeifenxiang)"
|
||
},
|
||
{
|
||
"id": "suxuang",
|
||
"name": "✨ suxuang",
|
||
"url": "https://gh-proxy.org/https://raw.githubusercontent.com/suxuang/myIPTV/main/ipv4.m3u",
|
||
"cover": TV_COVER,
|
||
"remarks": "suxuang",
|
||
"type": "m3u",
|
||
"ua": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
|
||
},
|
||
{
|
||
"id": "kulao_tv",
|
||
"name": "👖 裤佬TV直播",
|
||
"url": "https://gh-proxy.org/https://raw.githubusercontent.com/Jsnzkpg/Jsnzkpg/Jsnzkpg/Jsnzkpg1.m3u",
|
||
"cover": TV_COVER,
|
||
"remarks": "裤佬TV直播源",
|
||
"type": "m3u",
|
||
"playerType": 2,
|
||
"ua": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
|
||
}
|
||
]
|
||
|
||
LIVE_CATEGORY_ID = "online_live"
|
||
LIVE_CATEGORY_NAME = "📺 电视直播"
|
||
LIVE_CACHE_DURATION = 600
|
||
|
||
COMMON_HEADERS_LIST = [
|
||
{
|
||
"name": "Chrome浏览器",
|
||
"headers": {
|
||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||
"Accept": "*/*",
|
||
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
|
||
"Connection": "keep-alive"
|
||
}
|
||
},
|
||
{
|
||
"name": "Firefox浏览器",
|
||
"headers": {
|
||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:120.0) Gecko/20100101 Firefox/120.0",
|
||
"Accept": "*/*",
|
||
"Accept-Language": "zh-CN,zh;q=0.8,en-US;q=0.5,en;q=0.3",
|
||
"Connection": "keep-alive"
|
||
}
|
||
},
|
||
{
|
||
"name": "okhttp/3",
|
||
"headers": {
|
||
"User-Agent": "okhttp/3.12.11",
|
||
"Accept": "*/*",
|
||
"Connection": "Keep-Alive"
|
||
}
|
||
},
|
||
{
|
||
"name": "手机浏览器",
|
||
"headers": {
|
||
"User-Agent": "Mozilla/5.0 (Linux; Android 11; SM-G991B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36",
|
||
"Accept": "*/*",
|
||
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
|
||
"Connection": "keep-alive"
|
||
}
|
||
}
|
||
]
|
||
|
||
DOMAIN_SPECIFIC_HEADERS = {
|
||
"miguvideo.com": [
|
||
{
|
||
"name": "咪咕专用-Android Chrome",
|
||
"headers": {
|
||
"User-Agent": "com.android.chrome/3.7.0 (Linux;Android 15)",
|
||
"Accept": "*/*",
|
||
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
|
||
"Connection": "keep-alive",
|
||
"Referer": "https://www.miguvideo.com/"
|
||
}
|
||
}
|
||
],
|
||
"gongdian.top": [
|
||
{
|
||
"name": "宫殿直播专用",
|
||
"headers": {
|
||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
||
"Accept": "*/*",
|
||
"Referer": "https://gongdian.top/",
|
||
"Connection": "keep-alive"
|
||
}
|
||
}
|
||
],
|
||
"rihou.cc": [
|
||
{
|
||
"name": "日后源专用-Chrome",
|
||
"headers": {
|
||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||
"Referer": "https://rihou.cc:555/",
|
||
"Accept": "*/*",
|
||
"Connection": "keep-alive"
|
||
}
|
||
}
|
||
]
|
||
}
|
||
|
||
|
||
class ComicReader:
|
||
SUPPORTED_EXTS = ['pdf', 'epub']
|
||
EPUB_TEXT_THRESHOLD = 10
|
||
FIRST_BATCH_PAGES = 200
|
||
|
||
_render_executor = None
|
||
|
||
@classmethod
|
||
def get_render_executor(cls):
|
||
if cls._render_executor is None:
|
||
import multiprocessing
|
||
cpu_count = multiprocessing.cpu_count()
|
||
thread_count = min(max(cpu_count * 12, 24), 48)
|
||
cls._render_executor = ThreadPoolExecutor(max_workers=thread_count, thread_name_prefix="PDFRender")
|
||
print(f"[PDF渲染线程池] 已启动 {thread_count} 个线程")
|
||
return cls._render_executor
|
||
|
||
@classmethod
|
||
def shutdown_render_executor(cls):
|
||
if cls._render_executor:
|
||
cls._render_executor.shutdown(wait=False)
|
||
cls._render_executor = None
|
||
|
||
@staticmethod
|
||
def is_supported(filename):
|
||
ext = filename.split('.')[-1].lower() if '.' in filename else ''
|
||
return ext in ComicReader.SUPPORTED_EXTS
|
||
|
||
@staticmethod
|
||
def get_comic_preview_dir(file_path):
|
||
file_hash = hashlib.md5(file_path.encode()).hexdigest()[:16]
|
||
preview_dir = os.path.join(COMIC_PREVIEW_DIR, file_hash)
|
||
try:
|
||
if not os.path.exists(preview_dir):
|
||
os.makedirs(preview_dir, exist_ok=True)
|
||
return preview_dir
|
||
except:
|
||
os.makedirs(COMIC_PREVIEW_DIR, exist_ok=True)
|
||
return COMIC_PREVIEW_DIR
|
||
|
||
@staticmethod
|
||
def _generate_pdf_cover(pdf_path, cover_path, max_width=400):
|
||
if not HAS_JAVA:
|
||
return False
|
||
try:
|
||
File = jclass("java.io.File")
|
||
ParcelFileDescriptor = jclass("android.os.ParcelFileDescriptor")
|
||
PdfRenderer = jclass("android.graphics.pdf.PdfRenderer")
|
||
Bitmap = jclass("android.graphics.Bitmap")
|
||
CompressFormat = jclass("android.graphics.Bitmap$CompressFormat")
|
||
|
||
fd = ParcelFileDescriptor.open(
|
||
File(pdf_path),
|
||
ParcelFileDescriptor.MODE_READ_ONLY
|
||
)
|
||
renderer = PdfRenderer(fd)
|
||
|
||
if renderer.getPageCount() > 0:
|
||
page = renderer.openPage(0)
|
||
width = page.getWidth()
|
||
height = page.getHeight()
|
||
if width > max_width:
|
||
scale = max_width / width
|
||
width = max_width
|
||
height = int(height * scale)
|
||
|
||
bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
|
||
page.render(bitmap, None, None, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY)
|
||
|
||
fos = jclass("java.io.FileOutputStream")(cover_path)
|
||
bitmap.compress(CompressFormat.PNG, 85, fos)
|
||
fos.flush()
|
||
fos.close()
|
||
page.close()
|
||
|
||
renderer.close()
|
||
fd.close()
|
||
return True
|
||
except Exception as e:
|
||
print(f"[PDF封面生成失败] {e}")
|
||
return False
|
||
|
||
@staticmethod
|
||
def _generate_epub_cover(epub_path, cover_path):
|
||
try:
|
||
import zipfile
|
||
with zipfile.ZipFile(epub_path, 'r') as zf:
|
||
cover_names = ['cover', 'Cover', 'COVER', 'titlepage', 'TitlePage']
|
||
img_files = []
|
||
|
||
for name in zf.namelist():
|
||
lname = name.lower()
|
||
if any(lname.endswith(ext) for ext in ['.png', '.jpg', '.jpeg', '.webp']):
|
||
base = os.path.basename(lname)
|
||
is_cover = any(cn.lower() in base for cn in cover_names) or 'cover' in lname
|
||
if is_cover:
|
||
img_files.insert(0, name)
|
||
else:
|
||
img_files.append(name)
|
||
|
||
img_files.sort(key=lambda x: x.lower())
|
||
|
||
if img_files:
|
||
img_data = zf.read(img_files[0])
|
||
with open(cover_path, 'wb') as f:
|
||
f.write(img_data)
|
||
return True
|
||
except Exception as e:
|
||
print(f"[EPUB封面生成失败] {e}")
|
||
return False
|
||
|
||
@staticmethod
|
||
def get_cover_url(file_path):
|
||
if not os.path.exists(file_path):
|
||
return None
|
||
|
||
ext = file_path.split('.')[-1].lower()
|
||
file_hash = hashlib.md5(file_path.encode()).hexdigest()
|
||
|
||
os.makedirs(COMIC_CACHE_DIR, exist_ok=True)
|
||
|
||
cache_exts = ['.jpg', '.jpeg', '.png', '.webp']
|
||
cache_file = None
|
||
for cext in cache_exts:
|
||
test_path = os.path.join(COMIC_CACHE_DIR, f"{file_hash}{cext}")
|
||
if os.path.exists(test_path):
|
||
cache_file = test_path
|
||
break
|
||
|
||
if cache_file:
|
||
return f"file://{cache_file}"
|
||
|
||
cache_file = os.path.join(COMIC_CACHE_DIR, f"{file_hash}.jpg")
|
||
|
||
success = False
|
||
if ext == 'pdf':
|
||
success = ComicReader._generate_pdf_cover(file_path, cache_file)
|
||
elif ext == 'epub':
|
||
success = ComicReader._generate_epub_cover(file_path, cache_file)
|
||
|
||
if success and os.path.exists(cache_file):
|
||
return f"file://{cache_file}"
|
||
|
||
return ComicReader._get_default_comic_icon(ext)
|
||
|
||
@staticmethod
|
||
def _get_default_comic_icon(ext):
|
||
if ext == 'pdf':
|
||
return "https://img.icons8.com/color/96/000000/pdf-2.png"
|
||
else:
|
||
return "https://img.icons8.com/color/96/000000/epub.png"
|
||
|
||
@staticmethod
|
||
def get_pdf_page_count(pdf_path):
|
||
if not HAS_JAVA:
|
||
return 0
|
||
try:
|
||
File = jclass("java.io.File")
|
||
ParcelFileDescriptor = jclass("android.os.ParcelFileDescriptor")
|
||
PdfRenderer = jclass("android.graphics.pdf.PdfRenderer")
|
||
|
||
fd = ParcelFileDescriptor.open(
|
||
File(pdf_path),
|
||
ParcelFileDescriptor.MODE_READ_ONLY
|
||
)
|
||
renderer = PdfRenderer(fd)
|
||
page_count = renderer.getPageCount()
|
||
renderer.close()
|
||
fd.close()
|
||
return page_count
|
||
except Exception as e:
|
||
print(f"[PDF页数获取失败] {e}")
|
||
return 0
|
||
|
||
@staticmethod
|
||
def render_pdf_page(pdf_path, page_num, output_path, max_width=800):
|
||
if not HAS_JAVA:
|
||
return False
|
||
try:
|
||
File = jclass("java.io.File")
|
||
ParcelFileDescriptor = jclass("android.os.ParcelFileDescriptor")
|
||
PdfRenderer = jclass("android.graphics.pdf.PdfRenderer")
|
||
Bitmap = jclass("android.graphics.Bitmap")
|
||
CompressFormat = jclass("android.graphics.Bitmap$CompressFormat")
|
||
|
||
fd = ParcelFileDescriptor.open(
|
||
File(pdf_path),
|
||
ParcelFileDescriptor.MODE_READ_ONLY
|
||
)
|
||
renderer = PdfRenderer(fd)
|
||
|
||
if page_num < renderer.getPageCount():
|
||
page = renderer.openPage(page_num)
|
||
width = page.getWidth()
|
||
height = page.getHeight()
|
||
if width > max_width:
|
||
scale = max_width / width
|
||
width = max_width
|
||
height = int(height * scale)
|
||
|
||
bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
|
||
page.render(bitmap, None, None, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY)
|
||
|
||
fos = jclass("java.io.FileOutputStream")(output_path)
|
||
bitmap.compress(CompressFormat.JPEG, 75, fos)
|
||
fos.flush()
|
||
fos.close()
|
||
page.close()
|
||
|
||
renderer.close()
|
||
fd.close()
|
||
return True
|
||
except Exception as e:
|
||
print(f"[PDF单页渲染失败] page {page_num}: {e}")
|
||
return False
|
||
|
||
@staticmethod
|
||
def render_pdf_first_batch(pdf_path, pdf_name):
|
||
if not HAS_JAVA:
|
||
return 0, []
|
||
|
||
try:
|
||
page_count = ComicReader.get_pdf_page_count(pdf_path)
|
||
if page_count == 0:
|
||
return 0, []
|
||
|
||
preview_dir = ComicReader.get_comic_preview_dir(pdf_path)
|
||
|
||
all_cached = True
|
||
for i in range(min(page_count, 200)):
|
||
out_path = os.path.join(preview_dir, f"page_{i:04d}.jpg")
|
||
if not os.path.exists(out_path):
|
||
all_cached = False
|
||
break
|
||
|
||
if all_cached:
|
||
img_urls = []
|
||
for i in range(page_count):
|
||
out_path = os.path.join(preview_dir, f"page_{i:04d}.jpg")
|
||
if os.path.exists(out_path):
|
||
img_urls.append(f"file://{out_path}")
|
||
print(f"[PDF已缓存] {pdf_name}, 全部{len(img_urls)}页秒开")
|
||
return len(img_urls), img_urls
|
||
|
||
render_limit = min(ComicReader.FIRST_BATCH_PAGES, page_count)
|
||
|
||
tasks = []
|
||
for i in range(render_limit):
|
||
out_path = os.path.join(preview_dir, f"page_{i:04d}.jpg")
|
||
if not os.path.exists(out_path):
|
||
tasks.append((i, out_path))
|
||
|
||
if not tasks:
|
||
img_urls = [f"file://{os.path.join(preview_dir, f'page_{i:04d}.jpg')}" for i in range(render_limit)]
|
||
print(f"[PDF已缓存] {pdf_name}, 前{render_limit}页")
|
||
return render_limit, img_urls
|
||
|
||
print(f"[PDF并发渲染] {pdf_name}, 开始并发渲染前{len(tasks)}/{render_limit}页")
|
||
start_time = time.time()
|
||
|
||
executor = ComicReader.get_render_executor()
|
||
futures = {}
|
||
|
||
for i, out_path in tasks:
|
||
future = executor.submit(ComicReader.render_pdf_page, pdf_path, i, out_path)
|
||
futures[future] = (i, out_path)
|
||
|
||
timeout = max(30, render_limit * 0.3)
|
||
completed = 0
|
||
failed = 0
|
||
|
||
for future in futures:
|
||
try:
|
||
future.result(timeout=timeout)
|
||
completed += 1
|
||
except Exception as e:
|
||
i, out_path = futures[future]
|
||
print(f"[PDF渲染超时] page {i}: {e}")
|
||
ComicReader.render_pdf_page(pdf_path, i, out_path)
|
||
failed += 1
|
||
|
||
elapsed = time.time() - start_time
|
||
print(f"[PDF并发渲染完成] {pdf_name}, 成功{completed}页, 失败{failed}页, 耗时{elapsed:.1f}秒")
|
||
|
||
img_urls = []
|
||
for i in range(render_limit):
|
||
out_path = os.path.join(preview_dir, f"page_{i:04d}.jpg")
|
||
if os.path.exists(out_path):
|
||
img_urls.append(f"file://{out_path}")
|
||
else:
|
||
ComicReader.render_pdf_page(pdf_path, i, out_path)
|
||
img_urls.append(f"file://{out_path}")
|
||
|
||
if page_count > render_limit:
|
||
ComicReader._lazy_render_remaining_pages_concurrent(pdf_path, pdf_name, render_limit, page_count, preview_dir)
|
||
|
||
return len(img_urls), img_urls
|
||
except Exception as e:
|
||
print(f"[PDF渲染失败] {e}")
|
||
return 0, []
|
||
|
||
@staticmethod
|
||
def _lazy_render_remaining_pages_concurrent(pdf_path, pdf_name, start_page, total_pages, preview_dir):
|
||
def render_remaining_concurrent():
|
||
remaining = total_pages - start_page
|
||
if remaining <= 0:
|
||
return
|
||
|
||
print(f"[PDF后台渲染] {pdf_name}, 开始后台渲染剩余{remaining}页")
|
||
start_time = time.time()
|
||
|
||
executor = ComicReader.get_render_executor()
|
||
futures = {}
|
||
|
||
for i in range(start_page, total_pages):
|
||
out_path = os.path.join(preview_dir, f"page_{i:04d}.jpg")
|
||
if not os.path.exists(out_path):
|
||
future = executor.submit(ComicReader.render_pdf_page, pdf_path, i, out_path)
|
||
futures[future] = (i, out_path)
|
||
|
||
for future in futures:
|
||
try:
|
||
future.result(timeout=300)
|
||
except Exception as e:
|
||
i, out_path = futures[future]
|
||
print(f"[PDF后台渲染失败] page {i}: {e}")
|
||
|
||
elapsed = time.time() - start_time
|
||
print(f"[PDF后台渲染完成] {pdf_name}, 剩余{remaining}页, 耗时{elapsed:.1f}秒")
|
||
|
||
background_thread = threading.Thread(target=render_remaining_concurrent, daemon=True)
|
||
background_thread.start()
|
||
|
||
@staticmethod
|
||
def extract_epub_all_images(epub_path, epub_name):
|
||
preview_dir = ComicReader.get_comic_preview_dir(epub_path)
|
||
|
||
existing_images = [f for f in os.listdir(preview_dir)
|
||
if f.endswith(('.jpg','.jpeg','.webp','.gif'))]
|
||
if existing_images:
|
||
existing_images.sort()
|
||
img_urls = [f"file://{os.path.join(preview_dir, img)}" for img in existing_images]
|
||
print(f"[EPUB已缓存] {epub_name}, {len(img_urls)}张图片")
|
||
return len(img_urls), img_urls, preview_dir
|
||
|
||
img_urls = []
|
||
try:
|
||
import zipfile
|
||
with zipfile.ZipFile(epub_path, 'r') as zf:
|
||
image_exts = ('.png', '.jpg', '.jpeg', '.webp', '.gif')
|
||
img_files = []
|
||
cover_names = ['cover', 'Cover', 'COVER', 'titlepage']
|
||
|
||
for name in zf.namelist():
|
||
lname = name.lower()
|
||
if any(lname.endswith(ext) for ext in image_exts):
|
||
base = os.path.basename(lname)
|
||
is_cover = any(cn.lower() in base for cn in cover_names) or 'cover' in lname
|
||
if is_cover:
|
||
img_files.insert(0, name)
|
||
else:
|
||
img_files.append(name)
|
||
|
||
img_files.sort(key=lambda x: x.lower())
|
||
|
||
executor = ComicReader.get_render_executor()
|
||
futures = []
|
||
|
||
for idx, img_name in enumerate(img_files):
|
||
ext = os.path.splitext(img_name)[1].lower()
|
||
if not ext or ext not in image_exts:
|
||
ext = '.jpg'
|
||
out_filename = f"page_{idx:04d}{ext}"
|
||
out_path = os.path.join(preview_dir, out_filename)
|
||
if not os.path.exists(out_path):
|
||
future = executor.submit(ComicReader._extract_single_epub_image, epub_path, img_name, out_path)
|
||
futures.append((idx, future, out_path))
|
||
else:
|
||
img_urls.append(f"file://{out_path}")
|
||
|
||
for idx, future, out_path in futures:
|
||
try:
|
||
future.result(timeout=10)
|
||
img_urls.append(f"file://{out_path}")
|
||
except Exception as e:
|
||
print(f"[EPUB图片提取失败]: {e}")
|
||
|
||
print(f"[EPUB提取完成] {epub_name}, {len(img_urls)}张图片")
|
||
return len(img_urls), img_urls, preview_dir
|
||
except Exception as e:
|
||
print(f"[EPUB提取失败] {e}")
|
||
return 0, [], None
|
||
|
||
@staticmethod
|
||
def _extract_single_epub_image(epub_path, img_name, out_path):
|
||
try:
|
||
import zipfile
|
||
with zipfile.ZipFile(epub_path, 'r') as zf:
|
||
data = zf.read(img_name)
|
||
with open(out_path, 'wb') as f:
|
||
f.write(data)
|
||
return True
|
||
except:
|
||
return False
|
||
|
||
@staticmethod
|
||
def extract_epub_chapters(epub_path):
|
||
chapters = []
|
||
try:
|
||
import zipfile
|
||
with zipfile.ZipFile(epub_path, 'r') as zf:
|
||
if "META-INF/container.xml" not in zf.namelist():
|
||
return []
|
||
|
||
container = zf.read("META-INF/container.xml")
|
||
root = ET.fromstring(container)
|
||
ns = {'ns': 'urn:oasis:names:tc:opendocument:xmlns:container'}
|
||
rootfile_path = root.find('.//ns:rootfile', ns).get('full-path')
|
||
|
||
opf_data = zf.read(rootfile_path)
|
||
opf_root = ET.fromstring(opf_data)
|
||
opf_ns = {'opf': 'http://www.idpf.org/2007/opf'}
|
||
|
||
items = {}
|
||
for item in opf_root.findall('.//opf:item', opf_ns):
|
||
items[item.get('id')] = {
|
||
'href': item.get('href'),
|
||
'media_type': item.get('media-type', '')
|
||
}
|
||
|
||
spine = opf_root.find('.//opf:spine', opf_ns)
|
||
if spine is None:
|
||
return []
|
||
|
||
base_dir = os.path.dirname(rootfile_path)
|
||
chapter_idx = 0
|
||
|
||
for itemref in spine.findall('opf:itemref', opf_ns):
|
||
idref = itemref.get('idref')
|
||
if idref not in items:
|
||
continue
|
||
|
||
item_info = items[idref]
|
||
media_type = item_info['media_type']
|
||
if media_type not in ('application/xhtml+xml', 'text/html', 'application/xml'):
|
||
continue
|
||
|
||
href = item_info['href']
|
||
import posixpath
|
||
full_path = posixpath.join(base_dir, href) if base_dir else href
|
||
if full_path not in zf.namelist():
|
||
continue
|
||
|
||
raw = zf.read(full_path)
|
||
try:
|
||
content = raw.decode('utf-8')
|
||
except:
|
||
content = raw.decode('latin-1', errors='ignore')
|
||
|
||
title = None
|
||
for h_tag in ['h1', 'h2', 'h3', 'h4', 'h5', 'h6']:
|
||
m = re.search(rf'<{h_tag}[^>]*>(.*?)</{h_tag}>', content, re.IGNORECASE | re.DOTALL)
|
||
if m:
|
||
raw_title = re.sub(r'<[^>]+>', '', m.group(1)).strip()
|
||
raw_title = re.sub(r'^\d+\s*', '', raw_title)
|
||
if raw_title:
|
||
title = raw_title
|
||
break
|
||
|
||
if not title:
|
||
m = re.search(r'<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(' ', ' ').replace('&', '&')
|
||
text = text.replace('<', '<').replace('>', '>')
|
||
text = re.sub(r'[ \t]+', ' ', text)
|
||
text = re.sub(r'\n{3,}', '\n\n', text)
|
||
text = text.strip()
|
||
|
||
if text:
|
||
chapters.append({
|
||
'title': title,
|
||
'content': text,
|
||
'index': chapter_idx
|
||
})
|
||
chapter_idx += 1
|
||
|
||
return chapters
|
||
except Exception as e:
|
||
print(f"[EPUB章节提取失败] {e}")
|
||
return []
|
||
|
||
@staticmethod
|
||
def get_epub_image_count(epub_path):
|
||
try:
|
||
import zipfile
|
||
with zipfile.ZipFile(epub_path, 'r') as zf:
|
||
image_exts = ('.png', '.jpg', '.jpeg', '.webp', '.gif')
|
||
return sum(1 for name in zf.namelist()
|
||
if name.lower().endswith(image_exts))
|
||
except:
|
||
return 0
|
||
|
||
@staticmethod
|
||
def get_comic_detail(file_path, filename):
|
||
ext = filename.split('.')[-1].lower() if '.' in filename else ''
|
||
|
||
if ext == 'pdf':
|
||
if not HAS_JAVA:
|
||
return {"list": [{
|
||
"vod_id": file_path,
|
||
"vod_name": os.path.splitext(filename)[0],
|
||
"vod_pic": ComicReader._get_default_comic_icon(ext),
|
||
"vod_remarks": "需要Java支持",
|
||
"vod_actor": "PDF"
|
||
}]}
|
||
|
||
page_count, img_urls = ComicReader.render_pdf_first_batch(file_path, filename)
|
||
cover_url = ComicReader.get_cover_url(file_path)
|
||
|
||
if img_urls:
|
||
play_url = "pics://" + "&&".join(img_urls)
|
||
total_pages = ComicReader.get_pdf_page_count(file_path)
|
||
if page_count >= total_pages:
|
||
remarks = f"{page_count}页 | PDF (已全部缓存)"
|
||
else:
|
||
remarks = f"{page_count}/{total_pages}页 | 后台继续缓存..."
|
||
|
||
return {
|
||
"list": [{
|
||
"vod_id": f"comic_pdf_{hashlib.md5(file_path.encode()).hexdigest()}",
|
||
"vod_name": os.path.splitext(filename)[0],
|
||
"vod_pic": cover_url,
|
||
"vod_play_from": "PDF漫画",
|
||
"vod_play_url": play_url,
|
||
"vod_remarks": remarks,
|
||
"vod_actor": "漫画",
|
||
"vod_player": "画"
|
||
}]
|
||
}
|
||
|
||
elif ext == 'epub':
|
||
img_count = ComicReader.get_epub_image_count(file_path)
|
||
|
||
if img_count >= ComicReader.EPUB_TEXT_THRESHOLD:
|
||
page_count, img_urls, _ = ComicReader.extract_epub_all_images(file_path, filename)
|
||
if img_urls:
|
||
play_url = "pics://" + "&&".join(img_urls)
|
||
cover_url = ComicReader.get_cover_url(file_path)
|
||
|
||
return {
|
||
"list": [{
|
||
"vod_id": f"comic_epub_{hashlib.md5(file_path.encode()).hexdigest()}",
|
||
"vod_name": os.path.splitext(filename)[0],
|
||
"vod_pic": cover_url or img_urls[0],
|
||
"vod_play_from": "EPUB漫画",
|
||
"vod_play_url": play_url,
|
||
"vod_remarks": f"{page_count}页 | EPUB(图片)",
|
||
"vod_actor": "漫画",
|
||
"vod_player": "画"
|
||
}]
|
||
}
|
||
else:
|
||
chapters = ComicReader.extract_epub_chapters(file_path)
|
||
if chapters:
|
||
encoded_path = ComicReader._b64u_encode(file_path)
|
||
novel_id = f"novel://{encoded_path}"
|
||
|
||
play_url_parts = []
|
||
for idx, ch in enumerate(chapters):
|
||
title = ch['title'].replace('$', ' ').replace('#', ' ')
|
||
play_url_parts.append(f"{title}${novel_id}?chapter={idx}")
|
||
|
||
play_url = "#".join(play_url_parts)
|
||
cover_url = ComicReader.get_cover_url(file_path)
|
||
|
||
return {
|
||
"list": [{
|
||
"vod_id": novel_id,
|
||
"vod_name": os.path.splitext(filename)[0],
|
||
"vod_pic": cover_url or ComicReader._get_default_comic_icon(ext),
|
||
"vod_play_from": "EPUB文本",
|
||
"vod_play_url": play_url,
|
||
"vod_remarks": f"{len(chapters)}章 | EPUB",
|
||
"vod_actor": "小说",
|
||
"vod_player": "书"
|
||
}]
|
||
}
|
||
else:
|
||
page_count, img_urls, _ = ComicReader.extract_epub_all_images(file_path, filename)
|
||
if img_urls:
|
||
play_url = "pics://" + "&&".join(img_urls)
|
||
cover_url = ComicReader.get_cover_url(file_path)
|
||
return {
|
||
"list": [{
|
||
"vod_id": f"comic_epub_{hashlib.md5(file_path.encode()).hexdigest()}",
|
||
"vod_name": os.path.splitext(filename)[0],
|
||
"vod_pic": cover_url or img_urls[0],
|
||
"vod_play_from": "EPUB漫画",
|
||
"vod_play_url": play_url,
|
||
"vod_remarks": f"{page_count}页 | EPUB",
|
||
"vod_actor": "漫画",
|
||
"vod_player": "画"
|
||
}]
|
||
}
|
||
|
||
return {"list": []}
|
||
|
||
@staticmethod
|
||
def _b64u_encode(data):
|
||
if isinstance(data, str):
|
||
data = data.encode('utf-8')
|
||
encoded = base64.b64encode(data).decode('ascii')
|
||
return encoded.replace('+', '-').replace('/', '_').rstrip('=')
|
||
|
||
@staticmethod
|
||
def _b64u_decode(data):
|
||
data = data.replace('-', '+').replace('_', '/')
|
||
pad = len(data) % 4
|
||
if pad:
|
||
data += '=' * (4 - pad)
|
||
return base64.b64decode(data).decode('utf-8')
|
||
|
||
@staticmethod
|
||
def clear_all_cache():
|
||
cover_count = 0
|
||
cover_size = 0
|
||
preview_count = 0
|
||
preview_size = 0
|
||
|
||
if os.path.exists(COMIC_CACHE_DIR):
|
||
for filename in os.listdir(COMIC_CACHE_DIR):
|
||
if filename == '.nomedia':
|
||
continue
|
||
file_path = os.path.join(COMIC_CACHE_DIR, filename)
|
||
if os.path.isfile(file_path):
|
||
cover_size += os.path.getsize(file_path)
|
||
os.remove(file_path)
|
||
cover_count += 1
|
||
|
||
if os.path.exists(COMIC_PREVIEW_DIR):
|
||
for root, dirs, files in os.walk(COMIC_PREVIEW_DIR):
|
||
for file in files:
|
||
if file == '.nomedia':
|
||
continue
|
||
file_path = os.path.join(root, file)
|
||
try:
|
||
preview_size += os.path.getsize(file_path)
|
||
os.remove(file_path)
|
||
preview_count += 1
|
||
except:
|
||
pass
|
||
|
||
total_size = cover_size + preview_size
|
||
total_count = cover_count + preview_count
|
||
|
||
if total_size > 1024 * 1024:
|
||
size_str = f"{total_size / (1024 * 1024):.2f} MB"
|
||
elif total_size > 1024:
|
||
size_str = f"{total_size / 1024:.2f} KB"
|
||
else:
|
||
size_str = f"{total_size} B"
|
||
|
||
return total_count, size_str
|
||
|
||
|
||
class RadioProgramFetcher:
|
||
_cache = {}
|
||
_cache_time = {}
|
||
|
||
@classmethod
|
||
def get_current_program(cls, radio_id):
|
||
cache_key = f"program_{radio_id}"
|
||
current_time = time.time()
|
||
|
||
if cache_key in cls._cache and current_time - cls._cache_time.get(cache_key, 0) < LIVE_PROGRAM_CACHE_DURATION:
|
||
return cls._cache[cache_key]
|
||
|
||
try:
|
||
url = f"http://www.qingting.fm/radios/{radio_id}"
|
||
headers = {
|
||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
||
"Referer": "http://www.qingting.fm/",
|
||
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"
|
||
}
|
||
|
||
response = requests.get(url, headers=headers, timeout=10)
|
||
response.encoding = 'utf-8'
|
||
html = response.text
|
||
|
||
program_info = cls._parse_current_program(html, radio_id)
|
||
|
||
if program_info:
|
||
cls._cache[cache_key] = program_info
|
||
cls._cache_time[cache_key] = current_time
|
||
return program_info
|
||
|
||
return None
|
||
except Exception as e:
|
||
print(f"获取电台节目信息失败 {radio_id}: {e}")
|
||
return None
|
||
|
||
@classmethod
|
||
def _parse_current_program(cls, html, radio_id):
|
||
program = {
|
||
'current': '正在播出',
|
||
'next': '即将播出',
|
||
'current_time': '',
|
||
'next_time': ''
|
||
}
|
||
|
||
try:
|
||
current_patterns = [
|
||
r'正在播放[::]\s*<[^>]*>([^<]+)</',
|
||
r'current-program[^>]*>.*?<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._load_bookmarks()
|
||
|
||
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)
|
||
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 _add_bookmark(self, url, title=""):
|
||
if not url.startswith(('http://', 'https://')):
|
||
url = 'https://' + url
|
||
|
||
for bm in self.bookmarks:
|
||
if bm['url'] == url:
|
||
return False, f"❌ 书签已存在\n\n📌 {bm['title']}\n🔗 {bm['url']}"
|
||
|
||
if not title:
|
||
title = url.replace('https://', '').replace('http://', '').split('/')[0][:25]
|
||
|
||
self.bookmarks.append({
|
||
'id': hashlib.md5(url.encode()).hexdigest()[:8],
|
||
'url': url,
|
||
'title': title,
|
||
'add_time': time.time()
|
||
})
|
||
self._save_bookmarks()
|
||
return True, f"✅ 添加成功!\n\n📌 {title}\n🔗 {url[:50]}..."
|
||
|
||
def _delete_bookmark(self, keyword):
|
||
for bm in self.bookmarks[:]:
|
||
if bm['title'] == keyword or bm['url'] == keyword or keyword in bm['url']:
|
||
title = bm['title']
|
||
url = bm['url']
|
||
self.bookmarks.remove(bm)
|
||
self._save_bookmarks()
|
||
return True, f"✅ 删除成功!\n\n📌 {title}\n🔗 {url[:50]}..."
|
||
return False, "❌ 未找到书签\n\n请输入正确的书签名或网址"
|
||
|
||
def _edit_bookmark(self, old, new_title):
|
||
for bm in self.bookmarks:
|
||
if bm['title'] == old or bm['url'] == old:
|
||
old_title = bm['title']
|
||
old_url = bm['url']
|
||
bm['title'] = new_title
|
||
self._save_bookmarks()
|
||
return True, f"✅ 修改成功!\n\n📌 {old_title} → {new_title}\n🔗 {old_url[:50]}..."
|
||
return False, "❌ 未找到书签\n\n请输入正确的原书签名或网址"
|
||
|
||
# 关键修复:直接返回 action 格式,让系统打开浏览器
|
||
def _open_url(self, url):
|
||
if not url:
|
||
return None
|
||
|
||
if not url.startswith(('http://', 'https://')):
|
||
url = 'https://' + url
|
||
|
||
name = url.replace('https://', '').replace('http://', '').split('/')[0][:30]
|
||
|
||
return {
|
||
'action': {
|
||
'actionId': 'OPEN_URL',
|
||
'type': 'browser',
|
||
'title': name,
|
||
'url': url
|
||
}
|
||
}
|
||
|
||
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 _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 _create_vod_item(self, name, action_id, params):
|
||
config = {'actionId': action_id, **params}
|
||
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_vod_item('🌐 访问网址', '访问网址', {'id': 'text', 'type': 'input', 'title': '网址输入', 'tip': '输入网址,如 baidu.com'}),
|
||
self._create_vod_item('📌 添加书签', '添加书签', {'id': 'text', 'type': 'input', 'title': '添加书签', 'tip': '格式: 名称@网址 或直接输入网址'}),
|
||
self._create_vod_item('🗑️ 删除书签', '删除书签', {'id': 'text', 'type': 'input', 'title': '删除书签', 'tip': '输入书签名或网址'}),
|
||
self._create_vod_item('✏️ 修改书签名', '修改书签名', {'id': 'text', 'type': 'input', 'title': '修改书签名', 'tip': '格式: 原书签|新名称 例: 百度|百度一下'}),
|
||
]
|
||
|
||
def _get_tool_list(self):
|
||
return [
|
||
self._create_vod_item('📖PDF阅读器', 'OPEN_URL', {'url': f'http://localhost:{self.port}/PDF阅读器.php'}),
|
||
self._create_vod_item('🔓 解密工具', 'OPEN_URL', {'url': f'http://localhost:{self.port}/html/jiema.html'}),
|
||
self._create_vod_item('🎮 喜刷刷', 'OPEN_URL', {'url': f'http://localhost:{self.port}/html/妹子.html'}),
|
||
self._create_vod_item('🎵 裤佬音乐', 'OPEN_URL', {'url': f'http://localhost:{self.port}/html/裤佬音乐.html'}),
|
||
self._create_vod_item('💢 直播', 'OPEN_URL', {'url': f'http://localhost:{self.port}/html/index.html'}),
|
||
self._create_vod_item('🎮 网页小游戏', 'OPEN_URL', {'url': 'https://www.yikm.net/nes?tag=9'}),
|
||
self._create_vod_item('📺 WhosTV', 'OPEN_URL', {'url': 'https://whos.tv/actresses'}),
|
||
]
|
||
|
||
def _get_bookmark_vod_list(self):
|
||
if not self.bookmarks:
|
||
return {
|
||
'list': [self._create_vod_item('📭 暂无书签,点击动作页添加', '添加书签', {})],
|
||
'page': 1,
|
||
'pagecount': 1,
|
||
'limit': 1,
|
||
'total': 1
|
||
}
|
||
|
||
items = []
|
||
for bm in reversed(self.bookmarks):
|
||
items.append(self._create_vod_item(f'📌 {bm["title"]}', 'OPEN_URL', {'url': bm['url']}))
|
||
|
||
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':
|
||
tools = self._get_tool_list()
|
||
return {'list': tools, 'page': pg, 'pagecount': 1, 'limit': len(tools), 'total': len(tools)}
|
||
if tid == 'web_action':
|
||
actions = self._get_action_list()
|
||
return {'list': actions, 'page': pg, 'pagecount': 1, 'limit': len(actions), 'total': len(actions)}
|
||
|
||
return {'list': [], 'page': pg, 'pagecount': 1}
|
||
|
||
def handle_action(self, action_str):
|
||
try:
|
||
obj = json.loads(action_str)
|
||
|
||
act = obj.get('action', '')
|
||
action_id = obj.get('actionId', '')
|
||
value = obj.get('value', '')
|
||
|
||
input_text = ''
|
||
if isinstance(value, dict) and "text" in value:
|
||
input_text = value["text"].strip()
|
||
|
||
# 访问网址
|
||
if act == '访问网址' or action_id == '访问网址':
|
||
if input_text:
|
||
result = self._open_url(input_text)
|
||
if result:
|
||
return result
|
||
return self._create_result_vod("❌ 请输入有效的网址", False)
|
||
return {
|
||
'actionId': '单项输入',
|
||
'id': 'text',
|
||
'type': 'input',
|
||
'title': '🌐 访问网址',
|
||
'tip': '输入网址,如 baidu.com',
|
||
'value': '',
|
||
'msg': '请输入网址'
|
||
}
|
||
|
||
# 添加书签
|
||
if act == '添加书签' or action_id == '添加书签':
|
||
if input_text:
|
||
title = ""
|
||
url = input_text
|
||
if '@' in input_text:
|
||
parts = input_text.split('@', 1)
|
||
title = parts[0].strip()
|
||
url = parts[1].strip()
|
||
success, msg = self._add_bookmark(url, title)
|
||
return self._create_result_vod(msg, success)
|
||
return {
|
||
'actionId': '单项输入',
|
||
'id': 'text',
|
||
'type': 'input',
|
||
'title': '📌 添加书签',
|
||
'tip': '格式: 名称@网址 或直接输入网址',
|
||
'value': '',
|
||
'msg': '请输入网址'
|
||
}
|
||
|
||
# 删除书签
|
||
if act == '删除书签' or action_id == '删除书签':
|
||
if input_text:
|
||
success, msg = self._delete_bookmark(input_text)
|
||
return self._create_result_vod(msg, success)
|
||
return {
|
||
'actionId': '单项输入',
|
||
'id': 'text',
|
||
'type': 'input',
|
||
'title': '🗑️ 删除书签',
|
||
'tip': '输入书签名或网址',
|
||
'value': '',
|
||
'msg': '请输入书签名或网址'
|
||
}
|
||
|
||
# 修改书签名
|
||
if act == '修改书签名' or action_id == '修改书签名':
|
||
if input_text:
|
||
if '|' in input_text:
|
||
parts = input_text.split('|', 1)
|
||
old = parts[0].strip()
|
||
new = parts[1].strip()
|
||
success, msg = self._edit_bookmark(old, new)
|
||
return self._create_result_vod(msg, success)
|
||
return "格式错误\n请输入: 原书签|新名称\n例如: 百度|百度一下"
|
||
return {
|
||
'actionId': '单项输入',
|
||
'id': 'text',
|
||
'type': 'input',
|
||
'title': '✏️ 修改书签名',
|
||
'tip': '格式: 原书签|新名称 例: 百度|百度一下',
|
||
'value': '',
|
||
'msg': '请输入要修改的书签'
|
||
}
|
||
|
||
# 关键修复:处理 OPEN_URL(书签点击、工具点击)
|
||
if action_id == 'OPEN_URL':
|
||
url = obj.get('url', '') or obj.get('value', '')
|
||
if url:
|
||
return self._open_url(url)
|
||
|
||
except Exception as e:
|
||
print(f"❌ WebActionBrowser action错误: {e}")
|
||
|
||
return self._create_result_vod("操作完成", True)
|
||
|
||
|
||
# ==================== 字母/数字筛选辅助方法 ====================
|
||
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.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://img.icons8.com/color/96/000000/folder-invoices.png',
|
||
'video': 'https://img.icons8.com/color/96/000000/video.png',
|
||
'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://img.icons8.com/color/96/000000/audio-file.png',
|
||
'database': 'https://img.icons8.com/color/96/000000/database.png',
|
||
'novel': 'https://img.icons8.com/color/96/000000/book.png',
|
||
'text': 'https://img.icons8.com/color/96/000000/document.png',
|
||
'file': 'https://img.icons8.com/color/96/000000/file.png',
|
||
'json': 'https://img.icons8.com/color/96/000000/json.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://img.icons8.com/color/96/php.png',
|
||
'python': 'https://img.icons8.com/color/96/python.png',
|
||
'zip': 'https://img.icons8.com/color/96/zip.png',
|
||
'archive': 'https://img.icons8.com/color/96/archive.png',
|
||
'rar': 'https://img.icons8.com/color/96/rar.png',
|
||
'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.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": "🎬 高质量小姐姐", "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.uumnet.com/api/mn2.php",
|
||
"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()
|
||
|
||
# 画廊API列表
|
||
self.gallery_apis = [
|
||
{"name": "🎨 图源B", "url": "https://api.uumnet.com/api/mn2.php", "type": "random"},
|
||
{"name": "🎨 图源C", "url": "https://api.uumnet.com/api/mn3.php", "type": "random"},
|
||
{"name": "🎨 图源D", "url": "https://api.uumnet.com/api/mn4.php", "type": "random"},
|
||
{"name": "🎨 图源E", "url": "https://api.uumnet.com/api/mn5.php", "type": "random"},
|
||
{"name": "🎨 图源F", "url": "https://api.uumnet.com/api/mn6.php", "type": "random"},
|
||
{"name": "🎨 图源G", "url": "https://api.uumnet.com/api/mn7.php", "type": "random"},
|
||
{"name": "🎨 图源H", "url": "https://api.uumnet.com/api/mn8.php", "type": "random"},
|
||
{"name": "🎨 图源I", "url": "https://api.uumnet.com/api/mn9.php", "type": "random"},
|
||
{"name": "🎨 图源J", "url": "https://api.uumnet.com/api/mn10.php", "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.delete_mode_enabled = False
|
||
self.delete_mode_dir = None
|
||
self._pending_return_dir = None
|
||
self._current_page = 1
|
||
|
||
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.web_action_browser = WebActionBrowser()
|
||
|
||
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_code_file(self, ext):
|
||
return ext in self.code_exts
|
||
|
||
def is_archive_file(self, ext):
|
||
return ext in self.archive_exts
|
||
|
||
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 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 == '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://', '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 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": "🎨 画廊"})
|
||
|
||
# 添加动作/工具/书签分类
|
||
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"}
|
||
]
|
||
}
|
||
]
|
||
}
|
||
|
||
for i in range(len(self.root_paths)):
|
||
root_key = f"root_{i}"
|
||
filters[root_key] = [
|
||
{
|
||
"key": "delete_mode",
|
||
"name": "🗑️ 删除模式",
|
||
"value": [
|
||
{"n": "🔴 开启删除模式", "v": "on"},
|
||
{"n": "🟢 关闭删除模式", "v": "off"},
|
||
{"n": "🗑️ 清空回收站", "v": "empty"}
|
||
]
|
||
},
|
||
{
|
||
"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
|
||
},
|
||
{
|
||
"key": "action",
|
||
"name": "🗑️ 缓存管理",
|
||
"value": [
|
||
{"n": "📚 清除漫画缓存", "v": "clear_comic_cache"},
|
||
{"n": "🖼️ 清除封面缓存", "v": "clear_cover_cache"},
|
||
{"n": "📝 清除歌词缓存", "v": "clear_lyrics_cache"},
|
||
{"n": "📻 清除电台封面缓存", "v": "clear_radio_cover_cache"}
|
||
]
|
||
}
|
||
]
|
||
|
||
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 ext == 'txt'):
|
||
mtime = os.path.getmtime(full_path)
|
||
if time.time() - mtime < 7 * 24 * 3600:
|
||
file_list.append({'name': name, 'path': full_path, 'ext': ext, 'mtime': mtime})
|
||
except:
|
||
pass
|
||
|
||
def _create_recent_item(self, f):
|
||
if ComicReader.is_supported(f['name']):
|
||
cover_url = ComicReader.get_cover_url(f['path'])
|
||
size_bytes = os.path.getsize(f['path'])
|
||
size_mb = f"{size_bytes / 1024 / 1024:.1f}MB" if size_bytes > 0 else ""
|
||
return {
|
||
'vod_id': f['path'],
|
||
'vod_name': f"📚 {f['name']}",
|
||
'vod_pic': cover_url or ComicReader._get_default_comic_icon(f['ext']),
|
||
'vod_play_url': f"阅读${f['path']}",
|
||
'vod_remarks': f"{f['ext'].upper()}漫画 | {size_mb}",
|
||
'style': {'type': 'grid', 'ratio': 0.75},
|
||
'vod_player': '画'
|
||
}
|
||
|
||
if self.is_image_file(f['ext']) or f['ext'].lower() in ['heic', 'heif']:
|
||
return {
|
||
'vod_id': self.URL_B64U_PREFIX + self.b64u_encode(self.PICS_PREFIX + "file://" + f['path']),
|
||
'vod_name': f"🖼 {f['name']}",
|
||
'vod_pic': f"file://{f['path']}",
|
||
'vod_play_url': f"查看${self.PICS_PREFIX}file://{f['path']}",
|
||
'vod_remarks': self._format_time(f['mtime']),
|
||
'style': {'type': 'grid', 'ratio': 1},
|
||
'vod_player': '画'
|
||
}
|
||
|
||
if self.is_audio_file(f['ext']):
|
||
cover_url = self.get_audio_cover_ultra_fast(f['path'])
|
||
|
||
if cover_url and cover_url != self.DEFAULT_AUDIO_ICON and len(cover_url) > 50:
|
||
display_pic = cover_url
|
||
else:
|
||
color = self.default_colors[hash(f['name']) % len(self.default_colors)]
|
||
first_char = f['name'][0] if f['name'] else "🎵"
|
||
display_pic = self._generate_colored_icon(color, first_char)
|
||
|
||
has_lyrics = False
|
||
audio_dir = os.path.dirname(f['path'])
|
||
audio_name = os.path.splitext(f['name'])[0]
|
||
for lrc_ext in self.lrc_exts:
|
||
if os.path.exists(os.path.join(audio_dir, f"{audio_name}.{lrc_ext}")):
|
||
has_lyrics = True
|
||
break
|
||
remarks = self._format_time(f['mtime'])
|
||
if has_lyrics:
|
||
remarks += ' 📝'
|
||
return {
|
||
'vod_id': self.URL_B64U_PREFIX + self.b64u_encode(self.MP3_PREFIX + f['path']),
|
||
'vod_name': f"{f['name']}",
|
||
'vod_pic': display_pic,
|
||
'vod_play_url': f"播放${self.MP3_PREFIX + f['path']}",
|
||
'vod_remarks': remarks,
|
||
'style': {'type': 'grid', 'ratio': 1},
|
||
'vod_player': '听'
|
||
}
|
||
|
||
if self.is_media_file(f['ext']):
|
||
return {
|
||
'vod_id': f['path'],
|
||
'vod_name': f"🎬 {f['name']}",
|
||
'vod_pic': self.file_icons['video'],
|
||
'vod_remarks': self._format_time(f['mtime']),
|
||
'style': {'type': 'grid', 'ratio': 1}
|
||
}
|
||
|
||
if self.is_lrc_file(f['ext']):
|
||
return {
|
||
'vod_id': f['path'],
|
||
'vod_name': f"📝 {f['name']}",
|
||
'vod_pic': self.file_icons['lyrics'],
|
||
'vod_remarks': self._format_time(f['mtime']),
|
||
'style': {'type': 'grid', 'ratio': 1}
|
||
}
|
||
|
||
if f['ext'] == 'json':
|
||
return {
|
||
'vod_id': self.LIST_PREFIX + self.b64u_encode(f['path']),
|
||
'vod_name': f"📋 {f['name']}",
|
||
'vod_pic': self.file_icons['json'],
|
||
'vod_remarks': self._format_time(f['mtime']),
|
||
'style': {'type': 'grid', 'ratio': 1}
|
||
}
|
||
|
||
if f['ext'] == 'php':
|
||
return {
|
||
'vod_id': self.TEXT_PREFIX + self.b64u_encode(f['path']),
|
||
'vod_name': f"🐘 {f['name']}",
|
||
'vod_pic': self.file_icons['php'],
|
||
'vod_remarks': self._format_time(f['mtime']),
|
||
'style': {'type': 'grid', 'ratio': 1}
|
||
}
|
||
|
||
if f['ext'] == 'py':
|
||
return {
|
||
'vod_id': self.TEXT_PREFIX + self.b64u_encode(f['path']),
|
||
'vod_name': f"🐍 {f['name']}",
|
||
'vod_pic': self.file_icons['python'],
|
||
'vod_remarks': self._format_time(f['mtime']),
|
||
'style': {'type': 'grid', 'ratio': 1}
|
||
}
|
||
|
||
if f['ext'] == 'zip':
|
||
return {
|
||
'vod_id': f['path'],
|
||
'vod_name': f"🗜️ {f['name']}",
|
||
'vod_pic': self.file_icons['zip'],
|
||
'vod_remarks': self._format_time(f['mtime']),
|
||
'style': {'type': 'grid', 'ratio': 1}
|
||
}
|
||
|
||
if f['ext'] in ['rar', '7z', 'tar', 'gz', 'bz2', 'xz']:
|
||
icon_key = 'rar' if f['ext'] == 'rar' else 'archive'
|
||
return {
|
||
'vod_id': f['path'],
|
||
'vod_name': f"🗜️ {f['name']}",
|
||
'vod_pic': self.file_icons[icon_key],
|
||
'vod_remarks': self._format_time(f['mtime']),
|
||
'style': {'type': 'grid', 'ratio': 1}
|
||
}
|
||
|
||
if f['ext'] in ['m3u', 'm3u8']:
|
||
colors = ["#FF6B6B", "#4ECDC4", "#FFD93D", "#6BCB77", "#9D65C9", "#FF8C42", "#A2D729", "#FF6B8B", "#45B7D1", "#96CEB4"]
|
||
color = colors[hash(f['name']) % len(colors)]
|
||
first_char = f['name'][0].upper() if f['name'] else "M"
|
||
icon_svg = self._generate_colored_icon(color, first_char)
|
||
return {
|
||
'vod_id': self.LIST_PREFIX + self.b64u_encode(f['path']),
|
||
'vod_name': f['name'],
|
||
'vod_pic': icon_svg,
|
||
'vod_remarks': self._format_time(f['mtime']),
|
||
'style': {'type': 'grid', 'ratio': 1}
|
||
}
|
||
|
||
if self.is_db_file(f['ext']):
|
||
return {
|
||
'vod_id': self.LIST_PREFIX + self.b64u_encode(f['path']),
|
||
'vod_name': f"🗄️ {f['name']}",
|
||
'vod_pic': self.file_icons['database'],
|
||
'vod_remarks': self._format_time(f['mtime']),
|
||
'style': {'type': 'grid', 'ratio': 1}
|
||
}
|
||
|
||
if f['ext'] == 'txt':
|
||
is_live = False
|
||
is_novel = False
|
||
url_count = 0
|
||
try:
|
||
with open(f['path'], 'r', encoding='utf-8', errors='ignore') as ff:
|
||
preview = ff.read(4096)
|
||
is_live = self._is_txt_live_source(preview, f['path'])
|
||
if not is_live:
|
||
is_novel = self._is_txt_novel(preview, f['path'])
|
||
url_matches = re.findall(r'https?://[^\s\'"<>]+', preview)
|
||
url_count = len(url_matches)
|
||
except:
|
||
pass
|
||
|
||
if is_live or url_count >= 2:
|
||
colors = ["#FF6B6B", "#4ECDC4", "#FFD93D", "#6BCB77", "#9D65C9", "#FF8C42", "#A2D729", "#FF6B8B", "#45B7D1", "#96CEB4"]
|
||
color = colors[hash(f['name']) % len(colors)]
|
||
first_char = f['name'][0].upper() if f['name'] else "T"
|
||
icon_svg = self._generate_colored_icon(color, first_char)
|
||
return {
|
||
'vod_id': self.LIST_PREFIX + self.b64u_encode(f['path']),
|
||
'vod_name': f['name'],
|
||
'vod_pic': icon_svg,
|
||
'vod_remarks': self._format_time(f['mtime']),
|
||
'style': {'type': 'grid', 'ratio': 1}
|
||
}
|
||
elif is_novel:
|
||
encoded = self.b64u_encode(f['path'])
|
||
novel_url = f"{self.NOVEL_PREFIX}{encoded}"
|
||
return {
|
||
'vod_id': novel_url,
|
||
'vod_name': f"📖 {f['name']}",
|
||
'vod_pic': self.file_icons['novel'],
|
||
'vod_remarks': self._format_time(f['mtime']),
|
||
'style': {'type': 'grid', 'ratio': 1},
|
||
'vod_player': '书'
|
||
}
|
||
else:
|
||
return {
|
||
'vod_id': self.TEXT_PREFIX + self.b64u_encode(f['path']),
|
||
'vod_name': f"📄 {f['name']}",
|
||
'vod_pic': self.file_icons['text'],
|
||
'vod_remarks': self._format_time(f['mtime']),
|
||
'style': {'type': 'grid', 'ratio': 1}
|
||
}
|
||
|
||
if f['ext'] in ['xml', 'html', 'htm', 'css', 'js', 'sh', 'bash']:
|
||
return {
|
||
'vod_id': self.TEXT_PREFIX + self.b64u_encode(f['path']),
|
||
'vod_name': f"📄 {f['name']}",
|
||
'vod_pic': self.file_icons['text'],
|
||
'vod_remarks': self._format_time(f['mtime']),
|
||
'style': {'type': 'grid', 'ratio': 1}
|
||
}
|
||
|
||
return {
|
||
'vod_id': f['path'],
|
||
'vod_name': f"📁 {f['name']}",
|
||
'vod_pic': self.file_icons['file'],
|
||
'vod_remarks': self._format_time(f['mtime']),
|
||
'style': {'type': 'grid', 'ratio': 1}
|
||
}
|
||
|
||
def _format_time(self, timestamp):
|
||
diff = time.time() - timestamp
|
||
if diff < 3600:
|
||
return f"{int(diff/60)}分钟前"
|
||
elif diff < 86400:
|
||
return f"{int(diff/3600)}小时前"
|
||
else:
|
||
return time.strftime('%m-%d %H:%M', time.localtime(timestamp))
|
||
|
||
def _gallery_category_content(self, pg):
|
||
vlist = []
|
||
|
||
for idx, api in enumerate(self.gallery_apis):
|
||
encoded_url = self.b64u_encode(api['url'])
|
||
|
||
if '?' in api['url']:
|
||
preview_url = f"{api['url']}&_r={random.randint(1, 999999)}&_t={int(time.time())}"
|
||
else:
|
||
preview_url = f"{api['url']}?_r={random.randint(1, 999999)}&_t={int(time.time())}"
|
||
|
||
vlist.append({
|
||
'vod_id': f"gallery_{api['type']}_{encoded_url}",
|
||
'vod_name': api['name'],
|
||
'vod_pic': preview_url,
|
||
'vod_remarks': '点击查看50张',
|
||
'style': {'type': 'grid', 'ratio': 0.75},
|
||
'vod_player': '画'
|
||
})
|
||
|
||
return {'list': vlist, 'page': 1, 'pagecount': 1, 'limit': len(vlist), 'total': len(vlist)}
|
||
|
||
def _gallery_detail(self, api_type, encoded_url):
|
||
try:
|
||
api_url = self.b64u_decode(encoded_url)
|
||
except:
|
||
return {'list': []}
|
||
|
||
api_name = "图库"
|
||
for api in self.gallery_apis:
|
||
if api['url'] == api_url:
|
||
api_name = api['name']
|
||
break
|
||
|
||
images = []
|
||
for i in range(50):
|
||
if '?' in api_url:
|
||
rand_url = f"{api_url}&_r={random.randint(1, 999999)}&_t={int(time.time())}"
|
||
else:
|
||
rand_url = f"{api_url}?_r={random.randint(1, 999999)}&_t={int(time.time())}"
|
||
images.append(rand_url)
|
||
|
||
pics_protocol = self.PICS_PREFIX + '&&'.join(images)
|
||
|
||
vod = {
|
||
'vod_id': f"gallery_{api_type}_{encoded_url}",
|
||
'vod_name': f"{api_name} (50张)",
|
||
'vod_pic': images[0],
|
||
'vod_play_from': '图片浏览',
|
||
'vod_play_url': f"播放${pics_protocol}",
|
||
'vod_remarks': '共50张图片',
|
||
'style': {'type': 'list'},
|
||
'vod_player': '画'
|
||
}
|
||
return {'list': [vod]}
|
||
|
||
def _delete_to_trash(self, file_path):
|
||
try:
|
||
if not os.path.exists(file_path):
|
||
return False, "文件不存在"
|
||
|
||
file_name = os.path.basename(file_path)
|
||
unique_name = f"{int(time.time())}_{file_name}"
|
||
trash_path = os.path.join(self.trash_dir, unique_name)
|
||
|
||
os.rename(file_path, trash_path)
|
||
|
||
audio_dir = os.path.dirname(file_path)
|
||
audio_name = os.path.splitext(file_name)[0]
|
||
|
||
for lrc_ext in self.lrc_exts:
|
||
lrc_path = os.path.join(audio_dir, f"{audio_name}.{lrc_ext}")
|
||
if os.path.exists(lrc_path):
|
||
lrc_trash_name = f"{int(time.time())}_{audio_name}.{lrc_ext}"
|
||
lrc_trash_path = os.path.join(self.trash_dir, lrc_trash_name)
|
||
os.rename(lrc_path, lrc_trash_path)
|
||
|
||
lrc_path_upper = os.path.join(audio_dir, f"{audio_name}.{lrc_ext.upper()}")
|
||
if os.path.exists(lrc_path_upper):
|
||
lrc_trash_name = f"{int(time.time())}_{audio_name}.{lrc_ext.upper()}"
|
||
lrc_trash_path = os.path.join(self.trash_dir, lrc_trash_name)
|
||
os.rename(lrc_path_upper, lrc_trash_path)
|
||
|
||
cover_exts = ['jpg', 'jpeg', 'png', 'webp', 'gif']
|
||
for cover_ext in cover_exts:
|
||
cover_path = os.path.join(audio_dir, f"{audio_name}.{cover_ext}")
|
||
if os.path.exists(cover_path):
|
||
cover_trash_name = f"{int(time.time())}_{audio_name}.{cover_ext}"
|
||
cover_trash_path = os.path.join(self.trash_dir, cover_trash_name)
|
||
os.rename(cover_path, cover_trash_path)
|
||
|
||
cover_path_upper = os.path.join(audio_dir, f"{audio_name}.{cover_ext.upper()}")
|
||
if os.path.exists(cover_path_upper):
|
||
cover_trash_name = f"{int(time.time())}_{audio_name}.{cover_ext.upper()}"
|
||
cover_trash_path = os.path.join(self.trash_dir, cover_trash_name)
|
||
os.rename(cover_path_upper, cover_trash_path)
|
||
|
||
file_hash = hashlib.md5(file_path.encode()).hexdigest()
|
||
cache_file = f"{COVER_CACHE_DIR}{file_hash}.jpg"
|
||
if os.path.exists(cache_file):
|
||
os.remove(cache_file)
|
||
cache_file_hidden = f"{COVER_CACHE_DIR}.{file_hash}.jpg"
|
||
if os.path.exists(cache_file_hidden):
|
||
os.remove(cache_file_hidden)
|
||
|
||
cover_record = CoverScanRecord.load_record()
|
||
if file_hash in cover_record:
|
||
del cover_record[file_hash]
|
||
CoverScanRecord.save_record(cover_record)
|
||
|
||
return True, f"已删除: {file_name} (及关联的封面和歌词)"
|
||
except Exception as e:
|
||
return False, f"删除失败: {e}"
|
||
|
||
def _empty_trash(self):
|
||
deleted_count = 0
|
||
deleted_size = 0
|
||
|
||
if os.path.exists(self.trash_dir):
|
||
for item in os.listdir(self.trash_dir):
|
||
if item == '.nomedia':
|
||
continue
|
||
item_path = os.path.join(self.trash_dir, item)
|
||
try:
|
||
if os.path.isfile(item_path):
|
||
file_size = os.path.getsize(item_path)
|
||
deleted_size += file_size
|
||
deleted_count += 1
|
||
elif os.path.isdir(item_path):
|
||
for root, dirs, files in os.walk(item_path):
|
||
for file in files:
|
||
file_path = os.path.join(root, file)
|
||
try:
|
||
deleted_size += os.path.getsize(file_path)
|
||
deleted_count += 1
|
||
except:
|
||
pass
|
||
except:
|
||
pass
|
||
|
||
try:
|
||
shutil.rmtree(self.trash_dir)
|
||
os.makedirs(self.trash_dir, exist_ok=True)
|
||
nomedia_path = os.path.join(self.trash_dir, '.nomedia')
|
||
if not os.path.exists(nomedia_path):
|
||
with open(nomedia_path, 'w') as f:
|
||
f.write('# This file prevents media scanning in this directory\n')
|
||
except Exception as e:
|
||
for item in os.listdir(self.trash_dir):
|
||
if item == '.nomedia':
|
||
continue
|
||
item_path = os.path.join(self.trash_dir, item)
|
||
try:
|
||
if os.path.isfile(item_path):
|
||
os.remove(item_path)
|
||
elif os.path.isdir(item_path):
|
||
shutil.rmtree(item_path)
|
||
except:
|
||
pass
|
||
|
||
if deleted_size > 1024 * 1024:
|
||
size_str = f"{deleted_size / (1024 * 1024):.2f} MB"
|
||
elif deleted_size > 1024:
|
||
size_str = f"{deleted_size / 1024:.2f} KB"
|
||
else:
|
||
size_str = f"{deleted_size} B"
|
||
|
||
result_msg = f"🗑️ 已清空回收站\n\n✅ 删除 {deleted_count} 个文件/文件夹\n释放空间: {size_str}"
|
||
|
||
return {
|
||
'list': [{
|
||
'vod_id': 'empty_trash_result',
|
||
'vod_name': result_msg,
|
||
'vod_pic': self._generate_colored_icon("#4CAF50", "✓"),
|
||
'vod_remarks': '清空完成',
|
||
'style': {'type': 'list'},
|
||
'vod_player': '书'
|
||
}],
|
||
'page': 1,
|
||
'pagecount': 1,
|
||
'limit': 1,
|
||
'total': 1
|
||
}
|
||
|
||
def _create_delete_file_item(self, f):
|
||
file_size = self._get_file_size_str(f['path']) if not f['is_dir'] else ""
|
||
|
||
if f['is_dir']:
|
||
vod_pic = self.file_icons['folder']
|
||
vod_name = f"📁 {f['name']}"
|
||
remarks = f'🗑️ 点击删除整个文件夹'
|
||
if file_size:
|
||
remarks += f' ({file_size})'
|
||
return {
|
||
'vod_id': f'delete_folder_{self.b64u_encode(f["path"])}',
|
||
'vod_name': vod_name,
|
||
'vod_pic': vod_pic,
|
||
'vod_remarks': remarks,
|
||
'vod_remarks_color': '#F44336',
|
||
'style': {'type': 'list'},
|
||
'vod_player': '书',
|
||
'vod_tag': 'delete_folder'
|
||
}
|
||
|
||
if ComicReader.is_supported(f['name']):
|
||
cover_url = ComicReader.get_cover_url(f['path'])
|
||
return {
|
||
'vod_id': f'delete_file_{self.b64u_encode(f["path"])}',
|
||
'vod_name': f"📚 {f['name']}",
|
||
'vod_pic': cover_url or ComicReader._get_default_comic_icon(f['ext']),
|
||
'vod_remarks': f'🗑️ 点击删除 - {file_size}',
|
||
'vod_remarks_color': '#F44336',
|
||
'style': {'type': 'list'},
|
||
'vod_player': '书'
|
||
}
|
||
|
||
if self.is_image_file(f['ext']) or f['ext'].lower() in ['heic', 'heif']:
|
||
vod_pic = f"file://{f['path']}"
|
||
vod_name = f"🖼 {f['name']}"
|
||
elif self.is_audio_file(f['ext']):
|
||
cover_url = self.get_audio_cover_ultra_fast(f['path'])
|
||
if cover_url and cover_url != self.DEFAULT_AUDIO_ICON and len(cover_url) > 50:
|
||
vod_pic = cover_url
|
||
else:
|
||
color = self.default_colors[hash(f['name']) % len(self.default_colors)]
|
||
first_char = f['name'][0] if f['name'] else "🎵"
|
||
vod_pic = self._generate_colored_icon(color, first_char)
|
||
vod_name = f"{f['name']}"
|
||
elif self.is_media_file(f['ext']):
|
||
vod_pic = self.file_icons['video']
|
||
vod_name = f"🎬 {f['name']}"
|
||
else:
|
||
vod_pic = self.get_file_icon_url(f['ext'])
|
||
vod_name = f"{f['name']}"
|
||
|
||
return {
|
||
'vod_id': f'delete_file_{self.b64u_encode(f["path"])}',
|
||
'vod_name': vod_name,
|
||
'vod_pic': vod_pic,
|
||
'vod_remarks': f'🗑️ 点击删除 - {file_size}',
|
||
'vod_remarks_color': '#F44336',
|
||
'style': {'type': 'list'},
|
||
'vod_player': '书'
|
||
}
|
||
|
||
def get_file_icon_url(self, ext):
|
||
if ext in self.image_exts:
|
||
return self.file_icons['image']
|
||
elif ext in self.audio_exts:
|
||
return self.file_icons['audio']
|
||
elif ext in self.media_exts:
|
||
return self.file_icons['video']
|
||
elif ext in self.comic_exts:
|
||
return "https://img.icons8.com/color/96/000000/comic-book.png"
|
||
elif ext in self.list_exts:
|
||
return self.file_icons['list']
|
||
elif ext in self.db_exts:
|
||
return self.file_icons['database']
|
||
elif ext == 'php':
|
||
return self.file_icons['php']
|
||
elif ext == 'py':
|
||
return self.file_icons['python']
|
||
elif ext == 'zip':
|
||
return self.file_icons['zip']
|
||
elif ext in self.archive_exts:
|
||
return self.file_icons['archive']
|
||
else:
|
||
return self.file_icons['file']
|
||
|
||
def _get_file_size_str(self, file_path):
|
||
try:
|
||
size = os.path.getsize(file_path)
|
||
if size > 1024 * 1024:
|
||
return f"{size / (1024 * 1024):.1f} MB"
|
||
elif size > 1024:
|
||
return f"{size / 1024:.1f} KB"
|
||
else:
|
||
return f"{size} B"
|
||
except:
|
||
return ""
|
||
|
||
def _create_delete_result(self, message, success=True):
|
||
color = "#4CAF50" if success else "#F44336"
|
||
icon = "✓" if success else "✗"
|
||
|
||
return {
|
||
'list': [{
|
||
'vod_id': 'delete_result',
|
||
'vod_name': message,
|
||
'vod_pic': self._generate_colored_icon(color, icon),
|
||
'vod_remarks': '操作完成' if success else '操作失败',
|
||
'style': {'type': 'list'},
|
||
'vod_player': '书'
|
||
}],
|
||
'page': 1,
|
||
'pagecount': 1,
|
||
'limit': 1,
|
||
'total': 1
|
||
}
|
||
|
||
def _create_delete_result_with_back(self, message, back_path, current_pg=1):
|
||
return {
|
||
'list': [
|
||
{
|
||
'vod_id': 'delete_result',
|
||
'vod_name': message,
|
||
'vod_pic': self._generate_colored_icon("#4CAF50", "✓"),
|
||
'vod_remarks': '删除成功,可以继续删除其他文件',
|
||
'style': {'type': 'list'},
|
||
'vod_player': '书'
|
||
},
|
||
{
|
||
'vod_id': self.FOLDER_PREFIX + self.b64u_encode(back_path),
|
||
'vod_name': '⬅️ 点击返回目录(删除模式保持开启)',
|
||
'vod_pic': self.file_icons['folder'],
|
||
'vod_remarks': f'返回后列表已刷新,删除模式仍然开启,当前第{current_pg}页',
|
||
'style': {'type': 'list'},
|
||
'vod_player': '书'
|
||
}
|
||
],
|
||
'page': current_pg,
|
||
'pagecount': 1,
|
||
'limit': 2,
|
||
'total': 2
|
||
}
|
||
|
||
def _enable_delete_mode(self, target_path):
|
||
self.delete_mode_enabled = True
|
||
self.delete_mode_dir = target_path
|
||
return True
|
||
|
||
def _disable_delete_mode(self):
|
||
self.delete_mode_enabled = False
|
||
self.delete_mode_dir = None
|
||
self._pending_return_dir = None
|
||
return True
|
||
|
||
def _is_delete_mode_active_in_path(self, path):
|
||
if not self.delete_mode_enabled or not self.delete_mode_dir:
|
||
return False
|
||
current_norm = os.path.normpath(path)
|
||
active_norm = os.path.normpath(self.delete_mode_dir)
|
||
return current_norm == active_norm or current_norm.startswith(active_norm + os.sep)
|
||
|
||
def _delete_folder_to_trash(self, folder_path):
|
||
try:
|
||
if not os.path.exists(folder_path):
|
||
return False, "文件夹不存在"
|
||
|
||
if not os.path.isdir(folder_path):
|
||
return False, "不是文件夹"
|
||
|
||
folder_name = os.path.basename(folder_path)
|
||
unique_name = f"{int(time.time())}_{folder_name}"
|
||
trash_path = os.path.join(self.trash_dir, unique_name)
|
||
|
||
shutil.move(folder_path, trash_path)
|
||
|
||
self.dir_cache.pop(f"dir_{folder_path}", None)
|
||
self.dir_cache_time.pop(f"dir_{folder_path}", None)
|
||
self.audio_list_cache.pop(folder_path, None)
|
||
self.audio_list_cache_time.pop(folder_path, None)
|
||
|
||
cache_keys_to_delete = []
|
||
for key in self.dir_cache.keys():
|
||
if key.startswith(f"dir_{folder_path}"):
|
||
cache_keys_to_delete.append(key)
|
||
for key in cache_keys_to_delete:
|
||
self.dir_cache.pop(key, None)
|
||
self.dir_cache_time.pop(key, None)
|
||
|
||
return True, f"已删除文件夹: {folder_name} (及其所有内容)"
|
||
except Exception as e:
|
||
return False, f"删除文件夹失败: {e}"
|
||
|
||
def detailContent(self, ids):
|
||
id_val = ids[0]
|
||
|
||
# 处理动作/工具/书签分类入口
|
||
if id_val in ['web_action', 'web_tool', 'web_bookmarks']:
|
||
return self.web_action_browser.get_category_content(id_val, 1)
|
||
|
||
# 处理 web_action_browser 的 action
|
||
if id_val.startswith('{') and ('actionId' in id_val or '"action"' in id_val):
|
||
try:
|
||
result = self.web_action_browser.handle_action(id_val)
|
||
if result:
|
||
return result
|
||
except:
|
||
pass
|
||
|
||
if id_val.endswith('.epub') or id_val.endswith('.pdf'):
|
||
if os.path.isfile(id_val) and ComicReader.is_supported(os.path.basename(id_val)):
|
||
result = ComicReader.get_comic_detail(id_val, os.path.basename(id_val))
|
||
if result and result.get('list'):
|
||
return result
|
||
|
||
if id_val == 'delete_mode_status':
|
||
self._disable_delete_mode()
|
||
current_dir = getattr(self, '_pending_return_dir', None)
|
||
if current_dir and os.path.isdir(current_dir):
|
||
return {
|
||
'list': [{
|
||
'vod_id': self.FOLDER_PREFIX + self.b64u_encode(current_dir),
|
||
'vod_name': '🟢 删除模式已关闭,点击返回目录',
|
||
'vod_pic': self._generate_colored_icon("#4CAF50", "✓"),
|
||
'vod_remarks': '返回后删除模式已关闭',
|
||
'style': {'type': 'list'},
|
||
'vod_player': '书'
|
||
}],
|
||
'page': self._current_page,
|
||
'pagecount': 1
|
||
}
|
||
else:
|
||
return {
|
||
'list': [{
|
||
'vod_id': 'delete_mode_closed',
|
||
'vod_name': '🟢 删除模式已关闭',
|
||
'vod_pic': self._generate_colored_icon("#4CAF50", "✓"),
|
||
'vod_remarks': '现在点击文件正常打开,不会删除',
|
||
'style': {'type': 'list'},
|
||
'vod_player': '书'
|
||
}],
|
||
'page': self._current_page,
|
||
'pagecount': 1
|
||
}
|
||
|
||
system_status_ids = [
|
||
'delete_mode_status', 'filter_info', 'clear_result',
|
||
'clear_lyrics_result', 'clear_radio_result', 'empty_trash_result',
|
||
'delete_result', 'info', 'delete_mode_closed', 'clear_comic_result', 'action_result'
|
||
]
|
||
if id_val in system_status_ids:
|
||
return {'list': [{
|
||
'vod_id': 'info',
|
||
'vod_name': 'ℹ️ 系统状态提示',
|
||
'vod_pic': self._generate_colored_icon("#2196F3", "ℹ️"),
|
||
'vod_remarks': '这是状态提示信息,不可删除',
|
||
'style': {'type': 'list'},
|
||
'vod_player': '书'
|
||
}]}
|
||
|
||
if id_val.startswith('delete_file_'):
|
||
encoded_path = id_val[len('delete_file_'):]
|
||
file_path = self.b64u_decode(encoded_path)
|
||
success, msg = self._delete_to_trash(file_path)
|
||
current_pg = self._current_page
|
||
|
||
if success:
|
||
dir_path = os.path.dirname(file_path)
|
||
self.dir_cache.pop(f"dir_{dir_path}", None)
|
||
self.dir_cache_time.pop(f"dir_{dir_path}", None)
|
||
self.audio_list_cache.pop(dir_path, None)
|
||
self.audio_list_cache_time.pop(dir_path, None)
|
||
self._pending_return_dir = dir_path
|
||
return self._create_delete_result_with_back(msg, dir_path, current_pg)
|
||
else:
|
||
return self._create_delete_result(msg, False)
|
||
|
||
if id_val.startswith('delete_folder_'):
|
||
encoded_path = id_val[len('delete_folder_'):]
|
||
folder_path = self.b64u_decode(encoded_path)
|
||
success, msg = self._delete_folder_to_trash(folder_path)
|
||
current_pg = self._current_page
|
||
|
||
if success:
|
||
parent_dir = os.path.dirname(folder_path)
|
||
self.dir_cache.pop(f"dir_{parent_dir}", None)
|
||
self.dir_cache_time.pop(f"dir_{parent_dir}", None)
|
||
self._pending_return_dir = parent_dir
|
||
return self._create_delete_result_with_back(msg, parent_dir, current_pg)
|
||
else:
|
||
return self._create_delete_result(msg, False)
|
||
|
||
if id_val.startswith("short_video_"):
|
||
encoded_url = id_val[len("short_video_"):]
|
||
return self._short_video_detail(encoded_url)
|
||
|
||
try:
|
||
radio_id = int(id_val)
|
||
if radio_id > 100:
|
||
return self._radio_detail_content(str(radio_id))
|
||
except:
|
||
pass
|
||
|
||
if id_val.startswith("radio_play_"):
|
||
radio_id = id_val[len("radio_play_"):]
|
||
return self._radio_detail_content(radio_id)
|
||
|
||
if id_val.startswith("gallery_"):
|
||
parts = id_val.split('_', 2)
|
||
if len(parts) >= 3:
|
||
return self._gallery_detail(parts[1], parts[2])
|
||
|
||
if id_val == 'add_custom_api':
|
||
return self._handle_add_custom_api()
|
||
|
||
if id_val.startswith(self.LIVE_PREFIX):
|
||
source_id = self.b64u_decode(id_val[len(self.LIVE_PREFIX):])
|
||
return self._live_source_detail(source_id)
|
||
|
||
if id_val.startswith(self.NOVEL_PREFIX):
|
||
encoded = id_val[len(self.NOVEL_PREFIX):]
|
||
file_path = self.b64u_decode(encoded)
|
||
self.novel_path_cache[encoded] = file_path
|
||
vod_data = self._handle_novel_detail(file_path, id_val, encoded)
|
||
if vod_data and "list" in vod_data and len(vod_data["list"]) > 0:
|
||
vod_data["list"][0]["vod_player"] = "书"
|
||
return vod_data
|
||
|
||
if id_val.startswith(self.TEXT_PREFIX):
|
||
encoded = id_val[len(self.TEXT_PREFIX):]
|
||
file_path = self.b64u_decode(encoded)
|
||
vod_data = self._handle_text_detail(file_path, id_val)
|
||
if vod_data and "list" in vod_data and len(vod_data["list"]) > 0:
|
||
vod_data["list"][0]["vod_player"] = "书"
|
||
return vod_data
|
||
|
||
if id_val.startswith(self.FOLDER_PREFIX):
|
||
folder_path = self.b64u_decode(id_val[len(self.FOLDER_PREFIX):])
|
||
if os.path.exists(folder_path) and os.path.isdir(folder_path):
|
||
return self.categoryContent(folder_path, 1, None, None)
|
||
return {'list': []}
|
||
|
||
if id_val.startswith(self.PICS_PREFIX + 'slideshow/'):
|
||
dir_path = self.b64u_decode(id_val[len(self.PICS_PREFIX + 'slideshow/'):])
|
||
images = self.collect_images_in_dir(dir_path)
|
||
if not images:
|
||
return {'list': []}
|
||
pic_urls = [f"file://{img['path']}" for img in images]
|
||
vod = {
|
||
'vod_id': id_val,
|
||
'vod_name': f"🖼 图片连播 - {os.path.basename(dir_path)} ({len(images)}张)",
|
||
'vod_pic': self.file_icons['image_playlist'],
|
||
'vod_play_from': '图片浏览',
|
||
'vod_play_url': f"浏览${self.PICS_PREFIX + '&&'.join(pic_urls)}",
|
||
'style': {'type': 'list'},
|
||
'vod_player': '画'
|
||
}
|
||
return {'list': [vod]}
|
||
|
||
if id_val.startswith(self.URL_B64U_PREFIX):
|
||
decoded = self.b64u_decode(id_val[len(self.URL_B64U_PREFIX):])
|
||
if decoded and decoded.startswith(self.PICS_PREFIX):
|
||
return self._handle_pics_detail(decoded, id_val)
|
||
|
||
if id_val.startswith(self.CAMERA_ALL_PREFIX):
|
||
dir_path = self.b64u_decode(id_val[len(self.CAMERA_ALL_PREFIX):])
|
||
images = self.collect_images_in_dir(dir_path)
|
||
if not images:
|
||
return {'list': []}
|
||
pic_urls = [f"file://{img['path']}" for img in images]
|
||
vod = {
|
||
'vod_id': id_val,
|
||
'vod_name': f"🖼 相机照片 ({len(images)}张)",
|
||
'vod_pic': self.file_icons['image_playlist'],
|
||
'vod_play_from': '照片查看',
|
||
'vod_play_url': f"浏览${self.PICS_PREFIX + '&&'.join(pic_urls)}",
|
||
'style': {'type': 'list'},
|
||
'vod_player': '画'
|
||
}
|
||
return {'list': [vod]}
|
||
|
||
if id_val.startswith(self.LIST_PREFIX):
|
||
file_path = self.b64u_decode(id_val[len(self.LIST_PREFIX):])
|
||
return self._handle_list_detail(file_path, id_val)
|
||
|
||
if id_val.startswith(self.A_ALL_PREFIX):
|
||
dir_path = self.b64u_decode(id_val[len(self.A_ALL_PREFIX):])
|
||
return self._handle_audio_all_detail(dir_path, id_val)
|
||
|
||
if id_val.startswith(self.V_ALL_PREFIX):
|
||
dir_path = self.b64u_decode(id_val[len(self.V_ALL_PREFIX):])
|
||
return self._handle_video_all_detail(dir_path, id_val)
|
||
|
||
if os.path.isfile(id_val) and ComicReader.is_supported(os.path.basename(id_val)):
|
||
return ComicReader.get_comic_detail(id_val, os.path.basename(id_val))
|
||
|
||
if not os.path.exists(id_val):
|
||
return {'list': []}
|
||
|
||
if os.path.isdir(id_val):
|
||
return self.categoryContent(id_val, 1, None, None)
|
||
|
||
return self._handle_file_detail(id_val)
|
||
|
||
def _handle_pics_detail(self, decoded, id_val):
|
||
pics_data = decoded[len(self.PICS_PREFIX):]
|
||
if '&&' in pics_data:
|
||
pic_urls = pics_data.split('&&')
|
||
return {'list': [{
|
||
'vod_id': id_val,
|
||
'vod_name': f'图片相册 ({len(pic_urls)}张)',
|
||
'vod_pic': pic_urls[0],
|
||
'vod_play_from': '图片查看',
|
||
'vod_play_url': f"浏览${self.PICS_PREFIX + '&&'.join(pic_urls)}",
|
||
'style': {'type': 'list'},
|
||
'vod_player': '画'
|
||
}]}
|
||
else:
|
||
file_name = os.path.basename(pics_data.split('?')[0])
|
||
if pics_data.startswith('file://'):
|
||
file_name = os.path.basename(pics_data[7:])
|
||
return {'list': [{
|
||
'vod_id': id_val,
|
||
'vod_name': file_name,
|
||
'vod_pic': pics_data,
|
||
'vod_play_from': '图片查看',
|
||
'vod_play_url': f"查看${self.PICS_PREFIX + pics_data}",
|
||
'style': {'type': 'list'},
|
||
'vod_player': '画'
|
||
}]}
|
||
|
||
def _handle_add_custom_api(self):
|
||
return {'list': [{
|
||
'vod_id': 'add_custom_api_result',
|
||
'vod_name': '📝 添加自定义API说明',
|
||
'vod_pic': self.file_icons['text'],
|
||
'vod_content': '请在 /storage/emulated/0/custom_video_apis.json 文件中添加自定义API\n\n格式示例:\n[\n {"name": "我的API", "url": "https://example.com/api"}\n]\n\n添加后重启即可生效。',
|
||
'vod_remarks': '配置文件路径: /storage/emulated/0/custom_video_apis.json',
|
||
'style': {'type': 'list'}
|
||
}]}
|
||
|
||
def _handle_list_detail(self, file_path, vod_id):
|
||
if not os.path.exists(file_path) or not os.path.isfile(file_path):
|
||
return {'list': []}
|
||
ext = self.get_file_ext(file_path)
|
||
items = []
|
||
if ext == 'json':
|
||
items = self.parse_json_file(file_path)
|
||
elif self.is_db_file(ext):
|
||
items = self.parse_db_file(file_path)
|
||
elif ext in ['m3u', 'm3u8']:
|
||
items = self._parse_m3u_file(file_path)
|
||
if len(items) > 5:
|
||
return self._format_live_source(items, file_path, vod_id, ext)
|
||
elif ext == 'txt':
|
||
items = self._parse_txt_file(file_path)
|
||
if not items:
|
||
return []
|
||
if len(items) > 5:
|
||
return self._format_live_source(items, file_path, vod_id, ext)
|
||
|
||
if not items:
|
||
name = os.path.splitext(os.path.basename(file_path))[0]
|
||
return {'list': [self._create_fallback_vod(file_path, 'list', vod_id, name)]}
|
||
|
||
play_urls = self._build_play_urls(items)
|
||
if not play_urls:
|
||
return {'list': [self._create_fallback_vod(file_path, 'list', vod_id, os.path.splitext(os.path.basename(file_path))[0])]}
|
||
|
||
pic = items[0].get('pic', '') if items else ''
|
||
if not pic:
|
||
pic = self.file_icons['list']
|
||
return {'list': [{
|
||
'vod_id': vod_id,
|
||
'vod_name': os.path.basename(file_path),
|
||
'vod_pic': pic,
|
||
'vod_play_from': '播放列表',
|
||
'vod_play_url': '#'.join(play_urls),
|
||
'vod_remarks': f'共{len(items)}条',
|
||
'style': {'type': 'list'}
|
||
}]}
|
||
|
||
def _format_live_source(self, items, file_path, vod_id, ext):
|
||
channels = {}
|
||
for item in items:
|
||
name = item.get('name', '').strip()
|
||
url = item.get('url', '')
|
||
if not name or not url:
|
||
continue
|
||
clean_name = re.sub(r'^\[[^\]]+\]\s*', '', name)
|
||
clean_name = re.sub(r'\s*[\[\((]\s*\d+\s*[\]\))]\s*$', '', clean_name)
|
||
clean_name = re.sub(r'\s*[线|L|l]ine?\s*\d+$', '', clean_name, flags=re.I)
|
||
clean_name = clean_name.strip()
|
||
if clean_name not in channels:
|
||
channels[clean_name] = []
|
||
channels[clean_name].append(url)
|
||
|
||
if not channels:
|
||
play_urls = self._build_play_urls(items)
|
||
pic = items[0].get('pic', '') if items else self.file_icons['list']
|
||
return {'list': [{
|
||
'vod_id': vod_id,
|
||
'vod_name': os.path.basename(file_path),
|
||
'vod_pic': pic,
|
||
'vod_play_from': '播放列表',
|
||
'vod_play_url': '#'.join(play_urls),
|
||
'vod_remarks': f'共{len(items)}条',
|
||
'style': {'type': 'list'}
|
||
}]}
|
||
|
||
max_lines = max(len(urls) for urls in channels.values())
|
||
original_max_lines = max_lines
|
||
if max_lines > 1:
|
||
max_lines = 1
|
||
|
||
from_list = []
|
||
url_list = []
|
||
for line_idx in range(max_lines):
|
||
line_name = f"线路{line_idx + 1}"
|
||
channel_urls = []
|
||
for channel_name, urls in channels.items():
|
||
if line_idx < len(urls):
|
||
channel_urls.append(f"{channel_name}${urls[line_idx]}")
|
||
if channel_urls:
|
||
from_list.append(line_name)
|
||
url_list.append('#'.join(channel_urls))
|
||
|
||
if not from_list:
|
||
play_urls = self._build_play_urls(items)
|
||
pic = items[0].get('pic', '') if items else self.file_icons['list']
|
||
return {'list': [{
|
||
'vod_id': vod_id,
|
||
'vod_name': os.path.basename(file_path),
|
||
'vod_pic': pic,
|
||
'vod_play_from': '播放列表',
|
||
'vod_play_url': '#'.join(play_urls),
|
||
'vod_remarks': f'共{len(items)}条',
|
||
'style': {'type': 'list'}
|
||
}]}
|
||
|
||
colors = ["#FF6B6B", "#4ECDC4", "#FFD93D", "#6BCB77", "#9D65C9", "#FF8C42", "#A2D729", "#FF6B8B", "#45B7D1", "#96CEB4"]
|
||
color = colors[hash(os.path.basename(file_path)) % len(colors)]
|
||
first_char = os.path.basename(file_path)[0].upper() if os.path.basename(file_path) else "L"
|
||
icon_svg = self._generate_colored_icon(color, first_char)
|
||
current_date = time.strftime('%Y.%m.%d', time.localtime())
|
||
total_channels = len(channels)
|
||
total_programs = sum(len(urls) for urls in channels.values())
|
||
remarks = f'更新时间{current_date}'
|
||
if original_max_lines > 1:
|
||
remarks += f' (仅显示第1条线路)'
|
||
|
||
return {'list': [{
|
||
'vod_id': vod_id,
|
||
'vod_name': os.path.basename(file_path),
|
||
'vod_pic': icon_svg,
|
||
'vod_play_from': '$$$'.join(from_list),
|
||
'vod_play_url': '$$$'.join(url_list),
|
||
'vod_remarks': remarks,
|
||
'vod_content': f'共 {total_channels} 个频道,{total_programs} 条节目线路',
|
||
'style': {'type': 'list'},
|
||
'type': 'live',
|
||
'vod_type': 4,
|
||
'vod_class': 'live',
|
||
'vod_style': {'type': 'live'},
|
||
'playerType': 2
|
||
}]}
|
||
|
||
def _parse_m3u_file(self, file_path):
|
||
items = []
|
||
try:
|
||
with open(file_path, 'r', encoding='utf-8', errors='ignore') as f:
|
||
lines = f.readlines()
|
||
current_name = None
|
||
for line in lines[:10000]:
|
||
line = line.strip()
|
||
if line.startswith('#EXTINF:'):
|
||
name_match = re.search(r',(.+)$', line) or re.search(r'tvg-name="([^"]+)"', line)
|
||
current_name = name_match.group(1).strip() if name_match else None
|
||
elif line and not line.startswith('#'):
|
||
if self.is_playable_url(line):
|
||
items.append({'name': current_name or f"线路{len(items)+1}", 'url': line})
|
||
current_name = None
|
||
except:
|
||
pass
|
||
return items
|
||
|
||
def _handle_novel_detail(self, file_path, vod_id, encoded):
|
||
if not os.path.isfile(file_path):
|
||
return {'list': []}
|
||
cache_key = f"chapters_{encoded}"
|
||
if cache_key in self.novel_chapters_cache:
|
||
chapters = self.novel_chapters_cache[cache_key]
|
||
else:
|
||
chapters = NovelParser.parse_txt_novel(file_path)
|
||
self.novel_chapters_cache[cache_key] = chapters
|
||
self.current_novel = {'encoded_path': encoded, 'file_path': file_path, 'chapters': chapters}
|
||
urls = []
|
||
for i, c in enumerate(chapters):
|
||
title = c['title']
|
||
if title and len(title) > 2 and not title.strip().isdigit():
|
||
urls.append(f"{title}$chapter{i}")
|
||
return {'list': [{
|
||
'vod_id': vod_id,
|
||
'vod_name': os.path.basename(file_path),
|
||
'vod_pic': self.file_icons['novel'],
|
||
'vod_play_from': '小说章节',
|
||
'vod_play_url': '#'.join(urls),
|
||
'vod_remarks': f'共{len(chapters)}章',
|
||
'style': {'type': 'list'}
|
||
}]}
|
||
|
||
def _handle_text_detail(self, file_path, vod_id):
|
||
if not os.path.isfile(file_path):
|
||
return {'list': []}
|
||
try:
|
||
with open(file_path, 'r', encoding='utf-8', errors='ignore') as f:
|
||
content = f.read()
|
||
return {'list': [{
|
||
'vod_id': vod_id,
|
||
'vod_name': os.path.basename(file_path),
|
||
'vod_pic': self.file_icons['text'],
|
||
'vod_play_from': '文本阅读',
|
||
'vod_play_url': f"阅读${vod_id}",
|
||
'vod_content': content[:5000],
|
||
'vod_remarks': f'共{len(content)}字',
|
||
'style': {'type': 'list'}
|
||
}]}
|
||
except:
|
||
return {'list': []}
|
||
|
||
def _create_fallback_vod(self, file_path, file_type, vod_id, name=None):
|
||
return {
|
||
'vod_id': vod_id,
|
||
'vod_name': os.path.basename(file_path),
|
||
'vod_pic': self.file_icons[file_type],
|
||
'vod_play_from': file_type,
|
||
'vod_play_url': f"{name or os.path.splitext(os.path.basename(file_path))[0]}$file://{file_path}",
|
||
'style': {'type': 'list'}
|
||
}
|
||
|
||
def _build_play_urls(self, items):
|
||
play_urls = []
|
||
for item in items:
|
||
url = item.get('url') or item.get('play_url', '')
|
||
if url:
|
||
name = item.get('name', '').strip()
|
||
if not name:
|
||
name = f"节目{len(play_urls)+1}"
|
||
name = re.sub(r'[#$]', '', name)
|
||
if '$$$' in url:
|
||
first_url = url.split('$$$')[0]
|
||
if '$' in first_url:
|
||
url_parts = first_url.split('$', 1)
|
||
if len(url_parts) == 2:
|
||
play_urls.append(f"{name}${url_parts[1]}")
|
||
else:
|
||
play_urls.append(f"{name}${first_url}")
|
||
else:
|
||
play_urls.append(f"{name}${first_url}")
|
||
else:
|
||
play_urls.append(f"{name}${url}")
|
||
return play_urls
|
||
|
||
def _handle_audio_all_detail(self, dir_path, vod_id):
|
||
audios = self.collect_audios_in_dir(dir_path)
|
||
if not audios:
|
||
return {'list': []}
|
||
audios.sort(key=lambda x: x['name'])
|
||
play_urls = []
|
||
for audio in audios:
|
||
name = os.path.splitext(audio['name'])[0]
|
||
if len(name) > 50:
|
||
name = name[:47] + '...'
|
||
play_urls.append(f"{name}${self.MP3_PREFIX + audio['path']}")
|
||
return {'list': [{
|
||
'vod_id': vod_id,
|
||
'vod_name': f"🎵 {os.path.basename(dir_path)} ({len(audios)}首)",
|
||
'vod_pic': self.DEFAULT_AUDIO_ICON,
|
||
'vod_play_from': '本地音乐',
|
||
'vod_play_url': '#'.join(play_urls),
|
||
'vod_remarks': f'共{len(audios)}首',
|
||
'style': {'type': 'list'},
|
||
'vod_player': '听'
|
||
}]}
|
||
|
||
def _handle_video_all_detail(self, dir_path, vod_id):
|
||
videos = self.collect_videos_in_dir(dir_path)
|
||
if not videos:
|
||
return {'list': []}
|
||
play_urls = [f"{os.path.splitext(v['name'])[0]}$file://{v['path']}" for v in videos]
|
||
return {'list': [{
|
||
'vod_id': vod_id,
|
||
'vod_name': f"🎬 视频播放列表 - {os.path.basename(dir_path)} ({len(videos)}集)",
|
||
'vod_pic': self.file_icons['video_playlist'],
|
||
'vod_play_from': '本地视频',
|
||
'vod_play_url': '#'.join(play_urls),
|
||
'vod_remarks': f'共{len(videos)}集',
|
||
'style': {'type': 'list'}
|
||
}]}
|
||
|
||
def _handle_file_detail(self, file_path):
|
||
name = os.path.basename(file_path)
|
||
ext = self.get_file_ext(name)
|
||
vod = {'vod_id': file_path, 'vod_name': name, 'vod_play_from': '本地播放', 'vod_play_url': '', 'style': {'type': 'list'}}
|
||
|
||
if self.is_audio_file(ext):
|
||
return self._handle_audio_file_detail(file_path, name, vod)
|
||
|
||
if ComicReader.is_supported(name):
|
||
return ComicReader.get_comic_detail(file_path, name)
|
||
|
||
if self.is_image_file(ext) or ext.lower() in ['heic', 'heif']:
|
||
dir_path = os.path.dirname(file_path)
|
||
all_images = self.collect_images_in_dir(dir_path)
|
||
if len(all_images) > 1:
|
||
clicked_index = -1
|
||
for i, img in enumerate(all_images):
|
||
if img['path'] == file_path:
|
||
clicked_index = i
|
||
break
|
||
reordered_images = []
|
||
if clicked_index >= 0:
|
||
for i in range(clicked_index, len(all_images)):
|
||
reordered_images.append(all_images[i])
|
||
for i in range(0, clicked_index):
|
||
reordered_images.append(all_images[i])
|
||
else:
|
||
reordered_images = all_images
|
||
pic_urls = [f"file://{img['path']}" for img in reordered_images]
|
||
vod.update({
|
||
'vod_play_url': f"浏览${self.PICS_PREFIX + '&&'.join(pic_urls)}",
|
||
'vod_name': f"🖼 {name} (当前目录 {len(all_images)}张)",
|
||
'vod_pic': f"file://{file_path}",
|
||
'vod_play_from': '图片浏览',
|
||
'vod_remarks': f'共{len(all_images)}张照片,循环播放',
|
||
'vod_player': '画'
|
||
})
|
||
else:
|
||
vod.update({
|
||
'vod_play_url': f"查看${self.PICS_PREFIX}file://{file_path}",
|
||
'vod_pic': f"file://{file_path}",
|
||
'vod_name': f"🖼️ {name}",
|
||
'vod_player': '画'
|
||
})
|
||
elif self.is_media_file(ext):
|
||
dir_path = os.path.dirname(file_path)
|
||
all_videos = self.collect_videos_in_dir(dir_path)
|
||
if len(all_videos) > 1:
|
||
clicked_index = -1
|
||
for i, video in enumerate(all_videos):
|
||
if video['path'] == file_path:
|
||
clicked_index = i
|
||
break
|
||
reordered_videos = []
|
||
if clicked_index >= 0:
|
||
for i in range(clicked_index, len(all_videos)):
|
||
reordered_videos.append(all_videos[i])
|
||
for i in range(0, clicked_index):
|
||
reordered_videos.append(all_videos[i])
|
||
else:
|
||
reordered_videos = all_videos
|
||
play_urls = [f"{os.path.splitext(v['name'])[0]}$file://{v['path']}" for v in reordered_videos]
|
||
vod.update({
|
||
'vod_play_url': '#'.join(play_urls),
|
||
'vod_name': f"🎬 {name} (当前目录 {len(all_videos)}集)",
|
||
'vod_pic': self.file_icons['video_playlist'],
|
||
'vod_play_from': '本地视频',
|
||
'vod_remarks': f'共{len(all_videos)}集,循环播放'
|
||
})
|
||
else:
|
||
vod.update({
|
||
'vod_play_url': f"{os.path.splitext(name)[0]}$file://{file_path}",
|
||
'vod_name': f"🎬 {name}",
|
||
'vod_pic': self.file_icons['video']
|
||
})
|
||
elif self.is_list_file(ext) or self.is_db_file(ext):
|
||
return self.detailContent([self.LIST_PREFIX + self.b64u_encode(file_path)])
|
||
elif ext == 'php' or ext == 'py':
|
||
return self.detailContent([self.TEXT_PREFIX + self.b64u_encode(file_path)])
|
||
elif ext == 'zip' or ext in ['rar', '7z', 'tar', 'gz', 'bz2', 'xz']:
|
||
vod.update({
|
||
'vod_play_url': f"{os.path.splitext(name)[0]}$file://{file_path}",
|
||
'vod_name': f"🗜️ {name}",
|
||
'vod_pic': self.file_icons['zip'] if ext == 'zip' else self.file_icons['archive'],
|
||
'vod_remarks': f'{ext.upper()}压缩包'
|
||
})
|
||
elif ext == 'txt':
|
||
preview = ''
|
||
try:
|
||
with open(file_path, 'r', encoding='utf-8', errors='ignore') as f:
|
||
preview = f.read(4096)
|
||
except:
|
||
pass
|
||
|
||
is_live = self._is_txt_live_source(preview, file_path)
|
||
is_novel = self._is_txt_novel(preview, file_path) if not is_live else False
|
||
|
||
if is_live:
|
||
return self.detailContent([self.LIST_PREFIX + self.b64u_encode(file_path)])
|
||
elif is_novel:
|
||
return self.detailContent([self.NOVEL_PREFIX + self.b64u_encode(file_path)])
|
||
else:
|
||
return self.detailContent([self.TEXT_PREFIX + self.b64u_encode(file_path)])
|
||
else:
|
||
return {'list': [vod]}
|
||
|
||
return {'list': [vod]}
|
||
|
||
def _handle_audio_file_detail(self, file_path, name, vod):
|
||
dir_path = os.path.dirname(file_path)
|
||
all_audios = self.collect_audios_in_dir(dir_path)
|
||
|
||
cover_url = self.get_audio_cover_ultra_fast(file_path)
|
||
|
||
if cover_url and cover_url != self.DEFAULT_AUDIO_ICON and len(cover_url) > 50:
|
||
display_pic = cover_url
|
||
else:
|
||
color = self.default_colors[hash(name) % len(self.default_colors)]
|
||
first_char = name[0] if name else "🎵"
|
||
display_pic = self._generate_colored_icon(color, first_char)
|
||
|
||
if len(all_audios) > 1:
|
||
clicked_index = -1
|
||
for i, audio in enumerate(all_audios):
|
||
if audio['path'] == file_path:
|
||
clicked_index = i
|
||
break
|
||
reordered_audios = []
|
||
if clicked_index >= 0:
|
||
reordered_audios.extend(all_audios[clicked_index:])
|
||
reordered_audios.extend(all_audios[:clicked_index])
|
||
else:
|
||
reordered_audios = all_audios
|
||
if len(reordered_audios) > 500:
|
||
reordered_audios = reordered_audios[:500]
|
||
play_urls = []
|
||
for audio in reordered_audios:
|
||
audio_name = os.path.splitext(audio['name'])[0]
|
||
if len(audio_name) > 50:
|
||
audio_name = audio_name[:47] + '...'
|
||
play_urls.append(f"{audio_name}${self.MP3_PREFIX + audio['path']}")
|
||
|
||
vod.update({
|
||
'vod_play_url': '#'.join(play_urls),
|
||
'vod_name': f"🎵 {name} (共{len(all_audios)}首)",
|
||
'vod_pic': display_pic,
|
||
'vod_play_from': '本地音乐',
|
||
'vod_remarks': f'共{len(all_audios)}首',
|
||
'vod_player': '听'
|
||
})
|
||
else:
|
||
vod.update({
|
||
'vod_play_url': f"{os.path.splitext(name)[0]}${self.MP3_PREFIX + file_path}",
|
||
'vod_name': f"🎵 {name}",
|
||
'vod_pic': display_pic,
|
||
'vod_player': '听'
|
||
})
|
||
return {'list': [vod]}
|
||
|
||
def playerContent(self, flag, id, vipFlags):
|
||
if id.startswith('pics://'):
|
||
return {"parse": 0, "playUrl": "", "url": id, "header": {}, "vod_player": "画"}
|
||
|
||
if '|UAINFO|' in id:
|
||
parts = id.split('|UAINFO|')
|
||
real_url = parts[0]
|
||
ua_info_json = parts[1]
|
||
try:
|
||
ua_info = json.loads(self.b64u_decode(ua_info_json))
|
||
custom_ua = ua_info.get('ua', '')
|
||
custom_referer = ua_info.get('referer', '')
|
||
|
||
headers = {
|
||
"User-Agent": custom_ua if custom_ua else "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
||
"Accept": "*/*",
|
||
"Connection": "keep-alive"
|
||
}
|
||
if custom_referer:
|
||
headers["Referer"] = custom_referer
|
||
|
||
if custom_ua:
|
||
print(f"🎬 [直播播放UA生效] {custom_ua[:50]}...")
|
||
else:
|
||
print(f"🎬 [直播播放] 使用默认UA")
|
||
|
||
return {
|
||
"parse": 0,
|
||
"playUrl": "",
|
||
"url": real_url,
|
||
"header": headers
|
||
}
|
||
except Exception as e:
|
||
print(f"❌ 解析UA信息失败: {e}")
|
||
id = id.split('|UAINFO|')[0]
|
||
|
||
if id.startswith(self.MP3_PREFIX):
|
||
song_key = id
|
||
current_time = time.time()
|
||
|
||
if song_key in self.click_timer:
|
||
time_diff = current_time - self.click_timer[song_key]
|
||
if time_diff < 2:
|
||
self.click_count[song_key] = self.click_count.get(song_key, 0) + 1
|
||
else:
|
||
self.click_count[song_key] = 1
|
||
else:
|
||
self.click_count[song_key] = 1
|
||
|
||
self.click_timer[song_key] = current_time
|
||
count = self.click_count.get(song_key, 1)
|
||
|
||
print(f"🎵 连点检测: 第{count}次点击 - {id[:80]}")
|
||
|
||
if count >= 3:
|
||
self.click_count[song_key] = 0
|
||
if song_key in self.click_timer:
|
||
del self.click_timer[song_key]
|
||
|
||
if id.startswith(self.MP3_PREFIX):
|
||
file_path = id.replace(self.MP3_PREFIX, '')
|
||
if os.path.exists(file_path):
|
||
success, msg = self._delete_to_trash(file_path)
|
||
self.audio_list_cache.clear()
|
||
self.audio_list_cache_time.clear()
|
||
self.dir_cache.clear()
|
||
self.dir_cache_time.clear()
|
||
|
||
if success:
|
||
return {
|
||
"parse": 0,
|
||
"playUrl": "",
|
||
"url": "",
|
||
"header": {},
|
||
"vod_player": "听"
|
||
}
|
||
|
||
if flag == '蜻蜓FM':
|
||
try:
|
||
raw = self.d64(id).split("@@@@")[-1]
|
||
url = raw.split("|||")[0] if "|||" in raw else raw
|
||
url = url.replace(r"\/", "/")
|
||
return {
|
||
"parse": 0,
|
||
"playUrl": "",
|
||
"url": url,
|
||
"header": {
|
||
"User-Agent": self.headers["User-Agent"],
|
||
"Referer": "http://www.qingting.fm/",
|
||
"Accept": "*/*"
|
||
},
|
||
"vod_player": "听"
|
||
}
|
||
except:
|
||
return {"parse": 0, "playUrl": "", "url": "", "header": self.headers}
|
||
|
||
if flag == '短视频播放':
|
||
return self._handle_short_video_play(id)
|
||
|
||
if flag in ['PDF漫画', 'EPUB漫画']:
|
||
return {"parse": 0, "playUrl": "", "url": id, "header": {}, "vod_player": "画"}
|
||
|
||
if id.startswith(self.PICS_PREFIX):
|
||
return {"parse": 0, "playUrl": "", "url": id, "header": {}, "vod_player": "画"}
|
||
|
||
if id.startswith(self.TEXT_PREFIX):
|
||
encoded = id[len(self.TEXT_PREFIX):]
|
||
file_path = self.b64u_decode(encoded)
|
||
try:
|
||
with open(file_path, 'r', encoding='utf-8', errors='ignore') as f:
|
||
content = f.read()
|
||
data = {"title": os.path.basename(file_path), "content": content}
|
||
return {
|
||
"parse": 0,
|
||
"playUrl": "",
|
||
"url": self.TEXT_PREFIX + json.dumps(data, ensure_ascii=False),
|
||
"header": "",
|
||
"content": content,
|
||
"vod_player": "书"
|
||
}
|
||
except:
|
||
return {"parse": 0, "playUrl": "", "url": "", "header": ""}
|
||
|
||
if id.startswith(self.MP3_PREFIX):
|
||
return self._handle_mp3_play(id)
|
||
|
||
if id.startswith(self.NOVEL_PREFIX):
|
||
full_id = id[len(self.NOVEL_PREFIX):]
|
||
chapter_index = 0
|
||
encoded_path = full_id
|
||
if '#chapter' in full_id:
|
||
parts = full_id.split('#chapter', 1)
|
||
encoded_path = parts[0]
|
||
try:
|
||
chapter_index = int(parts[1])
|
||
except:
|
||
chapter_index = 0
|
||
if encoded_path in self.novel_path_cache:
|
||
file_path = self.novel_path_cache[encoded_path]
|
||
else:
|
||
file_path = self.b64u_decode(encoded_path)
|
||
self.novel_path_cache[encoded_path] = file_path
|
||
try:
|
||
cache_key = f"chapters_{encoded_path}"
|
||
if cache_key in self.novel_chapters_cache:
|
||
chapters = self.novel_chapters_cache[cache_key]
|
||
else:
|
||
chapters = NovelParser.parse_txt_novel(file_path)
|
||
self.novel_chapters_cache[cache_key] = chapters
|
||
if chapters and 0 <= chapter_index < len(chapters):
|
||
title = chapters[chapter_index]['title']
|
||
content = chapters[chapter_index]['content']
|
||
else:
|
||
with open(file_path, 'r', encoding='utf-8', errors='ignore') as f:
|
||
content = f.read()
|
||
title = os.path.basename(file_path)
|
||
data = {"title": title, "content": content}
|
||
return {
|
||
"parse": 0,
|
||
"playUrl": "",
|
||
"url": "novel://" + json.dumps(data, ensure_ascii=False),
|
||
"header": "",
|
||
"content": content,
|
||
"vod_player": "书"
|
||
}
|
||
except:
|
||
return {"parse": 0, "playUrl": "", "url": "", "header": ""}
|
||
|
||
if id.startswith('chapter') and flag == '小说章节' and self.current_novel['chapters']:
|
||
try:
|
||
idx = int(id.replace('chapter', ''))
|
||
if 0 <= idx < len(self.current_novel['chapters']):
|
||
c = self.current_novel['chapters'][idx]
|
||
data = {"title": c['title'], "content": c['content']}
|
||
return {
|
||
"parse": 0,
|
||
"playUrl": "",
|
||
"url": "novel://" + json.dumps(data, ensure_ascii=False),
|
||
"header": "",
|
||
"vod_player": "书"
|
||
}
|
||
except:
|
||
pass
|
||
|
||
url = id
|
||
if '$' in url:
|
||
parts = url.split('$', 1)
|
||
if len(parts) == 2:
|
||
url = parts[1]
|
||
|
||
if url.startswith(('http://', 'https://', 'file://')):
|
||
pass
|
||
else:
|
||
try:
|
||
decoded = base64.b64decode(url).decode('utf-8')
|
||
if decoded.startswith(('http://', 'https://', 'file://')):
|
||
url = decoded
|
||
except:
|
||
pass
|
||
|
||
if 'dytt-' in url and '/share/' in url and not url.endswith('.m3u8'):
|
||
real_url = self._extract_real_m3u8_url(url)
|
||
if real_url:
|
||
url = real_url
|
||
|
||
headers = self._build_headers(flag, url)
|
||
result = {"parse": 0, "playUrl": "", "url": url, "header": headers}
|
||
|
||
if url.startswith('file://'):
|
||
file_path = url[7:]
|
||
if os.path.exists(file_path) and self.is_audio_file(self.get_file_ext(file_path)):
|
||
self._add_audio_info_fast(result, file_path)
|
||
|
||
if url.startswith('file://'):
|
||
ext = self.get_file_ext(url[7:])
|
||
if self.is_image_file(ext) or ext.lower() in ['heic', 'heif'] or ComicReader.is_supported(url[7:]):
|
||
result["vod_player"] = "画"
|
||
|
||
return result
|
||
|
||
def _handle_mp3_play(self, id):
|
||
file_path = id.replace(self.MP3_PREFIX, '')
|
||
if not os.path.exists(file_path):
|
||
test_paths = [file_path, '/storage/emulated/0/' + file_path.lstrip('/'), file_path.replace('//', '/')]
|
||
for test_path in test_paths:
|
||
if os.path.exists(test_path):
|
||
file_path = test_path
|
||
break
|
||
else:
|
||
return {"parse": 0, "playUrl": "", "url": "", "header": {}, "error": "文件不存在"}
|
||
|
||
if not os.access(file_path, os.R_OK):
|
||
return {"parse": 0, "playUrl": "", "url": "", "header": {}, "error": "文件无法读取"}
|
||
|
||
play_url = f"http://127.0.0.1:9978/file{file_path}"
|
||
result = {"parse": 0, "playUrl": "", "url": play_url, "header": {}, "vod_player": "听"}
|
||
|
||
if self.is_audio_file(self.get_file_ext(file_path)):
|
||
self._add_audio_info_fast(result, file_path)
|
||
|
||
return result
|
||
|
||
def _extract_real_m3u8_url(self, page_url):
|
||
if page_url in self.m3u8_cache:
|
||
return self.m3u8_cache[page_url]
|
||
try:
|
||
from urllib.parse import urlparse
|
||
parsed = urlparse(page_url)
|
||
base_url = f"{parsed.scheme}://{parsed.netloc}"
|
||
headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", "Referer": base_url + "/"}
|
||
response = self.session.get(page_url, headers=headers, timeout=10)
|
||
if response.status_code != 200:
|
||
return None
|
||
html = response.text
|
||
patterns = [
|
||
r'(https?://[^\s"\']+\.m3u8[^\s"\']*)',
|
||
r'(//[^\s"\']+\.m3u8[^\s"\']*)',
|
||
r'url["\']?\s*[:=]\s*["\']([^"\']+\.m3u8[^"\']*)["\']',
|
||
]
|
||
for pattern in patterns:
|
||
matches = re.findall(pattern, html, re.IGNORECASE)
|
||
if matches:
|
||
url = matches[0]
|
||
if url.startswith('//'):
|
||
url = 'https:' + url
|
||
elif url.startswith('/'):
|
||
url = base_url + url
|
||
self.m3u8_cache[page_url] = url
|
||
return url
|
||
return None
|
||
except:
|
||
return None
|
||
|
||
def _build_headers(self, flag, url):
|
||
from urllib.parse import urlparse
|
||
domain = urlparse(url).netloc
|
||
headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", "Accept": "*/*"}
|
||
|
||
if flag == 'migu_live':
|
||
headers.update({"User-Agent": "com.android.chrome/3.7.0 (Linux;Android 15)", "Referer": "https://www.miguvideo.com/"})
|
||
elif flag == 'gongdian_live':
|
||
headers.update({"Referer": "https://gongdian.top/"})
|
||
|
||
if 't.061899.xyz' in domain:
|
||
headers.update({"User-Agent": "okhttp/3.12.11", "Referer": "http://t.061899.xyz/"})
|
||
elif 'rihou.cc' in domain:
|
||
headers.update({"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", "Referer": "https://rihou.cc:555/"})
|
||
elif 'miguvideo.com' in domain:
|
||
headers.update({"User-Agent": "com.android.chrome/3.7.0 (Linux;Android 15)", "Referer": "https://www.miguvideo.com/"})
|
||
elif 'gongdian.top' in domain:
|
||
headers.update({"Referer": "https://gongdian.top/"})
|
||
elif domain:
|
||
headers["Referer"] = f"https://{domain}/"
|
||
|
||
return headers
|
||
|
||
def searchContent(self, key, quick, pg="1"):
|
||
pg = int(pg)
|
||
results = []
|
||
clean_key = re.sub(r'^[📁📂🎬🎵🖼📋📝🗄️🧲📄🖼️🎞️⬅️📚\s]+', '', key.lower())
|
||
for path in self.root_paths:
|
||
if not os.path.exists(path):
|
||
continue
|
||
all_files = []
|
||
self._scan_for_search(path, all_files)
|
||
for f in all_files:
|
||
if clean_key in f['name'].lower():
|
||
item = self._create_search_item(f)
|
||
if item:
|
||
results.append(item)
|
||
results.sort(key=lambda x: (clean_key not in x['vod_name'].lower(), x['vod_name']))
|
||
per_page = 50
|
||
start = (pg - 1) * per_page
|
||
end = min(start + per_page, len(results))
|
||
return {'list': results[start:end], 'page': pg, 'pagecount': (len(results) + per_page - 1) // per_page, 'limit': per_page, 'total': len(results)}
|
||
|
||
def _scan_for_search(self, path, file_list, depth=0, max_depth=3):
|
||
if depth > max_depth:
|
||
return
|
||
try:
|
||
audio_names = []
|
||
try:
|
||
for name in os.listdir(path):
|
||
if name.startswith('.'):
|
||
continue
|
||
full_path = os.path.join(path, name)
|
||
if not os.path.isdir(full_path):
|
||
ext = self.get_file_ext(name)
|
||
if ext in self.audio_exts:
|
||
audio_names.append(os.path.splitext(name)[0])
|
||
except:
|
||
pass
|
||
for name in os.listdir(path):
|
||
if name.startswith('.'):
|
||
continue
|
||
full_path = os.path.join(path, name)
|
||
if os.path.isdir(full_path):
|
||
self._scan_for_search(full_path, file_list, depth + 1, max_depth)
|
||
else:
|
||
if self._should_hide_file(name, path, audio_names):
|
||
continue
|
||
file_list.append({'name': name, 'path': full_path, 'ext': self.get_file_ext(name)})
|
||
except:
|
||
pass
|
||
|
||
def _create_search_item(self, f):
|
||
if ComicReader.is_supported(f['name']):
|
||
cover_url = ComicReader.get_cover_url(f['path'])
|
||
return {
|
||
'vod_id': f['path'],
|
||
'vod_name': f"📚 {f['name']}",
|
||
'vod_pic': cover_url or ComicReader._get_default_comic_icon(f['ext']),
|
||
'vod_play_url': f"阅读${f['path']}",
|
||
'vod_remarks': f'{f["ext"].upper()}漫画',
|
||
'style': {'type': 'list'},
|
||
'vod_player': '画'
|
||
}
|
||
|
||
if self.is_image_file(f['ext']) or f['ext'].lower() in ['heic', 'heif']:
|
||
return {
|
||
'vod_id': self.URL_B64U_PREFIX + self.b64u_encode(self.PICS_PREFIX + "file://" + f['path']),
|
||
'vod_name': f"🖼 {f['name']}",
|
||
'vod_pic': f"file://{f['path']}",
|
||
'vod_play_url': f"查看${self.PICS_PREFIX}file://{f['path']}",
|
||
'vod_remarks': '',
|
||
'style': {'type': 'grid', 'ratio': 1},
|
||
'vod_player': '画'
|
||
}
|
||
if self.is_audio_file(f['ext']):
|
||
cover_url = self.get_audio_cover_ultra_fast(f['path'])
|
||
if cover_url and cover_url != self.DEFAULT_AUDIO_ICON and len(cover_url) > 50:
|
||
display_pic = cover_url
|
||
else:
|
||
color = self.default_colors[hash(f['name']) % len(self.default_colors)]
|
||
first_char = f['name'][0] if f['name'] else "🎵"
|
||
display_pic = self._generate_colored_icon(color, first_char)
|
||
return {
|
||
'vod_id': self.URL_B64U_PREFIX + self.b64u_encode(self.MP3_PREFIX + f['path']),
|
||
'vod_name': f"{f['name']}",
|
||
'vod_pic': display_pic,
|
||
'vod_play_url': f"播放${self.MP3_PREFIX + f['path']}",
|
||
'vod_remarks': '',
|
||
'style': {'type': 'list'},
|
||
'vod_player': '听'
|
||
}
|
||
if self.is_media_file(f['ext']):
|
||
return {'vod_id': f['path'], 'vod_name': f"🎬 {f['name']}", 'vod_pic': self.file_icons['video'], 'vod_remarks': '', 'style': {'type': 'list'}}
|
||
if self.is_lrc_file(f['ext']):
|
||
return {'vod_id': f['path'], 'vod_name': f"📝 {f['name']}", 'vod_pic': self.file_icons['lyrics'], 'vod_remarks': '', 'style': {'type': 'list'}}
|
||
if f['ext'] == 'json':
|
||
return {'vod_id': self.LIST_PREFIX + self.b64u_encode(f['path']), 'vod_name': f"📋 {f['name']}", 'vod_pic': self.file_icons['json'], 'vod_remarks': '', 'style': {'type': 'list'}}
|
||
if f['ext'] == 'php':
|
||
return {'vod_id': self.TEXT_PREFIX + self.b64u_encode(f['path']), 'vod_name': f"🐘 {f['name']}", 'vod_pic': self.file_icons['php'], 'vod_remarks': '', 'style': {'type': 'list'}}
|
||
if f['ext'] == 'py':
|
||
return {'vod_id': self.TEXT_PREFIX + self.b64u_encode(f['path']), 'vod_name': f"🐍 {f['name']}", 'vod_pic': self.file_icons['python'], 'vod_remarks': '', 'style': {'type': 'list'}}
|
||
if f['ext'] == 'zip':
|
||
return {'vod_id': f['path'], 'vod_name': f"🗜️ {f['name']}", 'vod_pic': self.file_icons['zip'], 'vod_remarks': '', 'style': {'type': 'list'}}
|
||
if f['ext'] in ['rar', '7z', 'tar', 'gz', 'bz2', 'xz']:
|
||
icon_key = 'rar' if f['ext'] == 'rar' else 'archive'
|
||
return {'vod_id': f['path'], 'vod_name': f"🗜️ {f['name']}", 'vod_pic': self.file_icons[icon_key], 'vod_remarks': '', 'style': {'type': 'list'}}
|
||
if f['ext'] in ['m3u', 'm3u8']:
|
||
colors = ["#FF6B6B", "#4ECDC4", "#FFD93D", "#6BCB77", "#9D65C9", "#FF8C42", "#A2D729", "#FF6B8B", "#45B7D1", "#96CEB4"]
|
||
color = colors[hash(f['name']) % len(colors)]
|
||
first_char = f['name'][0].upper() if f['name'] else "M"
|
||
icon_svg = self._generate_colored_icon(color, first_char)
|
||
return {'vod_id': self.LIST_PREFIX + self.b64u_encode(f['path']), 'vod_name': f['name'], 'vod_pic': icon_svg, 'vod_remarks': '', 'style': {'type': 'list'}}
|
||
if self.is_db_file(f['ext']):
|
||
return {'vod_id': self.LIST_PREFIX + self.b64u_encode(f['path']), 'vod_name': f"🗄️ {f['name']}", 'vod_pic': self.file_icons['database'], 'vod_remarks': '', 'style': {'type': 'list'}}
|
||
if f['ext'] == 'txt':
|
||
is_live = any(kw in f['name'].lower() for kw in self.live_keywords)
|
||
if is_live:
|
||
colors = ["#FF6B6B", "#4ECDC4", "#FFD93D", "#6BCB77", "#9D65C9", "#FF8C42", "#A2D729", "#FF6B8B", "#45B7D1", "#96CEB4"]
|
||
color = colors[hash(f['name']) % len(colors)]
|
||
first_char = f['name'][0].upper() if f['name'] else "T"
|
||
icon_svg = self._generate_colored_icon(color, first_char)
|
||
return {'vod_id': self.LIST_PREFIX + self.b64u_encode(f['path']), 'vod_name': f['name'], 'vod_pic': icon_svg, 'vod_remarks': '', 'style': {'type': 'list'}}
|
||
else:
|
||
encoded = self.b64u_encode(f['path'])
|
||
return {'vod_id': f"{self.NOVEL_PREFIX}{encoded}", 'vod_name': f"📖 {f['name']}", 'vod_pic': self.file_icons['novel'], 'vod_remarks': '', 'style': {'type': 'list'}, 'vod_player': '书'}
|
||
if f['ext'] in ['xml', 'html', 'htm', 'css', 'js', 'sh', 'bash']:
|
||
return {'vod_id': self.TEXT_PREFIX + self.b64u_encode(f['path']), 'vod_name': f"📄 {f['name']}", 'vod_pic': self.file_icons['text'], 'vod_remarks': '', 'style': {'type': 'list'}}
|
||
return {'vod_id': f['path'], 'vod_name': f"📁 {f['name']}", 'vod_pic': self.file_icons['file'], 'vod_remarks': '', 'style': {'type': 'list'}}
|
||
|
||
def clear_audio_cache(self):
|
||
self.audio_list_cache.clear()
|
||
self.audio_list_cache_time.clear()
|
||
self.audio_cover_cache.clear()
|
||
keys_to_delete = [k for k in self.lrc_cache if k.startswith('fast_lrc_')]
|
||
for key in keys_to_delete:
|
||
del self.lrc_cache[key]
|
||
|
||
def clear_network_cache(self):
|
||
self.network_lyrics_cache.clear()
|
||
self.network_cover_cache.clear()
|
||
self.song_info_cache.clear()
|
||
|
||
def shutdown(self):
|
||
self.preload_executor.shutdown(wait=False)
|
||
ComicReader.shutdown_render_executor()
|
||
|
||
def categoryContent(self, tid, pg, filter, extend):
|
||
pg = int(pg) if pg else 1
|
||
|
||
self._current_page = pg
|
||
|
||
# 处理动作/工具/书签分类
|
||
if tid in ['web_action', 'web_tool', 'web_bookmarks']:
|
||
return self.web_action_browser.get_category_content(tid, pg)
|
||
|
||
if tid == "online_radio":
|
||
cat_id = extend.get("category", "442") if extend and isinstance(extend, dict) else "442"
|
||
return self._online_radio_content(cat_id, pg)
|
||
if tid == "short_video":
|
||
return self._short_video_category_content(pg)
|
||
if tid == "gallery":
|
||
return self._gallery_category_content(pg)
|
||
if tid.startswith("short_video_"):
|
||
return self._short_video_detail(tid[len("short_video_"):])
|
||
if tid == self.live_category_id:
|
||
return self._live_category_content(pg)
|
||
if tid == 'recent':
|
||
return self._recent_content(pg)
|
||
|
||
current_path = None
|
||
if tid.startswith("root_"):
|
||
try:
|
||
idx = int(tid[5:])
|
||
if idx < len(self.root_paths):
|
||
current_path = self.root_paths[idx]
|
||
except:
|
||
pass
|
||
elif tid.startswith(self.FOLDER_PREFIX):
|
||
current_path = self.b64u_decode(tid[len(self.FOLDER_PREFIX):])
|
||
elif not tid.startswith(('online_', 'short_video', 'gallery', self.live_category_id, 'recent', 'web_action', 'web_tool', 'web_bookmarks')):
|
||
current_path = tid if os.path.exists(tid) else None
|
||
|
||
if extend and isinstance(extend, dict):
|
||
action = extend.get("action", "")
|
||
if action == "clear_comic_cache":
|
||
return self._clear_comic_cache_content()
|
||
elif action == "clear_cover_cache":
|
||
return self._clear_cover_cache_content()
|
||
elif action == "clear_lyrics_cache":
|
||
return self._clear_lyrics_cache_content()
|
||
elif action == "clear_radio_cover_cache":
|
||
return self._clear_radio_cover_cache_content()
|
||
|
||
delete_action = extend.get("delete_mode", "")
|
||
if delete_action == "on":
|
||
if current_path and os.path.isdir(current_path):
|
||
self._enable_delete_mode(current_path)
|
||
return self._get_category_content_with_delete_mode(tid, pg, filter, extend, current_path)
|
||
else:
|
||
return {
|
||
'list': [{
|
||
'vod_id': 'delete_mode_error',
|
||
'vod_name': '❌ 无法在当前目录开启删除模式',
|
||
'vod_pic': self._generate_colored_icon("#F44336", "✗"),
|
||
'vod_remarks': '请确保当前是有效文件夹',
|
||
'style': {'type': 'list'},
|
||
'vod_player': '书'
|
||
}],
|
||
'page': pg,
|
||
'pagecount': 1
|
||
}
|
||
elif delete_action == "off":
|
||
self._disable_delete_mode()
|
||
if current_path and os.path.isdir(current_path):
|
||
return self._get_category_content_with_delete_mode(tid, pg, filter, extend, current_path)
|
||
else:
|
||
return {
|
||
'list': [{
|
||
'vod_id': 'delete_mode_closed',
|
||
'vod_name': '🟢 删除模式已关闭',
|
||
'vod_pic': self._generate_colored_icon("#4CAF50", "✓"),
|
||
'vod_remarks': '现在点击文件正常打开,不会删除',
|
||
'style': {'type': 'list'},
|
||
'vod_player': '书'
|
||
}],
|
||
'page': pg,
|
||
'pagecount': 1
|
||
}
|
||
elif delete_action == "empty":
|
||
return self._empty_trash()
|
||
|
||
if not current_path or not os.path.isdir(current_path):
|
||
return {'list': [], 'page': pg, 'pagecount': 1}
|
||
|
||
return self._get_category_content_with_delete_mode(tid, pg, filter, extend, current_path)
|
||
|
||
def _get_category_content_with_delete_mode(self, tid, pg, filter, extend, current_path):
|
||
pg = int(pg) if pg else 1
|
||
|
||
is_delete_mode = self._is_delete_mode_active_in_path(current_path)
|
||
|
||
filter_value = None
|
||
if extend and isinstance(extend, dict):
|
||
letter_keys = ['letter_row1', 'letter_row2', 'letter_row3', 'letter_row4']
|
||
for key in letter_keys:
|
||
if key in extend and extend[key] and extend[key] != '全部':
|
||
filter_value = extend[key]
|
||
break
|
||
|
||
if not filter_value and 'digit_row' in extend and extend['digit_row'] and extend['digit_row'] != '全部':
|
||
filter_value = extend['digit_row']
|
||
|
||
cache_key = f"dir_{current_path}"
|
||
if cache_key in self.dir_cache and time.time() - self.dir_cache_time.get(cache_key, 0) < 3600:
|
||
files = self.dir_cache[cache_key]
|
||
else:
|
||
files = self.scan_directory(current_path)
|
||
self.dir_cache[cache_key] = files
|
||
self.dir_cache_time[cache_key] = time.time()
|
||
|
||
if filter_value and filter_value != '全部':
|
||
for f in files:
|
||
name = f['name']
|
||
f['match_score'] = 0
|
||
if not name:
|
||
continue
|
||
first_char = name[0]
|
||
|
||
if filter_value == 'all_digits':
|
||
if first_char.isdigit():
|
||
f['match_score'] = 1
|
||
elif filter_value.isdigit():
|
||
if first_char.isdigit() and first_char == filter_value:
|
||
f['match_score'] = 1
|
||
else:
|
||
if first_char.upper() == filter_value.upper():
|
||
f['match_score'] = 1
|
||
elif '\u4e00' <= first_char <= '\u9fff':
|
||
pinyin_initial = _get_chinese_pinyin_initial(name[0])
|
||
if pinyin_initial == filter_value.upper():
|
||
f['match_score'] = 1
|
||
|
||
files.sort(key=lambda x: (-x['match_score'], not x['is_dir'], x['name'].lower()))
|
||
else:
|
||
files.sort(key=lambda x: (not x['is_dir'], x['name'].lower()))
|
||
|
||
total = len(files)
|
||
per_page = 500
|
||
start = (pg - 1) * per_page
|
||
end = min(start + per_page, total)
|
||
page_files = files[start:end]
|
||
|
||
audio_paths = []
|
||
comic_paths = []
|
||
for f in page_files:
|
||
if not f['is_dir']:
|
||
if self.is_audio_file(f['ext']):
|
||
audio_paths.append(f['path'])
|
||
elif ComicReader.is_supported(f['name']):
|
||
comic_paths.append(f['path'])
|
||
|
||
if audio_paths:
|
||
self.preload_covers_batch(audio_paths, max_count=500)
|
||
|
||
for comic_path in comic_paths[:50]:
|
||
self.preload_executor.submit(ComicReader.get_cover_url, comic_path)
|
||
|
||
vlist = []
|
||
|
||
parent_item = self._create_parent_item(current_path)
|
||
if parent_item:
|
||
vlist.append(parent_item)
|
||
|
||
if is_delete_mode:
|
||
status_item = {
|
||
'vod_id': 'delete_mode_status',
|
||
'vod_name': '🔴 删除模式已开启(点击此条关闭)',
|
||
'vod_pic': self.delete_icon,
|
||
'vod_remarks': '⚠️ 点击文件/文件夹将移动到回收站,点击此条关闭',
|
||
'vod_remarks_color': '#F44336',
|
||
'style': {'type': 'list'},
|
||
'vod_player': '书',
|
||
'vod_tag': 'system_status'
|
||
}
|
||
vlist.append(status_item)
|
||
|
||
for f in page_files:
|
||
if f['is_dir']:
|
||
if is_delete_mode:
|
||
item = self._create_delete_file_item(f)
|
||
else:
|
||
item = self._create_file_item_with_flags(f)
|
||
if item:
|
||
vlist.append(item)
|
||
else:
|
||
if is_delete_mode:
|
||
item = self._create_delete_file_item(f)
|
||
else:
|
||
item = self._create_file_item_with_flags(f)
|
||
if item:
|
||
vlist.append(item)
|
||
|
||
if filter_value and filter_value != '全部':
|
||
if filter_value == 'all_digits':
|
||
filter_name = '全部数字'
|
||
icon_emoji = "❄️"
|
||
icon_color = "#FF9800"
|
||
elif filter_value.isdigit():
|
||
filter_name = f'数字 {filter_value}'
|
||
icon_emoji = "❄️"
|
||
icon_color = "#FF9800"
|
||
else:
|
||
filter_name = filter_value.upper()
|
||
icon_emoji = "☀️"
|
||
icon_color = "#4CAF50"
|
||
|
||
matched_count = sum(1 for f in files if f.get('match_score', 0) == 1)
|
||
|
||
info_item = {
|
||
'vod_id': 'filter_info',
|
||
'vod_name': f'{icon_emoji} 筛选: {filter_name} | 匹配 {matched_count} / 共 {total} 个文件',
|
||
'vod_pic': self._generate_colored_icon(icon_color, "🔍"),
|
||
'vod_remarks': f'📌 匹配的排在前面',
|
||
'style': {'type': 'list'},
|
||
'vod_player': '书'
|
||
}
|
||
vlist.insert(1 if parent_item else 0, info_item)
|
||
|
||
pagecount = (total + per_page - 1) // per_page if total > 0 else 1
|
||
|
||
return {
|
||
'list': vlist,
|
||
'page': pg,
|
||
'pagecount': pagecount,
|
||
'limit': per_page,
|
||
'total': total
|
||
}
|
||
|
||
def _resolve_path(self, tid):
|
||
if tid.startswith('root_'):
|
||
try:
|
||
idx = int(tid[5:])
|
||
return self.root_paths[idx] if idx < len(self.root_paths) else None
|
||
except:
|
||
return None
|
||
elif tid.startswith(self.FOLDER_PREFIX):
|
||
return self.b64u_decode(tid[len(self.FOLDER_PREFIX):])
|
||
return tid if os.path.exists(tid) else None
|
||
|
||
def _create_parent_item(self, current_path):
|
||
parent = os.path.dirname(current_path)
|
||
for root in self.root_paths:
|
||
if os.path.normpath(current_path) == os.path.normpath(root.rstrip('/')):
|
||
return None
|
||
if not parent or parent == current_path:
|
||
return None
|
||
for i, root in enumerate(self.root_paths):
|
||
if os.path.normpath(parent) == os.path.normpath(root.rstrip('/')):
|
||
parent_id = f"root_{i}"
|
||
parent_name = self.path_to_chinese.get(root, os.path.basename(parent))
|
||
break
|
||
else:
|
||
parent_id = self.FOLDER_PREFIX + self.b64u_encode(parent)
|
||
parent_name = os.path.basename(parent)
|
||
return {
|
||
'vod_id': parent_id,
|
||
'vod_name': f'⬅️ 返回 {parent_name}',
|
||
'vod_pic': self.file_icons['folder'],
|
||
'vod_remarks': '',
|
||
'vod_tag': 'folder',
|
||
'style': {'type': 'list'}
|
||
}
|
||
|
||
def _create_file_item_with_flags(self, f):
|
||
icon = self.get_file_icon(f['ext'], f['is_dir'])
|
||
|
||
if f['is_dir']:
|
||
return {
|
||
'vod_id': self.FOLDER_PREFIX + self.b64u_encode(f['path']),
|
||
'vod_name': f"{icon} {f['name']}",
|
||
'vod_pic': self.file_icons['folder'],
|
||
'vod_remarks': '文件夹',
|
||
'vod_tag': 'folder',
|
||
'style': {'type': 'list'}
|
||
}
|
||
|
||
if ComicReader.is_supported(f['name']):
|
||
cover_url = ComicReader.get_cover_url(f['path'])
|
||
size_bytes = os.path.getsize(f['path'])
|
||
size_mb = f"{size_bytes / 1024 / 1024:.1f}MB" if size_bytes > 0 else ""
|
||
return {
|
||
'vod_id': f['path'],
|
||
'vod_name': f"📚 {f['name']}",
|
||
'vod_pic': cover_url or ComicReader._get_default_comic_icon(f['ext']),
|
||
'vod_play_url': f"阅读${f['path']}",
|
||
'vod_remarks': f'{f["ext"].upper()}漫画 | {size_mb}' if size_mb else f'{f["ext"].upper()}漫画',
|
||
'vod_tag': 'comic',
|
||
'style': {'type': 'grid', 'ratio': 0.75},
|
||
'vod_player': '画'
|
||
}
|
||
|
||
has_local_lyrics = False
|
||
has_local_cover = False
|
||
|
||
if self.is_audio_file(f['ext']):
|
||
audio_dir = os.path.dirname(f['path'])
|
||
audio_name = os.path.splitext(f['name'])[0]
|
||
|
||
for lrc_ext in self.lrc_exts:
|
||
if os.path.exists(os.path.join(audio_dir, f"{audio_name}.{lrc_ext}")):
|
||
has_local_lyrics = True
|
||
break
|
||
if os.path.exists(os.path.join(audio_dir, f"{audio_name}.{lrc_ext.upper()}")):
|
||
has_local_lyrics = True
|
||
break
|
||
|
||
exact_names = [
|
||
f"{audio_name}.jpg", f"{audio_name}.jpeg", f"{audio_name}.png",
|
||
f"{audio_name}.webp", f"{audio_name}.gif",
|
||
f"{audio_name}.JPG", f"{audio_name}.JPEG", f"{audio_name}.PNG"
|
||
]
|
||
for cover_name in exact_names:
|
||
cover_path = os.path.join(audio_dir, cover_name)
|
||
if os.path.exists(cover_path) and os.path.isfile(cover_path):
|
||
has_local_cover = True
|
||
break
|
||
|
||
if self.is_image_file(f['ext']) or f['ext'].lower() in ['heic', 'heif']:
|
||
return {
|
||
'vod_id': f['path'],
|
||
'vod_name': f"🖼 {f['name']}",
|
||
'vod_pic': f"file://{f['path']}",
|
||
'vod_play_url': f"查看${self.PICS_PREFIX}file://{f['path']}",
|
||
'vod_remarks': '照片',
|
||
'vod_tag': 'image',
|
||
'style': {'type': 'grid', 'ratio': 1},
|
||
'vod_player': '画'
|
||
}
|
||
|
||
if self.is_audio_file(f['ext']):
|
||
cover_url = self.get_audio_cover_ultra_fast(f['path'])
|
||
|
||
if cover_url and cover_url != self.DEFAULT_AUDIO_ICON and len(cover_url) > 50:
|
||
display_pic = cover_url
|
||
else:
|
||
color = self.default_colors[hash(f['name']) % len(self.default_colors)]
|
||
first_char = f['name'][0] if f['name'] else "🎵"
|
||
display_pic = self._generate_colored_icon(color, first_char)
|
||
|
||
remarks = '音频'
|
||
if has_local_lyrics:
|
||
remarks += ' 📝'
|
||
if has_local_cover:
|
||
remarks += ' 🖼'
|
||
|
||
return {
|
||
'vod_id': f['path'],
|
||
'vod_name': f"{f['name']}",
|
||
'vod_pic': display_pic,
|
||
'vod_play_url': f"播放${self.MP3_PREFIX + f['path']}",
|
||
'vod_remarks': remarks,
|
||
'vod_tag': 'audio',
|
||
'style': {'type': 'list'},
|
||
'vod_player': '听'
|
||
}
|
||
|
||
if self.is_media_file(f['ext']):
|
||
return {
|
||
'vod_id': f['path'],
|
||
'vod_name': f"🎬 {f['name']}",
|
||
'vod_pic': self.file_icons['video'],
|
||
'vod_remarks': '视频',
|
||
'vod_tag': 'video',
|
||
'style': {'type': 'list'}
|
||
}
|
||
|
||
if self.is_lrc_file(f['ext']):
|
||
return {
|
||
'vod_id': f['path'],
|
||
'vod_name': f"📝 {f['name']}",
|
||
'vod_pic': self.file_icons['lyrics'],
|
||
'vod_remarks': '歌词',
|
||
'vod_tag': 'lrc',
|
||
'style': {'type': 'list'}
|
||
}
|
||
|
||
if f['ext'] == 'json':
|
||
return {
|
||
'vod_id': self.LIST_PREFIX + self.b64u_encode(f['path']),
|
||
'vod_name': f"📋 {f['name']}",
|
||
'vod_pic': self.file_icons['json'],
|
||
'vod_play_url': f"查看${self.LIST_PREFIX + self.b64u_encode(f['path'])}",
|
||
'vod_remarks': 'JSON数据',
|
||
'vod_tag': 'json',
|
||
'style': {'type': 'list'}
|
||
}
|
||
|
||
if f['ext'] == 'php':
|
||
return {
|
||
'vod_id': self.TEXT_PREFIX + self.b64u_encode(f['path']),
|
||
'vod_name': f"🐘 {f['name']}",
|
||
'vod_pic': self.file_icons['php'],
|
||
'vod_remarks': 'PHP文件',
|
||
'vod_tag': 'php',
|
||
'style': {'type': 'list'}
|
||
}
|
||
|
||
if f['ext'] == 'py':
|
||
return {
|
||
'vod_id': self.TEXT_PREFIX + self.b64u_encode(f['path']),
|
||
'vod_name': f"🐍 {f['name']}",
|
||
'vod_pic': self.file_icons['python'],
|
||
'vod_remarks': 'Python文件',
|
||
'vod_tag': 'python',
|
||
'style': {'type': 'list'}
|
||
}
|
||
|
||
if f['ext'] == 'zip':
|
||
return {
|
||
'vod_id': f['path'],
|
||
'vod_name': f"🗜️ {f['name']}",
|
||
'vod_pic': self.file_icons['zip'],
|
||
'vod_remarks': 'ZIP压缩包',
|
||
'vod_tag': 'zip',
|
||
'style': {'type': 'list'}
|
||
}
|
||
|
||
if f['ext'] in ['rar', '7z', 'tar', 'gz', 'bz2', 'xz']:
|
||
icon_key = 'rar' if f['ext'] == 'rar' else 'archive'
|
||
return {
|
||
'vod_id': f['path'],
|
||
'vod_name': f"🗜️ {f['name']}",
|
||
'vod_pic': self.file_icons[icon_key],
|
||
'vod_remarks': f'{f["ext"].upper()}压缩包',
|
||
'vod_tag': 'archive',
|
||
'style': {'type': 'list'}
|
||
}
|
||
|
||
if f['ext'] in ['m3u', 'm3u8']:
|
||
colors = ["#FF6B6B", "#4ECDC4", "#FFD93D", "#6BCB77", "#9D65C9", "#FF8C42", "#A2D729", "#FF6B8B", "#45B7D1", "#96CEB4"]
|
||
color = colors[hash(f['name']) % len(colors)]
|
||
first_char = f['name'][0].upper() if f['name'] else "M"
|
||
icon_svg = self._generate_colored_icon(color, first_char)
|
||
return {
|
||
'vod_id': self.LIST_PREFIX + self.b64u_encode(f['path']),
|
||
'vod_name': f['name'],
|
||
'vod_pic': icon_svg,
|
||
'vod_play_url': f"播放${self.LIST_PREFIX + self.b64u_encode(f['path'])}",
|
||
'vod_remarks': '直播源',
|
||
'vod_tag': 'live_m3u',
|
||
'style': {'type': 'list'}
|
||
}
|
||
|
||
if self.is_db_file(f['ext']):
|
||
return {
|
||
'vod_id': self.LIST_PREFIX + self.b64u_encode(f['path']),
|
||
'vod_name': f"🗄️ {f['name']}",
|
||
'vod_pic': self.file_icons['database'],
|
||
'vod_remarks': '数据库',
|
||
'vod_tag': 'database',
|
||
'style': {'type': 'list'}
|
||
}
|
||
|
||
if f['ext'] == 'txt':
|
||
is_live_source = False
|
||
is_novel = False
|
||
url_count = 0
|
||
content_preview = ""
|
||
try:
|
||
with open(f['path'], 'r', encoding='utf-8', errors='ignore') as ff:
|
||
content_preview = ff.read(4096)
|
||
|
||
is_live_source = self._is_txt_live_source(content_preview, f['path'])
|
||
|
||
if not is_live_source:
|
||
is_novel = self._is_txt_novel(content_preview, f['path'])
|
||
|
||
url_matches = re.findall(r'https?://[^\s\'"<>]+', content_preview)
|
||
url_count = len(url_matches)
|
||
|
||
except:
|
||
pass
|
||
|
||
if is_live_source or url_count >= 2:
|
||
colors = ["#FF6B6B", "#4ECDC4", "#FFD93D", "#6BCB77", "#9D65C9", "#FF8C42", "#A2D729", "#FF6B8B", "#45B7D1", "#96CEB4"]
|
||
color = colors[hash(f['name']) % len(colors)]
|
||
first_char = f['name'][0].upper() if f['name'] else "T"
|
||
icon_svg = self._generate_colored_icon(color, first_char)
|
||
return {
|
||
'vod_id': self.LIST_PREFIX + self.b64u_encode(f['path']),
|
||
'vod_name': f['name'],
|
||
'vod_pic': icon_svg,
|
||
'vod_play_url': f"播放${self.LIST_PREFIX + self.b64u_encode(f['path'])}",
|
||
'vod_remarks': f'直播源 ({url_count}个链接)',
|
||
'vod_tag': 'live_txt',
|
||
'style': {'type': 'list'}
|
||
}
|
||
elif is_novel:
|
||
encoded = self.b64u_encode(f['path'])
|
||
novel_url = f"{self.NOVEL_PREFIX}{encoded}"
|
||
return {
|
||
'vod_id': novel_url,
|
||
'vod_name': f"📖 {f['name']}",
|
||
'vod_pic': self.file_icons['novel'],
|
||
'vod_play_url': f"阅读${novel_url}",
|
||
'vod_remarks': '小说',
|
||
'vod_tag': 'novel',
|
||
'style': {'type': 'list'},
|
||
'vod_player': '书'
|
||
}
|
||
else:
|
||
encoded = self.b64u_encode(f['path'])
|
||
return {
|
||
'vod_id': self.TEXT_PREFIX + encoded,
|
||
'vod_name': f"📄 {f['name']}",
|
||
'vod_pic': self.file_icons['text'],
|
||
'vod_remarks': '文本文件',
|
||
'vod_tag': 'text',
|
||
'style': {'type': 'list'}
|
||
}
|
||
|
||
if f['ext'] in ['xml', 'html', 'htm', 'css', 'js', 'sh', 'bash']:
|
||
return {
|
||
'vod_id': self.TEXT_PREFIX + self.b64u_encode(f['path']),
|
||
'vod_name': f"📄 {f['name']}",
|
||
'vod_pic': self.file_icons['text'],
|
||
'vod_remarks': '代码文件',
|
||
'vod_tag': 'code',
|
||
'style': {'type': 'list'}
|
||
}
|
||
|
||
if f['ext']:
|
||
return {
|
||
'vod_id': f['path'],
|
||
'vod_name': f"📁 {f['name']}",
|
||
'vod_pic': self.file_icons['file'],
|
||
'vod_remarks': f'{f["ext"].upper()}文件',
|
||
'vod_tag': 'unknown',
|
||
'style': {'type': 'list'}
|
||
}
|
||
else:
|
||
return {
|
||
'vod_id': f['path'],
|
||
'vod_name': f"📁 {f['name']}",
|
||
'vod_pic': self.file_icons['file'],
|
||
'vod_remarks': '无扩展名',
|
||
'vod_tag': 'unknown',
|
||
'style': {'type': 'list'}
|
||
}
|
||
|
||
def action(self, action_str):
|
||
# 先让 web_action_browser 处理
|
||
result = self.web_action_browser.handle_action(action_str)
|
||
if result:
|
||
# 检查是否是 OPEN_URL 的 action 格式
|
||
if isinstance(result, dict) and 'action' in result:
|
||
return result
|
||
# 如果返回的是 {'list': [...]} 格式,返回给显示
|
||
if isinstance(result, dict) and 'list' in result:
|
||
return result
|
||
# 如果返回的是字符串,显示提示
|
||
if isinstance(result, str):
|
||
return {
|
||
'list': [{
|
||
'vod_id': 'action_result',
|
||
'vod_name': result,
|
||
'vod_pic': self._generate_colored_icon("#4CAF50", "✓"),
|
||
'vod_remarks': '操作完成',
|
||
'style': {'type': 'list'},
|
||
'vod_player': '书'
|
||
}],
|
||
'page': 1,
|
||
'pagecount': 1
|
||
}
|
||
return result
|
||
|
||
# 原有的处理逻辑
|
||
try:
|
||
if isinstance(action_str, str):
|
||
try:
|
||
obj = json.loads(action_str)
|
||
except:
|
||
if action_str.startswith(('http://', 'https://')):
|
||
return self.web_action_browser._open_url(action_str)
|
||
obj = {"action": action_str}
|
||
else:
|
||
obj = action_str
|
||
|
||
act = None
|
||
if isinstance(obj, dict):
|
||
act = obj.get('actionId', obj.get('action', ''))
|
||
value = obj.get('value', '')
|
||
else:
|
||
act = str(obj) if obj else ''
|
||
value = ''
|
||
|
||
# 处理单项输入(网址输入框)
|
||
if act == '单项输入':
|
||
if isinstance(value, dict) and "text" in value:
|
||
url = value["text"].strip()
|
||
if url:
|
||
if not url.startswith(('http://', 'https://')):
|
||
url = 'https://' + url
|
||
return self.web_action_browser._open_url(url)
|
||
elif isinstance(value, str) and value and value.strip():
|
||
url = value.strip()
|
||
if not url.startswith(('http://', 'https://')):
|
||
url = 'https://' + url
|
||
return self.web_action_browser._open_url(url)
|
||
else:
|
||
return {
|
||
'actionId': '单项输入',
|
||
'id': 'text',
|
||
'type': 'input',
|
||
'title': '🌐 访问网址',
|
||
'tip': '请输入网址,例如:www.baidu.com',
|
||
'value': '',
|
||
'msg': '请输入网址'
|
||
}
|
||
|
||
# 处理 OPEN_URL
|
||
if act == 'OPEN_URL':
|
||
url = obj.get('url', '')
|
||
if url:
|
||
return self.web_action_browser._open_url(url)
|
||
|
||
# 处理直接传入的网址字符串
|
||
if isinstance(action_str, str) and action_str.startswith(('http://', 'https://')):
|
||
return self.web_action_browser._open_url(action_str)
|
||
|
||
# 默认返回输入框
|
||
return {
|
||
'actionId': '单项输入',
|
||
'id': 'text',
|
||
'type': 'input',
|
||
'title': '🌐 访问网址',
|
||
'tip': '请输入网址',
|
||
'value': '',
|
||
'msg': '请输入网址'
|
||
}
|
||
|
||
except Exception as e:
|
||
print(f"❌ action 处理错误: {e}")
|
||
return {
|
||
'actionId': '单项输入',
|
||
'id': 'text',
|
||
'type': 'input',
|
||
'title': '🌐 访问网址',
|
||
'tip': '请输入网址',
|
||
'value': '',
|
||
'msg': '请输入网址'
|
||
}
|
||
|
||
def _handle_webview(self, url, name=""):
|
||
print(f"🌐 打开网页: {url}")
|
||
|
||
if not name:
|
||
from urllib.parse import urlparse
|
||
parsed = urlparse(url)
|
||
name = parsed.netloc.replace('www.', '') if parsed.netloc else '网页'
|
||
|
||
return self.web_action_browser._open_url(url)
|
||
|
||
def destroy(self):
|
||
self.shutdown() |