Feat: synctv init

This commit is contained in:
zijiren233 2023-10-04 13:59:36 +08:00
parent 586f37bbab
commit acf7718c0a
48 changed files with 4320 additions and 2 deletions

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
/log
/public/dist/*
!*.gitkeep
.DS_Store

19
README-CN.md Normal file
View File

@ -0,0 +1,19 @@
[English](./README.md) | 中文
# 特点
- [x] 同步观看
- [x] 视频同步
- [x] 直播同步
- [x] 影院模式
- [x] 聊天
- [x] 弹幕
- [x] 代理
- [ ] 视频代理
- [ ] 直播代理
# 免责声明
- 这个程序是一个免费且开源的项目。它旨在播放网络上的视频文件方便多人共同观看视频和学习golang。
- 在使用时,请遵守相关法律法规,不要滥用。
- 该程序仅进行客户端播放视频文件/流量转发,不会拦截、存储或篡改任何用户数据。
- 在使用该程序之前,您应该了解并承担相应的风险,包括但不限于版权纠纷、法律限制等,这与该程序无关。
- 如果有任何侵权行为,请通过[电子邮件](mailto:pyh1670605849@gmail.com)与我联系,将及时处理。

View File

@ -1,2 +1,22 @@
# synctv
Synchronized viewing, theater, live streaming, video, long-distance relationship
English | [中文](./README-CN.md)
# Features
- [x] Synchronized viewing
- [x] Videos Sync
- [x] Live streaming
- [x] Theater
- [x] Chat
- [x] Bullet chat
- [x] Proxy
- [ ] Videos proxy
- [ ] Live proxy
# License
The `SyncTV` is open-source software licensed under the AGPL-3.0 license.
# Disclaimer
- This program is a free and open-source project. It aims to play video files on the internet, making it convenient for multiple people to watch videos and learn golang together.
- Please comply with relevant laws and regulations when using it, and do not abuse it.
- The program only plays video files/forwards traffic on the client-side and will not intercept, store, or tamper with any user data.
- Before using the program, you should understand and assume the corresponding risks, including but not limited to copyright disputes, legal restrictions, etc., which are not related to the program.
- If there is any infringement, please contact me via [email](mailto:pyh1670605849@gmail.com), and it will be dealt with promptly.

17
cmd/common.go Normal file
View File

@ -0,0 +1,17 @@
package cmd
import (
"github.com/gin-gonic/gin"
"github.com/synctv-org/synctv/cmd/flags"
)
func InitGinMode() error {
if flags.Dev {
gin.SetMode(gin.DebugMode)
} else {
gin.SetMode(gin.ReleaseMode)
}
gin.ForceConsoleColor()
return nil
}

11
cmd/conf.go Normal file
View File

@ -0,0 +1,11 @@
package cmd
import "github.com/spf13/cobra"
func ConfCmd() *cobra.Command {
return &cobra.Command{
Use: "conf",
Short: "conf",
Long: `config file`,
}
}

16
cmd/flags/config.go Normal file
View File

@ -0,0 +1,16 @@
package flags
// Global
var (
Dev bool
LogStd bool
ConfigFile string
SkipEnv bool
SkipConfig bool
EnvNoPrefix bool
)

24
cmd/init.go Normal file
View File

@ -0,0 +1,24 @@
package cmd
import (
"github.com/spf13/cobra"
"github.com/synctv-org/synctv/internal/bootstrap"
)
func Init(cmd *cobra.Command, args []string) error {
bootstrap.InitConfig()
bootstrap.InitLog()
bootstrap.InitSysNotify()
return nil
}
var InitCmd = &cobra.Command{
Use: "init",
Short: "init and check config",
Long: `auto create config file or check config, and auto add new key and delete old key`,
RunE: Init,
}
func init() {
RootCmd.AddCommand(InitCmd)
}

31
cmd/root.go Normal file
View File

@ -0,0 +1,31 @@
package cmd
import (
"fmt"
"os"
"github.com/spf13/cobra"
"github.com/synctv-org/synctv/cmd/flags"
)
var RootCmd = &cobra.Command{
Use: "synctv-server",
Short: "synctv-server",
Long: `synctv-server https://github.com/synctv-org/synctv`,
}
func Execute() {
if err := RootCmd.Execute(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func init() {
RootCmd.PersistentFlags().BoolVar(&flags.Dev, "dev", false, "start with dev mode")
RootCmd.PersistentFlags().BoolVar(&flags.LogStd, "log-std", true, "log to std")
RootCmd.PersistentFlags().BoolVar(&flags.EnvNoPrefix, "env-no-prefix", false, "env no SYNCTV_ prefix")
RootCmd.PersistentFlags().BoolVar(&flags.SkipConfig, "skip-config", false, "skip config")
RootCmd.PersistentFlags().BoolVar(&flags.SkipEnv, "skip-env", false, "skip env")
RootCmd.PersistentFlags().StringVarP(&flags.ConfigFile, "config", "f", "", "config file path")
}

113
cmd/server.go Normal file
View File

@ -0,0 +1,113 @@
package cmd
import (
"fmt"
"net"
"net/http"
"github.com/quic-go/quic-go/http3"
log "github.com/sirupsen/logrus"
"github.com/soheilhy/cmux"
"github.com/spf13/cobra"
"github.com/synctv-org/synctv/internal/bootstrap"
"github.com/synctv-org/synctv/internal/conf"
"github.com/synctv-org/synctv/server"
)
var ServerCmd = &cobra.Command{
Use: "server",
Short: "Start synctv-server",
Long: `Start synctv-server`,
PersistentPreRunE: Init,
PreRunE: func(cmd *cobra.Command, args []string) error { return InitGinMode() },
Run: Server,
}
func Server(cmd *cobra.Command, args []string) {
tcpServerAddr, err := net.ResolveTCPAddr("tcp", fmt.Sprintf("%s:%d", conf.Conf.Server.Listen, conf.Conf.Server.Port))
if err != nil {
log.Panic(err)
}
udpServerAddr, err := net.ResolveUDPAddr("udp", fmt.Sprintf("%s:%d", conf.Conf.Server.Listen, conf.Conf.Server.Port))
if err != nil {
log.Panic(err)
}
serverListener, err := net.ListenTCP("tcp", tcpServerAddr)
if err != nil {
log.Panic(err)
}
var useMux bool
if conf.Conf.Rtmp.Port == 0 || conf.Conf.Rtmp.Port == conf.Conf.Server.Port {
useMux = true
conf.Conf.Rtmp.Port = conf.Conf.Server.Port
}
tcpRtmpAddr, err := net.ResolveTCPAddr("tcp", fmt.Sprintf("%s:%d", conf.Conf.Server.Listen, conf.Conf.Rtmp.Port))
if err != nil {
log.Fatal(err)
}
if conf.Conf.Rtmp.Enable {
if useMux {
muxer := cmux.New(serverListener)
e, s := server.NewAndInit()
switch {
case conf.Conf.Server.CertPath != "" && conf.Conf.Server.KeyPath != "":
httpl := muxer.Match(cmux.HTTP2(), cmux.TLS())
go http.ServeTLS(httpl, e.Handler(), conf.Conf.Server.CertPath, conf.Conf.Server.KeyPath)
if conf.Conf.Server.Quic {
go http3.ListenAndServeQUIC(udpServerAddr.String(), conf.Conf.Server.CertPath, conf.Conf.Server.KeyPath, e.Handler())
}
case conf.Conf.Server.CertPath == "" && conf.Conf.Server.KeyPath == "":
httpl := muxer.Match(cmux.HTTP1Fast())
go e.RunListener(httpl)
default:
log.Panic("cert and key must be both set")
}
tcp := muxer.Match(cmux.Any())
go s.Serve(tcp)
go muxer.Serve()
} else {
e, s := server.NewAndInit()
switch {
case conf.Conf.Server.CertPath != "" && conf.Conf.Server.KeyPath != "":
go http.ServeTLS(serverListener, e.Handler(), conf.Conf.Server.CertPath, conf.Conf.Server.KeyPath)
if conf.Conf.Server.Quic {
go http3.ListenAndServeQUIC(udpServerAddr.String(), conf.Conf.Server.CertPath, conf.Conf.Server.KeyPath, e.Handler())
}
case conf.Conf.Server.CertPath == "" && conf.Conf.Server.KeyPath == "":
go e.RunListener(serverListener)
default:
log.Panic("cert and key must be both set")
}
rtmpListener, err := net.ListenTCP("tcp", tcpRtmpAddr)
if err != nil {
log.Fatal(err)
}
go s.Serve(rtmpListener)
}
} else {
e, _ := server.NewAndInit()
switch {
case conf.Conf.Server.CertPath != "" && conf.Conf.Server.KeyPath != "":
go http.ServeTLS(serverListener, e.Handler(), conf.Conf.Server.CertPath, conf.Conf.Server.KeyPath)
if conf.Conf.Server.Quic {
go http3.ListenAndServeQUIC(udpServerAddr.String(), conf.Conf.Server.CertPath, conf.Conf.Server.KeyPath, e.Handler())
}
case conf.Conf.Server.CertPath == "" && conf.Conf.Server.KeyPath == "":
go e.RunListener(serverListener)
default:
log.Panic("cert and key must be both set")
}
}
if conf.Conf.Rtmp.Enable {
log.Infof("rtmp run on tcp://%s:%d", tcpServerAddr.IP, tcpRtmpAddr.Port)
}
if conf.Conf.Server.Quic && conf.Conf.Server.CertPath != "" && conf.Conf.Server.KeyPath != "" {
log.Infof("quic run on udp://%s:%d", udpServerAddr.IP, udpServerAddr.Port)
}
log.Infof("website run on http://%s:%d", tcpServerAddr.IP, tcpServerAddr.Port)
bootstrap.WaitCbk()
}
func init() {
RootCmd.AddCommand(ServerCmd)
}

20
cmd/version.go Normal file
View File

@ -0,0 +1,20 @@
package cmd
import (
"fmt"
"github.com/spf13/cobra"
)
var VersionCmd = &cobra.Command{
Use: "version",
Short: "Print the version number of Sync TV Server",
Long: `All software has versions. This is Sync TV Server's`,
Run: func(cmd *cobra.Command, args []string) {
fmt.Println("synctv-server v0.1 -- HEAD")
},
}
func init() {
RootCmd.AddCommand(VersionCmd)
}

67
go.mod Normal file
View File

@ -0,0 +1,67 @@
module github.com/synctv-org/synctv
go 1.18
require (
github.com/caarlos0/env/v9 v9.0.0
github.com/gin-contrib/cors v1.4.0
github.com/gin-gonic/gin v1.9.1
github.com/go-resty/resty/v2 v2.8.0
github.com/golang-jwt/jwt/v5 v5.0.0
github.com/google/uuid v1.3.1
github.com/gorilla/websocket v1.5.0
github.com/json-iterator/go v1.1.12
github.com/maruel/natural v1.1.0
github.com/mitchellh/go-homedir v1.1.0
github.com/natefinch/lumberjack v2.0.0+incompatible
github.com/quic-go/quic-go v0.39.0
github.com/sirupsen/logrus v1.9.3
github.com/soheilhy/cmux v0.1.5
github.com/spf13/cobra v1.7.0
github.com/zijiren233/gencontainer v0.0.0-20230930061950-82324a07eacb
github.com/zijiren233/go-colorable v0.0.0-20230930131441-997304c961cb
github.com/zijiren233/ksync v0.2.0
github.com/zijiren233/livelib v0.0.0-20230930062256-1d07bbddcefb
github.com/zijiren233/stream v0.5.1
github.com/zijiren233/yaml-comment v0.2.0
golang.org/x/crypto v0.13.0
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/BurntSushi/toml v1.3.2 // indirect
github.com/bytedance/sonic v1.10.1 // indirect
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d // indirect
github.com/chenzhuoyu/iasm v0.9.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.2 // 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.15.4 // indirect
github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/google/pprof v0.0.0-20230926050212-f7f687d19a98 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/klauspost/cpuid/v2 v2.2.5 // indirect
github.com/leodido/go-urn v1.2.4 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/onsi/ginkgo/v2 v2.12.1 // indirect
github.com/pelletier/go-toml/v2 v2.1.0 // indirect
github.com/quic-go/qpack v0.4.0 // indirect
github.com/quic-go/qtls-go1-20 v0.3.4 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.11 // indirect
go.uber.org/mock v0.3.0 // indirect
golang.org/x/arch v0.5.0 // indirect
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect
golang.org/x/mod v0.12.0 // indirect
golang.org/x/net v0.15.0 // indirect
golang.org/x/sys v0.12.0 // indirect
golang.org/x/text v0.13.0 // indirect
golang.org/x/tools v0.13.0 // indirect
google.golang.org/protobuf v1.31.0 // indirect
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
)

235
go.sum Normal file
View File

@ -0,0 +1,235 @@
github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8=
github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
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.10.1 h1:7a1wuFXL1cMy7a3f7/VFcEtriuXQnUBhtoVfOZiaysc=
github.com/bytedance/sonic v1.10.1/go.mod h1:iZcSUejdk5aukTND/Eu/ivjQuEL0Cu9/rf50Hi0u/g4=
github.com/caarlos0/env/v9 v9.0.0 h1:SI6JNsOA+y5gj9njpgybykATIylrRMklbs5ch6wO6pc=
github.com/caarlos0/env/v9 v9.0.0/go.mod h1:ye5mlCVMYh6tZ+vCgrs/B95sj88cg5Tlnc0XIzgZ020=
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 h1:9fhXjVzq5hUy2gkhhgHl95zG2cEAhw9OSGs8toWWAwo=
github.com/chenzhuoyu/iasm v0.9.0/go.mod h1:Xjy2NpN3h7aUqeqM+woSuuvxmIe6+DDsiNLIrkAmYog=
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/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=
github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
github.com/gin-contrib/cors v1.4.0 h1:oJ6gwtUl3lqV0WEIwM/LxPF1QZ5qe2lGWdY2+bz7y0g=
github.com/gin-contrib/cors v1.4.0/go.mod h1:bs9pNM0x/UsmHPBWT2xZz9ROh8xYjYkiURUfmBoMlcs=
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.8.1/go.mod h1:ji8BvRH1azfM+SYow9zQ6SZMvR8qOMZHmsCuWR9tTTk=
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/go-logr/logr v1.2.4 h1:g01GSCwiDw2xSZfjJ2/T9M+S6pFdcNtFYsp+Y43HYDQ=
github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/locales v0.14.0/go.mod h1:sawfccIbzZTqEDETgFXqTho0QybSa7l++s0DH+LDiLs=
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.0/go.mod h1:UvRDBj+xPUEGrFYl+lu/H90nyDXpg0fqeB/AQUGNTVA=
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.10.0/go.mod h1:74x4gJWsvQexRdW8Pn3dXSGrTK4nAUsbPlLADvpJkos=
github.com/go-playground/validator/v10 v10.15.4 h1:zMXza4EpOdooxPel5xDqXEdXG5r+WggpvnAKMsalBjs=
github.com/go-playground/validator/v10 v10.15.4/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
github.com/go-resty/resty/v2 v2.8.0 h1:J29d0JFWwSWrDCysnOK/YjsPMLQTx0TvgJEHVGvf2L8=
github.com/go-resty/resty/v2 v2.8.0/go.mod h1:UCui0cMHekLrSntoMyofdSTaPpinlRHFtPpizuyDW2w=
github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI=
github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls=
github.com/goccy/go-json v0.9.7/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
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/golang-jwt/jwt/v5 v5.0.0 h1:1n1XNM9hk7O9mnQoNBGolZvzebBQ7p93ULHRc28XJUE=
github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20230926050212-f7f687d19a98 h1:pUa4ghanp6q4IJHwE9RwLgmVFfReJN+KbQ8ExNEUUoQ=
github.com/google/pprof v0.0.0-20230926050212-f7f687d19a98/go.mod h1:czg5+yv1E0ZGTi6S6vVK1mke0fV+FaUhNGcd6VRS9Ik=
github.com/google/uuid v1.3.1 h1:KjJaJ9iWZ3jOFZIf1Lqf4laDRCasjl0BCmnEGxkdLb4=
github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
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.5 h1:0E5MSMDEoAulmXNFquVs//DdoomxaoTY1kUhbc/qbZg=
github.com/klauspost/cpuid/v2 v2.2.5/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.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY=
github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
github.com/maruel/natural v1.1.0 h1:2z1NgP/Vae+gYrtC0VuvrTJ6U35OuyUqDdfluLqMWuQ=
github.com/maruel/natural v1.1.0/go.mod h1:eFVhYCcUOfZFxXoDZam8Ktya72wa79fNC3lc/leA0DQ=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
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/natefinch/lumberjack v2.0.0+incompatible h1:4QJd3OLAMgj7ph+yZTuX13Ld4UpgHp07nNdFX7mqFfM=
github.com/natefinch/lumberjack v2.0.0+incompatible/go.mod h1:Wi9p2TTF5DG5oU+6YfsmYQpsTIOm0B1VNzQg9Mw6nPk=
github.com/onsi/ginkgo/v2 v2.12.1 h1:uHNEO1RP2SpuZApSkel9nEh1/Mu+hmQe7Q+Pepg5OYA=
github.com/onsi/ginkgo/v2 v2.12.1/go.mod h1:TE309ZR8s5FsKKpuB1YAQYBzCaAfUgatB/xlT/ETL/o=
github.com/onsi/gomega v1.27.10 h1:naR28SdDFlqrG6kScpT8VWpu1xWY5nJRCF3XaYyBjhI=
github.com/pelletier/go-toml/v2 v2.0.1/go.mod h1:r9LEWfGN8R5k0VXJ+0BkIe7MYkRdwZOjgMj2KwnJFUo=
github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4=
github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
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/quic-go/qpack v0.4.0 h1:Cr9BXA1sQS2SmDUWjSofMPNKmvF6IiIfDRmgU0w1ZCo=
github.com/quic-go/qpack v0.4.0/go.mod h1:UZVnYIfi5GRk+zI9UMaCPsmZ2xKJP7XBUvVyT1Knj9A=
github.com/quic-go/qtls-go1-20 v0.3.4 h1:MfFAPULvst4yoMgY9QmtpYmfij/em7O8UUi+bNVm7Cg=
github.com/quic-go/qtls-go1-20 v0.3.4/go.mod h1:X9Nh97ZL80Z+bX/gUXMbipO6OxdiDi58b/fMC9mAL+k=
github.com/quic-go/quic-go v0.39.0 h1:AgP40iThFMY0bj8jGxROhw3S0FMGa8ryqsmi9tBH3So=
github.com/quic-go/quic-go v0.39.0/go.mod h1:T09QsDQWjLiQ74ZmacDfqZmhY/NLnw5BC40MANNNZ1Q=
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/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/soheilhy/cmux v0.1.5 h1:jjzc5WVemNEDTLwv9tlmemhC73tI08BNOIGwBOo10Js=
github.com/soheilhy/cmux v0.1.5/go.mod h1:T7TcVDs9LWfQgPlPsdngu6I6QIoyIFZDDC6sNE1GqG0=
github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I=
github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
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=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
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.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
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 v1.2.7/go.mod h1:nF9osbDWLy6bDVv/Rtoh6QgnvNDpmCalQV5urGCCS6M=
github.com/ugorji/go/codec v1.2.7/go.mod h1:WGN1fab3R1fzQlVQTkfxVtIBhWDRqOviHU95kRgeqEY=
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/zijiren233/gencontainer v0.0.0-20230930061950-82324a07eacb h1:sOVY4BfQo/uzoACkFTvfCJd0mHhhPQjcwgKTICOW+Js=
github.com/zijiren233/gencontainer v0.0.0-20230930061950-82324a07eacb/go.mod h1:V5oL7PrZxgisuLCblFWd89Jg99O8vM1n58llcxZ2hDY=
github.com/zijiren233/go-colorable v0.0.0-20230930131441-997304c961cb h1:0DyOxf/TbbGodHhOVHNoPk+7v/YBJACs22gKpKlatWw=
github.com/zijiren233/go-colorable v0.0.0-20230930131441-997304c961cb/go.mod h1:6TCzjDiQ8+5gWZiwsC3pnA5M0vUy2jV2Y7ciHJh729g=
github.com/zijiren233/ksync v0.2.0 h1:OyXVXbVQYFEVfWM13NApt4LMHbLQ3HTs4oYcLmqL6NE=
github.com/zijiren233/ksync v0.2.0/go.mod h1:YNvvoErcbtF86Xn18J8QJ14jKOXinxFVOzyp4hn8FKw=
github.com/zijiren233/livelib v0.0.0-20230930062256-1d07bbddcefb h1:t6zLooW4uVz2gKWmbZN4ljXKJpbiLnQF9j1kTMfV8OM=
github.com/zijiren233/livelib v0.0.0-20230930062256-1d07bbddcefb/go.mod h1:C4OwAodQXpMLYiioqH4ANjZJD4+sFRK0nPbXNoYZU5k=
github.com/zijiren233/stream v0.5.1 h1:9SUwM/fpET6frtBRT5WZBHnan0Hyzkezk/P8N78cgZQ=
github.com/zijiren233/stream v0.5.1/go.mod h1:iIrOm3qgIepQFmptD/HDY+YzamSSzQOtPjpVcK7FCOw=
github.com/zijiren233/yaml-comment v0.2.0 h1:xGcmpFsjK+IIK1InHtl+rKxYVKQ9rne/aRP1gkczZt4=
github.com/zijiren233/yaml-comment v0.2.0/go.mod h1:jc/3jBkvi9BHafiFUoLKGL9U/X5hHnIpiNIT9QV/994=
go.uber.org/mock v0.3.0 h1:3mUxI1No2/60yUYax92Pt8eNOEecx2D3lcXZh2NEZJo=
go.uber.org/mock v0.3.0/go.mod h1:a6FSlNadKUHUa9IP5Vyt1zh4fC7uAwxMutEAscFbkZc=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.5.0 h1:jpGode6huXQxcskEIpOCvrU+tzo81b6+oFLUYXWtH/Y=
golang.org/x/arch v0.5.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0 h1:mvySKfSWJ+UKUii46M40LOvyWfN0s2U+46/jDd0e6Ck=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g=
golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0 h1:rmsUpXtvNzj340zd98LZ4KntptpfRHwpFOHG188oHXc=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201202161906-c7110b5ffcbb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0 h1:ugBLEUaxABaB5AJqW9enI0ACdci2RUd4eP51NTBvuJ8=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
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.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0 h1:Iey4qkscZuv0VvIt8E0neZjtPVQFSc870HQ448QgEmQ=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
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/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/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=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=

View File

@ -0,0 +1,93 @@
package bootstrap
import (
"errors"
"path/filepath"
"github.com/caarlos0/env/v9"
"github.com/mitchellh/go-homedir"
log "github.com/sirupsen/logrus"
"github.com/synctv-org/synctv/cmd/flags"
"github.com/synctv-org/synctv/internal/conf"
"github.com/synctv-org/synctv/utils"
)
func InitConfig() {
if flags.SkipConfig && flags.SkipEnv {
log.Fatal("skip config and skip env at the same time")
return
}
conf.Conf = conf.DefaultConfig()
if !flags.SkipConfig {
if flags.ConfigFile == "" {
homeDir, err := homedir.Dir()
if err != nil {
log.Fatalf("find home dir error: %v", err)
}
flags.ConfigFile = filepath.Join(homeDir, ".config", "synctv", "config.yaml")
} else {
fileAbs, err := filepath.Abs(flags.ConfigFile)
if err != nil {
log.Fatalf("get config file abs path error: %v", err)
}
flags.ConfigFile = fileAbs
}
err := confFromConfig(flags.ConfigFile, conf.Conf)
if err != nil {
log.Fatalf("load config from file error: %v", err)
}
log.Infof("load config success from file: %s", flags.ConfigFile)
if err = restoreConfig(flags.ConfigFile, conf.Conf); err != nil {
log.Warnf("restore config error: %v", err)
} else {
log.Info("restore config success")
}
}
if !flags.SkipEnv {
prefix := "SYNCTV_"
if flags.EnvNoPrefix {
prefix = ""
log.Info("load config from env without prefix")
} else {
log.Infof("load config from env with prefix: %s", prefix)
}
err := confFromEnv(prefix, conf.Conf)
if err != nil {
log.Fatalf("load config from env error: %v", err)
}
log.Info("load config success from env")
}
}
func confFromConfig(filePath string, conf *conf.Config) error {
if filePath == "" {
return errors.New("config file path is empty")
}
if !utils.Exists(filePath) {
log.Infof("config file not exists, create new config file: %s", filePath)
err := conf.Save(filePath)
if err != nil {
return err
}
} else {
err := utils.ReadYaml(filePath, conf)
if err != nil {
return err
}
}
return nil
}
func restoreConfig(filePath string, conf *conf.Config) error {
if filePath == "" {
return errors.New("config file path is empty")
}
return conf.Save(filePath)
}
func confFromEnv(prefix string, conf *conf.Config) error {
return env.ParseWithOptions(conf, env.Options{
Prefix: prefix,
})
}

63
internal/bootstrap/log.go Normal file
View File

@ -0,0 +1,63 @@
package bootstrap
import (
"io"
"log"
"os"
"github.com/natefinch/lumberjack"
"github.com/sirupsen/logrus"
"github.com/synctv-org/synctv/cmd/flags"
"github.com/synctv-org/synctv/internal/conf"
"github.com/zijiren233/go-colorable"
)
func setLog(l *logrus.Logger) {
if flags.Dev {
l.SetLevel(logrus.DebugLevel)
l.SetReportCaller(true)
} else {
l.SetLevel(logrus.InfoLevel)
l.SetReportCaller(false)
}
}
func InitLog() {
setLog(logrus.StandardLogger())
logConfig := conf.Conf.Log
if logConfig.Enable {
var l = &lumberjack.Logger{
Filename: logConfig.FilePath,
MaxSize: logConfig.MaxSize,
MaxBackups: logConfig.MaxBackups,
MaxAge: logConfig.MaxAge,
Compress: logConfig.Compress,
}
if err := l.Rotate(); err != nil {
logrus.Fatalf("log: rotate log file error: %v", err)
}
var w io.Writer = colorable.NewNonColorableWriter(l)
if flags.Dev || flags.LogStd {
w = io.MultiWriter(os.Stdout, w)
} else {
logrus.Infof("log: disable log to stdout, only log to file: %s", logConfig.FilePath)
}
logrus.SetOutput(w)
}
switch conf.Conf.Log.LogFormat {
case "json":
logrus.SetFormatter(&logrus.JSONFormatter{})
default:
if conf.Conf.Log.LogFormat != "text" {
logrus.Warnf("unknown log format: %s, use default: text", conf.Conf.Log.LogFormat)
}
if colorable.IsTerminal(os.Stdout.Fd()) {
logrus.SetFormatter(&logrus.TextFormatter{
ForceColors: true,
})
} else {
logrus.SetFormatter(&logrus.TextFormatter{})
}
}
log.SetOutput(logrus.StandardLogger().Out)
}

View File

@ -0,0 +1,72 @@
package bootstrap
import (
"errors"
"os"
"os/signal"
"sync"
"syscall"
log "github.com/sirupsen/logrus"
"github.com/zijiren233/gencontainer/pqueue"
)
var (
c chan os.Signal
notifyTaskLock sync.Mutex
notifyTaskQueue = pqueue.NewMaxPriorityQueue[*SysNotifyTask]()
WaitCbk func()
)
type SysNotifyTask struct {
Task func() error
Name string
}
func NewSysNotifyTask(name string, task func() error) *SysNotifyTask {
return &SysNotifyTask{
Name: name,
Task: task,
}
}
func InitSysNotify() {
c = make(chan os.Signal, 1)
signal.Notify(c, syscall.SIGHUP /*1*/, syscall.SIGINT /*2*/, syscall.SIGQUIT /*3*/, syscall.SIGTERM /*15*/)
WaitCbk = sync.OnceFunc(waitCbk)
}
func waitCbk() {
log.Info("wait sys notify")
log.Infof("receive sys notify: %v", <-c)
notifyTaskLock.Lock()
defer notifyTaskLock.Unlock()
log.Infof("task: running...")
for notifyTaskQueue.Len() > 0 {
_, task := notifyTaskQueue.Pop()
func() {
defer func() {
if err := recover(); err != nil {
log.Errorf("task: %s panic has returned: %v", task.Name, err)
}
}()
log.Infof("task: %s running", task.Name)
if err := task.Task(); err != nil {
log.Errorf("task: %s an error occurred: %v", task.Name, err)
}
log.Infof("task: %s done", task.Name)
}()
}
log.Info("task: all done")
}
func RegisterSysNotifyTask(priority int, task *SysNotifyTask) error {
if task == nil || task.Task == nil {
return errors.New("task is nil")
}
notifyTaskLock.Lock()
defer notifyTaskLock.Unlock()
notifyTaskQueue.Push(priority, task)
return nil
}

