fix(voice file): introducing copylock to improve accessibility of vocie files
This commit is contained in:
@@ -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) 的模式转成超链接形式,仅显示 [标签]。
|
||||
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
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user