目的:
小说这种,听就行了。眼睛还要盯着屏幕打游戏的
界面:
图书的内容来自网络,仅用于测试使用。

功能:
- 转换语音时可以设定速度, 播放声音可以设定速度
- 支持文本一键粘贴 文本一键删除
- 可以导入 txt, epub, azw3, mobi 格式的中文图书
- 转换使用 edgeTTS , 四种声音选择
- 播放器 支持+/-5秒 下载,格式 mp3 文件
- QNAP NAS docker/container 部署
完整代码
目录结构:
编程时有 claudi ai 帮助,特别是 css js
25.ebooktx2speak
├── app.py # 主程度
├── kindle_handler.py # kindle 格式文件转换
├── static/
│ ├── css/style.css
│ └── js/script.js
│ └─ progress.js # 进程监控,预估完成时间还是有问题
├── templates/
│ └── index.html
├── logs/
│ └── app.log # log file
├── cert.pem # SSL certificate
├── key.pem # SSL private key
│docker 部署:
├── pkg_manager.py # docker 部署用于安装 python 库
├── Dockerfile
├── requirements.txt
└── .dockerignore
1. app.py
from quart import Quart, request, send_file, render_template, jsonify, websocket
import os
import tempfile
import logging
import io
import sys
from logging.handlers import RotatingFileHandler
from datetime import datetime
import edge_tts
import warnings
from bs4 import BeautifulSoup
import pydub
import uuid
import asyncio
import ebooklib
from ebooklib import epub
import socket
import hypercorn
import chardet
from functools import partial
from kindle_handler import KindleFormatHandler
import platform
import os
if platform.system() == 'Windows':
os.environ['PATH'] = r'C:\Program Files\Calibre2;' + os.environ['PATH']
# 创建本地临时目录
TEMP_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'temp')
os.makedirs(TEMP_DIR, exist_ok=True)
app = Quart(__name__)
app.config['STATIC_FOLDER'] = 'static'
app.config['MAX_CONTENT_LENGTH'] = 50 * 1024 * 1024
# Initialize Kindle format handler
kindle_handler = KindleFormatHandler(app.logger)
# WebSocket连接存储
websocket_connections = set()
# 创建本地临时目录
TEMP_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'temp')
os.makedirs(TEMP_DIR, exist_ok=True)
# Setup logging
if not os.path.exists('logs'):
os.makedirs('logs')
file_handler = RotatingFileHandler('logs/app.log', maxBytes=10240, backupCount=10)
file_handler.setFormatter(logging.Formatter(
'%(asctime)s %(levelname)s: %(message)s [in %(pathname)s:%(lineno)d]'
))
file_handler.setLevel(logging.INFO)
app.logger.addHandler(file_handler)
app.logger.setLevel(logging.INFO)
app.logger.info('Text-to-Speech startup')
VOICE_OPTIONS = {
'zh-CN-XiaoxiaoNeural': '小筱 (女声)',
'zh-CN-YunxiNeural': '云希 (男声)',
'zh-CN-YunyangNeural': '云扬 (男声-新闻)',
'zh-TW-HsiaoChenNeural': '曉臻 (台湾女声)'
}
async def extract_text_from_file(file_path: str, file_type: str) -> str:
"""Extract text from various file formats"""
if file_type == 'epub':
book = epub.read_epub(file_path)
paragraphs = []
for item in book.get_items():
if item.get_type() == ebooklib.ITEM_DOCUMENT:
try:
soup = BeautifulSoup(item.get_content(), 'html.parser')
for tag in soup(['script', 'style']):
tag.decompose()
content = soup.get_text('\n', strip=True)
if content:
paragraphs.append(content)
except Exception as e:
app.logger.warning(f'Error processing EPUB chapter: {str(e)}')
continue
return '\n\n'.join(paragraphs)
elif file_type in ['azw3', 'mobi']:
return await kindle_handler.extract_text_from_azw3(file_path)
else: # txt files
with open(file_path, 'rb') as f:
raw = f.read()
detected = chardet.detect(raw)
if detected['confidence'] > 0.7:
try:
return raw.decode(detected['encoding'])
except UnicodeDecodeError:
encodings = ['utf-8', 'gbk', 'gb2312', 'big5', 'utf-16']
for encoding in encodings:
try:
return raw.decode(encoding)
except UnicodeDecodeError:
continue
raise Exception('Unable to detect file encoding')
def chunk_text(text, chunk_size=2000):
chunks = []
current_chunk = ""
current_size = 0
for paragraph in text.split('\n'):
para_size = len(paragraph.encode('utf-8'))
if current_size + para_size > chunk_size:
if current_chunk:
chunks.append(current_chunk)
current_chunk = paragraph
current_size = para_size
else:
current_chunk += '\n' + paragraph if current_chunk else paragraph
current_size += para_size
if current_chunk:
chunks.append(current_chunk)
return chunks
async def convert_chunk(text, output_path, voice, speed):
communicate = edge_tts.Communicate(text, voice, rate=f"+{int((speed-1)*100)}%")
await communicate.save(output_path)
async def merge_audio_files(files):
if len(files) == 1:
return files[0]
temp_output = tempfile.mktemp(suffix='.mp3')
combined = pydub.AudioSegment.from_mp3(files[0])
for file in files[1:]:
audio_segment = pydub.AudioSegment.from_mp3(file)
combined += audio_segment
combined.export(temp_output, format='mp3')
return temp_output
@app.websocket('/ws')
async def ws():
"""WebSocket连接处理"""
try:
websocket_connections.add(websocket._get_current_object())
while True:
await websocket.receive() # Keep connection alive
finally:
websocket_connections.remove(websocket._get_current_object())
async def broadcast_progress(message):
"""向所有WebSocket连接广播进度"""
for ws in websocket_connections.copy():
try:
await ws.send_json(message)
except:
pass
async def send_progress(data):
try:
await websocket.send_json(data)
except Exception:
pass
@app.route('/')
async def index():
return await render_template('index.html')
@app.route('/languages')
async def get_languages():
return jsonify(VOICE_OPTIONS)
@app.route('/convert', methods=['POST'])
async def convert():
try:
data = await request.get_json()
text = data.get('text', '')
voice = data.get('lang', 'zh-CN-XiaoxiaoNeural')
speed = float(data.get('speed', 1.0))
if not text:
return jsonify({'error': '请输入文本'}), 400
chunks = chunk_text(text)
output_files = []
conversion_dir = os.path.join(TEMP_DIR, str(uuid.uuid4()))
os.makedirs(conversion_dir, exist_ok=True)
try:
total_chunks = len(chunks)
# 初始化进度
await broadcast_progress({
'type': 'init',
'total': total_chunks
})
# 转换每个文本块
for i, chunk in enumerate(chunks, 1):
# 更新进度
await broadcast_progress({
'type': 'progress',
'current': i,
'total': total_chunks,
'percentage': (i / total_chunks) * 100
})
temp_audio = os.path.join(conversion_dir, f'chunk_{i}.mp3')
output_files.append(temp_audio)
await convert_chunk(chunk, temp_audio, voice, speed)
# 合并进度
await broadcast_progress({
'type': 'status',
'message': '正在合并音频文件...'
})
# 合并音频文件
final_output = os.path.join(conversion_dir, 'final.mp3')
combined = pydub.AudioSegment.empty()
for audio_file in output_files:
segment = pydub.AudioSegment.from_mp3(audio_file)
combined += segment
combined.export(final_output, format='mp3')
# 完成进度
await broadcast_progress({
'type': 'complete',
'message': '转换完成'
})
return await send_file(
final_output,
mimetype='audio/mp3',
as_attachment=True,
attachment_filename=f'speech_{datetime.now().strftime("%Y%m%d_%H%M%S")}.mp3'
)
finally:
# 清理临时文件
try:
for file in output_files:
if os.path.exists(file):
os.remove(file)
os.rmdir(conversion_dir)
except:
pass
except Exception as e:
app.logger.error(f'Error during conversion: {str(e)}')
await broadcast_progress({
'type': 'error',
'message': str(e)
})
return jsonify({'error': str(e)}), 500
@app.route('/upload', methods=['POST'])
async def upload_file():
try:
files = await request.files
if 'file' not in files:
return jsonify({'error': '没有文件'}), 400
file = files['file']
if file.filename == '':
return jsonify({'error': '没有选择文件'}), 400
filename = file.filename.lower()
if not any(filename.endswith(ext) for ext in ['.txt', '.epub', '.azw3', '.mobi']):
return jsonify({'error': '不支持的文件格式'}), 400
temp_dir = tempfile.mkdtemp()
temp_path = os.path.join(temp_dir, filename)
try:
await file.save(temp_path)
if filename.endswith('.epub'):
text = await extract_text_from_file(temp_path, 'epub')
elif filename.endswith(('.azw3', '.mobi')):
text = await extract_text_from_file(temp_path, 'azw3')
else: # txt files
text = await extract_text_from_file(temp_path, 'txt')
if text is None:
raise Exception('无法提取文本内容')
return jsonify({'success': True, 'text': text})
finally:
try:
os.remove(temp_path)
os.rmdir(temp_dir)
except:
pass
except Exception as e:
app.logger.error(f'File upload error: {str(e)}')
return jsonify({'error': str(e)}), 500
if __name__ == '__main__':
# Basic setup
hostname = socket.gethostname()
local_ip = socket.gethostbyname(hostname)
# Using logger instead of print
app.logger.info("\nServer running at:")
app.logger.info(f"Local: https://127.0.0.1:9014")
app.logger.info(f"Network: https://{local_ip}:9014\n")
config = hypercorn.Config()
config.bind = ["0.0.0.0:9014"]
config.certfile = "cert.pem"
config.keyfile = "key.pem"
asyncio.run(hypercorn.asyncio.serve(app, config))
2. kindle_handler.py
import os
import tempfile
import logging
import sys
import io
import subprocess
import shutil
from pathlib import Path
from bs4 import BeautifulSoup
class KindleFormatHandler:
def __init__(self, logger=None):
self.logger = logger or self._setup_logger()
self.temp_dir = tempfile.mkdtemp()
self._setup_calibre_path()
def _setup_calibre_path(self):
if sys.platform == 'win32':
calibre_paths = [
r'C:\Program Files\Calibre2',
r'C:\Program Files (x86)\Calibre2'
]
for path in calibre_paths:
if os.path.exists(path):
os.environ['PATH'] = f"{path};{os.environ['PATH']}"
break
else:
# Linux typically has Calibre in PATH after installation
pass
def _setup_logger(self):
logger = logging.getLogger(__name__)
handler = logging.StreamHandler(io.TextIOWrapper(
sys.stdout.buffer,
encoding='utf-8',
errors='replace'
))
formatter = logging.Formatter('%(levelname)s - %(message)s')
handler.setFormatter(formatter)
logger.handlers = []
logger.addHandler(handler)
logger.setLevel(logging.INFO)
return logger
async def extract_text_from_azw3(self, file_path: str) -> str:
output_path = Path(self.temp_dir) / 'output.txt'
try:
# Convert to TXT using Calibre
cmd = ['ebook-convert', str(file_path), str(output_path)]
process = subprocess.run(
cmd,
capture_output=True,
text=True,
encoding='utf-8',
errors='replace'
)
if process.returncode != 0:
raise Exception(f"转换失败: {process.stderr}")
if output_path.exists():
text = output_path.read_text(encoding='utf-8', errors='replace')
if not text.strip():
raise Exception("提取的文本内容为空")
return self._clean_text(text)
raise Exception("转换后的文件不存在")
except FileNotFoundError:
raise Exception("请安装Calibre并确保ebook-convert可用")
except Exception as e:
self._log('ERROR', f"Kindle文件处理失败: {str(e)}")
raise
finally:
if output_path.exists():
output_path.unlink()
def _clean_text(self, text: str) -> str:
lines = text.split('\n')
cleaned = [line.strip() for line in lines if line.strip()]
return '\n'.join(cleaned)
def _log(self, level: str, message: str):
try:
if level.upper() == 'INFO':
self.logger.info(message)
elif level.upper() == 'ERROR':
self.logger.error(message)
except:
pass
def __del__(self):
try:
if hasattr(self, 'temp_dir') and os.path.exists(self.temp_dir):
shutil.rmtree(self.temp_dir)
except:
pass
3. pkg_manager.py (docker 使用)
import os
import json
from pathlib import Path
import subprocess
import sys
import argparse
import re
import time
def ensure_packaging():
"""Ensure the packaging module is installed"""
try:
subprocess.check_call([sys.executable, "-m", "pip", "install", "packaging"])
except subprocess.CalledProcessError as e:
print(f"Failed to install 'packaging' module: {e}")
sys.exit(1)
# Call ensure_packaging at the start
ensure_packaging()
# Now import packaging after ensuring it's installed
from packaging import version as packaging_version
class PackageManager:
def __init__(self):
# Check the operating system and set the base directory accordingly
if os.name == 'nt': # Windows
self.base_dir = Path(r'\\davens\Python-Packages\python-packages')
else: # Linux or other Unix-like OS
self.base_dir = Path('/share/Python-Packages/python-packages')
# Define config directory as a subdirectory of wheels_dir
self.wheels_dir = self.base_dir / 'wheels'
self.config_dir = self.base_dir / 'config'
self.packages_file = self.config_dir / 'packages.json'
# Ensure directories exist
self.wheels_dir.mkdir(parents=True, exist_ok=True)
self.config_dir.mkdir(parents=True, exist_ok=True)
# Load package information
self.load_packages()
def parse_requirements(self, requirements_path):
"""解析 requirements.txt 文件"""
requirements = []
try:
# First try UTF-8
try:
with open(requirements_path, 'r', encoding='utf-8') as f:
for line in f:
# 去除注释和空白
line = line.strip()
if line and not line.startswith('#'):
# 处理行内注释
line = line.split('#')[0].strip()
requirements.append(line)
except UnicodeDecodeError:
# If UTF-8 fails, try GB18030
with open(requirements_path, 'r', encoding='gb18030') as f:
for line in f:
line = line.strip()
if line and not line.startswith('#'):
line = line.split('#')[0].strip()
requirements.append(line)
except Exception as e:
print(f"读取 requirements.txt 失败: {e}")
return []
return requirements
def is_builtin_module(self, module_name):
"""检查是否是Python内置模块"""
try:
import importlib
importlib.import_module(module_name)
return True
except ImportError:
return False
def load_packages(self):
"""加载包配置"""
if self.packages_file.exists():
try:
with open(self.packages_file, 'r', encoding='utf-8') as f:
self.packages = json.load(f)
except (json.JSONDecodeError, FileNotFoundError) as e:
print(f"配置文件读取错误: {e}")
self.create_default_config()
else:
self.create_default_config()
def create_default_config(self):
"""创建默认配置"""
self.packages = {
'base': {
'flask': '*', # '*' 表示最新版本
'numpy': '*'
},
'data_science': {
'pandas': '*',
'matplotlib': '*',
'scipy': '*',
'scikit-learn': '*'
}
}
self.save_packages()
def save_packages(self):
"""保存包配置"""
try:
# 确保配置目录存在
self.config_dir.mkdir(parents=True, exist_ok=True)
with open(self.packages_file, 'w', encoding='utf-8') as f:
json.dump(self.packages, f, indent=4, ensure_ascii=False)
except Exception as e:
print(f"保存配置文件时出错: {e}")
def parse_package_version(self, package_str):
"""解析包名和版本号"""
match = re.match(r'^([\w-]+)(==|>=|<=|>|<|!=|~=|===)?(.+)?$', package_str)
if match:
name, operator, version_str = match.groups()
if operator and version_str:
return name, f"{operator}{version_str}"
return name, '*'
return package_str, '*'
def download_package(self, package_name, version_spec='*', offline=False): # Added offline parameter
"""下载特定版本的包
Args:
package_name (str): 包名
version_spec (str): 版本说明符,默认为'*'(最新版本)
offline (bool): 是否离线模式,默认False
Returns:
bool: 下载是否成功
"""
# 检查是否是内置模块
if self.is_builtin_module(package_name):
print(f"'{package_name}' 是Python内置模块,不需要安装。")
return False
try:
if version_spec == '*':
spec = package_name
else:
spec = f"{package_name}{version_spec}"
# 首先检查本地是否已有该包的wheel文件
existing_versions = self.list_wheel_versions(package_name)
if existing_versions:
if version_spec == '*':
print(f"在NAS上找到包 {package_name} 的以下版本:")
for ver in sorted(existing_versions.keys(),
key=lambda x: packaging_version.parse(x),
reverse=True):
print(f" - {ver}")
return True
else:
# 检查是否有匹配的版本
requested_version = version_spec.replace('==', '')
if requested_version in existing_versions:
print(f"在NAS上找到包 {package_name}=={requested_version}")
return True
if offline:
print(f"离线模式:无法下载包 {spec}")
print("可用的本地版本:")
if existing_versions:
for ver in sorted(existing_versions.keys()):
print(f" - {ver}")
else:
print(" 没有找到本地版本")
return False
print(f"在NAS上未找到包 {spec},尝试从PyPI下载...")
# 构建pip命令
cmd = [
sys.executable,
"-m",
"pip",
"wheel",
spec,
"--wheel-dir",
str(self.wheels_dir)
]
# 执行下载
try:
result = subprocess.run(
cmd,
check=True,
capture_output=True,
text=True,
timeout=300 # 5分钟超时
)
print(f"下载成功: {spec}")
return True
except subprocess.TimeoutExpired:
print(f"下载超时: {spec}")
return False
except subprocess.CalledProcessError as e:
error_msg = e.stderr if e.stderr else str(e)
# 检查是否是包不存在的错误
if "Could not find a version that satisfies the requirement" in error_msg:
print(f"错误: 包 '{package_name}' 在PyPI上不存在")
else:
print(f"下载失败: {spec}")
print(f"错误信息: {error_msg}")
return False
except Exception as e:
print(f"下载过程出错: {e}")
return False
def add_package(self, group, package_str):
"""添加包到组"""
name, version = self.parse_package_version(package_str)
# 检查是否是内置模块
if self.is_builtin_module(name):
print(f"'{name}' 是Python内置模块,不需要添加到包组。")
return False
if group not in self.packages:
self.packages[group] = {}
# 尝试下载包
if self.download_package(name, version, offline=False): # Added offline parameter
self.packages[group][name] = version
self.save_packages()
return True
return False
def install_package(self, package_str, python_env=None):
"""Install a single package from NAS to Python environment"""
name, version = self.parse_package_version(package_str)
# Create temporary requirements file
temp_req = self.config_dir / f'temp_{name}_requirements.txt'
try:
# Write package info to temp file
with open(temp_req, 'w', encoding='utf-8') as f:
if version == '*':
f.write(f"{name}\n")
else:
f.write(f"{name}{version}\n")
# Install using temp file
success = self.install_from_requirements(temp_req, python_env)
# Clean up temp file
temp_req.unlink()
return success
except Exception as e:
print(f"Installation error: {e}")
if temp_req.exists():
temp_req.unlink()
return False
def clean_version(self, version_str):
"""清理版本号字符串"""
# 移除末尾的点和其他非法字符
cleaned = re.sub(r'[.]+$', '', version_str)
# 只保留数字和点
cleaned = re.sub(r'[^0-9.]', '', cleaned)
return cleaned
def normalize_package_name(self, package_name):
"""标准化包名,处理横线和下划线"""
return package_name.replace('-', '_').lower()
def list_wheel_versions(self, package_name):
"""列出某个包的所有可用版本 (处理横线和下划线)"""
versions = {}
# 标准化包名
normalized_name = self.normalize_package_name(package_name)
# 获取所有wheel文件
all_wheels = list(self.wheels_dir.glob('*.whl'))
# 改进版本号提取模式
pattern = re.compile(f"{normalized_name}-([\\d.]+)[^/\\\\]*\\.whl", re.IGNORECASE)
for wheel in all_wheels:
try:
# 标准化wheel文件名
wheel_name_lower = wheel.name.lower()
if normalized_name in wheel_name_lower.replace('-', '_'):
match = pattern.match(wheel_name_lower)
if match:
ver = self.clean_version(match.group(1))
try:
# Use packaging_version instead of version
packaging_version.parse(ver)
versions[ver] = wheel
except packaging_version.InvalidVersion:
print(f"警告:跳过无效版本号 {ver} ({wheel.name})")
except Exception as e:
print(f"警告:处理 {wheel.name} 时出错: {e}")
return versions
def check_versions(self, package_name=None):
"""检查已下载的包版本"""
if package_name:
versions = self.list_wheel_versions(package_name)
if versions:
print(f"\n{package_name} 的可用版本:")
try:
sorted_versions = sorted(versions.keys(),
key=lambda x: packaging_version.parse(x),
reverse=True)
for ver in sorted_versions:
print(f" - {ver}")
except Exception as e:
print(f"警告:版本排序出错: {e}")
# 如果排序失败,至少显示找到的版本
for ver in versions.keys():
print(f" - {ver}")
else:
print(f"\n没有找到 {package_name} 的wheel文件")
else:
# 检查所有包
all_wheels = list(self.wheels_dir.glob('*.whl'))
packages = {}
for wheel in all_wheels:
try:
name_ver = wheel.name.split('-')
if len(name_ver) >= 2:
name = name_ver[0].lower()
if name not in packages:
packages[name] = set()
# 提取并清理版本号
ver_match = re.search(r'-([0-9.]+)', wheel.name)
if ver_match:
clean_ver = self.clean_version(ver_match.group(1))
try:
# 验证版本号格式
packaging_version.parse(clean_ver)
packages[name].add(clean_ver)
except packaging_version.InvalidVersion:
continue
except Exception as e:
print(f"警告:处理 {wheel.name} 时出错: {e}")
print("\n所有包的可用版本:")
for pkg, versions in sorted(packages.items()):
print(f"\n{pkg}:")
try:
sorted_versions = sorted(versions,
key=lambda x: packaging_version.parse(x),
reverse=True)
for ver in sorted_versions:
print(f" - {ver}")
except Exception as e:
print(f"警告:{pkg} 的版本排序出错: {e}")
for ver in versions:
print(f" - {ver}")
def download_from_requirements(self, requirements_path, group=None):
"""从 requirements.txt 文件下载包"""
if not Path(requirements_path).exists():
print(f"找不到文件: {requirements_path}")
return
requirements = self.parse_requirements(requirements_path)
if not requirements:
print("没有找到有效的包信息")
return
print(f"从 {requirements_path} 中发现 {len(requirements)} 个包")
for req in requirements:
try:
# 下载包
print(f"正在处理: {req}")
name, version = self.parse_package_version(req)
if self.download_package(name, version):
# 如果指定了组,将包添加到配置中
if group:
if group not in self.packages:
self.packages[group] = {}
self.packages[group][name] = version
except Exception as e:
print(f"处理包 {req} 时出错: {e}")
# 如果指定了组,保存更新后的配置
if group:
self.save_packages()
def install_from_requirements(self, requirements_path, python_env=None, auto_download=True):
"""安装 requirements.txt 中的包到指定环境"""
if not Path(requirements_path).exists():
print(f"找不到文件: {requirements_path}")
return False
try:
# 读取并解析requirements
requirements = self.parse_requirements(requirements_path)
if not requirements:
print("没有找到有效的包信息")
return False
# 首先检查并下载缺失的包
if auto_download:
print("\n检查并下载缺失的包...")
for req in requirements:
name, ver_spec = self.parse_package_version(req)
available_versions = self.list_wheel_versions(name)
if not available_versions:
print(f" 正在下载缺失的包: {name}")
if not self.download_package(name, ver_spec):
print(f" 警告: 包 {name} 下载失败")
else:
print(f" 包 {name} 下载成功,等待5秒确保文件写入完成...")
time.sleep(5) # 等待文件系统同步
# 创建临时文件,用于写入解析后的具体版本
temp_req = self.config_dir / f'temp_requirements_{int(time.time())}.txt'
try:
# 处理每个包,确定具体版本
resolved_requirements = []
print("\n解析包版本信息:")
# 列出所有wheel文件用于调试
print("\n当前wheels目录中的文件:")
for wheel in self.wheels_dir.glob('*.whl'):
print(f" - {wheel.name}")
for req in requirements:
name, ver_spec = self.parse_package_version(req)
available_versions = self.list_wheel_versions(name)
if not available_versions:
# 检查标准化的包名
normalized_name = self.normalize_package_name(name)
available_versions = self.list_wheel_versions(normalized_name)
if not available_versions:
print(f"错误: 在wheels目录中找不到包 {name}")
print(f"建议: 尝试手动下载此包:")
print(f"python pkg_manager.py download {name}")
return False
if ver_spec == '*': # 没有指定版本
# 获取最新版本
latest_version = sorted(available_versions.keys(),
key=lambda x: packaging_version.parse(x),
reverse=True)[0]
resolved_req = f"{name}=={latest_version}"
print(f" {name}: 使用最新版本 {latest_version}")
else:
# 检查指定版本是否可用
requested_version = ver_spec.replace('==', '')
if requested_version not in available_versions:
print(f"错误: 找不到包 {name} 的指定版本 {requested_version}")
print(f"可用版本: {', '.join(sorted(available_versions.keys(), key=lambda x: packaging_version.parse(x), reverse=True))}")
return False
resolved_req = f"{name}=={requested_version}"
print(f" {name}: 使用指定版本 {requested_version}")
resolved_requirements.append(resolved_req)
# 写入解析后的版本到临时文件
print("\n将使用以下具体版本进行安装:")
with open(temp_req, 'w', encoding='utf-8') as f:
for req in resolved_requirements:
print(f" {req}")
f.write(f"{req}\n")
cmd = []
if python_env:
if os.name == 'nt':
cmd = [str(Path(python_env) / 'python.exe')]
else:
cmd = [str(Path(python_env) / 'bin' / 'python')]
else:
cmd = [sys.executable]
cmd.extend([
"-m",
"pip",
"install",
"-r",
str(temp_req),
"--find-links",
str(self.wheels_dir),
"--no-index"
])
print(f"\n执行安装命令: {' '.join(cmd)}")
result = subprocess.run(cmd, capture_output=True, text=True, encoding='utf-8')
if result.returncode != 0:
print(f"安装失败. 错误信息:")
print(result.stderr)
return False
print("\n安装成功完成")
return True
finally:
if temp_req.exists():
temp_req.unlink()
except Exception as e:
print(f"安装过程出错: {e}")
return False
def use_group(self, group_name, python_env=None):
"""安装指定组的所有包到Python环境"""
if group_name not in self.packages:
print(f"找不到组: {group_name}")
return False
print(f"开始安装 {group_name} 组的包...")
# 创建临时的requirements文件
temp_req = self.config_dir / f'temp_{group_name}_requirements.txt'
try:
# 写入包信息到临时文件
with open(temp_req, 'w', encoding='utf-8') as f:
for pkg, ver in self.packages[group_name].items():
if ver == '*':
f.write(f"{pkg}\n")
else:
f.write(f"{pkg}{ver}\n")
# 使用临时文件安装包
success = self.install_from_requirements(temp_req, python_env)
# 清理临时文件
temp_req.unlink()
if success:
print(f"{group_name} 组的包安装完成")
else:
print(f"{group_name} 组的包安装出现错误")
return success
except Exception as e:
print(f"安装过程出错: {e}")
if temp_req.exists():
temp_req.unlink()
return False
def list_groups(self):
"""列出所有可用的包组及其包含的包"""
print("\n可用的包组:")
for group, packages in self.packages.items():
print(f"\n{group}:")
for pkg, ver in packages.items():
if ver == '*':
print(f" - {pkg} (最新版本)")
else:
print(f" - {pkg}{ver}")
def main():
parser = argparse.ArgumentParser(description="包管理器(支持版本控制)")
subparsers = parser.add_subparsers(dest="command", help="可用命令")
# list-versions命令
list_versions_parser = subparsers.add_parser('list-versions', help='查看NAS里所有的安装包(版本号)')
list_versions_parser.add_argument('package', nargs='?', help='包名(可选)')
# add命令
add_parser = subparsers.add_parser('add', help='向NAS添加安装包')
add_parser.add_argument('group', help='目标包组')
add_parser.add_argument('package', help='包名[==版本号]')
# download命令
download_parser = subparsers.add_parser('download', help='从NAS下载安装包')
download_parser.add_argument('package', help='包名[==版本号]')
download_parser.add_argument('--offline', action='store_true', help='离线模式,只使用NAS上现有的包')
# requirements相关命令
req_download_parser = subparsers.add_parser('download-req', help='使用requirements.txt从官网下载安装包')
req_download_parser.add_argument('requirements', help='requirements.txt文件路径')
req_download_parser.add_argument('--group', help='可选:将包添加到指定组')
req_install_parser = subparsers.add_parser('install-req', help='使用requirements.txt从NAS下载安装包')
req_install_parser.add_argument('requirements', help='requirements.txt文件路径')
req_install_parser.add_argument('--env', help='可选:指定Python环境路径')
# install-group命令
use_group_parser = subparsers.add_parser('install-group', help='安装指定组名的安装包群')
use_group_parser.add_argument('group', help='包组名称')
use_group_parser.add_argument('--env', help='可选:指定Python环境路径')
# list-groups命令
subparsers.add_parser('list-groups', help='列出NAS里所有可用的(安装包)组')
#install command
install_parser = subparsers.add_parser('install', help='Install a package from NAS to Python environment')
install_parser.add_argument('package', help='Package name[==version]')
install_parser.add_argument('--env', help='Optional: Specify Python environment path')
args = parser.parse_args()
manager = PackageManager()
try:
if args.command == 'list-versions':
manager.check_versions(args.package)
elif args.command == 'add':
manager.add_package(args.group, args.package)
elif args.command == 'download':
name, version = manager.parse_package_version(args.package)
manager.download_package(name, version,
offline=getattr(args, 'offline', False))
elif args.command == 'download-req':
manager.download_from_requirements(args.requirements, args.group)
elif args.command == 'install-req':
manager.install_from_requirements(args.requirements, args.env)
elif args.command == 'install-group':
manager.use_group(args.group, args.env)
elif args.command == 'list-groups':
manager.list_groups()
elif args.command == 'install':
manager.install_package(args.package, args.env)
else:
parser.print_help()
except Exception as e:
print(f"执行命令时出错: {e}")
sys.exit(1)
if __name__ == '__main__':
main()
4. templates/index.html
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>文本转语音工具</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
<link rel="icon" type="image/jpeg" href="{{ url_for('static', filename='favicon.jpg') }}">
<script src="{{ url_for('static', filename='js/progress.js') }}"></script>
</head>
<body>
<div class="container">
<div class="header">
<h1>文本转语音工具</h1>
<div id="clock" class="clock"></div>
</div>
<div class="controls-panel">
<div class="controls-top">
<select id="languageSelect" class="language-select" aria-label="选择语音">
<option value="zh-CN-XiaoxiaoNeural">小筱 (女声)</option>
<option value="zh-CN-YunxiNeural">云希 (男声)</option>
<option value="zh-CN-YunyangNeural">云扬 (男声-新闻)</option>
<option value="zh-TW-HsiaoChenNeural">曉臻 (台湾女声)</option>
</select>
<div class="speed-control">
<span>转换速度:</span>
<input type="range" id="speedRange" min="0.5" max="2.0" step="0.1" value="1.0">
<span id="speedValue">1.0</span>x
</div>
<div class="playback-speed-control">
<span>播放速度:</span>
<input type="range" id="playbackSpeedRange" min="0.5" max="2.0" step="0.1" value="1.0">
<span id="playbackSpeedValue">1.0</span>x
</div>
</div>
</div>
<div class="textarea-container">
<div class="button-group">
<div class="file-inputs">
<input type="file" id="textFileInput" accept=".txt" style="display: none;">
<input type="file" id="epubFileInput" accept=".epub" style="display: none;">
<input type="file" id="kindleFileInput" accept=".azw3,.mobi" style="display: none;">
</div>
<div class="upload-dropdown">
<div id="uploadBtn">📄</div>
<div class="upload-menu">
<div id="uploadTextBtn">文本文件 (.txt)</div>
<div id="uploadEpubBtn">电子书 (.epub)</div>
<div id="uploadKindleBtn">Kindle电子书 (.azw3, .mobi)</div>
</div>
</div>
<div id="pasteBtn">📋</div>
<div id="clearBtn">✕</div>
</div>
<textarea id="textInput" placeholder="在此输入或粘贴文本,支持拖放文件"></textarea>
</div>
<div id="conversionProgress" class="conversion-progress"></div>
<div class="stats">
<div class="timer">
转换用时: <span id="timer">0.0</span>秒
</div>
<div class="char-count">
字数: <span id="charCount">0</span>
</div>
<div class="speed">
转换速度: <span id="conversionSpeed">0</span>字/秒
</div>
<div class="estimated-time">
预计: <span id="estimatedTime">-</span>
</div>
</div>
<div class="controls">
<button id="convertBtn">转换为语音</button>
<audio id="audioPlayer" controls style="display: none;"></audio>
</div>
<div class="shortcuts-info">
<div class="shortcut-item">空格: 播放/暂停</div>
<div class="shortcut-item">← →: 快退/快进5秒</div>
<div class="shortcut-item">Ctrl+L: 清空内容</div>
<div class="shortcut-item">Ctrl+V: 粘贴文本</div>
</div>
</div>
<script src="{{ url_for('static', filename='js/script.js') }}"></script>
</body>
</html>
5. static/js/script.js
document.addEventListener('DOMContentLoaded', function() {
const elements = {
textInput: document.getElementById('textInput'),
convertBtn: document.getElementById('convertBtn'),
clearBtn: document.getElementById('clearBtn'),
audioPlayer: document.getElementById('audioPlayer'),
languageSelect: document.getElementById('languageSelect'),
speedRange: document.getElementById('speedRange'),
speedValue: document.getElementById('speedValue'),
playbackSpeedRange: document.getElementById('playbackSpeedRange'),
playbackSpeedValue: document.getElementById('playbackSpeedValue'),
clockElement: document.getElementById('clock'),
timerElement: document.getElementById('timer'),
charCountElement: document.getElementById('charCount'),
conversionSpeedElement: document.getElementById('conversionSpeed'),
estimatedTimeElement: document.getElementById('estimatedTime'),
textFileInput: document.getElementById('textFileInput'),
epubFileInput: document.getElementById('epubFileInput'),
uploadBtn: document.getElementById('uploadBtn'),
uploadTextBtn: document.getElementById('uploadTextBtn'),
uploadEpubBtn: document.getElementById('uploadEpubBtn'),
uploadMenu: document.querySelector('.upload-menu'),
progressContainer: document.getElementById('conversionProgress'),
pasteBtn: document.getElementById('pasteBtn'),// 粘贴按钮
kindleFileInput: document.getElementById('kindleFileInput'),
uploadKindleBtn: document.getElementById('uploadKindleBtn'),
};
const metrics = {
standardSpeed: 270,
history: []
};
const progressTracker = new ConversionProgress();
if (elements.progressContainer) {
elements.progressContainer.appendChild(progressTracker.container);
}
const fileInputHandlers = [
{ btn: elements.uploadTextBtn, input: elements.textFileInput },
{ btn: elements.uploadEpubBtn, input: elements.epubFileInput },
{ btn: elements.uploadKindleBtn, input: elements.kindleFileInput }
];
// 更新时钟
function updateClock() {
const now = new Date();
elements.clockElement.textContent = now.toLocaleTimeString('zh-CN');
}
setInterval(updateClock, 1000);
updateClock();
// 计算中文字符数
function countChineseChars(text) {
return (text.match(/[\u4e00-\u9fa5]/g) || []).length;
}
// 格式化时间
function formatDuration(seconds) {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}分${secs}秒`;
}
fileInputHandlers.forEach(({ btn, input }) => {
if (btn && input) {
btn.addEventListener('click', (e) => {
e.preventDefault(); // 添加这行
e.stopPropagation();
input.click();
elements.uploadMenu.classList.remove('show');
});
// 添加 change 事件监听器
input.addEventListener('change', async (e) => {
const file = e.target.files[0];
if (file) await handleFile(file);
});
}
});
// 文件上传菜单
if (elements.uploadBtn && elements.uploadMenu) {
elements.uploadBtn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
elements.uploadMenu.classList.toggle('show');
});
document.addEventListener('click', () => {
elements.uploadMenu.classList.remove('show');
});
elements.uploadMenu.addEventListener('click', (e) => {
e.stopPropagation();
});
}
// 文件处理验证函数
async function handleFile(file) {
if (!file) return;
if (file.size > 50 * 1024 * 1024) {
alert('文件太大,请上传小于50MB的文件');
return;
}
const extension = file.name.toLowerCase().slice(file.name.lastIndexOf('.'));
const validExtensions = ['.txt', '.epub', '.azw3', '.mobi'];
if (!validExtensions.includes(extension)) {
alert('请上传 txt、epub、azw3 或 mobi 格式的文件');
return;
}
try {
elements.uploadBtn.classList.add('loading');
elements.uploadBtn.disabled = true;
const formData = new FormData();
formData.append('file', file);
const response = await fetch('/upload', {
method: 'POST',
body: formData
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || '上传失败');
}
const result = await response.json();
if (!result.text || result.text.trim().length === 0) {
throw new Error('提取的文本内容为空');
}
elements.textInput.value = result.text;
updateStats();
elements.textInput.scrollTop = 0;
} catch (error) {
console.error('处理文件失败:', error);
alert(`处理文件失败: ${error.message}`);
} finally {
elements.uploadBtn.classList.remove('loading');
elements.uploadBtn.disabled = false;
fileInputHandlers.forEach(({ input }) => {
if (input) input.value = '';
});
}
}
// 添加粘贴按钮点击事件
if (elements.pasteBtn) {
elements.pasteBtn.addEventListener('click', async () => {
try {
const text = await navigator.clipboard.readText();
if (text) {
elements.textInput.value = text;
updateStats();
elements.textInput.scrollTop = 0;
}
} catch (err) {
console.error('Failed to read clipboard:', err);
alert('无法访问剪贴板。请确保已授予剪贴板访问权限。');
}
});
}
// 拖放功能
elements.textInput.addEventListener('dragover', (e) => {
e.preventDefault();
e.stopPropagation();
elements.textInput.style.borderColor = '#4CAF50';
});
elements.textInput.addEventListener('dragleave', (e) => {
e.preventDefault();
e.stopPropagation();
elements.textInput.style.borderColor = '#ddd';
});
elements.textInput.addEventListener('drop', async (e) => {
e.preventDefault();
e.stopPropagation();
elements.textInput.classList.remove('drag-over');
const file = e.dataTransfer.files[0];
if (file) {
const fileType = getFileType(file.name);
if (fileType) {
await handleFile(file);
} else {
alert('不支持的文件格式。请上传 txt、epub、azw3 或 mobi 格式的文件。');
}
}
});
// 添加 Ctrl+V 快捷键支持
elements.textInput.addEventListener('paste', () => {
// 使用 setTimeout 确保在粘贴内容后更新统计信息
setTimeout(() => {
updateStats();
}, 0);
});
// 更新统计信息
function updateStats() {
const charCount = countChineseChars(elements.textInput.value);
elements.charCountElement.textContent = charCount;
if (charCount > 0) {
const estimatedSeconds = Math.ceil(charCount / metrics.standardSpeed);
elements.estimatedTimeElement.textContent = formatDuration(estimatedSeconds);
} else {
elements.estimatedTimeElement.textContent = '-';
}
}
// 清除按钮功能
elements.clearBtn.addEventListener('click', function() {
elements.textInput.value = '';
elements.audioPlayer.style.display = 'none';
elements.timerElement.textContent = '0.0';
elements.conversionSpeedElement.textContent = '0';
elements.estimatedTimeElement.textContent = '-';
progressTracker.reset();
updateStats();
});
// 文本输入监听
elements.textInput.addEventListener('input', function() {
currentCharCount = countChineseChars(this.value);
elements.charCountElement.textContent = currentCharCount;
// 更新预估时间
if (currentCharCount > 0) {
const estimatedTime = Math.ceil(currentCharCount / 270); // 使用基准速度270字/秒
elements.estimatedTimeElement.textContent = formatDuration(estimatedTime);
} else {
elements.estimatedTimeElement.textContent = '-';
}
});
// 速度控制
elements.speedRange.addEventListener('input', function() {
elements.speedValue.textContent = this.value;
});
elements.playbackSpeedRange.addEventListener('input', function() {
elements.playbackSpeedValue.textContent = this.value;
if(elements.audioPlayer.src) {
elements.audioPlayer.playbackRate = parseFloat(this.value);
}
});
let startTime;
let progressInterval;
let currentCharCount = 0;
function updateSpeed() {
if (!startTime) return;
const elapsed = (Date.now() - startTime) / 1000;
const charCount = currentCharCount;
const speed = Math.round(charCount / elapsed);
elements.conversionSpeedElement.textContent = speed;
// 更新预计剩余时间
const remainingChars = charCount - (speed * elapsed);
if (speed > 0 && remainingChars > 0) {
const remainingTime = Math.ceil(remainingChars / speed);
elements.estimatedTimeElement.textContent = formatDuration(remainingTime);
}
}
// 优化 handleFile 函数的文件类型检查
function getFileType(filename) {
const ext = filename.toLowerCase().slice(filename.lastIndexOf('.'));
const typeMap = {
'.txt': 'text',
'.epub': 'epub',
'.azw3': 'kindle',
'.mobi': 'kindle'
};
return typeMap[ext] || null;
}
// 转换按钮点击事件
elements.convertBtn.addEventListener('click', async function() {
const text = elements.textInput.value.trim();
if (!text) {
alert('请输入要转换的文本');
return;
}
elements.convertBtn.disabled = true;
elements.convertBtn.textContent = '转换中...';
currentCharCount = countChineseChars(text);
startTime = Date.now();
// 启动速度更新定时器
progressInterval = setInterval(() => {
const elapsed = (Date.now() - startTime) / 1000;
elements.timerElement.textContent = elapsed.toFixed(1);
updateSpeed();
}, 1000);
try {
const response = await fetch('/convert', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
text: text,
lang: elements.languageSelect.value,
speed: parseFloat(elements.speedRange.value)
})
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || '转换失败');
}
const blob = await response.blob();
const audioUrl = URL.createObjectURL(blob);
elements.audioPlayer.src = audioUrl;
elements.audioPlayer.style.display = 'block';
elements.audioPlayer.playbackRate = parseFloat(elements.playbackSpeedRange.value);
elements.audioPlayer.play();
// 最终更新速度
const elapsed = (Date.now() - startTime) / 1000;
const finalSpeed = Math.round(currentCharCount / elapsed);
elements.conversionSpeedElement.textContent = finalSpeed;
} catch (error) {
console.error('转换失败:', error);
alert('转换失败: ' + error.message);
} finally {
clearInterval(progressInterval);
elements.convertBtn.disabled = false;
elements.convertBtn.textContent = '转换为语音';
}
});
// 更新计时器函数
function startTimer() {
const startTime = Date.now();
progressInterval = setInterval(() => {
const elapsed = (Date.now() - startTime) / 1000;
elements.timerElement.textContent = elapsed.toFixed(1);
}, 100);
return startTime;
}
// 键盘快捷键
document.addEventListener('keydown', function(e) {
if (e.ctrlKey && e.key === 'l') {
e.preventDefault();
elements.clearBtn.click();
return;
}
if (elements.audioPlayer.style.display !== 'none') {
if (e.code === 'Space' && !elements.textInput.matches(':focus')) {
e.preventDefault();
elements.audioPlayer.paused ? elements.audioPlayer.play() : elements.audioPlayer.pause();
} else if (e.code === 'ArrowLeft') {
elements.audioPlayer.currentTime -= 5;
} else if (e.code === 'ArrowRight') {
elements.audioPlayer.currentTime += 5;
}
}
});
// 页面关闭前清理
window.addEventListener('beforeunload', function() {
if (elements.audioPlayer.src) {
URL.revokeObjectURL(elements.audioPlayer.src);
}
});
});
6. static/js/process.js
class ConversionProgress {
constructor() {
this.container = document.createElement('div');
this.container.className = 'conversion-progress';
this.chunks = [];
this.setupStyles();
}
setupStyles() {
const style = document.createElement('style');
style.textContent = `
.conversion-progress {
margin: 10px 0;
padding: 10px;
border: 1px solid #ddd;
border-radius: 4px;
background: #f9f9f9;
display: none;
}
.chunk-status {
margin: 5px 0;
font-size: 14px;
display: flex;
align-items: center;
gap: 10px;
}
.progress-bar {
flex-grow: 1;
height: 8px;
background: #eee;
border-radius: 4px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: #4CAF50;
width: 0%;
transition: width 0.3s;
}
.summary {
margin-bottom: 10px;
padding-bottom: 5px;
border-bottom: 1px solid #eee;
font-weight: bold;
display: flex;
justify-content: space-between;
align-items: center;
}
.overall-progress {
font-size: 14px;
color: #666;
}
.merging-status {
margin-top: 10px;
padding-top: 5px;
border-top: 1px solid #eee;
color: #666;
}
.chunk-label {
min-width: 60px;
}
.chunk-info {
min-width: 80px;
text-align: right;
}
.error-message {
color: #dc3545;
margin-top: 5px;
font-size: 14px;
}
.complete-message {
color: #28a745;
font-weight: bold;
}
`;
document.head.appendChild(style);
}
show() {
this.container.style.display = 'block';
}
hide() {
this.container.style.display = 'none';
}
initProgress(totalChunks) {
this.chunks = Array(totalChunks).fill(0);
this.container.innerHTML = `
<div class="summary">
<span>处理进度:共${totalChunks}个文本块</span>
<span class="overall-progress">总进度: <span id="overall-percent">0</span>%</span>
</div>
${this.chunks.map((_, i) => `
<div class="chunk-status">
<span class="chunk-label">块 ${i + 1}</span>
<div class="progress-bar">
<div class="progress-fill" id="chunk-${i}"></div>
</div>
<span class="chunk-info" id="chunk-${i}-status">等待中</span>
</div>
`).join('')}
<div class="merging-status" id="merging-status"></div>
`;
this.show();
return this.container;
}
updateChunkProgress(index, percent, status = '') {
if (index >= this.chunks.length) return;
const fill = document.getElementById(`chunk-${index}`);
const statusEl = document.getElementById(`chunk-${index}-status`);
if (fill) fill.style.width = `${percent}%`;
if (statusEl) statusEl.textContent = status || `${percent}%`;
this.chunks[index] = percent;
this.updateOverallProgress();
}
updateMergingStatus(status, isError = false) {
const mergingStatus = document.getElementById('merging-status');
if (mergingStatus) {
mergingStatus.className = `merging-status ${isError ? 'error-message' : ''}`;
mergingStatus.textContent = status;
}
}
updateOverallProgress() {
const overallPercent = document.getElementById('overall-percent');
if (overallPercent) {
overallPercent.textContent = this.getOverallProgress();
}
}
getOverallProgress() {
if (this.chunks.length === 0) return 0;
return Math.round(this.chunks.reduce((a, b) => a + b, 0) / this.chunks.length);
}
setError(message) {
const errorDiv = document.createElement('div');
errorDiv.className = 'error-message';
errorDiv.textContent = message;
this.container.appendChild(errorDiv);
}
setComplete() {
this.updateMergingStatus('转换完成!', false);
const completeDiv = document.createElement('div');
completeDiv.className = 'complete-message';
completeDiv.textContent = '所有文本块处理完成';
this.container.appendChild(completeDiv);
}
reset() {
this.chunks = [];
this.container.innerHTML = '';
this.hide();
}
}
7.static/css/style.css
/* 基础重置 */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
line-height: 1.5;
background: #f5f5f5;
color: #333;
}
/* 主容器 */
.container {
max-width: 800px;
margin: 20px auto;
padding: 20px;
background: #fff;
box-shadow: 0 0 10px rgba(0,0,0,0.1);
border-radius: 8px;
}
/* 标题区域 */
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding-bottom: 15px;
border-bottom: 1px solid #eee;
}
.header h1 {
margin: 0;
font-size: 24px;
color: #333;
}
.clock {
font-size: 18px;
color: #666;
font-family: monospace;
}
/* 控制面板 */
.controls-panel {
margin-bottom: 20px;
padding: 15px;
background: #f8f9fa;
border-radius: 6px;
}
.controls-top {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 15px;
}
/* 语言选择 */
#languageSelect {
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
background: #fff;
min-width: 150px;
cursor: pointer;
font-size: 14px;
}
#languageSelect:hover {
border-color: #4CAF50;
}
/* 速度控制 */
.speed-control, .playback-speed-control {
display: flex;
align-items: center;
gap: 10px;
font-size: 14px;
}
input[type="range"] {
width: 120px;
height: 6px;
background: #ddd;
border-radius: 3px;
appearance: none;
cursor: pointer;
}
input[type="range"]::-webkit-slider-thumb {
appearance: none;
width: 16px;
height: 16px;
background: #4CAF50;
border-radius: 50%;
cursor: pointer;
}
/* 文本区域 */
.textarea-container {
margin-bottom: 20px;
}
/* 按钮组 */
.button-group {
display: flex;
gap: 10px;
margin-bottom: 10px;
}
.button-group > div {
padding: 8px 12px;
cursor: pointer;
border-radius: 4px;
background: #f8f9fa;
transition: background-color 0.2s;
font-size: 16px;
}
.button-group > div:hover {
background: #e9ecef;
}
/* 文件输入 */
.file-inputs {
display: none;
}
/* 上传下拉菜单 */
.upload-dropdown {
position: relative;
}
.upload-menu {
display: none;
position: absolute;
top: 100%;
left: 0;
min-width: 200px;
background: white;
border: 1px solid #ddd;
border-radius: 4px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
z-index: 1000;
margin-top: 5px;
}
.upload-menu.show {
display: block;
}
.upload-menu div {
padding: 10px 15px;
cursor: pointer;
transition: background-color 0.2s;
font-size: 14px;
}
.upload-menu div:hover {
background: #f5f5f5;
}
/* 文本输入区域 */
#textInput {
width: 100%;
min-height: 200px;
padding: 15px;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 16px;
line-height: 1.5;
resize: vertical;
margin-bottom: 15px;
transition: border-color 0.2s;
}
#textInput:focus {
outline: none;
border-color: #4CAF50;
}
/* 转换进度 */
.conversion-progress {
margin: 15px 0;
padding: 15px;
border: 1px solid #ddd;
border-radius: 6px;
background: #f9f9f9;
}
/* 统计信息 */
.stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 15px;
margin: 20px 0;
padding: 15px;
background: #f8f9fa;
border-radius: 6px;
}
.stats > div {
text-align: center;
padding: 10px;
background: white;
border-radius: 4px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
font-size: 14px;
}
/* 转换控制 */
.controls {
margin: 20px 0;
}
#convertBtn {
display: block;
width: 100%;
padding: 12px;
background: #4CAF50;
color: white;
border: none;
border-radius: 6px;
font-size: 16px;
cursor: pointer;
transition: background-color 0.2s;
margin-bottom: 15px;
}
#convertBtn:hover {
background: #45a049;
}
#convertBtn:disabled {
background: #cccccc;
cursor: not-allowed;
}
/* 音频播放器 */
#audioPlayer {
width: 100%;
margin: 15px 0;
}
/* 快捷键信息 */
.shortcuts-info {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 10px;
margin-top: 20px;
padding: 15px;
background: #f8f9fa;
border-radius: 6px;
}
.shortcut-item {
text-align: center;
padding: 8px;
background: white;
border-radius: 4px;
font-size: 14px;
color: #666;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
/* 状态类 */
.loading {
opacity: 0.7;
cursor: not-allowed;
}
.error {
color: #dc3545;
padding: 10px;
margin: 10px 0;
border: 1px solid #dc3545;
border-radius: 4px;
background-color: #fff;
}
.success {
color: #28a745;
padding: 10px;
margin: 10px 0;
border: 1px solid #28a745;
border-radius: 4px;
background-color: #fff;
}
.drag-over {
border-color: #4CAF50 !important;
background-color: rgba(76, 175, 80, 0.1);
}
/* 响应式设计 */
@media (max-width: 768px) {
.container {
margin: 10px;
padding: 15px;
}
.controls-top {
flex-direction: column;
align-items: stretch;
}
.speed-control, .playback-speed-control {
justify-content: space-between;
}
.stats {
grid-template-columns: 1fr 1fr;
}
.shortcuts-info {
grid-template-columns: 1fr;
}
.button-group {
flex-wrap: wrap;
}
}
@media (max-width: 480px) {
.stats {
grid-template-columns: 1fr;
}
.header {
flex-direction: column;
align-items: flex-start;
gap: 10px;
}
#languageSelect {
width: 100%;
}
input[type="range"] {
width: 100%;
}
}
.error-state {
border-color: #dc3545 !important;
background-color: rgba(220, 53, 69, 0.1);
}
.upload-error {
color: #dc3545;
background: #fff;
padding: 10px;
margin: 10px 0;
border: 1px solid #dc3545;
border-radius: 4px;
font-size: 14px;
}
#convertBtn.loading {
background-color: #ccc;
pointer-events: none;
}
.file-processing {
display: inline-block;
margin-left: 10px;
color: #666;
font-size: 14px;
}
docker 部署:
1. Dockerfile
# Use a base image with Python 3.9.11
FROM python:3.9.11
# Install system dependencies
RUN apt-get update && apt-get install -y \
ffmpeg \
calibre \
&& rm -rf /var/lib/apt/lists/*
# Set the working directory
WORKDIR /app
# First, copy the package manager and requirements
COPY pkg_manager.py requirements.txt ./
# Install Python packages
#RUN pip install --no-cache-dir packaging
RUN pip install --upgrade pip
# Create necessary directories for pkg_manager
RUN mkdir -p /share/Python-Packages/python-packages/wheels \
/share/Python-Packages/python-packages/config
# Install requirements using pkg_manager
#RUN python pkg_manager.py download-req requirements.txt
RUN python pkg_manager.py install-req requirements.txt
# Copy the rest of the application code
COPY . .
# Create necessary directories
RUN mkdir -p logs temp
# Set permissions
RUN chmod 755 /app/temp
RUN chmod 755 /app/logs
# Expose the port the application runs on
EXPOSE 9014
# Run the application
CMD ["python", "app.py"]
2. requirements.txt
quart
edge-tts
beautifulsoup4
pydub
ebooklib
hypercorn
chardet
3. 部署在 QNAP NAS 中
a. 创建 docker image
[/share/Python-Packages/apt-packages] # docker build -t ebooktx2speak .
Setting up dbus (1.12.28-0+deb11u1) ...
invoke-rc.d: could not determine current runlevel
invoke-rc.d: policy-rc.d denied execution of start.
...
...
Step 14/14 : CMD ["python", "app.py"]
---> Running in bf347c35b7df
---> Removed intermediate container bf347c35b7df
---> 26f9bae60760
Successfully built 26f9bae60760
Successfully tagged ebooktx2speak:latest
[/share/Multimedia/2024-MyProgramFiles/25.ebooktx2speak] #
b. 分配 9014 端口,启动设置
[/share/Multimedia/2024-MyProgramFiles/25.ebooktx2speak] # docker run -d -p 9014:9014 --name ebooktx2speak_container --restart always ebooktx2speak
146e7d6a1123362181d56570e88a22ca0bc2845cf894e7cbed3d5243735aef96
[/share/Multimedia/2024-MyProgramFiles/25.ebooktx2speak]
c. 查看运行 Container Station:
注释 1 :
如果在 windows 11 下使用, 需要安装 calibre 软件, 下载:https://calibre-ebook.com/download_windows
并确保在 Windows 11 系统环境变量中有, 默认安装路径:
C:\Users\dave>echo %PATH%
...;C:\Program Files\Calibre2;
如果更改了路径,在 app.py 也要修改:
if platform.system() == 'Windows':
os.environ['PATH'] = r'C:\Program Files\Calibre2;' + os.environ['PATH']
注释 2 :
因为使用 ssl 证书文件, 你要自己生成,证书存放在 app.py 同目录, 具体参考 我另一篇文章:<QNAP 453D QTS-5.x> 日志记录:在 Docker 中运行的 Flask 应用安装 自签名 SSL 证书 解决 Chrome 等浏览器证书安全-CSDN博客
来参考使用。如果没有 SSL 要修改 app.py 最后部分为,参考:
if __name__ == '__main__':
logger.info("Starting Flask application...")
app.run(host='0.0.0.0', port=9014, debug=True)
注释 3:
calibre 支持很多格式的 ebooks,但在代码中只用 azw3 与 mobi, 你可以参考现有代码 ( 涉及 app.py index.html, kindle_handler.py ) 添加其支持的更多格式。


7676

被折叠的 条评论
为什么被折叠?