51
internal/conf/config.go Normal file
View File

@ -0,0 +1,51 @@
package conf
import (
"github.com/synctv-org/synctv/utils"
)
type Config struct {
// Global
Global GlobalConfig `yaml:"global"`
// Log
Log LogConfig `yaml:"log"`
// Server
Server ServerConfig `yaml:"server"`
// Jwt
Jwt JwtConfig `yaml:"jwt"`
// Rtmp
Rtmp RtmpConfig `yaml:"rtmp" hc:"you can use rtmp to publish live"`
// Proxy
Proxy ProxyConfig `yaml:"proxy" hc:"you can use proxy to proxy movie and live when custom headers or network is slow to connect to origin server"`
}
func (c *Config) Save(file string) error {
return utils.WriteYaml(file, c)
}
func DefaultConfig() *Config {
return &Config{
// Global
Global: DefaultGlobalConfig(),
// Log
Log: DefaultLogConfig(),
// Server
Server: DefaultServerConfig(),
// Jwt
Jwt: DefaultJwtConfig(),
// Rtmp
Rtmp: DefaultRtmpConfig(),
// Proxy
Proxy: DefaultProxyConfig(),
}
}

8
internal/conf/global.go Normal file
View File

@ -0,0 +1,8 @@
package conf
type GlobalConfig struct {
}
func DefaultGlobalConfig() GlobalConfig {
return GlobalConfig{}
}

17
internal/conf/jwt.go Normal file
View File

@ -0,0 +1,17 @@
package conf
import (
"github.com/synctv-org/synctv/utils"
)
type JwtConfig struct {
Secret string `yaml:"secret" lc:"jwt secret (default rand string)" env:"JWT_SECRET"`
Expire int `yaml:"expire" lc:"expire time (default: 12 hour)" env:"JWT_EXPIRE"`
}
func DefaultJwtConfig() JwtConfig {
return JwtConfig{
Secret: utils.RandString(32),
Expire: 12,
}
}

23
internal/conf/log.go Normal file
View File

