Files
uptodownbot/internal/handler/playlist.go
db123 609237f4e3
All checks were successful
CI / build (push) Successful in 52s
fix: resolve 8 architecture flaws across download, playlist, and webhook
- 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
2026-06-26 01:04:34 +03:30

264 lines
6.8 KiB
Go

package handler
import (
"context"
"fmt"
"log/slog"
"strconv"
"strings"
"time"
tgbot "github.com/go-telegram/bot"
"uptodownBot/internal/domain"
)
func (h *Handler) handlePlaylist(ctx context.Context, chatID int64, userID int64, url string, info *domain.MediaInfo) {
state := &domain.PlaylistState{
Entries: info.PlaylistEntries,
Page: 0,
PerPage: 10,
Selected: make(map[string]bool),
}
ss := &stepState{
URL: url,
MediaInfo: info,
PlaylistState: state,
CreatedAt: time.Now(),
}
h.setUserStepState(chatID, ss)
h.sendMediaPreview(ctx, chatID, info)
kb := playlistNavKeyboard(state, 0)
h.sendWithKB(ctx, chatID, fmt.Sprintf("Playlist: %s (%d videos)\n\nSelect videos to download:", info.Title, info.PlaylistCount), kb)
}
func (h *Handler) getPlaylistState(chatID int64) *domain.PlaylistState {
ss := h.getUserStepState(chatID)
if ss == nil {
return nil
}
return ss.PlaylistState
}
func (h *Handler) handlePlaylistPage(ctx context.Context, chatID int64, msgID int, userID int64, pageStr string) {
state := h.getPlaylistState(chatID)
if state == nil {
return
}
page, err := strconv.Atoi(pageStr)
if err != nil {
return
}
totalPages := (len(state.Entries) + state.PerPage - 1) / state.PerPage
if page < 0 || page >= totalPages {
return
}
state.Page = page
selectedCount := countSelected(state)
kb := playlistNavKeyboard(state, selectedCount)
ss := h.getUserStepState(chatID)
title := ""
if ss != nil {
title = ss.MediaInfo.Title
}
h.editText(ctx, chatID, msgID, fmt.Sprintf("Playlist: %s (%d videos)", title, len(state.Entries)), &kb)
}
func (h *Handler) handlePlaylistEntry(ctx context.Context, chatID int64, msgID int, userID int64, entryID string) {
state := h.getPlaylistState(chatID)
if state == nil {
return
}
state.Selected[entryID] = !state.Selected[entryID]
selectedCount := countSelected(state)
kb := playlistNavKeyboard(state, selectedCount)
ss := h.getUserStepState(chatID)
title := ""
if ss != nil {
title = ss.MediaInfo.Title
}
h.editText(ctx, chatID, msgID, fmt.Sprintf("Playlist: %s (%d videos)", title, len(state.Entries)), &kb)
}
func (h *Handler) handlePlaylistSelectAll(ctx context.Context, chatID int64, msgID int, userID int64) {
state := h.getPlaylistState(chatID)
if state == nil {
return
}
allSelected := true
for _, e := range state.Entries {
if !state.Selected[e.ID] {
allSelected = false
break
}
}
for _, e := range state.Entries {
state.Selected[e.ID] = !allSelected
}
selectedCount := 0
if !allSelected {
selectedCount = len(state.Entries)
}
kb := playlistNavKeyboard(state, selectedCount)
ss := h.getUserStepState(chatID)
title := ""
if ss != nil {
title = ss.MediaInfo.Title
}
h.editText(ctx, chatID, msgID, fmt.Sprintf("Playlist: %s (%d videos)", title, len(state.Entries)), &kb)
}
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
}
var selected []domain.PlaylistEntry
for _, e := range state.Entries {
if state.Selected[e.ID] {
selected = append(selected, e)
}
}
if len(selected) == 0 {
h.editText(ctx, chatID, msgID, "No videos selected.", nil)
return
}
h.clearStepState(chatID)
// Parent context for the entire playlist — cancelling this stops all entries
playlistCtx, playlistCancel := context.WithCancel(context.Background())
h.setCancelFunc(userID, playlistCancel)
h.editText(ctx, chatID, msgID, fmt.Sprintf("Downloading %d videos...", len(selected)), nil)
// 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
}
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
}
}
h.clearActiveJob(userID)
h.sendText(playlistCtx, chatID, "Playlist downloads finished.")
}()
}
// 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.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 {
count := 0
for _, s := range state.Selected {
if s {
count++
}
}
return count
}