Compare commits

...

2 Commits

Author SHA1 Message Date
e08ed77884 refactor: settings format picker redesign with Audio/Video sub-pickers, fix delete routing
All checks were successful
CI / build (push) Successful in 48s
- Replace Type/Quality settings with single Format button -> Audio/Video sub-pickers
- Each sub-picker shows format options (bitrates for audio, resolutions for video)
- Replace DefaultQuality with DefaultAudioFormat/DefaultVideoFormat in UserPreferences
- Add DB migration 002 for new preference columns
- Update repo layer SQL queries for new schema
- Update playlist flow to use new preference fields
- Fix missing deleteaccount and delaccount_ callback routing
- Add back_format routing for format picker navigation
2026-06-25 15:55:59 +03:30
30a99ac2fd fix: progress stuck at 0%, container not respected, audio display, Spotify DRM fallback
- Remove --no-progress from yt-dlp args that suppressed all progress output
- Add --merge-output-format when FormatString is set (container was ignored for combined formats)
- Improve formatLabelForDisplay for audio-only formats missing bitrate (show ID+extension)
- Detect DRM errors (Spotify) and fall back to YouTube Music search via ytsearch1:
2026-06-25 15:50:43 +03:30
9 changed files with 166 additions and 80 deletions

View File

@@ -220,6 +220,9 @@ func (c *Client) StartDownload(job *domain.DownloadJob, downloadDir string) (<-c
// Use explicit format string if set (video+audio combination) // Use explicit format string if set (video+audio combination)
if job.FormatString != "" { if job.FormatString != "" {
args = append(args, "-f", job.FormatString) args = append(args, "-f", job.FormatString)
if job.Container != "" {
args = append(args, "--merge-output-format", job.Container)
}
} else { } else {
// Build format string for merge // Build format string for merge
switch job.MediaType { switch job.MediaType {
@@ -243,8 +246,8 @@ func (c *Client) StartDownload(job *domain.DownloadJob, downloadDir string) (<-c
outputPath := filepath.Join(downloadDir, job.ID+"_%(id)s.%(ext)s") outputPath := filepath.Join(downloadDir, job.ID+"_%(id)s.%(ext)s")
args = append(args, "--output", outputPath) args = append(args, "--output", outputPath)
// Progress reporting // Progress reporting (--newline for line-based, no --no-progress so we get actual output)
args = append(args, "--newline", "--no-progress") args = append(args, "--newline")
// Language selection if specified // Language selection if specified
if job.Language != "" { if job.Language != "" {
@@ -337,6 +340,40 @@ func CancelDownload(job *domain.DownloadJob) {
} }
} }
// ytdlpSearchResult holds a single flat search result from yt-dlp.
type ytdlpSearchResult struct {
Title string `json:"title"`
URL string `json:"url"`
}
// SearchYoutube searches YouTube via yt-dlp ytsearch and returns the first result URL and title.
func (c *Client) SearchYoutube(query string) (url, title string, err error) {
args := []string{
"--flat-playlist", "-J", "--no-download",
"--no-warnings",
"ytsearch1:" + query,
}
if c.cookiesFile != "" {
args = append(args, "--cookies", c.cookiesFile)
}
out, err := c.run(args)
if err != nil {
return "", "", fmt.Errorf("youtube search failed: %w", err)
}
var info struct {
Entries []ytdlpSearchResult `json:"entries"`
}
if err := json.Unmarshal(out, &info); err != nil {
return "", "", fmt.Errorf("parse search result: %w", err)
}
if len(info.Entries) == 0 {
return "", "", fmt.Errorf("no results found")
}
return info.Entries[0].URL, info.Entries[0].Title, nil
}
func (c *Client) run(args []string) ([]byte, error) { func (c *Client) run(args []string) ([]byte, error) {
cmd := exec.Command(c.binaryPath, args...) cmd := exec.Command(c.binaryPath, args...)
var stderr bytes.Buffer var stderr bytes.Buffer

View File

@@ -134,10 +134,11 @@ type DownloadJob struct {
// UserPreferences holds per-user default preferences. // UserPreferences holds per-user default preferences.
type UserPreferences struct { type UserPreferences struct {
DefaultMediaType MediaType DefaultMediaType MediaType
DefaultQuality string DefaultAudioFormat string
DefaultContainer string DefaultVideoFormat string
DefaultLanguage string DefaultContainer string
DefaultLanguage string
} }
// UserQuotas holds the quota usage and limits for a user. // UserQuotas holds the quota usage and limits for a user.

View File

@@ -65,17 +65,34 @@ func (h *Handler) handleURLInput(ctx context.Context, chatID int64, text string,
info, err := h.YtDlp.GetMediaInfo(url) info, err := h.YtDlp.GetMediaInfo(url)
if err != nil { if err != nil {
errMsg := err.Error() errMsg := err.Error()
switch {
case strings.Contains(errMsg, "Unsupported URL") || strings.Contains(errMsg, "is not a valid URL"): // DRM content (Spotify, etc.): fall back to YouTube search
h.sendText(ctx, chatID, "This URL is not supported.") if strings.Contains(errMsg, "DRM") {
case strings.Contains(errMsg, "HTTP Error 403") || strings.Contains(errMsg, "cookies"): youtubeURL, ytTitle, searchErr := h.YtDlp.SearchYoutube(text)
h.sendText(ctx, chatID, "This content requires authentication (cookies).") if searchErr != nil {
case strings.Contains(errMsg, "live"): h.sendText(ctx, chatID, fmt.Sprintf("This content is DRM protected and no YouTube alternative was found: %s", errMsg))
h.sendText(ctx, chatID, "Cannot download live streams.") return
default: }
h.sendText(ctx, chatID, fmt.Sprintf("Error: %s", errMsg)) 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.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.sendText(ctx, chatID, "This URL is not supported.")
case strings.Contains(errMsg, "HTTP Error 403") || strings.Contains(errMsg, "cookies"):
h.sendText(ctx, chatID, "This content requires authentication (cookies).")
case strings.Contains(errMsg, "live"):
h.sendText(ctx, chatID, "Cannot download live streams.")
default:
h.sendText(ctx, chatID, fmt.Sprintf("Error: %s", errMsg))
}
return
} }
return
} }
if info.IsPlaylist { if info.IsPlaylist {
@@ -571,6 +588,17 @@ func formatLabelForDisplay(f domain.Format) string {
} }
return label return label
} }
// Audio-only with no bitrate: show format ID + extension
if f.IsAudioOnly {
label := f.ID
if f.Extension != "" {
label += " (" + f.Extension + ")"
}
if f.Filesize > 0 {
label += fmt.Sprintf(" (%s)", formatBytes(f.Filesize))
}
return label
}
return f.ID return f.ID
} }

View File

@@ -232,6 +232,9 @@ func (h *Handler) HandleCallback(ctx context.Context, cb *models.CallbackQuery)
h.answerCb(ctx, cb.ID) h.answerCb(ctx, cb.ID)
case data == "history": case data == "history":
h.historyCallback(ctx, chatID, msgID, cb.ID, userID) h.historyCallback(ctx, chatID, msgID, cb.ID, userID)
case data == "deleteaccount":
h.deleteAccountPrompt(ctx, chatID, msgID, userID)
h.answerCb(ctx, cb.ID)
case data == "pending_cancel": case data == "pending_cancel":
h.backToMenu(ctx, chatID, msgID, cb.ID, userID) h.backToMenu(ctx, chatID, msgID, cb.ID, userID)
default: default:
@@ -259,6 +262,12 @@ func (h *Handler) routePrefixedCallback(ctx context.Context, chatID int64, msgID
h.handlePlaylistSelectAll(ctx, chatID, msgID, userID) h.handlePlaylistSelectAll(ctx, chatID, msgID, userID)
case data == "pl_confirm": case data == "pl_confirm":
h.handlePlaylistConfirm(ctx, chatID, msgID, userID) 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)
}
case data == "back_format":
h.handleSettingsSelection(ctx, chatID, msgID, userID, "format")
case data == "back_settings": case data == "back_settings":
h.backToSettings(ctx, chatID, msgID, userID) h.backToSettings(ctx, chatID, msgID, userID)
case strings.HasPrefix(data, "settings_set_"): case strings.HasPrefix(data, "settings_set_"):

View File

@@ -142,15 +142,20 @@ func (h *Handler) handlePlaylistConfirm(ctx context.Context, chatID int64, msgID
prefs, prefErr := h.Repo.GetOrCreatePreferences(userID) prefs, prefErr := h.Repo.GetOrCreatePreferences(userID)
if prefErr != nil { if prefErr != nil {
prefs = &domain.UserPreferences{ prefs = &domain.UserPreferences{
DefaultMediaType: domain.MediaTypeVideoAudio, DefaultMediaType: domain.MediaTypeVideoAudio,
DefaultQuality: "best", DefaultAudioFormat: "best",
DefaultContainer: "mp4", DefaultVideoFormat: "best",
DefaultContainer: "mp4",
} }
} }
format := &domain.Format{ID: prefs.DefaultQuality} formatID := prefs.DefaultVideoFormat
if prefs.DefaultMediaType == domain.MediaTypeAudio {
formatID = prefs.DefaultAudioFormat
}
format := &domain.Format{ID: formatID}
for _, f := range info.Formats { for _, f := range info.Formats {
if f.ID == prefs.DefaultQuality { if f.ID == formatID {
format = &f format = &f
break break
} }

View File

@@ -12,16 +12,13 @@ import (
) )
func (h *Handler) buildSettingsKeyboard(userID int64, prefs *domain.UserPreferences) (string, models.InlineKeyboardMarkup) { func (h *Handler) buildSettingsKeyboard(userID int64, prefs *domain.UserPreferences) (string, models.InlineKeyboardMarkup) {
mediaTypeLabel := "Video+Audio" audioFmt := prefs.DefaultAudioFormat
switch prefs.DefaultMediaType { if audioFmt == "" {
case domain.MediaTypeAudio: audioFmt = "best"
mediaTypeLabel = "Audio Only"
case domain.MediaTypeVideo:
mediaTypeLabel = "Video Only"
} }
qualityLabel := prefs.DefaultQuality videoFmt := prefs.DefaultVideoFormat
if qualityLabel == "" { if videoFmt == "" {
qualityLabel = "best" videoFmt = "best"
} }
containerLabel := prefs.DefaultContainer containerLabel := prefs.DefaultContainer
if containerLabel == "" { if containerLabel == "" {
@@ -34,8 +31,7 @@ func (h *Handler) buildSettingsKeyboard(userID int64, prefs *domain.UserPreferen
rows := [][]models.InlineKeyboardButton{ rows := [][]models.InlineKeyboardButton{
{ {
{Text: fmt.Sprintf("Type: %s", mediaTypeLabel), CallbackData: "settings_media_type"}, {Text: fmt.Sprintf("Format: %s/%s", audioFmt, videoFmt), CallbackData: "settings_format"},
{Text: fmt.Sprintf("Quality: %s", qualityLabel), CallbackData: "settings_quality"},
}, },
{ {
{Text: fmt.Sprintf("Container: %s", containerLabel), CallbackData: "settings_container"}, {Text: fmt.Sprintf("Container: %s", containerLabel), CallbackData: "settings_container"},
@@ -73,11 +69,14 @@ func (h *Handler) handleSettingsSelection(ctx context.Context, chatID int64, msg
} }
switch setting { switch setting {
case "media_type": case "format":
text, kb := h.buildMediaTypePicker(prefs) text, kb := h.buildFormatPicker(prefs)
h.editText(ctx, chatID, msgID, text, &kb) h.editText(ctx, chatID, msgID, text, &kb)
case "quality": case "format_audio":
text, kb := h.buildQualityPicker(prefs) text, kb := h.buildAudioFormatPicker(prefs)
h.editText(ctx, chatID, msgID, text, &kb)
case "format_video":
text, kb := h.buildVideoFormatPicker(prefs)
h.editText(ctx, chatID, msgID, text, &kb) h.editText(ctx, chatID, msgID, text, &kb)
case "container": case "container":
text, kb := h.buildContainerPicker(prefs) text, kb := h.buildContainerPicker(prefs)
@@ -88,53 +87,54 @@ func (h *Handler) handleSettingsSelection(ctx context.Context, chatID int64, msg
} }
} }
func (h *Handler) buildMediaTypePicker(prefs *domain.UserPreferences) (string, models.InlineKeyboardMarkup) { func (h *Handler) buildFormatPicker(prefs *domain.UserPreferences) (string, models.InlineKeyboardMarkup) {
options := []struct { rows := [][]models.InlineKeyboardButton{
label string {{Text: "Audio", CallbackData: "settings_format_audio"}},
value domain.MediaType {{Text: "Video", CallbackData: "settings_format_video"}},
data string {{Text: "Back to Settings", CallbackData: "back_settings"}},
}{
{"Audio Only", domain.MediaTypeAudio, "settings_set_type_audio"},
{"Video Only", domain.MediaTypeVideo, "settings_set_type_video"},
{"Video+Audio", domain.MediaTypeVideoAudio, "settings_set_type_both"},
} }
return "Choose format type to set default:", models.InlineKeyboardMarkup{InlineKeyboard: rows}
}
func (h *Handler) buildAudioFormatPicker(prefs *domain.UserPreferences) (string, models.InlineKeyboardMarkup) {
bitrates := []string{"best", "128k", "192k", "256k", "320k"}
rows := [][]models.InlineKeyboardButton{} rows := [][]models.InlineKeyboardButton{}
for _, opt := range options { for _, b := range bitrates {
label := opt.label label := b
if opt.value == prefs.DefaultMediaType { if b == prefs.DefaultAudioFormat {
label = "> " + label label = "> " + label
} }
rows = append(rows, []models.InlineKeyboardButton{ rows = append(rows, []models.InlineKeyboardButton{
{Text: label, CallbackData: opt.data}, {Text: label, CallbackData: "settings_set_audioformat_" + b},
}) })
} }
rows = append(rows, []models.InlineKeyboardButton{ rows = append(rows, []models.InlineKeyboardButton{
{Text: "Back to Settings", CallbackData: "back_settings"}, {Text: "Back", CallbackData: "back_format"},
}) })
return "Select default media type:", models.InlineKeyboardMarkup{InlineKeyboard: rows} return "Select default audio format:", models.InlineKeyboardMarkup{InlineKeyboard: rows}
} }
func (h *Handler) buildQualityPicker(prefs *domain.UserPreferences) (string, models.InlineKeyboardMarkup) { func (h *Handler) buildVideoFormatPicker(prefs *domain.UserPreferences) (string, models.InlineKeyboardMarkup) {
qualities := []string{"best", "2160p", "1080p", "720p", "480p", "360p"} resolutions := []string{"best", "2160p", "1440p", "1080p", "720p", "480p", "360p"}
rows := [][]models.InlineKeyboardButton{} rows := [][]models.InlineKeyboardButton{}
row := []models.InlineKeyboardButton{} row := []models.InlineKeyboardButton{}
for i, q := range qualities { for i, r := range resolutions {
label := q label := r
if q == prefs.DefaultQuality { if r == prefs.DefaultVideoFormat {
label = "> " + label label = "> " + label
} }
row = append(row, models.InlineKeyboardButton{ row = append(row, models.InlineKeyboardButton{
Text: label, CallbackData: "settings_set_quality_" + q, Text: label, CallbackData: "settings_set_videoformat_" + r,
}) })
if len(row) == 3 || i == len(qualities)-1 { if len(row) == 3 || i == len(resolutions)-1 {
rows = append(rows, row) rows = append(rows, row)
row = nil row = nil
} }
} }
rows = append(rows, []models.InlineKeyboardButton{ rows = append(rows, []models.InlineKeyboardButton{
{Text: "Back to Settings", CallbackData: "back_settings"}, {Text: "Back", CallbackData: "back_format"},
}) })
return "Select default quality:", models.InlineKeyboardMarkup{InlineKeyboard: rows} return "Select default video format:", models.InlineKeyboardMarkup{InlineKeyboard: rows}
} }
func (h *Handler) buildContainerPicker(prefs *domain.UserPreferences) (string, models.InlineKeyboardMarkup) { func (h *Handler) buildContainerPicker(prefs *domain.UserPreferences) (string, models.InlineKeyboardMarkup) {
@@ -203,18 +203,11 @@ func (h *Handler) handleSettingsSet(ctx context.Context, chatID int64, msgID int
changed := false changed := false
switch action { switch action {
case "type": case "audioformat":
switch value { prefs.DefaultAudioFormat = value
case "audio":
prefs.DefaultMediaType = domain.MediaTypeAudio
case "video":
prefs.DefaultMediaType = domain.MediaTypeVideo
case "both":
prefs.DefaultMediaType = domain.MediaTypeVideoAudio
}
changed = true changed = true
case "quality": case "videoformat":
prefs.DefaultQuality = value prefs.DefaultVideoFormat = value
changed = true changed = true
case "container": case "container":
prefs.DefaultContainer = value prefs.DefaultContainer = value

View File

@@ -0,0 +1,7 @@
-- +goose Up
ALTER TABLE user_preferences ADD COLUMN default_audio_format TEXT NOT NULL DEFAULT 'best';
ALTER TABLE user_preferences ADD COLUMN default_video_format TEXT NOT NULL DEFAULT 'best';
-- +goose Down
ALTER TABLE user_preferences DROP COLUMN default_audio_format;
ALTER TABLE user_preferences DROP COLUMN default_video_format;

View File

@@ -75,12 +75,14 @@ func (s *Store) GetOrCreatePreferences(userID int64) (*domain.UserPreferences, e
func (s *Store) getPreferences(userID int64) (*domain.UserPreferences, error) { func (s *Store) getPreferences(userID int64) (*domain.UserPreferences, error) {
row := s.db.QueryRow(` row := s.db.QueryRow(`
SELECT default_media_type, default_quality, default_container, default_language SELECT default_media_type, default_audio_format, default_video_format,
default_container, default_language
FROM user_preferences WHERE user_id = ? FROM user_preferences WHERE user_id = ?
`, userID) `, userID)
var p domain.UserPreferences var p domain.UserPreferences
var mediaType string var mediaType string
if err := row.Scan(&mediaType, &p.DefaultQuality, &p.DefaultContainer, &p.DefaultLanguage); err != nil { if err := row.Scan(&mediaType, &p.DefaultAudioFormat, &p.DefaultVideoFormat,
&p.DefaultContainer, &p.DefaultLanguage); err != nil {
return nil, err return nil, err
} }
switch mediaType { switch mediaType {
@@ -107,11 +109,11 @@ func mediaTypeToString(mt domain.MediaType) string {
func (s *Store) UpdatePreferences(userID int64, prefs *domain.UserPreferences) error { func (s *Store) UpdatePreferences(userID int64, prefs *domain.UserPreferences) error {
_, err := s.db.Exec(` _, err := s.db.Exec(`
UPDATE user_preferences SET default_media_type=?, default_quality=?, UPDATE user_preferences SET default_media_type=?, default_audio_format=?,
default_container=?, default_language=? default_video_format=?, default_container=?, default_language=?
WHERE user_id=? WHERE user_id=?
`, mediaTypeToString(prefs.DefaultMediaType), prefs.DefaultQuality, `, mediaTypeToString(prefs.DefaultMediaType), prefs.DefaultAudioFormat,
prefs.DefaultContainer, prefs.DefaultLanguage, userID) prefs.DefaultVideoFormat, prefs.DefaultContainer, prefs.DefaultLanguage, userID)
return err return err
} }

View File

@@ -64,7 +64,8 @@ func TestPreferences(t *testing.T) {
} }
prefs.DefaultMediaType = domain.MediaTypeAudio prefs.DefaultMediaType = domain.MediaTypeAudio
prefs.DefaultQuality = "best" prefs.DefaultAudioFormat = "best"
prefs.DefaultVideoFormat = "1080p"
prefs.DefaultContainer = "mp3" prefs.DefaultContainer = "mp3"
prefs.DefaultLanguage = "en" prefs.DefaultLanguage = "en"
if err := s.UpdatePreferences(userID, prefs); err != nil { if err := s.UpdatePreferences(userID, prefs); err != nil {
@@ -78,8 +79,11 @@ func TestPreferences(t *testing.T) {
if got.DefaultMediaType != domain.MediaTypeAudio { if got.DefaultMediaType != domain.MediaTypeAudio {
t.Errorf("media type = %v, want Audio", got.DefaultMediaType) t.Errorf("media type = %v, want Audio", got.DefaultMediaType)
} }
if got.DefaultQuality != "best" { if got.DefaultAudioFormat != "best" {
t.Errorf("quality = %q, want best", got.DefaultQuality) t.Errorf("audio format = %q, want best", got.DefaultAudioFormat)
}
if got.DefaultVideoFormat != "1080p" {
t.Errorf("video format = %q, want 1080p", got.DefaultVideoFormat)
} }
if got.DefaultContainer != "mp3" { if got.DefaultContainer != "mp3" {
t.Errorf("container = %q, want mp3", got.DefaultContainer) t.Errorf("container = %q, want mp3", got.DefaultContainer)