@ -0,0 +1,23 @@
package conf
type LogConfig struct {
Enable bool `yaml:"enable" lc:"enable log to file (default: true)" env:"LOG_ENABLE"`
LogFormat string `yaml:"log_format" lc:"log format, can be set: text | json (default: text)" env:"LOG_FORMAT"`
FilePath string `yaml:"file_path" lc:"log file path (default: log/log.log)" env:"LOG_FILE_PATH"`
MaxSize int `yaml:"max_size" lc:"max size per log file (default: 10 megabytes)" env:"LOG_MAX_SIZE"`
MaxBackups int `yaml:"max_backups" lc:"max backups (default: 10)" env:"LOG_MAX_BACKUPS"`
MaxAge int `yaml:"max_age" lc:"max age (default: 28 days)" env:"LOG_MAX_AGE"`
Compress bool `yaml:"compress" lc:"compress (default: false)" env:"LOG_COMPRESS"`
}
func DefaultLogConfig() LogConfig {
return LogConfig{
Enable: true,
LogFormat: "text",
FilePath: "log/log.log",
MaxSize: 10,
MaxBackups: 10,
MaxAge: 28,
Compress: false,
}
}

13
internal/conf/proxy.go Normal file
View File

@ -0,0 +1,13 @@
package conf
type ProxyConfig struct {
MovieProxy bool `yaml:"movie_proxy" lc:"enable movie proxy (default: true)" env:"PROXY_MOVIE_PROXY"`
LiveProxy bool `yaml:"live_proxy" lc:"enable live proxy (default: true)" env:"PROXY_LIVE_PROXY"`
}
func DefaultProxyConfig() ProxyConfig {
return ProxyConfig{
MovieProxy: true,
LiveProxy: true,
}
}

20
internal/conf/rtmp.go Normal file
View File

@ -0,0 +1,20 @@
package conf
type RtmpConfig struct {
Enable bool `yaml:"enable" lc:"enable rtmp server (default: true)" env:"RTMP_ENABLE"`
Port uint16 `yaml:"port" lc:"rtmp server port (default use server port)" env:"RTMP_PORT"`
CustomPublishHost string `yaml:"custom_publish_host" lc:"publish host (default use http header host)" env:"RTMP_CUSTOM_PUBLISH_HOST"`
RtmpPlayer bool `yaml:"rtmp_player" lc:"enable rtmp player (default: false)" env:"RTMP_PLAYER"`
HlsPlayer bool `yaml:"hls_player" lc:"enable hls player (default: false)" env:"HLS_PLAYER"`
}
func DefaultRtmpConfig() RtmpConfig {
return RtmpConfig{
Enable: true,
Port: 0,
CustomPublishHost: "",
RtmpPlayer: false,
HlsPlayer: false,
}
}

20
internal/conf/server.go Normal file
View File

@ -0,0 +1,20 @@
package conf
type ServerConfig struct {
Listen string `yaml:"listen" lc:"server listen addr" env:"SERVER_LISTEN"`
Port uint16 `yaml:"port" lc:"server listen port" env:"SERVER_PORT"`
Quic bool `yaml:"quic" lc:"enable http3/quic, need enable ssl, set cert and key file (default: true)" env:"SERVER_QUIC"`
CertPath string `yaml:"cert_path" lc:"cert path" env:"SERVER_CERT_PATH"`
KeyPath string `yaml:"key_path" lc:"key path" env:"SERVER_KEY_PATH"`
}
func DefaultServerConfig() ServerConfig {
return ServerConfig{
Listen: "127.0.0.1",
Port: 8080,
Quic: true,
CertPath: "",
KeyPath: "",
}
}

5
internal/conf/var.go Normal file
View File

@ -0,0 +1,5 @@
package conf
var (
Conf *Config
)

7
main.go Normal file
View File

@ -0,0 +1,7 @@
package main
import "github.com/synctv-org/synctv/cmd"
func main() {
cmd.Execute()
}

0
public/dist/.gitkeep vendored Normal file
View File

11
public/public.go Normal file
View File

@ -0,0 +1,11 @@
package public
import (
"embed"
"io/fs"
)
//go:embed dist/*
var dist embed.FS
var Public, _ = fs.Sub(dist, "dist")

90
room/client.go Normal file
View File

@ -0,0 +1,90 @@
package room
import (
"errors"
"io"
"sync"
"sync/atomic"
"time"
"github.com/gorilla/websocket"
)
type Client struct {
user *User
c chan Message
wg sync.WaitGroup
conn *websocket.Conn
timeOut time.Duration
closed uint64
}
func NewClient(user *User, conn *websocket.Conn) (*Client, error) {
if user == nil {
return nil, errors.New("user is nil")
}
if conn == nil {
return nil, errors.New("conn is nil")
}
return &Client{
user: user,
c: make(chan Message, 128),
conn: conn,
timeOut: 10 * time.Second,
}, nil
}
func (c *Client) User() *User {
return c.user
}
func (c *Client) Username() string {
return c.user.name
}
func (c *Client) Room() *Room {
return c.user.room
}
func (c *Client) Broadcast(msg Message, conf ...BroadcastConf) {
c.user.Broadcast(msg, conf...)
}
func (c *Client) Send(msg Message) error {
c.wg.Add(1)
defer c.wg.Done()
if c.Closed() {
return ErrAlreadyClosed
}
c.c <- msg
return nil
}
func (c *Client) Unregister() error {
return c.user.room.UnRegClient(c.user)
}
func (c *Client) Close() error {
if !atomic.CompareAndSwapUint64(&c.closed, 0, 1) {
return ErrAlreadyClosed
}
c.wg.Wait()
close(c.c)
return nil
}
func (c *Client) Closed() bool {
return atomic.LoadUint64(&c.closed) == 1
}
func (c *Client) GetReadChan() <-chan Message {
return c.c
}
func (c *Client) NextWriter(messageType int) (io.WriteCloser, error) {
return c.conn.NextWriter(messageType)
}
func (c *Client) NextReader() (int, io.Reader, error) {
return c.conn.NextReader()
}

103
room/current.go Normal file
View File

@ -0,0 +1,103 @@
package room
import (
"sync"
"time"
json "github.com/json-iterator/go"
)
type Current struct {
movie MovieInfo
status Status
lock *sync.RWMutex
}
func newCurrent() *Current {
return &Current{
movie: MovieInfo{},
status: newStatus(),
lock: new(sync.RWMutex),
}
}
type Status struct {
Seek float64 `json:"seek"`
Rate float64 `json:"rate"`
Playing bool `json:"playing"`
lastUpdateTime time.Time
}
func newStatus() Status {
return Status{
Seek: 0,
Rate: 1.0,
lastUpdateTime: time.Now(),
}
}
func (c *Current) MarshalJSON() ([]byte, error) {
c.lock.RLock()
defer c.lock.RUnlock()
c.updateSeek()
return json.Marshal(map[string]interface{}{
"movie": c.movie,
"status": c.status,
})
}
func (c *Current) Movie() MovieInfo {
c.lock.RLock()
defer c.lock.RUnlock()
return c.movie
}
func (c *Current) SetMovie(movie MovieInfo) {
c.lock.Lock()
defer c.lock.Unlock()
c.movie = movie
c.status.Seek = 0
}
func (c *Current) Status() Status {
c.lock.RLock()
defer c.lock.RUnlock()
c.updateSeek()
return c.status
}
func (c *Current) updateSeek() {
if c.status.Playing {
c.status.Seek += time.Since(c.status.lastUpdateTime).Seconds() * c.status.Rate
}
c.status.lastUpdateTime = time.Now()
}
func (c *Current) SetStatus(playing bool, seek, rate, timeDiff float64) Status {
c.lock.Lock()
defer c.lock.Unlock()
c.status.Playing = playing
c.status.Rate = rate
if playing {
c.status.Seek = seek + (timeDiff * rate)
} else {
c.status.Seek = seek
}
c.status.lastUpdateTime = time.Now()
return c.status
}
func (c *Current) SetSeekRate(seek, rate, timeDiff float64) Status {
c.lock.Lock()
defer c.lock.Unlock()
if c.status.Playing {
c.status.Seek = seek + (timeDiff * rate)
} else {
c.status.Seek = seek
}
c.status.Rate = rate
c.status.lastUpdateTime = time.Now()
return c.status
}

180
room/hub.go Normal file
View File

@ -0,0 +1,180 @@
package room
import (
"errors"
"fmt"
"sync"
"sync/atomic"
"github.com/gorilla/websocket"
log "github.com/sirupsen/logrus"
"github.com/synctv-org/synctv/cmd/flags"
"github.com/synctv-org/synctv/utils"
"github.com/zijiren233/gencontainer/rwmap"
)
type hub struct {
id string
clientNum int64
clients *rwmap.RWMap[string, *Client]
broadcast chan *broadcastMessage
exit chan struct{}
closed uint32
wg sync.WaitGroup
}
type broadcastMessage struct {
data Message
sender string
sendToSelf bool
ignoreID []string
}
type BroadcastConf func(*broadcastMessage)
func WithSender(sender string) BroadcastConf {
return func(bm *broadcastMessage) {
bm.sender = sender
}
}
func WithSendToSelf() BroadcastConf {
return func(bm *broadcastMessage) {
bm.sendToSelf = true
}
}
func WithIgnoreID(id ...string) BroadcastConf {
return func(bm *broadcastMessage) {
bm.ignoreID = append(bm.ignoreID, id...)
}
}
func newHub(id string) *hub {
return &hub{
id: id,
broadcast: make(chan *broadcastMessage, 128),
clients: &rwmap.RWMap[string, *Client]{},
exit: make(chan struct{}),
}
}
func (h *hub) Closed() bool {
return atomic.LoadUint32(&h.closed) == 1
}
var (
ErrAlreadyClosed = fmt.Errorf("already closed")
)
func (h *hub) Start() {
go h.Serve()
}
func (h *hub) Serve() error {
if h.Closed() {
return ErrAlreadyClosed
}
for {
select {
case message := <-h.broadcast:
h.devMessage(message.data)
h.clients.Range(func(_ string, cli *Client) bool {
if !message.sendToSelf {
if cli.user.name == message.sender {
return true
}
}
if utils.In(message.ignoreID, cli.user.name) {
return true
}
if err := cli.Send(message.data); err != nil {
if flags.Dev {
log.Errorf("hub: %s, write to client err: %s\nmessage: %+v", h.id, err, message)
}
cli.Close()
}
return true
})
case <-h.exit:
log.Infof("hub: %s, closed", h.id)
return nil
}
}
}
func (h *hub) devMessage(msg Message) {
if flags.Dev {
switch msg.MessageType() {
case websocket.TextMessage:
log.Infof("hub: %s, broadcast:\nmessage: %+v", h.id, msg.String())
}
}
}
func (h *hub) Close() error {
if !atomic.CompareAndSwapUint32(&h.closed, 0, 1) {
return ErrAlreadyClosed
}
close(h.exit)
h.clients.Range(func(_ string, client *Client) bool {
client.Close()
return true
})
h.wg.Wait()
close(h.broadcast)
return nil
}
func (h *hub) Broadcast(data Message, conf ...BroadcastConf) error {
h.wg.Add(1)
defer h.wg.Done()
if h.Closed() {
return ErrAlreadyClosed
}
msg := &broadcastMessage{data: data}
for _, c := range conf {
c(msg)
}
select {
case h.broadcast <- msg:
return nil
case <-h.exit:
return ErrAlreadyClosed
}
}
func (h *hub) RegClient(user *User, conn *websocket.Conn) (*Client, error) {
if h.Closed() {
return nil, ErrAlreadyClosed
}
cli, err := NewClient(user, conn)
if err != nil {
return nil, err
}
c, loaded := h.clients.LoadOrStore(user.name, cli)
if loaded {
return nil, errors.New("client already registered")
}
atomic.AddInt64(&h.clientNum, 1)
return c, nil
}
func (h *hub) UnRegClient(user *User) error {
if h.Closed() {
return ErrAlreadyClosed
}
if user == nil {
return errors.New("user is nil")
}
_, loaded := h.clients.LoadAndDelete(user.name)
if !loaded {
return errors.New("client not found")
}
atomic.AddInt64(&h.clientNum, -1)
return nil
}
func (h *hub) ClientNum() int64 {
return atomic.LoadInt64(&h.clientNum)
}

98
room/message.go Normal file
View File

@ -0,0 +1,98 @@
package room
import (
"encoding/json"
"fmt"
"io"
"github.com/gorilla/websocket"
"gopkg.in/yaml.v3"
)
type Message interface {
MessageType() int
String() string
Encode(wc io.Writer) error
}
type ElementMessageType int
const (
Error ElementMessageType = iota + 1
ChatMessage
Play
Pause
CheckSeek
TooFast
TooSlow
ChangeRate
ChangeSeek
ChangeCurrent
ChangeMovieList
ChangePeopleNum
)
type ElementMessage struct {
Type ElementMessageType `json:"type" yaml:"type"`
Sender string `json:"sender,omitempty" yaml:"sender,omitempty"`
Message string `json:"message,omitempty" yaml:"message,omitempty"`
Seek float64 `json:"seek,omitempty" yaml:"seek,omitempty"`
Rate float64 `json:"rate,omitempty" yaml:"rate,omitempty"`
Current *Current `json:"current,omitempty" yaml:"current,omitempty"`
PeopleNum int64 `json:"peopleNum,omitempty" yaml:"peopleNum,omitempty"`
Time int64 `json:"time,omitempty" yaml:"time,omitempty"`
}
func (em *ElementMessage) MessageType() int {
return websocket.TextMessage
}
func (em *ElementMessage) String() string {
out, _ := yaml.Marshal(em)
switch em.Type {
case Error:
return fmt.Sprintf("Element Error: %s", out)
case ChatMessage:
return fmt.Sprintf("Element ChatMessage: %s", out)
case Play:
return fmt.Sprintf("Element Play: %s", out)
case Pause:
return fmt.Sprintf("Element Pause: %s", out)
case CheckSeek:
return fmt.Sprintf("Element CheckSeek: %s", out)
case TooFast:
return fmt.Sprintf("Element TooFast: %s", out)
case TooSlow:
return fmt.Sprintf("Element TooSlow: %s", out)
case ChangeRate:
return fmt.Sprintf("Element ChangeRate: %s", out)
case ChangeSeek:
return fmt.Sprintf("Element ChangeSeek: %s", out)
case ChangeCurrent:
return fmt.Sprintf("Element ChangeCurrent: %s", out)
case ChangeMovieList:
return fmt.Sprintf("Element ChangeMovieList: %s", out)
case ChangePeopleNum:
return fmt.Sprintf("Element ChangePeopleNum: %s", out)
default:
return fmt.Sprintf("Element Unknown: %s", out)
}
}
func (em *ElementMessage) Encode(wc io.Writer) error {
return json.NewEncoder(wc).Encode(em)
}
type PingMessage struct{}
func (pm *PingMessage) MessageType() int {
return websocket.PingMessage
}
func (pm *PingMessage) String() string {
return "Ping"
}
func (pm *PingMessage) Encode(wc io.Writer) error {
return nil
}

347
room/movies.go Normal file
View File

