Compare commits
No commits in common. "main" and "v2.0.1" have entirely different histories.
12 changed files with 492 additions and 554 deletions
147
README.md
147
README.md
|
@ -1,10 +1,9 @@
|
||||||
|
|
||||||
Definitely not an unofficial commandline downloader for https://gronkh.tv
|
<details><summary>what could it be?</summary>
|
||||||
|
|
||||||
## Compatibility
|
<br>
|
||||||
|
|
||||||
This tool is only compatible with recent Linux-based operating systems.
|
Definetly not a commandline downloader for https://gronkh.tv risen from the dead.
|
||||||
To run it on Windows, make use of WSL.
|
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
|
@ -12,98 +11,136 @@ To run it on Windows, make use of WSL.
|
||||||
- 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 / Limitations
|
## Known Issues
|
||||||
|
|
||||||
- Downloads are **capped to 10 Mbyte/s by default** and buffering is simulated to pre-empt IP blocking due to API rate-limiting
|
- You may get a "Windows Defender SmartScreen prevented an unrecognized app from starting" warning when running a new version for the first time
|
||||||
- Because of the length of video chunks, **start- and stop-timestamps are inaccurate** (± 8 seconds)
|
- Downloads are capped to 10 Mbyte/s and buffering is simulated to pre-empt IP blocking due to API ratelimiting
|
||||||
- **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:
|
- Start- and stop-timestamps are not very accurate (± 8 seconds)
|
||||||
`ffmpeg -i video.ts -acodec copy -vcodec copy video.mkv`
|
- 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).
|
||||||
|
|
||||||
## Download / Installation
|
## Download / Installation
|
||||||
|
|
||||||
New versions will appear under [Releases](https://github.com/ChaoticByte/lurch-dl/releases).
|
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/...
|
||||||
Just download the application and run it via your favourite terminal emulator.
|
|
||||||
|
|
||||||
> Note: **You may have to mark the file as executable before being able to run it.**
|
On Linux, you may have to mark the file as executable before being able to run it.
|
||||||
|
|
||||||
## Usage
|
## Cli 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
|
||||||
> Note: This tool runs entirely on the command line.
|
[-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
|
||||||
|
```
|
||||||
|
|
||||||
### Examples
|
### Examples
|
||||||
|
|
||||||
Download a video in its best available format:
|
Download a video in its best available format (Windows):
|
||||||
|
|
||||||
```
|
```
|
||||||
./lurch-dl --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 ...
|
Title: GTV0777, 2023-11-09 - DIESER STREAM IST ILLEGAL UND SOLLTE VERBOTEN WERDEN!! ⭐ ️ 247 auf @GronkhTV ⭐ ️ !comic !archiv !a
|
||||||
Format: 1080p60
|
Format: 1080p60
|
||||||
Output: GTV0777, 2023-11-09 - DIESER STREAM IST [...].ts
|
Downloaded 0.43% at 10.00 MB/s
|
||||||
|
...
|
||||||
Downloaded 0.32% at 10.00 MB/s ...
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Continue a download:
|
Continue a download (Windows):
|
||||||
|
|
||||||
```
|
```
|
||||||
./lurch-dl --url https://gronkh.tv/streams/777 --continue
|
.\lurch-dl.exe --url https://gronkh.tv/streams/777 --continue
|
||||||
```
|
|
||||||
|
|
||||||
Download a specific chapter:
|
Title: GTV0777, 2023-11-09 - DIESER STREAM IST ILLEGAL UND SOLLTE VERBOTEN WERDEN!! ⭐ ️ 247 auf @GronkhTV ⭐ ️ !comic !archiv !a
|
||||||
|
|
||||||
```
|
|
||||||
./lurch-dl --url https://gronkh.tv/streams/777 --chapter 2
|
|
||||||
|
|
||||||
Title: GTV0777, 2023-11-09 - DIESER STREAM IST ILLEGAL UND ...
|
|
||||||
Format: 1080p60
|
Format: 1080p60
|
||||||
Chapter: 2. Alan Wake II
|
Downloaded 0.68% at 10.00 MB/s
|
||||||
Output: GTV0777 - 2. Alan Wake II.ts
|
...
|
||||||
|
|
||||||
Downloaded 0.33% at 4.28 MB/s ...
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Specify a start- and stop-timestamp:
|
List all chapters (Windows):
|
||||||
|
|
||||||
```
|
```
|
||||||
./lurch-dl --url https://gronkh.tv/streams/777 --start 5h6m41s --stop 5h6m58s
|
.\lurch-dl.exe --url https://gronkh.tv/streams/777 --list-chapters
|
||||||
```
|
|
||||||
|
|
||||||
List all available formats for a video:
|
GTV0777, 2023-11-09 - DIESER STREAM IST ILLEGAL UND SOLLTE VERBOTEN WERDEN!! ⭐ ️ 247 auf @GronkhTV ⭐ ️ !comic !archiv !a
|
||||||
|
|
||||||
```
|
|
||||||
./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 the video in a specific format:
|
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):
|
||||||
|
|
||||||
|
```
|
||||||
|
./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:
|
Specify a filename (Windows):
|
||||||
|
|
||||||
```
|
```
|
||||||
./lurch-dl --url https://gronkh.tv/streams/777 --output Stream777.ts
|
.\lurch-dl.exe --url https://gronkh.tv/streams/777 --output Stream777.ts
|
||||||
|
...
|
||||||
```
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
2
VERSION
2
VERSION
|
@ -1 +1 @@
|
||||||
2.1.2
|
2.0.1
|
|
@ -1,5 +1,7 @@
|
||||||
#!/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"
|
||||||
|
@ -17,6 +19,9 @@ 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
|
1
cli/VERSION
Normal file
1
cli/VERSION
Normal file
|
@ -0,0 +1 @@
|
||||||
|
2.0.0
|
205
cli/cli.go
205
cli/cli.go
|
@ -3,6 +3,7 @@
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
@ -37,31 +38,19 @@ func XtermSetTitle(title string) {
|
||||||
|
|
||||||
// Commandline
|
// Commandline
|
||||||
|
|
||||||
var Arguments struct {
|
type CliOnlyArguments 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:"-"`
|
||||||
VideoInfo bool `json:"-"`
|
ListChapters 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
|
||||||
[--info] Show video info (chapters, formats, length, ...)
|
[--list-chapters] List chapters and exit
|
||||||
|
[--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
|
||||||
|
@ -82,63 +71,71 @@ lurch-dl --url string The url to the video
|
||||||
Version: ` + core.Version)
|
Version: ` + core.Version)
|
||||||
}
|
}
|
||||||
|
|
||||||
func CliParseArguments() error {
|
func CliParseArguments() (core.Arguments, CliOnlyArguments, error) {
|
||||||
var err error
|
var err error
|
||||||
var ratelimitMbs float64
|
var ratelimitMbs float64
|
||||||
flag.BoolVar(&Arguments.Help, "h", false, "")
|
a := core.Arguments{}
|
||||||
flag.BoolVar(&Arguments.Help, "help", false, "")
|
c := CliOnlyArguments{}
|
||||||
flag.BoolVar(&Arguments.VideoInfo, "info", false, "")
|
flag.BoolVar(&c.Help, "h", false, "")
|
||||||
flag.StringVar(&Arguments.Url, "url", "", "")
|
flag.BoolVar(&c.Help, "help", false, "")
|
||||||
flag.IntVar(&Arguments.ChapterNum, "chapter", 0, "") // 0 -> chapter idx -1 -> complete stream
|
flag.BoolVar(&c.ListChapters, "list-chapters", false, "")
|
||||||
flag.StringVar(&Arguments.FormatName, "format", "auto", "")
|
flag.BoolVar(&c.ListFormats, "list-formats", false, "")
|
||||||
flag.StringVar(&Arguments.OutputFile, "output", "", "")
|
flag.StringVar(&a.Url, "url", "", "")
|
||||||
flag.StringVar(&Arguments.TimestampStart, "start", "", "")
|
flag.IntVar(&c.ChapterNum, "chapter", 0, "") // 0 -> chapter idx -1 -> complete stream
|
||||||
flag.StringVar(&Arguments.TimestampStop, "stop", "", "")
|
flag.StringVar(&a.FormatName, "format", "auto", "")
|
||||||
flag.BoolVar(&Arguments.Overwrite, "overwrite", false, "")
|
flag.StringVar(&a.OutputFile, "output", "", "")
|
||||||
flag.BoolVar(&Arguments.ContinueDl, "continue", false, "")
|
flag.StringVar(&a.TimestampStart, "start", "", "")
|
||||||
|
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()
|
||||||
Arguments.Video, err = core.ParseGtvVideoUrl(Arguments.Url)
|
a.Video, err = core.ParseGtvVideoUrl(a.Url)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return a, c, err
|
||||||
}
|
}
|
||||||
if Arguments.TimestampStart == "" {
|
if a.Video.Class != "streams" {
|
||||||
Arguments.StartDuration = -1
|
return a, c, errors.New("video category '" + a.Video.Class + "' not supported")
|
||||||
|
}
|
||||||
|
if a.TimestampStart == "" {
|
||||||
|
a.StartDuration = -1
|
||||||
} else {
|
} else {
|
||||||
Arguments.StartDuration, err = time.ParseDuration(Arguments.TimestampStart)
|
a.StartDuration, err = time.ParseDuration(a.TimestampStart)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return a, c, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if Arguments.TimestampStop == "" {
|
if a.TimestampStop == "" {
|
||||||
Arguments.StopDuration = -1
|
a.StopDuration = -1
|
||||||
} else {
|
} else {
|
||||||
Arguments.StopDuration, err = time.ParseDuration(Arguments.TimestampStop)
|
a.StopDuration, err = time.ParseDuration(a.TimestampStop)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return a, c, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Arguments.Ratelimit = ratelimitMbs * 1_000_000.0 // MB/s -> B/s
|
a.ChapterIdx = c.ChapterNum - 1
|
||||||
if Arguments.Ratelimit <= 0 {
|
a.Ratelimit = ratelimitMbs * 1_000_000.0 // MB/s -> B/s
|
||||||
return &GenericCliAgumentError{Msg: "the value of --max-rate must be greater than 0"}
|
if a.Ratelimit <= 0 {
|
||||||
|
return a, c, errors.New("the value of --max-rate must be greater than 0")
|
||||||
}
|
}
|
||||||
return err
|
return a, c, 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
|
||||||
err := CliParseArguments()
|
args, cliArgs, err := CliParseArguments()
|
||||||
if Arguments.Help {
|
if cliArgs.Help {
|
||||||
CliShowHelp()
|
CliShowHelp()
|
||||||
return 0
|
return 0
|
||||||
} else if Arguments.Url == "" || err != nil {
|
} else if args.Url == "" || err != nil {
|
||||||
CliShowHelp()
|
CliShowHelp()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
CliErrorMessage(err)
|
cli.ErrorMessage(err)
|
||||||
}
|
}
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
@ -148,116 +145,74 @@ func CliRun() int {
|
||||||
if CliXtermTitle {
|
if CliXtermTitle {
|
||||||
XtermSetTitle("lurch-dl - Fetching video metadata ...")
|
XtermSetTitle("lurch-dl - Fetching video metadata ...")
|
||||||
}
|
}
|
||||||
streamEp, err := core.GetStreamEpisode(Arguments.Video.Id)
|
streamEp, err := core.GetStreamEpisode(args.Video.Id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
CliErrorMessage(err)
|
cli.ErrorMessage(err)
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
fmt.Print("\n")
|
fmt.Print("\n")
|
||||||
fmt.Printf("Title: %s\n", streamEp.Title)
|
fmt.Println(streamEp.Title)
|
||||||
// Check and list chapters/formats and exit
|
// Check and list chapters/formats and exit
|
||||||
targetChapter, err := streamEp.GetChapterByNumber(Arguments.ChapterNum)
|
if args.ChapterIdx >= 0 {
|
||||||
if err != nil {
|
if args.ChapterIdx >= len(streamEp.Chapters) {
|
||||||
CliErrorMessage(err)
|
cli.ErrorMessage(&core.ChapterNotFoundError{ChapterNum: cliArgs.ChapterNum})
|
||||||
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")
|
fmt.Print("\n")
|
||||||
} else {
|
|
||||||
fmt.Println("Tags: -")
|
|
||||||
}
|
|
||||||
CliAvailableFormats(streamEp.Formats)
|
|
||||||
CliAvailableChapters(streamEp.Chapters)
|
CliAvailableChapters(streamEp.Chapters)
|
||||||
|
}
|
||||||
|
if cliArgs.ListFormats {
|
||||||
|
fmt.Print("\n")
|
||||||
|
CliAvailableFormats(streamEp.Formats)
|
||||||
|
}
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
format, err := streamEp.GetFormatByName(Arguments.FormatName)
|
format, err := streamEp.GetFormatByName(args.FormatName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
CliErrorMessage(err)
|
cli.ErrorMessage(err)
|
||||||
CliAvailableFormats(streamEp.Formats)
|
CliAvailableFormats(streamEp.Formats)
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
fmt.Printf("Format: %v\n", format.Name)
|
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))
|
||||||
|
}
|
||||||
// We already set the output file correctly so we can output it
|
// We already set the output file correctly so we can output it
|
||||||
if Arguments.OutputFile == "" {
|
if args.OutputFile == "" {
|
||||||
Arguments.OutputFile = streamEp.GetProposedFilename(targetChapter)
|
args.OutputFile = streamEp.GetProposedFilename(args.ChapterIdx)
|
||||||
}
|
}
|
||||||
// Start Download
|
// Start Download
|
||||||
fmt.Printf("Output: %v\n", Arguments.OutputFile)
|
cli.InfoMessage(fmt.Sprintf("Output: %v", args.OutputFile))
|
||||||
fmt.Print("\n")
|
fmt.Print("\n")
|
||||||
successful := false
|
if err = streamEp.Download(args, &cli, make(chan os.Signal, 1)); err != nil {
|
||||||
aborted := false
|
cli.ErrorMessage(err)
|
||||||
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")
|
||||||
if aborted {
|
return 0
|
||||||
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) {
|
||||||
fmt.Println("Chapters:")
|
fmt.Println("Chapters:")
|
||||||
for _, f := range 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) {
|
func CliAvailableFormats(formats []core.VideoFormat) {
|
||||||
fmt.Print("Formats: ")
|
fmt.Println("Available formats:")
|
||||||
for i, f := range formats {
|
for _, f := range formats {
|
||||||
if i == 0 {
|
fmt.Println(" - " + f.Name)
|
||||||
fmt.Print(f.Name)
|
|
||||||
} else {
|
|
||||||
fmt.Print(", ", f.Name)
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
fmt.Print("\n")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func CliDownloadProgress(progress float32, rate float64, delaying bool, waiting bool, retries int, title string) {
|
type Cli struct{}
|
||||||
|
|
||||||
|
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")
|
||||||
|
@ -276,7 +231,15 @@ func CliDownloadProgress(progress float32, rate float64, delaying bool, waiting
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func CliErrorMessage(err error) {
|
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.Print("\n")
|
||||||
fmt.Println("An error occured:", err)
|
fmt.Println("An error occured:", err)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,15 +0,0 @@
|
||||||
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"
|
|
||||||
}
|
|
19
core/args.go
Normal file
19
core/args.go
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
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:"-"`
|
||||||
|
}
|
|
@ -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,27 +51,5 @@ 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"
|
|
||||||
}
|
}
|
||||||
|
|
239
core/gtv_api.go
239
core/gtv_api.go
|
@ -3,39 +3,12 @@
|
||||||
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"},
|
||||||
|
@ -58,215 +31,3 @@ 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})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -3,53 +3,37 @@
|
||||||
package core
|
package core
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"errors"
|
||||||
"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 {
|
||||||
Category string `json:"category"`
|
Class string `json:"class"`
|
||||||
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 len(match) < 2 {
|
if match == nil || len(match) < 2 {
|
||||||
return video, &GtvVideoUrlParseError{Url: url}
|
return video, errors.New("Could not parse URL " + url)
|
||||||
}
|
}
|
||||||
video.Category = match[1]
|
video.Class = 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
|
||||||
|
@ -75,58 +59,3 @@ 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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
252
core/gtv_stream.go
Normal file
252
core/gtv_stream.go
Normal file
|
@ -0,0 +1,252 @@
|
||||||
|
// 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
|
||||||
|
}
|
8
core/interface.go
Normal file
8
core/interface.go
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
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()
|
||||||
|
}
|
Loading…
Add table
Add a link
Reference in a new issue