feat: add thumbnail preview, audio-only detection, /download command, tests, and bugfixes

- Add thumbnail preview with metadata (title, uploader, duration) before download
- Auto-detect audio-only sites (Spotify, SoundCloud, etc.) and skip type selection
- Add /download command for feature parity with UI button
- Fix yt-dlp defer bug: StatusFailed was overwritten to StatusCompleted
- Fix handleURLInput using editText with msgID=0 (now uses sendWithKB)
- Fix filename detection: use job ID prefix for reliable file finding
- Fix double answerCb between handleBackStep and handleDownloadPrompt
- Fix runDownload unused msgID parameter
- Fix context for download goroutine (use context.Background)
- Remove unused startWebhook h parameter
- Remove dead buildToggleKeyboard function
- Add formatBytes, formatDuration, formatLabelForDisplay tests
- Add IsAudioOnlyContent, MediaType.String tests
- Add repo integration tests (users, preferences, quotas, history, delete)
- Add cmd/bot tests (parseAllowedUsers, env helpers)
- Add edge case tests for progress parser
- Add zero/negative safety guard in formatDuration
- Add .gitea, TODO.md, opencode.json to gitignore
This commit is contained in:
2026-06-25 15:07:31 +03:30
parent 05fcdf7458
commit cd27b4d28d
15 changed files with 631 additions and 64 deletions

View File

@@ -45,6 +45,8 @@ type Format struct {
type MediaInfo struct {
URL string
Title string
Uploader string
Thumbnail string
Duration float64
Formats []Format
Languages []string
@@ -54,6 +56,16 @@ type MediaInfo struct {
EstimatedSize int64
}
// IsAudioOnlyContent returns true when no format carries a video stream.
func (m *MediaInfo) IsAudioOnlyContent() bool {
for _, f := range m.Formats {
if f.HasVideo {
return false
}
}
return len(m.Formats) > 0
}
// PlaylistEntry represents a single entry in a playlist.
type PlaylistEntry struct {
ID string

View File

@@ -0,0 +1,90 @@
package domain
import "testing"
func TestMediaTypeString(t *testing.T) {
tests := []struct {
mt MediaType
want string
}{
{MediaTypeAudio, "audio"},
{MediaTypeVideo, "video"},
{MediaTypeVideoAudio, "video_audio"},
{MediaType(0), "unknown"},
{MediaType(99), "unknown"},
}
for _, tt := range tests {
if got := tt.mt.String(); got != tt.want {
t.Errorf("MediaType(%d).String() = %q, want %q", tt.mt, got, tt.want)
}
}
}
func TestIsAudioOnlyContent(t *testing.T) {
tests := []struct {
name string
media *MediaInfo
want bool
}{
{
name: "empty formats returns false",
media: &MediaInfo{Formats: nil},
want: false,
},
{
name: "all audio formats returns true",
media: &MediaInfo{
Formats: []Format{
{ID: "1", HasVideo: false, HasAudio: true, IsAudioOnly: true},
{ID: "2", HasVideo: false, HasAudio: true, IsAudioOnly: true},
},
},
want: true,
},
{
name: "mixed formats returns false",
media: &MediaInfo{
Formats: []Format{
{ID: "1", HasVideo: false, HasAudio: true, IsAudioOnly: true},
{ID: "2", HasVideo: true, HasAudio: true},
},
},
want: false,
},
{
name: "single format with no video returns true",
media: &MediaInfo{
Formats: []Format{
{ID: "1", HasVideo: false, HasAudio: true, IsAudioOnly: true},
},
},
want: true,
},
{
name: "format with both video and audio returns false",
media: &MediaInfo{
Formats: []Format{
{ID: "1", HasVideo: true, HasAudio: true},
},
},
want: false,
},
{
name: "only audio and no-video-no-audio formats returns true",
media: &MediaInfo{
Formats: []Format{
{ID: "1", HasVideo: false, HasAudio: true, IsAudioOnly: true},
{ID: "2", HasVideo: false, HasAudio: false},
},
},
want: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := tt.media.IsAudioOnlyContent(); got != tt.want {
t.Errorf("IsAudioOnlyContent() = %v, want %v", got, tt.want)
}
})
}
}