@ -0,0 +1,347 @@
package room
import (
"fmt"
"net/url"
"sync"
"time"
"github.com/zijiren233/gencontainer/dllist"
rtmps "github.com/zijiren233/livelib/server"
)
type FormatErrMovieNotFound uint64
func (e FormatErrMovieNotFound) Error() string {
return fmt.Sprintf("movie id: %v not found", uint64(e))
}
type FormatErrMovieAlreadyExist uint64
func (e FormatErrMovieAlreadyExist) Error() string {
return fmt.Sprintf("movie id: %v already exist", uint64(e))
}
type movies struct {
l *dllist.Dllist[*Movie]
lock *sync.RWMutex
}
// Url will be `PullKey` when Live and Proxy are true
type Movie struct {
BaseMovie
PullKey string `json:"pullKey"`
CreateAt int64 `json:"createAt"`
LastEditAt int64 `json:"lastEditAt"`
id uint64
channel *rtmps.Channel
creator *User
}
type BaseMovie struct {
Url string `json:"url"`
Name string `json:"name"`
Live bool `json:"live"`
Proxy bool `json:"proxy"`
RtmpSource bool `json:"rtmpSource"`
Type string `json:"type"`
Headers map[string][]string `json:"headers"`
}
type MovieConf func(m *Movie)
func WithPullKey(PullKey string) MovieConf {
return func(m *Movie) {
m.PullKey = PullKey
}
}
func WithChannel(channel *rtmps.Channel) MovieConf {
return func(m *Movie) {
m.channel = channel
}
}
func WithCreator(creator *User) MovieConf {
return func(m *Movie) {
m.creator = creator
}
}
func NewMovie(id uint64, url, name, type_ string, live, proxy, rtmpSource bool, headers map[string][]string, conf ...MovieConf) (*Movie, error) {
return NewMovieWithBaseMovie(id, BaseMovie{
Url: url,
Name: name,
Live: live,
Proxy: proxy,
RtmpSource: rtmpSource,
Type: type_,
Headers: headers,
})
}
func NewMovieWithBaseMovie(id uint64, baseMovie BaseMovie, conf ...MovieConf) (*Movie, error) {
now := time.Now().UnixMicro()
m := &Movie{
id: id,
BaseMovie: baseMovie,
CreateAt: now,
LastEditAt: now,
}
m.Init(conf...)
return m, m.Check()
}
func (m *Movie) Check() error {
_, err := url.Parse(m.Url)
if err != nil {
return err
}
return nil
}
func (m *Movie) Id() uint64 {
return m.id
}
func (m *Movie) Init(conf ...MovieConf) {
for _, c := range conf {
c(m)
}
}
func (m *Movie) Creator() *User {
return m.creator
}
func (m *Movie) SetCreator(creator *User) {
m.creator = creator
}
func (m *Movie) Channel() *rtmps.Channel {
return m.channel
}
func (m *Movie) SetChannel(channel *rtmps.Channel) {
m.channel = channel
}
func newMovies() *movies {
return &movies{l: dllist.New[*Movie](), lock: &sync.RWMutex{}}
}
func (m *movies) Range(f func(e *dllist.Element[*Movie]) bool) (interrupt bool) {
m.lock.RLock()
defer m.lock.RUnlock()
interrupt = false
for e := m.l.Front(); !interrupt && e != nil; e = e.Next() {
interrupt = !f(e)
}
return
}
func (m *movies) range_(f func(e *dllist.Element[*Movie]) bool) (interrupt bool) {
interrupt = false
for e := m.l.Front(); !interrupt && e != nil; e = e.Next() {
interrupt = !f(e)
}
return
}
type MovieInfo struct {
Id uint64 `json:"id"`
Url string `json:"url"`
Name string `json:"name"`
Live bool `json:"live"`
Proxy bool `json:"proxy"`
RtmpSource bool `json:"rtmpSource"`
Type string `json:"type"`
Headers map[string][]string `json:"headers"`
PullKey string `json:"pullKey"`
CreateAt int64 `json:"createAt"`
LastEditAt int64 `json:"lastEditAt"`
Creator string `json:"creator"`
}
func (m *movies) MovieList() (movies []MovieInfo) {
m.lock.RLock()
defer m.lock.RUnlock()
movies = make([]MovieInfo, 0, m.l.Len())
m.range_(func(e *dllist.Element[*Movie]) bool {
movies = append(movies, MovieInfo{
Id: e.Value.id,
Url: e.Value.Url,
Name: e.Value.Name,
Live: e.Value.Live,
Proxy: e.Value.Proxy,
RtmpSource: e.Value.RtmpSource,
Type: e.Value.Type,
Headers: e.Value.Headers,
CreateAt: e.Value.CreateAt,
LastEditAt: e.Value.LastEditAt,
Creator: e.Value.Creator().Name(),
})
return true
})
return
}
func (m *movies) Clear() error {
m.lock.Lock()
defer m.lock.Unlock()
m.l.Clear()
return nil
}
func (m *movies) GetAndClear() (movies []*Movie) {
m.lock.Lock()
defer m.lock.Unlock()
movies = make([]*Movie, 0, m.l.Len())
m.range_(func(e *dllist.Element[*Movie]) bool {
movies = append(movies, e.Value)
return true
})
m.l.Clear()
return
}
func (m *movies) HasMovie(id uint64) bool {
m.lock.RLock()
defer m.lock.RUnlock()
return m.hasMovie(id)
}
func (m *movies) hasMovie(id uint64) bool {
return m.range_(func(e *dllist.Element[*Movie]) bool {
return e.Value.id != id
})
}
func (m *movies) GetMovie(id uint64) (movie *Movie, err error) {
m.lock.RLock()
defer m.lock.RUnlock()
e, err := m.getMovie(id)
if err != nil {
return nil, FormatErrMovieNotFound(id)
}
return e.Value, nil
}
func (m *movies) GetMovieWithPullKey(pullKey string) (movie *Movie, err error) {
if pullKey == "" {
return nil, fmt.Errorf("pullKey is empty")
}
m.lock.RLock()
defer m.lock.RUnlock()
m.range_(func(e *dllist.Element[*Movie]) bool {
if e.Value.PullKey == pullKey {
movie = e.Value
return false
}
return true
})
if movie == nil {
return nil, fmt.Errorf("pullKey: %v not found", pullKey)
}
return
}
func (m *movies) GetAndDelMovie(id ...uint64) (movies []*Movie, err error) {
m.lock.Lock()
defer m.lock.Unlock()
es := make([]*dllist.Element[*Movie], 0, len(id))
for _, v := range id {
e, err := m.getMovie(v)
if err != nil {
return nil, FormatErrMovieNotFound(v)
}
es = append(es, e)
}
movies = make([]*Movie, 0, len(es))
for _, e := range es {
movies = append(movies, e.Value)
e.Remove()
}
return movies, nil
}
func (m *movies) getMovie(id uint64) (element *dllist.Element[*Movie], err error) {
err = FormatErrMovieNotFound(id)
m.range_(func(e *dllist.Element[*Movie]) bool {
if e.Value.id == id {
element = e
err = nil
return false
}
return true
})
return
}
func (m *movies) PushBackMovie(movie *Movie) error {
m.lock.Lock()
defer m.lock.Unlock()
if m.hasMovie(movie.id) {
return FormatErrMovieAlreadyExist(movie.id)
}
m.l.PushBack(movie)
return nil
}
func (m *movies) PushFrontMovie(movie *Movie) error {
m.lock.Lock()
defer m.lock.Unlock()
if m.hasMovie(movie.id) {
return FormatErrMovieAlreadyExist(movie.id)
}
m.l.PushFront(movie)
return nil
}
func (m *movies) DelMovie(id uint64) error {
m.lock.Lock()
defer m.lock.Unlock()
e, err := m.getMovie(id)
if err != nil {
return err
}
e.Remove()
return nil
}
func (m *movies) SwapMovie(id1, id2 uint64) error {
m.lock.Lock()
defer m.lock.Unlock()
e1, err := m.getMovie(id1)
if err != nil {
return err
}
e2, err := m.getMovie(id2)
if err != nil {
return err
}
m.l.Swap(e1, e2)
return nil
}

412
room/room.go Normal file
View File

@ -0,0 +1,412 @@
package room
import (
"errors"
"math/rand"
"sync"
"sync/atomic"
"time"
"github.com/gorilla/websocket"
"github.com/zijiren233/gencontainer/rwmap"
rtmps "github.com/zijiren233/livelib/server"
"github.com/zijiren233/stream"
"golang.org/x/crypto/bcrypt"
)
var (
ErrRoomIDEmpty = errors.New("roomid is empty")
ErrAdminPassWordEmpty = errors.New("admin password is empty")
)
type Room struct {
id string
lock *sync.RWMutex
password []byte
needPassword bool
version uint64
current *Current
maxInactivityTime time.Duration
lastActive time.Time
rtmps *rtmps.Server
rtmpa *rtmps.App
hidden bool
timer *time.Timer
inited bool
users *rwmap.RWMap[string, *User]
rootUser *User
createAt time.Time
mid uint64
*movies
*hub
}
type RoomConf func(r *Room)
func WithVersion(version uint64) RoomConf {
return func(r *Room) {
r.version = version
}
}
func WithMaxInactivityTime(maxInactivityTime time.Duration) RoomConf {
return func(r *Room) {
r.maxInactivityTime = maxInactivityTime
}
}
func WithHidden(hidden bool) RoomConf {
return func(r *Room) {
r.hidden = hidden
}
}
func WithRootUser(u *User) RoomConf {
return func(r *Room) {
u.admin = true
u.room = r
r.rootUser = u
r.AddUser(u)
}
}
// Version cant is 0
func NewRoom(RoomID string, Password string, rtmps *rtmps.Server, conf ...RoomConf) (*Room, error) {
if RoomID == "" {
return nil, ErrRoomIDEmpty
}
r := &Room{
id: RoomID,
lock: new(sync.RWMutex),
movies: newMovies(),
current: newCurrent(),
maxInactivityTime: 12 * time.Hour,
lastActive: time.Now(),
hub: newHub(RoomID),
rtmps: rtmps,
users: &rwmap.RWMap[string, *User]{},
createAt: time.Now(),
}
for _, c := range conf {
c(r)
}
if r.version == 0 {
r.version = rand.New(rand.NewSource(time.Now().UnixNano())).Uint64()
}
return r, r.SetPassword(Password)
}
func (r *Room) CreateAt() time.Time {
return r.createAt
}
func (r *Room) RootUser() *User {
return r.rootUser
}
func (r *Room) SetRootUser(u *User) {
r.rootUser = u
}
func (r *Room) NewUser(id string, password string, conf ...UserConf) (*User, error) {
u, err := NewUser(id, password, r, conf...)
if err != nil {
return nil, err
}
_, loaded := r.users.LoadOrStore(u.name, u)
if loaded {
return nil, errors.New("user already exist")
}
return u, nil
}
func (r *Room) AddUser(u *User) error {
_, loaded := r.users.LoadOrStore(u.name, u)
if loaded {
return errors.New("user already exist")
}
return nil
}
func (r *Room) GetUser(id string) (*User, error) {
u, ok := r.users.Load(id)
if !ok {
return nil, errors.New("user not found")
}
return u, nil
}
func (r *Room) DelUser(id string) error {
_, ok := r.users.LoadAndDelete(id)
if !ok {
return errors.New("user not found")
}
return nil
}
func (r *Room) GetAndDelUser(id string) (u *User, ok bool) {
return r.users.LoadAndDelete(id)
}
func (r *Room) GetOrNewUser(id string, password string, conf ...UserConf) (*User, error) {
u, err := NewUser(id, password, r, conf...)
if err != nil {
return nil, err
}
user, _ := r.users.LoadOrStore(u.name, u)
return user, nil
}
func (r *Room) UserList() (users []User) {
users = make([]User, 0, r.users.Len())
r.users.Range(func(name string, u *User) bool {
users = append(users, *u)
return true
})
return
}
func (r *Room) NewLiveChannel(channel string) (*rtmps.Channel, error) {
c, err := r.rtmpa.NewChannel(channel)
if err != nil {
return nil, err
}
return c, nil
}
func (r *Room) Start() {
go r.Serve()
}
func (r *Room) Serve() {
r.init()
r.hub.Serve()
}
func (r *Room) init() {
r.lock.Lock()
defer r.lock.Unlock()
if r.inited {
return
}
r.inited = true
if r.maxInactivityTime != 0 {
r.timer = time.AfterFunc(time.Duration(r.maxInactivityTime), func() {
r.Close()
})
}
r.rtmpa = r.rtmps.GetOrNewApp(r.id)
}
func (r *Room) Close() error {
r.lock.Lock()
defer r.lock.Unlock()
if err := r.hub.Close(); err != nil {
return err
}
err := r.rtmps.DelApp(r.id)
if err != nil {
return err
}
if r.timer != nil {
r.timer.Stop()
}
return nil
}
func (r *Room) SetHidden(hidden bool) {
r.lock.Lock()
defer r.lock.Unlock()
r.hidden = hidden
}
func (r *Room) Hidden() bool {
r.lock.RLock()
defer r.lock.RUnlock()
return r.hidden
}
func (r *Room) ID() string {
return r.id
}
func (r *Room) UpdateActiveTime() {
r.lock.Lock()
defer r.lock.Unlock()
r.updateActiveTime()
}
func (r *Room) updateActiveTime() {
if r.maxInactivityTime != 0 {
r.timer.Reset(r.maxInactivityTime)
}
r.lastActive = time.Now()
}
func (r *Room) ResetMaxInactivityTime(maxInactivityTime time.Duration) {
r.lock.Lock()
defer r.lock.Unlock()
r.maxInactivityTime = maxInactivityTime
r.updateActiveTime()
}
func (r *Room) LateActiveTime() time.Time {
r.lock.RLock()
defer r.lock.RUnlock()
return r.lastActive
}
func (r *Room) SetPassword(password string) error {
r.lock.Lock()
defer r.lock.Unlock()
if password != "" {
b, err := bcrypt.GenerateFromPassword(stream.StringToBytes(password), bcrypt.DefaultCost)
if err != nil {
return err
}
r.password = b
r.needPassword = true
} else {
r.needPassword = false
}
r.updateVersion()
r.hub.clients.Range(func(_ string, value *Client) bool {
value.Close()
return true
})
return nil
}
func (r *Room) CheckPassword(password string) (ok bool) {
r.lock.RLock()
defer r.lock.RUnlock()
if !r.needPassword {
return true
}
return bcrypt.CompareHashAndPassword(r.password, stream.StringToBytes(password)) == nil
}
func (r *Room) NeedPassword() bool {
r.lock.RLock()
defer r.lock.RUnlock()
return r.needPassword
}
func (r *Room) Version() uint64 {
return atomic.LoadUint64(&r.version)
}
func (r *Room) CheckVersion(version uint64) bool {
return r.Version() == version
}
func (r *Room) SetVersion(version uint64) {
atomic.StoreUint64(&r.version, version)
}
func (r *Room) updateVersion() uint64 {
return atomic.AddUint64(&r.version, 1)
}
func (r *Room) Current() *Current {
return r.current
}
// Seek will be set to 0
func (r *Room) ChangeCurrentMovie(id uint64) error {
e, err := r.movies.getMovie(id)
if err != nil {
return err
}
r.current.SetMovie(MovieInfo{
Id: e.Value.id,
Url: e.Value.Url,
Name: e.Value.Name,
Live: e.Value.Live,
Proxy: e.Value.Proxy,
RtmpSource: e.Value.RtmpSource,
Type: e.Value.Type,
Headers: e.Value.Headers,
PullKey: e.Value.PullKey,
CreateAt: e.Value.CreateAt,
LastEditAt: e.Value.LastEditAt,
Creator: e.Value.Creator().Name(),
})
return nil
}
func (r *Room) SetStatus(playing bool, seek, rate, timeDiff float64) Status {
r.UpdateActiveTime()
return r.current.SetStatus(playing, seek, rate, timeDiff)
}
func (r *Room) SetSeekRate(seek, rate, timeDiff float64) Status {
r.UpdateActiveTime()
return r.current.SetSeekRate(seek, rate, timeDiff)
}
func (r *Room) PushBackMovie(movie *Movie) error {
if r.hub.Closed() {
return ErrAlreadyClosed
}
return r.movies.PushBackMovie(movie)
}
func (r *Room) PushFrontMovie(movie *Movie) error {
if r.hub.Closed() {
return ErrAlreadyClosed
}
return r.movies.PushFrontMovie(movie)
}
func (r *Room) DelMovie(id ...uint64) error {
if r.hub.Closed() {
return ErrAlreadyClosed
}
m, err := r.movies.GetAndDelMovie(id...)
if err != nil {
return err
}
return r.closeLive(m)
}
func (r *Room) ClearMovies() (err error) {
if r.hub.Closed() {
return ErrAlreadyClosed
}
return r.closeLive(r.movies.GetAndClear())
}
func (r *Room) closeLive(m []*Movie) error {
for _, m := range m {
if m.Live {
if err := r.rtmpa.DelChannel(m.PullKey); err != nil {
return err
}
}
}
return nil
}
func (r *Room) SwapMovie(id1, id2 uint64) error {
if r.hub.Closed() {
return ErrAlreadyClosed
}
return r.movies.SwapMovie(id1, id2)
}
func (r *Room) Broadcast(msg Message, conf ...BroadcastConf) error {
r.UpdateActiveTime()
return r.hub.Broadcast(msg, conf...)
}
func (r *Room) RegClient(user *User, conn *websocket.Conn) (*Client, error) {
r.updateActiveTime()
return r.hub.RegClient(user, conn)
}

