2.0.0 refactor

This commit is contained in:
Yami Odymel 2025-05-02 23:52:58 +08:00
parent cad4689a5c
commit f26602b49e
No known key found for this signature in database
GPG Key ID: 68E469836934DB36
51 changed files with 2108 additions and 2989 deletions

4
.gitignore vendored
View File

@ -1,3 +1,3 @@
videos
chaturbate_channels.json
chaturbate-dvr
chaturbate-dvr
conf

1
.prettierignore Normal file
View File

@ -0,0 +1 @@
**/*.html

21
LICENSE
View File

@ -1,21 +0,0 @@
MIT License
Copyright (c) 2024 TeaCat
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -1,64 +0,0 @@
##
## ----------------------------------------------------------------------------
## Docker
## ----------------------------------------------------------------------------
##
docker-start-chaturbate-dvr: ## Start docker Chaturbate DVR
docker-compose -f docker-compose.yml up -d
docker-stop-chaturbate-dvr: ## Stop docker Chaturbate DVR
docker-compose -f docker-compose.yml down
docker-restart-chaturbate-dvr: ## Restart project Chaturbate DVR
docker-compose -f docker-compose.yml restart
docker-start-chaturbate-dvr-web: ## Start docker Chaturbate DVR WEB
docker-compose -f docker-compose-web.yml up -d
docker-stop-chaturbate-dvr-web: ## Stop docker Chaturbate DVR WEB
docker-compose -f docker-compose-web.yml down
docker-restart-chaturbate-dvr-web: ## Restart project Chaturbate DVR WEB
docker-compose -f docker-compose-web.yml restart
.PHONY: docker-start-chaturbate-dvr docker-stop-chaturbate-dvr docker-restart-chaturbate-dvr docker-start-chaturbate-dvr-web docker-stop-chaturbate-dvr-web docker-restart-chaturbate-dvr-web
##
## ----------------------------------------------------------------------------
## Compile
## ----------------------------------------------------------------------------
##
64bit-windows-macos-linux: ## Compile all arch amd64
GOOS=windows GOARCH=amd64 go build -o bin/windows/chatubrate-dvr.exe && \
GOOS=darwin GOARCH=amd64 go build -o bin/darwin/chatubrate-dvr && \
GOOS=linux GOARCH=amd64 go build -o bin/linux/chatubrate-dvr
arm64-windows-macos-linux: ## Compile all arch arm64
GOOS=windows GOARCH=arm64 go build -o bin/arm64/windows/chatubrate-dvr.exe && \
GOOS=darwin GOARCH=arm64 go build -o bin/arm64/darwin/chatubrate-dvr && \
GOOS=linux GOARCH=arm64 go build -o bin/arm64/linux/chatubrate-dvr
compile-all: ## Compile all
GOOS=windows GOARCH=amd64 go build -o bin/windows/chatubrate-dvr.exe && \
GOOS=darwin GOARCH=amd64 go build -o bin/darwin/chatubrate-dvr && \
GOOS=linux GOARCH=amd64 go build -o bin/linux/chatubrate-dvr && \
GOOS=windows GOARCH=arm64 go build -o bin/arm64/windows/chatubrate-dvr.exe && \
GOOS=darwin GOARCH=arm64 go build -o bin/arm64/darwin/chatubrate-dvr && \
GOOS=linux GOARCH=arm64 go build -o bin/arm64/linux/chatubrate-dvr
.PHONY: 64bit-windows-macos-linux arm64-windows-macos-linux
##
## ----------------------------------------------------------------------------
## Help
## ----------------------------------------------------------------------------
##
.DEFAULT_GOAL := help
.PHONY: help
help: ## Show this help
@egrep -h '(^[a-zA-Z0-9_-]+:.*?##.*$$)|(^##)' $(MAKEFILE_LIST) \
| awk 'BEGIN {FS = ":.*?## "}; {printf "\033[32m%-30s\033[0m %s\n", $$1, $$2}' \
| sed -e 's/\[32m##/[33m/'

311
README.md
View File

@ -1,154 +1,182 @@
# Chaturbate DVR
The program can records **multiple** Chaturbate streams, supports macOS, Windows, Linux, can be run on Docker.
A tool to record **multiple** Chaturbate streams. Supports macOS, Windows, Linux, and Docker.
For Chaturbate-**only**, private/ticket stream is **unsupported**.
![Image](https://github.com/user-attachments/assets/d71f0aaa-e821-4371-9f48-658a137b42b6)
**[DMCA WARNING](https://www.dmca.com/)**: Contents on Chaturbate are copyrighted, you should not copy, share, distribute the content.
![Image](https://github.com/user-attachments/assets/43ab0a07-0ece-40ba-9a0f-045ca0316638)
 
## Usage
# Getting Started
Download executable from **[Release](https://github.com/teacat/chaturbate-dvr/releases)** page (e.g., `windows_chatubrate-dvr.exe`).
Go to the [📦 Releases page](https://github.com/teacat/chaturbate-dvr/releases) and download the appropriate binary. (e.g., `x64_windows_chatubrate-dvr.exe`)
 
**🌐 Start the program with the Web UI**
Visit [`http://localhost:8080`](http://localhost:8080) to use the Web UI.
```yaml
# Windows (or double-click `chaturbate-dvr.exe` to open)
$ chaturbate-dvr.exe
# macOS or Linux
$ chaturbate-dvr
```
 
**💻 or... Run as a command-line tool**
Run the program with a channel name (`-u CHANNEL_USERNAME`) records the channel immediately, and the Web UI will be disabled.
```yaml
# Windows
$ chaturbate-dvr.exe -u CHANNEL_USERNAME
# macOS or Linux
$ chaturbate-dvr -u CHANNEL_USERNAME
```
 
## Preview
![image_1](https://github.com/teacat/chaturbate-dvr/assets/7308718/c6d17ffe-eba7-4296-9315-f501489d85f3)
![image_2](https://github.com/teacat/chaturbate-dvr/assets/7308718/d02923e0-574d-4a15-a373-8b0599101e3f)
**or... Command-line tool**
```
$ ./chaturbate-dvr -u emillybrowm start
██████╗██╗ ██╗ █████╗ ████████╗██╗ ██╗██████╗ ██████╗ █████╗ ████████╗███████╗
██╔════╝██║ ██║██╔══██╗╚══██╔══╝██║ ██║██╔══██╗██╔══██╗██╔══██╗╚══██╔══╝██╔════╝
██║ ███████║███████║ ██║ ██║ ██║██████╔╝██████╔╝███████║ ██║ █████╗
██║ ██╔══██║██╔══██║ ██║ ██║ ██║██╔══██╗██╔══██╗██╔══██║ ██║ ██╔══╝
╚██████╗██║ ██║██║ ██║ ██║ ╚██████╔╝██║ ██║██████╔╝██║ ██║ ██║ ███████╗
╚═════╝╚═╝ ╚═╝╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝╚═════╝ ╚═╝ ╚═╝ ╚═╝ ╚══════╝
██████╗ ██╗ ██╗██████╗
██╔══██╗██║ ██║██╔══██╗
██║ ██║██║ ██║██████╔╝
██║ ██║╚██╗ ██╔╝██╔══██╗
██████╔╝ ╚████╔╝ ██║ ██║
╚═════╝ ╚═══╝ ╚═╝ ╚═╝
[2024-01-24 00:11:54] [INFO] [emillybrowm] channel created
[2024-01-24 00:11:55] [INFO] [emillybrowm] channel is online, start fetching...
[2024-01-24 00:11:55] [INFO] [emillybrowm] the stream will be saved as videos/emillybrowm_2024-01-24_00-11-55.ts
[2024-01-24 00:11:55] [INFO] [emillybrowm] resolution 1080p is used
[2024-01-24 00:11:55] [INFO] [emillybrowm] framerate 30fps is used
[2024-01-24 00:11:57] [INFO] [emillybrowm] segment #0 written
[2024-01-24 00:11:57] [INFO] [emillybrowm] segment #1 written
[2024-01-24 00:11:57] [INFO] [emillybrowm] segment #2 written
```
 
## Help
## 🌐 Launching the Web UI
```bash
$ chaturbate-dvr -h
# Windows
$ x64_windows_chatubrate-dvr.exe
NAME:
chaturbate-dvr - Records your favorite Chaturbate stream 😎🫵
# macOS / Linux
$ ./x64_linux_chatubrate-dvr
```
USAGE:
chaturbate-dvr [global options] command [command options]
Then visit [`http://localhost:8080`](http://localhost:8080) in your browser.
VERSION:
1.0.0
 
COMMANDS:
help, h Shows a list of commands or help for one command
## 💻 Using as a CLI Tool
GLOBAL OPTIONS:
--username value, -u value channel username to record
--gui-username value, --gui-u value username for auth web (optional)
--gui-password value, --gui-p value password for auth web (optional)
--framerate value, -f value preferred framerate (default: 30)
--interval value, -i value minutes to check if the channel is online (default: 1)
--resolution value, -r value preferred resolution (default: 1080)
--resolution-fallback value, --rf value fallback to 'up' (larger) or 'down' (smaller) resolution if preferred resolution is not available (default: "down")
--filename-pattern value, --fp value filename pattern for videos (default: "videos/{{.Username}}_{{.Year}}-{{.Month}}-{{.Day}}_{{.Hour}}-{{.Minute}}-{{.Second}}{{if .Sequence}}_{{.Sequence}}{{end}}")
--split-duration value, --sd value minutes to split each video into segments ('0' to disable) (default: 0)
--split-filesize value, --sf value size in MB to split each video into segments ('0' to disable) (default: 0)
--log-level value log level, availables: 'DEBUG', 'INFO', 'WARN', 'ERROR' (default: "INFO")
--port value port to expose the web interface and API (default: "8080")
--cf-cookie value Cloudflare cookie to bypass anti-bot page
--user-agent value Custom user agent for when using cf-cookie
--help, -h show help
--version, -v print the version
```bash
# Windows
$ x64_windows_chatubrate-dvr.exe -u CHANNEL_USERNAME
# macOS / Linux
$ ./x64_linux_chatubrate-dvr -u CHANNEL_USERNAME
```
This starts recording immediately. The Web UI will be disabled.
 
## 🐳 Running with Docker
Pre-built image `yamiodymel/chaturbate-dvr` from [Docker Hub](https://hub.docker.com/r/yamiodymel/chaturbate-dvr):
```bash
# Run the container and save videos to ./videos
$ docker run -d \
--name my-dvr \
-p 8080:8080 \
-v "./videos:/usr/src/app/videos" \
-v "./conf:/usr/src/app/conf" \
yamiodymel/chaturbate-dvr
```
...Or build your own image using the Dockerfile in this repository.
```bash
# Build the image
$ docker build -t chaturbate-dvr .
# Run the container and save videos to ./videos
$ docker run -d \
--name my-dvr \
-p 8080:8080 \
-v "./videos:/usr/src/app/videos" \
-v "./conf:/usr/src/app/conf" \
chaturbate-dvr
```
...Or use [`docker-compose.yml`](https://github.com/teacat/chaturbate-dvr/blob/master/docker-compose.yml):
```bash
$ docker-compose up
```
Then visit [`http://localhost:8080`](http://localhost:8080) in your browser.
 
# 🧾 Command-Line Options
Available options:
```
--username value, -u value The username of the channel to record
--admin-username value Username for web authentication (optional)
--admin-password value Password for web authentication (optional)
--framerate value Desired framerate (FPS) (default: 30)
--resolution value Desired resolution (e.g., 1080 for 1080p) (default: 1080)
--pattern value Template for naming recorded videos (default: "videos/{{.Username}}_{{.Year}}-{{.Month}}-{{.Day}}_{{.Hour}}-{{.Minute}}-{{.Second}}{{if .Sequence}}_{{.Sequence}}{{end}}")
--max-duration value Split video into segments every N minutes ('0' to disable) (default: 0)
--max-filesize value Split video into segments every N MB ('0' to disable) (default: 0)
--port value, -p value Port for the web interface and API (default: "8080")
--interval value Check if the channel is online every N minutes (default: 1)
--cookies value Cookies to use in the request (format: key=value; key2=value2)
--user-agent value Custom User-Agent for the request
--domain value Chaturbate domain to use (default: "https://chaturbate.global/")
--help, -h show help
--version, -v print the version
```
**Examples**:
```yaml
# Records in 720p/60fps
$ chaturbate-dvr -u yamiodymel -r 720 -f 60
```bash
# Record at 720p / 60fps
$ ./chatubrate-dvr -u yamiodymel -resolution 720 -framerate 60
# Split the video every 30 minutes
$ chaturbate-dvr -u yamiodymel -sd 30
# Split every 30 minutes
$ ./chatubrate-dvr -u yamiodymel -max-duration 30
# Split the video every 1024 MB
$ chaturbate-dvr -u yamiodymel -sf 1024
# Split at 1024 MB
$ ./chatubrate-dvr -u yamiodymel -max-filesize 1024
# Change output filename pattern
$ chaturbate-dvr -u yamiodymel -fp video/{{.Username}}/{{.Year}}-{{.Month}}-{{.Day}}_{{.Hour}}-{{.Minute}}-{{.Second}}_{{.Sequence}}
# Custom filename format
$ ./chatubrate-dvr -u yamiodymel \
-pattern "video/{{.Username}}/{{.Year}}-{{.Month}}-{{.Day}}_{{.Hour}}-{{.Minute}}-{{.Second}}_{{.Sequence}}"
```
※ When runs in Web UI mode, the settings will be default settings for Web UI to create channels.
_Note: In Web UI mode, these flags serve as default values for new channels._
 
## 📺 Framerate & Resolution / Fallback
# 🍪 Cookies & User-Agent
Fallback indicates what to do when there's no expected target resolution, situation:
You can set Cookies and User-Agent via the Web UI or command-line arguments.
```
Availables: 1080p, 720p, 240p
![localhost_8080_ (4)](https://github.com/user-attachments/assets/cbd859a9-4255-404b-b6bf-fa89342f7258)
Resolution: 480p (fallback setted to: up)
Result: 720p will be used
Resolution: 480p (fallback setted to: down)
Result: 240p will be used
```
_Note: Use semicolons to separate multiple cookies, e.g., `key1=value1; key2=value2`._
 
## 📄 Filename Pattern
## ☁️ Bypass Cloudflare
1. Open [Chaturbate](https://chaturbate.com) in your browser and complete the Cloudflare check.
(Keep refresh with F5 if the check doesn't appear)
2. **DevTools (F12)****Application****Cookies**`https://chaturbate.com` → Copy the `cf_clearance` value
![sshot-2025-04-30-146](https://github.com/user-attachments/assets/69f4061b-29a2-48a7-ad57-0c86148805e2)
3. User-Agent can be found using [WhatIsMyBrowser](https://www.whatismybrowser.com/detect/what-is-my-user-agent/), now run with `-cookies` and `-user-agent`:
```bash
$ ./chatubrate-dvr -u yamiodymel \
-cookies "cf_clearance=PASTE_YOUR_CF_CLEARANCE_HERE" \
-user-agent "PASTE_YOUR_USER_AGENT_HERE"
```
Example:
```bash
$ ./chatubrate-dvr -u yamiodymel \
-cookies "cf_clearance=i975JyJSMZUuEj2kIqfaClPB2dLomx3.iYo6RO1IIRg-1746019135-1.2.1.1-2CX..." \
-user-agent "Mozilla/5.0 (Windows NT 10.0; Win64; x64)..."
```
 
## 🕵️ Record Private Shows
1. Login [Chaturbate](https://chaturbate.com) in your browser.
2. **DevTools (F12)****Application****Cookies**`https://chaturbate.com` → Copy the `sessionid` value
3. Run with `-cookies`:
```bash
$ ./chatubrate-dvr -u yamiodymel -cookies "sessionid=PASTE_YOUR_SESSIONID_HERE"
```
 
# 📄 Filename Pattern
The format is based on [Go Template Syntax](https://pkg.go.dev/text/template), available variables are:
@ -179,58 +207,39 @@ Pattern: video/{{.Username}}/{{.Year}}-{{.Month}}-{{.Day}}_{{.Hour}}-{{.Minute}}
Output: video/yamiodymel/2024-01-02_13-45-00_0.ts
```
※ The file will be saved as `.ts` format and it's not configurable.
_Note: Files are saved in `.ts` format, and this is not configurable._
 
## 🤔 Frequently Asked Questions
# 🤔 Frequently Asked Questions
**Q: The program closes itself when I just open it on Windows**
**Q: The program closes immediately on Windows.**
A: Try to open the program in **Command Prompt**, the error message should appear, and create a new [Issue](https://github.com/teacat/chaturbate-dvr/issues) for it.
> Open it via **Command Prompt**, the error message should appear. If needed, [create an issue](https://github.com/teacat/chaturbate-dvr/issues).
 
**Q: Channel is online but the program says it's Offline**
**Q: Error `listen tcp :8080: bind: An attempt was... by its access permissions`**
A: The program might be blocked by Chaturbate or Cloudflare. If the Channel is in a private/ticket show, the program doesn't support it yet.
> The port `8080` is in use. Try another port with `-p 8123`, then visit [http://localhost:8123](http://localhost:8123).
>
> If that fails, run **Command Prompt** as Administrator and execute:
>
> ```bash
> $ net stop winnat
> $ net start winnat
> ```
 
**Q: `listen tcp :8080: bind: An attempt was made to access a socket in a way forbidden by its access permissions.`**
**Q: Error `A connection attempt failed... host has failed to respond`**
A: The port `8080` is already in use, change the port with `-port` option (e.g. `-port 8123`) and visit `http://localhost:8123`.
If the error still occur, run **Command Prompt** as Administrator, and type `net stop winnat` then `net start winnat`, and re-run the Chaturbate DVR again.
> Likely a network issue (e.g., VPN, firewall, or blocked by Chaturbate). This cannot be fixed by the program.
 
**Q: `A connection attempt failed because the connected party did not properly respond after a period of time, or established connection failed because connected host has failed to respond.`**
**Q: Error `Channel was blocked by Cloudflare`**
A: Your network is unstable or being blocked by Chaturbate, the program can't help with the network issue. Usually happened when you are using VPN or Proxy.
> You've been temporarily blocked. See the [Cookies & User-Agent](#-cookies--user-agent) section to bypass.
 
**Q: `I'm receiving a message about CloudFlare anti-bot, what do I need to do?`**
A: You need to successfully pass the CloudFlare anti-bot check and retrieve the cf_clearance Cookie that is set in the browser after successfully passing the check. This MUST be done from the same IP address and the same User-Agent string MUST be provided to chaturbate-dvr. Provide the cookie value and User-Agent string with the --cf-cookie and --user-agent command line options. The Cookie does expire, but it looks like it's Age is at ~1 year.
 
## 💬 Verbose Log
Change `-log-level` to `DEBUG` to see more details in terminal, like Duration and Size.
```yaml
# Availables: DEBUG, INFO, WARN, ERROR
$ chaturbate-dvr -u hepbugbear -log-level DEBUG
[2024-01-24 01:18:11] [INFO] [hepbugbear] segment #0 written
[2024-01-24 01:18:11] [DEBUG] [hepbugbear] duration: 00:00:06, size: 0.00 MiB
[2024-01-24 01:18:11] [INFO] [hepbugbear] segment #1 written
[2024-01-24 01:18:11] [DEBUG] [hepbugbear] duration: 00:00:06, size: 1.36 MiB
[2024-01-24 01:18:11] [INFO] [hepbugbear] segment #2 written
[2024-01-24 01:18:11] [DEBUG] [hepbugbear] duration: 00:00:06, size: 2.72 MiB
[2024-01-24 01:18:12] [DEBUG] [hepbugbear] segment #3 fetched
[2024-01-24 01:18:13] [INFO] [hepbugbear] segment #3 written
[2024-01-24 01:18:13] [DEBUG] [hepbugbear] duration: 00:00:10, size: 4.08 MiB
```

View File

@ -1,4 +1,4 @@
Compile All at once:
64-bit + arm64
```
GOOS=windows GOARCH=amd64 go build -o bin/x64_windows_chatubrate-dvr.exe &&
@ -9,7 +9,7 @@ GOOS=darwin GOARCH=arm64 go build -o bin/arm64_macos_chatubrate-dvr &&
GOOS=linux GOARCH=arm64 go build -o bin/arm64_linux_chatubrate-dvr
```
or Compile for 64-bit Windows, macOS, Linux:
64-bit Windows, macOS, Linux:
```
GOOS=windows GOARCH=amd64 go build -o bin/x64_windows_chatubrate-dvr.exe &&
@ -17,10 +17,18 @@ GOOS=darwin GOARCH=amd64 go build -o bin/x64_macos_chatubrate-dvr &&
GOOS=linux GOARCH=amd64 go build -o bin/x64_linux_chatubrate-dvr
```
or for arm64 Windows, macOS, Linux:
arm64 Windows, macOS, Linux:
```
GOOS=windows GOARCH=arm64 go build -o bin/arm64_windows_chatubrate-dvr.exe &&
GOOS=darwin GOARCH=arm64 go build -o bin/arm64_macos_chatubrate-dvr &&
GOOS=linux GOARCH=arm64 go build -o bin/arm64_linux_chatubrate-dvr
```
Build Docker Tag:
s
```
docker build -t yamiodymel/chaturbate-dvr:2.0.0 .
docker push yamiodymel/chaturbate-dvr:2.0.0
```

145
channel/channel.go Normal file
View File

@ -0,0 +1,145 @@
package channel
import (
"context"
"fmt"
"log"
"os"
"time"
"github.com/teacat/chaturbate-dvr/entity"
"github.com/teacat/chaturbate-dvr/internal"
"github.com/teacat/chaturbate-dvr/server"
)
// Channel represents a channel instance.
type Channel struct {
CancelFunc context.CancelFunc
LogCh chan string
UpdateCh chan bool
IsOnline bool
StreamedAt int64
Duration float64 // Seconds
Filesize int // Bytes
Sequence int
Logs []string
File *os.File
Config *entity.ChannelConfig
}
// New creates a new channel instance with the given manager and configuration.
func New(conf *entity.ChannelConfig) *Channel {
ch := &Channel{
LogCh: make(chan string),
UpdateCh: make(chan bool),
Config: conf,
CancelFunc: func() {},
}
go ch.Publisher()
return ch
}
// Publisher listens for log messages and updates from the channel
// and publishes once received.
func (ch *Channel) Publisher() {
for {
select {
case v := <-ch.LogCh:
// Append the log message to ch.Logs and keep only the last 100 rows
ch.Logs = append(ch.Logs, v)
if len(ch.Logs) > 100 {
ch.Logs = ch.Logs[len(ch.Logs)-100:]
}
server.Manager.Publish(entity.EventLog, ch.ExportInfo())
case <-ch.UpdateCh:
server.Manager.Publish(entity.EventUpdate, ch.ExportInfo())
}
}
}
// WithCancel creates a new context with a cancel function,
// then stores the cancel function in the channel's CancelFunc field.
//
// This is used to cancel the context when the channel is stopped or paused.
func (ch *Channel) WithCancel(ctx context.Context) (context.Context, context.CancelFunc) {
ctx, ch.CancelFunc = context.WithCancel(ctx)
return ctx, ch.CancelFunc
}
// Info logs an informational message.
func (ch *Channel) Info(format string, a ...any) {
ch.LogCh <- fmt.Sprintf("%s [INFO] %s", time.Now().Format("15:04"), fmt.Sprintf(format, a...))
log.Printf(" INFO [%s] %s", ch.Config.Username, fmt.Sprintf(format, a...))
}
// Error logs an error message.
func (ch *Channel) Error(format string, a ...any) {
ch.LogCh <- fmt.Sprintf("%s [ERROR] %s", time.Now().Format("15:04"), fmt.Sprintf(format, a...))
log.Printf("ERROR [%s] %s", ch.Config.Username, fmt.Sprintf(format, a...))
}
// ExportInfo exports the channel information as a ChannelInfo struct.
func (ch *Channel) ExportInfo() *entity.ChannelInfo {
var filename string
if ch.File != nil {
filename = ch.File.Name()
}
var streamedAt string
if ch.StreamedAt != 0 {
streamedAt = time.Unix(ch.StreamedAt, 0).Format("2006-01-02 15:04 AM")
}
return &entity.ChannelInfo{
IsOnline: ch.IsOnline,
IsPaused: ch.Config.IsPaused,
Username: ch.Config.Username,
MaxDuration: internal.FormatDuration(float64(ch.Config.MaxDuration * 60)), // MaxDuration from config is in minutes
MaxFilesize: internal.FormatFilesize(ch.Config.MaxFilesize * 1024 * 1024), // MaxFilesize from config is in MB
StreamedAt: streamedAt,
CreatedAt: ch.Config.CreatedAt,
Duration: internal.FormatDuration(ch.Duration),
Filesize: internal.FormatFilesize(ch.Filesize),
Filename: filename,
Logs: ch.Logs,
GlobalConfig: server.Config,
}
}
// Pause pauses the channel and cancels the context.
func (ch *Channel) Pause() {
// Stop the monitoring loop
ch.CancelFunc()
ch.Config.IsPaused = true
ch.Sequence = 0
ch.IsOnline = false
ch.Update()
ch.Info("channel paused")
}
// Stop stops the channel and cancels the context.
func (ch *Channel) Stop() {
// Stop the monitoring loop
ch.CancelFunc()
ch.Info("channel stopped")
}
// Resume resumes the channel monitoring.
//
// `startSeq` is used to prevent all channels from starting at the same time, preventing TooManyRequests errors.
// It's only be used when program starting and trying to resume all channels at once.
func (ch *Channel) Resume(startSeq int) {
ch.Config.IsPaused = false
ch.Update()
ch.Info("channel resumed")
<-time.After(time.Duration(startSeq) * time.Second)
ch.Monitor()
}

125
channel/channel_file.go Normal file
View File

@ -0,0 +1,125 @@
package channel
import (
"bytes"
"fmt"
"html/template"
"os"
"path/filepath"
"time"
)
// Pattern holds the date/time and sequence information for the filename pattern
type Pattern struct {
Username string
Year string
Month string
Day string
Hour string
Minute string
Second string
Sequence int
}
// NextFile prepares the next file to be created, by cleaning up the last file and generating a new one
func (ch *Channel) NextFile() error {
if err := ch.Cleanup(); err != nil {
return err
}
filename, err := ch.GenerateFilename()
if err != nil {
return err
}
if err := ch.CreateNewFile(filename); err != nil {
return err
}
// Increment the sequence number for the next file
ch.Sequence++
return nil
}
// Cleanup cleans the file and resets it, called when the stream errors out or before next file was created.
func (ch *Channel) Cleanup() error {
if ch.File == nil {
return nil
}
defer func() {
ch.Filesize = 0
ch.Duration = 0
}()
// Sync the file to ensure data is written to disk
if err := ch.File.Sync(); err != nil {
return fmt.Errorf("sync file: %w", err)
}
if err := ch.File.Close(); err != nil {
return fmt.Errorf("close file: %w", err)
}
// Delete the empty file
if ch.Filesize <= 0 {
if err := os.Remove(ch.File.Name()); err != nil {
return fmt.Errorf("remove zero file: %w", err)
}
}
ch.File = nil
return nil
}
// GenerateFilename creates a filename based on the configured pattern and the current timestamp
func (ch *Channel) GenerateFilename() (string, error) {
var buf bytes.Buffer
// Parse the filename pattern defined in the channel's config
tpl, err := template.New("filename").Parse(ch.Config.Pattern)
if err != nil {
return "", fmt.Errorf("filename pattern error: %w", err)
}
// Get the current time based on the Unix timestamp when the stream was started
t := time.Unix(ch.StreamedAt, 0)
pattern := &Pattern{
Username: ch.Config.Username,
Sequence: ch.Sequence,
Year: t.Format("2006"),
Month: t.Format("01"),
Day: t.Format("02"),
Hour: t.Format("15"),
Minute: t.Format("04"),
Second: t.Format("05"),
}
if err := tpl.Execute(&buf, pattern); err != nil {
return "", fmt.Errorf("template execution error: %w", err)
}
return buf.String(), nil
}
// CreateNewFile creates a new file for the channel using the given filename
func (ch *Channel) CreateNewFile(filename string) error {
// Ensure the directory exists before creating the file
if err := os.MkdirAll(filepath.Dir(filename), 0777); err != nil {
return fmt.Errorf("mkdir all: %w", err)
}
// Open the file in append mode, create it if it doesn't exist
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)
}
ch.File = file
return nil
}
// ShouldSwitchFile determines whether a new file should be created.
func (ch *Channel) ShouldSwitchFile() bool {
maxFilesizeBytes := ch.Config.MaxFilesize * 1024 * 1024
maxDurationSeconds := ch.Config.MaxDuration * 60
return (ch.Duration >= float64(maxDurationSeconds) && ch.Config.MaxDuration > 0) ||
(ch.Filesize >= maxFilesizeBytes && ch.Config.MaxFilesize > 0)
}

123
channel/channel_record.go Normal file
View File

@ -0,0 +1,123 @@
package channel
import (
"context"
"errors"
"fmt"
"time"
"github.com/avast/retry-go/v4"
"github.com/teacat/chaturbate-dvr/chaturbate"
"github.com/teacat/chaturbate-dvr/internal"
"github.com/teacat/chaturbate-dvr/server"
)
// Monitor starts monitoring the channel for live streams and records them.
func (ch *Channel) Monitor() {
client := chaturbate.NewClient()
ch.Info("starting to record `%s`", ch.Config.Username)
// Create a new context with a cancel function,
// the CancelFunc will be stored in the channel's CancelFunc field
// and will be called by `Pause` or `Stop` functions
ctx, _ := ch.WithCancel(context.Background())
var err error
for {
if err = ctx.Err(); err != nil {
break
}
pipeline := func() error {
return ch.RecordStream(ctx, client)
}
onRetry := func(_ uint, err error) {
if errors.Is(err, internal.ErrChannelOffline) {
ch.Info("channel is offline, try again in %d min(s)", server.Config.Interval)
} else if errors.Is(err, internal.ErrCloudflareBlocked) {
ch.Info("channel was blocked by Cloudflare; try with `-cookies` and `-user-agent`? try again in %d min(s)", server.Config.Interval)
} else if errors.Is(err, context.Canceled) {
// ...
} else {
ch.Error("on retry: %s: retrying in %d min(s)", err.Error(), server.Config.Interval)
}
}
if err = retry.Do(
pipeline,
retry.Context(ctx),
retry.Attempts(0),
retry.Delay(time.Duration(server.Config.Interval)*time.Minute),
retry.DelayType(retry.FixedDelay),
retry.OnRetry(onRetry),
); err != nil {
break
}
}
if err != nil {
if !errors.Is(err, context.Canceled) {
ch.Error("record stream: %s", err.Error())
}
if err := ch.Cleanup(); err != nil {
ch.Error("cleanup canceled channel: %s", err.Error())
}
}
}
// Update sends an update signal to the channel's update channel.
// This notifies the Server-sent Event to boradcast the channel information to the client.
func (ch *Channel) Update() {
ch.UpdateCh <- true
}
// RecordStream records the stream of the channel using the provided client.
// It retrieves the stream information and starts watching the segments.
func (ch *Channel) RecordStream(ctx context.Context, client *chaturbate.Client) error {
stream, err := client.GetStream(ctx, ch.Config.Username)
if err != nil {
ch.IsOnline = false
return fmt.Errorf("get stream: %w", err)
}
ch.IsOnline = true
ch.StreamedAt = time.Now().Unix()
if err := ch.NextFile(); err != nil {
return fmt.Errorf("next file: %w", err)
}
playlist, err := stream.GetPlaylist(ctx, ch.Config.Resolution, ch.Config.Framerate)
if err != nil {
return fmt.Errorf("get playlist: %w", err)
}
ch.Info("stream quality - resolution %dp (target: %dp), framerate %dfps (target: %dfps)", playlist.Resolution, ch.Config.Resolution, playlist.Framerate, ch.Config.Framerate)
return playlist.WatchSegments(ctx, ch.HandleSegment)
}
// HandleSegment processes and writes segment data to a file.
func (ch *Channel) HandleSegment(b []byte, duration float64) error {
if ch.Config.IsPaused {
return retry.Unrecoverable(internal.ErrPaused)
}
n, err := ch.File.Write(b)
if err != nil {
return fmt.Errorf("write file: %w", err)
}
ch.Filesize += n
ch.Duration += duration
ch.Info("duration: %s, filesize: %s", internal.FormatDuration(ch.Duration), internal.FormatFilesize(ch.Filesize))
// Send an SSE update to update the view
ch.Update()
if ch.ShouldSwitchFile() {
if err := ch.NextFile(); err != nil {
return fmt.Errorf("next file: %w", err)
}
ch.Info("max filesize or duration exceeded, new file created: %s", ch.File.Name())
return nil
}
return nil
}

View File

@ -1,151 +0,0 @@
package chaturbate
import (
"os"
"regexp"
"strings"
"sync"
"time"
)
var (
regexpRoomDossier = regexp.MustCompile(`window\.initialRoomDossier = "(.*?)"`)
)
type roomDossier struct {
HLSSource string `json:"hls_source"`
}
type resolution struct {
framerate map[int]string // key: framerate, value: url
width int
}
type Channel struct {
Username string
ChannelURL string
filenamePattern string
LastStreamedAt string
Interval int
CFCookie string
UserAgent string
Framerate int
Resolution int
ResolutionFallback string
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
bufferIndex int
segmentIndex int
segmentUseds []string
rootURL string
sourceURL string
retries int
file *os.File
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(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...")
if err := w.record(body); err != nil { // blocking
w.log(LogTypeError, "record error: %v", err)
}
continue // this excutes when recording is over/interrupted
}
w.IsOnline = false
// 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: %v", err)
}
}
if strings.Contains(body, "<title>Just a moment...</title>") {
w.log(LogTypeError, "Cloudflare anti-bot page detected, Try providing cf-cookie and user-agent (Check GitHub for instructions)... Exiting")
os.Exit(1)
}
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
}
}
func (w *Channel) Pause() {
w.IsPaused = true
w.resetSession()
}
func (w *Channel) Resume() {
w.IsPaused = false
select {
case w.ResumeChannel <- true:
default:
}
}
func (w *Channel) Stop() {
w.isStopped = true
}
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()
}

View File

@ -1,103 +0,0 @@
package chaturbate
import (
"bytes"
"fmt"
"os"
"path/filepath"
"text/template"
"time"
)
// filename generates the filename based on the session pattern and current split index.
func (w *Channel) filename() (string, error) {
if w.sessionPattern == nil {
w.sessionPattern = map[string]any{
"Username": w.Username,
"Year": time.Now().Format("2006"),
"Month": time.Now().Format("01"),
"Day": time.Now().Format("02"),
"Hour": time.Now().Format("15"),
"Minute": time.Now().Format("04"),
"Second": time.Now().Format("05"),
"Sequence": 0,
}
}
w.sessionPattern["Sequence"] = w.splitIndex
var buf bytes.Buffer
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 creates a new file and prepares it for writing stream data.
func (w *Channel) newFile() error {
filename, err := w.filename()
if err != nil {
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.file = file
return nil
}
// releaseFile closes the current file and removes it if empty.
func (w *Channel) releaseFile() error {
if w.file == nil {
return nil
}
if err := w.file.Close(); err != nil {
return fmt.Errorf("close file: %s: %w", w.file.Name(), err)
}
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 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: %v", err)
return err
}
// Increment the split index for the next file.
w.splitIndex++
// 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()
}

View File

@ -1,476 +0,0 @@
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}
req, err := http.NewRequest("GET", w.ChannelURL, nil)
if err != nil {
return "", fmt.Errorf("new request: %w", err)
}
if w.CFCookie != "" {
cookie := &http.Cookie{
Name: "cf_clearance",
Value: w.CFCookie,
}
req.AddCookie(cookie)
}
if w.UserAgent != "" {
req.Header.Set("User-Agent", w.UserAgent)
}
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("client do: %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}
req, err := http.NewRequest("GET", roomData.HLSSource, nil)
if err != nil {
return "", "", fmt.Errorf("new request: %w", err)
}
if w.CFCookie != "" {
cookie := &http.Cookie{
Name: "cf_clearance",
Value: w.CFCookie,
}
req.AddCookie(cookie)
}
if w.UserAgent != "" {
req.Header.Set("User-Agent", w.UserAgent)
}
resp, err := client.Do(req)
if err != nil {
return "", "", fmt.Errorf("client do: %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
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.bufferIndex++
segmentRetries = 0
continue
}
// If buffer is empty, wait and retry.
if len(w.buffer) == 0 {
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.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.retries++
continue
}
// Update segment size and log progress.
w.SegmentFilesize += lens
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")
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.
}
// 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++ // Move to the next segment.
segmentRetries = 0 // Reset retries for the next segment.
}
}
// 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, uri string) {
if err := w.requestSegment(uri, index); err != nil {
w.log(LogTypeError, "segment #%d request error, ignored: %v", index, err)
return
}
}(w.segmentIndex, v.URI)
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?")
}
req, err := http.NewRequest("GET", w.sourceURL, nil)
if err != nil {
return nil, 0, fmt.Errorf("new request: %w", err)
}
if w.CFCookie != "" {
cookie := &http.Cookie{
Name: "cf_clearance",
Value: w.CFCookie,
}
req.AddCookie(cookie)
}
if w.UserAgent != "" {
req.Header.Set("User-Agent", w.UserAgent)
}
resp, err := client.Do(req)
if err != nil {
return nil, 3, fmt.Errorf("client do: %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, playlist.TargetDuration, 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?")
}
req, err := http.NewRequest("GET", w.rootURL+url, nil)
if err != nil {
return fmt.Errorf("new request: %w", err)
}
if w.CFCookie != "" {
cookie := &http.Cookie{
Name: "cf_clearance",
Value: w.CFCookie,
}
req.AddCookie(cookie)
}
if w.UserAgent != "" {
req.Header.Set("User-Agent", w.UserAgent)
}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("client do: %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
}

View File

@ -1,21 +0,0 @@
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)
}

View File

@ -1,88 +0,0 @@
package chaturbate
import (
"fmt"
"time"
)
// log
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 {
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.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,
Log: updateLog,
IsPaused: w.IsPaused,
IsOnline: w.IsOnline,
LastStreamedAt: w.LastStreamedAt,
SegmentDuration: w.SegmentDuration,
SegmentFilesize: w.SegmentFilesize,
}
if w.file != nil {
update.Filename = w.file.Name()
}
select {
case w.UpdateChannel <- update:
default:
}
fmt.Println(consoleLog)
w.Logs = append(w.Logs, updateLog)
// Only keep the last 100 logs in memory.
if len(w.Logs) > 100 {
w.Logs = w.Logs[len(w.Logs)-100:]
}
}
// isSegmentFetched returns true if the segment has been fetched.
func (w *Channel) isSegmentFetched(url string) bool {
for _, v := range w.segmentUseds {
if url == v {
return true
}
}
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)
}

259
chaturbate/chaturbate.go Normal file
View File

@ -0,0 +1,259 @@
package chaturbate
import (
"context"
"encoding/json"
"errors"
"fmt"
"regexp"
"strconv"
"strings"
"time"
"github.com/avast/retry-go/v4"
"github.com/grafov/m3u8"
"github.com/samber/lo"
"github.com/teacat/chaturbate-dvr/internal"
"github.com/teacat/chaturbate-dvr/server"
)
// roomDossierRegexp is used to extract the room dossier information from the HTML response.
var roomDossierRegexp = regexp.MustCompile(`window\.initialRoomDossier = "(.*?)"`)
// Client represents an API client for interacting with Chaturbate.
type Client struct {
Req *internal.Req
}
// NewClient initializes and returns a new Client instance.
func NewClient() *Client {
return &Client{
Req: internal.NewReq(),
}
}
// GetStream fetches the stream information for a given username.
func (c *Client) GetStream(ctx context.Context, username string) (*Stream, error) {
return FetchStream(ctx, c.Req, username)
}
// FetchStream retrieves the streaming data from the given username's page.
func FetchStream(ctx context.Context, client *internal.Req, username string) (*Stream, error) {
body, err := client.Get(ctx, fmt.Sprintf("%s%s", server.Config.Domain, username))
if err != nil {
return nil, fmt.Errorf("failed to get page body: %w", err)
}
// Check for Cloudflare protection page
if strings.Contains(body, "<title>Just a moment...</title>") {
return nil, internal.ErrCloudflareBlocked
}
// Ensure that the playlist.m3u8 file is present in the response
if !strings.Contains(body, "playlist.m3u8") {
return nil, internal.ErrChannelOffline
}
return ParseStream(body)
}
// ParseStream extracts the HLS source URL from the given page body.
func ParseStream(body string) (*Stream, error) {
matches := roomDossierRegexp.FindStringSubmatch(body)
if len(matches) == 0 {
return nil, errors.New("room dossier not found")
}
// Decode Unicode escape sequences in the extracted JSON string
sourceData, err := strconv.Unquote(strings.Replace(strconv.Quote(matches[1]), `\\u`, `\u`, -1))
if err != nil {
return nil, fmt.Errorf("failed to decode unicode: %w", err)
}
// Unmarshal JSON to extract HLS source URL
var room struct {
HLSSource string `json:"hls_source"`
}
if err := json.Unmarshal([]byte(sourceData), &room); err != nil {
return nil, fmt.Errorf("failed to parse JSON: %w", err)
}
return &Stream{HLSSource: room.HLSSource}, nil
}
// Stream represents an HLS stream source.
type Stream struct {
HLSSource string
}
// GetPlaylist retrieves the playlist corresponding to the given resolution and framerate.
func (s *Stream) GetPlaylist(ctx context.Context, resolution, framerate int) (*Playlist, error) {
return FetchPlaylist(ctx, s.HLSSource, resolution, framerate)
}
// FetchPlaylist fetches and decodes the HLS playlist file.
func FetchPlaylist(ctx context.Context, hlsSource string, resolution, framerate int) (*Playlist, error) {
if hlsSource == "" {
return nil, errors.New("HLS source is empty")
}
resp, err := internal.NewReq().Get(ctx, hlsSource)
if err != nil {
return nil, fmt.Errorf("failed to fetch HLS source: %w", err)
}
return ParsePlaylist(resp, hlsSource, resolution, framerate)
}
// ParsePlaylist decodes the M3U8 playlist and extracts the variant streams.
func ParsePlaylist(resp, hlsSource string, resolution, framerate int) (*Playlist, error) {
p, _, err := m3u8.DecodeFrom(strings.NewReader(resp), true)
if err != nil {
return nil, fmt.Errorf("failed to decode m3u8 playlist: %w", err)
}
masterPlaylist, ok := p.(*m3u8.MasterPlaylist)
if !ok {
return nil, errors.New("invalid master playlist format")
}
return PickPlaylist(masterPlaylist, hlsSource, resolution, framerate)
}
// Playlist represents an HLS playlist containing variant streams.
type Playlist struct {
PlaylistURL string
RootURL string
Resolution int
Framerate int
}
// Resolution represents a video resolution and its corresponding framerate.
type Resolution struct {
Framerate map[int]string // [framerate]url
Width int
}
// PickPlaylist selects the best matching variant stream based on resolution and framerate.
func PickPlaylist(masterPlaylist *m3u8.MasterPlaylist, baseURL string, resolution, framerate int) (*Playlist, error) {
resolutions := map[int]*Resolution{}
// Extract available resolutions and framerates from the master playlist
for _, v := range masterPlaylist.Variants {
parts := strings.Split(v.Resolution, "x")
if len(parts) != 2 {
continue
}
width, err := strconv.Atoi(parts[1])
if err != nil {
return nil, fmt.Errorf("parse resolution: %w", err)
}
framerateVal := 30
if strings.Contains(v.Name, "FPS:60.0") {
framerateVal = 60
}
if _, exists := resolutions[width]; !exists {
resolutions[width] = &Resolution{Framerate: map[int]string{}, Width: width}
}
resolutions[width].Framerate[framerateVal] = v.URI
}
// Find exact match for requested resolution
variant, exists := resolutions[resolution]
if !exists {
// Filter resolutions below the requested resolution
candidates := lo.Filter(lo.Values(resolutions), func(r *Resolution, _ int) bool {
return r.Width < resolution
})
// Pick the highest resolution among the candidates
variant = lo.MaxBy(candidates, func(a, b *Resolution) bool {
return a.Width > b.Width
})
}
if variant == nil {
return nil, fmt.Errorf("resolution not found")
}
var (
finalResolution = variant.Width
finalFramerate = framerate
)
// Select the desired framerate, or fallback to the first available framerate
playlistURL, exists := variant.Framerate[framerate]
if !exists {
for fr, url := range variant.Framerate {
playlistURL = url
finalFramerate = fr
break
}
}
return &Playlist{
PlaylistURL: strings.TrimSuffix(baseURL, "playlist.m3u8") + playlistURL,
RootURL: strings.TrimSuffix(baseURL, "playlist.m3u8"),
Resolution: finalResolution,
Framerate: finalFramerate,
}, nil
}
// WatchHandler is a function type that processes video segments.
type WatchHandler func(b []byte, duration float64) error
// WatchSegments continuously fetches and processes video segments.
func (p *Playlist) WatchSegments(ctx context.Context, handler WatchHandler) error {
var (
client = internal.NewReq()
lastSeq = -1
)
for {
// Fetch the latest playlist
resp, err := client.Get(ctx, p.PlaylistURL)
if err != nil {
return fmt.Errorf("get playlist: %w", err)
}
pl, _, err := m3u8.DecodeFrom(strings.NewReader(resp), true)
if err != nil {
return fmt.Errorf("decode from: %w", err)
}
playlist, ok := pl.(*m3u8.MediaPlaylist)
if !ok {
return fmt.Errorf("cast to media playlist")
}
// Process new segments
for _, v := range playlist.Segments {
if v == nil {
continue
}
seq := internal.SegmentSeq(v.URI)
if seq == -1 || seq <= lastSeq {
continue
}
lastSeq = seq
// Fetch segment data with retry mechanism
pipeline := func() ([]byte, error) {
return client.GetBytes(ctx, fmt.Sprintf("%s%s", p.RootURL, v.URI))
}
resp, err := retry.DoWithData(
pipeline,
retry.Context(ctx),
retry.Attempts(3),
retry.Delay(600*time.Millisecond),
retry.DelayType(retry.FixedDelay),
)
if err != nil {
break
}
// Process the segment using the provided handler
if err := handler(resp, v.Duration); err != nil {
return fmt.Errorf("handler: %w", err)
}
}
<-time.After(1 * time.Second) // time.Duration(playlist.TargetDuration)
}
}

View File

@ -1,64 +0,0 @@
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
}

View File

@ -1,215 +0,0 @@
package chaturbate
import (
"encoding/json"
"errors"
"os"
"github.com/google/uuid"
"github.com/urfave/cli/v2"
)
const (
ResolutionFallbackUpscale = "up"
ResolutionFallbackDownscale = "down"
)
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")
)
// Config
type Config struct {
Username string
FilenamePattern string
Framerate int
Resolution int
ResolutionFallback string
SplitDuration int
SplitFilesize int
Interval int
CFCookie string
UserAgent string
}
// Manager
type Manager struct {
cli *cli.Context
Channels map[string]*Channel
Updates map[string]chan *Update
}
// NewManager
func NewManager(c *cli.Context) *Manager {
return &Manager{
cli: c,
Channels: map[string]*Channel{},
Updates: map[string]chan *Update{},
}
}
// PauseChannel
func (m *Manager) PauseChannel(username string) error {
v, ok := m.Channels[username]
if !ok {
return ErrChannelNotFound
}
if v.IsPaused { // no-op
return nil
}
v.Pause()
return nil
}
// ResumeChannel
func (m *Manager) ResumeChannel(username string) error {
v, ok := m.Channels[username]
if !ok {
return ErrChannelNotFound
}
if !v.IsPaused { // no-op
return nil
}
v.Resume()
return nil
}
// DeleteChannel
func (m *Manager) DeleteChannel(username string) error {
v, ok := m.Channels[username]
if !ok {
return ErrChannelNotFound
}
v.Stop()
delete(m.Channels, username)
return nil
}
// CreateChannel
func (m *Manager) CreateChannel(conf *Config) error {
_, ok := m.Channels[conf.Username]
if ok {
return ErrChannelExists
}
c := &Channel{
Username: conf.Username,
ChannelURL: "https://chaturbate.global/" + conf.Username,
filenamePattern: conf.FilenamePattern,
Framerate: conf.Framerate,
Resolution: conf.Resolution,
ResolutionFallback: conf.ResolutionFallback,
Interval: conf.Interval,
CFCookie: m.cli.String("cf-cookie"),
UserAgent: m.cli.String("user-agent"),
LastStreamedAt: "-",
SegmentDuration: 0,
SplitDuration: conf.SplitDuration,
SegmentFilesize: 0,
SplitFilesize: conf.SplitFilesize,
IsOnline: false,
IsPaused: false,
isStopped: false,
Logs: []string{},
UpdateChannel: make(chan *Update),
ResumeChannel: make(chan bool),
LogType: LogType(m.cli.String("log-level")),
}
go func() {
for update := range c.UpdateChannel {
for _, v := range m.Updates {
if v != nil {
v <- update
}
}
}
}()
m.Channels[conf.Username] = c
c.log(LogTypeInfo, "channel created")
go c.Run()
return nil
}
// ListChannels
func (m *Manager) ListChannels() ([]*Channel, error) {
var channels []*Channel
for _, v := range m.Channels {
channels = append(channels, v)
}
return channels, nil
}
// GetChannel
func (m *Manager) GetChannel(username string) (*Channel, error) {
v, ok := m.Channels[username]
if !ok {
return nil, ErrChannelNotFound
}
return v, nil
}
// ListenUpdate
func (m *Manager) ListenUpdate() (<-chan *Update, string) {
c := make(chan *Update)
id := uuid.New().String()
m.Updates[id] = c
return c, id
}
// StopListenUpdate
func (m *Manager) StopListenUpdate(id string) error {
v, ok := m.Updates[id]
if !ok {
return ErrListenNotFound
}
delete(m.Updates, id)
close(v)
return nil
}
// SaveChannels
func (m *Manager) SaveChannels() error {
configs := make([]*Config, 0)
for _, v := range m.Channels {
configs = append(configs, &Config{
Username: v.Username,
Framerate: v.Framerate,
Resolution: v.Resolution,
ResolutionFallback: v.ResolutionFallback,
FilenamePattern: v.filenamePattern,
SplitDuration: v.SplitDuration,
SplitFilesize: v.SplitFilesize,
Interval: v.Interval,
})
}
b, err := json.MarshalIndent(configs, "", " ")
if err != nil {
return err
}
return os.WriteFile("chaturbate_channels.json", b, 0777)
}
// LoadChannels
func (m *Manager) LoadChannels() error {
b, err := os.ReadFile("chaturbate_channels.json")
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
var configs []*Config
if err := json.Unmarshal(b, &configs); err != nil {
return err
}
for _, v := range configs {
if err := m.CreateChannel(v); err != nil {
return err
}
}
return nil
}

View File

@ -1,12 +0,0 @@
[
{
"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
}
]

26
config/config.go Normal file
View File

@ -0,0 +1,26 @@
package config
import (
"github.com/teacat/chaturbate-dvr/entity"
"github.com/urfave/cli/v2"
)
// New initializes a new Config struct with values from the CLI context.
func New(c *cli.Context) (*entity.Config, error) {
return &entity.Config{
Version: c.App.Version,
Username: c.String("username"),
AdminUsername: c.String("admin-username"),
AdminPassword: c.String("admin-password"),
Framerate: c.Int("framerate"),
Resolution: c.Int("resolution"),
Pattern: c.String("pattern"),
MaxDuration: c.Int("max-duration") * 60,
MaxFilesize: c.Int("max-filesize"),
Port: c.String("port"),
Interval: c.Int("interval"),
Cookies: c.String("cookies"),
UserAgent: c.String("user-agent"),
Domain: c.String("domain"),
}, nil
}

View File

@ -1,13 +0,0 @@
version: "3.0"
services:
chaturbate-dvr-web:
container_name: chaturbate-dvr-web
build:
context: .
dockerfile: web.Dockerfile
environment:
- GUI_USERNAME=johndoe
- GUI_PASSWORD=password
ports:
- "8080:8080"

View File

@ -1,10 +1,11 @@
version: "3.0"
version: "3.8"
services:
chaturbate-dvr:
container_name: chaturbate-dvr
build: .
environment:
- USERNAME=CHANNEL_USERNAME
volumes:
- ./videos:/usr/src/app/videos
chaturbate-dvr:
image: yamiodymel/chaturbate-dvr
container_name: chaturbate-dvr
ports:
- "8080:8080"
volumes:
- ./videos:/usr/src/app/videos
- ./conf:/usr/src/app/conf

56
entity/entity.go Normal file
View File

@ -0,0 +1,56 @@
package entity
// Event represents the type of event for the channel.
type Event = string
const (
EventUpdate Event = "update"
EventLog Event = "log"
)
// ChannelConfig represents the configuration for a channel.
type ChannelConfig struct {
IsPaused bool `json:"is_paused"`
Username string `json:"username"`
Framerate int `json:"framerate"`
Resolution int `json:"resolution"`
Pattern string `json:"pattern"`
MaxDuration int `json:"max_duration"`
MaxFilesize int `json:"max_filesize"`
CreatedAt int64 `json:"created_at"`
}
// ChannelInfo represents the information about a channel,
// mostly used for the template rendering.
type ChannelInfo struct {
IsOnline bool
IsPaused bool
Username string
Duration string
Filesize string
Filename string
StreamedAt string
MaxDuration string
MaxFilesize string
CreatedAt int64
Logs []string
GlobalConfig *Config // for nested template to access $.Config
}
// Config holds the configuration for the application.
type Config struct {
Version string
Username string
AdminUsername string
AdminPassword string
Framerate int
Resolution int
Pattern string
MaxDuration int
MaxFilesize int
Port string
Interval int
Cookies string
UserAgent string
Domain string
}

46
go.mod
View File

@ -1,47 +1,45 @@
module github.com/teacat/chaturbate-dvr
go 1.22
toolchain go1.24.1
go 1.23.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
github.com/urfave/cli/v2 v2.27.1
github.com/avast/retry-go/v4 v4.6.1
github.com/gin-gonic/gin v1.10.0
github.com/grafov/m3u8 v0.12.1
github.com/r3labs/sse/v2 v2.10.0
github.com/samber/lo v1.49.1
github.com/urfave/cli/v2 v2.27.6
)
require (
github.com/bytedance/sonic v1.11.3 // indirect
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d // indirect
github.com/chenzhuoyu/iasm v0.9.1 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect
github.com/bytedance/sonic v1.11.6 // indirect
github.com/bytedance/sonic/loader v0.1.1 // indirect
github.com/cloudwego/base64x v0.1.4 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.5 // 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.20.0 // 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.7 // indirect
github.com/kr/pretty v0.3.0 // 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.2.0 // indirect
github.com/rogpeppe/go-internal v1.8.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // 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.12 // indirect
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect
golang.org/x/arch v0.7.0 // indirect
golang.org/x/crypto v0.35.0 // indirect
golang.org/x/exp v0.0.0-20220303212507-bbda1eaf7a17 // indirect
golang.org/x/net v0.36.0 // indirect
golang.org/x/sys v0.30.0 // indirect
golang.org/x/text v0.22.0 // indirect
google.golang.org/protobuf v1.33.0 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect
golang.org/x/arch v0.8.0 // indirect
golang.org/x/crypto v0.23.0 // indirect
golang.org/x/net v0.25.0 // indirect
golang.org/x/sys v0.20.0 // indirect
golang.org/x/text v0.21.0 // indirect
google.golang.org/protobuf v1.34.1 // indirect
gopkg.in/cenkalti/backoff.v1 v1.1.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

115
go.sum
View File

@ -1,17 +1,15 @@
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.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/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/avast/retry-go/v4 v4.6.1 h1:VkOLRubHdisGrHnTu89g08aQEWEgRU7LVEop3GbIcMk=
github.com/avast/retry-go/v4 v4.6.1/go.mod h1:V6oF8njAwxJ5gRo1Q7Cxab24xs5NCWZBeaHHBklR8mA=
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/cpuguy83/go-md2man/v2 v2.0.5 h1:ZtcqGrnekaHpVLArFSe4HK5DoKx1T0rq2DwVB0alcyc=
github.com/cpuguy83/go-md2man/v2 v2.0.5/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
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=
@ -19,37 +17,29 @@ github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uq
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=
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
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.19.0 h1:ol+5Fu+cSq9JD7SoSqe04GMI92cbn0+wvQ3bZ8b/AU4=
github.com/go-playground/validator/v10 v10.19.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
github.com/go-playground/validator/v10 v10.20.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/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=
github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU=
github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/grafov/m3u8 v0.12.0 h1:T6iTwTsSEtMcwkayef+FJO8kj+Sglr4Lh81Zj8Ked/4=
github.com/grafov/m3u8 v0.12.0/go.mod h1:nqzOkfBiZJENr52zTVd/Dcl03yzphIMbJqkXGu+u080=
github.com/grafov/m3u8 v0.12.1 h1:DuP1uA1kvRRmGNAZ0m+ObLv1dvrfNO0TPx0c/enNk0s=
github.com/grafov/m3u8 v0.12.1/go.mod h1:nqzOkfBiZJENr52zTVd/Dcl03yzphIMbJqkXGu+u080=
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.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=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
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.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
@ -61,18 +51,18 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
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.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/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8=
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
github.com/r3labs/sse/v2 v2.10.0 h1:hFEkLLFY4LDifoHdiCN/LlGBAdVJYsANaLqNYa1l/v0=
github.com/r3labs/sse/v2 v2.10.0/go.mod h1:Igau6Whc+F17QUgML1fYe1VPZzTV6EMCnYktEmkNJ7I=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/samber/lo v1.39.0 h1:4gTz1wUhNYLhFSKl6O+8peW0v2F4BCY034GRpU9WnuA=
github.com/samber/lo v1.39.0/go.mod h1:+m/ZKRl6ClXCE2Lgf3MsQlWfh4bn1bz6CXEOxnEXnEA=
github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew=
github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o=
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=
@ -83,40 +73,43 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
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.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/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.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.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=
github.com/urfave/cli/v2 v2.27.6 h1:VdRdS98FNhKZ8/Az8B7MTyGQmpIr36O1EHybx/LaZ4g=
github.com/urfave/cli/v2 v2.27.6/go.mod h1:3Sevf16NykTbInEnD0yKkjDAeZDS0A6bzhBH5hrMvTQ=
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4=
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
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.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs=
golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ=
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.36.0 h1:vWF2fRbw4qslQsQzgFqZff+BItCvGFQqKzKIzx1rmoA=
golang.org/x/net v0.36.0/go.mod h1:bFmbeoIPfrw4sMHNhb4J9f6+tPziuGjq7Jk/38fxi1I=
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/net v0.0.0-20191116160921-f9c825593386/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
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.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
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.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI=
google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
gopkg.in/cenkalti/backoff.v1 v1.1.0 h1:Arh75ttbsvlpVA7WtVpH4u9h6Zl46xuptxqLxPiSo4Y=
gopkg.in/cenkalti/backoff.v1 v1.1.0/go.mod h1:J6Vskwqd+OMVJl8C33mmtxTBs2gyzfv7UDAkHu8BrjI=
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=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@ -1,74 +0,0 @@
package handler
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/teacat/chaturbate-dvr/chaturbate"
"github.com/urfave/cli/v2"
)
//=======================================================
// Request & Response
//=======================================================
type CreateChannelRequest struct {
Username string `json:"username"`
Framerate int `json:"framerate"`
FilenamePattern string `json:"filename_pattern"`
Resolution int `json:"resolution"`
ResolutionFallback string `json:"resolution_fallback"`
SplitDuration int `json:"split_duration"`
SplitFilesize int `json:"split_filesize"`
Interval int `json:"interval"`
}
type CreateChannelResponse struct {
}
//=======================================================
// Factory
//=======================================================
type CreateChannelHandler struct {
chaturbate *chaturbate.Manager
cli *cli.Context
}
func NewCreateChannelHandler(c *chaturbate.Manager, cli *cli.Context) *CreateChannelHandler {
return &CreateChannelHandler{c, cli}
}
//=======================================================
// Handle
//=======================================================
func (h *CreateChannelHandler) Handle(c *gin.Context) {
var req *CreateChannelRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.AbortWithError(http.StatusBadRequest, err)
return
}
usernames := strings.Split(req.Username, ",")
for _, username := range usernames {
if err := h.chaturbate.CreateChannel(&chaturbate.Config{
Username: strings.TrimSpace(username),
Framerate: req.Framerate,
Resolution: req.Resolution,
ResolutionFallback: req.ResolutionFallback,
FilenamePattern: req.FilenamePattern,
SplitDuration: req.SplitDuration,
SplitFilesize: req.SplitFilesize,
Interval: req.Interval,
}); err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
}
if err := h.chaturbate.SaveChannels(); err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
c.JSON(http.StatusOK, &CreateChannelResponse{})
}

View File

@ -1,54 +0,0 @@
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/teacat/chaturbate-dvr/chaturbate"
"github.com/urfave/cli/v2"
)
//=======================================================
// Request & Response
//=======================================================
type DeleteChannelRequest struct {
Username string `json:"username"`
}
type DeleteChannelResponse struct {
}
//=======================================================
// Factory
//=======================================================
type DeleteChannelHandler struct {
chaturbate *chaturbate.Manager
cli *cli.Context
}
func NewDeleteChannelHandler(c *chaturbate.Manager, cli *cli.Context) *DeleteChannelHandler {
return &DeleteChannelHandler{c, cli}
}
//=======================================================
// Handle
//=======================================================
func (h *DeleteChannelHandler) Handle(c *gin.Context) {
var req *DeleteChannelRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.AbortWithError(http.StatusBadRequest, err)
return
}
if err := h.chaturbate.DeleteChannel(req.Username); err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
if err := h.chaturbate.SaveChannels(); err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
c.JSON(http.StatusOK, &DeleteChannelResponse{})
}

View File

@ -1,74 +0,0 @@
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/teacat/chaturbate-dvr/chaturbate"
"github.com/urfave/cli/v2"
)
//=======================================================
// Request & Response
//=======================================================
type GetChannelRequest struct {
Username string `json:"username"`
}
type GetChannelResponse struct {
Username string `json:"username"`
ChannelURL string `json:"channel_url"`
Filename string `json:"filename"`
LastStreamedAt string `json:"last_streamed_at"`
SegmentDuration string `json:"segment_duration"`
SplitDuration string `json:"split_duration"`
SegmentFilesize string `json:"segment_filesize"`
SplitFilesize string `json:"split_filesize"`
IsOnline bool `json:"is_online"`
IsPaused bool `json:"is_paused"`
Logs []string `json:"logs"`
}
//=======================================================
// Factory
//=======================================================
type GetChannelHandler struct {
chaturbate *chaturbate.Manager
cli *cli.Context
}
func NewGetChannelHandler(c *chaturbate.Manager, cli *cli.Context) *GetChannelHandler {
return &GetChannelHandler{c, cli}
}
//=======================================================
// Handle
//=======================================================
func (h *GetChannelHandler) Handle(c *gin.Context) {
var req *GetChannelRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.AbortWithError(http.StatusBadRequest, err)
return
}
channel, err := h.chaturbate.GetChannel(req.Username)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
c.JSON(http.StatusOK, &GetChannelResponse{
Username: channel.Username,
ChannelURL: channel.ChannelURL,
Filename: channel.Filename(),
LastStreamedAt: channel.LastStreamedAt,
SegmentDuration: channel.SegmentDurationStr(),
SplitDuration: channel.SplitDurationStr(),
SegmentFilesize: channel.SegmentFilesizeStr(),
SplitFilesize: channel.SplitFilesizeStr(),
IsOnline: channel.IsOnline,
IsPaused: channel.IsPaused,
Logs: channel.Logs,
})
}

View File

@ -1,66 +0,0 @@
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/urfave/cli/v2"
)
//=======================================================
// Request & Response
//=======================================================
type GetSettingsHandlerRequest struct {
}
type GetSettingsHandlerResponse struct {
Version string `json:"version"`
Framerate int `json:"framerate"`
Resolution int `json:"resolution"`
ResolutionFallback string `json:"resolution_fallback"`
FilenamePattern string `json:"filename_pattern"`
SplitDuration int `json:"split_duration"`
SplitFilesize int `json:"split_filesize"`
Interval int `json:"interval"`
LogLevel string `json:"log_level"`
Port string `json:"port"`
GUI string `json:"gui"`
}
//=======================================================
// Factory
//=======================================================
type GetSettingsHandler struct {
cli *cli.Context
}
func NewGetSettingsHandler(cli *cli.Context) *GetSettingsHandler {
return &GetSettingsHandler{cli}
}
//=======================================================
// Handle
//=======================================================
func (h *GetSettingsHandler) Handle(c *gin.Context) {
var req *GetSettingsHandlerRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.AbortWithError(http.StatusBadRequest, err)
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"),
FilenamePattern: h.cli.String("filename-pattern"),
SplitDuration: h.cli.Int("split-duration"),
SplitFilesize: h.cli.Int("split-filesize"),
Interval: h.cli.Int("interval"),
LogLevel: h.cli.String("log-level"),
Port: h.cli.String("port"),
GUI: h.cli.String("gui"),
})
}

View File

@ -1,98 +0,0 @@
package handler
import (
"net/http"
"sort"
"github.com/gin-gonic/gin"
"github.com/teacat/chaturbate-dvr/chaturbate"
"github.com/urfave/cli/v2"
)
//=======================================================
// Request & Response
//=======================================================
type ListChannelsRequest struct {
}
type ListChannelsResponse struct {
Channels []*ListChannelsResponseChannel `json:"channels"`
}
type ListChannelsResponseChannel struct {
Username string `json:"username"`
ChannelURL string `json:"channel_url"`
Filename string `json:"filename"`
LastStreamedAt string `json:"last_streamed_at"`
SegmentDuration string `json:"segment_duration"`
SplitDuration string `json:"split_duration"`
SegmentFilesize string `json:"segment_filesize"`
SplitFilesize string `json:"split_filesize"`
Interval int `json:"interval"`
IsOnline bool `json:"is_online"`
IsPaused bool `json:"is_paused"`
Logs []string `json:"logs"`
}
//=======================================================
// Factory
//=======================================================
type ListChannelsHandler struct {
chaturbate *chaturbate.Manager
cli *cli.Context
}
func NewListChannelsHandler(c *chaturbate.Manager, cli *cli.Context) *ListChannelsHandler {
return &ListChannelsHandler{c, cli}
}
//=======================================================
// 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)),
}
for i, channel := range channels {
resp.Channels[i] = &ListChannelsResponseChannel{
Username: channel.Username,
ChannelURL: channel.ChannelURL,
Filename: channel.Filename(),
LastStreamedAt: channel.LastStreamedAt,
SegmentDuration: channel.SegmentDurationStr(),
SplitDuration: channel.SplitDurationStr(),
SegmentFilesize: channel.SegmentFilesizeStr(),
SplitFilesize: channel.SplitFilesizeStr(),
Interval: channel.Interval,
IsOnline: channel.IsOnline,
IsPaused: channel.IsPaused,
Logs: channel.Logs,
}
}
// Send the response
c.JSON(http.StatusOK, resp)
}

View File

@ -1,56 +0,0 @@
package handler
import (
"io"
"github.com/gin-gonic/gin"
"github.com/teacat/chaturbate-dvr/chaturbate"
"github.com/urfave/cli/v2"
)
//=======================================================
// Request & Response
//=======================================================
type ListenUpdateRequest struct {
}
//=======================================================
// Factory
//=======================================================
type ListenUpdateHandler struct {
chaturbate *chaturbate.Manager
cli *cli.Context
}
func NewListenUpdateHandler(c *chaturbate.Manager, cli *cli.Context) *ListenUpdateHandler {
return &ListenUpdateHandler{c, cli}
}
//=======================================================
// Handle
//=======================================================
func (h *ListenUpdateHandler) Handle(c *gin.Context) {
ch, id := h.chaturbate.ListenUpdate()
c.Stream(func(w io.Writer) bool {
update := <-ch
if update == nil {
return false
}
c.SSEvent("message", map[string]any{
"username": update.Username,
"log": update.Log,
"is_paused": update.IsPaused,
"is_online": update.IsOnline,
"last_streamed_at": update.LastStreamedAt,
"segment_duration": update.SegmentDurationStr(),
"segment_filesize": update.SegmentFilesizeStr(),
"filename": update.Filename,
})
return true
})
h.chaturbate.StopListenUpdate(id)
}

View File

@ -1,50 +0,0 @@
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/teacat/chaturbate-dvr/chaturbate"
"github.com/urfave/cli/v2"
)
//=======================================================
// Request & Response
//=======================================================
type PauseChannelRequest struct {
Username string `json:"username"`
}
type PauseChannelResponse struct {
}
//=======================================================
// Factory
//=======================================================
type PauseChannelHandler struct {
chaturbate *chaturbate.Manager
cli *cli.Context
}
func NewPauseChannelHandler(c *chaturbate.Manager, cli *cli.Context) *PauseChannelHandler {
return &PauseChannelHandler{c, cli}
}
//=======================================================
// Handle
//=======================================================
func (h *PauseChannelHandler) Handle(c *gin.Context) {
var req *PauseChannelRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.AbortWithError(http.StatusBadRequest, err)
return
}
if err := h.chaturbate.PauseChannel(req.Username); err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
c.JSON(http.StatusOK, &PauseChannelResponse{})
}

View File

@ -1,50 +0,0 @@
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/teacat/chaturbate-dvr/chaturbate"
"github.com/urfave/cli/v2"
)
//=======================================================
// Request & Response
//=======================================================
type ResumeChannelRequest struct {
Username string `json:"username"`
}
type ResumeChannelResponse struct {
}
//=======================================================
// Factory
//=======================================================
type ResumeChannelHandler struct {
chaturbate *chaturbate.Manager
cli *cli.Context
}
func NewResumeChannelHandler(c *chaturbate.Manager, cli *cli.Context) *ResumeChannelHandler {
return &ResumeChannelHandler{c, cli}
}
//=======================================================
// Handle
//=======================================================
func (h *ResumeChannelHandler) Handle(c *gin.Context) {
var req *ResumeChannelRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.AbortWithError(http.StatusBadRequest, err)
return
}
if err := h.chaturbate.ResumeChannel(req.Username); err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
c.JSON(http.StatusOK, &ResumeChannelResponse{})
}

View File

@ -1,46 +0,0 @@
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)
}

View File

@ -1,120 +0,0 @@
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,
// })
// }
// }

View File

@ -1,423 +0,0 @@
<!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="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>
<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://cdnjs.cloudflare.com/ajax/libs/alpinejs/3.14.8/cdn.min.js" 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.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-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 -->
</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 &lt; 2" style="display: none"> channel is</span>
<span x-show="channels.length &gt; 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 }">&laquo;</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 }">&raquo;</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>

View File

@ -1,254 +0,0 @@
function data() {
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,
},
// Watch for changes in LogLevel
watchLogLevel() {
this.$watch("settings.log_level", async (newVal, oldVal) => {
if (newVal !== oldVal) {
await this.updateLogLevel();
}
});
},
// 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);
},
// Calculate total pages
get totalPages() {
return Math.ceil(this.channels.length / this.itemsPerPage);
},
// Change page on click
goToPage(page) {
if (page >= 1 && page <= this.totalPages) {
this.currentPage = page;
}
},
// openCreateDialog
openCreateDialog() {
document.getElementById("create-dialog").showModal();
},
// closeCreateDialog
closeCreateDialog() {
document.getElementById("create-dialog").close();
this.resetCreateDialog();
},
// submitCreateDialog
submitCreateDialog() {
this.createChannel();
this.closeCreateDialog();
},
// error
error() {
alert("Error occurred, please refresh the page if something is wrong.");
},
//
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];
}
},
// getSettings
async getSettings() {
var [resp, err] = await this.call("get_settings", {});
if (!err) {
this.settings = resp;
this.resetCreateDialog();
await this.updateLogLevel();
}
},
// init
async init() {
document
.getElementById('create-dialog')
.addEventListener('close', () => this.resetCreateDialog());
await this.getSettings(); // Ensure settings are loaded
this.watchLogLevel(); // Start watching LogLevel after settings load
await this.listChannels();
this.listenUpdate();
},
async updateLogLevel() {
const [_, err] = await this.call('update_log_level', {
log_level: this.settings.log_level,
});
if (err) {
this.error();
}
},
// resetCreateDialog
resetCreateDialog() {
document.getElementById("splitting-accordion").open = false;
// 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",
};
},
// 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),
});
},
// 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);
}
},
// pauseChannel
async pauseChannel(username) {
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 });
},
// 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;
},
// listenUpdate
listenUpdate() {
var source = new EventSource("/api/listen_update");
source.onmessage = (event) => {
var data = JSON.parse(event.data);
// 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;
}
var index = this.channels.findIndex((ch) => ch.username === data.username);
if (index === -1) {
return;
}
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 (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);
},
};
}

56
internal/internal.go Normal file
View File

@ -0,0 +1,56 @@
package internal
import (
"fmt"
"regexp"
"strconv"
)
// FormatDuration converts a float64 duration (in seconds) to h:m:s format.
func FormatDuration(duration float64) string {
if duration == 0 {
return ""
}
var (
hours = int(duration) / 3600
minutes = (int(duration) % 3600) / 60
seconds = int(duration) % 60
)
return fmt.Sprintf("%d:%02d:%02d", hours, minutes, seconds)
}
// FormatFilesize converts an int filesize in bytes to a human-readable string (KB, MB, GB).
func FormatFilesize(filesize int) string {
if filesize == 0 {
return ""
}
const (
KB = 1024
MB = KB * 1024
GB = MB * 1024
)
switch {
case filesize >= GB:
return fmt.Sprintf("%.2f GB", float64(filesize)/float64(GB))
case filesize >= MB:
return fmt.Sprintf("%.2f MB", float64(filesize)/float64(MB))
case filesize >= KB:
return fmt.Sprintf("%.2f KB", float64(filesize)/float64(KB))
default:
return fmt.Sprintf("%d bytes", filesize)
}
}
// SegmentSeq extracts the segment sequence number from a filename.
func SegmentSeq(filename string) int {
re := regexp.MustCompile(`_(\d+)\.ts$`)
match := re.FindStringSubmatch(filename)
if len(match) > 1 {
number, err := strconv.Atoi(match[1])
if err == nil {
return number
}
}
return -1
}

13
internal/internal_err.go Normal file
View File

@ -0,0 +1,13 @@
package internal
import "errors"
var (
ErrChannelExists = errors.New("channel exists")
ErrChannelNotFound = errors.New("channel not found")
ErrCloudflareBlocked = errors.New("blocked by Cloudflare; try with `-cookies` and `-user-agent`")
ErrChannelOffline = errors.New("channel offline")
ErrPrivateStream = errors.New("channel went offline or private")
ErrPaused = errors.New("channel paused")
ErrStopped = errors.New("channel stopped")
)

117
internal/internal_req.go Normal file
View File

@ -0,0 +1,117 @@
package internal
import (
"context"
"crypto/tls"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/teacat/chaturbate-dvr/server"
)
// Req represents an HTTP client with customized settings.
type Req struct {
client *http.Client
}
// NewReq creates a new HTTP client with specific transport configurations.
func NewReq() *Req {
return &Req{
client: &http.Client{
Transport: CreateTransport(),
},
}
}
// CreateTransport initializes a custom HTTP transport.
func CreateTransport() *http.Transport {
return &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}
}
// Get sends an HTTP GET request and returns the response as a string.
func (h *Req) Get(ctx context.Context, url string) (string, error) {
resp, err := h.GetBytes(ctx, url)
if err != nil {
return "", fmt.Errorf("get bytes: %w", err)
}
return string(resp), nil
}
// GetBytes sends an HTTP GET request and returns the response as a byte slice.
func (h *Req) GetBytes(ctx context.Context, url string) ([]byte, error) {
req, cancel, err := CreateRequest(ctx, url)
if err != nil {
return nil, fmt.Errorf("new request: %w", err)
}
defer cancel()
resp, err := h.client.Do(req)
if err != nil {
return nil, fmt.Errorf("client do: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusForbidden {
return nil, fmt.Errorf("forbidden: %w", ErrPrivateStream)
}
return ReadResponseBody(resp)
}
// CreateRequest constructs an HTTP GET request with necessary headers.
func CreateRequest(ctx context.Context, url string) (*http.Request, context.CancelFunc, error) {
ctx, cancel := context.WithTimeout(ctx, 10*time.Second) // timed out after 10 seconds
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, cancel, err
}
SetRequestHeaders(req)
return req, cancel, nil
}
// SetRequestHeaders applies necessary headers to the request.
func SetRequestHeaders(req *http.Request) {
if server.Config.UserAgent != "" {
req.Header.Set("User-Agent", server.Config.UserAgent)
}
if server.Config.Cookies != "" {
cookies := ParseCookies(server.Config.Cookies)
for name, value := range cookies {
req.AddCookie(&http.Cookie{Name: name, Value: value})
}
}
}
// ReadResponseBody reads and returns the response body.
func ReadResponseBody(resp *http.Response) ([]byte, error) {
b, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read body: %w", err)
}
return b, nil
}
// ParseCookies converts a cookie string into a map.
func ParseCookies(cookieStr string) map[string]string {
cookies := make(map[string]string)
pairs := strings.Split(cookieStr, ";")
// Iterate over each cookie pair and extract key-value pairs
for _, pair := range pairs {
parts := strings.SplitN(strings.TrimSpace(pair), "=", 2)
if len(parts) == 2 {
// Trim spaces around key and value
key := strings.TrimSpace(parts[0])
value := strings.TrimSpace(parts[1])
// Store cookie name and value in the map
cookies[key] = value
}
}
return cookies
}

210
main.go
View File

@ -1,16 +1,15 @@
package main
import (
"embed"
"fmt"
"io/fs"
"log"
"net/http"
"os"
"github.com/gin-gonic/gin"
"github.com/teacat/chaturbate-dvr/chaturbate"
"github.com/teacat/chaturbate-dvr/handler"
"github.com/teacat/chaturbate-dvr/config"
"github.com/teacat/chaturbate-dvr/entity"
"github.com/teacat/chaturbate-dvr/manager"
"github.com/teacat/chaturbate-dvr/router"
"github.com/teacat/chaturbate-dvr/server"
"github.com/urfave/cli/v2"
)
@ -31,94 +30,76 @@ const logo = `
func main() {
app := &cli.App{
Name: "chaturbate-dvr",
Version: "1.0.7",
Usage: "Records your favorite Chaturbate stream 😎🫵",
Version: "2.0.0",
Usage: "Record your favorite Chaturbate streams automatically. 😎🫵",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "username",
Aliases: []string{"u"},
Usage: "channel username to record",
Usage: "The username of the channel to record",
Value: "",
},
&cli.StringFlag{
Name: "gui-username",
Aliases: []string{"gui-u"},
Usage: "username for auth web (optional)",
Value: "",
Name: "admin-username",
Usage: "Username for web authentication (optional)",
Value: "",
},
&cli.StringFlag{
Name: "gui-password",
Aliases: []string{"gui-p"},
Usage: "password for auth web (optional)",
Value: "",
Name: "admin-password",
Usage: "Password for web authentication (optional)",
Value: "",
},
&cli.IntFlag{
Name: "framerate",
Aliases: []string{"f"},
Usage: "preferred framerate",
Value: 30,
Name: "framerate",
Usage: "Desired framerate (FPS)",
Value: 30,
},
&cli.IntFlag{
Name: "resolution",
Aliases: []string{"r"},
Usage: "preferred resolution",
Value: 1080,
Name: "resolution",
Usage: "Desired resolution (e.g., 1080 for 1080p)",
Value: 1080,
},
&cli.StringFlag{
Name: "resolution-fallback",
Aliases: []string{"rf"},
Usage: "fallback to 'up' (larger) or 'down' (smaller) resolution if preferred resolution is not available",
Value: "down",
},
&cli.StringFlag{
Name: "filename-pattern",
Aliases: []string{"fp"},
Usage: "filename pattern for videos",
Value: "videos/{{.Username}}_{{.Year}}-{{.Month}}-{{.Day}}_{{.Hour}}-{{.Minute}}-{{.Second}}{{if .Sequence}}_{{.Sequence}}{{end}}",
Name: "pattern",
Usage: "Template for naming recorded videos",
Value: "videos/{{.Username}}_{{.Year}}-{{.Month}}-{{.Day}}_{{.Hour}}-{{.Minute}}-{{.Second}}{{if .Sequence}}_{{.Sequence}}{{end}}",
},
&cli.IntFlag{
Name: "split-duration",
Aliases: []string{"sd"},
Usage: "minutes to split each video into segments ('0' to disable)",
Value: 0,
Name: "max-duration",
Usage: "Split video into segments every N minutes ('0' to disable)",
Value: 0,
},
&cli.IntFlag{
Name: "split-filesize",
Aliases: []string{"sf"},
Usage: "size in MB to split each video into segments ('0' to disable)",
Value: 0,
Name: "max-filesize",
Usage: "Split video into segments every N MB ('0' to disable)",
Value: 0,
},
&cli.StringFlag{
Name: "log-level",
Usage: "log level, available: 'DEBUG', 'INFO', 'WARN', 'ERROR'",
Value: "INFO",
},
&cli.StringFlag{
Name: "port",
Usage: "port to expose the web interface and API",
Value: "8080",
Name: "port",
Aliases: []string{"p"},
Usage: "Port for the web interface and API",
Value: "8080",
},
&cli.IntFlag{
Name: "interval",
Aliases: []string{"i"},
Usage: "minutes to check if the channel is online",
Value: 1,
Name: "interval",
Usage: "Check if the channel is online every N minutes",
Value: 1,
},
&cli.StringFlag{
Name: "cf-cookie",
Usage: "Cloudflare cookie to bypass anti-bot page",
Value: "",
Name: "cookies",
Usage: "Cookies to use in the request (format: key=value; key2=value2)",
Value: "",
},
&cli.StringFlag{
Name: "user-agent",
Usage: "Custom user agent for when using cf-cookie",
Usage: "Custom User-Agent for the request",
Value: "",
},
//&cli.StringFlag{
// Name: "gui",
// Usage: "enabling GUI, availables: 'no', 'web'",
// Value: "web",
//},
&cli.StringFlag{
Name: "domain",
Usage: "Chaturbate domain to use",
Value: "https://chaturbate.global/",
},
},
Action: start,
}
@ -129,84 +110,41 @@ func main() {
func start(c *cli.Context) error {
fmt.Println(logo)
if c.String("cf-cookie") != "" && c.String("user-agent") == ""{
return fmt.Errorf("When using the cf-cookie option a user-agent MUST be supplied")
}
//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"),
Interval: c.Int("interval"),
CFCookie: c.String("cf-cookie"),
UserAgent: c.String("user-agent"),
}); 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)
if err := m.LoadChannels(); err != nil {
return err
}
fe, err := fs.Sub(FS, "handler/view")
var err error
server.Config, err = config.New(c)
if err != nil {
log.Fatalln(err)
return fmt.Errorf("new config: %w", err)
}
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")
if guiUsername != "" && guiPassword != "" {
ginBasicAuth := gin.BasicAuth(gin.Accounts{
guiUsername: guiPassword,
})
authorized.Use(ginBasicAuth)
authorizedApi.Use(ginBasicAuth)
server.Manager, err = manager.New()
if err != nil {
return fmt.Errorf("new manager: %w", err)
}
authorized.StaticFS("/static", http.FS(fe))
authorized.StaticFileFS("/", "/", http.FS(fe))
// init web interface if username is not provided
if server.Config.Username == "" {
fmt.Printf("👋 Visit http://localhost:%s to use the Web UI\n\n\n", c.String("port"))
authorizedApi.POST("/get_channel", handler.NewGetChannelHandler(m, c).Handle)
authorizedApi.POST("/create_channel", handler.NewCreateChannelHandler(m, c).Handle)
authorizedApi.POST("/list_channels", handler.NewListChannelsHandler(m, c).Handle)
authorizedApi.POST("/delete_channel", handler.NewDeleteChannelHandler(m, c).Handle)
authorizedApi.POST("/pause_channel", handler.NewPauseChannelHandler(m, c).Handle)
authorizedApi.POST("/resume_channel", handler.NewResumeChannelHandler(m, c).Handle)
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)
if err := server.Manager.LoadConfig(); err != nil {
return fmt.Errorf("load config: %w", err)
}
fmt.Printf("👋 Visit http://localhost:%s to use the Web UI\n", c.String("port"))
return router.SetupRouter().Run(":" + c.String("port"))
}
return r.Run(fmt.Sprintf(":%s", c.String("port")))
// else create a channel with the provided username
if err := server.Manager.CreateChannel(&entity.ChannelConfig{
IsPaused: false,
Username: c.String("username"),
Framerate: c.Int("framerate"),
Resolution: c.Int("resolution"),
Pattern: c.String("pattern"),
MaxDuration: c.Int("max-duration"),
MaxFilesize: c.Int("max-filesize"),
}, false); err != nil {
return fmt.Errorf("create channel: %w", err)
}
// block forever
select {}
}

194
manager/manager.go Normal file
View File

@ -0,0 +1,194 @@
package manager
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"os"
"sort"
"strings"
"sync"
"github.com/r3labs/sse/v2"
"github.com/teacat/chaturbate-dvr/channel"
"github.com/teacat/chaturbate-dvr/entity"
"github.com/teacat/chaturbate-dvr/router/view"
)
// Manager is responsible for managing channels and their states.
type Manager struct {
Channels sync.Map
SSE *sse.Server
}
// New initializes a new Manager instance with an SSE server.
func New() (*Manager, error) {
server := sse.New()
server.SplitData = true
updateStream := server.CreateStream("updates")
updateStream.AutoReplay = false
return &Manager{
SSE: server,
}, nil
}
// SaveConfig saves the current channels and state to a JSON file.
func (m *Manager) SaveConfig() error {
var config []*entity.ChannelConfig
m.Channels.Range(func(key, value any) bool {
config = append(config, value.(*channel.Channel).Config)
return true
})
b, err := json.Marshal(config)
if err != nil {
return fmt.Errorf("marshal: %w", err)
}
if err := os.MkdirAll("./conf", 0777); err != nil {
return fmt.Errorf("mkdir all conf: %w", err)
}
if err := os.WriteFile("./conf/channels.json", b, 0777); err != nil {
return fmt.Errorf("write file: %w", err)
}
return nil
}
// LoadConfig loads the channels from JSON and starts them.
func (m *Manager) LoadConfig() error {
b, err := os.ReadFile("./conf/channels.json")
if os.IsNotExist(err) {
return nil
}
if err != nil {
return fmt.Errorf("read file: %w", err)
}
var config []*entity.ChannelConfig
if err := json.Unmarshal(b, &config); err != nil {
return fmt.Errorf("unmarshal: %w", err)
}
for i, conf := range config {
ch := channel.New(conf)
m.Channels.Store(conf.Username, ch)
if ch.Config.IsPaused {
ch.Info("channel was paused, waiting for resume")
continue
}
go ch.Resume(i)
}
return nil
}
// CreateChannel starts monitoring an M3U8 stream
func (m *Manager) CreateChannel(conf *entity.ChannelConfig, shouldSave bool) error {
ch := channel.New(conf)
// prevent duplicate channels
_, ok := m.Channels.Load(conf.Username)
if ok {
return fmt.Errorf("channel %s already exists", conf.Username)
}
m.Channels.Store(conf.Username, ch)
go ch.Resume(0)
if shouldSave {
if err := m.SaveConfig(); err != nil {
return fmt.Errorf("save config: %w", err)
}
}
return nil
}
// StopChannel stops the channel.
func (m *Manager) StopChannel(username string) error {
thing, ok := m.Channels.Load(username)
if !ok {
return nil
}
thing.(*channel.Channel).Stop()
m.Channels.Delete(username)
if err := m.SaveConfig(); err != nil {
return fmt.Errorf("save config: %w", err)
}
return nil
}
// PauseChannel pauses the channel.
func (m *Manager) PauseChannel(username string) error {
thing, ok := m.Channels.Load(username)
if !ok {
return nil
}
thing.(*channel.Channel).Pause()
if err := m.SaveConfig(); err != nil {
return fmt.Errorf("save config: %w", err)
}
return nil
}
// ResumeChannel resumes the channel.
func (m *Manager) ResumeChannel(username string) error {
thing, ok := m.Channels.Load(username)
if !ok {
return nil
}
go thing.(*channel.Channel).Resume(0)
if err := m.SaveConfig(); err != nil {
return fmt.Errorf("save config: %w", err)
}
return nil
}
// ChannelInfo returns a list of channel information for the web UI.
func (m *Manager) ChannelInfo() []*entity.ChannelInfo {
var channels []*entity.ChannelInfo
// Iterate over the channels and append their information to the slice
m.Channels.Range(func(key, value any) bool {
channels = append(channels, value.(*channel.Channel).ExportInfo())
return true
})
sort.Slice(channels, func(i, j int) bool {
return channels[i].CreatedAt > channels[j].CreatedAt
})
return channels
}
// Publish sends an SSE event to the specified channel.
func (m *Manager) Publish(evt entity.Event, info *entity.ChannelInfo) {
switch evt {
case entity.EventUpdate:
var b bytes.Buffer
if err := view.InfoTpl.ExecuteTemplate(&b, "channel_info", info); err != nil {
fmt.Println("Error executing template:", err)
return
}
m.SSE.Publish("updates", &sse.Event{
Event: []byte(info.Username + "-info"),
Data: b.Bytes(),
})
case entity.EventLog:
m.SSE.Publish("updates", &sse.Event{
Event: []byte(info.Username + "-log"),
Data: []byte(strings.Join(info.Logs, "\n")),
})
}
}
// Subscriber handles SSE subscriptions for the specified channel.
func (m *Manager) Subscriber(w http.ResponseWriter, r *http.Request) {
m.SSE.ServeHTTP(w, r)
}

81
router/router.go Normal file
View File

@ -0,0 +1,81 @@
package router
import (
"embed"
"html/template"
"log"
"path/filepath"
"github.com/gin-gonic/gin"
"github.com/teacat/chaturbate-dvr/router/view"
"github.com/teacat/chaturbate-dvr/server"
)
// SetupRouter initializes and returns the Gin router.
func SetupRouter() *gin.Engine {
gin.SetMode(gin.ReleaseMode)
r := gin.Default()
if err := LoadHTMLFromEmbedFS(r, view.FS, "templates/index.html", "templates/channel_info.html"); err != nil {
log.Fatalf("failed to load HTML templates: %v", err)
}
// Apply authentication if configured
SetupAuth(r)
// Serve static frontend files
SetupStatic(r)
// Register views
SetupViews(r)
return r
}
// SetupAuth applies basic authentication if credentials are provided.
func SetupAuth(r *gin.Engine) {
if server.Config.AdminUsername != "" && server.Config.AdminPassword != "" {
auth := gin.BasicAuth(gin.Accounts{
server.Config.AdminUsername: server.Config.AdminPassword,
})
r.Use(auth)
}
}
// SetupStatic serves static frontend files.
func SetupStatic(r *gin.Engine) {
fs, err := view.StaticFS()
if err != nil {
log.Fatalf("failed to initialize static files: %v", err)
}
r.StaticFS("/static", fs)
}
// setupViews registers HTML templates and view handlers.
func SetupViews(r *gin.Engine) {
r.GET("/", Index)
r.GET("/updates", Updates)
r.POST("/update_config", UpdateConfig)
r.POST("/create_channel", CreateChannel)
r.POST("/stop_channel/:username", StopChannel)
r.POST("/pause_channel/:username", PauseChannel)
r.POST("/resume_channel/:username", ResumeChannel)
}
// LoadHTMLFromEmbedFS loads specific HTML templates from an embedded filesystem and registers them with Gin.
func LoadHTMLFromEmbedFS(r *gin.Engine, embeddedFS embed.FS, files ...string) error {
templ := template.New("")
for _, file := range files {
content, err := embeddedFS.ReadFile(file)
if err != nil {
return err
}
_, err = templ.New(filepath.Base(file)).Parse(string(content))
if err != nil {
return err
}
}
// Set the parsed templates as the HTML renderer for Gin
r.SetHTMLTemplate(templ)
return nil
}

104
router/router_handler.go Normal file
View File

@ -0,0 +1,104 @@
package router
import (
"fmt"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/teacat/chaturbate-dvr/entity"
"github.com/teacat/chaturbate-dvr/server"
)
// IndexData represents the data structure for the index page.
type IndexData struct {
Config *entity.Config
Channels []*entity.ChannelInfo
}
// Index renders the index page with channel information.
func Index(c *gin.Context) {
c.HTML(200, "index.html", &IndexData{
Config: server.Config,
Channels: server.Manager.ChannelInfo(),
})
}
// CreateChannelRequest represents the request body for creating a channel.
type CreateChannelRequest struct {
Username string `form:"username" binding:"required"`
Framerate int `form:"framerate" binding:"required"`
Resolution int `form:"resolution" binding:"required"`
Pattern string `form:"pattern" binding:"required"`
MaxDuration int `form:"max_duration"`
MaxFilesize int `form:"max_filesize"`
}
// CreateChannel creates a new channel.
func CreateChannel(c *gin.Context) {
var req *CreateChannelRequest
if err := c.Bind(&req); err != nil {
c.AbortWithError(http.StatusBadRequest, fmt.Errorf("bind: %w", err))
return
}
for _, username := range strings.Split(req.Username, ",") {
server.Manager.CreateChannel(&entity.ChannelConfig{
IsPaused: false,
Username: username,
Framerate: req.Framerate,
Resolution: req.Resolution,
Pattern: req.Pattern,
MaxDuration: req.MaxDuration,
MaxFilesize: req.MaxFilesize,
CreatedAt: time.Now().Unix(),
}, true)
}
c.Redirect(http.StatusFound, "/")
}
// StopChannel stops a channel.
func StopChannel(c *gin.Context) {
server.Manager.StopChannel(c.Param("username"))
c.Redirect(http.StatusFound, "/")
}
// PauseChannel pauses a channel.
func PauseChannel(c *gin.Context) {
server.Manager.PauseChannel(c.Param("username"))
c.Redirect(http.StatusFound, "/")
}
// ResumeChannel resumes a paused channel.
func ResumeChannel(c *gin.Context) {
server.Manager.ResumeChannel(c.Param("username"))
c.Redirect(http.StatusFound, "/")
}
// Updates handles the SSE connection for updates.
func Updates(c *gin.Context) {
server.Manager.Subscriber(c.Writer, c.Request)
}
// UpdateConfigRequest represents the request body for updating configuration.
type UpdateConfigRequest struct {
Cookies string `form:"cookies"`
UserAgent string `form:"user_agent"`
}
// UpdateConfig updates the server configuration.
func UpdateConfig(c *gin.Context) {
var req *UpdateConfigRequest
if err := c.Bind(&req); err != nil {
c.AbortWithError(http.StatusBadRequest, fmt.Errorf("bind: %w", err))
return
}
server.Config.Cookies = req.Cookies
server.Config.UserAgent = req.UserAgent
c.Redirect(http.StatusFound, "/")
}

View File

@ -0,0 +1,115 @@
{{ define "channel_info" }}
<!-- Header -->
<div class="ts-grid is-middle-aligned">
<div class="column is-fluid">
<div class="ts-header">{{ .Username }}</div>
</div>
<div class="column">
{{ if and .IsOnline (not .IsPaused) }}
<span class="ts-badge is-small is-start-spaced">RECORDING</span>
{{ else if and (not .IsOnline) (not .IsPaused) }}
<span class="ts-badge is-secondary is-small is-start-spaced">OFFLINE</span>
{{ else if .IsPaused }}
<span class="ts-badge is-negative is-small is-start-spaced">PAUSED</span>
{{ end }}
</div>
</div>
<!-- / Header -->
<div class="ts-divider has-top-spaced"></div>
<!-- Info: Channel URL -->
<div class="ts-grid has-top-spaced">
<div class="column">
<span class="ts-icon is-link-icon"></span>
</div>
<div class="column is-fluid">
<div class="ts-text is-small is-bold">Channel URL</div>
<a class="ts-text is-small is-link is-external-link" href="{{ .GlobalConfig.Domain }}{{ .Username }}" target="_blank"> {{ .GlobalConfig.Domain }}{{ .Username }}</a>
</div>
</div>
<!-- / Info: Channel URL -->
<!-- Info: Filename -->
<div class="ts-grid has-top-spaced">
<div class="column">
<span class="ts-icon is-folder-icon"></span>
</div>
<div class="column is-fluid">
<div class="ts-text is-small is-bold">Filename</div>
{{ if .Filename }}
<div class="ts-text is-description">{{ .Filename }}</div>
{{ else }}
<span>-</span>
{{ end }}
</div>
</div>
<!-- / Info: Filename -->
<!-- Info: Last streamed at -->
<div class="ts-grid ts-grid has-top-spaced">
<div class="column">
<span class="ts-icon is-tower-broadcast-icon"></span>
</div>
<div class="column is-fluid">
<div class="ts-text is-small is-bold">Last streamed at</div>
<div class="ts-text is-description">{{ if .StreamedAt }}{{ .StreamedAt }} {{ if and .IsOnline (not .IsPaused) }}(NOW){{ end }}{{ else }} - {{ end }}</div>
</div>
</div>
<!-- / Info: Last streamed at -->
<!-- Info: Segment duration -->
<div class="ts-grid ts-grid has-top-spaced">
<div class="column">
<span class="ts-icon is-clock-icon"></span>
</div>
<div class="column is-fluid">
<div class="ts-text is-small is-bold">Segment duration</div>
<div class="ts-text is-description">{{ if .Duration }} {{ .Duration }} {{ if .MaxDuration }} / {{ .MaxDuration }} {{ end }} {{ else }} - {{ end }}</div>
</div>
</div>
<!-- / Info: Segment duration -->
<!-- Info: Segment filesize -->
<div class="ts-grid has-top-spaced">
<div class="column">
<span class="ts-icon is-chart-pie-icon"></span>
</div>
<div class="column is-fluid">
<div class="ts-text is-small is-bold">Segment filesize</div>
<div class="ts-text is-description">{{ if .Filesize }} {{ .Filesize }} {{ if .MaxFilesize }} / {{ .MaxFilesize }} {{ end }} {{ else }} - {{ end }}</div>
</div>
</div>
<!-- / Info: Segment filesize -->
<!-- Actions -->
<div class="ts-grid is-2-columns has-top-spaced-large">
<div class="column">
{{ if .IsPaused }}
<form>
<button class="ts-button is-start-icon is-fluid" hx-post="/resume_channel/{{ .Username }}" hx-swap="none">
<span class="ts-icon is-play-icon"></span>
Resume
</button>
</form>
{{ else }}
<form>
<button type="submit" class="ts-button is-start-icon is-secondary is-fluid" hx-post="/pause_channel/{{ .Username }}" hx-swap="none">
<span class="ts-icon is-pause-icon"></span>
Pause
</button>
</form>
{{ end }}
</div>
<div class="column">
<form action="/stop_channel/{{ .Username }}" method="POST" onsubmit="return confirm('Are you sure you want to delete `{{ .Username }}` channel?')">
<button class="ts-button is-start-icon is-outlined is-negative is-fluid" >
<span class="ts-icon is-trash-icon"></span>
Delete
</button>
</form>
</div>
</div>
<!-- / Actions -->
{{ end }}

View File

@ -0,0 +1,299 @@
<!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="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/scripts/htmx.min.js" crossorigin="anonymous"></script>
<script src="/static/scripts/sse.min.js" crossorigin="anonymous"></script>
<title>Chaturbate DVR</title>
</head>
<body hx-ext="sse">
<!-- Main Section -->
<div class="ts-container has-vertically-padded-big" style="--width: 990px">
<!-- 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 {{ .Config.Version }}</div>
</div>
<div class="column">
<button class="ts-button is-start-icon is-outlined" data-dialog="settings-dialog">
<span class="ts-icon is-gear-icon"></span>
Settings
</button>
</div>
<div class="column">
<button class="ts-button is-start-icon" data-dialog="create-dialog">
<span class="ts-icon is-plus-icon"></span>
Add Channel
</button>
</div>
</div>
<!-- / Header -->
{{ if not .Channels }}
<!-- Blankslate -->
<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 channels are currently recording</div>
<div class="description">Add a new Chaturbate channel to start recording.</div>
<div class="action">
<button class="ts-button is-start-icon" data-dialog="create-dialog">
<span class="ts-icon is-plus-icon"></span>
Add Channel
</button>
</div>
</div>
<!-- / Blankslate -->
{{ else }}
<!-- Channels -->
<div class="ts-wrap is-vertical has-top-spaced-large" sse-connect="/updates?stream=updates">
{{ range .Channels }}
<div class="ts-box is-horizontal">
<!-- Info Section -->
<div sse-swap="{{ .Username }}-info" class="ts-content is-padded has-break-all" style="width: 400px; line-height: 1.45; padding-right: 0">
{{ template "channel_info" . }}
</div>
<!-- / Info Section -->
<!-- Log Section -->
<div class="ts-content is-padded" style="flex: 1; gap: 0.8rem; display: flex; flex-direction: column">
<div class="ts-input" style="flex: 1">
<textarea class="has-full-height" readonly sse-swap="{{ .Username }}-log" style="scrollbar-width: thin">{{ range .Logs }}{{ . }}&NewLine;{{ end }}</textarea>
</div>
<div>
<label class="ts-switch is-small" style="display: flex">
<input type="checkbox" checked />
Auto-Update & Scroll Logs
</label>
</div>
</div>
<!-- / Log Section -->
</div>
{{ end }}
</div>
<!-- / Channels -->
{{ end }}
</div>
<!-- / Main Section -->
<!-- Settings Dialog -->
<dialog id="settings-dialog" class="ts-modal" style="--width: 680px">
<div class="content">
<form action="/update_config" method="POST">
<div class="ts-content is-horizontally-padded is-secondary">
<div class="ts-grid">
<div class="column is-fluid">
<div class="ts-header">Settings</div>
</div>
<div class="column">
<button type="reset" class="ts-close is-rounded is-large is-secondary" data-dialog="settings-dialog"></button>
</div>
</div>
</div>
<div class="ts-divider"></div>
<div class="ts-content is-vertically-padded">
<!-- Cookies -->
<div class="ts-control is-wide">
<div class="label">Cookies</div>
<div class="content">
<div class="ts-input">
<textarea name="cookies" rows="5">{{ .Config.Cookies }}</textarea>
</div>
<div class="ts-text is-description has-top-spaced-small">Use semicolons to separate multiple cookies, e.g., "key1=value1; key2=value2". See <a class="ts-text is-link" href="https://github.com/teacat/chaturbate-dvr/?tab=readme-ov-file#-cookies--user-agent" target="_blank">README</a> for details.</div>
</div>
</div>
<!-- / Cookies -->
<!-- User Agent -->
<div class="ts-control is-wide has-top-spaced-large">
<div class="label">User Agent</div>
<div class="content">
<div class="ts-input">
<textarea name="user_agent" rows="5">{{ .Config.UserAgent }}</textarea>
</div>
<div class="ts-text is-description has-top-spaced-small">User-Agent can be found using tools like <a class="ts-text is-link" href="https://www.whatismybrowser.com/detect/what-is-my-user-agent/" target="_blank">WhatIsMyBrowser</a>.</div>
</div>
</div>
<!-- / User Agent -->
</div>
<div class="ts-divider"></div>
<div class="ts-content is-secondary is-horizontally-padded">
<div class="ts-grid is-middle-aligned">
<div class="column is-fluid">
<div class="ts-text is-description">
<span class="ts-icon is-triangle-exclamation-icon is-end-spaced"></span>
Changes will be reverted after the program restarts
</div>
</div>
<div class="column">
<button type="reset" class="ts-button is-outlined is-secondary" data-dialog="settings-dialog">Cancel</button>
</div>
<div class="column">
<button type="submit" class="ts-button is-primary">Apply</button>
</div>
</div>
</div>
</form>
</div>
</dialog>
<!-- / Settings Dialog -->
<!-- Create Dialog -->
<dialog id="create-dialog" class="ts-modal" style="--width: 680px">
<div class="content">
<form action="/create_channel" method="POST">
<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 type="reset" class="ts-close is-rounded is-large is-secondary" data-dialog="create-dialog"></button>
</div>
</div>
</div>
<div class="ts-divider"></div>
<div class="ts-content is-vertically-padded">
<!-- 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">{{ .Config.Domain }}</div>
<input type="text" name="username" autofocus required />
</div>
<div class="ts-text is-description has-top-spaced-small">Use commas to separate multiple channel names, e.g. "channel1, channel2, channel3".</div>
</div>
</div>
<!-- / Channel Username -->
<!-- Resolution -->
<div class="ts-control is-wide has-top-spaced-large">
<div class="label">Resolution</div>
<div class="content">
<div class="ts-select">
<select name="resolution">
<option value="2160" {{ if eq .Config.Resolution 2160 }}selected{{ end }}>4K</option>
<option value="1440" {{ if eq .Config.Resolution 1440 }}selected{{ end }}>2K</option>
<option value="1080" {{ if eq .Config.Resolution 1080 }}selected{{ end }}>1080p</option>
<option value="720" {{ if eq .Config.Resolution 720 }}selected{{ end }}>720p</option>
<option value="540" {{ if eq .Config.Resolution 540 }}selected{{ end }}>540p</option>
<option value="480" {{ if eq .Config.Resolution 480 }}selected{{ end }}>480p</option>
<option value="240" {{ if eq .Config.Resolution 240 }}selected{{ end }}>240p</option>
</select>
</div>
<div class="ts-text is-description has-top-spaced-small">The lower resolution will be used if the selected resolution is not available.</div>
</div>
</div>
<!-- / Resolution -->
<!-- Framerate -->
<div class="ts-control is-wide has-top-spaced-large">
<div class="label">Framerate</div>
<div class="content is-padded">
<div class="ts-wrap is-compact is-vertical">
<label class="ts-radio">
<input type="radio" name="framerate" value="60" {{ if eq .Config.Framerate 60 }}checked{{ end }} />
60 FPS (or lower)
</label>
<label class="ts-radio">
<input type="radio" name="framerate" value="30" {{ if eq .Config.Framerate 30 }}checked{{ end }} />
30 FPS
</label>
</div>
</div>
</div>
<!-- / Framerate -->
<!-- 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" name="pattern" value="{{ .Config.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>
<!-- / Filename Pattern -->
<div class="ts-divider has-vertically-spaced-large"></div>
<!-- Splitting Options -->
<div class="ts-control is-wide has-top-spaced">
<div class="label">Splitting Options</div>
<div class="content">
<div class="ts-content is-padded is-secondary">
<div class="ts-grid is-relaxed is-2-columns">
<div class="column">
<div class="ts-text is-bold">Max Filesize</div>
<div class="ts-input is-end-labeled has-top-spaced-small">
<input type="number" name="max_filesize" value="{{ .Config.MaxFilesize }}" />
<span class="label">MB</span>
</div>
</div>
<div class="column">
<div class="ts-text is-bold">Max Duration</div>
<div class="ts-input is-end-labeled has-top-spaced-small">
<input type="number" name="max_duration" value="{{ .Config.MaxDuration }}" />
<span class="label">Min(s)</span>
</div>
</div>
</div>
<div class="ts-text is-description has-top-spaced">Splitting will be disabled if both options are 0.</div>
</div>
</div>
</div>
<!-- / Splitting Options -->
</div>
<div class="ts-divider"></div>
<div class="ts-content is-secondary is-horizontally-padded">
<div class="ts-wrap is-end-aligned">
<button type="reset" class="ts-button is-outlined is-secondary" data-dialog="create-dialog">Cancel</button>
<button type="submit" class="ts-button is-primary">Add Channel</button>
</div>
</div>
</form>
</div>
</dialog>
<!-- / Create Dialog -->
<script>
// before content was swapped by HTMX
document.body.addEventListener("htmx:sseBeforeMessage", function (e) {
// stop it if "auto-update" was unchecked
if (!e.detail.elt.closest(".ts-box").querySelector("[type=checkbox]").checked) {
e.preventDefault()
return
}
// else scroll the textarea to bottom with async trick
setTimeout(() => {
let textarea = e.detail.elt.closest(".ts-box").querySelector("textarea")
textarea.scrollTop = textarea.scrollHeight
}, 0)
})
document.body.querySelectorAll("textarea").forEach((textarea) => {
textarea.scrollTop = textarea.scrollHeight
})
</script>
</body>
</html>

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1 @@
!function(){var e;function t(e){return new EventSource(e,{withCredentials:!0})}function n(t){if(e.getAttributeValue(t,"sse-swap")){var n=e.getClosestMatch(t,a);if(null==n)return null;for(var i=e.getInternalData(n),o=i.sseEventSource,g=e.getAttributeValue(t,"sse-swap").split(","),u=0;u<g.length;u++){let l=g[u].trim(),c=function(a){if(!r(n)){if(!e.bodyContains(t)){o.removeEventListener(l,c);return}e.triggerEvent(t,"htmx:sseBeforeMessage",a)&&(s(t,a.data),e.triggerEvent(t,"htmx:sseMessage",a))}};e.getInternalData(t).sseEventListener=c,o.addEventListener(l,c)}}if(e.getAttributeValue(t,"hx-trigger")){var n=e.getClosestMatch(t,a);if(null==n)return null;var i=e.getInternalData(n),o=i.sseEventSource;e.getTriggerSpecs(t).forEach(function(s){if("sse:"===s.trigger.slice(0,4)){var a=function(i){!r(n)&&(e.bodyContains(t)||o.removeEventListener(s.trigger.slice(4),a),htmx.trigger(t,s.trigger,i),htmx.trigger(t,"htmx:sseMessage",i))};e.getInternalData(t).sseEventListener=a,o.addEventListener(s.trigger.slice(4),a)}})}}function r(t){if(!e.bodyContains(t)){var n=e.getInternalData(t).sseEventSource;if(void 0!=n)return e.triggerEvent(t,"htmx:sseClose",{source:n,type:"nodeMissing"}),n.close(),!0}return!1}function s(t,n){e.withExtensions(t,function(e){n=e.transformResponse(n,null,t)});var r=e.getSwapSpecification(t),s=e.getTarget(t);e.swap(s,n,r)}function a(t){return null!=e.getInternalData(t).sseEventSource}htmx.defineExtension("sse",{init:function(n){e=n,void 0==htmx.createEventSource&&(htmx.createEventSource=t)},getSelectors:function(){return["[sse-connect]","[data-sse-connect]","[sse-swap]","[data-sse-swap]"]},onEvent:function(t,s){var a=s.target||s.detail.elt;switch(t){case"htmx:beforeCleanupElement":var i=e.getInternalData(a),o=i.sseEventSource;o&&(e.triggerEvent(a,"htmx:sseClose",{source:o,type:"nodeReplaced"}),i.sseEventSource.close());return;case"htmx:afterProcessNode":!function t(s,a){if(null==s)return null;if(e.getAttributeValue(s,"sse-connect")){var i,o,g,u,l,c=e.getAttributeValue(s,"sse-connect");if(null==c)return;i=s,o=c,g=void 0,u=htmx.createEventSource(o),u.onerror=function(n){if(e.triggerErrorEvent(i,"htmx:sseError",{error:n,source:u}),!r(i)&&u.readyState===EventSource.CLOSED){var s=500*(g=Math.max(Math.min(2*(g=g||0),128),1));window.setTimeout(function(){t(i,g)},s)}},u.onopen=function(t){if(e.triggerEvent(i,"htmx:sseOpen",{source:u}),g&&g>0){let r=i.querySelectorAll("[sse-swap], [data-sse-swap], [hx-trigger], [data-hx-trigger]");for(let s=0;s<r.length;s++)n(r[s]);g=0}},e.getInternalData(i).sseEventSource=u,l=e.getAttributeValue(i,"sse-close"),l&&u.addEventListener(l,function(){e.triggerEvent(i,"htmx:sseClose",{source:u,type:"message"}),u.close()})}n(s)}(a)}}})}();

34
router/view/view.go Normal file
View File

@ -0,0 +1,34 @@
package view
import (
"embed"
"fmt"
"html/template"
"io/fs"
"log"
"net/http"
)
//go:embed templates
var FS embed.FS
// InfoTpl is a template for rendering channel information.
var InfoTpl *template.Template
func init() {
var err error
InfoTpl, err = template.New("update").ParseFS(FS, "templates/channel_info.html")
if err != nil {
log.Fatalf("failed to parse template: %v", err)
}
}
// StaticFS initializes the static file system for serving frontend files.
func StaticFS() (http.FileSystem, error) {
frontendFS, err := fs.Sub(FS, "templates")
if err != nil {
return nil, fmt.Errorf("failed to initialize static files: %w", err)
}
return http.FS(frontendFS), nil
}

5
server/config.go Normal file
View File

@ -0,0 +1,5 @@
package server
import "github.com/teacat/chaturbate-dvr/entity"
var Config *entity.Config

21
server/manager.go Normal file
View File

@ -0,0 +1,21 @@
package server
import (
"net/http"
"github.com/teacat/chaturbate-dvr/entity"
)
var Manager IManager
type IManager interface {
CreateChannel(conf *entity.ChannelConfig, shouldSave bool) error
StopChannel(username string) error
PauseChannel(username string) error
ResumeChannel(username string) error
ChannelInfo() []*entity.ChannelInfo
Publish(name string, ch *entity.ChannelInfo)
Subscriber(w http.ResponseWriter, r *http.Request)
LoadConfig() error
SaveConfig() error
}

View File

@ -1,11 +0,0 @@
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 -gui-username ${GUI_USERNAME} -gui-password ${GUI_PASSWORD}" ]