Added project files

This commit is contained in:
ChaoticByte 2025-03-08 21:09:32 +01:00
parent 84547f6dcb
commit 94a5aff260
No known key found for this signature in database
23 changed files with 1042 additions and 1 deletions

30
.gitignore vendored Normal file
View 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

View file

@ -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
View 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
View 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
View file

@ -0,0 +1 @@
2.0.1

244
cli/cli.go Normal file
View 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
View 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
View file

11
cli/main.go Normal file
View 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
View file

@ -0,0 +1,3 @@
package main
var Version = "dev"

1
core/VERSION Normal file
View file

@ -0,0 +1 @@
1.1.1

19
core/args.go Normal file
View 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
View 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
View file

@ -0,0 +1,3 @@
module github.com/ChaoticByte/lurch-dl/core
go 1.24.1

0
core/go.sum Normal file
View file

33
core/gtv_api.go Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View file

@ -0,0 +1,3 @@
package core
var Version = "dev"