Compare commits

..

4 Commits

Author SHA1 Message Date
f8f9d99e69 fix: set PYTHONUNBUFFERED=1 to force real-time progress output
All checks were successful
CI / build (push) Successful in 58s
yt-dlp is a Python script. When stderr is a pipe (not a TTY), Python
buffers output, causing progress updates to arrive in large chunks or
only at the end. Setting PYTHONUNBUFFERED=1 forces line-buffered output,
giving real-time progress.
2026-06-25 16:24:07 +03:30
e8120b9529 fix: show first progress update immediately instead of waiting for 5%
- Always show the first nonzero progress update so users don't see 0% for
  extended periods on slow downloads or fragment-based downloads
- Keep 5% throttle for subsequent updates to avoid excessive API calls
- Add readProgress summary logging (lines, matches, stderr length)
- Fix progressKeyboard not being updated until progress >= 5%
2026-06-25 16:23:07 +03:30
cd7aaeebc2 feat: capture yt-dlp stderr on download failures for debugging
Add StderrLog field to DownloadJob and collect all stderr lines during
download. Log the captured stderr output when a download fails to help
diagnose exit code 2 errors (geo-restrictions, auth, etc.).
2026-06-25 16:12:03 +03:30
8408b17ca0 feat: add DRM search-by-name fallback for individual Spotify tracks
When SearchYoutube with the Spotify URL fails for individual tracks,
offer a 'Search on YouTube' button that lets the user enter a search
query (artist/song name) to find the content on YouTube.
2026-06-25 16:10:37 +03:30
5 changed files with 59 additions and 17 deletions

View File

