Updates and refactors

Channel_file.go - fix issue with segments not correctly ending when they were supposed to

Log_type.go - moved log type to it's own file, setup global logging (touches on issue #47)

Main.go - added update_log_level handler, setting global log level

Channel.go, channel_internal.go, channel_util.go - updated to use new log_type

Manager.go - updated to use new log_type, update from .com to .global (issue #74)

Channel_update.go, create_channel.go, delete_channel.go, get_channel.go, get_settings.go, listen_update.go, pause_channel.go, resume_channel.go, terminal_program.go - go fmt / go vet

Chaturbate_channels.json.sample - added sample json of the channels file, for mapping in docker config

List_channels.go - refactored to sort by online status, so online is always at the first ones you see

Script.js - adjust default settings, added pagination, added global log logic

Index.html - updated to use online version of tocas ui, added pagination, added global log logic, visual improvements

Removal of local tocas folder since using online version
This commit is contained in:
J0nDoe
2024-10-22 15:04:53 -04:00
parent 9fb2916117
commit 3bdae1b872
14 changed files with 994 additions and 693 deletions

View File

@@ -2,6 +2,7 @@ package handler
import (
"net/http"
"sort"
"github.com/gin-gonic/gin"
"github.com/teacat/chaturbate-dvr/chaturbate"
@@ -51,17 +52,27 @@ func NewListChannelsHandler(c *chaturbate.Manager, cli *cli.Context) *ListChanne
// Handle
//=======================================================
// Handle processes the request to list channels, sorting by IsOnline.
func (h *ListChannelsHandler) Handle(c *gin.Context) {
var req *ListChannelsRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.AbortWithError(http.StatusBadRequest, err)
return
}
// Fetch channels
channels, err := h.chaturbate.ListChannels()
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
// Sort by IsOnline: online channels first, then offline
sort.SliceStable(channels, func(i, j int) bool {
return channels[i].IsOnline && !channels[j].IsOnline
})
// Populate response
resp := &ListChannelsResponse{
Channels: make([]*ListChannelsResponseChannel, len(channels)),
}
@@ -81,5 +92,7 @@ func (h *ListChannelsHandler) Handle(c *gin.Context) {
Logs: channel.Logs,
}
}
// Send the response
c.JSON(http.StatusOK, resp)
}

120
handler/update_log_level.go Normal file
View File

@@ -0,0 +1,120 @@
package handler
import (
"log"
"net/http"
"github.com/gin-gonic/gin"
"github.com/gin-gonic/gin/binding"
"github.com/go-playground/validator/v10"
"github.com/teacat/chaturbate-dvr/chaturbate"
"github.com/urfave/cli/v2"
)
type UpdateLogLevelHandler struct {
cli *cli.Context
}
// Custom validator for LogType
func LogTypeValidator(fl validator.FieldLevel) bool {
value := fl.Field().String()
switch value {
case string(chaturbate.LogTypeDebug), string(chaturbate.LogTypeInfo), string(chaturbate.LogTypeWarning), string(chaturbate.LogTypeError):
return true
}
return false
}
func init() {
if v, ok := binding.Validator.Engine().(*validator.Validate); ok {
v.RegisterValidation("logtype", LogTypeValidator)
}
}
func NewUpdateLogLevelHandler(cli *cli.Context) *UpdateLogLevelHandler {
return &UpdateLogLevelHandler{cli}
}
func (h *UpdateLogLevelHandler) Handle(c *gin.Context) {
var req chaturbate.LogLevelRequest
// Bind and validate the request body
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Invalid request format. Expected {\"log_level\": \"INFO\"}",
})
return
}
// Use the correct log type for setting the global log level
chaturbate.SetGlobalLogLevel(req.LogLevel)
log.Printf("Global log level updated to: %s", req.LogLevel)
// Send success response
c.JSON(http.StatusOK, gin.H{
"message": "Log level updated",
"log_level": req.LogLevel,
})
}
// func (h *UpdateLogLevelHandler) Handle(c *gin.Context) {
// // Read the raw request body for debugging
// bodyBytes, err := c.GetRawData()
// if err != nil {
// log.Printf("Error reading request body: %v", err)
// c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request format"})
// return
// }
// // Log the raw request body
// log.Printf("Received raw request body: %s", string(bodyBytes))
// // Reset the request body so it can be re-read by ShouldBindJSON
// c.Request.Body = ioutil.NopCloser(strings.NewReader(string(bodyBytes)))
// // Attempt to bind the JSON to the struct
// var req LogLevelRequest
// if err := c.ShouldBindJSON(&req); err != nil {
// log.Printf("Error binding JSON: %v", err)
// c.JSON(http.StatusBadRequest, gin.H{
// "error": "Invalid request format. Expected {\"log_level\": \"INFO\"}",
// })
// return
// }
// // Log the updated log level
// log.Printf("Log level updated to: %s", req.LogLevel)
// // Store the log level in the CLI context if needed
// h.cli.Set("log_level", string(req.LogLevel))
// // Send success response
// c.JSON(http.StatusOK, gin.H{
// "message": "Log level updated",
// "log_level": req.LogLevel,
// })
// }
// NewUpdateLogLevelHandler creates a handler for updating log level.
// func NewUpdateLogLevelHandler(c *cli.Context) gin.HandlerFunc {
// return func(ctx *gin.Context) {
// var req LogLevelRequest
// // Bind and validate request body
// if err := ctx.ShouldBindJSON(&req); err != nil {
// ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request format"})
// return
// }
// if !allowedLogLevels[req.LogLevel] {
// ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid log level"})
// return
// }
// ctx.JSON(http.StatusOK, gin.H{
// "message": "Log level updated",
// "log_level": req.LogLevel,
// })
// }
// }