219
room/user.go Normal file
View File

@ -0,0 +1,219 @@
package room
import (
"errors"
"math/rand"
"sync/atomic"
"time"
"github.com/gorilla/websocket"
"github.com/zijiren233/gencontainer/dllist"
"github.com/zijiren233/stream"
"golang.org/x/crypto/bcrypt"
)
type User struct {
room *Room
name string
password []byte
version uint64
admin bool
lastAct int64
}
var (
ErrUsernameEmpty = errors.New("user name is empty")
ErrUserPasswordEmpty = errors.New("user password is empty")
)
type UserConf func(*User)
func WithUserVersion(version uint64) UserConf {
return func(u *User) {
u.version = version
}
}
func WithUserAdmin(admin bool) UserConf {
return func(u *User) {
u.admin = admin
}
}
func NewUser(id string, password string, room *Room, conf ...UserConf) (*User, error) {
if id == "" {
return nil, ErrUsernameEmpty
}
if password == "" {
return nil, ErrUserPasswordEmpty
}
hashedPassword, err := bcrypt.GenerateFromPassword(stream.StringToBytes(password), bcrypt.DefaultCost)
if err != nil {
return nil, err
}
u := &User{
room: room,
name: id,
password: hashedPassword,
lastAct: time.Now().UnixMicro(),
}
for _, c := range conf {
c(u)
}
if u.version == 0 {
u.version = rand.New(rand.NewSource(time.Now().UnixNano())).Uint64()
}
return u, nil
}
func (u *User) LastAct() int64 {
return atomic.LoadInt64(&u.lastAct)
}
func (u *User) LastActTime() time.Time {
return time.UnixMicro(u.LastAct())
}
func (u *User) UpdateLastAct() int64 {
return atomic.SwapInt64(&u.lastAct, time.Now().UnixMicro())
}
func (u *User) Version() uint64 {
return atomic.LoadUint64(&u.version)
}
func (u *User) CheckVersion(version uint64) bool {
return u.Version() == version
}
func (u *User) SetVersion(version uint64) {
atomic.StoreUint64(&u.version, version)
}
func (u *User) updateVersion() uint64 {
return atomic.AddUint64(&u.version, 1)
}
func (u *User) CheckPassword(password string) bool {
err := bcrypt.CompareHashAndPassword(u.password, stream.StringToBytes(password))
return err == nil
}
func (u *User) SetPassword(password string) error {
hashedPassword, err := bcrypt.GenerateFromPassword(stream.StringToBytes(password), bcrypt.DefaultCost)
if err != nil {
return err
}
u.password = hashedPassword
u.updateVersion()
return nil
}
func (u *User) CloseHub() {
c, loaded := u.room.hub.clients.LoadAndDelete(u.name)
if loaded {
c.Close()
}
}
func (u *User) IsRoot() bool {
return u.room.rootUser == u
}
func (u *User) Name() string {
return u.name
}
func (u *User) Room() *Room {
return u.room
}
func (u *User) IsAdmin() bool {
return u.admin
}
func (u *User) SetAdmin(admin bool) {
u.admin = admin
}
func (u *User) NewMovie(url string, name string, type_ string, live bool, proxy bool, rtmpSource bool, headers map[string][]string, conf ...MovieConf) (*Movie, error) {
return u.NewMovieWithBaseMovie(BaseMovie{
Url: url,
Name: name,
Live: live,
Proxy: proxy,
RtmpSource: rtmpSource,
Type: type_,
Headers: headers,
}, conf...)
}
func (u *User) NewMovieWithBaseMovie(baseMovie BaseMovie, conf ...MovieConf) (*Movie, error) {
conf = append(conf, WithCreator(u))
return NewMovieWithBaseMovie(atomic.AddUint64(&u.room.mid, 1), baseMovie, conf...)
}
func (u *User) Movie(id uint64) (*MovieInfo, error) {
u.room.movies.lock.RLock()
defer u.room.movies.lock.RUnlock()
m, err := u.room.movies.GetMovie(id)
if err != nil {
return nil, err
}
movie := &MovieInfo{
Id: m.Id(),
Url: m.Url,
Name: m.Name,
Live: m.Live,
Proxy: m.Proxy,
RtmpSource: m.RtmpSource,
Type: m.Type,
Headers: m.Headers,
PullKey: m.PullKey,
CreateAt: m.CreateAt,
LastEditAt: m.LastEditAt,
Creator: m.Creator().Name(),
}
if movie.Proxy && u.name != movie.Creator {
m.Headers = nil
}
return movie, nil
}
func (u *User) MovieList() []*MovieInfo {
u.room.movies.lock.RLock()
defer u.room.movies.lock.RUnlock()
movies := make([]*MovieInfo, 0, u.room.movies.l.Len())
u.room.movies.range_(func(e *dllist.Element[*Movie]) bool {
m := &MovieInfo{
Id: e.Value.Id(),
Url: e.Value.Url,
Name: e.Value.Name,
Live: e.Value.Live,
Proxy: e.Value.Proxy,
RtmpSource: e.Value.RtmpSource,
Type: e.Value.Type,
Headers: e.Value.Headers,
PullKey: e.Value.PullKey,
CreateAt: e.Value.CreateAt,
LastEditAt: e.Value.LastEditAt,
Creator: e.Value.Creator().Name(),
}
if e.Value.Proxy && u.name != m.Creator {
m.Headers = nil
}
movies = append(movies, m)
return true
})
return movies
}
func (u *User) RegClient(conn *websocket.Conn) (*Client, error) {
return u.room.RegClient(u, conn)
}
func (u *User) Broadcast(msg Message, conf ...BroadcastConf) error {
return u.room.Broadcast(msg, append(conf, WithSender(u.name))...)
}

View File

