mirror of
https://github.com/teacat/chaturbate-dvr.git
synced 2025-10-29 16:59:59 +00:00
WIP
This commit is contained in:
parent
f97539ab96
commit
dc4a3d117f
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
./videos
|
||||
@ -1,67 +0,0 @@
|
||||
package service
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
ErrChannelNotFound = errors.New("channel not found")
|
||||
ErrChannelExists = errors.New("channel already exists")
|
||||
ErrChannelNotPaused = errors.New("channel not paused")
|
||||
ErrChannelIsPaused = errors.New("channel is paused")
|
||||
ErrListenNotFound = errors.New("listen not found")
|
||||
)
|
||||
|
||||
const (
|
||||
ResolutionFallbackUpscale = "up"
|
||||
ResolutionFallbackDownscale = "down"
|
||||
)
|
||||
|
||||
type Chaturbate interface {
|
||||
GetChannel(username string) (ChaturbateChannel, error)
|
||||
CreateChannel(config *ChaturbateConfig) error
|
||||
DeleteChannel(username string) error
|
||||
PauseChannel(username string) error
|
||||
ResumeChannel(username string) error
|
||||
ListChannels() ([]ChaturbateChannel, error)
|
||||
ListenUpdate() (<-chan *Update, string)
|
||||
StopListenUpdate(id string) error
|
||||
}
|
||||
|
||||
type ChaturbateChannel interface {
|
||||
Username() string
|
||||
ChannelURL() string
|
||||
FilenamePattern() string
|
||||
Framerate() int
|
||||
Resolution() int
|
||||
ResolutionFallback() string
|
||||
LastStreamedAt() string
|
||||
Filename() string
|
||||
SegmentDuration() int
|
||||
SplitDuration() int
|
||||
SegmentFilesize() int
|
||||
SplitFilesize() int
|
||||
IsOnline() bool
|
||||
IsPaused() bool
|
||||
Logs() []string
|
||||
}
|
||||
|
||||
type ChaturbateConfig struct {
|
||||
Username string
|
||||
FilenamePattern string
|
||||
Framerate int
|
||||
Resolution int
|
||||
ResolutionFallback string
|
||||
SplitDuration int
|
||||
SplitFilesize int
|
||||
}
|
||||
|
||||
type Update struct {
|
||||
Username string `json:"username"`
|
||||
Log string `json:"log"`
|
||||
IsPaused bool `json:"is_paused"`
|
||||
IsOnline bool `json:"is_online"`
|
||||
IsStopped bool `json:"is_stopped"`
|
||||
Filename string `json:"filename"`
|
||||
LastStreamedAt string `json:"last_streamed_at"`
|
||||
SegmentDuration string `json:"segment_duration"`
|
||||
SegmentFilesize string `json:"segment_filesize"`
|
||||
}
|
||||
11
Dockerfile
Normal file
11
Dockerfile
Normal file
@ -0,0 +1,11 @@
|
||||
FROM golang:latest
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download && go mod verify
|
||||
|
||||
COPY . .
|
||||
RUN go build
|
||||
|
||||
CMD [ "sh", "-c", "./chaturbate-dvr -u $USERNAME -ui no start" ]
|
||||
10
README.md
10
README.md
@ -1,4 +1,12 @@
|
||||
#
|
||||
# Chaturbate DVR
|
||||
|
||||
The program watches a specified Chaturbate channel and save the stream in real-time when the channel goes online.
|
||||
|
||||
**Warning**: The streaming content on Chaturbate is copyrighted, you should not copy, share, distribute the content. (for more information, check [DMCA](https://www.dmca.com/))
|
||||
|
||||
## Usage
|
||||
|
||||
The program works for 64-bit macOS, Linux, Windows (or ARM). Just get in the `/bin` folder and find your operating system then execute the program in terminal.
|
||||
|
||||
## 📺 Framerate & Resolution / Fallback
|
||||
|
||||
|
||||
BIN
chaturbate-dvr
BIN
chaturbate-dvr
Binary file not shown.
@ -1,22 +1,11 @@
|
||||
package chaturbate
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/grafov/m3u8"
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
|
||||
var (
|
||||
@ -40,14 +29,15 @@ type Channel struct {
|
||||
Framerate int
|
||||
Resolution int
|
||||
ResolutionFallback string
|
||||
SegmentDuration int
|
||||
SplitDuration int
|
||||
SegmentFilesize int
|
||||
SplitFilesize int
|
||||
SegmentDuration int // Seconds
|
||||
SplitDuration int // Minutes
|
||||
SegmentFilesize int // Bytes
|
||||
SplitFilesize int // MB
|
||||
IsOnline bool
|
||||
IsPaused bool
|
||||
isStopped bool
|
||||
Logs []string
|
||||
logType logType
|
||||
|
||||
bufferLock sync.Mutex
|
||||
buffer map[int][]byte
|
||||
@ -62,400 +52,86 @@ type Channel struct {
|
||||
sessionPattern map[string]any
|
||||
splitIndex int
|
||||
|
||||
PauseChannel chan bool
|
||||
UpdateChannel chan *Update
|
||||
ResumeChannel chan bool
|
||||
}
|
||||
|
||||
// Run
|
||||
func (w *Channel) Run() {
|
||||
if w.Username == "" {
|
||||
w.log(logTypeError, "username is empty, use `-u USERNAME` to specify")
|
||||
return
|
||||
}
|
||||
|
||||
for {
|
||||
if w.IsPaused {
|
||||
w.log(logTypeInfo, "channel is paused")
|
||||
<-w.ResumeChannel // blocking
|
||||
w.log(logTypeInfo, "channel is resumed")
|
||||
}
|
||||
if w.isStopped {
|
||||
w.log(logTypeInfo, "channel is stopped")
|
||||
break
|
||||
}
|
||||
|
||||
body, err := w.requestChannelBody()
|
||||
if err != nil {
|
||||
w.log("Error occurred while requesting channel body: %w", err)
|
||||
w.log(logTypeError, "body request error: %w", err)
|
||||
}
|
||||
if strings.Contains(body, "playlist.m3u8") {
|
||||
w.IsOnline = true
|
||||
w.LastStreamedAt = time.Now().Format("2006-01-02 15:04:05")
|
||||
w.log("Channel is online.")
|
||||
w.log(logTypeInfo, "channel is online, start fetching...")
|
||||
|
||||
if err := w.record(body); err != nil { // blocking
|
||||
w.log("Error occurred when start recording: %w", err)
|
||||
w.log(logTypeError, "record error: %w", err)
|
||||
}
|
||||
continue // this excutes when recording is over/interrupted
|
||||
}
|
||||
w.IsOnline = false
|
||||
w.log("Channel is offline.")
|
||||
|
||||
w.log(logTypeInfo, "channel is offline, check again 1 min later")
|
||||
<-time.After(1 * time.Minute) // 1 minute cooldown to check online status
|
||||
}
|
||||
}
|
||||
|
||||
// requestChannelBody
|
||||
func (w *Channel) requestChannelBody() (string, error) {
|
||||
transport := &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||
}
|
||||
client := &http.Client{Transport: transport}
|
||||
|
||||
resp, err := client.Get(w.ChannelURL)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("client get: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read body: %w", err)
|
||||
}
|
||||
|
||||
return string(body), nil
|
||||
}
|
||||
|
||||
// record
|
||||
func (w *Channel) record(body string) error {
|
||||
w.resetSession()
|
||||
|
||||
if err := w.newFile(); err != nil {
|
||||
return fmt.Errorf("new file: %w", err)
|
||||
}
|
||||
|
||||
rootURL, sourceURL, err := w.resolveSource(body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("request hls: %w", err)
|
||||
}
|
||||
w.rootURL = rootURL
|
||||
w.sourceURL = sourceURL
|
||||
|
||||
go w.mergeSegments()
|
||||
w.fetchSegments() // blocking
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *Channel) resetSession() {
|
||||
w.buffer = make(map[int][]byte)
|
||||
w.bufferLock = sync.Mutex{}
|
||||
w.bufferIndex = 0
|
||||
w.segmentIndex = 0
|
||||
w.segmentUseds = []string{}
|
||||
w.rootURL = ""
|
||||
w.sourceURL = ""
|
||||
w.retries = 0
|
||||
w.SegmentFilesize = 0
|
||||
w.SegmentDuration = 0
|
||||
w.splitIndex = 0
|
||||
w.sessionPattern = nil
|
||||
}
|
||||
|
||||
func (w *Channel) resolveSource(body string) (string, string, error) {
|
||||
// Find the room dossier.
|
||||
matches := regexpRoomDossier.FindAllStringSubmatch(body, -1)
|
||||
|
||||
// Get the HLS source from the room dossier.
|
||||
var roomData roomDossier
|
||||
data, err := strconv.Unquote(strings.Replace(strconv.Quote(string(matches[0][1])), `\\u`, `\u`, -1))
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("unquote unicode: %w", err)
|
||||
}
|
||||
if err := json.Unmarshal([]byte(data), &roomData); err != nil {
|
||||
return "", "", fmt.Errorf("unmarshal json: %w", err)
|
||||
}
|
||||
|
||||
// Get the HLS source.
|
||||
transport := &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||
}
|
||||
client := &http.Client{Transport: transport}
|
||||
|
||||
resp, err := client.Get(roomData.HLSSource)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("client get: %w", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
switch resp.StatusCode {
|
||||
case http.StatusForbidden:
|
||||
return "", "", fmt.Errorf("received status code %d, the stream is private?", resp.StatusCode)
|
||||
default:
|
||||
return "", "", fmt.Errorf("received status code %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
m3u8Body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("read body: %w", err)
|
||||
}
|
||||
|
||||
// Decode the m3u8 file.
|
||||
p, _, err := m3u8.DecodeFrom(bytes.NewReader(m3u8Body), true)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("decode m3u8: %w", err)
|
||||
}
|
||||
playlist, ok := p.(*m3u8.MasterPlaylist)
|
||||
if !ok {
|
||||
return "", "", fmt.Errorf("cast to master playlist")
|
||||
}
|
||||
|
||||
var resolutions []*resolution
|
||||
for _, v := range playlist.Variants {
|
||||
width := strings.Split(v.Resolution, "x")[1] // 1920x1080 -> 1080
|
||||
fps := 30
|
||||
if strings.Contains(v.Name, "FPS:60.0") {
|
||||
fps = 60
|
||||
}
|
||||
variant, ok := lo.Find(resolutions, func(v *resolution) bool {
|
||||
return strconv.Itoa(v.width) == width
|
||||
})
|
||||
if ok {
|
||||
variant.framerate[fps] = v.URI
|
||||
continue
|
||||
}
|
||||
widthInt, err := strconv.Atoi(width)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("convert width string to int: %w", err)
|
||||
}
|
||||
resolutions = append(resolutions, &resolution{
|
||||
framerate: map[int]string{fps: v.URI},
|
||||
width: widthInt,
|
||||
})
|
||||
}
|
||||
|
||||
variant, ok := lo.Find(resolutions, func(v *resolution) bool {
|
||||
return v.width == w.Resolution
|
||||
})
|
||||
// Fallback to the nearest resolution if the preferred resolution is not found.
|
||||
if !ok {
|
||||
switch w.ResolutionFallback {
|
||||
case ResolutionFallbackDownscale:
|
||||
variant = lo.MaxBy(lo.Filter(resolutions, func(v *resolution, _ int) bool {
|
||||
log.Println(v.width, w.Resolution)
|
||||
return v.width < w.Resolution
|
||||
}), func(v, max *resolution) bool {
|
||||
return v.width > max.width
|
||||
})
|
||||
case ResolutionFallbackUpscale:
|
||||
variant = lo.MinBy(lo.Filter(resolutions, func(v *resolution, _ int) bool {
|
||||
return v.width > w.Resolution
|
||||
}), func(v, min *resolution) bool {
|
||||
return v.width < min.width
|
||||
})
|
||||
}
|
||||
}
|
||||
if variant == nil {
|
||||
return "", "", fmt.Errorf("no available variant")
|
||||
}
|
||||
|
||||
url, ok := variant.framerate[w.Framerate]
|
||||
// If the framerate is not found, fallback to the first found framerate, this block pretends there're only 30 and 60 fps.
|
||||
// no complex logic here, im lazy.
|
||||
if ok {
|
||||
w.log("Framerate %d is used.", w.Framerate)
|
||||
} else {
|
||||
for k, v := range variant.framerate {
|
||||
url = v
|
||||
w.log("Framerate %d is not found, fallback to %d.", w.Framerate, k)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
rootURL := strings.TrimSuffix(roomData.HLSSource, "playlist.m3u8")
|
||||
sourceURL := rootURL + url
|
||||
return rootURL, sourceURL, nil
|
||||
}
|
||||
|
||||
func (w *Channel) mergeSegments() {
|
||||
var segmentRetries int
|
||||
|
||||
for {
|
||||
if w.IsPaused || w.isStopped {
|
||||
break
|
||||
}
|
||||
if segmentRetries > 5 {
|
||||
w.log("Segment #%d error, the segment has been skipped.", w.bufferIndex)
|
||||
w.bufferIndex++
|
||||
segmentRetries = 0
|
||||
continue
|
||||
}
|
||||
if len(w.buffer) == 0 {
|
||||
<-time.After(1 * time.Second)
|
||||
continue
|
||||
}
|
||||
buf, ok := w.buffer[w.bufferIndex]
|
||||
if !ok {
|
||||
segmentRetries++
|
||||
<-time.After(time.Duration(segmentRetries) * time.Second)
|
||||
continue
|
||||
}
|
||||
lens, err := w.file.Write(buf)
|
||||
if err != nil {
|
||||
w.log("Error occurred while writing segment #%d to file: %v", w.bufferIndex, err)
|
||||
w.retries++
|
||||
continue
|
||||
}
|
||||
w.log("Segment #%d written to file.", w.bufferIndex)
|
||||
|
||||
w.SegmentFilesize += lens
|
||||
segmentRetries = 0
|
||||
|
||||
if w.SplitFilesize > 0 && w.SegmentFilesize >= w.SplitFilesize*1024*1024 {
|
||||
w.log("File size has exceeded, creating new file.")
|
||||
|
||||
if err := w.nextFile(); err != nil {
|
||||
w.log("Error occurred while creating file for next part: %v", err)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if w.SplitDuration > 0 && w.SegmentDuration >= w.SplitDuration*60 {
|
||||
w.log("Duration has exceeded, creating new file.")
|
||||
|
||||
if err := w.nextFile(); err != nil {
|
||||
w.log("Error occurred while creating file for next part: %v", err)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
w.bufferLock.Lock()
|
||||
delete(w.buffer, w.bufferIndex)
|
||||
w.bufferLock.Unlock()
|
||||
|
||||
w.bufferIndex++
|
||||
}
|
||||
}
|
||||
|
||||
func (w *Channel) requestChunks() ([]*m3u8.MediaSegment, float64, error) {
|
||||
transport := &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||
}
|
||||
client := &http.Client{Transport: transport}
|
||||
|
||||
resp, err := client.Get(w.sourceURL)
|
||||
if err != nil {
|
||||
return nil, 3, fmt.Errorf("client get: %w", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
switch resp.StatusCode {
|
||||
case http.StatusForbidden:
|
||||
return nil, 3, fmt.Errorf("received status code %d, the stream is private?", resp.StatusCode)
|
||||
default:
|
||||
return nil, 3, fmt.Errorf("received status code %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, 3, fmt.Errorf("read body: %w", err)
|
||||
}
|
||||
|
||||
p, _, err := m3u8.DecodeFrom(bytes.NewReader(body), true)
|
||||
if err != nil {
|
||||
return nil, 3, fmt.Errorf("decode m3u8: %w", err)
|
||||
}
|
||||
playlist, ok := p.(*m3u8.MediaPlaylist)
|
||||
if !ok {
|
||||
return nil, 3, fmt.Errorf("cast to media playlist")
|
||||
}
|
||||
chunks := lo.Filter(playlist.Segments, func(v *m3u8.MediaSegment, _ int) bool {
|
||||
return v != nil
|
||||
})
|
||||
|
||||
//log.Println(playlist.TargetDuration)
|
||||
|
||||
return chunks, 1, nil
|
||||
}
|
||||
|
||||
// fetchSegments
|
||||
func (w *Channel) fetchSegments() {
|
||||
var disconnectRetries int
|
||||
|
||||
for {
|
||||
if w.IsPaused || w.isStopped {
|
||||
break
|
||||
}
|
||||
|
||||
chunks, wait, err := w.requestChunks()
|
||||
if err != nil {
|
||||
if disconnectRetries > 10 {
|
||||
w.IsOnline = false
|
||||
break
|
||||
}
|
||||
|
||||
w.log("Error occurred while parsing m3u8: %v", err)
|
||||
disconnectRetries++
|
||||
|
||||
<-time.After(time.Duration(wait) * time.Second)
|
||||
continue
|
||||
}
|
||||
|
||||
if disconnectRetries > 0 {
|
||||
w.log("Stream is online")
|
||||
w.IsOnline = true
|
||||
disconnectRetries = 0
|
||||
}
|
||||
|
||||
for _, v := range chunks {
|
||||
if w.isSegmentFetched(v.URI) {
|
||||
continue
|
||||
}
|
||||
|
||||
go w.requestSegment(v.URI, w.segmentIndex)
|
||||
w.SegmentDuration += int(v.Duration)
|
||||
w.segmentIndex++
|
||||
}
|
||||
<-time.After(time.Duration(wait) * time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
func (w *Channel) requestSegment(url string, index int) error {
|
||||
transport := &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||
}
|
||||
client := &http.Client{Transport: transport}
|
||||
|
||||
resp, err := client.Get(w.rootURL + url)
|
||||
if err != nil {
|
||||
return fmt.Errorf("client get: %w", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("received status code %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read body: %w", err)
|
||||
}
|
||||
|
||||
w.log("Segment #%d fetched.", index)
|
||||
|
||||
w.bufferLock.Lock()
|
||||
w.buffer[index] = body
|
||||
w.bufferLock.Unlock()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *Channel) Pause() {
|
||||
w.IsPaused = true
|
||||
w.resetSession()
|
||||
w.log("Channel was paused.")
|
||||
}
|
||||
|
||||
func (w *Channel) Resume() {
|
||||
w.IsPaused = false
|
||||
w.ResumeChannel <- true //BUG:
|
||||
w.log("Channel was resumed.")
|
||||
select {
|
||||
case w.ResumeChannel <- true:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func (w *Channel) Stop() {
|
||||
w.isStopped = true
|
||||
w.log("Channel was stopped.")
|
||||
}
|
||||
|
||||
func (w *Channel) SegmentDurationStr() string {
|
||||
return DurationStr(w.SegmentDuration)
|
||||
}
|
||||
|
||||
func (w *Channel) SplitDurationStr() string {
|
||||
return DurationStr(w.SplitDuration * 60)
|
||||
}
|
||||
|
||||
func (w *Channel) SegmentFilesizeStr() string {
|
||||
return ByteStr(w.SegmentFilesize)
|
||||
}
|
||||
|
||||
func (w *Channel) SplitFilesizeStr() string {
|
||||
return MBStr(w.SplitFilesize)
|
||||
}
|
||||
|
||||
func (w *Channel) Filename() string {
|
||||
if w.file == nil {
|
||||
return ""
|
||||
}
|
||||
return w.file.Name()
|
||||
}
|
||||
|
||||
@ -3,9 +3,9 @@ package chaturbate
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"text/template"
|
||||
"time"
|
||||
)
|
||||
|
||||
@ -27,17 +27,14 @@ func (w *Channel) filename() (string, error) {
|
||||
} else {
|
||||
data["Sequence"] = w.splitIndex
|
||||
}
|
||||
|
||||
t, err := template.New("filename").Parse(w.filenamePattern)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := t.Execute(&buf, data); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return buf.String(), nil
|
||||
}
|
||||
|
||||
@ -45,7 +42,7 @@ func (w *Channel) filename() (string, error) {
|
||||
func (w *Channel) newFile() error {
|
||||
filename, err := w.filename()
|
||||
if err != nil {
|
||||
return fmt.Errorf("error occurred while parsing filename pattern: %w", err)
|
||||
return fmt.Errorf("filename pattern error: %w", err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(filename), 0777); err != nil {
|
||||
return fmt.Errorf("create folder: %w", err)
|
||||
@ -54,7 +51,7 @@ func (w *Channel) newFile() error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot open file: %s: %w", filename, err)
|
||||
}
|
||||
w.log("The video will be saved as %s.ts", filename)
|
||||
w.log(logTypeInfo, "the stream will be saved as %s.ts", filename)
|
||||
w.file = file
|
||||
return nil
|
||||
}
|
||||
@ -67,10 +64,3 @@ func (w *Channel) nextFile() error {
|
||||
|
||||
return w.newFile()
|
||||
}
|
||||
|
||||
func (w *Channel) Filename() string {
|
||||
if w.file == nil {
|
||||
return ""
|
||||
}
|
||||
return w.file.Name()
|
||||
}
|
||||
|
||||
387
chaturbate/channel_internal.go
Normal file
387
chaturbate/channel_internal.go
Normal file
@ -0,0 +1,387 @@
|
||||
package chaturbate
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/grafov/m3u8"
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
|
||||
// requestChannelBody requests the channel page and returns the body.
|
||||
func (w *Channel) requestChannelBody() (string, error) {
|
||||
transport := &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||
}
|
||||
client := &http.Client{Transport: transport}
|
||||
|
||||
resp, err := client.Get(w.ChannelURL)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("client get: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read body: %w", err)
|
||||
}
|
||||
|
||||
return string(body), nil
|
||||
}
|
||||
|
||||
// record starts the recording process,
|
||||
// this function get called when the channel is online and back online from offline status.
|
||||
//
|
||||
// this is a blocking function until fetching segments gone wrong (or nothing to fetch, aka offline).
|
||||
func (w *Channel) record(body string) error {
|
||||
w.resetSession()
|
||||
|
||||
if err := w.newFile(); err != nil {
|
||||
return fmt.Errorf("new file: %w", err)
|
||||
}
|
||||
|
||||
rootURL, sourceURL, err := w.resolveSource(body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("request hls: %w", err)
|
||||
}
|
||||
w.rootURL = rootURL
|
||||
w.sourceURL = sourceURL
|
||||
|
||||
go w.mergeSegments()
|
||||
w.fetchSegments() // blocking
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// resetSession resets the session data,
|
||||
// usually called when the channel is online or paused to resumed.
|
||||
func (w *Channel) resetSession() {
|
||||
w.buffer = make(map[int][]byte)
|
||||
w.bufferLock = sync.Mutex{}
|
||||
w.bufferIndex = 0
|
||||
w.segmentIndex = 0
|
||||
w.segmentUseds = []string{}
|
||||
w.rootURL = ""
|
||||
w.sourceURL = ""
|
||||
w.retries = 0
|
||||
w.SegmentFilesize = 0
|
||||
w.SegmentDuration = 0
|
||||
w.splitIndex = 0
|
||||
w.sessionPattern = nil
|
||||
}
|
||||
|
||||
// resolveSource resolves the HLS source from the channel page.
|
||||
// the HLS Source is a list that contains all the available resolutions and framerates.
|
||||
func (w *Channel) resolveSource(body string) (string, string, error) {
|
||||
// Find the room dossier.
|
||||
matches := regexpRoomDossier.FindAllStringSubmatch(body, -1)
|
||||
|
||||
// Get the HLS source from the room dossier.
|
||||
var roomData roomDossier
|
||||
data, err := strconv.Unquote(strings.Replace(strconv.Quote(string(matches[0][1])), `\\u`, `\u`, -1))
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("unquote unicode: %w", err)
|
||||
}
|
||||
if err := json.Unmarshal([]byte(data), &roomData); err != nil {
|
||||
return "", "", fmt.Errorf("unmarshal json: %w", err)
|
||||
}
|
||||
|
||||
// Get the HLS source.
|
||||
transport := &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||
}
|
||||
client := &http.Client{Transport: transport}
|
||||
|
||||
resp, err := client.Get(roomData.HLSSource)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("client get: %w", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
switch resp.StatusCode {
|
||||
case http.StatusForbidden:
|
||||
return "", "", fmt.Errorf("ticket/private stream?")
|
||||
default:
|
||||
return "", "", fmt.Errorf("status code %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
m3u8Body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("read body: %w", err)
|
||||
}
|
||||
|
||||
// Decode the m3u8 file.
|
||||
p, _, err := m3u8.DecodeFrom(bytes.NewReader(m3u8Body), true)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("decode m3u8: %w", err)
|
||||
}
|
||||
playlist, ok := p.(*m3u8.MasterPlaylist)
|
||||
if !ok {
|
||||
return "", "", fmt.Errorf("cast to master playlist")
|
||||
}
|
||||
|
||||
var resolutions []*resolution
|
||||
for _, v := range playlist.Variants {
|
||||
width := strings.Split(v.Resolution, "x")[1] // 1920x1080 -> 1080
|
||||
fps := 30
|
||||
if strings.Contains(v.Name, "FPS:60.0") {
|
||||
fps = 60
|
||||
}
|
||||
variant, ok := lo.Find(resolutions, func(v *resolution) bool {
|
||||
return strconv.Itoa(v.width) == width
|
||||
})
|
||||
if ok {
|
||||
variant.framerate[fps] = v.URI
|
||||
continue
|
||||
}
|
||||
widthInt, err := strconv.Atoi(width)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("convert width string to int: %w", err)
|
||||
}
|
||||
resolutions = append(resolutions, &resolution{
|
||||
framerate: map[int]string{fps: v.URI},
|
||||
width: widthInt,
|
||||
})
|
||||
}
|
||||
variant, ok := lo.Find(resolutions, func(v *resolution) bool {
|
||||
return v.width == w.Resolution
|
||||
})
|
||||
// Fallback to the nearest resolution if the preferred resolution is not found.
|
||||
if !ok {
|
||||
switch w.ResolutionFallback {
|
||||
case ResolutionFallbackDownscale:
|
||||
variant = lo.MaxBy(lo.Filter(resolutions, func(v *resolution, _ int) bool {
|
||||
return v.width < w.Resolution
|
||||
}), func(v, max *resolution) bool {
|
||||
return v.width > max.width
|
||||
})
|
||||
case ResolutionFallbackUpscale:
|
||||
variant = lo.MinBy(lo.Filter(resolutions, func(v *resolution, _ int) bool {
|
||||
return v.width > w.Resolution
|
||||
}), func(v, min *resolution) bool {
|
||||
return v.width < min.width
|
||||
})
|
||||
}
|
||||
}
|
||||
if variant == nil {
|
||||
return "", "", fmt.Errorf("no available resolution")
|
||||
}
|
||||
w.log(logTypeInfo, "resolution %dp is used", variant.width)
|
||||
|
||||
url, ok := variant.framerate[w.Framerate]
|
||||
// If the framerate is not found, fallback to the first found framerate, this block pretends there're only 30 and 60 fps.
|
||||
// no complex logic here, im lazy.
|
||||
if ok {
|
||||
w.log(logTypeInfo, "framerate %dfps is used", w.Framerate)
|
||||
} else {
|
||||
for k, v := range variant.framerate {
|
||||
url = v
|
||||
w.log(logTypeWarning, "framerate %dfps not found, fallback to %dfps", w.Framerate, k)
|
||||
w.Framerate = k
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
rootURL := strings.TrimSuffix(roomData.HLSSource, "playlist.m3u8")
|
||||
sourceURL := rootURL + url
|
||||
return rootURL, sourceURL, nil
|
||||
}
|
||||
|
||||
// mergeSegments is a async function that runs in background for the channel,
|
||||
// and it merges the segments from buffer to the file.
|
||||
func (w *Channel) mergeSegments() {
|
||||
var segmentRetries int
|
||||
|
||||
for {
|
||||
if w.IsPaused || w.isStopped {
|
||||
break
|
||||
}
|
||||
if segmentRetries > 5 {
|
||||
w.log(logTypeWarning, "segment #%d not found in buffer, skipped", w.bufferIndex)
|
||||
w.bufferIndex++
|
||||
segmentRetries = 0
|
||||
continue
|
||||
}
|
||||
if len(w.buffer) == 0 {
|
||||
<-time.After(1 * time.Second)
|
||||
continue
|
||||
}
|
||||
buf, ok := w.buffer[w.bufferIndex]
|
||||
if !ok {
|
||||
segmentRetries++
|
||||
<-time.After(time.Duration(segmentRetries) * time.Second)
|
||||
continue
|
||||
}
|
||||
lens, err := w.file.Write(buf)
|
||||
if err != nil {
|
||||
w.log(logTypeError, "segment #%d written error: %v", w.bufferIndex, err)
|
||||
w.retries++
|
||||
continue
|
||||
}
|
||||
w.log(logTypeInfo, "segment #%d written", w.bufferIndex)
|
||||
w.log(logTypeDebug, "duration: %s, size: %s", DurationStr(w.SegmentDuration), ByteStr(w.SegmentFilesize))
|
||||
|
||||
w.SegmentFilesize += lens
|
||||
segmentRetries = 0
|
||||
|
||||
if w.SplitFilesize > 0 && w.SegmentFilesize >= w.SplitFilesize*1024*1024 {
|
||||
w.log(logTypeInfo, "filesize exceeded, creating new file")
|
||||
|
||||
if err := w.nextFile(); err != nil {
|
||||
w.log(logTypeError, "next file error: %v", err)
|
||||
break
|
||||
}
|
||||
} else if w.SplitDuration > 0 && w.SegmentDuration >= w.SplitDuration*60 {
|
||||
w.log(logTypeInfo, "duration exceeded, creating new file")
|
||||
|
||||
if err := w.nextFile(); err != nil {
|
||||
w.log(logTypeError, "next file error: %v", err)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
w.bufferLock.Lock()
|
||||
delete(w.buffer, w.bufferIndex)
|
||||
w.bufferLock.Unlock()
|
||||
|
||||
w.bufferIndex++
|
||||
}
|
||||
}
|
||||
|
||||
// fetchSegments is a blocking function,
|
||||
// it will keep asking the segment list for the latest segments.
|
||||
func (w *Channel) fetchSegments() {
|
||||
var disconnectRetries int
|
||||
|
||||
for {
|
||||
if w.IsPaused || w.isStopped {
|
||||
break
|
||||
}
|
||||
|
||||
chunks, wait, err := w.requestChunks()
|
||||
if err != nil {
|
||||
if disconnectRetries > 10 {
|
||||
w.IsOnline = false
|
||||
break
|
||||
}
|
||||
|
||||
w.log(logTypeError, "segment list error, will try again [%d/10]: %v", disconnectRetries, err)
|
||||
disconnectRetries++
|
||||
|
||||
<-time.After(time.Duration(wait) * time.Second)
|
||||
continue
|
||||
}
|
||||
|
||||
if disconnectRetries > 0 {
|
||||
w.log(logTypeInfo, "channel is back online!")
|
||||
w.IsOnline = true
|
||||
disconnectRetries = 0
|
||||
}
|
||||
|
||||
for _, v := range chunks {
|
||||
if w.isSegmentFetched(v.URI) {
|
||||
continue
|
||||
}
|
||||
|
||||
go func(index int) {
|
||||
if err := w.requestSegment(v.URI, index); err != nil {
|
||||
w.log(logTypeError, "segment #%d request error, ignored: %v", index, err)
|
||||
return
|
||||
}
|
||||
}(w.segmentIndex)
|
||||
w.SegmentDuration += int(v.Duration)
|
||||
w.segmentIndex++
|
||||
}
|
||||
<-time.After(time.Duration(wait) * time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
// requestChunks requests the segment list from the HLS source,
|
||||
// the same segment list will be updated every few seconds from chaturbate.
|
||||
func (w *Channel) requestChunks() ([]*m3u8.MediaSegment, float64, error) {
|
||||
transport := &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||
}
|
||||
client := &http.Client{Transport: transport}
|
||||
|
||||
if w.sourceURL == "" {
|
||||
return nil, 0, fmt.Errorf("channel seems to be paused?")
|
||||
}
|
||||
|
||||
resp, err := client.Get(w.sourceURL)
|
||||
if err != nil {
|
||||
return nil, 3, fmt.Errorf("client get: %w", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
switch resp.StatusCode {
|
||||
case http.StatusForbidden:
|
||||
return nil, 3, fmt.Errorf("ticket/private stream?")
|
||||
default:
|
||||
return nil, 3, fmt.Errorf("status code %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, 3, fmt.Errorf("read body: %w", err)
|
||||
}
|
||||
|
||||
p, _, err := m3u8.DecodeFrom(bytes.NewReader(body), true)
|
||||
if err != nil {
|
||||
return nil, 3, fmt.Errorf("decode m3u8: %w", err)
|
||||
}
|
||||
playlist, ok := p.(*m3u8.MediaPlaylist)
|
||||
if !ok {
|
||||
return nil, 3, fmt.Errorf("cast to media playlist")
|
||||
}
|
||||
chunks := lo.Filter(playlist.Segments, func(v *m3u8.MediaSegment, _ int) bool {
|
||||
return v != nil
|
||||
})
|
||||
return chunks, 1, nil
|
||||
}
|
||||
|
||||
// requestSegment requests the specific single segment and put it into the buffer.
|
||||
// the mergeSegments function will merge the segment from buffer to the file in the backgrond.
|
||||
func (w *Channel) requestSegment(url string, index int) error {
|
||||
transport := &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||
}
|
||||
client := &http.Client{Transport: transport}
|
||||
|
||||
if w.rootURL == "" {
|
||||
return fmt.Errorf("channel seems to be paused?")
|
||||
}
|
||||
|
||||
resp, err := client.Get(w.rootURL + url)
|
||||
if err != nil {
|
||||
return fmt.Errorf("client get: %w", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("received status code %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read body: %w", err)
|
||||
}
|
||||
|
||||
w.log(logTypeDebug, "segment #%d fetched", index)
|
||||
|
||||
w.bufferLock.Lock()
|
||||
w.buffer[index] = body
|
||||
w.bufferLock.Unlock()
|
||||
|
||||
return nil
|
||||
}
|
||||
21
chaturbate/channel_update.go
Normal file
21
chaturbate/channel_update.go
Normal file
@ -0,0 +1,21 @@
|
||||
package chaturbate
|
||||
|
||||
type Update struct {
|
||||
Username string `json:"username"`
|
||||
Log string `json:"log"`
|
||||
IsPaused bool `json:"is_paused"`
|
||||
IsOnline bool `json:"is_online"`
|
||||
IsStopped bool `json:"is_stopped"`
|
||||
Filename string `json:"filename"`
|
||||
LastStreamedAt string `json:"last_streamed_at"`
|
||||
SegmentDuration int `json:"segment_duration"`
|
||||
SegmentFilesize int `json:"segment_filesize"`
|
||||
}
|
||||
|
||||
func (u *Update) SegmentDurationStr() string {
|
||||
return DurationStr(u.SegmentDuration)
|
||||
}
|
||||
|
||||
func (u *Update) SegmentFilesizeStr() string {
|
||||
return ByteStr(u.SegmentFilesize)
|
||||
}
|
||||
@ -5,10 +5,34 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type logType string
|
||||
|
||||
const (
|
||||
logTypeDebug logType = "DEBUG"
|
||||
logTypeInfo logType = "INFO"
|
||||
logTypeWarning logType = "WARN"
|
||||
logTypeError logType = "ERROR"
|
||||
)
|
||||
|
||||
// log
|
||||
func (w *Channel) log(message string, v ...interface{}) {
|
||||
updateLog := fmt.Sprintf("[%s] %s", time.Now().Format("2006-01-02 15:04:05"), fmt.Errorf(message, v...))
|
||||
consoleLog := fmt.Sprintf("[%s] [%s] %s", time.Now().Format("2006-01-02 15:04:05"), w.Username, fmt.Errorf(message, v...))
|
||||
func (w *Channel) log(typ logType, message string, v ...interface{}) {
|
||||
switch w.logType {
|
||||
case logTypeInfo:
|
||||
if typ == logTypeDebug {
|
||||
return
|
||||
}
|
||||
case logTypeWarning:
|
||||
if typ == logTypeDebug || typ == logTypeInfo {
|
||||
return
|
||||
}
|
||||
case logTypeError:
|
||||
if typ == logTypeDebug || typ == logTypeInfo || typ == logTypeWarning {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
updateLog := fmt.Sprintf("[%s] [%s] %s", time.Now().Format("2006-01-02 15:04:05"), typ, fmt.Errorf(message, v...))
|
||||
consoleLog := fmt.Sprintf("[%s] [%s] [%s] %s", time.Now().Format("2006-01-02 15:04:05"), typ, w.Username, fmt.Errorf(message, v...))
|
||||
|
||||
update := &Update{
|
||||
Username: w.Username,
|
||||
@ -23,7 +47,10 @@ func (w *Channel) log(message string, v ...interface{}) {
|
||||
update.Filename = w.file.Name()
|
||||
}
|
||||
|
||||
w.UpdateChannel <- update
|
||||
select {
|
||||
case w.UpdateChannel <- update:
|
||||
default:
|
||||
}
|
||||
|
||||
fmt.Println(consoleLog)
|
||||
|
||||
@ -35,13 +62,32 @@ func (w *Channel) log(message string, v ...interface{}) {
|
||||
}
|
||||
}
|
||||
|
||||
// isDuplicateSegment returns true if the segment is already been fetched.
|
||||
// isSegmentFetched returns true if the segment has been fetched.
|
||||
func (w *Channel) isSegmentFetched(url string) bool {
|
||||
for _, v := range w.segmentUseds {
|
||||
if url[len(url)-10:] == v {
|
||||
if url == v {
|
||||
return true
|
||||
}
|
||||
}
|
||||
w.segmentUseds = append(w.segmentUseds, url[len(url)-10:])
|
||||
if len(w.segmentUseds) > 100 {
|
||||
w.segmentUseds = w.segmentUseds[len(w.segmentUseds)-30:]
|
||||
}
|
||||
w.segmentUseds = append(w.segmentUseds, url)
|
||||
return false
|
||||
}
|
||||
|
||||
func DurationStr(seconds int) string {
|
||||
hours := seconds / 3600
|
||||
seconds %= 3600
|
||||
minutes := seconds / 60
|
||||
seconds %= 60
|
||||
return fmt.Sprintf("%02d:%02d:%02d", hours, minutes, seconds)
|
||||
}
|
||||
|
||||
func MBStr(mibs int) string {
|
||||
return fmt.Sprintf("%.2f MiB", float64(mibs))
|
||||
}
|
||||
|
||||
func ByteStr(bytes int) string {
|
||||
return fmt.Sprintf("%.2f MiB", float64(bytes)/1024/1024)
|
||||
}
|
||||
|
||||
@ -4,6 +4,7 @@ import (
|
||||
"errors"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
const (
|
||||
@ -11,18 +12,6 @@ const (
|
||||
ResolutionFallbackDownscale = "down"
|
||||
)
|
||||
|
||||
type Update struct {
|
||||
Username string `json:"username"`
|
||||
Log string `json:"log"`
|
||||
IsPaused bool `json:"is_paused"`
|
||||
IsOnline bool `json:"is_online"`
|
||||
IsStopped bool `json:"is_stopped"`
|
||||
Filename string `json:"filename"`
|
||||
LastStreamedAt string `json:"last_streamed_at"`
|
||||
SegmentDuration int `json:"segment_duration"`
|
||||
SegmentFilesize int `json:"segment_filesize"`
|
||||
}
|
||||
|
||||
var (
|
||||
ErrChannelNotFound = errors.New("channel not found")
|
||||
ErrChannelExists = errors.New("channel already exists")
|
||||
@ -31,6 +20,7 @@ var (
|
||||
ErrListenNotFound = errors.New("listen not found")
|
||||
)
|
||||
|
||||
// Config
|
||||
type Config struct {
|
||||
Username string
|
||||
FilenamePattern string
|
||||
@ -43,16 +33,18 @@ type Config struct {
|
||||
|
||||
// Manager
|
||||
type Manager struct {
|
||||
cli *cli.Context
|
||||
Channels map[string]*Channel
|
||||
Updates map[string]chan *Update
|
||||
}
|
||||
|
||||
// NewManager
|
||||
func NewManager() (*Manager, error) {
|
||||
func NewManager(c *cli.Context) *Manager {
|
||||
return &Manager{
|
||||
cli: c,
|
||||
Channels: map[string]*Channel{},
|
||||
Updates: map[string]chan *Update{},
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// PauseChannel
|
||||
@ -116,6 +108,7 @@ func (m *Manager) CreateChannel(conf *Config) error {
|
||||
Logs: []string{},
|
||||
UpdateChannel: make(chan *Update),
|
||||
ResumeChannel: make(chan bool),
|
||||
logType: logType(m.cli.String("log-level")),
|
||||
}
|
||||
go func() {
|
||||
for update := range c.UpdateChannel {
|
||||
@ -127,7 +120,7 @@ func (m *Manager) CreateChannel(conf *Config) error {
|
||||
}
|
||||
}()
|
||||
m.Channels[conf.Username] = c
|
||||
c.log("Channel created")
|
||||
c.log(logTypeInfo, "channel created")
|
||||
go c.Run()
|
||||
return nil
|
||||
}
|
||||
9
docker-compose.yml
Normal file
9
docker-compose.yml
Normal file
@ -0,0 +1,9 @@
|
||||
version: "3.0"
|
||||
|
||||
services:
|
||||
chaturbate-dvr:
|
||||
build: .
|
||||
environment:
|
||||
- USERNAME=my_lovely_channel_name
|
||||
volumes:
|
||||
- ./video/my_lovely_channel_name:/usr/src/app/video
|
||||
@ -63,10 +63,10 @@ func (h *GetChannelHandler) Handle(c *gin.Context) {
|
||||
ChannelURL: channel.ChannelURL,
|
||||
Filename: channel.Filename(),
|
||||
LastStreamedAt: channel.LastStreamedAt,
|
||||
SegmentDuration: DurationStr(channel.SegmentDuration),
|
||||
SplitDuration: DurationStr(channel.SplitDuration),
|
||||
SegmentFilesize: ByteStr(channel.SegmentFilesize),
|
||||
SplitFilesize: MBStr(channel.SplitFilesize),
|
||||
SegmentDuration: channel.SegmentDurationStr(),
|
||||
SplitDuration: channel.SplitDurationStr(),
|
||||
SegmentFilesize: channel.SegmentFilesizeStr(),
|
||||
SplitFilesize: channel.SplitFilesizeStr(),
|
||||
IsOnline: channel.IsOnline,
|
||||
IsPaused: channel.IsPaused,
|
||||
Logs: channel.Logs,
|
||||
|
||||
@ -15,6 +15,7 @@ type GetSettingsHandlerRequest struct {
|
||||
}
|
||||
|
||||
type GetSettingsHandlerResponse struct {
|
||||
Version string `json:"version"`
|
||||
Framerate int `json:"framerate"`
|
||||
Resolution int `json:"resolution"`
|
||||
ResolutionFallback string `json:"resolution_fallback"`
|
||||
@ -49,6 +50,7 @@ func (h *GetSettingsHandler) Handle(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, &GetSettingsHandlerResponse{
|
||||
Version: h.cli.App.Version,
|
||||
Framerate: h.cli.Int("framerate"),
|
||||
Resolution: h.cli.Int("resolution"),
|
||||
ResolutionFallback: h.cli.String("resolution-fallback"),
|
||||
|
||||
@ -70,10 +70,10 @@ func (h *ListChannelsHandler) Handle(c *gin.Context) {
|
||||
ChannelURL: channel.ChannelURL,
|
||||
Filename: channel.Filename(),
|
||||
LastStreamedAt: channel.LastStreamedAt,
|
||||
SegmentDuration: DurationStr(channel.SegmentDuration),
|
||||
SplitDuration: DurationStr(channel.SplitDuration),
|
||||
SegmentFilesize: ByteStr(channel.SegmentFilesize),
|
||||
SplitFilesize: MBStr(channel.SplitFilesize),
|
||||
SegmentDuration: channel.SegmentDurationStr(),
|
||||
SplitDuration: channel.SplitDurationStr(),
|
||||
SegmentFilesize: channel.SegmentFilesizeStr(),
|
||||
SplitFilesize: channel.SplitFilesizeStr(),
|
||||
IsOnline: channel.IsOnline,
|
||||
IsPaused: channel.IsPaused,
|
||||
Logs: channel.Logs,
|
||||
|
||||
@ -46,8 +46,9 @@ func (h *ListenUpdateHandler) Handle(c *gin.Context) {
|
||||
"is_paused": update.IsPaused,
|
||||
"is_online": update.IsOnline,
|
||||
"last_streamed_at": update.LastStreamedAt,
|
||||
"segment_duration": DurationStr(update.SegmentDuration),
|
||||
"segment_filesize": ByteStr(update.SegmentFilesize),
|
||||
"segment_duration": update.SegmentDurationStr(),
|
||||
"segment_filesize": update.SegmentFilesizeStr(),
|
||||
"filename": update.Filename,
|
||||
})
|
||||
return true
|
||||
})
|
||||
|
||||
46
handler/terminate_program.go
Normal file
46
handler/terminate_program.go
Normal file
@ -0,0 +1,46 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
//=======================================================
|
||||
// Request & Response
|
||||
//=======================================================
|
||||
|
||||
type TerminateProgramRequest struct {
|
||||
}
|
||||
|
||||
type TerminateProgramResponse struct {
|
||||
}
|
||||
|
||||
//=======================================================
|
||||
// Factory
|
||||
//=======================================================
|
||||
|
||||
type TerminateProgramHandler struct {
|
||||
cli *cli.Context
|
||||
}
|
||||
|
||||
func NewTerminateProgramHandler(cli *cli.Context) *TerminateProgramHandler {
|
||||
return &TerminateProgramHandler{cli}
|
||||
}
|
||||
|
||||
//=======================================================
|
||||
// Handle
|
||||
//=======================================================
|
||||
|
||||
func (h *TerminateProgramHandler) Handle(c *gin.Context) {
|
||||
var req *TerminateProgramRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.AbortWithError(http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
log.Println("program terminated by user request, see ya 👋")
|
||||
os.Exit(0)
|
||||
}
|
||||
@ -1,23 +0,0 @@
|
||||
package handler
|
||||
|
||||
import "fmt"
|
||||
|
||||
func DurationStr(seconds int) string {
|
||||
hours := seconds / 3600
|
||||
seconds %= 3600
|
||||
minutes := seconds / 60
|
||||
seconds %= 60
|
||||
return fmt.Sprintf("%02d:%02d:%02d", hours, minutes, seconds)
|
||||
}
|
||||
|
||||
func ByteStr(bytes int) string {
|
||||
return fmt.Sprintf("%.2f MiB", float64(bytes)/1024/1024)
|
||||
}
|
||||
|
||||
func KBStr(kibs int) string {
|
||||
return fmt.Sprintf("%.2f MiB", float64(kibs)/1024)
|
||||
}
|
||||
|
||||
func MBStr(mibs int) string {
|
||||
return fmt.Sprintf("%.2f MiB", float64(mibs))
|
||||
}
|
||||
@ -3,9 +3,9 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="stylesheet" href="./tocas/tocas.min.css" />
|
||||
<script src="./tocas/tocas.min.js"></script>
|
||||
<script src="./script.js"></script>
|
||||
<link rel="stylesheet" href="/static/tocas/tocas.min.css" />
|
||||
<script src="/static/tocas/tocas.min.js"></script>
|
||||
<script src="/static/script.js"></script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+TC:wght@400;500;700&display=swap" rel="stylesheet" />
|
||||
@ -16,6 +16,7 @@
|
||||
<!-- Create Dialog -->
|
||||
<dialog id="create-dialog" class="ts-modal is-large" data-clickaway="close">
|
||||
<div class="content">
|
||||
<!-- Header -->
|
||||
<div class="ts-content is-horizontally-padded is-secondary">
|
||||
<div class="ts-grid">
|
||||
<div class="column is-fluid">
|
||||
@ -26,8 +27,13 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- / Header -->
|
||||
|
||||
<div class="ts-divider"></div>
|
||||
|
||||
<!-- Form -->
|
||||
<div class="ts-content is-vertically-padded">
|
||||
<!-- Field: Channel Username -->
|
||||
<div class="ts-control is-wide">
|
||||
<div class="label">Channel Username</div>
|
||||
<div class="content">
|
||||
@ -37,7 +43,9 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- / Field: Channel Username -->
|
||||
|
||||
<!-- Field: Resolution -->
|
||||
<div class="ts-control is-wide has-top-spaced-large">
|
||||
<div class="label">Resolution</div>
|
||||
<div class="content">
|
||||
@ -76,6 +84,9 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- / Field: Resolution -->
|
||||
|
||||
<!-- Field: Framerate -->
|
||||
<div class="ts-control is-wide has-top-spaced-large">
|
||||
<div class="label">Framerate</div>
|
||||
<div class="content">
|
||||
@ -94,6 +105,9 @@
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<!-- / Field: Framerate -->
|
||||
|
||||
<!-- Field: Filename Pattern -->
|
||||
<div class="ts-control is-wide has-top-spaced-large">
|
||||
<div class="label">Filename Pattern</div>
|
||||
<div class="content">
|
||||
@ -105,9 +119,11 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- / Field: Filename Pattern -->
|
||||
|
||||
<div class="ts-divider has-vertically-spaced-large"></div>
|
||||
|
||||
<!-- Field: Splitting Options -->
|
||||
<div class="ts-control is-wide has-top-spaced">
|
||||
<div class="label"></div>
|
||||
<div class="content">
|
||||
@ -135,28 +151,35 @@
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
<!-- / Field: Splitting Options -->
|
||||
</div>
|
||||
<!-- / Form -->
|
||||
|
||||
<div class="ts-divider"></div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="ts-content is-secondary is-horizontally-padded">
|
||||
<div class="ts-wrap is-end-aligned">
|
||||
<button class="ts-button is-outlined is-secondary" x-on:click="closeCreateDialog">Cancel</button>
|
||||
<button class="ts-button is-primary" x-on:click="submitCreateDialog">Add Channel</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- / Footer -->
|
||||
</div>
|
||||
</dialog>
|
||||
<!-- / Create Dialog -->
|
||||
|
||||
<div class="ts-container is-narrow has-vertically-spaced-big">
|
||||
<!-- Main Section -->
|
||||
<div class="ts-container is-narrow has-vertically-padded-big">
|
||||
<!-- Header -->
|
||||
<div class="ts-grid is-bottom-aligned">
|
||||
<div class="column is-fluid">
|
||||
<div class="ts-header is-huge is-uppercased is-heavy has-leading-small">Chaturbate DVR</div>
|
||||
<div class="ts-text is-description is-bold">Version 1.0.0</div>
|
||||
<div class="ts-text is-description is-bold">Version <span x-text="settings.version"></span></div>
|
||||
</div>
|
||||
<div class="column">
|
||||
<div class="ts-wrap">
|
||||
<button class="ts-button is-outlined is-negative is-start-icon">
|
||||
<button class="ts-button is-outlined is-negative is-start-icon" x-on:click="terminateProgram()">
|
||||
<span class="ts-icon is-hand-icon"></span>
|
||||
Terminate
|
||||
</button>
|
||||
@ -173,10 +196,9 @@
|
||||
<template x-if="channels.length === 0">
|
||||
<div>
|
||||
<div class="ts-divider has-vertically-spaced-large"></div>
|
||||
|
||||
<div class="ts-blankslate">
|
||||
<span class="ts-icon is-eye-low-vision-icon"></span>
|
||||
<div class="header">No channel was recording.</div>
|
||||
<div class="header">No channel was recording</div>
|
||||
<div class="description">Add a new Chaturbate channel to start the recording.</div>
|
||||
<div class="action">
|
||||
<button class="ts-button is-start-icon" x-on:click="openCreateDialog">
|
||||
@ -198,10 +220,12 @@
|
||||
<!-- / Divider -->
|
||||
|
||||
<div class="ts-wrap is-vertical is-relaxed">
|
||||
<!-- Each Channel -->
|
||||
<!-- Channel -->
|
||||
<template x-for="channel in channels" :key="channel.username">
|
||||
<div class="ts-box is-horizontal">
|
||||
<!-- Left Section -->
|
||||
<div class="ts-content is-padded" style="flex: 1.8; display: flex; flex-direction: column">
|
||||
<!-- Header -->
|
||||
<div class="ts-grid is-middle-aligned">
|
||||
<div class="column is-fluid">
|
||||
<div class="ts-header">
|
||||
@ -221,13 +245,23 @@
|
||||
<button class="ts-button is-secondary is-short is-outlined is-dense" x-on:click="downloadLogs(channel.username)">Download Logs</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- / Header -->
|
||||
|
||||
<!-- Logs -->
|
||||
<div class="ts-input has-top-spaced" style="flex: 1">
|
||||
<textarea class="has-full-height" x-bind:id="`${channel.username}-logs`" x-text="channel.logs.join('\n')" readonly></textarea>
|
||||
</div>
|
||||
<!-- / Logs -->
|
||||
</div>
|
||||
<!-- / Left Section -->
|
||||
|
||||
<div class="ts-divider is-vertical"></div>
|
||||
|
||||
<!-- Right Section -->
|
||||
<div class="ts-content is-padded has-break-all" style="flex: 1; min-width: 300px">
|
||||
<div class="ts-text is-description is-uppercased">Information</div>
|
||||
|
||||
<!-- Info: Channel URL -->
|
||||
<div class="ts-grid has-top-spaced-large">
|
||||
<div class="column has-leading-none" style="width: 16px">
|
||||
<span class="ts-icon is-link-icon"></span>
|
||||
@ -237,12 +271,15 @@
|
||||
<a class="ts-text is-link is-external-link" x-bind:href="channel.channel_url" x-text="channel.channel_url" target="_blank"></a>
|
||||
</div>
|
||||
</div>
|
||||
<!-- / Info: Channel URL -->
|
||||
|
||||
<!-- Info: Filename -->
|
||||
<div class="ts-grid has-top-spaced">
|
||||
<div class="column has-leading-none" style="width: 16px">
|
||||
<span class="ts-icon is-folder-icon"></span>
|
||||
</div>
|
||||
<div class="column is-fluid has-leading-small">
|
||||
<div class="ts-text is-label">Saved to</div>
|
||||
<div class="ts-text is-label">Filename</div>
|
||||
|
||||
<template x-if="channel.filename">
|
||||
<code class="ts-text is-code" x-text="channel.filename"></code>
|
||||
@ -252,6 +289,9 @@
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<!-- / Info: Filename -->
|
||||
|
||||
<!-- Info: Last streamed at -->
|
||||
<div class="ts-grid has-top-spaced">
|
||||
<div class="column has-leading-none" style="width: 16px">
|
||||
<span class="ts-icon is-tower-broadcast-icon"></span>
|
||||
@ -266,6 +306,9 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- / Info: Last streamed at -->
|
||||
|
||||
<!-- Info: Segment duration -->
|
||||
<div class="ts-grid has-top-spaced">
|
||||
<div class="column has-leading-none" style="width: 16px">
|
||||
<span class="ts-icon is-clock-icon"></span>
|
||||
@ -280,6 +323,9 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- / Info: Segment duration -->
|
||||
|
||||
<!-- Info: Segment filesize -->
|
||||
<div class="ts-grid has-top-spaced">
|
||||
<div class="column has-leading-none" style="width: 16px">
|
||||
<span class="ts-icon is-chart-pie-icon"></span>
|
||||
@ -294,11 +340,17 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- / Info: Segment filesize -->
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="ts-grid is-2-columns has-top-spaced-large">
|
||||
<div class="column">
|
||||
<template x-if="!channel.is_paused">
|
||||
<button class="ts-button is-start-icon is-secondary is-fluid" x-on:click="pauseChannel(channel.username)">
|
||||
<button
|
||||
class="ts-button is-start-icon is-secondary is-fluid"
|
||||
x-bind:disabled="!channel.is_online"
|
||||
x-on:click="pauseChannel(channel.username)"
|
||||
>
|
||||
<span class="ts-icon is-pause-icon"></span>
|
||||
Pause
|
||||
</button>
|
||||
@ -312,7 +364,7 @@
|
||||
</div>
|
||||
<div class="column">
|
||||
<button
|
||||
class="ts-button is-start-icon is-secondary is-fluid"
|
||||
class="ts-button is-start-icon is-secondary is-negative is-fluid"
|
||||
data-tooltip="Stop and remove the channel from the list."
|
||||
x-on:click="deleteChannel(channel.username)"
|
||||
>
|
||||
@ -321,11 +373,14 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- / Actions -->
|
||||
</div>
|
||||
<!-- / Right Section -->
|
||||
</div>
|
||||
<!-- / Each Channel -->
|
||||
</template>
|
||||
<!-- / Channel -->
|
||||
</div>
|
||||
</div>
|
||||
<!-- / Main Section -->
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -38,7 +38,7 @@ function data() {
|
||||
//
|
||||
async call(path, body) {
|
||||
try {
|
||||
var resp = await fetch(`http://localhost:8080/api/${path}`, {
|
||||
var resp = await fetch(`/api/${path}`, {
|
||||
body: JSON.stringify(body),
|
||||
method: "POST",
|
||||
})
|
||||
@ -101,6 +101,9 @@ function data() {
|
||||
|
||||
// deleteChannel
|
||||
async deleteChannel(username) {
|
||||
if (!confirm(`Are you sure you want to delete the channel "${username}"?`)) {
|
||||
return
|
||||
}
|
||||
var [_, err] = await this.call("delete_channel", { username })
|
||||
if (!err) {
|
||||
this.channels = this.channels.filter(ch => ch.username !== username)
|
||||
@ -112,6 +115,15 @@ function data() {
|
||||
await this.call("pause_channel", { username })
|
||||
},
|
||||
|
||||
// terminateProgram
|
||||
async terminateProgram() {
|
||||
if (confirm("Are you sure you want to terminate the program?")) {
|
||||
alert("The program is terminated, any error messages are safe to ignore.")
|
||||
await this.call("terminate_program", {})
|
||||
}
|
||||
|
||||
},
|
||||
|
||||
// resumeChannel
|
||||
async resumeChannel(username) {
|
||||
await this.call("resume_channel", { username })
|
||||
@ -134,7 +146,7 @@ function data() {
|
||||
|
||||
// listenUpdate
|
||||
listenUpdate() {
|
||||
var source = new EventSource("http://localhost:8080/api/listen_update")
|
||||
var source = new EventSource("/api/listen_update")
|
||||
|
||||
source.onmessage = event => {
|
||||
var data = JSON.parse(event.data)
|
||||
@ -174,13 +186,9 @@ function data() {
|
||||
downloadLogs(username) {
|
||||
var a = window.document.createElement('a');
|
||||
a.href = window.URL.createObjectURL(new Blob([this.channels[this.channels.findIndex(ch => ch.username === username)].logs.join('\n')], { type: 'text/plain', oneTimeOnly: true }));
|
||||
a.download = 'test.txt';
|
||||
|
||||
// Append anchor to body.
|
||||
a.download = `${username}_logs.txt`
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
|
||||
// Remove anchor from body
|
||||
document.body.removeChild(a);
|
||||
},
|
||||
|
||||
|
||||
81
main.go
81
main.go
@ -1,21 +1,38 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/gin-contrib/cors"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/teacat/chaturbate-dvr/chaturbate"
|
||||
"github.com/teacat/chaturbate-dvr/handler"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
const logo = `
|
||||
██████╗██╗ ██╗ █████╗ ████████╗██╗ ██╗██████╗ ██████╗ █████╗ ████████╗███████╗
|
||||
██╔════╝██║ ██║██╔══██╗╚══██╔══╝██║ ██║██╔══██╗██╔══██╗██╔══██╗╚══██╔══╝██╔════╝
|
||||
██║ ███████║███████║ ██║ ██║ ██║██████╔╝██████╔╝███████║ ██║ █████╗
|
||||
██║ ██╔══██║██╔══██║ ██║ ██║ ██║██╔══██╗██╔══██╗██╔══██║ ██║ ██╔══╝
|
||||
╚██████╗██║ ██║██║ ██║ ██║ ╚██████╔╝██║ ██║██████╔╝██║ ██║ ██║ ███████╗
|
||||
╚═════╝╚═╝ ╚═╝╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝╚═════╝ ╚═╝ ╚═╝ ╚═╝ ╚══════╝
|
||||
██████╗ ██╗ ██╗██████╗
|
||||
██╔══██╗██║ ██║██╔══██╗
|
||||
██║ ██║██║ ██║██████╔╝
|
||||
██║ ██║╚██╗ ██╔╝██╔══██╗
|
||||
██████╔╝ ╚████╔╝ ██║ ██║
|
||||
╚═════╝ ╚═══╝ ╚═╝ ╚═╝`
|
||||
|
||||
func main() {
|
||||
app := &cli.App{
|
||||
Name: "chaturbate-dvr",
|
||||
Usage: "",
|
||||
Name: "chaturbate-dvr",
|
||||
Version: "1.0.0",
|
||||
Usage: "Records your favorite Chaturbate stream 😎🫵",
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "username",
|
||||
@ -61,25 +78,24 @@ func main() {
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "log-level",
|
||||
Usage: "log level, availables: 'debug', 'info', 'error'",
|
||||
Value: "info",
|
||||
Usage: "log level, availables: 'DEBUG', 'INFO', 'WARN', 'ERROR'",
|
||||
Value: "INFO",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "port",
|
||||
Usage: "port to expose the web interface and API",
|
||||
Value: "8080",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "gui",
|
||||
Usage: "enabling GUI, availables: 'none', 'web'",
|
||||
Value: "none",
|
||||
},
|
||||
//&cli.StringFlag{
|
||||
// Name: "gui",
|
||||
// Usage: "enabling GUI, availables: 'no', 'web'",
|
||||
// Value: "web",
|
||||
//},
|
||||
},
|
||||
Action: start,
|
||||
Commands: []*cli.Command{
|
||||
{
|
||||
Name: "start",
|
||||
Usage: "",
|
||||
Action: start,
|
||||
},
|
||||
},
|
||||
@ -90,12 +106,46 @@ func main() {
|
||||
}
|
||||
|
||||
func start(c *cli.Context) error {
|
||||
r := gin.Default()
|
||||
r.Use(cors.Default())
|
||||
m, err := chaturbate.NewManager()
|
||||
if err != nil {
|
||||
fmt.Println(logo)
|
||||
|
||||
//if c.String("gui") == "web" {
|
||||
if c.String("username") == "" {
|
||||
return startWeb(c)
|
||||
}
|
||||
|
||||
m := chaturbate.NewManager(c)
|
||||
if err := m.CreateChannel(&chaturbate.Config{
|
||||
Username: c.String("username"),
|
||||
Framerate: c.Int("framerate"),
|
||||
Resolution: c.Int("resolution"),
|
||||
ResolutionFallback: c.String("resolution-fallback"),
|
||||
FilenamePattern: c.String("filename-pattern"),
|
||||
SplitDuration: c.Int("split-duration"),
|
||||
SplitFilesize: c.Int("split-filesize"),
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
select {} // block forever
|
||||
}
|
||||
|
||||
//go:embed handler/view
|
||||
var FS embed.FS
|
||||
|
||||
func startWeb(c *cli.Context) error {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
r := gin.Default()
|
||||
|
||||
//r.Use(cors.Default())
|
||||
m := chaturbate.NewManager(c)
|
||||
|
||||
fe, err := fs.Sub(FS, "handler/view")
|
||||
if err != nil {
|
||||
log.Fatalln(err)
|
||||
}
|
||||
r.StaticFS("/static", http.FS(fe))
|
||||
r.StaticFileFS("/", "/", http.FS(fe))
|
||||
|
||||
r.POST("/api/get_channel", handler.NewGetChannelHandler(m, c).Handle)
|
||||
r.POST("/api/create_channel", handler.NewCreateChannelHandler(m, c).Handle)
|
||||
r.POST("/api/list_channels", handler.NewListChannelsHandler(m, c).Handle)
|
||||
@ -104,6 +154,7 @@ func start(c *cli.Context) error {
|
||||
r.POST("/api/resume_channel", handler.NewResumeChannelHandler(m, c).Handle)
|
||||
r.GET("/api/listen_update", handler.NewListenUpdateHandler(m, c).Handle)
|
||||
r.POST("/api/get_settings", handler.NewGetSettingsHandler(c).Handle)
|
||||
r.POST("/api/terminate_program", handler.NewTerminateProgramHandler(c).Handle)
|
||||
|
||||
return r.Run(fmt.Sprintf(":%s", c.String("port")))
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user