Files
Ubuntu 32b43f85bd a
2026-07-03 16:58:28 +08:00

357 lines
11 KiB
Python
Executable File

#!/usr/bin/env python3
"""
Audiobook Player - Web Application
Minimalist immersive reading + streaming TTS
Adaptive directory structure support
"""
import os
import json
import asyncio
import edge_tts
from flask import Flask, render_template, request, Response, send_file
from pathlib import Path
from typing import List, Dict, Optional
app = Flask(__name__, static_folder='static')
# Configuration
MEDIA_DIR = Path("/home/ubuntu/media")
DEFAULT_VOICE = "en-US-AriaNeural"
def load_source_config(source_path: Path) -> Optional[Dict]:
"""加载 source.json 配置(如果存在)"""
config_file = source_path / "source.json"
if config_file.exists():
try:
with open(config_file, 'r', encoding='utf-8') as f:
return json.load(f)
except:
pass
return None
def detect_structure_type(source_path: Path) -> str:
"""
自动检测目录结构类型
- flat: 直接存放 txt 文件
- content_folder: 子目录包含 content.txt
- multi_file_folder: 子目录包含多个 txt 文件
"""
# 检查是否有子目录
subdirs = [d for d in source_path.iterdir() if d.is_dir()]
if not subdirs:
# 没有子目录,检查是否有 txt 文件
txt_files = list(source_path.glob("*.txt"))
if txt_files:
return "flat"
return "unknown"
# 有子目录,检查第一个子目录的结构
sample_dir = subdirs[0]
files = list(sample_dir.iterdir())
txt_files = [f for f in files if f.suffix == '.txt']
if len(txt_files) == 1 and txt_files[0].name == 'content.txt':
return "content_folder"
elif len(txt_files) >= 1:
return "multi_file_folder"
return "unknown"
def parse_txt_file(txt_file: Path) -> Dict:
"""解析 txt 文件,提取元数据"""
info = {
"title": txt_file.stem.replace("-", " ").title(),
"author": "Unknown",
"year": None,
"path": str(txt_file),
"has_content": True
}
try:
with open(txt_file, 'r', encoding='utf-8') as f:
lines = f.readlines()
for line in lines[:10]:
if line.startswith("# "):
info["title"] = line[2:].strip()
elif line.startswith("Author:"):
info["author"] = line[7:].strip()
elif line.startswith("Year:"):
info["year"] = line[5:].strip()
except:
pass
return info
def get_stories_from_flat(source_path: Path) -> List[Dict]:
"""处理扁平结构: /source/poem.txt"""
stories = []
for txt_file in source_path.glob("*.txt"):
info = parse_txt_file(txt_file)
info["id"] = txt_file.stem
info["type"] = "flat"
stories.append(info)
return stories
def get_stories_from_content_folder(source_path: Path) -> List[Dict]:
"""处理 content_folder 结构: /source/story-dir/content.txt"""
stories = []
for story_dir in source_path.iterdir():
if story_dir.is_dir() and story_dir.name != '__pycache__':
content_file = story_dir / "content.txt"
if content_file.exists():
info = parse_txt_file(content_file)
info["id"] = story_dir.name
info["type"] = "content_folder"
stories.append(info)
return stories
def get_stories_from_multi_file_folder(source_path: Path) -> List[Dict]:
"""处理 multi_file_folder 结构: /source/poet-dir/poem.txt"""
stories = []
for poet_dir in source_path.iterdir():
if poet_dir.is_dir() and poet_dir.name != '__pycache__':
poet_name = poet_dir.name.replace("-", " ").title()
for txt_file in poet_dir.glob("*.txt"):
info = parse_txt_file(txt_file)
info["id"] = f"{poet_dir.name}/{txt_file.stem}"
info["title"] = info["title"]
info["author"] = poet_name if info["author"] == "Unknown" else info["author"]
info["type"] = "multi_file_folder"
stories.append(info)
return stories
def get_media_sources():
"""获取所有媒体来源(自适应结构)"""
sources = []
if MEDIA_DIR.exists():
for item in MEDIA_DIR.iterdir():
if item.is_dir():
# 加载配置或自动检测
config = load_source_config(item)
if config:
structure = config.get("structure", "auto")
display_name = config.get("name", format_source_name(item.name))
else:
structure = detect_structure_type(item)
display_name = format_source_name(item.name)
# 统计故事数量
stories = get_stories_from_source(item.name)
sources.append({
"id": item.name,
"name": display_name,
"path": str(item),
"structure": structure,
"count": len(stories)
})
return sorted(sources, key=lambda x: x["name"])
def format_source_name(name):
"""转换目录名称为显示名称"""
name_map = {
"libraryofshortstories": "Library of Short Stories",
"public-domain-poetry": "Public Domain Poetry",
}
return name_map.get(name, name.replace("-", " ").title())
def get_stories_from_source(source_id: str) -> List[Dict]:
"""从来源获取所有故事(自适应结构)"""
source_path = MEDIA_DIR / source_id
if not source_path.exists():
return []
# 获取结构类型
config = load_source_config(source_path)
if config and config.get("structure") != "auto":
structure = config["structure"]
else:
structure = detect_structure_type(source_path)
# 根据结构类型处理
handlers = {
"flat": get_stories_from_flat,
"content_folder": get_stories_from_content_folder,
"multi_file_folder": get_stories_from_multi_file_folder,
}
handler = handlers.get(structure, get_stories_from_content_folder)
stories = handler(source_path)
return sorted(stories, key=lambda x: x["title"])
def read_story_content(source_id: str, story_id: str) -> Optional[str]:
"""读取故事内容(自适应路径)"""
source_path = MEDIA_DIR / source_id
if not source_path.exists():
return None
# 尝试不同的路径格式
possible_paths = [
# content_folder: story-id/content.txt
source_path / story_id / "content.txt",
# multi_file_folder: poet-slug/poem-slug
source_path / f"{story_id}.txt",
# flat: story-id.txt
source_path / f"{story_id}.txt",
]
# 如果 story_id 包含 /,直接解析
if "/" in story_id:
parts = story_id.split("/", 1)
possible_paths.insert(0, source_path / parts[0] / f"{parts[1]}.txt")
for path in possible_paths:
if path.exists():
with open(path, 'r', encoding='utf-8') as f:
return f.read()
return None
# Routes
@app.route("/")
def index():
"""Home page - show media sources"""
sources = get_media_sources()
return render_template("index.html", sources=sources)
@app.route("/list/<source_id>")
def story_list(source_id):
"""Story list page for a source"""
source = None
for s in get_media_sources():
if s["id"] == source_id:
source = s
break
if not source:
return "Source not found", 404
stories = get_stories_from_source(source_id)
return render_template("list.html", source=source, stories=stories)
@app.route("/play/<path:story_path>")
def player(story_path):
"""Player page for a story (支持多级路径)"""
# 解析路径: source_id/story_id 或 source_id/poet/poem
parts = story_path.split("/", 1)
if len(parts) < 2:
return "Invalid story path", 400
source_id = parts[0]
story_id = parts[1]
content = read_story_content(source_id, story_id)
if not content:
return "Story not found", 404
# 解析元数据
title = story_id.replace("-", " ").replace("/", " - ").title()
author = "Unknown"
year = ""
lines = content.split('\n')
body_start = 0
for i, line in enumerate(lines[:20]):
if line.startswith("# "):
title = line[2:].strip()
elif line.startswith("Author:"):
author = line[7:].strip()
elif line.startswith("Year:"):
year = line[5:].strip()
elif line.startswith("==="):
body_start = i + 1
break
body = '\n'.join(lines[body_start:]).strip()
return render_template("player.html",
source_id=source_id,
story_id=story_id,
title=title,
author=author,
year=year,
content=body)
# API Routes
@app.route("/api/sources")
def api_sources():
"""API: Get all sources"""
return json.dumps(get_media_sources())
@app.route("/api/stories/<source_id>")
def api_stories(source_id):
"""API: Get stories from a source"""
return json.dumps(get_stories_from_source(source_id))
async def generate_audio_stream(text, voice=DEFAULT_VOICE, rate="0%"):
"""Generate audio stream using edge-tts with speed control"""
communicate = edge_tts.Communicate(text, voice, rate=rate)
async for chunk in communicate.stream():
if chunk["type"] == "audio":
yield chunk["data"]
@app.route("/api/stream")
def stream_audio():
"""Stream audio from text with speed control"""
text = request.args.get("text", "")
voice = request.args.get("voice", DEFAULT_VOICE)
# edge-tts rate format: [+-]\d+% (e.g., +20%, -20%)
# "0%" is invalid, use "-10%" as default for comfortable speed
rate = request.args.get("rate", "-10%")
if rate == "0%":
rate = "-10%"
if not text:
return "No text provided", 400
def generate():
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
async def stream():
async for chunk in generate_audio_stream(text, voice, rate):
yield chunk
gen = stream()
while True:
try:
chunk = loop.run_until_complete(gen.__anext__())
yield chunk
except StopAsyncIteration:
break
loop.close()
return Response(generate(), mimetype="audio/mpeg")
@app.route("/screenshots/<path:filename>")
def serve_screenshot(filename):
"""提供截图文件下载"""
return send_file(f"static/{filename}")
if __name__ == "__main__":
app.run(host="0.0.0.0", port=8899, debug=False, threaded=True)