Compare commits

...

2 Commits

Author SHA1 Message Date
0c27822871 fix: 'best' format on audio-only sites (SoundCloud) now shows audio containers
All checks were successful
CI / build (push) Successful in 51s
- When content is audio-only (IsAudioOnlyContent), 'best' format is marked as
  IsAudioOnly so determineMediaType returns MediaTypeAudio
- Previously 'best' was hardcoded with HasVideo:true, causing video containers
  (mp4/mkv) to be shown instead of audio containers (mp3/m4a/opus)
2026-06-25 16:06:57 +03:30
785e20bf94 refactor: rename Format to Quality, add container Audio/Video sub-pickers
- Rename 'Format' button to 'Quality' in settings (audio_quality/video_quality)
- Add DefaultAudioContainer and DefaultVideoContainer to UserPreferences
- Container settings now has Audio/Video sub-pickers (mp3/m4a/opus vs mp4/mkv/webm)
- Add migration 003 for new container columns
- Update repo layer and playlist flow for new fields
- Update back routing: back_quality, back_container
2026-06-25 16:03:03 +03:30
8 changed files with 117 additions and 54 deletions

View File

@@ -137,7 +137,8 @@ type UserPreferences struct {
DefaultMediaType MediaType DefaultMediaType MediaType
DefaultAudioFormat string DefaultAudioFormat string
DefaultVideoFormat string DefaultVideoFormat string
DefaultContainer string DefaultAudioContainer string
DefaultVideoContainer string
DefaultLanguage string DefaultLanguage string
} }

View File

@@ -166,7 +166,11 @@ func (h *Handler) handleFormatSelection(ctx context.Context, chatID int64, msgID
ss.MainFormatID = formatID ss.MainFormatID = formatID
if formatID == "best" { if formatID == "best" {
if ss.MediaInfo.IsAudioOnlyContent() {
ss.MainFormat = &domain.Format{ID: "best", HasAudio: true, IsAudioOnly: true}
} else {
ss.MainFormat = &domain.Format{ID: "best", HasVideo: true, HasAudio: true} ss.MainFormat = &domain.Format{ID: "best", HasVideo: true, HasAudio: true}
}
h.advanceToLanguage(ctx, chatID, msgID, userID, ss) h.advanceToLanguage(ctx, chatID, msgID, userID, ss)
return return
} }

View File

@@ -266,8 +266,10 @@ func (h *Handler) routePrefixedCallback(ctx context.Context, chatID int64, msgID
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)
} }
case data == "back_format": case data == "back_quality":
h.handleSettingsSelection(ctx, chatID, msgID, userID, "format") h.handleSettingsSelection(ctx, chatID, msgID, userID, "quality")
case data == "back_container":
h.handleSettingsSelection(ctx, chatID, msgID, userID, "container")
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

