From d2b0ca37be9c0f1b7c2e93388c57598820516a69 Mon Sep 17 00:00:00 2001 From: ChaoticByte Date: Sun, 9 Mar 2025 09:08:07 +0100 Subject: [PATCH 01/12] Refactored core, improved core <-> cli interaction --- cli/cli.go | 99 ++++++++++++------ core/args.go | 19 ---- core/gtv_api.go | 228 ++++++++++++++++++++++++++++++++++++++++ core/gtv_common.go | 61 ++++++++++- core/gtv_stream.go | 252 --------------------------------------------- core/interface.go | 15 ++- 6 files changed, 363 insertions(+), 311 deletions(-) delete mode 100644 core/args.go delete mode 100644 core/gtv_stream.go diff --git a/cli/cli.go b/cli/cli.go index 4670642..5b72940 100644 --- a/cli/cli.go +++ b/cli/cli.go @@ -38,11 +38,25 @@ func XtermSetTitle(title string) { // 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:"-"` ListChapters 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() { @@ -71,17 +85,16 @@ lurch-dl --url string The url to the video Version: ` + core.Version) } -func CliParseArguments() (core.Arguments, CliOnlyArguments, error) { +func CliParseArguments() (Arguments, error) { var err error var ratelimitMbs float64 - a := core.Arguments{} - c := CliOnlyArguments{} - flag.BoolVar(&c.Help, "h", false, "") - flag.BoolVar(&c.Help, "help", false, "") - flag.BoolVar(&c.ListChapters, "list-chapters", false, "") - flag.BoolVar(&c.ListFormats, "list-formats", false, "") + a := Arguments{} + flag.BoolVar(&a.Help, "h", false, "") + flag.BoolVar(&a.Help, "help", false, "") + flag.BoolVar(&a.ListChapters, "list-chapters", false, "") + flag.BoolVar(&a.ListFormats, "list-formats", false, "") 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.OutputFile, "output", "", "") flag.StringVar(&a.TimestampStart, "start", "", "") @@ -92,17 +105,17 @@ func CliParseArguments() (core.Arguments, CliOnlyArguments, error) { flag.Parse() a.Video, err = core.ParseGtvVideoUrl(a.Url) if err != nil { - return a, c, err + return a, err } 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 == "" { a.StartDuration = -1 } else { a.StartDuration, err = time.ParseDuration(a.TimestampStart) if err != nil { - return a, c, err + return a, err } } if a.TimestampStop == "" { @@ -110,15 +123,15 @@ func CliParseArguments() (core.Arguments, CliOnlyArguments, error) { } else { a.StopDuration, err = time.ParseDuration(a.TimestampStop) 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 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 @@ -128,8 +141,8 @@ func CliRun() int { defer fmt.Print("\n") // cli arguments & help text flag.Usage = CliShowHelp - args, cliArgs, err := CliParseArguments() - if cliArgs.Help { + args, err := CliParseArguments() + if args.Help { CliShowHelp() return 0 } else if args.Url == "" || err != nil { @@ -141,11 +154,13 @@ func CliRun() int { } // detect terminal features XtermDetectFeatures() + // + api := core.GtvApi{}; // Get video metadata if CliXtermTitle { XtermSetTitle("lurch-dl - Fetching video metadata ...") } - streamEp, err := core.GetStreamEpisode(args.Video.Id) + streamEp, err := api.GetStreamEpisode(args.Video.Id) if err != nil { cli.ErrorMessage(err) return 1 @@ -155,17 +170,17 @@ func CliRun() int { // Check and list chapters/formats and exit if args.ChapterIdx >= 0 { if args.ChapterIdx >= len(streamEp.Chapters) { - cli.ErrorMessage(&core.ChapterNotFoundError{ChapterNum: cliArgs.ChapterNum}) + cli.ErrorMessage(&core.ChapterNotFoundError{ChapterNum: args.UnparsedChapterNum}) CliAvailableChapters(streamEp.Chapters) return 1 } } - if cliArgs.ListChapters || cliArgs.ListFormats { - if cliArgs.ListChapters { + if args.ListChapters || args.ListFormats { + if args.ListChapters { fmt.Print("\n") CliAvailableChapters(streamEp.Chapters) } - if cliArgs.ListFormats { + if args.ListFormats { fmt.Print("\n") CliAvailableFormats(streamEp.Formats) } @@ -178,8 +193,11 @@ func CliRun() int { return 1 } cli.InfoMessage(fmt.Sprintf("Format: %v", format.Name)) - if args.ChapterIdx >= 0 { - cli.InfoMessage(fmt.Sprintf("Chapter: %v. %v", cliArgs.ChapterNum, streamEp.Chapters[args.ChapterIdx].Title)) + // chapter + 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 if args.OutputFile == "" { @@ -188,12 +206,33 @@ func CliRun() int { // Start Download cli.InfoMessage(fmt.Sprintf("Output: %v", args.OutputFile)) fmt.Print("\n") - if err = streamEp.Download(args, &cli, make(chan os.Signal, 1)); err != nil { - cli.ErrorMessage(err) - return 1 + successful := false + for p := range api.DownloadEpisode( + 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") - return 0 + if !successful { + cli.ErrorMessage(errors.New("download failed")) + return 1 + } else { return 0 } } func CliAvailableChapters(chapters []core.Chapter) { diff --git a/core/args.go b/core/args.go deleted file mode 100644 index 64b7596..0000000 --- a/core/args.go +++ /dev/null @@ -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:"-"` -} diff --git a/core/gtv_api.go b/core/gtv_api.go index dfb8e75..24f34f2 100644 --- a/core/gtv_api.go +++ b/core/gtv_api.go @@ -3,9 +3,25 @@ package core import ( + "encoding/json" + "errors" + "fmt" + "io" + "iter" "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 ApiBaseurlStreamEpisodePlInfo = "https://api.gronkh.tv/v1/video/playlist?episode=%s" @@ -31,3 +47,215 @@ var ApiHeadersMetaAdditional = http.Header{ var ApiHeadersVideoAdditional = http.Header{ "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}) + } +} diff --git a/core/gtv_common.go b/core/gtv_common.go index 7956559..b3b3595 100644 --- a/core/gtv_common.go +++ b/core/gtv_common.go @@ -4,16 +4,15 @@ package core import ( "errors" + "fmt" "regexp" "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]+)`) +// + type GtvVideo struct { Class string `json:"class"` Id string `json:"id"` @@ -22,7 +21,7 @@ type GtvVideo struct { func ParseGtvVideoUrl(url string) (GtvVideo, error) { video := GtvVideo{} match := videoUrlRegex.FindStringSubmatch(url) - if match == nil || len(match) < 2 { + if len(match) < 2 { return video, errors.New("Could not parse URL " + url) } video.Class = match[1] @@ -30,11 +29,15 @@ func ParseGtvVideoUrl(url string) (GtvVideo, error) { return video, nil } +// + type VideoFormat struct { Name string `json:"format"` Url string `json:"url"` } +// + type ChunkList struct { BaseUrl string Chunks []string @@ -59,3 +62,51 @@ func (cl *ChunkList) Cut(from time.Duration, to time.Duration) ChunkList { 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" + } +} diff --git a/core/gtv_stream.go b/core/gtv_stream.go deleted file mode 100644 index 7d7d759..0000000 --- a/core/gtv_stream.go +++ /dev/null @@ -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 -} diff --git a/core/interface.go b/core/interface.go index 949e33f..26bb1a2 100644 --- a/core/interface.go +++ b/core/interface.go @@ -1,8 +1,13 @@ package core -type UserInterface interface { - DownloadProgress(progress float32, rate float64, delaying bool, waiting bool, retries int, title string) - InfoMessage(msg string) - ErrorMessage(err error) - Aborted() +type DownloadProgress struct { + Aborted bool + Error error + Success bool + Delaying bool + Progress float32 + Rate float64 + Retries int + Title string + Waiting bool } From 6bbc319d1b73848797a6017709ba6e3f2f1c9520 Mon Sep 17 00:00:00 2001 From: ChaoticByte Date: Sun, 9 Mar 2025 09:13:41 +0100 Subject: [PATCH 02/12] Replace long cli usage section from README with hint to --help command --- README.md | 25 +------------------------ 1 file changed, 1 insertion(+), 24 deletions(-) diff --git a/README.md b/README.md index bdb47e8..38ebb28 100644 --- a/README.md +++ b/README.md @@ -32,30 +32,7 @@ On Linux, you may have to mark the file as executable before being able to run i ## Cli Usage -If you chose the cli variant of this software. - -``` -lurch-dl --url string The url to the video - [-h --help] Show this help and exit - [--list-chapters] List chapters and exit - [--list-formats] List available formats and exit - [--chapter int] The chapter you want to download - The calculated start and stop timestamps can be - overwritten by --start and --stop - default: -1 (disabled) - [--format string] The desired video format - default: auto - [--output string] The output file. Will be determined automatically - if omitted. - [--start string] Define a video timestamp to start at, e.g. 12m34s - [--stop string] Define a video timestamp to stop at, e.g. 1h23m45s - [--continue] Continue the download if possible - [--overwrite] Overwrite the output file if it already exists - [--max-rate] The maximum download rate in MB/s - don't set this - too high, you may run into a ratelimit and your - IP address might get banned from the servers. - default: 10.0 -``` +Run `lurch-dl --help` to see available options. ### Examples From 5d490de7f17a2ffdc497685c0f76b8a3f64ffa22 Mon Sep 17 00:00:00 2001 From: ChaoticByte Date: Sun, 9 Mar 2025 19:46:44 +0100 Subject: [PATCH 03/12] Replace --list-chapters and --list-formats with --info, include additional infos, update README, and some minor fixes and improvements --- README.md | 79 ++++++++++++++++++------------------------ cli/cli.go | 85 ++++++++++++++++++++++++++-------------------- core/gtv_api.go | 4 ++- core/gtv_common.go | 52 +++++++++++++++------------- 4 files changed, 114 insertions(+), 106 deletions(-) diff --git a/README.md b/README.md index 38ebb28..dc970fa 100644 --- a/README.md +++ b/README.md @@ -11,10 +11,10 @@ Definetly not a commandline downloader for https://gronkh.tv risen from the dead - Specify a start- and stop-timestamp to download only a portion of the video - Download a specific chapter - Continuable Downloads +- Show infos about that Episode ## Known Issues -- You may get a "Windows Defender SmartScreen prevented an unrecognized app from starting" warning when running a new version for the first time - Downloads are capped to 10 Mbyte/s and buffering is simulated to pre-empt IP blocking due to API ratelimiting - Start- and stop-timestamps are not very accurate (± 8 seconds) - Some videoplayers may have problems with the resulting file. To fix this, you can use ffmpeg to rewrite the video into a MKV-File: `ffmpeg -i video.ts -acodec copy -vcodec copy video.mkv` @@ -36,70 +36,59 @@ Run `lurch-dl --help` to see available options. ### Examples -Download a video in its best available format (Windows): +Download a video in its best available format: ``` -.\lurch-dl.exe --url https://gronkh.tv/streams/777 +./lurch-dl.exe --url https://gronkh.tv/streams/777 -Title: GTV0777, 2023-11-09 - DIESER STREAM IST ILLEGAL UND SOLLTE VERBOTEN WERDEN!! ⭐ ️ 247 auf @GronkhTV ⭐ ️ !comic !archiv !a -Format: 1080p60 -Downloaded 0.43% at 10.00 MB/s -... +Title: GTV0777, 2023-11-09 - DIESER STREAM IST ILLEGAL UND ... +Format: 1080p60 +Output: GTV0777, 2023-11-09 - DIESER STREAM IST [...].ts + +Downloaded 0.32% at 10.00 MB/s ... ``` -Continue a download (Windows): +Continue a download: ``` -.\lurch-dl.exe --url https://gronkh.tv/streams/777 --continue - -Title: GTV0777, 2023-11-09 - DIESER STREAM IST ILLEGAL UND SOLLTE VERBOTEN WERDEN!! ⭐ ️ 247 auf @GronkhTV ⭐ ️ !comic !archiv !a -Format: 1080p60 -Downloaded 0.68% at 10.00 MB/s -... -``` - -List all chapters (Windows): - -``` -.\lurch-dl.exe --url https://gronkh.tv/streams/777 --list-chapters - -GTV0777, 2023-11-09 - DIESER STREAM IST ILLEGAL UND SOLLTE VERBOTEN WERDEN!! ⭐ ️ 247 auf @GronkhTV ⭐ ️ !comic !archiv !a - -Chapters: - 1 0s Just Chatting - 2 2h53m7s Alan Wake II - 3 9h35m0s Just Chatting +./lurch-dl.exe --url https://gronkh.tv/streams/777 --continue ``` Download a specific chapter (Windows): ``` -.\lurch-dl.exe --url https://gronkh.tv/streams/777 --chapter 2 +./lurch-dl.exe --url https://gronkh.tv/streams/777 --chapter 2 -GTV0777, 2023-11-09 - DIESER STREAM IST ILLEGAL UND SOLLTE VERBOTEN WERDEN!! ⭐ ️ 247 auf @GronkhTV ⭐ ️ !comic !archiv !a -Format: 1080p60 -Chapter: 2. Alan Wake II +Title: GTV0777, 2023-11-09 - DIESER STREAM IST ILLEGAL UND ... +Format: 1080p60 +Chapter: 2. Alan Wake II +Output: GTV0777 - 2. Alan Wake II.ts -Downloaded 3.22% at 10.00 MB/s -... +Downloaded 0.33% at 4.28 MB/s ... ``` -Specify a start- and stop-timestamp (Linux): +Specify a start- and stop-timestamp: ``` ./lurch-dl --url https://gronkh.tv/streams/777 --start 5h6m41s --stop 5h6m58s -... ``` List all available formats for a video (Linux): ``` -./lurch-dl --url https://gronkh.tv/streams/777 --list-formats +./lurch-dl --url https://gronkh.tv/streams/777 --info -Available formats: - - 1080p60 - - 720p - - 360p +Title: GTV0777, 2023-11-09 - DIESER STREAM IST ILLEGAL UND ... +Episode: 777 +Length: 9h48m55s +Views: 45424 +Timestamp: 2023-11-09T18:23:01Z +Tags: - +Formats: 1080p60, 720p, 360p +Chapters: + 1 0s Just Chatting + 2 2h53m7s Alan Wake II + 3 9h35m0s Just Chatting ``` Download the video in a specific format (Linux): @@ -107,17 +96,15 @@ Download the video in a specific format (Linux): ``` ./lurch-dl --url https://gronkh.tv/streams/777 --format 720p -Title: GTV0777, 2023-11-09 - DIESER STREAM IST ILLEGAL UND SOLLTE VERBOTEN WERDEN!! ⭐ ️ 247 auf @GronkhTV ⭐ ️ !comic !archiv !a -Format: 720p -Downloaded 0.32% at 10.00 MB/s -... +[...] +Format: 720p +[...] ``` Specify a filename (Windows): ``` -.\lurch-dl.exe --url https://gronkh.tv/streams/777 --output Stream777.ts -... +./lurch-dl.exe --url https://gronkh.tv/streams/777 --output Stream777.ts ``` diff --git a/cli/cli.go b/cli/cli.go index 5b72940..eb0b1d8 100644 --- a/cli/cli.go +++ b/cli/cli.go @@ -47,9 +47,9 @@ type Arguments struct { Overwrite bool `json:"overwrite"` ContinueDl bool `json:"continue"` // - Help bool `json:"-"` - ListChapters bool `json:"-"` - ListFormats bool `json:"-"` + Help bool `json:"-"` + VideoInfo bool `json:"-"` + ListFormats bool `json:"-"` UnparsedChapterNum int `json:"chapter_num"` // Parsed Video core.GtvVideo `json:"-"` @@ -63,8 +63,7 @@ func CliShowHelp() { fmt.Println(` lurch-dl --url string The url to the video [-h --help] Show this help and exit - [--list-chapters] List chapters and exit - [--list-formats] List available formats and exit + [--info] Show video info (chapters, formats, length, ...) [--chapter int] The chapter you want to download The calculated start and stop timestamps can be overwritten by --start and --stop @@ -91,8 +90,7 @@ func CliParseArguments() (Arguments, error) { a := Arguments{} flag.BoolVar(&a.Help, "h", false, "") flag.BoolVar(&a.Help, "help", false, "") - flag.BoolVar(&a.ListChapters, "list-chapters", false, "") - flag.BoolVar(&a.ListFormats, "list-formats", false, "") + flag.BoolVar(&a.VideoInfo, "info", false, "") flag.StringVar(&a.Url, "url", "", "") flag.IntVar(&a.UnparsedChapterNum, "chapter", 0, "") // 0 -> chapter idx -1 -> complete stream flag.StringVar(&a.FormatName, "format", "auto", "") @@ -107,8 +105,8 @@ func CliParseArguments() (Arguments, error) { if err != nil { return a, err } - if a.Video.Class != "streams" { - return a, errors.New("video category '" + a.Video.Class + "' not supported") + if a.Video.Category != "streams" { + return a, errors.New("video category '" + a.Video.Category + "' not supported") } if a.TimestampStart == "" { a.StartDuration = -1 @@ -166,7 +164,7 @@ func CliRun() int { return 1 } fmt.Print("\n") - fmt.Println(streamEp.Title) + fmt.Printf("Title: %s\n", streamEp.Title) // Check and list chapters/formats and exit if args.ChapterIdx >= 0 { if args.ChapterIdx >= len(streamEp.Chapters) { @@ -175,15 +173,26 @@ func CliRun() int { return 1 } } - if args.ListChapters || args.ListFormats { - if args.ListChapters { + if args.VideoInfo { + fmt.Printf("Episode: %s\n", streamEp.Episode) + fmt.Printf("Length: %s\n", streamEp.Length) + fmt.Printf("Views: %d\n", streamEp.Views) + fmt.Printf("Timestamp: %s\n", streamEp.Timestamp) + if len(streamEp.Tags) > 0 { + fmt.Print("Tags: ") + for i, t := range streamEp.Tags { + if i == 0 { + fmt.Print(t.Title) + } else { + fmt.Print(", ", t.Title) + } + } fmt.Print("\n") - CliAvailableChapters(streamEp.Chapters) - } - if args.ListFormats { - fmt.Print("\n") - CliAvailableFormats(streamEp.Formats) + } else { + fmt.Println("Tags: -") } + CliAvailableFormats(streamEp.Formats) + CliAvailableChapters(streamEp.Chapters) return 0 } format, err := streamEp.GetFormatByName(args.FormatName) @@ -192,21 +201,22 @@ func CliRun() int { CliAvailableFormats(streamEp.Formats) return 1 } - cli.InfoMessage(fmt.Sprintf("Format: %v", format.Name)) + fmt.Printf("Format: %v\n", format.Name) // chapter 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)) + fmt.Printf("Chapter: %v. %v\n", args.UnparsedChapterNum, targetChapter.Title) } // We already set the output file correctly so we can output it if args.OutputFile == "" { args.OutputFile = streamEp.GetProposedFilename(args.ChapterIdx) } // Start Download - cli.InfoMessage(fmt.Sprintf("Output: %v", args.OutputFile)) + fmt.Printf("Output: %v\n", args.OutputFile) fmt.Print("\n") successful := false + aborted := false for p := range api.DownloadEpisode( streamEp, targetChapter, @@ -218,18 +228,24 @@ func CliRun() int { args.StopDuration, args.Ratelimit, make(chan os.Signal, 1), - ) { + ) { // Iterate over download progress if p.Error != nil { cli.ErrorMessage(p.Error) return 1 } - if p.Success { successful = true } - if p.Aborted { cli.Aborted() } else { + if p.Success { + successful = true + } else if p.Aborted { + aborted = true + } else { cli.DownloadProgress(p.Progress, p.Rate, p.Delaying, p.Waiting, p.Retries, p.Title) } } fmt.Print("\n") - if !successful { + if aborted { + fmt.Print("\nAborted. ") + return 130 + } else if !successful { cli.ErrorMessage(errors.New("download failed")) return 1 } else { return 0 } @@ -238,15 +254,20 @@ func CliRun() int { func CliAvailableChapters(chapters []core.Chapter) { fmt.Println("Chapters:") for _, f := range chapters { - fmt.Printf("%3d %10s\t%s\n", f.Index+1, f.Offset, f.Title) + fmt.Printf(" %3d %10s\t%s\n", f.Index+1, f.Offset, f.Title) } } func CliAvailableFormats(formats []core.VideoFormat) { - fmt.Println("Available formats:") - for _, f := range formats { - fmt.Println(" - " + f.Name) + fmt.Print("Formats: ") + for i, f := range formats { + if i == 0 { + fmt.Print(f.Name) + } else { + fmt.Print(", ", f.Name) + } } + fmt.Print("\n") } type Cli struct{} @@ -270,14 +291,6 @@ func (cli *Cli) DownloadProgress(progress float32, rate float64, delaying bool, } } -func (cli *Cli) Aborted() { - fmt.Print("\nAborted. ") -} - -func (cli *Cli) InfoMessage(msg string) { - fmt.Println(msg) -} - func (cli *Cli) ErrorMessage(err error) { fmt.Print("\n") fmt.Println("An error occured:", err) diff --git a/core/gtv_api.go b/core/gtv_api.go index 24f34f2..b77a1d4 100644 --- a/core/gtv_api.go +++ b/core/gtv_api.go @@ -64,6 +64,8 @@ func (api *GtvApi) GetStreamEpisode(episode string) (StreamEpisode, error) { // Title json.Unmarshal(info_data, &ep) ep.Title = strings.ToValidUTF8(ep.Title, "") + // Length + ep.Length = ep.Length * time.Second // 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 @@ -194,7 +196,7 @@ func (api *GtvApi) DownloadEpisode( // Handle Interrupts <-interruptChan keyboardInterrupt = true - yield(DownloadProgress{Progress: progress, Rate: actualRate, Retries: 0, Title: ep.Title}) + yield(DownloadProgress{Aborted: true, Progress: progress, Rate: actualRate, Retries: 0, Title: ep.Title}) }() for i, chunk := range chunklist.Chunks { if i < nextChunk { diff --git a/core/gtv_common.go b/core/gtv_common.go index b3b3595..9ee0b5a 100644 --- a/core/gtv_common.go +++ b/core/gtv_common.go @@ -13,8 +13,26 @@ var videoUrlRegex = regexp.MustCompile(`gronkh\.tv\/([a-z]+)\/([0-9]+)`) // +type VideoFormat struct { + Name string `json:"format"` + Url string `json:"url"` +} + +type Chapter struct { + Index int `json:"index"` + Title string `json:"title"` + Offset time.Duration `json:"offset"` +} + +type VideoTag struct { + Id int `json:"id"` + Title string `json:"title"` +} + +// + type GtvVideo struct { - Class string `json:"class"` + Category string `json:"category"` Id string `json:"id"` } @@ -24,20 +42,13 @@ func ParseGtvVideoUrl(url string) (GtvVideo, error) { if len(match) < 2 { return video, errors.New("Could not parse URL " + url) } - video.Class = match[1] + video.Category = match[1] video.Id = match[2] return video, nil } // -type VideoFormat struct { - Name string `json:"format"` - Url string `json:"url"` -} - -// - type ChunkList struct { BaseUrl string Chunks []string @@ -65,21 +76,16 @@ func (cl *ChunkList) Cut(from time.Duration, to time.Duration) ChunkList { // -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"` + Episode string `json:"episode"` + Title string `json:"title"` + Formats []VideoFormat `json:"formats"` + Chapters []Chapter `json:"chapters"` + PlaylistUrl string `json:"playlist_url"` + Length time.Duration `json:"source_length"` + Views int `json:"views"` + Timestamp string `json:"created_at"` + Tags []VideoTag `json:"tags"` } func (ep *StreamEpisode) GetFormatByName(formatName string) (VideoFormat, error) { From f83aee66f42549d1d59a087df57ff57d119d0a08 Mon Sep 17 00:00:00 2001 From: ChaoticByte Date: Sun, 9 Mar 2025 19:49:06 +0100 Subject: [PATCH 04/12] Bump version to 2.1.0 --- VERSION | 2 +- cli/VERSION | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) delete mode 100644 cli/VERSION diff --git a/VERSION b/VERSION index 10bf840..50aea0e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.0.1 \ No newline at end of file +2.1.0 \ No newline at end of file diff --git a/cli/VERSION b/cli/VERSION deleted file mode 100644 index 359a5b9..0000000 --- a/cli/VERSION +++ /dev/null @@ -1 +0,0 @@ -2.0.0 \ No newline at end of file From 18171422f785e33317c47d1f08f05e43927fe5b4 Mon Sep 17 00:00:00 2001 From: ChaoticByte Date: Sun, 9 Mar 2025 19:51:36 +0100 Subject: [PATCH 05/12] Renamed build-cli.sh to release-cli.sh --- build-cli.sh => release-cli.sh | 2 -- 1 file changed, 2 deletions(-) rename build-cli.sh => release-cli.sh (98%) diff --git a/build-cli.sh b/release-cli.sh similarity index 98% rename from build-cli.sh rename to release-cli.sh index 75a130f..24af3d6 100755 --- a/build-cli.sh +++ b/release-cli.sh @@ -1,7 +1,5 @@ #!/usr/bin/env bash -setopt -e - WORKDIR="./cli" CORE_DIR="./core" OUTPUT_DIR="../dist" From 5825892dfd81cd347bbfffbaa4a16924e6c28706 Mon Sep 17 00:00:00 2001 From: ChaoticByte Date: Sun, 9 Mar 2025 20:00:15 +0100 Subject: [PATCH 06/12] Cleanup README examples --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index dc970fa..ab8c727 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ Continue a download: ./lurch-dl.exe --url https://gronkh.tv/streams/777 --continue ``` -Download a specific chapter (Windows): +Download a specific chapter: ``` ./lurch-dl.exe --url https://gronkh.tv/streams/777 --chapter 2 @@ -73,7 +73,7 @@ Specify a start- and stop-timestamp: ./lurch-dl --url https://gronkh.tv/streams/777 --start 5h6m41s --stop 5h6m58s ``` -List all available formats for a video (Linux): +List all available formats for a video: ``` ./lurch-dl --url https://gronkh.tv/streams/777 --info @@ -91,7 +91,7 @@ Chapters: 3 9h35m0s Just Chatting ``` -Download the video in a specific format (Linux): +Download the video in a specific format: ``` ./lurch-dl --url https://gronkh.tv/streams/777 --format 720p @@ -101,7 +101,7 @@ Format: 720p [...] ``` -Specify a filename (Windows): +Specify a filename: ``` ./lurch-dl.exe --url https://gronkh.tv/streams/777 --output Stream777.ts From 2f0542eaed0c5be6b7c714301ff4d2aeb6b201ab Mon Sep 17 00:00:00 2001 From: ChaoticByte Date: Sun, 9 Mar 2025 20:26:44 +0100 Subject: [PATCH 07/12] Remove windows support --- README.md | 10 +++++----- VERSION | 2 +- release-cli.sh | 3 --- 3 files changed, 6 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index ab8c727..766d603 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ Definetly not a commandline downloader for https://gronkh.tv risen from the dead ## Supported Platforms -Tested on Linux and Windows (64bit). +Only compatible with Linux. ## Download / Installation @@ -39,7 +39,7 @@ Run `lurch-dl --help` to see available options. Download a video in its best available format: ``` -./lurch-dl.exe --url https://gronkh.tv/streams/777 +./lurch-dl --url https://gronkh.tv/streams/777 Title: GTV0777, 2023-11-09 - DIESER STREAM IST ILLEGAL UND ... Format: 1080p60 @@ -51,13 +51,13 @@ Downloaded 0.32% at 10.00 MB/s ... Continue a download: ``` -./lurch-dl.exe --url https://gronkh.tv/streams/777 --continue +./lurch-dl --url https://gronkh.tv/streams/777 --continue ``` Download a specific chapter: ``` -./lurch-dl.exe --url https://gronkh.tv/streams/777 --chapter 2 +./lurch-dl --url https://gronkh.tv/streams/777 --chapter 2 Title: GTV0777, 2023-11-09 - DIESER STREAM IST ILLEGAL UND ... Format: 1080p60 @@ -104,7 +104,7 @@ Format: 720p Specify a filename: ``` -./lurch-dl.exe --url https://gronkh.tv/streams/777 --output Stream777.ts +./lurch-dl --url https://gronkh.tv/streams/777 --output Stream777.ts ``` diff --git a/VERSION b/VERSION index 50aea0e..7c32728 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.1.0 \ No newline at end of file +2.1.1 \ No newline at end of file diff --git a/release-cli.sh b/release-cli.sh index 24af3d6..04f6958 100755 --- a/release-cli.sh +++ b/release-cli.sh @@ -17,9 +17,6 @@ NAME_BASE="lurch-dl_v${VERSION}" echo "Building ${NAME_BASE} into ${OUTPUT_DIR}" -GOOS=windows GOARCH=386 OUTPUT_FILE=${NAME_BASE}_32bit.exe gobuild -GOOS=windows GOARCH=amd64 OUTPUT_FILE=${NAME_BASE}_64bit.exe gobuild -GOOS=windows GOARCH=arm64 OUTPUT_FILE=${NAME_BASE}_arm64.exe gobuild GOOS=linux GOARCH=386 OUTPUT_FILE=${NAME_BASE}_linux_i386 gobuild GOOS=linux GOARCH=amd64 OUTPUT_FILE=${NAME_BASE}_linux_amd64 gobuild GOOS=linux GOARCH=arm OUTPUT_FILE=${NAME_BASE}_linux_arm gobuild From 38ec0729ba6c5dfb867343707a7444947d57eeb0 Mon Sep 17 00:00:00 2001 From: ChaoticByte Date: Sun, 9 Mar 2025 20:29:12 +0100 Subject: [PATCH 08/12] Update README --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index 766d603..4f3c09d 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,6 @@ Definetly not a commandline downloader for https://gronkh.tv risen from the dead - Downloads are capped to 10 Mbyte/s and buffering is simulated to pre-empt IP blocking due to API ratelimiting - Start- and stop-timestamps are not very accurate (± 8 seconds) - Some videoplayers may have problems with the resulting file. To fix this, you can use ffmpeg to rewrite the video into a MKV-File: `ffmpeg -i video.ts -acodec copy -vcodec copy video.mkv` -- Emojis and other Unicode characters don't get displayed properly in a Powershell Console ## Supported Platforms From 424e912f6c4e6579c27788e7beb37edcfc72ef36 Mon Sep 17 00:00:00 2001 From: ChaoticByte Date: Fri, 14 Mar 2025 19:47:23 +0100 Subject: [PATCH 09/12] Remove the usage of custom types where it isn't required --- cli/cli.go | 128 ++++++++++++++++++++++------------------------ core/gtv_api.go | 22 +++++--- core/interface.go | 13 ----- 3 files changed, 77 insertions(+), 86 deletions(-) delete mode 100644 core/interface.go diff --git a/cli/cli.go b/cli/cli.go index eb0b1d8..ef1f7c5 100644 --- a/cli/cli.go +++ b/cli/cli.go @@ -38,7 +38,7 @@ func XtermSetTitle(title string) { // Commandline -type Arguments struct { +var Arguments struct { Url string `json:"url"` FormatName string `json:"format_name"` OutputFile string `json:"output_file"` @@ -84,96 +84,92 @@ lurch-dl --url string The url to the video Version: ` + core.Version) } -func CliParseArguments() (Arguments, error) { +func CliParseArguments() error { var err error var ratelimitMbs float64 - a := Arguments{} - flag.BoolVar(&a.Help, "h", false, "") - flag.BoolVar(&a.Help, "help", false, "") - flag.BoolVar(&a.VideoInfo, "info", false, "") - flag.StringVar(&a.Url, "url", "", "") - flag.IntVar(&a.UnparsedChapterNum, "chapter", 0, "") // 0 -> chapter idx -1 -> complete stream - flag.StringVar(&a.FormatName, "format", "auto", "") - flag.StringVar(&a.OutputFile, "output", "", "") - flag.StringVar(&a.TimestampStart, "start", "", "") - flag.StringVar(&a.TimestampStop, "stop", "", "") - flag.BoolVar(&a.Overwrite, "overwrite", false, "") - flag.BoolVar(&a.ContinueDl, "continue", false, "") + flag.BoolVar(&Arguments.Help, "h", false, "") + flag.BoolVar(&Arguments.Help, "help", false, "") + flag.BoolVar(&Arguments.VideoInfo, "info", false, "") + flag.StringVar(&Arguments.Url, "url", "", "") + flag.IntVar(&Arguments.UnparsedChapterNum, "chapter", 0, "") // 0 -> chapter idx -1 -> complete stream + flag.StringVar(&Arguments.FormatName, "format", "auto", "") + flag.StringVar(&Arguments.OutputFile, "output", "", "") + flag.StringVar(&Arguments.TimestampStart, "start", "", "") + flag.StringVar(&Arguments.TimestampStop, "stop", "", "") + flag.BoolVar(&Arguments.Overwrite, "overwrite", false, "") + flag.BoolVar(&Arguments.ContinueDl, "continue", false, "") flag.Float64Var(&ratelimitMbs, "max-rate", 10.0, "") flag.Parse() - a.Video, err = core.ParseGtvVideoUrl(a.Url) + Arguments.Video, err = core.ParseGtvVideoUrl(Arguments.Url) if err != nil { - return a, err + return err } - if a.Video.Category != "streams" { - return a, errors.New("video category '" + a.Video.Category + "' not supported") + if Arguments.Video.Category != "streams" { + return errors.New("video category '" + Arguments.Video.Category + "' not supported") } - if a.TimestampStart == "" { - a.StartDuration = -1 + if Arguments.TimestampStart == "" { + Arguments.StartDuration = -1 } else { - a.StartDuration, err = time.ParseDuration(a.TimestampStart) + Arguments.StartDuration, err = time.ParseDuration(Arguments.TimestampStart) if err != nil { - return a, err + return err } } - if a.TimestampStop == "" { - a.StopDuration = -1 + if Arguments.TimestampStop == "" { + Arguments.StopDuration = -1 } else { - a.StopDuration, err = time.ParseDuration(a.TimestampStop) + Arguments.StopDuration, err = time.ParseDuration(Arguments.TimestampStop) if err != nil { - return a, err + return err } } - a.ChapterIdx = a.UnparsedChapterNum - 1 - a.Ratelimit = ratelimitMbs * 1_000_000.0 // MB/s -> B/s - if a.Ratelimit <= 0 { - return a, errors.New("the value of --max-rate must be greater than 0") + Arguments.ChapterIdx = Arguments.UnparsedChapterNum - 1 + Arguments.Ratelimit = ratelimitMbs * 1_000_000.0 // MB/s -> B/s + if Arguments.Ratelimit <= 0 { + return errors.New("the value of --max-rate must be greater than 0") } - return a, err + return err } // Main func CliRun() int { - cli := Cli{} defer fmt.Print("\n") // cli arguments & help text flag.Usage = CliShowHelp - args, err := CliParseArguments() - if args.Help { + err := CliParseArguments() + if Arguments.Help { CliShowHelp() return 0 - } else if args.Url == "" || err != nil { + } else if Arguments.Url == "" || err != nil { CliShowHelp() if err != nil { - cli.ErrorMessage(err) + CliErrorMessage(err) } return 1 } // detect terminal features XtermDetectFeatures() - // - api := core.GtvApi{}; // Get video metadata if CliXtermTitle { XtermSetTitle("lurch-dl - Fetching video metadata ...") } - streamEp, err := api.GetStreamEpisode(args.Video.Id) + streamEp, err := core.GetStreamEpisode(Arguments.Video.Id) if err != nil { - cli.ErrorMessage(err) + CliErrorMessage(err) return 1 } fmt.Print("\n") fmt.Printf("Title: %s\n", streamEp.Title) // Check and list chapters/formats and exit - if args.ChapterIdx >= 0 { - if args.ChapterIdx >= len(streamEp.Chapters) { - cli.ErrorMessage(&core.ChapterNotFoundError{ChapterNum: args.UnparsedChapterNum}) + if Arguments.ChapterIdx >= 0 { + if Arguments.ChapterIdx >= len(streamEp.Chapters) { + CliErrorMessage(&core.ChapterNotFoundError{ChapterNum: Arguments.UnparsedChapterNum}) CliAvailableChapters(streamEp.Chapters) return 1 } } - if args.VideoInfo { + if Arguments.VideoInfo { fmt.Printf("Episode: %s\n", streamEp.Episode) fmt.Printf("Length: %s\n", streamEp.Length) fmt.Printf("Views: %d\n", streamEp.Views) @@ -195,42 +191,42 @@ func CliRun() int { CliAvailableChapters(streamEp.Chapters) return 0 } - format, err := streamEp.GetFormatByName(args.FormatName) + format, err := streamEp.GetFormatByName(Arguments.FormatName) if err != nil { - cli.ErrorMessage(err) + CliErrorMessage(err) CliAvailableFormats(streamEp.Formats) return 1 } fmt.Printf("Format: %v\n", format.Name) // chapter targetChapter := core.Chapter{Index: -1} // set Index to -1 for noop - if len(streamEp.Chapters) > 0 && args.ChapterIdx >= 0 { - targetChapter = streamEp.Chapters[args.ChapterIdx] - fmt.Printf("Chapter: %v. %v\n", args.UnparsedChapterNum, targetChapter.Title) + if len(streamEp.Chapters) > 0 && Arguments.ChapterIdx >= 0 { + targetChapter = streamEp.Chapters[Arguments.ChapterIdx] + fmt.Printf("Chapter: %v. %v\n", Arguments.UnparsedChapterNum, targetChapter.Title) } // We already set the output file correctly so we can output it - if args.OutputFile == "" { - args.OutputFile = streamEp.GetProposedFilename(args.ChapterIdx) + if Arguments.OutputFile == "" { + Arguments.OutputFile = streamEp.GetProposedFilename(Arguments.ChapterIdx) } // Start Download - fmt.Printf("Output: %v\n", args.OutputFile) + fmt.Printf("Output: %v\n", Arguments.OutputFile) fmt.Print("\n") successful := false aborted := false - for p := range api.DownloadEpisode( + for p := range core.DownloadEpisode( streamEp, targetChapter, - args.FormatName, - args.OutputFile, - args.Overwrite, - args.ContinueDl, - args.StartDuration, - args.StopDuration, - args.Ratelimit, + Arguments.FormatName, + Arguments.OutputFile, + Arguments.Overwrite, + Arguments.ContinueDl, + Arguments.StartDuration, + Arguments.StopDuration, + Arguments.Ratelimit, make(chan os.Signal, 1), ) { // Iterate over download progress if p.Error != nil { - cli.ErrorMessage(p.Error) + CliErrorMessage(p.Error) return 1 } if p.Success { @@ -238,7 +234,7 @@ func CliRun() int { } else if p.Aborted { aborted = true } else { - cli.DownloadProgress(p.Progress, p.Rate, p.Delaying, p.Waiting, p.Retries, p.Title) + CliDownloadProgress(p.Progress, p.Rate, p.Delaying, p.Waiting, p.Retries, p.Title) } } fmt.Print("\n") @@ -246,7 +242,7 @@ func CliRun() int { fmt.Print("\nAborted. ") return 130 } else if !successful { - cli.ErrorMessage(errors.New("download failed")) + CliErrorMessage(errors.New("download failed")) return 1 } else { return 0 } } @@ -270,9 +266,7 @@ func CliAvailableFormats(formats []core.VideoFormat) { fmt.Print("\n") } -type Cli struct{} - -func (cli *Cli) DownloadProgress(progress float32, rate float64, delaying bool, waiting bool, retries int, title string) { +func CliDownloadProgress(progress float32, rate float64, delaying bool, waiting bool, retries int, title string) { if retries > 0 { if retries == 1 { fmt.Print("\n") @@ -291,7 +285,7 @@ func (cli *Cli) DownloadProgress(progress float32, rate float64, delaying bool, } } -func (cli *Cli) ErrorMessage(err error) { +func CliErrorMessage(err error) { fmt.Print("\n") fmt.Println("An error occured:", err) } diff --git a/core/gtv_api.go b/core/gtv_api.go index b77a1d4..471a561 100644 --- a/core/gtv_api.go +++ b/core/gtv_api.go @@ -25,6 +25,18 @@ const RatelimitDelayAfter = 5.0 // in Seconds; Delay the next chunk download aft const ApiBaseurlStreamEpisodeInfo = "https://api.gronkh.tv/v1/video/info?episode=%s" const ApiBaseurlStreamEpisodePlInfo = "https://api.gronkh.tv/v1/video/playlist?episode=%s" +type DownloadProgress struct { + Aborted bool + Error error + Success bool + Delaying bool + Progress float32 + Rate float64 + Retries int + Title string + Waiting bool +} + var ApiHeadersBase = http.Header{ "User-Agent": {"Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/119.0"}, "Accept-Language": {"de,en-US;q=0.7,en;q=0.3"}, @@ -48,9 +60,7 @@ var ApiHeadersVideoAdditional = http.Header{ "Accept": {"*/*"}, } -type GtvApi struct{} - -func (api *GtvApi) GetStreamEpisode(episode string) (StreamEpisode, error) { +func GetStreamEpisode(episode string) (StreamEpisode, error) { ep := StreamEpisode{} ep.Episode = episode info_data, err := httpGet( @@ -93,7 +103,7 @@ func (api *GtvApi) GetStreamEpisode(episode string) (StreamEpisode, error) { return ep, err } -func (api *GtvApi) GetStreamChunkList(video VideoFormat) (ChunkList, error) { +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 { @@ -103,7 +113,7 @@ func (api *GtvApi) GetStreamChunkList(video VideoFormat) (ChunkList, error) { return chunklist, err } -func (api *GtvApi) DownloadEpisode( +func DownloadEpisode( ep StreamEpisode, chapter Chapter, formatName string, @@ -181,7 +191,7 @@ func (api *GtvApi) DownloadEpisode( } // 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, err := GetStreamChunkList(format) chunklist = chunklist.Cut(startDuration, stopDuration) if err != nil { yield(DownloadProgress{Error: err}) diff --git a/core/interface.go b/core/interface.go deleted file mode 100644 index 26bb1a2..0000000 --- a/core/interface.go +++ /dev/null @@ -1,13 +0,0 @@ -package core - -type DownloadProgress struct { - Aborted bool - Error error - Success bool - Delaying bool - Progress float32 - Rate float64 - Retries int - Title string - Waiting bool -} From ee3518ab5c268f32bad62911295f44f3cd7a207d Mon Sep 17 00:00:00 2001 From: ChaoticByte Date: Fri, 14 Mar 2025 23:20:47 +0100 Subject: [PATCH 10/12] Refactoring, added remaining custom errors --- cli/cli.go | 39 +++++++++++++++------------------------ cli/errors.go | 15 +++++++++++++++ core/errors.go | 28 +++++++++++++++++++++++++--- core/gtv_api.go | 7 +++---- core/gtv_common.go | 24 +++++++++++++++++++----- 5 files changed, 77 insertions(+), 36 deletions(-) create mode 100644 cli/errors.go diff --git a/cli/cli.go b/cli/cli.go index ef1f7c5..5030f27 100644 --- a/cli/cli.go +++ b/cli/cli.go @@ -3,7 +3,6 @@ package main import ( - "errors" "flag" "fmt" "os" @@ -50,12 +49,11 @@ var Arguments struct { Help bool `json:"-"` VideoInfo bool `json:"-"` ListFormats bool `json:"-"` - UnparsedChapterNum int `json:"chapter_num"` + ChapterNum int `json:"chapter_num"` // Parsed Video core.GtvVideo `json:"-"` StartDuration time.Duration `json:"-"` StopDuration time.Duration `json:"-"` - ChapterIdx int `json:"-"` Ratelimit float64 `json:"-"` } @@ -91,7 +89,7 @@ func CliParseArguments() error { flag.BoolVar(&Arguments.Help, "help", false, "") flag.BoolVar(&Arguments.VideoInfo, "info", false, "") flag.StringVar(&Arguments.Url, "url", "", "") - flag.IntVar(&Arguments.UnparsedChapterNum, "chapter", 0, "") // 0 -> chapter idx -1 -> complete stream + flag.IntVar(&Arguments.ChapterNum, "chapter", 0, "") // 0 -> chapter idx -1 -> complete stream flag.StringVar(&Arguments.FormatName, "format", "auto", "") flag.StringVar(&Arguments.OutputFile, "output", "", "") flag.StringVar(&Arguments.TimestampStart, "start", "", "") @@ -104,9 +102,6 @@ func CliParseArguments() error { if err != nil { return err } - if Arguments.Video.Category != "streams" { - return errors.New("video category '" + Arguments.Video.Category + "' not supported") - } if Arguments.TimestampStart == "" { Arguments.StartDuration = -1 } else { @@ -123,10 +118,9 @@ func CliParseArguments() error { return err } } - Arguments.ChapterIdx = Arguments.UnparsedChapterNum - 1 Arguments.Ratelimit = ratelimitMbs * 1_000_000.0 // MB/s -> B/s if Arguments.Ratelimit <= 0 { - return errors.New("the value of --max-rate must be greater than 0") + return &GenericCliAgumentError{Msg: "the value of --max-rate must be greater than 0"} } return err } @@ -162,13 +156,16 @@ func CliRun() int { fmt.Print("\n") fmt.Printf("Title: %s\n", streamEp.Title) // Check and list chapters/formats and exit - if Arguments.ChapterIdx >= 0 { - if Arguments.ChapterIdx >= len(streamEp.Chapters) { - CliErrorMessage(&core.ChapterNotFoundError{ChapterNum: Arguments.UnparsedChapterNum}) - CliAvailableChapters(streamEp.Chapters) - return 1 - } + targetChapter, err := streamEp.GetChapterByNumber(Arguments.ChapterNum) + if err != nil { + CliErrorMessage(err) + CliAvailableChapters(streamEp.Chapters) + return 1 } + if Arguments.ChapterNum > 0 && len(streamEp.Chapters) > 0 { + fmt.Printf("Chapter: %v. %v\n", Arguments.ChapterNum, targetChapter.Title) + } + // Video Info if Arguments.VideoInfo { fmt.Printf("Episode: %s\n", streamEp.Episode) fmt.Printf("Length: %s\n", streamEp.Length) @@ -198,22 +195,16 @@ func CliRun() int { return 1 } fmt.Printf("Format: %v\n", format.Name) - // chapter - targetChapter := core.Chapter{Index: -1} // set Index to -1 for noop - if len(streamEp.Chapters) > 0 && Arguments.ChapterIdx >= 0 { - targetChapter = streamEp.Chapters[Arguments.ChapterIdx] - fmt.Printf("Chapter: %v. %v\n", Arguments.UnparsedChapterNum, targetChapter.Title) - } // We already set the output file correctly so we can output it if Arguments.OutputFile == "" { - Arguments.OutputFile = streamEp.GetProposedFilename(Arguments.ChapterIdx) + Arguments.OutputFile = streamEp.GetProposedFilename(targetChapter) } // Start Download fmt.Printf("Output: %v\n", Arguments.OutputFile) fmt.Print("\n") successful := false aborted := false - for p := range core.DownloadEpisode( + for p := range core.DownloadStreamEpisode( streamEp, targetChapter, Arguments.FormatName, @@ -242,7 +233,7 @@ func CliRun() int { fmt.Print("\nAborted. ") return 130 } else if !successful { - CliErrorMessage(errors.New("download failed")) + CliErrorMessage(&GenericDownloadError{}) return 1 } else { return 0 } } diff --git a/cli/errors.go b/cli/errors.go new file mode 100644 index 0000000..941ac6f --- /dev/null +++ b/cli/errors.go @@ -0,0 +1,15 @@ +package main + +type GenericCliAgumentError struct { + Msg string +} + +func (err *GenericCliAgumentError) Error() string { + return err.Msg +} + +type GenericDownloadError struct {} + +func (err *GenericDownloadError) Error() string { + return "download failed" +} diff --git a/core/errors.go b/core/errors.go index b9e5240..38ce24d 100644 --- a/core/errors.go +++ b/core/errors.go @@ -35,7 +35,7 @@ type FileExistsError struct { } func (err *FileExistsError) Error() string { - return "File '" + err.Filename + "' already exists. See the available options on how to proceed." + return "file '" + err.Filename + "' already exists - see the available options on how to proceed" } type FormatNotFoundError struct { @@ -43,7 +43,7 @@ type FormatNotFoundError struct { } func (err *FormatNotFoundError) Error() string { - return "Format " + err.FormatName + " is not available." + return "format " + err.FormatName + " is not available" } type ChapterNotFoundError struct { @@ -51,5 +51,27 @@ type ChapterNotFoundError struct { } func (err *ChapterNotFoundError) Error() string { - return fmt.Sprintf("Chapter %v not found.", err.ChapterNum) + return fmt.Sprintf("chapter %v not found", err.ChapterNum) +} + +type VideoCategoryUnsupportedError struct { + Category string +} + +func (err *VideoCategoryUnsupportedError) Error() string { + return fmt.Sprintf("video category '%v' not supported", err.Category) +} + +type GtvVideoUrlParseError struct { + Url string +} + +func (err *GtvVideoUrlParseError) Error() string { + return fmt.Sprintf("Could not parse URL %v", err.Url) +} + +type DownloadInfoFileReadError struct {} + +func (err *DownloadInfoFileReadError) Error() string { + return "could not read download info file, can't continue download" } diff --git a/core/gtv_api.go b/core/gtv_api.go index 471a561..f41a9ac 100644 --- a/core/gtv_api.go +++ b/core/gtv_api.go @@ -4,7 +4,6 @@ package core import ( "encoding/json" - "errors" "fmt" "io" "iter" @@ -113,7 +112,7 @@ func GetStreamChunkList(video VideoFormat) (ChunkList, error) { return chunklist, err } -func DownloadEpisode( +func DownloadStreamEpisode( ep StreamEpisode, chapter Chapter, formatName string, @@ -128,7 +127,7 @@ func DownloadEpisode( return func (yield func(DownloadProgress) bool) { // Set automatic values if outputFile == "" { - outputFile = ep.GetProposedFilename(chapter.Index) + outputFile = ep.GetProposedFilename(chapter) } if chapter.Index >= 0 { if startDuration < 0 { @@ -167,7 +166,7 @@ func DownloadEpisode( 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")}) + yield(DownloadProgress{Error: &DownloadInfoFileReadError{}}) return } i, err := strconv.ParseInt(string(infoFileData), 10, 32) diff --git a/core/gtv_common.go b/core/gtv_common.go index 9ee0b5a..436e7d5 100644 --- a/core/gtv_common.go +++ b/core/gtv_common.go @@ -3,7 +3,6 @@ package core import ( - "errors" "fmt" "regexp" "time" @@ -40,10 +39,13 @@ func ParseGtvVideoUrl(url string) (GtvVideo, error) { video := GtvVideo{} match := videoUrlRegex.FindStringSubmatch(url) if len(match) < 2 { - return video, errors.New("Could not parse URL " + url) + return video, &GtvVideoUrlParseError{Url: url} } video.Category = match[1] video.Id = match[2] + if video.Category != "streams" { + return video, &VideoCategoryUnsupportedError{Category: video.Category} + } return video, nil } @@ -109,9 +111,21 @@ func (ep *StreamEpisode) GetFormatByName(formatName string) (VideoFormat, error) } } -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)) +func (ep *StreamEpisode) GetChapterByNumber(number int) (Chapter, error) { + chapter := Chapter{Index: -1} // set Index to -1 for noop + idx := number-1 + if idx >= 0 && idx >= len(ep.Chapters) { + return chapter, &ChapterNotFoundError{ChapterNum: number} + } + if len(ep.Chapters) > 0 && idx >= 0 { + chapter = ep.Chapters[idx] + } + return chapter, nil +} + +func (ep *StreamEpisode) GetProposedFilename(chapter Chapter) string { + if chapter.Index >= 0 && chapter.Index < len(ep.Chapters) { + return fmt.Sprintf("GTV%04s - %v. %s.ts", ep.Episode, chapter.Index, sanitizeUnicodeFilename(ep.Chapters[chapter.Index].Title)) } else { return sanitizeUnicodeFilename(ep.Title) + ".ts" } From 727575ff2eec5a9a67aa282a5142b40e39c440db Mon Sep 17 00:00:00 2001 From: ChaoticByte Date: Sat, 15 Mar 2025 17:13:38 +0100 Subject: [PATCH 11/12] Bump version to 2.1.2 --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index 7c32728..8f9174b 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.1.1 \ No newline at end of file +2.1.2 \ No newline at end of file From 965c30c5011f6d334806fff8db96e691d8315fda Mon Sep 17 00:00:00 2001 From: ChaoticByte Date: Tue, 5 Aug 2025 21:05:08 +0200 Subject: [PATCH 12/12] Restructure and rewrite some parts of the README --- README.md | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 4f3c09d..f15d89b 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,10 @@ -
what could it be? +Definitely not an unofficial commandline downloader for https://gronkh.tv -
+## Compatibility -Definetly not a commandline downloader for https://gronkh.tv risen from the dead. +This tool is only compatible with recent Linux-based operating systems. +To run it on Windows, make use of WSL. ## Features @@ -13,26 +14,27 @@ Definetly not a commandline downloader for https://gronkh.tv risen from the dead - Continuable Downloads - Show infos about that Episode -## Known Issues +## Known Issues / Limitations -- Downloads are capped to 10 Mbyte/s and buffering is simulated to pre-empt IP blocking due to API ratelimiting -- Start- and stop-timestamps are not very accurate (± 8 seconds) -- Some videoplayers may have problems with the resulting file. To fix this, you can use ffmpeg to rewrite the video into a MKV-File: `ffmpeg -i video.ts -acodec copy -vcodec copy video.mkv` - -## Supported Platforms - -Only compatible with Linux. +- Downloads are **capped to 10 Mbyte/s by default** and buffering is simulated to pre-empt IP blocking due to API rate-limiting +- Because of the length of video chunks, **start- and stop-timestamps are inaccurate** (± 8 seconds) +- **Some videoplayers may have problems with the downloaded video file**. To fix this, you can use ffmpeg to rewrite the video into a MKV-File: + `ffmpeg -i video.ts -acodec copy -vcodec copy video.mkv` ## Download / Installation -New versions will appear under [Releases](https://github.com/ChaoticByte/lurch-dl/releases). Just download the application and run it via the terminal/cmd/powershell/... +New versions will appear under [Releases](https://github.com/ChaoticByte/lurch-dl/releases). +Just download the application and run it via your favourite terminal emulator. -On Linux, you may have to mark the file as executable before being able to run it. +> Note: **You may have to mark the file as executable before being able to run it.** + +## Usage -## Cli Usage Run `lurch-dl --help` to see available options. +> Note: This tool runs entirely on the command line. + ### Examples Download a video in its best available format: @@ -105,5 +107,3 @@ Specify a filename: ``` ./lurch-dl --url https://gronkh.tv/streams/777 --output Stream777.ts ``` - -