Compare commits

...

13 commits
v2.0.0 ... main

12 changed files with 545 additions and 482 deletions

147
README.md
View file

@ -1,9 +1,10 @@
<details><summary>what could it be?</summary> Definitely not an unofficial commandline downloader for https://gronkh.tv
<br> ## 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 ## 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 - Specify a start- and stop-timestamp to download only a portion of the video
- Download a specific chapter - Download a specific chapter
- Continuable Downloads - 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 by default** and buffering is simulated to pre-empt IP blocking due to API rate-limiting
- Downloads are capped to 10 Mbyte/s and buffering is simulated to pre-empt IP blocking due to API ratelimiting - Because of the length of video chunks, **start- and stop-timestamps are inaccurate** (± 8 seconds)
- Start- and stop-timestamps are not very accurate (± 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:
- 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` `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).
## Download / Installation ## 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.
``` Run `lurch-dl --help` to see available options.
lurch-dl --url string The url to the video
[-h --help] Show this help and exit > Note: This tool runs entirely on the command line.
[--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
```
### Examples ### 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 Title: GTV0777, 2023-11-09 - DIESER STREAM IST ILLEGAL UND ...
Format: 1080p60 Format: 1080p60
Downloaded 0.43% at 10.00 MB/s 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 ./lurch-dl --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 Download a specific chapter:
```
./lurch-dl --url https://gronkh.tv/streams/777 --chapter 2
Title: GTV0777, 2023-11-09 - DIESER STREAM IST ILLEGAL UND ...
Format: 1080p60 Format: 1080p60
Downloaded 0.68% at 10.00 MB/s Chapter: 2. Alan Wake II
... Output: GTV0777 - 2. Alan Wake II.ts
Downloaded 0.33% at 4.28 MB/s ...
``` ```
List all chapters (Windows): Specify a start- and stop-timestamp:
``` ```
.\lurch-dl.exe --url https://gronkh.tv/streams/777 --list-chapters ./lurch-dl --url https://gronkh.tv/streams/777 --start 5h6m41s --stop 5h6m58s
```
GTV0777, 2023-11-09 - DIESER STREAM IST ILLEGAL UND SOLLTE VERBOTEN WERDEN!! ⭐ 247 auf @GronkhTV !comic !archiv !a List all available formats for a video:
```
./lurch-dl --url https://gronkh.tv/streams/777 --info
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: Chapters:
1 0s Just Chatting 1 0s Just Chatting
2 2h53m7s Alan Wake II 2 2h53m7s Alan Wake II
3 9h35m0s Just Chatting 3 9h35m0s Just Chatting
``` ```
Download a specific chapter (Windows): Download the video in a specific format:
```
.\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):
```
./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
Available formats:
- 1080p60
- 720p
- 360p
```
Download the video in a specific format (Linux):
``` ```
./lurch-dl --url https://gronkh.tv/streams/777 --format 720p ./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 Format: 720p
Downloaded 0.32% at 10.00 MB/s [...]
...
``` ```
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
...
``` ```
</details>

View file

@ -1 +1 @@
2.0.0 2.1.2

View file

@ -1 +0,0 @@
2.0.0

View file