@ -0,0 +1,600 @@
package handlers
import (
"errors"
"fmt"
"io"
"net/http"
"net/url"
"path"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/go-resty/resty/v2"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
json "github.com/json-iterator/go"
"github.com/synctv-org/synctv/internal/conf"
"github.com/synctv-org/synctv/room"
"github.com/synctv-org/synctv/utils"
"github.com/zijiren233/livelib/av"
"github.com/zijiren233/livelib/protocol/hls"
"github.com/zijiren233/livelib/protocol/httpflv"
"github.com/zijiren233/livelib/protocol/rtmp"
"github.com/zijiren233/livelib/protocol/rtmp/core"
rtmps "github.com/zijiren233/livelib/server"
"github.com/zijiren233/stream"
)
func GetPageItems[T any](ctx *gin.Context, items []T) ([]T, error) {
max, err := strconv.ParseUint(ctx.DefaultQuery("max", "10"), 10, 64)
if err != nil {
return items, errors.New("max must be a number")
}
page, err := strconv.ParseUint(ctx.DefaultQuery("page", "1"), 10, 64)
if err != nil {
return items, errors.New("page must be a number")
}
return utils.GetPageItems(items, int(max), int(page)), nil
}
func MovieList(ctx *gin.Context) {
user, err := AuthRoom(ctx.GetHeader("Authorization"))
if err != nil {
ctx.AbortWithStatusJSON(http.StatusUnauthorized, NewApiErrorResp(err))
return
}
ml := user.MovieList()
movies, err := GetPageItems(ctx, ml)
if err != nil {
ctx.AbortWithStatusJSON(http.StatusBadRequest, NewApiErrorResp(err))
return
}
ctx.JSON(http.StatusOK, NewApiDataResp(gin.H{
"current": user.Room().Current(),
"total": len(ml),
"movies": movies,
}))
}
func CurrentMovie(ctx *gin.Context) {
user, err := AuthRoom(ctx.GetHeader("Authorization"))
if err != nil {
ctx.AbortWithStatusJSON(http.StatusUnauthorized, NewApiErrorResp(err))
return
}
ctx.JSON(http.StatusOK, NewApiDataResp(gin.H{
"current": user.Room().Current(),
}))
}
func Movies(ctx *gin.Context) {
user, err := AuthRoom(ctx.GetHeader("Authorization"))
if err != nil {
ctx.AbortWithStatusJSON(http.StatusUnauthorized, NewApiErrorResp(err))
return
}
ml := user.MovieList()
movies, err := GetPageItems(ctx, ml)
if err != nil {
ctx.AbortWithStatusJSON(http.StatusBadRequest, NewApiErrorResp(err))
return
}
ctx.JSON(http.StatusOK, NewApiDataResp(gin.H{
"total": len(ml),
"movies": movies,
}))
}
type PushMovieReq = room.BaseMovie
func PushMovie(ctx *gin.Context) {
user, err := AuthRoom(ctx.GetHeader("Authorization"))
if err != nil {
ctx.AbortWithStatusJSON(http.StatusUnauthorized, NewApiErrorResp(err))
return
}
req := new(PushMovieReq)
if err := json.NewDecoder(ctx.Request.Body).Decode(req); err != nil {
ctx.AbortWithStatusJSON(http.StatusBadRequest, NewApiErrorResp(err))
return
}
movie, err := user.NewMovieWithBaseMovie(*req)
if err != nil {
ctx.AbortWithStatusJSON(http.StatusBadRequest, NewApiErrorResp(err))
return
}
switch {
case movie.RtmpSource && movie.Proxy:
ctx.AbortWithStatusJSON(http.StatusBadRequest, NewApiErrorStringResp("rtmp source and proxy can not be true at the same time"))
return
case movie.Live && movie.RtmpSource:
if !conf.Conf.Rtmp.Enable {
ctx.AbortWithStatusJSON(http.StatusBadRequest, NewApiErrorStringResp("rtmp source is not enabled"))
return
}
movie.PullKey = uuid.New().String()
c, err := user.Room().NewLiveChannel(movie.PullKey)
if err != nil {
ctx.AbortWithStatusJSON(http.StatusInternalServerError, NewApiErrorResp(err))
return
}
movie.SetChannel(c)
case movie.Live && movie.Proxy:
if !conf.Conf.Proxy.LiveProxy {
ctx.AbortWithStatusJSON(http.StatusBadRequest, NewApiErrorStringResp("live proxy is not enabled"))
return
}
u, err := url.Parse(movie.Url)
if err != nil {
ctx.AbortWithStatusJSON(http.StatusBadRequest, NewApiErrorResp(err))
return
}
switch u.Scheme {
case "rtmp":
PullKey := uuid.New().String()
c, err := user.Room().NewLiveChannel(PullKey)
if err != nil {
ctx.AbortWithStatusJSON(http.StatusInternalServerError, NewApiErrorResp(err))
return
}
movie.PullKey = PullKey
go func() {
for {
cli := core.NewConnClient()
err = cli.Start(movie.PullKey, av.PLAY)
if err != nil {
cli.Close()
time.Sleep(time.Second)
continue
}
if err := c.PushStart(rtmp.NewReader(cli)); err != nil && err == rtmps.ErrClosed {
cli.Close()
time.Sleep(time.Second)
continue
}
return
}
}()
case "http", "https":
// TODO: http https flv proxy
fallthrough
default:
ctx.AbortWithStatusJSON(http.StatusBadRequest, NewApiErrorStringResp("only support rtmp temporarily"))
return
}
case !movie.Live && movie.RtmpSource:
ctx.AbortWithStatusJSON(http.StatusBadRequest, NewApiErrorStringResp("rtmp source must be live"))
return
case !movie.Live && movie.Proxy:
if !conf.Conf.Proxy.MovieProxy {
ctx.AbortWithStatusJSON(http.StatusBadRequest, NewApiErrorStringResp("movie proxy is not enabled"))
return
}
fallthrough
case !movie.Live && !movie.Proxy, movie.Live && !movie.Proxy && !movie.RtmpSource:
u, err := url.Parse(movie.Url)
if err != nil {
ctx.AbortWithStatusJSON(http.StatusBadRequest, NewApiErrorResp(err))
return
}
if u.Scheme != "http" && u.Scheme != "https" {
ctx.AbortWithStatusJSON(http.StatusBadRequest, NewApiErrorStringResp("only support http or https"))
return
}
default:
ctx.AbortWithStatusJSON(http.StatusBadRequest, NewApiErrorStringResp("unknown error"))
return
}
s := ctx.DefaultQuery("pos", "back")
switch s {
case "back":
err = user.Room().PushBackMovie(movie)
case "front":
err = user.Room().PushFrontMovie(movie)
default:
err = FormatErrNotSupportPosition(s)
}
if err != nil {
ctx.AbortWithStatusJSON(http.StatusBadRequest, NewApiErrorResp(err))
return
}
if err := user.Broadcast(&room.ElementMessage{
Type: room.ChangeMovieList,
Sender: user.Name(),
}, room.WithSendToSelf()); err != nil {
ctx.AbortWithStatusJSON(http.StatusInternalServerError, NewApiErrorResp(err))
return
}
ctx.JSON(http.StatusCreated, NewApiDataResp(gin.H{
"id": movie.Id(),
}))
}
func NewPublishKey(ctx *gin.Context) {
user, err := AuthRoom(ctx.GetHeader("Authorization"))
if err != nil {
ctx.AbortWithStatus(http.StatusUnauthorized)
return
}
req := new(IdReq)
if err := json.NewDecoder(ctx.Request.Body).Decode(req); err != nil {
ctx.AbortWithStatusJSON(http.StatusBadRequest, NewApiErrorResp(err))
return
}
movie, err := user.Room().GetMovie(req.Id)
if err != nil {
ctx.AbortWithStatusJSON(http.StatusBadRequest, NewApiErrorResp(err))
return
}
if movie.Creator().Name() != user.Name() {
ctx.AbortWithStatus(http.StatusForbidden)
return
}
if !movie.RtmpSource {
ctx.AbortWithStatusJSON(http.StatusBadRequest, NewApiErrorStringResp("only live movie can get publish key"))
return
}
if movie.PullKey == "" {
ctx.AbortWithStatusJSON(http.StatusInternalServerError, NewApiErrorStringResp("pull key is empty"))
return
}
token, err := NewRtmpAuthorization(movie.PullKey)
if err != nil {
ctx.AbortWithStatusJSON(http.StatusInternalServerError, NewApiErrorResp(err))
return
}
host := conf.Conf.Rtmp.CustomPublishHost
if host == "" {
host = ctx.Request.Host
}
ctx.JSON(http.StatusOK, NewApiDataResp(gin.H{
"host": host,
"app": user.Room().ID(),
"token": token,
}))
}
type EditMovieReq struct {
Id uint64 `json:"id"`
Url string `json:"url"`
Name string `json:"name"`
Type string `json:"type"`
Headers map[string][]string `json:"headers"`
}
func EditMovie(ctx *gin.Context) {
user, err := AuthRoom(ctx.GetHeader("Authorization"))
if err != nil {
ctx.AbortWithStatusJSON(http.StatusUnauthorized, NewApiErrorResp(err))
return
}
req := new(EditMovieReq)
if err := json.NewDecoder(ctx.Request.Body).Decode(req); err != nil {
ctx.AbortWithStatusJSON(http.StatusBadRequest, NewApiErrorResp(err))
return
}
m, err := user.Room().GetMovie(req.Id)
if err != nil {
ctx.AbortWithStatusJSON(http.StatusBadRequest, NewApiErrorResp(err))
return
}
// Dont edit live and proxy
m.Url = req.Url
m.Name = req.Name
m.Type = req.Type
m.Headers = req.Headers
if err := user.Broadcast(&room.ElementMessage{
Type: room.ChangeMovieList,
Sender: user.Name(),
}, room.WithSendToSelf()); err != nil {
ctx.AbortWithStatusJSON(http.StatusInternalServerError, NewApiErrorResp(err))
return
}
ctx.Status(http.StatusNoContent)
}
type IdsReq struct {
Ids []uint64 `json:"ids"`
}
func DelMovie(ctx *gin.Context) {
user, err := AuthRoom(ctx.GetHeader("Authorization"))
if err != nil {
ctx.AbortWithStatusJSON(http.StatusUnauthorized, NewApiErrorResp(err))
return
}
req := new(IdsReq)
if err := json.NewDecoder(ctx.Request.Body).Decode(req); err != nil {
ctx.AbortWithStatusJSON(http.StatusBadRequest, NewApiErrorResp(err))
return
}
if err := user.Room().DelMovie(req.Ids...); err != nil {
ctx.AbortWithStatusJSON(http.StatusBadRequest, NewApiErrorResp(err))
return
}
if err := user.Broadcast(&room.ElementMessage{
Type: room.ChangeMovieList,
Sender: user.Name(),
}, room.WithSendToSelf()); err != nil {
ctx.AbortWithStatusJSON(http.StatusInternalServerError, NewApiErrorResp(err))
return
}
ctx.Status(http.StatusNoContent)
}
func ClearMovies(ctx *gin.Context) {
user, err := AuthRoom(ctx.GetHeader("Authorization"))
if err != nil {
ctx.AbortWithStatusJSON(http.StatusUnauthorized, NewApiErrorResp(err))
return
}
if err := user.Room().ClearMovies(); err != nil {
ctx.AbortWithStatusJSON(http.StatusBadRequest, NewApiErrorResp(err))
return
}
if err := user.Broadcast(&room.ElementMessage{
Type: room.ChangeMovieList,
Sender: user.Name(),
}, room.WithSendToSelf()); err != nil {
ctx.AbortWithStatusJSON(http.StatusInternalServerError, NewApiErrorResp(err))
return
}
ctx.Status(http.StatusNoContent)
}
type SwapMovieReq struct {
Id1 uint64 `json:"id1"`
Id2 uint64 `json:"id2"`
}
func SwapMovie(ctx *gin.Context) {
user, err := AuthRoom(ctx.GetHeader("Authorization"))
if err != nil {
ctx.AbortWithStatusJSON(http.StatusUnauthorized, NewApiErrorResp(err))
return
}
req := new(SwapMovieReq)
if err := json.NewDecoder(ctx.Request.Body).Decode(req); err != nil {
ctx.AbortWithStatusJSON(http.StatusBadRequest, NewApiErrorResp(err))
return
}
if err := user.Room().SwapMovie(req.Id1, req.Id2); err != nil {
ctx.AbortWithStatusJSON(http.StatusBadRequest, NewApiErrorResp(err))
return
}
if err := user.Broadcast(&room.ElementMessage{
Type: room.ChangeMovieList,
Sender: user.Name(),
}, room.WithSendToSelf()); err != nil {
ctx.AbortWithStatusJSON(http.StatusInternalServerError, NewApiErrorResp(err))
return
}
ctx.Status(http.StatusNoContent)
}
type IdReq struct {
Id uint64 `json:"id"`
}
func ChangeCurrentMovie(ctx *gin.Context) {
user, err := AuthRoom(ctx.GetHeader("Authorization"))
if err != nil {
ctx.AbortWithStatusJSON(http.StatusUnauthorized, NewApiErrorResp(err))
return
}
req := new(IdReq)
if err := json.NewDecoder(ctx.Request.Body).Decode(req); err != nil {
ctx.AbortWithStatusJSON(http.StatusBadRequest, NewApiErrorResp(err))
return
}
if err := user.Room().ChangeCurrentMovie(req.Id); err != nil {
ctx.AbortWithStatusJSON(http.StatusBadRequest, NewApiErrorResp(err))
return
}
if err := user.Broadcast(&room.ElementMessage{
Type: room.ChangeCurrent,
Sender: user.Name(),
Current: user.Room().Current(),
}, room.WithSendToSelf()); err != nil {
ctx.AbortWithStatusJSON(http.StatusInternalServerError, NewApiErrorResp(err))
return
}
ctx.Status(http.StatusNoContent)
}
type RtmpClaims struct {
PullKey string `json:"p"`
jwt.RegisteredClaims
}
func AuthRtmpPublish(Authorization string) (channelName string, err error) {
t, err := jwt.ParseWithClaims(strings.TrimPrefix(Authorization, `Bearer `), &RtmpClaims{}, func(token *jwt.Token) (any, error) {
return stream.StringToBytes(conf.Conf.Jwt.Secret), nil
})
if err != nil {
return "", ErrAuthFailed
}
claims, ok := t.Claims.(*RtmpClaims)
if !ok {
return "", ErrAuthFailed
}
return claims.PullKey, nil
}
var allowedProxyMovieType = map[string]struct{}{
"video/avi": {},
"video/mp4": {},
"video/webm": {},
}
func NewRtmpAuthorization(channelName string) (string, error) {
claims := &RtmpClaims{
PullKey: channelName,
RegisteredClaims: jwt.RegisteredClaims{
NotBefore: jwt.NewNumericDate(time.Now()),
},
}
return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(stream.StringToBytes(conf.Conf.Jwt.Secret))
}
const UserAgent = `Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/117.0.0.0 Safari/537.36 Edg/117.0.2045.40`
func ProxyMovie(ctx *gin.Context) {
roomid := ctx.Query("roomid")
if roomid == "" {
ctx.AbortWithStatusJSON(http.StatusBadRequest, NewApiErrorStringResp("roomid is empty"))
return
}
room, err := Rooms.GetRoom(roomid)
if err != nil {
ctx.AbortWithStatusJSON(http.StatusBadRequest, NewApiErrorResp(err))
}
cm := room.Current().Movie()
if !cm.Proxy || cm.Live {
ctx.AbortWithStatusJSON(http.StatusBadRequest, NewApiErrorStringResp("not support proxy"))
return
}
u, err := url.Parse(cm.Url)
if err != nil {
ctx.AbortWithStatusJSON(http.StatusBadRequest, NewApiErrorResp(err))
return
}
req := resty.New().R().
SetHeader("Range", ctx.GetHeader("Range")).
SetHeader("User-Agent", UserAgent).
SetHeader("Referer", fmt.Sprintf("%s://%s/", u.Scheme, u.Host)).
SetHeader("Origin", fmt.Sprintf("%s://%s", u.Scheme, u.Host)).
SetHeader("Accept", ctx.GetHeader("Accept")).
SetHeader("Accept-Encoding", ctx.GetHeader("Accept-Encoding")).
SetHeader("Accept-Language", ctx.GetHeader("Accept-Language"))
if cm.Headers != nil {
for k, v := range cm.Headers {
req.SetHeader(k, v[0])
}
}
resp, err := req.Get(cm.Url)
if err != nil {
ctx.AbortWithStatusJSON(http.StatusInternalServerError, NewApiErrorResp(err))
return
}
defer resp.RawBody().Close()
if _, ok := allowedProxyMovieType[resp.Header().Get("Content-Type")]; !ok {
ctx.AbortWithStatusJSON(http.StatusBadRequest, NewApiErrorResp(fmt.Errorf("this movie type support proxy: %s", resp.Header().Get("Content-Type"))))
return
}
for k, v := range resp.Header() {
ctx.Header(k, v[0])
}
ctx.Status(resp.StatusCode())
io.Copy(ctx.Writer, resp.RawBody())
}
type FormatErrNotSupportFileType string
func (e FormatErrNotSupportFileType) Error() string {
return fmt.Sprintf("not support file type %s", string(e))
}
func JoinLive(ctx *gin.Context) {
if !conf.Conf.Proxy.LiveProxy && !conf.Conf.Rtmp.Enable {
ctx.AbortWithStatusJSON(http.StatusForbidden, NewApiErrorStringResp("live proxy and rtmp source is not enabled"))
return
}
user, err := AuthRoom(ctx.GetHeader("Authorization"))
if err != nil {
ctx.AbortWithStatusJSON(http.StatusUnauthorized, NewApiErrorResp(err))
return
}
pullKey := strings.Trim(ctx.Param("pullKey"), "/")
pullKeySplitd := strings.Split(pullKey, "/")
fileName := pullKeySplitd[0]
fileExt := path.Ext(pullKey)
channelName := strings.TrimSuffix(fileName, fileExt)
m, err := user.Room().GetMovieWithPullKey(channelName)
// channel, err := s.GetChannelWithApp(r.ID(), channelName)
if err != nil {
ctx.AbortWithStatusJSON(http.StatusNotFound, NewApiErrorResp(err))
return
}
channel := m.Channel()
switch fileExt {
case ".flv":
ctx.Header("Cache-Control", "no-store")
w := httpflv.NewHttpFLVWriter(ctx.Writer)
defer w.Close()
channel.AddPlayer(w)
w.SendPacket()
case ".m3u8":
ctx.Header("Cache-Control", "no-store")
b, err := channel.GenM3U8PlayList(fmt.Sprintf("/api/movie/live/%s", channelName))
if err != nil {
ctx.AbortWithStatusJSON(http.StatusNotFound, NewApiErrorResp(err))
return
}
ctx.Data(http.StatusOK, hls.M3U8ContentType, b.Bytes())
case ".ts":
b, err := channel.GetTsFile(pullKeySplitd[1])
if err != nil {
ctx.AbortWithStatusJSON(http.StatusNotFound, NewApiErrorResp(err))
return
}
ctx.Header("Cache-Control", "public, max-age=90")
ctx.Data(http.StatusOK, hls.TSContentType, b)
default:
ctx.Header("Cache-Control", "no-store")
ctx.AbortWithStatusJSON(http.StatusBadRequest, NewApiErrorResp(FormatErrNotSupportFileType(fileExt)))
}
}

View File

@ -0,0 +1,20 @@
package handlers
import (
"github.com/gin-gonic/gin"
"github.com/synctv-org/synctv/internal/conf"
)
func Settings(ctx *gin.Context) {
ctx.JSON(200, NewApiDataResp(gin.H{
"rtmp": gin.H{
"enable": conf.Conf.Rtmp.Enable,
"rtmpPlayer": conf.Conf.Rtmp.RtmpPlayer,
"hlsPlayer": conf.Conf.Rtmp.HlsPlayer,
},
"proxy": gin.H{
"movieProxy": conf.Conf.Proxy.MovieProxy,
"liveProxy": conf.Conf.Proxy.LiveProxy,
},
}))
}

491
server/handlers/api-room.go Normal file
View File

