feat: add auto-select from preferences and type picker
All checks were successful
CI / build (push) Successful in 52s
All checks were successful
CI / build (push) Successful in 52s
- Add MediaTypeAsk (0) as the default, mapping old video_audio to Ask - Show Video/Audio/Ask type picker when no default media type is set - Auto-select matching format and skip picker when defaults are configured - Add autoSelectVideoFormat / autoSelectAudioFormat helpers with closest-match - Add buildFilteredFormatList for type-filtered format lists - Add Media Type setting in settings UI (Ask/Video/Audio) - Add --merge-output-format mp4 for video downloads via yt-dlp - Fix formatLabelForDisplay to show Height fallback and Extension - Add cookies volume mount to docker-compose.yml - Document cookies setup and new features in README
This commit is contained in:
@@ -24,6 +24,7 @@ type stepState struct {
|
||||
URL string
|
||||
MediaInfo *domain.MediaInfo
|
||||
Step int
|
||||
SelectedType string // "video", "audio", "ask", or "" when not yet chosen
|
||||
MainFormatID string
|
||||
MainFormat *domain.Format
|
||||
SecondaryID string
|
||||
@@ -158,6 +159,34 @@ func (h *Handler) handleURLInput(ctx context.Context, chatID int64, text string,
|
||||
|
||||
h.sendMediaPreview(ctx, chatID, info)
|
||||
|
||||
prefs, prefErr := h.Repo.GetOrCreatePreferences(userID)
|
||||
if prefErr == nil && prefs.DefaultMediaType != domain.MediaTypeAsk {
|
||||
h.autoSelectAndDownload(ctx, chatID, userID, url, info, prefs)
|
||||
return
|
||||
}
|
||||
|
||||
ss := &stepState{
|
||||
URL: url,
|
||||
MediaInfo: info,
|
||||
Step: 0,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
h.setUserStepState(chatID, ss)
|
||||
|
||||
kb := mediaTypeKeyboard()
|
||||
h.sendWithKB(ctx, chatID, "Select media type:", kb)
|
||||
}
|
||||
|
||||
// autoSelectAndDownload picks the best matching format from preferences and
|
||||
// skips the picker entirely.
|
||||
func (h *Handler) autoSelectAndDownload(ctx context.Context, chatID int64, userID int64, url string, info *domain.MediaInfo, prefs *domain.UserPreferences) {
|
||||
msg, err := h.Bot.SendMessage(ctx, &tgbot.SendMessageParams{ChatID: chatID, Text: "Processing..."})
|
||||
if err != nil {
|
||||
slog.Warn("send processing message failed", "error", err)
|
||||
return
|
||||
}
|
||||
msgID := msg.ID
|
||||
|
||||
ss := &stepState{
|
||||
URL: url,
|
||||
MediaInfo: info,
|
||||
@@ -165,14 +194,74 @@ func (h *Handler) handleURLInput(ctx context.Context, chatID int64, text string,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
ss.FormatIDs, _ = buildFormatList(info.Formats)
|
||||
var mainID, secondaryID string
|
||||
switch prefs.DefaultMediaType {
|
||||
case domain.MediaTypeVideo:
|
||||
mainID, secondaryID = autoSelectVideoFormat(info.Formats, prefs.DefaultVideoFormat)
|
||||
case domain.MediaTypeAudio:
|
||||
mainID = autoSelectAudioFormat(info.Formats, prefs.DefaultAudioFormat)
|
||||
default:
|
||||
mainID = "best"
|
||||
}
|
||||
|
||||
if mainID == "" {
|
||||
mainID = "best"
|
||||
}
|
||||
ss.MainFormatID = mainID
|
||||
|
||||
if mainID == "best" {
|
||||
if info.IsAudioOnlyContent() {
|
||||
ss.MainFormat = &domain.Format{ID: "best", HasAudio: true, IsAudioOnly: true}
|
||||
} else {
|
||||
ss.MainFormat = &domain.Format{ID: "best", HasVideo: true, HasAudio: true}
|
||||
}
|
||||
} else {
|
||||
for i := range info.Formats {
|
||||
f := &info.Formats[i]
|
||||
if f.ID == mainID {
|
||||
ss.MainFormat = f
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if secondaryID != "" {
|
||||
ss.SecondaryID = secondaryID
|
||||
for i := range info.Formats {
|
||||
f := &info.Formats[i]
|
||||
if f.ID == secondaryID {
|
||||
ss.SecondaryFmt = f
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
h.advanceToLanguage(ctx, chatID, msgID, userID, ss)
|
||||
}
|
||||
|
||||
// handleTypeSelection processes the initial type picker (Video/Audio/Ask).
|
||||
func (h *Handler) handleTypeSelection(ctx context.Context, chatID int64, msgID int, userID int64, mediaType string) {
|
||||
ss := h.getUserStepState(chatID)
|
||||
if ss == nil {
|
||||
slog.Warn("nil stepState in handleTypeSelection", "chat_id", chatID, "user_id", userID)
|
||||
return
|
||||
}
|
||||
ss.SelectedType = mediaType
|
||||
|
||||
ids, err := buildFilteredFormatList(ss.MediaInfo.Formats, mediaType)
|
||||
if err != nil {
|
||||
h.editText(ctx, chatID, msgID, "No formats available for this type.", nil)
|
||||
return
|
||||
}
|
||||
ss.FormatIDs = ids
|
||||
h.setUserStepState(chatID, ss)
|
||||
labels := make([]string, len(ss.FormatIDs))
|
||||
for i, id := range ss.FormatIDs {
|
||||
|
||||
labels := make([]string, len(ids))
|
||||
for i, id := range ids {
|
||||
if id == "best" {
|
||||
labels[i] = "Best"
|
||||
} else {
|
||||
f := findFormatByID(info.Formats, id)
|
||||
f := findFormatByID(ss.MediaInfo.Formats, id)
|
||||
if f != nil {
|
||||
labels[i] = formatLabelForDisplay(*f)
|
||||
} else {
|
||||
@@ -180,8 +269,8 @@ func (h *Handler) handleURLInput(ctx context.Context, chatID int64, text string,
|
||||
}
|
||||
}
|
||||
}
|
||||
kb := formatKeyboard("format_", labels, ss.FormatIDs)
|
||||
h.sendWithKB(ctx, chatID, "Select format:", kb)
|
||||
kb := formatKeyboard("format_", labels, ids)
|
||||
h.editText(ctx, chatID, msgID, "Select format:", &kb)
|
||||
}
|
||||
|
||||
// handleFormatSelection processes a format pick from the unified list.
|
||||
@@ -612,13 +701,22 @@ func (h *Handler) handleBackStep(ctx context.Context, chatID int64, msgID int, c
|
||||
return
|
||||
}
|
||||
|
||||
// If on the format list with a selected type, go back to type picker.
|
||||
if ss.Step == 0 && ss.SelectedType != "" {
|
||||
ss.SelectedType = ""
|
||||
h.setUserStepState(chatID, ss)
|
||||
kb := mediaTypeKeyboard()
|
||||
h.editText(ctx, chatID, msgID, "Select media type:", &kb)
|
||||
return
|
||||
}
|
||||
|
||||
ss.Step--
|
||||
switch ss.Step {
|
||||
case -1:
|
||||
h.clearStepState(chatID)
|
||||
h.handleDownloadPrompt(ctx, chatID, msgID, cbID, userID)
|
||||
case 0:
|
||||
ss.FormatIDs, _ = buildFormatList(ss.MediaInfo.Formats)
|
||||
ss.FormatIDs, _ = buildFilteredFormatList(ss.MediaInfo.Formats, "")
|
||||
labels := make([]string, len(ss.FormatIDs))
|
||||
for i, id := range ss.FormatIDs {
|
||||
if id == "best" {
|
||||
@@ -685,6 +783,162 @@ func buildFormatList(formats []domain.Format) ([]string, error) {
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
// buildFilteredFormatList returns format IDs filtered by media type for the type picker.
|
||||
// When mediaType is "video", only formats with video are returned.
|
||||
// When mediaType is "audio", only audio-only formats are returned.
|
||||
// When empty or "ask", all formats are returned (current behavior).
|
||||
func buildFilteredFormatList(formats []domain.Format, mediaType string) ([]string, error) {
|
||||
deduped := dl.DeduplicateResolutions(formats)
|
||||
ids := make([]string, 0, len(deduped)+1)
|
||||
ids = append(ids, "best")
|
||||
|
||||
switch mediaType {
|
||||
case "video":
|
||||
for _, f := range deduped {
|
||||
if f.HasVideo {
|
||||
ids = append(ids, f.ID)
|
||||
}
|
||||
}
|
||||
case "audio":
|
||||
for _, f := range deduped {
|
||||
if f.HasAudio && f.IsAudioOnly {
|
||||
ids = append(ids, f.ID)
|
||||
}
|
||||
}
|
||||
default:
|
||||
for _, f := range deduped {
|
||||
if f.HasVideo {
|
||||
ids = append(ids, f.ID)
|
||||
}
|
||||
}
|
||||
for _, f := range deduped {
|
||||
if !f.HasVideo {
|
||||
ids = append(ids, f.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
// autoSelectVideoFormat finds the closest video format to the preferred resolution.
|
||||
// Returns the main format ID and optionally a secondary audio ID if the best match
|
||||
// is video-only. Prefers formats at or above the target resolution, falling back to
|
||||
// the closest below. Returns ("best", "") if the preference is "best" or no match.
|
||||
func autoSelectVideoFormat(formats []domain.Format, preferredID string) (mainID, secondaryID string) {
|
||||
if preferredID == "" || preferredID == "best" {
|
||||
return "best", ""
|
||||
}
|
||||
|
||||
var targetHeight int
|
||||
if n, err := fmt.Sscanf(preferredID, "%dp", &targetHeight); err != nil || n != 1 {
|
||||
return "best", ""
|
||||
}
|
||||
|
||||
var bestFmt *domain.Format
|
||||
bestDiff := math.MaxInt32
|
||||
|
||||
for i := range formats {
|
||||
f := &formats[i]
|
||||
if !f.HasVideo || f.Height == 0 {
|
||||
continue
|
||||
}
|
||||
diff := f.Height - targetHeight
|
||||
if diff >= 0 && diff < bestDiff {
|
||||
bestDiff = diff
|
||||
bestFmt = f
|
||||
}
|
||||
}
|
||||
|
||||
if bestFmt == nil {
|
||||
bestDiff = math.MaxInt32
|
||||
for i := range formats {
|
||||
f := &formats[i]
|
||||
if !f.HasVideo || f.Height == 0 {
|
||||
continue
|
||||
}
|
||||
diff := targetHeight - f.Height
|
||||
if diff >= 0 && diff < bestDiff {
|
||||
bestDiff = diff
|
||||
bestFmt = f
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if bestFmt == nil {
|
||||
return "best", ""
|
||||
}
|
||||
|
||||
mainID = bestFmt.ID
|
||||
|
||||
if bestFmt.HasVideo && !bestFmt.HasAudio {
|
||||
for i := range formats {
|
||||
f := &formats[i]
|
||||
if f.HasAudio && f.IsAudioOnly {
|
||||
secondaryID = f.ID
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// autoSelectAudioFormat finds the closest audio format to the preferred bitrate.
|
||||
// Returns the format ID, or "best" if the preference is "best" or no match.
|
||||
func autoSelectAudioFormat(formats []domain.Format, preferredID string) string {
|
||||
if preferredID == "" || preferredID == "best" {
|
||||
return "best"
|
||||
}
|
||||
|
||||
var targetBitrate int
|
||||
if n, err := fmt.Sscanf(preferredID, "%dk", &targetBitrate); err != nil || n != 1 {
|
||||
return "best"
|
||||
}
|
||||
|
||||
var bestFmt *domain.Format
|
||||
bestDiff := math.MaxInt32
|
||||
|
||||
for i := range formats {
|
||||
f := &formats[i]
|
||||
if !f.HasAudio || !f.IsAudioOnly {
|
||||
continue
|
||||
}
|
||||
var bitrate int
|
||||
if n, err := fmt.Sscanf(f.Bitrate, "%dk", &bitrate); err != nil || n != 1 {
|
||||
continue
|
||||
}
|
||||
diff := bitrate - targetBitrate
|
||||
if diff >= 0 && diff < bestDiff {
|
||||
bestDiff = diff
|
||||
bestFmt = f
|
||||
}
|
||||
}
|
||||
|
||||
if bestFmt == nil {
|
||||
bestDiff = math.MaxInt32
|
||||
for i := range formats {
|
||||
f := &formats[i]
|
||||
if !f.HasAudio || !f.IsAudioOnly {
|
||||
continue
|
||||
}
|
||||
var bitrate int
|
||||
if n, err := fmt.Sscanf(f.Bitrate, "%dk", &bitrate); err != nil || n != 1 {
|
||||
continue
|
||||
}
|
||||
diff := targetBitrate - bitrate
|
||||
if diff >= 0 && diff < bestDiff {
|
||||
bestDiff = diff
|
||||
bestFmt = f
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if bestFmt == nil {
|
||||
return "best"
|
||||
}
|
||||
return bestFmt.ID
|
||||
}
|
||||
|
||||
// findFormatByID returns the format with the given ID from the list, or nil.
|
||||
func findFormatByID(formats []domain.Format, id string) *domain.Format {
|
||||
for i := range formats {
|
||||
@@ -716,6 +970,13 @@ func formatLabelForDisplay(f domain.Format) string {
|
||||
}
|
||||
return label
|
||||
}
|
||||
if f.Height > 0 {
|
||||
label := fmt.Sprintf("%dp", f.Height)
|
||||
if f.Filesize > 0 {
|
||||
label += fmt.Sprintf(" (%s)", formatBytes(f.Filesize))
|
||||
}
|
||||
return label
|
||||
}
|
||||
if f.Bitrate != "" {
|
||||
label := f.Bitrate
|
||||
if f.Filesize > 0 {
|
||||
@@ -723,7 +984,11 @@ func formatLabelForDisplay(f domain.Format) string {
|
||||
}
|
||||
return label
|
||||
}
|
||||
return f.ID
|
||||
label := f.ID
|
||||
if f.Extension != "" {
|
||||
label += " (" + f.Extension + ")"
|
||||
}
|
||||
return label
|
||||
}
|
||||
|
||||
// sendMediaPreview sends a thumbnail and metadata info before the selection flow.
|
||||
|
||||
Reference in New Issue
Block a user