diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2020af0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/log +/public/dist/* +!*.gitkeep +.DS_Store \ No newline at end of file diff --git a/README-CN.md b/README-CN.md new file mode 100644 index 0000000..6b6e993 --- /dev/null +++ b/README-CN.md @@ -0,0 +1,19 @@ +[English](./README.md) | 中文 + +# 特点 +- [x] 同步观看 + - [x] 视频同步 + - [x] 直播同步 +- [x] 影院模式 + - [x] 聊天 + - [x] 弹幕 +- [x] 代理 + - [ ] 视频代理 + - [ ] 直播代理 + +# 免责声明 +- 这个程序是一个免费且开源的项目。它旨在播放网络上的视频文件,方便多人共同观看视频和学习golang。 +- 在使用时,请遵守相关法律法规,不要滥用。 +- 该程序仅进行客户端播放视频文件/流量转发,不会拦截、存储或篡改任何用户数据。 +- 在使用该程序之前,您应该了解并承担相应的风险,包括但不限于版权纠纷、法律限制等,这与该程序无关。 +- 如果有任何侵权行为,请通过[电子邮件](mailto:pyh1670605849@gmail.com)与我联系,将及时处理。 \ No newline at end of file diff --git a/README.md b/README.md index 59127b9..d8914bc 100644 --- a/README.md +++ b/README.md @@ -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. \ No newline at end of file diff --git a/cmd/common.go b/cmd/common.go new file mode 100644 index 0000000..72f3d74 --- /dev/null +++ b/cmd/common.go @@ -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 +} diff --git a/cmd/conf.go b/cmd/conf.go new file mode 100644 index 0000000..ef54740 --- /dev/null +++ b/cmd/conf.go @@ -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`, + } +} diff --git a/cmd/flags/config.go b/cmd/flags/config.go new file mode 100644 index 0000000..c7f14e0 --- /dev/null +++ b/cmd/flags/config.go @@ -0,0 +1,16 @@ +package flags + +// Global +var ( + Dev bool + + LogStd bool + + ConfigFile string + + SkipEnv bool + + SkipConfig bool + + EnvNoPrefix bool +) diff --git a/cmd/init.go b/cmd/init.go new file mode 100644 index 0000000..61b086c --- /dev/null +++ b/cmd/init.go @@ -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) +} diff --git a/cmd/root.go b/cmd/root.go new file mode 100644 index 0000000..ff62a5f --- /dev/null +++ b/cmd/root.go @@ -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") +} diff --git a/cmd/server.go b/cmd/server.go new file mode 100644 index 0000000..0f869ed --- /dev/null +++ b/cmd/server.go @@ -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) +} diff --git a/cmd/version.go b/cmd/version.go new file mode 100644 index 0000000..bc4f87d --- /dev/null +++ b/cmd/version.go @@ -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) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..5b43e46 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..893f363 --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/bootstrap/config.go b/internal/bootstrap/config.go new file mode 100644 index 0000000..51d3408 --- /dev/null +++ b/internal/bootstrap/config.go @@ -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, + }) +} diff --git a/internal/bootstrap/log.go b/internal/bootstrap/log.go new file mode 100644 index 0000000..b8cc89a --- /dev/null +++ b/internal/bootstrap/log.go @@ -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) +} diff --git a/internal/bootstrap/sysNotify.go b/internal/bootstrap/sysNotify.go new file mode 100644 index 0000000..2cb288f --- /dev/null +++ b/internal/bootstrap/sysNotify.go @@ -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 +} diff --git a/internal/conf/config.go b/internal/conf/config.go new file mode 100644 index 0000000..00495f1 --- /dev/null +++ b/internal/conf/config.go @@ -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(), + } +} diff --git a/internal/conf/global.go b/internal/conf/global.go new file mode 100644 index 0000000..0fe5949 --- /dev/null +++ b/internal/conf/global.go @@ -0,0 +1,8 @@ +package conf + +type GlobalConfig struct { +} + +func DefaultGlobalConfig() GlobalConfig { + return GlobalConfig{} +} diff --git a/internal/conf/jwt.go b/internal/conf/jwt.go new file mode 100644 index 0000000..9f9865a --- /dev/null +++ b/internal/conf/jwt.go @@ -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, + } +} diff --git a/internal/conf/log.go b/internal/conf/log.go new file mode 100644 index 0000000..7b8a2b3 --- /dev/null +++ b/internal/conf/log.go @@ -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, + } +} diff --git a/internal/conf/proxy.go b/internal/conf/proxy.go new file mode 100644 index 0000000..912b729 --- /dev/null +++ b/internal/conf/proxy.go @@ -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, + } +} diff --git a/internal/conf/rtmp.go b/internal/conf/rtmp.go new file mode 100644 index 0000000..a358329 --- /dev/null +++ b/internal/conf/rtmp.go @@ -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, + } +} diff --git a/internal/conf/server.go b/internal/conf/server.go new file mode 100644 index 0000000..238c4f8 --- /dev/null +++ b/internal/conf/server.go @@ -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: "", + } +} diff --git a/internal/conf/var.go b/internal/conf/var.go new file mode 100644 index 0000000..ec6d985 --- /dev/null +++ b/internal/conf/var.go @@ -0,0 +1,5 @@ +package conf + +var ( + Conf *Config +) diff --git a/main.go b/main.go new file mode 100644 index 0000000..bf1ed21 --- /dev/null +++ b/main.go @@ -0,0 +1,7 @@ +package main + +import "github.com/synctv-org/synctv/cmd" + +func main() { + cmd.Execute() +} diff --git a/public/dist/.gitkeep b/public/dist/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/public/public.go b/public/public.go new file mode 100644 index 0000000..c0877cf --- /dev/null +++ b/public/public.go @@ -0,0 +1,11 @@ +package public + +import ( + "embed" + "io/fs" +) + +//go:embed dist/* +var dist embed.FS + +var Public, _ = fs.Sub(dist, "dist") diff --git a/room/client.go b/room/client.go new file mode 100644 index 0000000..10b31c6 --- /dev/null +++ b/room/client.go @@ -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() +} diff --git a/room/current.go b/room/current.go new file mode 100644 index 0000000..8fe60e5 --- /dev/null +++ b/room/current.go @@ -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 +} diff --git a/room/hub.go b/room/hub.go new file mode 100644 index 0000000..62b78de --- /dev/null +++ b/room/hub.go @@ -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) +} diff --git a/room/message.go b/room/message.go new file mode 100644 index 0000000..28ab1bb --- /dev/null +++ b/room/message.go @@ -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 +} diff --git a/room/movies.go b/room/movies.go new file mode 100644 index 0000000..f0c23ba --- /dev/null +++ b/room/movies.go @@ -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 +} diff --git a/room/room.go b/room/room.go new file mode 100644 index 0000000..4c95fb9 --- /dev/null +++ b/room/room.go @@ -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) +} diff --git a/room/user.go b/room/user.go new file mode 100644 index 0000000..240dc72 --- /dev/null +++ b/room/user.go @@ -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))...) +} diff --git a/server/handlers/api-movie.go b/server/handlers/api-movie.go new file mode 100644 index 0000000..ceaf061 --- /dev/null +++ b/server/handlers/api-movie.go @@ -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))) + } +} diff --git a/server/handlers/api-public.go b/server/handlers/api-public.go new file mode 100644 index 0000000..c6e6382 --- /dev/null +++ b/server/handlers/api-public.go @@ -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, + }, + })) +} diff --git a/server/handlers/api-room.go b/server/handlers/api-room.go new file mode 100644 index 0000000..3c0eff0 --- /dev/null +++ b/server/handlers/api-room.go @@ -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) +} diff --git a/server/handlers/api-user.go b/server/handlers/api-user.go new file mode 100644 index 0000000..9e9eeef --- /dev/null +++ b/server/handlers/api-user.go @@ -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, + })) +} diff --git a/server/handlers/api.go b/server/handlers/api.go new file mode 100644 index 0000000..da80ccb --- /dev/null +++ b/server/handlers/api.go @@ -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, + } +} diff --git a/server/handlers/init.go b/server/handlers/init.go new file mode 100644 index 0000000..f209134 --- /dev/null +++ b/server/handlers/init.go @@ -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/") + }) +} diff --git a/server/handlers/rooms.go b/server/handlers/rooms.go new file mode 100644 index 0000000..670c3f5 --- /dev/null +++ b/server/handlers/rooms.go @@ -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() +} diff --git a/server/handlers/web.go b/server/handlers/web.go new file mode 100644 index 0000000..d97c56e --- /dev/null +++ b/server/handlers/web.go @@ -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)) +} diff --git a/server/handlers/websocket.go b/server/handlers/websocket.go new file mode 100644 index 0000000..8590556 --- /dev/null +++ b/server/handlers/websocket.go @@ -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, + }) + } + } + } + } +} diff --git a/server/middlewares/cors.go b/server/middlewares/cors.go new file mode 100644 index 0000000..2b804a0 --- /dev/null +++ b/server/middlewares/cors.go @@ -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) +} diff --git a/server/middlewares/init.go b/server/middlewares/init.go new file mode 100644 index 0000000..f2f3f98 --- /dev/null +++ b/server/middlewares/init.go @@ -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()) + } +} diff --git a/server/middlewares/quic.go b/server/middlewares/quic.go new file mode 100644 index 0000000..bb071ca --- /dev/null +++ b/server/middlewares/quic.go @@ -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())) + } +} diff --git a/server/router.go b/server/router.go new file mode 100644 index 0000000..075a79e --- /dev/null +++ b/server/router.go @@ -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 +} diff --git a/utils/utils.go b/utils/utils.go new file mode 100644 index 0000000..31396a3 --- /dev/null +++ b/utils/utils.go @@ -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) +} diff --git a/utils/websocket.go b/utils/websocket.go new file mode 100644 index 0000000..dc58010 --- /dev/null +++ b/utils/websocket.go @@ -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 +}