mirror of
https://github.com/teacat/chaturbate-dvr.git
synced 2025-10-29 16:59:59 +00:00
2.0.0 refactor
This commit is contained in:
parent
cad4689a5c
commit
f26602b49e
4
.gitignore
vendored
4
.gitignore
vendored
@ -1,3 +1,3 @@
|
||||
videos
|
||||
chaturbate_channels.json
|
||||
chaturbate-dvr
|
||||
chaturbate-dvr
|
||||
conf
|
||||
1
.prettierignore
Normal file
1
.prettierignore
Normal file
@ -0,0 +1 @@
|
||||
**/*.html
|
||||
21
LICENSE
21
LICENSE
@ -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.
|
||||
64
Makefile
64
Makefile
@ -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
311
README.md
@ -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**.
|
||||

|
||||
|
||||
※ **[DMCA WARNING](https://www.dmca.com/)**: Contents on Chaturbate are copyrighted, you should not copy, share, distribute the content.
|
||||

|
||||
|
||||
|
||||
|
||||
## 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
|
||||
|
||||

|
||||

|
||||
|
||||
**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
|
||||

|
||||
|
||||
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
|
||||
|
||||

|
||||
|
||||
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
|
||||
```
|
||||
|
||||
@ -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
145
channel/channel.go
Normal 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
125
channel/channel_file.go
Normal 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
123
channel/channel_record.go
Normal 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
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
@ -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
259
chaturbate/chaturbate.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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
26
config/config.go
Normal 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
|
||||
}
|
||||
@ -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"
|
||||
@ -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
56
entity/entity.go
Normal 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
46
go.mod
@ -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
115
go.sum
@ -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=
|
||||
|
||||
@ -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{})
|
||||
}
|
||||
@ -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{})
|
||||
}
|
||||
@ -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,
|
||||
})
|
||||
}
|
||||
@ -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"),
|
||||
})
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
@ -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{})
|
||||
}
|
||||
@ -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{})
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
@ -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,
|
||||
// })
|
||||
// }
|
||||
// }
|
||||
@ -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 < 2" style="display: none"> channel is</span>
|
||||
<span x-show="channels.length > 1"> channels are</span> being recorded
|
||||
</span>
|
||||
<span class="ts-text is-description">
|
||||
<span x-text="channels.filter(channel => channel.is_online).length"></span> online /
|
||||
<span x-text="channels.filter(channel => !channel.is_online).length"></span> offline
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<!-- / Divider -->
|
||||
<!-- Pagination Controls -->
|
||||
<div class="ts-pagination is-fluid" style="margin-bottom: 1rem">
|
||||
<a class="item" x-on:click="goToPage(1)" :class="{ 'is-disabled': currentPage === 1 }">«</a>
|
||||
<template x-for="page in totalPages" :key="page">
|
||||
<a class="item" x-on:click="goToPage(page)" :class="{ 'is-active': currentPage === page }" x-text="page"></a>
|
||||
</template>
|
||||
<a class="item" x-on:click="goToPage(totalPages)" :class="{ 'is-disabled': currentPage === totalPages }">»</a>
|
||||
</div>
|
||||
<!-- / Pagination controls -->
|
||||
<div class="ts-wrap is-vertical is-relaxed">
|
||||
<!-- Channel -->
|
||||
<template x-for="channel in paginatedChannels" :key="channel.username">
|
||||
<!-- <div class="ts-box is-horizontal"> -->
|
||||
<div
|
||||
class="ts-box is-horizontal"
|
||||
:class="{
|
||||
'is-positive is-top-indicated': channel.is_online && !channel.is_paused,
|
||||
'is-negative is-top-indicated': !channel.is_online && !channel.is_paused,
|
||||
'is-top-indicated': channel.is_paused
|
||||
}"
|
||||
>
|
||||
<!-- Left Section -->
|
||||
<div class="ts-content is-padded" style="flex: 1.25; display: flex; flex-direction: column">
|
||||
<!-- Header -->
|
||||
<div class="ts-grid is-middle-aligned">
|
||||
<div class="column is-fluid">
|
||||
<div class="ts-header">
|
||||
<span x-text="channel.username"></span>
|
||||
<template x-if="channel.is_online && !channel.is_paused">
|
||||
<span class="ts-badge is-small is-start-spaced">RECORDING</span>
|
||||
</template>
|
||||
<template x-if="!channel.is_online && !channel.is_paused">
|
||||
<span class="ts-badge is-secondary is-small is-start-spaced">OFFLINE</span>
|
||||
</template>
|
||||
<template x-if="channel.is_paused">
|
||||
<span class="ts-badge is-negative is-small is-start-spaced">PAUSED</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column">
|
||||
<button class="ts-button is-secondary is-short is-outlined is-dense" x-on:click="downloadLogs(channel.username)">Download Logs</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- / Header -->
|
||||
|
||||
<!-- Logs -->
|
||||
<div class="ts-input has-top-spaced" style="flex: 1">
|
||||
<textarea class="has-full-height" x-bind:id="`${channel.username}-logs`" x-text="channel.logs.join('\n')" readonly></textarea>
|
||||
</div>
|
||||
<!-- / Logs -->
|
||||
</div>
|
||||
<!-- / Left Section -->
|
||||
|
||||
<div class="ts-divider is-vertical"></div>
|
||||
|
||||
<!-- Right Section -->
|
||||
<div class="ts-content is-padded has-break-all" style="flex: 1; min-width: 300px">
|
||||
<div class="ts-text is-description is-uppercased">Information</div>
|
||||
|
||||
<!-- Info: Channel URL -->
|
||||
<div class="ts-grid has-top-spaced-large">
|
||||
<div class="column has-leading-none" style="width: 16px">
|
||||
<span class="ts-icon is-link-icon"></span>
|
||||
</div>
|
||||
<div class="column is-fluid has-leading-small">
|
||||
<div class="ts-text is-label">Channel URL</div>
|
||||
<a class="ts-text is-link is-external-link" x-bind:href="channel.channel_url" x-text="channel.channel_url" target="_blank"></a>
|
||||
</div>
|
||||
</div>
|
||||
<!-- / Info: Channel URL -->
|
||||
|
||||
<!-- Info: Filename -->
|
||||
<div class="ts-grid has-top-spaced">
|
||||
<div class="column has-leading-none" style="width: 16px">
|
||||
<span class="ts-icon is-folder-icon"></span>
|
||||
</div>
|
||||
<div class="column is-fluid has-leading-small">
|
||||
<div class="ts-text is-label">Filename</div>
|
||||
|
||||
<template x-if="channel.filename">
|
||||
<code class="ts-text is-code" x-text="channel.filename"></code>
|
||||
</template>
|
||||
<template x-if="!channel.filename">
|
||||
<span>-</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<!-- / Info: Filename -->
|
||||
|
||||
<!-- Info: Last streamed at -->
|
||||
<div class="ts-grid has-top-spaced">
|
||||
<div class="column has-leading-none" style="width: 16px">
|
||||
<span class="ts-icon is-tower-broadcast-icon"></span>
|
||||
</div>
|
||||
<div class="column is-fluid">
|
||||
<div class="ts-text is-label">Last streamed at</div>
|
||||
<div class="ts-text is-description">
|
||||
<span x-text="channel.last_streamed_at"></span>
|
||||
<template x-if="channel.is_online && !channel.is_paused">
|
||||
<span>(NOW)</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- / Info: Last streamed at -->
|
||||
|
||||
<!-- Info: Segment duration -->
|
||||
<div class="ts-grid has-top-spaced">
|
||||
<div class="column has-leading-none" style="width: 16px">
|
||||
<span class="ts-icon is-clock-icon"></span>
|
||||
</div>
|
||||
<div class="column is-fluid">
|
||||
<div class="ts-text is-label">Segment duration</div>
|
||||
<div class="ts-text is-description">
|
||||
<span x-text="channel.segment_duration"></span>
|
||||
<template x-if="channel.split_duration !== '00:00:00'">
|
||||
<span> / <span x-text="channel.split_duration"></span></span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- / Info: Segment duration -->
|
||||
|
||||
<!-- Info: Segment filesize -->
|
||||
<div class="ts-grid has-top-spaced">
|
||||
<div class="column has-leading-none" style="width: 16px">
|
||||
<span class="ts-icon is-chart-pie-icon"></span>
|
||||
</div>
|
||||
<div class="column is-fluid">
|
||||
<div class="ts-text is-label">Segment filesize</div>
|
||||
<div class="ts-text is-description">
|
||||
<span x-text="channel.segment_filesize"></span>
|
||||
<template x-if="channel.split_filesize !== '0.00 MiB'">
|
||||
<span> / <span x-text="channel.split_filesize"></span></span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- / Info: Segment filesize -->
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="ts-grid is-2-columns has-top-spaced-large">
|
||||
<div class="column">
|
||||
<template x-if="!channel.is_paused">
|
||||
<button
|
||||
class="ts-button is-start-icon is-secondary is-fluid"
|
||||
x-bind:disabled="!channel.is_online"
|
||||
x-on:click="pauseChannel(channel.username)"
|
||||
>
|
||||
<span class="ts-icon is-pause-icon"></span>
|
||||
Pause
|
||||
</button>
|
||||
</template>
|
||||
<template x-if="channel.is_paused">
|
||||
<button class="ts-button is-start-icon is-fluid" x-on:click="resumeChannel(channel.username)">
|
||||
<span class="ts-icon is-play-icon"></span>
|
||||
Resume
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
<div class="column">
|
||||
<button
|
||||
class="ts-button is-start-icon is-secondary is-negative is-fluid"
|
||||
data-tooltip="Stop and remove the channel from the list."
|
||||
x-on:click="deleteChannel(channel.username)"
|
||||
>
|
||||
<span class="ts-icon is-stop-icon"></span>
|
||||
Stop
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- / Actions -->
|
||||
</div>
|
||||
<!-- / Right Section -->
|
||||
</div>
|
||||
</template>
|
||||
<!-- / Channel -->
|
||||
</div>
|
||||
</div>
|
||||
<!-- / Main Section -->
|
||||
</body>
|
||||
</html>
|
||||
@ -1,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
56
internal/internal.go
Normal 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
13
internal/internal_err.go
Normal 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
117
internal/internal_req.go
Normal 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
210
main.go
@ -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
194
manager/manager.go
Normal 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
81
router/router.go
Normal 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
104
router/router_handler.go
Normal 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, "/")
|
||||
}
|
||||
115
router/view/templates/channel_info.html
Normal file
115
router/view/templates/channel_info.html
Normal 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 }}
|
||||
299
router/view/templates/index.html
Normal file
299
router/view/templates/index.html
Normal 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 }}{{ . }}
{{ 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>
|
||||
1
router/view/templates/scripts/htmx.min.js
vendored
Normal file
1
router/view/templates/scripts/htmx.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
router/view/templates/scripts/sse.min.js
vendored
Normal file
1
router/view/templates/scripts/sse.min.js
vendored
Normal 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
34
router/view/view.go
Normal 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
5
server/config.go
Normal file
@ -0,0 +1,5 @@
|
||||
package server
|
||||
|
||||
import "github.com/teacat/chaturbate-dvr/entity"
|
||||
|
||||
var Config *entity.Config
|
||||
21
server/manager.go
Normal file
21
server/manager.go
Normal 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
|
||||
}
|
||||
@ -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}" ]
|
||||
Loading…
x
Reference in New Issue
Block a user