@ -0,0 +1,491 @@
package handlers
import (
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"time"
json "github.com/json-iterator/go"
log "github.com/sirupsen/logrus"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
"github.com/maruel/natural"
"github.com/synctv-org/synctv/internal/conf"
"github.com/synctv-org/synctv/room"
"github.com/zijiren233/gencontainer/vec"
rtmps "github.com/zijiren233/livelib/server"
"github.com/zijiren233/stream"
)
var (
ErrAuthFailed = errors.New("auth failed")
ErrAuthExpired = errors.New("auth expired")
ErrRoomAlready = errors.New("room already exists")
)
type FormatErrNotSupportPosition string
func (e FormatErrNotSupportPosition) Error() string {
return fmt.Sprintf("not support position %s", string(e))
}
type AuthClaims struct {
RoomID string `json:"id"`
Version uint64 `json:"v"`
Username string `json:"un"`
UserVersion uint64 `json:"uv"`
jwt.RegisteredClaims
}
func AuthRoom(Authorization string) (*room.User, error) {
t, err := jwt.ParseWithClaims(strings.TrimPrefix(Authorization, `Bearer `), &AuthClaims{}, func(token *jwt.Token) (any, error) {
return stream.StringToBytes(conf.Conf.Jwt.Secret), nil
})
if err != nil {
return nil, ErrAuthFailed
}
claims := t.Claims.(*AuthClaims)
r, err := Rooms.GetRoom(claims.RoomID)
if err != nil {
return nil, err
}
if !r.CheckVersion(claims.Version) {
return nil, ErrAuthExpired
}
user, err := r.GetUser(claims.Username)
if err != nil {
return nil, ErrUserNotFound
}
if !user.CheckVersion(claims.UserVersion) {
return nil, ErrAuthExpired
}
return user, nil
}
func authWithPassword(roomid, password, username, userPassword string) (*room.User, error) {
r, err := Rooms.GetRoom(roomid)
if err != nil {
return nil, err
}
if !r.CheckPassword(password) {
return nil, ErrAuthFailed
}
user, err := r.GetUser(username)
if err != nil {
return nil, ErrUserNotFound
}
if !user.CheckPassword(userPassword) {
return nil, ErrAuthFailed
}
return user, nil
}
func authOrNewWithPassword(roomid, password, username, userPassword string, conf ...room.UserConf) (*room.User, error) {
r, err := Rooms.GetRoom(roomid)
if err != nil {
return nil, err
}
if !r.CheckPassword(password) {
return nil, ErrAuthFailed
}
user, err := r.GetOrNewUser(username, userPassword, conf...)
if err != nil {
return nil, err
}
if !user.CheckPassword(userPassword) {
return nil, ErrAuthFailed
}
return user, nil
}
func newAuthorization(user *room.User) (string, error) {
claims := &AuthClaims{
RoomID: user.Room().ID(),
Version: user.Room().Version(),
Username: user.Name(),
UserVersion: user.Version(),
RegisteredClaims: jwt.RegisteredClaims{
NotBefore: jwt.NewNumericDate(time.Now()),
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour * time.Duration(conf.Conf.Jwt.Expire))),
},
}
return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(stream.StringToBytes(conf.Conf.Jwt.Secret))
}
type CreateRoomReq struct {
RoomID string `json:"roomId"`
Password string `json:"password"`
Username string `json:"username"`
UserPassword string `json:"userPassword"`
Hidden bool `json:"hidden"`
}
func NewCreateRoomHandler(s *rtmps.Server) gin.HandlerFunc {
return func(ctx *gin.Context) {
req := new(CreateRoomReq)
if err := json.NewDecoder(ctx.Request.Body).Decode(req); err != nil {
ctx.AbortWithStatusJSON(http.StatusBadRequest, NewApiErrorResp(err))
return
}
user, err := room.NewUser(req.Username, req.UserPassword, nil, room.WithUserAdmin(true))
if err != nil {
ctx.AbortWithStatusJSON(http.StatusBadRequest, NewApiErrorResp(err))
return
}
r, err := Rooms.CreateRoom(req.RoomID, req.Password, s,
room.WithHidden(req.Hidden),
room.WithMaxInactivityTime(roomMaxInactivityTime),
room.WithRootUser(user),
)
if err != nil {
ctx.AbortWithStatusJSON(http.StatusBadRequest, NewApiErrorResp(err))
return
}
token, err := newAuthorization(user)
if err != nil {
ctx.AbortWithStatusJSON(http.StatusInternalServerError, NewApiErrorResp(err))
return
}
go func() {
ticker := time.NewTicker(time.Second * 5)
defer ticker.Stop()
var pre int64 = 0
for range ticker.C {
if r.Closed() {
log.Infof("ws: room %s closed, stop broadcast people num", r.ID())
return
}
current := r.ClientNum()
if current != pre {
if err := r.Broadcast(&room.ElementMessage{
Type: room.ChangePeopleNum,
PeopleNum: current,
}); err != nil {
log.Errorf("ws: room %s broadcast people num error: %v", r.ID(), err)
continue
}
pre = current
} else {
if err := r.Broadcast(&room.PingMessage{}); err != nil {
log.Errorf("ws: room %s broadcast ping error: %v", r.ID(), err)
continue
}
}
}
}()
r.Start()
r.SetRootUser(user)
ctx.JSON(http.StatusCreated, NewApiDataResp(gin.H{
"token": token,
}))
}
}
type RoomListResp struct {
RoomID string `json:"roomId"`
PeopleNum int64 `json:"peopleNum"`
NeedPassword bool `json:"needPassword"`
Creator string `json:"creator"`
CreateAt int64 `json:"createAt"`
}
func RoomList(ctx *gin.Context) {
r := Rooms.ListNonHidden()
resp := vec.New[*RoomListResp](vec.WithCmpLess[*RoomListResp](func(v1, v2 *RoomListResp) bool {
return v1.PeopleNum < v2.PeopleNum
}), vec.WithCmpEqual[*RoomListResp](func(v1, v2 *RoomListResp) bool {
return v1.PeopleNum == v2.PeopleNum
}))
for _, v := range r {
resp.Push(&RoomListResp{
RoomID: v.ID(),
PeopleNum: v.ClientNum(),
NeedPassword: v.NeedPassword(),
Creator: v.RootUser().Name(),
CreateAt: v.CreateAt().UnixMilli(),
})
}
switch ctx.DefaultQuery("sort", "peopleNum") {
case "peopleNum":
resp.SortStable()
case "creator":
resp.SortStableFunc(func(v1, v2 *RoomListResp) bool {
return natural.Less(v1.Creator, v2.Creator)
}, func(t1, t2 *RoomListResp) bool {
return t1.Creator == t2.Creator
})
case "createAt":
resp.SortStableFunc(func(v1, v2 *RoomListResp) bool {
return v1.CreateAt < v2.CreateAt
}, func(t1, t2 *RoomListResp) bool {
return t1.CreateAt == t2.CreateAt
})
case "roomId":
resp.SortStableFunc(func(v1, v2 *RoomListResp) bool {
return natural.Less(v1.RoomID, v2.RoomID)
}, func(t1, t2 *RoomListResp) bool {
return t1.RoomID == t2.RoomID
})
case "needPassword":
resp.SortStableFunc(func(v1, v2 *RoomListResp) bool {
return v1.NeedPassword && !v2.NeedPassword
}, func(t1, t2 *RoomListResp) bool {
return t1.NeedPassword == t2.NeedPassword
})
default:
ctx.AbortWithStatusJSON(http.StatusBadRequest, NewApiErrorStringResp("sort must be peoplenum or roomid"))
return
}
switch ctx.DefaultQuery("order", "desc") {
case "asc":
// do nothing
case "desc":
resp.Reverse()
default:
ctx.AbortWithStatusJSON(http.StatusBadRequest, NewApiErrorStringResp("order must be asc or desc"))
return
}
list, err := GetPageItems(ctx, resp.Slice())
if err != nil {
ctx.AbortWithStatusJSON(http.StatusBadRequest, NewApiErrorResp(err))
return
}
ctx.JSON(http.StatusOK, NewApiDataResp(gin.H{
"total": resp.Len(),
"list": list,
}))
}
func CheckRoom(ctx *gin.Context) {
r, err := Rooms.GetRoom(ctx.Query("roomId"))
if err != nil {
ctx.AbortWithStatusJSON(http.StatusNotFound, NewApiErrorResp(err))
return
}
ctx.JSON(http.StatusOK, NewApiDataResp(gin.H{
"peopleNum": r.ClientNum(),
"needPassword": r.NeedPassword(),
}))
}
func CheckUser(ctx *gin.Context) {
r, err := Rooms.GetRoom(ctx.Query("roomId"))
if err != nil {
ctx.AbortWithStatusJSON(http.StatusNotFound, NewApiErrorResp(err))
return
}
u, err := r.GetUser(ctx.Query("username"))
if err != nil {
ctx.AbortWithStatusJSON(http.StatusNotFound, NewApiErrorResp(err))
return
}
ctx.JSON(http.StatusOK, NewApiDataResp(gin.H{
"idRoot": u.IsRoot(),
"idAdmin": u.IsAdmin(),
"lastAct": u.LastAct(),
}))
}
type LoginRoomReq struct {
RoomID string `json:"roomId"`
Password string `json:"password"`
Username string `json:"username"`
UserPassword string `json:"userPassword"`
}
func LoginRoom(ctx *gin.Context) {
req := new(LoginRoomReq)
if err := json.NewDecoder(ctx.Request.Body).Decode(req); err != nil {
ctx.AbortWithStatusJSON(http.StatusBadRequest, NewApiErrorResp(err))
return
}
autoNew, err := strconv.ParseBool(ctx.DefaultQuery("autoNew", "false"))
if err != nil {
ctx.AbortWithStatusJSON(http.StatusBadRequest, NewApiErrorStringResp("autoNew must be bool"))
return
}
var (
user *room.User
)
if autoNew {
user, err = authOrNewWithPassword(req.RoomID, req.Password, req.Username, req.UserPassword)
if err != nil {
ctx.AbortWithStatusJSON(http.StatusUnauthorized, NewApiErrorResp(err))
return
}
} else {
user, err = authWithPassword(req.RoomID, req.Password, req.Username, req.UserPassword)
if err != nil {
ctx.AbortWithStatusJSON(http.StatusUnauthorized, NewApiErrorResp(err))
return
}
}
token, err := newAuthorization(user)
if err != nil {
ctx.AbortWithStatusJSON(http.StatusInternalServerError, NewApiErrorResp(err))
return
}
ctx.JSON(http.StatusOK, NewApiDataResp(gin.H{
"token": token,
}))
}
func DeleteRoom(ctx *gin.Context) {
user, err := AuthRoom(ctx.GetHeader("Authorization"))
if err != nil {
ctx.AbortWithStatusJSON(http.StatusUnauthorized, NewApiErrorResp(err))
return
}
if err := Rooms.DelRoom(user.Room().ID()); err != nil {
ctx.AbortWithStatusJSON(http.StatusBadRequest, NewApiErrorResp(err))
return
}
ctx.Status(http.StatusNoContent)
}
type SetPasswordReq struct {
Password string `json:"password"`
}
func SetPassword(ctx *gin.Context) {
user, err := AuthRoom(ctx.GetHeader("Authorization"))
if err != nil {
ctx.AbortWithStatusJSON(http.StatusUnauthorized, NewApiErrorResp(err))
return
}
if !user.IsRoot() || !user.IsAdmin() {
ctx.AbortWithStatusJSON(http.StatusUnauthorized, NewApiErrorStringResp("only root or admin can set password"))
return
}
req := new(SetPasswordReq)
if err := json.NewDecoder(ctx.Request.Body).Decode(req); err != nil {
ctx.AbortWithStatusJSON(http.StatusBadRequest, NewApiErrorResp(err))
return
}
user.Room().SetPassword(req.Password)
token, err := newAuthorization(user)
if err != nil {
ctx.AbortWithStatusJSON(http.StatusInternalServerError, NewApiErrorResp(err))
return
}
ctx.JSON(http.StatusOK, NewApiDataResp(gin.H{
"token": token,
}))
}
type UsernameReq struct {
Username string `json:"username"`
}
func AddAdmin(ctx *gin.Context) {
user, err := AuthRoom(ctx.GetHeader("Authorization"))
if err != nil {
ctx.AbortWithStatusJSON(http.StatusUnauthorized, NewApiErrorResp(err))
return
}
if !user.IsRoot() && !user.IsAdmin() {
ctx.AbortWithStatusJSON(http.StatusUnauthorized, NewApiErrorStringResp("only root or admin can add admin"))
return
}
req := new(UsernameReq)
if err := json.NewDecoder(ctx.Request.Body).Decode(req); err != nil {
ctx.AbortWithStatusJSON(http.StatusBadRequest, NewApiErrorResp(err))
return
}
u, err := user.Room().GetUser(req.Username)
if err != nil {
ctx.AbortWithStatusJSON(http.StatusNotFound, NewApiErrorResp(err))
return
}
u.SetAdmin(true)
ctx.Status(http.StatusNoContent)
}
func DelAdmin(ctx *gin.Context) {
user, err := AuthRoom(ctx.GetHeader("Authorization"))
if err != nil {
ctx.AbortWithStatusJSON(http.StatusUnauthorized, NewApiErrorResp(err))
return
}
if !user.IsRoot() {
ctx.AbortWithStatusJSON(http.StatusUnauthorized, NewApiErrorStringResp("only root can del admin"))
return
}
req := new(UsernameReq)
if err := json.NewDecoder(ctx.Request.Body).Decode(req); err != nil {
ctx.AbortWithStatusJSON(http.StatusBadRequest, NewApiErrorResp(err))
return
}
u, err := user.Room().GetUser(req.Username)
if err != nil {
ctx.AbortWithStatusJSON(http.StatusNotFound, NewApiErrorResp(err))
return
}
u.SetAdmin(false)
ctx.Status(http.StatusNoContent)
}
func CloseRoom(ctx *gin.Context) {
user, err := AuthRoom(ctx.GetHeader("Authorization"))
if err != nil {
ctx.AbortWithStatusJSON(http.StatusUnauthorized, NewApiErrorResp(err))
return
}
if !user.IsRoot() {
ctx.AbortWithStatusJSON(http.StatusUnauthorized, NewApiErrorStringResp("only root can close room"))
return
}
err = Rooms.DelRoom(user.Room().ID())
if err != nil {
ctx.AbortWithStatusJSON(http.StatusInternalServerError, NewApiErrorResp(err))
return
}
ctx.Status(http.StatusNoContent)
}

View File

@ -0,0 +1,54 @@
package handlers
import (
"net/http"
"github.com/gin-gonic/gin"
json "github.com/json-iterator/go"
)
func Me(ctx *gin.Context) {
user, err := AuthRoom(ctx.GetHeader("Authorization"))
if err != nil {
ctx.AbortWithStatusJSON(http.StatusUnauthorized, NewApiErrorResp(err))
return
}
ctx.JSON(http.StatusOK, NewApiDataResp(gin.H{
"isRoot": user.IsRoot(),
"isAdmin": user.IsAdmin(),
"username": user.Name(),
"lastAct": user.LastAct(),
}))
}
func SetUserPassword(ctx *gin.Context) {
user, err := AuthRoom(ctx.GetHeader("Authorization"))
if err != nil {
ctx.AbortWithStatusJSON(http.StatusUnauthorized, NewApiErrorResp(err))
return
}
req := new(SetPasswordReq)
if err := json.NewDecoder(ctx.Request.Body).Decode(req); err != nil {
ctx.AbortWithStatusJSON(http.StatusBadRequest, NewApiErrorResp(err))
return
}
if err := user.SetPassword(req.Password); err != nil {
ctx.AbortWithStatusJSON(http.StatusBadRequest, NewApiErrorResp(err))
return
}
user.CloseHub()
token, err := newAuthorization(user)
if err != nil {
ctx.AbortWithStatusJSON(http.StatusInternalServerError, NewApiErrorResp(err))
return
}
ctx.JSON(http.StatusOK, NewApiDataResp(gin.H{
"token": token,
}))
}

40
server/handlers/api.go Normal file
View File

@ -0,0 +1,40 @@
package handlers
import (
"time"
)
type ApiResp struct {
Time int64 `json:"time"`
Error string `json:"error,omitempty"`
Data any `json:"data,omitempty"`
}
func (ar *ApiResp) SetError(err error) {
ar.Error = err.Error()
}
func (ar *ApiResp) SetDate(data any) {
ar.Data = data
}
func NewApiErrorResp(err error) *ApiResp {
return &ApiResp{
Time: time.Now().UnixMicro(),
Error: err.Error(),
}
}
func NewApiErrorStringResp(err string) *ApiResp {
return &ApiResp{
Time: time.Now().UnixMicro(),
Error: err,
}
}
func NewApiDataResp(data any) *ApiResp {
return &ApiResp{
Time: time.Now().UnixMicro(),
Data: data,
}
}

125
server/handlers/init.go Normal file
View File

@ -0,0 +1,125 @@
package handlers
import (
"fmt"
"net/http"
"github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus"
"github.com/synctv-org/synctv/internal/conf"
"github.com/synctv-org/synctv/public"
"github.com/synctv-org/synctv/utils"
rtmps "github.com/zijiren233/livelib/server"
)
func Init(e *gin.Engine, s *rtmps.Server) {
initOnce()
{
s.SetParseChannelFunc(func(ReqAppName, ReqChannelName string, IsPublisher bool) (TrueAppName string, TrueChannel string, err error) {
if IsPublisher {
channelName, err := AuthRtmpPublish(ReqChannelName)
if err != nil {
log.Errorf("rtmp: publish auth to %s error: %v", ReqAppName, err)
return "", "", err
}
if !Rooms.HasRoom(ReqAppName) {
log.Infof("rtmp: publish to %s/%s error: %s", ReqAppName, channelName, fmt.Sprintf("room %s not exist", ReqAppName))
return "", "", fmt.Errorf("room %s not exist", ReqAppName)
}
log.Infof("rtmp: publish to success: %s/%s", ReqAppName, channelName)
return ReqAppName, channelName, nil
} else if !conf.Conf.Rtmp.RtmpPlayer {
log.Infof("rtmp: dial to %s/%s error: %s", ReqAppName, ReqChannelName, "rtmp player is not enabled")
return "", "", fmt.Errorf("rtmp: dial to %s/%s error: %s", ReqAppName, ReqChannelName, "rtmp player is not enabled")
}
return ReqAppName, ReqChannelName, nil
})
}
{
web := e.Group("/web")
web.StaticFS("", http.FS(public.Public))
}
{
api := e.Group("/api")
{
public := api.Group("/public")
public.GET("/settings", Settings)
}
{
room := api.Group("/room")
room.GET("/ws", NewWebSocketHandler(utils.NewWebSocketServer()))
room.GET("/check", CheckRoom)
room.GET("/user", CheckUser)
room.GET("/list", RoomList)
room.POST("/create", NewCreateRoomHandler(s))
room.POST("/login", LoginRoom)
room.POST("/delete", DeleteRoom)
room.POST("/pwd", SetPassword)
room.PUT("/admin", AddAdmin)
room.DELETE("/admin", DelAdmin)
room.POST("/close", CloseRoom)
}
{
movie := api.Group("/movie")
movie.GET("/list", MovieList)
movie.GET("/movies", Movies)
movie.GET("/current", CurrentMovie)
movie.POST("/current", ChangeCurrentMovie)
movie.POST("/push", PushMovie)
movie.POST("/edit", EditMovie)
movie.POST("/swap", SwapMovie)
movie.POST("/delete", DelMovie)
movie.POST("/clear", ClearMovies)
movie.GET("/proxy/:roomid", ProxyMovie)
{
live := movie.Group("/live")
live.POST("/publishKey", NewPublishKey)
live.GET("/*pullKey", JoinLive)
}
}
{
user := api.Group("/user")
user.GET("/me", Me)
user.POST("/pwd", SetUserPassword)
}
}
e.NoRoute(func(c *gin.Context) {
c.Redirect(http.StatusFound, "/web/")
})
}

