Refactored core, improved core <-> cli interaction

This commit is contained in:
ChaoticByte 2025-03-09 09:08:07 +01:00
parent 5e6884af66
commit d2b0ca37be
No known key found for this signature in database
6 changed files with 363 additions and 311 deletions

View file

@ -38,11 +38,25 @@ func XtermSetTitle(title string) {
// Commandline // Commandline
type CliOnlyArguments struct { type Arguments struct {
Url string `json:"url"`
FormatName string `json:"format_name"`
OutputFile string `json:"output_file"`
TimestampStart string `json:"timestamp_start"`
TimestampStop string `json:"timestamp_stop"`
Overwrite bool `json:"overwrite"`
ContinueDl bool `json:"continue"`
//
Help bool `json:"-"` Help bool `json:"-"`
ListChapters bool `json:"-"` ListChapters bool `json:"-"`
ListFormats bool `json:"-"` ListFormats bool `json:"-"`
ChapterNum int `json:"chapter_num"` UnparsedChapterNum int `json:"chapter_num"`
// Parsed
Video core.GtvVideo `json:"-"`
StartDuration time.Duration `json:"-"`
StopDuration time.Duration `json:"-"`
ChapterIdx int `json:"-"`
Ratelimit float64 `json:"-"`
} }
func CliShowHelp() { func CliShowHelp() {
@ -71,17 +85,16 @@ lurch-dl --url string The url to the video
Version: ` + core.Version) Version: ` + core.Version)
} }
func CliParseArguments() (core.Arguments, CliOnlyArguments, error) { func CliParseArguments() (Arguments, error) {
var err error var err error
var ratelimitMbs float64 var ratelimitMbs float64
a := core.Arguments{} a := Arguments{}
c := CliOnlyArguments{} flag.BoolVar(&a.Help, "h", false, "")
flag.BoolVar(&c.Help, "h", false, "") flag.BoolVar(&a.Help, "help", false, "")
flag.BoolVar(&c.Help, "help", false, "") flag.BoolVar(&a.ListChapters, "list-chapters", false, "")
flag.BoolVar(&c.ListChapters, "list-chapters", false, "") flag.BoolVar(&a.ListFormats, "list-formats", false, "")
flag.BoolVar(&c.ListFormats, "list-formats", false, "")
flag.StringVar(&a.Url, "url", "", "") flag.StringVar(&a.Url, "url", "", "")
flag.IntVar(&c.ChapterNum, "chapter", 0, "") // 0 -> chapter idx -1 -> complete stream flag.IntVar(&a.UnparsedChapterNum, "chapter", 0, "") // 0 -> chapter idx -1 -> complete stream
flag.StringVar(&a.FormatName, "format", "auto", "") flag.StringVar(&a.FormatName, "format", "auto", "")
flag.StringVar(&a.OutputFile, "output", "", "") flag.StringVar(&a.OutputFile, "output", "", "")
flag.StringVar(&a.TimestampStart, "start", "", "") flag.StringVar(&a.TimestampStart, "start", "", "")
@ -92,17 +105,17 @@ func CliParseArguments() (core.Arguments, CliOnlyArguments, error) {
flag.Parse() flag.Parse()
a.Video, err = core.ParseGtvVideoUrl(a.Url) a.Video, err = core.ParseGtvVideoUrl(a.Url)
if err != nil { if err != nil {
return a, c, err return a, err
} }
if a.Video.Class != "streams" { if a.Video.Class != "streams" {
return a, c, errors.New("video category '" + a.Video.Class + "' not supported") return a, errors.New("video category '" + a.Video.Class + "' not supported")
} }
if a.TimestampStart == "" { if a.TimestampStart == "" {
a.StartDuration = -1 a.StartDuration = -1
} else { } else {
a.StartDuration, err = time.ParseDuration(a.TimestampStart) a.StartDuration, err = time.ParseDuration(a.TimestampStart)
if err != nil { if err != nil {
return a, c, err return a, err
} }
} }
if a.TimestampStop == "" { if a.TimestampStop == "" {
@ -110,15 +123,15 @@ func CliParseArguments() (core.Arguments, CliOnlyArguments, error) {
} else { } else {
a.StopDuration, err = time.ParseDuration(a.TimestampStop) a.StopDuration, err = time.ParseDuration(a.TimestampStop)
if err != nil { if err != nil {
return a, c, err return a, err
} }
} }
a.ChapterIdx = c.ChapterNum - 1 a.ChapterIdx = a.UnparsedChapterNum - 1
a.Ratelimit = ratelimitMbs * 1_000_000.0 // MB/s -> B/s a.Ratelimit = ratelimitMbs * 1_000_000.0 // MB/s -> B/s
if a.Ratelimit <= 0 { if a.Ratelimit <= 0 {
return a, c, errors.New("the value of --max-rate must be greater than 0") return a, errors.New("the value of --max-rate must be greater than 0")
} }
return a, c, err return a, err
} }
// Main // Main
@ -128,8 +141,8 @@ func CliRun() int {
defer fmt.Print("\n") defer fmt.Print("\n")
// cli arguments & help text // cli arguments & help text
flag.Usage = CliShowHelp flag.Usage = CliShowHelp
args, cliArgs, err := CliParseArguments() args, err := CliParseArguments()
if cliArgs.Help { if args.Help {
CliShowHelp() CliShowHelp()
return 0 return 0
} else if args.Url == "" || err != nil { } else if args.Url == "" || err != nil {
@ -141,11 +154,13 @@ func CliRun() int {
} }
// detect terminal features // detect terminal features
XtermDetectFeatures() XtermDetectFeatures()
//
api := core.GtvApi{};
// Get video metadata // Get video metadata
if CliXtermTitle { if CliXtermTitle {
XtermSetTitle("lurch-dl - Fetching video metadata ...") XtermSetTitle("lurch-dl - Fetching video metadata ...")
} }
streamEp, err := core.GetStreamEpisode(args.Video.Id) streamEp, err := api.GetStreamEpisode(args.Video.Id)
if err != nil { if err != nil {
cli.ErrorMessage(err) cli.ErrorMessage(err)
return 1 return 1
@ -155,17 +170,17 @@ func CliRun() int {
// Check and list chapters/formats and exit // Check and list chapters/formats and exit
if args.ChapterIdx >= 0 { if args.ChapterIdx >= 0 {
if args.ChapterIdx >= len(streamEp.Chapters) { if args.ChapterIdx >= len(streamEp.Chapters) {
cli.ErrorMessage(&core.ChapterNotFoundError{ChapterNum: cliArgs.ChapterNum}) cli.ErrorMessage(&core.ChapterNotFoundError{ChapterNum: args.UnparsedChapterNum})
CliAvailableChapters(streamEp.Chapters) CliAvailableChapters(streamEp.Chapters)
return 1 return 1
} }
} }
if cliArgs.ListChapters || cliArgs.ListFormats { if args.ListChapters || args.ListFormats {
if cliArgs.ListChapters { if args.ListChapters {
fmt.Print("\n") fmt.Print("\n")
CliAvailableChapters(streamEp.Chapters) CliAvailableChapters(streamEp.Chapters)
} }
if cliArgs.ListFormats { if args.ListFormats {
fmt.Print("\n") fmt.Print("\n")
CliAvailableFormats(streamEp.Formats) CliAvailableFormats(streamEp.Formats)
} }
@ -178,8 +193,11 @@ func CliRun() int {
return 1 return 1
} }
cli.InfoMessage(fmt.Sprintf("Format: %v", format.Name)) cli.InfoMessage(fmt.Sprintf("Format: %v", format.Name))
if args.ChapterIdx >= 0 { // chapter
cli.InfoMessage(fmt.Sprintf("Chapter: %v. %v", cliArgs.ChapterNum, streamEp.Chapters[args.ChapterIdx].Title)) targetChapter := core.Chapter{Index: -1} // set Index to -1 for noop
if len(streamEp.Chapters) > 0 && args.ChapterIdx >= 0 {
targetChapter = streamEp.Chapters[args.ChapterIdx]
cli.InfoMessage(fmt.Sprintf("Chapter: %v. %v", args.UnparsedChapterNum, targetChapter.Title))
} }
// We already set the output file correctly so we can output it // We already set the output file correctly so we can output it
if args.OutputFile == "" { if args.OutputFile == "" {
@ -188,12 +206,33 @@ func CliRun() int {
// Start Download // Start Download
cli.InfoMessage(fmt.Sprintf("Output: %v", args.OutputFile)) cli.InfoMessage(fmt.Sprintf("Output: %v", args.OutputFile))
fmt.Print("\n") fmt.Print("\n")
if err = streamEp.Download(args, &cli, make(chan os.Signal, 1)); err != nil { successful := false
cli.ErrorMessage(err) for p := range api.DownloadEpisode(
return 1 streamEp,
targetChapter,
args.FormatName,
args.OutputFile,
args.Overwrite,
args.ContinueDl,
args.StartDuration,
args.StopDuration,
args.Ratelimit,
make(chan os.Signal, 1),
) {
if p.Error != nil {
cli.ErrorMessage(p.Error)
return 1
}
if p.Success { successful = true }
if p.Aborted { cli.Aborted() } else {
cli.DownloadProgress(p.Progress, p.Rate, p.Delaying, p.Waiting, p.Retries, p.Title)
}
} }
fmt.Print("\n") fmt.Print("\n")
return 0 if !successful {
cli.ErrorMessage(errors.New("download failed"))
return 1
} else { return 0 }
} }
func CliAvailableChapters(chapters []core.Chapter) { func CliAvailableChapters(chapters []core.Chapter) {

View file

@ -1,19 +0,0 @@
package core
import "time"
type Arguments struct {
Url string `json:"url"`
FormatName string `json:"format_name"`
OutputFile string `json:"output_file"`
TimestampStart string `json:"timestamp_start"`
TimestampStop string `json:"timestamp_stop"`
Overwrite bool `json:"overwrite"`
ContinueDl bool `json:"continue"`
// Parsed
Video GtvVideo `json:"-"`
StartDuration time.Duration `json:"-"`
StopDuration time.Duration `json:"-"`
ChapterIdx int `json:"-"`
Ratelimit float64 `json:"-"`
}

View file

@ -3,9 +3,25 @@
package core package core
import ( import (
"encoding/json"
"errors"
"fmt"
"io"
"iter"
"net/http" "net/http"
"os"
"os/signal"
"sort"
"strconv"
"strings"
"time"
) )
const MaxRetries = 5
// The following two values are used to simulate buffering
const RatelimitDelay = 2.0 // in Seconds; How long to delay the next chunk download.
const RatelimitDelayAfter = 5.0 // in Seconds; Delay the next chunk download after this duration.
const ApiBaseurlStreamEpisodeInfo = "https://api.gronkh.tv/v1/video/info?episode=%s" const ApiBaseurlStreamEpisodeInfo = "https://api.gronkh.tv/v1/video/info?episode=%s"
const ApiBaseurlStreamEpisodePlInfo = "https://api.gronkh.tv/v1/video/playlist?episode=%s" const ApiBaseurlStreamEpisodePlInfo = "https://api.gronkh.tv/v1/video/playlist?episode=%s"
@ -31,3 +47,215 @@ var ApiHeadersMetaAdditional = http.Header{
var ApiHeadersVideoAdditional = http.Header{ var ApiHeadersVideoAdditional = http.Header{
"Accept": {"*/*"}, "Accept": {"*/*"},
} }
type GtvApi struct{}
func (api *GtvApi) GetStreamEpisode(episode string) (StreamEpisode, error) {
ep := StreamEpisode{}
ep.Episode = episode
info_data, err := httpGet(
fmt.Sprintf(ApiBaseurlStreamEpisodeInfo, episode),
[]http.Header{ApiHeadersBase, ApiHeadersMetaAdditional},
time.Second*10,
)
if err != nil {
return ep, err
}
// Title
json.Unmarshal(info_data, &ep)
ep.Title = strings.ToValidUTF8(ep.Title, "")
// Sort Chapters, correct offset and set index
sort.Slice(ep.Chapters, func(i int, j int) bool {
return ep.Chapters[i].Offset < ep.Chapters[j].Offset
})
for i := range ep.Chapters {
ep.Chapters[i].Offset = ep.Chapters[i].Offset * time.Second
ep.Chapters[i].Index = i
}
// Formats
playlist_url_data, err := httpGet(
fmt.Sprintf(ApiBaseurlStreamEpisodePlInfo, episode),
[]http.Header{ApiHeadersBase, ApiHeadersMetaAdditional},
time.Second*10,
)
if err != nil {
return ep, err
}
json.Unmarshal(playlist_url_data, &ep)
playlist_data, err := httpGet(
ep.PlaylistUrl,
[]http.Header{ApiHeadersBase, ApiHeadersMetaAdditional},
time.Second*10,
)
ep.Formats = parseAvailFormatsFromM3u8(string(playlist_data))
return ep, err
}
func (api *GtvApi) GetStreamChunkList(video VideoFormat) (ChunkList, error) {
baseUrl := video.Url[:strings.LastIndex(video.Url, "/")]
data, err := httpGet(video.Url, []http.Header{ApiHeadersBase, ApiHeadersMetaAdditional}, time.Second*10)
if err != nil {
return ChunkList{}, err
}
chunklist, err := parseChunkListFromM3u8(string(data), baseUrl)
return chunklist, err
}
func (api *GtvApi) DownloadEpisode(
ep StreamEpisode,
chapter Chapter,
formatName string,
outputFile string,
overwrite bool,
continueDl bool,
startDuration time.Duration,
stopDuration time.Duration,
ratelimit float64,
interruptChan chan os.Signal,
) iter.Seq[DownloadProgress] {
return func (yield func(DownloadProgress) bool) {
// Set automatic values
if outputFile == "" {
outputFile = ep.GetProposedFilename(chapter.Index)
}
if chapter.Index >= 0 {
if startDuration < 0 {
startDuration = time.Duration(ep.Chapters[chapter.Index].Offset)
}
if stopDuration < 0 && chapter.Index+1 < len(ep.Chapters) {
// next chapter is stop
stopDuration = time.Duration(ep.Chapters[chapter.Index+1].Offset)
}
}
//
var err error
var nextChunk int = 0
var videoFile *os.File
var infoFile *os.File
var infoFilename string
if !overwrite && !continueDl {
if _, err := os.Stat(outputFile); err == nil {
yield(DownloadProgress{Error: &FileExistsError{Filename: outputFile}})
return
}
}
videoFile, err = os.OpenFile(outputFile, os.O_RDWR|os.O_CREATE, 0660)
if err != nil {
yield(DownloadProgress{Error: err})
return
}
defer videoFile.Close()
if overwrite {
videoFile.Truncate(0)
}
// always seek to the end
videoFile.Seek(0, io.SeekEnd)
// info file
infoFilename = outputFile + ".dl-info"
if continueDl {
infoFileData, err := os.ReadFile(infoFilename)
if err != nil {
yield(DownloadProgress{Error: errors.New("could not access download info file, can't continue download")})
return
}
i, err := strconv.ParseInt(string(infoFileData), 10, 32)
nextChunk = int(i)
if err != nil {
yield(DownloadProgress{Error: err})
return
}
}
infoFile, err = os.OpenFile(infoFilename, os.O_RDWR|os.O_CREATE, 0660)
if err != nil {
yield(DownloadProgress{Error: err})
return
}
infoFile.Truncate(0)
infoFile.Seek(0, io.SeekStart)
_, err = infoFile.Write([]byte(strconv.Itoa(nextChunk)))
if err != nil {
yield(DownloadProgress{Error: err})
return
}
// download
format, _ := ep.GetFormatByName(formatName) // we don't have to check the error, as it was already checked by CliRun()
chunklist, err := api.GetStreamChunkList(format)
chunklist = chunklist.Cut(startDuration, stopDuration)
if err != nil {
yield(DownloadProgress{Error: err})
return
}
var bufferDt float64
var progress float32
var actualRate float64
keyboardInterrupt := false
signal.Notify(interruptChan, os.Interrupt)
go func() {
// Handle Interrupts
<-interruptChan
keyboardInterrupt = true
yield(DownloadProgress{Progress: progress, Rate: actualRate, Retries: 0, Title: ep.Title})
}()
for i, chunk := range chunklist.Chunks {
if i < nextChunk {
continue
}
var time1 int64
var data []byte
retries := 0
for {
if keyboardInterrupt {
break
}
time1 = time.Now().UnixNano()
if !yield(DownloadProgress{Progress: progress, Rate: actualRate, Delaying: false, Waiting: true, Retries: retries, Title: ep.Title}) { return }
data, err = httpGet(chunklist.BaseUrl+"/"+chunk, []http.Header{ApiHeadersBase, ApiHeadersVideoAdditional}, time.Second*5)
if err != nil {
if retries == MaxRetries {
yield(DownloadProgress{Error: err})
return
}
retries++
continue
}
break
}
if keyboardInterrupt {
break
}
var dtDownload float64 = float64(time.Now().UnixNano()-time1) / 1000000000.0
rate := float64(len(data)) / dtDownload
actualRate = rate - max(rate-ratelimit, 0)
progress = float32(i+1) / float32(len(chunklist.Chunks))
delayNow := bufferDt > RatelimitDelayAfter
if !yield(DownloadProgress{Progress: progress, Rate: actualRate, Delaying: delayNow, Waiting: false, Retries: retries, Title: ep.Title}) { return }
if delayNow {
bufferDt = 0
// this simulates that the buffering is finished and the player is playing
time.Sleep(time.Duration(RatelimitDelay * float64(time.Second)))
} else if rate > ratelimit {
// slow down, we are too fast.
deferTime := (rate - ratelimit) / ratelimit * dtDownload
time.Sleep(time.Duration(deferTime * float64(time.Second)))
}
videoFile.Write(data)
nextChunk++
infoFile.Truncate(0)
infoFile.Seek(0, io.SeekStart)
infoFile.Write([]byte(strconv.Itoa(nextChunk)))
var dtIteration float64 = float64(time.Now().UnixNano()-time1) / 1000000000.0
if !delayNow {
bufferDt += dtIteration
}
}
infoFile.Close()
if !keyboardInterrupt {
err := os.Remove(infoFilename)
if err != nil {
yield(DownloadProgress{Progress: progress, Rate: actualRate, Error: err})
return
}
}
yield(DownloadProgress{Progress: progress, Rate: actualRate, Success: true})
}
}

View file

@ -4,16 +4,15 @@ package core
import ( import (
"errors" "errors"
"fmt"
"regexp" "regexp"
"time" "time"
) )
// The following two values are used to simulate buffering
const RatelimitDelay = 2.0 // in Seconds; How long to delay the next chunk download.
const RatelimitDelayAfter = 5.0 // in Seconds; Delay the next chunk download after this duration.
var videoUrlRegex = regexp.MustCompile(`gronkh\.tv\/([a-z]+)\/([0-9]+)`) var videoUrlRegex = regexp.MustCompile(`gronkh\.tv\/([a-z]+)\/([0-9]+)`)
//
type GtvVideo struct { type GtvVideo struct {
Class string `json:"class"` Class string `json:"class"`
Id string `json:"id"` Id string `json:"id"`
@ -22,7 +21,7 @@ type GtvVideo struct {
func ParseGtvVideoUrl(url string) (GtvVideo, error) { func ParseGtvVideoUrl(url string) (GtvVideo, error) {
video := GtvVideo{} video := GtvVideo{}
match := videoUrlRegex.FindStringSubmatch(url) match := videoUrlRegex.FindStringSubmatch(url)
if match == nil || len(match) < 2 { if len(match) < 2 {
return video, errors.New("Could not parse URL " + url) return video, errors.New("Could not parse URL " + url)
} }
video.Class = match[1] video.Class = match[1]
@ -30,11 +29,15 @@ func ParseGtvVideoUrl(url string) (GtvVideo, error) {
return video, nil return video, nil
} }
//
type VideoFormat struct { type VideoFormat struct {
Name string `json:"format"` Name string `json:"format"`
Url string `json:"url"` Url string `json:"url"`
} }
//
type ChunkList struct { type ChunkList struct {
BaseUrl string BaseUrl string
Chunks []string Chunks []string
@ -59,3 +62,51 @@ func (cl *ChunkList) Cut(from time.Duration, to time.Duration) ChunkList {
ChunkDuration: cl.ChunkDuration, ChunkDuration: cl.ChunkDuration,
} }
} }
//
type Chapter struct {
Index int `json:"index"`
Title string `json:"title"`
Offset time.Duration `json:"offset"`
}
//
type StreamEpisode struct {
Episode string `json:"episode"`
Formats []VideoFormat `json:"formats"`
Title string `json:"title"`
// ProposedFilename string `json:"proposed_filename"`
PlaylistUrl string `json:"playlist_url"`
Chapters []Chapter `json:"chapters"`
}
func (ep *StreamEpisode) GetFormatByName(formatName string) (VideoFormat, error) {
var idx int
var err error = nil
if formatName == "auto" {
// at the moment, the best format is always the first -> 0
return ep.Formats[idx], nil
} else {
formatFound := false
for i, f := range ep.Formats {
if f.Name == formatName {
idx = i
formatFound = true
}
}
if !formatFound {
err = &FormatNotFoundError{FormatName: formatName}
}
return ep.Formats[idx], err
}
}
func (ep *StreamEpisode) GetProposedFilename(chapterIdx int) string {
if chapterIdx >= 0 && chapterIdx < len(ep.Chapters) {
return fmt.Sprintf("GTV%04s - %v. %s.ts", ep.Episode, chapterIdx+1, sanitizeUnicodeFilename(ep.Chapters[chapterIdx].Title))
} else {
return sanitizeUnicodeFilename(ep.Title) + ".ts"
}
}

View file

@ -1,252 +0,0 @@
// Copyright (c) 2025, Julian Müller (ChaoticByte)
package core
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"os/signal"
"sort"
"strconv"
"strings"
"time"
)
const MaxRetries = 5
type Chapter struct {
Index int `json:"index"`
Title string `json:"title"`
Offset time.Duration `json:"offset"`
}
type StreamEpisode struct {
Episode string `json:"episode"`
Formats []VideoFormat `json:"formats"`
Title string `json:"title"`
// ProposedFilename string `json:"proposed_filename"`
PlaylistUrl string `json:"playlist_url"`
Chapters []Chapter `json:"chapters"`
}
func (ep *StreamEpisode) GetFormatByName(formatName string) (VideoFormat, error) {
var idx int
var err error = nil
if formatName == "auto" {
// at the moment, the best format is always the first -> 0
return ep.Formats[idx], nil
} else {
formatFound := false
for i, f := range ep.Formats {
if f.Name == formatName {
idx = i
formatFound = true
}
}
if !formatFound {
err = &FormatNotFoundError{FormatName: formatName}
}
return ep.Formats[idx], err
}
}
func (ep *StreamEpisode) GetProposedFilename(chapterIdx int) string {
if chapterIdx >= 0 && chapterIdx < len(ep.Chapters) {
return fmt.Sprintf("GTV%04s - %v. %s.ts", ep.Episode, chapterIdx+1, sanitizeUnicodeFilename(ep.Chapters[chapterIdx].Title))
} else {
return sanitizeUnicodeFilename(ep.Title) + ".ts"
}
}
func (ep *StreamEpisode) Download(args Arguments, ui UserInterface, interruptChan chan os.Signal) error {
// Set automatic values
if args.OutputFile == "" {
args.OutputFile = ep.GetProposedFilename(args.ChapterIdx)
}
if args.ChapterIdx >= 0 {
if args.StartDuration < 0 {
args.StartDuration = time.Duration(ep.Chapters[args.ChapterIdx].Offset)
}
if args.StopDuration < 0 && args.ChapterIdx+1 < len(ep.Chapters) {
// next chapter is stop
args.StopDuration = time.Duration(ep.Chapters[args.ChapterIdx+1].Offset)
}
}
//
var err error
var nextChunk int = 0
var videoFile *os.File
var infoFile *os.File
var infoFilename string
if !args.Overwrite && !args.ContinueDl {
if _, err := os.Stat(args.OutputFile); err == nil {
return &FileExistsError{Filename: args.OutputFile}
}
}
videoFile, err = os.OpenFile(args.OutputFile, os.O_RDWR|os.O_CREATE, 0660)
if err != nil {
return err
}
defer videoFile.Close()
if args.Overwrite {
videoFile.Truncate(0)
}
// always seek to the end
videoFile.Seek(0, io.SeekEnd)
// info file
infoFilename = args.OutputFile + ".dl-info"
if args.ContinueDl {
infoFileData, err := os.ReadFile(infoFilename)
if err != nil {
return errors.New("could not access download info file, can't continue download")
}
i, err := strconv.ParseInt(string(infoFileData), 10, 32)
nextChunk = int(i)
if err != nil {
return err
}
}
infoFile, err = os.OpenFile(infoFilename, os.O_RDWR|os.O_CREATE, 0660)
if err != nil {
return err
}
infoFile.Truncate(0)
infoFile.Seek(0, io.SeekStart)
_, err = infoFile.Write([]byte(strconv.Itoa(nextChunk)))
if err != nil {
return err
}
// download
format, _ := ep.GetFormatByName(args.FormatName) // we don't have to check the error, as it was already checked by CliRun()
chunklist, err := GetStreamChunkList(format)
chunklist = chunklist.Cut(args.StartDuration, args.StopDuration)
if err != nil {
return err
}
var bufferDt float64
var progress float32
var actualRate float64
keyboardInterrupt := false
signal.Notify(interruptChan, os.Interrupt)
go func() {
// Handle Interrupts
<-interruptChan
keyboardInterrupt = true
ui.DownloadProgress(progress, actualRate, false, false, 0, ep.Title)
ui.Aborted()
}()
for i, chunk := range chunklist.Chunks {
if i < nextChunk {
continue
}
var time1 int64
var data []byte
retries := 0
for {
if keyboardInterrupt {
break
}
time1 = time.Now().UnixNano()
ui.DownloadProgress(progress, actualRate, false, true, retries, ep.Title)
data, err = httpGet(chunklist.BaseUrl+"/"+chunk, []http.Header{ApiHeadersBase, ApiHeadersVideoAdditional}, time.Second*5)
if err != nil {
if retries == MaxRetries {
return err
}
retries++
continue
}
break
}
if keyboardInterrupt {
break
}
var dtDownload float64 = float64(time.Now().UnixNano()-time1) / 1000000000.0
rate := float64(len(data)) / dtDownload
actualRate = rate - max(rate-args.Ratelimit, 0)
progress = float32(i+1) / float32(len(chunklist.Chunks))
delayNow := bufferDt > RatelimitDelayAfter
ui.DownloadProgress(progress, actualRate, delayNow, false, retries, ep.Title)
if delayNow {
bufferDt = 0
// this simulates that the buffering is finished and the player is playing
time.Sleep(time.Duration(RatelimitDelay * float64(time.Second)))
} else if rate > args.Ratelimit {
// slow down, we are too fast.
deferTime := (rate - args.Ratelimit) / args.Ratelimit * dtDownload
time.Sleep(time.Duration(deferTime * float64(time.Second)))
}
videoFile.Write(data)
nextChunk++
infoFile.Truncate(0)
infoFile.Seek(0, io.SeekStart)
infoFile.Write([]byte(strconv.Itoa(nextChunk)))
var dtIteration float64 = float64(time.Now().UnixNano()-time1) / 1000000000.0
if !delayNow {
bufferDt += dtIteration
}
}
infoFile.Close()
if !keyboardInterrupt {
err := os.Remove(infoFilename)
if err != nil {
return err
}
}
return nil
}
func GetStreamEpisode(episode string) (StreamEpisode, error) {
ep := StreamEpisode{}
ep.Episode = episode
info_data, err := httpGet(
fmt.Sprintf(ApiBaseurlStreamEpisodeInfo, episode),
[]http.Header{ApiHeadersBase, ApiHeadersMetaAdditional},
time.Second*10,
)
if err != nil {
return ep, err
}
// Title
json.Unmarshal(info_data, &ep)
ep.Title = strings.ToValidUTF8(ep.Title, "")
// Sort Chapters, correct offset and set index
sort.Slice(ep.Chapters, func(i int, j int) bool {
return ep.Chapters[i].Offset < ep.Chapters[j].Offset
})
for i := range ep.Chapters {
ep.Chapters[i].Offset = ep.Chapters[i].Offset * time.Second
ep.Chapters[i].Index = i
}
// Formats
playlist_url_data, err := httpGet(
fmt.Sprintf(ApiBaseurlStreamEpisodePlInfo, episode),
[]http.Header{ApiHeadersBase, ApiHeadersMetaAdditional},
time.Second*10,
)
if err != nil {
return ep, err
}
json.Unmarshal(playlist_url_data, &ep)
playlist_data, err := httpGet(
ep.PlaylistUrl,
[]http.Header{ApiHeadersBase, ApiHeadersMetaAdditional},
time.Second*10,
)
ep.Formats = parseAvailFormatsFromM3u8(string(playlist_data))
return ep, err
}
func GetStreamChunkList(video VideoFormat) (ChunkList, error) {
baseUrl := video.Url[:strings.LastIndex(video.Url, "/")]
data, err := httpGet(video.Url, []http.Header{ApiHeadersBase, ApiHeadersMetaAdditional}, time.Second*10)
if err != nil {
return ChunkList{}, err
}
chunklist, err := parseChunkListFromM3u8(string(data), baseUrl)
return chunklist, err
}

View file

@ -1,8 +1,13 @@
package core package core
type UserInterface interface { type DownloadProgress struct {
DownloadProgress(progress float32, rate float64, delaying bool, waiting bool, retries int, title string) Aborted bool
InfoMessage(msg string) Error error
ErrorMessage(err error) Success bool
Aborted() Delaying bool
Progress float32
Rate float64
Retries int
Title string
Waiting bool
} }