Added project files
This commit is contained in:
parent
84547f6dcb
commit
94a5aff260
23 changed files with 1042 additions and 1 deletions
30
.gitignore
vendored
Normal file
30
.gitignore
vendored
Normal file
|
@ -0,0 +1,30 @@
|
|||
# If you prefer the allow list template instead of the deny list, see community template:
|
||||
# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
|
||||
#
|
||||
# Binaries for programs and plugins
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
|
||||
# Test binary, built with `go test -c`
|
||||
*.test
|
||||
|
||||
# Output of the go coverage tool, specifically when used with LiteIDE
|
||||
*.out
|
||||
|
||||
# Dependency directories (remove the comment below to include it)
|
||||
# vendor/
|
||||
|
||||
# Go workspace file
|
||||
go.work
|
||||
|
||||
# Build output
|
||||
lurch-dl
|
||||
dist/
|
||||
|
||||
# Media files
|
||||
*.ts
|
||||
*.dl-info
|
||||
/output
|
2
LICENSE
2
LICENSE
|
@ -1,6 +1,6 @@
|
|||
BSD 3-Clause License
|
||||
|
||||
Copyright (c) 2025, Julian Müller
|
||||
Copyright (c) 2025, Julian Müller (ChaoticByte)
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:
|
||||
|
|
146
README.md
Normal file
146
README.md
Normal file
|
@ -0,0 +1,146 @@
|
|||
|
||||
<details><summary>what could it be?</summary>
|
||||
|
||||
<br>
|
||||
|
||||
Definetly not a commandline downloader for https://gronkh.tv risen from the dead.
|
||||
|
||||
## Features
|
||||
|
||||
- Download [Stream-Episodes](https://gronkh.tv/streams/)
|
||||
- Specify a start- and stop-timestamp to download only a portion of the video
|
||||
- Download a specific chapter
|
||||
- Continuable Downloads
|
||||
|
||||
## Known Issues
|
||||
|
||||
- You may get a "Windows Defender SmartScreen prevented an unrecognized app from starting" warning when running a new version for the first time
|
||||
- Downloads are capped to 10 Mbyte/s and buffering is simulated to pre-empt IP blocking due to API ratelimiting
|
||||
- Start- and stop-timestamps are not very accurate (± 8 seconds)
|
||||
- Some videoplayers may have problems with the resulting file. To fix this, you can use ffmpeg to rewrite the video into a MKV-File: `ffmpeg -i video.ts -acodec copy -vcodec copy video.mkv`
|
||||
- Emojis and other Unicode characters don't get displayed properly in a Powershell Console
|
||||
|
||||
## Supported Platforms
|
||||
|
||||
Tested on Linux and Windows (64bit).
|
||||
|
||||
## 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/...
|
||||
|
||||
On Linux, you may have to mark the file as executable before being able to run it.
|
||||
|
||||
## Cli Usage
|
||||
|
||||
If you chose the cli variant of this software.
|
||||
|
||||
```
|
||||
lurch-dl --url string The url to the video
|
||||
[-h --help] Show this help and exit
|
||||
[--list-chapters] List chapters and exit
|
||||
[--list-formats] List available formats and exit
|
||||
[--chapter int] The chapter you want to download
|
||||
The calculated start and stop timestamps can be
|
||||
overwritten by --start and --stop
|
||||
default: -1 (disabled)
|
||||
[--format string] The desired video format
|
||||
default: auto
|
||||
[--output string] The output file. Will be determined automatically
|
||||
if omitted.
|
||||
[--start string] Define a video timestamp to start at, e.g. 12m34s
|
||||
[--stop string] Define a video timestamp to stop at, e.g. 1h23m45s
|
||||
[--continue] Continue the download if possible
|
||||
[--overwrite] Overwrite the output file if it already exists
|
||||
[--max-rate] The maximum download rate in MB/s - don't set this
|
||||
too high, you may run into a ratelimit and your
|
||||
IP address might get banned from the servers.
|
||||
default: 10.0
|
||||
```
|
||||
|
||||
### Examples
|
||||
|
||||
Download a video in its best available format (Windows):
|
||||
|
||||
```
|
||||
.\lurch-dl.exe --url https://gronkh.tv/streams/777
|
||||
|
||||
Title: GTV0777, 2023-11-09 - DIESER STREAM IST ILLEGAL UND SOLLTE VERBOTEN WERDEN!! ⭐ ️ 247 auf @GronkhTV ⭐ ️ !comic !archiv !a
|
||||
Format: 1080p60
|
||||
Downloaded 0.43% at 10.00 MB/s
|
||||
...
|
||||
```
|
||||
|
||||
Continue a download (Windows):
|
||||
|
||||
```
|
||||
.\lurch-dl.exe --url https://gronkh.tv/streams/777 --continue
|
||||
|
||||
Title: GTV0777, 2023-11-09 - DIESER STREAM IST ILLEGAL UND SOLLTE VERBOTEN WERDEN!! ⭐ ️ 247 auf @GronkhTV ⭐ ️ !comic !archiv !a
|
||||
Format: 1080p60
|
||||
Downloaded 0.68% at 10.00 MB/s
|
||||
...
|
||||
```
|
||||
|
||||
List all chapters (Windows):
|
||||
|
||||
```
|
||||
.\lurch-dl.exe --url https://gronkh.tv/streams/777 --list-chapters
|
||||
|
||||
GTV0777, 2023-11-09 - DIESER STREAM IST ILLEGAL UND SOLLTE VERBOTEN WERDEN!! ⭐ ️ 247 auf @GronkhTV ⭐ ️ !comic !archiv !a
|
||||
|
||||
Chapters:
|
||||
1 0s Just Chatting
|
||||
2 2h53m7s Alan Wake II
|
||||
3 9h35m0s Just Chatting
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
...
|
||||
```
|
||||
|
||||
Specify a filename (Windows):
|
||||
|
||||
```
|
||||
.\lurch-dl.exe --url https://gronkh.tv/streams/777 --output Stream777.ts
|
||||
...
|
||||
```
|
||||
|
||||
</details>
|
34
build-cli.sh
Executable file
34
build-cli.sh
Executable file
|
@ -0,0 +1,34 @@
|
|||
#!/usr/bin/env bash
|
||||
|
||||
setopt -e
|
||||
|
||||
WORKDIR="./cli"
|
||||
CORE_DIR="./core"
|
||||
OUTPUT_DIR="../dist"
|
||||
|
||||
function gobuild {
|
||||
printf -- "-> ${GOOS}\t${GOARCH}\t${OUTPUT_FILE} "
|
||||
go build -ldflags="-X 'main.Version=${VERSION}' -X 'github.com/ChaoticByte/lurch-dl/core.Version=${CORE_VERSION}'" -o "${OUTPUT_DIR}/${OUTPUT_FILE}" && printf "\t✔\n"
|
||||
}
|
||||
|
||||
read -r CORE_VERSION < "${CORE_DIR}/VERSION"
|
||||
|
||||
cd "${WORKDIR}"
|
||||
read -r VERSION < ./VERSION
|
||||
|
||||
NAME_BASE="lurchdl-cli_${VERSION}_core${CORE_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
|
||||
GOOS=linux GOARCH=arm64 OUTPUT_FILE=${NAME_BASE}_linux_arm64 gobuild
|
||||
|
||||
cd ..
|
||||
|
||||
printf -- "Creating tag cli${VERSION}_core${CORE_VERSION}"
|
||||
git tag -f "cli${VERSION}_core${CORE_VERSION}" && printf "\t\t✔\n"
|
1
cli/VERSION
Normal file
1
cli/VERSION
Normal file
|
@ -0,0 +1 @@
|
|||
2.0.1
|
244
cli/cli.go
Normal file
244
cli/cli.go
Normal file
|
@ -0,0 +1,244 @@
|
|||
// Copyright (c) 2025, Julian Müller (ChaoticByte)
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ChaoticByte/lurch-dl/core"
|
||||
)
|
||||
|
||||
// Global Variables
|
||||
var CliXtermTitle bool
|
||||
|
||||
//
|
||||
|
||||
func XtermDetectFeatures() {
|
||||
for _, entry := range os.Environ() {
|
||||
kv := strings.Split(entry, "=")
|
||||
if len(kv) > 1 && kv[0] == "TERM" {
|
||||
if strings.Contains(kv[1], "xterm") ||
|
||||
strings.Contains(kv[1], "rxvt") ||
|
||||
strings.Contains(kv[1], "alacritty") {
|
||||
CliXtermTitle = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func XtermSetTitle(title string) {
|
||||
fmt.Printf("\033]2;%s\007", title)
|
||||
}
|
||||
|
||||
// Commandline
|
||||
|
||||
type CliOnlyArguments struct {
|
||||
Help bool `json:"-"`
|
||||
ListChapters bool `json:"-"`
|
||||
ListFormats bool `json:"-"`
|
||||
ChapterNum int `json:"chapter_num"`
|
||||
}
|
||||
|
||||
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
|
||||
[--chapter int] The chapter you want to download
|
||||
The calculated start and stop timestamps can be
|
||||
overwritten by --start and --stop
|
||||
default: 0 (complete stream)
|
||||
[--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 float] 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
|
||||
|
||||
Version: cli` + Version + "_core" + core.Version)
|
||||
}
|
||||
|
||||
func CliParseArguments() (core.Arguments, CliOnlyArguments, 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.Float64Var(&ratelimitMbs, "max-rate", 10.0, "")
|
||||
flag.Parse()
|
||||
a.Video, err = core.ParseGtvVideoUrl(a.Url)
|
||||
if err != nil {
|
||||
return a, c, err
|
||||
}
|
||||
if a.Video.Class != "streams" {
|
||||
return a, c, errors.New("video category '" + a.Video.Class + "' not supported")
|
||||
}
|
||||
if a.TimestampStart == "" {
|
||||
a.StartDuration = -1
|
||||
} else {
|
||||
a.StartDuration, err = time.ParseDuration(a.TimestampStart)
|
||||
if err != nil {
|
||||
return a, c, err
|
||||
}
|
||||
}
|
||||
if a.TimestampStop == "" {
|
||||
a.StopDuration = -1
|
||||
} else {
|
||||
a.StopDuration, err = time.ParseDuration(a.TimestampStop)
|
||||
if err != nil {
|
||||
return a, c, 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")
|
||||
}
|
||||
return a, c, 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 {
|
||||
CliShowHelp()
|
||||
return 0
|
||||
} else if args.Url == "" || err != nil {
|
||||
CliShowHelp()
|
||||
if err != nil {
|
||||
cli.ErrorMessage(err)
|
||||
}
|
||||
return 1
|
||||
}
|
||||
// detect terminal features
|
||||
XtermDetectFeatures()
|
||||
// Get video metadata
|
||||
if CliXtermTitle {
|
||||
XtermSetTitle("lurch-dl - Fetching video metadata ...")
|
||||
}
|
||||
streamEp, err := core.GetStreamEpisode(args.Video.Id)
|
||||
if err != nil {
|
||||
cli.ErrorMessage(err)
|
||||
return 1
|
||||
}
|
||||
fmt.Print("\n")
|
||||
fmt.Println(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
|
||||
}
|
||||
}
|
||||
if cliArgs.ListChapters || cliArgs.ListFormats {
|
||||
if cliArgs.ListChapters {
|
||||
fmt.Print("\n")
|
||||
CliAvailableChapters(streamEp.Chapters)
|
||||
}
|
||||
if cliArgs.ListFormats {
|
||||
fmt.Print("\n")
|
||||
CliAvailableFormats(streamEp.Formats)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
format, err := streamEp.GetFormatByName(args.FormatName)
|
||||
if err != nil {
|
||||
cli.ErrorMessage(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))
|
||||
}
|
||||
// Start Download
|
||||
fmt.Print("\n")
|
||||
if err = streamEp.Download(args, &cli, make(chan os.Signal, 1)); err != nil {
|
||||
cli.ErrorMessage(err)
|
||||
return 1
|
||||
}
|
||||
fmt.Print("\n")
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func CliAvailableFormats(formats []core.VideoFormat) {
|
||||
fmt.Println("Available formats:")
|
||||
for _, f := range formats {
|
||||
fmt.Println(" - " + f.Name)
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
if retries > 0 {
|
||||
if retries == 1 {
|
||||
fmt.Print("\n")
|
||||
}
|
||||
fmt.Printf("Downloaded %.2f%% at %.2f MB/s (retry %v) ... ", progress*100.0, rate/1000000.0, retries)
|
||||
fmt.Print("\n")
|
||||
} else if waiting {
|
||||
fmt.Printf("Downloaded %.2f%% at %.2f MB/s ... \r", progress*100.0, rate/1000000.0)
|
||||
} else if delaying {
|
||||
fmt.Printf("Downloaded %.2f%% at %.2f MB/s (delaying) ... \r", progress*100.0, rate/1000000.0)
|
||||
} else {
|
||||
fmt.Printf("Downloaded %.2f%% at %.2f MB/s \r", progress*100.0, rate/1000000.0)
|
||||
}
|
||||
if CliXtermTitle {
|
||||
XtermSetTitle(fmt.Sprintf("lurch-dl - Downloaded %.2f%% at %.2f MB/s - %v", progress*100.0, rate/1000000.0, title))
|
||||
}
|
||||
}
|
||||
|
||||
func (cli *Cli) Aborted() {
|
||||
fmt.Print("\nAborted. ")
|
||||
}
|
||||
|
||||
func (cli *Cli) InfoMessage(msg string) {
|
||||
fmt.Println(msg)
|
||||
}
|
||||
|
||||
func (cli *Cli) ErrorMessage(err error) {
|
||||
fmt.Print("\n")
|
||||
fmt.Println("An error occured:", err)
|
||||
}
|
7
cli/go.mod
Normal file
7
cli/go.mod
Normal file
|
@ -0,0 +1,7 @@
|
|||
module github.com/ChaoticByte/lurch-dl/cli
|
||||
|
||||
go 1.24.1
|
||||
|
||||
require github.com/ChaoticByte/lurch-dl/core v0.0.0
|
||||
|
||||
replace github.com/ChaoticByte/lurch-dl/core => ../core
|
0
cli/go.sum
Normal file
0
cli/go.sum
Normal file
11
cli/main.go
Normal file
11
cli/main.go
Normal file
|
@ -0,0 +1,11 @@
|
|||
// Copyright (c) 2025, Julian Müller (ChaoticByte)
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
)
|
||||
|
||||
func main() {
|
||||
os.Exit(CliRun())
|
||||
}
|
3
cli/version.go
Normal file
3
cli/version.go
Normal file
|
@ -0,0 +1,3 @@
|
|||
package main
|
||||
|
||||
var Version = "dev"
|
1
core/VERSION
Normal file
1
core/VERSION
Normal file
|
@ -0,0 +1 @@
|
|||
1.1.1
|
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:"-"`
|
||||
}
|
55
core/errors.go
Normal file
55
core/errors.go
Normal file
|
@ -0,0 +1,55 @@
|
|||
// Copyright (c) 2025, Julian Müller (ChaoticByte)
|
||||
|
||||
package core
|
||||
|
||||
import "fmt"
|
||||
|
||||
type HttpStatusCodeError struct {
|
||||
Url string
|
||||
StatusCode int
|
||||
}
|
||||
|
||||
func (err *HttpStatusCodeError) Error() string {
|
||||
var e string
|
||||
switch err.StatusCode {
|
||||
case 400:
|
||||
e = "Bad Request"
|
||||
case 401:
|
||||
e = "Unauthorized"
|
||||
case 403:
|
||||
e = "Forbidden"
|
||||
case 404:
|
||||
e = "Not Found"
|
||||
case 500, 502, 504:
|
||||
e = "Server Error"
|
||||
case 503:
|
||||
e = "Service Unavailable"
|
||||
default:
|
||||
e = "Request failed"
|
||||
}
|
||||
return fmt.Sprintf("%v - got status code %v while fetching %v", e, err.StatusCode, err.Url)
|
||||
}
|
||||
|
||||
type FileExistsError struct {
|
||||
Filename string
|
||||
}
|
||||
|
||||
func (err *FileExistsError) Error() string {
|
||||
return "File '" + err.Filename + "' already exists. See the available options on how to proceed."
|
||||
}
|
||||
|
||||
type FormatNotFoundError struct {
|
||||
FormatName string
|
||||
}
|
||||
|
||||
func (err *FormatNotFoundError) Error() string {
|
||||
return "Format " + err.FormatName + " is not available."
|
||||
}
|
||||
|
||||
type ChapterNotFoundError struct {
|
||||
ChapterNum int
|
||||
}
|
||||
|
||||
func (err *ChapterNotFoundError) Error() string {
|
||||
return fmt.Sprintf("Chapter %v not found.", err.ChapterNum)
|
||||
}
|
3
core/go.mod
Normal file
3
core/go.mod
Normal file
|
@ -0,0 +1,3 @@
|
|||
module github.com/ChaoticByte/lurch-dl/core
|
||||
|
||||
go 1.24.1
|
0
core/go.sum
Normal file
0
core/go.sum
Normal file
33
core/gtv_api.go
Normal file
33
core/gtv_api.go
Normal file
|
@ -0,0 +1,33 @@
|
|||
// Copyright (c) 2025, Julian Müller (ChaoticByte)
|
||||
|
||||
package core
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
const ApiBaseurlStreamEpisodeInfo = "https://api.gronkh.tv/v1/video/info?episode=%s"
|
||||
const ApiBaseurlStreamEpisodePlInfo = "https://api.gronkh.tv/v1/video/playlist?episode=%s"
|
||||
|
||||
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"},
|
||||
//"Accept-Encoding": {"gzip"},
|
||||
"Origin": {"https://gronkh.tv"},
|
||||
"Referer": {"https://gronkh.tv/"},
|
||||
"Connection": {"keep-alive"},
|
||||
"Sec-Fetch-Dest": {"empty"},
|
||||
"Sec-Fetch-Mode": {"cors"},
|
||||
"Sec-Fetch-Site": {"same-site"},
|
||||
"Pragma": {"no-cache"},
|
||||
"Cache-Control": {"no-cache"},
|
||||
"TE": {"trailers"},
|
||||
}
|
||||
|
||||
var ApiHeadersMetaAdditional = http.Header{
|
||||
"Accept": {"application/json, text/plain, */*"},
|
||||
}
|
||||
|
||||
var ApiHeadersVideoAdditional = http.Header{
|
||||
"Accept": {"*/*"},
|
||||
}
|
61
core/gtv_common.go
Normal file
61
core/gtv_common.go
Normal file
|
@ -0,0 +1,61 @@
|
|||
// Copyright (c) 2025, Julian Müller (ChaoticByte)
|
||||
|
||||
package core
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"regexp"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The following two values are used to simulate buffering
|
||||
const RatelimitDelay = 2.0 // in Seconds; How long to delay the next chunk download.
|
||||
const RatelimitDelayAfter = 5.0 // in Seconds; Delay the next chunk download after this duration.
|
||||
|
||||
var videoUrlRegex = regexp.MustCompile(`gronkh\.tv\/([a-z]+)\/([0-9]+)`)
|
||||
|
||||
type GtvVideo struct {
|
||||
Class string `json:"class"`
|
||||
Id string `json:"id"`
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
video.Class = match[1]
|
||||
video.Id = match[2]
|
||||
return video, nil
|
||||
}
|
||||
|
||||
type VideoFormat struct {
|
||||
Name string `json:"format"`
|
||||
Url string `json:"url"`
|
||||
}
|
||||
|
||||
type ChunkList struct {
|
||||
BaseUrl string
|
||||
Chunks []string
|
||||
ChunkDuration float64
|
||||
}
|
||||
|
||||
func (cl *ChunkList) Cut(from time.Duration, to time.Duration) ChunkList {
|
||||
var newChunks []string
|
||||
var firstChunk = 0
|
||||
if from != -1 {
|
||||
firstChunk = int(from.Seconds() / cl.ChunkDuration)
|
||||
}
|
||||
if to != -1 {
|
||||
lastChunk := min(int(to.Seconds()/cl.ChunkDuration)+1, len(cl.Chunks))
|
||||
newChunks = cl.Chunks[firstChunk:lastChunk]
|
||||
} else {
|
||||
newChunks = cl.Chunks[firstChunk:]
|
||||
}
|
||||
return ChunkList{
|
||||
BaseUrl: cl.BaseUrl,
|
||||
Chunks: newChunks,
|
||||
ChunkDuration: cl.ChunkDuration,
|
||||
}
|
||||
}
|
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
|
||||
}
|
32
core/http.go
Normal file
32
core/http.go
Normal file
|
@ -0,0 +1,32 @@
|
|||
// Copyright (c) 2025, Julian Müller (ChaoticByte)
|
||||
|
||||
package core
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
func httpGet(url string, headers []http.Header, timeout time.Duration) ([]byte, error) {
|
||||
data := []byte{}
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return data, err
|
||||
}
|
||||
for _, h := range headers {
|
||||
for k, v := range h {
|
||||
req.Header.Set(k, v[0])
|
||||
}
|
||||
}
|
||||
client := &http.Client{Timeout: timeout}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return data, err
|
||||
}
|
||||
data, err = io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != 200 {
|
||||
return data, &HttpStatusCodeError{Url: url, StatusCode: resp.StatusCode}
|
||||
}
|
||||
return data, 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()
|
||||
}
|
67
core/naive_m3u8_parser.go
Normal file
67
core/naive_m3u8_parser.go
Normal file
|
@ -0,0 +1,67 @@
|
|||
// Copyright (c) 2025, Julian Müller (ChaoticByte)
|
||||
|
||||
package core
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var availFormatsRegex = regexp.MustCompile(`NAME="(.+)"`)
|
||||
|
||||
func parseAvailFormatsFromM3u8(m3u8 string) []VideoFormat {
|
||||
foundFormats := []VideoFormat{}
|
||||
m3u8 = strings.ReplaceAll(m3u8, "\r", "")
|
||||
parts := strings.Split(m3u8, "#EXT-X-STREAM-INF")
|
||||
for _, p := range parts {
|
||||
p := strings.Trim(p, " \n")
|
||||
if strings.HasPrefix(p, ":") && strings.Contains(p, "RESOLUTION=") && strings.Contains(p, "FRAMERATE=") && strings.Contains(p, "NAME=") {
|
||||
format := VideoFormat{}
|
||||
plItem := strings.Split(p, "\n")
|
||||
if len(plItem) < 2 {
|
||||
continue
|
||||
}
|
||||
formatName := availFormatsRegex.FindStringSubmatch(plItem[0])
|
||||
if formatName == nil {
|
||||
continue // didn't find format
|
||||
}
|
||||
format.Name = formatName[1]
|
||||
format.Url = plItem[1]
|
||||
foundFormats = append(foundFormats, format)
|
||||
}
|
||||
}
|
||||
return foundFormats
|
||||
}
|
||||
|
||||
var targetDurationRegex = regexp.MustCompile(`#EXT-X-TARGETDURATION:(.+)`)
|
||||
|
||||
func parseChunkListFromM3u8(m3u8 string, baseurl string) (ChunkList, error) {
|
||||
chunklist := ChunkList{BaseUrl: baseurl}
|
||||
m3u8 = strings.ReplaceAll(m3u8, "\r", "")
|
||||
parts := strings.Split(m3u8, "#EXTINF")
|
||||
for _, p := range parts {
|
||||
if strings.HasPrefix(p, "#EXTM3U") {
|
||||
lines := strings.Split(p, "\n")
|
||||
for _, l := range lines {
|
||||
if strings.HasPrefix(l, "#EXT-X-TARGETDURATION") {
|
||||
targetDuration := targetDurationRegex.FindStringSubmatch(l)
|
||||
if targetDuration == nil {
|
||||
continue
|
||||
}
|
||||
chunkDuration, err := strconv.ParseFloat(targetDuration[1], 64)
|
||||
if err != nil {
|
||||
return chunklist, err
|
||||
}
|
||||
chunklist.ChunkDuration = chunkDuration
|
||||
}
|
||||
}
|
||||
} else if strings.HasPrefix(p, ":") {
|
||||
lines := strings.Split(p, "\n")
|
||||
if len(lines) > 1 {
|
||||
chunklist.Chunks = append(chunklist.Chunks, lines[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
return chunklist, nil
|
||||
}
|
31
core/sanitization.go
Normal file
31
core/sanitization.go
Normal file
|
@ -0,0 +1,31 @@
|
|||
// Copyright (c) 2025, Julian Müller (ChaoticByte)
|
||||
|
||||
package core
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
var FnInvalidRunes = []rune("/<>:\"\\|?*")
|
||||
|
||||
func sanitizeUnicodeFilename(filename string) string {
|
||||
filename = strings.Trim(strings.ToValidUTF8(filename, ""), " \033\007\u00A0\t\n\r.")
|
||||
var filenameBuilder strings.Builder
|
||||
for _, r := range filename {
|
||||
isInvalid := !unicode.IsPrint(r)
|
||||
if isInvalid {
|
||||
continue
|
||||
}
|
||||
for _, c := range FnInvalidRunes {
|
||||
if r == c {
|
||||
isInvalid = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !isInvalid {
|
||||
filenameBuilder.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return filenameBuilder.String()
|
||||
}
|
3
core/version.go
Normal file
3
core/version.go
Normal file
|
@ -0,0 +1,3 @@
|
|||
package core
|
||||
|
||||
var Version = "dev"
|
Loading…
Add table
Add a link
Reference in a new issue