diff --git a/README.md b/README.md index bdb47e8..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 @@ -11,136 +12,98 @@ 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 +## Known Issues / Limitations -- 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` -- Emojis and other Unicode characters don't get displayed properly in a Powershell Console - -## Supported Platforms - -Tested on Linux and Windows (64bit). +- 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.** -## Cli Usage +## 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. + +> Note: This tool runs entirely on the command line. ### 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 --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 -... +./lurch-dl --url https://gronkh.tv/streams/777 --continue ``` -List all chapters (Windows): +Download a specific chapter: ``` -.\lurch-dl.exe --url https://gronkh.tv/streams/777 --list-chapters +./lurch-dl --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 +Title: GTV0777, 2023-11-09 - DIESER STREAM IST ILLEGAL UND ... +Format: 1080p60 +Chapter: 2. Alan Wake II +Output: GTV0777 - 2. Alan Wake II.ts -Chapters: - 1 0s Just Chatting - 2 2h53m7s Alan Wake II - 3 9h35m0s Just Chatting +Downloaded 0.33% at 4.28 MB/s ... ``` -Download a specific chapter (Windows): - -``` -.\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 - -Downloaded 3.22% at 10.00 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): +List all available formats for a video: ``` -./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): +Download the video in a specific format: ``` ./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): +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 359a5b9..8f9174b 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.0.0 \ No newline at end of file +2.1.2 \ 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 diff --git a/cli/cli.go b/cli/cli.go index f588bd3..5030f27 100644 --- a/cli/cli.go +++ b/cli/cli.go @@ -3,7 +3,6 @@ package main import ( - "errors" "flag" "fmt" "os" @@ -38,19 +37,31 @@ func XtermSetTitle(title string) { // Commandline -type CliOnlyArguments struct { - Help bool `json:"-"` - ListChapters bool `json:"-"` - ListFormats bool `json:"-"` +var 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:"-"` + VideoInfo bool `json:"-"` + ListFormats bool `json:"-"` ChapterNum int `json:"chapter_num"` + // Parsed + Video core.GtvVideo `json:"-"` + StartDuration time.Duration `json:"-"` + StopDuration time.Duration `json:"-"` + Ratelimit float64 `json:"-"` } 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 @@ -71,71 +82,63 @@ lurch-dl --url string The url to the video Version: ` + core.Version) } -func CliParseArguments() (core.Arguments, CliOnlyArguments, error) { +func CliParseArguments() 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, "") - flag.StringVar(&a.Url, "url", "", "") - flag.IntVar(&c.ChapterNum, "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.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", "", "") + 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, c, err + return err } - if a.Video.Class != "streams" { - return a, c, errors.New("video category '" + a.Video.Class + "' 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, c, 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, c, err + return err } } - a.ChapterIdx = c.ChapterNum - 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") + Arguments.Ratelimit = ratelimitMbs * 1_000_000.0 // MB/s -> B/s + if Arguments.Ratelimit <= 0 { + return &GenericCliAgumentError{Msg: "the value of --max-rate must be greater than 0"} } - return a, c, err + return err } // Main func CliRun() int { - cli := Cli{} defer fmt.Print("\n") // cli arguments & help text flag.Usage = CliShowHelp - args, cliArgs, err := CliParseArguments() - if cliArgs.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 } @@ -145,73 +148,116 @@ func CliRun() int { if CliXtermTitle { XtermSetTitle("lurch-dl - Fetching video metadata ...") } - streamEp, err := core.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.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) { - cli.ErrorMessage(&core.ChapterNotFoundError{ChapterNum: cliArgs.ChapterNum}) - CliAvailableChapters(streamEp.Chapters) - return 1 - } + targetChapter, err := streamEp.GetChapterByNumber(Arguments.ChapterNum) + if err != nil { + CliErrorMessage(err) + CliAvailableChapters(streamEp.Chapters) + return 1 } - if cliArgs.ListChapters || cliArgs.ListFormats { - if cliArgs.ListChapters { + 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) + 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 cliArgs.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) + format, err := streamEp.GetFormatByName(Arguments.FormatName) if err != nil { - cli.ErrorMessage(err) + CliErrorMessage(err) CliAvailableFormats(streamEp.Formats) return 1 } - CliShowFormat(format) - if args.ChapterIdx >= 0 { - cli.InfoMessage(fmt.Sprintf("Chapter: %v. %v", cliArgs.ChapterNum, streamEp.Chapters[args.ChapterIdx].Title)) + fmt.Printf("Format: %v\n", format.Name) + // We already set the output file correctly so we can output it + if Arguments.OutputFile == "" { + Arguments.OutputFile = streamEp.GetProposedFilename(targetChapter) } // Start Download + fmt.Printf("Output: %v\n", Arguments.OutputFile) fmt.Print("\n") - if err = streamEp.Download(args, &cli, make(chan os.Signal, 1)); err != nil { - cli.ErrorMessage(err) - return 1 + successful := false + aborted := false + for p := range core.DownloadStreamEpisode( + streamEp, + targetChapter, + 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 { + CliErrorMessage(p.Error) + return 1 + } + if p.Success { + successful = true + } else if p.Aborted { + aborted = true + } else { + CliDownloadProgress(p.Progress, p.Rate, p.Delaying, p.Waiting, p.Retries, p.Title) + } } fmt.Print("\n") - return 0 + if aborted { + fmt.Print("\nAborted. ") + return 130 + } else if !successful { + CliErrorMessage(&GenericDownloadError{}) + return 1 + } else { return 0 } } 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") } -func CliShowFormat(format core.VideoFormat) { - fmt.Printf("Format: %v\n", format.Name) -} - -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") @@ -230,15 +276,7 @@ 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) { +func CliErrorMessage(err error) { fmt.Print("\n") fmt.Println("An error occured:", err) } 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/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/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 dfb8e75..f41a9ac 100644 --- a/core/gtv_api.go +++ b/core/gtv_api.go @@ -3,12 +3,39 @@ package core import ( + "encoding/json" + "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" +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"}, @@ -31,3 +58,215 @@ var ApiHeadersMetaAdditional = http.Header{ var ApiHeadersVideoAdditional = http.Header{ "Accept": {"*/*"}, } + +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, "") + // 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 + }) + 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 +} + +func DownloadStreamEpisode( + 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) + } + 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: &DownloadInfoFileReadError{}}) + 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 := 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{Aborted: true, 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..436e7d5 100644 --- a/core/gtv_common.go +++ b/core/gtv_common.go @@ -3,37 +3,53 @@ 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 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"` } func ParseGtvVideoUrl(url string) (GtvVideo, error) { video := GtvVideo{} match := videoUrlRegex.FindStringSubmatch(url) - if match == nil || len(match) < 2 { - return video, errors.New("Could not parse URL " + url) + if len(match) < 2 { + return video, &GtvVideoUrlParseError{Url: url} } - video.Class = match[1] + video.Category = match[1] video.Id = match[2] + if video.Category != "streams" { + return video, &VideoCategoryUnsupportedError{Category: video.Category} + } return video, nil } -type VideoFormat struct { - Name string `json:"format"` - Url string `json:"url"` -} +// type ChunkList struct { BaseUrl string @@ -59,3 +75,58 @@ func (cl *ChunkList) Cut(from time.Duration, to time.Duration) ChunkList { ChunkDuration: cl.ChunkDuration, } } + +// + +type StreamEpisode struct { + 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) { + 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) 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" + } +} 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 deleted file mode 100644 index 949e33f..0000000 --- a/core/interface.go +++ /dev/null @@ -1,8 +0,0 @@ -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() -} diff --git a/build-cli.sh b/release-cli.sh similarity index 78% rename from build-cli.sh rename to release-cli.sh index 75a130f..04f6958 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" @@ -19,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