fix: resolve 8 architecture flaws across download, playlist, and webhook
All checks were successful
CI / build (push) Successful in 52s
All checks were successful
CI / build (push) Successful in 52s
- Add --continue and --max-filesize to yt-dlp for download resilience - Route thumbnail downloads through HTTP_PROXY - Show per-video progress (i/N) with cancel for playlist downloads - Run playlist asynchronously so cancel callbacks can be processed - Guard handlePlaylistConfirm against concurrent active jobs - Close Done channel in playlist entry lifecycle - Add stepState TTL (30 min) to prevent stale states - Wrap webhook server for graceful shutdown on SIGTERM - Refactor formatBytes to loop-based implementation - Show first line of stderr in user-facing error messages - Fix format pointer reuse in playlist loop - Add CreatedAt field to stepState for TTL tracking - Add MaxFileSize and ProgressPrefix fields to DownloadJob
This commit is contained in:
@@ -3,7 +3,12 @@ package handler
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
tgbot "github.com/go-telegram/bot"
|
||||
|
||||
"uptodownBot/internal/domain"
|
||||
)
|
||||
@@ -20,6 +25,7 @@ func (h *Handler) handlePlaylist(ctx context.Context, chatID int64, userID int64
|
||||
URL: url,
|
||||
MediaInfo: info,
|
||||
PlaylistState: state,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
h.setUserStepState(chatID, ss)
|
||||
|
||||
@@ -110,6 +116,11 @@ func (h *Handler) handlePlaylistSelectAll(ctx context.Context, chatID int64, msg
|
||||
}
|
||||
|
||||
func (h *Handler) handlePlaylistConfirm(ctx context.Context, chatID int64, msgID int, userID int64) {
|
||||
if job := h.getActiveJob(userID); job != nil {
|
||||
h.sendText(ctx, chatID, "You already have an active download. Cancel it first.")
|
||||
return
|
||||
}
|
||||
|
||||
state := h.getPlaylistState(chatID)
|
||||
if state == nil {
|
||||
return
|
||||
@@ -127,65 +138,118 @@ func (h *Handler) handlePlaylistConfirm(ctx context.Context, chatID int64, msgID
|
||||
}
|
||||
|
||||
h.clearStepState(chatID)
|
||||
h.editText(ctx, chatID, msgID, fmt.Sprintf("Downloading %d videos sequentially...", len(selected)), nil)
|
||||
|
||||
for i, entry := range selected {
|
||||
h.sendText(ctx, chatID, fmt.Sprintf("Downloading (%d/%d): %s", i+1, len(selected), entry.Title))
|
||||
// Parent context for the entire playlist — cancelling this stops all entries
|
||||
playlistCtx, playlistCancel := context.WithCancel(context.Background())
|
||||
h.setCancelFunc(userID, playlistCancel)
|
||||
|
||||
info, err := h.YtDlp.GetMediaInfo(entry.URL)
|
||||
if err != nil {
|
||||
h.sendText(ctx, chatID, fmt.Sprintf("Failed: %s", err.Error()))
|
||||
continue
|
||||
}
|
||||
h.editText(ctx, chatID, msgID, fmt.Sprintf("Downloading %d videos...", len(selected)), nil)
|
||||
|
||||
prefs, prefErr := h.Repo.GetOrCreatePreferences(userID)
|
||||
if prefErr != nil {
|
||||
prefs = &domain.UserPreferences{
|
||||
DefaultMediaType: domain.MediaTypeVideoAudio,
|
||||
DefaultAudioFormat: "best",
|
||||
DefaultVideoFormat: "best",
|
||||
// Run the playlist loop in a goroutine so the handler is free to process
|
||||
// cancel callbacks and other updates concurrently.
|
||||
go func() {
|
||||
for i, entry := range selected {
|
||||
if playlistCtx.Err() != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
formatID := prefs.DefaultVideoFormat
|
||||
if prefs.DefaultMediaType == domain.MediaTypeAudio {
|
||||
formatID = prefs.DefaultAudioFormat
|
||||
}
|
||||
format := &domain.Format{ID: formatID}
|
||||
for _, f := range info.Formats {
|
||||
if f.ID == formatID {
|
||||
format = &f
|
||||
info, err := h.YtDlp.GetMediaInfo(entry.URL)
|
||||
if err != nil {
|
||||
h.sendText(playlistCtx, chatID, fmt.Sprintf("Playlist entry (%d/%d) failed: %s", i+1, len(selected), err.Error()))
|
||||
continue
|
||||
}
|
||||
|
||||
prefs, prefErr := h.Repo.GetOrCreatePreferences(userID)
|
||||
if prefErr != nil {
|
||||
prefs = &domain.UserPreferences{
|
||||
DefaultMediaType: domain.MediaTypeVideoAudio,
|
||||
DefaultAudioFormat: "best",
|
||||
DefaultVideoFormat: "best",
|
||||
}
|
||||
}
|
||||
|
||||
formatID := prefs.DefaultVideoFormat
|
||||
if prefs.DefaultMediaType == domain.MediaTypeAudio {
|
||||
formatID = prefs.DefaultAudioFormat
|
||||
}
|
||||
ff := &domain.Format{ID: formatID}
|
||||
for i := range info.Formats {
|
||||
if info.Formats[i].ID == formatID {
|
||||
ff = &info.Formats[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
job := &domain.DownloadJob{
|
||||
ID: h.generateJobID(),
|
||||
UserID: userID,
|
||||
ChatID: chatID,
|
||||
MessageID: 0,
|
||||
URL: entry.URL,
|
||||
MediaType: prefs.DefaultMediaType,
|
||||
SelectedFormat: ff,
|
||||
FormatString: "",
|
||||
Language: prefs.DefaultLanguage,
|
||||
Status: domain.StatusDownloading,
|
||||
Title: entry.Title,
|
||||
FileSize: ff.Filesize,
|
||||
MaxFileSize: h.maxFileSize,
|
||||
ProgressPrefix: fmt.Sprintf("Downloading (%d/%d)", i+1, len(selected)),
|
||||
Done: make(chan struct{}),
|
||||
}
|
||||
|
||||
ok := h.downloadPlaylistEntry(playlistCtx, userID, chatID, job)
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
dlCtx, dlCancel := context.WithCancel(context.Background())
|
||||
h.clearActiveJob(userID)
|
||||
h.sendText(playlistCtx, chatID, "Playlist downloads finished.")
|
||||
}()
|
||||
}
|
||||
|
||||
job := &domain.DownloadJob{
|
||||
ID: h.generateJobID(),
|
||||
UserID: userID,
|
||||
ChatID: chatID,
|
||||
URL: entry.URL,
|
||||
MediaType: prefs.DefaultMediaType,
|
||||
SelectedFormat: format,
|
||||
Language: prefs.DefaultLanguage,
|
||||
Status: domain.StatusDownloading,
|
||||
Title: entry.Title,
|
||||
FileSize: format.Filesize,
|
||||
Done: make(chan struct{}),
|
||||
}
|
||||
|
||||
h.setActiveJob(userID, job)
|
||||
h.setCancelFunc(userID, dlCancel)
|
||||
|
||||
h.runDownload(dlCtx, job)
|
||||
|
||||
// runDownload is synchronous — it blocks until the download completes.
|
||||
// The Done channel is closed inside runDownload when it exits.
|
||||
// No busy-polling needed.
|
||||
// downloadPlaylistEntry handles a single entry in a playlist download.
|
||||
// It returns false if the playlist should stop (cancelled/fatal).
|
||||
func (h *Handler) downloadPlaylistEntry(ctx context.Context, userID, chatID int64, job *domain.DownloadJob) bool {
|
||||
if job.Done != nil {
|
||||
defer close(job.Done)
|
||||
}
|
||||
|
||||
h.sendText(ctx, chatID, "All playlist downloads completed.")
|
||||
h.setActiveJob(userID, job)
|
||||
|
||||
kb := progressKeyboard(0, "", "")
|
||||
progressHeader := job.ProgressPrefix + "\nDownloading: " + job.Title
|
||||
msg, sendErr := h.Bot.SendMessage(ctx, &tgbot.SendMessageParams{
|
||||
ChatID: chatID,
|
||||
Text: progressHeader,
|
||||
ReplyMarkup: &kb,
|
||||
})
|
||||
if sendErr != nil {
|
||||
slog.Error("send playlist progress msg failed", "error", sendErr)
|
||||
return true // non-fatal, continue to next
|
||||
}
|
||||
job.MessageID = msg.ID
|
||||
|
||||
updates, dlErr := h.YtDlp.StartDownload(job, h.downloadDir)
|
||||
if dlErr != nil {
|
||||
errMsg := dlErr.Error()
|
||||
if job.StderrLog != "" {
|
||||
if lines := strings.SplitN(job.StderrLog, "\n", 2); len(lines) > 0 && lines[0] != "" {
|
||||
errMsg = strings.TrimSpace(lines[0])
|
||||
}
|
||||
}
|
||||
h.editText(ctx, chatID, job.MessageID, fmt.Sprintf("Download failed: %s", errMsg), nil)
|
||||
return true
|
||||
}
|
||||
|
||||
h.trackDownloadProgress(ctx, job, updates)
|
||||
|
||||
if job.Status == domain.StatusCompleted {
|
||||
h.handleDownloadCompletion(ctx, job)
|
||||
}
|
||||
|
||||
return job.Status != domain.StatusCancelled
|
||||
}
|
||||
|
||||
func countSelected(state *domain.PlaylistState) int {
|
||||
|
||||
Reference in New Issue
Block a user