@ -3,7 +3,6 @@
package main package main
import ( import (
"errors"
"flag" "flag"
"fmt" "fmt"
"os" "os"
@ -38,19 +37,31 @@ func XtermSetTitle(title string) {
// Commandline // Commandline
type CliOnlyArguments struct { 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:"-"` Help bool `json:"-"`
ListChapters bool `json:"-"` VideoInfo bool `json:"-"`
ListFormats bool `json:"-"` ListFormats bool `json:"-"`
ChapterNum int `json:"chapter_num"` ChapterNum int `json:"chapter_num"`
// Parsed
Video core.GtvVideo `json:"-"`
StartDuration time.Duration `json:"-"`
StopDuration time.Duration `json:"-"`
Ratelimit float64 `json:"-"`
} }
func CliShowHelp() { func CliShowHelp() {
fmt.Println(` fmt.Println(`
lurch-dl --url string The url to the video lurch-dl --url string The url to the video
[-h --help] Show this help and exit [-h --help] Show this help and exit
[--list-chapters] List chapters and exit [--info] Show video info (chapters, formats, length, ...)
[--list-formats] List available formats and exit
[--chapter int] The chapter you want to download [--chapter int] The chapter you want to download
The calculated start and stop timestamps can be The calculated start and stop timestamps can be
overwritten by --start and --stop overwritten by --start and --stop
@ -71,71 +82,63 @@ lurch-dl --url string The url to the video
Version: ` + core.Version) Version: ` + core.Version)
} }
func CliParseArguments() (core.Arguments, CliOnlyArguments, error) { func CliParseArguments() error {
var err error var err error
var ratelimitMbs float64 var ratelimitMbs float64
a := core.Arguments{} flag.BoolVar(&Arguments.Help, "h", false, "")
c := CliOnlyArguments{} flag.BoolVar(&Arguments.Help, "help", false, "")
flag.BoolVar(&c.Help, "h", false, "") flag.BoolVar(&Arguments.VideoInfo, "info", false, "")
flag.BoolVar(&c.Help, "help", false, "") flag.StringVar(&Arguments.Url, "url", "", "")
flag.BoolVar(&c.ListChapters, "list-chapters", false, "") flag.IntVar(&Arguments.ChapterNum, "chapter", 0, "") // 0 -> chapter idx -1 -> complete stream
flag.BoolVar(&c.ListFormats, "list-formats", false, "") flag.StringVar(&Arguments.FormatName, "format", "auto", "")
flag.StringVar(&a.Url, "url", "", "") flag.StringVar(&Arguments.OutputFile, "output", "", "")
flag.IntVar(&c.ChapterNum, "chapter", 0, "") // 0 -> chapter idx -1 -> complete stream flag.StringVar(&Arguments.TimestampStart, "start", "", "")
flag.StringVar(&a.FormatName, "format", "auto", "") flag.StringVar(&Arguments.TimestampStop, "stop", "", "")
flag.StringVar(&a.OutputFile, "output", "", "") flag.BoolVar(&Arguments.Overwrite, "overwrite", false, "")
flag.StringVar(&a.TimestampStart, "start", "", "") flag.BoolVar(&Arguments.ContinueDl, "continue", false, "")
flag.StringVar(&a.TimestampStop, "stop", "", "")
flag.BoolVar(&a.Overwrite, "overwrite", false, "")
flag.BoolVar(&a.ContinueDl, "continue", false, "")
flag.Float64Var(&ratelimitMbs, "max-rate", 10.0, "") flag.Float64Var(&ratelimitMbs, "max-rate", 10.0, "")
flag.Parse() flag.Parse()
a.Video, err = core.ParseGtvVideoUrl(a.Url) Arguments.Video, err = core.ParseGtvVideoUrl(Arguments.Url)
if err != nil { if err != nil {
return a, c, err return err
} }
if a.Video.Class != "streams" { if Arguments.TimestampStart == "" {
return a, c, errors.New("video category '" + a.Video.Class + "' not supported") Arguments.StartDuration = -1
}
if a.TimestampStart == "" {
a.StartDuration = -1
} else { } else {
a.StartDuration, err = time.ParseDuration(a.TimestampStart) Arguments.StartDuration, err = time.ParseDuration(Arguments.TimestampStart)
if err != nil { if err != nil {
return a, c, err return err
} }
} }
if a.TimestampStop == "" { if Arguments.TimestampStop == "" {
a.StopDuration = -1 Arguments.StopDuration = -1
} else { } else {
a.StopDuration, err = time.ParseDuration(a.TimestampStop) Arguments.StopDuration, err = time.ParseDuration(Arguments.TimestampStop)
if err != nil { if err != nil {
return a, c, err return err
} }
} }
a.ChapterIdx = c.ChapterNum - 1 Arguments.Ratelimit = ratelimitMbs * 1_000_000.0 // MB/s -> B/s
a.Ratelimit = ratelimitMbs * 1_000_000.0 // MB/s -> B/s if Arguments.Ratelimit <= 0 {
if a.Ratelimit <= 0 { return &GenericCliAgumentError{Msg: "the value of --max-rate must be greater than 0"}
return a, c, errors.New("the value of --max-rate must be greater than 0")
} }
return a, c, err return err
} }
// Main // Main
func CliRun() int { func CliRun() int {
cli := Cli{}
defer fmt.Print("\n") defer fmt.Print("\n")
// cli arguments & help text // cli arguments & help text
flag.Usage = CliShowHelp flag.Usage = CliShowHelp
args, cliArgs, err := CliParseArguments() err := CliParseArguments()
if cliArgs.Help { if Arguments.Help {
CliShowHelp() CliShowHelp()
return 0 return 0
} else if args.Url == "" || err != nil { } else if Arguments.Url == "" || err != nil {
CliShowHelp() CliShowHelp()
if err != nil { if err != nil {
cli.ErrorMessage(err) CliErrorMessage(err)
} }
return 1 return 1
} }
@ -145,50 +148,94 @@ func CliRun() int {
if CliXtermTitle { if CliXtermTitle {
XtermSetTitle("lurch-dl - Fetching video metadata ...") XtermSetTitle("lurch-dl - Fetching video metadata ...")
} }
streamEp, err := core.GetStreamEpisode(args.Video.Id) streamEp, err := core.GetStreamEpisode(Arguments.Video.Id)
if err != nil { if err != nil {
cli.ErrorMessage(err) CliErrorMessage(err)
return 1 return 1
} }
fmt.Print("\n") fmt.Print("\n")
fmt.Println(streamEp.Title) fmt.Printf("Title: %s\n", streamEp.Title)
// Check and list chapters/formats and exit // Check and list chapters/formats and exit
if args.ChapterIdx >= 0 { targetChapter, err := streamEp.GetChapterByNumber(Arguments.ChapterNum)
if args.ChapterIdx >= len(streamEp.Chapters) { if err != nil {
cli.ErrorMessage(&core.ChapterNotFoundError{ChapterNum: cliArgs.ChapterNum}) CliErrorMessage(err)
CliAvailableChapters(streamEp.Chapters) CliAvailableChapters(streamEp.Chapters)
return 1 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)
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)
} }
if cliArgs.ListChapters || cliArgs.ListFormats {
if cliArgs.ListChapters {
fmt.Print("\n")
CliAvailableChapters(streamEp.Chapters)
} }
if cliArgs.ListFormats {
fmt.Print("\n") fmt.Print("\n")
} else {
fmt.Println("Tags: -")
}
CliAvailableFormats(streamEp.Formats) CliAvailableFormats(streamEp.Formats)
} CliAvailableChapters(streamEp.Chapters)
return 0 return 0
} }
format, err := streamEp.GetFormatByName(args.FormatName) format, err := streamEp.GetFormatByName(Arguments.FormatName)
if err != nil { if err != nil {
cli.ErrorMessage(err) CliErrorMessage(err)
CliAvailableFormats(streamEp.Formats) CliAvailableFormats(streamEp.Formats)
return 1 return 1
} }
CliShowFormat(format) fmt.Printf("Format: %v\n", format.Name)
if args.ChapterIdx >= 0 { // We already set the output file correctly so we can output it
cli.InfoMessage(fmt.Sprintf("Chapter: %v. %v", cliArgs.ChapterNum, streamEp.Chapters[args.ChapterIdx].Title)) if Arguments.OutputFile == "" {
Arguments.OutputFile = streamEp.GetProposedFilename(targetChapter)
} }
// Start Download // Start Download
fmt.Printf("Output: %v\n", Arguments.OutputFile)
fmt.Print("\n") fmt.Print("\n")
if err = streamEp.Download(args, &cli, make(chan os.Signal, 1)); err != nil { successful := false
cli.ErrorMessage(err) 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 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") 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) { func CliAvailableChapters(chapters []core.Chapter) {
@ -199,19 +246,18 @@ func CliAvailableChapters(chapters []core.Chapter) {
} }
func CliAvailableFormats(formats []core.VideoFormat) { func CliAvailableFormats(formats []core.VideoFormat) {
fmt.Println("Available formats:") fmt.Print("Formats: ")
for _, f := range formats { for i, f := range formats {
fmt.Println(" - " + f.Name) 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 CliDownloadProgress(progress float32, rate float64, delaying bool, waiting bool, retries int, title string) {
func (cli *Cli) DownloadProgress(progress float32, rate float64, delaying bool, waiting bool, retries int, title string) {
if retries > 0 { if retries > 0 {
if retries == 1 { if retries == 1 {
fmt.Print("\n") fmt.Print("\n")
@ -230,15 +276,7 @@ func (cli *Cli) DownloadProgress(progress float32, rate float64, delaying bool,
} }
} }
func (cli *Cli) Aborted() { func CliErrorMessage(err error) {
fmt.Print("\nAborted. ")
}
func (cli *Cli) InfoMessage(msg string) {
fmt.Println(msg)
}
func (cli *Cli) ErrorMessage(err error) {
fmt.Print("\n") fmt.Print("\n")
fmt.Println("An error occured:", err) fmt.Println("An error occured:", err)
} }

15
cli/errors.go Normal file
View file

@ -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"
}

View file

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

View file

@ -35,7 +35,7 @@ type FileExistsError struct {
} }
func (err *FileExistsError) Error() string { 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 { type FormatNotFoundError struct {
@ -43,7 +43,7 @@ type FormatNotFoundError struct {
} }
func (err *FormatNotFoundError) Error() string { func (err *FormatNotFoundError) Error() string {
return "Format " + err.FormatName + " is not available." return "format " + err.FormatName + " is not available"
} }
type ChapterNotFoundError struct { type ChapterNotFoundError struct {
@ -51,5 +51,27 @@ type ChapterNotFoundError struct {
} }
func (err *ChapterNotFoundError) Error() string { 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"
} }

View file

@ -3,12 +3,39 @@
package core package core
import ( import (
"encoding/json"
"fmt"
"io"
"iter"
"net/http" "net/http"
"os"
"os/signal"
"sort"
"strconv"
"strings"
"time"
) )
const MaxRetries = 5
// The following two values are used to simulate buffering
const RatelimitDelay = 2.0 // in Seconds; How long to delay the next chunk download.
const RatelimitDelayAfter = 5.0 // in Seconds; Delay the next chunk download after this duration.
const ApiBaseurlStreamEpisodeInfo = "https://api.gronkh.tv/v1/video/info?episode=%s" const ApiBaseurlStreamEpisodeInfo = "https://api.gronkh.tv/v1/video/info?episode=%s"
const ApiBaseurlStreamEpisodePlInfo = "https://api.gronkh.tv/v1/video/playlist?episode=%s" const ApiBaseurlStreamEpisodePlInfo = "https://api.gronkh.tv/v1/video/playlist?episode=%s"
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{ var ApiHeadersBase = http.Header{
"User-Agent": {"Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/119.0"}, "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"}, "Accept-Language": {"de,en-US;q=0.7,en;q=0.3"},
@ -31,3 +58,215 @@ var ApiHeadersMetaAdditional = http.Header{
var ApiHeadersVideoAdditional = http.Header{ var ApiHeadersVideoAdditional = http.Header{
"Accept": {"*/*"}, "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})
}
}

View file

@ -3,37 +3,53 @@
package core package core
import ( import (
"errors" "fmt"
"regexp" "regexp"
"time" "time"
) )
// The following two values are used to simulate buffering
const RatelimitDelay = 2.0 // in Seconds; How long to delay the next chunk download.
const RatelimitDelayAfter = 5.0 // in Seconds; Delay the next chunk download after this duration.
var videoUrlRegex = regexp.MustCompile(`gronkh\.tv\/([a-z]+)\/([0-9]+)`) var videoUrlRegex = regexp.MustCompile(`gronkh\.tv\/([a-z]+)\/([0-9]+)`)
//
type 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 { type GtvVideo struct {
Class string `json:"class"` Category string `json:"category"`
Id string `json:"id"` Id string `json:"id"`
} }
func ParseGtvVideoUrl(url string) (GtvVideo, error) { func ParseGtvVideoUrl(url string) (GtvVideo, error) {
video := GtvVideo{} video := GtvVideo{}
match := videoUrlRegex.FindStringSubmatch(url) match := videoUrlRegex.FindStringSubmatch(url)
if match == nil || len(match) < 2 { if len(match) < 2 {
return video, errors.New("Could not parse URL " + url) return video, &GtvVideoUrlParseError{Url: url}
} }
video.Class = match[1] video.Category = match[1]
video.Id = match[2] video.Id = match[2]
if video.Category != "streams" {
return video, &VideoCategoryUnsupportedError{Category: video.Category}
}
return video, nil return video, nil
} }
type VideoFormat struct { //
Name string `json:"format"`
Url string `json:"url"`
}
type ChunkList struct { type ChunkList struct {
BaseUrl string BaseUrl string
@ -59,3 +75,58 @@ func (cl *ChunkList) Cut(from time.Duration, to time.Duration) ChunkList {
ChunkDuration: cl.ChunkDuration, 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"
}
}

View file

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

View file

@ -1,8 +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()
}

View file

@ -1,7 +1,5 @@
#!/usr/bin/env bash #!/usr/bin/env bash
setopt -e
WORKDIR="./cli" WORKDIR="./cli"
CORE_DIR="./core" CORE_DIR="./core"
OUTPUT_DIR="../dist" OUTPUT_DIR="../dist"
@ -19,9 +17,6 @@ NAME_BASE="lurch-dl_v${VERSION}"
echo "Building ${NAME_BASE} into ${OUTPUT_DIR}" 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=386 OUTPUT_FILE=${NAME_BASE}_linux_i386 gobuild
GOOS=linux GOARCH=amd64 OUTPUT_FILE=${NAME_BASE}_linux_amd64 gobuild GOOS=linux GOARCH=amd64 OUTPUT_FILE=${NAME_BASE}_linux_amd64 gobuild
GOOS=linux GOARCH=arm OUTPUT_FILE=${NAME_BASE}_linux_arm gobuild GOOS=linux GOARCH=arm OUTPUT_FILE=${NAME_BASE}_linux_arm gobuild