From d0291d1e9e4d0dc783dffd6b56b483235c7a8d58 Mon Sep 17 00:00:00 2001 From: db123 Date: Fri, 26 Jun 2026 12:56:10 +0330 Subject: [PATCH] refactor: remove all playlist support, fix message ordering - Remove playlist handler, keyboard, and all related callback routing - Strip playlist parameter from YouTube Music radio URLs, reject pure playlists - Send media preview before format selection prompt (correct ordering) - Remove DetectPlaylist/GetPlaylistInfo methods (no longer needed) - Remove IsPlaylist, PlaylistCount, PlaylistEntries from MediaInfo - Remove PlaylistState struct, keep PlaylistEntry for search results - Update README to reflect removal of playlist feature --- README.md | 1 - internal/dl/ytdlp.go | 82 +---------- internal/domain/types.go | 27 ++-- internal/handler/download.go | 88 ++++++------ internal/handler/handler.go | 8 -- internal/handler/keyboard.go | 83 ----------- internal/handler/playlist.go | 263 ----------------------------------- 7 files changed, 61 insertions(+), 491 deletions(-) delete mode 100644 internal/handler/playlist.go diff --git a/README.md b/README.md index 9dc3181..432ab59 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,6 @@ Telegram bot for downloading media from any site yt-dlp supports. - Thumbnail preview with metadata (title, uploader, duration) before download - Audio-only sites (Spotify, SoundCloud, YouTube Music, etc.) auto-detect and skip to audio quality selection - Multi-step format selection: quality, secondary format (audio/video combine), language -- Playlist support with paginated entry selection (10 per page, select all, confirm with count) - Download progress updates (with speed and ETA) - Cancel downloads at any time (terminates yt-dlp process) - Per-user quota system (daily, weekly, monthly; 0 = unlimited) diff --git a/internal/dl/ytdlp.go b/internal/dl/ytdlp.go index 09084fb..a47b8f2 100644 --- a/internal/dl/ytdlp.go +++ b/internal/dl/ytdlp.go @@ -151,19 +151,11 @@ func (c *Client) GetMediaInfo(url string) (*domain.MediaInfo, error) { } mi := &domain.MediaInfo{ - URL: url, - Title: info.Title, - Uploader: info.Uploader, - Thumbnail: info.Thumbnail, - Duration: info.Duration, - IsPlaylist: info.Playlist != "", - } - - if mi.IsPlaylist { - mi.PlaylistCount = info.PlaylistCount - mi.PlaylistEntries = c.parsePlaylistEntries(info.Entries) - c.setCache(url, mi) - return mi, nil + URL: url, + Title: info.Title, + Uploader: info.Uploader, + Thumbnail: info.Thumbnail, + Duration: info.Duration, } langSet := make(map[string]bool) @@ -171,8 +163,6 @@ func (c *Client) GetMediaInfo(url string) (*domain.MediaInfo, error) { for _, f := range info.Formats { ff := c.convertFormat(f) mi.Formats = append(mi.Formats, ff) - // Only consider languages from formats with audio or video to avoid - // prompting for subtitle-only or auto-generated caption languages. if (ff.HasAudio || ff.HasVideo) && ff.Language != "" && !langSet[ff.Language] { langSet[ff.Language] = true mi.Languages = append(mi.Languages, ff.Language) @@ -187,68 +177,6 @@ func (c *Client) GetMediaInfo(url string) (*domain.MediaInfo, error) { return mi, nil } -// GetPlaylistInfo fetches a flat playlist listing (fast, no format detail). -func (c *Client) GetPlaylistInfo(url string) (*domain.MediaInfo, error) { - if cached := c.getCached(url); cached != nil { - slog.Debug("cache hit", "url", url) - return cached, nil - } - - isPlaylist, mi, err := c.DetectPlaylist(url) - if err != nil { - return nil, err - } - if !isPlaylist { - mi.IsPlaylist = false - mi.PlaylistCount = 0 - mi.PlaylistEntries = nil - } - - c.setCache(url, mi) - return mi, nil -} - -// DetectPlaylist runs a fast flat yt-dlp check to determine if the URL is a playlist. -// It returns basic info without format details. For non-playlist URLs it returns false. -// Does NOT cache the result so a subsequent GetMediaInfo call fetches full format info. -func (c *Client) DetectPlaylist(url string) (bool, *domain.MediaInfo, error) { - args := []string{ - "-J", "--flat-playlist", "--no-download", - "--no-warnings", - url, - } - if c.cookiesFile != "" { - args = append(args, "--cookies", c.cookiesFile) - } - if c.proxyURL != "" { - args = append(args, "--proxy", c.proxyURL) - } - - slog.Info("detecting playlist", "url", url) - out, err := c.run(args) - if err != nil { - return false, nil, fmt.Errorf("yt-dlp playlist detection failed: %w", err) - } - - var rawInfo ytdlpInfo - if err := json.Unmarshal(out, &rawInfo); err != nil { - return false, nil, fmt.Errorf("parse yt-dlp output: %w", err) - } - - mi := &domain.MediaInfo{ - URL: url, - Title: rawInfo.Title, - Uploader: rawInfo.Uploader, - Thumbnail: rawInfo.Thumbnail, - Duration: rawInfo.Duration, - IsPlaylist: rawInfo.Playlist != "", - PlaylistCount: rawInfo.PlaylistCount, - PlaylistEntries: c.parsePlaylistEntries(rawInfo.Entries), - } - - return mi.IsPlaylist, mi, nil -} - func (c *Client) parsePlaylistEntries(entries []json.RawMessage) []domain.PlaylistEntry { var result []domain.PlaylistEntry for _, raw := range entries { diff --git a/internal/domain/types.go b/internal/domain/types.go index 9e93aae..5dfbe29 100644 --- a/internal/domain/types.go +++ b/internal/domain/types.go @@ -43,17 +43,14 @@ type Format struct { // MediaInfo holds the full information about a downloadable media item. type MediaInfo struct { - URL string - Title string - Uploader string - Thumbnail string - Duration float64 - Formats []Format - Languages []string - IsPlaylist bool - PlaylistCount int - PlaylistEntries []PlaylistEntry - EstimatedSize int64 + URL string + Title string + Uploader string + Thumbnail string + Duration float64 + Formats []Format + Languages []string + EstimatedSize int64 } // IsAudioOnlyContent returns true when no format carries a video stream. @@ -172,14 +169,6 @@ type DownloadRecord struct { CreatedAt string } -// PlaylistState tracks the user's current playlist selection flow. -type PlaylistState struct { - Entries []PlaylistEntry - Page int - PerPage int - Selected map[string]bool -} - // SearchState tracks the user's current search results flow. type SearchState struct { Query string diff --git a/internal/handler/download.go b/internal/handler/download.go index 5e6bc21..32d266d 100644 --- a/internal/handler/download.go +++ b/internal/handler/download.go @@ -9,6 +9,7 @@ import ( "net/http" "os" "path/filepath" + "regexp" "strings" "time" @@ -20,18 +21,17 @@ import ( ) type stepState struct { - URL string - MediaInfo *domain.MediaInfo - Step int - MainFormatID string - MainFormat *domain.Format - SecondaryID string - SecondaryFmt *domain.Format - Language string - FormatIDs []string - PlaylistState *domain.PlaylistState - SearchState *domain.SearchState - CreatedAt time.Time + URL string + MediaInfo *domain.MediaInfo + Step int + MainFormatID string + MainFormat *domain.Format + SecondaryID string + SecondaryFmt *domain.Format + Language string + FormatIDs []string + SearchState *domain.SearchState + CreatedAt time.Time } const stepStateTTL = 30 * time.Minute @@ -59,6 +59,25 @@ func (h *Handler) clearStepState(chatID int64) { delete(h.stepStates, chatID) } +// stripPlaylistParam removes the list parameter from YouTube/Music URLs. +// Playlists are not supported; this converts a video-with-playlist URL (e.g. a +// YouTube Music radio) to a plain video URL. Pure playlist URLs are rejected. +func stripPlaylistParam(rawURL string) (string, bool) { + // Reject pure playlist URLs (https://youtube.com/playlist?list=...) + if strings.Contains(rawURL, "/playlist") || strings.Contains(rawURL, "youtube.com/pL") { + return "", false + } + // Strip the list query parameter from watch URLs + url := rawURL + if strings.Contains(url, "list=") { + // Remove &list=... or ?list=... + url = regexp.MustCompile(`[?&]list=[^&]+`).ReplaceAllString(url, "") + // Clean up trailing ? or & if the param was the only one + url = strings.TrimRight(url, "?&") + } + return url, true +} + // handleURLInput is the entry point when a user sends a URL or types in Download mode. func (h *Handler) handleURLInput(ctx context.Context, chatID int64, text string, userID int64) { url, ok := domain.ValidateURL(text) @@ -67,26 +86,15 @@ func (h *Handler) handleURLInput(ctx context.Context, chatID int64, text string, return } - if job := h.getActiveJob(userID); job != nil { - h.sendText(ctx, chatID, "You already have an active download. Cancel it first.") + // Strip playlist parameter and reject pure playlists. + url, ok = stripPlaylistParam(url) + if !ok { + h.sendText(ctx, chatID, "Playlists are not supported. Send a single video/audio URL.") return } - // Show a temporary status message that we will edit with results later. - fetchingMsg, _ := h.Bot.SendMessage(ctx, &tgbot.SendMessageParams{ - ChatID: chatID, - Text: "Fetching metadata...", - }) - fetchingMsgID := 0 - if fetchingMsg != nil { - fetchingMsgID = fetchingMsg.ID - } - - // Fast playlist detection first — avoids expensive full metadata fetch for large playlists. - isPlaylist, plInfo, detectErr := h.YtDlp.DetectPlaylist(url) - if detectErr == nil && isPlaylist { - h.editText(ctx, chatID, fetchingMsgID, fmt.Sprintf("Playlist detected: %s (%d videos)", plInfo.Title, plInfo.PlaylistCount), nil) - h.handlePlaylist(ctx, chatID, userID, url, plInfo) + if job := h.getActiveJob(userID); job != nil { + h.sendText(ctx, chatID, "You already have an active download. Cancel it first.") return } @@ -104,33 +112,33 @@ func (h *Handler) handleURLInput(ctx context.Context, chatID int64, text string, {{Text: "Cancel", CallbackData: "pending_cancel"}}, }, } - h.editText(ctx, chatID, fetchingMsgID, "This content is DRM protected and no YouTube alternative was found automatically. Try searching by name:", &kb) + h.sendWithKB(ctx, chatID, "This content is DRM protected and no YouTube alternative was found automatically. Try searching by name:", kb) return } - h.editText(ctx, chatID, fetchingMsgID, fmt.Sprintf("This content is DRM protected. Found on YouTube: %s", ytTitle), nil) + h.sendText(ctx, chatID, fmt.Sprintf("This content is DRM protected. Found on YouTube: %s", ytTitle)) info, err = h.YtDlp.GetMediaInfo(youtubeURL) if err != nil { - h.editText(ctx, chatID, fetchingMsgID, fmt.Sprintf("YouTube fallback failed: %s", err.Error()), nil) + h.sendText(ctx, chatID, fmt.Sprintf("YouTube fallback failed: %s", err.Error())) return } url = youtubeURL } else { switch { case strings.Contains(errMsg, "Unsupported URL") || strings.Contains(errMsg, "is not a valid URL"): - h.editText(ctx, chatID, fetchingMsgID, "This URL is not supported.", nil) + h.sendText(ctx, chatID, "This URL is not supported.") case strings.Contains(errMsg, "HTTP Error 403") || strings.Contains(errMsg, "cookies"): - h.editText(ctx, chatID, fetchingMsgID, "This content requires authentication (cookies).", nil) + h.sendText(ctx, chatID, "This content requires authentication (cookies).") case strings.Contains(errMsg, "live"): - h.editText(ctx, chatID, fetchingMsgID, "Cannot download live streams.", nil) + h.sendText(ctx, chatID, "Cannot download live streams.") default: - h.editText(ctx, chatID, fetchingMsgID, fmt.Sprintf("Error: %s", errMsg), nil) + h.sendText(ctx, chatID, fmt.Sprintf("Error: %s", errMsg)) } return } } if len(info.Formats) == 0 { - h.editText(ctx, chatID, fetchingMsgID, "No formats available for this URL.", nil) + h.sendText(ctx, chatID, "No formats available for this URL.") return } @@ -141,9 +149,9 @@ func (h *Handler) handleURLInput(ctx context.Context, chatID int64, text string, if (q.DailyLimitMB > 0 && q.DailyUsedMB >= q.DailyLimitMB) || (q.WeeklyLimitMB > 0 && q.WeeklyUsedMB >= q.WeeklyLimitMB) || (q.MonthlyLimitMB > 0 && q.MonthlyUsedMB >= q.MonthlyLimitMB) { - h.editText(ctx, chatID, fetchingMsgID, fmt.Sprintf( + h.sendText(ctx, chatID, fmt.Sprintf( "Quota exceeded. Daily: %d/%d MB, Weekly: %d/%d MB, Monthly: %d/%d MB.", - q.DailyUsedMB, q.DailyLimitMB, q.WeeklyUsedMB, q.WeeklyLimitMB, q.MonthlyUsedMB, q.MonthlyLimitMB), nil) + q.DailyUsedMB, q.DailyLimitMB, q.WeeklyUsedMB, q.WeeklyLimitMB, q.MonthlyUsedMB, q.MonthlyLimitMB)) return } } @@ -173,7 +181,7 @@ func (h *Handler) handleURLInput(ctx context.Context, chatID int64, text string, } } kb := formatKeyboard("format_", labels, ss.FormatIDs) - h.editText(ctx, chatID, fetchingMsgID, "Select format:", &kb) + h.sendWithKB(ctx, chatID, "Select format:", kb) } // handleFormatSelection processes a format pick from the unified list. diff --git a/internal/handler/handler.go b/internal/handler/handler.go index 599dc99..7fdda68 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -281,14 +281,6 @@ func (h *Handler) routePrefixedCallback(ctx context.Context, chatID int64, msgID h.handleLanguageSelection(ctx, chatID, msgID, userID, data[5:]) case strings.HasPrefix(data, "container_"): // container selection was removed — yt-dlp handles it - case strings.HasPrefix(data, "pl_page_"): - h.handlePlaylistPage(ctx, chatID, msgID, userID, data[8:]) - case strings.HasPrefix(data, "pl_entry_"): - h.handlePlaylistEntry(ctx, chatID, msgID, userID, data[9:]) - case data == "pl_select_all": - h.handlePlaylistSelectAll(ctx, chatID, msgID, userID) - case data == "pl_confirm": - h.handlePlaylistConfirm(ctx, chatID, msgID, userID) case strings.HasPrefix(data, "delaccount_"): if uid, err := strconv.ParseInt(data[11:], 10, 64); err == nil && uid > 0 { h.deleteAccountConfirm(ctx, chatID, msgID, uid) diff --git a/internal/handler/keyboard.go b/internal/handler/keyboard.go index ab0bded..f26eb47 100644 --- a/internal/handler/keyboard.go +++ b/internal/handler/keyboard.go @@ -4,8 +4,6 @@ import ( "fmt" "github.com/go-telegram/bot/models" - - "uptodownBot/internal/domain" ) func mainKeyboard() models.InlineKeyboardMarkup { @@ -82,87 +80,6 @@ func progressKeyboard(pct float64, speed, eta string) models.InlineKeyboardMarku } } -// playlistNavKeyboard builds the paginated navigation for playlist selection. -func playlistNavKeyboard(state *domain.PlaylistState, selectedCount int) models.InlineKeyboardMarkup { - totalPages := (len(state.Entries) + state.PerPage - 1) / state.PerPage - if totalPages < 1 { - totalPages = 1 - } - - // Build entry rows (2 per row) - rows := [][]models.InlineKeyboardButton{} - start := state.Page * state.PerPage - end := start + state.PerPage - if end > len(state.Entries) { - end = len(state.Entries) - } - - for i := start; i < end; i += 2 { - row := []models.InlineKeyboardButton{} - // First entry - e := state.Entries[i] - prefix := " " - if state.Selected[e.ID] { - prefix = "> " - } - // Truncate title for button - title := e.Title - if len(title) > 30 { - title = title[:27] + "..." - } - row = append(row, models.InlineKeyboardButton{ - Text: prefix + title, CallbackData: "pl_entry_" + e.ID, - }) - // Second entry if available - if i+1 < end { - e2 := state.Entries[i+1] - prefix2 := " " - if state.Selected[e2.ID] { - prefix2 = "> " - } - title2 := e2.Title - if len(title2) > 30 { - title2 = title2[:27] + "..." - } - row = append(row, models.InlineKeyboardButton{ - Text: prefix2 + title2, CallbackData: "pl_entry_" + e2.ID, - }) - } - rows = append(rows, row) - } - - // Navigation row - navRow := []models.InlineKeyboardButton{} - navRow = append(navRow, models.InlineKeyboardButton{ - Text: "<", CallbackData: fmt.Sprintf("pl_page_%d", state.Page-1), - }) - navRow = append(navRow, models.InlineKeyboardButton{ - Text: fmt.Sprintf("Page %d/%d", state.Page+1, totalPages), CallbackData: "noop", - }) - navRow = append(navRow, models.InlineKeyboardButton{ - Text: ">", CallbackData: fmt.Sprintf("pl_page_%d", state.Page+1), - }) - rows = append(rows, navRow) - - // Action row - actionRow := []models.InlineKeyboardButton{ - {Text: "Select All", CallbackData: "pl_select_all"}, - } - if selectedCount > 0 { - actionRow = append(actionRow, models.InlineKeyboardButton{ - Text: fmt.Sprintf("Confirm (%d)", selectedCount), CallbackData: "pl_confirm", - }) - } - rows = append(rows, actionRow) - - // Cancel - rows = append(rows, []models.InlineKeyboardButton{ - {Text: "Cancel", CallbackData: "cancel_dl"}, - }) - - return models.InlineKeyboardMarkup{InlineKeyboard: rows} -} - // deleteConfirmKeyboard builds the confirmation prompt for account deletion. func deleteConfirmKeyboard(userID int64) models.InlineKeyboardMarkup { return models.InlineKeyboardMarkup{ diff --git a/internal/handler/playlist.go b/internal/handler/playlist.go deleted file mode 100644 index f74fa52..0000000 --- a/internal/handler/playlist.go +++ /dev/null @@ -1,263 +0,0 @@ -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 -}