diff --git a/.gitignore b/.gitignore index f49b947..8739b7a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,3 @@ videos -chaturbate_channels.json -chaturbate-dvr \ No newline at end of file +chaturbate-dvr +conf \ No newline at end of file diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..16564d3 --- /dev/null +++ b/.prettierignore @@ -0,0 +1 @@ +**/*.html \ No newline at end of file diff --git a/LICENSE b/LICENSE index b003ae2..17f72ed 100644 --- a/LICENSE +++ b/LICENSE @@ -18,4 +18,4 @@ 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. +SOFTWARE. \ No newline at end of file diff --git a/Makefile b/Makefile deleted file mode 100644 index 55f5727..0000000 --- a/Makefile +++ /dev/null @@ -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/' \ No newline at end of file diff --git a/README.md b/README.md index 9cc18d6..37a0354 100644 --- a/README.md +++ b/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**. +![Image](https://github.com/user-attachments/assets/d71f0aaa-e821-4371-9f48-658a137b42b6) -โ€ป **[DMCA WARNING](https://www.dmca.com/)**: Contents on Chaturbate are copyrighted, you should not copy, share, distribute the content. +![Image](https://github.com/user-attachments/assets/43ab0a07-0ece-40ba-9a0f-045ca0316638)   -## Usage +# Getting Started -Download executable from **[Release](https://github.com/teacat/chaturbate-dvr/releases)** page (e.g., `windows_chatubrate-dvr.exe`). +Go to the [๐Ÿ“ฆ Releases page](https://github.com/teacat/chaturbate-dvr/releases) and download the appropriate binary. (e.g., `x64_windows_chatubrate-dvr.exe`)   -**๐ŸŒ Start the program with the Web UI** - -Visit [`http://localhost:8080`](http://localhost:8080) to use the Web UI. - -```yaml -# Windows (or double-click `chaturbate-dvr.exe` to open) -$ chaturbate-dvr.exe - -# macOS or Linux -$ chaturbate-dvr -``` - -  - -**๐Ÿ’ป or... Run as a command-line tool** - -Run the program with a channel name (`-u CHANNEL_USERNAME`) records the channel immediately, and the Web UI will be disabled. - -```yaml -# Windows -$ chaturbate-dvr.exe -u CHANNEL_USERNAME - -# macOS or Linux -$ chaturbate-dvr -u CHANNEL_USERNAME -``` - -  - -## Preview - -![image_1](https://github.com/teacat/chaturbate-dvr/assets/7308718/c6d17ffe-eba7-4296-9315-f501489d85f3) -![image_2](https://github.com/teacat/chaturbate-dvr/assets/7308718/d02923e0-574d-4a15-a373-8b0599101e3f) - -**or... Command-line tool** - -``` -$ ./chaturbate-dvr -u emillybrowm start - - โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•—โ–ˆโ–ˆโ•— โ–ˆโ–ˆโ•— โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•— โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•—โ–ˆโ–ˆโ•— โ–ˆโ–ˆโ•—โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•— โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•— โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•— โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•—โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•— -โ–ˆโ–ˆโ•”โ•โ•โ•โ•โ•โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•‘โ–ˆโ–ˆโ•”โ•โ•โ–ˆโ–ˆโ•—โ•šโ•โ•โ–ˆโ–ˆโ•”โ•โ•โ•โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•‘โ–ˆโ–ˆโ•”โ•โ•โ–ˆโ–ˆโ•—โ–ˆโ–ˆโ•”โ•โ•โ–ˆโ–ˆโ•—โ–ˆโ–ˆโ•”โ•โ•โ–ˆโ–ˆโ•—โ•šโ•โ•โ–ˆโ–ˆโ•”โ•โ•โ•โ–ˆโ–ˆโ•”โ•โ•โ•โ•โ• -โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•‘โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•‘โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•”โ•โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•”โ•โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•— -โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•”โ•โ•โ–ˆโ–ˆโ•‘โ–ˆโ–ˆโ•”โ•โ•โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•‘โ–ˆโ–ˆโ•”โ•โ•โ–ˆโ–ˆโ•—โ–ˆโ–ˆโ•”โ•โ•โ–ˆโ–ˆโ•—โ–ˆโ–ˆโ•”โ•โ•โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•”โ•โ•โ• -โ•šโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•—โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•‘โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•‘ โ•šโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•”โ•โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•‘โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•”โ•โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•— - โ•šโ•โ•โ•โ•โ•โ•โ•šโ•โ• โ•šโ•โ•โ•šโ•โ• โ•šโ•โ• โ•šโ•โ• โ•šโ•โ•โ•โ•โ•โ• โ•šโ•โ• โ•šโ•โ•โ•šโ•โ•โ•โ•โ•โ• โ•šโ•โ• โ•šโ•โ• โ•šโ•โ• โ•šโ•โ•โ•โ•โ•โ•โ• -โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•— โ–ˆโ–ˆโ•— โ–ˆโ–ˆโ•—โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•— -โ–ˆโ–ˆโ•”โ•โ•โ–ˆโ–ˆโ•—โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•‘โ–ˆโ–ˆโ•”โ•โ•โ–ˆโ–ˆโ•— -โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•‘โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•‘โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•”โ• -โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•‘โ•šโ–ˆโ–ˆโ•— โ–ˆโ–ˆโ•”โ•โ–ˆโ–ˆโ•”โ•โ•โ–ˆโ–ˆโ•— -โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•”โ• โ•šโ–ˆโ–ˆโ–ˆโ–ˆโ•”โ• โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•‘ -โ•šโ•โ•โ•โ•โ•โ• โ•šโ•โ•โ•โ• โ•šโ•โ• โ•šโ•โ• -[2024-01-24 00:11:54] [INFO] [emillybrowm] channel created -[2024-01-24 00:11:55] [INFO] [emillybrowm] channel is online, start fetching... -[2024-01-24 00:11:55] [INFO] [emillybrowm] the stream will be saved as videos/emillybrowm_2024-01-24_00-11-55.ts -[2024-01-24 00:11:55] [INFO] [emillybrowm] resolution 1080p is used -[2024-01-24 00:11:55] [INFO] [emillybrowm] framerate 30fps is used -[2024-01-24 00:11:57] [INFO] [emillybrowm] segment #0 written -[2024-01-24 00:11:57] [INFO] [emillybrowm] segment #1 written -[2024-01-24 00:11:57] [INFO] [emillybrowm] segment #2 written -``` - -  - -## Help +## ๐ŸŒ Launching the Web UI ```bash -$ chaturbate-dvr -h +# Windows +$ x64_windows_chatubrate-dvr.exe -NAME: - chaturbate-dvr - Records your favorite Chaturbate stream ๐Ÿ˜Ž๐Ÿซต +# macOS / Linux +$ ./x64_linux_chatubrate-dvr +``` -USAGE: - chaturbate-dvr [global options] command [command options] +Then visit [`http://localhost:8080`](http://localhost:8080) in your browser. -VERSION: - 1.0.0 +  -COMMANDS: - help, h Shows a list of commands or help for one command +## ๐Ÿ’ป Using as a CLI Tool -GLOBAL OPTIONS: - --username value, -u value channel username to record - --gui-username value, --gui-u value username for auth web (optional) - --gui-password value, --gui-p value password for auth web (optional) - --framerate value, -f value preferred framerate (default: 30) - --interval value, -i value minutes to check if the channel is online (default: 1) - --resolution value, -r value preferred resolution (default: 1080) - --resolution-fallback value, --rf value fallback to 'up' (larger) or 'down' (smaller) resolution if preferred resolution is not available (default: "down") - --filename-pattern value, --fp value filename pattern for videos (default: "videos/{{.Username}}_{{.Year}}-{{.Month}}-{{.Day}}_{{.Hour}}-{{.Minute}}-{{.Second}}{{if .Sequence}}_{{.Sequence}}{{end}}") - --split-duration value, --sd value minutes to split each video into segments ('0' to disable) (default: 0) - --split-filesize value, --sf value size in MB to split each video into segments ('0' to disable) (default: 0) - --log-level value log level, availables: 'DEBUG', 'INFO', 'WARN', 'ERROR' (default: "INFO") - --port value port to expose the web interface and API (default: "8080") - --cf-cookie value Cloudflare cookie to bypass anti-bot page - --user-agent value Custom user agent for when using cf-cookie - --help, -h show help - --version, -v print the version +```bash +# Windows +$ x64_windows_chatubrate-dvr.exe -u CHANNEL_USERNAME + +# macOS / Linux +$ ./x64_linux_chatubrate-dvr -u CHANNEL_USERNAME +``` + +This starts recording immediately. The Web UI will be disabled. + +  + +## ๐Ÿณ Running with Docker + +Pre-built image `yamiodymel/chaturbate-dvr` from [Docker Hub](https://hub.docker.com/r/yamiodymel/chaturbate-dvr): + +```bash +# Run the container and save videos to ./videos +$ docker run -d \ + --name my-dvr \ + -p 8080:8080 \ + -v "./videos:/usr/src/app/videos" \ + -v "./conf:/usr/src/app/conf" \ + yamiodymel/chaturbate-dvr +``` + +...Or build your own image using the Dockerfile in this repository. + +```bash +# Build the image +$ docker build -t chaturbate-dvr . + +# Run the container and save videos to ./videos +$ docker run -d \ + --name my-dvr \ + -p 8080:8080 \ + -v "./videos:/usr/src/app/videos" \ + -v "./conf:/usr/src/app/conf" \ + chaturbate-dvr +``` + +...Or use [`docker-compose.yml`](https://github.com/teacat/chaturbate-dvr/blob/master/docker-compose.yml): + +```bash +$ docker-compose up +``` + +Then visit [`http://localhost:8080`](http://localhost:8080) in your browser. + +  + +# ๐Ÿงพ Command-Line Options + +Available options: + +``` +--username value, -u value The username of the channel to record +--admin-username value Username for web authentication (optional) +--admin-password value Password for web authentication (optional) +--framerate value Desired framerate (FPS) (default: 30) +--resolution value Desired resolution (e.g., 1080 for 1080p) (default: 1080) +--pattern value Template for naming recorded videos (default: "videos/{{.Username}}_{{.Year}}-{{.Month}}-{{.Day}}_{{.Hour}}-{{.Minute}}-{{.Second}}{{if .Sequence}}_{{.Sequence}}{{end}}") +--max-duration value Split video into segments every N minutes ('0' to disable) (default: 0) +--max-filesize value Split video into segments every N MB ('0' to disable) (default: 0) +--port value, -p value Port for the web interface and API (default: "8080") +--interval value Check if the channel is online every N minutes (default: 1) +--cookies value Cookies to use in the request (format: key=value; key2=value2) +--user-agent value Custom User-Agent for the request +--domain value Chaturbate domain to use (default: "https://chaturbate.global/") +--help, -h show help +--version, -v print the version ``` **Examples**: -```yaml -# Records in 720p/60fps -$ chaturbate-dvr -u yamiodymel -r 720 -f 60 +```bash +# Record at 720p / 60fps +$ ./chatubrate-dvr -u yamiodymel -resolution 720 -framerate 60 -# Split the video every 30 minutes -$ chaturbate-dvr -u yamiodymel -sd 30 +# Split every 30 minutes +$ ./chatubrate-dvr -u yamiodymel -max-duration 30 -# Split the video every 1024 MB -$ chaturbate-dvr -u yamiodymel -sf 1024 +# Split at 1024 MB +$ ./chatubrate-dvr -u yamiodymel -max-filesize 1024 -# Change output filename pattern -$ chaturbate-dvr -u yamiodymel -fp video/{{.Username}}/{{.Year}}-{{.Month}}-{{.Day}}_{{.Hour}}-{{.Minute}}-{{.Second}}_{{.Sequence}} +# Custom filename format +$ ./chatubrate-dvr -u yamiodymel \ + -pattern "video/{{.Username}}/{{.Year}}-{{.Month}}-{{.Day}}_{{.Hour}}-{{.Minute}}-{{.Second}}_{{.Sequence}}" ``` -โ€ป When runs in Web UI mode, the settings will be default settings for Web UI to create channels. +_Note: In Web UI mode, these flags serve as default values for new channels._   -## ๐Ÿ“บ Framerate & Resolution / Fallback +# ๐Ÿช Cookies & User-Agent -Fallback indicates what to do when there's no expected target resolution, situation: +You can set Cookies and User-Agent via the Web UI or command-line arguments. -``` -Availables: 1080p, 720p, 240p +![localhost_8080_ (4)](https://github.com/user-attachments/assets/cbd859a9-4255-404b-b6bf-fa89342f7258) -Resolution: 480p (fallback setted to: up) - Result: 720p will be used - -Resolution: 480p (fallback setted to: down) - Result: 240p will be used -``` +_Note: Use semicolons to separate multiple cookies, e.g., `key1=value1; key2=value2`._   -## ๐Ÿ“„ Filename Pattern +## โ˜๏ธ Bypass Cloudflare + +1. Open [Chaturbate](https://chaturbate.com) in your browser and complete the Cloudflare check. + + (Keep refresh with F5 if the check doesn't appear) + +2. **DevTools (F12)** โ†’ **Application** โ†’ **Cookies** โ†’ `https://chaturbate.com` โ†’ Copy the `cf_clearance` value + +![sshot-2025-04-30-146](https://github.com/user-attachments/assets/69f4061b-29a2-48a7-ad57-0c86148805e2) + +3. User-Agent can be found using [WhatIsMyBrowser](https://www.whatismybrowser.com/detect/what-is-my-user-agent/), now run with `-cookies` and `-user-agent`: + + ```bash + $ ./chatubrate-dvr -u yamiodymel \ + -cookies "cf_clearance=PASTE_YOUR_CF_CLEARANCE_HERE" \ + -user-agent "PASTE_YOUR_USER_AGENT_HERE" + ``` + + Example: + + ```bash + $ ./chatubrate-dvr -u yamiodymel \ + -cookies "cf_clearance=i975JyJSMZUuEj2kIqfaClPB2dLomx3.iYo6RO1IIRg-1746019135-1.2.1.1-2CX..." \ + -user-agent "Mozilla/5.0 (Windows NT 10.0; Win64; x64)..." + ``` + +  + +## ๐Ÿ•ต๏ธ Record Private Shows + +1. Login [Chaturbate](https://chaturbate.com) in your browser. + +2. **DevTools (F12)** โ†’ **Application** โ†’ **Cookies** โ†’ `https://chaturbate.com` โ†’ Copy the `sessionid` value + +3. Run with `-cookies`: + + ```bash + $ ./chatubrate-dvr -u yamiodymel -cookies "sessionid=PASTE_YOUR_SESSIONID_HERE" + ``` + +  + +# ๐Ÿ“„ Filename Pattern The format is based on [Go Template Syntax](https://pkg.go.dev/text/template), available variables are: @@ -179,58 +207,39 @@ Pattern: video/{{.Username}}/{{.Year}}-{{.Month}}-{{.Day}}_{{.Hour}}-{{.Minute}} Output: video/yamiodymel/2024-01-02_13-45-00_0.ts ``` -โ€ป The file will be saved as `.ts` format and it's not configurable. +_Note: Files are saved in `.ts` format, and this is not configurable._   -## ๐Ÿค” Frequently Asked Questions +# ๐Ÿค” Frequently Asked Questions -**Q: The program closes itself when I just open it on Windows** +**Q: The program closes immediately on Windows.** -A: Try to open the program in **Command Prompt**, the error message should appear, and create a new [Issue](https://github.com/teacat/chaturbate-dvr/issues) for it. +> Open it via **Command Prompt**, the error message should appear. If needed, [create an issue](https://github.com/teacat/chaturbate-dvr/issues).   -**Q: Channel is online but the program says it's Offline** +**Q: Error `listen tcp :8080: bind: An attempt was... by its access permissions`** -A: The program might be blocked by Chaturbate or Cloudflare. If the Channel is in a private/ticket show, the program doesn't support it yet. +> The port `8080` is in use. Try another port with `-p 8123`, then visit [http://localhost:8123](http://localhost:8123). +> +> If that fails, run **Command Prompt** as Administrator and execute: +> +> ```bash +> $ net stop winnat +> $ net start winnat +> ```   -**Q: `listen tcp :8080: bind: An attempt was made to access a socket in a way forbidden by its access permissions.`** +**Q: Error `A connection attempt failed... host has failed to respond`** -A: The port `8080` is already in use, change the port with `-port` option (e.g. `-port 8123`) and visit `http://localhost:8123`. - -If the error still occur, run **Command Prompt** as Administrator, and type `net stop winnat` then `net start winnat`, and re-run the Chaturbate DVR again. +> Likely a network issue (e.g., VPN, firewall, or blocked by Chaturbate). This cannot be fixed by the program.   -**Q: `A connection attempt failed because the connected party did not properly respond after a period of time, or established connection failed because connected host has failed to respond.`** +**Q: Error `Channel was blocked by Cloudflare`** -A: Your network is unstable or being blocked by Chaturbate, the program can't help with the network issue. Usually happened when you are using VPN or Proxy. +> You've been temporarily blocked. See the [Cookies & User-Agent](#-cookies--user-agent) section to bypass.   - -**Q: `I'm receiving a message about CloudFlare anti-bot, what do I need to do?`** - -A: You need to successfully pass the CloudFlare anti-bot check and retrieve the cf_clearance Cookie that is set in the browser after successfully passing the check. This MUST be done from the same IP address and the same User-Agent string MUST be provided to chaturbate-dvr. Provide the cookie value and User-Agent string with the --cf-cookie and --user-agent command line options. The Cookie does expire, but it looks like it's Age is at ~1 year. - -  - -## ๐Ÿ’ฌ Verbose Log - -Change `-log-level` to `DEBUG` to see more details in terminal, like Duration and Size. - -```yaml -# Availables: DEBUG, INFO, WARN, ERROR -$ chaturbate-dvr -u hepbugbear -log-level DEBUG -[2024-01-24 01:18:11] [INFO] [hepbugbear] segment #0 written -[2024-01-24 01:18:11] [DEBUG] [hepbugbear] duration: 00:00:06, size: 0.00 MiB -[2024-01-24 01:18:11] [INFO] [hepbugbear] segment #1 written -[2024-01-24 01:18:11] [DEBUG] [hepbugbear] duration: 00:00:06, size: 1.36 MiB -[2024-01-24 01:18:11] [INFO] [hepbugbear] segment #2 written -[2024-01-24 01:18:11] [DEBUG] [hepbugbear] duration: 00:00:06, size: 2.72 MiB -[2024-01-24 01:18:12] [DEBUG] [hepbugbear] segment #3 fetched -[2024-01-24 01:18:13] [INFO] [hepbugbear] segment #3 written -[2024-01-24 01:18:13] [DEBUG] [hepbugbear] duration: 00:00:10, size: 4.08 MiB -``` diff --git a/README_DEV.md b/README_DEV.md index d7c1bff..e1251bf 100644 --- a/README_DEV.md +++ b/README_DEV.md @@ -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 +``` diff --git a/channel/channel.go b/channel/channel.go new file mode 100644 index 0000000..6f09f3d --- /dev/null +++ b/channel/channel.go @@ -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() +} diff --git a/channel/channel_file.go b/channel/channel_file.go new file mode 100644 index 0000000..f793a70 --- /dev/null +++ b/channel/channel_file.go @@ -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) +} diff --git a/channel/channel_record.go b/channel/channel_record.go new file mode 100644 index 0000000..95c0e54 --- /dev/null +++ b/channel/channel_record.go @@ -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 +} diff --git a/chaturbate/channel.go b/chaturbate/channel.go deleted file mode 100644 index b3ae29f..0000000 --- a/chaturbate/channel.go +++ /dev/null @@ -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, "Just a moment...") { - 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() -} diff --git a/chaturbate/channel_file.go b/chaturbate/channel_file.go deleted file mode 100644 index 21f27c6..0000000 --- a/chaturbate/channel_file.go +++ /dev/null @@ -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() -} diff --git a/chaturbate/channel_internal.go b/chaturbate/channel_internal.go deleted file mode 100644 index fb54a94..0000000 --- a/chaturbate/channel_internal.go +++ /dev/null @@ -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 -} - diff --git a/chaturbate/channel_update.go b/chaturbate/channel_update.go deleted file mode 100644 index 462d2a4..0000000 --- a/chaturbate/channel_update.go +++ /dev/null @@ -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) -} diff --git a/chaturbate/channel_util.go b/chaturbate/channel_util.go deleted file mode 100644 index f886ff9..0000000 --- a/chaturbate/channel_util.go +++ /dev/null @@ -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) -} diff --git a/chaturbate/chaturbate.go b/chaturbate/chaturbate.go new file mode 100644 index 0000000..1c786e3 --- /dev/null +++ b/chaturbate/chaturbate.go @@ -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, "Just a moment...") { + 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) + } +} diff --git a/chaturbate/log_type.go b/chaturbate/log_type.go deleted file mode 100644 index 5c4d783..0000000 --- a/chaturbate/log_type.go +++ /dev/null @@ -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 -} diff --git a/chaturbate/manager.go b/chaturbate/manager.go deleted file mode 100644 index daf9959..0000000 --- a/chaturbate/manager.go +++ /dev/null @@ -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 -} diff --git a/chaturbate_channels.json.sample b/chaturbate_channels.json.sample deleted file mode 100644 index b64f449..0000000 --- a/chaturbate_channels.json.sample +++ /dev/null @@ -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 - } -] \ No newline at end of file diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..ed685ef --- /dev/null +++ b/config/config.go @@ -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 +} diff --git a/docker-compose-web.yml b/docker-compose-web.yml deleted file mode 100644 index 63f334b..0000000 --- a/docker-compose-web.yml +++ /dev/null @@ -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" \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 4c217e6..b26de75 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/entity/entity.go b/entity/entity.go new file mode 100644 index 0000000..acd23eb --- /dev/null +++ b/entity/entity.go @@ -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 +} diff --git a/go.mod b/go.mod index cb3241f..493ef94 100644 --- a/go.mod +++ b/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 ) diff --git a/go.sum b/go.sum index 892a5e6..bb5f380 100644 --- a/go.sum +++ b/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= diff --git a/handler/create_channel.go b/handler/create_channel.go deleted file mode 100644 index 7ef7f04..0000000 --- a/handler/create_channel.go +++ /dev/null @@ -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{}) -} diff --git a/handler/delete_channel.go b/handler/delete_channel.go deleted file mode 100644 index f393c06..0000000 --- a/handler/delete_channel.go +++ /dev/null @@ -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{}) -} diff --git a/handler/get_channel.go b/handler/get_channel.go deleted file mode 100644 index 271006e..0000000 --- a/handler/get_channel.go +++ /dev/null @@ -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, - }) -} diff --git a/handler/get_settings.go b/handler/get_settings.go deleted file mode 100644 index 2942838..0000000 --- a/handler/get_settings.go +++ /dev/null @@ -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"), - }) -} diff --git a/handler/list_channels.go b/handler/list_channels.go deleted file mode 100644 index 174e1fd..0000000 --- a/handler/list_channels.go +++ /dev/null @@ -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) -} diff --git a/handler/listen_update.go b/handler/listen_update.go deleted file mode 100644 index 290d293..0000000 --- a/handler/listen_update.go +++ /dev/null @@ -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) -} diff --git a/handler/pause_channel.go b/handler/pause_channel.go deleted file mode 100644 index c900ab1..0000000 --- a/handler/pause_channel.go +++ /dev/null @@ -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{}) -} diff --git a/handler/resume_channel.go b/handler/resume_channel.go deleted file mode 100644 index 34c265b..0000000 --- a/handler/resume_channel.go +++ /dev/null @@ -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{}) -} diff --git a/handler/terminate_program.go b/handler/terminate_program.go deleted file mode 100644 index b7ab330..0000000 --- a/handler/terminate_program.go +++ /dev/null @@ -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) -} diff --git a/handler/update_log_level.go b/handler/update_log_level.go deleted file mode 100644 index 03767c5..0000000 --- a/handler/update_log_level.go +++ /dev/null @@ -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, -// }) -// } -// } diff --git a/handler/view/index.html b/handler/view/index.html deleted file mode 100644 index 3a8051a..0000000 --- a/handler/view/index.html +++ /dev/null @@ -1,423 +0,0 @@ - - - - - - - - - - - - - Chaturbate DVR - - - - -
- -
-
-
-
Add Channel
-
-
- -
-
-
- - -
- - -
- -
-
Channel Username
-
-
-
chaturbate.global/
- -
-
Use commas to separate multiple channel names. For example, "channel1,channel2,channel3".
-
-
- - - -
-
Resolution
-
-
-
-
- -
-
-
-
- -
-
-
-
- The resolution will be used if - was not available. -
-
-
- - - -
-
Framerate
-
-
- - -
- -
-
- - - -
-
Filename Pattern
-
-
- -
-
- See the README for details. -
-
-
- - - - - - -
- - -
-
-
-
- Splitting Options -
-
-
-
by Filesize
-
- - MB -
-
-
-
by Duration
-
- - Mins -
-
-
-
Splitting will be disabled if both options are 0.
-
-
-
-
- -
- - -
- - -
-
- - -
-
- -
-
- - - -
- -
-
-
Chaturbate DVR
-
Version
-
-
-
-
- -
- - -
-
-
- - - - - - - - - - -
- « - - » -
- -
- - - -
-
- - - diff --git a/handler/view/script.js b/handler/view/script.js deleted file mode 100644 index 7141b09..0000000 --- a/handler/view/script.js +++ /dev/null @@ -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); - }, - }; -} diff --git a/internal/internal.go b/internal/internal.go new file mode 100644 index 0000000..31d260f --- /dev/null +++ b/internal/internal.go @@ -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 +} diff --git a/internal/internal_err.go b/internal/internal_err.go new file mode 100644 index 0000000..71fd66c --- /dev/null +++ b/internal/internal_err.go @@ -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") +) diff --git a/internal/internal_req.go b/internal/internal_req.go new file mode 100644 index 0000000..5061296 --- /dev/null +++ b/internal/internal_req.go @@ -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 +} diff --git a/main.go b/main.go index 9e77385..e9f3cbf 100644 --- a/main.go +++ b/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 {} } diff --git a/manager/manager.go b/manager/manager.go new file mode 100644 index 0000000..d675c33 --- /dev/null +++ b/manager/manager.go @@ -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) +} diff --git a/router/router.go b/router/router.go new file mode 100644 index 0000000..19e06f4 --- /dev/null +++ b/router/router.go @@ -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 +} diff --git a/router/router_handler.go b/router/router_handler.go new file mode 100644 index 0000000..d6b7c5d --- /dev/null +++ b/router/router_handler.go @@ -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, "/") +} diff --git a/router/view/templates/channel_info.html b/router/view/templates/channel_info.html new file mode 100644 index 0000000..de4eea8 --- /dev/null +++ b/router/view/templates/channel_info.html @@ -0,0 +1,115 @@ +{{ define "channel_info" }} + + +
+
+
{{ .Username }}
+
+
+ {{ if and .IsOnline (not .IsPaused) }} + RECORDING + {{ else if and (not .IsOnline) (not .IsPaused) }} + OFFLINE + {{ else if .IsPaused }} + PAUSED + {{ end }} +
+
+ + +
+ + +
+
+ +
+
+
Channel URL
+ {{ .GlobalConfig.Domain }}{{ .Username }} +
+
+ + + +
+
+ +
+
+
Filename
+ {{ if .Filename }} +
{{ .Filename }}
+ {{ else }} + - + {{ end }} +
+
+ + + +
+
+ +
+
+
Last streamed at
+
{{ if .StreamedAt }}{{ .StreamedAt }} {{ if and .IsOnline (not .IsPaused) }}(NOW){{ end }}{{ else }} - {{ end }}
+
+
+ + + +
+
+ +
+
+
Segment duration
+
{{ if .Duration }} {{ .Duration }} {{ if .MaxDuration }} / {{ .MaxDuration }} {{ end }} {{ else }} - {{ end }}
+
+
+ + + +
+
+ +
+
+
Segment filesize
+
{{ if .Filesize }} {{ .Filesize }} {{ if .MaxFilesize }} / {{ .MaxFilesize }} {{ end }} {{ else }} - {{ end }}
+
+
+ + + +
+
+ {{ if .IsPaused }} +
+ +
+ {{ else }} +
+ +
+ {{ end }} +
+
+
+ +
+
+
+ +{{ end }} diff --git a/router/view/templates/index.html b/router/view/templates/index.html new file mode 100644 index 0000000..655d1b2 --- /dev/null +++ b/router/view/templates/index.html @@ -0,0 +1,299 @@ + + + + + + + + + + + + + Chaturbate DVR + + + + +
+ +
+
+
Chaturbate DVR
+
VERSION {{ .Config.Version }}
+
+
+ +
+
+ +
+
+ + + {{ if not .Channels }} + +
+
+ +
No channels are currently recording
+
Add a new Chaturbate channel to start recording.
+
+ +
+
+ + {{ else }} + + +
+ {{ range .Channels }} +
+ +
+ {{ template "channel_info" . }} +
+ + + +
+
+ +
+
+ +
+
+ +
+ {{ end }} +
+ + {{ end }} +
+ + + + +
+
+
+
+
+
Settings
+
+
+ +
+
+
+ +
+ +
+ +
+
Cookies
+
+
+ +
+
Use semicolons to separate multiple cookies, e.g., "key1=value1; key2=value2". See README for details.
+
+
+ + + +
+
User Agent
+
+
+ +
+
User-Agent can be found using tools like WhatIsMyBrowser.
+
+
+ +
+ +
+ +
+
+
+
+ + Changes will be reverted after the program restarts +
+
+
+ +
+
+ +
+
+
+
+
+
+ + + + +
+
+
+
+
+
Add Channel
+
+
+ +
+
+
+ +
+ +
+ +
+
Channel Username
+
+
+
{{ .Config.Domain }}
+ +
+
Use commas to separate multiple channel names, e.g. "channel1, channel2, channel3".
+
+
+ + + +
+
Resolution
+
+
+ +
+
The lower resolution will be used if the selected resolution is not available.
+
+
+ + + +
+
Framerate
+
+
+ + +
+
+
+ + + +
+
Filename Pattern
+
+
+ +
+
+ See the README for details. +
+
+
+ + +
+ + +
+
Splitting Options
+
+
+
+
+
Max Filesize
+
+ + MB +
+
+
+
Max Duration
+
+ + Min(s) +
+
+
+
Splitting will be disabled if both options are 0.
+
+
+
+ +
+ +
+ +
+
+ + +
+
+
+
+
+ + + + + diff --git a/router/view/templates/scripts/htmx.min.js b/router/view/templates/scripts/htmx.min.js new file mode 100644 index 0000000..59937d7 --- /dev/null +++ b/router/view/templates/scripts/htmx.min.js @@ -0,0 +1 @@ +var htmx=function(){"use strict";const Q={onLoad:null,process:null,on:null,off:null,trigger:null,ajax:null,find:null,findAll:null,closest:null,values:function(e,t){const n=cn(e,t||"post");return n.values},remove:null,addClass:null,removeClass:null,toggleClass:null,takeClass:null,swap:null,defineExtension:null,removeExtension:null,logAll:null,logNone:null,logger:null,config:{historyEnabled:true,historyCacheSize:10,refreshOnHistoryMiss:false,defaultSwapStyle:"innerHTML",defaultSwapDelay:0,defaultSettleDelay:20,includeIndicatorStyles:true,indicatorClass:"htmx-indicator",requestClass:"htmx-request",addedClass:"htmx-added",settlingClass:"htmx-settling",swappingClass:"htmx-swapping",allowEval:true,allowScriptTags:true,inlineScriptNonce:"",inlineStyleNonce:"",attributesToSettle:["class","style","width","height"],withCredentials:false,timeout:0,wsReconnectDelay:"full-jitter",wsBinaryType:"blob",disableSelector:"[hx-disable], [data-hx-disable]",scrollBehavior:"instant",defaultFocusScroll:false,getCacheBusterParam:false,globalViewTransitions:false,methodsThatUseUrlParams:["get","delete"],selfRequestsOnly:true,ignoreTitle:false,scrollIntoViewOnBoost:true,triggerSpecsCache:null,disableInheritance:false,responseHandling:[{code:"204",swap:false},{code:"[23]..",swap:true},{code:"[45]..",swap:false,error:true}],allowNestedOobSwaps:true},parseInterval:null,_:null,version:"2.0.4"};Q.onLoad=j;Q.process=kt;Q.on=ye;Q.off=be;Q.trigger=he;Q.ajax=Rn;Q.find=u;Q.findAll=x;Q.closest=g;Q.remove=z;Q.addClass=K;Q.removeClass=G;Q.toggleClass=W;Q.takeClass=Z;Q.swap=$e;Q.defineExtension=Fn;Q.removeExtension=Bn;Q.logAll=V;Q.logNone=_;Q.parseInterval=d;Q._=e;const n={addTriggerHandler:St,bodyContains:le,canAccessLocalStorage:B,findThisElement:Se,filterValues:hn,swap:$e,hasAttribute:s,getAttributeValue:te,getClosestAttributeValue:re,getClosestMatch:o,getExpressionVars:En,getHeaders:fn,getInputValues:cn,getInternalData:ie,getSwapSpecification:gn,getTriggerSpecs:st,getTarget:Ee,makeFragment:P,mergeObjects:ce,makeSettleInfo:xn,oobSwap:He,querySelectorExt:ae,settleImmediately:Kt,shouldCancel:ht,triggerEvent:he,triggerErrorEvent:fe,withExtensions:Ft};const r=["get","post","put","delete","patch"];const H=r.map(function(e){return"[hx-"+e+"], [data-hx-"+e+"]"}).join(", ");function d(e){if(e==undefined){return undefined}let t=NaN;if(e.slice(-2)=="ms"){t=parseFloat(e.slice(0,-2))}else if(e.slice(-1)=="s"){t=parseFloat(e.slice(0,-1))*1e3}else if(e.slice(-1)=="m"){t=parseFloat(e.slice(0,-1))*1e3*60}else{t=parseFloat(e)}return isNaN(t)?undefined:t}function ee(e,t){return e instanceof Element&&e.getAttribute(t)}function s(e,t){return!!e.hasAttribute&&(e.hasAttribute(t)||e.hasAttribute("data-"+t))}function te(e,t){return ee(e,t)||ee(e,"data-"+t)}function c(e){const t=e.parentElement;if(!t&&e.parentNode instanceof ShadowRoot)return e.parentNode;return t}function ne(){return document}function m(e,t){return e.getRootNode?e.getRootNode({composed:t}):ne()}function o(e,t){while(e&&!t(e)){e=c(e)}return e||null}function i(e,t,n){const r=te(t,n);const o=te(t,"hx-disinherit");var i=te(t,"hx-inherit");if(e!==t){if(Q.config.disableInheritance){if(i&&(i==="*"||i.split(" ").indexOf(n)>=0)){return r}else{return null}}if(o&&(o==="*"||o.split(" ").indexOf(n)>=0)){return"unset"}}return r}function re(t,n){let r=null;o(t,function(e){return!!(r=i(t,ue(e),n))});if(r!=="unset"){return r}}function h(e,t){const n=e instanceof Element&&(e.matches||e.matchesSelector||e.msMatchesSelector||e.mozMatchesSelector||e.webkitMatchesSelector||e.oMatchesSelector);return!!n&&n.call(e,t)}function T(e){const t=/<([a-z][^\/\0>\x20\t\r\n\f]*)/i;const n=t.exec(e);if(n){return n[1].toLowerCase()}else{return""}}function q(e){const t=new DOMParser;return t.parseFromString(e,"text/html")}function L(e,t){while(t.childNodes.length>0){e.append(t.childNodes[0])}}function A(e){const t=ne().createElement("script");se(e.attributes,function(e){t.setAttribute(e.name,e.value)});t.textContent=e.textContent;t.async=false;if(Q.config.inlineScriptNonce){t.nonce=Q.config.inlineScriptNonce}return t}function N(e){return e.matches("script")&&(e.type==="text/javascript"||e.type==="module"||e.type==="")}function I(e){Array.from(e.querySelectorAll("script")).forEach(e=>{if(N(e)){const t=A(e);const n=e.parentNode;try{n.insertBefore(t,e)}catch(e){O(e)}finally{e.remove()}}})}function P(e){const t=e.replace(/]*)?>[\s\S]*?<\/head>/i,"");const n=T(t);let r;if(n==="html"){r=new DocumentFragment;const i=q(e);L(r,i.body);r.title=i.title}else if(n==="body"){r=new DocumentFragment;const i=q(t);L(r,i.body);r.title=i.title}else{const i=q('");r=i.querySelector("template").content;r.title=i.title;var o=r.querySelector("title");if(o&&o.parentNode===r){o.remove();r.title=o.innerText}}if(r){if(Q.config.allowScriptTags){I(r)}else{r.querySelectorAll("script").forEach(e=>e.remove())}}return r}function oe(e){if(e){e()}}function t(e,t){return Object.prototype.toString.call(e)==="[object "+t+"]"}function k(e){return typeof e==="function"}function D(e){return t(e,"Object")}function ie(e){const t="htmx-internal-data";let n=e[t];if(!n){n=e[t]={}}return n}function M(t){const n=[];if(t){for(let e=0;e=0}function le(e){return e.getRootNode({composed:true})===document}function F(e){return e.trim().split(/\s+/)}function ce(e,t){for(const n in t){if(t.hasOwnProperty(n)){e[n]=t[n]}}return e}function S(e){try{return JSON.parse(e)}catch(e){O(e);return null}}function B(){const e="htmx:localStorageTest";try{localStorage.setItem(e,e);localStorage.removeItem(e);return true}catch(e){return false}}function U(t){try{const e=new URL(t);if(e){t=e.pathname+e.search}if(!/^\/$/.test(t)){t=t.replace(/\/+$/,"")}return t}catch(e){return t}}function e(e){return vn(ne().body,function(){return eval(e)})}function j(t){const e=Q.on("htmx:load",function(e){t(e.detail.elt)});return e}function V(){Q.logger=function(e,t,n){if(console){console.log(t,e,n)}}}function _(){Q.logger=null}function u(e,t){if(typeof e!=="string"){return e.querySelector(t)}else{return u(ne(),e)}}function x(e,t){if(typeof e!=="string"){return e.querySelectorAll(t)}else{return x(ne(),e)}}function E(){return window}function z(e,t){e=y(e);if(t){E().setTimeout(function(){z(e);e=null},t)}else{c(e).removeChild(e)}}function ue(e){return e instanceof Element?e:null}function $(e){return e instanceof HTMLElement?e:null}function J(e){return typeof e==="string"?e:null}function f(e){return e instanceof Element||e instanceof Document||e instanceof DocumentFragment?e:null}function K(e,t,n){e=ue(y(e));if(!e){return}if(n){E().setTimeout(function(){K(e,t);e=null},n)}else{e.classList&&e.classList.add(t)}}function G(e,t,n){let r=ue(y(e));if(!r){return}if(n){E().setTimeout(function(){G(r,t);r=null},n)}else{if(r.classList){r.classList.remove(t);if(r.classList.length===0){r.removeAttribute("class")}}}}function W(e,t){e=y(e);e.classList.toggle(t)}function Z(e,t){e=y(e);se(e.parentElement.children,function(e){G(e,t)});K(ue(e),t)}function g(e,t){e=ue(y(e));if(e&&e.closest){return e.closest(t)}else{do{if(e==null||h(e,t)){return e}}while(e=e&&ue(c(e)));return null}}function l(e,t){return e.substring(0,t.length)===t}function Y(e,t){return e.substring(e.length-t.length)===t}function ge(e){const t=e.trim();if(l(t,"<")&&Y(t,"/>")){return t.substring(1,t.length-2)}else{return t}}function p(t,r,n){if(r.indexOf("global ")===0){return p(t,r.slice(7),true)}t=y(t);const o=[];{let t=0;let n=0;for(let e=0;e"){t--}}if(n0){const r=ge(o.shift());let e;if(r.indexOf("closest ")===0){e=g(ue(t),ge(r.substr(8)))}else if(r.indexOf("find ")===0){e=u(f(t),ge(r.substr(5)))}else if(r==="next"||r==="nextElementSibling"){e=ue(t).nextElementSibling}else if(r.indexOf("next ")===0){e=pe(t,ge(r.substr(5)),!!n)}else if(r==="previous"||r==="previousElementSibling"){e=ue(t).previousElementSibling}else if(r.indexOf("previous ")===0){e=me(t,ge(r.substr(9)),!!n)}else if(r==="document"){e=document}else if(r==="window"){e=window}else if(r==="body"){e=document.body}else if(r==="root"){e=m(t,!!n)}else if(r==="host"){e=t.getRootNode().host}else{s.push(r)}if(e){i.push(e)}}if(s.length>0){const e=s.join(",");const c=f(m(t,!!n));i.push(...M(c.querySelectorAll(e)))}return i}var pe=function(t,e,n){const r=f(m(t,n)).querySelectorAll(e);for(let e=0;e=0;e--){const o=r[e];if(o.compareDocumentPosition(t)===Node.DOCUMENT_POSITION_FOLLOWING){return o}}};function ae(e,t){if(typeof e!=="string"){return p(e,t)[0]}else{return p(ne().body,e)[0]}}function y(e,t){if(typeof e==="string"){return u(f(t)||document,e)}else{return e}}function xe(e,t,n,r){if(k(t)){return{target:ne().body,event:J(e),listener:t,options:n}}else{return{target:y(e),event:J(t),listener:n,options:r}}}function ye(t,n,r,o){Vn(function(){const e=xe(t,n,r,o);e.target.addEventListener(e.event,e.listener,e.options)});const e=k(n);return e?n:r}function be(t,n,r){Vn(function(){const e=xe(t,n,r);e.target.removeEventListener(e.event,e.listener)});return k(n)?n:r}const ve=ne().createElement("output");function we(e,t){const n=re(e,t);if(n){if(n==="this"){return[Se(e,t)]}else{const r=p(e,n);if(r.length===0){O('The selector "'+n+'" on '+t+" returned no matches!");return[ve]}else{return r}}}}function Se(e,t){return ue(o(e,function(e){return te(ue(e),t)!=null}))}function Ee(e){const t=re(e,"hx-target");if(t){if(t==="this"){return Se(e,"hx-target")}else{return ae(e,t)}}else{const n=ie(e);if(n.boosted){return ne().body}else{return e}}}function Ce(t){const n=Q.config.attributesToSettle;for(let e=0;e0){s=e.substring(0,e.indexOf(":"));n=e.substring(e.indexOf(":")+1)}else{s=e}o.removeAttribute("hx-swap-oob");o.removeAttribute("data-hx-swap-oob");const r=p(t,n,false);if(r){se(r,function(e){let t;const n=o.cloneNode(true);t=ne().createDocumentFragment();t.appendChild(n);if(!Re(s,e)){t=f(n)}const r={shouldSwap:true,target:e,fragment:t};if(!he(e,"htmx:oobBeforeSwap",r))return;e=r.target;if(r.shouldSwap){qe(t);_e(s,e,e,t,i);Te()}se(i.elts,function(e){he(e,"htmx:oobAfterSwap",r)})});o.parentNode.removeChild(o)}else{o.parentNode.removeChild(o);fe(ne().body,"htmx:oobErrorNoTarget",{content:o})}return e}function Te(){const e=u("#--htmx-preserve-pantry--");if(e){for(const t of[...e.children]){const n=u("#"+t.id);n.parentNode.moveBefore(t,n);n.remove()}e.remove()}}function qe(e){se(x(e,"[hx-preserve], [data-hx-preserve]"),function(e){const t=te(e,"id");const n=ne().getElementById(t);if(n!=null){if(e.moveBefore){let e=u("#--htmx-preserve-pantry--");if(e==null){ne().body.insertAdjacentHTML("afterend","
");e=u("#--htmx-preserve-pantry--")}e.moveBefore(n,null)}else{e.parentNode.replaceChild(n,e)}}})}function Le(l,e,c){se(e.querySelectorAll("[id]"),function(t){const n=ee(t,"id");if(n&&n.length>0){const r=n.replace("'","\\'");const o=t.tagName.replace(":","\\:");const e=f(l);const i=e&&e.querySelector(o+"[id='"+r+"']");if(i&&i!==e){const s=t.cloneNode();Oe(t,i);c.tasks.push(function(){Oe(t,s)})}}})}function Ae(e){return function(){G(e,Q.config.addedClass);kt(ue(e));Ne(f(e));he(e,"htmx:load")}}function Ne(e){const t="[autofocus]";const n=$(h(e,t)?e:e.querySelector(t));if(n!=null){n.focus()}}function a(e,t,n,r){Le(e,n,r);while(n.childNodes.length>0){const o=n.firstChild;K(ue(o),Q.config.addedClass);e.insertBefore(o,t);if(o.nodeType!==Node.TEXT_NODE&&o.nodeType!==Node.COMMENT_NODE){r.tasks.push(Ae(o))}}}function Ie(e,t){let n=0;while(n0}function $e(e,t,r,o){if(!o){o={}}e=y(e);const i=o.contextElement?m(o.contextElement,false):ne();const n=document.activeElement;let s={};try{s={elt:n,start:n?n.selectionStart:null,end:n?n.selectionEnd:null}}catch(e){}const l=xn(e);if(r.swapStyle==="textContent"){e.textContent=t}else{let n=P(t);l.title=n.title;if(o.selectOOB){const u=o.selectOOB.split(",");for(let t=0;t0){E().setTimeout(c,r.settleDelay)}else{c()}}function Je(e,t,n){const r=e.getResponseHeader(t);if(r.indexOf("{")===0){const o=S(r);for(const i in o){if(o.hasOwnProperty(i)){let e=o[i];if(D(e)){n=e.target!==undefined?e.target:n}else{e={value:e}}he(n,i,e)}}}else{const s=r.split(",");for(let e=0;e0){const s=o[0];if(s==="]"){e--;if(e===0){if(n===null){t=t+"true"}o.shift();t+=")})";try{const l=vn(r,function(){return Function(t)()},function(){return true});l.source=t;return l}catch(e){fe(ne().body,"htmx:syntax:error",{error:e,source:t});return null}}}else if(s==="["){e++}if(tt(s,n,i)){t+="(("+i+"."+s+") ? ("+i+"."+s+") : (window."+s+"))"}else{t=t+s}n=o.shift()}}}function C(e,t){let n="";while(e.length>0&&!t.test(e[0])){n+=e.shift()}return n}function rt(e){let t;if(e.length>0&&Ye.test(e[0])){e.shift();t=C(e,Qe).trim();e.shift()}else{t=C(e,v)}return t}const ot="input, textarea, select";function it(e,t,n){const r=[];const o=et(t);do{C(o,w);const l=o.length;const c=C(o,/[,\[\s]/);if(c!==""){if(c==="every"){const u={trigger:"every"};C(o,w);u.pollInterval=d(C(o,/[,\[\s]/));C(o,w);var i=nt(e,o,"event");if(i){u.eventFilter=i}r.push(u)}else{const a={trigger:c};var i=nt(e,o,"event");if(i){a.eventFilter=i}C(o,w);while(o.length>0&&o[0]!==","){const f=o.shift();if(f==="changed"){a.changed=true}else if(f==="once"){a.once=true}else if(f==="consume"){a.consume=true}else if(f==="delay"&&o[0]===":"){o.shift();a.delay=d(C(o,v))}else if(f==="from"&&o[0]===":"){o.shift();if(Ye.test(o[0])){var s=rt(o)}else{var s=C(o,v);if(s==="closest"||s==="find"||s==="next"||s==="previous"){o.shift();const h=rt(o);if(h.length>0){s+=" "+h}}}a.from=s}else if(f==="target"&&o[0]===":"){o.shift();a.target=rt(o)}else if(f==="throttle"&&o[0]===":"){o.shift();a.throttle=d(C(o,v))}else if(f==="queue"&&o[0]===":"){o.shift();a.queue=C(o,v)}else if(f==="root"&&o[0]===":"){o.shift();a[f]=rt(o)}else if(f==="threshold"&&o[0]===":"){o.shift();a[f]=C(o,v)}else{fe(e,"htmx:syntax:error",{token:o.shift()})}C(o,w)}r.push(a)}}if(o.length===l){fe(e,"htmx:syntax:error",{token:o.shift()})}C(o,w)}while(o[0]===","&&o.shift());if(n){n[t]=r}return r}function st(e){const t=te(e,"hx-trigger");let n=[];if(t){const r=Q.config.triggerSpecsCache;n=r&&r[t]||it(e,t,r)}if(n.length>0){return n}else if(h(e,"form")){return[{trigger:"submit"}]}else if(h(e,'input[type="button"], input[type="submit"]')){return[{trigger:"click"}]}else if(h(e,ot)){return[{trigger:"change"}]}else{return[{trigger:"click"}]}}function lt(e){ie(e).cancelled=true}function ct(e,t,n){const r=ie(e);r.timeout=E().setTimeout(function(){if(le(e)&&r.cancelled!==true){if(!gt(n,e,Mt("hx:poll:trigger",{triggerSpec:n,target:e}))){t(e)}ct(e,t,n)}},n.pollInterval)}function ut(e){return location.hostname===e.hostname&&ee(e,"href")&&ee(e,"href").indexOf("#")!==0}function at(e){return g(e,Q.config.disableSelector)}function ft(t,n,e){if(t instanceof HTMLAnchorElement&&ut(t)&&(t.target===""||t.target==="_self")||t.tagName==="FORM"&&String(ee(t,"method")).toLowerCase()!=="dialog"){n.boosted=true;let r,o;if(t.tagName==="A"){r="get";o=ee(t,"href")}else{const i=ee(t,"method");r=i?i.toLowerCase():"get";o=ee(t,"action");if(o==null||o===""){o=ne().location.href}if(r==="get"&&o.includes("?")){o=o.replace(/\?[^#]+/,"")}}e.forEach(function(e){pt(t,function(e,t){const n=ue(e);if(at(n)){b(n);return}de(r,o,n,t)},n,e,true)})}}function ht(e,t){const n=ue(t);if(!n){return false}if(e.type==="submit"||e.type==="click"){if(n.tagName==="FORM"){return true}if(h(n,'input[type="submit"], button')&&(h(n,"[form]")||g(n,"form")!==null)){return true}if(n instanceof HTMLAnchorElement&&n.href&&(n.getAttribute("href")==="#"||n.getAttribute("href").indexOf("#")!==0)){return true}}return false}function dt(e,t){return ie(e).boosted&&e instanceof HTMLAnchorElement&&t.type==="click"&&(t.ctrlKey||t.metaKey)}function gt(e,t,n){const r=e.eventFilter;if(r){try{return r.call(t,n)!==true}catch(e){const o=r.source;fe(ne().body,"htmx:eventFilter:error",{error:e,source:o});return true}}return false}function pt(l,c,e,u,a){const f=ie(l);let t;if(u.from){t=p(l,u.from)}else{t=[l]}if(u.changed){if(!("lastValue"in f)){f.lastValue=new WeakMap}t.forEach(function(e){if(!f.lastValue.has(u)){f.lastValue.set(u,new WeakMap)}f.lastValue.get(u).set(e,e.value)})}se(t,function(i){const s=function(e){if(!le(l)){i.removeEventListener(u.trigger,s);return}if(dt(l,e)){return}if(a||ht(e,l)){e.preventDefault()}if(gt(u,l,e)){return}const t=ie(e);t.triggerSpec=u;if(t.handledFor==null){t.handledFor=[]}if(t.handledFor.indexOf(l)<0){t.handledFor.push(l);if(u.consume){e.stopPropagation()}if(u.target&&e.target){if(!h(ue(e.target),u.target)){return}}if(u.once){if(f.triggeredOnce){return}else{f.triggeredOnce=true}}if(u.changed){const n=event.target;const r=n.value;const o=f.lastValue.get(u);if(o.has(n)&&o.get(n)===r){return}o.set(n,r)}if(f.delayed){clearTimeout(f.delayed)}if(f.throttle){return}if(u.throttle>0){if(!f.throttle){he(l,"htmx:trigger");c(l,e);f.throttle=E().setTimeout(function(){f.throttle=null},u.throttle)}}else if(u.delay>0){f.delayed=E().setTimeout(function(){he(l,"htmx:trigger");c(l,e)},u.delay)}else{he(l,"htmx:trigger");c(l,e)}}};if(e.listenerInfos==null){e.listenerInfos=[]}e.listenerInfos.push({trigger:u.trigger,listener:s,on:i});i.addEventListener(u.trigger,s)})}let mt=false;let xt=null;function yt(){if(!xt){xt=function(){mt=true};window.addEventListener("scroll",xt);window.addEventListener("resize",xt);setInterval(function(){if(mt){mt=false;se(ne().querySelectorAll("[hx-trigger*='revealed'],[data-hx-trigger*='revealed']"),function(e){bt(e)})}},200)}}function bt(e){if(!s(e,"data-hx-revealed")&&X(e)){e.setAttribute("data-hx-revealed","true");const t=ie(e);if(t.initHash){he(e,"revealed")}else{e.addEventListener("htmx:afterProcessNode",function(){he(e,"revealed")},{once:true})}}}function vt(e,t,n,r){const o=function(){if(!n.loaded){n.loaded=true;he(e,"htmx:trigger");t(e)}};if(r>0){E().setTimeout(o,r)}else{o()}}function wt(t,n,e){let i=false;se(r,function(r){if(s(t,"hx-"+r)){const o=te(t,"hx-"+r);i=true;n.path=o;n.verb=r;e.forEach(function(e){St(t,e,n,function(e,t){const n=ue(e);if(g(n,Q.config.disableSelector)){b(n);return}de(r,o,n,t)})})}});return i}function St(r,e,t,n){if(e.trigger==="revealed"){yt();pt(r,n,t,e);bt(ue(r))}else if(e.trigger==="intersect"){const o={};if(e.root){o.root=ae(r,e.root)}if(e.threshold){o.threshold=parseFloat(e.threshold)}const i=new IntersectionObserver(function(t){for(let e=0;e0){t.polling=true;ct(ue(r),n,e)}else{pt(r,n,t,e)}}function Et(e){const t=ue(e);if(!t){return false}const n=t.attributes;for(let e=0;e", "+e).join(""));return o}else{return[]}}function Tt(e){const t=g(ue(e.target),"button, input[type='submit']");const n=Lt(e);if(n){n.lastButtonClicked=t}}function qt(e){const t=Lt(e);if(t){t.lastButtonClicked=null}}function Lt(e){const t=g(ue(e.target),"button, input[type='submit']");if(!t){return}const n=y("#"+ee(t,"form"),t.getRootNode())||g(t,"form");if(!n){return}return ie(n)}function At(e){e.addEventListener("click",Tt);e.addEventListener("focusin",Tt);e.addEventListener("focusout",qt)}function Nt(t,e,n){const r=ie(t);if(!Array.isArray(r.onHandlers)){r.onHandlers=[]}let o;const i=function(e){vn(t,function(){if(at(t)){return}if(!o){o=new Function("event",n)}o.call(t,e)})};t.addEventListener(e,i);r.onHandlers.push({event:e,listener:i})}function It(t){ke(t);for(let e=0;eQ.config.historyCacheSize){i.shift()}while(i.length>0){try{localStorage.setItem("htmx-history-cache",JSON.stringify(i));break}catch(e){fe(ne().body,"htmx:historyCacheError",{cause:e,cache:i});i.shift()}}}function Vt(t){if(!B()){return null}t=U(t);const n=S(localStorage.getItem("htmx-history-cache"))||[];for(let e=0;e=200&&this.status<400){he(ne().body,"htmx:historyCacheMissLoad",i);const e=P(this.response);const t=e.querySelector("[hx-history-elt],[data-hx-history-elt]")||e;const n=Ut();const r=xn(n);kn(e.title);qe(e);Ve(n,t,r);Te();Kt(r.tasks);Bt=o;he(ne().body,"htmx:historyRestore",{path:o,cacheMiss:true,serverResponse:this.response})}else{fe(ne().body,"htmx:historyCacheMissLoadError",i)}};e.send()}function Wt(e){zt();e=e||location.pathname+location.search;const t=Vt(e);if(t){const n=P(t.content);const r=Ut();const o=xn(r);kn(t.title);qe(n);Ve(r,n,o);Te();Kt(o.tasks);E().setTimeout(function(){window.scrollTo(0,t.scroll)},0);Bt=e;he(ne().body,"htmx:historyRestore",{path:e,item:t})}else{if(Q.config.refreshOnHistoryMiss){window.location.reload(true)}else{Gt(e)}}}function Zt(e){let t=we(e,"hx-indicator");if(t==null){t=[e]}se(t,function(e){const t=ie(e);t.requestCount=(t.requestCount||0)+1;e.classList.add.call(e.classList,Q.config.requestClass)});return t}function Yt(e){let t=we(e,"hx-disabled-elt");if(t==null){t=[]}se(t,function(e){const t=ie(e);t.requestCount=(t.requestCount||0)+1;e.setAttribute("disabled","");e.setAttribute("data-disabled-by-htmx","")});return t}function Qt(e,t){se(e.concat(t),function(e){const t=ie(e);t.requestCount=(t.requestCount||1)-1});se(e,function(e){const t=ie(e);if(t.requestCount===0){e.classList.remove.call(e.classList,Q.config.requestClass)}});se(t,function(e){const t=ie(e);if(t.requestCount===0){e.removeAttribute("disabled");e.removeAttribute("data-disabled-by-htmx")}})}function en(t,n){for(let e=0;en.indexOf(e)<0)}else{e=e.filter(e=>e!==n)}r.delete(t);se(e,e=>r.append(t,e))}}function on(t,n,r,o,i){if(o==null||en(t,o)){return}else{t.push(o)}if(tn(o)){const s=ee(o,"name");let e=o.value;if(o instanceof HTMLSelectElement&&o.multiple){e=M(o.querySelectorAll("option:checked")).map(function(e){return e.value})}if(o instanceof HTMLInputElement&&o.files){e=M(o.files)}nn(s,e,n);if(i){sn(o,r)}}if(o instanceof HTMLFormElement){se(o.elements,function(e){if(t.indexOf(e)>=0){rn(e.name,e.value,n)}else{t.push(e)}if(i){sn(e,r)}});new FormData(o).forEach(function(e,t){if(e instanceof File&&e.name===""){return}nn(t,e,n)})}}function sn(e,t){const n=e;if(n.willValidate){he(n,"htmx:validation:validate");if(!n.checkValidity()){t.push({elt:n,message:n.validationMessage,validity:n.validity});he(n,"htmx:validation:failed",{message:n.validationMessage,validity:n.validity})}}}function ln(n,e){for(const t of e.keys()){n.delete(t)}e.forEach(function(e,t){n.append(t,e)});return n}function cn(e,t){const n=[];const r=new FormData;const o=new FormData;const i=[];const s=ie(e);if(s.lastButtonClicked&&!le(s.lastButtonClicked)){s.lastButtonClicked=null}let l=e instanceof HTMLFormElement&&e.noValidate!==true||te(e,"hx-validate")==="true";if(s.lastButtonClicked){l=l&&s.lastButtonClicked.formNoValidate!==true}if(t!=="get"){on(n,o,i,g(e,"form"),l)}on(n,r,i,e,l);if(s.lastButtonClicked||e.tagName==="BUTTON"||e.tagName==="INPUT"&&ee(e,"type")==="submit"){const u=s.lastButtonClicked||e;const a=ee(u,"name");nn(a,u.value,o)}const c=we(e,"hx-include");se(c,function(e){on(n,r,i,ue(e),l);if(!h(e,"form")){se(f(e).querySelectorAll(ot),function(e){on(n,r,i,e,l)})}});ln(r,o);return{errors:i,formData:r,values:An(r)}}function un(e,t,n){if(e!==""){e+="&"}if(String(n)==="[object Object]"){n=JSON.stringify(n)}const r=encodeURIComponent(n);e+=encodeURIComponent(t)+"="+r;return e}function an(e){e=qn(e);let n="";e.forEach(function(e,t){n=un(n,t,e)});return n}function fn(e,t,n){const r={"HX-Request":"true","HX-Trigger":ee(e,"id"),"HX-Trigger-Name":ee(e,"name"),"HX-Target":te(t,"id"),"HX-Current-URL":ne().location.href};bn(e,"hx-headers",false,r);if(n!==undefined){r["HX-Prompt"]=n}if(ie(e).boosted){r["HX-Boosted"]="true"}return r}function hn(n,e){const t=re(e,"hx-params");if(t){if(t==="none"){return new FormData}else if(t==="*"){return n}else if(t.indexOf("not ")===0){se(t.slice(4).split(","),function(e){e=e.trim();n.delete(e)});return n}else{const r=new FormData;se(t.split(","),function(t){t=t.trim();if(n.has(t)){n.getAll(t).forEach(function(e){r.append(t,e)})}});return r}}else{return n}}function dn(e){return!!ee(e,"href")&&ee(e,"href").indexOf("#")>=0}function gn(e,t){const n=t||re(e,"hx-swap");const r={swapStyle:ie(e).boosted?"innerHTML":Q.config.defaultSwapStyle,swapDelay:Q.config.defaultSwapDelay,settleDelay:Q.config.defaultSettleDelay};if(Q.config.scrollIntoViewOnBoost&&ie(e).boosted&&!dn(e)){r.show="top"}if(n){const s=F(n);if(s.length>0){for(let e=0;e0?o.join(":"):null;r.scroll=u;r.scrollTarget=i}else if(l.indexOf("show:")===0){const a=l.slice(5);var o=a.split(":");const f=o.pop();var i=o.length>0?o.join(":"):null;r.show=f;r.showTarget=i}else if(l.indexOf("focus-scroll:")===0){const h=l.slice("focus-scroll:".length);r.focusScroll=h=="true"}else if(e==0){r.swapStyle=l}else{O("Unknown modifier in hx-swap: "+l)}}}}return r}function pn(e){return re(e,"hx-encoding")==="multipart/form-data"||h(e,"form")&&ee(e,"enctype")==="multipart/form-data"}function mn(t,n,r){let o=null;Ft(n,function(e){if(o==null){o=e.encodeParameters(t,r,n)}});if(o!=null){return o}else{if(pn(n)){return ln(new FormData,qn(r))}else{return an(r)}}}function xn(e){return{tasks:[],elts:[e]}}function yn(e,t){const n=e[0];const r=e[e.length-1];if(t.scroll){var o=null;if(t.scrollTarget){o=ue(ae(n,t.scrollTarget))}if(t.scroll==="top"&&(n||o)){o=o||n;o.scrollTop=0}if(t.scroll==="bottom"&&(r||o)){o=o||r;o.scrollTop=o.scrollHeight}}if(t.show){var o=null;if(t.showTarget){let e=t.showTarget;if(t.showTarget==="window"){e="body"}o=ue(ae(n,e))}if(t.show==="top"&&(n||o)){o=o||n;o.scrollIntoView({block:"start",behavior:Q.config.scrollBehavior})}if(t.show==="bottom"&&(r||o)){o=o||r;o.scrollIntoView({block:"end",behavior:Q.config.scrollBehavior})}}}function bn(r,e,o,i){if(i==null){i={}}if(r==null){return i}const s=te(r,e);if(s){let e=s.trim();let t=o;if(e==="unset"){return null}if(e.indexOf("javascript:")===0){e=e.slice(11);t=true}else if(e.indexOf("js:")===0){e=e.slice(3);t=true}if(e.indexOf("{")!==0){e="{"+e+"}"}let n;if(t){n=vn(r,function(){return Function("return ("+e+")")()},{})}else{n=S(e)}for(const l in n){if(n.hasOwnProperty(l)){if(i[l]==null){i[l]=n[l]}}}}return bn(ue(c(r)),e,o,i)}function vn(e,t,n){if(Q.config.allowEval){return t()}else{fe(e,"htmx:evalDisallowedError");return n}}function wn(e,t){return bn(e,"hx-vars",true,t)}function Sn(e,t){return bn(e,"hx-vals",false,t)}function En(e){return ce(wn(e),Sn(e))}function Cn(t,n,r){if(r!==null){try{t.setRequestHeader(n,r)}catch(e){t.setRequestHeader(n,encodeURIComponent(r));t.setRequestHeader(n+"-URI-AutoEncoded","true")}}}function On(t){if(t.responseURL&&typeof URL!=="undefined"){try{const e=new URL(t.responseURL);return e.pathname+e.search}catch(e){fe(ne().body,"htmx:badResponseUrl",{url:t.responseURL})}}}function R(e,t){return t.test(e.getAllResponseHeaders())}function Rn(t,n,r){t=t.toLowerCase();if(r){if(r instanceof Element||typeof r==="string"){return de(t,n,null,null,{targetOverride:y(r)||ve,returnPromise:true})}else{let e=y(r.target);if(r.target&&!e||r.source&&!e&&!y(r.source)){e=ve}return de(t,n,y(r.source),r.event,{handler:r.handler,headers:r.headers,values:r.values,targetOverride:e,swapOverride:r.swap,select:r.select,returnPromise:true})}}else{return de(t,n,null,null,{returnPromise:true})}}function Hn(e){const t=[];while(e){t.push(e);e=e.parentElement}return t}function Tn(e,t,n){let r;let o;if(typeof URL==="function"){o=new URL(t,document.location.href);const i=document.location.origin;r=i===o.origin}else{o=t;r=l(t,document.location.origin)}if(Q.config.selfRequestsOnly){if(!r){return false}}return he(e,"htmx:validateUrl",ce({url:o,sameHost:r},n))}function qn(e){if(e instanceof FormData)return e;const t=new FormData;for(const n in e){if(e.hasOwnProperty(n)){if(e[n]&&typeof e[n].forEach==="function"){e[n].forEach(function(e){t.append(n,e)})}else if(typeof e[n]==="object"&&!(e[n]instanceof Blob)){t.append(n,JSON.stringify(e[n]))}else{t.append(n,e[n])}}}return t}function Ln(r,o,e){return new Proxy(e,{get:function(t,e){if(typeof e==="number")return t[e];if(e==="length")return t.length;if(e==="push"){return function(e){t.push(e);r.append(o,e)}}if(typeof t[e]==="function"){return function(){t[e].apply(t,arguments);r.delete(o);t.forEach(function(e){r.append(o,e)})}}if(t[e]&&t[e].length===1){return t[e][0]}else{return t[e]}},set:function(e,t,n){e[t]=n;r.delete(o);e.forEach(function(e){r.append(o,e)});return true}})}function An(o){return new Proxy(o,{get:function(e,t){if(typeof t==="symbol"){const r=Reflect.get(e,t);if(typeof r==="function"){return function(){return r.apply(o,arguments)}}else{return r}}if(t==="toJSON"){return()=>Object.fromEntries(o)}if(t in e){if(typeof e[t]==="function"){return function(){return o[t].apply(o,arguments)}}else{return e[t]}}const n=o.getAll(t);if(n.length===0){return undefined}else if(n.length===1){return n[0]}else{return Ln(e,t,n)}},set:function(t,n,e){if(typeof n!=="string"){return false}t.delete(n);if(e&&typeof e.forEach==="function"){e.forEach(function(e){t.append(n,e)})}else if(typeof e==="object"&&!(e instanceof Blob)){t.append(n,JSON.stringify(e))}else{t.append(n,e)}return true},deleteProperty:function(e,t){if(typeof t==="string"){e.delete(t)}return true},ownKeys:function(e){return Reflect.ownKeys(Object.fromEntries(e))},getOwnPropertyDescriptor:function(e,t){return Reflect.getOwnPropertyDescriptor(Object.fromEntries(e),t)}})}function de(t,n,r,o,i,D){let s=null;let l=null;i=i!=null?i:{};if(i.returnPromise&&typeof Promise!=="undefined"){var e=new Promise(function(e,t){s=e;l=t})}if(r==null){r=ne().body}const M=i.handler||Dn;const X=i.select||null;if(!le(r)){oe(s);return e}const c=i.targetOverride||ue(Ee(r));if(c==null||c==ve){fe(r,"htmx:targetError",{target:te(r,"hx-target")});oe(l);return e}let u=ie(r);const a=u.lastButtonClicked;if(a){const L=ee(a,"formaction");if(L!=null){n=L}const A=ee(a,"formmethod");if(A!=null){if(A.toLowerCase()!=="dialog"){t=A}}}const f=re(r,"hx-confirm");if(D===undefined){const K=function(e){return de(t,n,r,o,i,!!e)};const G={target:c,elt:r,path:n,verb:t,triggeringEvent:o,etc:i,issueRequest:K,question:f};if(he(r,"htmx:confirm",G)===false){oe(s);return e}}let h=r;let d=re(r,"hx-sync");let g=null;let F=false;if(d){const N=d.split(":");const I=N[0].trim();if(I==="this"){h=Se(r,"hx-sync")}else{h=ue(ae(r,I))}d=(N[1]||"drop").trim();u=ie(h);if(d==="drop"&&u.xhr&&u.abortable!==true){oe(s);return e}else if(d==="abort"){if(u.xhr){oe(s);return e}else{F=true}}else if(d==="replace"){he(h,"htmx:abort")}else if(d.indexOf("queue")===0){const W=d.split(" ");g=(W[1]||"last").trim()}}if(u.xhr){if(u.abortable){he(h,"htmx:abort")}else{if(g==null){if(o){const P=ie(o);if(P&&P.triggerSpec&&P.triggerSpec.queue){g=P.triggerSpec.queue}}if(g==null){g="last"}}if(u.queuedRequests==null){u.queuedRequests=[]}if(g==="first"&&u.queuedRequests.length===0){u.queuedRequests.push(function(){de(t,n,r,o,i)})}else if(g==="all"){u.queuedRequests.push(function(){de(t,n,r,o,i)})}else if(g==="last"){u.queuedRequests=[];u.queuedRequests.push(function(){de(t,n,r,o,i)})}oe(s);return e}}const p=new XMLHttpRequest;u.xhr=p;u.abortable=F;const m=function(){u.xhr=null;u.abortable=false;if(u.queuedRequests!=null&&u.queuedRequests.length>0){const e=u.queuedRequests.shift();e()}};const B=re(r,"hx-prompt");if(B){var x=prompt(B);if(x===null||!he(r,"htmx:prompt",{prompt:x,target:c})){oe(s);m();return e}}if(f&&!D){if(!confirm(f)){oe(s);m();return e}}let y=fn(r,c,x);if(t!=="get"&&!pn(r)){y["Content-Type"]="application/x-www-form-urlencoded"}if(i.headers){y=ce(y,i.headers)}const U=cn(r,t);let b=U.errors;const j=U.formData;if(i.values){ln(j,qn(i.values))}const V=qn(En(r));const v=ln(j,V);let w=hn(v,r);if(Q.config.getCacheBusterParam&&t==="get"){w.set("org.htmx.cache-buster",ee(c,"id")||"true")}if(n==null||n===""){n=ne().location.href}const S=bn(r,"hx-request");const _=ie(r).boosted;let E=Q.config.methodsThatUseUrlParams.indexOf(t)>=0;const C={boosted:_,useUrlParams:E,formData:w,parameters:An(w),unfilteredFormData:v,unfilteredParameters:An(v),headers:y,target:c,verb:t,errors:b,withCredentials:i.credentials||S.credentials||Q.config.withCredentials,timeout:i.timeout||S.timeout||Q.config.timeout,path:n,triggeringEvent:o};if(!he(r,"htmx:configRequest",C)){oe(s);m();return e}n=C.path;t=C.verb;y=C.headers;w=qn(C.parameters);b=C.errors;E=C.useUrlParams;if(b&&b.length>0){he(r,"htmx:validation:halted",C);oe(s);m();return e}const z=n.split("#");const $=z[0];const O=z[1];let R=n;if(E){R=$;const Z=!w.keys().next().done;if(Z){if(R.indexOf("?")<0){R+="?"}else{R+="&"}R+=an(w);if(O){R+="#"+O}}}if(!Tn(r,R,C)){fe(r,"htmx:invalidPath",C);oe(l);return e}p.open(t.toUpperCase(),R,true);p.overrideMimeType("text/html");p.withCredentials=C.withCredentials;p.timeout=C.timeout;if(S.noHeaders){}else{for(const k in y){if(y.hasOwnProperty(k)){const Y=y[k];Cn(p,k,Y)}}}const H={xhr:p,target:c,requestConfig:C,etc:i,boosted:_,select:X,pathInfo:{requestPath:n,finalRequestPath:R,responsePath:null,anchor:O}};p.onload=function(){try{const t=Hn(r);H.pathInfo.responsePath=On(p);M(r,H);if(H.keepIndicators!==true){Qt(T,q)}he(r,"htmx:afterRequest",H);he(r,"htmx:afterOnLoad",H);if(!le(r)){let e=null;while(t.length>0&&e==null){const n=t.shift();if(le(n)){e=n}}if(e){he(e,"htmx:afterRequest",H);he(e,"htmx:afterOnLoad",H)}}oe(s);m()}catch(e){fe(r,"htmx:onLoadError",ce({error:e},H));throw e}};p.onerror=function(){Qt(T,q);fe(r,"htmx:afterRequest",H);fe(r,"htmx:sendError",H);oe(l);m()};p.onabort=function(){Qt(T,q);fe(r,"htmx:afterRequest",H);fe(r,"htmx:sendAbort",H);oe(l);m()};p.ontimeout=function(){Qt(T,q);fe(r,"htmx:afterRequest",H);fe(r,"htmx:timeout",H);oe(l);m()};if(!he(r,"htmx:beforeRequest",H)){oe(s);m();return e}var T=Zt(r);var q=Yt(r);se(["loadstart","loadend","progress","abort"],function(t){se([p,p.upload],function(e){e.addEventListener(t,function(e){he(r,"htmx:xhr:"+t,{lengthComputable:e.lengthComputable,loaded:e.loaded,total:e.total})})})});he(r,"htmx:beforeSend",H);const J=E?null:mn(p,r,w);p.send(J);return e}function Nn(e,t){const n=t.xhr;let r=null;let o=null;if(R(n,/HX-Push:/i)){r=n.getResponseHeader("HX-Push");o="push"}else if(R(n,/HX-Push-Url:/i)){r=n.getResponseHeader("HX-Push-Url");o="push"}else if(R(n,/HX-Replace-Url:/i)){r=n.getResponseHeader("HX-Replace-Url");o="replace"}if(r){if(r==="false"){return{}}else{return{type:o,path:r}}}const i=t.pathInfo.finalRequestPath;const s=t.pathInfo.responsePath;const l=re(e,"hx-push-url");const c=re(e,"hx-replace-url");const u=ie(e).boosted;let a=null;let f=null;if(l){a="push";f=l}else if(c){a="replace";f=c}else if(u){a="push";f=s||i}if(f){if(f==="false"){return{}}if(f==="true"){f=s||i}if(t.pathInfo.anchor&&f.indexOf("#")===-1){f=f+"#"+t.pathInfo.anchor}return{type:a,path:f}}else{return{}}}function In(e,t){var n=new RegExp(e.code);return n.test(t.toString(10))}function Pn(e){for(var t=0;t0){E().setTimeout(e,x.swapDelay)}else{e()}}if(f){fe(o,"htmx:responseError",ce({error:"Response Status Error Code "+s.status+" from "+i.pathInfo.requestPath},i))}}const Mn={};function Xn(){return{init:function(e){return null},getSelectors:function(){return null},onEvent:function(e,t){return true},transformResponse:function(e,t,n){return e},isInlineSwap:function(e){return false},handleSwap:function(e,t,n,r){return false},encodeParameters:function(e,t,n){return null}}}function Fn(e,t){if(t.init){t.init(n)}Mn[e]=ce(Xn(),t)}function Bn(e){delete Mn[e]}function Un(e,n,r){if(n==undefined){n=[]}if(e==undefined){return n}if(r==undefined){r=[]}const t=te(e,"hx-ext");if(t){se(t.split(","),function(e){e=e.replace(/ /g,"");if(e.slice(0,7)=="ignore:"){r.push(e.slice(7));return}if(r.indexOf(e)<0){const t=Mn[e];if(t&&n.indexOf(t)<0){n.push(t)}}})}return Un(ue(c(e)),n,r)}var jn=false;ne().addEventListener("DOMContentLoaded",function(){jn=true});function Vn(e){if(jn||ne().readyState==="complete"){e()}else{ne().addEventListener("DOMContentLoaded",e)}}function _n(){if(Q.config.includeIndicatorStyles!==false){const e=Q.config.inlineStyleNonce?` nonce="${Q.config.inlineStyleNonce}"`:"";ne().head.insertAdjacentHTML("beforeend"," ."+Q.config.indicatorClass+"{opacity:0} ."+Q.config.requestClass+" ."+Q.config.indicatorClass+"{opacity:1; transition: opacity 200ms ease-in;} ."+Q.config.requestClass+"."+Q.config.indicatorClass+"{opacity:1; transition: opacity 200ms ease-in;} ")}}function zn(){const e=ne().querySelector('meta[name="htmx-config"]');if(e){return S(e.content)}else{return null}}function $n(){const e=zn();if(e){Q.config=ce(Q.config,e)}}Vn(function(){$n();_n();let e=ne().body;kt(e);const t=ne().querySelectorAll("[hx-trigger='restored'],[data-hx-trigger='restored']");e.addEventListener("htmx:abort",function(e){const t=e.target;const n=ie(t);if(n&&n.xhr){n.xhr.abort()}});const n=window.onpopstate?window.onpopstate.bind(window):null;window.onpopstate=function(e){if(e.state&&e.state.htmx){Wt();se(t,function(e){he(e,"htmx:restored",{document:ne(),triggerEvent:he})})}else{if(n){n(e)}}};E().setTimeout(function(){he(e,"htmx:load",{});e=null},0)});return Q}(); \ No newline at end of file diff --git a/router/view/templates/scripts/sse.min.js b/router/view/templates/scripts/sse.min.js new file mode 100644 index 0000000..ae5fcf4 --- /dev/null +++ b/router/view/templates/scripts/sse.min.js @@ -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;u0){let r=i.querySelectorAll("[sse-swap], [data-sse-swap], [hx-trigger], [data-hx-trigger]");for(let s=0;s