View File

@@ -1,391 +1,410 @@
<!DOCTYPE html>
<html lang="en" class="is-secondary">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" href="/static/tocas/tocas.min.css" />
<script src="/static/tocas/tocas.min.js"></script>
<script src="/static/script.js"></script>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+TC:wght@400;500;700&display=swap" rel="stylesheet" />
<script src="https://unpkg.com/alpinejs" defer></script>
<title>Chaturbate DVR</title>
</head>
<body x-data="data()">
<!-- Create Dialog -->
<dialog id="create-dialog" class="ts-modal is-large" data-clickaway="close">
<div class="content">
<!-- Header -->
<div class="ts-content is-horizontally-padded is-secondary">
<div class="ts-grid">
<div class="column is-fluid">
<div class="ts-header">Add Channel</div>
</div>
<div class="column">
<button class="ts-close is-rounded is-large is-secondary" x-on:click="closeCreateDialog"></button>
</div>
</div>
</div>
<!-- / Header -->
<div class="ts-divider"></div>
<!-- Form -->
<div class="ts-content is-vertically-padded">
<!-- Field: Channel Username -->
<div class="ts-control is-wide">
<div class="label">Channel Username</div>
<div class="content">
<div class="ts-input is-start-labeled">
<div class="label">chaturbate.com/</div>
<input type="text" autofocus x-model="form_data.username" />
</div>
<div class="ts-text is-description has-top-spaced-small">Use commas to separate multiple channel names. For example, "channel1,channel2,channel3".</div>
</div>
</div>
<!-- / Field: Channel Username -->
<!-- Field: Resolution -->
<div class="ts-control is-wide has-top-spaced-large">
<div class="label">Resolution</div>
<div class="content">
<div class="ts-grid">
<div class="column">
<div class="ts-select">
<select x-model="form_data.resolution">
<option value="2160">4K</option>
<option value="1440">2K</option>
<option value="1080">1080p</option>
<option value="720">720p</option>
<option value="540">540p</option>
<option value="480">480p</option>
<option value="240">240p</option>
</select>
</div>
</div>
<div class="column">
<div class="ts-select">
<select x-model="form_data.resolution_fallback">
<option value="up">or higher</option>
<option value="down">or lower</option>
</select>
</div>
</div>
</div>
<div class="ts-text is-description has-top-spaced-small">
The <template x-if="form_data.resolution_fallback === 'up'"><span>higher</span></template
><template x-if="form_data.resolution_fallback === 'down'"><span>lower</span></template> resolution will be used if
<template x-if="form_data.resolution === '2160'"><span>4K</span></template
><template x-if="form_data.resolution === '1440'"><span>2K</span></template
><template x-if="form_data.resolution === '1080'"><span>1080p</span></template
><template x-if="form_data.resolution === '720'"><span>720p</span></template
><template x-if="form_data.resolution === '480'"><span>480p</span></template
><template x-if="form_data.resolution === '240'"><span>240p</span></template> was not available.
</div>
</div>
</div>
<!-- / Field: Resolution -->
<!-- Field: Framerate -->
<div class="ts-control is-wide has-top-spaced-large">
<div class="label">Framerate</div>
<div class="content">
<div class="ts-wrap is-compact is-vertical has-top-spaced-small">
<label class="ts-radio">
<input name="framerate" value="60" type="radio" x-model="form_data.framerate" />
60 FPS
</label>
<label class="ts-radio">
<input name="framerate" value="30" type="radio" x-model="form_data.framerate" />
30 FPS
</label>
</div>
<template x-if="form_data.framerate === '60'">
<div class="ts-text is-description has-top-spaced-small">30 FPS will be used if 60 FPS was not available.</div>
</template>
</div>
</div>
<!-- / Field: Framerate -->
<!-- Field: Filename Pattern -->
<div class="ts-control is-wide has-top-spaced-large">
<div class="label">Filename Pattern</div>
<div class="content">
<div class="ts-input">
<input type="text" x-model="form_data.filename_pattern" />
</div>
<div class="ts-text is-description has-top-spaced-small">
See the <a class="ts-text is-external-link is-link" href="https://github.com/teacat/chaturbate-dvr" target="_blank">README</a> for details.
</div>
</div>
</div>
<!-- / Field: Filename Pattern -->
<!-- Field: Check Interval -->
<input type="hidden" x-model="form_data.interval" />
<!-- / Field: Check Interval -->
<div class="ts-divider has-vertically-spaced-large"></div>
<!-- Field: Splitting Options -->
<div class="ts-control is-wide has-top-spaced">
<div class="label"></div>
<div class="content">
<details id="splitting-accordion" class="ts-accordion">
<summary>Splitting Options</summary>
<div class="ts-content is-rounded is-secondary has-top-spaced">
<div class="ts-grid is-2-columns">
<div class="column">
<div class="ts-text is-bold">by Filesize</div>
<div class="ts-input is-end-labeled has-top-spaced-small">
<input type="text" x-model="form_data.split_filesize" />
<span class="label">MB</span>
</div>
</div>
<div class="column">
<div class="ts-text is-bold">by Duration</div>
<div class="ts-input is-end-labeled has-top-spaced-small">
<input type="text" x-model="form_data.split_duration" />
<span class="label">Mins</span>
</div>
</div>
</div>
<div class="ts-text is-description has-top-spaced">Splitting will be disabled if both options are 0.</div>
</div>
</details>
</div>
</div>
<!-- / Field: Splitting Options -->
</div>
<!-- / Form -->
<div class="ts-divider"></div>
<!-- Footer -->
<div class="ts-content is-secondary is-horizontally-padded">
<div class="ts-wrap is-end-aligned">
<button class="ts-button is-outlined is-secondary" x-on:click="closeCreateDialog">Cancel</button>
<button class="ts-button is-primary" x-on:click="submitCreateDialog">Add Channel</button>
</div>
</div>
<!-- / Footer -->
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/tocas/5.0.1/tocas.min.css" />
<script src="https://cdnjs.cloudflare.com/ajax/libs/tocas/5.0.1/tocas.min.js"></script>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+TC:wght@400;500;700&display=swap" rel="stylesheet" />
<script src="/static/script.js"></script>
<script src="https://unpkg.com/alpinejs" defer></script>
<title>Chaturbate DVR</title>
</head>
<body x-data="data()">
<!-- Create Dialog -->
<dialog id="create-dialog" class="ts-modal is-large" data-clickaway="close">
<div class="content">
<!-- Header -->
<div class="ts-content is-horizontally-padded is-secondary">
<div class="ts-grid">
<div class="column is-fluid">
<div class="ts-header">Add Channel</div>
</div>
</dialog>
<!-- / Create Dialog -->
<div class="column">
<button class="ts-close is-rounded is-large is-secondary" x-on:click="closeCreateDialog"></button>
</div>
</div>
</div>
<!-- / Header -->
<!-- Main Section -->
<div class="ts-container is-narrow has-vertically-padded-big">
<!-- Header -->
<div class="ts-grid is-bottom-aligned">
<div class="column is-fluid">
<div class="ts-header is-huge is-uppercased is-heavy has-leading-small">Chaturbate DVR</div>
<div class="ts-text is-description is-bold">Version <span x-text="settings.version"></span></div>
<div class="ts-divider"></div>
<!-- Form -->
<div class="ts-content is-vertically-padded">
<!-- Field: Channel Username -->
<div class="ts-control is-wide">
<div class="label">Channel Username</div>
<div class="content">
<div class="ts-input is-start-labeled">
<div class="label">chaturbate.global/</div>
<input type="text" autofocus x-model="form_data.username" />
</div>
<div class="ts-text is-description has-top-spaced-small">Use commas to separate multiple channel names. For example, "channel1,channel2,channel3".</div>
</div>
</div>
<!-- / Field: Channel Username -->
<!-- Field: Resolution -->
<div class="ts-control is-wide has-top-spaced-large">
<div class="label">Resolution</div>
<div class="content">
<div class="ts-grid">
<div class="column">
<div class="ts-select">
<select x-model="form_data.resolution">
<option value="2160">4K</option>
<option value="1440">2K</option>
<option value="1080">1080p</option>
<option value="720">720p</option>
<option value="540">540p</option>
<option value="480">480p</option>
<option value="240">240p</option>
</select>
</div>
</div>
<div class="column">
<div class="ts-wrap">
<button class="ts-button is-outlined is-negative is-start-icon" x-on:click="terminateProgram()">
<span class="ts-icon is-hand-icon"></span>
Terminate
</button>
<button class="ts-button is-start-icon" x-on:click="openCreateDialog">
<span class="ts-icon is-plus-icon"></span>
Add Channel
</button>
</div>
<div class="ts-select">
<select x-model="form_data.resolution_fallback">
<option value="up">or higher</option>
<option value="down">or lower</option>
</select>
</div>
</div>
</div>
<div class="ts-text is-description has-top-spaced-small">
The <template x-if="form_data.resolution_fallback === 'up'"><span>higher</span></template
><template x-if="form_data.resolution_fallback === 'down'"><span>lower</span></template> resolution will be used if
<template x-if="form_data.resolution === '2160'"><span>4K</span></template
><template x-if="form_data.resolution === '1440'"><span>2K</span></template
><template x-if="form_data.resolution === '1080'"><span>1080p</span></template
><template x-if="form_data.resolution === '720'"><span>720p</span></template
><template x-if="form_data.resolution === '480'"><span>480p</span></template
><template x-if="form_data.resolution === '240'"><span>240p</span></template> was not available.
</div>
</div>
<!-- / Header -->
</div>
<!-- / Field: Resolution -->
<!-- Empty State -->
<template x-if="channels.length === 0">
<div>
<div class="ts-divider has-vertically-spaced-large"></div>
<div class="ts-blankslate">
<span class="ts-icon is-eye-low-vision-icon"></span>
<div class="header">No channel was recording</div>
<div class="description">Add a new Chaturbate channel to start the recording.</div>
<div class="action">
<button class="ts-button is-start-icon" x-on:click="openCreateDialog">
<span class="ts-icon is-plus-icon"></span>
Add Channel
</button>
</div>
</div>
</div>
</template>
<!-- / Empty State -->
<!-- Divider -->
<template x-if="channels.length > 0">
<div class="ts-divider is-start-text is-section">
<span class="ts-text is-description"><span x-text="channels.length"></span> channel(s) are being recorded</span>
</div>
</template>
<!-- / Divider -->
<div class="ts-wrap is-vertical is-relaxed">
<!-- Channel -->
<template x-for="channel in channels" :key="channel.username">
<div class="ts-box is-horizontal">
<!-- Left Section -->
<div class="ts-content is-padded" style="flex: 1.8; display: flex; flex-direction: column">
<!-- Header -->
<div class="ts-grid is-middle-aligned">
<div class="column is-fluid">
<div class="ts-header">
<span x-text="channel.username"></span>
<template x-if="channel.is_online && !channel.is_paused">
<span class="ts-badge is-small is-start-spaced">RECORDING</span>
</template>
<template x-if="!channel.is_online && !channel.is_paused">
<span class="ts-badge is-secondary is-small is-start-spaced">OFFLINE</span>
</template>
<template x-if="channel.is_paused">
<span class="ts-badge is-negative is-small is-start-spaced">PAUSED</span>
</template>
</div>
</div>
<div class="column">
<button class="ts-button is-secondary is-short is-outlined is-dense" x-on:click="downloadLogs(channel.username)">Download Logs</button>
</div>
</div>
<!-- / Header -->
<!-- Logs -->
<div class="ts-input has-top-spaced" style="flex: 1">
<textarea class="has-full-height" x-bind:id="`${channel.username}-logs`" x-text="channel.logs.join('\n')" readonly></textarea>
</div>
<!-- / Logs -->
</div>
<!-- / Left Section -->
<div class="ts-divider is-vertical"></div>
<!-- Right Section -->
<div class="ts-content is-padded has-break-all" style="flex: 1; min-width: 300px">
<div class="ts-text is-description is-uppercased">Information</div>
<!-- Info: Channel URL -->
<div class="ts-grid has-top-spaced-large">
<div class="column has-leading-none" style="width: 16px">
<span class="ts-icon is-link-icon"></span>
</div>
<div class="column is-fluid has-leading-small">
<div class="ts-text is-label">Channel URL</div>
<a class="ts-text is-link is-external-link" x-bind:href="channel.channel_url" x-text="channel.channel_url" target="_blank"></a>
</div>
</div>
<!-- / Info: Channel URL -->
<!-- Info: Filename -->
<div class="ts-grid has-top-spaced">
<div class="column has-leading-none" style="width: 16px">
<span class="ts-icon is-folder-icon"></span>
</div>
<div class="column is-fluid has-leading-small">
<div class="ts-text is-label">Filename</div>
<template x-if="channel.filename">
<code class="ts-text is-code" x-text="channel.filename"></code>
</template>
<template x-if="!channel.filename">
<span>-</span>
</template>
</div>
</div>
<!-- / Info: Filename -->
<!-- Info: Last streamed at -->
<div class="ts-grid has-top-spaced">
<div class="column has-leading-none" style="width: 16px">
<span class="ts-icon is-tower-broadcast-icon"></span>
</div>
<div class="column is-fluid">
<div class="ts-text is-label">Last streamed at</div>
<div class="ts-text is-description">
<span x-text="channel.last_streamed_at"></span>
<template x-if="channel.is_online && !channel.is_paused">
<span>(NOW)</span>
</template>
</div>
</div>
</div>
<!-- / Info: Last streamed at -->
<!-- Info: Segment duration -->
<div class="ts-grid has-top-spaced">
<div class="column has-leading-none" style="width: 16px">
<span class="ts-icon is-clock-icon"></span>
</div>
<div class="column is-fluid">
<div class="ts-text is-label">Segment duration</div>
<div class="ts-text is-description">
<span x-text="channel.segment_duration"></span>
<template x-if="channel.split_duration !== '00:00:00'">
<span> / <span x-text="channel.split_duration"></span></span>
</template>
</div>
</div>
</div>
<!-- / Info: Segment duration -->
<!-- Info: Segment filesize -->
<div class="ts-grid has-top-spaced">
<div class="column has-leading-none" style="width: 16px">
<span class="ts-icon is-chart-pie-icon"></span>
</div>
<div class="column is-fluid">
<div class="ts-text is-label">Segment filesize</div>
<div class="ts-text is-description">
<span x-text="channel.segment_filesize"></span>
<template x-if="channel.split_filesize !== '0.00 MiB'">
<span> / <span x-text="channel.split_filesize"></span></span>
</template>
</div>
</div>
</div>
<!-- / Info: Segment filesize -->
<!-- Actions -->
<div class="ts-grid is-2-columns has-top-spaced-large">
<div class="column">
<template x-if="!channel.is_paused">
<button
class="ts-button is-start-icon is-secondary is-fluid"
x-bind:disabled="!channel.is_online"
x-on:click="pauseChannel(channel.username)"
>
<span class="ts-icon is-pause-icon"></span>
Pause
</button>
</template>
<template x-if="channel.is_paused">
<button class="ts-button is-start-icon is-fluid" x-on:click="resumeChannel(channel.username)">
<span class="ts-icon is-play-icon"></span>
Resume
</button>
</template>
</div>
<div class="column">
<button
class="ts-button is-start-icon is-secondary is-negative is-fluid"
data-tooltip="Stop and remove the channel from the list."
x-on:click="deleteChannel(channel.username)"
>
<span class="ts-icon is-stop-icon"></span>
Stop
</button>
</div>
</div>
<!-- / Actions -->
</div>
<!-- / Right Section -->
</div>
</template>
<!-- / Channel -->
<!-- Field: Framerate -->
<div class="ts-control is-wide has-top-spaced-large">
<div class="label">Framerate</div>
<div class="content">
<div class="ts-wrap is-compact is-vertical has-top-spaced-small">
<label class="ts-radio">
<input name="framerate" value="60" type="radio" x-model="form_data.framerate" />
60 FPS
</label>
<label class="ts-radio">
<input name="framerate" value="30" type="radio" x-model="form_data.framerate" />
30 FPS
</label>
</div>
<template x-if="form_data.framerate === '60'">
<div class="ts-text is-description has-top-spaced-small">30 FPS will be used if 60 FPS was not available.</div>
</template>
</div>
</div>
<!-- / Field: Framerate -->
<!-- Field: Filename Pattern -->
<div class="ts-control is-wide has-top-spaced-large">
<div class="label">Filename Pattern</div>
<div class="content">
<div class="ts-input">
<input type="text" x-model="form_data.filename_pattern" />
</div>
<div class="ts-text is-description has-top-spaced-small">See the <a class="ts-text is-external-link is-link" href="https://github.com/teacat/chaturbate-dvr" target="_blank">README</a> for details.</div>
</div>
</div>
<!-- / Field: Filename Pattern -->
<!-- Field: Check Interval -->
<input type="hidden" x-model="form_data.interval" />
<!-- / Field: Check Interval -->
<div class="ts-divider has-vertically-spaced-large"></div>
<!-- Field: Splitting Options -->
<div class="ts-control is-wide has-top-spaced">
<div class="label"></div>
<div class="content">
<details id="splitting-accordion" class="ts-accordion">
<summary>Splitting Options</summary>
<div class="ts-content is-rounded is-secondary has-top-spaced">
<div class="ts-grid is-2-columns">
<div class="column">
<div class="ts-text is-bold">by Filesize</div>
<div class="ts-input is-end-labeled has-top-spaced-small">
<input type="text" x-model="form_data.split_filesize" />
<span class="label">MB</span>
</div>
</div>
<div class="column">
<div class="ts-text is-bold">by Duration</div>
<div class="ts-input is-end-labeled has-top-spaced-small">
<input type="text" x-model="form_data.split_duration" />
<span class="label">Mins</span>
</div>
</div>
</div>
<div class="ts-text is-description has-top-spaced">Splitting will be disabled if both options are 0.</div>
</div>
</details>
</div>
</div>
<!-- / Field: Splitting Options -->
</div>
<!-- / Main Section -->
</body>
<!-- / Form -->
<div class="ts-divider"></div>
<!-- Footer -->
<div class="ts-content is-secondary is-horizontally-padded">
<div class="ts-wrap is-end-aligned">
<button class="ts-button is-outlined is-secondary" x-on:click="closeCreateDialog">Cancel</button>
<button class="ts-button is-primary" x-on:click="submitCreateDialog">Add Channel</button>
</div>
</div>
<!-- / Footer -->
</div>
</dialog>
<!-- / Create Dialog -->
<!-- Main Section -->
<div class="ts-container has-vertically-padded-big">
<!-- Header -->
<div class="ts-grid is-bottom-aligned">
<div class="column is-fluid">
<div class="ts-header is-huge is-uppercased is-heavy has-leading-small">Chaturbate DVR</div>
<div class="ts-text is-description is-bold">Version <span x-text="settings.version"></span></div>
</div>
<div class="column">
<div class="ts-wrap">
<div class="ts-select">
<select x-model="settings.log_level">
<option>DEBUG</option>
<option>INFO</option>
<option>WARN</option>
<option>ERROR</option>
</select>
</div>
<button class="ts-button is-outlined is-negative is-start-icon" x-on:click="terminateProgram()">
<span class="ts-icon is-hand-icon"></span>
Terminate
</button>
<button class="ts-button is-start-icon" x-on:click="openCreateDialog">
<span class="ts-icon is-plus-icon"></span>
Add Channel
</button>
</div>
</div>
</div>
<!-- / Header -->
<!-- Empty State -->
<template x-if="channels.length === 0">
<div>
<div class="ts-divider has-vertically-spaced-large"></div>
<div class="ts-blankslate">
<span class="ts-icon is-eye-low-vision-icon"></span>
<div class="header">No channel was recording</div>
<div class="description">Add a new Chaturbate channel to start the recording.</div>
<div class="action">
<button class="ts-button is-start-icon" x-on:click="openCreateDialog">
<span class="ts-icon is-plus-icon"></span>
Add Channel
</button>
</div>
</div>
</div>
</template>
<!-- / Empty State -->
<!-- Divider -->
<template x-if="channels.length > 0">
<div class="ts-divider is-start-text is-section">
<span class="ts-text is-description"
><span x-text="channels.length"></span>
<span x-show="channels.length &lt; 2" style="display: none"> channel is</span>
<span x-show="channels.length &gt; 1"> channels are</span> being recorded
</span>
<span class="ts-text is-description"> <span x-text="channels.filter(channel => channel.is_online).length"></span> online / <span x-text="channels.filter(channel => !channel.is_online).length"></span> offline </span>
</div>
</template>
<!-- / Divider -->
<!-- Pagination Controls -->
<div class="ts-pagination is-fluid" style="margin-bottom: 1rem;">
<a class="item" x-on:click="goToPage(1)" :class="{ 'is-disabled': currentPage === 1 }">&laquo;</a>
<template x-for="page in totalPages" :key="page">
<a class="item" x-on:click="goToPage(page)" :class="{ 'is-active': currentPage === page }" x-text="page"></a>
</template>
<a class="item" x-on:click="goToPage(totalPages)" :class="{ 'is-disabled': currentPage === totalPages }">&raquo;</a>
</div>
<!-- / Pagination controls -->
<div class="ts-wrap is-vertical is-relaxed">
<!-- Channel -->
<template x-for="channel in paginatedChannels" :key="channel.username">
<!-- <div class="ts-box is-horizontal"> -->
<div
class="ts-box is-horizontal"
:class="{
'is-positive is-top-indicated': channel.is_online && !channel.is_paused,
'is-negative is-top-indicated': !channel.is_online && !channel.is_paused,
'is-top-indicated': channel.is_paused
}"
>
<!-- Left Section -->
<div class="ts-content is-padded" style="flex: 1.25; display: flex; flex-direction: column">
<!-- Header -->
<div class="ts-grid is-middle-aligned">
<div class="column is-fluid">
<div class="ts-header">
<span x-text="channel.username"></span>
<template x-if="channel.is_online && !channel.is_paused">
<span class="ts-badge is-small is-start-spaced">RECORDING</span>
</template>
<template x-if="!channel.is_online && !channel.is_paused">
<span class="ts-badge is-secondary is-small is-start-spaced">OFFLINE</span>
</template>
<template x-if="channel.is_paused">
<span class="ts-badge is-negative is-small is-start-spaced">PAUSED</span>
</template>
</div>
</div>
<div class="column">
<button class="ts-button is-secondary is-short is-outlined is-dense" x-on:click="downloadLogs(channel.username)">Download Logs</button>
</div>
</div>
<!-- / Header -->
<!-- Logs -->
<div class="ts-input has-top-spaced" style="flex: 1">
<textarea class="has-full-height" x-bind:id="`${channel.username}-logs`" x-text="channel.logs.join('\n')" readonly></textarea>
</div>
<!-- / Logs -->
</div>
<!-- / Left Section -->
<div class="ts-divider is-vertical"></div>
<!-- Right Section -->
<div class="ts-content is-padded has-break-all" style="flex: 1; min-width: 300px">
<div class="ts-text is-description is-uppercased">Information</div>
<!-- Info: Channel URL -->
<div class="ts-grid has-top-spaced-large">
<div class="column has-leading-none" style="width: 16px">
<span class="ts-icon is-link-icon"></span>
</div>
<div class="column is-fluid has-leading-small">
<div class="ts-text is-label">Channel URL</div>
<a class="ts-text is-link is-external-link" x-bind:href="channel.channel_url" x-text="channel.channel_url" target="_blank"></a>
</div>
</div>
<!-- / Info: Channel URL -->
<!-- Info: Filename -->
<div class="ts-grid has-top-spaced">
<div class="column has-leading-none" style="width: 16px">
<span class="ts-icon is-folder-icon"></span>
</div>
<div class="column is-fluid has-leading-small">
<div class="ts-text is-label">Filename</div>
<template x-if="channel.filename">
<code class="ts-text is-code" x-text="channel.filename"></code>
</template>
<template x-if="!channel.filename">
<span>-</span>
</template>
</div>
</div>
<!-- / Info: Filename -->
<!-- Info: Last streamed at -->
<div class="ts-grid has-top-spaced">
<div class="column has-leading-none" style="width: 16px">
<span class="ts-icon is-tower-broadcast-icon"></span>
</div>
<div class="column is-fluid">
<div class="ts-text is-label">Last streamed at</div>
<div class="ts-text is-description">
<span x-text="channel.last_streamed_at"></span>
<template x-if="channel.is_online && !channel.is_paused">
<span>(NOW)</span>
</template>
</div>
</div>
</div>
<!-- / Info: Last streamed at -->
<!-- Info: Segment duration -->
<div class="ts-grid has-top-spaced">
<div class="column has-leading-none" style="width: 16px">
<span class="ts-icon is-clock-icon"></span>
</div>
<div class="column is-fluid">
<div class="ts-text is-label">Segment duration</div>
<div class="ts-text is-description">
<span x-text="channel.segment_duration"></span>
<template x-if="channel.split_duration !== '00:00:00'">
<span> / <span x-text="channel.split_duration"></span></span>
</template>
</div>
</div>
</div>
<!-- / Info: Segment duration -->
<!-- Info: Segment filesize -->
<div class="ts-grid has-top-spaced">
<div class="column has-leading-none" style="width: 16px">
<span class="ts-icon is-chart-pie-icon"></span>
</div>
<div class="column is-fluid">
<div class="ts-text is-label">Segment filesize</div>
<div class="ts-text is-description">
<span x-text="channel.segment_filesize"></span>
<template x-if="channel.split_filesize !== '0.00 MiB'">
<span> / <span x-text="channel.split_filesize"></span></span>
</template>
</div>
</div>
</div>
<!-- / Info: Segment filesize -->
<!-- Actions -->
<div class="ts-grid is-2-columns has-top-spaced-large">
<div class="column">
<template x-if="!channel.is_paused">
<button class="ts-button is-start-icon is-secondary is-fluid" x-bind:disabled="!channel.is_online" x-on:click="pauseChannel(channel.username)">
<span class="ts-icon is-pause-icon"></span>
Pause
</button>
</template>
<template x-if="channel.is_paused">
<button class="ts-button is-start-icon is-fluid" x-on:click="resumeChannel(channel.username)">
<span class="ts-icon is-play-icon"></span>
Resume
</button>
</template>
</div>
<div class="column">
<button class="ts-button is-start-icon is-secondary is-negative is-fluid" data-tooltip="Stop and remove the channel from the list." x-on:click="deleteChannel(channel.username)">
<span class="ts-icon is-stop-icon"></span>
Stop
</button>
</div>
</div>
<!-- / Actions -->
</div>
<!-- / Right Section -->
</div>
</template>
<!-- / Channel -->
</div>
</div>
<!-- / Main Section -->
</body>
</html>

