refactor: remove all playlist support, fix message ordering
All checks were successful
CI / build (push) Successful in 54s
All checks were successful
CI / build (push) Successful in 54s
- 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
This commit is contained in:
@@ -10,7 +10,6 @@ Telegram bot for downloading media from any site yt-dlp supports.
|
|||||||
- Thumbnail preview with metadata (title, uploader, duration) before download
|
- 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
|
- 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
|
- 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)
|
- Download progress updates (with speed and ETA)
|
||||||
- Cancel downloads at any time (terminates yt-dlp process)
|
- Cancel downloads at any time (terminates yt-dlp process)
|
||||||
- Per-user quota system (daily, weekly, monthly; 0 = unlimited)
|
- Per-user quota system (daily, weekly, monthly; 0 = unlimited)
|
||||||
|
|||||||
@@ -151,19 +151,11 @@ func (c *Client) GetMediaInfo(url string) (*domain.MediaInfo, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
mi := &domain.MediaInfo{
|
mi := &domain.MediaInfo{
|
||||||
URL: url,
|
URL: url,
|
||||||
Title: info.Title,
|
Title: info.Title,
|
||||||
Uploader: info.Uploader,
|
Uploader: info.Uploader,
|
||||||
Thumbnail: info.Thumbnail,
|
Thumbnail: info.Thumbnail,
|
||||||
Duration: info.Duration,
|
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
langSet := make(map[string]bool)
|
langSet := make(map[string]bool)
|
||||||
@@ -171,8 +163,6 @@ func (c *Client) GetMediaInfo(url string) (*domain.MediaInfo, error) {
|
|||||||
for _, f := range info.Formats {
|
for _, f := range info.Formats {
|
||||||
ff := c.convertFormat(f)
|
ff := c.convertFormat(f)
|
||||||
mi.Formats = append(mi.Formats, ff)
|
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] {
|
if (ff.HasAudio || ff.HasVideo) && ff.Language != "" && !langSet[ff.Language] {
|
||||||
langSet[ff.Language] = true
|
langSet[ff.Language] = true
|
||||||
mi.Languages = append(mi.Languages, ff.Language)
|
mi.Languages = append(mi.Languages, ff.Language)
|
||||||
@@ -187,68 +177,6 @@ func (c *Client) GetMediaInfo(url string) (*domain.MediaInfo, error) {
|
|||||||
return mi, nil
|
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 {
|
func (c *Client) parsePlaylistEntries(entries []json.RawMessage) []domain.PlaylistEntry {
|
||||||
var result []domain.PlaylistEntry
|
var result []domain.PlaylistEntry
|
||||||
for _, raw := range entries {
|
for _, raw := range entries {
|
||||||
|
|||||||
@@ -43,17 +43,14 @@ type Format struct {
|
|||||||
|
|
||||||
// MediaInfo holds the full information about a downloadable media item.
|
// MediaInfo holds the full information about a downloadable media item.
|
||||||
type MediaInfo struct {
|
type MediaInfo struct {
|
||||||
URL string
|
URL string
|
||||||
Title string
|
Title string
|
||||||
Uploader string
|
Uploader string
|
||||||
Thumbnail string
|
Thumbnail string
|
||||||
Duration float64
|
Duration float64
|
||||||
Formats []Format
|
Formats []Format
|
||||||
Languages []string
|
Languages []string
|
||||||
IsPlaylist bool
|
EstimatedSize int64
|
||||||
PlaylistCount int
|
|
||||||
PlaylistEntries []PlaylistEntry
|
|
||||||
EstimatedSize int64
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsAudioOnlyContent returns true when no format carries a video stream.
|
// IsAudioOnlyContent returns true when no format carries a video stream.
|
||||||
@@ -172,14 +169,6 @@ type DownloadRecord struct {
|
|||||||
CreatedAt string
|
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.
|
// SearchState tracks the user's current search results flow.
|
||||||
type SearchState struct {
|
type SearchState struct {
|
||||||
Query string
|
Query string
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -20,18 +21,17 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type stepState struct {
|
type stepState struct {
|
||||||
URL string
|
URL string
|
||||||
MediaInfo *domain.MediaInfo
|
MediaInfo *domain.MediaInfo
|
||||||
Step int
|
Step int
|
||||||
MainFormatID string
|
MainFormatID string
|
||||||
MainFormat *domain.Format
|
MainFormat *domain.Format
|
||||||
SecondaryID string
|
SecondaryID string
|
||||||
SecondaryFmt *domain.Format
|
SecondaryFmt *domain.Format
|
||||||
Language string
|
Language string
|
||||||
FormatIDs []string
|
FormatIDs []string
|
||||||
PlaylistState *domain.PlaylistState
|
SearchState *domain.SearchState
|
||||||
SearchState *domain.SearchState
|
CreatedAt time.Time
|
||||||
CreatedAt time.Time
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const stepStateTTL = 30 * time.Minute
|
const stepStateTTL = 30 * time.Minute
|
||||||
@@ -59,6 +59,25 @@ func (h *Handler) clearStepState(chatID int64) {
|
|||||||
delete(h.stepStates, chatID)
|
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.
|
// 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) {
|
func (h *Handler) handleURLInput(ctx context.Context, chatID int64, text string, userID int64) {
|
||||||
url, ok := domain.ValidateURL(text)
|
url, ok := domain.ValidateURL(text)
|
||||||
@@ -67,26 +86,15 @@ func (h *Handler) handleURLInput(ctx context.Context, chatID int64, text string,
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if job := h.getActiveJob(userID); job != nil {
|
// Strip playlist parameter and reject pure playlists.
|
||||||
h.sendText(ctx, chatID, "You already have an active download. Cancel it first.")
|
url, ok = stripPlaylistParam(url)
|
||||||
|
if !ok {
|
||||||
|
h.sendText(ctx, chatID, "Playlists are not supported. Send a single video/audio URL.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show a temporary status message that we will edit with results later.
|
if job := h.getActiveJob(userID); job != nil {
|
||||||
fetchingMsg, _ := h.Bot.SendMessage(ctx, &tgbot.SendMessageParams{
|
h.sendText(ctx, chatID, "You already have an active download. Cancel it first.")
|
||||||
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)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,33 +112,33 @@ func (h *Handler) handleURLInput(ctx context.Context, chatID int64, text string,
|
|||||||
{{Text: "Cancel", CallbackData: "pending_cancel"}},
|
{{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
|
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)
|
info, err = h.YtDlp.GetMediaInfo(youtubeURL)
|
||||||
if err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
url = youtubeURL
|
url = youtubeURL
|
||||||
} else {
|
} else {
|
||||||
switch {
|
switch {
|
||||||
case strings.Contains(errMsg, "Unsupported URL") || strings.Contains(errMsg, "is not a valid URL"):
|
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"):
|
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"):
|
case strings.Contains(errMsg, "live"):
|
||||||
h.editText(ctx, chatID, fetchingMsgID, "Cannot download live streams.", nil)
|
h.sendText(ctx, chatID, "Cannot download live streams.")
|
||||||
default:
|
default:
|
||||||
h.editText(ctx, chatID, fetchingMsgID, fmt.Sprintf("Error: %s", errMsg), nil)
|
h.sendText(ctx, chatID, fmt.Sprintf("Error: %s", errMsg))
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(info.Formats) == 0 {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,9 +149,9 @@ func (h *Handler) handleURLInput(ctx context.Context, chatID int64, text string,
|
|||||||
if (q.DailyLimitMB > 0 && q.DailyUsedMB >= q.DailyLimitMB) ||
|
if (q.DailyLimitMB > 0 && q.DailyUsedMB >= q.DailyLimitMB) ||
|
||||||
(q.WeeklyLimitMB > 0 && q.WeeklyUsedMB >= q.WeeklyLimitMB) ||
|
(q.WeeklyLimitMB > 0 && q.WeeklyUsedMB >= q.WeeklyLimitMB) ||
|
||||||
(q.MonthlyLimitMB > 0 && q.MonthlyUsedMB >= q.MonthlyLimitMB) {
|
(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.",
|
"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
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -173,7 +181,7 @@ func (h *Handler) handleURLInput(ctx context.Context, chatID int64, text string,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
kb := formatKeyboard("format_", labels, ss.FormatIDs)
|
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.
|
// handleFormatSelection processes a format pick from the unified list.
|
||||||
|
|||||||
@@ -281,14 +281,6 @@ func (h *Handler) routePrefixedCallback(ctx context.Context, chatID int64, msgID
|
|||||||
h.handleLanguageSelection(ctx, chatID, msgID, userID, data[5:])
|
h.handleLanguageSelection(ctx, chatID, msgID, userID, data[5:])
|
||||||
case strings.HasPrefix(data, "container_"):
|
case strings.HasPrefix(data, "container_"):
|
||||||
// container selection was removed — yt-dlp handles it
|
// 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_"):
|
case strings.HasPrefix(data, "delaccount_"):
|
||||||
if uid, err := strconv.ParseInt(data[11:], 10, 64); err == nil && uid > 0 {
|
if uid, err := strconv.ParseInt(data[11:], 10, 64); err == nil && uid > 0 {
|
||||||
h.deleteAccountConfirm(ctx, chatID, msgID, uid)
|
h.deleteAccountConfirm(ctx, chatID, msgID, uid)
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"github.com/go-telegram/bot/models"
|
"github.com/go-telegram/bot/models"
|
||||||
|
|
||||||
"uptodownBot/internal/domain"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func mainKeyboard() models.InlineKeyboardMarkup {
|
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.
|
// deleteConfirmKeyboard builds the confirmation prompt for account deletion.
|
||||||
func deleteConfirmKeyboard(userID int64) models.InlineKeyboardMarkup {
|
func deleteConfirmKeyboard(userID int64) models.InlineKeyboardMarkup {
|
||||||
return models.InlineKeyboardMarkup{
|
return models.InlineKeyboardMarkup{
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user