feat: add auto-select from preferences and type picker
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:
2026-06-28 11:28:53 +03:30
parent 6bbc87a7c8
commit b11b5649a5
12 changed files with 578 additions and 18 deletions

View File

@@ -169,6 +169,209 @@ func TestFormatDuration(t *testing.T) {
}
}
func TestBuildFilteredFormatList(t *testing.T) {
formats := []domain.Format{
{ID: "1", HasVideo: true, HasAudio: false, Resolution: "1920x1080"},
{ID: "2", HasVideo: false, HasAudio: true, IsAudioOnly: true, Bitrate: "128k"},
{ID: "3", HasVideo: true, HasAudio: false, Resolution: "1280x720"},
{ID: "4", HasVideo: true, HasAudio: true, Resolution: "640x480"},
{ID: "5", HasVideo: false, HasAudio: true, IsAudioOnly: true, Bitrate: "320k"},
}
t.Run("ask type returns all formats", func(t *testing.T) {
ids, err := buildFilteredFormatList(formats, "ask")
if err != nil {
t.Fatalf("buildFilteredFormatList(ask) error: %v", err)
}
if ids[0] != "best" {
t.Errorf("first ID = %q, want best", ids[0])
}
// Should include all non-best IDs
expectedIDs := map[string]bool{"1": true, "2": true, "3": true, "4": true, "5": true}
for _, id := range ids[1:] {
if !expectedIDs[id] {
t.Errorf("unexpected ID %q in ask list", id)
}
delete(expectedIDs, id)
}
if len(expectedIDs) > 0 {
t.Errorf("missing IDs in ask list: %v", expectedIDs)
}
})
t.Run("video type only returns video formats", func(t *testing.T) {
ids, err := buildFilteredFormatList(formats, "video")
if err != nil {
t.Fatalf("buildFilteredFormatList(video) error: %v", err)
}
if ids[0] != "best" {
t.Errorf("first ID = %q, want best", ids[0])
}
for _, id := range ids[1:] {
if id == "2" || id == "5" {
t.Errorf("video list contains audio-only ID %q", id)
}
}
})
t.Run("audio type only returns audio-only formats", func(t *testing.T) {
ids, err := buildFilteredFormatList(formats, "audio")
if err != nil {
t.Fatalf("buildFilteredFormatList(audio) error: %v", err)
}
if ids[0] != "best" {
t.Errorf("first ID = %q, want best", ids[0])
}
for _, id := range ids[1:] {
if id == "1" || id == "3" || id == "4" {
t.Errorf("audio list contains video ID %q", id)
}
}
})
t.Run("default (empty) returns all formats", func(t *testing.T) {
ids, err := buildFilteredFormatList(formats, "")
if err != nil {
t.Fatalf("buildFilteredFormatList('') error: %v", err)
}
if len(ids) != 6 { // best + 5 formats
t.Errorf("got %d IDs, want 6", len(ids))
}
})
}
func TestAutoSelectVideoFormat(t *testing.T) {
formats := []domain.Format{
{ID: "137", HasVideo: true, HasAudio: false, Height: 1080, Width: 1920, Resolution: "1920x1080"},
{ID: "136", HasVideo: true, HasAudio: false, Height: 720, Width: 1280, Resolution: "1280x720"},
{ID: "135", HasVideo: true, HasAudio: false, Height: 480, Width: 854, Resolution: "854x480"},
{ID: "247", HasVideo: true, HasAudio: true, Height: 1080, Width: 1920, Resolution: "1920x1080"},
{ID: "140", HasVideo: false, HasAudio: true, IsAudioOnly: true, Bitrate: "128k"},
}
t.Run("best returns best, no secondary", func(t *testing.T) {
mainID, secondaryID := autoSelectVideoFormat(formats, "best")
if mainID != "best" {
t.Errorf("mainID = %q, want best", mainID)
}
if secondaryID != "" {
t.Errorf("secondaryID = %q, want empty", secondaryID)
}
})
t.Run("empty returns best", func(t *testing.T) {
mainID, _ := autoSelectVideoFormat(formats, "")
if mainID != "best" {
t.Errorf("mainID = %q, want best", mainID)
}
})
t.Run("exact match 1080p", func(t *testing.T) {
mainID, _ := autoSelectVideoFormat(formats, "1080p")
if mainID != "137" && mainID != "247" {
t.Errorf("mainID = %q, want 137 or 247", mainID)
}
})
t.Run("prefer above 360p", func(t *testing.T) {
mainID, _ := autoSelectVideoFormat(formats, "360p")
// 480p (135) is the closest above 360p; below is none
if mainID != "135" {
t.Errorf("mainID = %q, want 135 (480p is closest above 360p)", mainID)
}
})
t.Run("exact match 720p", func(t *testing.T) {
mainID, _ := autoSelectVideoFormat(formats, "720p")
// 720p (136) is an exact match
if mainID != "136" {
t.Errorf("mainID = %q, want 136 (exact match for 720p)", mainID)
}
})
t.Run("above 1080p takes 1080p as closest below", func(t *testing.T) {
mainID, _ := autoSelectVideoFormat(formats, "2160p")
// No 4K format, should fall back to 1080p (closest below)
if mainID != "137" && mainID != "247" {
t.Errorf("mainID = %q, want 137 or 247 (1080p is closest below 2160p)", mainID)
}
})
t.Run("video-only picks best audio secondary", func(t *testing.T) {
formats := []domain.Format{
{ID: "137", HasVideo: true, HasAudio: false, Height: 1080},
{ID: "140", HasVideo: false, HasAudio: true, IsAudioOnly: true, Bitrate: "128k"},
}
_, secondaryID := autoSelectVideoFormat(formats, "1080p")
if secondaryID != "140" {
t.Errorf("secondaryID = %q, want 140", secondaryID)
}
})
t.Run("no match returns best", func(t *testing.T) {
formats := []domain.Format{
{ID: "140", HasVideo: false, HasAudio: true, IsAudioOnly: true},
}
mainID, _ := autoSelectVideoFormat(formats, "1080p")
if mainID != "best" {
t.Errorf("mainID = %q, want best", mainID)
}
})
}
func TestAutoSelectAudioFormat(t *testing.T) {
formats := []domain.Format{
{ID: "140", HasVideo: false, HasAudio: true, IsAudioOnly: true, Bitrate: "128k"},
{ID: "256", HasVideo: false, HasAudio: true, IsAudioOnly: true, Bitrate: "256k"},
{ID: "320", HasVideo: false, HasAudio: true, IsAudioOnly: true, Bitrate: "320k"},
}
t.Run("best returns best", func(t *testing.T) {
id := autoSelectAudioFormat(formats, "best")
if id != "best" {
t.Errorf("got %q, want best", id)
}
})
t.Run("empty returns best", func(t *testing.T) {
id := autoSelectAudioFormat(formats, "")
if id != "best" {
t.Errorf("got %q, want best", id)
}
})
t.Run("exact match 256k", func(t *testing.T) {
id := autoSelectAudioFormat(formats, "256k")
if id != "256" {
t.Errorf("got %q, want 256", id)
}
})
t.Run("prefer above 192k", func(t *testing.T) {
id := autoSelectAudioFormat(formats, "192k")
if id != "256" {
t.Errorf("got %q, want 256 (closest above 192k)", id)
}
})
t.Run("above highest takes highest below", func(t *testing.T) {
id := autoSelectAudioFormat(formats, "384k")
if id != "320" {
t.Errorf("got %q, want 320 (closest below 384k)", id)
}
})
t.Run("no match returns best", func(t *testing.T) {
formats := []domain.Format{
{ID: "1", HasVideo: true, HasAudio: true},
}
id := autoSelectAudioFormat(formats, "128k")
if id != "best" {
t.Errorf("got %q, want best", id)
}
})
}
func TestFormatLabelForDisplay(t *testing.T) {
tests := []struct {
f domain.Format