refactor: split mutex, add download queue, wire context cancellation
All checks were successful
CI / build (push) Successful in 53s
All checks were successful
CI / build (push) Successful in 53s
- Split single sync.Mutex into per-map mutexes (activeJobs, stepStates, rateLimiter, pendingInput, cancelFuncs) to reduce contention - Add download semaphore (channel-buffered, 3 concurrent) as worker pool - Add cancelFuncs map with context cancellation wired into download goroutines for clean shutdown - Replace playlist busy-poll (500ms sleep loop) with synchronous runDownload + Done channel for completion tracking - Use full UUID for job IDs instead of 8-char prefix - Download thumbnail URL to temp file before sending (InputFileUpload) - Change MAX_FILE_SIZE_MB default from 2000 to 50 - Clean up cancel funcs after normal download completion
This commit is contained in:
@@ -3,8 +3,10 @@ package handler
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"math"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -33,20 +35,20 @@ type stepState struct {
|
||||
}
|
||||
|
||||
func (h *Handler) getUserStepState(chatID int64) *stepState {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
h.stepStatesMu.Lock()
|
||||
defer h.stepStatesMu.Unlock()
|
||||
return h.stepStates[chatID]
|
||||
}
|
||||
|
||||
func (h *Handler) setUserStepState(chatID int64, ss *stepState) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
h.stepStatesMu.Lock()
|
||||
defer h.stepStatesMu.Unlock()
|
||||
h.stepStates[chatID] = ss
|
||||
}
|
||||
|
||||
func (h *Handler) clearStepState(chatID int64) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
h.stepStatesMu.Lock()
|
||||
defer h.stepStatesMu.Unlock()
|
||||
delete(h.stepStates, chatID)
|
||||
}
|
||||
|
||||
@@ -355,6 +357,8 @@ func (h *Handler) handleContainerSelection(ctx context.Context, chatID int64, ms
|
||||
return
|
||||
}
|
||||
|
||||
dlCtx, dlCancel := context.WithCancel(context.Background())
|
||||
|
||||
job := &domain.DownloadJob{
|
||||
ID: h.generateJobID(),
|
||||
UserID: userID,
|
||||
@@ -369,17 +373,33 @@ func (h *Handler) handleContainerSelection(ctx context.Context, chatID int64, ms
|
||||
Status: domain.StatusDownloading,
|
||||
Title: ss.MediaInfo.Title,
|
||||
FileSize: fileSize,
|
||||
Done: make(chan struct{}),
|
||||
}
|
||||
|
||||
h.editText(ctx, chatID, msgID, "Processing...", nil)
|
||||
h.setActiveJob(userID, job)
|
||||
h.setCancelFunc(userID, dlCancel)
|
||||
h.clearStepState(chatID)
|
||||
|
||||
go h.runDownload(context.Background(), job)
|
||||
go h.runDownload(dlCtx, job)
|
||||
}
|
||||
|
||||
// runDownload manages the download lifecycle, progress, and completion.
|
||||
func (h *Handler) runDownload(ctx context.Context, job *domain.DownloadJob) {
|
||||
defer func() {
|
||||
if job.Done != nil {
|
||||
close(job.Done)
|
||||
}
|
||||
}()
|
||||
|
||||
// Acquire semaphore slot or wait for cancellation
|
||||
select {
|
||||
case h.downloadSem <- struct{}{}:
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
defer func() { <-h.downloadSem }()
|
||||
|
||||
kb := progressKeyboard(0, "", "")
|
||||
msg, err := h.Bot.SendMessage(ctx, &tgbot.SendMessageParams{
|
||||
ChatID: job.ChatID,
|
||||
@@ -396,61 +416,77 @@ func (h *Handler) runDownload(ctx context.Context, job *domain.DownloadJob) {
|
||||
if err != nil {
|
||||
h.editText(ctx, job.ChatID, job.MessageID, fmt.Sprintf("Download failed: %s", err.Error()), nil)
|
||||
job.Status = domain.StatusFailed
|
||||
h.mu.Lock()
|
||||
h.activeJobsMu.Lock()
|
||||
delete(h.activeJobs, job.UserID)
|
||||
h.mu.Unlock()
|
||||
h.activeJobsMu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
lastProgress := 0.0
|
||||
for upd := range updates {
|
||||
slog.Info("progress update", "pct", upd.Progress, "speed", upd.Speed, "status", upd.Status)
|
||||
loop:
|
||||
for {
|
||||
select {
|
||||
case upd, ok := <-updates:
|
||||
if !ok {
|
||||
break loop
|
||||
}
|
||||
slog.Info("progress update", "pct", upd.Progress, "speed", upd.Speed, "status", upd.Status)
|
||||
|
||||
h.mu.Lock()
|
||||
currentJob := h.activeJobs[job.UserID]
|
||||
h.mu.Unlock()
|
||||
if currentJob == nil {
|
||||
return
|
||||
h.activeJobsMu.Lock()
|
||||
currentJob := h.activeJobs[job.UserID]
|
||||
h.activeJobsMu.Unlock()
|
||||
if currentJob == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if upd.Status == domain.StatusCancelled {
|
||||
h.editText(ctx, job.ChatID, job.MessageID, "Download cancelled.", nil)
|
||||
h.activeJobsMu.Lock()
|
||||
delete(h.activeJobs, job.UserID)
|
||||
h.activeJobsMu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
if upd.Status == domain.StatusFailed {
|
||||
h.editText(ctx, job.ChatID, job.MessageID, "Download failed.", nil)
|
||||
h.activeJobsMu.Lock()
|
||||
delete(h.activeJobs, job.UserID)
|
||||
h.activeJobsMu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
if upd.Progress > 0 && lastProgress == 0 {
|
||||
lastProgress = upd.Progress
|
||||
} else if upd.Progress-lastProgress < domain.ProgressThreshold && upd.Status != domain.StatusCompleted {
|
||||
continue
|
||||
} else {
|
||||
lastProgress = upd.Progress
|
||||
}
|
||||
|
||||
if upd.Progress > domain.QuotaProgressThreshold && !job.QuotaApplied && job.FileSize > 0 {
|
||||
job.QuotaApplied = true
|
||||
h.applyQuota(job.UserID, job.FileSize)
|
||||
}
|
||||
|
||||
kb := progressKeyboard(upd.Progress, upd.Speed, upd.ETA)
|
||||
text := fmt.Sprintf("Downloading: %s", job.Title)
|
||||
h.editText(ctx, job.ChatID, job.MessageID, text, &kb)
|
||||
|
||||
case <-ctx.Done():
|
||||
dl.CancelDownload(job)
|
||||
for range updates {
|
||||
}
|
||||
break loop
|
||||
}
|
||||
|
||||
if upd.Status == domain.StatusCancelled {
|
||||
h.editText(ctx, job.ChatID, job.MessageID, "Download cancelled.", nil)
|
||||
h.mu.Lock()
|
||||
delete(h.activeJobs, job.UserID)
|
||||
h.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
if upd.Status == domain.StatusFailed {
|
||||
h.editText(ctx, job.ChatID, job.MessageID, "Download failed.", nil)
|
||||
h.mu.Lock()
|
||||
delete(h.activeJobs, job.UserID)
|
||||
h.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
// Always show the first real progress update, then throttle at 5% intervals
|
||||
if upd.Progress > 0 && lastProgress == 0 {
|
||||
lastProgress = upd.Progress
|
||||
} else if upd.Progress-lastProgress < domain.ProgressThreshold && upd.Status != domain.StatusCompleted {
|
||||
continue
|
||||
} else {
|
||||
lastProgress = upd.Progress
|
||||
}
|
||||
|
||||
if upd.Progress > domain.QuotaProgressThreshold && !job.QuotaApplied && job.FileSize > 0 {
|
||||
job.QuotaApplied = true
|
||||
h.applyQuota(job.UserID, job.FileSize)
|
||||
}
|
||||
|
||||
kb := progressKeyboard(upd.Progress, upd.Speed, upd.ETA)
|
||||
text := fmt.Sprintf("Downloading: %s", job.Title)
|
||||
h.editText(ctx, job.ChatID, job.MessageID, text, &kb)
|
||||
}
|
||||
|
||||
h.mu.Lock()
|
||||
h.activeJobsMu.Lock()
|
||||
delete(h.activeJobs, job.UserID)
|
||||
h.mu.Unlock()
|
||||
h.activeJobsMu.Unlock()
|
||||
|
||||
h.cancelFuncsMu.Lock()
|
||||
delete(h.cancelFuncs, job.UserID)
|
||||
h.cancelFuncsMu.Unlock()
|
||||
|
||||
if job.Status == domain.StatusCompleted {
|
||||
pattern := filepath.Join(h.downloadDir, job.ID+"_*")
|
||||
@@ -518,12 +554,19 @@ func (h *Handler) sendDocument(ctx context.Context, chatID int64, filePath, titl
|
||||
func (h *Handler) handleCancelDownload(ctx context.Context, chatID int64, msgID int, cbID string, userID int64) {
|
||||
defer h.answerCb(ctx, cbID)
|
||||
|
||||
h.cancelFuncsMu.Lock()
|
||||
if cancel, ok := h.cancelFuncs[userID]; ok {
|
||||
cancel()
|
||||
delete(h.cancelFuncs, userID)
|
||||
}
|
||||
h.cancelFuncsMu.Unlock()
|
||||
|
||||
job := h.getActiveJob(userID)
|
||||
if job != nil {
|
||||
dl.CancelDownload(job)
|
||||
h.mu.Lock()
|
||||
h.activeJobsMu.Lock()
|
||||
delete(h.activeJobs, userID)
|
||||
h.mu.Unlock()
|
||||
h.activeJobsMu.Unlock()
|
||||
h.editText(ctx, chatID, msgID, "Download cancelled.", nil)
|
||||
return
|
||||
}
|
||||
@@ -647,21 +690,71 @@ func (h *Handler) sendMediaPreview(ctx context.Context, chatID int64, info *doma
|
||||
}
|
||||
|
||||
if info.Thumbnail != "" {
|
||||
photo := &models.InputFileString{Data: info.Thumbnail}
|
||||
_, err := h.Bot.SendPhoto(ctx, &tgbot.SendPhotoParams{
|
||||
ChatID: chatID,
|
||||
Photo: photo,
|
||||
Caption: caption,
|
||||
thumbPath, err := downloadFile(info.Thumbnail, h.downloadDir)
|
||||
if err != nil {
|
||||
slog.Warn("download thumbnail failed, falling back to text", "url", info.Thumbnail, "error", err)
|
||||
h.sendText(ctx, chatID, caption)
|
||||
return
|
||||
}
|
||||
defer os.Remove(thumbPath)
|
||||
|
||||
f, err := os.Open(thumbPath)
|
||||
if err != nil {
|
||||
slog.Warn("open thumbnail failed, falling back to text", "error", err)
|
||||
h.sendText(ctx, chatID, caption)
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
photo := &models.InputFileUpload{Filename: "thumb.jpg", Data: f}
|
||||
_, err = h.Bot.SendPhoto(ctx, &tgbot.SendPhotoParams{
|
||||
ChatID: chatID, Photo: photo, Caption: caption,
|
||||
})
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
slog.Warn("send thumbnail failed, falling back to text", "error", err)
|
||||
slog.Warn("send thumbnail photo failed, falling back to text", "error", err)
|
||||
}
|
||||
|
||||
h.sendText(ctx, chatID, caption)
|
||||
}
|
||||
|
||||
// downloadFile downloads a URL to a temp file in the given directory and returns the path.
|
||||
func downloadFile(url, dir string) (string, error) {
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("http get: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("unexpected status: %s", resp.Status)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return "", fmt.Errorf("create download dir: %w", err)
|
||||
}
|
||||
|
||||
f, err := os.CreateTemp(dir, "thumb_*")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create temp file: %w", err)
|
||||
}
|
||||
|
||||
_, err = io.Copy(f, resp.Body)
|
||||
if err != nil {
|
||||
f.Close()
|
||||
os.Remove(f.Name())
|
||||
return "", fmt.Errorf("write temp file: %w", err)
|
||||
}
|
||||
|
||||
if err := f.Close(); err != nil {
|
||||
os.Remove(f.Name())
|
||||
return "", fmt.Errorf("close temp file: %w", err)
|
||||
}
|
||||
|
||||
return f.Name(), nil
|
||||
}
|
||||
|
||||
func formatDuration(seconds float64) string {
|
||||
if seconds <= 0 {
|
||||
return "0:00"
|
||||
|
||||
Reference in New Issue
Block a user