@@ -145,13 +145,16 @@ func (h *Handler) handlePlaylistConfirm(ctx context.Context, chatID int64, msgID
DefaultMediaType: domain.MediaTypeVideoAudio, DefaultMediaType: domain.MediaTypeVideoAudio,
DefaultAudioFormat: "best", DefaultAudioFormat: "best",
DefaultVideoFormat: "best", DefaultVideoFormat: "best",
DefaultContainer: "mp4", DefaultAudioContainer: "mp3",
DefaultVideoContainer: "mp4",
} }
} }
formatID := prefs.DefaultVideoFormat formatID := prefs.DefaultVideoFormat
container := prefs.DefaultVideoContainer
if prefs.DefaultMediaType == domain.MediaTypeAudio { if prefs.DefaultMediaType == domain.MediaTypeAudio {
formatID = prefs.DefaultAudioFormat formatID = prefs.DefaultAudioFormat
container = prefs.DefaultAudioContainer
} }
format := &domain.Format{ID: formatID} format := &domain.Format{ID: formatID}
for _, f := range info.Formats { for _, f := range info.Formats {
@@ -168,7 +171,7 @@ func (h *Handler) handlePlaylistConfirm(ctx context.Context, chatID int64, msgID
URL: entry.URL, URL: entry.URL,
MediaType: prefs.DefaultMediaType, MediaType: prefs.DefaultMediaType,
SelectedFormat: format, SelectedFormat: format,
Container: prefs.DefaultContainer, Container: container,
Language: prefs.DefaultLanguage, Language: prefs.DefaultLanguage,
Status: domain.StatusDownloading, Status: domain.StatusDownloading,
Title: entry.Title, Title: entry.Title,

View File

@@ -20,9 +20,13 @@ func (h *Handler) buildSettingsKeyboard(userID int64, prefs *domain.UserPreferen
if videoFmt == "" { if videoFmt == "" {
videoFmt = "best" videoFmt = "best"
} }
containerLabel := prefs.DefaultContainer audioCont := prefs.DefaultAudioContainer
if containerLabel == "" { if audioCont == "" {
containerLabel = "mp4" audioCont = "mp3"
}
videoCont := prefs.DefaultVideoContainer
if videoCont == "" {
videoCont = "mp4"
} }
langLabel := prefs.DefaultLanguage langLabel := prefs.DefaultLanguage
if langLabel == "" { if langLabel == "" {
@@ -31,10 +35,10 @@ func (h *Handler) buildSettingsKeyboard(userID int64, prefs *domain.UserPreferen
rows := [][]models.InlineKeyboardButton{ rows := [][]models.InlineKeyboardButton{
{ {
{Text: fmt.Sprintf("Format: %s/%s", audioFmt, videoFmt), CallbackData: "settings_format"}, {Text: fmt.Sprintf("Quality: %s/%s", audioFmt, videoFmt), CallbackData: "settings_quality"},
}, },
{ {
{Text: fmt.Sprintf("Container: %s", containerLabel), CallbackData: "settings_container"}, {Text: fmt.Sprintf("Container: %s/%s", audioCont, videoCont), CallbackData: "settings_container"},
{Text: fmt.Sprintf("Language: %s", langLabel), CallbackData: "settings_language"}, {Text: fmt.Sprintf("Language: %s", langLabel), CallbackData: "settings_language"},
}, },
{{Text: "Delete all data", CallbackData: "deleteaccount", Style: "danger"}}, {{Text: "Delete all data", CallbackData: "deleteaccount", Style: "danger"}},
@@ -69,17 +73,23 @@ func (h *Handler) handleSettingsSelection(ctx context.Context, chatID int64, msg
} }
switch setting { switch setting {
case "format": case "quality":
text, kb := h.buildFormatPicker(prefs) text, kb := h.buildQualityAudioVideoPicker(prefs)
h.editText(ctx, chatID, msgID, text, &kb) h.editText(ctx, chatID, msgID, text, &kb)
case "format_audio": case "quality_audio":
text, kb := h.buildAudioFormatPicker(prefs) text, kb := h.buildAudioQualityPicker(prefs)
h.editText(ctx, chatID, msgID, text, &kb) h.editText(ctx, chatID, msgID, text, &kb)
case "format_video": case "quality_video":
text, kb := h.buildVideoFormatPicker(prefs) text, kb := h.buildVideoQualityPicker(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.buildContainerAudioVideoPicker(prefs)
h.editText(ctx, chatID, msgID, text, &kb)
case "container_audio":
text, kb := h.buildAudioContainerPicker(prefs)
h.editText(ctx, chatID, msgID, text, &kb)
case "container_video":
text, kb := h.buildVideoContainerPicker(prefs)
h.editText(ctx, chatID, msgID, text, &kb) h.editText(ctx, chatID, msgID, text, &kb)
case "language": case "language":
text, kb := h.buildLanguagePicker(prefs) text, kb := h.buildLanguagePicker(prefs)
@@ -87,16 +97,16 @@ func (h *Handler) handleSettingsSelection(ctx context.Context, chatID int64, msg
} }
} }
func (h *Handler) buildFormatPicker(prefs *domain.UserPreferences) (string, models.InlineKeyboardMarkup) { func (h *Handler) buildQualityAudioVideoPicker(prefs *domain.UserPreferences) (string, models.InlineKeyboardMarkup) {
rows := [][]models.InlineKeyboardButton{ rows := [][]models.InlineKeyboardButton{
{{Text: "Audio", CallbackData: "settings_format_audio"}}, {{Text: "Audio", CallbackData: "settings_quality_audio"}},
{{Text: "Video", CallbackData: "settings_format_video"}}, {{Text: "Video", CallbackData: "settings_quality_video"}},
{{Text: "Back to Settings", CallbackData: "back_settings"}}, {{Text: "Back to Settings", CallbackData: "back_settings"}},
} }
return "Choose format type to set default:", models.InlineKeyboardMarkup{InlineKeyboard: rows} return "Choose quality type to set default:", models.InlineKeyboardMarkup{InlineKeyboard: rows}
} }
func (h *Handler) buildAudioFormatPicker(prefs *domain.UserPreferences) (string, models.InlineKeyboardMarkup) { func (h *Handler) buildAudioQualityPicker(prefs *domain.UserPreferences) (string, models.InlineKeyboardMarkup) {
bitrates := []string{"best", "128k", "192k", "256k", "320k"} bitrates := []string{"best", "128k", "192k", "256k", "320k"}
rows := [][]models.InlineKeyboardButton{} rows := [][]models.InlineKeyboardButton{}
for _, b := range bitrates { for _, b := range bitrates {
@@ -105,16 +115,16 @@ func (h *Handler) buildAudioFormatPicker(prefs *domain.UserPreferences) (string,
label = "> " + label label = "> " + label
} }
rows = append(rows, []models.InlineKeyboardButton{ rows = append(rows, []models.InlineKeyboardButton{
{Text: label, CallbackData: "settings_set_audioformat_" + b}, {Text: label, CallbackData: "settings_set_audioquality_" + b},
}) })
} }
rows = append(rows, []models.InlineKeyboardButton{ rows = append(rows, []models.InlineKeyboardButton{
{Text: "Back", CallbackData: "back_format"}, {Text: "Back", CallbackData: "back_quality"},
}) })
return "Select default audio format:", models.InlineKeyboardMarkup{InlineKeyboard: rows} return "Select default audio quality:", models.InlineKeyboardMarkup{InlineKeyboard: rows}
} }
func (h *Handler) buildVideoFormatPicker(prefs *domain.UserPreferences) (string, models.InlineKeyboardMarkup) { func (h *Handler) buildVideoQualityPicker(prefs *domain.UserPreferences) (string, models.InlineKeyboardMarkup) {
resolutions := []string{"best", "2160p", "1440p", "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{}
@@ -124,7 +134,7 @@ func (h *Handler) buildVideoFormatPicker(prefs *domain.UserPreferences) (string,
label = "> " + label label = "> " + label
} }
row = append(row, models.InlineKeyboardButton{ row = append(row, models.InlineKeyboardButton{
Text: label, CallbackData: "settings_set_videoformat_" + r, Text: label, CallbackData: "settings_set_videoquality_" + r,
}) })
if len(row) == 3 || i == len(resolutions)-1 { if len(row) == 3 || i == len(resolutions)-1 {
rows = append(rows, row) rows = append(rows, row)
@@ -132,22 +142,49 @@ func (h *Handler) buildVideoFormatPicker(prefs *domain.UserPreferences) (string,
} }
} }
rows = append(rows, []models.InlineKeyboardButton{ rows = append(rows, []models.InlineKeyboardButton{
{Text: "Back", CallbackData: "back_format"}, {Text: "Back", CallbackData: "back_quality"},
}) })
return "Select default video format:", models.InlineKeyboardMarkup{InlineKeyboard: rows} return "Select default video quality:", models.InlineKeyboardMarkup{InlineKeyboard: rows}
} }
func (h *Handler) buildContainerPicker(prefs *domain.UserPreferences) (string, models.InlineKeyboardMarkup) { func (h *Handler) buildContainerAudioVideoPicker(prefs *domain.UserPreferences) (string, models.InlineKeyboardMarkup) {
containers := []string{"mp4", "mkv", "webm", "mp3", "m4a", "opus"} rows := [][]models.InlineKeyboardButton{
{{Text: "Audio", CallbackData: "settings_container_audio"}},
{{Text: "Video", CallbackData: "settings_container_video"}},
{{Text: "Back to Settings", CallbackData: "back_settings"}},
}
return "Choose container type to set default:", models.InlineKeyboardMarkup{InlineKeyboard: rows}
}
func (h *Handler) buildAudioContainerPicker(prefs *domain.UserPreferences) (string, models.InlineKeyboardMarkup) {
containers := []string{"mp3", "m4a", "opus"}
rows := [][]models.InlineKeyboardButton{}
for _, c := range containers {
label := c
if c == prefs.DefaultAudioContainer {
label = "> " + label
}
rows = append(rows, []models.InlineKeyboardButton{
{Text: label, CallbackData: "settings_set_audiocontainer_" + c},
})
}
rows = append(rows, []models.InlineKeyboardButton{
{Text: "Back", CallbackData: "back_container"},
})
return "Select default audio container:", models.InlineKeyboardMarkup{InlineKeyboard: rows}
}
func (h *Handler) buildVideoContainerPicker(prefs *domain.UserPreferences) (string, models.InlineKeyboardMarkup) {
containers := []string{"mp4", "mkv", "webm"}
rows := [][]models.InlineKeyboardButton{} rows := [][]models.InlineKeyboardButton{}
row := []models.InlineKeyboardButton{} row := []models.InlineKeyboardButton{}
for i, c := range containers { for i, c := range containers {
label := c label := c
if c == prefs.DefaultContainer { if c == prefs.DefaultVideoContainer {
label = "> " + label label = "> " + label
} }
row = append(row, models.InlineKeyboardButton{ row = append(row, models.InlineKeyboardButton{
Text: label, CallbackData: "settings_set_container_" + c, Text: label, CallbackData: "settings_set_videocontainer_" + c,
}) })
if len(row) == 3 || i == len(containers)-1 { if len(row) == 3 || i == len(containers)-1 {
rows = append(rows, row) rows = append(rows, row)
@@ -155,9 +192,9 @@ func (h *Handler) buildContainerPicker(prefs *domain.UserPreferences) (string, m
} }
} }
rows = append(rows, []models.InlineKeyboardButton{ rows = append(rows, []models.InlineKeyboardButton{
{Text: "Back to Settings", CallbackData: "back_settings"}, {Text: "Back", CallbackData: "back_container"},
}) })
return "Select default container:", models.InlineKeyboardMarkup{InlineKeyboard: rows} return "Select default video container:", models.InlineKeyboardMarkup{InlineKeyboard: rows}
} }
func (h *Handler) buildLanguagePicker(prefs *domain.UserPreferences) (string, models.InlineKeyboardMarkup) { func (h *Handler) buildLanguagePicker(prefs *domain.UserPreferences) (string, models.InlineKeyboardMarkup) {
@@ -203,14 +240,17 @@ func (h *Handler) handleSettingsSet(ctx context.Context, chatID int64, msgID int
changed := false changed := false
switch action { switch action {
case "audioformat": case "audioquality":
prefs.DefaultAudioFormat = value prefs.DefaultAudioFormat = value
changed = true changed = true
case "videoformat": case "videoquality":
prefs.DefaultVideoFormat = value prefs.DefaultVideoFormat = value
changed = true changed = true
case "container": case "audiocontainer":
prefs.DefaultContainer = value prefs.DefaultAudioContainer = value
changed = true
case "videocontainer":
prefs.DefaultVideoContainer = value
changed = true changed = true
case "language": case "language":
prefs.DefaultLanguage = value prefs.DefaultLanguage = value

View File

@@ -0,0 +1,7 @@
-- +goose Up
ALTER TABLE user_preferences ADD COLUMN default_audio_container TEXT NOT NULL DEFAULT 'mp3';
ALTER TABLE user_preferences ADD COLUMN default_video_container TEXT NOT NULL DEFAULT 'mp4';
-- +goose Down
ALTER TABLE user_preferences DROP COLUMN default_audio_container;
ALTER TABLE user_preferences DROP COLUMN default_video_container;

View File

@@ -76,13 +76,13 @@ 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_audio_format, default_video_format, SELECT default_media_type, default_audio_format, default_video_format,
default_container, default_language default_audio_container, default_video_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.DefaultAudioFormat, &p.DefaultVideoFormat, if err := row.Scan(&mediaType, &p.DefaultAudioFormat, &p.DefaultVideoFormat,
&p.DefaultContainer, &p.DefaultLanguage); err != nil { &p.DefaultAudioContainer, &p.DefaultVideoContainer, &p.DefaultLanguage); err != nil {
return nil, err return nil, err
} }
switch mediaType { switch mediaType {
@@ -110,10 +110,12 @@ 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_audio_format=?, UPDATE user_preferences SET default_media_type=?, default_audio_format=?,
default_video_format=?, default_container=?, default_language=? default_video_format=?, default_audio_container=?,
default_video_container=?, default_language=?
WHERE user_id=? WHERE user_id=?
`, mediaTypeToString(prefs.DefaultMediaType), prefs.DefaultAudioFormat, `, mediaTypeToString(prefs.DefaultMediaType), prefs.DefaultAudioFormat,
prefs.DefaultVideoFormat, prefs.DefaultContainer, prefs.DefaultLanguage, userID) prefs.DefaultVideoFormat, prefs.DefaultAudioContainer,
prefs.DefaultVideoContainer, prefs.DefaultLanguage, userID)
return err return err
} }

View File

@@ -66,7 +66,8 @@ func TestPreferences(t *testing.T) {
prefs.DefaultMediaType = domain.MediaTypeAudio prefs.DefaultMediaType = domain.MediaTypeAudio
prefs.DefaultAudioFormat = "best" prefs.DefaultAudioFormat = "best"
prefs.DefaultVideoFormat = "1080p" prefs.DefaultVideoFormat = "1080p"
prefs.DefaultContainer = "mp3" prefs.DefaultAudioContainer = "mp3"
prefs.DefaultVideoContainer = "mp4"
prefs.DefaultLanguage = "en" prefs.DefaultLanguage = "en"
if err := s.UpdatePreferences(userID, prefs); err != nil { if err := s.UpdatePreferences(userID, prefs); err != nil {
t.Fatalf("UpdatePreferences() failed: %v", err) t.Fatalf("UpdatePreferences() failed: %v", err)
@@ -85,8 +86,11 @@ func TestPreferences(t *testing.T) {
if got.DefaultVideoFormat != "1080p" { if got.DefaultVideoFormat != "1080p" {
t.Errorf("video format = %q, want 1080p", got.DefaultVideoFormat) t.Errorf("video format = %q, want 1080p", got.DefaultVideoFormat)
} }
if got.DefaultContainer != "mp3" { if got.DefaultAudioContainer != "mp3" {
t.Errorf("container = %q, want mp3", got.DefaultContainer) t.Errorf("audio container = %q, want mp3", got.DefaultAudioContainer)
}
if got.DefaultVideoContainer != "mp4" {
t.Errorf("video container = %q, want mp4", got.DefaultVideoContainer)
} }
if got.DefaultLanguage != "en" { if got.DefaultLanguage != "en" {
t.Errorf("language = %q, want en", got.DefaultLanguage) t.Errorf("language = %q, want en", got.DefaultLanguage)