@@ -3,6 +3,7 @@ package dl
import (
"bufio"
"io"
"log/slog"
"regexp"
"strconv"
"strings"
@@ -25,8 +26,6 @@ func parseProgressLine(line string) *progressUpdate {
if line == "" || !strings.HasPrefix(line, "[download]") {
return nil
}
// Try to extract the simple percentage from the default format:
// [download] 45.2% of 123.45MiB at 2.34MiB/s ETA 00:45
m := progressRE.FindStringSubmatch(line)
if m == nil {
return nil
@@ -38,12 +37,10 @@ func parseProgressLine(line string) *progressUpdate {
u := &progressUpdate{pct: pct}
// Extract speed and ETA from remaining parts
rest := line
if idx := strings.Index(rest, "%"); idx >= 0 {
rest = rest[idx+1:]
}
// Look for "at <speed>"
if atIdx := strings.Index(rest, " at "); atIdx >= 0 {
afterAt := rest[atIdx+4:]
if spaceIdx := strings.Index(afterAt, " "); spaceIdx >= 0 {
@@ -51,7 +48,6 @@ func parseProgressLine(line string) *progressUpdate {
rest = afterAt[spaceIdx:]
}
}
// Look for "ETA <duration>"
if etaIdx := strings.Index(rest, "ETA "); etaIdx >= 0 {
etaStr := strings.TrimSpace(rest[etaIdx+4:])
u.eta = etaStr
@@ -63,16 +59,29 @@ func parseProgressLine(line string) *progressUpdate {
// readProgress reads yt-dlp stderr and sends progress updates to the channel.
// The caller is responsible for closing the updates channel.
func readProgress(stderr io.ReadCloser, updates chan<- *domain.DownloadJob, job *domain.DownloadJob) {
var stderrBuf strings.Builder
scanner := bufio.NewScanner(stderr)
lineCount := 0
matchCount := 0
for scanner.Scan() {
line := scanner.Text()
lineCount++
if stderrBuf.Len() < 4096 {
stderrBuf.WriteString(line)
stderrBuf.WriteByte('\n')
}
parsed := parseProgressLine(line)
if parsed == nil {
continue
}
matchCount++
job.Progress = parsed.pct
job.Speed = parsed.speed
job.ETA = parsed.eta
updates <- job
}
job.StderrLog = stderrBuf.String()
slog.Info("readProgress finished", "url", job.URL, "lines", lineCount, "matches", matchCount, "stderr_len", len(job.StderrLog))
}

View File

@@ -265,6 +265,7 @@ func (c *Client) StartDownload(job *domain.DownloadJob, downloadDir string) (<-c
slog.Info("starting download", "url", job.URL, "format", formatID)
cmd := exec.Command(c.binaryPath, args...)
cmd.Env = append(os.Environ(), "PYTHONUNBUFFERED=1")
stderr, err := cmd.StderrPipe()
if err != nil {
@@ -315,7 +316,7 @@ func (c *Client) StartDownload(job *domain.DownloadJob, downloadDir string) (<-c
<-progressDone
if err != nil {
job.Status = domain.StatusFailed
slog.Error("download failed", "url", job.URL, "error", err)
slog.Error("download failed", "url", job.URL, "error", err, "stderr", job.StderrLog)
} else {
job.Status = domain.StatusCompleted
}

View File

@@ -128,6 +128,7 @@ type DownloadJob struct {
FilePath string
Title string
QuotaApplied bool
StderrLog string
CancelCh chan struct{}
Cmd *exec.Cmd
}

View File

@@ -70,7 +70,15 @@ func (h *Handler) handleURLInput(ctx context.Context, chatID int64, text string,
if strings.Contains(errMsg, "DRM") {
youtubeURL, ytTitle, searchErr := h.YtDlp.SearchYoutube(text)
if searchErr != nil {
h.sendText(ctx, chatID, fmt.Sprintf("This content is DRM protected and no YouTube alternative was found: %s", errMsg))
kb := models.InlineKeyboardMarkup{
InlineKeyboard: [][]models.InlineKeyboardButton{
{{Text: "Search on YouTube", CallbackData: "drm_search"}},
{{Text: "Cancel", CallbackData: "pending_cancel"}},
},
}
h.Bot.SendMessage(ctx, &tgbot.SendMessageParams{
ChatID: chatID, Text: "This content is DRM protected and no YouTube alternative was found automatically. Try searching by name:", ReplyMarkup: kb,
})
return
}
h.sendText(ctx, chatID, fmt.Sprintf("This content is DRM protected. Found on YouTube: %s", ytTitle))
@@ -388,6 +396,8 @@ func (h *Handler) runDownload(ctx context.Context, job *domain.DownloadJob) {
lastProgress := 0.0
for upd := range updates {
slog.Info("progress update", "pct", upd.Progress, "speed", upd.Speed, "status", upd.Status)
h.mu.Lock()
currentJob := h.activeJobs[job.UserID]
h.mu.Unlock()
@@ -411,18 +421,23 @@ func (h *Handler) runDownload(ctx context.Context, job *domain.DownloadJob) {
return
}
if upd.Progress-lastProgress >= domain.ProgressThreshold || upd.Status == domain.StatusCompleted {
// Always show the first real progress update, then throttle at 5% intervals
if upd.Progress > 0 && lastProgress == 0 {
lastProgress = upd.Progress
} else if upd.Progress-lastProgress < domain.ProgressThreshold && upd.Status != domain.StatusCompleted {
continue
} else {
lastProgress = upd.Progress
if upd.Progress > domain.QuotaProgressThreshold && !job.QuotaApplied && job.FileSize > 0 {
job.QuotaApplied = true
h.applyQuota(job.UserID, job.FileSize)
}
kb := progressKeyboard(upd.Progress, upd.Speed, upd.ETA)
text := fmt.Sprintf("Downloading: %s", job.Title)
h.editText(ctx, job.ChatID, job.MessageID, text, &kb)
}
if upd.Progress > domain.QuotaProgressThreshold && !job.QuotaApplied && job.FileSize > 0 {
job.QuotaApplied = true
h.applyQuota(job.UserID, job.FileSize)
}
kb := progressKeyboard(upd.Progress, upd.Speed, upd.ETA)
text := fmt.Sprintf("Downloading: %s", job.Title)
h.editText(ctx, job.ChatID, job.MessageID, text, &kb)
}
h.mu.Lock()

View File

@@ -24,6 +24,7 @@ type PendingKind string
const (
PendingURL PendingKind = "url"
PendingDRM PendingKind = "drm"
)
type PendingInput struct {
@@ -235,6 +236,9 @@ func (h *Handler) HandleCallback(ctx context.Context, cb *models.CallbackQuery)
case data == "deleteaccount":
h.deleteAccountPrompt(ctx, chatID, msgID, userID)
h.answerCb(ctx, cb.ID)
case data == "drm_search":
h.promptForInput(ctx, chatID, msgID, userID, PendingDRM, "Enter a search query to find this content on YouTube:", nil)
h.answerCb(ctx, cb.ID)
case data == "pending_cancel":
h.backToMenu(ctx, chatID, msgID, cb.ID, userID)
default:
@@ -296,11 +300,23 @@ func (h *Handler) processPendingInput(ctx context.Context, msg *models.Message,
switch pi.Kind {
case PendingURL:
h.handleURLInput(ctx, msg.Chat.ID, text, userID)
case PendingDRM:
h.handleDRMSearch(ctx, msg.Chat.ID, text, userID)
default:
h.sendText(ctx, msg.Chat.ID, "Unknown input type")
}
}
func (h *Handler) handleDRMSearch(ctx context.Context, chatID int64, text string, userID int64) {
youtubeURL, ytTitle, err := h.YtDlp.SearchYoutube(text)
if err != nil {
h.sendText(ctx, chatID, fmt.Sprintf("No results found for '%s'. Try a different search query or send a YouTube URL directly.", text))
return
}
h.sendText(ctx, chatID, fmt.Sprintf("Found on YouTube: %s", ytTitle))
h.handleURLInput(ctx, chatID, youtubeURL, userID)
}
func (h *Handler) promptForInput(ctx context.Context, chatID int64, msgID int, userID int64, kind PendingKind, prompt string, data map[string]string) {
kb := models.InlineKeyboardMarkup{
InlineKeyboard: [][]models.InlineKeyboardButton{