Refactor (#43)

* refactor

* unit test

* govendor -> go mod (Close: #37), go 1.12.9 -> 1.13

* add Referer header, fix zhanqi tv

* Fix bilibili live resolution

Missing 1080p resolution by default since bilibili updated API according to https://github.com/streamlink/streamlink/pull/2625

* recorder manager test, refine base live, refine cache

* String Filter
This commit is contained in:
Chigusa authored and GitHub committed 2019-11-04 16:31:47 +08:00
1 parent 9f101f7c9f
commit f53ff00ef6
559 files changed
+3473 -228903

No files matched your search

+16 -4
View File
@@ -1,8 +1,20 @@
language: go
sudo: required
go:
- master
- "1.13"
env:
- GO111MODULE=on
script:
- make
- make test
- make test
before_deploy:
- make release
deploy:
provider: releases
api_key:
secure: xfefGaJVPyP2y+wvtosX7c0YmNw85ANfCIrUyfIY56++XlKLNSTUl3l38pXV0D9/MZyk4jOj+UPqfPoUskjB9uX7oVLLH3X7ESh/XlqP0J+VVxpVtL+iYyZ4BkuxzolZFV8nMJ5X6XfUnzG4Z3FFJUagpQN4acfX+WAP2zw5ILGrdcgWwun3kLvi2SuybAApHhEzsty7wOdmczVQYKLkU066jtG2Zs1gPl1IVg4k/U8mVEgiHwZdsB4QTg01lL4+qPd9xC7f+EHY33FQ+o4MkO6ZKnR3VjKfS4ufJp6KoVdat/m9RP8ykDpVL3n0fKtu+bYWjvetxGroFm75es8lD5vKpXAE/8mKLI+MltJgknzJlOep/i38i+6SDHZhLLa+6uCgNSZxd7ie5f2rsrFHp7X8pnevuUS4jGU/XwKuoKF9fGRa3AA6w3QEgWkn+sjECk9zyk9c5ADNTmoNaux6kLjg+acTYQHpnIApQEsGd/WK0bdCibs93FFqPQrXpr29gtC+w3ivSSzevVSNLdDmwm6AgtJbNAS+1M/hXAQDo7pZ5sxpoXAuZ2c3jAgz7htEolz0QCZX3SwMEaBQuem5jMW/nbrFGfrhN6y4a4vYr9z3V+VAhdF5q+s67kHs7+p1uZai/d8uPu2e17Yj7sIDbPPk4LU/gsrJKCCP9bgcT3k=
skip_cleanup: true
file_glob: true
file: "bin/*"
on:
tags: true
+25 -91
View File
@@ -1,102 +1,36 @@
BIN_PATH=./bin
BIN_NAME=bililive-go
VERSION=$(shell git describe --tags --always)
BUILD_TIME=$(shell date '+%Y-%m-%d_%H:%M:%S')
GIT_HASH=$(shell git rev-parse HEAD)
CONSTS_PATH=github.com/hr3lxphr6j/bililive-go/src/consts
LD_FLAGS=-ldflags '-s -w -X ${CONSTS_PATH}.BuildTime=${BUILD_TIME} -X ${CONSTS_PATH}.AppVersion=${VERSION} -X ${CONSTS_PATH}.GitHash=${GIT_HASH}'
UPX=1
UPX_ENABLE ?= 0
PLATFORM ?= $(shell go env GOHOSTOS)
ARCH ?= $(shell go env GOHOSTARCH)
.PHONY: all
all: mkdir
go build -o '${BIN_PATH}/${BIN_NAME}' ${LD_FLAGS} ./
.PHONY: mkdir
mkdir:
@mkdir -p ${BIN_PATH}
.PHONY: $(notdir $(abspath $(wildcard src/cmd/*/)))
local_go_version := $(shell go version | cut -d' ' -f3 | sed -e 's/go//g')
$(notdir $(abspath $(wildcard src/cmd/*/))):
@echo "building $@ (Platform: $(PLATFORM), Arch: $(ARCH), GoVersion: $(local_go_version))"
@GOOS=$(PLATFORM) \
GOARCH=$(ARCH) \
CGO_ENABLED=0 \
GOFLAGS=$(GOFLAGS) \
UPX_ENABLE=$(UPX_ENABLE) \
./src/hack/build.sh $@
.PHONY: release-all-platform-package
release-all-platform-package: release-all-platform
for file in `ls ${BIN_PATH}`; \
do \
7z a ${BIN_PATH}/$${file%.*}.7z ${BIN_PATH}/$${file} ./config.yml; \
rm ${BIN_PATH}/$${file}; \
done
.PHONY: release-all-platform
release-all-platform: release-darwin-amd64 \
release-linux-386 \
release-linux-amd64 \
release-linux-arm \
release-linux-arm64 \
release-windows-386 \
release-windows-amd64
.PHONY: release-darwin-amd64
release-darwin-amd64: mkdir
CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build ${LD_FLAGS} -o ${BIN_PATH}/${BIN_NAME}-darwin-amd64
@if [ ${UPX} -eq 1 ]; \
then \
upx --no-progress ${BIN_PATH}/${BIN_NAME}-darwin-amd64; \
fi
.PHONY: release-linux-386
release-linux-386: mkdir
CGO_ENABLED=0 GOOS=linux GOARCH=386 go build ${LD_FLAGS} -o ${BIN_PATH}/${BIN_NAME}-linux-386
@if [ ${UPX} -eq 1 ]; \
then \
upx --no-progress ${BIN_PATH}/${BIN_NAME}-linux-386; \
fi
.PHONY: release-linux-amd64
release-linux-amd64: mkdir
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build ${LD_FLAGS} -o ${BIN_PATH}/${BIN_NAME}-linux-amd64
@if [ ${UPX} -eq 1 ]; \
then \
upx --no-progress ${BIN_PATH}/${BIN_NAME}-linux-amd64; \
fi
.PHONY: release-linux-arm
release-linux-arm: mkdir
CGO_ENABLED=0 GOOS=linux GOARCH=arm go build ${LD_FLAGS} -o ${BIN_PATH}/${BIN_NAME}-linux-arm
@if [ ${UPX} -eq 1 ]; \
then \
upx --no-progress ${BIN_PATH}/${BIN_NAME}-linux-arm; \
fi
.PHONY: release-linux-arm64
release-linux-arm64: mkdir
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build ${LD_FLAGS} -o ${BIN_PATH}/${BIN_NAME}-linux-arm64
@if [ ${UPX} -eq 1 ]; \
then \
upx --no-progress ${BIN_PATH}/${BIN_NAME}-linux-arm64; \
fi
.PHONY: release-windows-386
release-windows-386: mkdir
CGO_ENABLED=0 GOOS=windows GOARCH=386 go build ${LD_FLAGS} -o ${BIN_PATH}/${BIN_NAME}-windows-386.exe
@if [ ${UPX} -eq 1 ]; \
then \
upx --no-progress ${BIN_PATH}/${BIN_NAME}-windows-386.exe; \
fi
.PHONY: release-windows-amd64
release-windows-amd64: mkdir
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build ${LD_FLAGS} -o ${BIN_PATH}/${BIN_NAME}-windows-amd64.exe
@if [ ${UPX} -eq 1 ]; \
then \
upx --no-progress ${BIN_PATH}/${BIN_NAME}-windows-amd64.exe; \
fi
.PHONY: release
release:
@./src/hack/release.sh
.PHONY: release-docker
release-docker: release-linux-amd64
docker build -t 'chigusa/bililive-go' .
release-docker: clean
@./src/hack/release-docker.sh
.PHONY: test
test:
@go test --cover ./src/api
@go test --cover ./src/...
.PHONY: clean
clean:
@rm -rf ${BIN_PATH}
@echo "All clean"
@rm -rf bin
@echo "All clean"
.PHONY: generate
generate:
go generate ./...
-140
View File
@@ -1,140 +0,0 @@
package main
import (
"context"
"fmt"
"net/url"
"os"
"os/signal"
"syscall"
"github.com/alecthomas/kingpin"
"github.com/hr3lxphr6j/bililive-go/src/api"
"github.com/hr3lxphr6j/bililive-go/src/configs"
"github.com/hr3lxphr6j/bililive-go/src/consts"
"github.com/hr3lxphr6j/bililive-go/src/instance"
"github.com/hr3lxphr6j/bililive-go/src/lib/events"
"github.com/hr3lxphr6j/bililive-go/src/lib/utils"
"github.com/hr3lxphr6j/bililive-go/src/listeners"
"github.com/hr3lxphr6j/bililive-go/src/log"
"github.com/hr3lxphr6j/bililive-go/src/recorders"
"github.com/hr3lxphr6j/bililive-go/src/servers"
)
var (
app = kingpin.New(consts.AppName, "A command-line live stream save tools.").Version(consts.AppVersion)
debug = app.Flag("debug", "Enable debug mode.").Default("false").Bool()
interval = app.Flag("interval", "Interval of query live status").Default("20").Short('t').Int()
output = app.Flag("output", "Output file path.").Short('o').Default("./").String()
input = app.Flag("input", "Live room urls").Short('i').Strings()
conf = app.Flag("config", "Config file.").Short('c').String()
rpc = app.Flag("enable-rpc", "Enable RPC server.").Default("false").Bool()
rpcAddr = app.Flag("rpc-addr", "RPC server listen port").Default(":8080").String()
rpcToken = app.Flag("rpc-token", "RPC server token.").String()
rpcTLS = app.Flag("enable-rpc-tls", "Enable TLS for RPC server").Bool()
certFile = app.Flag("rpc-tls-cert-file", "Cert file for TLS on RPC").String()
keyFile = app.Flag("rpc-tls-key-file", "Key file for TLS on RPC").String()
)
func getConfig() (*configs.Config, error) {
kingpin.MustParse(app.Parse(os.Args[1:]))
var config *configs.Config
if *conf != "" {
if c, err := configs.NewConfigWithFile(*conf); err == nil {
config = c
} else {
return nil, err
}
} else {
config = &configs.Config{
RPC: configs.RPC{
Enable: *rpc,
Port: *rpcAddr,
Token: *rpcToken,
TLS: configs.TLS{
Enable: *rpcTLS,
CertFile: *certFile,
KeyFile: *keyFile,
},
},
Debug: *debug,
Interval: *interval,
OutPutPath: *output,
LiveRooms: *input,
}
}
if err := configs.VerifyConfig(config); err != nil {
return nil, err
}
return config, nil
}
func main() {
if !utils.IsFFmpegExist() {
fmt.Fprintf(os.Stderr, "FFmpeg binary not found, Please Check.\n")
os.Exit(3)
}
config, err := getConfig()
if err != nil {
fmt.Fprint(os.Stderr, err.Error())
os.Exit(1)
}
inst := new(instance.Instance)
inst.Config = config
ctx := context.WithValue(context.Background(), instance.InstanceKey, inst)
logger := log.NewLogger(ctx)
logger.Infof("%s Version: %s Link Start", consts.AppName, consts.AppVersion)
logger.Debugf("%+v", consts.AppInfo)
logger.Debugf("%+v", inst.Config)
events.NewIEventDispatcher(ctx)
inst.Lives = make(map[api.LiveId]api.Live)
for _, room := range inst.Config.LiveRooms {
u, err := url.Parse(room)
if err != nil {
logger.WithField("url", room).Error(err)
}
if l, err := api.NewLive(u); err == nil {
if _, ok := inst.Lives[l.GetLiveId()]; ok {
logger.Errorf("%s is exist!", room)
} else {
inst.Lives[l.GetLiveId()] = l
}
} else {
logger.WithField("url", room).Error(err.Error())
}
}
if inst.Config.RPC.Enable {
servers.NewServer(ctx).Start(ctx)
}
lm := listeners.NewIListenerManager(ctx)
recorders.NewIRecorderManager(ctx)
inst.ListenerManager.Start(ctx)
inst.RecorderManager.Start(ctx)
for _, live := range inst.Lives {
if err := lm.AddListener(ctx, live); err != nil {
logger.WithFields(map[string]interface{}{"url": live.GetRawUrl()}).Error(err)
}
}
c := make(chan os.Signal)
signal.Notify(c, syscall.SIGHUP, syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT)
go func() {
<-c
if inst.Config.RPC.Enable {
inst.Server.Close(ctx)
}
inst.ListenerManager.Close(ctx)
inst.RecorderManager.Close(ctx)
}()
inst.WaitGroup.Wait()
logger.Info("Bye~")
}
+1 -1
View File
@@ -8,7 +8,7 @@ rpc:
key_file: ""
debug: false
interval: 15
out_put_path: /Users/chigusa/Movies
out_put_path: ./
feature:
use_native_flv_parser: false
live_rooms:
+27
View File
@@ -0,0 +1,27 @@
module github.com/hr3lxphr6j/bililive-go
go 1.13
require (
github.com/alecthomas/kingpin v2.2.7-0.20180312062423-a39589180ebd+incompatible
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc // indirect
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf // indirect
github.com/bluele/gcache v0.0.0-20190518031135-bc40bd653833
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/golang/mock v1.3.2-0.20190713102442-dd8d2a22370e
github.com/gorilla/context v1.1.1 // indirect
github.com/gorilla/mux v1.6.2-0.20180501031136-ded0c29b24f9
github.com/onsi/ginkgo v1.10.1 // indirect
github.com/onsi/gomega v1.7.0 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/robertkrimen/otto v0.0.0-20180617131154-15f95af6e78d
github.com/satori/go.uuid v1.2.1-0.20181028125025-b2ce2384e17b
github.com/sirupsen/logrus v1.0.6-0.20180329225952-778f2e774c72
github.com/stretchr/testify v1.4.0
github.com/tidwall/gjson v1.0.6
github.com/tidwall/match v1.0.0 // indirect
gopkg.in/airbrake/gobrake.v2 v2.0.9 // indirect
gopkg.in/gemnasium/logrus-airbrake-hook.v2 v2.1.2 // indirect
gopkg.in/sourcemap.v1 v1.0.5 // indirect
gopkg.in/yaml.v2 v2.2.2
)
+73
View File
@@ -0,0 +1,73 @@
github.com/alecthomas/kingpin v2.2.7-0.20180312062423-a39589180ebd+incompatible h1:aDLzaG6QPT5c7ug8eTPaNbWqyEI9dzMY9OzPrlKydSw=
github.com/alecthomas/kingpin v2.2.7-0.20180312062423-a39589180ebd+incompatible/go.mod h1:59OFYbFVLKQKq+mqrL6Rw5bR0c3ACQaawgXx0QYndlE=
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc h1:cAKDfWh5VpdgMhJosfJnn5/FoN2SRZ4p7fJNX58YPaU=
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf h1:qet1QNfXsQxTZqLG4oE62mJzwPIB8+Tee4RNCL9ulrY=
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/bluele/gcache v0.0.0-20190518031135-bc40bd653833 h1:yCfXxYaelOyqnia8F/Yng47qhmfC9nKTRIbYRrRueq4=
github.com/bluele/gcache v0.0.0-20190518031135-bc40bd653833/go.mod h1:8c4/i2VlovMO2gBnHGQPN5EJw+H0lx1u/5p+cgsXtCk=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/golang/mock v1.3.2-0.20190713102442-dd8d2a22370e h1:M0vqwaXA0SBjFssWzqd+KxcqD/JKc+fnwq+q89DLYuk=
github.com/golang/mock v1.3.2-0.20190713102442-dd8d2a22370e/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y=
github.com/golang/protobuf v1.2.0 h1:P3YflyNX/ehuJFLhxviNdFxQPkGK5cDcApsge1SqnvM=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/gorilla/context v1.1.1 h1:AWwleXJkX/nhcU9bZSnZoi3h/qGYqQAGhq6zZe/aQW8=
github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg=
github.com/gorilla/mux v1.6.2-0.20180501031136-ded0c29b24f9 h1:PaHZOkKXjo5QHqXwKMjDQz8fwkcxqTXgEs+nyOF9M2I=
github.com/gorilla/mux v1.6.2-0.20180501031136-ded0c29b24f9/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.10.1 h1:q/mM8GF/n0shIN8SaAZ0V+jnLPzen6WIVZdiwrRlMlo=
github.com/onsi/ginkgo v1.10.1/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/gomega v1.7.0 h1:XPnZz8VVBHjVsy1vzJmRwIcSwiUO+JFfrv/xGiigmME=
github.com/onsi/gomega v1.7.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/robertkrimen/otto v0.0.0-20180617131154-15f95af6e78d h1:1VUlQbCfkoSGv7qP7Y+ro3ap1P1pPZxgdGVqiTVy5C4=
github.com/robertkrimen/otto v0.0.0-20180617131154-15f95af6e78d/go.mod h1:xvqspoSXJTIpemEonrMDFq6XzwHYYgToXWj5eRX1OtY=
github.com/satori/go.uuid v1.2.1-0.20181028125025-b2ce2384e17b h1:gQZ0qzfKHQIybLANtM3mBXNUtOfsCFXeTsnBqCsx1KM=
github.com/satori/go.uuid v1.2.1-0.20181028125025-b2ce2384e17b/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
github.com/sirupsen/logrus v1.0.6-0.20180329225952-778f2e774c72 h1:Q+ZnVIwiN5ZgpKd4h/7rUiJgD5pQx6WYlcvHw4yUaAs=
github.com/sirupsen/logrus v1.0.6-0.20180329225952-778f2e774c72/go.mod h1:pMByvHTf9Beacp5x1UXfOR9xyW/9antXMhjMPG0dEzc=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/tidwall/gjson v1.0.6 h1:HYH2srVkCC53232wJXWptchlbF1n2JxJBmM8i1/bLXU=
github.com/tidwall/gjson v1.0.6/go.mod h1:c/nTNbUr0E0OrXEhq1pwa8iEgc2DOt4ZZqAt1HtCkPA=
github.com/tidwall/match v1.0.0 h1:Ym1EcFkp+UQ4ptxfWlW+iMdq5cPH5nEuGzdf/Pb7VmI=
github.com/tidwall/match v1.0.0/go.mod h1:LujAq0jyVjBy028G1WhWfIzbpQfMO8bBZ6Tyb0+pL9E=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2 h1:VklqNMn3ovrHsnt90PveolxSbWFaJdECFbxSq0Mqo2M=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a h1:oWX7TPOiFAMXLq8o0ikBYfCJVlRHBcsciT5bXOrH628=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58 h1:8gQV6CLnAEikrhgkHFbMAEhagSSnXWGV915qUMm9mrU=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a h1:1BGLXjeY4akVXGgbC9HugT3Jv3hCI0z56oJR5vAMgBU=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
gopkg.in/airbrake/gobrake.v2 v2.0.9 h1:7z2uVWwn7oVeeugY1DtlPAy5H+KYgB1KeKTnqjNatLo=
gopkg.in/airbrake/gobrake.v2 v2.0.9/go.mod h1:/h5ZAUhDkGaJfjzjKLSjv6zCL6O0LLBxU4K+aSYdM/U=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/gemnasium/logrus-airbrake-hook.v2 v2.1.2 h1:OAj3g0cR6Dx/R07QgQe8wkA9RNjB2u4i700xBkIT4e0=
gopkg.in/gemnasium/logrus-airbrake-hook.v2 v2.1.2/go.mod h1:Xk6kEKp8OKb+X14hQBKWaSkCsqBpgog8nAV2xsGOxlo=
gopkg.in/sourcemap.v1 v1.0.5 h1:inv58fC9f9J3TK2Y2R1NPntXEn3/wjWHkonhIUODNTI=
gopkg.in/sourcemap.v1 v1.0.5/go.mod h1:2RlvNNSMglmRrcvhfuzp4hQHwOtjxlbjX7UPY/GXb78=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
-47
View File
@@ -1,47 +0,0 @@
package api
import (
"net/url"
"time"
)
type abstractLive struct {
Url *url.URL
lastStartTime time.Time
cachedInfo *Info
liveId LiveId
}
func (a *abstractLive) GetLiveId() LiveId {
return a.liveId
}
func (a *abstractLive) GetRawUrl() string {
return a.Url.String()
}
func (a *abstractLive) GetCachedInfo() *Info {
return a.cachedInfo
}
func (a *abstractLive) GetInfoMap() map[string]interface{} {
return map[string]interface{}{
"id": a.GetLiveId(),
"url": a.GetRawUrl(),
"host_name": a.GetCachedInfo().HostName,
"room_name": a.GetCachedInfo().RoomName,
"status": a.GetCachedInfo().Status,
}
}
func (a *abstractLive) GetPlatformCNName() string {
return LivePlatformCNNameMap[a.Url.Host]
}
func (a *abstractLive) GetLastStartTime() time.Time {
return a.lastStartTime
}
func (a *abstractLive) SetLastStartTime(time time.Time) {
a.lastStartTime = time
}
-108
View File
@@ -1,108 +0,0 @@
package api
import (
"net/url"
"strings"
"github.com/tidwall/gjson"
"github.com/hr3lxphr6j/bililive-go/src/lib/http"
)
const (
biliBiliRoomInitUrl = "https://api.live.bilibili.com/room/v1/Room/room_init"
biliBiliRoomApiUrl = "https://api.live.bilibili.com/room/v1/Room/get_info"
biliBiliUserApiUrl = "https://api.live.bilibili.com/live_user/v1/UserInfo/get_anchor_in_room"
biliBiliLiveApiUrl = "https://api.live.bilibili.com/room/v1/Room/playUrl"
)
type BiliBiliLive struct {
abstractLive
realId string
}
func (b *BiliBiliLive) parseRealId() error {
if body, err := http.Get(biliBiliRoomInitUrl, map[string]string{"id": strings.Split(b.Url.Path, "/")[1]}, nil); err != nil {
return err
} else {
if gjson.GetBytes(body, "code").Int() != 0 {
return &RoomNotExistsError{b.Url}
} else {
b.realId = gjson.GetBytes(body, "data.room_id").String()
}
return nil
}
}
func (b *BiliBiliLive) GetInfo() (info *Info, err error) {
defer func() {
if e := recover(); e != nil {
err = e.(error)
}
}()
// Parse the short id from URL to full id
if b.realId == "" {
if err := b.parseRealId(); err != nil {
return nil, err
}
}
body, err := http.Get(biliBiliRoomApiUrl, map[string]string{
"room_id": b.realId,
"from": "room",
}, nil)
if err != nil {
return nil, err
}
if gjson.GetBytes(body, "code").Int() != 0 {
return nil, &RoomNotExistsError{b.Url}
}
info = &Info{
Live: b,
RoomName: gjson.GetBytes(body, "data.title").String(),
Status: gjson.GetBytes(body, "data.live_status").Int() == 1,
}
body2, err := http.Get(biliBiliUserApiUrl, map[string]string{
"roomid": b.realId,
}, nil)
if err != nil {
return nil, err
}
info.HostName = gjson.GetBytes(body2, "data.info.uname").String()
b.cachedInfo = info
return info, nil
}
func (b *BiliBiliLive) GetStreamUrls() (us []*url.URL, err error) {
defer func() {
if e := recover(); e != nil {
err = e.(error)
}
}()
if b.realId == "" {
if err := b.parseRealId(); err != nil {
return nil, err
}
}
body, err := http.Get(biliBiliLiveApiUrl, map[string]string{
"cid": b.realId,
"quality": "4",
"platform": "web",
}, nil)
if err != nil {
return nil, err
}
urls := gjson.GetBytes(body, "data.durl.#.url").Array()
us = make([]*url.URL, 0, 4)
for _, u := range urls {
url_, _ := url.Parse(u.String())
us = append(us, url_)
}
return us, nil
}
-20
View File
@@ -1,20 +0,0 @@
package api
import (
"net/url"
"testing"
)
const biliBiliTestUrl = "https://live.bilibili.com/161"
func TestBiliBiliLive_GetInfo(t *testing.T) {
u, _ := url.Parse(biliBiliTestUrl)
live, _ := NewLive(u)
t.Log(live.GetInfo())
}
func TestBiliBiliLive_GetStreamUrls(t *testing.T) {
u, _ := url.Parse(biliBiliTestUrl)
live, _ := NewLive(u)
t.Log(live.GetStreamUrls())
}
-59
View File
@@ -1,59 +0,0 @@
package api
import (
"fmt"
"net/url"
"regexp"
"github.com/tidwall/gjson"
"github.com/hr3lxphr6j/bililive-go/src/lib/http"
)
const (
ccLiveApiUrl = "http://cgi.v.cc.163.com/video_play_url/"
)
type CCLive struct {
abstractLive
ccid string
}
func (c *CCLive) GetInfo() (info *Info, err error) {
defer func() {
if e := recover(); e != nil {
err = e.(error)
}
}()
dom, err := http.Get(c.Url.String(), nil, nil)
if err != nil {
return nil, err
}
c.ccid = regexp.MustCompile(`anchorCcId\s*:\s*'(\d*)'`).FindStringSubmatch(string(dom))[1]
info = &Info{
Live: c,
HostName: regexp.MustCompile(`anchorName\s*:\s*'([^']*)',`).FindStringSubmatch(string(dom))[1],
RoomName: regexp.MustCompile(`js-live-title nick" title\s*=\s*"([^"]*)"`).FindStringSubmatch(string(dom))[1],
Status: len(regexp.MustCompile(`isLive\s*:\s*\d+,`).FindAll(dom, -1)) > 0,
}
c.cachedInfo = info
return info, nil
}
func (c *CCLive) GetStreamUrls() (us []*url.URL, err error) {
defer func() {
if e := recover(); e != nil {
err = e.(error)
}
}()
data, err := http.Get(fmt.Sprintf("%s%s", ccLiveApiUrl, c.ccid), nil, nil)
if err != nil {
return nil, err
}
us = make([]*url.URL, 0)
u0, _ := url.Parse(gjson.GetBytes(data, "videourl").String())
u1, _ := url.Parse(gjson.GetBytes(data, "bakvideourl").String())
us = append(us, u0, u1)
return us, nil
}
-20
View File
@@ -1,20 +0,0 @@
package api
import (
"net/url"
"testing"
)
const ccTestUrl = "https://cc.163.com/361433"
func TestCCLive_GetInfo(t *testing.T) {
u, _ := url.Parse(ccTestUrl)
live, _ := NewLive(u)
t.Log(live.GetInfo())
}
func TestCCLive_GetStreamUrls(t *testing.T) {
u, _ := url.Parse(ccTestUrl)
live, _ := NewLive(u)
t.Log(live.GetStreamUrls())
}
-20
View File
@@ -1,20 +0,0 @@
package api
import (
"net/url"
"testing"
)
const douyuTestUrl = "https://www.douyu.com/6655"
func TestDouyuLive_GetInfo(t *testing.T) {
u, _ := url.Parse(douyuTestUrl)
live, _ := NewLive(u)
t.Log(live.GetInfo())
}
func TestDouyuLive_GetStreamUrls(t *testing.T) {
u, _ := url.Parse(douyuTestUrl)
live, _ := NewLive(u)
t.Log(live.GetStreamUrls())
}
-18
View File
@@ -1,18 +0,0 @@
package api
import (
"net/url"
)
type RoomNotExistsError struct {
Url *url.URL
}
func (e *RoomNotExistsError) Error() string {
return "room not exists"
}
func IsRoomNotExistsError(err error) bool {
_, ok := err.(*RoomNotExistsError)
return ok
}
-92
View File
@@ -1,92 +0,0 @@
package api
import (
"fmt"
"net/url"
"regexp"
"time"
"github.com/tidwall/gjson"
"github.com/hr3lxphr6j/bililive-go/src/lib/http"
"github.com/hr3lxphr6j/bililive-go/src/lib/utils"
)
const (
huomaoLiveApiUrl = "http://www.huomao.com/swf/live_data"
huomaoSalt = "6FE26D855E1AEAE090E243EB1AF73685"
)
type HuoMaoLive struct {
abstractLive
isDuanbo bool
}
func (h *HuoMaoLive) GetInfo() (info *Info, err error) {
defer func() {
if e := recover(); e != nil {
err = e.(error)
}
}()
dom, err := http.Get(h.Url.String(), nil, nil)
if err != nil {
return nil, err
}
h.isDuanbo = regexp.MustCompile(`face_label\s?=\s?(\d*);`).FindStringSubmatch(string(dom))[1] == "1"
var hostNameReg string
var roomNameReg string
var statusReg string
if h.isDuanbo {
hostNameReg = `live_yz_h_nickName\s?=\s?"([^"]*)";`
roomNameReg = `live_yz_h_channelName\s?=\s?"([^"]*)";`
statusReg = `is_live\s?=\s?"?(\d*)"?;`
} else {
hostNameReg = `"nickname":"([^"]*)",`
roomNameReg = `"channel":"([^"]*)"`
statusReg = `"is_live":"?(\d*)"?,`
}
info = &Info{
Live: h,
HostName: utils.ParseUnicode(regexp.MustCompile(hostNameReg).FindStringSubmatch(string(dom))[1]),
RoomName: utils.ParseUnicode(regexp.MustCompile(roomNameReg).FindStringSubmatch(string(dom))[1]),
Status: utils.ParseUnicode(regexp.MustCompile(statusReg).FindStringSubmatch(string(dom))[1]) == "1",
}
h.cachedInfo = info
return info, nil
}
func (h *HuoMaoLive) GetStreamUrls() ([]*url.URL, error) {
dom, err := http.Get(h.Url.String(), nil, nil)
if err != nil {
return nil, err
}
var streamReg string
if !h.isDuanbo {
streamReg = `"stream":"([^"]*)"`
} else {
streamReg = `getFlash\("\d*","([^"]*)","\d*"\);`
}
from := "huomaoh5room"
t := fmt.Sprintf("%d", time.Now().Unix())
streamID := regexp.MustCompile(streamReg).FindStringSubmatch(string(dom))[1]
token := utils.GetMd5String([]byte(fmt.Sprintf("%s%s%s%s", streamID, from, t, huomaoSalt)))
body, err := http.Post(huomaoLiveApiUrl, map[string]string{
"VideoIDS": streamID,
"streamtype": "live",
"cdns": "1",
"from": from,
"time": t,
"token": token,
}, nil, nil)
us := make([]*url.URL, 0, 4)
gjson.GetBytes(body, "streamList.#.list.#.url").ForEach(func(key, value gjson.Result) bool {
for _, u := range value.Array() {
url_, _ := url.Parse(u.String())
us = append(us, url_)
}
return true
})
return us, nil
}
-23
View File
@@ -1,23 +0,0 @@
package api
import (
"net/url"
"testing"
)
const (
huomaoTestUrl = "https://www.huomao.com/6710" // 正常直播间
huomaoTestUrl2 = "https://www.huomao.com/954927" // 娱乐直播间
)
func TestHuoMaoLive_GetInfo(t *testing.T) {
u, _ := url.Parse(huomaoTestUrl2)
live, _ := NewLive(u)
t.Log(live.GetInfo())
}
func TestHuoMaoLive_GetStreamUrls(t *testing.T) {
u, _ := url.Parse(huomaoTestUrl)
live, _ := NewLive(u)
t.Log(live.GetStreamUrls())
}
-71
View File
@@ -1,71 +0,0 @@
package api
import (
"fmt"
"math/rand"
"net/url"
"regexp"
"strings"
"time"
"github.com/hr3lxphr6j/bililive-go/src/lib/http"
"github.com/hr3lxphr6j/bililive-go/src/lib/utils"
)
type HuYaLive struct {
abstractLive
}
func (h *HuYaLive) GetInfo() (info *Info, err error) {
defer func() {
if e := recover(); e != nil {
err = e.(error)
}
}()
dom, err := http.Get(h.Url.String(), nil, nil)
if err != nil {
return nil, err
}
if res := regexp.MustCompile("哎呀,虎牙君找不到这个主播,要不搜索看看?").FindStringSubmatch(string(dom)); res != nil {
return nil, &RoomNotExistsError{h.Url}
}
info = &Info{
Live: h,
HostName: utils.ParseUnicode(regexp.MustCompile(`"nick":"([^"]*)"`).FindStringSubmatch(string(dom))[1]),
RoomName: utils.ParseUnicode(regexp.MustCompile(`"introduction":"([^"]*)"`).FindStringSubmatch(string(dom))[1]),
Status: utils.ParseUnicode(regexp.MustCompile(`"isOn":([^,]*),`).FindStringSubmatch(string(dom))[1]) == "true",
}
h.cachedInfo = info
return info, nil
}
func (h *HuYaLive) GetStreamUrls() (us []*url.URL, err error) {
defer func() {
if e := recover(); e != nil {
err = e.(error)
}
}()
dom, err := http.Get(h.Url.String(), nil, nil)
if err != nil {
return nil, err
}
sStreamName := regexp.MustCompile(`"sStreamName":"([^"]*)"`).FindStringSubmatch(string(dom))[1]
sFlvUrl := strings.Replace(regexp.MustCompile(`"sFlvUrl":"([^"]*)"`).FindStringSubmatch(string(dom))[1], `\/`, `/`, -1)
sFlvAntiCode := regexp.MustCompile(`"sFlvAntiCode":"([^"]*)"`).FindStringSubmatch(string(dom))[1]
iLineIndex := regexp.MustCompile(`"iLineIndex":(\d*),`).FindStringSubmatch(string(dom))[1]
uid := (time.Now().Unix()%1e7*1e6 + int64(1e3*rand.Float64())) % 4294967295
u, err := url.Parse(fmt.Sprintf("%s/%s.flv", sFlvUrl, sStreamName))
if err != nil {
return nil, err
}
value := &url.Values{}
value.Add("line", iLineIndex)
value.Add("p2p", "0")
value.Add("type", "web")
value.Add("ver", "1805071653")
value.Add("uid", fmt.Sprintf("%d", uid))
u.RawQuery = fmt.Sprintf("%s&%s", value.Encode(), strings.ReplaceAll(sFlvAntiCode, "&amp;", "&"))
return []*url.URL{u}, nil
}
-23
View File
@@ -1,23 +0,0 @@
package api
import (
"net/url"
"testing"
)
const huyaTestUrl = "https://www.huya.com/wexiamo"
func TestHuYaLive_GetInfo(t *testing.T) {
u, _ := url.Parse(huyaTestUrl)
live, _ := NewLive(u)
t.Log(live.GetInfo())
}
func TestHuYaLive_GetStreamUrls(t *testing.T) {
u, _ := url.Parse(huyaTestUrl)
live, _ := NewLive(u)
info, _ := live.GetInfo()
if info.Status {
t.Log(live.GetStreamUrls())
}
}
-121
View File
@@ -1,121 +0,0 @@
package api
import (
"encoding/json"
"errors"
"fmt"
"net/url"
"time"
"github.com/hr3lxphr6j/bililive-go/src/lib/utils"
)
type LiveId string
var LivePlatformCNNameMap = map[string]string{
"live.bilibili.com": "哔哩哔哩",
"www.zhanqi.tv": "战旗",
"www.douyu.com": "斗鱼",
"star.longzhu.com": "龙珠",
"www.huomao.com": "火猫",
"www.yizhibo.com": "一直播",
"www.twitch.tv": "twitch",
"www.huya.com": "虎牙",
"cc.163.com": "CC直播",
"www.openrec.tv": "openrec",
"egame.qq.com": "企鹅电竞",
}
type Info struct {
Live Live
HostName, RoomName string
Status, Listening, Recoding bool
}
func (i *Info) MarshalJSON() ([]byte, error) {
t := struct {
Id LiveId `json:"id"`
LiveUrl string `json:"live_url"`
PlatformCNName string `json:"platform_cn_name"`
HostName string `json:"host_name"`
RoomName string `json:"room_name"`
Status bool `json:"status"`
Listening bool `json:"listening"`
Recoding bool `json:"recoding"`
LastStartTime string `json:"last_start_time,omitempty"`
LastStartTimeUnix int64 `json:"last_start_time_unix,omitempty"`
}{
Id: i.Live.GetLiveId(),
LiveUrl: i.Live.GetRawUrl(),
PlatformCNName: i.Live.GetPlatformCNName(),
HostName: i.HostName,
RoomName: i.RoomName,
Status: i.Status,
Listening: i.Listening,
Recoding: i.Recoding,
}
if !i.Live.GetLastStartTime().IsZero() {
t.LastStartTime = i.Live.GetLastStartTime().Format("2006-01-02 15:04:05")
t.LastStartTimeUnix = i.Live.GetLastStartTime().Unix()
}
return json.Marshal(t)
}
type Live interface {
GetLiveId() LiveId
GetRawUrl() string
GetInfo() (*Info, error)
GetInfoMap() map[string]interface{}
GetCachedInfo() *Info
GetStreamUrls() ([]*url.URL, error)
GetPlatformCNName() string
GetLastStartTime() time.Time
SetLastStartTime(time.Time)
}
func NewLive(url *url.URL) (Live, error) {
baseLive := abstractLive{
Url: url,
liveId: LiveId(utils.GetMd5String([]byte(fmt.Sprintf("%s%s", url.Host, url.Path)))),
}
var live Live
switch url.Host {
case "live.bilibili.com":
live = &BiliBiliLive{abstractLive: baseLive}
case "www.zhanqi.tv":
live = &ZhanQiLive{abstractLive: baseLive}
case "www.douyu.com":
live = &DouyuLive{abstractLive: baseLive}
case "star.longzhu.com":
live = &LongzhuLive{abstractLive: baseLive}
case "www.huomao.com":
live = &HuoMaoLive{abstractLive: baseLive}
case "www.yizhibo.com":
live = &YiZhiBoLive{abstractLive: baseLive}
case "www.twitch.tv":
live = &TwitchLive{abstractLive: baseLive}
case "www.huya.com":
live = &HuYaLive{abstractLive: baseLive}
case "cc.163.com":
live = &CCLive{abstractLive: baseLive}
case "www.openrec.tv":
live = &OpenRecLive{abstractLive: baseLive}
case "egame.qq.com":
live = &QQLive{abstractLive: baseLive}
default:
live = nil
}
if live != nil {
for i := 0; i < 3; i++ {
if _, err := live.GetInfo(); err != nil {
if IsRoomNotExistsError(err) {
return nil, err
}
time.Sleep(1 * time.Second)
} else {
return live, nil
}
}
}
return nil, errors.New("can not parse")
}
-89
View File
@@ -1,89 +0,0 @@
package api
import (
"fmt"
"net/url"
"regexp"
"strings"
"github.com/tidwall/gjson"
"github.com/hr3lxphr6j/bililive-go/src/lib/http"
)
const (
longzhuMobileUrl = "http://m.longzhu.com/"
longzhuRoomApiUrl = "http://liveapi.plu.cn/liveapp/roomstatus"
longzhuLiveApiUrl = "http://livestream.plu.cn/live/getlivePlayurl"
)
type LongzhuLive struct {
abstractLive
realId string
}
func (l *LongzhuLive) parseRealId() error {
dom, err := http.Get(fmt.Sprintf("%s%s", longzhuMobileUrl, strings.Split(l.Url.Path, "/")[1]), nil, nil)
if err != nil {
return err
}
reg := regexp.MustCompile(`var\s*roomId\s*=\s*(\d+);`)
realIds := reg.FindStringSubmatch(string(dom))
if realIds == nil {
return &RoomNotExistsError{l.Url}
}
l.realId = realIds[1]
return nil
}
func (l *LongzhuLive) GetInfo() (info *Info, err error) {
defer func() {
if e := recover(); e != nil {
err = e.(error)
}
}()
if l.realId == "" {
if err := l.parseRealId(); err != nil {
return nil, err
}
}
body, err := http.Get(longzhuRoomApiUrl, map[string]string{"roomId": l.realId}, nil)
if err != nil {
return nil, err
}
info = &Info{
Live: l,
HostName: gjson.GetBytes(body, "userName").String(),
RoomName: gjson.GetBytes(body, "title").String(),
Status: len(gjson.GetBytes(body, "streamUri").String()) > 4,
}
l.cachedInfo = info
return info, nil
}
func (l *LongzhuLive) GetStreamUrls() (us []*url.URL, err error) {
defer func() {
if e := recover(); e != nil {
err = e.(error)
}
}()
if l.realId == "" {
if err := l.parseRealId(); err != nil {
return nil, err
}
}
body, err := http.Get(longzhuLiveApiUrl, map[string]string{"roomId": l.realId}, nil)
if err != nil {
return nil, err
}
urls := gjson.GetBytes(body, "playLines.0.urls.#.securityUrl").Array()
us = make([]*url.URL, 0, 4)
for _, u := range urls {
url_, _ := url.Parse(u.String())
us = append(us, url_)
}
return us, nil
}
-20
View File
@@ -1,20 +0,0 @@
package api
import (
"net/url"
"testing"
)
const longzhuTestUrl = "http://star.longzhu.com/777777"
func TestLongzhuLive_GetInfo(t *testing.T) {
u, _ := url.Parse(longzhuTestUrl)
live, _ := NewLive(u)
t.Log(live.GetInfo())
}
func TestLongzhuLive_GetStreamUrls(t *testing.T) {
u, _ := url.Parse(longzhuTestUrl)
live, _ := NewLive(u)
t.Log(live.GetStreamUrls())
}
-51
View File
@@ -1,51 +0,0 @@
package api
import (
"net/url"
"regexp"
"strings"
"github.com/hr3lxphr6j/bililive-go/src/lib/http"
"github.com/hr3lxphr6j/bililive-go/src/lib/utils"
)
type OpenRecLive struct {
abstractLive
}
func (o *OpenRecLive) GetInfo() (info *Info, err error) {
defer func() {
if e := recover(); e != nil {
err = e.(error)
}
}()
dom, err := http.Get(o.Url.String(), nil, nil)
if err != nil {
return nil, err
}
info = &Info{
Live: o,
RoomName: strings.TrimSpace(regexp.MustCompile(`"title":"([^:]*)",`).FindStringSubmatch(string(dom))[1]),
HostName: utils.ParseUnicode(regexp.MustCompile(`"name":"([^:]*)",`).FindStringSubmatch(string(dom))[1]),
Status: regexp.MustCompile(`"onairStatus":(\d),`).FindStringSubmatch(string(dom))[1] == "1",
}
o.cachedInfo = info
return info, nil
}
func (o *OpenRecLive) GetStreamUrls() (us []*url.URL, err error) {
defer func() {
if e := recover(); e != nil {
err = e.(error)
}
}()
dom, err := http.Get(o.Url.String(), nil, nil)
if err != nil {
return nil, err
}
u1, _ := url.Parse(regexp.MustCompile(`{"url":"(\S*m3u8)",`).FindStringSubmatch(string(dom))[1])
us = []*url.URL{u1}
return us, nil
}
-22
View File
@@ -1,22 +0,0 @@
package api
import (
"net/url"
"testing"
)
const openrecTestUrl = "https://www.openrec.tv/live/JEmT0qQP1BM"
func TestOpenRecLive_GetInfo(t *testing.T) {
u, _ := url.Parse(openrecTestUrl)
live, _ := NewLive(u)
t.Log(live.GetInfo())
}
func TestOpenRecLive_GetStreamUrls(t *testing.T) {
u, _ := url.Parse(openrecTestUrl)
live, _ := NewLive(u)
if live.GetCachedInfo().Status {
t.Log(live.GetStreamUrls())
}
}
-74
View File
@@ -1,74 +0,0 @@
package api
import (
"errors"
"net/url"
"regexp"
"strings"
"github.com/tidwall/gjson"
"github.com/hr3lxphr6j/bililive-go/src/lib/http"
)
var (
qqLiveMobileUrl = "https://m.egame.qq.com/live"
qqLiveStreamsReg = regexp.MustCompile(`"urlArray":(\[[^\]]+\])`)
qqLiveRoomNameReg = regexp.MustCompile(`title:"([^"]*)"`)
qqLiveHostNameReg = regexp.MustCompile(`nickName:"([^"]+)"`)
qqLiveIsLiveReg = regexp.MustCompile(`"isLive":(\d+)`)
)
type QQLive struct {
abstractLive
}
func (q *QQLive) GetInfo() (info *Info, err error) {
defer func() {
if e := recover(); e != nil {
err = e.(error)
}
}()
anchorID := strings.Split(q.Url.Path, "/")[1]
dom, err := http.Get(q.Url.String(), nil, nil)
if err != nil {
return nil, err
}
roomName := qqLiveRoomNameReg.FindSubmatch(dom)[1]
hostName := qqLiveHostNameReg.FindSubmatch(dom)[1]
dom2, err := http.Get(qqLiveMobileUrl, map[string]string{
"anchorid": anchorID,
}, nil)
isLive := string(qqLiveIsLiveReg.FindSubmatch(dom2)[1]) == "1"
info = &Info{
Live: q,
RoomName: string(roomName),
HostName: string(hostName),
Status: isLive,
}
q.cachedInfo = info
return info, nil
}
func (q *QQLive) GetStreamUrls() (us []*url.URL, err error) {
defer func() {
if e := recover(); e != nil {
err = e.(error)
}
}()
dom, err := http.Get(q.Url.String(), nil, nil)
if err != nil {
return nil, err
}
result := qqLiveStreamsReg.FindSubmatch(dom)
if len(result) != 2 {
return nil, errors.New("failed to get streams")
}
u, err := url.Parse(gjson.GetBytes(result[1], "#[bitrate==0].playUrl").String())
if err != nil {
return nil, err
}
return []*url.URL{u}, nil
}
-22
View File
@@ -1,22 +0,0 @@
package api
import (
"net/url"
"testing"
)
const (
qqTestUrl = "https://egame.qq.com/497383565"
)
func TestQQ_GetInfo(t *testing.T) {
u, _ := url.Parse(qqTestUrl)
live, _ := NewLive(u)
t.Log(live.GetInfo())
}
func TestQQ_GetStreamUrls(t *testing.T) {
u, _ := url.Parse(qqTestUrl)
live, _ := NewLive(u)
t.Log(live.GetStreamUrls())
}
-103
View File
@@ -1,103 +0,0 @@
package api
import (
"fmt"
"math/rand"
"net/url"
"strings"
"github.com/tidwall/gjson"
"github.com/hr3lxphr6j/bililive-go/src/lib/http"
)
const (
twitchClientId = "jzkbprff40iqj646a697cyrvl0zt2m6"
twitchChannelApiUrl = "https://api.twitch.tv/kraken/channels/%s"
twitchLiveBaseUrl = "https://usher.ttvnw.net/api/channel/hls/%s.m3u8"
twitchStreamApiUrl = "https://api.twitch.tv/kraken/streams/%s"
twitchTokenApiUrl = "https://api.twitch.tv/api/channels/%s/access_token"
)
var twitchHeader = map[string]string{"client-id": twitchClientId}
type TwitchLive struct {
abstractLive
hostName, roomName string
}
// 在hostName, roomName为空执行,在live有效时再从steam api解析
func (t *TwitchLive) parseInfo() error {
chanId := strings.Split(t.Url.Path, "/")[1]
body, err := http.Get(fmt.Sprintf(twitchChannelApiUrl, chanId), nil, twitchHeader)
if err != nil {
return &RoomNotExistsError{t.Url}
}
t.hostName = gjson.GetBytes(body, "name").String()
t.roomName = gjson.GetBytes(body, "status").String()
return nil
}
func (t *TwitchLive) GetInfo() (info *Info, err error) {
defer func() {
if e := recover(); e != nil {
err = e.(error)
}
}()
if t.hostName == "" || t.roomName == "" {
if err := t.parseInfo(); err != nil {
return nil, err
}
}
body, err := http.Get(fmt.Sprintf(twitchStreamApiUrl, t.hostName), nil, twitchHeader)
if err != nil {
return nil, err
}
status := gjson.GetBytes(body, "stream").String() != ""
if status {
t.roomName = gjson.GetBytes(body, "stream.channel.status").String()
}
info = &Info{
Live: t,
HostName: t.hostName,
RoomName: t.roomName,
Status: status,
}
t.cachedInfo = info
return info, nil
}
func (t *TwitchLive) GetStreamUrls() (us []*url.URL, err error) {
defer func() {
if e := recover(); e != nil {
err = e.(error)
}
}()
if t.hostName == "" || t.roomName == "" {
if err := t.parseInfo(); err != nil {
return nil, err
}
}
body, err := http.Get(fmt.Sprintf(twitchTokenApiUrl, t.hostName), nil, twitchHeader)
if err != nil {
return nil, err
}
token := gjson.GetBytes(body, "token").String()
sig := gjson.GetBytes(body, "sig").String()
p := fmt.Sprintf("%d", rand.Intn(9000000)+1000000)
u, err := url.Parse(fmt.Sprintf(twitchLiveBaseUrl, t.hostName))
v := &url.Values{}
v.Add("allow_source", "true")
v.Add("allow_audio_only", "true")
v.Add("allow_spectre", "true")
v.Add("p", p)
v.Add("player", "twitchweb")
v.Add("segment_preference", "4")
v.Add("sig", sig)
v.Add("token", token)
u.RawQuery = v.Encode()
return []*url.URL{u}, nil
}
-20
View File
@@ -1,20 +0,0 @@
package api
import (
"net/url"
"testing"
)
const twitchTestUrl = "https://www.twitch.tv/wuyikoei"
func TestTwitchLive_GetInfo(t *testing.T) {
u, _ := url.Parse(twitchTestUrl)
live, _ := NewLive(u)
t.Log(live.GetInfo())
}
func TestTwitchLive_GetStreamUrls(t *testing.T) {
u, _ := url.Parse(twitchTestUrl)
live, _ := NewLive(u)
t.Log(live.GetStreamUrls())
}
-68
View File
@@ -1,68 +0,0 @@
package api
import (
"net/url"
"strings"
"github.com/tidwall/gjson"
"github.com/hr3lxphr6j/bililive-go/src/lib/http"
)
const yizhiboApiUrl = "http://www.yizhibo.com/live/h5api/get_basic_live_info"
type YiZhiBoLive struct {
abstractLive
}
func (y *YiZhiBoLive) requestRoomInfo() ([]byte, error) {
scid := strings.Split(strings.Split(y.Url.Path, "/")[2], ".")[0]
body, err := http.Get(yizhiboApiUrl, map[string]string{"scid": scid}, nil)
if err != nil {
return nil, err
}
if gjson.GetBytes(body, "result").Int() != 1 {
return nil, &RoomNotExistsError{y.Url}
} else {
return body, nil
}
}
func (y *YiZhiBoLive) GetInfo() (info *Info, err error) {
defer func() {
if e := recover(); e != nil {
err = e.(error)
}
}()
data, err := y.requestRoomInfo()
if err != nil {
return nil, err
}
info = &Info{
Live: y,
HostName: gjson.GetBytes(data, "data.nickname").String(),
RoomName: gjson.GetBytes(data, "data.live_title").String(),
Status: gjson.GetBytes(data, "data.status").Int() == 10,
}
y.cachedInfo = info
return info, nil
}
func (y *YiZhiBoLive) GetStreamUrls() (us []*url.URL, err error) {
defer func() {
if e := recover(); e != nil {
err = e.(error)
}
}()
data, err := y.requestRoomInfo()
if err != nil {
return nil, err
}
u, err := url.Parse(gjson.GetBytes(data, "data.play_url").String())
if err != nil {
return nil, err
}
return []*url.URL{u}, nil
}
-20
View File
@@ -1,20 +0,0 @@
package api
import (
"net/url"
"testing"
)
const yizhiboTestUrl = "https://www.yizhibo.com/l/ytbdVP1SSmWXzUx_.html"
func TestYiZhiBoLive_GetInfo(t *testing.T) {
u, _ := url.Parse(yizhiboTestUrl)
live, _ := NewLive(u)
t.Log(live.GetInfo())
}
func TestYiZhiBoLive_GetStreamUrls(t *testing.T) {
u, _ := url.Parse(yizhiboTestUrl)
live, _ := NewLive(u)
t.Log(live.GetStreamUrls())
}
-73
View File
@@ -1,73 +0,0 @@
package api
import (
"encoding/base64"
"fmt"
"net/url"
"strings"
"github.com/tidwall/gjson"
"github.com/hr3lxphr6j/bililive-go/src/lib/http"
)
const (
zhanQiApiUrl = "https://www.zhanqi.tv/api/static/v2.1/room/domain/%s.json"
)
type ZhanQiLive struct {
abstractLive
}
func (z *ZhanQiLive) requestRoomInfo() ([]byte, error) {
body, err := http.Get(fmt.Sprintf(zhanQiApiUrl, strings.Split(z.Url.Path, "/")[1]), nil, nil)
if err != nil {
return nil, err
}
if gjson.GetBytes(body, "code").Int() == 0 {
return body, nil
} else {
return nil, &RoomNotExistsError{z.Url}
}
}
func (z *ZhanQiLive) GetInfo() (info *Info, err error) {
defer func() {
if e := recover(); e != nil {
err = e.(error)
}
}()
body, err := z.requestRoomInfo()
if err != nil {
return nil, err
}
info = &Info{
Live: z,
HostName: gjson.GetBytes(body, "data.nickname").String(),
RoomName: gjson.GetBytes(body, "data.title").String(),
Status: gjson.GetBytes(body, "data.status").String() == "4",
}
z.cachedInfo = info
return info, nil
}
func (z *ZhanQiLive) GetStreamUrls() (us []*url.URL, err error) {
defer func() {
if e := recover(); e != nil {
err = e.(error)
}
}()
body, err := z.requestRoomInfo()
if err != nil {
return nil, err
}
videoLevels := gjson.GetBytes(body, "data.flashvars.VideoLevels").String()
data, err := base64.StdEncoding.DecodeString(videoLevels)
if err != nil {
return nil, err
}
m3u8, _ := url.Parse(gjson.GetBytes(data, "streamUrl").String())
return []*url.URL{m3u8}, nil
}
-20
View File
@@ -1,20 +0,0 @@
package api
import (
"net/url"
"testing"
)
const zhanQiTestUrl = "https://www.zhanqi.tv/12qaq"
func TestZhanQiLive_GetInfo(t *testing.T) {
u, _ := url.Parse(zhanQiTestUrl)
live, _ := NewLive(u)
t.Log(live.GetInfo())
}
func TestZhanQiLive_GetStreamUrls(t *testing.T) {
u, _ := url.Parse(zhanQiTestUrl)
live, _ := NewLive(u)
t.Log(live.GetStreamUrls())
}
+119
View File
@@ -0,0 +1,119 @@
package main
import (
"context"
"fmt"
"net/url"
"os"
"os/signal"
"syscall"
"github.com/bluele/gcache"
_ "github.com/hr3lxphr6j/bililive-go/src/cmd/bililive/internal"
"github.com/hr3lxphr6j/bililive-go/src/cmd/bililive/internal/flag"
"github.com/hr3lxphr6j/bililive-go/src/configs"
"github.com/hr3lxphr6j/bililive-go/src/consts"
"github.com/hr3lxphr6j/bililive-go/src/instance"
"github.com/hr3lxphr6j/bililive-go/src/lib/events"
"github.com/hr3lxphr6j/bililive-go/src/lib/utils"
"github.com/hr3lxphr6j/bililive-go/src/listeners"
"github.com/hr3lxphr6j/bililive-go/src/live"
"github.com/hr3lxphr6j/bililive-go/src/log"
"github.com/hr3lxphr6j/bililive-go/src/recorders"
"github.com/hr3lxphr6j/bililive-go/src/servers"
)
func init() {
if !utils.IsFFmpegExist() {
fmt.Fprintf(os.Stderr, "FFmpeg binary not found, Please Check.\n")
os.Exit(1)
}
}
func getConfig() (*configs.Config, error) {
var config *configs.Config
if *flag.Conf != "" {
c, err := configs.NewConfigWithFile(*flag.Conf)
if err != nil {
return nil, err
}
config = c
} else {
config = flag.GenConfigFromFlags()
}
return config, config.Verify()
}
func main() {
config, err := getConfig()
if err != nil {
fmt.Fprint(os.Stderr, err.Error())
os.Exit(1)
}
inst := new(instance.Instance)
inst.Config = config
inst.Cache = gcache.New(128).LRU().Build()
ctx := context.WithValue(context.Background(), instance.InstanceKey, inst)
logger := log.New(ctx)
logger.Infof("%s Version: %s Link Start", consts.AppName, consts.AppVersion)
logger.Debugf("%+v", consts.AppInfo)
logger.Debugf("%+v", inst.Config)
events.NewDispatcher(ctx)
inst.Lives = make(map[live.ID]live.Live)
for _, room := range inst.Config.LiveRooms {
u, err := url.Parse(room)
if err != nil {
logger.WithField("url", room).Error(err)
continue
}
l, err := live.New(u, inst.Cache)
if err != nil {
logger.WithField("url", room).Error(err.Error())
continue
}
if _, ok := inst.Lives[l.GetLiveId()]; ok {
logger.Errorf("%s is exist!", room)
continue
}
inst.Lives[l.GetLiveId()] = l
}
if inst.Config.RPC.Enable {
if err := servers.NewServer(ctx).Start(ctx); err != nil {
logger.WithError(err).Fatalf("failed to init server")
}
}
lm := listeners.NewManager(ctx)
rm := recorders.NewManager(ctx)
if err := lm.Start(ctx); err != nil {
logger.Fatalf("failed to init listener manager, error: %s", err)
}
if err := rm.Start(ctx); err != nil {
logger.Fatalf("failed to init recorder manager, error: %s", err)
}
for _, _live := range inst.Lives {
if err := lm.AddListener(ctx, _live); err != nil {
logger.WithFields(map[string]interface{}{"url": _live.GetRawUrl()}).Error(err)
}
}
c := make(chan os.Signal)
signal.Notify(c, syscall.SIGHUP, syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT)
go func() {
<-c
if inst.Config.RPC.Enable {
inst.Server.Close(ctx)
}
inst.ListenerManager.Close(ctx)
inst.RecorderManager.Close(ctx)
}()
inst.WaitGroup.Wait()
logger.Info("Bye~")
}
+52
View File
@@ -0,0 +1,52 @@
package flag
import (
"os"
"github.com/alecthomas/kingpin"
"github.com/hr3lxphr6j/bililive-go/src/configs"
"github.com/hr3lxphr6j/bililive-go/src/consts"
)
var (
App = kingpin.New(consts.AppName, "A command-line live stream save tools.").Version(consts.AppVersion)
Debug = App.Flag("debug", "Enable debug mode.").Default("false").Bool()
Interval = App.Flag("interval", "Interval of query live status").Default("20").Short('t').Int()
Output = App.Flag("output", "Output file path.").Short('o').Default("./").String()
Input = App.Flag("input", "Live room urls").Short('i').Strings()
Conf = App.Flag("config", "Config file.").Short('c').String()
Rpc = App.Flag("enable-rpc", "Enable RPC server.").Default("false").Bool()
RpcAddr = App.Flag("rpc-addr", "RPC server listen port").Default(":8080").String()
RpcToken = App.Flag("rpc-token", "RPC server token.").String()
RpcTLS = App.Flag("enable-rpc-tls", "Enable TLS for RPC server").Bool()
CertFile = App.Flag("rpc-tls-cert-file", "Cert file for TLS on RPC").String()
KeyFile = App.Flag("rpc-tls-key-file", "Key file for TLS on RPC").String()
NativeFlvParser = App.Flag("native-flv-parser", "use native flv parser").Default("false").Bool()
)
func init() {
kingpin.MustParse(App.Parse(os.Args[1:]))
}
func GenConfigFromFlags() *configs.Config {
return &configs.Config{
RPC: configs.RPC{
Enable: *Rpc,
Bind: *RpcAddr,
Token: *RpcToken,
TLS: configs.TLS{
Enable: *RpcTLS,
CertFile: *CertFile,
KeyFile: *KeyFile,
},
},
Debug: *Debug,
Interval: *Interval,
OutPutPath: *Output,
LiveRooms: *Input,
Feature: configs.Feature{
UseNativeFlvParser: *NativeFlvParser,
},
}
}
+15
View File
@@ -0,0 +1,15 @@
package internal
import (
_ "github.com/hr3lxphr6j/bililive-go/src/live/bilibili"
_ "github.com/hr3lxphr6j/bililive-go/src/live/cc"
_ "github.com/hr3lxphr6j/bililive-go/src/live/douyu"
_ "github.com/hr3lxphr6j/bililive-go/src/live/huomao"
_ "github.com/hr3lxphr6j/bililive-go/src/live/huya"
_ "github.com/hr3lxphr6j/bililive-go/src/live/longzhu"
_ "github.com/hr3lxphr6j/bililive-go/src/live/openrec"
_ "github.com/hr3lxphr6j/bililive-go/src/live/qq"
_ "github.com/hr3lxphr6j/bililive-go/src/live/twitch"
_ "github.com/hr3lxphr6j/bililive-go/src/live/yizhibo"
_ "github.com/hr3lxphr6j/bililive-go/src/live/zhanqi"
)
+52 -25
View File
@@ -2,9 +2,9 @@ package configs
import (
"crypto/tls"
"errors"
"fmt"
"io/ioutil"
"net"
"os"
"gopkg.in/yaml.v2"
@@ -15,15 +15,47 @@ type TLS struct {
CertFile string `yaml:"cert_file"`
KeyFile string `yaml:"key_file"`
}
func (t *TLS) Verify() error {
if t == nil {
return nil
}
if !t.Enable {
return nil
}
if _, err := tls.LoadX509KeyPair(t.CertFile, t.KeyFile); err != nil {
return err
}
return nil
}
type RPC struct {
Enable bool `yaml:"enable"`
Port string `yaml:"port"`
Bind string `yaml:"port"`
Token string `yaml:"token"`
TLS TLS `yaml:"tls"`
}
func (r *RPC) Verify() error {
if r == nil {
return nil
}
if !r.Enable {
return nil
}
if _, err := net.ResolveTCPAddr("tcp", r.Bind); err != nil {
return err
}
if err := r.TLS.Verify(); err != nil {
return err
}
return nil
}
type Feature struct {
UseNativeFlvParser bool `yaml:"use_native_flv_parser"`
}
type Config struct {
RPC RPC `yaml:"rpc"`
Debug bool `yaml:"debug"`
@@ -34,44 +66,39 @@ type Config struct {
file string
}
func VerifyConfig(config *Config) error {
if config.Interval <= 0 {
return errors.New(fmt.Sprintf(`the interval can not <= 0`))
func (c *Config) Verify() error {
if c == nil {
return fmt.Errorf("config is null")
}
if _, err := os.Stat(config.OutPutPath); err != nil {
return errors.New(fmt.Sprintf(`the out put path: "%s" is not exist`, config.OutPutPath))
if err := c.RPC.Verify(); err != nil {
return err
}
if config.RPC.Enable {
if config.RPC.Port == "" {
return errors.New("rpc listen port can not be null")
}
if config.RPC.TLS.Enable {
if _, err := tls.LoadX509KeyPair(config.RPC.TLS.CertFile, config.RPC.TLS.KeyFile); err != nil {
return err
}
}
if c.Interval <= 0 {
return fmt.Errorf("the interval can not <= 0")
}
if _, err := os.Stat(c.OutPutPath); err != nil {
return fmt.Errorf(`the out put path: "%s" is not exist`, c.OutPutPath)
}
return nil
}
func NewConfigWithFile(configFilePath string) (*Config, error) {
b, err := ioutil.ReadFile(configFilePath)
func NewConfigWithFile(file string) (*Config, error) {
b, err := ioutil.ReadFile(file)
if err != nil {
return nil, errors.New(fmt.Sprintf("can`t open file: %s", configFilePath))
return nil, fmt.Errorf("can`t open file: %s", file)
}
config := new(Config)
err = yaml.Unmarshal(b, config)
if err != nil {
if err = yaml.Unmarshal(b, config); err != nil {
return nil, err
}
config.file = configFilePath
config.file = file
return config, nil
}
func (config *Config) Marshal() error {
b, err := yaml.Marshal(config)
func (c *Config) Marshal() error {
b, err := yaml.Marshal(c)
if err != nil {
return err
}
return ioutil.WriteFile(config.file, b, os.ModeAppend)
return ioutil.WriteFile(c.file, b, os.ModeAppend)
}
+42 -1
View File
@@ -1,9 +1,50 @@
package configs
import (
"os"
"testing"
"github.com/stretchr/testify/assert"
)
func TestNewConfig(t *testing.T) {
t.Log(NewConfigWithFile("../../config.yml"))
file := "../../config.yml"
c, err := NewConfigWithFile("../../config.yml")
assert.NoError(t, err)
assert.Equal(t, file, c.file)
}
func TestTLS_Verify(t *testing.T) {
var tls *TLS
assert.NoError(t, tls.Verify())
tls = new(TLS)
assert.NoError(t, tls.Verify())
tls.Enable = true
assert.Error(t, tls.Verify())
}
func TestRPC_Verify(t *testing.T) {
var rpc *RPC
assert.NoError(t, rpc.Verify())
rpc = new(RPC)
rpc.Bind = "foo@bar"
assert.NoError(t, rpc.Verify())
rpc.Enable = true
assert.Error(t, rpc.Verify())
}
func TestConfig_Verify(t *testing.T) {
var cfg *Config
assert.Error(t, cfg.Verify())
cfg = &Config{
Interval: 30,
OutPutPath: os.TempDir(),
}
assert.NoError(t, cfg.Verify())
cfg.Interval = 0
assert.Error(t, cfg.Verify())
cfg.Interval = 30
cfg.OutPutPath = "foobar"
assert.Error(t, cfg.Verify())
}
+56
View File
@@ -0,0 +1,56 @@
#!/usr/bin/env bash
set -o errexit
set -o pipefail
set -o nounset
OUTPUT_PATH=bin
SRC_PATH="${GOPATH}/src/github.com/hr3lxphr6j/bililive-go"
CONSTS_PATH="github.com/hr3lxphr6j/bililive-go/src/consts"
_build() {
target=$1
bin_name=$2
ld_flags=$3
go build \
-gcflags=${GOFLAGS:-""} \
-o ${OUTPUT_PATH}/${bin_name} \
-ldflags="${ld_flags}" \
./src/cmd/${target}/
}
build() {
target=$1
if [[ ${target} == 'bililive' ]]; then
now=$(date '+%Y-%m-%d_%H:%M:%S')
rev=$(echo "${rev:-$(git rev-parse HEAD)}")
ver=$(git describe --tags --always)
ld_flags="-s -w -X ${CONSTS_PATH}.BuildTime=${now} -X ${CONSTS_PATH}.AppVersion=${ver} -X ${CONSTS_PATH}.GitHash=${rev}"
fi
if [[ $(go env GOOS) == "windows" ]]; then
ext=".exe"
fi
bin_name="${target}-$(go env GOOS)-$(go env GOARCH)${ext:-}"
_build "${target}" "${bin_name}" "${ld_flags:-}"
if [[ ${UPX_ENABLE:-"0"} == "1" ]]; then
upx --no-progress ${OUTPUT_PATH}/"${bin_name}"
fi
}
main() {
pushd ${SRC_PATH} >/dev/null
if [[ ! -d src/cmd/$1 ]]; then
echo 'Target not exist in src/cmd/'
popd >/dev/null
exit 1
fi
build $1
popd >/dev/null
}
main $@
+8
View File
@@ -0,0 +1,8 @@
#!/usr/bin/env bash
set -o errexit
set -o pipefail
set -o nounset
make PLATFORM=linux ARCH=amd64 bililive
docker build -t 'chigusa/bililive-go' .
+54
View File
@@ -0,0 +1,54 @@
#!/usr/bin/env bash
set -o errexit
set -o pipefail
set -o nounset
DISTS=(
"darwin-amd64"
"windows-amd64"
"windows-386"
"linux-amd64"
"linux-386"
"linux-arm64"
"linux-arm"
)
BIN_PATH=bin
package() {
pushd $BIN_PATH >/dev/null 2>&1
file=$1
type=$2
case $type in
zip)
zip ${file%.exe}.zip ${file} ../config.yml
;;
tar)
tar zcvf ${file}.tar.gz ${file} -C ../ config.yml
;;
7z)
7z a ${file}.7z ${file} ../config.yml
;;
*) ;;
esac
popd >/dev/null 2>&1
}
for dist in ${DISTS[@]}; do
platform=$(echo ${dist} | cut -d'-' -f1)
arch=$(echo ${dist} | cut -d'-' -f2)
make PLATFORM=${platform} ARCH=${arch} bililive
done
for file in $(ls $BIN_PATH); do
if [[ $file == *".tar.gz" || $file == *".zip" || $file == *".7z" || $file == *".yml" ]]; then
continue
fi
package_type=tar
if [[ $file == *"windows"* ]]; then
package_type=zip
fi
package $file $package_type
rm -f $BIN_PATH/$file
done
+12 -9
View File
@@ -3,18 +3,21 @@ package instance
import (
"sync"
"github.com/hr3lxphr6j/bililive-go/src/api"
"github.com/bluele/gcache"
"github.com/hr3lxphr6j/bililive-go/src/configs"
"github.com/hr3lxphr6j/bililive-go/src/interfaces"
"github.com/hr3lxphr6j/bililive-go/src/live"
)
type Instance struct {
WaitGroup sync.WaitGroup // 用于阻塞主线程,直到所有模块均结束
Config *configs.Config // 配置信息
Logger *interfaces.Logger // Log
Lives map[api.LiveId]api.Live // 直播间集合
Server interfaces.Module // RPC服务
EventDispatcher interfaces.Module // 事件分发
ListenerManager interfaces.Module // 直播间状态监听
RecorderManager interfaces.Module // 录制模块
WaitGroup sync.WaitGroup
Config *configs.Config
Logger *interfaces.Logger
Lives map[live.ID]live.Live
Cache gcache.Cache
Server interfaces.Module
EventDispatcher interfaces.Module
ListenerManager interfaces.Module
RecorderManager interfaces.Module
}
+99
View File
@@ -0,0 +1,99 @@
//go:generate mockgen -package mock -destination mock/mock.go github.com/hr3lxphr6j/bililive-go/src/lib/events Dispatcher
package events
import (
"container/list"
"context"
"sync"
"github.com/hr3lxphr6j/bililive-go/src/instance"
"github.com/hr3lxphr6j/bililive-go/src/interfaces"
)
func NewDispatcher(ctx context.Context) Dispatcher {
ed := &dispatcher{
saver: make(map[EventType]*list.List),
}
inst := instance.GetInstance(ctx)
if inst != nil {
inst.EventDispatcher = ed
}
return ed
}
type Dispatcher interface {
interfaces.Module
AddEventListener(eventType EventType, listener *EventListener)
RemoveEventListener(eventType EventType, listener *EventListener)
RemoveAllEventListener(eventType EventType)
DispatchEvent(event *Event)
}
type dispatcher struct {
sync.RWMutex
saver map[EventType]*list.List // map<EventType, List<*EventListener>>
}
func (e *dispatcher) Start(ctx context.Context) error {
return nil
}
func (e *dispatcher) Close(ctx context.Context) {
}
func (e *dispatcher) AddEventListener(eventType EventType, listener *EventListener) {
e.Lock()
defer e.Unlock()
listeners, ok := e.saver[eventType]
if !ok || listener == nil {
listeners = list.New()
e.saver[eventType] = listeners
}
listeners.PushBack(listener)
}
func (e *dispatcher) RemoveEventListener(eventType EventType, listener *EventListener) {
e.Lock()
defer e.Unlock()
listeners, ok := e.saver[eventType]
if !ok || listeners == nil {
return
}
for e := listeners.Front(); e != nil; e = e.Next() {
if e.Value == listener {
listeners.Remove(e)
}
}
if listeners.Len() == 0 {
delete(e.saver, eventType)
}
}
func (e *dispatcher) RemoveAllEventListener(eventType EventType) {
e.Lock()
defer e.Unlock()
e.saver = make(map[EventType]*list.List)
}
func (e *dispatcher) DispatchEvent(event *Event) {
if event == nil {
return
}
e.RLock()
listeners, ok := e.saver[event.Type]
if !ok || listeners == nil {
e.RUnlock()
return
}
hs := make([]*EventListener, 0)
for e := listeners.Front(); e != nil; e = e.Next() {
hs = append(hs, e.Value.(*EventListener))
}
e.RUnlock()
go func() {
for _, h := range hs {
h.Handler(event)
}
}()
}
+44
View File
@@ -0,0 +1,44 @@
package events
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/assert"
)
func TestAddAndRemoveEventListener(t *testing.T) {
d := NewDispatcher(context.Background()).(*dispatcher)
l := NewEventListener(func(event *Event) {})
d.AddEventListener("test", l)
d.AddEventListener("test2", NewEventListener(func(event *Event) {}))
ls, ok := d.saver["test"]
assert.True(t, ok)
assert.Equal(t, l, ls.Front().Value)
d.RemoveEventListener("test", l)
_, ok = d.saver["test"]
assert.False(t, ok)
d.RemoveAllEventListener("test2")
assert.Empty(t, d.saver)
}
func TestDispatchEvent(t *testing.T) {
l := make([]int, 0)
d := NewDispatcher(context.Background()).(*dispatcher)
d.AddEventListener("test", NewEventListener(func(event *Event) {
l = append(l, 0)
}))
d.AddEventListener("test", NewEventListener(func(event *Event) {
l = append(l, 1)
}))
d.AddEventListener("test", NewEventListener(func(event *Event) {
l = append(l, 2)
}))
d.AddEventListener("test", NewEventListener(func(event *Event) {
l = append(l, 3)
}))
d.DispatchEvent(NewEvent("test", nil))
time.Sleep(time.Second)
assert.Equal(t, []int{0, 1, 2, 3}, l)
}
-7
View File
@@ -1,12 +1,9 @@
package events
// 事件种类
type EventType string
// 事件回调
type EventHandler func(event *Event)
// 事件类型基类
type Event struct {
Type EventType
Object interface{}
@@ -16,7 +13,6 @@ func NewEvent(eventType EventType, object interface{}) *Event {
return &Event{eventType, object}
}
// 事件监听器
type EventListener struct {
Handler EventHandler
}
@@ -24,6 +20,3 @@ type EventListener struct {
func NewEventListener(handler EventHandler) *EventListener {
return &EventListener{handler}
}
// 事件监听器集合
type eventListenerSet map[*EventListener]bool
-58
View File
@@ -1,58 +0,0 @@
package events
import (
"context"
"github.com/hr3lxphr6j/bililive-go/src/instance"
)
func NewIEventDispatcher(ctx context.Context) IEventDispatcher {
ed := &EventDispatcher{
saver: make(map[EventType]eventListenerSet),
}
instance.GetInstance(ctx).EventDispatcher = ed
return ed
}
// 事件分发器接口
type IEventDispatcher interface {
AddEventListener(eventType EventType, listener *EventListener)
RemoveEventListener(eventType EventType, listener *EventListener)
RemoveAllEventListener(eventType EventType)
DispatchEvent(event *Event)
}
// 事件分发器
type EventDispatcher struct {
saver map[EventType]eventListenerSet
}
func (e *EventDispatcher) Start(ctx context.Context) error {
return nil
}
func (e *EventDispatcher) Close(ctx context.Context) {
}
func (e *EventDispatcher) AddEventListener(eventType EventType, listener *EventListener) {
_, isExist := e.saver[eventType]
if !isExist {
e.saver[eventType] = make(map[*EventListener]bool)
}
e.saver[eventType][listener] = true
}
func (e *EventDispatcher) RemoveEventListener(eventType EventType, listener *EventListener) {
delete(e.saver[eventType], listener)
}
func (e *EventDispatcher) RemoveAllEventListener(eventType EventType) {
delete(e.saver, eventType)
}
func (e *EventDispatcher) DispatchEvent(event *Event) {
for l := range e.saver[event.Type] {
go l.Handler(event)
}
}
-9
View File
@@ -1,9 +0,0 @@
package events
import "errors"
var listenerExistError = errors.New("this event listener was exist")
var listenerNotExistError = errors.New("this event listener was not exist")
var eventExistError = errors.New("this event was exist")
var eventNotExistError = errors.New("this event was not exist")
+109
View File
@@ -0,0 +1,109 @@
// Code generated by MockGen. DO NOT EDIT.
// Source: github.com/hr3lxphr6j/bililive-go/src/lib/events (interfaces: Dispatcher)
// Package mock is a generated GoMock package.
package mock
import (
context "context"
gomock "github.com/golang/mock/gomock"
events "github.com/hr3lxphr6j/bililive-go/src/lib/events"
reflect "reflect"
)
// MockDispatcher is a mock of Dispatcher interface
type MockDispatcher struct {
ctrl *gomock.Controller
recorder *MockDispatcherMockRecorder
}
// MockDispatcherMockRecorder is the mock recorder for MockDispatcher
type MockDispatcherMockRecorder struct {
mock *MockDispatcher
}
// NewMockDispatcher creates a new mock instance
func NewMockDispatcher(ctrl *gomock.Controller) *MockDispatcher {
mock := &MockDispatcher{ctrl: ctrl}
mock.recorder = &MockDispatcherMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use
func (m *MockDispatcher) EXPECT() *MockDispatcherMockRecorder {
return m.recorder
}
// AddEventListener mocks base method
func (m *MockDispatcher) AddEventListener(arg0 events.EventType, arg1 *events.EventListener) {
m.ctrl.T.Helper()
m.ctrl.Call(m, "AddEventListener", arg0, arg1)
}
// AddEventListener indicates an expected call of AddEventListener
func (mr *MockDispatcherMockRecorder) AddEventListener(arg0, arg1 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddEventListener", reflect.TypeOf((*MockDispatcher)(nil).AddEventListener), arg0, arg1)
}
// Close mocks base method
func (m *MockDispatcher) Close(arg0 context.Context) {
m.ctrl.T.Helper()
m.ctrl.Call(m, "Close", arg0)
}
// Close indicates an expected call of Close
func (mr *MockDispatcherMockRecorder) Close(arg0 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Close", reflect.TypeOf((*MockDispatcher)(nil).Close), arg0)
}
// DispatchEvent mocks base method
func (m *MockDispatcher) DispatchEvent(arg0 *events.Event) {
m.ctrl.T.Helper()
m.ctrl.Call(m, "DispatchEvent", arg0)
}
// DispatchEvent indicates an expected call of DispatchEvent
func (mr *MockDispatcherMockRecorder) DispatchEvent(arg0 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DispatchEvent", reflect.TypeOf((*MockDispatcher)(nil).DispatchEvent), arg0)
}
// RemoveAllEventListener mocks base method
func (m *MockDispatcher) RemoveAllEventListener(arg0 events.EventType) {
m.ctrl.T.Helper()
m.ctrl.Call(m, "RemoveAllEventListener", arg0)
}
// RemoveAllEventListener indicates an expected call of RemoveAllEventListener
func (mr *MockDispatcherMockRecorder) RemoveAllEventListener(arg0 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RemoveAllEventListener", reflect.TypeOf((*MockDispatcher)(nil).RemoveAllEventListener), arg0)
}
// RemoveEventListener mocks base method
func (m *MockDispatcher) RemoveEventListener(arg0 events.EventType, arg1 *events.EventListener) {
m.ctrl.T.Helper()
m.ctrl.Call(m, "RemoveEventListener", arg0, arg1)
}
// RemoveEventListener indicates an expected call of RemoveEventListener
func (mr *MockDispatcherMockRecorder) RemoveEventListener(arg0, arg1 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RemoveEventListener", reflect.TypeOf((*MockDispatcher)(nil).RemoveEventListener), arg0, arg1)
}
// Start mocks base method
func (m *MockDispatcher) Start(arg0 context.Context) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Start", arg0)
ret0, _ := ret[0].(error)
return ret0
}
// Start indicates an expected call of Start
func (mr *MockDispatcherMockRecorder) Start(arg0 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Start", reflect.TypeOf((*MockDispatcher)(nil).Start), arg0)
}
+86
View File
@@ -0,0 +1,86 @@
package http
import (
"bytes"
"compress/gzip"
"errors"
"io"
"io/ioutil"
"net/http"
"net/url"
)
var commonHeader = map[string]string{
"Accept": "application/json, text/javascript, */*; q=0.01",
"Accept-Encoding": "gzip, deflate",
"Accept-Language": "zh-CN,zh;q=0.8,en-US;q=0.6,en;q=0.4,zh-TW;q=0.2",
"Connection": "keep-alive",
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/59.0.3071.115 Safari/537.36",
}
var client = new(http.Client)
func Get(path string, header, query map[string]string) ([]byte, error) {
return do(http.MethodGet, path, header, query, nil)
}
func Post(path string, header, query map[string]string, body []byte) ([]byte, error) {
return do(http.MethodPost, path, header, query, body)
}
func parseResponse(resp *http.Response) ([]byte, error) {
var reader io.ReadCloser
defer func() {
if reader != nil {
reader.Close()
}
}()
switch resp.Header.Get("Content-Encoding") {
case "gzip":
r, err := gzip.NewReader(resp.Body)
if err != nil {
return nil, err
}
reader = r
default:
reader = resp.Body
}
body, err := ioutil.ReadAll(reader)
if err != nil {
return nil, err
}
return body, nil
}
func do(method string, path string, header, query map[string]string, body []byte) ([]byte, error) {
var r io.Reader = nil
if body != nil {
r = bytes.NewReader(body)
}
req, err := http.NewRequest(method, path, r)
if err != nil {
return nil, err
}
if header == nil || len(header) == 0 {
header = commonHeader
}
for k, v := range header {
req.Header.Set(k, v)
}
q := url.Values{}
for k, v := range query {
q.Set(k, v)
}
req.URL.RawQuery = q.Encode()
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, errors.New(resp.Status)
}
return parseResponse(resp)
}
-89
View File
@@ -1,89 +0,0 @@
package http
import (
"bytes"
"compress/gzip"
"errors"
"io"
"io/ioutil"
"net/http"
"github.com/mozillazg/request"
)
var commonHeader = map[string]string{
"Accept": "application/json, text/javascript, */*; q=0.01",
"Accept-Encoding": "gzip, deflate",
"Accept-Language": "zh-CN,zh;q=0.8,en-US;q=0.6,en;q=0.4,zh-TW;q=0.2",
"Connection": "keep-alive",
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/59.0.3071.115 Safari/537.36",
}
var client = new(http.Client)
func parseResponse(resp *request.Response) ([]byte, error) {
var reader io.ReadCloser
defer func() {
if reader != nil {
reader.Close()
}
}()
switch resp.Header.Get("Content-Encoding") {
case "gzip":
r, err := gzip.NewReader(resp.Body)
if err != nil {
return nil, err
}
reader = r
default:
reader = resp.Body
}
body, err := ioutil.ReadAll(reader)
if err != nil {
return nil, err
}
return body, nil
}
func Get(url string, query map[string]string, header map[string]string) ([]byte, error) {
req := request.NewRequest(client)
if header != nil {
req.Headers = header
} else {
req.Headers = commonHeader
}
req.Params = query
if resp, err := req.Get(url); err == nil {
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, errors.New(resp.Status)
}
return parseResponse(resp)
} else {
return nil, err
}
}
func Post(url string, query map[string]string, body []byte, header map[string]string) ([]byte, error) {
req := request.NewRequest(client)
if header != nil {
req.Headers = header
} else {
req.Headers = commonHeader
}
req.Params = query
req.Body = bytes.NewReader(body)
if resp, err := req.Post(url); err == nil {
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, errors.New(resp.Status)
}
return parseResponse(resp)
} else {
return nil, err
}
}
+60
View File
@@ -0,0 +1,60 @@
package http
import (
"compress/gzip"
"fmt"
"io/ioutil"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
)
func TestGet(t *testing.T) {
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, http.MethodGet, r.Method)
assert.EqualValues(t, "bar", r.Header.Get("foo"))
assert.Len(t, r.URL.Query(), 1)
assert.EqualValues(t, "test", r.URL.Query().Get("q"))
w.Write([]byte("OK"))
}))
b, err := Get(fmt.Sprintf("%s/test", s.URL), map[string]string{"foo": "bar"}, map[string]string{"q": "test"})
assert.NoError(t, err)
assert.EqualValues(t, "OK", string(b))
}
func TestPost(t *testing.T) {
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, http.MethodPost, r.Method)
assert.EqualValues(t, "bar", r.Header.Get("foo"))
assert.Len(t, r.URL.Query(), 1)
assert.EqualValues(t, "test", r.URL.Query().Get("q"))
b, err := ioutil.ReadAll(r.Body)
assert.NoError(t, err)
assert.EqualValues(t, "hello", string(b))
w.Write([]byte("OK"))
}))
b, err := Post(fmt.Sprintf("%s/test", s.URL), map[string]string{"foo": "bar"}, map[string]string{"q": "test"}, []byte("hello"))
assert.NoError(t, err)
assert.EqualValues(t, "OK", string(b))
}
func TestGZipContent(t *testing.T) {
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, http.MethodGet, r.Method)
assert.EqualValues(t, "bar", r.Header.Get("foo"))
assert.Len(t, r.URL.Query(), 1)
assert.EqualValues(t, "test", r.URL.Query().Get("q"))
w.Header().Add("Content-Encoding", "gzip")
gw := gzip.NewWriter(w)
_, err := gw.Write([]byte("hello, gzip!"))
assert.NoError(t, err)
assert.NoError(t, gw.Flush())
assert.NoError(t, gw.Close())
}))
b, err := Get(fmt.Sprintf("%s/test", s.URL), map[string]string{"foo": "bar"}, map[string]string{"q": "test"})
assert.NoError(t, err)
assert.EqualValues(t, "hello, gzip!", string(b))
}
+19 -7
View File
@@ -5,30 +5,42 @@ import (
"net/url"
"os/exec"
"sync"
"github.com/hr3lxphr6j/bililive-go/src/lib/parser"
"github.com/hr3lxphr6j/bililive-go/src/live"
)
const (
Name = "ffmpeg"
userAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/59.0.3071.115 Safari/537.36"
)
func init() {
parser.Register(Name, new(builder))
}
type builder struct{}
func (b *builder) Build() (parser.Parser, error) {
return &Parser{
closeOnce: new(sync.Once),
}, nil
}
type Parser struct {
cmd *exec.Cmd
cmdStdIn io.WriteCloser
closeOnce *sync.Once
}
func New() *Parser {
return &Parser{
closeOnce: new(sync.Once),
}
}
func (p *Parser) ParseLiveStream(url *url.URL, file string) error {
func (p *Parser) ParseLiveStream(url *url.URL, live live.Live, file string) error {
p.cmd = exec.Command(
"ffmpeg",
"-loglevel", "warning",
"-y", "-re",
"-user_agent", userAgent,
"-referer", live.GetRawUrl(),
"-timeout", "60000000",
"-i", url.String(),
"-c", "copy",
+63
View File
@@ -0,0 +1,63 @@
// Code generated by MockGen. DO NOT EDIT.
// Source: github.com/hr3lxphr6j/bililive-go/src/lib/parser (interfaces: Parser)
// Package mock is a generated GoMock package.
package mock
import (
gomock "github.com/golang/mock/gomock"
live "github.com/hr3lxphr6j/bililive-go/src/live"
url "net/url"
reflect "reflect"
)
// MockParser is a mock of Parser interface
type MockParser struct {
ctrl *gomock.Controller
recorder *MockParserMockRecorder
}
// MockParserMockRecorder is the mock recorder for MockParser
type MockParserMockRecorder struct {
mock *MockParser
}
// NewMockParser creates a new mock instance
func NewMockParser(ctrl *gomock.Controller) *MockParser {
mock := &MockParser{ctrl: ctrl}
mock.recorder = &MockParserMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use
func (m *MockParser) EXPECT() *MockParserMockRecorder {
return m.recorder
}
// ParseLiveStream mocks base method
func (m *MockParser) ParseLiveStream(arg0 *url.URL, arg1 live.Live, arg2 string) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "ParseLiveStream", arg0, arg1, arg2)
ret0, _ := ret[0].(error)
return ret0
}
// ParseLiveStream indicates an expected call of ParseLiveStream
func (mr *MockParserMockRecorder) ParseLiveStream(arg0, arg1, arg2 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ParseLiveStream", reflect.TypeOf((*MockParser)(nil).ParseLiveStream), arg0, arg1, arg2)
}
// Stop mocks base method
func (m *MockParser) Stop() error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Stop")
ret0, _ := ret[0].(error)
return ret0
}
// Stop indicates an expected call of Stop
func (mr *MockParserMockRecorder) Stop() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Stop", reflect.TypeOf((*MockParser)(nil).Stop))
}
+27 -16
View File
@@ -10,21 +10,41 @@ import (
"os"
"sync"
"github.com/hr3lxphr6j/bililive-go/src/lib/parser"
"github.com/hr3lxphr6j/bililive-go/src/lib/reader"
"github.com/hr3lxphr6j/bililive-go/src/live"
)
const (
Name = "native"
audioTag uint8 = 8
videoTag uint8 = 9
scriptTag uint8 = 18
)
var (
flvSign = []byte{0x46, 0x4c, 0x56, 0x01} // flv version01
NotFlvStream = errors.New("not flv stream")
UnknownTag = errors.New("unknown tag")
flvSign = []byte{0x46, 0x4c, 0x56, 0x01} // flv version01
ErrNotFlvStream = errors.New("not flv stream")
ErrUnknownTag = errors.New("unknown tag")
)
func init() {
parser.Register(Name, new(builder))
}
type builder struct{}
func (b *builder) Build() (parser.Parser, error) {
return &Parser{
Metadata: Metadata{},
hc: &http.Client{},
stopCh: make(chan struct{}),
closeOnce: new(sync.Once),
}, nil
}
type Metadata struct {
HasVideo, HasAudio bool
}
@@ -42,22 +62,13 @@ type Parser struct {
closeOnce *sync.Once
}
func NewParser() *Parser {
return &Parser{
Metadata: Metadata{},
hc: &http.Client{},
stopCh: make(chan struct{}),
closeOnce: new(sync.Once),
}
}
func (p *Parser) ParseLiveStream(url *url.URL, file string) error {
func (p *Parser) ParseLiveStream(url *url.URL, live live.Live, file string) error {
// init input
req, err := http.NewRequest("GET", url.String(), nil)
req.Header.Add("User-Agent", "Chrome/59.0.3071.115")
if err != nil {
return err
}
req.Header.Add("User-Agent", "Chrome/59.0.3071.115")
resp, err := p.hc.Do(req)
if err != nil {
return err
@@ -93,7 +104,7 @@ func (p *Parser) doParse() error {
}
// signature
if !bytes.Equal(b[:4], flvSign) {
return NotFlvStream
return ErrNotFlvStream
}
// flag
p.Metadata.HasVideo = uint8(b[4])&(1<<2) != 0
@@ -101,7 +112,7 @@ func (p *Parser) doParse() error {
// offset must be 9
if binary.BigEndian.Uint32(b[5:]) != 9 {
return NotFlvStream
return ErrNotFlvStream
}
// write flv header
+1 -1
View File
@@ -24,7 +24,7 @@ func (p *Parser) parseTag() error {
case scriptTag:
return p.parseScriptTag(length)
default:
return UnknownTag
return ErrUnknownTag
}
return nil
+15 -12
View File
@@ -1,17 +1,20 @@
package flv
type SoundFormat uint8
type SoundRate uint8
type SoundSize uint8
type SoundType uint8
type AACPacketType uint8
type AudioTagHeader struct {
SoundFormat SoundFormat
SoundRate SoundRate
SoundSize SoundSize
SoundType SoundType
AACPacketType AACPacketType
}
type (
SoundFormat uint8
SoundRate uint8
SoundSize uint8
SoundType uint8
AACPacketType uint8
AudioTagHeader struct {
SoundFormat SoundFormat
SoundRate SoundRate
SoundSize SoundSize
SoundType SoundType
AACPacketType AACPacketType
}
)
const (
// SoundFormat
+12 -9
View File
@@ -4,15 +4,18 @@ import (
"io"
)
type FrameType uint8
type CodeID uint8
type AVCPacketType uint8
type VideoTagHeader struct {
FrameType FrameType
CodeID CodeID
AVCPacketType AVCPacketType
CompositionTime uint32
}
type (
FrameType uint8
CodeID uint8
AVCPacketType uint8
VideoTagHeader struct {
FrameType FrameType
CodeID CodeID
AVCPacketType AVCPacketType
CompositionTime uint32
}
)
const (
// Frame Type
-100
View File
@@ -1,100 +0,0 @@
package h264
import (
"bytes"
"errors"
"io"
)
type NaluType uint8
const (
SLICE NaluType = 1
DPA NaluType = 2
DPB NaluType = 3
DPC NaluType = 4
IDR NaluType = 5
SEI NaluType = 6
SPS NaluType = 7
PPS NaluType = 8
AUD NaluType = 9
EOSEQ NaluType = 10
EOSTREAM NaluType = 11
FILL NaluType = 12
UNKNOWN NaluType = 255
)
var (
NALUStartCode = []byte{0x00, 0x00, 0x01}
NALUStartCode2 = []byte{0x00, 0x00, 0x00, 0x01}
UnknownNALUStartCode = errors.New("unknown NALU start code")
)
type Parser struct {
buf *bytes.Buffer
buf1, buf2, buf3, buf4 []byte
}
func NewParser() *Parser {
return &Parser{
buf: bytes.NewBuffer(make([]byte, 2<<10)),
buf1: make([]byte, 1),
buf2: make([]byte, 2),
buf3: make([]byte, 3),
buf4: make([]byte, 4),
}
}
func (p *Parser) parseNALUStartCode(i io.Reader) ([]byte, error) {
if n, err := i.Read(p.buf3); err != nil || n != len(p.buf3) {
if err == nil {
err = io.EOF
}
return nil, err
}
if !bytes.Equal(p.buf3, NALUStartCode) {
if n, err := i.Read(p.buf1); err != nil || n != len(p.buf1) {
if err == nil {
err = io.EOF
}
return nil, err
}
if !bytes.Equal(append(p.buf3, p.buf1...), NALUStartCode2) {
return nil, UnknownNALUStartCode
} else {
return NALUStartCode2, nil
}
} else {
return NALUStartCode, nil
}
}
func (p *Parser) ParseAVCSequenceHeader(i io.Reader, length uint32) {
}
func (p *Parser) ParseAnnexBNalu(i io.Reader, length uint32) (NaluType, error) {
startCode, err := p.parseNALUStartCode(i)
if err != nil {
return UNKNOWN, err
}
if n, err := i.Read(p.buf1); err != nil || n != len(p.buf1) {
if err == nil {
err = io.EOF
}
return UNKNOWN, err
}
naluType := NaluType(uint8(p.buf1[0]) & 31)
buf := make([]byte, length-1-uint32(len(startCode)))
if n, err := i.Read(buf); err != nil || n != len(buf) {
if err == nil {
err = io.EOF
}
return UNKNOWN, err
}
return naluType, nil
}
func (p *Parser) ParseAVCCNalu(i io.Reader, o io.Writer, length uint32) {
}
+26 -2
View File
@@ -1,8 +1,32 @@
//go:generate mockgen -package mock -destination mock/mock.go github.com/hr3lxphr6j/bililive-go/src/lib/parser Parser
package parser
import "net/url"
import (
"errors"
"net/url"
"github.com/hr3lxphr6j/bililive-go/src/live"
)
type Builder interface {
Build() (Parser, error)
}
type Parser interface {
ParseLiveStream(url *url.URL, file string) error
ParseLiveStream(url *url.URL, live live.Live, file string) error
Stop() error
}
var m = make(map[string]Builder)
func Register(name string, b Builder) {
m[name] = b
}
func New(name string) (Parser, error) {
builder, ok := m[name]
if !ok {
return nil, errors.New("unknown parser")
}
return builder.Build()
}
+4 -3
View File
@@ -11,8 +11,9 @@ const (
)
var (
OutOfBuffer = errors.New("n is bigger than len of buffer")
pool = sync.Pool{New: func() interface{} { return make([]byte, defaultBufferSize) }}
ErrOutOfBuffer = errors.New("n is bigger than len of buffer")
pool = sync.Pool{New: func() interface{} { return make([]byte, defaultBufferSize) }}
)
type BufferedReader struct {
@@ -30,7 +31,7 @@ func New(rd io.Reader) *BufferedReader {
func (b *BufferedReader) ReadN(n int) ([]byte, error) {
if n > len(b.buf)-b.r {
return nil, OutOfBuffer
return nil, ErrOutOfBuffer
}
b.l = b.r
return b.readN(n, b.l)
+63
View File
@@ -0,0 +1,63 @@
package utils
import (
"bytes"
"fmt"
"html"
"regexp"
"strconv"
)
type StringFilter interface {
Do(string) string
}
type StringFilterFunc func(string) string
func (f StringFilterFunc) Do(s string) string {
return f(s)
}
type StringFilterChain struct {
filters []StringFilter
}
func NewStringFilterChain(filter ...StringFilter) *StringFilterChain {
return &StringFilterChain{
filters: filter,
}
}
func (c *StringFilterChain) Do(str string) string {
for _, f := range c.filters {
str = f.Do(str)
}
return str
}
func ParseString(str string, filter ...StringFilter) string {
return NewStringFilterChain(filter...).Do(str)
}
var ParseUnicode = StringFilterFunc(func(str string) string {
buf := new(bytes.Buffer)
chars := []byte(str)
for i := 0; i < len(str); {
if chars[i] == 92 && chars[i+1] == 117 {
t, _ := strconv.ParseInt(string(chars[i+2:i+6]), 16, 32)
buf.WriteString(fmt.Sprintf("%c", t))
i += 6
} else {
buf.WriteByte(chars[i])
i++
}
}
return buf.String()
})
var ReplaceIllegalChar = StringFilterFunc(func(str string) string {
reg := regexp.MustCompile(`[\/\\\:\*\?\"\<\>\|]`)
return reg.ReplaceAllString(str, "_")
})
var UnescapeHTMLEntity = StringFilterFunc(html.UnescapeString)
+25 -24
View File
@@ -1,14 +1,12 @@
package utils
import (
"bytes"
"crypto/md5"
"encoding/hex"
"fmt"
"math/rand"
"net/url"
"os/exec"
"regexp"
"strconv"
)
func IsFFmpegExist() bool {
@@ -22,27 +20,6 @@ func GetMd5String(b []byte) string {
return hex.EncodeToString(md5Obj.Sum(nil))
}
func ParseUnicode(str string) string {
buf := new(bytes.Buffer)
chars := []byte(str)
for i := 0; i < len(str); {
if chars[i] == 92 && chars[i+1] == 117 {
t, _ := strconv.ParseInt(string(chars[i+2:i+6]), 16, 32)
buf.WriteString(fmt.Sprintf("%c", t))
i += 6
} else {
buf.WriteByte(chars[i])
i++
}
}
return buf.String()
}
func ReplaceIllegalChar(str string) string {
reg := regexp.MustCompile(`[\/\\\:\*\?\"\<\>\|]`)
return reg.ReplaceAllString(str, "_")
}
var (
lowercaseRunes = []rune("abcdefghijklmnopqrstuvwxyz")
uppercaseRunes = []rune("ABCDEFGHIJKLMNOPQRSTUVWXYZ")
@@ -59,3 +36,27 @@ func GenRandomName(n int) string {
}
return string(b)
}
func Match1(re, str string) string {
reg, err := regexp.Compile(re)
if err != nil {
return ""
}
match := reg.FindStringSubmatch(str)
if match == nil || len(match) < 2 {
return ""
}
return match[1]
}
func GenUrls(strs ...string) ([]*url.URL, error) {
urls := make([]*url.URL, 0, len(strs))
for _, str := range strs {
u, err := url.Parse(str)
if err != nil {
return nil, err
}
urls = append(urls, u)
}
return urls, nil
}
-7
View File
@@ -1,7 +0,0 @@
package utils
import "testing"
func TestReplaceIllegalChar(t *testing.T) {
t.Log(ReplaceIllegalChar(":123.mp4"))
}
+8
View File
@@ -0,0 +1,8 @@
package listeners
import "errors"
var (
ErrListenerExist = errors.New("this live has a listener")
ErrListenerNotExist = errors.New("this live has not a listener")
)
File renamed without changes.
+36 -17
View File
@@ -1,3 +1,4 @@
//go:generate mockgen -package listeners -destination mock_test.go github.com/hr3lxphr6j/bililive-go/src/listeners Listener,Manager
package listeners
import (
@@ -5,11 +6,11 @@ import (
"sync/atomic"
"time"
"github.com/hr3lxphr6j/bililive-go/src/api"
"github.com/hr3lxphr6j/bililive-go/src/configs"
"github.com/hr3lxphr6j/bililive-go/src/instance"
"github.com/hr3lxphr6j/bililive-go/src/interfaces"
"github.com/hr3lxphr6j/bililive-go/src/lib/events"
"github.com/hr3lxphr6j/bililive-go/src/live"
)
const (
@@ -19,73 +20,91 @@ const (
stopped
)
func NewListener(ctx context.Context, live api.Live) *Listener {
type Listener interface {
Start() error
Close()
}
func NewListener(ctx context.Context, live live.Live) Listener {
inst := instance.GetInstance(ctx)
return &Listener{
return &listener{
Live: live,
status: false,
config: inst.Config,
stop: make(chan struct{}),
ed: inst.EventDispatcher.(events.IEventDispatcher),
ed: inst.EventDispatcher.(events.Dispatcher),
logger: inst.Logger,
state: begin,
}
}
type Listener struct {
Live api.Live
type listener struct {
Live live.Live
status bool
config *configs.Config
ed events.IEventDispatcher
ed events.Dispatcher
logger *interfaces.Logger
state uint32
stop chan struct{}
}
func (l *Listener) Start() error {
func (l *listener) Start() error {
if !atomic.CompareAndSwapUint32(&l.state, begin, pending) {
return nil
}
defer atomic.CompareAndSwapUint32(&l.state, pending, running)
l.logger.WithFields(l.Live.GetInfoMap()).Info("Listener Start")
l.ed.DispatchEvent(events.NewEvent(ListenStart, l.Live))
l.refresh()
go l.run()
return nil
}
func (l *Listener) Close() {
func (l *listener) Close() {
if !atomic.CompareAndSwapUint32(&l.state, running, stopped) {
return
}
l.logger.WithFields(l.Live.GetInfoMap()).Info("Listener Close")
l.ed.DispatchEvent(events.NewEvent(ListenStop, l.Live))
close(l.stop)
}
func (l *Listener) refresh() {
func (l *listener) refresh() {
info, err := l.Live.GetInfo()
if err != nil {
l.logger.
WithError(err).
WithField("url", l.Live.GetRawUrl()).
Error("failed to load room info")
return
}
if info.Status == l.status {
return
}
l.status = info.Status
var (
evtTyp events.EventType
logInfo string
fields = map[string]interface{}{
"room": info.RoomName,
"host": info.HostName,
}
)
if l.status {
l.Live.SetLastStartTime(time.Now())
l.logger.WithFields(l.Live.GetInfoMap()).Info("Live Start")
l.ed.DispatchEvent(events.NewEvent(LiveStart, l.Live))
evtTyp = LiveStart
logInfo = "Live Start"
} else {
l.logger.WithFields(l.Live.GetInfoMap()).Info("Live End")
l.ed.DispatchEvent(events.NewEvent(LiveEnd, l.Live))
evtTyp = LiveEnd
logInfo = "Live end"
}
l.ed.DispatchEvent(events.NewEvent(evtTyp, l.Live))
l.logger.WithFields(fields).Info(logInfo)
}
func (l *Listener) run() {
func (l *listener) run() {
ticker := time.NewTicker(time.Duration(l.config.Interval) * time.Second)
defer ticker.Stop()
-7
View File
@@ -1,7 +0,0 @@
package listeners
import "errors"
var roomNotExistError = errors.New("live room was not exist")
var listenerExistError = errors.New("this live has a listener")
var listenerNotExistError = errors.New("this live has not a listener")
-95
View File
@@ -1,95 +0,0 @@
package listeners
import (
"context"
"sync"
"github.com/hr3lxphr6j/bililive-go/src/api"
"github.com/hr3lxphr6j/bililive-go/src/instance"
)
func NewIListenerManager(ctx context.Context) IListenerManager {
lm := &ListenerManager{
savers: make(map[api.LiveId]*Listener),
lock: new(sync.RWMutex),
}
instance.GetInstance(ctx).ListenerManager = lm
return lm
}
// 监听管理器接口
type IListenerManager interface {
AddListener(ctx context.Context, live api.Live) error
RemoveListener(ctx context.Context, liveId api.LiveId) error
GetListener(ctx context.Context, liveId api.LiveId) (*Listener, error)
HasListener(ctx context.Context, liveId api.LiveId) bool
}
type ListenerManager struct {
savers map[api.LiveId]*Listener
lock *sync.RWMutex
}
func (l *ListenerManager) Start(ctx context.Context) error {
inst := instance.GetInstance(ctx)
if inst.Config.RPC.Enable || len(inst.Lives) > 0 {
inst.WaitGroup.Add(1)
}
instance.GetInstance(ctx).Logger.Info("ListenerManager Start")
return nil
}
func (l *ListenerManager) Close(ctx context.Context) {
l.lock.Lock()
defer l.lock.Unlock()
for id, listener := range l.savers {
listener.Close()
delete(l.savers, id)
}
inst := instance.GetInstance(ctx)
inst.WaitGroup.Done()
instance.GetInstance(ctx).Logger.Info("ListenerManager Closed")
}
func (l *ListenerManager) AddListener(ctx context.Context, live api.Live) error {
l.lock.Lock()
defer l.lock.Unlock()
if _, ok := l.savers[live.GetLiveId()]; ok {
return listenerExistError
}
listener := NewListener(ctx, live)
listener.Start()
l.savers[live.GetLiveId()] = listener
return nil
}
func (l *ListenerManager) RemoveListener(ctx context.Context, liveId api.LiveId) error {
l.lock.Lock()
defer l.lock.Unlock()
if listener, ok := l.savers[liveId]; !ok {
return listenerNotExistError
} else {
listener.Close()
delete(l.savers, liveId)
return nil
}
}
func (l *ListenerManager) GetListener(ctx context.Context, liveId api.LiveId) (*Listener, error) {
l.lock.RLock()
defer l.lock.RUnlock()
if r, ok := l.savers[liveId]; !ok {
return nil, listenerNotExistError
} else {
return r, nil
}
}
func (l *ListenerManager) HasListener(ctx context.Context, liveId api.LiveId) bool {
l.lock.RLock()
defer l.lock.RUnlock()
_, ok := l.savers[liveId]
return ok
}
+91
View File
@@ -0,0 +1,91 @@
package listeners
import (
"context"
"errors"
"testing"
"github.com/bluele/gcache"
"github.com/golang/mock/gomock"
"github.com/stretchr/testify/assert"
"github.com/hr3lxphr6j/bililive-go/src/configs"
"github.com/hr3lxphr6j/bililive-go/src/instance"
"github.com/hr3lxphr6j/bililive-go/src/lib/events"
evtmock "github.com/hr3lxphr6j/bililive-go/src/lib/events/mock"
livepkg "github.com/hr3lxphr6j/bililive-go/src/live"
livemock "github.com/hr3lxphr6j/bililive-go/src/live/mock"
"github.com/hr3lxphr6j/bililive-go/src/log"
)
func TestRefresh(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
ed := evtmock.NewMockDispatcher(ctrl)
ctx := context.WithValue(context.Background(), instance.InstanceKey, &instance.Instance{
EventDispatcher: ed,
Config: new(configs.Config),
})
log.New(ctx)
live := livemock.NewMockLive(ctrl)
l := NewListener(ctx, live).(*listener)
// false -> false
live.EXPECT().GetInfo().Return(&livepkg.Info{Status: false}, nil)
l.refresh()
assert.False(t, l.status)
// false -> true
live.EXPECT().GetInfo().Return(&livepkg.Info{Status: true}, nil)
live.EXPECT().SetLastStartTime(gomock.Any())
ed.EXPECT().DispatchEvent(events.NewEvent(LiveStart, live))
l.refresh()
assert.True(t, l.status)
// true -> false
live.EXPECT().GetInfo().Return(&livepkg.Info{Status: false}, nil)
ed.EXPECT().DispatchEvent(events.NewEvent(LiveEnd, live))
l.refresh()
assert.False(t, l.status)
}
func TestRefreshWithError(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
ed := evtmock.NewMockDispatcher(ctrl)
cache := gcache.New(4).LRU().Build()
ctx := context.WithValue(context.Background(), instance.InstanceKey, &instance.Instance{
EventDispatcher: ed,
Cache: cache,
Config: new(configs.Config),
})
log.New(ctx)
live := livemock.NewMockLive(ctrl)
l := NewListener(ctx, live).(*listener)
live.EXPECT().GetInfo().Return(nil, errors.New("this is error"))
live.EXPECT().GetRawUrl().Return("")
l.refresh()
assert.False(t, l.status)
}
func TestListenerStartAndClose(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
ed := evtmock.NewMockDispatcher(ctrl)
cache := gcache.New(4).LRU().Build()
ctx := context.WithValue(context.Background(), instance.InstanceKey, &instance.Instance{
EventDispatcher: ed,
Cache: cache,
Config: &configs.Config{Interval: 5},
})
log.New(ctx)
live := livemock.NewMockLive(ctrl)
live.EXPECT().GetInfo().Return(&livepkg.Info{Status: false}, nil)
ed.EXPECT().DispatchEvent(gomock.Any()).Times(2)
l := NewListener(ctx, live)
assert.NoError(t, l.Start())
assert.NoError(t, l.Start())
l.Close()
l.Close()
}
+94
View File
@@ -0,0 +1,94 @@
package listeners
import (
"context"
"sync"
"github.com/hr3lxphr6j/bililive-go/src/instance"
"github.com/hr3lxphr6j/bililive-go/src/interfaces"
"github.com/hr3lxphr6j/bililive-go/src/live"
)
// for test
var newListener = NewListener
func NewManager(ctx context.Context) Manager {
lm := &manager{
savers: make(map[live.ID]Listener),
}
instance.GetInstance(ctx).ListenerManager = lm
return lm
}
type Manager interface {
interfaces.Module
AddListener(ctx context.Context, live live.Live) error
RemoveListener(ctx context.Context, liveId live.ID) error
GetListener(ctx context.Context, liveId live.ID) (Listener, error)
HasListener(ctx context.Context, liveId live.ID) bool
}
type manager struct {
lock sync.RWMutex
savers map[live.ID]Listener
}
func (m *manager) Start(ctx context.Context) error {
inst := instance.GetInstance(ctx)
if inst.Config.RPC.Enable || len(inst.Lives) > 0 {
inst.WaitGroup.Add(1)
}
return nil
}
func (m *manager) Close(ctx context.Context) {
m.lock.Lock()
defer m.lock.Unlock()
for id, listener := range m.savers {
listener.Close()
delete(m.savers, id)
}
inst := instance.GetInstance(ctx)
inst.WaitGroup.Done()
}
func (m *manager) AddListener(ctx context.Context, live live.Live) error {
m.lock.Lock()
defer m.lock.Unlock()
if _, ok := m.savers[live.GetLiveId()]; ok {
return ErrListenerExist
}
listener := newListener(ctx, live)
m.savers[live.GetLiveId()] = listener
return listener.Start()
}
func (m *manager) RemoveListener(ctx context.Context, liveId live.ID) error {
m.lock.Lock()
defer m.lock.Unlock()
listener, ok := m.savers[liveId]
if !ok {
return ErrListenerNotExist
}
listener.Close()
delete(m.savers, liveId)
return nil
}
func (m *manager) GetListener(ctx context.Context, liveId live.ID) (Listener, error) {
m.lock.RLock()
defer m.lock.RUnlock()
listener, ok := m.savers[liveId]
if !ok {
return nil, ErrListenerNotExist
}
return listener, nil
}
func (m *manager) HasListener(ctx context.Context, liveId live.ID) bool {
m.lock.RLock()
defer m.lock.RUnlock()
_, ok := m.savers[liveId]
return ok
}
+71
View File
@@ -0,0 +1,71 @@
package listeners
import (
"context"
"fmt"
"testing"
"github.com/golang/mock/gomock"
"github.com/stretchr/testify/assert"
"github.com/hr3lxphr6j/bililive-go/src/configs"
"github.com/hr3lxphr6j/bililive-go/src/instance"
"github.com/hr3lxphr6j/bililive-go/src/live"
livemock "github.com/hr3lxphr6j/bililive-go/src/live/mock"
)
func TestManagerAddAndRemoveListener(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
ctx := context.WithValue(context.Background(), instance.InstanceKey, &instance.Instance{})
m := NewManager(ctx)
backup := newListener
newListener = func(ctx context.Context, live live.Live) Listener {
ln := NewMockListener(ctrl)
ln.EXPECT().Start().Return(nil)
ln.EXPECT().Close()
return ln
}
defer func() { newListener = backup }()
l := livemock.NewMockLive(ctrl)
l.EXPECT().GetLiveId().Return(live.ID("test")).Times(3)
assert.NoError(t, m.AddListener(context.Background(), l))
assert.Equal(t, ErrListenerExist, m.AddListener(context.Background(), l))
ln, err := m.GetListener(context.Background(), "test")
assert.NoError(t, err)
assert.NotNil(t, ln)
assert.True(t, m.HasListener(context.Background(), "test"))
assert.NoError(t, m.RemoveListener(context.Background(), "test"))
assert.Equal(t, ErrListenerNotExist, m.RemoveListener(context.Background(), "test"))
_, err = m.GetListener(context.Background(), "test")
assert.Equal(t, ErrListenerNotExist, err)
assert.False(t, m.HasListener(context.Background(), "test"))
}
func TestManagerStartAndClose(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
ctx := context.WithValue(context.Background(), instance.InstanceKey, &instance.Instance{
Config: &configs.Config{
RPC: configs.RPC{Enable: true},
},
})
backup := newListener
newListener = func(ctx context.Context, live live.Live) Listener {
ln := NewMockListener(ctrl)
ln.EXPECT().Start().Return(nil)
ln.EXPECT().Close()
return ln
}
defer func() { newListener = backup }()
m := NewManager(ctx)
assert.NoError(t, m.Start(ctx))
for i := 0; i < 3; i++ {
l := livemock.NewMockLive(ctrl)
id := live.ID(fmt.Sprintf("test_%d", i))
l.EXPECT().GetLiveId().Return(id).AnyTimes()
assert.NoError(t, m.AddListener(ctx, l))
}
m.Close(ctx)
}
+167
View File
@@ -0,0 +1,167 @@
// Code generated by MockGen. DO NOT EDIT.
// Source: github.com/hr3lxphr6j/bililive-go/src/listeners (interfaces: Listener,Manager)
// Package listeners is a generated GoMock package.
package listeners
import (
context "context"
gomock "github.com/golang/mock/gomock"
live "github.com/hr3lxphr6j/bililive-go/src/live"
reflect "reflect"
)
// MockListener is a mock of Listener interface
type MockListener struct {
ctrl *gomock.Controller
recorder *MockListenerMockRecorder
}
// MockListenerMockRecorder is the mock recorder for MockListener
type MockListenerMockRecorder struct {
mock *MockListener
}
// NewMockListener creates a new mock instance
func NewMockListener(ctrl *gomock.Controller) *MockListener {
mock := &MockListener{ctrl: ctrl}
mock.recorder = &MockListenerMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use
func (m *MockListener) EXPECT() *MockListenerMockRecorder {
return m.recorder
}
// Close mocks base method
func (m *MockListener) Close() {
m.ctrl.T.Helper()
m.ctrl.Call(m, "Close")
}
// Close indicates an expected call of Close
func (mr *MockListenerMockRecorder) Close() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Close", reflect.TypeOf((*MockListener)(nil).Close))
}
// Start mocks base method
func (m *MockListener) Start() error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Start")
ret0, _ := ret[0].(error)
return ret0
}
// Start indicates an expected call of Start
func (mr *MockListenerMockRecorder) Start() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Start", reflect.TypeOf((*MockListener)(nil).Start))
}
// MockManager is a mock of Manager interface
type MockManager struct {
ctrl *gomock.Controller
recorder *MockManagerMockRecorder
}
// MockManagerMockRecorder is the mock recorder for MockManager
type MockManagerMockRecorder struct {
mock *MockManager
}
// NewMockManager creates a new mock instance
func NewMockManager(ctrl *gomock.Controller) *MockManager {
mock := &MockManager{ctrl: ctrl}
mock.recorder = &MockManagerMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use
func (m *MockManager) EXPECT() *MockManagerMockRecorder {
return m.recorder
}
// AddListener mocks base method
func (m *MockManager) AddListener(arg0 context.Context, arg1 live.Live) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "AddListener", arg0, arg1)
ret0, _ := ret[0].(error)
return ret0
}
// AddListener indicates an expected call of AddListener
func (mr *MockManagerMockRecorder) AddListener(arg0, arg1 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddListener", reflect.TypeOf((*MockManager)(nil).AddListener), arg0, arg1)
}
// Close mocks base method
func (m *MockManager) Close(arg0 context.Context) {
m.ctrl.T.Helper()
m.ctrl.Call(m, "Close", arg0)
}
// Close indicates an expected call of Close
func (mr *MockManagerMockRecorder) Close(arg0 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Close", reflect.TypeOf((*MockManager)(nil).Close), arg0)
}
// GetListener mocks base method
func (m *MockManager) GetListener(arg0 context.Context, arg1 live.ID) (Listener, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetListener", arg0, arg1)
ret0, _ := ret[0].(Listener)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetListener indicates an expected call of GetListener
func (mr *MockManagerMockRecorder) GetListener(arg0, arg1 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetListener", reflect.TypeOf((*MockManager)(nil).GetListener), arg0, arg1)
}
// HasListener mocks base method
func (m *MockManager) HasListener(arg0 context.Context, arg1 live.ID) bool {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "HasListener", arg0, arg1)
ret0, _ := ret[0].(bool)
return ret0
}
// HasListener indicates an expected call of HasListener
func (mr *MockManagerMockRecorder) HasListener(arg0, arg1 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HasListener", reflect.TypeOf((*MockManager)(nil).HasListener), arg0, arg1)
}
// RemoveListener mocks base method
func (m *MockManager) RemoveListener(arg0 context.Context, arg1 live.ID) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "RemoveListener", arg0, arg1)
ret0, _ := ret[0].(error)
return ret0
}
// RemoveListener indicates an expected call of RemoveListener
func (mr *MockManagerMockRecorder) RemoveListener(arg0, arg1 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RemoveListener", reflect.TypeOf((*MockManager)(nil).RemoveListener), arg0, arg1)
}
// Start mocks base method
func (m *MockManager) Start(arg0 context.Context) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Start", arg0)
ret0, _ := ret[0].(error)
return ret0
}
// Start indicates an expected call of Start
func (mr *MockManagerMockRecorder) Start(arg0 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Start", reflect.TypeOf((*MockManager)(nil).Start), arg0)
}
+119
View File
@@ -0,0 +1,119 @@
package bilibili
import (
"net/url"
"strings"
"github.com/tidwall/gjson"
"github.com/hr3lxphr6j/bililive-go/src/lib/http"
"github.com/hr3lxphr6j/bililive-go/src/lib/utils"
"github.com/hr3lxphr6j/bililive-go/src/live"
"github.com/hr3lxphr6j/bililive-go/src/live/internal"
)
const (
domain = "live.bilibili.com"
cnName = "哔哩哔哩"
roomInitUrl = "https://api.live.bilibili.com/room/v1/Room/room_init"
roomApiUrl = "https://api.live.bilibili.com/room/v1/Room/get_info"
userApiUrl = "https://api.live.bilibili.com/live_user/v1/UserInfo/get_anchor_in_room"
liveApiUrl = "https://api.live.bilibili.com/room/v1/Room/playUrl"
)
func init() {
live.Register(domain, new(builder))
}
type builder struct{}
func (b *builder) Build(url *url.URL) (live.Live, error) {
return &Live{
BaseLive: internal.NewBaseLive(url),
}, nil
}
type Live struct {
internal.BaseLive
realID string
}
func (l *Live) parseRealId() error {
paths := strings.Split(l.Url.Path, "/")
if len(paths) < 2 {
return live.ErrRoomUrlIncorrect
}
body, err := http.Get(roomInitUrl, nil, map[string]string{
"id": paths[1],
})
if err != nil {
return nil
}
if gjson.GetBytes(body, "code").Int() != 0 {
return live.ErrRoomNotExist
}
l.realID = gjson.GetBytes(body, "data.room_id").String()
return nil
}
func (l *Live) GetInfo() (info *live.Info, err error) {
// Parse the short id from URL to full id
if l.realID == "" {
if err := l.parseRealId(); err != nil {
return nil, err
}
}
body, err := http.Get(roomApiUrl, nil, map[string]string{
"room_id": l.realID,
"from": "room",
})
if err != nil {
return nil, err
}
if gjson.GetBytes(body, "code").Int() != 0 {
return nil, live.ErrRoomNotExist
}
info = &live.Info{
Live: l,
RoomName: gjson.GetBytes(body, "data.title").String(),
Status: gjson.GetBytes(body, "data.live_status").Int() == 1,
}
body2, err := http.Get(userApiUrl, nil, map[string]string{
"roomid": l.realID,
})
if err != nil {
return nil, err
}
info.HostName = gjson.GetBytes(body2, "data.info.uname").String()
return info, nil
}
func (l *Live) GetStreamUrls() (us []*url.URL, err error) {
if l.realID == "" {
if err := l.parseRealId(); err != nil {
return nil, err
}
}
body, err := http.Get(liveApiUrl, nil, map[string]string{
"cid": l.realID,
"quality": "4",
"platform": "web",
})
if err != nil {
return nil, err
}
urls := make([]string, 0, 0)
gjson.GetBytes(body, "data.durl.#.url").ForEach(func(key, value gjson.Result) bool {
urls = append(urls, value.String())
return true
})
return utils.GenUrls(urls...)
}
func (l *Live) GetPlatformCNName() string {
return cnName
}
+95
View File
@@ -0,0 +1,95 @@
package cc
import (
"fmt"
"net/url"
"regexp"
"github.com/tidwall/gjson"
"github.com/hr3lxphr6j/bililive-go/src/lib/http"
"github.com/hr3lxphr6j/bililive-go/src/lib/utils"
"github.com/hr3lxphr6j/bililive-go/src/live"
"github.com/hr3lxphr6j/bililive-go/src/live/internal"
)
const (
domain = "cc.163.com"
cnName = "CC直播"
apiUrl = "http://cgi.v.cc.163.com/video_play_url/"
)
func init() {
live.Register(domain, new(builder))
}
type builder struct{}
func (b *builder) Build(url *url.URL) (live.Live, error) {
return &Live{
BaseLive: internal.NewBaseLive(url),
}, nil
}
type Live struct {
internal.BaseLive
ccid string
}
func (l *Live) parseCCId() error {
dom, err := http.Get(l.Url.String(), nil, nil)
if err != nil {
return err
}
ccid := utils.Match1(`anchorCcId\s*:\s*'(\d*)'`, string(dom))
if ccid == "" {
return live.ErrInternalError
}
l.ccid = ccid
return nil
}
func (l *Live) GetInfo() (info *live.Info, err error) {
dom, err := http.Get(l.Url.String(), nil, nil)
if err != nil {
return nil, err
}
var (
hostName = utils.UnescapeHTMLEntity(utils.Match1(`anchorName\s*:\s*'([^']*)',`, string(dom)))
roomName = utils.UnescapeHTMLEntity(utils.Match1(`js-live-title nick" title\s*=\s*"([^"]*)"`, string(dom)))
)
if hostName == "" || roomName == "" {
return nil, live.ErrInternalError
}
info = &live.Info{
Live: l,
HostName: hostName,
RoomName: roomName,
Status: len(regexp.MustCompile(`isLive\s*:\s*\d+,`).FindAll(dom, -1)) > 0,
}
return info, nil
}
func (l *Live) GetStreamUrls() (us []*url.URL, err error) {
if l.ccid == "" {
if err := l.parseCCId(); err != nil {
return nil, err
}
}
data, err := http.Get(fmt.Sprintf("%s%s", apiUrl, l.ccid), nil, nil)
if err != nil {
return nil, err
}
return utils.GenUrls(
gjson.GetBytes(data, "videourl").String(),
gjson.GetBytes(data, "bakvideourl").String(),
)
}
func (l *Live) GetPlatformCNName() string {
return cnName
}
@@ -1,4 +1,4 @@
package api
package douyu
import (
"bytes"
@@ -10,6 +10,9 @@ import (
"time"
"github.com/hr3lxphr6j/bililive-go/src/lib/utils"
"github.com/hr3lxphr6j/bililive-go/src/live"
"github.com/hr3lxphr6j/bililive-go/src/live/internal"
"github.com/robertkrimen/otto"
"github.com/satori/go.uuid"
"github.com/tidwall/gjson"
@@ -23,11 +26,26 @@ import (
Thanks
*/
const (
douyuLiveInfoUrl = "https://open.douyucdn.cn/api/RoomApi/room"
douyuLiveEncUrl = "https://www.douyu.com/swf_api/homeH5Enc"
douyuLiveAPIUrl = "https://www.douyu.com/lapi/live/getH5Play"
domain = "www.douyu.com"
cnName = "斗鱼"
liveInfoUrl = "https://open.douyucdn.cn/api/RoomApi/room"
liveEncUrl = "https://www.douyu.com/swf_api/homeH5Enc"
liveAPIUrl = "https://www.douyu.com/lapi/live/getH5Play"
)
func init() {
live.Register(domain, new(builder))
}
type builder struct{}
func (b *builder) Build(url *url.URL) (live.Live, error) {
return &Live{
BaseLive: internal.NewBaseLive(url),
}, nil
}
var (
cryptoJS []byte
header = map[string]string{
@@ -80,9 +98,9 @@ var (
`))
)
func render(template *template.Template, data interface{}) (string, error) {
func render(tmpl *template.Template, data interface{}) (string, error) {
buf := bytes.NewBuffer(nil)
if err := template.Execute(buf, data); err != nil {
if err := tmpl.Execute(buf, data); err != nil {
return "", err
}
return buf.String(), nil
@@ -108,77 +126,70 @@ func getEngineWithCryptoJS() (*otto.Otto, error) {
return engine, nil
}
type DouyuLive struct {
abstractLive
type Live struct {
internal.BaseLive
roomID string
}
func (d *DouyuLive) getRoomID() {
if d.roomID != "" {
func (l *Live) fetchRoomID() {
if l.roomID != "" {
return
}
d.roomID = strings.Split(d.Url.Path, "/")[1]
body, err := http.Get(d.Url.String(), nil, nil)
l.roomID = strings.Split(l.Url.Path, "/")[1]
body, err := http.Get(l.Url.String(), nil, nil)
if err != nil {
return
}
for _, reg := range douyuRoomIDRegs {
strs := reg.FindStringSubmatch(string(body))
if len(strs) == 2 {
d.roomID = strs[1]
l.roomID = strs[1]
return
}
}
}
func (d *DouyuLive) GetInfo() (info *Info, err error) {
defer func() {
if e := recover(); e != nil {
err = e.(error)
}
}()
d.getRoomID()
body, err := http.Get(fmt.Sprintf("%s/%s", douyuLiveInfoUrl, d.roomID), nil, nil)
func (l *Live) GetInfo() (info *live.Info, err error) {
l.fetchRoomID()
body, err := http.Get(fmt.Sprintf("%s/%s", liveInfoUrl, l.roomID), nil, nil)
if err != nil {
return nil, err
}
if gjson.GetBytes(body, "error").Int() != 0 {
return nil, &RoomNotExistsError{d.Url}
return nil, live.ErrRoomNotExist
}
info = &Info{
Live: d,
info = &live.Info{
Live: l,
HostName: gjson.GetBytes(body, "data.owner_name").String(),
RoomName: gjson.GetBytes(body, "data.room_name").String(),
Status: gjson.GetBytes(body, "data.room_status").String() == "1",
}
d.cachedInfo = info
return info, nil
}
type context struct {
DebugMessages string
DecryptedCodes string
Resoult string
Ub98484234 string
Workflow string
}
func (d *DouyuLive) getSignParams() (url.Values, error) {
body, err := http.Get(douyuLiveEncUrl, map[string]string{
"rids": d.roomID,
}, nil)
func (l *Live) getSignParams() (url.Values, error) {
body, err := http.Get(liveEncUrl, nil, map[string]string{
"rids": l.roomID,
})
if err != nil {
return nil, err
}
jsEnc := gjson.GetBytes(body, fmt.Sprintf("data.room%s", d.roomID)).String()
fmt.Print(workflowReg.FindStringSubmatch(jsEnc))
jsEnc := gjson.GetBytes(body, fmt.Sprintf("data.room%s", l.roomID)).String()
workflow := ""
workflowMatch := workflowReg.FindStringSubmatch(jsEnc)
if len(workflowMatch) == 2 {
if workflowMatch := workflowReg.FindStringSubmatch(jsEnc); len(workflowMatch) == 2 {
workflow = workflowMatch[1]
}
context := context{
context := struct {
DebugMessages string
DecryptedCodes string
Resoult string
Ub98484234 string
Workflow string
}{
DebugMessages: utils.GenRandomName(8),
DecryptedCodes: utils.GenRandomName(8),
Resoult: utils.GenRandomName(8),
@@ -199,7 +210,6 @@ func (d *DouyuLive) getSignParams() (url.Values, error) {
}
jsEnc = strings.ReplaceAll(jsEnc, fmt.Sprintf("eval(%s);", context.Workflow), jsPatch)
engine, err := getEngineWithCryptoJS()
if err != nil {
return nil, err
@@ -215,7 +225,7 @@ func (d *DouyuLive) getSignParams() (url.Values, error) {
}
did := strings.ReplaceAll(uuid.Must(uuid.NewV4()).String(), "-", "")
ts := time.Now()
res, err := engine.Call("ub98484234", nil, d.roomID, did, ts.Unix())
res, err := engine.Call("ub98484234", nil, l.roomID, did, ts.Unix())
if err != nil {
return nil, err
}
@@ -239,19 +249,24 @@ func (d *DouyuLive) getSignParams() (url.Values, error) {
return values, nil
}
func (d *DouyuLive) GetStreamUrls() (us []*url.URL, err error) {
d.getRoomID()
params, err := d.getSignParams()
func (l *Live) GetStreamUrls() (us []*url.URL, err error) {
l.fetchRoomID()
params, err := l.getSignParams()
if err != nil {
return nil, err
}
resp, err := http.Post(fmt.Sprintf("%s/%s", douyuLiveAPIUrl, d.roomID), nil, []byte(params.Encode()), header)
resp, err := http.Post(fmt.Sprintf("%s/%s", liveAPIUrl, l.roomID), header, nil, []byte(params.Encode()))
if gjson.GetBytes(resp, "error").Int() != 0 {
return nil, fmt.Errorf("get stream error")
}
u, err := url.Parse(gjson.GetBytes(resp, "data.rtmp_url").String() + "/" + gjson.GetBytes(resp, "data.rtmp_live").String())
if err != nil {
return nil, err
}
return []*url.URL{u}, nil
return utils.GenUrls(
fmt.Sprintf("%s/%s",
gjson.GetBytes(resp, "data.rtmp_url").String(),
gjson.GetBytes(resp, "data.rtmp_live").String(),
),
)
}
func (l *Live) GetPlatformCNName() string {
return cnName
}
+11
View File
@@ -0,0 +1,11 @@
package live
import (
"errors"
)
var (
ErrRoomNotExist = errors.New("room not exists")
ErrRoomUrlIncorrect = errors.New("room url incorrect")
ErrInternalError = errors.New("internal error")
)
+118
View File
@@ -0,0 +1,118 @@
package huomao
import (
"fmt"
"net/url"
"time"
"github.com/tidwall/gjson"
"github.com/hr3lxphr6j/bililive-go/src/lib/http"
"github.com/hr3lxphr6j/bililive-go/src/lib/utils"
"github.com/hr3lxphr6j/bililive-go/src/live"
"github.com/hr3lxphr6j/bililive-go/src/live/internal"
)
const (
domain = "www.huomao.com"
cnName = "火猫"
liveApiUrl = "http://www.huomao.com/swf/live_data"
salt = "6FE26D855E1AEAE090E243EB1AF73685"
)
func init() {
live.Register(domain, new(builder))
}
type builder struct{}
func (b *builder) Build(url *url.URL) (live.Live, error) {
return &Live{
BaseLive: internal.NewBaseLive(url),
}, nil
}
type Live struct {
internal.BaseLive
isDuanbo bool
}
func (l *Live) GetInfo() (info *live.Info, err error) {
dom, err := http.Get(l.Url.String(), nil, nil)
if err != nil {
return nil, err
}
l.isDuanbo = utils.Match1(`face_label\s?=\s?(\d*);`, string(dom)) == "1"
var (
hostNameReg string
roomNameReg string
statusReg string
)
if l.isDuanbo {
hostNameReg = `live_yz_h_nickName\s?=\s?"([^"]*)";`
roomNameReg = `live_yz_h_channelName\s?=\s?"([^"]*)";`
statusReg = `is_live\s?=\s?"?(\d*)"?;`
} else {
hostNameReg = `"nickname":"([^"]*)",`
roomNameReg = `"channel":"([^"]*)"`
statusReg = `"is_live":"?(\d*)"?,`
}
var (
hostName = utils.Match1(hostNameReg, string(dom))
roomName = utils.Match1(roomNameReg, string(dom))
status = utils.Match1(statusReg, string(dom))
)
if hostName == "" || roomName == "" || status == "" {
return nil, live.ErrInternalError
}
info = &live.Info{
Live: l,
HostName: hostName,
RoomName: roomName,
Status: status == "1",
}
return info, nil
}
func (l *Live) GetStreamUrls() ([]*url.URL, error) {
dom, err := http.Get(l.Url.String(), nil, nil)
if err != nil {
return nil, err
}
var streamReg string
if !l.isDuanbo {
streamReg = `"stream":"([^"]*)"`
} else {
streamReg = `getFlash\("\d*","([^"]*)","\d*"\);`
}
var (
from = "huomaoh5room"
t = fmt.Sprintf("%d", time.Now().Unix())
streamID = utils.Match1(streamReg, string(dom))
token = utils.GetMd5String([]byte(fmt.Sprintf("%s%s%s%s", streamID, from, t, salt)))
)
body, err := http.Post(liveApiUrl, nil, map[string]string{
"VideoIDS": streamID,
"streamtype": "live",
"cdns": "1",
"from": from,
"time": t,
"token": token,
}, nil)
if err != nil {
return nil, err
}
urls := make([]string, 0, 0)
gjson.GetBytes(body, "streamList.#.list.#.url").ForEach(func(key, value gjson.Result) bool {
for _, u := range value.Array() {
urls = append(urls, u.String())
}
return true
})
return utils.GenUrls(urls...)
}
func (l *Live) GetPlatformCNName() string {
return cnName
}
+94
View File
@@ -0,0 +1,94 @@
package huya
import (
"fmt"
"math/rand"
"net/url"
"strings"
"time"
"github.com/hr3lxphr6j/bililive-go/src/lib/http"
"github.com/hr3lxphr6j/bililive-go/src/lib/utils"
"github.com/hr3lxphr6j/bililive-go/src/live"
"github.com/hr3lxphr6j/bililive-go/src/live/internal"
)
const (
domain = "www.huya.com"
cnName = "虎牙"
)
func init() {
live.Register(domain, new(builder))
}
type builder struct{}
func (b *builder) Build(url *url.URL) (live.Live, error) {
return &Live{
BaseLive: internal.NewBaseLive(url),
}, nil
}
type Live struct {
internal.BaseLive
}
func (l *Live) GetInfo() (info *live.Info, err error) {
dom, err := http.Get(l.Url.String(), nil, nil)
if err != nil {
return nil, err
}
if res := utils.Match1("哎呀,虎牙君找不到这个主播,要不搜索看看?", string(dom)); res != "" {
return nil, live.ErrRoomNotExist
}
var (
strFilter = utils.NewStringFilterChain(utils.ParseUnicode, utils.UnescapeHTMLEntity)
hostName = strFilter.Do(utils.Match1(`"nick":"([^"]*)"`, string(dom)))
roomName = strFilter.Do(utils.Match1(`"introduction":"([^"]*)"`, string(dom)))
status = strFilter.Do(utils.Match1(`"isOn":([^,]*),`, string(dom)))
)
if hostName == "" || roomName == "" || status == "" {
return nil, live.ErrInternalError
}
info = &live.Info{
Live: l,
HostName: hostName,
RoomName: roomName,
Status: status == "true",
}
return info, nil
}
func (l *Live) GetStreamUrls() (us []*url.URL, err error) {
dom, err := http.Get(l.Url.String(), nil, nil)
if err != nil {
return nil, err
}
var (
sStreamName = utils.Match1(`"sStreamName":"([^"]*)"`, string(dom))
sFlvUrl = strings.ReplaceAll(utils.Match1(`"sFlvUrl":"([^"]*)"`, string(dom)), `\/`, `/`)
sFlvAntiCode = utils.Match1(`"sFlvAntiCode":"([^"]*)"`, string(dom))
iLineIndex = utils.Match1(`"iLineIndex":(\d*),`, string(dom))
uid = (time.Now().Unix()%1e7*1e6 + int64(1e3*rand.Float64())) % 4294967295
)
u, err := url.Parse(fmt.Sprintf("%s/%s.flv", sFlvUrl, sStreamName))
if err != nil {
return nil, err
}
value := url.Values{}
value.Add("line", iLineIndex)
value.Add("p2p", "0")
value.Add("type", "web")
value.Add("ver", "1805071653")
value.Add("uid", fmt.Sprintf("%d", uid))
u.RawQuery = fmt.Sprintf("%s&%s", value.Encode(), utils.UnescapeHTMLEntity(sFlvAntiCode))
return []*url.URL{u}, nil
}
func (l *Live) GetPlatformCNName() string {
return cnName
}
+40
View File
@@ -0,0 +1,40 @@
package live
import (
"encoding/json"
)
type Info struct {
Live Live
HostName, RoomName string
Status, Listening, Recoding bool
}
func (i *Info) MarshalJSON() ([]byte, error) {
t := struct {
Id ID `json:"id"`
LiveUrl string `json:"live_url"`
PlatformCNName string `json:"platform_cn_name"`
HostName string `json:"host_name"`
RoomName string `json:"room_name"`
Status bool `json:"status"`
Listening bool `json:"listening"`
Recoding bool `json:"recoding"`
LastStartTime string `json:"last_start_time,omitempty"`
LastStartTimeUnix int64 `json:"last_start_time_unix,omitempty"`
}{
Id: i.Live.GetLiveId(),
LiveUrl: i.Live.GetRawUrl(),
PlatformCNName: i.Live.GetPlatformCNName(),
HostName: i.HostName,
RoomName: i.RoomName,
Status: i.Status,
Listening: i.Listening,
Recoding: i.Recoding,
}
if !i.Live.GetLastStartTime().IsZero() {
t.LastStartTime = i.Live.GetLastStartTime().Format("2006-01-02 15:04:05")
t.LastStartTimeUnix = i.Live.GetLastStartTime().Unix()
}
return json.Marshal(t)
}
+43
View File
@@ -0,0 +1,43 @@
package internal
import (
"fmt"
"net/url"
"time"
"github.com/hr3lxphr6j/bililive-go/src/lib/utils"
"github.com/hr3lxphr6j/bililive-go/src/live"
)
type BaseLive struct {
Url *url.URL
LastStartTime time.Time
LiveId live.ID
}
func genLiveId(url *url.URL) live.ID {
return live.ID(utils.GetMd5String([]byte(fmt.Sprintf("%s%s", url.Host, url.Path))))
}
func NewBaseLive(url *url.URL) BaseLive {
return BaseLive{
Url: url,
LiveId: genLiveId(url),
}
}
func (a *BaseLive) GetLiveId() live.ID {
return a.LiveId
}
func (a *BaseLive) GetRawUrl() string {
return a.Url.String()
}
func (a *BaseLive) GetLastStartTime() time.Time {
return a.LastStartTime
}
func (a *BaseLive) SetLastStartTime(time time.Time) {
a.LastStartTime = time
}
+81
View File
@@ -0,0 +1,81 @@
//go:generate mockgen -package mock -destination mock/mock.go github.com/hr3lxphr6j/bililive-go/src/live Live
package live
import (
"errors"
"net/url"
"time"
"github.com/bluele/gcache"
)
var (
m = make(map[string]Builder)
)
func Register(domain string, b Builder) {
m[domain] = b
}
func getBuilder(domain string) (Builder, bool) {
builder, ok := m[domain]
return builder, ok
}
type Builder interface {
Build(*url.URL) (Live, error)
}
type ID string
type Live interface {
GetLiveId() ID
GetRawUrl() string
GetInfo() (*Info, error)
GetStreamUrls() ([]*url.URL, error)
GetPlatformCNName() string
GetLastStartTime() time.Time
SetLastStartTime(time.Time)
}
type wrappedLive struct {
Live
cache gcache.Cache
}
func newWrappedLive(live Live, cache gcache.Cache) Live {
return &wrappedLive{
Live: live,
cache: cache,
}
}
func (w *wrappedLive) GetInfo() (*Info, error) {
i, err := w.Live.GetInfo()
if err != nil {
return nil, err
}
if w.cache != nil {
w.cache.Set(w, i)
}
return i, nil
}
func New(url *url.URL, cache gcache.Cache) (live Live, err error) {
builder, ok := getBuilder(url.Host)
if !ok {
return nil, errors.New("not support this url")
}
live, err = builder.Build(url)
if err != nil {
return
}
live = newWrappedLive(live, cache)
for i := 0; i < 3; i++ {
if _, err = live.GetInfo(); err == nil {
break
}
time.Sleep(1 * time.Second)
}
return
}
+99
View File
@@ -0,0 +1,99 @@
package longzhu
import (
"fmt"
"net/url"
"strings"
"github.com/tidwall/gjson"
"github.com/hr3lxphr6j/bililive-go/src/lib/http"
"github.com/hr3lxphr6j/bililive-go/src/lib/utils"
"github.com/hr3lxphr6j/bililive-go/src/live"
"github.com/hr3lxphr6j/bililive-go/src/live/internal"
)
const (
domain = "star.longzhu.com"
cnName = "龙珠"
mobileUrl = "http://m.longzhu.com/"
roomApiUrl = "http://liveapi.plu.cn/liveapp/roomstatus"
liveApiUrl = "http://livestream.plu.cn/live/getlivePlayurl"
)
func init() {
live.Register(domain, new(builder))
}
type builder struct{}
func (b *builder) Build(url *url.URL) (live.Live, error) {
return &Live{
BaseLive: internal.NewBaseLive(url),
}, nil
}
type Live struct {
internal.BaseLive
realId string
}
func (l *Live) parseRealId() error {
paths := strings.Split(l.Url.Path, "/")
if len(paths) < 2 {
return live.ErrRoomUrlIncorrect
}
dom, err := http.Get(fmt.Sprintf("%s%s", mobileUrl, paths[1]), nil, nil)
if err != nil {
return err
}
realId := utils.Match1(`var\s*roomId\s*=\s*(\d+);`, string(dom))
if realId == "" {
return live.ErrRoomNotExist
}
l.realId = realId
return nil
}
func (l *Live) GetInfo() (info *live.Info, err error) {
if l.realId == "" {
if err := l.parseRealId(); err != nil {
return nil, err
}
}
body, err := http.Get(roomApiUrl, nil, map[string]string{"roomId": l.realId})
if err != nil {
return nil, err
}
info = &live.Info{
Live: l,
HostName: gjson.GetBytes(body, "userName").String(),
RoomName: gjson.GetBytes(body, "title").String(),
Status: len(gjson.GetBytes(body, "streamUri").String()) > 4,
}
return info, nil
}
func (l *Live) GetStreamUrls() (us []*url.URL, err error) {
if l.realId == "" {
if err := l.parseRealId(); err != nil {
return nil, err
}
}
body, err := http.Get(liveApiUrl, nil, map[string]string{"roomId": l.realId})
if err != nil {
return nil, err
}
urls := make([]string, 0, 0)
gjson.GetBytes(body, "playLines.0.urls.#.securityUrl").ForEach(func(key, value gjson.Result) bool {
urls = append(urls, value.String())
return true
})
return utils.GenUrls(urls...)
}
func (l *Live) GetPlatformCNName() string {
return cnName
}
+134
View File
@@ -0,0 +1,134 @@
// Code generated by MockGen. DO NOT EDIT.
// Source: github.com/hr3lxphr6j/bililive-go/src/live (interfaces: Live)
// Package mock is a generated GoMock package.
package mock
import (
gomock "github.com/golang/mock/gomock"
live "github.com/hr3lxphr6j/bililive-go/src/live"
url "net/url"
reflect "reflect"
time "time"
)
// MockLive is a mock of Live interface
type MockLive struct {
ctrl *gomock.Controller
recorder *MockLiveMockRecorder
}
// MockLiveMockRecorder is the mock recorder for MockLive
type MockLiveMockRecorder struct {
mock *MockLive
}
// NewMockLive creates a new mock instance
func NewMockLive(ctrl *gomock.Controller) *MockLive {
mock := &MockLive{ctrl: ctrl}
mock.recorder = &MockLiveMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use
func (m *MockLive) EXPECT() *MockLiveMockRecorder {
return m.recorder
}
// GetInfo mocks base method
func (m *MockLive) GetInfo() (*live.Info, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetInfo")
ret0, _ := ret[0].(*live.Info)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetInfo indicates an expected call of GetInfo
func (mr *MockLiveMockRecorder) GetInfo() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetInfo", reflect.TypeOf((*MockLive)(nil).GetInfo))
}
// GetLastStartTime mocks base method
func (m *MockLive) GetLastStartTime() time.Time {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetLastStartTime")
ret0, _ := ret[0].(time.Time)
return ret0
}
// GetLastStartTime indicates an expected call of GetLastStartTime
func (mr *MockLiveMockRecorder) GetLastStartTime() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetLastStartTime", reflect.TypeOf((*MockLive)(nil).GetLastStartTime))
}
// GetLiveId mocks base method
func (m *MockLive) GetLiveId() live.ID {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetLiveId")
ret0, _ := ret[0].(live.ID)
return ret0
}
// GetLiveId indicates an expected call of GetLiveId
func (mr *MockLiveMockRecorder) GetLiveId() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetLiveId", reflect.TypeOf((*MockLive)(nil).GetLiveId))
}
// GetPlatformCNName mocks base method
func (m *MockLive) GetPlatformCNName() string {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetPlatformCNName")
ret0, _ := ret[0].(string)
return ret0
}
// GetPlatformCNName indicates an expected call of GetPlatformCNName
func (mr *MockLiveMockRecorder) GetPlatformCNName() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPlatformCNName", reflect.TypeOf((*MockLive)(nil).GetPlatformCNName))
}
// GetRawUrl mocks base method
func (m *MockLive) GetRawUrl() string {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetRawUrl")
ret0, _ := ret[0].(string)
return ret0
}
// GetRawUrl indicates an expected call of GetRawUrl
func (mr *MockLiveMockRecorder) GetRawUrl() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetRawUrl", reflect.TypeOf((*MockLive)(nil).GetRawUrl))
}
// GetStreamUrls mocks base method
func (m *MockLive) GetStreamUrls() ([]*url.URL, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetStreamUrls")
ret0, _ := ret[0].([]*url.URL)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetStreamUrls indicates an expected call of GetStreamUrls
func (mr *MockLiveMockRecorder) GetStreamUrls() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetStreamUrls", reflect.TypeOf((*MockLive)(nil).GetStreamUrls))
}
// SetLastStartTime mocks base method
func (m *MockLive) SetLastStartTime(arg0 time.Time) {
m.ctrl.T.Helper()
m.ctrl.Call(m, "SetLastStartTime", arg0)
}
// SetLastStartTime indicates an expected call of SetLastStartTime
func (mr *MockLiveMockRecorder) SetLastStartTime(arg0 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetLastStartTime", reflect.TypeOf((*MockLive)(nil).SetLastStartTime), arg0)
}
+74
View File
@@ -0,0 +1,74 @@
package openrec
import (
"net/url"
"strings"
"github.com/hr3lxphr6j/bililive-go/src/lib/http"
"github.com/hr3lxphr6j/bililive-go/src/lib/utils"
"github.com/hr3lxphr6j/bililive-go/src/live"
"github.com/hr3lxphr6j/bililive-go/src/live/internal"
)
const (
domain = "www.openrec.tv"
cnName = "openrec"
)
type Live struct {
internal.BaseLive
}
func init() {
live.Register(domain, new(builder))
}
type builder struct{}
func (b *builder) Build(url *url.URL) (live.Live, error) {
return &Live{
BaseLive: internal.NewBaseLive(url),
}, nil
}
func (l *Live) GetInfo() (info *live.Info, err error) {
dom, err := http.Get(l.Url.String(), nil, nil)
if err != nil {
return nil, err
}
var (
roomName = utils.ParseString(
utils.Match1(`"title":"([^:]*)",`, string(dom)),
utils.StringFilterFunc(strings.TrimSpace),
utils.UnescapeHTMLEntity,
)
hostName = utils.ParseString(
utils.Match1(`"name":"([^:]*)",`, string(dom)),
utils.ParseUnicode,
utils.UnescapeHTMLEntity,
)
status = utils.Match1(`"onairStatus":(\d),`, string(dom))
)
if roomName == "" || hostName == "" || status == "" {
return nil, live.ErrInternalError
}
info = &live.Info{
Live: l,
RoomName: roomName,
HostName: hostName,
Status: status == "1",
}
return info, nil
}
func (l *Live) GetStreamUrls() (us []*url.URL, err error) {
dom, err := http.Get(l.Url.String(), nil, nil)
if err != nil {
return nil, err
}
return utils.GenUrls(utils.Match1(`{"url":"(\S*m3u8)",`, string(dom)))
}
func (l *Live) GetPlatformCNName() string {
return cnName
}
+82
View File
@@ -0,0 +1,82 @@
package qq
import (
"net/url"
"strings"
"github.com/tidwall/gjson"
"github.com/hr3lxphr6j/bililive-go/src/lib/http"
"github.com/hr3lxphr6j/bililive-go/src/lib/utils"
"github.com/hr3lxphr6j/bililive-go/src/live"
"github.com/hr3lxphr6j/bililive-go/src/live/internal"
)
const (
domain = "egame.qq.com"
cnName = "企鹅电竞"
mobileUrl = "https://m.egame.qq.com/live"
)
func init() {
live.Register(domain, new(builder))
}
type builder struct{}
func (b *builder) Build(url *url.URL) (live.Live, error) {
return &Live{
BaseLive: internal.NewBaseLive(url),
}, nil
}
type Live struct {
internal.BaseLive
}
func (l *Live) GetInfo() (info *live.Info, err error) {
paths := strings.Split(l.Url.Path, "/")
if len(paths) < 2 {
return nil, live.ErrRoomUrlIncorrect
}
anchorID := paths[1]
dom, err := http.Get(l.Url.String(), nil, nil)
if err != nil {
return nil, err
}
roomName := utils.UnescapeHTMLEntity(utils.Match1(`title:"([^"]*)"`, string(dom)))
hostName := utils.UnescapeHTMLEntity(utils.Match1(`nickName:"([^"]+)"`, string(dom)))
dom2, err := http.Get(mobileUrl, nil, map[string]string{
"anchorid": anchorID,
})
if err != nil {
return nil, err
}
isLive := utils.Match1(`"isLive":(\d+)`, string(dom2))
if roomName == "" || hostName == "" || isLive == "" {
return nil, live.ErrInternalError
}
info = &live.Info{
Live: l,
RoomName: string(roomName),
HostName: string(hostName),
Status: isLive == "1",
}
return info, nil
}
func (l *Live) GetStreamUrls() (us []*url.URL, err error) {
dom, err := http.Get(l.Url.String(), nil, nil)
if err != nil {
return nil, err
}
result := utils.Match1(`"urlArray":(\[[^\]]+\])`, string(dom))
if result == "" {
return nil, live.ErrInternalError
}
return utils.GenUrls(gjson.Get(result, "#[bitrate==0].playUrl").String())
}
func (l *Live) GetPlatformCNName() string {
return cnName
}
+119
View File
@@ -0,0 +1,119 @@
package twitch
import (
"fmt"
"math/rand"
"net/url"
"strings"
"github.com/tidwall/gjson"
"github.com/hr3lxphr6j/bililive-go/src/lib/http"
"github.com/hr3lxphr6j/bililive-go/src/live"
"github.com/hr3lxphr6j/bililive-go/src/live/internal"
)
const (
domain = "www.twitch.tv"
cnName = "twitch"
clientId = "jzkbprff40iqj646a697cyrvl0zt2m6"
channelApiUrl = "https://api.twitch.tv/kraken/channels/%s"
liveBaseUrl = "https://usher.ttvnw.net/api/channel/hls/%s.m3u8"
streamApiUrl = "https://api.twitch.tv/kraken/streams/%s"
tokenApiUrl = "https://api.twitch.tv/api/channels/%s/access_token"
)
func init() {
live.Register(domain, new(builder))
}
type builder struct{}
func (b *builder) Build(url *url.URL) (live.Live, error) {
return &Live{
BaseLive: internal.NewBaseLive(url),
}, nil
}
var headers = map[string]string{"client-id": clientId}
type Live struct {
internal.BaseLive
hostName, roomName string
}
// 在hostName, roomName为空执行,在live有效时再从steam api解析
func (l *Live) parseInfo() error {
paths := strings.Split(l.Url.Path, "/")
if len(paths) < 2 {
return live.ErrRoomUrlIncorrect
}
chanId := paths[1]
body, err := http.Get(fmt.Sprintf(channelApiUrl, chanId), headers, nil)
if err != nil {
return live.ErrRoomNotExist
}
l.hostName = gjson.GetBytes(body, "name").String()
l.roomName = gjson.GetBytes(body, "status").String()
return nil
}
func (l *Live) GetInfo() (info *live.Info, err error) {
if l.hostName == "" || l.roomName == "" {
if err := l.parseInfo(); err != nil {
return nil, err
}
}
body, err := http.Get(fmt.Sprintf(streamApiUrl, l.hostName), headers, nil)
if err != nil {
return nil, err
}
status := gjson.GetBytes(body, "stream").String() != ""
if status {
l.roomName = gjson.GetBytes(body, "stream.channel.status").String()
}
info = &live.Info{
Live: l,
HostName: l.hostName,
RoomName: l.roomName,
Status: status,
}
return info, nil
}
func (l *Live) GetStreamUrls() (us []*url.URL, err error) {
if l.hostName == "" || l.roomName == "" {
if err := l.parseInfo(); err != nil {
return nil, err
}
}
body, err := http.Get(fmt.Sprintf(tokenApiUrl, l.hostName), headers, nil)
if err != nil {
return nil, err
}
var (
token = gjson.GetBytes(body, "token").String()
sig = gjson.GetBytes(body, "sig").String()
p = fmt.Sprintf("%d", rand.Intn(9000000)+1000000)
)
u, err := url.Parse(fmt.Sprintf(liveBaseUrl, l.hostName))
if err != nil {
return nil, err
}
v := url.Values{}
v.Add("allow_source", "true")
v.Add("allow_audio_only", "true")
v.Add("allow_spectre", "true")
v.Add("p", p)
v.Add("player", "twitchweb")
v.Add("segment_preference", "4")
v.Add("sig", sig)
v.Add("token", token)
u.RawQuery = v.Encode()
return []*url.URL{u}, nil
}
func (l *Live) GetPlatformCNName() string {
return cnName
}
+75
View File
@@ -0,0 +1,75 @@
package yizhibo
import (
"net/url"
"strings"
"github.com/tidwall/gjson"
"github.com/hr3lxphr6j/bililive-go/src/lib/http"
"github.com/hr3lxphr6j/bililive-go/src/lib/utils"
"github.com/hr3lxphr6j/bililive-go/src/live"
"github.com/hr3lxphr6j/bililive-go/src/live/internal"
)
const (
domain = "www.yizhibo.com"
cnName = "一直播"
apiUrl = "http://www.yizhibo.com/live/h5api/get_basic_live_info"
)
func init() {
live.Register(domain, new(builder))
}
type builder struct{}
func (b *builder) Build(url *url.URL) (live.Live, error) {
return &Live{
BaseLive: internal.NewBaseLive(url),
}, nil
}
type Live struct {
internal.BaseLive
}
func (l *Live) requestRoomInfo() ([]byte, error) {
scid := strings.Split(strings.Split(l.Url.Path, "/")[2], ".")[0]
body, err := http.Get(apiUrl, nil, map[string]string{"scid": scid})
if err != nil {
return nil, err
}
if gjson.GetBytes(body, "result").Int() != 1 {
return nil, live.ErrRoomNotExist
} else {
return body, nil
}
}
func (l *Live) GetInfo() (info *live.Info, err error) {
data, err := l.requestRoomInfo()
if err != nil {
return nil, err
}
info = &live.Info{
Live: l,
HostName: gjson.GetBytes(data, "data.nickname").String(),
RoomName: gjson.GetBytes(data, "data.live_title").String(),
Status: gjson.GetBytes(data, "data.status").Int() == 10,
}
return info, nil
}
func (l *Live) GetStreamUrls() (us []*url.URL, err error) {
data, err := l.requestRoomInfo()
if err != nil {
return nil, err
}
return utils.GenUrls(gjson.GetBytes(data, "data.play_url").String())
}
func (l *Live) GetPlatformCNName() string {
return cnName
}
+81
View File
@@ -0,0 +1,81 @@
package zhanqi
import (
"encoding/base64"
"fmt"
"net/url"
"strings"
"github.com/tidwall/gjson"
"github.com/hr3lxphr6j/bililive-go/src/lib/http"
"github.com/hr3lxphr6j/bililive-go/src/lib/utils"
"github.com/hr3lxphr6j/bililive-go/src/live"
"github.com/hr3lxphr6j/bililive-go/src/live/internal"
)
const (
domain = "www.zhanqi.tv"
cnName = "战旗"
apiUrl = "https://www.zhanqi.tv/api/static/v2.1/room/domain/%s.json"
)
func init() {
live.Register(domain, new(builder))
}
type builder struct{}
func (b *builder) Build(url *url.URL) (live.Live, error) {
return &Live{
BaseLive: internal.NewBaseLive(url),
}, nil
}
type Live struct {
internal.BaseLive
}
func (l *Live) requestRoomInfo() ([]byte, error) {
body, err := http.Get(fmt.Sprintf(apiUrl, strings.Split(l.Url.Path, "/")[1]), nil, nil)
if err != nil {
return nil, err
}
if gjson.GetBytes(body, "code").Int() == 0 {
return body, nil
} else {
return nil, live.ErrRoomNotExist
}
}
func (l *Live) GetInfo() (info *live.Info, err error) {
body, err := l.requestRoomInfo()
if err != nil {
return nil, err
}
info = &live.Info{
Live: l,
HostName: gjson.GetBytes(body, "data.nickname").String(),
RoomName: gjson.GetBytes(body, "data.title").String(),
Status: gjson.GetBytes(body, "data.status").String() == "4",
}
return info, nil
}
func (l *Live) GetStreamUrls() (us []*url.URL, err error) {
body, err := l.requestRoomInfo()
if err != nil {
return nil, err
}
videoLevels := gjson.GetBytes(body, "data.flashvars.VideoLevels").String()
data, err := base64.StdEncoding.DecodeString(videoLevels)
if err != nil {
return nil, err
}
return utils.GenUrls(gjson.GetBytes(data, "streamUrl").String())
}
func (l *Live) GetPlatformCNName() string {
return cnName
}
+1 -1
View File
@@ -10,7 +10,7 @@ import (
"github.com/hr3lxphr6j/bililive-go/src/interfaces"
)
func NewLogger(ctx context.Context) *interfaces.Logger {
func New(ctx context.Context) *interfaces.Logger {
inst := instance.GetInstance(ctx)
logLevel := logrus.InfoLevel
if inst.Config.Debug {
+8
View File
@@ -0,0 +1,8 @@
package recorders
import "errors"
var (
ErrRecorderExist = errors.New("recorder is exist")
ErrRecorderNotExist = errors.New("recorder is not exist")
)
File renamed without changes.
+125
View File
@@ -0,0 +1,125 @@
package recorders
import (
"context"
"sync"
"github.com/hr3lxphr6j/bililive-go/src/instance"
"github.com/hr3lxphr6j/bililive-go/src/interfaces"
"github.com/hr3lxphr6j/bililive-go/src/lib/events"
"github.com/hr3lxphr6j/bililive-go/src/listeners"
"github.com/hr3lxphr6j/bililive-go/src/live"
)
func NewManager(ctx context.Context) Manager {
rm := &manager{
savers: make(map[live.ID]Recorder),
}
instance.GetInstance(ctx).RecorderManager = rm
return rm
}
type Manager interface {
interfaces.Module
AddRecorder(ctx context.Context, live live.Live) error
RemoveRecorder(ctx context.Context, liveId live.ID) error
GetRecorder(ctx context.Context, liveId live.ID) (Recorder, error)
HasRecorder(ctx context.Context, liveId live.ID) bool
}
// for test
var (
newRecorder = NewRecorder
)
type manager struct {
lock sync.RWMutex
savers map[live.ID]Recorder
}
func (m *manager) registryListener(ctx context.Context, ed events.Dispatcher) {
ed.AddEventListener(listeners.LiveStart, events.NewEventListener(func(event *events.Event) {
live := event.Object.(live.Live)
if err := m.AddRecorder(ctx, live); err != nil {
instance.GetInstance(ctx).Logger.
Errorf("failed to add recorder, err: %v", err)
}
}))
removeEvtListener := events.NewEventListener(func(event *events.Event) {
live := event.Object.(live.Live)
if !m.HasRecorder(ctx, live.GetLiveId()) {
return
}
if err := m.RemoveRecorder(ctx, live.GetLiveId()); err != nil {
instance.GetInstance(ctx).Logger.
Errorf("failed to remove recorder, err: %v", err)
}
})
ed.AddEventListener(listeners.LiveEnd, removeEvtListener)
ed.AddEventListener(listeners.ListenStop, removeEvtListener)
}
func (m *manager) Start(ctx context.Context) error {
inst := instance.GetInstance(ctx)
if inst.Config.RPC.Enable || len(inst.Lives) > 0 {
inst.WaitGroup.Add(1)
}
m.registryListener(ctx, inst.EventDispatcher.(events.Dispatcher))
return nil
}
func (m *manager) Close(ctx context.Context) {
m.lock.Lock()
defer m.lock.Unlock()
for id, recorder := range m.savers {
recorder.Close()
delete(m.savers, id)
}
inst := instance.GetInstance(ctx)
inst.WaitGroup.Done()
}
func (m *manager) AddRecorder(ctx context.Context, live live.Live) error {
m.lock.Lock()
defer m.lock.Unlock()
if _, ok := m.savers[live.GetLiveId()]; ok {
return ErrRecorderExist
}
recorder, err := newRecorder(ctx, live)
if err != nil {
return err
}
m.savers[live.GetLiveId()] = recorder
return recorder.Start()
}
func (m *manager) RemoveRecorder(ctx context.Context, liveId live.ID) error {
m.lock.Lock()
defer m.lock.Unlock()
recorder, ok := m.savers[liveId]
if !ok {
return ErrRecorderNotExist
}
recorder.Close()
delete(m.savers, liveId)
return nil
}
func (m *manager) GetRecorder(ctx context.Context, liveId live.ID) (Recorder, error) {
m.lock.RLock()
defer m.lock.RUnlock()
r, ok := m.savers[liveId]
if !ok {
return nil, ErrRecorderNotExist
}
return r, nil
}
func (m *manager) HasRecorder(ctx context.Context, liveId live.ID) bool {
m.lock.RLock()
defer m.lock.RUnlock()
_, ok := m.savers[liveId]
return ok
}
+42
View File
@@ -0,0 +1,42 @@
package recorders
import (
"context"
"testing"
"github.com/golang/mock/gomock"
"github.com/stretchr/testify/assert"
"github.com/hr3lxphr6j/bililive-go/src/instance"
"github.com/hr3lxphr6j/bililive-go/src/live"
livemock "github.com/hr3lxphr6j/bililive-go/src/live/mock"
)
func TestManagerAddAndRemoveRecorder(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
ctx := context.WithValue(context.Background(), instance.InstanceKey, &instance.Instance{})
m := NewManager(ctx)
backup := newRecorder
newRecorder = func(ctx context.Context, live live.Live) (Recorder, error) {
r := NewMockRecorder(ctrl)
r.EXPECT().Start().Return(nil)
r.EXPECT().Close()
return r, nil
}
defer func() { newRecorder = backup }()
l := livemock.NewMockLive(ctrl)
l.EXPECT().GetLiveId().Return(live.ID("test")).Times(3)
assert.NoError(t, m.AddRecorder(context.Background(), l))
assert.Equal(t, ErrRecorderExist, m.AddRecorder(context.Background(), l))
ln, err := m.GetRecorder(context.Background(), "test")
assert.NoError(t, err)
assert.NotNil(t, ln)
assert.True(t, m.HasRecorder(context.Background(), "test"))
assert.NoError(t, m.RemoveRecorder(context.Background(), "test"))
assert.Equal(t, ErrRecorderNotExist, m.RemoveRecorder(context.Background(), "test"))
_, err = m.GetRecorder(context.Background(), "test")
assert.Equal(t, ErrRecorderNotExist, err)
assert.False(t, m.HasRecorder(context.Background(), "test"))
}
+167
View File
@@ -0,0 +1,167 @@
// Code generated by MockGen. DO NOT EDIT.
// Source: github.com/hr3lxphr6j/bililive-go/src/recorders (interfaces: Recorder,Manager)
// Package recorders is a generated GoMock package.
package recorders
import (
context "context"
gomock "github.com/golang/mock/gomock"
live "github.com/hr3lxphr6j/bililive-go/src/live"
reflect "reflect"
)
// MockRecorder is a mock of Recorder interface
type MockRecorder struct {
ctrl *gomock.Controller
recorder *MockRecorderMockRecorder
}
// MockRecorderMockRecorder is the mock recorder for MockRecorder
type MockRecorderMockRecorder struct {
mock *MockRecorder
}
// NewMockRecorder creates a new mock instance
func NewMockRecorder(ctrl *gomock.Controller) *MockRecorder {
mock := &MockRecorder{ctrl: ctrl}
mock.recorder = &MockRecorderMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use
func (m *MockRecorder) EXPECT() *MockRecorderMockRecorder {
return m.recorder
}
// Close mocks base method
func (m *MockRecorder) Close() {
m.ctrl.T.Helper()
m.ctrl.Call(m, "Close")
}
// Close indicates an expected call of Close
func (mr *MockRecorderMockRecorder) Close() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Close", reflect.TypeOf((*MockRecorder)(nil).Close))
}
// Start mocks base method
func (m *MockRecorder) Start() error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Start")
ret0, _ := ret[0].(error)
return ret0
}
// Start indicates an expected call of Start
func (mr *MockRecorderMockRecorder) Start() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Start", reflect.TypeOf((*MockRecorder)(nil).Start))
}
// MockManager is a mock of Manager interface
type MockManager struct {
ctrl *gomock.Controller
recorder *MockManagerMockRecorder
}
// MockManagerMockRecorder is the mock recorder for MockManager
type MockManagerMockRecorder struct {
mock *MockManager
}
// NewMockManager creates a new mock instance
func NewMockManager(ctrl *gomock.Controller) *MockManager {
mock := &MockManager{ctrl: ctrl}
mock.recorder = &MockManagerMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use
func (m *MockManager) EXPECT() *MockManagerMockRecorder {
return m.recorder
}
// AddRecorder mocks base method
func (m *MockManager) AddRecorder(arg0 context.Context, arg1 live.Live) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "AddRecorder", arg0, arg1)
ret0, _ := ret[0].(error)
return ret0
}
// AddRecorder indicates an expected call of AddRecorder
func (mr *MockManagerMockRecorder) AddRecorder(arg0, arg1 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddRecorder", reflect.TypeOf((*MockManager)(nil).AddRecorder), arg0, arg1)
}
// Close mocks base method
func (m *MockManager) Close(arg0 context.Context) {
m.ctrl.T.Helper()
m.ctrl.Call(m, "Close", arg0)
}
// Close indicates an expected call of Close
func (mr *MockManagerMockRecorder) Close(arg0 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Close", reflect.TypeOf((*MockManager)(nil).Close), arg0)
}
// GetRecorder mocks base method
func (m *MockManager) GetRecorder(arg0 context.Context, arg1 live.ID) (Recorder, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetRecorder", arg0, arg1)
ret0, _ := ret[0].(Recorder)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetRecorder indicates an expected call of GetRecorder
func (mr *MockManagerMockRecorder) GetRecorder(arg0, arg1 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetRecorder", reflect.TypeOf((*MockManager)(nil).GetRecorder), arg0, arg1)
}
// HasRecorder mocks base method
func (m *MockManager) HasRecorder(arg0 context.Context, arg1 live.ID) bool {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "HasRecorder", arg0, arg1)
ret0, _ := ret[0].(bool)
return ret0
}
// HasRecorder indicates an expected call of HasRecorder
func (mr *MockManagerMockRecorder) HasRecorder(arg0, arg1 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HasRecorder", reflect.TypeOf((*MockManager)(nil).HasRecorder), arg0, arg1)
}
// RemoveRecorder mocks base method
func (m *MockManager) RemoveRecorder(arg0 context.Context, arg1 live.ID) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "RemoveRecorder", arg0, arg1)
ret0, _ := ret[0].(error)
return ret0
}
// RemoveRecorder indicates an expected call of RemoveRecorder
func (mr *MockManagerMockRecorder) RemoveRecorder(arg0, arg1 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RemoveRecorder", reflect.TypeOf((*MockManager)(nil).RemoveRecorder), arg0, arg1)
}
// Start mocks base method
func (m *MockManager) Start(arg0 context.Context) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Start", arg0)
ret0, _ := ret[0].(error)
return ret0
}
// Start indicates an expected call of Start
func (mr *MockManagerMockRecorder) Start(arg0 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Start", reflect.TypeOf((*MockManager)(nil).Start), arg0)
}
+100 -39
View File
@@ -1,8 +1,10 @@
//go:generate mockgen -package recorders -destination mock_test.go github.com/hr3lxphr6j/bililive-go/src/recorders Recorder,Manager
package recorders
import (
"context"
"fmt"
"net/url"
"os"
"path/filepath"
"strings"
@@ -10,7 +12,9 @@ import (
"sync/atomic"
"time"
"github.com/hr3lxphr6j/bililive-go/src/api"
"github.com/bluele/gcache"
"github.com/sirupsen/logrus"
"github.com/hr3lxphr6j/bililive-go/src/configs"
"github.com/hr3lxphr6j/bililive-go/src/instance"
"github.com/hr3lxphr6j/bililive-go/src/interfaces"
@@ -19,6 +23,7 @@ import (
"github.com/hr3lxphr6j/bililive-go/src/lib/parser/ffmpeg"
"github.com/hr3lxphr6j/bililive-go/src/lib/parser/native/flv"
"github.com/hr3lxphr6j/bililive-go/src/lib/utils"
"github.com/hr3lxphr6j/bililive-go/src/live"
)
const (
@@ -28,13 +33,40 @@ const (
stopped
)
type Recorder struct {
Live api.Live
// for test
var (
newParser = func(u *url.URL, useNativeFlvParser bool) (parser.Parser, error) {
parserName := ffmpeg.Name
if strings.Contains(u.Path, ".flv") && useNativeFlvParser {
parserName = flv.Name
}
return parser.New(parserName)
}
mkdir = func(path string) error {
return os.MkdirAll(path, os.ModePerm)
}
removeEmptyFile = func(file string) {
if stat, err := os.Stat(file); err == nil && stat.Size() == 0 {
os.Remove(file)
}
}
)
type Recorder interface {
Start() error
Close()
}
type recorder struct {
Live live.Live
OutPutPath string
config *configs.Config
ed events.IEventDispatcher
ed events.Dispatcher
logger *interfaces.Logger
cache gcache.Cache
parser parser.Parser
parserLock *sync.RWMutex
@@ -43,13 +75,14 @@ type Recorder struct {
state uint32
}
func NewRecorder(ctx context.Context, live api.Live) (*Recorder, error) {
func NewRecorder(ctx context.Context, live live.Live) (Recorder, error) {
inst := instance.GetInstance(ctx)
return &Recorder{
return &recorder{
Live: live,
OutPutPath: instance.GetInstance(ctx).Config.OutPutPath,
config: inst.Config,
ed: inst.EventDispatcher.(events.IEventDispatcher),
cache: inst.Cache,
ed: inst.EventDispatcher.(events.Dispatcher),
logger: inst.Logger,
state: begin,
stop: make(chan struct{}),
@@ -57,47 +90,59 @@ func NewRecorder(ctx context.Context, live api.Live) (*Recorder, error) {
}, nil
}
func (r *Recorder) run() {
func (r *recorder) tryRecode() {
urls, err := r.Live.GetStreamUrls()
if err != nil || len(urls) == 0 {
r.getLogger().WithError(err).Warn("failed to get stream url, will retry after 5s...")
time.Sleep(5 * time.Second)
return
}
obj, _ := r.cache.Get(r.Live)
info := obj.(*live.Info)
var (
strFilter = utils.NewStringFilterChain(utils.ReplaceIllegalChar, utils.UnescapeHTMLEntity)
platformName = strFilter.Do(r.Live.GetPlatformCNName())
hostName = strFilter.Do(info.HostName)
roomName = strFilter.Do(info.RoomName)
ts = time.Now().Format("2006-01-02 15-04-05")
fileName = fmt.Sprintf("[%s][%s][%s].flv", ts, hostName, roomName)
outputPath = filepath.Join(r.OutPutPath, platformName, hostName)
file = filepath.Join(outputPath, fileName)
url = urls[0]
)
if err := mkdir(outputPath); err != nil {
r.getLogger().WithError(err).Errorf("failed to create output path[%s]", outputPath)
return
}
p, err := newParser(url, r.config.Feature.UseNativeFlvParser)
if err != nil {
r.getLogger().WithError(err).Error("failed to init parse")
return
}
r.setAndCloseParser(p)
r.getLogger().Debugln(r.parser.ParseLiveStream(url, r.Live, file))
removeEmptyFile(file)
}
func (r *recorder) run() {
for {
select {
case <-r.stop:
return
default:
urls, err := r.Live.GetStreamUrls()
if err != nil {
time.Sleep(5 * time.Second)
continue
}
var (
platformName = utils.ReplaceIllegalChar(r.Live.GetPlatformCNName())
hostName = utils.ReplaceIllegalChar(r.Live.GetCachedInfo().HostName)
roomName = utils.ReplaceIllegalChar(r.Live.GetCachedInfo().RoomName)
fileName = fmt.Sprintf("[%s][%s][%s].flv", time.Now().Format("2006-01-02 15-04-05"), hostName, roomName)
outputPath = filepath.Join(r.OutPutPath, platformName, hostName)
file = filepath.Join(outputPath, fileName)
url = urls[0]
)
os.MkdirAll(outputPath, os.ModePerm)
if strings.Contains(url.Path, ".flv") && r.config.Feature.UseNativeFlvParser {
r.setAndCloseParser(flv.NewParser())
} else {
r.setAndCloseParser(ffmpeg.New())
}
r.logger.Debugln(r.parser.ParseLiveStream(url, file))
if stat, err := os.Stat(file); err == nil && stat.Size() == 0 {
os.Remove(file)
}
r.tryRecode()
}
}
}
func (r *Recorder) getParser() parser.Parser {
func (r *recorder) getParser() parser.Parser {
r.parserLock.RLock()
defer r.parserLock.RUnlock()
return r.parser
}
func (r *Recorder) setAndCloseParser(p parser.Parser) {
func (r *recorder) setAndCloseParser(p parser.Parser) {
r.parserLock.Lock()
defer r.parserLock.Unlock()
if r.parser != nil {
@@ -106,18 +151,18 @@ func (r *Recorder) setAndCloseParser(p parser.Parser) {
r.parser = p
}
func (r *Recorder) Start() error {
func (r *recorder) Start() error {
if !atomic.CompareAndSwapUint32(&r.state, begin, pending) {
return fmt.Errorf("recorder in error state")
return nil
}
go r.run()
r.logger.WithFields(r.Live.GetInfoMap()).Info("Recorde Start")
r.getLogger().Info("Record Start")
r.ed.DispatchEvent(events.NewEvent(RecorderStart, r.Live))
atomic.CompareAndSwapUint32(&r.state, pending, running)
return nil
}
func (r *Recorder) Close() {
func (r *recorder) Close() {
if !atomic.CompareAndSwapUint32(&r.state, running, stopped) {
return
}
@@ -125,6 +170,22 @@ func (r *Recorder) Close() {
if p := r.getParser(); p != nil {
p.Stop()
}
r.logger.WithFields(r.Live.GetInfoMap()).Info("Recorde End")
r.getLogger().Info("Record End")
r.ed.DispatchEvent(events.NewEvent(RecorderStop, r.Live))
}
func (r *recorder) getLogger() *logrus.Entry {
return r.logger.WithFields(r.getFields())
}
func (r *recorder) getFields() map[string]interface{} {
obj, err := r.cache.Get(r.Live)
if err != nil {
return nil
}
info := obj.(*live.Info)
return map[string]interface{}{
"host": info.HostName,
"room": info.RoomName,
}
}
-6
View File
@@ -1,6 +0,0 @@
package recorders
import "errors"
var recorderExistError = errors.New("this recorder is exist")
var recorderNotExistError = errors.New("this recorder is not exist")
-136
View File
@@ -1,136 +0,0 @@
package recorders
import (
"context"
"sync"
"github.com/hr3lxphr6j/bililive-go/src/api"
"github.com/hr3lxphr6j/bililive-go/src/instance"
"github.com/hr3lxphr6j/bililive-go/src/lib/events"
"github.com/hr3lxphr6j/bililive-go/src/listeners"
)
func NewIRecorderManager(ctx context.Context) IRecorderManager {
rm := &RecorderManager{
savers: make(map[api.LiveId]*Recorder),
lock: new(sync.RWMutex),
}
instance.GetInstance(ctx).RecorderManager = rm
return rm
}
type IRecorderManager interface {
AddRecorder(ctx context.Context, live api.Live) error
RemoveRecorder(ctx context.Context, liveId api.LiveId) error
GetRecorder(ctx context.Context, liveId api.LiveId) (*Recorder, error)
HasRecorder(ctx context.Context, liveId api.LiveId) bool
}
type RecorderManager struct {
savers map[api.LiveId]*Recorder
lock *sync.RWMutex
}
func (r *RecorderManager) Start(ctx context.Context) error {
inst := instance.GetInstance(ctx)
if inst.Config.RPC.Enable || len(inst.Lives) > 0 {
inst.WaitGroup.Add(1)
}
inst.Logger.Info("RecorderManager Start")
ed := inst.EventDispatcher.(events.IEventDispatcher)
// 开播事件
ed.AddEventListener(listeners.LiveStart, events.NewEventListener(func(event *events.Event) {
live := event.Object.(api.Live)
if err := r.AddRecorder(ctx, live); err != nil {
instance.GetInstance(ctx).Logger.
WithFields(live.GetInfoMap()).
Errorf("failed to add recorder, err: %v", err)
}
}))
// 下播事件
ed.AddEventListener(listeners.LiveEnd, events.NewEventListener(func(event *events.Event) {
live := event.Object.(api.Live)
if !r.HasRecorder(ctx, live.GetLiveId()) {
return
}
if err := r.RemoveRecorder(ctx, live.GetLiveId()); err != nil {
instance.GetInstance(ctx).Logger.
WithFields(live.GetInfoMap()).
Errorf("failed to remove recorder, err: %v", err)
}
}))
// 监听关闭事件
ed.AddEventListener(listeners.ListenStop, events.NewEventListener(func(event *events.Event) {
live := event.Object.(api.Live)
if !r.HasRecorder(ctx, live.GetLiveId()) {
return
}
if err := r.RemoveRecorder(ctx, live.GetLiveId()); err != nil {
instance.GetInstance(ctx).Logger.
WithFields(live.GetInfoMap()).
Errorf("failed to remove recorder, err: %v", err)
}
}))
return nil
}
func (r *RecorderManager) Close(ctx context.Context) {
r.lock.Lock()
defer r.lock.Unlock()
for id, recorder := range r.savers {
recorder.Close()
delete(r.savers, id)
}
inst := instance.GetInstance(ctx)
inst.WaitGroup.Done()
inst.Logger.Info("RecorderManager Closed")
}
func (r *RecorderManager) AddRecorder(ctx context.Context, live api.Live) error {
r.lock.Lock()
defer r.lock.Unlock()
if _, ok := r.savers[live.GetLiveId()]; ok {
return recorderExistError
}
recorder, err := NewRecorder(ctx, live)
if err != nil {
return err
}
r.savers[live.GetLiveId()] = recorder
recorder.Start()
return nil
}
func (r *RecorderManager) RemoveRecorder(ctx context.Context, liveId api.LiveId) error {
r.lock.Lock()
defer r.lock.Unlock()
if recorder, ok := r.savers[liveId]; !ok {
return recorderNotExistError
} else {
recorder.Close()
delete(r.savers, liveId)
return nil
}
}
func (r *RecorderManager) GetRecorder(ctx context.Context, liveId api.LiveId) (*Recorder, error) {
r.lock.RLock()
defer r.lock.RUnlock()
if r, ok := r.savers[liveId]; !ok {
return nil, recorderNotExistError
} else {
return r, nil
}
}
func (r *RecorderManager) HasRecorder(ctx context.Context, liveId api.LiveId) bool {
r.lock.RLock()
defer r.lock.RUnlock()
_, ok := r.savers[liveId]
return ok
}
+16 -15
View File
@@ -11,10 +11,10 @@ import (
"github.com/gorilla/mux"
"github.com/tidwall/gjson"
"github.com/hr3lxphr6j/bililive-go/src/api"
"github.com/hr3lxphr6j/bililive-go/src/consts"
"github.com/hr3lxphr6j/bililive-go/src/instance"
"github.com/hr3lxphr6j/bililive-go/src/listeners"
"github.com/hr3lxphr6j/bililive-go/src/live"
"github.com/hr3lxphr6j/bililive-go/src/recorders"
)
@@ -24,17 +24,18 @@ type CommonResp struct {
Data interface{} `json:"data"`
}
func parseInfo(ctx context.Context, live api.Live) *api.Info {
func parseInfo(ctx context.Context, l live.Live) *live.Info {
inst := instance.GetInstance(ctx)
info := live.GetCachedInfo()
info.Listening = inst.ListenerManager.(listeners.IListenerManager).HasListener(ctx, api.LiveId(live.GetLiveId()))
info.Recoding = inst.RecorderManager.(recorders.IRecorderManager).HasRecorder(ctx, api.LiveId(live.GetLiveId()))
obj, _ := inst.Cache.Get(l)
info := obj.(*live.Info)
info.Listening = inst.ListenerManager.(listeners.Manager).HasListener(ctx, l.GetLiveId())
info.Recoding = inst.RecorderManager.(recorders.Manager).HasRecorder(ctx, l.GetLiveId())
return info
}
func getAllLives(writer http.ResponseWriter, r *http.Request) {
inst := instance.GetInstance(r.Context())
info := make([]*api.Info, 0)
info := make([]*live.Info, 0)
for _, v := range inst.Lives {
info = append(info, parseInfo(r.Context(), v))
}
@@ -50,7 +51,7 @@ func getLive(writer http.ResponseWriter, r *http.Request) {
inst := instance.GetInstance(r.Context())
vars := mux.Vars(r)
resp := CommonResp{}
if live, ok := inst.Lives[api.LiveId(vars["id"])]; ok {
if live, ok := inst.Lives[live.ID(vars["id"])]; ok {
resp.Data = parseInfo(r.Context(), live)
} else {
resp.ErrNo = 404
@@ -66,13 +67,13 @@ func parseLiveAction(writer http.ResponseWriter, r *http.Request) {
inst := instance.GetInstance(r.Context())
vars := mux.Vars(r)
resp := CommonResp{}
if live, ok := inst.Lives[api.LiveId(vars["id"])]; ok {
if live, ok := inst.Lives[live.ID(vars["id"])]; ok {
switch vars["action"] {
case "start":
inst.ListenerManager.(listeners.IListenerManager).AddListener(r.Context(), live)
inst.ListenerManager.(listeners.Manager).AddListener(r.Context(), live)
resp.Data = parseInfo(r.Context(), live)
case "stop":
inst.ListenerManager.(listeners.IListenerManager).RemoveListener(r.Context(), live.GetLiveId())
inst.ListenerManager.(listeners.Manager).RemoveListener(r.Context(), live.GetLiveId())
resp.Data = parseInfo(r.Context(), live)
default:
resp.ErrNo = 400
@@ -106,16 +107,16 @@ func parseLiveAction(writer http.ResponseWriter, r *http.Request) {
*/
func addLives(writer http.ResponseWriter, r *http.Request) {
b, _ := ioutil.ReadAll(r.Body)
info := make([]*api.Info, 0)
info := make([]*live.Info, 0)
gjson.GetBytes(b, "lives").ForEach(func(key, value gjson.Result) bool {
isListen := value.Get("listen").Bool()
u, _ := url.Parse(value.Get("url").String())
if live, err := api.NewLive(u); err == nil {
if live, err := live.New(u, instance.GetInstance(r.Context()).Cache); err == nil {
inst := instance.GetInstance(r.Context())
if _, ok := inst.Lives[live.GetLiveId()]; !ok {
inst.Lives[live.GetLiveId()] = live
if isListen {
inst.ListenerManager.(listeners.IListenerManager).AddListener(r.Context(), live)
inst.ListenerManager.(listeners.Manager).AddListener(r.Context(), live)
}
info = append(info, parseInfo(r.Context(), live))
}
@@ -163,8 +164,8 @@ func removeLive(writer http.ResponseWriter, r *http.Request) {
inst := instance.GetInstance(r.Context())
vars := mux.Vars(r)
res := CommonResp{}
if live, ok := inst.Lives[api.LiveId(vars["id"])]; ok {
inst.ListenerManager.(listeners.IListenerManager).RemoveListener(r.Context(), live.GetLiveId())
if live, ok := inst.Lives[live.ID(vars["id"])]; ok {
inst.ListenerManager.(listeners.Manager).RemoveListener(r.Context(), live.GetLiveId())
delete(inst.Lives, live.GetLiveId())
res.Data = "OK"
} else {
+9 -11
View File
@@ -105,7 +105,7 @@ func NewServer(ctx context.Context) *Server {
inst := instance.GetInstance(ctx)
config := inst.Config
httpServer := &http.Server{
Addr: config.RPC.Port,
Addr: config.RPC.Bind,
Handler: initMux(ctx),
}
server := &Server{server: httpServer}
@@ -118,18 +118,16 @@ func (s *Server) Start(ctx context.Context) error {
inst.WaitGroup.Add(1)
config := inst.Config
go func() {
var err error
if config.RPC.TLS.Enable {
if err := s.server.ListenAndServeTLS(config.RPC.TLS.CertFile, config.RPC.TLS.KeyFile); err != nil {
if err != http.ErrServerClosed {
inst.Logger.Error(err)
}
}
err = s.server.ListenAndServeTLS(config.RPC.TLS.CertFile, config.RPC.TLS.KeyFile)
} else {
if err := s.server.ListenAndServe(); err != nil {
if err != http.ErrServerClosed {
inst.Logger.Error(err)
}
}
err = s.server.ListenAndServe()
}
switch err {
case nil, http.ErrServerClosed:
default:
inst.Logger.Error(err)
}
}()
inst.Logger.Infof("Server start at %s", s.server.Addr)
-19
View File
@@ -1,19 +0,0 @@
Copyright (C) 2014 Alec Thomas
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
of the Software, and to permit persons to whom the Software is furnished to do
so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
-674
View File
@@ -1,674 +0,0 @@
# Kingpin - A Go (golang) command line and flag parser
[![](https://godoc.org/github.com/alecthomas/kingpin?status.svg)](http://godoc.org/github.com/alecthomas/kingpin) [![Build Status](https://travis-ci.org/alecthomas/kingpin.svg?branch=master)](https://travis-ci.org/alecthomas/kingpin) [![Gitter chat](https://badges.gitter.im/alecthomas.png)](https://gitter.im/alecthomas/Lobby)
<!-- MarkdownTOC -->
- [Overview](#overview)
- [Features](#features)
- [User-visible changes between v1 and v2](#user-visible-changes-between-v1-and-v2)
- [Flags can be used at any point after their definition.](#flags-can-be-used-at-any-point-after-their-definition)
- [Short flags can be combined with their parameters](#short-flags-can-be-combined-with-their-parameters)
- [API changes between v1 and v2](#api-changes-between-v1-and-v2)
- [Versions](#versions)
- [V2 is the current stable version](#v2-is-the-current-stable-version)
- [V1 is the OLD stable version](#v1-is-the-old-stable-version)
- [Change History](#change-history)
- [Examples](#examples)
- [Simple Example](#simple-example)
- [Complex Example](#complex-example)
- [Reference Documentation](#reference-documentation)
- [Displaying errors and usage information](#displaying-errors-and-usage-information)
- [Sub-commands](#sub-commands)
- [Custom Parsers](#custom-parsers)
- [Repeatable flags](#repeatable-flags)
- [Boolean Values](#boolean-values)
- [Default Values](#default-values)
- [Place-holders in Help](#place-holders-in-help)
- [Consuming all remaining arguments](#consuming-all-remaining-arguments)
- [Bash/ZSH Shell Completion](#bashzsh-shell-completion)
- [Supporting -h for help](#supporting--h-for-help)
- [Custom help](#custom-help)
<!-- /MarkdownTOC -->
## Overview
Kingpin is a [fluent-style](http://en.wikipedia.org/wiki/Fluent_interface),
type-safe command-line parser. It supports flags, nested commands, and
positional arguments.
Install it with:
$ go get gopkg.in/alecthomas/kingpin.v2
It looks like this:
```go
var (
verbose = kingpin.Flag("verbose", "Verbose mode.").Short('v').Bool()
name = kingpin.Arg("name", "Name of user.").Required().String()
)
func main() {
kingpin.Parse()
fmt.Printf("%v, %s\n", *verbose, *name)
}
```
More [examples](https://github.com/alecthomas/kingpin/tree/master/_examples) are available.
Second to parsing, providing the user with useful help is probably the most
important thing a command-line parser does. Kingpin tries to provide detailed
contextual help if `--help` is encountered at any point in the command line
(excluding after `--`).
## Features
- Help output that isn't as ugly as sin.
- Fully [customisable help](#custom-help), via Go templates.
- Parsed, type-safe flags (`kingpin.Flag("f", "help").Int()`)
- Parsed, type-safe positional arguments (`kingpin.Arg("a", "help").Int()`).
- Parsed, type-safe, arbitrarily deep commands (`kingpin.Command("c", "help")`).
- Support for required flags and required positional arguments (`kingpin.Flag("f", "").Required().Int()`).
- Support for arbitrarily nested default commands (`command.Default()`).
- Callbacks per command, flag and argument (`kingpin.Command("c", "").Action(myAction)`).
- POSIX-style short flag combining (`-a -b` -> `-ab`).
- Short-flag+parameter combining (`-a parm` -> `-aparm`).
- Read command-line from files (`@<file>`).
- Automatically generate man pages (`--help-man`).
## User-visible changes between v1 and v2
### Flags can be used at any point after their definition.
Flags can be specified at any point after their definition, not just
*immediately after their associated command*. From the chat example below, the
following used to be required:
```
$ chat --server=chat.server.com:8080 post --image=~/Downloads/owls.jpg pics
```
But the following will now work:
```
$ chat post --server=chat.server.com:8080 --image=~/Downloads/owls.jpg pics
```
### Short flags can be combined with their parameters
Previously, if a short flag was used, any argument to that flag would have to
be separated by a space. That is no longer the case.
## API changes between v1 and v2
- `ParseWithFileExpansion()` is gone. The new parser directly supports expanding `@<file>`.
- Added `FatalUsage()` and `FatalUsageContext()` for displaying an error + usage and terminating.
- `Dispatch()` renamed to `Action()`.
- Added `ParseContext()` for parsing a command line into its intermediate context form without executing.
- Added `Terminate()` function to override the termination function.
- Added `UsageForContextWithTemplate()` for printing usage via a custom template.
- Added `UsageTemplate()` for overriding the default template to use. Two templates are included:
1. `DefaultUsageTemplate` - default template.
2. `CompactUsageTemplate` - compact command template for larger applications.
## Versions
Kingpin uses [gopkg.in](https://gopkg.in/alecthomas/kingpin) for versioning.
The current stable version is [gopkg.in/alecthomas/kingpin.v2](https://gopkg.in/alecthomas/kingpin.v2). The previous version, [gopkg.in/alecthomas/kingpin.v1](https://gopkg.in/alecthomas/kingpin.v1), is deprecated and in maintenance mode.
### [V2](https://gopkg.in/alecthomas/kingpin.v2) is the current stable version
Installation:
```sh
$ go get gopkg.in/alecthomas/kingpin.v2
```
### [V1](https://gopkg.in/alecthomas/kingpin.v1) is the OLD stable version
Installation:
```sh
$ go get gopkg.in/alecthomas/kingpin.v1
```
## Change History
- *2015-09-19* -- Stable v2.1.0 release.
- Added `command.Default()` to specify a default command to use if no other
command matches. This allows for convenient user shortcuts.
- Exposed `HelpFlag` and `VersionFlag` for further customisation.
- `Action()` and `PreAction()` added and both now support an arbitrary
number of callbacks.
- `kingpin.SeparateOptionalFlagsUsageTemplate`.
- `--help-long` and `--help-man` (hidden by default) flags.
- Flags are "interspersed" by default, but can be disabled with `app.Interspersed(false)`.
- Added flags for all simple builtin types (int8, uint16, etc.) and slice variants.
- Use `app.Writer(os.Writer)` to specify the default writer for all output functions.
- Dropped `os.Writer` prefix from all printf-like functions.
- *2015-05-22* -- Stable v2.0.0 release.
- Initial stable release of v2.0.0.
- Fully supports interspersed flags, commands and arguments.
- Flags can be present at any point after their logical definition.
- Application.Parse() terminates if commands are present and a command is not parsed.
- Dispatch() -> Action().
- Actions are dispatched after all values are populated.
- Override termination function (defaults to os.Exit).
- Override output stream (defaults to os.Stderr).
- Templatised usage help, with default and compact templates.
- Make error/usage functions more consistent.
- Support argument expansion from files by default (with @<file>).
- Fully public data model is available via .Model().
- Parser has been completely refactored.
- Parsing and execution has been split into distinct stages.
- Use `go generate` to generate repeated flags.
- Support combined short-flag+argument: -fARG.
- *2015-01-23* -- Stable v1.3.4 release.
- Support "--" for separating flags from positional arguments.
- Support loading flags from files (ParseWithFileExpansion()). Use @FILE as an argument.
- Add post-app and post-cmd validation hooks. This allows arbitrary validation to be added.
- A bunch of improvements to help usage and formatting.
- Support arbitrarily nested sub-commands.
- *2014-07-08* -- Stable v1.2.0 release.
- Pass any value through to `Strings()` when final argument.
Allows for values that look like flags to be processed.
- Allow `--help` to be used with commands.
- Support `Hidden()` flags.
- Parser for [units.Base2Bytes](https://github.com/alecthomas/units)
type. Allows for flags like `--ram=512MB` or `--ram=1GB`.
- Add an `Enum()` value, allowing only one of a set of values
to be selected. eg. `Flag(...).Enum("debug", "info", "warning")`.
- *2014-06-27* -- Stable v1.1.0 release.
- Bug fixes.
- Always return an error (rather than panicing) when misconfigured.
- `OpenFile(flag, perm)` value type added, for finer control over opening files.
- Significantly improved usage formatting.
- *2014-06-19* -- Stable v1.0.0 release.
- Support [cumulative positional](#consuming-all-remaining-arguments) arguments.
- Return error rather than panic when there are fatal errors not caught by
the type system. eg. when a default value is invalid.
- Use gokpg.in.
- *2014-06-10* -- Place-holder streamlining.
- Renamed `MetaVar` to `PlaceHolder`.
- Removed `MetaVarFromDefault`. Kingpin now uses [heuristics](#place-holders-in-help)
to determine what to display.
## Examples
### Simple Example
Kingpin can be used for simple flag+arg applications like so:
```
$ ping --help
usage: ping [<flags>] <ip> [<count>]
Flags:
--debug Enable debug mode.
--help Show help.
-t, --timeout=5s Timeout waiting for ping.
Args:
<ip> IP address to ping.
[<count>] Number of packets to send
$ ping 1.2.3.4 5
Would ping: 1.2.3.4 with timeout 5s and count 5
```
From the following source:
```go
package main
import (
"fmt"
"gopkg.in/alecthomas/kingpin.v2"
)
var (
debug = kingpin.Flag("debug", "Enable debug mode.").Bool()
timeout = kingpin.Flag("timeout", "Timeout waiting for ping.").Default("5s").OverrideDefaultFromEnvar("PING_TIMEOUT").Short('t').Duration()
ip = kingpin.Arg("ip", "IP address to ping.").Required().IP()
count = kingpin.Arg("count", "Number of packets to send").Int()
)
func main() {
kingpin.Version("0.0.1")
kingpin.Parse()
fmt.Printf("Would ping: %s with timeout %s and count %d\n", *ip, *timeout, *count)
}
```
### Complex Example
Kingpin can also produce complex command-line applications with global flags,
subcommands, and per-subcommand flags, like this:
```
$ chat --help
usage: chat [<flags>] <command> [<flags>] [<args> ...]
A command-line chat application.
Flags:
--help Show help.
--debug Enable debug mode.
--server=127.0.0.1 Server address.
Commands:
help [<command>]
Show help for a command.
register <nick> <name>
Register a new user.
post [<flags>] <channel> [<text>]
Post a message to a channel.
$ chat help post
usage: chat [<flags>] post [<flags>] <channel> [<text>]
Post a message to a channel.
Flags:
--image=IMAGE Image to post.
Args:
<channel> Channel to post to.
[<text>] Text to post.
$ chat post --image=~/Downloads/owls.jpg pics
...
```
From this code:
```go
package main
import (
"os"
"strings"
"gopkg.in/alecthomas/kingpin.v2"
)
var (
app = kingpin.New("chat", "A command-line chat application.")
debug = app.Flag("debug", "Enable debug mode.").Bool()
serverIP = app.Flag("server", "Server address.").Default("127.0.0.1").IP()
register = app.Command("register", "Register a new user.")
registerNick = register.Arg("nick", "Nickname for user.").Required().String()
registerName = register.Arg("name", "Name of user.").Required().String()
post = app.Command("post", "Post a message to a channel.")
postImage = post.Flag("image", "Image to post.").File()
postChannel = post.Arg("channel", "Channel to post to.").Required().String()
postText = post.Arg("text", "Text to post.").Strings()
)
func main() {
switch kingpin.MustParse(app.Parse(os.Args[1:])) {
// Register user
case register.FullCommand():
println(*registerNick)
// Post message
case post.FullCommand():
if *postImage != nil {
}
text := strings.Join(*postText, " ")
println("Post:", text)
}
}
```
## Reference Documentation
### Displaying errors and usage information
Kingpin exports a set of functions to provide consistent errors and usage
information to the user.
Error messages look something like this:
<app>: error: <message>
The functions on `Application` are:
Function | Purpose
---------|--------------
`Errorf(format, args)` | Display a printf formatted error to the user.
`Fatalf(format, args)` | As with Errorf, but also call the termination handler.
`FatalUsage(format, args)` | As with Fatalf, but also print contextual usage information.
`FatalUsageContext(context, format, args)` | As with Fatalf, but also print contextual usage information from a `ParseContext`.
`FatalIfError(err, format, args)` | Conditionally print an error prefixed with format+args, then call the termination handler
There are equivalent global functions in the kingpin namespace for the default
`kingpin.CommandLine` instance.
### Sub-commands
Kingpin supports nested sub-commands, with separate flag and positional
arguments per sub-command. Note that positional arguments may only occur after
sub-commands.
For example:
```go
var (
deleteCommand = kingpin.Command("delete", "Delete an object.")
deleteUserCommand = deleteCommand.Command("user", "Delete a user.")
deleteUserUIDFlag = deleteUserCommand.Flag("uid", "Delete user by UID rather than username.")
deleteUserUsername = deleteUserCommand.Arg("username", "Username to delete.")
deletePostCommand = deleteCommand.Command("post", "Delete a post.")
)
func main() {
switch kingpin.Parse() {
case "delete user":
case "delete post":
}
}
```
### Custom Parsers
Kingpin supports both flag and positional argument parsers for converting to
Go types. For example, some included parsers are `Int()`, `Float()`,
`Duration()` and `ExistingFile()` (see [parsers.go](./parsers.go) for a complete list of included parsers).
Parsers conform to Go's [`flag.Value`](http://godoc.org/flag#Value)
interface, so any existing implementations will work.
For example, a parser for accumulating HTTP header values might look like this:
```go
type HTTPHeaderValue http.Header
func (h *HTTPHeaderValue) Set(value string) error {
parts := strings.SplitN(value, ":", 2)
if len(parts) != 2 {
return fmt.Errorf("expected HEADER:VALUE got '%s'", value)
}
(*http.Header)(h).Add(parts[0], parts[1])
return nil
}
func (h *HTTPHeaderValue) String() string {
return ""
}
```
As a convenience, I would recommend something like this:
```go
func HTTPHeader(s Settings) (target *http.Header) {
target = &http.Header{}
s.SetValue((*HTTPHeaderValue)(target))
return
}
```
You would use it like so:
```go
headers = HTTPHeader(kingpin.Flag("header", "Add a HTTP header to the request.").Short('H'))
```
### Repeatable flags
Depending on the `Value` they hold, some flags may be repeated. The
`IsCumulative() bool` function on `Value` tells if it's safe to call `Set()`
multiple times or if an error should be raised if several values are passed.
The built-in `Value`s returning slices and maps, as well as `Counter` are
examples of `Value`s that make a flag repeatable.
### Boolean values
Boolean values are uniquely managed by Kingpin. Each boolean flag will have a negative complement:
`--<name>` and `--no-<name>`.
### Default Values
The default value is the zero value for a type. This can be overridden with
the `Default(value...)` function on flags and arguments. This function accepts
one or several strings, which are parsed by the value itself, so they *must*
be compliant with the format expected.
### Place-holders in Help
The place-holder value for a flag is the value used in the help to describe
the value of a non-boolean flag.
The value provided to PlaceHolder() is used if provided, then the value
provided by Default() if provided, then finally the capitalised flag name is
used.
Here are some examples of flags with various permutations:
--name=NAME // Flag(...).String()
--name="Harry" // Flag(...).Default("Harry").String()
--name=FULL-NAME // Flag(...).PlaceHolder("FULL-NAME").Default("Harry").String()
### Consuming all remaining arguments
A common command-line idiom is to use all remaining arguments for some
purpose. eg. The following command accepts an arbitrary number of
IP addresses as positional arguments:
./cmd ping 10.1.1.1 192.168.1.1
Such arguments are similar to [repeatable flags](#repeatable-flags), but for
arguments. Therefore they use the same `IsCumulative() bool` function on the
underlying `Value`, so the built-in `Value`s for which the `Set()` function
can be called several times will consume multiple arguments.
To implement the above example with a custom `Value`, we might do something
like this:
```go
type ipList []net.IP
func (i *ipList) Set(value string) error {
if ip := net.ParseIP(value); ip == nil {
return fmt.Errorf("'%s' is not an IP address", value)
} else {
*i = append(*i, ip)
return nil
}
}
func (i *ipList) String() string {
return ""
}
func (i *ipList) IsCumulative() bool {
return true
}
func IPList(s Settings) (target *[]net.IP) {
target = new([]net.IP)
s.SetValue((*ipList)(target))
return
}
```
And use it like so:
```go
ips := IPList(kingpin.Arg("ips", "IP addresses to ping."))
```
### Bash/ZSH Shell Completion
By default, all flags and commands/subcommands generate completions
internally.
Out of the box, CLI tools using kingpin should be able to take advantage
of completion hinting for flags and commands. By specifying
`--completion-bash` as the first argument, your CLI tool will show
possible subcommands. By ending your argv with `--`, hints for flags
will be shown.
To allow your end users to take advantage you must package a
`/etc/bash_completion.d` script with your distribution (or the equivalent
for your target platform/shell). An alternative is to instruct your end
user to source a script from their `bash_profile` (or equivalent).
Fortunately Kingpin makes it easy to generate or source a script for use
with end users shells. `./yourtool --completion-script-bash` and
`./yourtool --completion-script-zsh` will generate these scripts for you.
**Installation by Package**
For the best user experience, you should bundle your pre-created
completion script with your CLI tool and install it inside
`/etc/bash_completion.d` (or equivalent). A good suggestion is to add
this as an automated step to your build pipeline, in the implementation
is improved for bug fixed.
**Installation by `bash_profile`**
Alternatively, instruct your users to add an additional statement to
their `bash_profile` (or equivalent):
```
eval "$(your-cli-tool --completion-script-bash)"
```
Or for ZSH
```
eval "$(your-cli-tool --completion-script-zsh)"
```
#### Additional API
To provide more flexibility, a completion option API has been
exposed for flags to allow user defined completion options, to extend
completions further than just EnumVar/Enum.
**Provide Static Options**
When using an `Enum` or `EnumVar`, users are limited to only the options
given. Maybe we wish to hint possible options to the user, but also
allow them to provide their own custom option. `HintOptions` gives
this functionality to flags.
```
app := kingpin.New("completion", "My application with bash completion.")
app.Flag("port", "Provide a port to connect to").
Required().
HintOptions("80", "443", "8080").
IntVar(&c.port)
```
**Provide Dynamic Options**
Consider the case that you needed to read a local database or a file to
provide suggestions. You can dynamically generate the options
```
func listHosts() []string {
// Provide a dynamic list of hosts from a hosts file or otherwise
// for bash completion. In this example we simply return static slice.
// You could use this functionality to reach into a hosts file to provide
// completion for a list of known hosts.
return []string{"sshhost.example", "webhost.example", "ftphost.example"}
}
app := kingpin.New("completion", "My application with bash completion.")
app.Flag("flag-1", "").HintAction(listHosts).String()
```
**EnumVar/Enum**
When using `Enum` or `EnumVar`, any provided options will be automatically
used for bash autocompletion. However, if you wish to provide a subset or
different options, you can use `HintOptions` or `HintAction` which will override
the default completion options for `Enum`/`EnumVar`.
**Examples**
You can see an in depth example of the completion API within
`examples/completion/main.go`
### Supporting -h for help
`kingpin.CommandLine.HelpFlag.Short('h')`
### Custom help
Kingpin v2 supports templatised help using the text/template library (actually, [a fork](https://github.com/alecthomas/template)).
You can specify the template to use with the [Application.UsageTemplate()](http://godoc.org/gopkg.in/alecthomas/kingpin.v2#Application.UsageTemplate) function.
There are four included templates: `kingpin.DefaultUsageTemplate` is the default,
`kingpin.CompactUsageTemplate` provides a more compact representation for more complex command-line structures,
`kingpin.SeparateOptionalFlagsUsageTemplate` looks like the default template, but splits required
and optional command flags into separate lists, and `kingpin.ManPageTemplate` is used to generate man pages.
See the above templates for examples of usage, and the the function [UsageForContextWithTemplate()](https://github.com/alecthomas/kingpin/blob/master/usage.go#L198) method for details on the context.
#### Default help template
```
$ go run ./examples/curl/curl.go --help
usage: curl [<flags>] <command> [<args> ...]
An example implementation of curl.
Flags:
--help Show help.
-t, --timeout=5s Set connection timeout.
-H, --headers=HEADER=VALUE
Add HTTP headers to the request.
Commands:
help [<command>...]
Show help.
get url <url>
Retrieve a URL.
get file <file>
Retrieve a file.
post [<flags>] <url>
POST a resource.
```
#### Compact help template
```
$ go run ./examples/curl/curl.go --help
usage: curl [<flags>] <command> [<args> ...]
An example implementation of curl.
Flags:
--help Show help.
-t, --timeout=5s Set connection timeout.
-H, --headers=HEADER=VALUE
Add HTTP headers to the request.
Commands:
help [<command>...]
get [<flags>]
url <url>
file <file>
post [<flags>] <url>
```
-42
View File
@@ -1,42 +0,0 @@
package kingpin
// Action callback executed at various stages after all values are populated.
// The application, commands, arguments and flags all have corresponding
// actions.
type Action func(*ParseContext) error
type actionMixin struct {
actions []Action
preActions []Action
}
type actionApplier interface {
applyActions(*ParseContext) error
applyPreActions(*ParseContext) error
}
func (a *actionMixin) addAction(action Action) {
a.actions = append(a.actions, action)
}
func (a *actionMixin) addPreAction(action Action) {
a.preActions = append(a.preActions, action)
}
func (a *actionMixin) applyActions(context *ParseContext) error {
for _, action := range a.actions {
if err := action(context); err != nil {
return err
}
}
return nil
}
func (a *actionMixin) applyPreActions(context *ParseContext) error {
for _, preAction := range a.preActions {
if err := preAction(context); err != nil {
return err
}
}
return nil
}
-688
View File
@@ -1,688 +0,0 @@
package kingpin
import (
"fmt"
"io"
"os"
"regexp"
"strings"
)
var (
ErrCommandNotSpecified = fmt.Errorf("command not specified")
)
var (
envarTransformRegexp = regexp.MustCompile(`[^a-zA-Z0-9_]+`)
)
type ApplicationValidator func(*Application) error
// An Application contains the definitions of flags, arguments and commands
// for an application.
type Application struct {
cmdMixin
initialized bool
Name string
Help string
author string
version string
errorWriter io.Writer // Destination for errors.
usageWriter io.Writer // Destination for usage
usageTemplate string
validator ApplicationValidator
terminate func(status int) // See Terminate()
noInterspersed bool // can flags be interspersed with args (or must they come first)
defaultEnvars bool
completion bool
// Help flag. Exposed for user customisation.
HelpFlag *FlagClause
// Help command. Exposed for user customisation. May be nil.
HelpCommand *CmdClause
// Version flag. Exposed for user customisation. May be nil.
VersionFlag *FlagClause
}
// New creates a new Kingpin application instance.
func New(name, help string) *Application {
a := &Application{
Name: name,
Help: help,
errorWriter: os.Stderr, // Left for backwards compatibility purposes.
usageWriter: os.Stderr,
usageTemplate: DefaultUsageTemplate,
terminate: os.Exit,
}
a.flagGroup = newFlagGroup()
a.argGroup = newArgGroup()
a.cmdGroup = newCmdGroup(a)
a.HelpFlag = a.Flag("help", "Show context-sensitive help (also try --help-long and --help-man).")
a.HelpFlag.Bool()
a.Flag("help-long", "Generate long help.").Hidden().PreAction(a.generateLongHelp).Bool()
a.Flag("help-man", "Generate a man page.").Hidden().PreAction(a.generateManPage).Bool()
a.Flag("completion-bash", "Output possible completions for the given args.").Hidden().BoolVar(&a.completion)
a.Flag("completion-script-bash", "Generate completion script for bash.").Hidden().PreAction(a.generateBashCompletionScript).Bool()
a.Flag("completion-script-zsh", "Generate completion script for ZSH.").Hidden().PreAction(a.generateZSHCompletionScript).Bool()
return a
}
func (a *Application) generateLongHelp(c *ParseContext) error {
a.Writer(os.Stdout)
if err := a.UsageForContextWithTemplate(c, 2, LongHelpTemplate); err != nil {
return err
}
a.terminate(0)
return nil
}
func (a *Application) generateManPage(c *ParseContext) error {
a.Writer(os.Stdout)
if err := a.UsageForContextWithTemplate(c, 2, ManPageTemplate); err != nil {
return err
}
a.terminate(0)
return nil
}
func (a *Application) generateBashCompletionScript(c *ParseContext) error {
a.Writer(os.Stdout)
if err := a.UsageForContextWithTemplate(c, 2, BashCompletionTemplate); err != nil {
return err
}
a.terminate(0)
return nil
}
func (a *Application) generateZSHCompletionScript(c *ParseContext) error {
a.Writer(os.Stdout)
if err := a.UsageForContextWithTemplate(c, 2, ZshCompletionTemplate); err != nil {
return err
}
a.terminate(0)
return nil
}
// DefaultEnvars configures all flags (that do not already have an associated
// envar) to use a default environment variable in the form "<app>_<flag>".
//
// For example, if the application is named "foo" and a flag is named "bar-
// waz" the environment variable: "FOO_BAR_WAZ".
func (a *Application) DefaultEnvars() *Application {
a.defaultEnvars = true
return a
}
// Terminate specifies the termination handler. Defaults to os.Exit(status).
// If nil is passed, a no-op function will be used.
func (a *Application) Terminate(terminate func(int)) *Application {
if terminate == nil {
terminate = func(int) {}
}
a.terminate = terminate
return a
}
// Writer specifies the writer to use for usage and errors. Defaults to os.Stderr.
// DEPRECATED: See ErrorWriter and UsageWriter.
func (a *Application) Writer(w io.Writer) *Application {
a.errorWriter = w
a.usageWriter = w
return a
}
// ErrorWriter sets the io.Writer to use for errors.
func (a *Application) ErrorWriter(w io.Writer) *Application {
a.errorWriter = w
return a
}
// UsageWriter sets the io.Writer to use for errors.
func (a *Application) UsageWriter(w io.Writer) *Application {
a.usageWriter = w
return a
}
// UsageTemplate specifies the text template to use when displaying usage
// information. The default is UsageTemplate.
func (a *Application) UsageTemplate(template string) *Application {
a.usageTemplate = template
return a
}
// Validate sets a validation function to run when parsing.
func (a *Application) Validate(validator ApplicationValidator) *Application {
a.validator = validator
return a
}
// ParseContext parses the given command line and returns the fully populated
// ParseContext.
func (a *Application) ParseContext(args []string) (*ParseContext, error) {
return a.parseContext(false, args)
}
func (a *Application) parseContext(ignoreDefault bool, args []string) (*ParseContext, error) {
if err := a.init(); err != nil {
return nil, err
}
context := tokenize(args, ignoreDefault)
err := parse(context, a)
return context, err
}
// Parse parses command-line arguments. It returns the selected command and an
// error. The selected command will be a space separated subcommand, if
// subcommands have been configured.
//
// This will populate all flag and argument values, call all callbacks, and so
// on.
func (a *Application) Parse(args []string) (command string, err error) {
context, parseErr := a.ParseContext(args)
selected := []string{}
var setValuesErr error
if context == nil {
// Since we do not throw error immediately, there could be a case
// where a context returns nil. Protect against that.
return "", parseErr
}
if err = a.setDefaults(context); err != nil {
return "", err
}
selected, setValuesErr = a.setValues(context)
if err = a.applyPreActions(context, !a.completion); err != nil {
return "", err
}
if a.completion {
a.generateBashCompletion(context)
a.terminate(0)
} else {
if parseErr != nil {
return "", parseErr
}
a.maybeHelp(context)
if !context.EOL() {
return "", fmt.Errorf("unexpected argument '%s'", context.Peek())
}
if setValuesErr != nil {
return "", setValuesErr
}
command, err = a.execute(context, selected)
if err == ErrCommandNotSpecified {
a.writeUsage(context, nil)
}
}
return command, err
}
func (a *Application) writeUsage(context *ParseContext, err error) {
if err != nil {
a.Errorf("%s", err)
}
if err := a.UsageForContext(context); err != nil {
panic(err)
}
if err != nil {
a.terminate(1)
} else {
a.terminate(0)
}
}
func (a *Application) maybeHelp(context *ParseContext) {
for _, element := range context.Elements {
if flag, ok := element.Clause.(*FlagClause); ok && flag == a.HelpFlag {
// Re-parse the command-line ignoring defaults, so that help works correctly.
context, _ = a.parseContext(true, context.rawArgs)
a.writeUsage(context, nil)
}
}
}
// Version adds a --version flag for displaying the application version.
func (a *Application) Version(version string) *Application {
a.version = version
a.VersionFlag = a.Flag("version", "Show application version.").PreAction(func(*ParseContext) error {
fmt.Fprintln(a.usageWriter, version)
a.terminate(0)
return nil
})
a.VersionFlag.Bool()
return a
}
// Author sets the author output by some help templates.
func (a *Application) Author(author string) *Application {
a.author = author
return a
}
// Action callback to call when all values are populated and parsing is
// complete, but before any command, flag or argument actions.
//
// All Action() callbacks are called in the order they are encountered on the
// command line.
func (a *Application) Action(action Action) *Application {
a.addAction(action)
return a
}
// Action called after parsing completes but before validation and execution.
func (a *Application) PreAction(action Action) *Application {
a.addPreAction(action)
return a
}
// Command adds a new top-level command.
func (a *Application) Command(name, help string) *CmdClause {
return a.addCommand(name, help)
}
// Interspersed control if flags can be interspersed with positional arguments
//
// true (the default) means that they can, false means that all the flags must appear before the first positional arguments.
func (a *Application) Interspersed(interspersed bool) *Application {
a.noInterspersed = !interspersed
return a
}
func (a *Application) defaultEnvarPrefix() string {
if a.defaultEnvars {
return a.Name
}
return ""
}
func (a *Application) init() error {
if a.initialized {
return nil
}
if a.cmdGroup.have() && a.argGroup.have() {
return fmt.Errorf("can't mix top-level Arg()s with Command()s")
}
// If we have subcommands, add a help command at the top-level.
if a.cmdGroup.have() {
var command []string
a.HelpCommand = a.Command("help", "Show help.").PreAction(func(context *ParseContext) error {
a.Usage(command)
a.terminate(0)
return nil
})
a.HelpCommand.Arg("command", "Show help on command.").StringsVar(&command)
// Make help first command.
l := len(a.commandOrder)
a.commandOrder = append(a.commandOrder[l-1:l], a.commandOrder[:l-1]...)
}
if err := a.flagGroup.init(a.defaultEnvarPrefix()); err != nil {
return err
}
if err := a.cmdGroup.init(); err != nil {
return err
}
if err := a.argGroup.init(); err != nil {
return err
}
for _, cmd := range a.commands {
if err := cmd.init(); err != nil {
return err
}
}
flagGroups := []*flagGroup{a.flagGroup}
for _, cmd := range a.commandOrder {
if err := checkDuplicateFlags(cmd, flagGroups); err != nil {
return err
}
}
a.initialized = true
return nil
}
// Recursively check commands for duplicate flags.
func checkDuplicateFlags(current *CmdClause, flagGroups []*flagGroup) error {
// Check for duplicates.
for _, flags := range flagGroups {
for _, flag := range current.flagOrder {
if flag.shorthand != 0 {
if _, ok := flags.short[string(flag.shorthand)]; ok {
return fmt.Errorf("duplicate short flag -%c", flag.shorthand)
}
}
if _, ok := flags.long[flag.name]; ok {
return fmt.Errorf("duplicate long flag --%s", flag.name)
}
}
}
flagGroups = append(flagGroups, current.flagGroup)
// Check subcommands.
for _, subcmd := range current.commandOrder {
if err := checkDuplicateFlags(subcmd, flagGroups); err != nil {
return err
}
}
return nil
}
func (a *Application) execute(context *ParseContext, selected []string) (string, error) {
var err error
if err = a.validateRequired(context); err != nil {
return "", err
}
if err = a.applyValidators(context); err != nil {
return "", err
}
if err = a.applyActions(context); err != nil {
return "", err
}
command := strings.Join(selected, " ")
if command == "" && a.cmdGroup.have() {
return "", ErrCommandNotSpecified
}
return command, err
}
func (a *Application) setDefaults(context *ParseContext) error {
flagElements := map[string]*ParseElement{}
for _, element := range context.Elements {
if flag, ok := element.Clause.(*FlagClause); ok {
if flag.name == "help" {
return nil
}
flagElements[flag.name] = element
}
}
argElements := map[string]*ParseElement{}
for _, element := range context.Elements {
if arg, ok := element.Clause.(*ArgClause); ok {
argElements[arg.name] = element
}
}
// Check required flags and set defaults.
for _, flag := range context.flags.long {
if flagElements[flag.name] == nil {
if err := flag.setDefault(); err != nil {
return err
}
}
}
for _, arg := range context.arguments.args {
if argElements[arg.name] == nil {
if err := arg.setDefault(); err != nil {
return err
}
}
}
return nil
}
func (a *Application) validateRequired(context *ParseContext) error {
flagElements := map[string]*ParseElement{}
for _, element := range context.Elements {
if flag, ok := element.Clause.(*FlagClause); ok {
flagElements[flag.name] = element
}
}
argElements := map[string]*ParseElement{}
for _, element := range context.Elements {
if arg, ok := element.Clause.(*ArgClause); ok {
argElements[arg.name] = element
}
}
// Check required flags and set defaults.
for _, flag := range context.flags.long {
if flagElements[flag.name] == nil {
// Check required flags were provided.
if flag.needsValue() {
return fmt.Errorf("required flag --%s not provided", flag.name)
}
}
}
for _, arg := range context.arguments.args {
if argElements[arg.name] == nil {
if arg.needsValue() {
return fmt.Errorf("required argument '%s' not provided", arg.name)
}
}
}
return nil
}
func (a *Application) setValues(context *ParseContext) (selected []string, err error) {
// Set all arg and flag values.
var (
lastCmd *CmdClause
flagSet = map[string]struct{}{}
)
for _, element := range context.Elements {
switch clause := element.Clause.(type) {
case *FlagClause:
if _, ok := flagSet[clause.name]; ok {
if v, ok := clause.value.(repeatableFlag); !ok || !v.IsCumulative() {
return nil, fmt.Errorf("flag '%s' cannot be repeated", clause.name)
}
}
if err = clause.value.Set(*element.Value); err != nil {
return
}
flagSet[clause.name] = struct{}{}
case *ArgClause:
if err = clause.value.Set(*element.Value); err != nil {
return
}
case *CmdClause:
if clause.validator != nil {
if err = clause.validator(clause); err != nil {
return
}
}
selected = append(selected, clause.name)
lastCmd = clause
}
}
if lastCmd != nil && len(lastCmd.commands) > 0 {
return nil, fmt.Errorf("must select a subcommand of '%s'", lastCmd.FullCommand())
}
return
}
func (a *Application) applyValidators(context *ParseContext) (err error) {
// Call command validation functions.
for _, element := range context.Elements {
if cmd, ok := element.Clause.(*CmdClause); ok && cmd.validator != nil {
if err = cmd.validator(cmd); err != nil {
return err
}
}
}
if a.validator != nil {
err = a.validator(a)
}
return err
}
func (a *Application) applyPreActions(context *ParseContext, dispatch bool) error {
if err := a.actionMixin.applyPreActions(context); err != nil {
return err
}
// Dispatch to actions.
if dispatch {
for _, element := range context.Elements {
if applier, ok := element.Clause.(actionApplier); ok {
if err := applier.applyPreActions(context); err != nil {
return err
}
}
}
}
return nil
}
func (a *Application) applyActions(context *ParseContext) error {
if err := a.actionMixin.applyActions(context); err != nil {
return err
}
// Dispatch to actions.
for _, element := range context.Elements {
if applier, ok := element.Clause.(actionApplier); ok {
if err := applier.applyActions(context); err != nil {
return err
}
}
}
return nil
}
// Errorf prints an error message to w in the format "<appname>: error: <message>".
func (a *Application) Errorf(format string, args ...interface{}) {
fmt.Fprintf(a.errorWriter, a.Name+": error: "+format+"\n", args...)
}
// Fatalf writes a formatted error to w then terminates with exit status 1.
func (a *Application) Fatalf(format string, args ...interface{}) {
a.Errorf(format, args...)
a.terminate(1)
}
// FatalUsage prints an error message followed by usage information, then
// exits with a non-zero status.
func (a *Application) FatalUsage(format string, args ...interface{}) {
a.Errorf(format, args...)
// Force usage to go to error output.
a.usageWriter = a.errorWriter
a.Usage([]string{})
a.terminate(1)
}
// FatalUsageContext writes a printf formatted error message to w, then usage
// information for the given ParseContext, before exiting.
func (a *Application) FatalUsageContext(context *ParseContext, format string, args ...interface{}) {
a.Errorf(format, args...)
if err := a.UsageForContext(context); err != nil {
panic(err)
}
a.terminate(1)
}
// FatalIfError prints an error and exits if err is not nil. The error is printed
// with the given formatted string, if any.
func (a *Application) FatalIfError(err error, format string, args ...interface{}) {
if err != nil {
prefix := ""
if format != "" {
prefix = fmt.Sprintf(format, args...) + ": "
}
a.Errorf(prefix+"%s", err)
a.terminate(1)
}
}
func (a *Application) completionOptions(context *ParseContext) []string {
args := context.rawArgs
var (
currArg string
prevArg string
target cmdMixin
)
numArgs := len(args)
if numArgs > 1 {
args = args[1:]
currArg = args[len(args)-1]
}
if numArgs > 2 {
prevArg = args[len(args)-2]
}
target = a.cmdMixin
if context.SelectedCommand != nil {
// A subcommand was in use. We will use it as the target
target = context.SelectedCommand.cmdMixin
}
if (currArg != "" && strings.HasPrefix(currArg, "--")) || strings.HasPrefix(prevArg, "--") {
// Perform completion for A flag. The last/current argument started with "-"
var (
flagName string // The name of a flag if given (could be half complete)
flagValue string // The value assigned to a flag (if given) (could be half complete)
)
if strings.HasPrefix(prevArg, "--") && !strings.HasPrefix(currArg, "--") {
// Matches: ./myApp --flag value
// Wont Match: ./myApp --flag --
flagName = prevArg[2:] // Strip the "--"
flagValue = currArg
} else if strings.HasPrefix(currArg, "--") {
// Matches: ./myApp --flag --
// Matches: ./myApp --flag somevalue --
// Matches: ./myApp --
flagName = currArg[2:] // Strip the "--"
}
options, flagMatched, valueMatched := target.FlagCompletion(flagName, flagValue)
if valueMatched {
// Value Matched. Show cmdCompletions
return target.CmdCompletion(context)
}
// Add top level flags if we're not at the top level and no match was found.
if context.SelectedCommand != nil && !flagMatched {
topOptions, topFlagMatched, topValueMatched := a.FlagCompletion(flagName, flagValue)
if topValueMatched {
// Value Matched. Back to cmdCompletions
return target.CmdCompletion(context)
}
if topFlagMatched {
// Top level had a flag which matched the input. Return it's options.
options = topOptions
} else {
// Add top level flags
options = append(options, topOptions...)
}
}
return options
}
// Perform completion for sub commands and arguments.
return target.CmdCompletion(context)
}
func (a *Application) generateBashCompletion(context *ParseContext) {
options := a.completionOptions(context)
fmt.Printf("%s", strings.Join(options, "\n"))
}
func envarTransform(name string) string {
return strings.ToUpper(envarTransformRegexp.ReplaceAllString(name, "_"))
}
Loaded 100 of 559 files, more files were not shown because too many files have changed in this diff. Show more