mirror of
https://github.com/teacat/chaturbate-dvr.git
synced 2025-10-29 16:59:59 +00:00
Updates and refactors
Channel_file.go - fix issue with segments not correctly ending when they were supposed to Log_type.go - moved log type to it's own file, setup global logging (touches on issue #47) Main.go - added update_log_level handler, setting global log level Channel.go, channel_internal.go, channel_util.go - updated to use new log_type Manager.go - updated to use new log_type, update from .com to .global (issue #74) Channel_update.go, create_channel.go, delete_channel.go, get_channel.go, get_settings.go, listen_update.go, pause_channel.go, resume_channel.go, terminal_program.go - go fmt / go vet Chaturbate_channels.json.sample - added sample json of the channels file, for mapping in docker config List_channels.go - refactored to sort by online status, so online is always at the first ones you see Script.js - adjust default settings, added pagination, added global log logic Index.html - updated to use online version of tocas ui, added pagination, added global log logic, visual improvements Removal of local tocas folder since using online version
This commit is contained in:
parent
9fb2916117
commit
3bdae1b872
@ -38,7 +38,7 @@ type Channel struct {
|
||||
IsPaused bool
|
||||
isStopped bool
|
||||
Logs []string
|
||||
logType logType
|
||||
LogType LogType
|
||||
|
||||
bufferLock sync.Mutex
|
||||
buffer map[int][]byte
|
||||
@ -61,32 +61,32 @@ type Channel struct {
|
||||
// Run
|
||||
func (w *Channel) Run() {
|
||||
if w.Username == "" {
|
||||
w.log(logTypeError, "username is empty, use `-u USERNAME` to specify")
|
||||
w.log(LogTypeError, "username is empty, use `-u USERNAME` to specify")
|
||||
return
|
||||
}
|
||||
|
||||
for {
|
||||
if w.IsPaused {
|
||||
w.log(logTypeInfo, "channel is paused")
|
||||
w.log(LogTypeInfo, "channel is paused")
|
||||
<-w.ResumeChannel // blocking
|
||||
w.log(logTypeInfo, "channel is resumed")
|
||||
w.log(LogTypeInfo, "channel is resumed")
|
||||
}
|
||||
if w.isStopped {
|
||||
w.log(logTypeInfo, "channel is stopped")
|
||||
w.log(LogTypeInfo, "channel is stopped")
|
||||
break
|
||||
}
|
||||
|
||||
body, err := w.requestChannelBody()
|
||||
if err != nil {
|
||||
w.log(logTypeError, "body request error: %w", err)
|
||||
w.log(LogTypeError, "body request error: %v", err)
|
||||
}
|
||||
if strings.Contains(body, "playlist.m3u8") {
|
||||
w.IsOnline = true
|
||||
w.LastStreamedAt = time.Now().Format("2006-01-02 15:04:05")
|
||||
w.log(logTypeInfo, "channel is online, start fetching...")
|
||||
w.log(LogTypeInfo, "channel is online, start fetching...")
|
||||
|
||||
if err := w.record(body); err != nil { // blocking
|
||||
w.log(logTypeError, "record error: %w", err)
|
||||
w.log(LogTypeError, "record error: %v", err)
|
||||
}
|
||||
continue // this excutes when recording is over/interrupted
|
||||
}
|
||||
@ -95,11 +95,11 @@ func (w *Channel) Run() {
|
||||
// close file when offline so user can move/delete it
|
||||
if w.file != nil {
|
||||
if err := w.releaseFile(); err != nil {
|
||||
w.log(logTypeError, "release file: %w", err)
|
||||
w.log(LogTypeError, "release file: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
w.log(logTypeInfo, "channel is offline, check again %d min(s) later", w.Interval)
|
||||
w.log(LogTypeInfo, "channel is offline, check again %d min(s) later", w.Interval)
|
||||
<-time.After(time.Duration(w.Interval) * time.Minute) // minutes cooldown to check online status
|
||||
}
|
||||
}
|
||||
|
||||
@ -9,11 +9,10 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// filename
|
||||
// filename generates the filename based on the session pattern and current split index.
|
||||
func (w *Channel) filename() (string, error) {
|
||||
data := w.sessionPattern
|
||||
if data == nil {
|
||||
data = map[string]any{
|
||||
if w.sessionPattern == nil {
|
||||
w.sessionPattern = map[string]any{
|
||||
"Username": w.Username,
|
||||
"Year": time.Now().Format("2006"),
|
||||
"Month": time.Now().Format("01"),
|
||||
@ -23,69 +22,82 @@ func (w *Channel) filename() (string, error) {
|
||||
"Second": time.Now().Format("05"),
|
||||
"Sequence": 0,
|
||||
}
|
||||
w.sessionPattern = data
|
||||
} else {
|
||||
data["Sequence"] = w.splitIndex
|
||||
}
|
||||
t, err := template.New("filename").Parse(w.filenamePattern)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
w.sessionPattern["Sequence"] = w.splitIndex
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := t.Execute(&buf, data); err != nil {
|
||||
return "", err
|
||||
tmpl, err := template.New("filename").Parse(w.filenamePattern)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("filename pattern error: %w", err)
|
||||
}
|
||||
if err := tmpl.Execute(&buf, w.sessionPattern); err != nil {
|
||||
return "", fmt.Errorf("template execution error: %w", err)
|
||||
}
|
||||
|
||||
return buf.String(), nil
|
||||
}
|
||||
|
||||
// newFile
|
||||
// newFile creates a new file and prepares it for writing stream data.
|
||||
func (w *Channel) newFile() error {
|
||||
filename, err := w.filename()
|
||||
if err != nil {
|
||||
return fmt.Errorf("filename pattern error: %w", err)
|
||||
return err
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(filename), 0777); err != nil {
|
||||
return fmt.Errorf("create folder: %w", err)
|
||||
}
|
||||
|
||||
file, err := os.OpenFile(filename+".ts", os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0777)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot open file: %s: %w", filename, err)
|
||||
}
|
||||
w.log(logTypeInfo, "the stream will be saved as %s.ts", filename)
|
||||
|
||||
w.log(LogTypeInfo, "the stream will be saved as %s.ts", filename)
|
||||
w.file = file
|
||||
return nil
|
||||
}
|
||||
|
||||
// releaseFile
|
||||
// releaseFile closes the current file and removes it if empty.
|
||||
func (w *Channel) releaseFile() error {
|
||||
if w.file == nil {
|
||||
return nil
|
||||
}
|
||||
// close the file to remove it
|
||||
|
||||
if err := w.file.Close(); err != nil {
|
||||
return fmt.Errorf("close file: %s: %w", w.file.Name(), err)
|
||||
}
|
||||
// remove it if it was empty
|
||||
if w.SegmentFilesize == 0 {
|
||||
w.log(logTypeInfo, "%s was removed because it was empty", w.file.Name())
|
||||
|
||||
if w.SegmentFilesize == 0 {
|
||||
w.log(LogTypeInfo, "%s was removed because it was empty", w.file.Name())
|
||||
if err := os.Remove(w.file.Name()); err != nil {
|
||||
return fmt.Errorf("remove zero file: %s: %w", w.file.Name(), err)
|
||||
}
|
||||
}
|
||||
|
||||
w.file = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
// nextFile
|
||||
func (w *Channel) nextFile() error {
|
||||
// nextFile handles the transition to a new file segment, ensuring correct timing.
|
||||
func (w *Channel) nextFile(startTime time.Time) error {
|
||||
// Release the current file before creating a new one.
|
||||
if err := w.releaseFile(); err != nil {
|
||||
w.log(logTypeError, "release file: %w", err)
|
||||
w.log(LogTypeError, "release file: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
// Increment the split index for the next file.
|
||||
w.splitIndex++
|
||||
w.SegmentFilesize = 0
|
||||
w.SegmentDuration = 0
|
||||
|
||||
// Reset segment data.
|
||||
w.SegmentFilesize = 0
|
||||
|
||||
// Calculate the actual segment duration using the elapsed time.
|
||||
elapsed := int(time.Since(startTime).Minutes())
|
||||
w.SegmentDuration = elapsed
|
||||
|
||||
// Create the new file.
|
||||
return w.newFile()
|
||||
}
|
||||
|
||||
@ -175,17 +175,17 @@ func (w *Channel) resolveSource(body string) (string, string, error) {
|
||||
if variant == nil {
|
||||
return "", "", fmt.Errorf("no available resolution")
|
||||
}
|
||||
w.log(logTypeInfo, "resolution %dp is used", variant.width)
|
||||
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)
|
||||
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.log(LogTypeWarning, "framerate %dfps not found, fallback to %dfps", w.Framerate, k)
|
||||
w.Framerate = k
|
||||
break
|
||||
}
|
||||
@ -196,66 +196,86 @@ func (w *Channel) resolveSource(body string) (string, string, error) {
|
||||
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.
|
||||
// mergeSegments runs in the background and merges segments from the buffer to the file.
|
||||
func (w *Channel) mergeSegments() {
|
||||
var segmentRetries int
|
||||
startTime := time.Now() // Track the start time of the current segment.
|
||||
|
||||
for {
|
||||
if w.IsPaused || w.isStopped {
|
||||
break
|
||||
}
|
||||
|
||||
// Handle segment retries if not found.
|
||||
if segmentRetries > 5 {
|
||||
w.log(logTypeWarning, "segment #%d not found in buffer, skipped", w.bufferIndex)
|
||||
w.log(LogTypeWarning, "segment #%d not found in buffer, skipped", w.bufferIndex)
|
||||
w.bufferIndex++
|
||||
segmentRetries = 0
|
||||
continue
|
||||
}
|
||||
|
||||
// If buffer is empty, wait and retry.
|
||||
if len(w.buffer) == 0 {
|
||||
<-time.After(1 * time.Second)
|
||||
time.Sleep(1 * time.Second)
|
||||
continue
|
||||
}
|
||||
|
||||
// Retrieve segment from buffer.
|
||||
w.bufferLock.Lock()
|
||||
buf, ok := w.buffer[w.bufferIndex]
|
||||
w.bufferLock.Unlock()
|
||||
|
||||
if !ok {
|
||||
segmentRetries++
|
||||
<-time.After(time.Duration(segmentRetries) * time.Second)
|
||||
time.Sleep(time.Duration(segmentRetries) * time.Second)
|
||||
continue
|
||||
}
|
||||
|
||||
// Write the segment to the file.
|
||||
lens, err := w.file.Write(buf)
|
||||
if err != nil {
|
||||
w.log(logTypeError, "segment #%d written error: %v", w.bufferIndex, err)
|
||||
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))
|
||||
|
||||
// Update segment size and log progress.
|
||||
w.SegmentFilesize += lens
|
||||
segmentRetries = 0
|
||||
w.log(LogTypeInfo, "segment #%d written", w.bufferIndex)
|
||||
w.log(LogTypeDebug, "duration: %s, size: %s", DurationStr(w.SegmentDuration), ByteStr(w.SegmentFilesize))
|
||||
|
||||
// Check if the file size limit has been reached.
|
||||
if w.SplitFilesize > 0 && w.SegmentFilesize >= w.SplitFilesize*1024*1024 {
|
||||
w.log(logTypeInfo, "filesize exceeded, creating new file")
|
||||
w.log(LogTypeInfo, "filesize exceeded, creating new file")
|
||||
|
||||
if err := w.nextFile(); err != nil {
|
||||
w.log(logTypeError, "next file error: %v", err)
|
||||
if err := w.nextFile(startTime); 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
|
||||
}
|
||||
startTime = time.Now() // Reset start time for the new segment.
|
||||
}
|
||||
|
||||
// Check if the duration limit has been reached.
|
||||
elapsed := int(time.Since(startTime).Minutes())
|
||||
if w.SplitDuration > 0 && elapsed >= w.SplitDuration {
|
||||
w.log(LogTypeInfo, "duration exceeded, creating new file")
|
||||
|
||||
if err := w.nextFile(startTime); err != nil {
|
||||
w.log(LogTypeError, "next file error: %v", err)
|
||||
break
|
||||
}
|
||||
|
||||
startTime = time.Now() // Reset start time for the new segment.
|
||||
}
|
||||
|
||||
// Remove the processed segment from the buffer.
|
||||
w.bufferLock.Lock()
|
||||
delete(w.buffer, w.bufferIndex)
|
||||
w.bufferLock.Unlock()
|
||||
|
||||
w.bufferIndex++
|
||||
w.bufferIndex++ // Move to the next segment.
|
||||
segmentRetries = 0 // Reset retries for the next segment.
|
||||
}
|
||||
}
|
||||
|
||||
@ -276,7 +296,7 @@ func (w *Channel) fetchSegments() {
|
||||
break
|
||||
}
|
||||
|
||||
w.log(logTypeError, "segment list error, will try again [%d/10]: %v", disconnectRetries, err)
|
||||
w.log(LogTypeError, "segment list error, will try again [%d/10]: %v", disconnectRetries, err)
|
||||
disconnectRetries++
|
||||
|
||||
<-time.After(time.Duration(wait) * time.Second)
|
||||
@ -284,7 +304,7 @@ func (w *Channel) fetchSegments() {
|
||||
}
|
||||
|
||||
if disconnectRetries > 0 {
|
||||
w.log(logTypeInfo, "channel is back online!")
|
||||
w.log(LogTypeInfo, "channel is back online!")
|
||||
w.IsOnline = true
|
||||
disconnectRetries = 0
|
||||
}
|
||||
@ -296,7 +316,7 @@ func (w *Channel) fetchSegments() {
|
||||
|
||||
go func(index int, uri string) {
|
||||
if err := w.requestSegment(uri, index); err != nil {
|
||||
w.log(logTypeError, "segment #%d request error, ignored: %v", index, err)
|
||||
w.log(LogTypeError, "segment #%d request error, ignored: %v", index, err)
|
||||
return
|
||||
}
|
||||
}(w.segmentIndex, v.URI)
|
||||
@ -379,7 +399,7 @@ func (w *Channel) requestSegment(url string, index int) error {
|
||||
return fmt.Errorf("read body: %w", err)
|
||||
}
|
||||
|
||||
w.log(logTypeDebug, "segment #%d fetched", index)
|
||||
w.log(LogTypeDebug, "segment #%d fetched", index)
|
||||
|
||||
w.bufferLock.Lock()
|
||||
w.buffer[index] = body
|
||||
|
||||
@ -5,34 +5,28 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type logType string
|
||||
|
||||
const (
|
||||
logTypeDebug logType = "DEBUG"
|
||||
logTypeInfo logType = "INFO"
|
||||
logTypeWarning logType = "WARN"
|
||||
logTypeError logType = "ERROR"
|
||||
)
|
||||
|
||||
// log
|
||||
func (w *Channel) log(typ logType, message string, v ...interface{}) {
|
||||
switch w.logType {
|
||||
case logTypeInfo:
|
||||
if typ == logTypeDebug {
|
||||
func (w *Channel) log(typ LogType, message string, v ...interface{}) {
|
||||
// Check the global log level
|
||||
currentLogLevel := GetGlobalLogLevel()
|
||||
|
||||
switch currentLogLevel {
|
||||
case LogTypeInfo:
|
||||
if typ == LogTypeDebug {
|
||||
return
|
||||
}
|
||||
case logTypeWarning:
|
||||
if typ == logTypeDebug || typ == logTypeInfo {
|
||||
case LogTypeWarning:
|
||||
if typ == LogTypeDebug || typ == LogTypeInfo {
|
||||
return
|
||||
}
|
||||
case logTypeError:
|
||||
if typ == logTypeDebug || typ == logTypeInfo || typ == logTypeWarning {
|
||||
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...))
|
||||
updateLog := fmt.Sprintf("[%s] [%s] %s", time.Now().Format("2006-01-02 15:04:05"), typ, fmt.Sprintf(message, v...))
|
||||
consoleLog := fmt.Sprintf("[%s] [%s] [%s] %s", time.Now().Format("2006-01-02 15:04:05"), typ, w.Username, fmt.Sprintf(message, v...))
|
||||
|
||||
update := &Update{
|
||||
Username: w.Username,
|
||||
@ -43,6 +37,7 @@ func (w *Channel) log(typ logType, message string, v ...interface{}) {
|
||||
SegmentDuration: w.SegmentDuration,
|
||||
SegmentFilesize: w.SegmentFilesize,
|
||||
}
|
||||
|
||||
if w.file != nil {
|
||||
update.Filename = w.file.Name()
|
||||
}
|
||||
|
||||
64
chaturbate/log_type.go
Normal file
64
chaturbate/log_type.go
Normal file
@ -0,0 +1,64 @@
|
||||
package chaturbate
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type LogType string
|
||||
|
||||
type LogLevelRequest struct {
|
||||
LogLevel LogType `json:"log_level" binding:"required"`
|
||||
}
|
||||
|
||||
// Define the log types
|
||||
const (
|
||||
LogTypeDebug LogType = "DEBUG"
|
||||
LogTypeInfo LogType = "INFO"
|
||||
LogTypeWarning LogType = "WARN"
|
||||
LogTypeError LogType = "ERROR"
|
||||
)
|
||||
|
||||
// Global log level with mutex protection
|
||||
var (
|
||||
globalLogLevel LogType
|
||||
logMutex sync.RWMutex // Protects global log level access
|
||||
)
|
||||
|
||||
// UnmarshalJSON ensures that LogType is properly parsed from JSON.
|
||||
func (l *LogType) UnmarshalJSON(b []byte) error {
|
||||
var s string
|
||||
if err := json.Unmarshal(b, &s); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
parsed := LogType(strings.ToUpper(s))
|
||||
switch parsed {
|
||||
case LogTypeDebug, LogTypeInfo, LogTypeWarning, LogTypeError:
|
||||
*l = parsed
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("invalid log level: %s", s)
|
||||
}
|
||||
}
|
||||
|
||||
// InitGlobalLogLevel initializes the global log level from settings.
|
||||
func InitGlobalLogLevel(initialLevel LogType) {
|
||||
SetGlobalLogLevel(initialLevel)
|
||||
}
|
||||
|
||||
// SetGlobalLogLevel updates the global log level
|
||||
func SetGlobalLogLevel(level LogType) {
|
||||
logMutex.Lock()
|
||||
defer logMutex.Unlock()
|
||||
globalLogLevel = level
|
||||
}
|
||||
|
||||
// GetGlobalLogLevel retrieves the current global log level
|
||||
func GetGlobalLogLevel() LogType {
|
||||
logMutex.RLock()
|
||||
defer logMutex.RUnlock()
|
||||
return globalLogLevel
|
||||
}
|
||||
@ -95,7 +95,7 @@ func (m *Manager) CreateChannel(conf *Config) error {
|
||||
}
|
||||
c := &Channel{
|
||||
Username: conf.Username,
|
||||
ChannelURL: "https://chaturbate.com/" + conf.Username,
|
||||
ChannelURL: "https://chaturbate.global/" + conf.Username,
|
||||
filenamePattern: conf.FilenamePattern,
|
||||
Framerate: conf.Framerate,
|
||||
Resolution: conf.Resolution,
|
||||
@ -112,7 +112,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")),
|
||||
LogType: LogType(m.cli.String("log-level")),
|
||||
}
|
||||
go func() {
|
||||
for update := range c.UpdateChannel {
|
||||
@ -124,7 +124,7 @@ func (m *Manager) CreateChannel(conf *Config) error {
|
||||
}
|
||||
}()
|
||||
m.Channels[conf.Username] = c
|
||||
c.log(logTypeInfo, "channel created")
|
||||
c.log(LogTypeInfo, "channel created")
|
||||
go c.Run()
|
||||
return nil
|
||||
}
|
||||
|
||||
12
chaturbate_channels.json.sample
Normal file
12
chaturbate_channels.json.sample
Normal file
@ -0,0 +1,12 @@
|
||||
[
|
||||
{
|
||||
"Username": "",
|
||||
"FilenamePattern": "videos/{{.Username}}/{{.Year}}-{{.Month}}-{{.Day}}/{{.Username}}_{{.Hour}}-{{.Minute}}-{{.Second}}{{if .Sequence}}_{{.Sequence}}{{end}}",
|
||||
"Framerate": 30,
|
||||
"Resolution": 1080,
|
||||
"ResolutionFallback": "down",
|
||||
"SplitDuration": 30,
|
||||
"SplitFilesize": 0,
|
||||
"Interval": 1
|
||||
}
|
||||
]
|
||||
28
go.mod
28
go.mod
@ -4,6 +4,7 @@ go 1.22.0
|
||||
|
||||
require (
|
||||
github.com/gin-gonic/gin v1.9.1
|
||||
github.com/go-playground/validator/v10 v10.19.0
|
||||
github.com/google/uuid v1.5.0
|
||||
github.com/grafov/m3u8 v0.12.0
|
||||
github.com/samber/lo v1.39.0
|
||||
@ -11,36 +12,35 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/bytedance/sonic v1.10.1 // indirect
|
||||
github.com/bytedance/sonic v1.11.3 // indirect
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d // indirect
|
||||
github.com/chenzhuoyu/iasm v0.9.0 // indirect
|
||||
github.com/chenzhuoyu/iasm v0.9.1 // indirect
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
|
||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.15.5 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.5 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
|
||||
github.com/kr/pretty v0.3.0 // indirect
|
||||
github.com/leodido/go-urn v1.2.4 // indirect
|
||||
github.com/mattn/go-isatty v0.0.19 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.1.0 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.0 // indirect
|
||||
github.com/rogpeppe/go-internal v1.8.0 // indirect
|
||||
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.11 // indirect
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect
|
||||
golang.org/x/arch v0.5.0 // indirect
|
||||
golang.org/x/crypto v0.18.0 // indirect
|
||||
golang.org/x/arch v0.7.0 // indirect
|
||||
golang.org/x/crypto v0.22.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20220303212507-bbda1eaf7a17 // indirect
|
||||
golang.org/x/net v0.20.0 // indirect
|
||||
golang.org/x/sys v0.16.0 // indirect
|
||||
golang.org/x/net v0.24.0 // indirect
|
||||
golang.org/x/sys v0.19.0 // indirect
|
||||
golang.org/x/text v0.14.0 // indirect
|
||||
google.golang.org/protobuf v1.31.0 // indirect
|
||||
google.golang.org/protobuf v1.33.0 // indirect
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
63
go.sum
63
go.sum
@ -1,21 +1,22 @@
|
||||
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
|
||||
github.com/bytedance/sonic v1.10.0-rc/go.mod h1:ElCzW+ufi8qKqNW0FY314xriJhyJhuoJ3gFZdAHF7NM=
|
||||
github.com/bytedance/sonic v1.10.1 h1:7a1wuFXL1cMy7a3f7/VFcEtriuXQnUBhtoVfOZiaysc=
|
||||
github.com/bytedance/sonic v1.10.1/go.mod h1:iZcSUejdk5aukTND/Eu/ivjQuEL0Cu9/rf50Hi0u/g4=
|
||||
github.com/bytedance/sonic v1.11.3 h1:jRN+yEjakWh8aK5FzrciUHG8OFXK+4/KrAX/ysEtHAA=
|
||||
github.com/bytedance/sonic v1.11.3/go.mod h1:iZcSUejdk5aukTND/Eu/ivjQuEL0Cu9/rf50Hi0u/g4=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d h1:77cEq6EriyTZ0g/qfRdp61a3Uu/AWrgIq2s0ClJV1g0=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d/go.mod h1:8EPpVsBuRksnlj1mLy4AWzRNQYxauNi62uWcE3to6eA=
|
||||
github.com/chenzhuoyu/iasm v0.9.0 h1:9fhXjVzq5hUy2gkhhgHl95zG2cEAhw9OSGs8toWWAwo=
|
||||
github.com/chenzhuoyu/iasm v0.9.0/go.mod h1:Xjy2NpN3h7aUqeqM+woSuuvxmIe6+DDsiNLIrkAmYog=
|
||||
github.com/chenzhuoyu/iasm v0.9.1 h1:tUHQJXo3NhBqw6s33wkGn9SP3bvrWLdlVIJ3hQBL7P0=
|
||||
github.com/chenzhuoyu/iasm v0.9.1/go.mod h1:Xjy2NpN3h7aUqeqM+woSuuvxmIe6+DDsiNLIrkAmYog=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
|
||||
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
|
||||
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
|
||||
@ -26,11 +27,10 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.15.5 h1:LEBecTWb/1j5TNY1YYG2RcOUN3R7NLylN+x8TTueE24=
|
||||
github.com/go-playground/validator/v10 v10.15.5/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
|
||||
github.com/go-playground/validator/v10 v10.19.0 h1:ol+5Fu+cSq9JD7SoSqe04GMI92cbn0+wvQ3bZ8b/AU4=
|
||||
github.com/go-playground/validator/v10 v10.19.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
@ -41,8 +41,8 @@ github.com/grafov/m3u8 v0.12.0/go.mod h1:nqzOkfBiZJENr52zTVd/Dcl03yzphIMbJqkXGu+
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.5 h1:0E5MSMDEoAulmXNFquVs//DdoomxaoTY1kUhbc/qbZg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.5/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
|
||||
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
||||
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||
@ -52,17 +52,17 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
|
||||
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
|
||||
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
|
||||
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4=
|
||||
github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc=
|
||||
github.com/pelletier/go-toml/v2 v2.2.0 h1:QLgLl2yMN7N+ruc31VynXs1vhMZa7CeHHejIeBAsoHo=
|
||||
github.com/pelletier/go-toml/v2 v2.2.0/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
||||
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
@ -76,43 +76,42 @@ github.com/samber/lo v1.39.0/go.mod h1:+m/ZKRl6ClXCE2Lgf3MsQlWfh4bn1bz6CXEOxnEXn
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
|
||||
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
||||
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
github.com/urfave/cli/v2 v2.27.1 h1:8xSQ6szndafKVRmfyeUMxkNUJQMjL1F2zmsZ+qHpfho=
|
||||
github.com/urfave/cli/v2 v2.27.1/go.mod h1:8qnjx1vcq5s2/wpsqoZFndg2CE5tNFyrTvS6SinrnYQ=
|
||||
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU=
|
||||
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8=
|
||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/arch v0.5.0 h1:jpGode6huXQxcskEIpOCvrU+tzo81b6+oFLUYXWtH/Y=
|
||||
golang.org/x/arch v0.5.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/crypto v0.18.0 h1:PGVlW0xEltQnzFZ55hkuX5+KLyrMYhHld1YHO4AKcdc=
|
||||
golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
|
||||
golang.org/x/arch v0.7.0 h1:pskyeJh/3AmoQ8CPE95vxHLqp1G1GfGNXTmcl9NEKTc=
|
||||
golang.org/x/arch v0.7.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||
golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30=
|
||||
golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M=
|
||||
golang.org/x/exp v0.0.0-20220303212507-bbda1eaf7a17 h1:3MTrJm4PyNL9NBqvYDSj3DHl46qQakyfqfWo4jgfaEM=
|
||||
golang.org/x/exp v0.0.0-20220303212507-bbda1eaf7a17/go.mod h1:lgLbSvA5ygNOMpwM/9anMpWVlVJ7Z+cHWq/eFuinpGE=
|
||||
golang.org/x/net v0.20.0 h1:aCL9BSgETF1k+blQaYUBx9hJ9LOGP3gAVemcZlf1Kpo=
|
||||
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
|
||||
golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w=
|
||||
golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU=
|
||||
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o=
|
||||
golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||
google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
|
||||
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
||||
google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI=
|
||||
google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
|
||||
@ -2,6 +2,7 @@ package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"sort"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/teacat/chaturbate-dvr/chaturbate"
|
||||
@ -51,17 +52,27 @@ func NewListChannelsHandler(c *chaturbate.Manager, cli *cli.Context) *ListChanne
|
||||
// Handle
|
||||
//=======================================================
|
||||
|
||||
// Handle processes the request to list channels, sorting by IsOnline.
|
||||
func (h *ListChannelsHandler) Handle(c *gin.Context) {
|
||||
var req *ListChannelsRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.AbortWithError(http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Fetch channels
|
||||
channels, err := h.chaturbate.ListChannels()
|
||||
if err != nil {
|
||||
c.AbortWithError(http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Sort by IsOnline: online channels first, then offline
|
||||
sort.SliceStable(channels, func(i, j int) bool {
|
||||
return channels[i].IsOnline && !channels[j].IsOnline
|
||||
})
|
||||
|
||||
// Populate response
|
||||
resp := &ListChannelsResponse{
|
||||
Channels: make([]*ListChannelsResponseChannel, len(channels)),
|
||||
}
|
||||
@ -81,5 +92,7 @@ func (h *ListChannelsHandler) Handle(c *gin.Context) {
|
||||
Logs: channel.Logs,
|
||||
}
|
||||
}
|
||||
|
||||
// Send the response
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
120
handler/update_log_level.go
Normal file
120
handler/update_log_level.go
Normal file
@ -0,0 +1,120 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gin-gonic/gin/binding"
|
||||
"github.com/go-playground/validator/v10"
|
||||
"github.com/teacat/chaturbate-dvr/chaturbate"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
type UpdateLogLevelHandler struct {
|
||||
cli *cli.Context
|
||||
}
|
||||
|
||||
// Custom validator for LogType
|
||||
func LogTypeValidator(fl validator.FieldLevel) bool {
|
||||
value := fl.Field().String()
|
||||
switch value {
|
||||
case string(chaturbate.LogTypeDebug), string(chaturbate.LogTypeInfo), string(chaturbate.LogTypeWarning), string(chaturbate.LogTypeError):
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func init() {
|
||||
if v, ok := binding.Validator.Engine().(*validator.Validate); ok {
|
||||
v.RegisterValidation("logtype", LogTypeValidator)
|
||||
}
|
||||
}
|
||||
|
||||
func NewUpdateLogLevelHandler(cli *cli.Context) *UpdateLogLevelHandler {
|
||||
return &UpdateLogLevelHandler{cli}
|
||||
}
|
||||
|
||||
func (h *UpdateLogLevelHandler) Handle(c *gin.Context) {
|
||||
var req chaturbate.LogLevelRequest
|
||||
|
||||
// Bind and validate the request body
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Invalid request format. Expected {\"log_level\": \"INFO\"}",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Use the correct log type for setting the global log level
|
||||
chaturbate.SetGlobalLogLevel(req.LogLevel)
|
||||
|
||||
log.Printf("Global log level updated to: %s", req.LogLevel)
|
||||
|
||||
// Send success response
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "Log level updated",
|
||||
"log_level": req.LogLevel,
|
||||
})
|
||||
}
|
||||
|
||||
// func (h *UpdateLogLevelHandler) Handle(c *gin.Context) {
|
||||
// // Read the raw request body for debugging
|
||||
// bodyBytes, err := c.GetRawData()
|
||||
// if err != nil {
|
||||
// log.Printf("Error reading request body: %v", err)
|
||||
// c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request format"})
|
||||
// return
|
||||
// }
|
||||
|
||||
// // Log the raw request body
|
||||
// log.Printf("Received raw request body: %s", string(bodyBytes))
|
||||
|
||||
// // Reset the request body so it can be re-read by ShouldBindJSON
|
||||
// c.Request.Body = ioutil.NopCloser(strings.NewReader(string(bodyBytes)))
|
||||
|
||||
// // Attempt to bind the JSON to the struct
|
||||
// var req LogLevelRequest
|
||||
// if err := c.ShouldBindJSON(&req); err != nil {
|
||||
// log.Printf("Error binding JSON: %v", err)
|
||||
// c.JSON(http.StatusBadRequest, gin.H{
|
||||
// "error": "Invalid request format. Expected {\"log_level\": \"INFO\"}",
|
||||
// })
|
||||
// return
|
||||
// }
|
||||
|
||||
// // Log the updated log level
|
||||
// log.Printf("Log level updated to: %s", req.LogLevel)
|
||||
|
||||
// // Store the log level in the CLI context if needed
|
||||
// h.cli.Set("log_level", string(req.LogLevel))
|
||||
|
||||
// // Send success response
|
||||
// c.JSON(http.StatusOK, gin.H{
|
||||
// "message": "Log level updated",
|
||||
// "log_level": req.LogLevel,
|
||||
// })
|
||||
// }
|
||||
|
||||
// NewUpdateLogLevelHandler creates a handler for updating log level.
|
||||
// func NewUpdateLogLevelHandler(c *cli.Context) gin.HandlerFunc {
|
||||
// return func(ctx *gin.Context) {
|
||||
// var req LogLevelRequest
|
||||
|
||||
// // Bind and validate request body
|
||||
// if err := ctx.ShouldBindJSON(&req); err != nil {
|
||||
// ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request format"})
|
||||
// return
|
||||
// }
|
||||
|
||||
// if !allowedLogLevels[req.LogLevel] {
|
||||
// ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid log level"})
|
||||
// return
|
||||
// }
|
||||
|
||||
// ctx.JSON(http.StatusOK, gin.H{
|
||||
// "message": "Log level updated",
|
||||
// "log_level": req.LogLevel,
|
||||
// })
|
||||
// }
|
||||
// }
|
||||
@ -1,391 +1,410 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="is-secondary">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<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" />
|
||||
<script src="https://unpkg.com/alpinejs" defer></script>
|
||||
<title>Chaturbate DVR</title>
|
||||
</head>
|
||||
<body x-data="data()">
|
||||
<!-- 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">
|
||||
<div class="ts-header">Add Channel</div>
|
||||
</div>
|
||||
<div class="column">
|
||||
<button class="ts-close is-rounded is-large is-secondary" x-on:click="closeCreateDialog"></button>
|
||||
</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">
|
||||
<div class="ts-input is-start-labeled">
|
||||
<div class="label">chaturbate.com/</div>
|
||||
<input type="text" autofocus x-model="form_data.username" />
|
||||
</div>
|
||||
<div class="ts-text is-description has-top-spaced-small">Use commas to separate multiple channel names. For example, "channel1,channel2,channel3".</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">
|
||||
<div class="ts-grid">
|
||||
<div class="column">
|
||||
<div class="ts-select">
|
||||
<select x-model="form_data.resolution">
|
||||
<option value="2160">4K</option>
|
||||
<option value="1440">2K</option>
|
||||
<option value="1080">1080p</option>
|
||||
<option value="720">720p</option>
|
||||
<option value="540">540p</option>
|
||||
<option value="480">480p</option>
|
||||
<option value="240">240p</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column">
|
||||
<div class="ts-select">
|
||||
<select x-model="form_data.resolution_fallback">
|
||||
<option value="up">or higher</option>
|
||||
<option value="down">or lower</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ts-text is-description has-top-spaced-small">
|
||||
The <template x-if="form_data.resolution_fallback === 'up'"><span>higher</span></template
|
||||
><template x-if="form_data.resolution_fallback === 'down'"><span>lower</span></template> resolution will be used if
|
||||
<template x-if="form_data.resolution === '2160'"><span>4K</span></template
|
||||
><template x-if="form_data.resolution === '1440'"><span>2K</span></template
|
||||
><template x-if="form_data.resolution === '1080'"><span>1080p</span></template
|
||||
><template x-if="form_data.resolution === '720'"><span>720p</span></template
|
||||
><template x-if="form_data.resolution === '480'"><span>480p</span></template
|
||||
><template x-if="form_data.resolution === '240'"><span>240p</span></template> was not available.
|
||||
</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">
|
||||
<div class="ts-wrap is-compact is-vertical has-top-spaced-small">
|
||||
<label class="ts-radio">
|
||||
<input name="framerate" value="60" type="radio" x-model="form_data.framerate" />
|
||||
60 FPS
|
||||
</label>
|
||||
<label class="ts-radio">
|
||||
<input name="framerate" value="30" type="radio" x-model="form_data.framerate" />
|
||||
30 FPS
|
||||
</label>
|
||||
</div>
|
||||
<template x-if="form_data.framerate === '60'">
|
||||
<div class="ts-text is-description has-top-spaced-small">30 FPS will be used if 60 FPS was not available.</div>
|
||||
</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">
|
||||
<div class="ts-input">
|
||||
<input type="text" x-model="form_data.filename_pattern" />
|
||||
</div>
|
||||
<div class="ts-text is-description has-top-spaced-small">
|
||||
See the <a class="ts-text is-external-link is-link" href="https://github.com/teacat/chaturbate-dvr" target="_blank">README</a> for details.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- / Field: Filename Pattern -->
|
||||
|
||||
<!-- Field: Check Interval -->
|
||||
<input type="hidden" x-model="form_data.interval" />
|
||||
<!-- / Field: Check Interval -->
|
||||
|
||||
<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">
|
||||
<details id="splitting-accordion" class="ts-accordion">
|
||||
<summary>Splitting Options</summary>
|
||||
<div class="ts-content is-rounded is-secondary has-top-spaced">
|
||||
<div class="ts-grid is-2-columns">
|
||||
<div class="column">
|
||||
<div class="ts-text is-bold">by Filesize</div>
|
||||
<div class="ts-input is-end-labeled has-top-spaced-small">
|
||||
<input type="text" x-model="form_data.split_filesize" />
|
||||
<span class="label">MB</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column">
|
||||
<div class="ts-text is-bold">by Duration</div>
|
||||
<div class="ts-input is-end-labeled has-top-spaced-small">
|
||||
<input type="text" x-model="form_data.split_duration" />
|
||||
<span class="label">Mins</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ts-text is-description has-top-spaced">Splitting will be disabled if both options are 0.</div>
|
||||
</div>
|
||||
</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 -->
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/tocas/5.0.1/tocas.min.css" />
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/tocas/5.0.1/tocas.min.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" />
|
||||
<script src="/static/script.js"></script>
|
||||
<script src="https://unpkg.com/alpinejs" defer></script>
|
||||
<title>Chaturbate DVR</title>
|
||||
</head>
|
||||
<body x-data="data()">
|
||||
<!-- 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">
|
||||
<div class="ts-header">Add Channel</div>
|
||||
</div>
|
||||
</dialog>
|
||||
<!-- / Create Dialog -->
|
||||
<div class="column">
|
||||
<button class="ts-close is-rounded is-large is-secondary" x-on:click="closeCreateDialog"></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- / Header -->
|
||||
|
||||
<!-- 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 <span x-text="settings.version"></span></div>
|
||||
<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">
|
||||
<div class="ts-input is-start-labeled">
|
||||
<div class="label">chaturbate.global/</div>
|
||||
<input type="text" autofocus x-model="form_data.username" />
|
||||
</div>
|
||||
<div class="ts-text is-description has-top-spaced-small">Use commas to separate multiple channel names. For example, "channel1,channel2,channel3".</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">
|
||||
<div class="ts-grid">
|
||||
<div class="column">
|
||||
<div class="ts-select">
|
||||
<select x-model="form_data.resolution">
|
||||
<option value="2160">4K</option>
|
||||
<option value="1440">2K</option>
|
||||
<option value="1080">1080p</option>
|
||||
<option value="720">720p</option>
|
||||
<option value="540">540p</option>
|
||||
<option value="480">480p</option>
|
||||
<option value="240">240p</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column">
|
||||
<div class="ts-wrap">
|
||||
<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>
|
||||
<button class="ts-button is-start-icon" x-on:click="openCreateDialog">
|
||||
<span class="ts-icon is-plus-icon"></span>
|
||||
Add Channel
|
||||
</button>
|
||||
</div>
|
||||
<div class="ts-select">
|
||||
<select x-model="form_data.resolution_fallback">
|
||||
<option value="up">or higher</option>
|
||||
<option value="down">or lower</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ts-text is-description has-top-spaced-small">
|
||||
The <template x-if="form_data.resolution_fallback === 'up'"><span>higher</span></template
|
||||
><template x-if="form_data.resolution_fallback === 'down'"><span>lower</span></template> resolution will be used if
|
||||
<template x-if="form_data.resolution === '2160'"><span>4K</span></template
|
||||
><template x-if="form_data.resolution === '1440'"><span>2K</span></template
|
||||
><template x-if="form_data.resolution === '1080'"><span>1080p</span></template
|
||||
><template x-if="form_data.resolution === '720'"><span>720p</span></template
|
||||
><template x-if="form_data.resolution === '480'"><span>480p</span></template
|
||||
><template x-if="form_data.resolution === '240'"><span>240p</span></template> was not available.
|
||||
</div>
|
||||
</div>
|
||||
<!-- / Header -->
|
||||
</div>
|
||||
<!-- / Field: Resolution -->
|
||||
|
||||
<!-- Empty State -->
|
||||
<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="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">
|
||||
<span class="ts-icon is-plus-icon"></span>
|
||||
Add Channel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<!-- / Empty State -->
|
||||
|
||||
<!-- Divider -->
|
||||
<template x-if="channels.length > 0">
|
||||
<div class="ts-divider is-start-text is-section">
|
||||
<span class="ts-text is-description"><span x-text="channels.length"></span> channel(s) are being recorded</span>
|
||||
</div>
|
||||
</template>
|
||||
<!-- / Divider -->
|
||||
|
||||
<div class="ts-wrap is-vertical is-relaxed">
|
||||
<!-- 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">
|
||||
<span x-text="channel.username"></span>
|
||||
<template x-if="channel.is_online && !channel.is_paused">
|
||||
<span class="ts-badge is-small is-start-spaced">RECORDING</span>
|
||||
</template>
|
||||
<template x-if="!channel.is_online && !channel.is_paused">
|
||||
<span class="ts-badge is-secondary is-small is-start-spaced">OFFLINE</span>
|
||||
</template>
|
||||
<template x-if="channel.is_paused">
|
||||
<span class="ts-badge is-negative is-small is-start-spaced">PAUSED</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column">
|
||||
<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>
|
||||
</div>
|
||||
<div class="column is-fluid has-leading-small">
|
||||
<div class="ts-text is-label">Channel URL</div>
|
||||
<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">Filename</div>
|
||||
|
||||
<template x-if="channel.filename">
|
||||
<code class="ts-text is-code" x-text="channel.filename"></code>
|
||||
</template>
|
||||
<template x-if="!channel.filename">
|
||||
<span>-</span>
|
||||
</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>
|
||||
</div>
|
||||
<div class="column is-fluid">
|
||||
<div class="ts-text is-label">Last streamed at</div>
|
||||
<div class="ts-text is-description">
|
||||
<span x-text="channel.last_streamed_at"></span>
|
||||
<template x-if="channel.is_online && !channel.is_paused">
|
||||
<span>(NOW)</span>
|
||||
</template>
|
||||
</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>
|
||||
</div>
|
||||
<div class="column is-fluid">
|
||||
<div class="ts-text is-label">Segment duration</div>
|
||||
<div class="ts-text is-description">
|
||||
<span x-text="channel.segment_duration"></span>
|
||||
<template x-if="channel.split_duration !== '00:00:00'">
|
||||
<span> / <span x-text="channel.split_duration"></span></span>
|
||||
</template>
|
||||
</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>
|
||||
</div>
|
||||
<div class="column is-fluid">
|
||||
<div class="ts-text is-label">Segment filesize</div>
|
||||
<div class="ts-text is-description">
|
||||
<span x-text="channel.segment_filesize"></span>
|
||||
<template x-if="channel.split_filesize !== '0.00 MiB'">
|
||||
<span> / <span x-text="channel.split_filesize"></span></span>
|
||||
</template>
|
||||
</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-bind:disabled="!channel.is_online"
|
||||
x-on:click="pauseChannel(channel.username)"
|
||||
>
|
||||
<span class="ts-icon is-pause-icon"></span>
|
||||
Pause
|
||||
</button>
|
||||
</template>
|
||||
<template x-if="channel.is_paused">
|
||||
<button class="ts-button is-start-icon is-fluid" x-on:click="resumeChannel(channel.username)">
|
||||
<span class="ts-icon is-play-icon"></span>
|
||||
Resume
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
<div class="column">
|
||||
<button
|
||||
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)"
|
||||
>
|
||||
<span class="ts-icon is-stop-icon"></span>
|
||||
Stop
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- / Actions -->
|
||||
</div>
|
||||
<!-- / Right Section -->
|
||||
</div>
|
||||
</template>
|
||||
<!-- / Channel -->
|
||||
<!-- Field: Framerate -->
|
||||
<div class="ts-control is-wide has-top-spaced-large">
|
||||
<div class="label">Framerate</div>
|
||||
<div class="content">
|
||||
<div class="ts-wrap is-compact is-vertical has-top-spaced-small">
|
||||
<label class="ts-radio">
|
||||
<input name="framerate" value="60" type="radio" x-model="form_data.framerate" />
|
||||
60 FPS
|
||||
</label>
|
||||
<label class="ts-radio">
|
||||
<input name="framerate" value="30" type="radio" x-model="form_data.framerate" />
|
||||
30 FPS
|
||||
</label>
|
||||
</div>
|
||||
<template x-if="form_data.framerate === '60'">
|
||||
<div class="ts-text is-description has-top-spaced-small">30 FPS will be used if 60 FPS was not available.</div>
|
||||
</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">
|
||||
<div class="ts-input">
|
||||
<input type="text" x-model="form_data.filename_pattern" />
|
||||
</div>
|
||||
<div class="ts-text is-description has-top-spaced-small">See the <a class="ts-text is-external-link is-link" href="https://github.com/teacat/chaturbate-dvr" target="_blank">README</a> for details.</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- / Field: Filename Pattern -->
|
||||
|
||||
<!-- Field: Check Interval -->
|
||||
<input type="hidden" x-model="form_data.interval" />
|
||||
<!-- / Field: Check Interval -->
|
||||
|
||||
<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">
|
||||
<details id="splitting-accordion" class="ts-accordion">
|
||||
<summary>Splitting Options</summary>
|
||||
<div class="ts-content is-rounded is-secondary has-top-spaced">
|
||||
<div class="ts-grid is-2-columns">
|
||||
<div class="column">
|
||||
<div class="ts-text is-bold">by Filesize</div>
|
||||
<div class="ts-input is-end-labeled has-top-spaced-small">
|
||||
<input type="text" x-model="form_data.split_filesize" />
|
||||
<span class="label">MB</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column">
|
||||
<div class="ts-text is-bold">by Duration</div>
|
||||
<div class="ts-input is-end-labeled has-top-spaced-small">
|
||||
<input type="text" x-model="form_data.split_duration" />
|
||||
<span class="label">Mins</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ts-text is-description has-top-spaced">Splitting will be disabled if both options are 0.</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
<!-- / Field: Splitting Options -->
|
||||
</div>
|
||||
<!-- / Main Section -->
|
||||
</body>
|
||||
<!-- / 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 -->
|
||||
|
||||
<!-- Main Section -->
|
||||
<div class="ts-container 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 <span x-text="settings.version"></span></div>
|
||||
</div>
|
||||
<div class="column">
|
||||
<div class="ts-wrap">
|
||||
<div class="ts-select">
|
||||
<select x-model="settings.log_level">
|
||||
<option>DEBUG</option>
|
||||
<option>INFO</option>
|
||||
<option>WARN</option>
|
||||
<option>ERROR</option>
|
||||
</select>
|
||||
</div>
|
||||
<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>
|
||||
<button class="ts-button is-start-icon" x-on:click="openCreateDialog">
|
||||
<span class="ts-icon is-plus-icon"></span>
|
||||
Add Channel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- / Header -->
|
||||
|
||||
<!-- Empty State -->
|
||||
<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="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">
|
||||
<span class="ts-icon is-plus-icon"></span>
|
||||
Add Channel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<!-- / Empty State -->
|
||||
|
||||
<!-- Divider -->
|
||||
<template x-if="channels.length > 0">
|
||||
<div class="ts-divider is-start-text is-section">
|
||||
<span class="ts-text is-description"
|
||||
><span x-text="channels.length"></span>
|
||||
<span x-show="channels.length < 2" style="display: none"> channel is</span>
|
||||
<span x-show="channels.length > 1"> channels are</span> being recorded
|
||||
</span>
|
||||
<span class="ts-text is-description"> <span x-text="channels.filter(channel => channel.is_online).length"></span> online / <span x-text="channels.filter(channel => !channel.is_online).length"></span> offline </span>
|
||||
</div>
|
||||
</template>
|
||||
<!-- / Divider -->
|
||||
<!-- Pagination Controls -->
|
||||
<div class="ts-pagination is-fluid" style="margin-bottom: 1rem;">
|
||||
<a class="item" x-on:click="goToPage(1)" :class="{ 'is-disabled': currentPage === 1 }">«</a>
|
||||
<template x-for="page in totalPages" :key="page">
|
||||
<a class="item" x-on:click="goToPage(page)" :class="{ 'is-active': currentPage === page }" x-text="page"></a>
|
||||
</template>
|
||||
<a class="item" x-on:click="goToPage(totalPages)" :class="{ 'is-disabled': currentPage === totalPages }">»</a>
|
||||
</div>
|
||||
<!-- / Pagination controls -->
|
||||
<div class="ts-wrap is-vertical is-relaxed">
|
||||
<!-- Channel -->
|
||||
<template x-for="channel in paginatedChannels" :key="channel.username">
|
||||
<!-- <div class="ts-box is-horizontal"> -->
|
||||
<div
|
||||
class="ts-box is-horizontal"
|
||||
:class="{
|
||||
'is-positive is-top-indicated': channel.is_online && !channel.is_paused,
|
||||
'is-negative is-top-indicated': !channel.is_online && !channel.is_paused,
|
||||
'is-top-indicated': channel.is_paused
|
||||
}"
|
||||
>
|
||||
<!-- Left Section -->
|
||||
<div class="ts-content is-padded" style="flex: 1.25; display: flex; flex-direction: column">
|
||||
<!-- Header -->
|
||||
<div class="ts-grid is-middle-aligned">
|
||||
<div class="column is-fluid">
|
||||
<div class="ts-header">
|
||||
<span x-text="channel.username"></span>
|
||||
<template x-if="channel.is_online && !channel.is_paused">
|
||||
<span class="ts-badge is-small is-start-spaced">RECORDING</span>
|
||||
</template>
|
||||
<template x-if="!channel.is_online && !channel.is_paused">
|
||||
<span class="ts-badge is-secondary is-small is-start-spaced">OFFLINE</span>
|
||||
</template>
|
||||
<template x-if="channel.is_paused">
|
||||
<span class="ts-badge is-negative is-small is-start-spaced">PAUSED</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column">
|
||||
<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>
|
||||
</div>
|
||||
<div class="column is-fluid has-leading-small">
|
||||
<div class="ts-text is-label">Channel URL</div>
|
||||
<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">Filename</div>
|
||||
|
||||
<template x-if="channel.filename">
|
||||
<code class="ts-text is-code" x-text="channel.filename"></code>
|
||||
</template>
|
||||
<template x-if="!channel.filename">
|
||||
<span>-</span>
|
||||
</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>
|
||||
</div>
|
||||
<div class="column is-fluid">
|
||||
<div class="ts-text is-label">Last streamed at</div>
|
||||
<div class="ts-text is-description">
|
||||
<span x-text="channel.last_streamed_at"></span>
|
||||
<template x-if="channel.is_online && !channel.is_paused">
|
||||
<span>(NOW)</span>
|
||||
</template>
|
||||
</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>
|
||||
</div>
|
||||
<div class="column is-fluid">
|
||||
<div class="ts-text is-label">Segment duration</div>
|
||||
<div class="ts-text is-description">
|
||||
<span x-text="channel.segment_duration"></span>
|
||||
<template x-if="channel.split_duration !== '00:00:00'">
|
||||
<span> / <span x-text="channel.split_duration"></span></span>
|
||||
</template>
|
||||
</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>
|
||||
</div>
|
||||
<div class="column is-fluid">
|
||||
<div class="ts-text is-label">Segment filesize</div>
|
||||
<div class="ts-text is-description">
|
||||
<span x-text="channel.segment_filesize"></span>
|
||||
<template x-if="channel.split_filesize !== '0.00 MiB'">
|
||||
<span> / <span x-text="channel.split_filesize"></span></span>
|
||||
</template>
|
||||
</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-bind:disabled="!channel.is_online" x-on:click="pauseChannel(channel.username)">
|
||||
<span class="ts-icon is-pause-icon"></span>
|
||||
Pause
|
||||
</button>
|
||||
</template>
|
||||
<template x-if="channel.is_paused">
|
||||
<button class="ts-button is-start-icon is-fluid" x-on:click="resumeChannel(channel.username)">
|
||||
<span class="ts-icon is-play-icon"></span>
|
||||
Resume
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
<div class="column">
|
||||
<button 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)">
|
||||
<span class="ts-icon is-stop-icon"></span>
|
||||
Stop
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- / Actions -->
|
||||
</div>
|
||||
<!-- / Right Section -->
|
||||
</div>
|
||||
</template>
|
||||
<!-- / Channel -->
|
||||
</div>
|
||||
</div>
|
||||
<!-- / Main Section -->
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -1,212 +1,254 @@
|
||||
function data() {
|
||||
return {
|
||||
settings: {},
|
||||
channels: [],
|
||||
is_updating_channels: false,
|
||||
form_data: {
|
||||
username: "",
|
||||
resolution: "1080",
|
||||
resolution_fallback: "up",
|
||||
framerate: "30",
|
||||
filename_pattern: "{{.Username}}_{{.Year}}-{{.Month}}-{{.Day}}_{{.Hour}}-{{.Minute}}-{{.Second}}{{if .Sequence}}_{{.Sequence}}{{end}}",
|
||||
split_filesize: 0,
|
||||
split_duration: 0,
|
||||
interval: 1,
|
||||
},
|
||||
return {
|
||||
settings: {},
|
||||
channels: [],
|
||||
currentPage: 1,
|
||||
itemsPerPage: 5,
|
||||
is_updating_channels: false,
|
||||
form_data: {
|
||||
username: "",
|
||||
resolution: "1080",
|
||||
resolution_fallback: "down",
|
||||
framerate: "30",
|
||||
filename_pattern: "{{.Username}}/{{.Year}}-{{.Month}}-{{.Day}}/{{.Username}}_{{.Hour}}-{{.Minute}}-{{.Second}}{{if .Sequence}}_{{.Sequence}}{{end}}",
|
||||
split_filesize: 0,
|
||||
split_duration: 0,
|
||||
interval: 1,
|
||||
},
|
||||
|
||||
// openCreateDialog
|
||||
openCreateDialog() {
|
||||
document.getElementById("create-dialog").showModal()
|
||||
},
|
||||
// Watch for changes in LogLevel
|
||||
watchLogLevel() {
|
||||
this.$watch("settings.log_level", async (newVal, oldVal) => {
|
||||
if (newVal !== oldVal) {
|
||||
await this.updateLogLevel();
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
// closeCreateDialog
|
||||
closeCreateDialog() {
|
||||
document.getElementById("create-dialog").close()
|
||||
this.resetCreateDialog()
|
||||
},
|
||||
// Compute the channels to display for the current page
|
||||
get paginatedChannels() {
|
||||
const start = (this.currentPage - 1) * this.itemsPerPage;
|
||||
return this.channels.slice(start, start + this.itemsPerPage);
|
||||
},
|
||||
|
||||
// submitCreateDialog
|
||||
submitCreateDialog() {
|
||||
this.createChannel()
|
||||
this.closeCreateDialog()
|
||||
},
|
||||
// Calculate total pages
|
||||
get totalPages() {
|
||||
return Math.ceil(this.channels.length / this.itemsPerPage);
|
||||
},
|
||||
|
||||
// error
|
||||
error() {
|
||||
alert("Error occurred, please refresh the page if something is wrong.")
|
||||
},
|
||||
// Change page on click
|
||||
goToPage(page) {
|
||||
if (page >= 1 && page <= this.totalPages) {
|
||||
this.currentPage = page;
|
||||
}
|
||||
},
|
||||
// openCreateDialog
|
||||
openCreateDialog() {
|
||||
document.getElementById("create-dialog").showModal();
|
||||
},
|
||||
|
||||
//
|
||||
async call(path, body) {
|
||||
try {
|
||||
var resp = await fetch(`/api/${path}`, {
|
||||
body: JSON.stringify(body),
|
||||
method: "POST",
|
||||
})
|
||||
if (resp.status !== 200) {
|
||||
this.error()
|
||||
return [null, true]
|
||||
}
|
||||
return [await resp.json(), false]
|
||||
} catch {
|
||||
this.error()
|
||||
return [null, true]
|
||||
}
|
||||
},
|
||||
// closeCreateDialog
|
||||
closeCreateDialog() {
|
||||
document.getElementById("create-dialog").close();
|
||||
this.resetCreateDialog();
|
||||
},
|
||||
|
||||
// getSettings
|
||||
async getSettings() {
|
||||
var [resp, err] = await this.call("get_settings", {})
|
||||
if (!err) {
|
||||
this.settings = resp
|
||||
this.resetCreateDialog()
|
||||
}
|
||||
},
|
||||
// submitCreateDialog
|
||||
submitCreateDialog() {
|
||||
this.createChannel();
|
||||
this.closeCreateDialog();
|
||||
},
|
||||
|
||||
// init
|
||||
async init() {
|
||||
document.getElementById("create-dialog").addEventListener("close", () => this.resetCreateDialog())
|
||||
// error
|
||||
error() {
|
||||
alert("Error occurred, please refresh the page if something is wrong.");
|
||||
},
|
||||
|
||||
await this.getSettings()
|
||||
await this.listChannels()
|
||||
this.listenUpdate()
|
||||
},
|
||||
//
|
||||
async call(path, body) {
|
||||
try {
|
||||
var resp = await fetch(`/api/${path}`, {
|
||||
body: JSON.stringify(body),
|
||||
method: "POST",
|
||||
});
|
||||
if (resp.status !== 200) {
|
||||
this.error();
|
||||
return [null, true];
|
||||
}
|
||||
return [await resp.json(), false];
|
||||
} catch {
|
||||
this.error();
|
||||
return [null, true];
|
||||
}
|
||||
},
|
||||
|
||||
// resetCreateDialog
|
||||
resetCreateDialog() {
|
||||
document.getElementById("splitting-accordion").open = false
|
||||
// getSettings
|
||||
async getSettings() {
|
||||
var [resp, err] = await this.call("get_settings", {});
|
||||
if (!err) {
|
||||
this.settings = resp;
|
||||
this.resetCreateDialog();
|
||||
await this.updateLogLevel();
|
||||
}
|
||||
},
|
||||
|
||||
this.form_data = {
|
||||
username: "",
|
||||
resolution: this.settings.resolution.toString(),
|
||||
resolution_fallback: this.settings.resolution_fallback,
|
||||
framerate: this.settings.framerate.toString(),
|
||||
filename_pattern: this.settings.filename_pattern,
|
||||
split_filesize: this.settings.split_filesize.toString(),
|
||||
split_duration: this.settings.split_duration.toString(),
|
||||
interval: this.settings.interval.toString(),
|
||||
}
|
||||
},
|
||||
// init
|
||||
async init() {
|
||||
document
|
||||
.getElementById('create-dialog')
|
||||
.addEventListener('close', () => this.resetCreateDialog());
|
||||
|
||||
// createChannel
|
||||
async createChannel() {
|
||||
await this.call("create_channel", {
|
||||
username: this.form_data.username,
|
||||
resolution: parseInt(this.form_data.resolution),
|
||||
resolution_fallback: this.form_data.resolution_fallback,
|
||||
framerate: parseInt(this.form_data.framerate),
|
||||
filename_pattern: this.form_data.filename_pattern,
|
||||
split_filesize: parseInt(this.form_data.split_filesize),
|
||||
split_duration: parseInt(this.form_data.split_duration),
|
||||
interval: parseInt(this.form_data.interval),
|
||||
})
|
||||
},
|
||||
await this.getSettings(); // Ensure settings are loaded
|
||||
this.watchLogLevel(); // Start watching LogLevel after settings load
|
||||
await this.listChannels();
|
||||
this.listenUpdate();
|
||||
},
|
||||
|
||||
// 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)
|
||||
}
|
||||
},
|
||||
async updateLogLevel() {
|
||||
const [_, err] = await this.call('update_log_level', {
|
||||
log_level: this.settings.log_level,
|
||||
});
|
||||
|
||||
// pauseChannel
|
||||
async pauseChannel(username) {
|
||||
await this.call("pause_channel", { username })
|
||||
},
|
||||
if (err) {
|
||||
this.error();
|
||||
}
|
||||
},
|
||||
|
||||
// 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", {})
|
||||
}
|
||||
},
|
||||
// resetCreateDialog
|
||||
resetCreateDialog() {
|
||||
document.getElementById("splitting-accordion").open = false;
|
||||
|
||||
// resumeChannel
|
||||
async resumeChannel(username) {
|
||||
await this.call("resume_channel", { username })
|
||||
},
|
||||
// Ensure settings are loaded before resetting form_data
|
||||
this.form_data = {
|
||||
username: "",
|
||||
resolution: this.settings.resolution?.toString() || "1080",
|
||||
resolution_fallback: this.settings.resolution_fallback || "down",
|
||||
framerate: this.settings.framerate?.toString() || "30",
|
||||
filename_pattern: this.settings.filename_pattern || "{{.Username}}/{{.Year}}-{{.Month}}-{{.Day}}/{{.Username}}_{{.Hour}}-{{.Minute}}-{{.Second}}{{if .Sequence}}_{{.Sequence}}{{end}}",
|
||||
split_filesize: this.settings.split_filesize?.toString() || "0",
|
||||
split_duration: this.settings.split_duration?.toString() || "30",
|
||||
interval: this.settings.interval?.toString() || "1",
|
||||
};
|
||||
},
|
||||
|
||||
// listChannels
|
||||
async listChannels() {
|
||||
if (this.is_updating_channels) {
|
||||
return
|
||||
}
|
||||
var [resp, err] = await this.call("list_channels", {})
|
||||
if (!err) {
|
||||
this.channels = resp.channels
|
||||
this.channels.forEach(ch => {
|
||||
this.scrollLogs(ch.username)
|
||||
})
|
||||
}
|
||||
this.is_updating_channels = false
|
||||
},
|
||||
// createChannel
|
||||
async createChannel() {
|
||||
await this.call("create_channel", {
|
||||
username: this.form_data.username,
|
||||
resolution: parseInt(this.form_data.resolution),
|
||||
resolution_fallback: this.form_data.resolution_fallback,
|
||||
framerate: parseInt(this.form_data.framerate),
|
||||
filename_pattern: this.form_data.filename_pattern,
|
||||
split_filesize: parseInt(this.form_data.split_filesize),
|
||||
split_duration: parseInt(this.form_data.split_duration),
|
||||
interval: parseInt(this.form_data.interval),
|
||||
});
|
||||
},
|
||||
|
||||
// listenUpdate
|
||||
listenUpdate() {
|
||||
var source = new EventSource("/api/listen_update")
|
||||
// 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);
|
||||
}
|
||||
},
|
||||
|
||||
source.onmessage = event => {
|
||||
var data = JSON.parse(event.data)
|
||||
// pauseChannel
|
||||
async pauseChannel(username) {
|
||||
await this.call("pause_channel", { username });
|
||||
},
|
||||
|
||||
// If the channel is not in the list or is stopped, refresh the list.
|
||||
if (!this.channels.some(ch => ch.username === data.username) || data.is_stopped) {
|
||||
this.listChannels()
|
||||
return
|
||||
}
|
||||
// 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", {});
|
||||
}
|
||||
},
|
||||
|
||||
var index = this.channels.findIndex(ch => ch.username === data.username)
|
||||
// resumeChannel
|
||||
async resumeChannel(username) {
|
||||
await this.call("resume_channel", { username });
|
||||
},
|
||||
|
||||
if (index === -1) {
|
||||
return
|
||||
}
|
||||
// listChannels
|
||||
async listChannels() {
|
||||
if (this.is_updating_channels) {
|
||||
return;
|
||||
}
|
||||
var [resp, err] = await this.call("list_channels", {});
|
||||
if (!err) {
|
||||
this.channels = resp.channels;
|
||||
this.currentPage = 1;
|
||||
this.channels.forEach((ch) => {
|
||||
this.scrollLogs(ch.username);
|
||||
});
|
||||
}
|
||||
this.is_updating_channels = false;
|
||||
},
|
||||
|
||||
this.channels[index].segment_duration = data.segment_duration
|
||||
this.channels[index].segment_filesize = data.segment_filesize
|
||||
this.channels[index].filename = data.filename
|
||||
this.channels[index].last_streamed_at = data.last_streamed_at
|
||||
this.channels[index].is_online = data.is_online
|
||||
this.channels[index].is_paused = data.is_paused
|
||||
this.channels[index].logs = [...this.channels[index].logs, data.log]
|
||||
// listenUpdate
|
||||
listenUpdate() {
|
||||
var source = new EventSource("/api/listen_update");
|
||||
|
||||
if (this.channels[index].logs.length > 100) {
|
||||
this.channels[index].logs = this.channels[index].logs.slice(-100)
|
||||
}
|
||||
source.onmessage = (event) => {
|
||||
var data = JSON.parse(event.data);
|
||||
|
||||
this.scrollLogs(data.username)
|
||||
}
|
||||
// If the channel is not in the list or is stopped, refresh the list.
|
||||
if (!this.channels.some((ch) => ch.username === data.username) || data.is_stopped) {
|
||||
this.listChannels();
|
||||
return;
|
||||
}
|
||||
|
||||
source.onerror = err => {
|
||||
source.close()
|
||||
}
|
||||
},
|
||||
var index = this.channels.findIndex((ch) => ch.username === data.username);
|
||||
|
||||
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 = `${username}_logs.txt`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
},
|
||||
if (index === -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
//
|
||||
scrollLogs(username) {
|
||||
// Wait for the DOM to update.
|
||||
setTimeout(() => {
|
||||
var logs_element = document.getElementById(`${username}-logs`)
|
||||
this.channels[index].segment_duration = data.segment_duration;
|
||||
this.channels[index].segment_filesize = data.segment_filesize;
|
||||
this.channels[index].filename = data.filename;
|
||||
this.channels[index].last_streamed_at = data.last_streamed_at;
|
||||
this.channels[index].is_online = data.is_online;
|
||||
this.channels[index].is_paused = data.is_paused;
|
||||
this.channels[index].logs = [...this.channels[index].logs, data.log];
|
||||
|
||||
if (!logs_element) {
|
||||
return
|
||||
}
|
||||
logs_element.scrollTop = logs_element.scrollHeight
|
||||
}, 1)
|
||||
},
|
||||
}
|
||||
if (this.channels[index].logs.length > 100) {
|
||||
this.channels[index].logs = this.channels[index].logs.slice(-100);
|
||||
}
|
||||
|
||||
this.scrollLogs(data.username);
|
||||
};
|
||||
|
||||
source.onerror = (err) => {
|
||||
source.close();
|
||||
};
|
||||
},
|
||||
|
||||
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 = `${username}_logs.txt`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
},
|
||||
|
||||
//
|
||||
scrollLogs(username) {
|
||||
// Wait for the DOM to update.
|
||||
setTimeout(() => {
|
||||
var logs_element = document.getElementById(`${username}-logs`);
|
||||
|
||||
if (!logs_element) {
|
||||
return;
|
||||
}
|
||||
logs_element.scrollTop = logs_element.scrollHeight;
|
||||
}, 1);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
9
main.go
9
main.go
@ -31,7 +31,7 @@ const logo = `
|
||||
func main() {
|
||||
app := &cli.App{
|
||||
Name: "chaturbate-dvr",
|
||||
Version: "1.0.6",
|
||||
Version: "1.0.7",
|
||||
Usage: "Records your favorite Chaturbate stream 😎🫵",
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
@ -90,7 +90,7 @@ func main() {
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "log-level",
|
||||
Usage: "log level, availables: 'DEBUG', 'INFO', 'WARN', 'ERROR'",
|
||||
Usage: "log level, available: 'DEBUG', 'INFO', 'WARN', 'ERROR'",
|
||||
Value: "INFO",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
@ -163,6 +163,10 @@ func startWeb(c *cli.Context) error {
|
||||
guiUsername := c.String("gui-username")
|
||||
guiPassword := c.String("gui-password")
|
||||
|
||||
logLevel := c.String("log-level")
|
||||
|
||||
chaturbate.InitGlobalLogLevel(chaturbate.LogType(logLevel))
|
||||
|
||||
var authorized = r.Group("/")
|
||||
var authorizedApi = r.Group("/api")
|
||||
|
||||
@ -186,6 +190,7 @@ func startWeb(c *cli.Context) error {
|
||||
authorizedApi.GET("/listen_update", handler.NewListenUpdateHandler(m, c).Handle)
|
||||
authorizedApi.POST("/get_settings", handler.NewGetSettingsHandler(c).Handle)
|
||||
authorizedApi.POST("/terminate_program", handler.NewTerminateProgramHandler(c).Handle)
|
||||
authorizedApi.POST("/update_log_level", handler.NewUpdateLogLevelHandler(c).Handle)
|
||||
|
||||
fmt.Printf("👋 Visit http://localhost:%s to use the Web UI\n", c.String("port"))
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user