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

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"