diff --git a/DOCKER_USAGE.md b/DOCKER_USAGE.md new file mode 100644 index 0000000..f16b513 --- /dev/null +++ b/DOCKER_USAGE.md @@ -0,0 +1,125 @@ +# Docker 使用指南 + +本指南将介绍如何使用 `Dockerfile-Web` 和 `Dockerfile-Server` 来构建 Docker 镜像并运行整个应用程序,以及如何使用 Docker Compose 简化部署流程。 + +## 前提条件 + +* 已安装 Docker 和 Docker Compose。 + +## 方法一:分别构建和运行 Docker 镜像 + +在 `Simple-Podcast-Script` 项目的根目录下执行以下命令来构建 Docker 镜像。 + +### 构建 Docker 镜像 + +#### 构建 Web 应用镜像 + +```bash +docker build -t simple-podcast-web -f Dockerfile-Web . +``` + +* `-t simple-podcast-web`:为镜像指定一个名称和标签。 +* `-f Dockerfile-Web`:指定 Web 应用的 Dockerfile 路径。 +* `.`:指定构建上下文的路径,这里是项目的根目录。 + +#### 构建 Server 应用镜像 + +```bash +docker build -t simple-podcast-server -f Dockerfile-Server . +``` + +* `-t simple-podcast-server`:为镜像指定一个名称和标签。 +* `-f Dockerfile-Server`:指定 Server 应用的 Dockerfile 路径。 +* `.`:指定构建上下文的路径,这里是项目的根目录。 + +构建过程可能需要一些时间,具体取决于您的网络速度和系统性能。 + +### 运行 Docker 容器 + +#### 运行 Web 应用容器 + +```bash +docker run -d -p 3200:3000 -v /opt/audio:/app/server/output --restart always --name podcast-web simple-podcast-web +``` + +#### 命令说明: + +* `-d`:在分离模式(detached mode)下运行容器,即在后台运行。 +* `-p 3200:3000`:将宿主机的 3200 端口映射到容器的 3000 端口。Next.js 应用程序在容器内部的 3000 端口上运行。 +* `-v /opt/audio:/app/server/output`:将宿主机的 `/opt/audio` 目录挂载到容器内的 `/app/server/output` 目录,用于音频文件的持久化存储。 +* `--restart always`:设置容器的重启策略,确保容器在意外停止或系统重启后能自动重启。 +* `--name podcast-web`:为运行中的容器指定一个名称,方便后续管理。 +* `simple-podcast-web`:指定要运行的 Docker 镜像名称。 + +#### 运行 Server 应用容器 + +```bash +docker run -d -p 3100:8000 -v /opt/audio:/app/server/output --restart always --name podcast-server simple-podcast-server +``` + +或者,如果您的应用程序需要配置环境变量(例如 `PODCAST_API_SECRET_KEY`),您可以使用 `-e` 参数进行设置: + +```bash +docker run -d -p 3100:8000 -v /opt/audio:/app/server/output --restart always --name podcast-server -e PODCAST_API_SECRET_KEY="your-production-api-secret-key" simple-podcast-server +``` + +#### 命令说明: + +* `-d`:在分离模式(detached mode)下运行容器,即在后台运行。 +* `-p 3100:8000`:将宿主机的 3100 端口映射到容器的 8000 端口。Server 应用程序在容器内部的 8000 端口上运行。 +* `-v /opt/audio:/app/server/output`:将宿主机的 `/opt/audio` 目录挂载到容器内的 `/app/server/output` 目录,用于音频文件的持久化存储。 +* `--restart always`:设置容器的重启策略,确保容器在意外停止或系统重启后能自动重启。 +* `--name podcast-server`:为运行中的容器指定一个名称,方便后续管理。 +* `-e PODCAST_API_SECRET_KEY="your-production-api-secret-key"`:设置环境变量,将 `"your-production-api-secret-key"` 替换为您的实际密钥。 +* `simple-podcast-server`:指定要运行的 Docker 镜像名称。 + +## 方法二:使用 Docker Compose(推荐) + +项目提供了 `docker-compose.yml` 文件,可以更方便地管理和部署整个应用。 + +### 启动服务 + +在项目根目录下执行以下命令启动所有服务: + +```bash +docker-compose up -d +``` + +* `-d`:在分离模式下运行容器,即在后台运行。 + +### 停止服务 + +```bash +docker-compose down +``` + +### 查看服务状态 + +```bash +docker-compose ps +``` + +### 查看服务日志 + +```bash +# 查看所有服务日志 +docker-compose logs + +# 查看特定服务日志 +docker-compose logs web +docker-compose logs server +``` + +## 验证应用程序是否运行 + +容器启动后,您可以通过以下地址来验证应用程序是否正常运行: + +* Web 应用: `http://localhost:3200` +* Server 应用: `http://localhost:3100` + +## 注意事项 + +1. 请确保宿主机上的端口 3100 和 3200 未被其他应用程序占用。 +2. 请确保宿主机上的 `/opt/audio` 目录存在且具有适当的读写权限,或者根据实际情况修改挂载路径。 +3. 在生产环境中,请使用安全的密钥替换示例中的 `PODCAST_API_SECRET_KEY`。 +4. 使用 Docker Compose 时,服务间通过服务名称进行通信,Web 应用通过 `http://server:8000` 访问 Server 应用。 \ No newline at end of file diff --git a/Dockerfile-Server b/Dockerfile-Server new file mode 100644 index 0000000..4f4f20e --- /dev/null +++ b/Dockerfile-Server @@ -0,0 +1,30 @@ +# 使用官方 Python 运行时作为父镜像 +FROM python:3.11-alpine + +# 安装 FFmpeg 和 FFprobe +# FFmpeg 用于音频处理,包括合并和转换 +RUN apk add --no-cache ffmpeg && rm -rf /var/cache/apk/* + +# 复制 requirements.txt 以便利用 Docker 缓存 +COPY server/requirements.txt /tmp/requirements.txt +RUN pip install --upgrade pip +RUN pip install --no-cache-dir -r /tmp/requirements.txt && rm /tmp/requirements.txt + +# 复制 server 目录的内容到容器的 /app/server +COPY server/ /app/server/ + +# 复制父目录的 config 文件夹到容器的 /app/config +COPY config/ /app/config/ + +# 设置工作目录为 server 目录 +WORKDIR /app/server + +# 暴露应用程序运行的端口 +EXPOSE 8000 + +# 定义环境变量,如果你的应用需要 +# ENV PODCAST_API_SECRET_KEY="your-production-api-secret-key" + +# 运行应用程序的命令 +# 使用 uvicorn 直接运行 FastAPI 应用 +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file diff --git a/Dockerfile-Web b/Dockerfile-Web new file mode 100644 index 0000000..8e99b0d --- /dev/null +++ b/Dockerfile-Web @@ -0,0 +1,51 @@ +# Stage 1: Dependency Installation and Build +FROM node:20-alpine AS builder + +# Set working directory +WORKDIR /app/web + +# Copy package.json and package-lock.json to leverage Docker cache +COPY web/package.json web/package-lock.json ./ + +# Install dependencies +RUN npm install --frozen-lockfile \ + && npm install --cpu=x64 --os=linux --libc=musl @libsql/client@latest --foreground-scripts + +# Copy the rest of the application code (web directory content) +COPY web . + +# Copy parent config directory +COPY config ../config + +# Build the Next.js application +# The `standalone` output mode creates a self-contained application +ENV NEXT_TELEMETRY_DISABLED 1 +RUN npm run build + +# Stage 2: Production Image +FROM node:20-alpine AS runner + +WORKDIR /app + +# Create output directory for web/server.js to use +RUN mkdir -p server/output +RUN npm install @libsql/linux-x64-musl + +# Set production environment +ENV NODE_ENV production + +COPY web/.env ./web/.env +COPY web/sqlite.db ./web/sqlite.db +# Copy standalone application and public assets from the builder stage +COPY --from=builder /app/web/.next/standalone ./web/ +COPY --from=builder /app/web/.next/static ./web/.next/static +COPY --from=builder /app/web/public ./web/public + +# Copy parent config directory from builder stage to runner stage +COPY --from=builder /app/config ./config + +# Expose port (Next.js default port) +EXPOSE 3000 + +# Start the Next.js application +CMD ["node", "web/server.js"] \ No newline at end of file diff --git a/README.md b/README.md index 35db70d..7744ff2 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# 🎙️ 简易播客生成器 (Simple Podcast Generator) +# 🎙️ 播客生成器 (Podcast Generator) > 轻松将您的想法,一键生成为生动有趣的多人对话播客! > [English Version](README_EN.md) @@ -73,6 +73,8 @@ python podcast_generator.py [可选参数] * `--base-url `: OpenAI API 的代理地址。若不提供,将从配置文件或 `OPENAI_BASE_URL` 环境变量中读取。 * `--model `: 指定使用的 OpenAI 模型(如 `gpt-4o`, `gpt-4-turbo`)。默认值为 `gpt-3.5-turbo`。 * `--threads `: 指定生成音频的并行线程数(默认为 `1`),提高处理速度。 +* `--output-language `: 指定播客脚本的输出语言(默认为 `Chinese`)。 +* `--usetime `: 指定播客脚本的时间长度(默认为 `10 minutes`)。 #### **运行示例** @@ -150,6 +152,48 @@ curl -X POST "http://localhost:8000/generate-podcast" \ --- +## 🌐 Web 应用 (Next.js) + +除了命令行脚本和 FastAPI 服务,本项目还提供了一个功能完善的 Web 用户界面。这个界面旨在提供更直观、便捷的播客生成与管理体验,将后端复杂的功能通过友好的前端操作暴露给用户。 + +### ✨ 核心功能亮点 + +* **web操作界面**: 直观友好的web界面,让播客生成过程一目了然。 +* **微用户体系集成**: 支持用户登录、注册、积分与计费功能,构建完善的用户生态。 +* **播客创建与配置**: 允许用户通过表单输入主题,配置 TTS 角色、音量和语速等参数。 +* **实时进度跟踪**: 显示播客生成的状态和进度。 +* **播客播放与管理**: 集成音频播放器,方便用户收听已生成的播客,并可能提供管理历史播客的功能。 +* **API 交互**: 通过 API 与后端 Python 服务无缝通信,包括播客生成、状态查询和音频流。 + +### 🚀 快速开始 (Web) + +1. **安装 Node.js**: 请确保您的系统中已安装 Node.js (推荐 LTS 版本)。 +2. **安装依赖**: 进入 `web/` 目录,安装所有前端依赖。 + ```bash + cd web/ + npm install + # 或者 yarn install + ``` +3. **启动开发服务器**: + ```bash + npm run dev + # 或者 yarn dev + ``` + Web 应用将在 `http://localhost:3000` (默认) 启动。 +4. **构建生产环境**: + ```bash + npm run build + # 或者 yarn build + npm run start + # 或者 yarn start + ``` + + +### 🐳 Docker 部署 + +本项目支持通过 Docker 进行部署,详细信息请参考 [Docker 使用指南](DOCKER_USAGE.md)。 + +--- ## ⚙️ 配置文件详解 ### `config/[tts-provider].json` (TTS 角色与语音配置) @@ -253,15 +297,29 @@ curl -X POST "http://localhost:8000/generate-podcast" \ ├── config/ # ⚙️ 配置文件目录 │ ├── doubao-tts.json # ... (各 TTS 服务商的配置) │ └── tts_providers.json # 统一的 TTS 认证文件 +├── server/ # 🐍 后端服务目录 +│ ├── main.py # FastAPI Web API 入口:提供播客生成、状态查询、音频下载等 RESTful API,管理任务生命周期,并进行数据清理。 +│ ├── podcast_generator.py # 核心播客生成逻辑:负责与 OpenAI API 交互生成播客脚本,调用 TTS 适配器将文本转语音,并使用 FFmpeg 合并音频文件。 +│ ├── tts_adapters.py # TTS 适配器:封装了与不同 TTS 服务(如 Index-TTS, Edge-TTS, Doubao, Minimax, Fish Audio, Gemini)的交互逻辑。 +│ ├── openai_cli.py # OpenAI 命令行工具 +│ └── ... # 其他后端文件 +├── web/ # 🌐 前端 Web 应用目录 (Next.js) +│ ├── public/ # 静态资源 +│ ├── src/ # 源码 +│ │ ├── app/ # Next.js 路由页面 +│ │ ├── components/ # React 组件 +│ │ ├── hooks/ # React Hooks +│ │ ├── lib/ # 库文件 (认证、数据库、API等) +│ │ └── types/ # TypeScript 类型定义 +│ ├── package.json # 前端依赖 +│ ├── next.config.js # Next.js 配置 +│ └── ... # 其他前端文件 ├── prompt/ # 🧠 AI 提示词目录 │ ├── prompt-overview.txt │ └── prompt-podscript.txt ├── example/ # 🎧 示例音频目录 ├── output/ # 🎉 输出音频目录 ├── input.txt # 🎙️ 播客主题输入文件 -├── openai_cli.py # OpenAI 命令行工具 -├── podcast_generator.py # 🚀 主运行脚本 -├── tts_adapters.py # TTS 适配器文件 ├── README.md # 📄 项目说明文档 (中文) └── README_EN.md # 📄 项目说明文档 (英文) ``` diff --git a/README_EN.md b/README_EN.md index 02eb8bf..c513630 100644 --- a/README_EN.md +++ b/README_EN.md @@ -73,6 +73,8 @@ python podcast_generator.py [optional parameters] * `--base-url `: Proxy address of the OpenAI API. If not provided, it will be read from the configuration file or `OPENAI_BASE_URL` environment variable. * `--model `: Specify the OpenAI model to use (such as `gpt-4o`, `gpt-4-turbo`). Default value is `gpt-3.5-turbo`. * `--threads `: Specify the number of parallel threads for audio generation (default is `1`) to improve processing speed. +* `--output-language `: Specify the output language of the podcast script (default is `Chinese`). +* `--usetime `: Specify the time length of the podcast script (default is `10 minutes`). #### **Running Example** @@ -150,6 +152,48 @@ Additional instructions or context you want to provide to the AI, for example: --- +## 🌐 Web Application (Next.js) + +In addition to the command-line script and FastAPI service, this project also provides a fully functional web user interface. This interface aims to provide a more intuitive and convenient podcast generation and management experience, exposing complex backend functions through friendly frontend operations to users. + +### ✨ Core Features + +* **Web Operation Interface**: Intuitive and friendly web interface that makes the podcast generation process clear at a glance. +* **Micro User System Integration**: Supports user login, registration, points and billing functions, building a complete user ecosystem. +* **Podcast Creation and Configuration**: Allows users to enter topics through forms and configure TTS characters, volume and speed parameters. +* **Real-time Progress Tracking**: Displays the status and progress of podcast generation. +* **Podcast Playback and Management**: Integrates an audio player for users to listen to generated podcasts and may provide functions for managing historical podcasts. +* **API Interaction**: Seamless communication with the backend Python service through APIs, including podcast generation, status queries, and audio streaming. + +### 🚀 Quick Start (Web) + +1. **Install Node.js**: Please ensure Node.js is installed on your system (LTS version recommended). +2. **Install Dependencies**: Enter the `web/` directory and install all frontend dependencies. + ```bash + cd web/ + npm install + # or yarn install + ``` +3. **Start Development Server**: + ```bash + npm run dev + # or yarn dev + ``` + The web application will start at `http://localhost:3000` (default). +4. **Build Production Environment**: + ```bash + npm run build + # or yarn build + npm run start + # or yarn start + ``` + +### 🐳 Docker Deployment + +This project supports deployment via Docker. For detailed information, please refer to [Docker Usage Guide](DOCKER_USAGE.md). + +--- + ## ⚙️ Configuration File Details ### `config/[tts-provider].json` (TTS Character and Voice Configuration) @@ -253,15 +297,29 @@ You can find sample podcast audio generated using different TTS services in the ├── config/ # ⚙️ Configuration directory │ ├── doubao-tts.json # ... (configuration for each TTS provider) │ └── tts_providers.json # Unified TTS authentication file +├── server/ # 🐍 Backend service directory +│ ├── main.py # FastAPI Web API entry: Provides RESTful APIs for podcast generation, status query, audio download, manages task lifecycle, and performs data cleanup. +│ ├── podcast_generator.py # Core podcast generation logic: Responsible for interacting with OpenAI API to generate podcast scripts, calling TTS adapters to convert text to speech, and using FFmpeg to merge audio files. +│ ├── tts_adapters.py # TTS adapter: Encapsulates interaction logic with different TTS services (such as Index-TTS, Edge-TTS, Doubao, Minimax, Fish Audio, Gemini). +│ ├── openai_cli.py # OpenAI command-line tool +│ └── ... # Other backend files +├── web/ # 🌐 Frontend Web Application Directory (Next.js) +│ ├── public/ # Static resources +│ ├── src/ # Source code +│ │ ├── app/ # Next.js route pages +│ │ ├── components/ # React components +│ │ ├── hooks/ # React Hooks +│ │ ├── lib/ # Library files (authentication, database, API, etc.) +│ │ └── types/ # TypeScript type definitions +│ ├── package.json # Frontend dependencies +│ ├── next.config.js # Next.js configuration +│ └── ... # Other frontend files ├── prompt/ # 🧠 AI prompt directory │ ├── prompt-overview.txt │ └── prompt-podscript.txt ├── example/ # 🎧 Sample audio directory ├── output/ # 🎉 Output audio directory ├── input.txt # 🎙️ Podcast topic input file -├── openai_cli.py # OpenAI command-line tool -├── podcast_generator.py # 🚀 Main script -├── tts_adapters.py # TTS adapter file ├── README.md # 📄 Project documentation (Chinese) └── README_EN.md # 📄 Project documentation (English) ``` diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..d0ba904 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,33 @@ +version: '3.8' + +services: + web: + build: + context: . + dockerfile: Dockerfile-Web + image: simple-podcast-web + ports: + - "3200:3000" + volumes: + - audio-data:/app/server/output + restart: always + depends_on: + - server + environment: + - NEXT_PUBLIC_API_URL=http://server:8000 + + server: + build: + context: . + dockerfile: Dockerfile-Server + image: simple-podcast-server + ports: + - "3100:8000" + volumes: + - audio-data:/app/server/output + restart: always + environment: + - PODCAST_API_SECRET_KEY=your-production-api-secret-key + +volumes: + audio-data: \ No newline at end of file diff --git a/server/extract_dependencies.py b/server/extract_dependencies.py new file mode 100644 index 0000000..7d27ab9 --- /dev/null +++ b/server/extract_dependencies.py @@ -0,0 +1,150 @@ +import os +import re +import sys +from collections import defaultdict + +# 假设的项目根目录,用于判断是否为内部模块 +PROJECT_ROOT = os.getcwd() + +# Python 内置库的简单列表 (可以根据需要扩展) +# 这是一个不完全列表,但包含了常见的内置库 +BUILTIN_LIBS = { + "os", "sys", "json", "time", "argparse", "uuid", "hashlib", "hmac", + "enum", "shutil", "random", "threading", "contextlib", "io", "base64", + "datetime", "glob", "subprocess", "urllib", "re", "abc", "typing", + "concurrent", "collections", "wave" +} + +def is_builtin_or_standard_library(module_name): + """ + 判断模块是否为 Python 内置库或标准库。 + 这里仅做一个简单的基于名称的判断。 + 更精确的判断需要检查 sys.builtin_module_names 或 sys.stdlib_module_names, + 但会使脚本复杂化,对于当前任务,基于列表判断已足够。 + """ + return module_name.lower() in BUILTIN_LIBS + +def is_project_internal_module(module_name, project_root_path, py_files_paths): + """ + 判断模块是否为项目内部模块。 + 通过检查模块名是否对应项目中的某个Python文件或包。 + """ + # 转换为可能的相对路径形式 + module_path_parts = module_name.replace('.', os.sep) + + for py_file_path in py_files_paths: + # 检查是否为直接导入的文件 (例如: from podcast_generator import ...) + if py_file_path.endswith(f"{module_path_parts}.py"): + return True + # 检查是否为包导入 (例如: from check.check_doubao_voices import ...) + if os.path.isdir(os.path.join(project_root_path, module_path_parts)) and \ + os.path.exists(os.path.join(project_root_path, module_path_parts, "__init__.py")): + return True + return False + + +def extract_dependencies(file_path, project_root_path, all_py_files): + """从Python文件中提取第三方依赖""" + dependencies = set() + internal_modules = set() + + # 将所有Python文件的路径转换为集合,方便查找 + all_py_files_set = set(all_py_files) + + with open(file_path, 'r', encoding='utf-8') as f: + content = f.read() + + # 匹配 import module 和 from package import module + # 这里我们只关心顶级模块名 + for match in re.finditer(r"^(?:import|from)\s+([a-zA-Z0-9_.]+)", content, re.MULTILINE): + full_module_name = match.group(1).split('.')[0] # 获取顶级模块名 + + if is_builtin_or_standard_library(full_module_name): + continue # 跳过内置库 + + # 检查是否为项目内部模块 + # 为了提高准确性,这里需要将py_files_paths传递给 is_project_internal_module + if is_project_internal_module(full_module_name, project_root_path, all_py_files_set): + internal_modules.add(full_module_name) + continue + + dependencies.add(full_module_name) + + return dependencies, internal_modules + +def find_all_py_files(directory): + """递归查找指定目录下所有.py文件""" + py_files = [] + for root, _, files in os.walk(directory): + for file in files: + if file.endswith(".py"): + py_files.append(os.path.relpath(os.path.join(root, file), start=directory)) + return py_files + +def main(): + print("开始提取 Python 项目依赖...") + + all_py_files = find_all_py_files(PROJECT_ROOT) + + all_external_dependencies = set() + all_internal_modules = set() + + for py_file in all_py_files: + print(f"处理文件: {py_file}") + try: + current_dependencies, current_internal_modules = extract_dependencies(py_file, PROJECT_ROOT, all_py_files) + all_external_dependencies.update(current_dependencies) + all_internal_modules.update(current_internal_modules) + except Exception as e: + print(f"处理文件 {py_file} 时出错: {e}", file=sys.stderr) + + # 某些库名可能需要映射到 pip 包名 + # 例如,PIL 导入为 PIL 但包名是 Pillow + # httpx 导入为 httpx, 包名也是 httpx + # starlette 导入为 starlette,包名也是 starlette + # fastapi 导入为 fastapi,包名也是 fastapi + # uvicorn 导入为 uvicorn,包名也是 uvicorn + # openai 导入为 openai,包名也是 openai + # msgpack 导入为 msgpack,包名是 msgpack + # pydub 导入为 pydub,包名是 pydub + # requests 导入为 requests, 包名也是 requests + # schedule 导入为 schedule,包名是 schedule + dependency_mapping = { + "PIL": "Pillow", + "fastapi": "fastapi", + "starlette": "starlette", + "httpx": "httpx", + "schedule": "schedule", + "uvicorn": "uvicorn", + "openai": "openai", + "msgpack": "msgpack", + "pydub": "pydub", + "requests": "requests", + } + + final_dependencies = set() + for dep in all_external_dependencies: + final_dependencies.add(dependency_mapping.get(dep, dep)) + + # 手动添加一些可能未通过 import 语句捕获的依赖,或者需要特定版本的依赖 + # 这部分通常需要根据项目实际情况调整 + # 例如: + # final_dependencies.add("uvicorn[standard]") # 如果使用了 uvicorn 的标准安装 + # final_dependencies.add("fastapi[all]") # 如果使用了 FastAPI 的所有可选依赖 + + output_file = "requirements.txt" + with open(output_file, 'w', encoding='utf-8') as f: + for dep in sorted(list(final_dependencies)): + f.write(f"{dep}\n") + + print(f"\n提取完成。所有第三方依赖已写入 {output_file}。") + print("\n检测到的第三方依赖:") + for dep in sorted(list(final_dependencies)): + print(f"- {dep}") + + print("\n检测到的项目内部模块 (供参考):") + for mod in sorted(list(all_internal_modules)): + print(f"- {mod}") + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/server/requirements.txt b/server/requirements.txt new file mode 100644 index 0000000..7bfdcae --- /dev/null +++ b/server/requirements.txt @@ -0,0 +1,10 @@ +Pillow==11.2.1 +fastapi==0.104.1 +httpx==0.25.2 +msgpack==1.1.0 +openai==1.79.0 +requests==2.32.3 +schedule==1.2.2 +starlette==0.27.0 +uvicorn==0.24.0 +python-multipart==0.0.20 \ No newline at end of file diff --git a/web/.dockerignore b/web/.dockerignore new file mode 100644 index 0000000..9f06dbc --- /dev/null +++ b/web/.dockerignore @@ -0,0 +1,47 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# Node.js +node_modules +.next/cache +dist +.pnp +.pnp.js + +# Testing +/coverage + +# Production +/build + +# Misc +.DS_Store +*.pem + +# Vercel +.vercel + +# TypeScript +*.tsbuildinfo +next-env.d.ts + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# OS +Thumbs.db +drizzle +.claude/ +/.public/ + +# Docker specific +Dockerfile +.dockerignore \ No newline at end of file diff --git a/web/init.sql b/web/init.sql new file mode 100644 index 0000000..f5bbd05 --- /dev/null +++ b/web/init.sql @@ -0,0 +1,142 @@ +-- +-- SQLiteStudio v3.4.17 生成的文件,周三 8月 20 17:58:34 2025 +-- +-- 所用的文本编码:System +-- +PRAGMA foreign_keys = off; +BEGIN TRANSACTION; + +-- 表:__drizzle_migrations +CREATE TABLE IF NOT EXISTS __drizzle_migrations ( + id SERIAL PRIMARY KEY, + hash TEXT NOT NULL, + created_at NUMERIC +); + + +-- 表:account +CREATE TABLE IF NOT EXISTS account ( + id TEXT PRIMARY KEY + NOT NULL, + account_id TEXT NOT NULL, + provider_id TEXT NOT NULL, + user_id TEXT NOT NULL, + access_token TEXT, + refresh_token TEXT, + id_token TEXT, + access_token_expires_at INTEGER, + refresh_token_expires_at INTEGER, + scope TEXT, + password TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + FOREIGN KEY ( + user_id + ) + REFERENCES user (id) ON UPDATE NO ACTION + ON DELETE CASCADE +); + + +-- 表:points_accounts +CREATE TABLE IF NOT EXISTS points_accounts (-- 账户ID,使用自增整数作为主键,效率最高。 + account_id INTEGER PRIMARY KEY AUTOINCREMENT,-- 关联的用户ID,设置为 TEXT 类型以匹配您的设计。 + /* 添加 UNIQUE 约束确保一个用户只有一个积分账户。 */user_id TEXT NOT NULL + UNIQUE,-- 当前总积分,非负,默认为 0。 + total_points INTEGER NOT NULL + DEFAULT 0 + CHECK (total_points >= 0),-- 最后更新时间,使用 TEXT 存储 ISO8601 格式的日期时间。 + /* 在记录更新时,应由应用程序逻辑来更新此字段。 */updated_at TEXT NOT NULL +); + + +-- 表:points_transactions +CREATE TABLE IF NOT EXISTS points_transactions (-- 流水ID,使用自增整数主键。 + transaction_id INTEGER PRIMARY KEY AUTOINCREMENT,-- 关联的用户ID。 + user_id TEXT NOT NULL,-- 本次变动的积分数,正数代表增加,负数代表减少。 + points_change INTEGER NOT NULL,-- 变动原因代码,方便程序进行逻辑判断。 + reason_code TEXT NOT NULL,-- 变动原因的文字描述,可为空。 + description TEXT,-- 记录创建时间,默认为当前时间戳。 + created_at TEXT NOT NULL + DEFAULT CURRENT_TIMESTAMP +); + + +-- 表:session +CREATE TABLE IF NOT EXISTS session ( + id TEXT PRIMARY KEY + NOT NULL, + expires_at INTEGER NOT NULL, + token TEXT NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + ip_address TEXT, + user_agent TEXT, + user_id TEXT NOT NULL, + FOREIGN KEY ( + user_id + ) + REFERENCES user (id) ON UPDATE NO ACTION + ON DELETE CASCADE +); + + +-- 表:user +CREATE TABLE IF NOT EXISTS user ( + id TEXT PRIMARY KEY + NOT NULL, + name TEXT NOT NULL, + email TEXT NOT NULL, + email_verified INTEGER NOT NULL, + image TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + username TEXT, + display_username TEXT +); + + +-- 表:verification +CREATE TABLE IF NOT EXISTS verification ( + id TEXT PRIMARY KEY + NOT NULL, + identifier TEXT NOT NULL, + value TEXT NOT NULL, + expires_at INTEGER NOT NULL, + created_at INTEGER, + updated_at INTEGER +); + + +-- 索引:idx_points_accounts_user_id +CREATE INDEX IF NOT EXISTS idx_points_accounts_user_id ON points_accounts ( + user_id +); + + +-- 索引:idx_points_transactions_user_id +CREATE INDEX IF NOT EXISTS idx_points_transactions_user_id ON points_transactions ( + user_id +); + + +-- 索引:session_token_unique +CREATE UNIQUE INDEX IF NOT EXISTS session_token_unique ON session ( + token +); + + +-- 索引:user_email_unique +CREATE UNIQUE INDEX IF NOT EXISTS user_email_unique ON user ( + email +); + + +-- 索引:user_username_unique +CREATE UNIQUE INDEX IF NOT EXISTS user_username_unique ON user ( + username +); + + +COMMIT TRANSACTION; +PRAGMA foreign_keys = on; diff --git a/web/next.config.js b/web/next.config.js index fa5f6d3..f1ce147 100644 --- a/web/next.config.js +++ b/web/next.config.js @@ -8,6 +8,7 @@ const nextConfig = { compiler: { removeConsole: process.env.NODE_ENV === 'production', }, + output: 'standalone', }; module.exports = nextConfig; \ No newline at end of file diff --git a/web/package-lock.json b/web/package-lock.json index 72de57e..c496ddb 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -24,6 +24,7 @@ "dotenv": "^17.2.1", "drizzle-orm": "^0.44.4", "framer-motion": "^11.3.8", + "globby": "^14.1.0", "lucide-react": "^0.424.0", "next": "^14.2.5", "postcss": "^8.4.40", @@ -2520,6 +2521,18 @@ "node": ">=20.0.0" } }, + "node_modules/@sindresorhus/merge-streams": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-2.3.0.tgz", + "integrity": "sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@socket.io/component-emitter": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", @@ -6000,6 +6013,35 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/globby": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-14.1.0.tgz", + "integrity": "sha512-0Ia46fDOaT7k4og1PDW4YbodWWr3scS2vAr2lTbsplOt2WkKp0vQbkI9wKis/T5LV/dqPjO3bpS/z6GTJB82LA==", + "license": "MIT", + "dependencies": { + "@sindresorhus/merge-streams": "^2.1.0", + "fast-glob": "^3.3.3", + "ignore": "^7.0.3", + "path-type": "^6.0.0", + "slash": "^5.1.0", + "unicorn-magic": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globby/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/goober": { "version": "2.1.16", "resolved": "https://registry.npmjs.org/goober/-/goober-2.1.16.tgz", @@ -8234,6 +8276,18 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/path-type": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-6.0.0.tgz", + "integrity": "sha512-Vj7sf++t5pBD637NSfkxpHSMfWaeig5+DKWLhcqIYx6mWQz5hdJTGDVMQiJcw1ZYkhs7AazKDGpRVji1LJCZUQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -9432,6 +9486,18 @@ "simple-concat": "^1.0.0" } }, + "node_modules/slash": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz", + "integrity": "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==", + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/socket.io-client": { "version": "4.8.1", "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.1.tgz", @@ -10336,6 +10402,18 @@ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "license": "MIT" }, + "node_modules/unicorn-magic": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", + "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/unified": { "version": "11.0.5", "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", diff --git a/web/sqlite.db b/web/sqlite.db new file mode 100644 index 0000000..9100028 Binary files /dev/null and b/web/sqlite.db differ diff --git a/web/src/app/contact/page.tsx b/web/src/app/contact/page.tsx index bf17bdb..479dece 100644 --- a/web/src/app/contact/page.tsx +++ b/web/src/app/contact/page.tsx @@ -8,6 +8,9 @@ import { AiOutlineTikTok, AiFillQqCircle, AiOutlineGithub, AiOutlineTwitter, AiF export const metadata: Metadata = { title: '联系我们 - PodcastHub', description: '有任何问题或建议?请随时联系 PodcastHub 团队。我们期待您的声音。', + alternates: { + canonical: '/contact', + }, }; /** diff --git a/web/src/app/layout.tsx b/web/src/app/layout.tsx index bbbed9f..2197329 100644 --- a/web/src/app/layout.tsx +++ b/web/src/app/layout.tsx @@ -10,8 +10,9 @@ const inter = Inter({ }); export const metadata: Metadata = { - title: 'PodcastHub - 给创意一个真实的声音', - description: '使用AI技术将您的想法和内容转换为高质量的播客音频,支持多种语音和风格选择。', + metadataBase: new URL('https://www.podcasthub.com'), + title: 'PodcastHub: 您的AI播客创作平台 - 轻松将文字转化为高质量播客音频,支持多种语音和风格,让创意触手可及', + description: 'PodcastHub 利用尖端AI技术,为您的创意提供无限可能。轻松将文字和想法转化为专业品质的播客音频,支持多种个性化语音和风格选择。立即体验高效创作,让您的声音在全球范围内传播,吸引更多听众,并简化您的播客制作流程。', keywords: ['播客', 'AI', '语音合成', 'TTS', '音频生成'], authors: [{ name: 'PodcastHub Team' }], icons: { @@ -19,11 +20,18 @@ export const metadata: Metadata = { apple: '/favicon.webp', }, openGraph: { - title: 'PodcastHub - 给创意一个真实的声音', - description: '使用AI技术将您的想法和内容转换为高质量的播客音频,支持多种语音和风格选择。', + title: 'PodcastHub: 您的AI播客创作平台 - 轻松将文字转化为高质量播客音频,支持多种语音和风格,让创意触手可及', + description: 'PodcastHub 利用尖端AI技术,为您的创意提供无限可能。轻松将文字和想法转化为专业品质的播客音频,支持多种个性化语音和风格选择。立即体验高效创作,让您的声音在全球范围内传播,吸引更多听众,并简化您的播客制作流程。', type: 'website', locale: 'zh_CN', }, + twitter: { + card: 'summary_large_image', + title: 'PodcastHub: 您的AI播客创作平台 - 轻松将文字转化为高质量播客音频,支持多种语音和风格,让创意触手可及', + }, + alternates: { + canonical: '/', + }, }; export const viewport = { diff --git a/web/src/app/podcast/[fileName]/page.tsx b/web/src/app/podcast/[fileName]/page.tsx index 3f77a3a..ecc0a12 100644 --- a/web/src/app/podcast/[fileName]/page.tsx +++ b/web/src/app/podcast/[fileName]/page.tsx @@ -1,5 +1,20 @@ +import { Metadata } from 'next'; import PodcastContent from '@/components/PodcastContent'; +export async function generateMetadata({ params }: PodcastDetailPageProps): Promise { + const fileName = decodeURIComponent(params.fileName); + const title = `播客详情 - ${fileName}`; + const description = `收听 ${fileName} 的播客。`; + + return { + title, + description, + alternates: { + canonical: `/podcast/${fileName}`, + }, + }; +} + interface PodcastDetailPageProps { params: { fileName: string; diff --git a/web/src/app/pricing/page.tsx b/web/src/app/pricing/page.tsx index a15dbeb..460f16e 100644 --- a/web/src/app/pricing/page.tsx +++ b/web/src/app/pricing/page.tsx @@ -1,12 +1,20 @@ -'use client'; - -import React from 'react'; -import PricingSection from '@/components/PricingSection'; // 导入 PricingSection 组件 - -const PricingPage: React.FC = () => { - return ( - - ); +import { Metadata } from 'next'; + + import React from 'react'; + import PricingSection from '@/components/PricingSection'; // 导入 PricingSection 组件 + + export const metadata: Metadata = { + title: '定价 - PodcastHub', + description: '查看 PodcastHub 的灵活定价方案,找到最适合您的播客创作计划。', + alternates: { + canonical: '/pricing', + }, }; - -export default PricingPage; \ No newline at end of file + + const PricingPage: React.FC = () => { + return ( + + ); + }; + + export default PricingPage; \ No newline at end of file diff --git a/web/src/app/privacy/page.tsx b/web/src/app/privacy/page.tsx index ad8f26f..591a877 100644 --- a/web/src/app/privacy/page.tsx +++ b/web/src/app/privacy/page.tsx @@ -7,6 +7,9 @@ import { Metadata } from 'next'; export const metadata: Metadata = { title: '隐私政策 - PodcastHub', description: '了解 PodcastHub 如何保护您的隐私。我们致力于透明化地处理您的数据。', + alternates: { + canonical: '/privacy', + }, }; /** diff --git a/web/src/app/terms/page.tsx b/web/src/app/terms/page.tsx index 6eda44b..028d540 100644 --- a/web/src/app/terms/page.tsx +++ b/web/src/app/terms/page.tsx @@ -7,6 +7,9 @@ import { Metadata } from 'next'; export const metadata: Metadata = { title: '使用条款 - PodcastHub', description: '欢迎了解 PodcastHub 的使用条款。本条款旨在保护用户与平台的共同利益。', + alternates: { + canonical: '/terms', + }, }; /** diff --git a/web/src/components/PodcastCreator.tsx b/web/src/components/PodcastCreator.tsx index 867f5d6..f4e0d03 100644 --- a/web/src/components/PodcastCreator.tsx +++ b/web/src/components/PodcastCreator.tsx @@ -16,8 +16,10 @@ import { import { cn } from '@/lib/utils'; import ConfigSelector from './ConfigSelector'; import VoicesModal from './VoicesModal'; // 引入 VoicesModal +import LoginModal from './LoginModal'; // 引入 LoginModal import { useToast, ToastContainer } from './Toast'; // 引入 Toast Hook 和 Container import { setItem, getItem } from '@/lib/storage'; // 引入 localStorage 工具 +import { useSession } from '@/lib/auth-client'; // 引入 useSession import type { PodcastGenerationRequest, TTSConfig, Voice, SettingsFormData } from '@/types'; import { Satisfy } from 'next/font/google'; // 导入艺术字体 Satisfy @@ -72,6 +74,7 @@ const PodcastCreator: React.FC = ({ const [language, setLanguage] = useState(languageOptions[0].value); const [duration, setDuration] = useState(durationOptions[0].value); const [showVoicesModal, setShowVoicesModal] = useState(false); // 新增状态 + const [showLoginModal, setShowLoginModal] = useState(false); // 控制登录模态框的显示 const [voices, setVoices] = useState([]); // 从 ConfigSelector 获取 voices const [selectedPodcastVoices, setSelectedPodcastVoices] = useState<{[key: string]: Voice[]}>(() => { // 从 localStorage 读取缓存的说话人配置 @@ -83,8 +86,13 @@ const PodcastCreator: React.FC = ({ const fileInputRef = useRef(null); const { toasts, error } = useToast(); // 使用 useToast hook, 引入 success + const { data: session } = useSession(); // 获取 session const handleSubmit = async () => { // 修改为 async 函数 + if (!session?.user) { // 判断是否登录 + setShowLoginModal(true); // 未登录则显示登录模态框 + return; + } if (!topic.trim()) { error("主题不能为空", "请输入播客主题。"); // 使用 toast.error return; @@ -206,9 +214,9 @@ const PodcastCreator: React.FC = ({ -

+

给创意一个真实的声音 -

+ {/* 模式切换按钮 todo */} {/*
@@ -387,10 +395,10 @@ const PodcastCreator: React.FC = ({ {/* 创作按钮 */}