fix(voice file): introducing copylock to improve accessibility of vocie files

This commit is contained in:
Symb0x76
2025-10-14 15:11:55 +08:00
parent b7f2102e9a
commit 8925bd1cd4
4 changed files with 172 additions and 101 deletions
+4
View File
@@ -0,0 +1,4 @@
# TODO
- [ ] 修复 html 渲染部分头像缺失的问题
- [ ] 美化搜索界面使风格一致
+34 -88
View File
@@ -906,7 +906,9 @@ func (s *Service) handleSearch(c *gin.Context) {
resp.Offset = offset
format := strings.ToLower(strings.TrimSpace(params.Format))
terms := extractQueryTerms(resp.Query)
if format == "" {
format = "json"
}
switch format {
case "html":
@@ -966,9 +968,9 @@ func (s *Service) handleSearch(c *gin.Context) {
c.Writer.WriteString("</div>")
c.Writer.WriteString("<div class=\"meta-row\"><span>时间:" + template.HTMLEscapeString(msg.Time.Format("2006-01-02 15:04:05")) + "</span><span>发送者:" + template.HTMLEscapeString(senderDisplay) + "</span></div>")
if snippet := strings.TrimSpace(hit.Snippet); snippet != "" {
h := highlightPlainTextToHTML(snippet, terms)
h = strings.ReplaceAll(h, "\n", "<br/>")
c.Writer.WriteString("<div class=\"snippet\">" + h + "</div>")
escaped := template.HTMLEscapeString(snippet)
escaped = strings.ReplaceAll(escaped, "\n", "<br/>")
c.Writer.WriteString("<div class=\"snippet\">" + escaped + "</div>")
}
c.Writer.WriteString("<div class=\"msg-content\"><pre>" + messageHTMLPlaceholder(msg) + "</pre></div>")
c.Writer.WriteString("</div>")
@@ -1055,7 +1057,7 @@ func (s *Service) handleSearch(c *gin.Context) {
}
csvWriter.Flush()
return
case "json", "":
case "json":
c.JSON(http.StatusOK, resp)
return
default:
@@ -1091,6 +1093,11 @@ func (s *Service) handleChatlog(c *gin.Context) {
q.Offset = 0
}
format := strings.ToLower(strings.TrimSpace(q.Format))
if format == "" {
format = "json"
}
// 1. 未指定 talker: 分组输出
if q.Talker == "" {
sessionsResp, err := s.db.GetSessions("", 0, 0)
@@ -1111,7 +1118,7 @@ func (s *Service) handleChatlog(c *gin.Context) {
}
groups = append(groups, &grouped{Talker: sess.UserName, TalkerName: sess.NickName, Messages: msgs})
}
switch strings.ToLower(q.Format) {
switch format {
case "html":
c.Writer.Header().Set("Content-Type", "text/html; charset=utf-8")
c.Writer.WriteString("<html><head><meta charset=\"utf-8\"><title>Chatlog</title><style>body{font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:1.4;}details{margin:8px 0;padding:4px 8px;border:1px solid #ddd;border-radius:4px; background:#fafafa;}summary{cursor:pointer;font-weight:600;} .msg{margin:4px 0;padding:4px 6px;border-left:3px solid #3498db;background:#fff;} .msg-row{display:flex;gap:8px;align-items:flex-start;} .avatar{width:28px;height:28px;border-radius:6px;object-fit:cover;background:#f2f2f2;border:1px solid #eee;flex:0 0 28px} .msg-content{flex:1;min-width:0} .meta{color:#666;font-size:12px;} pre{white-space:pre-wrap;word-break:break-word;margin:2px 0;} .talker{color:#2c3e50;} .sender{color:#8e44ad;} .time{color:#16a085;} .content{margin-left:4px;} a.media{color:#2c3e50;text-decoration:none;} a.media:hover{text-decoration:underline;}</style></head><body>")
@@ -1156,7 +1163,7 @@ func (s *Service) handleChatlog(c *gin.Context) {
}
}
csvWriter.Flush()
default:
case "text", "plain":
c.Writer.Header().Set("Content-Type", "text/plain; charset=utf-8")
c.Writer.Header().Set("Cache-Control", "no-cache")
c.Writer.Header().Set("Connection", "keep-alive")
@@ -1179,6 +1186,8 @@ func (s *Service) handleChatlog(c *gin.Context) {
}
c.Writer.WriteString("-----------------------------\n")
}
default:
c.JSON(http.StatusOK, groups)
}
return
}
@@ -1189,7 +1198,7 @@ func (s *Service) handleChatlog(c *gin.Context) {
errors.Err(c, err)
return
}
switch strings.ToLower(q.Format) {
switch format {
case "html":
c.Writer.Header().Set("Content-Type", "text/html; charset=utf-8")
c.Writer.WriteString("<html><head><meta charset=\"utf-8\"><title>Chatlog</title><style>body{font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:1.4;} .msg{margin:8px 0;padding:6px 8px;border-left:3px solid #3498db;background:#fafafa;} .msg-row{display:flex;gap:8px;align-items:flex-start;} .avatar{width:28px;height:28px;border-radius:6px;object-fit:cover;background:#f2f2f2;border:1px solid #eee;flex:0 0 28px} .msg-content{flex:1;min-width:0} .meta{color:#666;font-size:12px;margin-bottom:2px;} pre{white-space:pre-wrap;word-break:break-word;margin:0;} .sender{color:#8e44ad;} .time{color:#16a085;margin-left:6px;} a.media{color:#2c3e50;text-decoration:none;} a.media:hover{text-decoration:underline;}</style></head><body>")
@@ -1260,7 +1269,10 @@ func (s *Service) handleContacts(c *gin.Context) {
return
}
format := strings.ToLower(q.Format)
format := strings.ToLower(strings.TrimSpace(q.Format))
if format == "" {
format = "json"
}
switch format {
case "html":
c.Writer.Header().Set("Content-Type", "text/html; charset=utf-8")
@@ -1375,7 +1387,10 @@ func (s *Service) handleChatRooms(c *gin.Context) {
errors.Err(c, err)
return
}
format := strings.ToLower(q.Format)
format := strings.ToLower(strings.TrimSpace(q.Format))
if format == "" {
format = "json"
}
switch format {
case "json":
// json
@@ -1419,7 +1434,10 @@ func (s *Service) handleSessions(c *gin.Context) {
errors.Err(c, err)
return
}
format := strings.ToLower(q.Format)
format := strings.ToLower(strings.TrimSpace(q.Format))
if format == "" {
format = "json"
}
switch format {
case "html":
c.Writer.Header().Set("Content-Type", "text/html; charset=utf-8")
@@ -1537,7 +1555,10 @@ func (s *Service) handleDiary(c *gin.Context) {
groups = append(groups, &grouped{Talker: sess.UserName, TalkerName: sess.NickName, Messages: msgs})
}
format := strings.ToLower(q.Format)
format := strings.ToLower(strings.TrimSpace(q.Format))
if format == "" {
format = "json"
}
switch format {
case "html":
c.Writer.Header().Set("Content-Type", "text/html; charset=utf-8")
@@ -1812,8 +1833,7 @@ func (s *Service) HandleVoice(c *gin.Context, data []byte) {
// 统一占位符:将 PlainTextContent 里形如 ![标签](url) 或 [标签](url) 的模式转成超链接形式,仅显示 [标签]。
var (
placeholderPattern = regexp.MustCompile(`!?\[([^\]]+)\]\((https?://[^)]+)\)`)
searchTermSplitPattern = regexp.MustCompile(`[\s,,;;、]+`)
placeholderPattern = regexp.MustCompile(`!?\[([^\]]+)\]\((https?://[^)]+)\)`)
)
func messageHTMLPlaceholder(m *model.Message) string {
@@ -1857,77 +1877,3 @@ func messageHTMLPlaceholder(m *model.Message) string {
return anchor
})
}
func extractQueryTerms(raw string) []string {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil
}
parts := searchTermSplitPattern.Split(raw, -1)
terms := make([]string, 0, len(parts))
seen := make(map[string]struct{})
for _, part := range parts {
term := strings.Trim(part, "\"'()[]{}")
if term == "" {
continue
}
switch strings.ToUpper(term) {
case "AND", "OR", "NOT":
continue
}
key := strings.ToLower(term)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
terms = append(terms, term)
}
return terms
}
func highlightPlainTextToHTML(text string, terms []string) string {
if text == "" {
return ""
}
if len(terms) == 0 {
return template.HTMLEscapeString(text)
}
unique := make(map[string]struct{})
escaped := make([]string, 0, len(terms))
for _, term := range terms {
trimmed := strings.TrimSpace(term)
if trimmed == "" {
continue
}
key := strings.ToLower(trimmed)
if _, ok := unique[key]; ok {
continue
}
unique[key] = struct{}{}
escaped = append(escaped, regexp.QuoteMeta(trimmed))
}
if len(escaped) == 0 {
return template.HTMLEscapeString(text)
}
sort.Slice(escaped, func(i, j int) bool { return len(escaped[i]) > len(escaped[j]) })
pattern := strings.Join(escaped, "|")
re, err := regexp.Compile("(?i)" + pattern)
if err != nil {
return template.HTMLEscapeString(text)
}
matches := re.FindAllStringIndex(text, -1)
if len(matches) == 0 {
return template.HTMLEscapeString(text)
}
var builder strings.Builder
last := 0
for _, loc := range matches {
builder.WriteString(template.HTMLEscapeString(text[last:loc[0]]))
builder.WriteString(`<mark class="search-highlight">`)
builder.WriteString(template.HTMLEscapeString(text[loc[0]:loc[1]]))
builder.WriteString("</mark>")
last = loc[1]
}
builder.WriteString(template.HTMLEscapeString(text[last:]))
return builder.String()
}
+17 -13
View File
@@ -57,6 +57,7 @@ type FileCopyManager struct {
cancel context.CancelFunc // Cancel function for graceful shutdown
wg sync.WaitGroup // WaitGroup for goroutine synchronization
cacheSize int64 // Current number of cached entries (atomic)
copyLocks sync.Map // Per-cacheKey mutexes to avoid duplicate copies
}
// FileIndexEntry represents an indexed temporary file with comprehensive metadata.
@@ -608,30 +609,28 @@ func (fm *FileCopyManager) GetTempCopy(originalPath string) (string, error) {
expectedDataHash = fmt.Sprintf("%x", currentSize+currentModTime.UnixNano())[:16]
}
// Strategy 1: Check index for existing file using unified cache key
baseName := fm.extractBaseName(originalPath)
ext := extractFileExtension(originalPath)
// Use unified cache key generation
cacheKey := fm.generateCacheKey(fm.instanceID, baseName, ext, currentHash, expectedDataHash)
lock := fm.acquireCopyLock(cacheKey)
lock.Lock()
defer lock.Unlock()
var oldTempPath string // Track old file to delete after successful creation
if value, exists := fm.fileIndex.Load(cacheKey); exists {
entry := value.(*FileIndexEntry)
// Found cached file, verify it still exists and matches
if _, err := os.Stat(entry.TempPath); err == nil && currentSize == entry.Size {
// File exists and size matches, reuse cached copy
entry.SetLastAccess(now) // Update access time atomically
entry.SetOriginalPath(originalPath) // Update original path thread-safely
entry.SetLastAccess(now)
entry.SetOriginalPath(originalPath)
return entry.TempPath, nil
} else {
// Cached file is missing or size mismatch, remove from index
fm.fileIndex.Delete(cacheKey)
atomic.AddInt64(&fm.cacheSize, -1)
if err == nil {
// File exists but size mismatch, mark for cleanup
oldTempPath = entry.TempPath
}
}
fm.fileIndex.Delete(cacheKey)
atomic.AddInt64(&fm.cacheSize, -1)
if err == nil {
oldTempPath = entry.TempPath
}
}
@@ -677,6 +676,11 @@ func (fm *FileCopyManager) GetTempCopy(originalPath string) (string, error) {
return tempPath, nil
}
func (fm *FileCopyManager) acquireCopyLock(cacheKey string) *sync.Mutex {
lockIface, _ := fm.copyLocks.LoadOrStore(cacheKey, &sync.Mutex{})
return lockIface.(*sync.Mutex)
}
// generateTempPath creates a unique temporary file path using a structured naming convention.
// The format is: instanceID_+baseName_+ext_+pathHash_+dataHash.ext
// This naming scheme uses "_+" separator to avoid conflicts with filenames containing underscores.
+117
View File
@@ -1,17 +1,44 @@
package silk
import (
"bytes"
"compress/gzip"
"compress/zlib"
"encoding/binary"
"fmt"
"io"
"github.com/rs/zerolog/log"
"github.com/sjzar/go-lame"
"github.com/sjzar/go-silk"
"github.com/sjzar/chatlog/pkg/util/zstd"
)
const decodedSampleRate = 24000
// Silk2PCM16 解码 Silk 数据,返回 16-bit PCM 采样数据及采样率。
func Silk2PCM16(data []byte) ([]int16, int, error) {
samples, rate, err := decodeSilk(data)
if err == nil {
return samples, rate, nil
}
log.Debug().Err(err).Msg("silk decode failed, retry with normalized payload")
normalized := normalizeSilkPayload(data)
if bytes.Equal(normalized, data) {
log.Error().Err(err).Msg("silk decode failed; payload unchanged after normalization")
return nil, 0, err
}
samples, rate, err = decodeSilk(normalized)
if err != nil {
log.Error().Err(err).Msg("silk decode failed after normalization")
}
return samples, rate, err
}
func decodeSilk(data []byte) ([]int16, int, error) {
sd := silk.SilkInit()
defer sd.Close()
@@ -31,6 +58,96 @@ func Silk2PCM16(data []byte) ([]int16, int, error) {
return samples, decodedSampleRate, nil
}
var (
silkMagic = []byte("#!SILK")
zstdMagic = []byte{0x28, 0xb5, 0x2f, 0xfd}
)
func normalizeSilkPayload(data []byte) []byte {
current := data
for i := 0; i < 3; i++ {
trimmed := bytes.TrimLeft(current, "\x00\xff")
if idx := bytes.Index(trimmed, silkMagic); idx >= 0 {
return trimmed[idx:]
}
if next, ok := tryDecompress(trimmed); ok {
current = next
continue
}
if !bytes.Equal(trimmed, current) {
current = trimmed
continue
}
break
}
return current
}
type decompressor struct {
name string
match func([]byte) bool
fn func([]byte) ([]byte, error)
}
var decompressors = []decompressor{
{
name: "zstd",
match: func(b []byte) bool {
return len(b) >= 4 && bytes.Equal(b[:4], zstdMagic)
},
fn: func(b []byte) ([]byte, error) {
return zstd.Decompress(b)
},
},
{
name: "gzip",
match: func(b []byte) bool {
return len(b) >= 2 && b[0] == 0x1f && b[1] == 0x8b
},
fn: func(b []byte) ([]byte, error) {
reader, err := gzip.NewReader(bytes.NewReader(b))
if err != nil {
return nil, err
}
defer reader.Close()
return io.ReadAll(reader)
},
},
{
name: "zlib",
match: func(b []byte) bool {
return len(b) >= 2 && b[0] == 0x78
},
fn: func(b []byte) ([]byte, error) {
reader, err := zlib.NewReader(bytes.NewReader(b))
if err != nil {
return nil, err
}
defer reader.Close()
return io.ReadAll(reader)
},
},
}
func tryDecompress(data []byte) ([]byte, bool) {
for _, dc := range decompressors {
if !dc.match(data) {
continue
}
out, err := dc.fn(data)
if err != nil {
log.Debug().Str("codec", dc.name).Err(err).Msg("silk payload decompress failed")
continue
}
log.Debug().Str("codec", dc.name).Msg("silk payload decompressed")
return out, true
}
return nil, false
}
func Silk2MP3(data []byte) ([]byte, error) {
sd := silk.SilkInit()