From 94a5aff260e21d2606c4cf83cc6ddcc2f5ab6fae Mon Sep 17 00:00:00 2001 From: ChaoticByte Date: Sat, 8 Mar 2025 21:09:32 +0100 Subject: [PATCH] Added project files --- .gitignore | 30 +++++ LICENSE | 2 +- README.md | 146 ++++++++++++++++++++++ build-cli.sh | 34 +++++ cli/VERSION | 1 + cli/cli.go | 244 ++++++++++++++++++++++++++++++++++++ cli/go.mod | 7 ++ cli/go.sum | 0 cli/main.go | 11 ++ cli/version.go | 3 + core/VERSION | 1 + core/args.go | 19 +++ core/errors.go | 55 +++++++++ core/go.mod | 3 + core/go.sum | 0 core/gtv_api.go | 33 +++++ core/gtv_common.go | 61 +++++++++ core/gtv_stream.go | 252 ++++++++++++++++++++++++++++++++++++++ core/http.go | 32 +++++ core/interface.go | 8 ++ core/naive_m3u8_parser.go | 67 ++++++++++ core/sanitization.go | 31 +++++ core/version.go | 3 + 23 files changed, 1042 insertions(+), 1 deletion(-) create mode 100644 .gitignore create mode 100644 README.md create mode 100755 build-cli.sh create mode 100644 cli/VERSION create mode 100644 cli/cli.go create mode 100644 cli/go.mod create mode 100644 cli/go.sum create mode 100644 cli/main.go create mode 100644 cli/version.go create mode 100644 core/VERSION create mode 100644 core/args.go create mode 100644 core/errors.go create mode 100644 core/go.mod create mode 100644 core/go.sum create mode 100644 core/gtv_api.go create mode 100644 core/gtv_common.go create mode 100644 core/gtv_stream.go create mode 100644 core/http.go create mode 100644 core/interface.go create mode 100644 core/naive_m3u8_parser.go create mode 100644 core/sanitization.go create mode 100644 core/version.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..11316d2 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/LICENSE b/LICENSE index 7000719..ba50b01 100644 --- a/LICENSE +++ b/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: diff --git a/README.md b/README.md new file mode 100644 index 0000000..bdb47e8 --- /dev/null +++ b/README.md @@ -0,0 +1,146 @@ + +
what could it be? + +
+ +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 +... +``` + +
diff --git a/build-cli.sh b/build-cli.sh new file mode 100755 index 0000000..c851b13 --- /dev/null +++ b/build-cli.sh @@ -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" diff --git a/cli/VERSION b/cli/VERSION new file mode 100644 index 0000000..10bf840 --- /dev/null +++ b/cli/VERSION @@ -0,0 +1 @@ +2.0.1 \ No newline at end of file diff --git a/cli/cli.go b/cli/cli.go new file mode 100644 index 0000000..526b7a2 --- /dev/null +++ b/cli/cli.go @@ -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) +} diff --git a/cli/go.mod b/cli/go.mod new file mode 100644 index 0000000..1f7d469 --- /dev/null +++ b/cli/go.mod @@ -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 diff --git a/cli/go.sum b/cli/go.sum new file mode 100644 index 0000000..e69de29 diff --git a/cli/main.go b/cli/main.go new file mode 100644 index 0000000..928a2c6 --- /dev/null +++ b/cli/main.go @@ -0,0 +1,11 @@ +// Copyright (c) 2025, Julian Müller (ChaoticByte) + +package main + +import ( + "os" +) + +func main() { + os.Exit(CliRun()) +} diff --git a/cli/version.go b/cli/version.go new file mode 100644 index 0000000..23bec87 --- /dev/null +++ b/cli/version.go @@ -0,0 +1,3 @@ +package main + +var Version = "dev" diff --git a/core/VERSION b/core/VERSION new file mode 100644 index 0000000..8cfbc90 --- /dev/null +++ b/core/VERSION @@ -0,0 +1 @@ +1.1.1 \ No newline at end of file diff --git a/core/args.go b/core/args.go new file mode 100644 index 0000000..64b7596 --- /dev/null +++ b/core/args.go @@ -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:"-"` +} diff --git a/core/errors.go b/core/errors.go new file mode 100644 index 0000000..b9e5240 --- /dev/null +++ b/core/errors.go @@ -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) +} diff --git a/core/go.mod b/core/go.mod new file mode 100644 index 0000000..2dfcc71 --- /dev/null +++ b/core/go.mod @@ -0,0 +1,3 @@ +module github.com/ChaoticByte/lurch-dl/core + +go 1.24.1 diff --git a/core/go.sum b/core/go.sum new file mode 100644 index 0000000..e69de29 diff --git a/core/gtv_api.go b/core/gtv_api.go new file mode 100644 index 0000000..dfb8e75 --- /dev/null +++ b/core/gtv_api.go @@ -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": {"*/*"}, +} diff --git a/core/gtv_common.go b/core/gtv_common.go new file mode 100644 index 0000000..7956559 --- /dev/null +++ b/core/gtv_common.go @@ -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, + } +} diff --git a/core/gtv_stream.go b/core/gtv_stream.go new file mode 100644 index 0000000..7d7d759 --- /dev/null +++ b/core/gtv_stream.go @@ -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 +} diff --git a/core/http.go b/core/http.go new file mode 100644 index 0000000..ab16d26 --- /dev/null +++ b/core/http.go @@ -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 +} diff --git a/core/interface.go b/core/interface.go new file mode 100644 index 0000000..949e33f --- /dev/null +++ b/core/interface.go @@ -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() +} diff --git a/core/naive_m3u8_parser.go b/core/naive_m3u8_parser.go new file mode 100644 index 0000000..8002b76 --- /dev/null +++ b/core/naive_m3u8_parser.go @@ -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 +} diff --git a/core/sanitization.go b/core/sanitization.go new file mode 100644 index 0000000..0766962 --- /dev/null +++ b/core/sanitization.go @@ -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() +} diff --git a/core/version.go b/core/version.go new file mode 100644 index 0000000..d0d7868 --- /dev/null +++ b/core/version.go @@ -0,0 +1,3 @@ +package core + +var Version = "dev"