feat: add reader endpoint and update TTS link generation with display name

This commit is contained in:
王锦强
2025-03-16 23:29:16 +08:00
parent 1cc4ef556c
commit ebb7e2b292
5 changed files with 99 additions and 5 deletions

View File

@@ -1,6 +1,7 @@
package handlers package handlers
import ( import (
"encoding/json"
"fmt" "fmt"
"log" "log"
"net/http" "net/http"
@@ -19,6 +20,8 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
var cfg = config.Get()
// truncateForLog 截断文本用于日志显示,同时显示开头和结尾 // truncateForLog 截断文本用于日志显示,同时显示开头和结尾
func truncateForLog(text string, maxLength int) string { func truncateForLog(text string, maxLength int) string {
// 先去除换行符 // 先去除换行符
@@ -450,6 +453,57 @@ func (h *TTSHandler) handleSegmentedTTS(c *gin.Context, req models.TTSRequest) {
totalTime, splitTime, synthesisTime, writeTime, formatFileSize(len(audioData))) totalTime, splitTime, synthesisTime, writeTime, formatFileSize(len(audioData)))
} }
// HandleReader 返回 reader 可导入的格式
func (h *TTSHandler) HandleReader(context *gin.Context) {
// 从URL参数获取
req := models.TTSRequest{
Text: context.Query("t"),
Voice: context.Query("v"),
Rate: context.Query("r"),
Pitch: context.Query("p"),
Style: context.Query("s"),
}
displayName := context.Query("n")
baseUrl := utils.GetBaseURL(context)
basePath, err := utils.JoinURL(baseUrl, cfg.Server.BasePath)
if err != nil {
context.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// 构建基本URL
urlParams := []string{"t={{java.encodeURI(speakText)}}", "r={{speakSpeed*4}}"}
// 只有有值的参数才添加
if req.Voice != "" {
urlParams = append(urlParams, fmt.Sprintf("v=%s", req.Voice))
}
if req.Pitch != "" {
urlParams = append(urlParams, fmt.Sprintf("p=%s", req.Pitch))
}
if req.Style != "" {
urlParams = append(urlParams, fmt.Sprintf("s=%s", req.Style))
}
if cfg.TTS.ApiKey != "" {
urlParams = append(urlParams, fmt.Sprintf("api_key=%s", cfg.TTS.ApiKey))
}
url := fmt.Sprintf("%s/tts?%s", basePath, strings.Join(urlParams, "&"))
encoder := json.NewEncoder(context.Writer)
encoder.SetEscapeHTML(false)
context.Status(http.StatusOK)
encoder.Encode(models.ReaderResponse{
Id: time.Now().Unix(),
Name: displayName,
Url: url,
})
}
// splitTextBySentences 将文本按句子分割 // splitTextBySentences 将文本按句子分割
func splitTextBySentences(text string) []string { func splitTextBySentences(text string) []string {
// 如果文本过短,直接作为一个句子返回 // 如果文本过短,直接作为一个句子返回
@@ -457,9 +511,8 @@ func splitTextBySentences(text string) []string {
return []string{text} return []string{text}
} }
cfg := config.Get().TTS maxLen := cfg.TTS.MaxSentenceLength
maxLen := cfg.MaxSentenceLength minLen := cfg.TTS.MinSentenceLength
minLen := cfg.MinSentenceLength
// 第一次分割:按标点和长度限制分割 // 第一次分割:按标点和长度限制分割
sentences := utils.SplitAndFilterEmptyLines(text) sentences := utils.SplitAndFilterEmptyLines(text)

View File

@@ -50,6 +50,7 @@ func SetupRoutes(cfg *config.Config, ttsService tts.Service) (*gin.Engine, error
baseRouter.POST("/tts", middleware.TTSAuth(cfg.TTS.ApiKey), ttsHandler.HandleTTS) baseRouter.POST("/tts", middleware.TTSAuth(cfg.TTS.ApiKey), ttsHandler.HandleTTS)
baseRouter.GET("/tts", middleware.TTSAuth(cfg.TTS.ApiKey), ttsHandler.HandleTTS) baseRouter.GET("/tts", middleware.TTSAuth(cfg.TTS.ApiKey), ttsHandler.HandleTTS)
baseRouter.GET("/reader.json", middleware.TTSAuth(cfg.TTS.ApiKey), ttsHandler.HandleReader)
// 设置语音列表API路由 // 设置语音列表API路由
baseRouter.GET("/voices", voicesHandler.HandleVoices) baseRouter.GET("/voices", voicesHandler.HandleVoices)

View File

@@ -23,3 +23,10 @@ type OpenAIRequest struct {
Voice string `json:"voice"` Voice string `json:"voice"`
Speed float64 `json:"speed"` Speed float64 `json:"speed"`
} }
// ReaderResponse reader 响应结构体
type ReaderResponse struct {
Id int64 `json:"id"`
Name string `json:"name"`
Url string `json:"url"`
}

View File

@@ -8,6 +8,7 @@ import (
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"fmt" "fmt"
"github.com/gin-gonic/gin"
"net/http" "net/http"
"net/url" "net/url"
"strings" "strings"
@@ -157,3 +158,28 @@ func MergeStringsWithLimit(strs []string, minLen int, maxLen int) []string {
return result return result
} }
// GetBaseURL 返回基础 URL包括方案和主机但不包括路径和查询参数
func GetBaseURL(c *gin.Context) string {
scheme := "http"
if c.Request.TLS != nil || c.Request.Header.Get("X-Forwarded-Proto") == "https" {
scheme = "https"
}
return fmt.Sprintf("%s://%s", scheme, c.Request.Host)
}
// JoinURL 安全地拼接基础 URL 和相对路径
func JoinURL(baseURL, relativePath string) (string, error) {
base, err := url.Parse(baseURL)
if err != nil {
return "", err
}
rel, err := url.Parse(relativePath)
if err != nil {
return "", err
}
return base.ResolveReference(rel).String(), nil
}

View File

@@ -211,13 +211,14 @@ document.addEventListener('DOMContentLoaded', function () {
copyHttpTtsLinkButton.addEventListener('click', function () { copyHttpTtsLinkButton.addEventListener('click', function () {
const text = "{{java.encodeURI(speakText)}}"; const text = "{{java.encodeURI(speakText)}}";
const voice = voiceSelect.value; const voice = voiceSelect.value;
const displayName = voiceSelect.options[voiceSelect.selectedIndex].text;
const style = styleSelect.value; const style = styleSelect.value;
const rate = "{{speakSpeed*4}}" const rate = "{{speakSpeed*4}}"
const pitch = pitchInput.value; const pitch = pitchInput.value;
const apiKey = apiKeyInput.value.trim(); const apiKey = apiKeyInput.value.trim();
// 构建HttpTTS链接 // 构建HttpTTS链接
let httpTtsLink = `${window.location.origin}${config.basePath}/tts?t=${text}&v=${voice}&r=${rate}&p=${pitch}`; let httpTtsLink = `${window.location.origin}${config.basePath}/reader.json?&v=${voice}&r=${rate}&p=${pitch}&n=${displayName}`;
// 只有当style不为空时才添加 // 只有当style不为空时才添加
if (style) { if (style) {
@@ -229,7 +230,7 @@ document.addEventListener('DOMContentLoaded', function () {
httpTtsLink += `&api_key=${apiKey}`; httpTtsLink += `&api_key=${apiKey}`;
} }
copyToClipboard(httpTtsLink); window.open(httpTtsLink, '_blank')
}); });
// 显示/隐藏API Key区域的按钮事件 // 显示/隐藏API Key区域的按钮事件
@@ -421,8 +422,10 @@ document.addEventListener('DOMContentLoaded', function () {
// 复制内容到剪贴板的通用函数 // 复制内容到剪贴板的通用函数
function copyToClipboard(text) { function copyToClipboard(text) {
let success = false;
navigator.clipboard.writeText(text).then(() => { navigator.clipboard.writeText(text).then(() => {
showCustomAlert('链接已复制到剪贴板', 'success'); showCustomAlert('链接已复制到剪贴板', 'success');
success = true;
}).catch(err => { }).catch(err => {
console.error('复制失败:', err); console.error('复制失败:', err);
// 兼容处理 // 兼容处理
@@ -435,12 +438,16 @@ document.addEventListener('DOMContentLoaded', function () {
try { try {
document.execCommand('copy'); document.execCommand('copy');
showCustomAlert('链接已复制到剪贴板', 'success'); showCustomAlert('链接已复制到剪贴板', 'success');
success = true;
} catch (err) { } catch (err) {
console.error('复制失败:', err); console.error('复制失败:', err);
shwoCustomAlert('复制失败', 'error');
success = false;
} }
document.body.removeChild(textArea); document.body.removeChild(textArea);
}); });
return success;
} }
// 添加通知函数 // 添加通知函数