Skip to content

Commit f5bfaf1

Browse files
committed
feat: Implement batch download range with progress UI and AniSkip sidecar support
1 parent d62431f commit f5bfaf1

4 files changed

Lines changed: 220 additions & 22 deletions

File tree

internal/api/allanime_smart.go

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ func DownloadAllAnimeSmartRange(anime *models.Anime, startEp, endEp int, quality
5959
if stat, err := os.Stat(filePath); err == nil && stat.Size() > 1024 {
6060
util.Infof("Episode %d already exists, skipping", i)
6161
// Even if file exists, (re)write sidecar if needed
62-
_ = writeAniSkipSidecar(filePath, &ep)
62+
_ = WriteAniSkipSidecar(filePath, &ep)
6363
continue
6464
}
6565

@@ -83,7 +83,7 @@ func DownloadAllAnimeSmartRange(anime *models.Anime, startEp, endEp int, quality
8383
}
8484

8585
// Write AniSkip sidecar markers if available
86-
if err := writeAniSkipSidecar(filePath, &ep); err != nil {
86+
if err := WriteAniSkipSidecar(filePath, &ep); err != nil {
8787
util.Debugf("Failed to write AniSkip sidecar for episode %d: %v", i, err)
8888
}
8989

@@ -187,6 +187,13 @@ func writeAniSkipSidecar(videoPath string, ep *models.Episode) error {
187187
return os.WriteFile(sidecar, b, 0644)
188188
}
189189

190+
// WriteAniSkipSidecar is an exported wrapper to write AniSkip sidecar files.
191+
// It delegates to the internal writeAniSkipSidecar and exists to allow other
192+
// packages (e.g., player) to generate the sidecar after downloads.
193+
func WriteAniSkipSidecar(videoPath string, ep *models.Episode) error {
194+
return writeAniSkipSidecar(videoPath, ep)
195+
}
196+
190197
func smartOutputDir(anime *models.Anime) (string, error) {
191198
userHome, _ := os.UserHomeDir()
192199
safeName := sanitizeSmart(anime.Name)

internal/download/workflow.go

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"github.com/alvarorichard/Goanime/internal/api"
88
"github.com/alvarorichard/Goanime/internal/appflow"
99
"github.com/alvarorichard/Goanime/internal/downloader"
10+
"github.com/alvarorichard/Goanime/internal/player"
1011
"github.com/alvarorichard/Goanime/internal/util"
1112
)
1213

@@ -38,7 +39,18 @@ func HandleDownloadRequest(request *util.DownloadRequest) error {
3839

3940
// Exclusive AllAnime Smart Range
4041
if request.AllAnimeSmart && (anime.Source == "AllAnime" || source == "allanime" || source == "AllAnime") {
41-
util.Info("AllAnime Smart Range enabled: mirror priority + AniSkip integration")
42+
util.Info("AllAnime Smart Range enabled: mirror priority + AniSkip integration + progress UI")
43+
// Use player batch downloader with provided range to get consistent progress UI
44+
eps, err := api.GetAnimeEpisodesEnhanced(anime)
45+
if err == nil && len(eps) > 0 {
46+
if err := player.HandleBatchDownloadRange(eps, anime.URL, request.StartEpisode, request.EndEpisode); err == nil {
47+
return nil
48+
}
49+
// Fall through to API-based smart range if UI path fails
50+
util.Infof("Progress UI path failed, falling back to API smart range: %v", err)
51+
} else if err != nil {
52+
util.Infof("Enhanced episodes fetch failed for progress path: %v", err)
53+
}
4254
if err := api.DownloadAllAnimeSmartRange(anime, request.StartEpisode, request.EndEpisode, quality); err != nil {
4355
util.Errorf("AllAnime Smart Range failed: %v", err)
4456
// Fallback to normal enhanced

internal/player/download.go

Lines changed: 180 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -551,20 +551,190 @@ func HandleBatchDownload(episodes []models.Episode, animeURL string) error {
551551
return askAndPlayDownloadedEpisode(episodes, animeURL, startNum, endNum)
552552
}
553553

554+
// HandleBatchDownloadRange performs batch download of episodes using a provided range.
555+
// It mirrors HandleBatchDownload but skips prompting for the range and enables optional
556+
// AniSkip sidecar generation when AllAnime Smart is enabled.
557+
func HandleBatchDownloadRange(episodes []models.Episode, animeURL string, startNum, endNum int) error {
558+
start := time.Now()
559+
if util.IsDebug {
560+
util.Logger.Debug("HandleBatchDownloadRange started", "animeURL", animeURL, "start", startNum, "end", endNum)
561+
}
562+
563+
if startNum < 1 || endNum < startNum {
564+
return fmt.Errorf("invalid episode range: %d-%d", startNum, endNum)
565+
}
566+
567+
var (
568+
m *model
569+
p *tea.Program
570+
totalBytes int64
571+
httpClient = &http.Client{Transport: api.SafeTransport(10 * time.Second)}
572+
episodesToDownload []int
573+
)
574+
575+
// First pass: check which episodes need downloading and calculate total bytes
576+
for episodeNum := startNum; episodeNum <= endNum; episodeNum++ {
577+
episode, found := findEpisode(episodes, episodeNum)
578+
if !found {
579+
util.Logger.Warn("Episode not found", "episode", episodeNum)
580+
continue
581+
}
582+
583+
episodePath, err := createEpisodePath(animeURL, episodeNum)
584+
if err != nil {
585+
util.Logger.Error("Episode path error", "episode", episodeNum, "error", err)
586+
continue
587+
}
588+
if fileExists(episodePath) {
589+
util.Logger.Info("Episode already exists", "episode", episodeNum)
590+
continue
591+
}
592+
593+
episodesToDownload = append(episodesToDownload, episodeNum)
594+
595+
videoURL, err := getBestQualityURL(episode, animeURL)
596+
if err != nil {
597+
util.Logger.Warn("Skipping episode", "episode", episodeNum, "error", err)
598+
continue
599+
}
600+
if sz, err := getContentLength(videoURL, httpClient); err == nil {
601+
totalBytes += sz
602+
}
603+
}
604+
605+
if len(episodesToDownload) == 0 {
606+
return handleExistingEpisodes(episodes, animeURL, startNum, endNum)
607+
}
608+
609+
fmt.Printf("Found %d episode(s) to download...\n", len(episodesToDownload))
610+
611+
if totalBytes > 0 {
612+
m = &model{
613+
progress: progress.New(progress.WithDefaultGradient()),
614+
keys: keyMap{quit: key.NewBinding(key.WithKeys("ctrl+c"), key.WithHelp("ctrl+c", "quit"))},
615+
totalBytes: totalBytes,
616+
}
617+
p = tea.NewProgram(m)
618+
}
619+
620+
downloadErrChan := make(chan error)
621+
go func() {
622+
var wg sync.WaitGroup
623+
sem := make(chan struct{}, 4)
624+
for _, epNum := range episodesToDownload {
625+
sem <- struct{}{}
626+
wg.Add(1)
627+
go func(epNum int) {
628+
defer func() { <-sem; wg.Done() }()
629+
episode, found := findEpisode(episodes, epNum)
630+
if !found {
631+
util.Logger.Warn("Episode not found in batch", "episode", epNum)
632+
return
633+
}
634+
635+
videoURL, err := getBestQualityURL(episode, animeURL)
636+
if err != nil {
637+
util.Logger.Warn("Skipping episode in batch", "episode", epNum, "error", err)
638+
return
639+
}
640+
episodePath, err := createEpisodePath(animeURL, epNum)
641+
if err != nil {
642+
util.Logger.Error("Episode path error", "episode", epNum, "error", err)
643+
return
644+
}
645+
646+
if fileExists(episodePath) {
647+
if p != nil {
648+
p.Send(statusMsg(fmt.Sprintf("Episode %d already exists, skipping...", epNum)))
649+
}
650+
return
651+
}
652+
653+
if p != nil {
654+
p.Send(statusMsg(fmt.Sprintf("Downloading episode %d...", epNum)))
655+
}
656+
657+
var dlErr error
658+
if strings.Contains(videoURL, ".m3u8") || strings.Contains(videoURL, ".mpd") || strings.Contains(videoURL, "repackager.wixmp.com") || strings.Contains(videoURL, "blogger.com") {
659+
dlErr = downloadWithYtDlp(videoURL, episodePath, m)
660+
} else {
661+
dlErr = DownloadVideo(videoURL, episodePath, 4, m)
662+
}
663+
if dlErr != nil {
664+
util.Logger.Error("Failed episode download", "episode", epNum, "error", dlErr)
665+
return
666+
}
667+
668+
// Optional: write AniSkip sidecar when AllAnime Smart is enabled
669+
if util.GlobalDownloadRequest != nil && util.GlobalDownloadRequest.AllAnimeSmart {
670+
// Basic heuristic for AllAnime
671+
if strings.Contains(strings.ToLower(animeURL), "allanime") || (len(animeURL) < 30 && !strings.Contains(animeURL, "http")) {
672+
_ = api.WriteAniSkipSidecar(episodePath, &episode)
673+
}
674+
}
675+
}(epNum)
676+
}
677+
wg.Wait()
678+
679+
if m != nil {
680+
if p != nil {
681+
p.Send(statusMsg("All downloads completed!"))
682+
}
683+
time.Sleep(500 * time.Millisecond)
684+
m.mu.Lock()
685+
m.done = true
686+
m.mu.Unlock()
687+
}
688+
downloadErrChan <- nil
689+
}()
690+
691+
if p != nil {
692+
if _, err := p.Run(); err != nil {
693+
return fmt.Errorf("progress UI error: %w", err)
694+
}
695+
}
696+
if err := <-downloadErrChan; err != nil {
697+
return err
698+
}
699+
fmt.Println("\nAll episodes downloaded successfully!")
700+
if util.IsDebug {
701+
util.Logger.Debug("HandleBatchDownloadRange completed", "animeURL", animeURL, "duration", time.Since(start))
702+
}
703+
return nil
704+
}
705+
554706
// getEpisodeRange asks the user for the episode range for download.
555707
func getEpisodeRange() (startNum, endNum int, err error) {
556-
prompt := promptui.Prompt{Label: "Enter start episode number"}
557-
startStr, err := prompt.Run()
558-
if err != nil {
559-
return 0, 0, err
560-
}
561-
prompt.Label = "Enter end episode number"
562-
endStr, err := prompt.Run()
563-
if err != nil {
708+
var startStr, endStr string
709+
710+
form := huh.NewForm(
711+
huh.NewGroup(
712+
huh.NewInput().
713+
Title("Enter start episode number").
714+
Value(&startStr).
715+
Validate(func(v string) error {
716+
if _, e := strconv.Atoi(strings.TrimSpace(v)); e != nil {
717+
return fmt.Errorf("invalid number")
718+
}
719+
return nil
720+
}),
721+
huh.NewInput().
722+
Title("Enter end episode number").
723+
Value(&endStr).
724+
Validate(func(v string) error {
725+
if _, e := strconv.Atoi(strings.TrimSpace(v)); e != nil {
726+
return fmt.Errorf("invalid number")
727+
}
728+
return nil
729+
}),
730+
),
731+
)
732+
733+
if err := form.Run(); err != nil {
564734
return 0, 0, err
565735
}
566-
startNum, _ = strconv.Atoi(startStr)
567-
endNum, _ = strconv.Atoi(endStr)
736+
startNum, _ = strconv.Atoi(strings.TrimSpace(startStr))
737+
endNum, _ = strconv.Atoi(strings.TrimSpace(endStr))
568738
if startNum > endNum {
569739
return 0, 0, fmt.Errorf("start cannot be greater than end")
570740
}

internal/util/util.go

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import (
88
"strings"
99

1010
"github.com/alvarorichard/Goanime/internal/version"
11-
"github.com/manifoldco/promptui"
11+
"github.com/charmbracelet/huh"
1212
)
1313

1414
var (
@@ -121,17 +121,26 @@ func FlagParser() (string, error) {
121121

122122
// getUserInput prompts the user for input the anime name and returns it
123123
func getUserInput(label string) (string, error) {
124-
prompt := promptui.Prompt{
125-
Label: label,
126-
}
124+
var animeName string
127125

128-
animeName, err := prompt.Run()
129-
if err != nil {
126+
form := huh.NewForm(
127+
huh.NewGroup(
128+
huh.NewInput().
129+
Title(label).
130+
Description("Type the anime title and press Enter").
131+
Value(&animeName).
132+
Validate(func(v string) error {
133+
if len(strings.TrimSpace(v)) < minNameLength {
134+
return fmt.Errorf("name must have at least %d characters", minNameLength)
135+
}
136+
return nil
137+
}),
138+
),
139+
)
140+
141+
if err := form.Run(); err != nil {
130142
return "", err
131143
}
132-
if len(animeName) < minNameLength {
133-
return "", fmt.Errorf("anime name must have at least %d characters, you entered: %v", minNameLength, animeName)
134-
}
135144
return animeName, nil
136145
}
137146

0 commit comments

Comments
 (0)