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 }