145
server/handlers/rooms.go Normal file
View File

@ -0,0 +1,145 @@
package handlers
import (
"errors"
"sync"
"time"
"github.com/synctv-org/synctv/room"
"github.com/zijiren233/gencontainer/rwmap"
"github.com/zijiren233/ksync"
rtmps "github.com/zijiren233/livelib/server"
)
const (
roomMaxInactivityTime = time.Hour * 12
)
var (
Rooms *rooms
initOnce = sync.OnceFunc(func() {
Rooms = newRooms()
})
)
var (
ErrRoomIDEmpty = errors.New("roomid is empty")
ErrRoomNotFound = errors.New("room not found")
ErrUserNotFound = errors.New("user not found")
ErrRoomAlreadyExist = errors.New("room already exist")
)
type rooms struct {
rooms *rwmap.RWMap[string, *room.Room]
lock *ksync.Krwmutex
}
func newRooms() *rooms {
return &rooms{
rooms: &rwmap.RWMap[string, *room.Room]{},
lock: ksync.NewKrwmutex(),
}
}
func (rs *rooms) List() (rooms []*room.Room) {
rooms = make([]*room.Room, 0, rs.rooms.Len())
rs.rooms.Range(func(id string, r *room.Room) bool {
rs.lock.RLock(id)
defer rs.lock.RUnlock(id)
if r.Closed() {
rs.rooms.Delete(id)
return true
}
rooms = append(rooms, r)
return true
})
return
}
func (rs *rooms) ListNonHidden() (rooms []*room.Room) {
rooms = make([]*room.Room, 0, rs.rooms.Len())
rs.rooms.Range(func(id string, r *room.Room) bool {
rs.lock.RLock(id)
defer rs.lock.RUnlock(id)
if r.Closed() {
rs.rooms.Delete(id)
return true
}
if !r.Hidden() {
rooms = append(rooms, r)
}
return true
})
return
}
func (rs *rooms) ListHidden() (rooms []*room.Room) {
rooms = make([]*room.Room, 0, rs.rooms.Len())
rs.rooms.Range(func(id string, r *room.Room) bool {
rs.lock.RLock(id)
defer rs.lock.RUnlock(id)
if r.Closed() {
rs.rooms.Delete(id)
return true
}
if r.Hidden() {
rooms = append(rooms, r)
}
return true
})
return
}
func (rs *rooms) HasRoom(id string) bool {
rs.lock.Lock(id)
defer rs.lock.Unlock(id)
r, ok := rs.rooms.Load(id)
if !ok || r.Closed() {
return false
}
return ok
}
func (rs *rooms) GetRoom(id string) (*room.Room, error) {
if id == "" {
return nil, ErrRoomIDEmpty
}
rs.lock.RLock(id)
defer rs.lock.RUnlock(id)
r, ok := rs.rooms.Load(id)
if !ok || r.Closed() {
return nil, ErrRoomNotFound
}
return r, nil
}
func (rs *rooms) CreateRoom(id string, password string, s *rtmps.Server, conf ...room.RoomConf) (*room.Room, error) {
if id == "" {
return nil, ErrRoomIDEmpty
}
rs.lock.Lock(id)
defer rs.lock.Unlock(id)
if oldR, ok := rs.rooms.Load(id); ok && !oldR.Closed() {
return nil, ErrRoomAlreadyExist
}
r, err := room.NewRoom(id, password, s, conf...)
if err != nil {
return nil, err
}
rs.rooms.Store(id, r)
return r, nil
}
func (rs *rooms) DelRoom(id string) error {
if id == "" {
return ErrRoomIDEmpty
}
rs.lock.Lock(id)
defer rs.lock.Unlock(id)
r, ok := rs.rooms.LoadAndDelete(id)
if !ok {
return ErrRoomNotFound
}
return r.Close()
}

12
server/handlers/web.go Normal file
View File

@ -0,0 +1,12 @@
package handlers
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/synctv-org/synctv/public"
)
func WebServer(e gin.IRoutes) {
e.StaticFS("", http.FS(public.Public))
}

View File

@ -0,0 +1,180 @@
package handlers
import (
"net/http"
"time"
json "github.com/json-iterator/go"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
log "github.com/sirupsen/logrus"
"github.com/synctv-org/synctv/cmd/flags"
"github.com/synctv-org/synctv/room"
"github.com/synctv-org/synctv/utils"
)
const maxInterval = 10
func NewWebSocketHandler(wss *utils.WebSocket) gin.HandlerFunc {
return func(ctx *gin.Context) {
token := ctx.GetHeader("Sec-WebSocket-Protocol")
user, err := AuthRoom(token)
if err != nil {
ctx.AbortWithStatusJSON(http.StatusUnauthorized, NewApiErrorResp(err))
return
}
wss.Server(ctx.Writer, ctx.Request, []string{token}, NewWSMessageHandler(user))
}
}
func NewWSMessageHandler(u *room.User) func(c *websocket.Conn) error {
return func(c *websocket.Conn) error {
client, err := u.RegClient(c)
if err != nil {
log.Errorf("ws: register client error: %v", err)
b, err := json.Marshal(room.ElementMessage{
Type: room.Error,
Message: err.Error(),
})
if err != nil {
return err
}
return c.WriteMessage(websocket.TextMessage, b)
}
log.Infof("ws: room %s user %s connected", u.Room().ID(), u.Name())
defer func() {
client.Unregister()
client.Close()
log.Infof("ws: room %s user %s disconnected", u.Room().ID(), u.Name())
}()
go handleReaderMessage(client)
return handleWriterMessage(client)
}
}
func handleWriterMessage(c *room.Client) error {
for v := range c.GetReadChan() {
wc, err := c.NextWriter(v.MessageType())
if err != nil {
if flags.Dev {
log.Errorf("ws: room %s user %s get next writer error: %v", c.Room().ID(), c.Username(), err)
}
return err
}
if err := v.Encode(wc); err != nil {
if flags.Dev {
log.Errorf("ws: room %s user %s encode message error: %v", c.Room().ID(), c.Username(), err)
}
continue
}
if err := wc.Close(); err != nil {
return err
}
}
return nil
}
func handleReaderMessage(c *room.Client) error {
defer c.Close()
var timeDiff float64
for {
t, rd, err := c.NextReader()
if err != nil {
if flags.Dev {
log.Errorf("ws: room %s user %s get next reader error: %v", c.Room().ID(), c.Username(), err)
}
return err
}
log.Infof("ws: room %s user %s receive message type: %d", c.Room().ID(), c.Username(), t)
switch t {
case websocket.CloseMessage:
if flags.Dev {
log.Infof("ws: room %s user %s receive close message", c.Room().ID(), c.Username())
}
return nil
case websocket.TextMessage:
msg := room.ElementMessage{}
if err := json.NewDecoder(rd).Decode(&msg); err != nil {
log.Errorf("ws: room %s user %s decode message error: %v", c.Room().ID(), c.Username(), err)
if err := c.Send(&room.ElementMessage{
Type: room.Error,
Message: err.Error(),
}); err != nil {
log.Errorf("ws: room %s user %s send error message error: %v", c.Room().ID(), c.Username(), err)
return err
}
continue
}
if flags.Dev {
log.Infof("ws: receive room %s user %s message: %+v", c.Room().ID(), c.Username(), msg)
}
if msg.Time != 0 {
timeDiff = time.Since(time.UnixMilli(msg.Time)).Seconds()
} else {
timeDiff = 0.0
}
if timeDiff < 0 {
timeDiff = 0
} else if timeDiff > 1.5 {
timeDiff = 1.5
}
switch msg.Type {
case room.ChatMessage:
c.Broadcast(&room.ElementMessage{
Type: room.ChatMessage,
Sender: c.Username(),
Message: msg.Message,
}, room.WithSendToSelf())
case room.Play:
status := c.Room().SetStatus(true, msg.Seek, msg.Rate, timeDiff)
c.Broadcast(&room.ElementMessage{
Type: room.Play,
Sender: c.Username(),
Seek: status.Seek,
Rate: status.Rate,
})
case room.Pause:
status := c.Room().SetStatus(false, msg.Seek, msg.Rate, timeDiff)
c.Broadcast(&room.ElementMessage{
Type: room.Pause,
Sender: c.Username(),
Seek: status.Seek,
Rate: status.Rate,
})
case room.ChangeRate:
status := c.Room().SetSeekRate(msg.Seek, msg.Rate, timeDiff)
c.Broadcast(&room.ElementMessage{
Type: room.ChangeRate,
Sender: c.Username(),
Seek: status.Seek,
Rate: status.Rate,
})
case room.ChangeSeek:
status := c.Room().SetSeekRate(msg.Seek, msg.Rate, timeDiff)
c.Broadcast(&room.ElementMessage{
Type: room.ChangeSeek,
Sender: c.Username(),
Seek: status.Seek,
Rate: status.Rate,
})
case room.CheckSeek:
status := c.Room().Current().Status()
if status.Seek+maxInterval < msg.Seek+timeDiff {
c.Send(&room.ElementMessage{
Type: room.TooFast,
Seek: status.Seek,
Rate: status.Rate,
})
} else if status.Seek-maxInterval > msg.Seek+timeDiff {
c.Send(&room.ElementMessage{
Type: room.TooSlow,
Seek: status.Seek,
Rate: status.Rate,
})
}
}
}
}
}

View File

@ -0,0 +1,14 @@
package middlewares
import (
"github.com/gin-contrib/cors"
"github.com/gin-gonic/gin"
)
func NewCors() gin.HandlerFunc {
config := cors.DefaultConfig()
config.AllowAllOrigins = true
config.AllowHeaders = []string{"*"}
config.AllowMethods = []string{"*"}
return cors.New(config)
}

View File

@ -0,0 +1,16 @@
package middlewares
import (
"github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus"
"github.com/synctv-org/synctv/internal/conf"
)
func Init(e *gin.Engine) {
e.
Use(gin.LoggerWithWriter(log.StandardLogger().Out), gin.RecoveryWithWriter(log.StandardLogger().Out)).
Use(NewCors())
if conf.Conf.Server.Quic {
e.Use(NewQuic())
}
}

View File

@ -0,0 +1,13 @@
package middlewares
import (
"fmt"
"github.com/gin-gonic/gin"
)
func NewQuic() gin.HandlerFunc {
return func(c *gin.Context) {
c.Header("Alt-Svc", fmt.Sprintf("h3=\":%s\"; ma=86400", c.Request.URL.Port()))
}
}

21
server/router.go Normal file
View File

@ -0,0 +1,21 @@
package server
import (
"github.com/gin-gonic/gin"
"github.com/synctv-org/synctv/internal/conf"
"github.com/synctv-org/synctv/server/handlers"
"github.com/synctv-org/synctv/server/middlewares"
rtmps "github.com/zijiren233/livelib/server"
)
func Init(e *gin.Engine, s *rtmps.Server) {
middlewares.Init(e)
handlers.Init(e, s)
}
func NewAndInit() (e *gin.Engine, s *rtmps.Server) {
e = gin.New()
s = rtmps.NewRtmpServer(rtmps.WithInitHlsPlayer(conf.Conf.Rtmp.HlsPlayer))
Init(e, s)
return
}

86
utils/utils.go Normal file
View File

@ -0,0 +1,86 @@
package utils
import (
"math/rand"
"os"
"path/filepath"
yamlcomment "github.com/zijiren233/yaml-comment"
"gopkg.in/yaml.v3"
)
var letters = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
func RandString(n int) string {
b := make([]rune, n)
for i := range b {
b[i] = letters[rand.Intn(len(letters))]
}
return string(b)
}
func RandBytes(n int) []byte {
b := make([]byte, n)
for i := range b {
b[i] = byte(rand.Intn(256))
}
return b
}
func GetPageItems[T any](items []T, max, page int) []T {
start := (page - 1) * max
if start < 0 {
start = 0
} else if start > len(items) {
start = len(items)
}
end := int(page * max)
if end > len(items) {
end = len(items)
}
return items[start:end]
}
func Index[T comparable](items []T, item T) int {
for i, v := range items {
if v == item {
return i
}
}
return -1
}
func In[T comparable](items []T, item T) bool {
return Index(items, item) != -1
}
func Exists(name string) bool {
if _, err := os.Stat(name); err != nil {
if os.IsNotExist(err) {
return false
}
}
return true
}
func WriteYaml(file string, module any) error {
err := os.MkdirAll(filepath.Dir(file), os.ModePerm)
if err != nil {
return err
}
f, err := os.Create(file)
if err != nil {
return err
}
defer f.Close()
return yamlcomment.NewEncoder(yaml.NewEncoder(f)).Encode(module)
}
func ReadYaml(file string, module any) error {
f, err := os.Open(file)
if err != nil {
return err
}
defer f.Close()
return yaml.NewDecoder(f).Decode(module)
}

72
utils/websocket.go Normal file
View File

@ -0,0 +1,72 @@
package utils
import (
"net/http"
"time"
"github.com/gorilla/websocket"
)
type WebSocket struct {
Heartbeat time.Duration
}
func DefaultWebSocket() *WebSocket {
return &WebSocket{Heartbeat: time.Second * 5}
}
type WebSocketConfig func(*WebSocket)
func WithHeartbeatInterval(d time.Duration) WebSocketConfig {
return func(ws *WebSocket) {
ws.Heartbeat = d
}
}
func NewWebSocketServer(conf ...WebSocketConfig) *WebSocket {
ws := DefaultWebSocket()
for _, wsc := range conf {
wsc(ws)
}
return ws
}
func (ws *WebSocket) Server(w http.ResponseWriter, r *http.Request, Subprotocols []string, handler func(c *websocket.Conn) error) error {
wsc, err := ws.NewWebSocketClient(w, r, nil, WithSubprotocols(Subprotocols))
if err != nil {
return err
}
defer wsc.Close()
return handler(wsc)
}
type UpgraderConf func(*websocket.Upgrader)
func WithSubprotocols(Subprotocols []string) UpgraderConf {
return func(ug *websocket.Upgrader) {
ug.Subprotocols = Subprotocols
}
}
func (ws *WebSocket) newUpgrader(conf ...UpgraderConf) *websocket.Upgrader {
ug := &websocket.Upgrader{
HandshakeTimeout: time.Second * 30,
ReadBufferSize: 1024,
WriteBufferSize: 1024,
CheckOrigin: func(r *http.Request) bool {
return true
},
}
for _, uc := range conf {
uc(ug)
}
return ug
}
func (ws *WebSocket) NewWebSocketClient(w http.ResponseWriter, r *http.Request, responseHeader http.Header, conf ...UpgraderConf) (*websocket.Conn, error) {
conn, err := ws.newUpgrader(conf...).Upgrade(w, r, responseHeader)
if err != nil {
return nil, err
}
return conn, nil
}