2.0.0 refactor

This commit is contained in:
Yami Odymel
2025-05-02 23:52:58 +08:00
parent cad4689a5c
commit f26602b49e
51 changed files with 2108 additions and 2989 deletions

81
router/router.go Normal file
View File

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

104
router/router_handler.go Normal file
View File

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

View File

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

View File

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

File diff suppressed because one or more lines are too long

View File

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

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

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