View File

@@ -1,212 +1,254 @@
function data() {
return {
settings: {},
channels: [],
is_updating_channels: false,
form_data: {
username: "",
resolution: "1080",
resolution_fallback: "up",
framerate: "30",
filename_pattern: "{{.Username}}_{{.Year}}-{{.Month}}-{{.Day}}_{{.Hour}}-{{.Minute}}-{{.Second}}{{if .Sequence}}_{{.Sequence}}{{end}}",
split_filesize: 0,
split_duration: 0,
interval: 1,
},
return {
settings: {},
channels: [],
currentPage: 1,
itemsPerPage: 5,
is_updating_channels: false,
form_data: {
username: "",
resolution: "1080",
resolution_fallback: "down",
framerate: "30",
filename_pattern: "{{.Username}}/{{.Year}}-{{.Month}}-{{.Day}}/{{.Username}}_{{.Hour}}-{{.Minute}}-{{.Second}}{{if .Sequence}}_{{.Sequence}}{{end}}",
split_filesize: 0,
split_duration: 0,
interval: 1,
},
// openCreateDialog
openCreateDialog() {
document.getElementById("create-dialog").showModal()
},
// Watch for changes in LogLevel
watchLogLevel() {
this.$watch("settings.log_level", async (newVal, oldVal) => {
if (newVal !== oldVal) {
await this.updateLogLevel();
}
});
},
// closeCreateDialog
closeCreateDialog() {
document.getElementById("create-dialog").close()
this.resetCreateDialog()
},
// Compute the channels to display for the current page
get paginatedChannels() {
const start = (this.currentPage - 1) * this.itemsPerPage;
return this.channels.slice(start, start + this.itemsPerPage);
},
// submitCreateDialog
submitCreateDialog() {
this.createChannel()
this.closeCreateDialog()
},
// Calculate total pages
get totalPages() {
return Math.ceil(this.channels.length / this.itemsPerPage);
},
// error
error() {
alert("Error occurred, please refresh the page if something is wrong.")
},
// Change page on click
goToPage(page) {
if (page >= 1 && page <= this.totalPages) {
this.currentPage = page;
}
},
// openCreateDialog
openCreateDialog() {
document.getElementById("create-dialog").showModal();
},
//
async call(path, body) {
try {
var resp = await fetch(`/api/${path}`, {
body: JSON.stringify(body),
method: "POST",
})
if (resp.status !== 200) {
this.error()
return [null, true]
}
return [await resp.json(), false]
} catch {
this.error()
return [null, true]
}
},
// closeCreateDialog
closeCreateDialog() {
document.getElementById("create-dialog").close();
this.resetCreateDialog();
},
// getSettings
async getSettings() {
var [resp, err] = await this.call("get_settings", {})
if (!err) {
this.settings = resp
this.resetCreateDialog()
}
},
// submitCreateDialog
submitCreateDialog() {
this.createChannel();
this.closeCreateDialog();
},
// init
async init() {
document.getElementById("create-dialog").addEventListener("close", () => this.resetCreateDialog())
// error
error() {
alert("Error occurred, please refresh the page if something is wrong.");
},
await this.getSettings()
await this.listChannels()
this.listenUpdate()
},
//
async call(path, body) {
try {
var resp = await fetch(`/api/${path}`, {
body: JSON.stringify(body),
method: "POST",
});
if (resp.status !== 200) {
this.error();
return [null, true];
}
return [await resp.json(), false];
} catch {
this.error();
return [null, true];
}
},
// resetCreateDialog
resetCreateDialog() {
document.getElementById("splitting-accordion").open = false
// getSettings
async getSettings() {
var [resp, err] = await this.call("get_settings", {});
if (!err) {
this.settings = resp;
this.resetCreateDialog();
await this.updateLogLevel();
}
},
this.form_data = {
username: "",
resolution: this.settings.resolution.toString(),
resolution_fallback: this.settings.resolution_fallback,
framerate: this.settings.framerate.toString(),
filename_pattern: this.settings.filename_pattern,
split_filesize: this.settings.split_filesize.toString(),
split_duration: this.settings.split_duration.toString(),
interval: this.settings.interval.toString(),
}
},
// init
async init() {
document
.getElementById('create-dialog')
.addEventListener('close', () => this.resetCreateDialog());
// createChannel
async createChannel() {
await this.call("create_channel", {
username: this.form_data.username,
resolution: parseInt(this.form_data.resolution),
resolution_fallback: this.form_data.resolution_fallback,
framerate: parseInt(this.form_data.framerate),
filename_pattern: this.form_data.filename_pattern,
split_filesize: parseInt(this.form_data.split_filesize),
split_duration: parseInt(this.form_data.split_duration),
interval: parseInt(this.form_data.interval),
})
},
await this.getSettings(); // Ensure settings are loaded
this.watchLogLevel(); // Start watching LogLevel after settings load
await this.listChannels();
this.listenUpdate();
},
// deleteChannel
async deleteChannel(username) {
if (!confirm(`Are you sure you want to delete the channel "${username}"?`)) {
return
}
var [_, err] = await this.call("delete_channel", { username })
if (!err) {
this.channels = this.channels.filter(ch => ch.username !== username)
}
},
async updateLogLevel() {
const [_, err] = await this.call('update_log_level', {
log_level: this.settings.log_level,
});
// pauseChannel
async pauseChannel(username) {
await this.call("pause_channel", { username })
},
if (err) {
this.error();
}
},
// terminateProgram
async terminateProgram() {
if (confirm("Are you sure you want to terminate the program?")) {
alert("The program is terminated, any error messages are safe to ignore.")
await this.call("terminate_program", {})
}
},
// resetCreateDialog
resetCreateDialog() {
document.getElementById("splitting-accordion").open = false;
// resumeChannel
async resumeChannel(username) {
await this.call("resume_channel", { username })
},
// Ensure settings are loaded before resetting form_data
this.form_data = {
username: "",
resolution: this.settings.resolution?.toString() || "1080",
resolution_fallback: this.settings.resolution_fallback || "down",
framerate: this.settings.framerate?.toString() || "30",
filename_pattern: this.settings.filename_pattern || "{{.Username}}/{{.Year}}-{{.Month}}-{{.Day}}/{{.Username}}_{{.Hour}}-{{.Minute}}-{{.Second}}{{if .Sequence}}_{{.Sequence}}{{end}}",
split_filesize: this.settings.split_filesize?.toString() || "0",
split_duration: this.settings.split_duration?.toString() || "30",
interval: this.settings.interval?.toString() || "1",
};
},
// listChannels
async listChannels() {
if (this.is_updating_channels) {
return
}
var [resp, err] = await this.call("list_channels", {})
if (!err) {
this.channels = resp.channels
this.channels.forEach(ch => {
this.scrollLogs(ch.username)
})
}
this.is_updating_channels = false
},
// createChannel
async createChannel() {
await this.call("create_channel", {
username: this.form_data.username,
resolution: parseInt(this.form_data.resolution),
resolution_fallback: this.form_data.resolution_fallback,
framerate: parseInt(this.form_data.framerate),
filename_pattern: this.form_data.filename_pattern,
split_filesize: parseInt(this.form_data.split_filesize),
split_duration: parseInt(this.form_data.split_duration),
interval: parseInt(this.form_data.interval),
});
},
// listenUpdate
listenUpdate() {
var source = new EventSource("/api/listen_update")
// deleteChannel
async deleteChannel(username) {
if (!confirm(`Are you sure you want to delete the channel "${username}"?`)) {
return;
}
var [_, err] = await this.call("delete_channel", { username });
if (!err) {
this.channels = this.channels.filter((ch) => ch.username !== username);
}
},
source.onmessage = event => {
var data = JSON.parse(event.data)
// pauseChannel
async pauseChannel(username) {
await this.call("pause_channel", { username });
},
// If the channel is not in the list or is stopped, refresh the list.
if (!this.channels.some(ch => ch.username === data.username) || data.is_stopped) {
this.listChannels()
return
}
// terminateProgram
async terminateProgram() {
if (confirm("Are you sure you want to terminate the program?")) {
alert("The program is terminated, any error messages are safe to ignore.");
await this.call("terminate_program", {});
}
},
var index = this.channels.findIndex(ch => ch.username === data.username)
// resumeChannel
async resumeChannel(username) {
await this.call("resume_channel", { username });
},
if (index === -1) {
return
}
// listChannels
async listChannels() {
if (this.is_updating_channels) {
return;
}
var [resp, err] = await this.call("list_channels", {});
if (!err) {
this.channels = resp.channels;
this.currentPage = 1;
this.channels.forEach((ch) => {
this.scrollLogs(ch.username);
});
}
this.is_updating_channels = false;
},
this.channels[index].segment_duration = data.segment_duration
this.channels[index].segment_filesize = data.segment_filesize
this.channels[index].filename = data.filename
this.channels[index].last_streamed_at = data.last_streamed_at
this.channels[index].is_online = data.is_online
this.channels[index].is_paused = data.is_paused
this.channels[index].logs = [...this.channels[index].logs, data.log]
// listenUpdate
listenUpdate() {
var source = new EventSource("/api/listen_update");
if (this.channels[index].logs.length > 100) {
this.channels[index].logs = this.channels[index].logs.slice(-100)
}
source.onmessage = (event) => {
var data = JSON.parse(event.data);
this.scrollLogs(data.username)
}
// If the channel is not in the list or is stopped, refresh the list.
if (!this.channels.some((ch) => ch.username === data.username) || data.is_stopped) {
this.listChannels();
return;
}
source.onerror = err => {
source.close()
}
},
var index = this.channels.findIndex((ch) => ch.username === data.username);
downloadLogs(username) {
var a = window.document.createElement("a")
a.href = window.URL.createObjectURL(
new Blob([this.channels[this.channels.findIndex(ch => ch.username === username)].logs.join("\n")], { type: "text/plain", oneTimeOnly: true })
)
a.download = `${username}_logs.txt`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
},
if (index === -1) {
return;
}
//
scrollLogs(username) {
// Wait for the DOM to update.
setTimeout(() => {
var logs_element = document.getElementById(`${username}-logs`)
this.channels[index].segment_duration = data.segment_duration;
this.channels[index].segment_filesize = data.segment_filesize;
this.channels[index].filename = data.filename;
this.channels[index].last_streamed_at = data.last_streamed_at;
this.channels[index].is_online = data.is_online;
this.channels[index].is_paused = data.is_paused;
this.channels[index].logs = [...this.channels[index].logs, data.log];
if (!logs_element) {
return
}
logs_element.scrollTop = logs_element.scrollHeight
}, 1)
},
}
if (this.channels[index].logs.length > 100) {
this.channels[index].logs = this.channels[index].logs.slice(-100);
}
this.scrollLogs(data.username);
};
source.onerror = (err) => {
source.close();
};
},
downloadLogs(username) {
var a = window.document.createElement("a");
a.href = window.URL.createObjectURL(new Blob([this.channels[this.channels.findIndex((ch) => ch.username === username)].logs.join("\n")], { type: "text/plain", oneTimeOnly: true }));
a.download = `${username}_logs.txt`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
},
//
scrollLogs(username) {
// Wait for the DOM to update.
setTimeout(() => {
var logs_element = document.getElementById(`${username}-logs`);
if (!logs_element) {
return;
}
logs_element.scrollTop = logs_element.scrollHeight;
}, 1);
},
};
}