├── .dockerignore
├── frontend
├── .gitignore
├── src
│ ├── index.html
│ ├── config.ts
│ ├── actions
│ │ ├── themeActions.ts
│ │ ├── headerActions.ts
│ │ ├── snackbarActions.ts
│ │ ├── statsActions.ts
│ │ ├── socketActions.ts
│ │ ├── authenticationActions.ts
│ │ ├── channelActions.ts
│ │ └── streamActions.ts
│ ├── containers
│ │ ├── ThemeContainer.ts
│ │ ├── LoginFormContainer.ts
│ │ ├── RegisterFormContainer.ts
│ │ ├── SnackbarContainer.ts
│ │ ├── ChannelContainer.ts
│ │ ├── AddChannelContainer.ts
│ │ ├── NewStreamContainer.ts
│ │ ├── StreamsContainer.ts
│ │ ├── HeaderContainer.ts
│ │ ├── AuthenticationContainer.ts
│ │ └── DashboardContainer.ts
│ ├── components
│ │ ├── Theme
│ │ │ └── Theme.tsx
│ │ ├── Streams
│ │ │ ├── StreamsLoader.tsx
│ │ │ ├── NewStream.tsx
│ │ │ └── Streams.tsx
│ │ ├── Title
│ │ │ └── Title.tsx
│ │ ├── Icons
│ │ │ ├── Facebook.tsx
│ │ │ ├── Twitch.tsx
│ │ │ ├── Periscope.tsx
│ │ │ └── Youtube.tsx
│ │ ├── Player
│ │ │ └── Player.tsx
│ │ ├── StreamStatusIcon
│ │ │ └── StreamStatusIcon.tsx
│ │ ├── Socket
│ │ │ └── Socket.tsx
│ │ ├── App.tsx
│ │ ├── LandingPage
│ │ │ └── LandingPage.tsx
│ │ ├── Authentication
│ │ │ └── Authentication.tsx
│ │ ├── Snackbar
│ │ │ └── Snackbar.tsx
│ │ ├── LoginForm
│ │ │ └── LoginForm.tsx
│ │ ├── RegisterForm
│ │ │ └── RegisterForm.tsx
│ │ ├── Header
│ │ │ └── Header.tsx
│ │ ├── AddChannelForm
│ │ │ └── AddChannelForm.tsx
│ │ └── AddChannel
│ │ │ └── AddChannel.tsx
│ ├── models.ts
│ ├── axios.ts
│ ├── reducers
│ │ ├── headerReducer.ts
│ │ ├── themeReducer.ts
│ │ ├── socketReducer.ts
│ │ ├── statsReducer.ts
│ │ ├── snackbarReducer.ts
│ │ ├── channelReducer.ts
│ │ ├── authReducer.ts
│ │ ├── index.ts
│ │ └── streamReducer.ts
│ ├── theme.ts
│ ├── epics
│ │ ├── index.ts
│ │ ├── streamEpics.ts
│ │ ├── channelEpics.ts
│ │ ├── socketEpics.ts
│ │ ├── authEpics.ts
│ │ └── snackbarEpics.ts
│ └── index.tsx
├── .prettierrc
├── tsconfig.json
├── tslint.json
├── webpack.prod.js
├── webpack.config.js
└── package.json
├── .gitignore
├── screenshot.png
├── scripts
├── nginx
│ ├── Dockerfile
│ └── nginx.conf
├── ui
│ └── Dockerfile
├── configs.env
├── rtmp
│ ├── Dockerfile
│ └── .air.toml
├── web
│ ├── Dockerfile
│ └── .air.toml
└── wait-for-it.sh
├── helm
└── go-rtmp-web-server
│ ├── Chart.yaml
│ ├── templates
│ ├── namespace.yaml
│ ├── mysql-cluster-ip.yaml
│ ├── redis-cluster-ip.yaml
│ ├── mysql-persistent-volume-claim.yaml
│ ├── frontend-cluster-ip.yaml
│ ├── preview-persistent-volume-claim.yaml
│ ├── api-cluster-ip.yaml
│ ├── rtmp-cluster-ip.yaml
│ ├── redis-deployment.yaml
│ ├── frontend-deployment.yaml
│ ├── rtmp-deployment.yaml
│ ├── mysql-deployment.yaml
│ ├── api-deployment.yaml
│ └── ingress-service.yaml
│ ├── values.yaml
│ └── .helmignore
├── k8s
├── mysql-cluster-ip.yaml
├── redis-cluster-ip.yaml
├── mysql-persistent-volume-claim.yaml
├── frontend-cluster-ip.yaml
├── preview-persistent-volume-claim.yaml
├── api-cluster-ip.yaml
├── rtmp-cluster-ip.yaml
├── redis-deployment.yaml
├── frontend-deployment.yaml
├── rtmp-deployment.yaml
├── mysql-deployment.yaml
├── api-deployment.yaml
└── ingress-service.yaml
├── models
├── stream_model.go
├── channel_model.go
└── user_model.go
├── router
├── websocket_router.go
├── stream_router.go
├── user_router.go
├── channel_router.go
└── router.go
├── docker
├── multi-streamer-ui
│ ├── Dockerfile
│ └── nginx.conf
├── multi-streamer-api
│ └── Dockerfile
└── multi-streamer-rtmp
│ └── Dockerfile
├── amf
├── encoder.go
└── decoder.go
├── go.mod
├── README.md
├── controllers
├── redis.go
├── rpc.go
├── stream.go
├── channels.go
├── users.go
├── websocket.go
├── youtube.go
├── twitch.go
└── app_context.go
├── rtmp
├── server.go
├── handshake.go
├── connection.go
├── preview.go
└── chunk.go
├── docker-compose.yml
└── main.go
/.dockerignore:
--------------------------------------------------------------------------------
1 | **/node_modules
--------------------------------------------------------------------------------
/frontend/.gitignore:
--------------------------------------------------------------------------------
1 | /node_modules/
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /node_modules/
2 | /dist/
--------------------------------------------------------------------------------
/screenshot.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/praveen001/go-rtmp-web-server/HEAD/screenshot.png
--------------------------------------------------------------------------------
/scripts/nginx/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM nginx:stable
2 |
3 | COPY ./scripts/nginx/nginx.conf /etc/nginx/nginx.conf
4 |
--------------------------------------------------------------------------------
/helm/go-rtmp-web-server/Chart.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: v2
2 | name: RTMP Restreaming Server in Go
3 | version: 0.0.1
4 | appVersion: 0.0.1
--------------------------------------------------------------------------------
/frontend/src/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 | Test
4 |
5 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/helm/go-rtmp-web-server/templates/namespace.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: v1
2 | kind: Namespace
3 | metadata:
4 | name: go-rtmp-web-server
5 | labels:
6 | namepace: go-rtmp-web-server
--------------------------------------------------------------------------------
/frontend/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "semi": true,
3 | "singleQuote": true,
4 | "trailingComma": "none",
5 | "tabWidth": 2,
6 | "useTabs": false,
7 | "bracketSpacing": true
8 | }
9 |
--------------------------------------------------------------------------------
/frontend/src/config.ts:
--------------------------------------------------------------------------------
1 | // export const BaseUrl = 'http://localhost:5000';
2 | // export const SocketUrl = 'ws://localhost:5000/ws/connect?token=';
3 |
4 | export const BaseUrl = `/v1/api`;
5 | export const SocketUrl = `ws://${window.location.hostname}`;
6 |
--------------------------------------------------------------------------------
/scripts/ui/Dockerfile:
--------------------------------------------------------------------------------
1 | ############
2 | # FRONTEND #
3 | ############
4 | FROM node AS build-ui
5 |
6 | WORKDIR /ui
7 |
8 | COPY ./frontend/package.json .
9 | COPY ./frontend/yarn.lock .
10 |
11 | RUN yarn
12 |
13 | COPY ./frontend .
14 |
15 |
--------------------------------------------------------------------------------
/k8s/mysql-cluster-ip.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: v1
2 | kind: Service
3 | metadata:
4 | name: mysql-cluster-ip
5 | labels:
6 | service: mysql-cluster-ip
7 |
8 | spec:
9 | type: ClusterIP
10 | ports:
11 | - port: 3306
12 | selector:
13 | app: mysql
--------------------------------------------------------------------------------
/k8s/redis-cluster-ip.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: v1
2 | kind: Service
3 | metadata:
4 | name: redis-cluster-ip
5 | labels:
6 | service: redis-cluster-ip
7 |
8 | spec:
9 | type: ClusterIP
10 | ports:
11 | - port: 6379
12 | selector:
13 | app: redis
--------------------------------------------------------------------------------
/k8s/mysql-persistent-volume-claim.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: v1
2 | kind: PersistentVolumeClaim
3 |
4 | metadata:
5 | name: mysql-persistent-volume-claim
6 |
7 | spec:
8 | accessModes:
9 | - ReadWriteOnce
10 | resources:
11 | requests:
12 | storage: 1Gi
--------------------------------------------------------------------------------
/k8s/frontend-cluster-ip.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: v1
2 | kind: Service
3 | metadata:
4 | name: frontend-cluster-ip
5 | labels:
6 | service: frontend-cluster-ip
7 |
8 | spec:
9 | type: ClusterIP
10 | ports:
11 | - port: 80
12 | selector:
13 | app: frontend
--------------------------------------------------------------------------------
/k8s/preview-persistent-volume-claim.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: v1
2 | kind: PersistentVolumeClaim
3 |
4 | metadata:
5 | name: preview-persistent-volume-claim
6 |
7 | spec:
8 | accessModes:
9 | - ReadWriteMany
10 | resources:
11 | requests:
12 | storage: 2Gi
--------------------------------------------------------------------------------
/helm/go-rtmp-web-server/templates/mysql-cluster-ip.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: v1
2 | kind: Service
3 | metadata:
4 | name: mysql-cluster-ip
5 | labels:
6 | service: mysql-cluster-ip
7 |
8 | spec:
9 | type: ClusterIP
10 | ports:
11 | - port: 3306
12 | selector:
13 | app: mysql
--------------------------------------------------------------------------------
/helm/go-rtmp-web-server/templates/redis-cluster-ip.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: v1
2 | kind: Service
3 | metadata:
4 | name: redis-cluster-ip
5 | labels:
6 | service: redis-cluster-ip
7 |
8 | spec:
9 | type: ClusterIP
10 | ports:
11 | - port: 6379
12 | selector:
13 | app: redis
--------------------------------------------------------------------------------
/helm/go-rtmp-web-server/templates/mysql-persistent-volume-claim.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: v1
2 | kind: PersistentVolumeClaim
3 |
4 | metadata:
5 | name: mysql-persistent-volume-claim
6 |
7 | spec:
8 | accessModes:
9 | - ReadWriteOnce
10 | resources:
11 | requests:
12 | storage: 1Gi
--------------------------------------------------------------------------------
/models/stream_model.go:
--------------------------------------------------------------------------------
1 | package models
2 |
3 | // Stream ..
4 | type Stream struct {
5 | Model
6 | Name string `json:"name"`
7 | Key string `json:"key"`
8 | UserID int `json:"userId"`
9 | Channels []Channel `json:"channels" gorm:"foreignkey:StreamID"`
10 | }
11 |
--------------------------------------------------------------------------------
/helm/go-rtmp-web-server/templates/frontend-cluster-ip.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: v1
2 | kind: Service
3 | metadata:
4 | name: frontend-cluster-ip
5 | labels:
6 | service: frontend-cluster-ip
7 |
8 | spec:
9 | type: ClusterIP
10 | ports:
11 | - port: 80
12 | selector:
13 | app: frontend
--------------------------------------------------------------------------------
/helm/go-rtmp-web-server/templates/preview-persistent-volume-claim.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: v1
2 | kind: PersistentVolumeClaim
3 |
4 | metadata:
5 | name: preview-persistent-volume-claim
6 |
7 | spec:
8 | accessModes:
9 | - ReadWriteMany
10 | resources:
11 | requests:
12 | storage: 2Gi
--------------------------------------------------------------------------------
/router/websocket_router.go:
--------------------------------------------------------------------------------
1 | package router
2 |
3 | import (
4 | "github.com/go-chi/chi"
5 | )
6 |
7 | // websocketRouter Add channel route handlers
8 | func (cr *CustomRouter) websocketRouter() *chi.Mux {
9 | router := chi.NewRouter()
10 |
11 | router.Get("/connect", cr.HandleWebsocket)
12 |
13 | return router
14 | }
15 |
--------------------------------------------------------------------------------
/scripts/configs.env:
--------------------------------------------------------------------------------
1 | UI_ENDPOINT=http://localhost
2 | API_ENDPOINT=http://localhost
3 |
4 | YOUTUBE_CLIENT_ID=672041665322-5papoq1ose8s8aqb8j4c5abjgb9a5d0u.apps.googleusercontent.com
5 | YOUTUBE_CLIENT_SECRET=kmjcipIVYcgqpzUK3kZPsguw
6 |
7 | TWITCH_CLIENT_ID=d72o1jp8j07z6z3km9zxaz266us9lx
8 | TWITCH_CLIENT_SECRET=mc6tzwdmpbapslalifmzycgpkn011w
--------------------------------------------------------------------------------
/frontend/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "module": "commonjs",
5 | "sourceMap": false,
6 | "outDir": "./dist",
7 | "jsx": "react",
8 | "esModuleInterop": true,
9 | "allowSyntheticDefaultImports": true,
10 | "lib": ["es2015", "dom"],
11 | "resolveJsonModule": true
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/k8s/api-cluster-ip.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: v1
2 | kind: Service
3 | metadata:
4 | name: api-cluster-ip
5 | labels:
6 | service: api-cluster-ip
7 |
8 | spec:
9 | type: ClusterIP
10 | ports:
11 | - name: http
12 | port: 5000
13 | targetPort: 5000
14 | - name: grpc
15 | port: 4005
16 | targetPort: 4005
17 | selector:
18 | app: api
--------------------------------------------------------------------------------
/k8s/rtmp-cluster-ip.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: v1
2 | kind: Service
3 | metadata:
4 | name: rtmp-cluster-ip
5 | labels:
6 | service: rtmp-cluster-ip
7 |
8 | spec:
9 | type: ClusterIP
10 | ports:
11 | - name: rtmp
12 | port: 1935
13 | targetPort: 1935
14 | - name: preview-nginx-server
15 | port: 80
16 | targetPort: 80
17 | selector:
18 | app: rtmp
--------------------------------------------------------------------------------
/helm/go-rtmp-web-server/templates/api-cluster-ip.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: v1
2 | kind: Service
3 | metadata:
4 | name: api-cluster-ip
5 | labels:
6 | service: api-cluster-ip
7 |
8 | spec:
9 | type: ClusterIP
10 | ports:
11 | - name: http
12 | port: 5000
13 | targetPort: 5000
14 | - name: grpc
15 | port: 4005
16 | targetPort: 4005
17 | selector:
18 | app: api
--------------------------------------------------------------------------------
/helm/go-rtmp-web-server/templates/rtmp-cluster-ip.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: v1
2 | kind: Service
3 | metadata:
4 | name: rtmp-cluster-ip
5 | labels:
6 | service: rtmp-cluster-ip
7 |
8 | spec:
9 | type: ClusterIP
10 | ports:
11 | - name: rtmp
12 | port: 1935
13 | targetPort: 1935
14 | - name: preview-nginx-server
15 | port: 80
16 | targetPort: 80
17 | selector:
18 | app: rtmp
--------------------------------------------------------------------------------
/frontend/src/actions/themeActions.ts:
--------------------------------------------------------------------------------
1 | export enum ThemeActionTypes {
2 | TOGGLE_THEME = 'theme/toggleTheme'
3 | }
4 |
5 | export function toggleTheme(): IToggleThemeAction {
6 | return {
7 | type: ThemeActionTypes.TOGGLE_THEME
8 | };
9 | }
10 |
11 | export interface IToggleThemeAction {
12 | type: ThemeActionTypes.TOGGLE_THEME;
13 | }
14 |
15 | export type CounterAction = IToggleThemeAction;
16 |
--------------------------------------------------------------------------------
/docker/multi-streamer-ui/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM node AS ui-builder
2 |
3 | WORKDIR /frontend
4 |
5 | COPY ./frontend/package.json .
6 | COPY ./frontend/yarn.lock .
7 |
8 | RUN yarn
9 |
10 | COPY ./frontend .
11 |
12 | RUN yarn build
13 |
14 | FROM nginx:stable
15 |
16 | COPY ./docker/multi-streamer-ui/nginx.conf /etc/nginx/nginx.conf
17 |
18 | COPY --from=ui-builder /frontend/dist /usr/share/nginx/html
19 |
20 | EXPOSE 80
--------------------------------------------------------------------------------
/router/stream_router.go:
--------------------------------------------------------------------------------
1 | package router
2 |
3 | import "github.com/go-chi/chi"
4 |
5 | // streamRouter Add channel route handlers
6 | func (cr *CustomRouter) streamRouter() *chi.Mux {
7 | router := chi.NewRouter()
8 |
9 | router.Post("/add", cr.Authentication(cr.AddStream))
10 | router.Get("/list", cr.Authentication(cr.ListStreams))
11 | router.Delete("/delete", cr.Authentication(cr.DeleteStream))
12 | return router
13 | }
14 |
--------------------------------------------------------------------------------
/models/channel_model.go:
--------------------------------------------------------------------------------
1 | package models
2 |
3 | import (
4 | "github.com/jinzhu/gorm"
5 | )
6 |
7 | // Channel ..
8 | type Channel struct {
9 | Model
10 | Name string `json:"name"`
11 | URL string `json:"url"`
12 | Key string `json:"key"`
13 | Enabled bool `json:"enabled"`
14 | StreamID int `json:"streamId"`
15 | }
16 |
17 | // AddChannel to database
18 | func (c Channel) AddChannel(db *gorm.DB) {
19 |
20 | }
21 |
--------------------------------------------------------------------------------
/scripts/rtmp/Dockerfile:
--------------------------------------------------------------------------------
1 | ###########
2 | # BACKEND #
3 | ###########
4 | FROM golang:1.14 AS api-builder
5 |
6 | WORKDIR /go/src/github.com/praveen001/go-rtmp-web-server
7 |
8 | COPY ./go.sum .
9 | COPY ./go.mod .
10 |
11 | RUN chmod +x -R .
12 |
13 | RUN go mod download
14 | RUN curl -sSfL https://raw.githubusercontent.com/cosmtrek/air/master/install.sh | sh -s -- -b $(go env GOPATH)/bin
15 |
16 | COPY . .
17 |
18 | RUN chmod +x -R .
19 |
--------------------------------------------------------------------------------
/scripts/web/Dockerfile:
--------------------------------------------------------------------------------
1 | ###########
2 | # BACKEND #
3 | ###########
4 | FROM golang:1.14 AS api-builder
5 |
6 | WORKDIR /go/src/github.com/praveen001/go-rtmp-web-server
7 |
8 | COPY ./go.sum .
9 | COPY ./go.mod .
10 |
11 | RUN chmod +x -R .
12 |
13 | RUN go mod download
14 | RUN curl -sSfL https://raw.githubusercontent.com/cosmtrek/air/master/install.sh | sh -s -- -b $(go env GOPATH)/bin
15 |
16 | COPY . .
17 |
18 | RUN chmod +x -R .
19 |
--------------------------------------------------------------------------------
/k8s/redis-deployment.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: apps/v1
2 | kind: Deployment
3 |
4 | metadata:
5 | name: redis-deployment
6 | labels:
7 | app: redis
8 |
9 | spec:
10 | selector:
11 | matchLabels:
12 | app: redis
13 |
14 | template:
15 | metadata:
16 | labels:
17 | app: redis
18 |
19 | spec:
20 | containers:
21 | - name: redis
22 | image: redis
23 | ports:
24 | - containerPort: 6379
--------------------------------------------------------------------------------
/helm/go-rtmp-web-server/values.yaml:
--------------------------------------------------------------------------------
1 | endpoint:
2 | api: http://localhost
3 | ui: http://localhost
4 |
5 | mysql:
6 | user: multi-streamer
7 | password: multi-streamer
8 | database: multi-streamer
9 |
10 | youtube:
11 | client_id: 813004830141-3tamleghb88jb8c38pjdfbobp2mg7qt3.apps.googleusercontent.com
12 | client_secret: ''
13 |
14 | twitch:
15 | client_id: d72o1jp8j07z6z3km9zxaz266us9lx
16 | client_secret: mc6tzwdmpbapslalifmzycgpkn011w
--------------------------------------------------------------------------------
/router/user_router.go:
--------------------------------------------------------------------------------
1 | package router
2 |
3 | import (
4 | "github.com/go-chi/chi"
5 | )
6 |
7 | // userRouter Add user route handlers
8 | func (cr *CustomRouter) userRouter() *chi.Mux {
9 | router := chi.NewRouter()
10 |
11 | router.Post("/register", cr.RegisterUser)
12 | router.Get("/verify", cr.VerifyUser)
13 | router.Post("/login", cr.LoginUser)
14 | router.Get("/tokeninfo", cr.Authentication(cr.TokenInfo))
15 |
16 | return router
17 | }
18 |
--------------------------------------------------------------------------------
/frontend/src/containers/ThemeContainer.ts:
--------------------------------------------------------------------------------
1 | import { connect } from 'react-redux';
2 | import Theme, { IThemeStateProps } from '../components/Theme/Theme';
3 | import { IState } from '../reducers';
4 |
5 | function mapStateToProps(state: IState): IThemeStateProps {
6 | return {
7 | type: state.theme.type
8 | };
9 | }
10 |
11 | const mapDispatchToProps = {};
12 |
13 | export default connect(
14 | mapStateToProps,
15 | mapDispatchToProps
16 | )(Theme);
17 |
--------------------------------------------------------------------------------
/docker/multi-streamer-api/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM golang:1.14 AS api-builder
2 |
3 | WORKDIR /go/src/github.com/praveen001/go-rtmp-web-server
4 |
5 | COPY ./go.sum .
6 | COPY ./go.mod .
7 |
8 | RUN go mod download
9 |
10 | COPY . .
11 |
12 | RUN CGO_ENABLED=0 go build
13 |
14 | FROM alpine
15 |
16 | WORKDIR /app
17 |
18 | COPY --from=api-builder /go/src/github.com/praveen001/go-rtmp-web-server/go-rtmp-web-server ./go-rtmp-web-server
19 |
20 | CMD ["./go-rtmp-web-server", "web"]
--------------------------------------------------------------------------------
/amf/encoder.go:
--------------------------------------------------------------------------------
1 | package amf
2 |
3 | import (
4 | "github.com/praveen001/joy4/format/flv/flvio"
5 | )
6 |
7 | // Encode the payload into AMF binary form
8 | func Encode(args ...interface{}) ([]byte, int) {
9 | var len int
10 | for _, val := range args {
11 | len += flvio.LenAMF0Val(val)
12 | }
13 | res := make([]byte, len)
14 | var offset int
15 | for _, val := range args {
16 | offset += flvio.FillAMF0Val(res[offset:], val)
17 | }
18 |
19 | return res, len
20 | }
21 |
--------------------------------------------------------------------------------
/helm/go-rtmp-web-server/.helmignore:
--------------------------------------------------------------------------------
1 | # Patterns to ignore when building packages.
2 | # This supports shell glob matching, relative path matching, and
3 | # negation (prefixed with !). Only one pattern per line.
4 | .DS_Store
5 | # Common VCS dirs
6 | .git/
7 | .gitignore
8 | .bzr/
9 | .bzrignore
10 | .hg/
11 | .hgignore
12 | .svn/
13 | # Common backup files
14 | *.swp
15 | *.bak
16 | *.tmp
17 | *.orig
18 | *~
19 | # Various IDEs
20 | .project
21 | .idea/
22 | *.tmproj
23 | .vscode/
24 |
--------------------------------------------------------------------------------
/helm/go-rtmp-web-server/templates/redis-deployment.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: apps/v1
2 | kind: Deployment
3 |
4 | metadata:
5 | name: redis-deployment
6 | labels:
7 | deployment: redis
8 |
9 | spec:
10 | selector:
11 | matchLabels:
12 | app: redis
13 |
14 | template:
15 | metadata:
16 | labels:
17 | app: redis
18 |
19 | spec:
20 | containers:
21 | - name: redis
22 | image: redis
23 | ports:
24 | - containerPort: 6379
--------------------------------------------------------------------------------
/frontend/tslint.json:
--------------------------------------------------------------------------------
1 | {
2 | "defaultSeverity": "error",
3 | "extends": [
4 | "tslint:recommended",
5 | "tslint-config-standard",
6 | "tslint-react",
7 | "tslint-config-prettier"
8 | ],
9 | "jsRules": {},
10 | "rules": {
11 | "ordered-imports": true,
12 | "object-literal-sort-keys": false,
13 | "member-ordering": false,
14 | "jsx-no-lambda": false,
15 | "jsx-boolean-value": false,
16 | "member-access": false,
17 | "max-line-length": [true, 150]
18 | },
19 | "rulesDirectory": []
20 | }
21 |
--------------------------------------------------------------------------------
/docker/multi-streamer-rtmp/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM golang:1.14 AS api-builder
2 |
3 | WORKDIR /go/src/github.com/praveen001/go-rtmp-web-server
4 |
5 | COPY ./go.mod .
6 | COPY ./go.sum .
7 |
8 | RUN go mod download
9 |
10 | COPY . .
11 |
12 | RUN CGO_ENABLED=0 go build
13 |
14 | FROM ubuntu:18.04
15 |
16 | WORKDIR /app
17 |
18 | RUN apt-get update
19 |
20 | RUN apt-get --yes install ffmpeg
21 |
22 | COPY --from=api-builder /go/src/github.com/praveen001/go-rtmp-web-server/go-rtmp-web-server ./go-rtmp-web-server
23 |
24 | CMD ["./go-rtmp-web-server", "rtmp"]
--------------------------------------------------------------------------------
/frontend/src/containers/LoginFormContainer.ts:
--------------------------------------------------------------------------------
1 | import { connect } from 'react-redux';
2 |
3 | // Types
4 | import LoginForm, { IDispatchProps } from '../components/LoginForm/LoginForm';
5 | import { IState } from '../reducers';
6 |
7 | // Actions
8 | import { login } from '../actions/authenticationActions';
9 |
10 | function mapStateToProps(state: IState) {
11 | return {};
12 | }
13 |
14 | const mapDispatchToProps: IDispatchProps = {
15 | login
16 | };
17 |
18 | export default connect<{}, IDispatchProps>(
19 | mapStateToProps,
20 | mapDispatchToProps
21 | )(LoginForm);
22 |
--------------------------------------------------------------------------------
/frontend/src/containers/RegisterFormContainer.ts:
--------------------------------------------------------------------------------
1 | import { connect } from 'react-redux';
2 |
3 | // Types
4 | import RegisterForm, {
5 | IDispatchProps
6 | } from '../components/RegisterForm/RegisterForm';
7 | import { IState } from '../reducers';
8 |
9 | // Actions
10 | import { register } from '../actions/authenticationActions';
11 |
12 | function mapStateToProps(state: IState) {
13 | return {};
14 | }
15 |
16 | const mapDispatchToProps: IDispatchProps = {
17 | register
18 | };
19 |
20 | export default connect<{}, IDispatchProps>(
21 | mapStateToProps,
22 | mapDispatchToProps
23 | )(RegisterForm);
24 |
--------------------------------------------------------------------------------
/frontend/src/components/Theme/Theme.tsx:
--------------------------------------------------------------------------------
1 | import { MuiThemeProvider, Theme } from '@material-ui/core/styles';
2 | import React from 'react';
3 |
4 | import themeCreator from '../../theme';
5 |
6 | export default function Theme(props: IThemeProps) {
7 | const theme: Theme = themeCreator(props.type);
8 |
9 | return {props.children};
10 | }
11 |
12 | export interface IThemeOwnProps {
13 | children: React.ReactNode;
14 | }
15 |
16 | export interface IThemeStateProps {
17 | type: string;
18 | }
19 |
20 | export type IThemeProps = IThemeOwnProps & IThemeStateProps;
21 |
--------------------------------------------------------------------------------
/frontend/src/actions/headerActions.ts:
--------------------------------------------------------------------------------
1 | export enum HeaderActionTypes {
2 | OPEN_MENU = 'header/openMenu',
3 | CLOSE_MENU = 'header/closeMenu'
4 | }
5 |
6 | export function openMenu() {
7 | return {
8 | type: HeaderActionTypes.OPEN_MENU
9 | };
10 | }
11 |
12 | export function closeMenu() {
13 | return {
14 | type: HeaderActionTypes.CLOSE_MENU
15 | };
16 | }
17 |
18 | export interface IOpenMenuAction {
19 | type: HeaderActionTypes.OPEN_MENU;
20 | }
21 |
22 | export interface ICloseMenuAction {
23 | type: HeaderActionTypes.CLOSE_MENU;
24 | }
25 |
26 | export type HeaderAction = IOpenMenuAction & ICloseMenuAction;
27 |
--------------------------------------------------------------------------------
/frontend/src/models.ts:
--------------------------------------------------------------------------------
1 | export enum ApiStatus {
2 | IN_PROGRESS = 'IN_PROGRESS',
3 | SUCCESS = 'SUCCESS',
4 | FAILURE = 'FAILURE'
5 | }
6 |
7 | export interface IStream {
8 | id: number;
9 | name: string;
10 | key: string;
11 | userId: number;
12 | deleting: ApiStatus;
13 | url: string;
14 | }
15 | export interface IChannel {
16 | id: number;
17 | name: string;
18 | url: string;
19 | key: string;
20 | enabled: boolean;
21 | }
22 |
23 | export interface IStats {
24 | online: boolean;
25 | previewReady: boolean;
26 | }
27 |
28 | export const defaultStats: IStats = {
29 | online: false,
30 | previewReady: false
31 | };
32 |
--------------------------------------------------------------------------------
/frontend/src/containers/SnackbarContainer.ts:
--------------------------------------------------------------------------------
1 | import { connect } from 'react-redux';
2 | import { hideSnackbar } from '../actions/snackbarActions';
3 | import Snackbar, {
4 | IToastDispatchProps,
5 | IToastStateProps
6 | } from '../components/Snackbar/Snackbar';
7 | import { IState } from '../reducers';
8 |
9 | function mapStateToProps(state: IState): IToastStateProps {
10 | return {
11 | toasts: state.snackbar.ids.map(id => state.snackbar.byId[id])
12 | };
13 | }
14 |
15 | const mapDispatchToProps: IToastDispatchProps = {
16 | hideSnackbar
17 | };
18 |
19 | export default connect(
20 | mapStateToProps,
21 | mapDispatchToProps
22 | )(Snackbar);
23 |
--------------------------------------------------------------------------------
/frontend/src/axios.ts:
--------------------------------------------------------------------------------
1 | import axios from 'axios';
2 | import { BaseUrl } from './config';
3 | import { store } from './index';
4 |
5 | const axiosInstance = axios.create({
6 | baseURL: BaseUrl
7 | });
8 |
9 | axiosInstance.interceptors.request.use(
10 | config => {
11 | const state = store.getState();
12 |
13 | if (state.auth.token) {
14 | config.headers.Authorization = state.auth.token;
15 | }
16 |
17 | return config;
18 | },
19 | error => Promise.reject(error)
20 | );
21 |
22 | axiosInstance.interceptors.response.use(
23 | response => response,
24 | error => Promise.reject(error)
25 | );
26 |
27 | export default axiosInstance;
28 |
--------------------------------------------------------------------------------
/frontend/src/reducers/headerReducer.ts:
--------------------------------------------------------------------------------
1 | import produce from 'immer';
2 | import { HeaderActionTypes } from '../actions/headerActions';
3 |
4 | export const initialHeaderState: IHeaderState = {
5 | open: false
6 | };
7 |
8 | export default function headerReducer(state = initialHeaderState, action) {
9 | return produce(state, draft => {
10 | switch (action.type) {
11 | case HeaderActionTypes.OPEN_MENU:
12 | draft.open = true;
13 | break;
14 |
15 | case HeaderActionTypes.CLOSE_MENU:
16 | draft.open = false;
17 | break;
18 | }
19 | });
20 | }
21 |
22 | export interface IHeaderState {
23 | open: boolean;
24 | }
25 |
--------------------------------------------------------------------------------
/frontend/src/containers/ChannelContainer.ts:
--------------------------------------------------------------------------------
1 | import { connect } from 'react-redux';
2 |
3 | // Types
4 | import AddChannelForm, {
5 | IDispatchProps,
6 | IOwnProps
7 | } from '../components/AddChannelForm/AddChannelForm';
8 | import { IState } from '../reducers';
9 |
10 | // Actions
11 | import { addChannel } from '../actions/channelActions';
12 |
13 | function mapStateToProps(state: IState) {
14 | return {
15 | streamId: state.streams.streamId
16 | };
17 | }
18 |
19 | const mapDispatchToProps: IDispatchProps = {
20 | addChannel
21 | };
22 |
23 | export default connect(
24 | mapStateToProps,
25 | mapDispatchToProps
26 | )(AddChannelForm);
27 |
--------------------------------------------------------------------------------
/frontend/src/containers/AddChannelContainer.ts:
--------------------------------------------------------------------------------
1 | import { connect } from 'react-redux';
2 | import { hideAddChannel } from '../actions/channelActions';
3 | import AddChannel, {
4 | IAddChannelDispatchProps,
5 | IAddChannelStatusProps
6 | } from '../components/AddChannel/AddChannel';
7 | import { IState } from '../reducers';
8 |
9 | function mapStateToProps(state: IState): IAddChannelStatusProps {
10 | return {
11 | open: state.channels.addChannelOpen,
12 | streamId: state.streams.streamId
13 | };
14 | }
15 |
16 | const mapDispatchToProps: IAddChannelDispatchProps = {
17 | hideAddChannel
18 | };
19 |
20 | export default connect(
21 | mapStateToProps,
22 | mapDispatchToProps
23 | )(AddChannel);
24 |
--------------------------------------------------------------------------------
/frontend/src/reducers/themeReducer.ts:
--------------------------------------------------------------------------------
1 | import produce from 'immer';
2 |
3 | import { CounterAction, ThemeActionTypes } from '../actions/themeActions';
4 |
5 | export const initialThemeState: IThemeState = {
6 | type: 'dark'
7 | };
8 |
9 | export default function themeReducer(
10 | state: IThemeState = initialThemeState,
11 | action: CounterAction
12 | ): IThemeState {
13 | return produce(state, draft => {
14 | switch (action.type) {
15 | case ThemeActionTypes.TOGGLE_THEME:
16 | draft.type = draft.type === 'light' ? 'dark' : 'light';
17 | break;
18 |
19 | default:
20 | break;
21 | }
22 | });
23 | }
24 |
25 | export interface IThemeState {
26 | type: string;
27 | }
28 |
--------------------------------------------------------------------------------
/router/channel_router.go:
--------------------------------------------------------------------------------
1 | package router
2 |
3 | import (
4 | "github.com/go-chi/chi"
5 | )
6 |
7 | // channelRouter Add channel route handlers
8 | func (cr *CustomRouter) channelRouter() *chi.Mux {
9 | router := chi.NewRouter()
10 |
11 | router.Post("/add", cr.Authentication(cr.AddChannel))
12 | router.Get("/list", cr.Authentication(cr.ListChannels))
13 | router.Delete("/delete", cr.Authentication(cr.DeleteChannel))
14 |
15 | router.Get("/youtube", cr.Authentication(cr.GoogleAuth))
16 | router.Get("/youtube/callback", cr.Authentication(cr.GoogleAuthCallback))
17 |
18 | router.Get("/twitch", cr.Authentication(cr.TwitchAuth))
19 | router.Get("/twitch/callback", cr.Authentication(cr.TwitchAuthCallback))
20 |
21 | return router
22 | }
23 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/praveen001/go-rtmp-web-server
2 |
3 | go 1.14
4 |
5 | require (
6 | github.com/dgrijalva/jwt-go v3.2.0+incompatible
7 | github.com/go-chi/chi v4.1.2+incompatible
8 | github.com/gomodule/redigo v1.8.2
9 | github.com/google/uuid v1.1.2
10 | github.com/gorilla/websocket v1.4.2
11 | github.com/jinzhu/gorm v1.9.16
12 | github.com/nareix/joy4 v0.0.0-20200507095837-05a4ffbb5369 // indirect
13 | github.com/praveen001/go-rtmp-grpc v0.0.0-20181220144517-7a98ba4ed346
14 | github.com/praveen001/joy4 v0.0.0-20181220185709-7181f3f7230f
15 | github.com/rs/cors v1.7.0
16 | golang.org/x/net v0.0.0-20200927032502-5d4f70055728
17 | golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43
18 | google.golang.org/api v0.32.0
19 | google.golang.org/grpc v1.32.0
20 | )
21 |
--------------------------------------------------------------------------------
/frontend/src/containers/NewStreamContainer.ts:
--------------------------------------------------------------------------------
1 | import { connect } from 'react-redux';
2 |
3 | import { closeNewStreamDialog, createStream } from '../actions/streamActions';
4 | import NewStream, {
5 | INewStreamDispatchProps,
6 | INewStreamStateProps
7 | } from '../components/Streams/NewStream';
8 | import { IState } from '../reducers';
9 |
10 | function mapStateToProps(state: IState): INewStreamStateProps {
11 | return {
12 | open: state.streams.open,
13 | loadingStatus: state.streams.addingStatus
14 | };
15 | }
16 |
17 | const mapDispatchToProps: INewStreamDispatchProps = {
18 | closeNewStreamDialog,
19 | createStream
20 | };
21 |
22 | export default connect(
23 | mapStateToProps,
24 | mapDispatchToProps
25 | )(NewStream);
26 |
--------------------------------------------------------------------------------
/k8s/frontend-deployment.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: apps/v1
2 | kind: Deployment
3 |
4 | metadata:
5 | name: frontend-deployment
6 | labels:
7 | app: frontend
8 |
9 | spec:
10 | selector:
11 | matchLabels:
12 | app: frontend
13 |
14 | template:
15 | metadata:
16 | labels:
17 | app: frontend
18 |
19 | spec:
20 | volumes:
21 | - name: preview-storage
22 | persistentVolumeClaim:
23 | claimName: preview-persistent-volume-claim
24 | containers:
25 | - name: multi-streamer-ui
26 | image: praveenraj9495/multi-streamer-ui:v0.2.0
27 | ports:
28 | - containerPort: 80
29 | volumeMounts:
30 | - name: preview-storage
31 | mountPath: /usr/share/nginx/html/hls-preview
32 | subPath: hls-preview
--------------------------------------------------------------------------------
/frontend/src/components/Streams/StreamsLoader.tsx:
--------------------------------------------------------------------------------
1 | import CircularProgress from '@material-ui/core/CircularProgress';
2 | import React, { useEffect } from 'react';
3 | import { useDispatch, useSelector } from 'react-redux';
4 | import { loadStreams } from '../../actions/streamActions';
5 | import { ApiStatus } from '../../models';
6 | import { IState } from '../../reducers';
7 |
8 | const StreamsLoader: React.FC = props => {
9 | const dispatch = useDispatch();
10 | const loadingStatus = useSelector(
11 | (state: IState) => state.streams.loadingStatus
12 | );
13 |
14 | useEffect(() => {
15 | dispatch(loadStreams());
16 | }, []);
17 |
18 | if (loadingStatus === ApiStatus.IN_PROGRESS) {
19 | return ;
20 | }
21 | return <>{props.children}>;
22 | };
23 |
24 | export default StreamsLoader;
25 |
--------------------------------------------------------------------------------
/frontend/src/containers/StreamsContainer.ts:
--------------------------------------------------------------------------------
1 | import { connect } from 'react-redux';
2 |
3 | import {
4 | deleteStream,
5 | loadStreams,
6 | openNewStreamDialog
7 | } from '../actions/streamActions';
8 | import Streams, {
9 | IStreamsDispatchProps,
10 | IStreamsStateProps
11 | } from '../components/Streams/Streams';
12 | import { IState } from '../reducers';
13 |
14 | function mapStateToProps(state: IState): IStreamsStateProps {
15 | return {
16 | streams: state.streams.ids.map(id => state.streams.byId[id]),
17 | statsById: state.stats.byId
18 | };
19 | }
20 |
21 | const mapDispatchToProps: IStreamsDispatchProps = {
22 | openNewStreamDialog,
23 | deleteStream
24 | };
25 |
26 | export default connect(
27 | mapStateToProps,
28 | mapDispatchToProps
29 | )(Streams);
30 |
--------------------------------------------------------------------------------
/frontend/src/components/Title/Title.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | createStyles,
3 | Theme,
4 | withStyles,
5 | WithStyles
6 | } from '@material-ui/core/styles';
7 | import React from 'react';
8 |
9 | const styles = (theme: Theme) =>
10 | createStyles({
11 | title: {
12 | display: 'flex',
13 | justifyContent: 'space-between',
14 | alignItems: 'center',
15 | borderBottom: `1px solid ${theme.palette.divider}`,
16 | paddingBottom: theme.spacing.unit,
17 | marginBottom: theme.spacing.unit,
18 | height: 40
19 | }
20 | });
21 |
22 | class Title extends React.Component> {
23 | render() {
24 | const { classes } = this.props;
25 | return {this.props.children}
;
26 | }
27 | }
28 |
29 | export default withStyles(styles)(Title);
30 |
--------------------------------------------------------------------------------
/helm/go-rtmp-web-server/templates/frontend-deployment.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: apps/v1
2 | kind: Deployment
3 |
4 | metadata:
5 | name: frontend-deployment
6 | labels:
7 | deployment: frontend
8 |
9 | spec:
10 | selector:
11 | matchLabels:
12 | app: frontend
13 |
14 | template:
15 | metadata:
16 | labels:
17 | app: frontend
18 |
19 | spec:
20 | volumes:
21 | - name: preview-storage
22 | persistentVolumeClaim:
23 | claimName: preview-persistent-volume-claim
24 | containers:
25 | - name: multi-streamer-ui
26 | image: praveenraj9495/multi-streamer-ui:v0.2.0
27 | ports:
28 | - containerPort: 80
29 | volumeMounts:
30 | - name: preview-storage
31 | mountPath: /usr/share/nginx/html/hls-preview
32 | subPath: hls-preview
--------------------------------------------------------------------------------
/frontend/src/containers/HeaderContainer.ts:
--------------------------------------------------------------------------------
1 | import { connect } from 'react-redux';
2 | import { logout } from '../actions/authenticationActions';
3 | import { closeMenu, openMenu } from '../actions/headerActions';
4 | import { toggleTheme } from '../actions/themeActions';
5 | import Header, {
6 | IHeaderDispatchProps,
7 | IHeaderStateProps
8 | } from '../components/Header/Header';
9 | import { IState } from '../reducers';
10 |
11 | function mapStateToProps(state: IState): IHeaderStateProps {
12 | return {
13 | open: state.header.open,
14 | user: state.auth.user,
15 | theme: state.theme.type
16 | };
17 | }
18 |
19 | const mapDispatchToProps: IHeaderDispatchProps = {
20 | openMenu,
21 | closeMenu,
22 | toggleTheme,
23 | logout
24 | };
25 |
26 | export default connect(
27 | mapStateToProps,
28 | mapDispatchToProps
29 | )(Header);
30 |
--------------------------------------------------------------------------------
/frontend/src/containers/AuthenticationContainer.ts:
--------------------------------------------------------------------------------
1 | import { connect } from 'react-redux';
2 |
3 | // Types
4 | import Authentication, {
5 | IDispatchProps,
6 | IStateProps
7 | } from '../components/Authentication/Authentication';
8 | import { IState } from '../reducers';
9 |
10 | // Actions
11 | import { withRouter } from 'react-router';
12 | import { authenticateUser } from '../actions/authenticationActions';
13 |
14 | function mapStateToProps(state: IState): IStateProps {
15 | return {
16 | isAuthenticated: state.auth.isAuthenticated,
17 | token: state.auth.token,
18 | loading: state.auth.loading,
19 | loaded: state.auth.loaded
20 | };
21 | }
22 |
23 | const mapDispatchToProps: IDispatchProps = {
24 | authenticateUser
25 | };
26 |
27 | export default withRouter(
28 | connect(
29 | mapStateToProps,
30 | mapDispatchToProps
31 | )(Authentication)
32 | );
33 |
--------------------------------------------------------------------------------
/k8s/rtmp-deployment.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: apps/v1
2 | kind: Deployment
3 |
4 | metadata:
5 | name: rtmp-deployment
6 | labels:
7 | app: rtmp
8 |
9 | spec:
10 | selector:
11 | matchLabels:
12 | app: rtmp
13 |
14 | template:
15 | metadata:
16 | labels:
17 | app: rtmp
18 |
19 | spec:
20 | volumes:
21 | - name: preview-storage
22 | persistentVolumeClaim:
23 | claimName: preview-persistent-volume-claim
24 | containers:
25 | - name: multi-streamer-rtmp
26 | image: praveenraj9495/multi-streamer-rtmp:v0.2.0
27 | volumeMounts:
28 | - name: preview-storage
29 | mountPath: /hls-preview
30 | subPath: hls-preview
31 | env:
32 | - name: REDIS_HOST
33 | value: redis-cluster-ip
34 | - name: GRPC_HOST
35 | value: api-cluster-ip
36 | ports:
37 | - containerPort: 1935
--------------------------------------------------------------------------------
/router/router.go:
--------------------------------------------------------------------------------
1 | package router
2 |
3 | import (
4 | "net/http"
5 |
6 | "github.com/go-chi/chi"
7 | "github.com/praveen001/go-rtmp-web-server/controllers"
8 | )
9 |
10 | // CustomRouter wrapped mux router
11 | type CustomRouter struct {
12 | *chi.Mux
13 | *controllers.ApplicationContext
14 | }
15 |
16 | func (cr *CustomRouter) ServeHTTP(w http.ResponseWriter, r *http.Request) {
17 | cr.Mux.ServeHTTP(w, r)
18 | }
19 |
20 | // New initializes the application's router
21 | func New(ctx *controllers.ApplicationContext) http.Handler {
22 | cr := &CustomRouter{
23 | chi.NewMux(),
24 | ctx,
25 | }
26 |
27 | cr.Use(ctx.CORSHandler, ctx.LogHandler, ctx.RecoveryHandler)
28 |
29 | cr.Mount("/ws", cr.websocketRouter())
30 |
31 | cr.Route("/v1", func(r chi.Router) {
32 | r.Mount("/api/users", cr.userRouter())
33 | r.Mount("/api/streams", cr.streamRouter())
34 | r.Mount("/api/channels", cr.channelRouter())
35 | })
36 |
37 | return cr
38 | }
39 |
--------------------------------------------------------------------------------
/helm/go-rtmp-web-server/templates/rtmp-deployment.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: apps/v1
2 | kind: Deployment
3 |
4 | metadata:
5 | name: rtmp-deployment
6 | labels:
7 | deployment: rtmp
8 |
9 | spec:
10 | selector:
11 | matchLabels:
12 | app: rtmp
13 |
14 | template:
15 | metadata:
16 | labels:
17 | app: rtmp
18 |
19 | spec:
20 | volumes:
21 | - name: preview-storage
22 | persistentVolumeClaim:
23 | claimName: preview-persistent-volume-claim
24 | containers:
25 | - name: multi-streamer-rtmp
26 | image: praveenraj9495/multi-streamer-rtmp:v0.2.0
27 | volumeMounts:
28 | - name: preview-storage
29 | mountPath: /hls-preview
30 | subPath: hls-preview
31 | env:
32 | - name: REDIS_HOST
33 | value: redis-cluster-ip
34 | - name: GRPC_HOST
35 | value: api-cluster-ip
36 | ports:
37 | - containerPort: 1935
--------------------------------------------------------------------------------
/k8s/mysql-deployment.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: apps/v1
2 | kind: Deployment
3 |
4 | metadata:
5 | name: mysql-deployment
6 | labels:
7 | app: mysql-deployment
8 |
9 | spec:
10 | selector:
11 | matchLabels:
12 | app: mysql
13 |
14 | template:
15 | metadata:
16 | labels:
17 | app: mysql
18 |
19 | spec:
20 | volumes:
21 | - name: mysql-storage
22 | persistentVolumeClaim:
23 | claimName: mysql-persistent-volume-claim
24 |
25 | containers:
26 | - name: mysql
27 | image: mysql
28 | volumeMounts:
29 | - name: mysql-storage
30 | mountPath: /var/lib/mysql
31 | subPath: mysql
32 | env:
33 | - name: MYSQL_ROOT_PASSWORD
34 | value: root
35 | - name: MYSQL_DATABASE
36 | value: multi-streamer
37 | - name: MYSQL_USER
38 | value: multi-streamer
39 | - name: MYSQL_PASSWORD
40 | value: multi-streamer
--------------------------------------------------------------------------------
/frontend/src/actions/snackbarActions.ts:
--------------------------------------------------------------------------------
1 | import { IToast } from '../reducers/snackbarReducer';
2 |
3 | export enum SnackbarActionTypes {
4 | SHOW_SNACKBAR = 'snackbar/show',
5 | HIDE_SNACKBAR = 'snackbar/hide'
6 | }
7 |
8 | export function showSnackbar(
9 | message: string,
10 | error: boolean
11 | ): IShowSnackbarAction {
12 | return {
13 | type: SnackbarActionTypes.SHOW_SNACKBAR,
14 | payload: { message, error }
15 | };
16 | }
17 |
18 | export function hideSnackbar(id: number): IHideSnackbarAction {
19 | return {
20 | type: SnackbarActionTypes.HIDE_SNACKBAR,
21 | payload: {
22 | id
23 | }
24 | };
25 | }
26 |
27 | export interface IShowSnackbarAction {
28 | type: SnackbarActionTypes.SHOW_SNACKBAR;
29 | payload: Pick;
30 | }
31 |
32 | export interface IHideSnackbarAction {
33 | type: SnackbarActionTypes.HIDE_SNACKBAR;
34 | payload: {
35 | id: number;
36 | };
37 | }
38 |
39 | export type SnackbarAction = IShowSnackbarAction | IHideSnackbarAction;
40 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 | ```
3 | go get -u github.com/praveen001/go-rtmp-web-server
4 | ```
5 |
6 | Just a learning project.
7 |
8 | # RTMP Restreaming Server
9 |
10 | Restreaming server that can stream to Youtube, Twitch and Custom RTMP servers. Like [restream.io](https://restream.io)
11 |
12 | It consists of three components:
13 | * Web server - Provides APIs for authentication, channel listing and creation, etc.
14 | * RTMP restreaming server - RTMP Server that can receive a stream and distribute to multiple endpoints.
15 | * Frontend - User interface.
16 |
17 | 
18 |
19 | # Running in development mode
20 |
21 | Update `./scripts/configs.env` and run
22 |
23 | ```
24 | docker-compose up
25 | ```
26 |
27 | # Running in production mode
28 |
29 | Production mode will run in a kubernetes cluster.
30 |
31 | Configs should be done in `./helm/go-rtmp-web-server/values.yaml`
32 |
33 | ```
34 | helm install go-restream ./helm/go-rtmp-web-server/
35 | ```
--------------------------------------------------------------------------------
/frontend/src/components/Icons/Facebook.tsx:
--------------------------------------------------------------------------------
1 | import SvgIcon from '@material-ui/core/SvgIcon';
2 | import React from 'react';
3 |
4 | /* tslint:disable */
5 | export default props => (
6 |
7 |
12 |
13 | );
14 |
--------------------------------------------------------------------------------
/helm/go-rtmp-web-server/templates/mysql-deployment.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: apps/v1
2 | kind: Deployment
3 |
4 | metadata:
5 | name: mysql-deployment
6 | labels:
7 | deployment: mysql-deployment
8 |
9 | spec:
10 | selector:
11 | matchLabels:
12 | app: mysql
13 |
14 | template:
15 | metadata:
16 | labels:
17 | app: mysql
18 |
19 | spec:
20 | volumes:
21 | - name: mysql-storage
22 | persistentVolumeClaim:
23 | claimName: mysql-persistent-volume-claim
24 |
25 | containers:
26 | - name: mysql
27 | image: mysql
28 | volumeMounts:
29 | - name: mysql-storage
30 | mountPath: /var/lib/mysql
31 | subPath: mysql
32 | env:
33 | - name: MYSQL_ROOT_PASSWORD
34 | value: root
35 | - name: MYSQL_DATABASE
36 | value: {{ .Values.mysql.database }}
37 | - name: MYSQL_USER
38 | value: {{ .Values.mysql.user }}
39 | - name: MYSQL_PASSWORD
40 | value: {{ .Values.mysql.password }}
--------------------------------------------------------------------------------
/frontend/src/reducers/socketReducer.ts:
--------------------------------------------------------------------------------
1 | import produce from 'immer';
2 | import { SocketActionTypes } from '../actions/socketActions';
3 | import { ApiStatus } from '../models';
4 |
5 | export const initialSocketState = {
6 | status: ApiStatus.IN_PROGRESS,
7 | wasConnected: false
8 | };
9 |
10 | export default function socketReducer(state = initialSocketState, action) {
11 | return produce(state, draft => {
12 | switch (action.type) {
13 | case SocketActionTypes.CONNECT:
14 | draft.status = ApiStatus.IN_PROGRESS;
15 | break;
16 |
17 | case SocketActionTypes.CONNECTED:
18 | draft.status = ApiStatus.SUCCESS;
19 | draft.wasConnected = true;
20 | break;
21 |
22 | case SocketActionTypes.SENT_MESSAGE:
23 | break;
24 |
25 | case SocketActionTypes.DISCONNECTED:
26 | draft.status = ApiStatus.FAILURE;
27 | break;
28 |
29 | default:
30 | }
31 | });
32 | }
33 |
34 | export interface ISocketState {
35 | status: ApiStatus;
36 | wasConnected: boolean;
37 | }
38 |
--------------------------------------------------------------------------------
/frontend/src/theme.ts:
--------------------------------------------------------------------------------
1 | import createMuiTheme, { Theme } from '@material-ui/core/styles/createMuiTheme';
2 |
3 | export default (type: any) => {
4 | return createMuiTheme({
5 | palette: {
6 | type: type as 'light' | 'dark',
7 | primary: {
8 | main: '#02444e'
9 | },
10 | secondary: {
11 | main: '#15ac02'
12 | },
13 | text: {
14 | primary: type === 'light' ? '#363f45' : '#ddd',
15 | secondary: type === 'light' ? '#525e66' : '#ccc'
16 | },
17 | error: {
18 | main: '#df041f'
19 | }
20 | },
21 | transitions: {
22 | duration: {
23 | leavingScreen: 200,
24 | enteringScreen: 200
25 | }
26 | },
27 | typography: {
28 | useNextVariants: true
29 | }
30 | });
31 | };
32 |
33 | /* tslint:disable */
34 | interface ICustomTheme {}
35 |
36 | declare module '@material-ui/core/styles/createMuiTheme' {
37 | interface Theme extends ICustomTheme {}
38 | interface ThemeOptions extends ICustomTheme {}
39 | }
40 | /* tslint:enable */
41 |
--------------------------------------------------------------------------------
/frontend/src/components/Icons/Twitch.tsx:
--------------------------------------------------------------------------------
1 | import SvgIcon from '@material-ui/core/SvgIcon';
2 | import React from 'react';
3 | /* tslint:disable */
4 | export default props => (
5 |
6 |
11 |
12 | );
13 |
--------------------------------------------------------------------------------
/frontend/src/reducers/statsReducer.ts:
--------------------------------------------------------------------------------
1 | import produce from 'immer';
2 | import { StatsAction, StatsActionTypes } from '../actions/statsActions';
3 | import { defaultStats, IStats } from '../models';
4 |
5 | export const initialStatsState: IStatsState = {
6 | byId: {}
7 | };
8 |
9 | export default function statsReducer(
10 | state = initialStatsState,
11 | action: StatsAction
12 | ) {
13 | return produce(state, draft => {
14 | switch (action.type) {
15 | case StatsActionTypes.STREAM_ONLINE:
16 | draft.byId[action.payload.streamId] = defaultStats;
17 | draft.byId[action.payload.streamId].online = true;
18 | break;
19 |
20 | case StatsActionTypes.STREAM_OFFLINE:
21 | draft.byId[action.payload.streamId].online = false;
22 | draft.byId[action.payload.streamId].previewReady = false;
23 | break;
24 |
25 | case StatsActionTypes.PREVIEW_READY:
26 | draft.byId[action.payload.streamId].previewReady = true;
27 | break;
28 | }
29 | });
30 | }
31 |
32 | export interface IStatsState {
33 | byId: { [key: number]: IStats };
34 | }
35 |
--------------------------------------------------------------------------------
/frontend/src/epics/index.ts:
--------------------------------------------------------------------------------
1 | import { combineEpics, createEpicMiddleware } from 'redux-observable';
2 | import 'rxjs/add/observable/dom/webSocket';
3 | import 'rxjs/add/observable/fromPromise';
4 | import 'rxjs/add/observable/of';
5 | import 'rxjs/add/operator/catch';
6 | import 'rxjs/add/operator/debounceTime';
7 | import 'rxjs/add/operator/delay';
8 | import 'rxjs/add/operator/do';
9 | import 'rxjs/add/operator/exhaustMap';
10 | import 'rxjs/add/operator/map';
11 | import 'rxjs/add/operator/mapTo';
12 | import 'rxjs/add/operator/merge';
13 | import 'rxjs/add/operator/mergeMap';
14 | import 'rxjs/add/operator/startWith';
15 | import 'rxjs/add/operator/switchMap';
16 | import 'rxjs/add/operator/takeUntil';
17 | import 'rxjs/add/operator/throttleTime';
18 | import authEpics from './authEpics';
19 | import channelEpics from './channelEpics';
20 | import snackbarEpics from './snackbarEpics';
21 | import socketEpics from './socketEpics';
22 | import streamEpics from './streamEpics';
23 |
24 | export const rootEpic = combineEpics(
25 | authEpics,
26 | channelEpics,
27 | streamEpics,
28 | socketEpics,
29 | snackbarEpics
30 | );
31 |
32 | export default createEpicMiddleware();
33 |
--------------------------------------------------------------------------------
/frontend/src/components/Player/Player.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import videojs from 'video.js';
3 | import 'video.js/dist/video-js.css';
4 | import 'videojs-contrib-hls';
5 |
6 | export default class VideoPlayer extends React.Component {
7 | videoNode;
8 | player;
9 |
10 | componentDidMount() {
11 | // instantiate Video.js
12 | this.player = videojs(this.videoNode, this.props, function onPlayerReady() {
13 | //
14 | });
15 | }
16 |
17 | // destroy player on unmount
18 | componentWillUnmount() {
19 | if (this.player) {
20 | this.player.dispose();
21 | }
22 | }
23 |
24 | // wrap the player in a div with a `data-vjs-player` attribute
25 | // so videojs won't create additional wrapper in the DOM
26 | // see https://github.com/videojs/video.js/pull/3856
27 | render() {
28 | return (
29 |
30 |
31 |
37 |
38 | );
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/frontend/src/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom';
3 | import { Provider } from 'react-redux';
4 | import { applyMiddleware, createStore } from 'redux';
5 | import { composeWithDevTools } from 'redux-devtools-extension';
6 |
7 | import CssBaseline from '@material-ui/core/CssBaseline';
8 | import { routerMiddleware } from 'connected-react-router';
9 | import App from './components/App';
10 | import ThemeContainer from './containers/ThemeContainer';
11 | import epicMiddleware, { rootEpic } from './epics';
12 | import rootReducer, { history, initialState } from './reducers';
13 |
14 | const composeEnhancer = composeWithDevTools({
15 | name: 'stream'
16 | });
17 |
18 | export const store = createStore(
19 | rootReducer,
20 | initialState,
21 | composeEnhancer(applyMiddleware(epicMiddleware, routerMiddleware(history)))
22 | );
23 |
24 | epicMiddleware.run(rootEpic);
25 |
26 | ReactDOM.render(
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 | ,
35 | document.getElementById('app')
36 | );
37 |
--------------------------------------------------------------------------------
/frontend/src/reducers/snackbarReducer.ts:
--------------------------------------------------------------------------------
1 | import produce from 'immer';
2 | import {
3 | SnackbarAction,
4 | SnackbarActionTypes
5 | } from '../actions/snackbarActions';
6 |
7 | export const initialSnackbarState: ISnackbarState = {
8 | byId: {},
9 | ids: []
10 | };
11 |
12 | export default function snackbarReducer(
13 | state: ISnackbarState = initialSnackbarState,
14 | action: SnackbarAction
15 | ) {
16 | return produce(state, draft => {
17 | switch (action.type) {
18 | case SnackbarActionTypes.SHOW_SNACKBAR:
19 | draft.byId[draft.ids.length] = {
20 | id: draft.ids.length,
21 | ...action.payload
22 | };
23 | draft.ids.push(draft.ids.length);
24 | break;
25 |
26 | case SnackbarActionTypes.HIDE_SNACKBAR:
27 | draft.ids.splice(draft.ids.indexOf(action.payload.id), 1);
28 | delete draft.byId[action.payload.id];
29 | break;
30 | }
31 | });
32 | }
33 |
34 | export interface ISnackbarState {
35 | byId: {
36 | [key: string]: IToast;
37 | };
38 | ids: number[];
39 | }
40 |
41 | export interface IToast {
42 | id: number;
43 | message: string;
44 | error: boolean;
45 | }
46 |
--------------------------------------------------------------------------------
/frontend/src/components/StreamStatusIcon/StreamStatusIcon.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | createStyles,
3 | Theme,
4 | withStyles,
5 | WithStyles
6 | } from '@material-ui/core/styles';
7 | import classnames from 'classnames';
8 | import React from 'react';
9 |
10 | const styles = (theme: Theme) =>
11 | createStyles({
12 | status: {
13 | width: 20,
14 | height: 20,
15 | borderRadius: 30,
16 | marginRight: theme.spacing.unit
17 | },
18 | offline: {
19 | background: '#888'
20 | },
21 | online: {
22 | background: 'green'
23 | }
24 | });
25 |
26 | class StreamStatusIcon extends React.Component {
27 | render() {
28 | const { classes, online } = this.props;
29 |
30 | return (
31 |
37 | );
38 | }
39 | }
40 |
41 | export default withStyles(styles)(StreamStatusIcon);
42 |
43 | interface IStreamStatusIconOwnProps {
44 | online: boolean;
45 | }
46 |
47 | type StreamStatusIconProps = IStreamStatusIconOwnProps &
48 | WithStyles;
49 |
--------------------------------------------------------------------------------
/frontend/src/containers/DashboardContainer.ts:
--------------------------------------------------------------------------------
1 | import { connect } from 'react-redux';
2 |
3 | // Types
4 | import Dashboard, {
5 | IDispatchProps,
6 | IStateProps
7 | } from '../components/Dashboard/Dashboard';
8 | import { IState } from '../reducers';
9 |
10 | // Actions
11 | import {
12 | addChannel,
13 | deleteChannel,
14 | fetchChannelList,
15 | showAddChannel
16 | } from '../actions/channelActions';
17 | import { showSnackbar } from '../actions/snackbarActions';
18 | import { selectStream } from '../actions/streamActions';
19 | import { defaultStats, IStats } from '../models';
20 |
21 | function mapStateToProps(state: IState): IStateProps {
22 | return {
23 | token: state.auth.token,
24 | stream: state.streams.byId[state.streams.streamId],
25 | stats: state.stats.byId[state.streams.streamId] || defaultStats,
26 | channels: state.channels.ids.map(id => state.channels.byId[id])
27 | };
28 | }
29 |
30 | const mapDispatchToProps: IDispatchProps = {
31 | showAddChannel,
32 | fetchChannelList,
33 | selectStream,
34 | deleteChannel,
35 | showSnackbar
36 | };
37 |
38 | export default connect(
39 | mapStateToProps,
40 | mapDispatchToProps
41 | )(Dashboard);
42 |
--------------------------------------------------------------------------------
/frontend/src/components/Socket/Socket.tsx:
--------------------------------------------------------------------------------
1 | import CircularProgress from '@material-ui/core/CircularProgress';
2 | import React, { useEffect } from 'react';
3 | import { useDispatch, useSelector } from 'react-redux';
4 | import { connect, disconnect } from '../../actions/socketActions';
5 | import { SocketUrl } from '../../config';
6 | import { ApiStatus } from '../../models';
7 | import { IState } from '../../reducers';
8 |
9 | const WebSocket: React.FC = props => {
10 | const dispatch = useDispatch();
11 | const { status, wasConnected, token } = useSelector((state: IState) => ({
12 | status: state.socket.status,
13 | wasConnected: state.socket.wasConnected,
14 | token: state.auth.token
15 | }));
16 |
17 | useEffect(() => {
18 | dispatch(connect(`${SocketUrl}/ws/connect?token=${token}`));
19 | return () => dispatch(disconnect());
20 | }, []);
21 |
22 | if (status === ApiStatus.IN_PROGRESS) {
23 | return ;
24 | }
25 | if (status === ApiStatus.FAILURE) {
26 | return Unable to connect to websocket server
;
27 | }
28 | if (status === ApiStatus.SUCCESS) {
29 | return <>{props.children}>;
30 | }
31 | return null;
32 | };
33 |
34 | export default WebSocket;
35 |
--------------------------------------------------------------------------------
/controllers/redis.go:
--------------------------------------------------------------------------------
1 | package controllers
2 |
3 | import (
4 | "encoding/json"
5 | "fmt"
6 |
7 | "github.com/gomodule/redigo/redis"
8 | )
9 |
10 | // PubSub ..
11 | type PubSub struct {
12 | *redis.PubSubConn
13 | }
14 |
15 | // NewPubSub ..
16 | func NewPubSub(redisConn redis.Conn, channel string) *PubSub {
17 | pubsub := &PubSub{
18 | &redis.PubSubConn{
19 | Conn: redisConn,
20 | },
21 | }
22 |
23 | if pubsub.PubSubConn.Subscribe(channel) != nil {
24 | panic("Unable to subscribe to redis")
25 | }
26 |
27 | return pubsub
28 | }
29 |
30 | // Listen will read messages published to the subscribed channel
31 | func (ps *PubSub) Listen(hub *WSHub) {
32 | var t map[string]interface{}
33 |
34 | for {
35 | raw := ps.PubSubConn.Receive()
36 | switch raw.(type) {
37 | case redis.Subscription:
38 | case redis.Message:
39 | if err := json.Unmarshal(raw.(redis.Message).Data, &t); err != nil {
40 | fmt.Println("Unable to unmarshal redis message")
41 | continue
42 | }
43 |
44 | payload, err := json.Marshal(t["Message"])
45 | if err != nil {
46 | fmt.Println("Unable to marshal redis message payload")
47 | continue
48 | }
49 |
50 | outgoing := WSOutgoing{
51 | ID: int(t["ID"].(float64)),
52 | Message: payload,
53 | }
54 | hub.Outgoing <- outgoing
55 | }
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/docker/multi-streamer-ui/nginx.conf:
--------------------------------------------------------------------------------
1 | user nginx;
2 | worker_processes 1;
3 |
4 | error_log /var/log/nginx/error.log warn;
5 | pid /var/run/nginx.pid;
6 |
7 |
8 | events {
9 | worker_connections 1024;
10 | }
11 |
12 |
13 | http {
14 | include /etc/nginx/mime.types;
15 | default_type application/octet-stream;
16 |
17 | log_format main '$remote_addr - $remote_user [$time_local] "$request" '
18 | '$status $body_bytes_sent "$http_referer" '
19 | '"$http_user_agent" "$http_x_forwarded_for"';
20 |
21 | access_log /var/log/nginx/access.log main;
22 |
23 | sendfile on;
24 | #tcp_nopush on;
25 |
26 | keepalive_timeout 65;
27 |
28 | #gzip on;
29 |
30 | # include /etc/nginx/conf.d/*.conf;
31 |
32 | server {
33 | listen 80;
34 | server_name _;
35 |
36 | location /v1/api/ws/ {
37 | proxy_http_version 1.1;
38 | proxy_set_header Upgrade $http_upgrade;
39 | proxy_set_header Connection "upgrade";
40 | proxy_pass http://localhost:5000;
41 | }
42 |
43 | location /v1/api/ {
44 | proxy_pass http://localhost:5000;
45 | }
46 |
47 | root /usr/share/nginx/html;
48 | index index.html;
49 | try_files $uri $uri/ /index.html =404;
50 | }
51 | }
--------------------------------------------------------------------------------
/k8s/api-deployment.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: apps/v1
2 | kind: Deployment
3 |
4 | metadata:
5 | name: api-deployment
6 | labels:
7 | app: api
8 |
9 | spec:
10 | selector:
11 | matchLabels:
12 | app: api
13 |
14 | template:
15 | metadata:
16 | labels:
17 | app: api
18 |
19 | spec:
20 | containers:
21 | - name: multi-streamer-api
22 | image: praveenraj9495/multi-streamer-api:v0.2.0
23 | env:
24 | - name: API_ENDPOINT
25 | value: http://localhost
26 | - name: UI_ENDPOINT
27 | value: http://localhost
28 | - name: MYSQL_USER
29 | value: multi-streamer
30 | - name: MYSQL_PASSWORD
31 | value: multi-streamer
32 | - name: MYSQL_HOST
33 | value: mysql-cluster-ip
34 | - name: MYSQL_DATABASE
35 | value: multi-streamer
36 | - name: REDIS_HOST
37 | value: redis-cluster-ip
38 | - name: YOUTUBE_CLIENT_ID
39 | value: 813004830141-3tamleghb88jb8c38pjdfbobp2mg7qt3.apps.googleusercontent.com
40 | - name: YOUTUBE_CLIENT_SECRET
41 | value:
42 | - name: TWITCH_CLIENT_ID
43 | value: d72o1jp8j07z6z3km9zxaz266us9lx
44 | - name: TWITCH_CLIENT_SECRET
45 | value: mc6tzwdmpbapslalifmzycgpkn011w
46 | ports:
47 | - containerPort: 5000
48 | - containerPort: 4005
--------------------------------------------------------------------------------
/frontend/src/actions/statsActions.ts:
--------------------------------------------------------------------------------
1 | export enum StatsActionTypes {
2 | STREAM_ONLINE = 'stats/streamOnline',
3 | STREAM_OFFLINE = 'stats/streamOffline',
4 | PREVIEW_READY = 'stats/previewReady'
5 | }
6 |
7 | export function streamOnline(streamId: number): IStreamOnlineAction {
8 | return {
9 | type: StatsActionTypes.STREAM_ONLINE,
10 | payload: {
11 | streamId
12 | }
13 | };
14 | }
15 |
16 | export function streamOffline(streamId: number): IStreamOfflineAction {
17 | return {
18 | type: StatsActionTypes.STREAM_OFFLINE,
19 | payload: {
20 | streamId
21 | }
22 | };
23 | }
24 |
25 | export function previewReady(streamId: number): IPreviewReadyAction {
26 | return {
27 | type: StatsActionTypes.PREVIEW_READY,
28 | payload: {
29 | streamId
30 | }
31 | };
32 | }
33 |
34 | export interface IStreamOnlineAction {
35 | type: StatsActionTypes.STREAM_ONLINE;
36 | payload: {
37 | streamId: number;
38 | };
39 | }
40 |
41 | export interface IStreamOfflineAction {
42 | type: StatsActionTypes.STREAM_OFFLINE;
43 | payload: {
44 | streamId: number;
45 | };
46 | }
47 |
48 | export interface IPreviewReadyAction {
49 | type: StatsActionTypes.PREVIEW_READY;
50 | payload: {
51 | streamId: number;
52 | };
53 | }
54 |
55 | export type StatsAction =
56 | | IStreamOnlineAction
57 | | IStreamOfflineAction
58 | | IPreviewReadyAction;
59 |
--------------------------------------------------------------------------------
/frontend/webpack.prod.js:
--------------------------------------------------------------------------------
1 | const path = require('path'),
2 | HtmlWebpackPlugin = require('html-webpack-plugin'),
3 | BundleAnalyzerPlugin = require('webpack-bundle-analyzer')
4 | .BundleAnalyzerPlugin,
5 | webpack = require('webpack');
6 |
7 | module.exports = {
8 | mode: 'production',
9 |
10 | entry: './src/index.tsx',
11 |
12 | resolve: {
13 | extensions: ['.ts', '.tsx', '.js', '.jsx']
14 | },
15 |
16 | output: {
17 | path: path.resolve(__dirname, 'dist'),
18 | filename: 'bundle.js',
19 | publicPath: '/'
20 | },
21 |
22 | module: {
23 | rules: [
24 | {
25 | test: /\.tsx?$/,
26 | loader: 'awesome-typescript-loader'
27 | },
28 | {
29 | test: /\.css$/,
30 | use: ['style-loader', 'css-loader']
31 | },
32 | {
33 | test: /\.(woff(2)?|ttf|eot|svg)(\?v=\d+\.\d+\.\d+)?$/,
34 | use: [
35 | {
36 | loader: 'file-loader',
37 | options: {
38 | name: '[name].[ext]',
39 | outputPath: 'fonts/'
40 | }
41 | }
42 | ]
43 | }
44 | ]
45 | },
46 |
47 | plugins: [
48 | new HtmlWebpackPlugin({
49 | template: './src/index.html'
50 | }),
51 | // new BundleAnalyzerPlugin(),
52 | new webpack.IgnorePlugin(/^\.\/locale$/, /moment$/),
53 | new webpack.HotModuleReplacementPlugin()
54 | ]
55 | };
56 |
--------------------------------------------------------------------------------
/helm/go-rtmp-web-server/templates/api-deployment.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: apps/v1
2 | kind: Deployment
3 |
4 | metadata:
5 | name: api-deployment
6 | labels:
7 | deployment: api
8 |
9 | spec:
10 | selector:
11 | matchLabels:
12 | app: api
13 |
14 | template:
15 | metadata:
16 | labels:
17 | app: api
18 |
19 | spec:
20 | containers:
21 | - name: multi-streamer-api
22 | image: praveenraj9495/multi-streamer-api:v0.2.0
23 | env:
24 | - name: API_ENDPOINT
25 | value: {{ .Values.endpoint.api }}
26 | - name: UI_ENDPOINT
27 | value: {{ .Values.endpoint.ui }}
28 | - name: MYSQL_USER
29 | value: {{ .Values.mysql.user }}
30 | - name: MYSQL_PASSWORD
31 | value: {{ .Values.mysql.password }}
32 | - name: MYSQL_HOST
33 | value: mysql-cluster-ip
34 | - name: MYSQL_DATABASE
35 | value: {{ .Values.mysql.database }}
36 | - name: REDIS_HOST
37 | value: redis-cluster-ip
38 | - name: YOUTUBE_CLIENT_ID
39 | value: {{ .Values.youtube.client_id }}
40 | - name: YOUTUBE_CLIENT_SECRET
41 | value: {{ .Values.youtube.client_secret }}
42 | - name: TWITCH_CLIENT_ID
43 | value: {{ .Values.twitch.client_id }}
44 | - name: TWITCH_CLIENT_SECRET
45 | value: {{ .Values.twitch.client_secret }}
46 | ports:
47 | - containerPort: 5000
48 | - containerPort: 4005
--------------------------------------------------------------------------------
/scripts/nginx/nginx.conf:
--------------------------------------------------------------------------------
1 | user nginx;
2 | worker_processes 1;
3 |
4 | error_log /var/log/nginx/error.log warn;
5 | pid /var/run/nginx.pid;
6 |
7 |
8 | events {
9 | worker_connections 1024;
10 | }
11 |
12 |
13 | http {
14 | include /etc/nginx/mime.types;
15 | default_type application/octet-stream;
16 |
17 | log_format main '$remote_addr - $remote_user [$time_local] "$request" '
18 | '$status $body_bytes_sent "$http_referer" '
19 | '"$http_user_agent" "$http_x_forwarded_for"';
20 |
21 | access_log /var/log/nginx/access.log main;
22 |
23 | sendfile on;
24 | #tcp_nopush on;
25 |
26 | keepalive_timeout 65;
27 |
28 | #gzip on;
29 |
30 | # include /etc/nginx/conf.d/*.conf;
31 |
32 | server {
33 | listen 80;
34 | server_name _;
35 | root /usr/share/nginx/html;
36 |
37 | location /ws/ {
38 | proxy_http_version 1.1;
39 | proxy_set_header Upgrade $http_upgrade;
40 | proxy_set_header Connection "upgrade";
41 | proxy_pass http://web:5000;
42 | }
43 |
44 | location /v1/api/ {
45 | proxy_pass http://web:5000;
46 | }
47 |
48 | location /hls-preview/ {
49 | index index.html;
50 | try_files $uri $uri/ /index.html =404;
51 | }
52 |
53 | location / {
54 | proxy_pass http://ui:8080;
55 | }
56 | }
57 | }
--------------------------------------------------------------------------------
/frontend/src/components/App.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Redirect, Route, Switch } from 'react-router-dom';
3 |
4 | // Material-UI Components
5 | import {
6 | createStyles,
7 | Theme,
8 | withStyles,
9 | WithStyles
10 | } from '@material-ui/core/styles';
11 |
12 | // Components/Containers
13 | import { ConnectedRouter } from 'connected-react-router';
14 | import AuthenticationContainer from '../containers/AuthenticationContainer';
15 | import SnackbarContainer from '../containers/SnackbarContainer';
16 | import { history } from '../reducers';
17 | import LandingPage from './LandingPage/LandingPage';
18 |
19 | const styles = (theme: Theme) =>
20 | createStyles({
21 | content: {
22 | padding: theme.spacing.unit * 2,
23 | height: '100%',
24 | width: '100%'
25 | }
26 | });
27 |
28 | class App extends React.Component {
29 | render() {
30 | const { classes } = this.props;
31 |
32 | return (
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 | );
44 | }
45 | }
46 |
47 | export default withStyles(styles)(App);
48 |
49 | export type AppProps = WithStyles;
50 |
--------------------------------------------------------------------------------
/controllers/rpc.go:
--------------------------------------------------------------------------------
1 | package controllers
2 |
3 | import (
4 | "context"
5 | "fmt"
6 | "net"
7 |
8 | "github.com/praveen001/go-rtmp-grpc/pkg/api/v1"
9 | "github.com/praveen001/go-rtmp-web-server/models"
10 | "google.golang.org/grpc"
11 | )
12 |
13 | // UserChannelServiceServer ..
14 | type UserChannelServiceServer struct {
15 | AppContext *ApplicationContext
16 | }
17 |
18 | // Get ..
19 | func (s *UserChannelServiceServer) Get(ctx context.Context, r *v1.GetUserChannelRequest) (*v1.GetUserChannelResponse, error) {
20 | stream := &models.Stream{
21 | Key: r.GetStreamKey(),
22 | }
23 | s.AppContext.DB.Find(stream, stream).Related(&stream.Channels)
24 |
25 | var channels []*v1.UserChannel
26 | for _, c := range stream.Channels {
27 | ch := &v1.UserChannel{
28 | Name: c.Name,
29 | URL: c.URL,
30 | Key: c.Key,
31 | Enabled: c.Enabled,
32 | ID: int64(c.ID),
33 | }
34 | channels = append(channels, ch)
35 | }
36 |
37 | return &v1.GetUserChannelResponse{User: &v1.User{ID: int64(stream.UserID)}, StreamID: int64(stream.ID), Channels: channels}, nil
38 | }
39 |
40 | // NewRPCServer ..
41 | func NewRPCServer(appContext *ApplicationContext) {
42 | listener, err := net.Listen("tcp", fmt.Sprintf("0.0.0.0:4005"))
43 | if err != nil {
44 | fmt.Println("Error listening")
45 | return
46 | }
47 |
48 | rpcServer := grpc.NewServer()
49 | v1.RegisterUserChannelServiceServer(rpcServer, &UserChannelServiceServer{
50 | AppContext: appContext,
51 | })
52 | rpcServer.Serve(listener)
53 | }
54 |
--------------------------------------------------------------------------------
/frontend/webpack.config.js:
--------------------------------------------------------------------------------
1 | const path = require('path'),
2 | HtmlWebpackPlugin = require('html-webpack-plugin'),
3 | BundleAnalyzerPlugin = require('webpack-bundle-analyzer')
4 | .BundleAnalyzerPlugin,
5 | webpack = require('webpack');
6 |
7 | module.exports = {
8 | mode: 'development',
9 |
10 | entry: './src/index.tsx',
11 |
12 | resolve: {
13 | extensions: ['.ts', '.tsx', '.js', '.jsx']
14 | },
15 |
16 | devtool: 'inline-source-map',
17 |
18 | devServer: {
19 | historyApiFallback: true,
20 | disableHostCheck: true
21 | },
22 |
23 | output: {
24 | path: path.resolve(__dirname, 'dist'),
25 | filename: 'bundle.js',
26 | publicPath: '/'
27 | },
28 |
29 | module: {
30 | rules: [
31 | {
32 | test: /\.tsx?$/,
33 | loader: 'awesome-typescript-loader'
34 | },
35 | {
36 | test: /\.css$/,
37 | use: ['style-loader', 'css-loader']
38 | },
39 | {
40 | test: /\.(woff(2)?|ttf|eot|svg)(\?v=\d+\.\d+\.\d+)?$/,
41 | use: [
42 | {
43 | loader: 'file-loader',
44 | options: {
45 | name: '[name].[ext]',
46 | outputPath: 'fonts/'
47 | }
48 | }
49 | ]
50 | }
51 | ]
52 | },
53 |
54 | plugins: [
55 | new HtmlWebpackPlugin({
56 | template: './src/index.html'
57 | }),
58 | // new BundleAnalyzerPlugin(),
59 | new webpack.IgnorePlugin(/^\.\/locale$/, /moment$/),
60 | new webpack.HotModuleReplacementPlugin()
61 | ]
62 | };
63 |
--------------------------------------------------------------------------------
/scripts/web/.air.toml:
--------------------------------------------------------------------------------
1 | # Config file for [Air](https://github.com/cosmtrek/air) in TOML format
2 |
3 | # Working directory
4 | # . or absolute path, please note that the directories following must be under root.
5 | root = "."
6 | tmp_dir = "tmp"
7 |
8 | [build]
9 | # Just plain old shell command. You could use `make` as well.
10 | cmd = "go build ."
11 | # Binary file yields from `cmd`.
12 | bin = "go-rtmp-web-server web"
13 | # Customize binary.
14 | full_bin = ""
15 | # Watch these filename extensions.
16 | include_ext = ["go", "tpl", "tmpl", "html"]
17 | # Ignore these filename extensions or directories.
18 | exclude_dir = ["tmp", "frontend", "rtmp", "amf"]
19 | # Watch these directories if you specified.
20 | include_dir = []
21 | # Exclude files.
22 | exclude_file = []
23 | # Exclude specific regular expressions.
24 | exclude_regex = ["_test.go"]
25 | # Exclude unchanged files.
26 | exclude_unchanged = true
27 | # Follow symlink for directories
28 | follow_symlink = true
29 | # This log file places in your tmp_dir.
30 | log = "air.log"
31 | # It's not necessary to trigger build each time file changes if it's too frequent.
32 | delay = 1000 # ms
33 | # Stop running old binary when build errors occur.
34 | stop_on_error = true
35 | # Send Interrupt signal before killing process (windows does not support this feature)
36 | send_interrupt = false
37 | # Delay after sending Interrupt signal
38 | kill_delay = 500 # ms
39 |
40 | [log]
41 | # Show log time
42 | time = false
43 |
44 | [color]
45 | # Customize each part's color. If no color found, use the raw app log.
46 | main = "magenta"
47 | watcher = "cyan"
48 | build = "yellow"
49 | runner = "green"
50 |
51 | [misc]
52 | # Delete tmp directory on exit
53 | clean_on_exit = true
--------------------------------------------------------------------------------
/k8s/ingress-service.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: networking.k8s.io/v1
2 | kind: Ingress
3 |
4 | metadata:
5 | name: ingress-service
6 | annotations:
7 | kubernetes.io/ingress.class: 'nginx'
8 | nginx.ingress.kubernetes.io/server-snippets: |
9 | location /ws {
10 | proxy_set_header Upgrade $http_upgrade;
11 | proxy_http_version 1.1;
12 | proxy_set_header X-Forwarded-Host $http_host;
13 | proxy_set_header X-Forwarded-Proto $scheme;
14 | proxy_set_header X-Forwarded-For $remote_addr;
15 | proxy_set_header Host $host;
16 | proxy_set_header Connection "upgrade";
17 | proxy_cache_bypass $http_upgrade;
18 | }
19 |
20 | spec:
21 | rules:
22 | - http:
23 | paths:
24 | - path: /v1/api
25 | pathType: Prefix
26 | backend:
27 | service:
28 | name: api-cluster-ip
29 | port:
30 | number: 5000
31 | - path: /ws
32 | pathType: Prefix
33 | backend:
34 | service:
35 | name: api-cluster-ip
36 | port:
37 | number: 5000
38 | - path: /hls-preview
39 | pathType: Prefix
40 | backend:
41 | service:
42 | name: frontend-cluster-ip
43 | port:
44 | number: 80
45 | - path: /
46 | pathType: Prefix
47 | backend:
48 | service:
49 | name: frontend-cluster-ip
50 | port:
51 | number: 80
52 |
53 | ---
54 |
55 | apiVersion: v1
56 | kind: Service
57 |
58 | metadata:
59 | name: rtmp-ingress
60 |
61 | spec:
62 | type: LoadBalancer
63 | selector:
64 | app: rtmp
65 | ports:
66 | - port: 1935
67 | targetPort: 1935
--------------------------------------------------------------------------------
/scripts/rtmp/.air.toml:
--------------------------------------------------------------------------------
1 | # Config file for [Air](https://github.com/cosmtrek/air) in TOML format
2 |
3 | # Working directory
4 | # . or absolute path, please note that the directories following must be under root.
5 | root = "."
6 | tmp_dir = "tmp"
7 |
8 | [build]
9 | # Just plain old shell command. You could use `make` as well.
10 | cmd = "go build ."
11 | # Binary file yields from `cmd`.
12 | bin = "go-rtmp-web-server rtmp"
13 | # Customize binary.
14 | full_bin = ""
15 | # Watch these filename extensions.
16 | include_ext = ["go", "tpl", "tmpl", "html"]
17 | # Ignore these filename extensions or directories.
18 | exclude_dir = ["tmp", "frontend", "controllers", "router", "models"]
19 | # Watch these directories if you specified.
20 | include_dir = []
21 | # Exclude files.
22 | exclude_file = []
23 | # Exclude specific regular expressions.
24 | exclude_regex = ["_test.go"]
25 | # Exclude unchanged files.
26 | exclude_unchanged = true
27 | # Follow symlink for directories
28 | follow_symlink = true
29 | # This log file places in your tmp_dir.
30 | log = "air.log"
31 | # It's not necessary to trigger build each time file changes if it's too frequent.
32 | delay = 1000 # ms
33 | # Stop running old binary when build errors occur.
34 | stop_on_error = true
35 | # Send Interrupt signal before killing process (windows does not support this feature)
36 | send_interrupt = false
37 | # Delay after sending Interrupt signal
38 | kill_delay = 500 # ms
39 |
40 | [log]
41 | # Show log time
42 | time = false
43 |
44 | [color]
45 | # Customize each part's color. If no color found, use the raw app log.
46 | main = "magenta"
47 | watcher = "cyan"
48 | build = "yellow"
49 | runner = "green"
50 |
51 | [misc]
52 | # Delete tmp directory on exit
53 | clean_on_exit = true
--------------------------------------------------------------------------------
/frontend/src/reducers/channelReducer.ts:
--------------------------------------------------------------------------------
1 | import produce from 'immer';
2 |
3 | import { ChannelAction, ChannelActionTypes } from '../actions/channelActions';
4 | import { IChannel } from '../models';
5 |
6 | export const initialChannelState: IChannelState = {
7 | loading: false,
8 | byId: {},
9 | ids: [],
10 | addChannelOpen: false
11 | };
12 |
13 | export default function channelReducer(
14 | state: IChannelState = initialChannelState,
15 | action: ChannelAction
16 | ): IChannelState {
17 | return produce(state, draft => {
18 | switch (action.type) {
19 | case ChannelActionTypes.FETCHED_CHANNEL_LIST:
20 | action.payload.channels.forEach(channel => {
21 | draft.byId[channel.id] = channel;
22 | draft.ids.push(channel.id);
23 | });
24 | draft.loading = false;
25 | break;
26 |
27 | case ChannelActionTypes.FETCHING_CHANNEL_LIST:
28 | case ChannelActionTypes.ADDING_CHANNEL:
29 | draft.loading = true;
30 | draft.byId = {};
31 | draft.ids = [];
32 | break;
33 |
34 | case ChannelActionTypes.SHOW_ADD_CHANNEL:
35 | draft.addChannelOpen = true;
36 | break;
37 |
38 | case ChannelActionTypes.HIDE_ADD_CHANNEL:
39 | draft.addChannelOpen = false;
40 | break;
41 |
42 | case ChannelActionTypes.DELETE_CHANNEL_SUCCESS:
43 | delete draft.byId[action.payload.channelId];
44 | draft.ids.splice(draft.ids.indexOf(action.payload.channelId), 1);
45 | break;
46 |
47 | default:
48 | break;
49 | }
50 | });
51 | }
52 |
53 | export interface IChannelState {
54 | byId: { [key: number]: IChannel };
55 | ids: number[];
56 | loading: boolean;
57 | addChannelOpen: boolean;
58 | }
59 |
--------------------------------------------------------------------------------
/helm/go-rtmp-web-server/templates/ingress-service.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: networking.k8s.io/v1
2 | kind: Ingress
3 |
4 | metadata:
5 | name: ingress-service
6 | annotations:
7 | kubernetes.io/ingress.class: 'nginx'
8 | nginx.ingress.kubernetes.io/server-snippets: |
9 | location /ws {
10 | proxy_set_header Upgrade $http_upgrade;
11 | proxy_http_version 1.1;
12 | proxy_set_header X-Forwarded-Host $http_host;
13 | proxy_set_header X-Forwarded-Proto $scheme;
14 | proxy_set_header X-Forwarded-For $remote_addr;
15 | proxy_set_header Host $host;
16 | proxy_set_header Connection "upgrade";
17 | proxy_cache_bypass $http_upgrade;
18 | }
19 |
20 | spec:
21 | rules:
22 | - http:
23 | paths:
24 | - path: /v1/api
25 | pathType: Prefix
26 | backend:
27 | service:
28 | name: api-cluster-ip
29 | port:
30 | number: 5000
31 | - path: /ws
32 | pathType: Prefix
33 | backend:
34 | service:
35 | name: api-cluster-ip
36 | port:
37 | number: 5000
38 | - path: /hls-preview
39 | pathType: Prefix
40 | backend:
41 | service:
42 | name: frontend-cluster-ip
43 | port:
44 | number: 80
45 | - path: /
46 | pathType: Prefix
47 | backend:
48 | service:
49 | name: frontend-cluster-ip
50 | port:
51 | number: 80
52 |
53 | ---
54 |
55 | apiVersion: v1
56 | kind: Service
57 |
58 | metadata:
59 | name: rtmp-ingress
60 |
61 | spec:
62 | type: LoadBalancer
63 | selector:
64 | app: rtmp
65 | ports:
66 | - port: 1935
67 | targetPort: 1935
--------------------------------------------------------------------------------
/rtmp/server.go:
--------------------------------------------------------------------------------
1 | package rtmp
2 |
3 | import (
4 | "bufio"
5 | "fmt"
6 | "net"
7 |
8 | "github.com/gomodule/redigo/redis"
9 | "github.com/praveen001/go-rtmp-grpc/pkg/api/v1"
10 | )
11 |
12 | // StreamerContext ..
13 | type StreamerContext struct {
14 | RPC v1.UserChannelServiceClient
15 | Redis redis.Conn
16 | sessions map[string]*Connection
17 | preview chan string
18 | }
19 |
20 | func (ctx *StreamerContext) set(key string, c *Connection) {
21 | ctx.sessions[key] = c
22 | }
23 |
24 | func (ctx *StreamerContext) get(key string) *Connection {
25 | return ctx.sessions[key]
26 | }
27 |
28 | func (ctx *StreamerContext) delete(key string) {
29 | delete(ctx.sessions, key)
30 | }
31 |
32 | // InitServer starts the RTMP server
33 | func InitServer(ctx *StreamerContext) {
34 | ctx.sessions = make(map[string]*Connection)
35 | ctx.preview = make(chan string)
36 |
37 | go InitPreviewServer(ctx)
38 |
39 | listener, err := net.Listen("tcp", ":1935")
40 | if err != nil {
41 | fmt.Printf("Error starting RTMP server %s", err.Error())
42 | }
43 | fmt.Println("RTMP Server started at port 1935")
44 |
45 | for {
46 | conn, err := listener.Accept()
47 | if err != nil {
48 | fmt.Printf("Unable to accept connection %s", err.Error())
49 | }
50 |
51 | rtmpConnection := &Connection{
52 | Conn: conn,
53 | Reader: bufio.NewReader(conn),
54 | Writer: bufio.NewWriter(conn),
55 | ReadBuffer: make([]byte, 5096),
56 | WriteBuffer: make([]byte, 5096),
57 | csMap: make(map[uint32]*rtmpChunk),
58 | ReadMaxChunkSize: 128,
59 | WriteMaxChunkSize: 4096,
60 | Stage: handshakeStage,
61 | Context: ctx,
62 | }
63 |
64 | go rtmpConnection.Serve()
65 | }
66 | }
67 |
--------------------------------------------------------------------------------
/frontend/src/components/LandingPage/LandingPage.tsx:
--------------------------------------------------------------------------------
1 | import AppBar from '@material-ui/core/AppBar';
2 | import Paper from '@material-ui/core/Paper';
3 | import {
4 | createStyles,
5 | Theme,
6 | withStyles,
7 | WithStyles
8 | } from '@material-ui/core/styles';
9 | import Tab from '@material-ui/core/Tab';
10 | import Tabs from '@material-ui/core/Tabs';
11 | import React from 'react';
12 | import LoginFormContainer from '../../containers/LoginFormContainer';
13 | import RegisterFormContainer from '../../containers/RegisterFormContainer';
14 |
15 | const styles = (theme: Theme) =>
16 | createStyles({
17 | form: {
18 | height: '90%',
19 | display: 'flex',
20 | justifyContent: 'center',
21 | alignItems: 'center'
22 | },
23 | paper: {
24 | width: 350,
25 | padding: 0
26 | },
27 | content: {
28 | padding: theme.spacing.unit * 2
29 | }
30 | });
31 |
32 | class LandingPage extends React.Component> {
33 | state = {
34 | value: 0
35 | };
36 |
37 | handleChange = (event, value) => {
38 | this.setState({ value });
39 | };
40 |
41 | render() {
42 | const { classes } = this.props;
43 | const { value } = this.state;
44 |
45 | return (
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 | {value === 0 && }
56 | {value === 1 && }
57 |
58 |
59 |
60 | );
61 | }
62 | }
63 |
64 | export default withStyles(styles)(LandingPage);
65 |
--------------------------------------------------------------------------------
/frontend/src/reducers/authReducer.ts:
--------------------------------------------------------------------------------
1 | import { produce } from 'immer';
2 | import Cookie from 'js-cookie';
3 |
4 | // Types
5 | import {
6 | AuthenticationAction,
7 | AuthenticationActionTypes
8 | } from '../actions/authenticationActions';
9 |
10 | const token = localStorage.getItem('token') || '';
11 |
12 | export const initialAuthenticationState: IAuthenticationState = {
13 | isAuthenticated: false,
14 | loading: token ? true : false,
15 | loaded: false,
16 | token,
17 | user: {}
18 | };
19 |
20 | export default function authenticationReducer(
21 | state: IAuthenticationState = initialAuthenticationState,
22 | action: AuthenticationAction
23 | ): IAuthenticationState {
24 | return produce(state, draft => {
25 | switch (action.type) {
26 | case AuthenticationActionTypes.AUTHENTICATE_TOKEN:
27 | localStorage.setItem('token', action.payload.token);
28 | Cookie.set('token', action.payload.token);
29 | draft.token = action.payload.token;
30 | break;
31 |
32 | case AuthenticationActionTypes.AUTHENTICATING_TOKEN:
33 | draft.loading = true;
34 | break;
35 |
36 | case AuthenticationActionTypes.AUTHENTICATED_TOKEN:
37 | draft.isAuthenticated = true;
38 | draft.loading = false;
39 | draft.loaded = true;
40 | draft.token = action.payload.token;
41 | draft.user = action.payload.user;
42 | break;
43 |
44 | case AuthenticationActionTypes.LOGOUT:
45 | localStorage.removeItem('token');
46 | Cookie.remove('token');
47 | draft.isAuthenticated = false;
48 | draft.user = {};
49 | draft.token = '';
50 | break;
51 |
52 | default:
53 | return state;
54 | }
55 | });
56 | }
57 |
58 | export interface IAuthenticationState {
59 | isAuthenticated: boolean;
60 | loading: boolean;
61 | loaded: boolean;
62 | token: string;
63 | user: any;
64 | }
65 |
--------------------------------------------------------------------------------
/frontend/src/epics/streamEpics.ts:
--------------------------------------------------------------------------------
1 | import { Action } from 'redux';
2 | import { ActionsObservable, combineEpics, Epic } from 'redux-observable';
3 | import { from, of } from 'rxjs';
4 | import axios from '../axios';
5 |
6 | import { showSnackbar } from '../actions/snackbarActions';
7 | import {
8 | createStreamFailure,
9 | createStreamSuccess,
10 | deleteStreamFailure,
11 | deleteStreamSuccess,
12 | ICreateStreamAction,
13 | IDeleteStreamAction,
14 | ILoadStreamsAction,
15 | loadStreams,
16 | loadStreamsFailure,
17 | loadStreamsSuccess,
18 | StreamAction,
19 | StreamActionTypes
20 | } from '../actions/streamActions';
21 |
22 | export const loadStreamsEpic: Epic = action$ =>
23 | action$
24 | .ofType(StreamActionTypes.LOAD_STREAMS)
25 | .mergeMap((action: ILoadStreamsAction) =>
26 | from(axios.get('/streams/list'))
27 | .mergeMap(response => [loadStreamsSuccess(response.data.data)])
28 | .catch(() => of(loadStreamsFailure()))
29 | );
30 |
31 | export const createStreamEpic: Epic = action$ =>
32 | action$
33 | .ofType(StreamActionTypes.CREATE_STREAM)
34 | .mergeMap((action: ICreateStreamAction) =>
35 | from(axios.post('/streams/add', action.payload))
36 | .mergeMap(response => [loadStreams(), createStreamSuccess()])
37 | .catch(() => of(createStreamFailure()))
38 | );
39 |
40 | export const deleteStreamEpic = (action$: ActionsObservable) =>
41 | action$
42 | .ofType(StreamActionTypes.DELETE_STREAM)
43 | .mergeMap((action: IDeleteStreamAction) =>
44 | from(axios.delete(`/streams/delete?streamId=${action.payload.streamId}`))
45 | .mergeMap(response => [deleteStreamSuccess(action.payload.streamId)])
46 | .catch(() => of(deleteStreamFailure(action.payload.streamId)))
47 | );
48 |
49 | export default combineEpics(
50 | loadStreamsEpic,
51 | createStreamEpic,
52 | deleteStreamEpic
53 | );
54 |
--------------------------------------------------------------------------------
/rtmp/handshake.go:
--------------------------------------------------------------------------------
1 | package rtmp
2 |
3 | import (
4 | "encoding/binary"
5 | "fmt"
6 | "io"
7 | )
8 |
9 | /*
10 | handshake will first read C0(1 byte) and C1(1536 bytes) together
11 |
12 | It will then generate S0(1 byte, same as C0), S1(1536 bytes) and S2(1536 bytes)
13 | and send it to the RTMP client.
14 |
15 | It will wait till it gets C2, which should be same as S1
16 | */
17 | func (c *Connection) handshake() (err error) {
18 | var random [(1 + 1536 + 1536) * 2]byte
19 |
20 | var C0C1C2 = random[:(1 + 1536 + 1536)]
21 | var C0 = C0C1C2[:1]
22 | var C1 = C0C1C2[1 : 1536+1]
23 | var C0C1 = C0C1C2[:1536+1]
24 | var C2 = C0C1C2[1+1536:]
25 |
26 | var S0S1S2 = random[(1 + 1536 + 1536):]
27 | var S0 = S0S1S2[:1]
28 | var S1 = S0S1S2[1 : 1536+1]
29 | var S2 = S0S1S2[1+1536:]
30 |
31 | _, err = io.ReadFull(c.Reader, C0C1)
32 | if err != nil {
33 | fmt.Printf("Error while reading C0C1 %s", err.Error())
34 | return
35 | }
36 |
37 | if C0[0] != 3 {
38 | fmt.Printf("Unsupported RTMP version %d", C0[0])
39 | return
40 | }
41 | fmt.Printf("Client requesting version %d\n", C0[0])
42 |
43 | clientTime := binary.BigEndian.Uint32(C1[:4])
44 | // serverTime := clientTime
45 | clientVersion := binary.BigEndian.Uint32(C1[4:8])
46 | // serverVersion := uint32(0x0d0e0a0d)
47 |
48 | fmt.Println("Time", clientTime)
49 | fmt.Println("Flash Version", binary.BigEndian.Uint32(C1[4:8]))
50 |
51 | // S0[0] = 3
52 | if clientVersion != 0 {
53 | // Complex handshake
54 | } else {
55 | copy(S0, C0)
56 | copy(S1, C1)
57 | copy(S2, C2)
58 | }
59 |
60 | _, err = c.Writer.Write(S0S1S2)
61 | if err != nil {
62 | fmt.Printf("Error writing S0S1S2 %s\n", err.Error())
63 | return
64 | }
65 | if err = c.Writer.Flush(); err != nil {
66 | fmt.Println("Error flushing S0S1S2")
67 | }
68 |
69 | if _, err = io.ReadFull(c.Reader, C2); err != nil {
70 | fmt.Printf("Error reading C2 %s", err.Error())
71 | return
72 | }
73 |
74 | c.Stage++
75 | return
76 | }
77 |
--------------------------------------------------------------------------------
/frontend/src/reducers/index.ts:
--------------------------------------------------------------------------------
1 | import { connectRouter, LocationChangeAction } from 'connected-react-router';
2 | import { createBrowserHistory, LocationState } from 'history';
3 | import { Reducer } from 'react';
4 | import { combineReducers } from 'redux';
5 | import authenticationReducer, {
6 | IAuthenticationState,
7 | initialAuthenticationState
8 | } from './authReducer';
9 | import channelReducer, {
10 | IChannelState,
11 | initialChannelState
12 | } from './channelReducer';
13 | import headerReducer, {
14 | IHeaderState,
15 | initialHeaderState
16 | } from './headerReducer';
17 | import snackbarReducer, {
18 | initialSnackbarState,
19 | ISnackbarState
20 | } from './snackbarReducer';
21 | import socketReducer, {
22 | initialSocketState,
23 | ISocketState
24 | } from './socketReducer';
25 | import statsReducer, { initialStatsState, IStatsState } from './statsReducer';
26 | import streamReducer, {
27 | initialStreamState,
28 | IStreamState
29 | } from './streamReducer';
30 | import themeReducer, { initialThemeState, IThemeState } from './themeReducer';
31 |
32 | export const history = createBrowserHistory();
33 |
34 | export interface IState {
35 | theme: IThemeState;
36 | auth: IAuthenticationState;
37 | router: Reducer;
38 | channels: IChannelState;
39 | streams: IStreamState;
40 | socket: ISocketState;
41 | header: IHeaderState;
42 | stats: IStatsState;
43 | snackbar: ISnackbarState;
44 | }
45 |
46 | export const initialState = {
47 | theme: initialThemeState,
48 | auth: initialAuthenticationState,
49 | channels: initialChannelState,
50 | streams: initialStreamState,
51 | socket: initialSocketState,
52 | header: initialHeaderState,
53 | stats: initialStatsState,
54 | snackbar: initialSnackbarState
55 | };
56 |
57 | export default combineReducers({
58 | theme: themeReducer,
59 | auth: authenticationReducer,
60 | router: connectRouter(history),
61 | channels: channelReducer,
62 | streams: streamReducer,
63 | socket: socketReducer,
64 | header: headerReducer,
65 | stats: statsReducer,
66 | snackbar: snackbarReducer
67 | });
68 |
--------------------------------------------------------------------------------
/frontend/src/epics/channelEpics.ts:
--------------------------------------------------------------------------------
1 | import { Action } from 'redux';
2 | import { combineEpics, Epic } from 'redux-observable';
3 | import { from, of } from 'rxjs';
4 | import axios from '../axios';
5 |
6 | import {
7 | addedChannel,
8 | addingChannel,
9 | ChannelActionTypes,
10 | deleteChannelFailure,
11 | deleteChannelSuccess,
12 | fetchChannelList,
13 | fetchedChannelList,
14 | fetchingChannelList,
15 | IAddChannelAction,
16 | IDeleteChannelAction,
17 | IFetchChannelListAction
18 | } from '../actions/channelActions';
19 |
20 | export const addChannelEpic: Epic = action$ =>
21 | action$
22 | .ofType(ChannelActionTypes.ADD_CHANNEL)
23 | .mergeMap((action: IAddChannelAction) =>
24 | from(axios.post('/channels/add', action.payload))
25 | .mergeMap(() => [addedChannel()])
26 | .startWith(addingChannel())
27 | );
28 |
29 | export const addedChannelEpic: Epic = action$ =>
30 | action$.ofType(ChannelActionTypes.ADDED_CHANNEL).mapTo(fetchChannelList('1'));
31 |
32 | export const fetchChannelEpic: Epic = action$ =>
33 | action$
34 | .ofType(ChannelActionTypes.FETCH_CHANNEL_LIST)
35 | .mergeMap((action: IFetchChannelListAction) =>
36 | from(axios.get(`/channels/list?streamId=${action.payload.streamId}`))
37 | .mergeMap(response => [fetchedChannelList(response.data.data)])
38 | .startWith(fetchingChannelList())
39 | );
40 |
41 | export const deleteChannelEpic: Epic = action$ =>
42 | action$
43 | .ofType(ChannelActionTypes.DELETE_CHANNEL)
44 | .mergeMap((action: IDeleteChannelAction) =>
45 | from(
46 | axios.delete(
47 | `/channels/delete?streamId=${action.payload.streamId}&channelId=${
48 | action.payload.channelId
49 | }`
50 | )
51 | )
52 | .mergeMap(response => [
53 | deleteChannelSuccess(
54 | action.payload.streamId,
55 | action.payload.channelId
56 | )
57 | ])
58 | .catch(() => of(deleteChannelFailure()))
59 | );
60 |
61 | export default combineEpics(
62 | addChannelEpic,
63 | addedChannelEpic,
64 | fetchChannelEpic,
65 | deleteChannelEpic
66 | );
67 |
--------------------------------------------------------------------------------
/controllers/stream.go:
--------------------------------------------------------------------------------
1 | package controllers
2 |
3 | import (
4 | "encoding/json"
5 | "net/http"
6 | "strconv"
7 |
8 | "github.com/google/uuid"
9 |
10 | "github.com/praveen001/go-rtmp-web-server/models"
11 | )
12 |
13 | // AddStream creates a new stream for an user
14 | func (c *ApplicationContext) AddStream(w http.ResponseWriter, r *http.Request) {
15 | stream := &models.Stream{}
16 | if err := json.NewDecoder(r.Body).Decode(stream); err != nil {
17 | c.NewResponse(w).Status(400).SendJSON(Response{
18 | Error: true,
19 | Message: "Unable to parse request",
20 | })
21 | return
22 | }
23 |
24 | stream.Key = uuid.New().String()
25 | stream.UserID = int(r.Context().Value("id").(float64))
26 |
27 | c.DB.Save(stream)
28 |
29 | c.NewResponse(w).Status(200).SendJSON(Response{
30 | Message: "Stream created successfully",
31 | })
32 | }
33 |
34 | // ListStreams gives the list of stream that belongs to the user
35 | func (c *ApplicationContext) ListStreams(w http.ResponseWriter, r *http.Request) {
36 | var streams []models.Stream
37 | userID := int(r.Context().Value("id").(float64))
38 |
39 | c.DB.Find(&streams, models.Stream{UserID: userID})
40 |
41 | c.NewResponse(w).Status(200).SendJSON(Response{
42 | Message: "Retrieved stream list successfully",
43 | Data: streams,
44 | })
45 | }
46 |
47 | // DeleteStream will remove a stream by ID
48 | func (c *ApplicationContext) DeleteStream(w http.ResponseWriter, r *http.Request) {
49 | streamID, err := strconv.Atoi(r.FormValue("streamId"))
50 | if err != nil {
51 | c.NewResponse(w).Status(400).SendJSON(Response{
52 | Error: true,
53 | Message: "Invalid stream ID",
54 | })
55 | return
56 | }
57 |
58 | // Access check
59 | stream := &models.Stream{
60 | Model: models.Model{
61 | ID: streamID,
62 | },
63 | }
64 | c.DB.Find(&stream, stream)
65 | if stream.UserID != int(r.Context().Value("id").(float64)) {
66 | c.NewResponse(w).Status(403).SendJSON(Response{
67 | Error: true,
68 | Message: "Unauthorized",
69 | })
70 | return
71 | }
72 |
73 | c.DB.Where("id = ?", streamID).Delete(models.Stream{})
74 |
75 | c.NewResponse(w).Status(200).SendJSON(Response{
76 | Message: "Deleted stream successfully",
77 | })
78 | }
79 |
--------------------------------------------------------------------------------
/frontend/src/epics/socketEpics.ts:
--------------------------------------------------------------------------------
1 | import { Action } from 'redux';
2 | import { ActionsObservable, combineEpics } from 'redux-observable';
3 | import { of } from 'rxjs';
4 | import { Subject } from 'rxjs/Subject';
5 | import { webSocket, WebSocketSubject } from 'rxjs/webSocket';
6 | import {
7 | connect,
8 | connected,
9 | disconnected,
10 | IConnectAction,
11 | ISendMessageAction,
12 | receiveMessage,
13 | sentMessage,
14 | SocketActionTypes
15 | } from '../actions/socketActions';
16 |
17 | let webSocketSubject: WebSocketSubject;
18 | let onOpenSubject = new Subject();
19 | let onCloseSubject = new Subject();
20 |
21 | const connectSocket = websocketUrl => {
22 | onOpenSubject = new Subject();
23 | onCloseSubject = new Subject();
24 | webSocketSubject = webSocket({
25 | url: websocketUrl,
26 | openObserver: onOpenSubject,
27 | closeObserver: onCloseSubject
28 | });
29 | return webSocketSubject;
30 | };
31 |
32 | const connectEpic = (action$: ActionsObservable) =>
33 | action$
34 | .ofType(SocketActionTypes.CONNECT)
35 | .switchMap((action: IConnectAction) =>
36 | connectSocket(action.payload.url)
37 | .map(data => {
38 | const { type, ...payload } = data;
39 | return {
40 | type,
41 | payload
42 | };
43 | })
44 | .catch(() =>
45 | of(connect(action.payload.url))
46 | .delay(5000)
47 | .startWith(disconnected())
48 | )
49 | );
50 |
51 | const connectedEpic = (action$: ActionsObservable) =>
52 | action$
53 | .ofType(SocketActionTypes.CONNECT)
54 | .switchMap(() => onOpenSubject.mapTo(connected()));
55 |
56 | const sendMessageEpic = (action$: ActionsObservable) =>
57 | action$
58 | .ofType(SocketActionTypes.SEND_MESSAGE)
59 | .map((action: ISendMessageAction) => {
60 | webSocketSubject.next(action.payload.msg);
61 | return sentMessage();
62 | });
63 |
64 | const disconnectEpic = (action$: ActionsObservable) =>
65 | action$.ofType(SocketActionTypes.DISCONNECT).map(() => {
66 | webSocketSubject.complete();
67 | return disconnected();
68 | });
69 |
70 | export default combineEpics(
71 | connectEpic,
72 | connectedEpic,
73 | sendMessageEpic,
74 | disconnectEpic
75 | );
76 |
--------------------------------------------------------------------------------
/frontend/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "ui",
3 | "version": "1.0.0",
4 | "main": "index.js",
5 | "license": "MIT",
6 | "scripts": {
7 | "format": "prettier --write packages/**/src/**/*.{ts,tsx,js,jsx,json}",
8 | "lint": "tslint 'packages/**/src/**/*.{ts,tsx}'",
9 | "lint-fix": "tslint --fix 'packages/**/src/**/*.{ts,tsx}'",
10 | "start": "webpack-dev-server --host 0.0.0.0",
11 | "build": "webpack --config webpack.prod.js"
12 | },
13 | "husky": {
14 | "hooks": {
15 | "pre-commit": "lint-staged"
16 | }
17 | },
18 | "lint-staged": {
19 | "*.{json}": [
20 | "prettier --write"
21 | ],
22 | "*.{ts,tsx}": [
23 | "prettier --write",
24 | "tslint --fix",
25 | "git add"
26 | ]
27 | },
28 | "dependencies": {
29 | "@material-ui/core": "^3.6.1",
30 | "@material-ui/icons": "^3.0.1",
31 | "axios": "^0.18.0",
32 | "classnames": "^2.2.6",
33 | "connected-react-router": "^6.0.0",
34 | "css-loader": "^2.1.0",
35 | "immer": "^1.8.0",
36 | "js-cookie": "^2.2.0",
37 | "path-to-regexp": "^2.4.0",
38 | "react": "^16.13.1",
39 | "react-dom": "^16.13.1",
40 | "react-redux": "^7.1.9",
41 | "react-router": "^5.2.0",
42 | "react-router-dom": "^5.2.0",
43 | "redux": "^4.0.1",
44 | "redux-observable": "^1.0.0",
45 | "rxjs": "^6.3.3",
46 | "rxjs-compat": "^6.3.3",
47 | "style-loader": "^0.23.1",
48 | "tslint-config-prettier": "^1.17.0",
49 | "tslint-config-standard": "^8.0.1",
50 | "tslint-react": "^3.6.0",
51 | "videojs": "^1.0.0",
52 | "videojs-contrib-hls": "^5.15.0"
53 | },
54 | "devDependencies": {
55 | "@types/react": "^16.9.49",
56 | "@types/react-dom": "^16.9.8",
57 | "@types/react-redux": "^7.1.9",
58 | "@types/react-router": "^5.1.8",
59 | "@types/react-router-dom": "5.1.5",
60 | "awesome-typescript-loader": "^5.2.1",
61 | "file-loader": "^3.0.1",
62 | "history": "^4.7.2",
63 | "html-webpack-plugin": "^3.2.0",
64 | "husky": "^1.2.0",
65 | "lint-staged": "^8.1.0",
66 | "prettier": "^1.15.3",
67 | "redux-devtools-extension": "^2.13.7",
68 | "tslint": "^5.11.0",
69 | "typescript": "4.0.3",
70 | "webpack": "^4.26.1",
71 | "webpack-bundle-analyzer": "^3.0.3",
72 | "webpack-cli": "^3.1.2",
73 | "webpack-dev-server": "^3.1.10"
74 | }
75 | }
76 |
--------------------------------------------------------------------------------
/frontend/src/components/Authentication/Authentication.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | createStyles,
3 | Theme,
4 | WithStyles,
5 | withStyles
6 | } from '@material-ui/core/styles';
7 | import React from 'react';
8 | import { Route, RouteComponentProps, Switch } from 'react-router';
9 | import DashboardContainer from '../../containers/DashboardContainer';
10 | import HeaderContainer from '../../containers/HeaderContainer';
11 | import StreamsContainer from '../../containers/StreamsContainer';
12 | import WebSocket from '../Socket/Socket';
13 | import StreamsLoader from '../Streams/StreamsLoader';
14 |
15 | const styles = (theme: Theme) =>
16 | createStyles({
17 | content: {
18 | paddingTop: 60
19 | }
20 | });
21 |
22 | class Authentications extends React.Component {
23 | componentDidUpdate() {
24 | if (!this.props.loading && !this.props.isAuthenticated) {
25 | this.props.history.replace('/get-started');
26 | }
27 | }
28 |
29 | componentWillMount = () => {
30 | this.init();
31 | };
32 |
33 | init = () => {
34 | const token = this.props.token;
35 | if (token) {
36 | this.props.authenticateUser(token);
37 | }
38 | };
39 |
40 | render = () => {
41 | const { classes } = this.props;
42 |
43 | if (!this.props.isAuthenticated) {
44 | return null;
45 | }
46 |
47 | return (
48 |
49 |
50 |
51 |
52 |
53 |
54 |
58 |
59 |
60 |
61 |
62 |
63 |
64 | );
65 | };
66 | }
67 |
68 | export default withStyles(styles)(Authentications);
69 |
70 | export interface IStateProps {
71 | isAuthenticated: boolean;
72 | token: string;
73 | loading: boolean;
74 | loaded: boolean;
75 | }
76 |
77 | export interface IDispatchProps {
78 | authenticateUser: (token: string) => void;
79 | }
80 |
81 | export type AuthenticationProps = IStateProps &
82 | IDispatchProps &
83 | RouteComponentProps<{}> &
84 | WithStyles;
85 |
--------------------------------------------------------------------------------
/frontend/src/actions/socketActions.ts:
--------------------------------------------------------------------------------
1 | export enum SocketActionTypes {
2 | CONNECT = 'socket/CONNECT',
3 | CONNECTED = 'socket/CONNECTED',
4 | RECEIVE_MESSAGE = 'socket/RECEIVE_MESSAGE',
5 | SEND_MESSAGE = 'socket/SEND_MESSAGE',
6 | SENT_MESSAGE = 'socket/SENT_MESSAGE',
7 | DISCONNECT = 'socket/DISCONNECT',
8 | DISCONNECTED = 'socket/DISCONNECTED'
9 | }
10 |
11 | export function connect(url: string): IConnectAction {
12 | return {
13 | type: SocketActionTypes.CONNECT,
14 | payload: {
15 | url
16 | }
17 | };
18 | }
19 |
20 | export function connected(): IConnectedAction {
21 | return { type: SocketActionTypes.CONNECTED };
22 | }
23 |
24 | export function receiveMessage(msg: any): IReceiveMessageAction {
25 | if (msg.type) {
26 | return msg;
27 | }
28 | return { type: SocketActionTypes.RECEIVE_MESSAGE, payload: msg };
29 | }
30 |
31 | export function sendMessage(msg: any): ISendMessageAction {
32 | return { type: SocketActionTypes.SEND_MESSAGE, payload: { msg } };
33 | }
34 |
35 | export function sentMessage(): ISentMessageAction {
36 | return { type: SocketActionTypes.SENT_MESSAGE };
37 | }
38 |
39 | export function disconnect(): IDisconnectAction {
40 | return { type: SocketActionTypes.DISCONNECT };
41 | }
42 |
43 | export function disconnected(): IDisconnectedAction {
44 | return { type: SocketActionTypes.DISCONNECTED };
45 | }
46 |
47 | export interface IConnectAction {
48 | type: SocketActionTypes.CONNECT;
49 | payload: {
50 | url: string;
51 | };
52 | }
53 |
54 | export interface IConnectedAction {
55 | type: SocketActionTypes.CONNECTED;
56 | }
57 |
58 | export interface IReceiveMessageAction {
59 | type: SocketActionTypes.RECEIVE_MESSAGE;
60 | payload: {
61 | msg: any;
62 | };
63 | }
64 |
65 | export interface ISendMessageAction {
66 | type: SocketActionTypes.SEND_MESSAGE;
67 | payload: {
68 | msg: any;
69 | };
70 | }
71 |
72 | export interface ISentMessageAction {
73 | type: SocketActionTypes.SENT_MESSAGE;
74 | }
75 |
76 | export interface IDisconnectAction {
77 | type: SocketActionTypes.DISCONNECT;
78 | }
79 |
80 | export interface IDisconnectedAction {
81 | type: SocketActionTypes.DISCONNECTED;
82 | }
83 |
84 | export type SocketAction =
85 | | IConnectAction
86 | | IConnectedAction
87 | | IReceiveMessageAction
88 | | ISendMessageAction
89 | | IDisconnectAction
90 | | IDisconnectedAction;
91 |
--------------------------------------------------------------------------------
/frontend/src/epics/authEpics.ts:
--------------------------------------------------------------------------------
1 | import { Action } from 'redux';
2 | import { combineEpics, Epic } from 'redux-observable';
3 | import { from, of } from 'rxjs';
4 | import axios from '../axios';
5 |
6 | import { push } from 'connected-react-router';
7 | import { mapTo } from 'rxjs-compat/operator/mapTo';
8 | import {
9 | authenticatedUser,
10 | authenticatingUser,
11 | AuthenticationActionTypes,
12 | IAuthenticatedUserAction,
13 | IAuthenticateUserAction,
14 | ILoginAction,
15 | IRegisterAction
16 | } from '../actions/authenticationActions';
17 |
18 | export const registerEpic: Epic = action$ =>
19 | action$
20 | .ofType(AuthenticationActionTypes.REGISTER)
21 | .mergeMap((action: IRegisterAction) =>
22 | from(axios.post('/users/register', action.payload))
23 | .mergeMap(response => [
24 | authenticatedUser(
25 | response.data.data.token,
26 | response.data.data.user,
27 | true
28 | )
29 | ])
30 | .startWith(authenticatingUser())
31 | );
32 |
33 | export const loginEpic: Epic = action$ =>
34 | action$
35 | .ofType(AuthenticationActionTypes.LOGIN)
36 | .mergeMap((action: ILoginAction) =>
37 | from(axios.post('/users/login', action.payload))
38 | .mergeMap(response => [
39 | authenticatedUser(
40 | response.data.data.token,
41 | response.data.data.user,
42 | true
43 | )
44 | ])
45 | .startWith(authenticatingUser())
46 | );
47 |
48 | export const authenticatedUserEpic: Epic = action$ =>
49 | action$
50 | .ofType(AuthenticationActionTypes.AUTHENTICATED_TOKEN)
51 | .mergeMap((action: IAuthenticatedUserAction) => {
52 | if (!action.payload.shouldRedirect) {
53 | return of({ type: 'noop' });
54 | }
55 | return of(push('/streams'));
56 | });
57 |
58 | export const authenticateUserEpic: Epic = action$ =>
59 | action$
60 | .ofType(AuthenticationActionTypes.AUTHENTICATE_TOKEN)
61 | .mergeMap((action: IAuthenticateUserAction) =>
62 | from(axios.get(`/users/tokeninfo?token=${action.payload.token}`))
63 | .map(response =>
64 | authenticatedUser(action.payload.token, response.data.data, false)
65 | )
66 | .startWith(authenticatingUser())
67 | );
68 |
69 | export default combineEpics(
70 | registerEpic,
71 | loginEpic,
72 | authenticatedUserEpic,
73 | authenticateUserEpic
74 | );
75 |
--------------------------------------------------------------------------------
/frontend/src/components/Snackbar/Snackbar.tsx:
--------------------------------------------------------------------------------
1 | import Button from '@material-ui/core/Button';
2 | import IconButton from '@material-ui/core/IconButton';
3 | import Snackbar from '@material-ui/core/Snackbar';
4 | import SnackbarContent from '@material-ui/core/SnackbarContent';
5 | import {
6 | createStyles,
7 | Theme,
8 | withStyles,
9 | WithStyles
10 | } from '@material-ui/core/styles';
11 | import CloseIcon from '@material-ui/icons/Close';
12 | import classNames from 'classnames';
13 | import React from 'react';
14 | import { IToast } from '../../reducers/snackbarReducer';
15 |
16 | const styles = (theme: Theme) =>
17 | createStyles({
18 | closeButton: {
19 | padding: theme.spacing.unit / 2
20 | },
21 | error: {
22 | background: 'red'
23 | }
24 | });
25 |
26 | class Toast extends React.Component {
27 | hideSnackbar = (id: number) => e => {
28 | this.props.hideSnackbar(id);
29 | };
30 |
31 | getActions = (toast: IToast) => {
32 | const { classes } = this.props;
33 |
34 | return (
35 |
36 |
41 |
42 |
43 |
44 | );
45 | };
46 |
47 | render() {
48 | const { classes } = this.props;
49 |
50 | return (
51 |
52 | {this.props.toasts.map(toast => (
53 |
67 | {toast.message}
}
70 | action={this.getActions(toast)}
71 | />
72 |
73 | ))}
74 |
75 | );
76 | }
77 | }
78 |
79 | export interface IToastStateProps {
80 | toasts: IToast[];
81 | }
82 |
83 | export interface IToastDispatchProps {
84 | hideSnackbar: (id: number) => void;
85 | }
86 |
87 | type ToastProps = IToastStateProps &
88 | IToastDispatchProps &
89 | WithStyles;
90 |
91 | export default withStyles(styles)(Toast);
92 |
--------------------------------------------------------------------------------
/frontend/src/epics/snackbarEpics.ts:
--------------------------------------------------------------------------------
1 | import { ActionsObservable, combineEpics, Epic } from 'redux-observable';
2 | import { from, of } from 'rxjs';
3 | import {
4 | ChannelAction,
5 | ChannelActionTypes,
6 | IDeleteChannelFailureAction,
7 | IDeleteChannelSuccessAction
8 | } from '../actions/channelActions';
9 | import { showSnackbar } from '../actions/snackbarActions';
10 | import {
11 | ICreateStreamFailureAction,
12 | ICreateStreamSuccessAction,
13 | IDeleteStreamFailureAction,
14 | IDeleteStreamSuccessAction,
15 | StreamAction,
16 | StreamActionTypes
17 | } from '../actions/streamActions';
18 |
19 | export const createStreamSuccessEpic = (
20 | action$: ActionsObservable
21 | ) =>
22 | action$
23 | .ofType(StreamActionTypes.CREATE_SUCCESS_SUCCESS)
24 | .map((action: ICreateStreamSuccessAction) =>
25 | showSnackbar('Stream created successfully', false)
26 | );
27 |
28 | export const createStreamFailureEpic = (
29 | action$: ActionsObservable
30 | ) =>
31 | action$
32 | .ofType(StreamActionTypes.CREATE_STREAM_FAILURE)
33 | .map((action: ICreateStreamFailureAction) =>
34 | showSnackbar('Unable to create stream', true)
35 | );
36 |
37 | export const deleteStreamSuccessEpic = (
38 | action$: ActionsObservable
39 | ) =>
40 | action$
41 | .ofType(StreamActionTypes.DELETE_STREAM_SUCCESS)
42 | .map((action: IDeleteStreamSuccessAction) =>
43 | showSnackbar('Stream deleted successfully', false)
44 | );
45 |
46 | export const deleteStreamFailureEpic = (
47 | action$: ActionsObservable
48 | ) =>
49 | action$
50 | .ofType(StreamActionTypes.DELETE_STREAM_FAILURE)
51 | .map((action: IDeleteStreamFailureAction) =>
52 | showSnackbar('Unable to delete stream', true)
53 | );
54 |
55 | export const deleteChannelSuccessEpic = (
56 | action$: ActionsObservable
57 | ) =>
58 | action$
59 | .ofType(ChannelActionTypes.DELETE_CHANNEL_SUCCESS)
60 | .map((action: IDeleteChannelSuccessAction) =>
61 | showSnackbar('Deleted channel successfully', false)
62 | );
63 |
64 | export const deleteChannelFailureEpic = (
65 | action$: ActionsObservable
66 | ) =>
67 | action$
68 | .ofType(ChannelActionTypes.DELETE_CHANNEL_FAILURE)
69 | .map((action: IDeleteChannelFailureAction) =>
70 | showSnackbar('Unable to delete channel', true)
71 | );
72 |
73 | export default combineEpics(
74 | createStreamSuccessEpic,
75 | createStreamFailureEpic,
76 | deleteStreamSuccessEpic,
77 | deleteStreamFailureEpic,
78 | deleteChannelSuccessEpic,
79 | deleteChannelFailureEpic
80 | );
81 |
--------------------------------------------------------------------------------
/frontend/src/components/LoginForm/LoginForm.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | // Material-UI Components
4 | import Button from '@material-ui/core/Button';
5 | import Checkbox from '@material-ui/core/Checkbox';
6 | import FormControlLabel from '@material-ui/core/FormControlLabel';
7 | import Paper from '@material-ui/core/Paper';
8 | import {
9 | createStyles,
10 | Theme,
11 | withStyles,
12 | WithStyles
13 | } from '@material-ui/core/styles';
14 | import TextField from '@material-ui/core/TextField';
15 | import Typography from '@material-ui/core/Typography';
16 | import { Link } from 'react-router-dom';
17 |
18 | const styles = (theme: Theme) =>
19 | createStyles({
20 | row: {
21 | marginTop: 25,
22 | display: 'flex',
23 | justifyContent: 'space-between'
24 | }
25 | });
26 |
27 | class LoginForm extends React.Component {
28 | state = {
29 | email: '',
30 | password: '',
31 | rememberMe: false
32 | };
33 |
34 | changeEmail = (e: React.ChangeEvent) => {
35 | this.setState({ email: e.target.value });
36 | };
37 |
38 | changePassword = (e: React.ChangeEvent) => {
39 | this.setState({ password: e.target.value });
40 | };
41 |
42 | changeRememberMe = (e: React.ChangeEvent) => {
43 | this.setState({ rememberMe: e.target.checked });
44 | };
45 |
46 | login = () => {
47 | const { email, password, rememberMe } = this.state;
48 | this.props.login(email, password, rememberMe);
49 | };
50 |
51 | render() {
52 | const { classes } = this.props;
53 | const { email, password, rememberMe } = this.state;
54 |
55 | return (
56 | <>
57 |
58 |
64 |
65 |
66 |
67 |
74 |
75 |
76 |
77 |
78 |
81 |
82 |
83 | >
84 | );
85 | }
86 | }
87 |
88 | export default withStyles(styles)(LoginForm);
89 |
90 | export interface IDispatchProps {
91 | login: (email: string, password: string, shouldRemember: boolean) => void;
92 | }
93 |
94 | export type LoginFormProps = IDispatchProps & WithStyles;
95 |
--------------------------------------------------------------------------------
/frontend/src/reducers/streamReducer.ts:
--------------------------------------------------------------------------------
1 | import { produce } from 'immer';
2 | import { StreamAction, StreamActionTypes } from '../actions/streamActions';
3 | import { ApiStatus, IStream } from '../models';
4 |
5 | export const initialStreamState: IStreamState = {
6 | byId: {},
7 | ids: [],
8 | loadingStatus: ApiStatus.IN_PROGRESS,
9 | open: false,
10 | addingStatus: ApiStatus.SUCCESS,
11 | streamId: -1
12 | };
13 |
14 | export default function streamReducer(
15 | state = initialStreamState,
16 | action: StreamAction
17 | ) {
18 | return produce(state, draft => {
19 | switch (action.type) {
20 | case StreamActionTypes.LOAD_STREAMS:
21 | draft.loadingStatus = ApiStatus.IN_PROGRESS;
22 | draft.byId = {};
23 | draft.ids = [];
24 | break;
25 |
26 | case StreamActionTypes.LOAD_STREAMS_FAILURE:
27 | draft.loadingStatus = ApiStatus.FAILURE;
28 | break;
29 |
30 | case StreamActionTypes.LOAD_STREAMS_SUCCESS:
31 | draft.loadingStatus = ApiStatus.SUCCESS;
32 | action.payload.streams.forEach(stream => {
33 | draft.byId[stream.id] = stream;
34 | draft.ids.push(stream.id);
35 | });
36 | break;
37 |
38 | case StreamActionTypes.OPEN_NEW_STREAM_DIALOG:
39 | draft.open = true;
40 | break;
41 |
42 | case StreamActionTypes.CLOSE_NEW_STREAM_DIALOG:
43 | draft.open = false;
44 | break;
45 |
46 | case StreamActionTypes.CREATE_STREAM:
47 | draft.addingStatus = ApiStatus.IN_PROGRESS;
48 | break;
49 |
50 | case StreamActionTypes.CREATE_STREAM_FAILURE:
51 | draft.addingStatus = ApiStatus.FAILURE;
52 | break;
53 |
54 | case StreamActionTypes.CREATE_SUCCESS_SUCCESS:
55 | draft.addingStatus = ApiStatus.SUCCESS;
56 | draft.open = false;
57 | break;
58 |
59 | case StreamActionTypes.SELECT_STREAM:
60 | draft.streamId = action.payload.streamId;
61 | break;
62 |
63 | case StreamActionTypes.DELETE_STREAM:
64 | draft.byId[action.payload.streamId].deleting = ApiStatus.IN_PROGRESS;
65 | break;
66 |
67 | case StreamActionTypes.DELETE_STREAM_FAILURE:
68 | draft.byId[action.payload.streamId].deleting = ApiStatus.SUCCESS;
69 | break;
70 |
71 | case StreamActionTypes.DELETE_STREAM_SUCCESS:
72 | draft.ids.splice(draft.ids.indexOf(action.payload.streamId), 1);
73 | delete draft.byId[action.payload.streamId];
74 | break;
75 | }
76 | });
77 | }
78 |
79 | export interface IStreamState {
80 | byId: { [key: number]: IStream };
81 | ids: number[];
82 | loadingStatus: ApiStatus;
83 | open: boolean;
84 | addingStatus: ApiStatus;
85 | streamId: number;
86 | }
87 |
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: "3"
2 |
3 | services:
4 | redis:
5 | container_name: redis
6 | image: redis:alpine
7 |
8 | db:
9 | container_name: db
10 | restart: unless-stopped
11 | image: mysql:latest
12 | command: --default-authentication-plugin=mysql_native_password
13 | environment:
14 | MYSQL_ROOT_PASSWORD: root
15 | MYSQL_USER: streamer
16 | MYSQL_PASSWORD: password
17 | MYSQL_DATABASE: gotest
18 | ports:
19 | - 3306:3306
20 | expose:
21 | - 3306
22 | volumes:
23 | - storage:/var/lib/mysql
24 |
25 | rtmp:
26 | container_name: rtmp
27 | links:
28 | - web
29 | build:
30 | context: .
31 | dockerfile: ./scripts/rtmp/Dockerfile
32 | volumes:
33 | - public:/hls-preview/
34 | entrypoint: ./scripts/wait-for-it.sh web:80 -- air -c ./scripts/rtmp/.air.toml
35 | restart: unless-stopped
36 | ports:
37 | - "1935:1935"
38 | expose:
39 | - 1935
40 | environment:
41 | GRPC_HOST: web
42 | REDIS_HOST: redis
43 |
44 | web:
45 | container_name: web
46 | restart: always
47 | links:
48 | - db
49 | - redis
50 | build:
51 | context: .
52 | dockerfile: ./scripts/web/Dockerfile
53 | entrypoint: ./scripts/wait-for-it.sh db:3306 -- air -c ./scripts/web/.air.toml
54 | volumes:
55 | - ./:/go/src/github.com/praveen001/go-rtmp-web-server
56 | restart: unless-stopped
57 | expose:
58 | - 4005
59 | - 5000
60 | environment:
61 | MYSQL_HOST: db
62 | MYSQL_USER: streamer
63 | MYSQL_PASSWORD: password
64 | MYSQL_DATABASE: gotest
65 | REDIS_HOST: redis
66 | env_file:
67 | - ./scripts/configs.env
68 |
69 | ui:
70 | container_name: ui
71 | build:
72 | context: .
73 | dockerfile: ./scripts/ui/Dockerfile
74 | entrypoint: yarn start
75 | volumes:
76 | - ./frontend/src/:/ui/src
77 | restart: unless-stopped
78 | expose:
79 | - 8080
80 |
81 | nginx:
82 | container_name: nginx
83 | links:
84 | - web
85 | - ui
86 | build:
87 | context: .
88 | dockerfile: ./scripts/nginx/Dockerfile
89 | volumes:
90 | - public:/usr/share/nginx/html/hls-preview/
91 | ports:
92 | - "80:80"
93 | expose:
94 | - 80
95 |
96 | volumes:
97 | public:
98 | storage:
99 |
--------------------------------------------------------------------------------
/frontend/src/components/RegisterForm/RegisterForm.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | // Material-UI Components
4 | import Button from '@material-ui/core/Button';
5 | import Paper from '@material-ui/core/Paper';
6 | import {
7 | createStyles,
8 | Theme,
9 | withStyles,
10 | WithStyles
11 | } from '@material-ui/core/styles';
12 | import TextField from '@material-ui/core/TextField';
13 |
14 | const styles = (theme: Theme) =>
15 | createStyles({
16 | row: {
17 | marginTop: 25,
18 | display: 'flex',
19 | justifyContent: 'space-between'
20 | }
21 | });
22 |
23 | class RegisterForm extends React.Component {
24 | state = {
25 | name: '',
26 | email: '',
27 | password: '',
28 | rememberMe: false
29 | };
30 |
31 | changeName = (e: React.ChangeEvent) => {
32 | this.setState({ name: e.target.value });
33 | };
34 |
35 | changeEmail = (e: React.ChangeEvent) => {
36 | this.setState({ email: e.target.value });
37 | };
38 |
39 | changePassword = (e: React.ChangeEvent) => {
40 | this.setState({ password: e.target.value });
41 | };
42 |
43 | register = () => {
44 | const { name, email, password } = this.state;
45 | this.props.register(name, email, password);
46 | };
47 |
48 | render() {
49 | const { classes } = this.props;
50 | const { name, email, password } = this.state;
51 |
52 | return (
53 | <>
54 |
55 |
61 |
62 |
63 |
64 |
70 |
71 |
72 |
73 |
80 |
81 |
82 |
83 |
84 |
87 |
88 |
89 | >
90 | );
91 | }
92 | }
93 |
94 | export default withStyles(styles)(RegisterForm);
95 |
96 | export interface IDispatchProps {
97 | register: (name: string, email: string, password: string) => void;
98 | }
99 |
100 | export type RegisterFormProps = IDispatchProps & WithStyles;
101 |
--------------------------------------------------------------------------------
/frontend/src/actions/authenticationActions.ts:
--------------------------------------------------------------------------------
1 | export enum AuthenticationActionTypes {
2 | REGISTER = 'auth/register',
3 | LOGIN = 'auth/login',
4 | AUTHENTICATE_TOKEN = 'auth/authenticateUser',
5 | AUTHENTICATING_TOKEN = 'auth/authenticatingUser',
6 | AUTHENTICATED_TOKEN = 'auth/authenticatedUser',
7 | LOGOUT = 'auth/logout'
8 | }
9 |
10 | export function register(
11 | name: string,
12 | email: string,
13 | password: string
14 | ): IRegisterAction {
15 | return {
16 | type: AuthenticationActionTypes.REGISTER,
17 | payload: {
18 | name,
19 | email,
20 | password
21 | }
22 | };
23 | }
24 |
25 | export function login(
26 | email: string,
27 | password: string,
28 | rememberMe: boolean
29 | ): ILoginAction {
30 | return {
31 | type: AuthenticationActionTypes.LOGIN,
32 | payload: { email, password, rememberMe }
33 | };
34 | }
35 |
36 | export function authenticateUser(token: string): IAuthenticateUserAction {
37 | return {
38 | type: AuthenticationActionTypes.AUTHENTICATE_TOKEN,
39 | payload: {
40 | token
41 | }
42 | };
43 | }
44 |
45 | export function authenticatingUser(): IAuthenticatingUserAction {
46 | return {
47 | type: AuthenticationActionTypes.AUTHENTICATING_TOKEN
48 | };
49 | }
50 |
51 | export function authenticatedUser(
52 | token: string,
53 | user: any,
54 | shouldRedirect: boolean
55 | ): IAuthenticatedUserAction {
56 | return {
57 | type: AuthenticationActionTypes.AUTHENTICATED_TOKEN,
58 | payload: { token, user, shouldRedirect }
59 | };
60 | }
61 |
62 | export function logout(): ILogoutAction {
63 | return { type: AuthenticationActionTypes.LOGOUT };
64 | }
65 |
66 | export interface IRegisterAction {
67 | type: AuthenticationActionTypes.REGISTER;
68 | payload: {
69 | name: string;
70 | email: string;
71 | password: string;
72 | };
73 | }
74 |
75 | export interface ILoginAction {
76 | type: AuthenticationActionTypes.LOGIN;
77 | payload: {
78 | email: string;
79 | password: string;
80 | rememberMe: boolean;
81 | };
82 | }
83 |
84 | export interface IAuthenticateUserAction {
85 | type: AuthenticationActionTypes.AUTHENTICATE_TOKEN;
86 | payload: {
87 | token: string;
88 | };
89 | }
90 |
91 | export interface IAuthenticatingUserAction {
92 | type: AuthenticationActionTypes.AUTHENTICATING_TOKEN;
93 | }
94 |
95 | export interface IAuthenticatedUserAction {
96 | type: AuthenticationActionTypes.AUTHENTICATED_TOKEN;
97 | payload: {
98 | token: string;
99 | user: any;
100 | shouldRedirect: boolean;
101 | };
102 | }
103 |
104 | export interface ILogoutAction {
105 | type: AuthenticationActionTypes.LOGOUT;
106 | }
107 |
108 | export type AuthenticationAction =
109 | | IRegisterAction
110 | | ILoginAction
111 | | IAuthenticateUserAction
112 | | IAuthenticatingUserAction
113 | | IAuthenticatedUserAction
114 | | ILogoutAction;
115 |
--------------------------------------------------------------------------------
/frontend/src/components/Icons/Periscope.tsx:
--------------------------------------------------------------------------------
1 | import SvgIcon from '@material-ui/core/SvgIcon';
2 | import React from 'react';
3 | /* tslint:disable */
4 | export default props => (
5 |
6 |
11 |
12 | );
13 |
--------------------------------------------------------------------------------
/frontend/src/components/Streams/NewStream.tsx:
--------------------------------------------------------------------------------
1 | import Button from '@material-ui/core/Button';
2 | import CircularProgress from '@material-ui/core/CircularProgress';
3 | import Dialog from '@material-ui/core/Dialog';
4 | import IconButton from '@material-ui/core/IconButton';
5 | import {
6 | createStyles,
7 | Theme,
8 | withStyles,
9 | WithStyles
10 | } from '@material-ui/core/styles';
11 | import TextField from '@material-ui/core/TextField';
12 | import Typography from '@material-ui/core/Typography';
13 | import CloseIcon from '@material-ui/icons/Close';
14 | import React from 'react';
15 | import { ApiStatus } from '../../models';
16 | import Title from '../Title/Title';
17 |
18 | const styles = (theme: Theme) =>
19 | createStyles({
20 | content: {
21 | padding: theme.spacing.unit,
22 | display: 'flex',
23 | flexDirection: 'column'
24 | },
25 | row: {
26 | marginBottom: 25
27 | }
28 | });
29 |
30 | class NewStream extends React.Component {
31 | state = {
32 | name: ''
33 | };
34 |
35 | onChange = e => {
36 | this.setState({
37 | name: e.target.value
38 | });
39 | };
40 |
41 | createStream = () => {
42 | this.props.createStream(this.state.name);
43 | };
44 |
45 | getContent = () => {
46 | const { loadingStatus, closeNewStreamDialog, classes } = this.props;
47 |
48 | if (loadingStatus === ApiStatus.IN_PROGRESS) {
49 | return ;
50 | }
51 |
52 | return (
53 | <>
54 |
55 | Create Stream
56 |
57 |
58 |
59 |
60 |
61 |
67 |
68 |
71 | >
72 | );
73 | };
74 |
75 | render() {
76 | const { classes, open, closeNewStreamDialog } = this.props;
77 |
78 | return (
79 |
87 | );
88 | }
89 | }
90 |
91 | export default withStyles(styles)(NewStream);
92 |
93 | export interface INewStreamStateProps {
94 | open: boolean;
95 | loadingStatus: ApiStatus;
96 | }
97 |
98 | export interface INewStreamDispatchProps {
99 | closeNewStreamDialog: () => {};
100 | createStream: (name: string) => {};
101 | }
102 |
103 | type NewStreamProps = INewStreamStateProps &
104 | INewStreamDispatchProps &
105 | WithStyles;
106 |
--------------------------------------------------------------------------------
/controllers/channels.go:
--------------------------------------------------------------------------------
1 | package controllers
2 |
3 | import (
4 | "encoding/json"
5 | "net/http"
6 | "strconv"
7 |
8 | "github.com/praveen001/go-rtmp-web-server/models"
9 | )
10 |
11 | // AddChannel a new channel for a given user
12 | func (c *ApplicationContext) AddChannel(w http.ResponseWriter, r *http.Request) {
13 | channel := &models.Channel{}
14 | if err := json.NewDecoder(r.Body).Decode(channel); err != nil {
15 | c.NewResponse(w).Status(400).SendJSON(Response{
16 | Error: true,
17 | Message: "Unable to parse request",
18 | })
19 | return
20 | }
21 | channel.Enabled = true
22 |
23 | stream := &models.Stream{
24 | Model: models.Model{
25 | ID: channel.StreamID,
26 | },
27 | }
28 | c.DB.Find(&stream, stream)
29 | if stream.UserID != int(r.Context().Value("id").(float64)) {
30 | c.NewResponse(w).Status(403).SendJSON(Response{
31 | Error: true,
32 | Message: "Unauthorized",
33 | })
34 | return
35 | }
36 |
37 | c.DB.Save(channel)
38 |
39 | c.NewResponse(w).Status(200).SendJSON(Response{
40 | Error: false,
41 | Message: "Channel added successfully",
42 | })
43 | }
44 |
45 | // ListChannels all channels associated to a stream
46 | func (c *ApplicationContext) ListChannels(w http.ResponseWriter, r *http.Request) {
47 | streamID, err := strconv.Atoi(r.FormValue("streamId"))
48 | if err != nil {
49 | c.NewResponse(w).Status(400).SendJSON(Response{
50 | Error: true,
51 | Message: "Invalid stream ID",
52 | })
53 | return
54 | }
55 |
56 | var channels []models.Channel
57 | c.DB.Find(&channels, models.Channel{StreamID: streamID})
58 |
59 | c.NewResponse(w).Status(http.StatusOK).SendJSON(Response{
60 | Data: channels,
61 | })
62 | }
63 |
64 | // DeleteChannel will delete a channel associated to a stream
65 | func (c *ApplicationContext) DeleteChannel(w http.ResponseWriter, r *http.Request) {
66 | streamID, err := strconv.Atoi(r.FormValue("streamId"))
67 | if err != nil {
68 | c.NewResponse(w).Status(400).SendJSON(Response{
69 | Error: true,
70 | Message: "Invalid stream ID",
71 | })
72 | return
73 | }
74 | channelID, err := strconv.Atoi(r.FormValue("channelId"))
75 | if err != nil {
76 | c.NewResponse(w).Status(400).SendJSON(Response{
77 | Error: true,
78 | Message: "Invalid stream ID",
79 | })
80 | return
81 | }
82 |
83 | // Access check
84 | stream := &models.Stream{
85 | Model: models.Model{
86 | ID: streamID,
87 | },
88 | }
89 | c.DB.Find(&stream, stream)
90 | if stream.UserID != int(r.Context().Value("id").(float64)) {
91 | c.NewResponse(w).Status(403).SendJSON(Response{
92 | Error: true,
93 | Message: "Unauthorized",
94 | })
95 | return
96 | }
97 |
98 | channel := &models.Channel{
99 | StreamID: streamID,
100 | Model: models.Model{
101 | ID: channelID,
102 | },
103 | }
104 |
105 | c.DB.Delete(channel)
106 |
107 | c.NewResponse(w).Status(200).SendJSON(Response{
108 | Message: "Channel has been deleted successfully",
109 | })
110 | }
111 |
--------------------------------------------------------------------------------
/frontend/src/components/Header/Header.tsx:
--------------------------------------------------------------------------------
1 | import AppBar from '@material-ui/core/AppBar';
2 | import Button from '@material-ui/core/Button';
3 | import FormControlLabel from '@material-ui/core/FormControlLabel';
4 | import Menu from '@material-ui/core/Menu';
5 | import MenuItem from '@material-ui/core/MenuItem';
6 | import {
7 | createStyles,
8 | Theme,
9 | withStyles,
10 | WithStyles
11 | } from '@material-ui/core/styles';
12 | import Switch from '@material-ui/core/Switch';
13 | import Toolbar from '@material-ui/core/Toolbar';
14 | import Typography from '@material-ui/core/Typography';
15 | import React from 'react';
16 |
17 | const styles = (theme: Theme) =>
18 | createStyles({
19 | appBar: {
20 | color: '#fff'
21 | },
22 | toolbar: {
23 | display: 'flex',
24 | justifyContent: 'space-between'
25 | },
26 | logo: {
27 | textTransform: 'none'
28 | }
29 | });
30 |
31 | class Header extends React.Component {
32 | anchorEl;
33 |
34 | openMenu = e => {
35 | this.anchorEl = e.target;
36 | this.props.openMenu();
37 | };
38 |
39 | closeMenu = () => {
40 | this.anchorEl = undefined;
41 | this.props.closeMenu();
42 | };
43 |
44 | render() {
45 | const { classes, theme, toggleTheme, logout } = this.props;
46 |
47 | const darkTheme = theme === 'dark';
48 |
49 | return (
50 |
51 |
52 |
57 |
58 |
66 |
81 |
82 |
83 |
84 | );
85 | }
86 | }
87 |
88 | export default withStyles(styles)(Header);
89 |
90 | export interface IHeaderStateProps {
91 | open: boolean;
92 | user: any;
93 | theme: string;
94 | }
95 |
96 | export interface IHeaderDispatchProps {
97 | openMenu: () => {};
98 | closeMenu: () => {};
99 | toggleTheme: () => {};
100 | logout: () => {};
101 | }
102 |
103 | type HeaderProps = IHeaderStateProps &
104 | IHeaderDispatchProps &
105 | WithStyles;
106 |
--------------------------------------------------------------------------------
/frontend/src/components/Icons/Youtube.tsx:
--------------------------------------------------------------------------------
1 | import SvgIcon from '@material-ui/core/SvgIcon';
2 | import React from 'react';
3 | /* tslint:disable */
4 | export default props => (
5 |
6 |
11 |
12 | );
13 |
--------------------------------------------------------------------------------
/rtmp/connection.go:
--------------------------------------------------------------------------------
1 | package rtmp
2 |
3 | import (
4 | "bufio"
5 | "fmt"
6 | "net"
7 | )
8 |
9 | const (
10 | _ = iota
11 | handshakeStage
12 | commandStage
13 | commandStageDone
14 | )
15 |
16 | const (
17 | genuineFMS = "Genuine Adobe Flash Media Server 001"
18 | genuineFp = "Genuine Adobe Flash Player 001"
19 | )
20 |
21 | // Parse States
22 | const (
23 | RtmpParseInit = iota
24 | RtmpParseBasicHeader
25 | RtmpParseMessageHeader
26 | RtmpParseExtendedHeader
27 | RtmpParsePayload
28 | )
29 |
30 | type Channel struct {
31 | ChannelID int64
32 | Send chan []byte
33 | Exit chan bool
34 | }
35 |
36 | // Connection a RTMP connection
37 | type Connection struct {
38 | Conn net.Conn
39 | Reader *bufio.Reader
40 | Writer *bufio.Writer
41 | ReadBuffer []byte
42 | WriteBuffer []byte
43 | Stage int
44 | HandshakeDone bool
45 | ConnectionDone bool
46 | GotMessage bool
47 | csMap map[uint32]*rtmpChunk
48 | ReadMaxChunkSize int
49 | WriteMaxChunkSize int
50 | AppName string
51 | StreamKey string
52 | Streams int
53 |
54 | AudioSampleRate float64
55 | AudioChannels float64
56 | VideoWidth float64
57 | VideoHeight float64
58 | FPS float64
59 |
60 | Clients []Channel
61 | WaitingClients []Channel
62 | StreamID int64
63 | UserID int64
64 | Context *StreamerContext
65 | GotFirstAudio bool
66 | GotFirstVideo bool
67 | MetaData []byte
68 | FirstAudio []byte
69 | FirstVideo []byte
70 | GOP [][]byte
71 |
72 | Proxy []*bufio.Writer
73 | }
74 |
75 | // Serve the RTMP connection
76 | func (c *Connection) Serve() {
77 |
78 | if err := c.handshake(); err != nil {
79 | fmt.Println("Handshake failed")
80 | return
81 | }
82 | fmt.Println("Handhshake completed")
83 |
84 | if err := c.prepare(); err != nil {
85 | fmt.Println("Error while preparing RTMP connection")
86 | return
87 | }
88 | fmt.Println("Connection completed")
89 |
90 | for c.Stage < commandStageDone {
91 | c.readMessage()
92 | }
93 | fmt.Println("Command stage completed")
94 |
95 | for {
96 | if err := c.readChunk(); err != nil {
97 | c.closeConnection()
98 | return
99 | }
100 | }
101 |
102 | // var zz bool
103 | // var cc int
104 | // p := make([]byte, 5000)
105 | // for {
106 | // n, err := c.Reader.Read(p)
107 | // if err != nil {
108 | // if err == io.EOF {
109 | // c.closeConnection()
110 | // break
111 | // }
112 | // }
113 |
114 | // m := make([]byte, n)
115 | // copy(m, p)
116 | // for _, client := range c.Clients {
117 | // client.Send <- m
118 | // }
119 | // c.GOP = append(c.GOP, m)
120 | // if !zz && cc > 200 {
121 | // zz = true
122 | // c.Context.preview <- "rtmp://localhost/" + c.AppName + "/" + c.StreamKey
123 | // } else {
124 | // cc++
125 | // }
126 | // }
127 | }
128 |
129 | /*
130 | ffmpeg -v verbose -i rtmp://localhost:1935/live2/123 -c:v libx264 -c:a aac -ac 1 -strict -2 -crf 18 -profile:v baseline -maxrate 400k -bufsize 1835k -pix_fmt yuv420p -flags -global_header -hls_time 10 -hls_list_size 6 -hls_wrap 10 -start_number 1 /home/praveen/hls/123.m3u8
131 | */
132 |
--------------------------------------------------------------------------------
/frontend/src/components/AddChannelForm/AddChannelForm.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | // Material-UI Components
4 | import Button from '@material-ui/core/Button';
5 | import {
6 | createStyles,
7 | Theme,
8 | withStyles,
9 | WithStyles
10 | } from '@material-ui/core/styles';
11 | import TextField from '@material-ui/core/TextField';
12 | import Typography from '@material-ui/core/Typography';
13 |
14 | const styles = (theme: Theme) =>
15 | createStyles({
16 | addChannelForm: {
17 | display: 'flex',
18 | flexDirection: 'column',
19 | width: 450,
20 | padding: theme.spacing.unit * 1,
21 | paddingRight: 35,
22 | marginRight: 20,
23 | borderRight: `1px solid ${theme.palette.divider}`
24 | },
25 | row: {
26 | marginTop: 25,
27 | display: 'flex',
28 | justifyContent: 'space-between'
29 | }
30 | });
31 |
32 | class AddChannelForm extends React.Component {
33 | state = {
34 | name: '',
35 | url: '',
36 | key: ''
37 | };
38 |
39 | changeName = (e: React.ChangeEvent) => {
40 | this.setState({ name: e.target.value });
41 | };
42 |
43 | changeURL = (e: React.ChangeEvent) => {
44 | this.setState({ url: e.target.value });
45 | };
46 |
47 | changeKey = (e: React.ChangeEvent) => {
48 | this.setState({ key: e.target.value });
49 | };
50 |
51 | addChannel = () => {
52 | const { name, url, key } = this.state;
53 | this.props.addChannel(this.props.streamId, name, url, key);
54 | };
55 |
56 | render() {
57 | const { classes } = this.props;
58 | const { name, url, key } = this.state;
59 |
60 | return (
61 |
62 |
Add Custom RTMP Endpoint
63 |
64 |
70 |
71 |
72 |
73 |
79 |
80 |
81 |
82 |
89 |
90 |
91 |
92 |
93 |
100 |
101 |
102 |
103 | );
104 | }
105 | }
106 |
107 | export default withStyles(styles)(AddChannelForm);
108 |
109 | export interface IOwnProps {
110 | streamId: number;
111 | }
112 |
113 | export interface IDispatchProps {
114 | addChannel: (
115 | streamId: number,
116 | name: string,
117 | url: string,
118 | key: string
119 | ) => void;
120 | }
121 |
122 | export type AddChannelFormProps = IOwnProps &
123 | IDispatchProps &
124 | WithStyles;
125 |
--------------------------------------------------------------------------------
/rtmp/preview.go:
--------------------------------------------------------------------------------
1 | package rtmp
2 |
3 | import (
4 | "encoding/json"
5 | "fmt"
6 | "log"
7 | "os"
8 | "os/exec"
9 | )
10 |
11 | // Path where hls playlist will get saved
12 | var (
13 | HLSOutputBasePath = "/hls-preview/"
14 | )
15 |
16 | // InitPreviewServer ..
17 | func InitPreviewServer(ctx *StreamerContext) {
18 | for {
19 | select {
20 | case streamKey := <-ctx.preview:
21 | if len(HLSOutputBasePath) != 0 {
22 | go makeHls(ctx, streamKey)
23 | }
24 | c := ctx.get(streamKey)
25 | redisMsg := map[string]interface{}{
26 | "ID": c.UserID,
27 | "Message": map[string]interface{}{
28 | "type": "stats/previewReady",
29 | "streamId": c.StreamID,
30 | },
31 | }
32 | redisMsgBytes, _ := json.Marshal(redisMsg)
33 |
34 | ctx.Redis.Do("PUBLISH", "Stream", redisMsgBytes)
35 | }
36 | }
37 | }
38 |
39 | func makeHls(ctx *StreamerContext, streamKey string) {
40 | c, ok := ctx.sessions[streamKey]
41 | if !ok {
42 | fmt.Println("Not Found", streamKey)
43 | }
44 | output := HLSOutputBasePath + streamKey
45 | if _, err := os.Stat(output); err != nil {
46 | if os.IsNotExist(err) {
47 | os.MkdirAll(output, os.ModePerm)
48 | }
49 | }
50 |
51 | f, err := os.Create(output + "/index.m3u8")
52 | if err != nil {
53 | panic(err)
54 | }
55 | f.Write([]byte("#EXTM3U\n#EXT-X-VERSION:3\n#EXT-X-TARGETDURATION:8\n#EXT-X-MEDIA-SEQUENCE:1"))
56 | f.Close()
57 |
58 | fmt.Println("Generating preview for", streamKey, c.AppName)
59 | cmd := exec.Command("ffmpeg", "-v", "verbose", "-i", "rtmp://localhost/"+c.AppName+"/"+streamKey, "-c:v", "libx264", "-c:a", "aac", "-ac", "1", "-strict", "-2", "-crf", "18", "-profile:v", "baseline", "-maxrate", "400k", "-bufsize", "1835k", "-pix_fmt", "yuv420p", "-flags", "-global_header", "-hls_time", "3", "-hls_list_size", "6", "-hls_wrap", "10", "-start_number", "1", output+"/index.m3u8")
60 | err = cmd.Run()
61 | os.RemoveAll(output)
62 | if err != nil {
63 | log.Fatal(err)
64 | }
65 | fmt.Println("FFMPEG DONE")
66 | }
67 |
68 | //ffmpeg -v verbose -i rtmp://localhost/live2/688ecdd5-44ba-4445-95a2-624d58c420bd -c:v libx264 -c:a aac -ac 1 -strict -2 -crf 18 -profile:v baseline -maxrate 400k -bufsize 1835k -pix_fmt yuv420p -flags -global_header -hls_time 3 -hls_list_size 6 -hls_wrap 10 -start_number 1 hls/index.m3u8
69 |
70 | // ffmpeg -i demo.mp4 -i demo.mp4 -filter hstack output.mp4
71 |
72 | // Not working
73 | // ffmpeg -v verbose -i rtmp://localhost/live2/688ecdd5-44ba-4445-95a2-624d58c420bd -i rtmp://localhost/live2/688ecdd5-44ba-4445-95a2-624d58c420bd -filter hstack -c:v libx264 -c:a aac -ac 1 -strict -2 -crf 18 -profile:v baseline -maxrate 400k -bufsize 1835k -pix_fmt yuv420p -flags -global_header -hls_time 3 -hls_list_size 6 -hls_wrap 10 -start_number 1 hls/index.m3u8
74 |
75 | // ffmpeg -i rtmp://localhost/live2/688ecdd5-44ba-4445-95a2-624d58c420bd -i rtmp://localhost/live2/688ecdd5-44ba-4445-95a2-624d58c420bd -filter_complex "[0:v][1:v]hstack=inputs=2[v]" -map "[v]" output.mp4
76 |
77 | // ffmpeg -i rtmp://localhost/live2/688ecdd5-44ba-4445-95a2-624d58c420bd -i rtmp://localhost/live2/688ecdd5-44ba-4445-95a2-624d58c420bd -filter_complex "[0:v][1:v]hstack=inputs=2[v]" -map "[v]" -f flv rtmp://localhost/live2/eedd6f60-96dc-4623-9f8a-dc02e6030a09
78 |
79 | // ffmpeg -re -i demo.mp4 -vcodec libx264 -profile:v main -preset:v medium -r 30 -g 60 -keyint_min 60 -sc_threshold 0 -b:v 2500k -maxrate 2500k -bufsize 2500k -filter:v scale="trunc(oha/2)2:720" -sws_flags lanczos+accurate_rnd -acodec libfdk_aac -b:a 96k -ar 48000 -ac 2 -f flv rtmp://localhost/live2/688ecdd5-44ba-4445-95a2-624d58c420bd
80 |
--------------------------------------------------------------------------------
/amf/decoder.go:
--------------------------------------------------------------------------------
1 | package amf
2 |
3 | import (
4 | "encoding/binary"
5 | "math"
6 | )
7 |
8 | var rtmpCommandParams = map[string][]string{
9 | "connect": []string{"transId", "cmdObj", "args"},
10 | "_result": []string{"transId", "cmdObj", "info"},
11 | "releaseStream": []string{"transId", "cmdObj", "streamName"},
12 | "createStream": []string{"transId", "cmdObj"},
13 | "publish": []string{"transId", "cmdObj", "streamName", "type"},
14 | "FCPublish": []string{"transId", "cmdObj", "streamName"},
15 | "FCUnpublish": []string{"transId", "cmdObj", "streamName"},
16 | "onFCPublish": []string{"transId", "cmdObj", "info"},
17 | "@setDataFrame": []string{"method", "dataObj"},
18 | "play": []string{"transId", "cmdObj", "streamName", "start", "duration", "reset"},
19 | }
20 |
21 | // DecodedData ..
22 | type DecodedData struct {
23 | remainingData []byte
24 | value interface{}
25 | }
26 |
27 | func getDecoder(data []byte) func() DecodedData {
28 | typeMarker := uint8(data[0])
29 | var decoder func([]byte) DecodedData
30 | switch typeMarker {
31 | case 0x00:
32 | decoder = decodeNumber
33 |
34 | case 0x01:
35 | decoder = decodeBool
36 |
37 | case 0x02:
38 | decoder = decodeString
39 |
40 | case 0x03:
41 | decoder = decodeObject
42 |
43 | case 0x05:
44 | decoder = decodeNull
45 |
46 | case 0x08:
47 | decoder = decodeECMAArray
48 | }
49 |
50 | return func() DecodedData {
51 | return decoder(data[1:])
52 | }
53 | }
54 |
55 | // Decode AMF0 command object
56 | func Decode(data []byte) map[string]interface{} {
57 | decoded := getDecoder(data)()
58 | cmd := map[string]interface{}{
59 | "cmd": decoded.value,
60 | }
61 | params := rtmpCommandParams[decoded.value.(string)]
62 |
63 | for _, param := range params {
64 | if len(decoded.remainingData) > 0 {
65 | decoded = getDecoder(decoded.remainingData)()
66 | cmd[param] = decoded.value
67 | }
68 | }
69 |
70 | return cmd
71 | }
72 |
73 | func decodeNumber(data []byte) DecodedData {
74 | return DecodedData{
75 | remainingData: data[8:],
76 | value: math.Float64frombits(binary.BigEndian.Uint64(data[:8])),
77 | }
78 | }
79 |
80 | func decodeBool(data []byte) DecodedData {
81 | return DecodedData{
82 | remainingData: data[1:],
83 | value: data[0] != 0,
84 | }
85 | }
86 |
87 | func decodeString(data []byte) DecodedData {
88 | len := binary.BigEndian.Uint16(data[:2])
89 | return DecodedData{
90 | remainingData: data[2+len:],
91 | value: string(data[2 : 2+len]),
92 | }
93 | }
94 |
95 | func decodeObject(data []byte) DecodedData {
96 | object := make(map[string]interface{})
97 | tData := data
98 | for len(tData) != 0 {
99 | decoded := decodeString(tData)
100 | key := decoded.value.(string)
101 | tData = decoded.remainingData
102 |
103 | if uint8(tData[0]) == 0x09 {
104 | tData = tData[1:]
105 | break
106 | }
107 |
108 | decoded = getDecoder(tData)()
109 | tData = decoded.remainingData
110 | object[key] = decoded.value
111 | }
112 |
113 | return DecodedData{
114 | remainingData: tData,
115 | value: object,
116 | }
117 | }
118 |
119 | func decodeNull(data []byte) DecodedData {
120 | return DecodedData{
121 | remainingData: data,
122 | value: nil,
123 | }
124 | }
125 |
126 | func decodeECMAArray(data []byte) DecodedData {
127 | decoded := decodeObject(data[4:])
128 | return DecodedData{
129 | remainingData: decoded.remainingData,
130 | value: decoded.value,
131 | }
132 | }
133 |
--------------------------------------------------------------------------------
/models/user_model.go:
--------------------------------------------------------------------------------
1 | package models
2 |
3 | import (
4 | "errors"
5 | "regexp"
6 | "time"
7 |
8 | jwt "github.com/dgrijalva/jwt-go"
9 | "github.com/jinzhu/gorm"
10 | )
11 |
12 | // Model base
13 | type Model struct {
14 | ID int `json:"id" gorm:"primary_key"`
15 | CreatedAt time.Time `json:"-"`
16 | UpdatedAt time.Time `json:"-"`
17 | DeletedAt *time.Time `json:"-" sql:"index"`
18 | }
19 |
20 | // User ..
21 | type User struct {
22 | Model
23 | Name string `json:"name"`
24 | Email string `json:"email" gorm:"unique"`
25 | Password string `json:"password"`
26 | IsVerified bool `json:"isVerified"`
27 | VerificationToken string `json:"-"`
28 | Streams []Stream `json:"streams" gorm:"foreignkey:UserID"`
29 | }
30 |
31 | // PublicUserData has the public information of an user
32 | type PublicUserData struct {
33 | *User
34 | Password string `json:"password,omitempty"`
35 | }
36 |
37 | // CanRegister validates the user for registration
38 | func (u User) CanRegister(db *gorm.DB) (errorMap map[string]string) {
39 | errorMap = make(map[string]string)
40 | if u.Name == "" {
41 | errorMap["name"] = "Name cannot be blank"
42 | }
43 |
44 | var c int
45 |
46 | if u.Email == "" {
47 | errorMap["email"] = "Email cannot be blank"
48 | } else if re := regexp.MustCompile("^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$"); !re.MatchString(u.Email) {
49 | errorMap["email"] = "Given email is not valid"
50 | } else if db.Model(&User{}).Where(&User{Email: u.Email}).Count(&c); c != 0 {
51 | errorMap["email"] = "Email address already in use"
52 | }
53 |
54 | if u.Password == "" {
55 | errorMap["password"] = "Password cannot be blank"
56 | } else if len(u.Password) < 6 {
57 | errorMap["password"] = "Password must be atleast 6 characters long"
58 | }
59 |
60 | return
61 | }
62 |
63 | // Register creates a new user in database
64 | func (u *User) Register(db *gorm.DB) error {
65 | u.VerificationToken = "12345"
66 | return db.Create(u).Error
67 | }
68 |
69 | // SendVerificationEmail sends the verification link in email
70 | func (u User) SendVerificationEmail() error {
71 | return nil
72 | }
73 |
74 | // VerifyUser marks the user as verified
75 | func (u *User) VerifyUser(db *gorm.DB, token string) error {
76 | if u.VerificationToken != token {
77 | return errors.New("Invalid token")
78 | }
79 |
80 | u.IsVerified = true
81 | u.VerificationToken = ""
82 |
83 | return db.Save(u).Error
84 | }
85 |
86 | // GetJWT generates a JWT token for the user
87 | func (u User) GetJWT() (string, error) {
88 | token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
89 | "email": u.Email,
90 | "id": u.ID,
91 | })
92 | return token.SignedString([]byte("Secret"))
93 | }
94 |
95 | // CanLogin checks validates user for login
96 | func (u *User) CanLogin(db *gorm.DB) (errorMap map[string]string) {
97 | errorMap = make(map[string]string)
98 | if u.Email == "" {
99 | errorMap["email"] = "Email is required"
100 | }
101 |
102 | if u.Password == "" {
103 | errorMap["password"] = "Password is required"
104 | }
105 |
106 | db.Where(u).First(u)
107 | if u.ID == 0 {
108 | errorMap["login"] = "Invalid username/password"
109 | }
110 |
111 | return
112 | }
113 |
114 | // FindByEmail returns a user for an given email
115 | func (u *User) FindByEmail(db *gorm.DB) error {
116 | return db.Where("email = ?", u.Email).First(u).Error
117 | }
118 |
--------------------------------------------------------------------------------
/controllers/users.go:
--------------------------------------------------------------------------------
1 | package controllers
2 |
3 | import (
4 | "encoding/json"
5 | "net/http"
6 |
7 | "github.com/praveen001/go-rtmp-web-server/models"
8 | )
9 |
10 | // RegisterUser adds a new user to database
11 | func (c *ApplicationContext) RegisterUser(w http.ResponseWriter, r *http.Request) {
12 | user := &models.User{}
13 | if err := json.NewDecoder(r.Body).Decode(user); err != nil {
14 | c.NewResponse(w).Status(400).SendJSON(Response{
15 | Error: true,
16 | Message: "Unable to parse request",
17 | })
18 | return
19 | }
20 |
21 | if errorMap := user.CanRegister(c.DB); len(errorMap) != 0 {
22 | c.NewResponse(w).Status(400).SendJSON(Response{
23 | Error: true,
24 | Message: "Validation error",
25 | Data: errorMap,
26 | })
27 | return
28 | }
29 |
30 | if err := user.Register(c.DB); err != nil {
31 | c.NewResponse(w).Status(200).SendJSON(Response{
32 | Error: true,
33 | Message: "Unknown error occured while registering",
34 | })
35 | return
36 | }
37 |
38 | if err := user.SendVerificationEmail(); err != nil {
39 | // Just Log and Ignore
40 | }
41 |
42 | token, _ := user.GetJWT()
43 |
44 | c.NewResponse(w).Status(200).SendJSON(Response{
45 | Message: "Successfully Registered",
46 | Data: map[string]interface{}{
47 | "token": token,
48 | "user": models.PublicUserData{User: user},
49 | },
50 | })
51 | }
52 |
53 | // VerifyUser marks an user as verified user
54 | func (c *ApplicationContext) VerifyUser(w http.ResponseWriter, r *http.Request) {
55 | query := r.URL.Query()
56 | email := query.Get("email")
57 | token := query.Get("token")
58 |
59 | if email == "" || token == "" {
60 | c.NewResponse(w).Status(200).SendJSON(Response{
61 | Error: true,
62 | Message: "Missing email/token",
63 | })
64 | return
65 | }
66 |
67 | user := models.User{
68 | Email: email,
69 | }
70 | user.FindByEmail(c.DB)
71 | if err := user.VerifyUser(c.DB, token); err != nil {
72 | c.NewResponse(w).Status(403).SendJSON(Response{
73 | Error: true,
74 | Message: err.Error(),
75 | })
76 | return
77 | }
78 |
79 | c.NewResponse(w).Status(200).SendJSON(Response{
80 | Message: "Successfully verified",
81 | })
82 | }
83 |
84 | // LoginUser login handler
85 | func (c *ApplicationContext) LoginUser(w http.ResponseWriter, r *http.Request) {
86 | user := &models.User{}
87 | if err := json.NewDecoder(r.Body).Decode(user); err != nil {
88 | c.NewResponse(w).Status(http.StatusBadRequest).SendJSON(Response{
89 | Error: true,
90 | Message: "Unable to parse request",
91 | })
92 | return
93 | }
94 |
95 | if errorMap := user.CanLogin(c.DB); len(errorMap) != 0 {
96 | c.NewResponse(w).Status(http.StatusOK).SendJSON(Response{
97 | Error: true,
98 | Message: "Validation error",
99 | Data: errorMap,
100 | })
101 | return
102 | }
103 |
104 | token, _ := user.GetJWT()
105 |
106 | c.NewResponse(w).Status(200).SendJSON(Response{
107 | Message: "Successfully logged in",
108 | Data: map[string]interface{}{
109 | "token": token,
110 | "user": models.PublicUserData{User: user},
111 | },
112 | })
113 | }
114 |
115 | // TokenInfo returns information about user by decoding the JWT token
116 | func (c *ApplicationContext) TokenInfo(w http.ResponseWriter, r *http.Request) {
117 | user := &models.User{
118 | Model: models.Model{
119 | ID: int(r.Context().Value("id").(float64)),
120 | },
121 | }
122 | c.DB.Find(&user, user)
123 |
124 | c.NewResponse(w).Status(200).SendJSON(Response{
125 | Data: models.PublicUserData{User: user},
126 | })
127 | }
128 |
--------------------------------------------------------------------------------
/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "context"
5 | "fmt"
6 | "net/http"
7 | "os"
8 | "os/signal"
9 |
10 | "github.com/gomodule/redigo/redis"
11 | "google.golang.org/grpc"
12 |
13 | "github.com/praveen001/go-rtmp-grpc/pkg/api/v1"
14 | "github.com/praveen001/go-rtmp-web-server/controllers"
15 | "github.com/praveen001/go-rtmp-web-server/models"
16 | "github.com/praveen001/go-rtmp-web-server/router"
17 | "github.com/praveen001/go-rtmp-web-server/rtmp"
18 |
19 | "github.com/jinzhu/gorm"
20 | _ "github.com/jinzhu/gorm/dialects/mysql"
21 | )
22 |
23 | func main() {
24 | switch os.Args[1] {
25 | case "rtmp":
26 | rtmpServer()
27 | case "web":
28 | webServer()
29 | default:
30 | fmt.Println("Server mode should be streamer or web")
31 | }
32 | }
33 |
34 | func rtmpServer() {
35 | conn, err := grpc.Dial(fmt.Sprintf("%s:%s", os.Getenv("GRPC_HOST"), "4005"), grpc.WithInsecure())
36 | if err != nil {
37 | fmt.Println("GRPC Connection Error")
38 | return
39 | }
40 | rpcClient := v1.NewUserChannelServiceClient(conn)
41 |
42 | redisClient, err := redis.Dial("tcp", fmt.Sprintf("%s:%s", os.Getenv("REDIS_HOST"), "6379"))
43 | if err != nil {
44 | panic("Unable to connect to Redis" + err.Error())
45 | }
46 |
47 | ctx := &rtmp.StreamerContext{
48 | RPC: rpcClient,
49 | Redis: redisClient,
50 | }
51 |
52 | rtmp.InitServer(ctx)
53 |
54 | c := make(chan os.Signal, 1)
55 | signal.Notify(c, os.Interrupt)
56 |
57 | // Block till we receive an interrupt
58 | <-c
59 |
60 | os.Exit(0)
61 | }
62 |
63 | func webServer() {
64 | controllers.InitYoutube()
65 | controllers.InitTwitch()
66 | fmt.Println(os.Environ())
67 |
68 | // MySQL DB
69 | db, err := gorm.Open("mysql", fmt.Sprintf("%s:%s@tcp(%s)/%s?parseTime=true", os.Getenv("MYSQL_USER"), os.Getenv("MYSQL_PASSWORD"), os.Getenv("MYSQL_HOST"), os.Getenv("MYSQL_DATABASE")))
70 | if err != nil {
71 | panic("Unable to connect to database " + err.Error())
72 | }
73 | db.AutoMigrate(&models.User{}, &models.Stream{}, &models.Channel{})
74 | defer db.Close()
75 |
76 | // Websocket
77 | hub := controllers.NewHub()
78 | go hub.Run()
79 |
80 | // Redis PubSub
81 | redisPool := &redis.Pool{
82 | MaxIdle: 80,
83 | MaxActive: 100,
84 | Dial: func() (redis.Conn, error) {
85 | c, err := redis.Dial("tcp", fmt.Sprintf("%s:%s", os.Getenv("REDIS_HOST"), "6379"))
86 | if err != nil {
87 | panic("Redis connection failed " + err.Error())
88 | }
89 | return c, err
90 | },
91 | }
92 | pubsub := controllers.NewPubSub(redisPool.Get(), "Stream")
93 | go pubsub.Listen(hub)
94 | defer pubsub.Close()
95 |
96 | appContext := &controllers.ApplicationContext{
97 | DB: db,
98 | Hub: hub,
99 | PubSub: pubsub,
100 | RedisPool: redisPool,
101 | }
102 |
103 | // Grpc
104 | go controllers.NewRPCServer(appContext)
105 |
106 | // Create a HTTP Server instance
107 | srv := &http.Server{
108 | Addr: fmt.Sprintf("0.0.0.0:5000"),
109 | Handler: router.New(appContext),
110 | }
111 |
112 | // Run the server in a goroutine
113 | go func() {
114 | if err := srv.ListenAndServe(); err != nil {
115 | panic(err)
116 | }
117 | }()
118 | // go rtmp.InitServer(db)
119 | fmt.Println("Started server at port 5000")
120 |
121 | // Create a channel to listen for OS Interrupts
122 | c := make(chan os.Signal, 1)
123 | signal.Notify(c, os.Interrupt)
124 |
125 | // Block till we receive an interrupt
126 | <-c
127 |
128 | // Wait for timeout to complete
129 | ctx, cancel := context.WithTimeout(context.Background(), 30)
130 | defer cancel()
131 |
132 | // Shutdown the server
133 | srv.Shutdown(ctx)
134 | os.Exit(0)
135 | }
136 |
--------------------------------------------------------------------------------
/controllers/websocket.go:
--------------------------------------------------------------------------------
1 | package controllers
2 |
3 | import (
4 | "bytes"
5 | "fmt"
6 | "log"
7 | "net/http"
8 |
9 | jwt "github.com/dgrijalva/jwt-go"
10 | "github.com/gorilla/websocket"
11 | )
12 |
13 | func checkOrigin(r *http.Request) bool {
14 | return true
15 | }
16 |
17 | var upgrader = websocket.Upgrader{
18 | CheckOrigin: checkOrigin,
19 | }
20 |
21 | var (
22 | newline = []byte{'\n'}
23 | space = []byte{' '}
24 | )
25 |
26 | // WSOutgoing ..
27 | type WSOutgoing struct {
28 | ID int
29 | Message []byte
30 | }
31 |
32 | // WSHub ..
33 | type WSHub struct {
34 | Clients map[int]map[int]*WSClient
35 | Register chan *WSClient
36 | Unregister chan *WSClient
37 | Incoming chan []byte
38 | Outgoing chan WSOutgoing
39 | ID int
40 | }
41 |
42 | // NewHub ..
43 | func NewHub() *WSHub {
44 | return &WSHub{
45 | Clients: make(map[int]map[int]*WSClient),
46 | Register: make(chan *WSClient),
47 | Unregister: make(chan *WSClient),
48 | Incoming: make(chan []byte, 10),
49 | Outgoing: make(chan WSOutgoing, 10),
50 | }
51 | }
52 |
53 | // Run .
54 | func (h *WSHub) Run() {
55 | for {
56 | select {
57 | case client := <-h.Register:
58 | h.ID++
59 | client.Index = h.ID
60 | if h.Clients[client.ID] == nil {
61 | h.Clients[client.ID] = make(map[int]*WSClient)
62 | }
63 | fmt.Println("Connected", client.ID)
64 | h.Clients[client.ID][h.ID] = client
65 |
66 | case client := <-h.Unregister:
67 | fmt.Println("Disconnected", client.ID)
68 | delete(h.Clients[client.ID], client.Index)
69 | close(client.Send)
70 |
71 | case message := <-h.Incoming:
72 | fmt.Println("Got a message", message)
73 |
74 | case outgoing := <-h.Outgoing:
75 | fmt.Println("Got a message - 1", outgoing)
76 | if h.Clients[outgoing.ID] != nil {
77 | for _, client := range h.Clients[outgoing.ID] {
78 | client.Send <- outgoing.Message
79 | }
80 | }
81 | }
82 | }
83 | }
84 |
85 | // WSClient ..
86 | type WSClient struct {
87 | ID int
88 | Index int
89 | Hub *WSHub
90 | Conn *websocket.Conn
91 | Send chan []byte
92 | }
93 |
94 | // Reader ..
95 | func (wc *WSClient) Reader() {
96 | defer func() {
97 | wc.Hub.Unregister <- wc
98 | wc.Conn.Close()
99 | }()
100 |
101 | wc.Conn.SetReadLimit(512)
102 | for {
103 | _, message, err := wc.Conn.ReadMessage()
104 | if err != nil {
105 | fmt.Println("Read Error", err.Error())
106 | break
107 | }
108 |
109 | message = bytes.TrimSpace(bytes.Replace(message, newline, space, -1))
110 | wc.Hub.Incoming <- message
111 | }
112 | }
113 |
114 | // Writer ..
115 | func (wc *WSClient) Writer() {
116 | for {
117 | select {
118 | case message := <-wc.Send:
119 | wc.Conn.WriteMessage(websocket.TextMessage, message)
120 | }
121 | }
122 | }
123 |
124 | // HandleWebsocket ..
125 | func (c *ApplicationContext) HandleWebsocket(w http.ResponseWriter, r *http.Request) {
126 | tokenString := r.FormValue("token")
127 | token, err := VerifyJWT(tokenString)
128 | if err != nil {
129 | log.Println("Unauthenticated ", err.Error())
130 | c.NewResponse(w).Status(403)
131 | return
132 | }
133 | claims := token.Claims.(jwt.MapClaims)
134 | id := int(claims["id"].(float64))
135 |
136 | conn, err := upgrader.Upgrade(w, r, nil)
137 | if err != nil {
138 | log.Println("Unable to upgrade websocket connection ", err.Error())
139 | return
140 | }
141 |
142 | client := &WSClient{
143 | ID: id,
144 | Hub: c.Hub,
145 | Conn: conn,
146 | Send: make(chan []byte, 512),
147 | }
148 | client.Hub.Register <- client
149 |
150 | go client.Reader()
151 | go client.Writer()
152 | }
153 |
154 | // VerifyJWT decodes a JWT and returns the token
155 | func VerifyJWT(tokenString string) (*jwt.Token, error) {
156 | return jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
157 | return []byte("Secret"), nil
158 | })
159 | }
160 |
--------------------------------------------------------------------------------
/controllers/youtube.go:
--------------------------------------------------------------------------------
1 | package controllers
2 |
3 | import (
4 | "fmt"
5 | "net/http"
6 | "os"
7 | "strconv"
8 |
9 | "github.com/google/uuid"
10 | "github.com/praveen001/go-rtmp-web-server/models"
11 | "golang.org/x/net/context"
12 | "golang.org/x/oauth2"
13 | "golang.org/x/oauth2/google"
14 | "google.golang.org/api/youtube/v3"
15 | )
16 |
17 | var conf *oauth2.Config
18 |
19 | // InitYoutube will initialize a youtube config
20 | func InitYoutube() {
21 | conf = &oauth2.Config{
22 | ClientID: os.Getenv("YOUTUBE_CLIENT_ID"),
23 | ClientSecret: os.Getenv("YOUTUBE_CLIENT_SECRET"),
24 | RedirectURL: os.Getenv("API_ENDPOINT") + "/v1/api/channels/youtube/callback",
25 | Scopes: []string{
26 | "https://www.googleapis.com/auth/userinfo.email",
27 | "https://www.googleapis.com/auth/youtube.readonly",
28 | "https://www.googleapis.com/auth/youtubepartner-channel-audit",
29 | },
30 | Endpoint: google.Endpoint,
31 | }
32 | }
33 |
34 | // GoogleAuth ..
35 | func (c *ApplicationContext) GoogleAuth(w http.ResponseWriter, r *http.Request) {
36 | redisConn := c.RedisPool.Get()
37 | defer redisConn.Close()
38 |
39 | id := uuid.New().String()
40 | userID := int(r.Context().Value("id").(float64))
41 | streamID, err := strconv.Atoi(r.FormValue("streamId"))
42 | if err != nil {
43 | c.NewResponse(w).Status(400).SendJSON(Response{
44 | Error: true,
45 | Message: "Invalid Stream ID",
46 | })
47 | return
48 | }
49 |
50 | // Check whether stream belongs to user
51 | stream := &models.Stream{
52 | Model: models.Model{
53 | ID: streamID,
54 | },
55 | }
56 | c.DB.Find(stream, stream)
57 |
58 | if stream.UserID != userID {
59 | c.NewResponse(w).Status(403).SendJSON(Response{
60 | Error: true,
61 | Message: "Unauthorized",
62 | })
63 | return
64 | }
65 |
66 | redisConn.Do("SET", id, streamID)
67 | http.Redirect(w, r, conf.AuthCodeURL(id), http.StatusSeeOther)
68 | }
69 |
70 | // GoogleAuthCallback ..
71 | func (c *ApplicationContext) GoogleAuthCallback(w http.ResponseWriter, r *http.Request) {
72 | redisConn := c.RedisPool.Get()
73 | defer redisConn.Close()
74 |
75 | id := r.FormValue("state")
76 | rawStreamID, err := redisConn.Do("GET", id)
77 | redisConn.Do("DELETE", id)
78 | if err != nil {
79 | fmt.Println("Error occured")
80 | return
81 | }
82 | streamID, _ := strconv.Atoi(string(rawStreamID.([]byte)))
83 |
84 | code := r.FormValue("code")
85 |
86 | // Exchange authorization code for access token
87 | token, err := conf.Exchange(context.Background(), code)
88 | if err != nil {
89 | fmt.Println("Error occured", err.Error())
90 | return
91 | }
92 |
93 | // Create a youtube api client
94 | y, _ := youtube.New(conf.Client(context.Background(), token))
95 |
96 | // live broadcast list api call to get default live broadcast
97 | liveBroadcastListResponse, err := y.LiveBroadcasts.List([]string{"id", "status", "snippet", "contentDetails"}).BroadcastType("persistent").Mine(true).Do()
98 | if err != nil {
99 | c.NewResponse(w).Status(200).SendJSON(Response{
100 | Data: err.Error(),
101 | })
102 | return
103 | }
104 | boundStreamID := liveBroadcastListResponse.Items[0].ContentDetails.BoundStreamId
105 |
106 | // live stream list api call to get default live stream associated to default live broadcast using bound stream id of live broadcast
107 | liveStreamListResponse, err := y.LiveStreams.List([]string{"id", "status", "snippet", "cdn"}).Id(boundStreamID).Do()
108 | if err != nil {
109 | c.NewResponse(w).Status(200).SendJSON(Response{
110 | Data: err.Error(),
111 | })
112 | return
113 | }
114 | defaultStream := liveStreamListResponse.Items[0]
115 |
116 | // Create the youtube channel
117 | channel := &models.Channel{
118 | Name: "Youtube",
119 | URL: defaultStream.Cdn.IngestionInfo.IngestionAddress,
120 | Key: defaultStream.Cdn.IngestionInfo.StreamName,
121 | StreamID: streamID,
122 | Enabled: true,
123 | }
124 |
125 | // Add the channel and save it
126 | c.DB.Save(channel)
127 | http.Redirect(w, r, os.Getenv("UI_ENDPOINT")+"/streams/"+strconv.Itoa(streamID)+"/dashboard", http.StatusSeeOther)
128 | }
129 |
--------------------------------------------------------------------------------
/rtmp/chunk.go:
--------------------------------------------------------------------------------
1 | package rtmp
2 |
3 | import (
4 | "encoding/binary"
5 | "math"
6 |
7 | "github.com/praveen001/joy4/utils/bits/pio"
8 | )
9 |
10 | type rtmpChunk struct {
11 | header *chunkHeader
12 | clock uint32
13 | delta uint32
14 | payload []byte
15 | bytes int
16 | capacity uint32
17 | }
18 |
19 | type chunkHeader struct {
20 | fmt uint8
21 | csid uint32
22 | timestamp uint32
23 | length uint32
24 | messageType uint8
25 | messageStreamID uint32
26 | hasExtendedTimestamp bool
27 | }
28 |
29 | var chunkHeaderSize = map[uint8]int{
30 | 0: 11,
31 | 1: 7,
32 | 2: 3,
33 | 3: 0,
34 | }
35 |
36 | func (c *Connection) createRtmpChunk(fmt uint8, csid uint32) *rtmpChunk {
37 | header := &chunkHeader{
38 | fmt: fmt,
39 | csid: csid,
40 | timestamp: 0,
41 | length: 0,
42 | messageType: 0,
43 | messageStreamID: 0,
44 | hasExtendedTimestamp: false,
45 | }
46 |
47 | chunk := &rtmpChunk{
48 | header: header,
49 | clock: 0,
50 | delta: 0,
51 | bytes: 0,
52 | capacity: 0,
53 | }
54 |
55 | return chunk
56 | }
57 |
58 | func (c Connection) create(chunk *rtmpChunk) [][]byte {
59 | basicHeader := chunk.createBasicHeader()
60 | messageHeader := chunk.createMessageHeader()
61 | extendedTimestamp := chunk.createExtendedTimestamp()
62 | payloads := chunk.createPayloadArray(c)
63 |
64 | chunks := make([][]byte, len(payloads))
65 | var bytes []byte
66 | bytes = append(bytes, basicHeader...)
67 | bytes = append(bytes, messageHeader...)
68 | bytes = append(bytes, extendedTimestamp...)
69 | bytes = append(bytes, payloads[0]...)
70 |
71 | chunks[0] = bytes
72 |
73 | for i := 1; i < len(payloads); i++ {
74 | tempChunk := rtmpChunk{
75 | header: &chunkHeader{
76 | fmt: 3,
77 | csid: chunk.header.csid,
78 | },
79 | payload: payloads[i],
80 | }
81 | basicHeader = tempChunk.createBasicHeader()
82 |
83 | chunks[i] = append(basicHeader, payloads[i]...)
84 | }
85 |
86 | return chunks
87 | }
88 |
89 | func (chunk rtmpChunk) createBasicHeader() []byte {
90 | var res []byte
91 | if chunk.header.csid >= 64+255 {
92 | res = make([]byte, 3)
93 | res[0] = (chunk.header.fmt << 6) | 1
94 | res[1] = uint8((chunk.header.csid - 64) & 0xFF)
95 | res[2] = uint8(((chunk.header.csid - 64) >> 8) & 0xFF)
96 | } else if chunk.header.csid >= 64 {
97 | res = make([]byte, 2)
98 | res[0] = (chunk.header.fmt << 6) | 0
99 | res[1] = uint8((chunk.header.csid - 64) & 0xFF)
100 | } else {
101 | res = make([]byte, 1)
102 | res[0] = (chunk.header.fmt << 6) | uint8(chunk.header.csid)
103 | }
104 |
105 | return res
106 | }
107 |
108 | func (chunk *rtmpChunk) createMessageHeader() []byte {
109 | res := make([]byte, chunkHeaderSize[chunk.header.fmt])
110 |
111 | if chunk.header.fmt <= 2 {
112 | if chunk.header.timestamp >= 0xffffff {
113 | pio.PutU24BE(res, 0xffffff)
114 | chunk.header.hasExtendedTimestamp = true
115 | } else {
116 | pio.PutU24BE(res, chunk.header.timestamp)
117 | }
118 | }
119 |
120 | if chunk.header.fmt <= 1 {
121 | pio.PutU24BE(res[3:], chunk.header.length)
122 | res[6] = chunk.header.messageType
123 | }
124 |
125 | if chunk.header.fmt == 0 {
126 | binary.LittleEndian.PutUint32(res[7:], chunk.header.messageStreamID)
127 | }
128 |
129 | return res
130 | }
131 |
132 | func (chunk rtmpChunk) createExtendedTimestamp() []byte {
133 | if chunk.header.hasExtendedTimestamp {
134 | res := make([]byte, 4)
135 | binary.BigEndian.PutUint32(res, chunk.header.timestamp)
136 | return res
137 | }
138 | return make([]byte, 0)
139 | }
140 |
141 | func (chunk rtmpChunk) createPayloadArray(c Connection) [][]byte {
142 | totalChunks := int(math.Ceil(float64(float64(chunk.header.length) / float64(c.WriteMaxChunkSize))))
143 | if totalChunks == 0 {
144 | totalChunks = 1
145 | }
146 | payloads := make([][]byte, totalChunks)
147 |
148 | offset := 0
149 | for i := 0; i < totalChunks; i++ {
150 | size := int(chunk.header.length) - offset
151 | if size > c.WriteMaxChunkSize {
152 | size = c.WriteMaxChunkSize
153 | }
154 | payloads[i] = chunk.payload[offset : offset+size]
155 | offset += size
156 | }
157 |
158 | return payloads
159 | }
160 |
--------------------------------------------------------------------------------
/controllers/twitch.go:
--------------------------------------------------------------------------------
1 | package controllers
2 |
3 | import (
4 | "encoding/json"
5 | "fmt"
6 | "io/ioutil"
7 | "net/http"
8 | "os"
9 | "strconv"
10 |
11 | "github.com/google/uuid"
12 | "github.com/praveen001/go-rtmp-web-server/models"
13 | )
14 |
15 | var clientID string
16 | var clientSecret string
17 | var redirectURL string
18 |
19 | // InitTwitch will initialize twitch api configs
20 | func InitTwitch() {
21 | clientID = os.Getenv("TWITCH_CLIENT_ID")
22 | clientSecret = os.Getenv("TWITCH_CLIENT_SECRET")
23 | redirectURL = os.Getenv("API_ENDPOINT") + "/v1/api/channels/twitch/callback"
24 | }
25 |
26 | // TwitchAuth ..
27 | func (c *ApplicationContext) TwitchAuth(w http.ResponseWriter, r *http.Request) {
28 | redisConn := c.RedisPool.Get()
29 | defer redisConn.Close()
30 |
31 | id := uuid.New().String()
32 | userID := int(r.Context().Value("id").(float64))
33 | streamID, err := strconv.Atoi(r.FormValue("streamId"))
34 | if err != nil {
35 | c.NewResponse(w).Status(400).SendJSON(Response{
36 | Error: true,
37 | Message: "Invalid Stream ID",
38 | })
39 | return
40 | }
41 |
42 | // Check whether stream belongs to user
43 | stream := &models.Stream{
44 | Model: models.Model{
45 | ID: streamID,
46 | },
47 | }
48 | c.DB.Find(stream, stream)
49 |
50 | if stream.UserID != userID {
51 | c.NewResponse(w).Status(403).SendJSON(Response{
52 | Error: true,
53 | Message: "Unauthorized",
54 | })
55 | return
56 | }
57 |
58 | redisConn.Do("SET", id, streamID)
59 | http.Redirect(w, r, "https://id.twitch.tv/oauth2/authorize?client_id="+clientID+"&redirect_uri="+redirectURL+"&response_type=code&scope=channel:read:stream_key channel_read user_read channel_editor channel_stream&state="+id, http.StatusSeeOther)
60 | }
61 |
62 | // TwitchAuthCallback ..
63 | func (c *ApplicationContext) TwitchAuthCallback(w http.ResponseWriter, r *http.Request) {
64 | redisConn := c.RedisPool.Get()
65 | defer redisConn.Close()
66 |
67 | id := r.FormValue("state")
68 | rawStreamID, err := redisConn.Do("GET", id)
69 | redisConn.Do("DELETE", id)
70 | if err != nil {
71 | fmt.Println("Error occured", err)
72 | return
73 | }
74 | streamID, _ := strconv.Atoi(string(rawStreamID.([]byte)))
75 |
76 | token := r.FormValue("code")
77 |
78 | client := &http.Client{}
79 |
80 | // Exchange authorization code for access token
81 | req, _ := http.NewRequest("POST", "https://id.twitch.tv/oauth2/token?client_id="+clientID+"&client_secret="+clientSecret+"&code="+token+"&grant_type=authorization_code&redirect_uri="+redirectURL, nil)
82 | res, err := client.Do(req)
83 | if err != nil {
84 | fmt.Println("Twitch Request Error 1", err)
85 | return
86 | }
87 |
88 | // Read the body
89 | body, _ := ioutil.ReadAll(res.Body)
90 | var response1 struct {
91 | AccessToken string `json:"access_token"`
92 | }
93 |
94 | if err := json.Unmarshal(body, &response1); err != nil {
95 | fmt.Println("Unable to read response 1", err)
96 | }
97 | accessToken := response1.AccessToken
98 |
99 | // Validate the access token
100 | req, _ = http.NewRequest("GET", "https://id.twitch.tv/oauth2/validate", nil)
101 | req.Header.Add("Authorization", "OAuth "+accessToken)
102 | res, err = client.Do(req)
103 | if err != nil {
104 | fmt.Println("Unable to validate twitch access token", err)
105 | }
106 |
107 | body, _ = ioutil.ReadAll(res.Body)
108 | var response2 struct {
109 | UserID string `json:"user_id"`
110 | }
111 |
112 | if err := json.Unmarshal(body, &response2); err != nil {
113 | fmt.Println("Unable to read response 2", err)
114 | }
115 | twitchUserID := response2.UserID
116 |
117 | // Get Stream Key
118 | req, _ = http.NewRequest("GET", "https://api.twitch.tv/helix/streams/key?broadcaster_id="+twitchUserID, nil)
119 | req.Header.Add("Client-ID", clientID)
120 | req.Header.Add("Authorization", "Bearer "+accessToken)
121 | res, err = client.Do(req)
122 | if err != nil {
123 | fmt.Println("Twitch Request Error 2", err)
124 | }
125 |
126 | body, _ = ioutil.ReadAll(res.Body)
127 | var response3 struct {
128 | Data []struct {
129 | StreamKey string `json:"stream_key"`
130 | } `json:"data"`
131 | }
132 |
133 | if err := json.Unmarshal(body, &response3); err != nil {
134 | fmt.Println("Unable to read response 3", err)
135 | }
136 |
137 | channel := &models.Channel{
138 | Name: "Twitch",
139 | URL: "rtmp://live-sin.twitch.tv/app",
140 | Key: response3.Data[0].StreamKey,
141 | StreamID: streamID,
142 | Enabled: true,
143 | }
144 | c.DB.Save(channel)
145 |
146 | http.Redirect(w, r, os.Getenv("UI_ENDPOINT")+"/streams/"+strconv.Itoa(streamID)+"/dashboard", http.StatusSeeOther)
147 | }
148 |
--------------------------------------------------------------------------------
/frontend/src/components/Streams/Streams.tsx:
--------------------------------------------------------------------------------
1 | import Button from '@material-ui/core/Button';
2 | import Paper from '@material-ui/core/Paper';
3 | import {
4 | createStyles,
5 | Theme,
6 | withStyles,
7 | WithStyles
8 | } from '@material-ui/core/styles';
9 | import Typography from '@material-ui/core/Typography';
10 | import classnames from 'classnames';
11 | import React from 'react';
12 | import NewStreamContainer from '../../containers/NewStreamContainer';
13 | import { ApiStatus, IStats, IStream } from '../../models';
14 | import VideoPlayer from '../Player/Player';
15 | import Title from '../Title/Title';
16 |
17 | const styles = (theme: Theme) =>
18 | createStyles({
19 | wrap: {
20 | display: 'flex',
21 | flexWrap: 'wrap',
22 | '& > :nth-child(3n)': {
23 | paddingRight: 0
24 | }
25 | },
26 | streamWrap: {
27 | paddingRight: theme.spacing.unit,
28 | paddingBottom: theme.spacing.unit,
29 | width: `33.3%`
30 | },
31 | stream: {
32 | padding: theme.spacing.unit
33 | },
34 | streamOnline: {
35 | border: '1px solid green'
36 | },
37 | player: {
38 | background: '#000',
39 | height: 250,
40 | width: '100%',
41 | marginBottom: theme.spacing.unit
42 | },
43 | controls: {
44 | display: 'flex',
45 | justifyContent: 'space-between',
46 | alignItems: 'center'
47 | },
48 | actions: {
49 | display: 'flex',
50 | '& > *': {
51 | marginLeft: theme.spacing.unit
52 | }
53 | }
54 | });
55 |
56 | class Streams extends React.Component {
57 | deleteStream = (streamId: number) => () => {
58 | this.props.deleteStream(streamId);
59 | };
60 |
61 | getOptions = (stream: IStream) => {
62 | const videoJsOptions = {
63 | autoplay: true,
64 | controls: false,
65 | muted: true,
66 | sources: [
67 | {
68 | src: `/hls-preview/${stream.key}/index.m3u8`,
69 | type: 'application/x-mpegURL'
70 | }
71 | ]
72 | };
73 |
74 | return videoJsOptions;
75 | };
76 |
77 | render() {
78 | const { classes, streams, statsById, openNewStreamDialog } = this.props;
79 |
80 | return (
81 |
82 |
83 | My Streams
84 |
91 |
92 |
93 | {streams.map(stream => (
94 |
95 |
102 |
103 | {statsById[stream.id] && statsById[stream.id].online && (
104 |
105 | )}
106 |
107 |
108 |
{stream.name}
109 |
110 |
118 |
126 |
127 |
128 |
129 |
130 | ))}
131 |
132 |
133 |
134 | );
135 | }
136 | }
137 |
138 | export default withStyles(styles)(Streams);
139 |
140 | export interface IStreamsStateProps {
141 | streams: IStream[];
142 | statsById: { [key: number]: IStats };
143 | }
144 |
145 | export interface IStreamsDispatchProps {
146 | openNewStreamDialog: () => {};
147 | deleteStream: (streamId: number) => void;
148 | }
149 |
150 | type StreamsProps = IStreamsStateProps &
151 | IStreamsDispatchProps &
152 | WithStyles;
153 |
--------------------------------------------------------------------------------
/frontend/src/components/AddChannel/AddChannel.tsx:
--------------------------------------------------------------------------------
1 | import ButtonBase from '@material-ui/core/ButtonBase';
2 | import Dialog from '@material-ui/core/Dialog';
3 | import Divider from '@material-ui/core/Divider';
4 | import IconButton from '@material-ui/core/IconButton';
5 | import {
6 | createStyles,
7 | Theme,
8 | withStyles,
9 | WithStyles
10 | } from '@material-ui/core/styles';
11 | import Typography from '@material-ui/core/Typography';
12 | import CloseIcon from '@material-ui/icons/Close';
13 | import React from 'react';
14 | import { BaseUrl } from '../../config';
15 | import ChannelContainer from '../../containers/ChannelContainer';
16 | import Facebook from '../Icons/Facebook';
17 | import Periscope from '../Icons/Periscope';
18 | import Twitch from '../Icons/Twitch';
19 | import Youtube from '../Icons/Youtube';
20 | import Title from '../Title/Title';
21 |
22 | const styles = (theme: Theme) =>
23 | createStyles({
24 | dialog: {
25 | padding: theme.spacing.unit
26 | },
27 | wrap: {
28 | display: 'flex',
29 | padding: theme.spacing.unit
30 | },
31 | channelListWrap: {
32 | display: 'flex',
33 | justifyContent: 'center',
34 | flexDirection: 'column'
35 | },
36 | channelList: {
37 | display: 'flex',
38 | flexWrap: 'wrap'
39 | },
40 | channelTile: {
41 | width: 150,
42 | height: 120,
43 | background: '#333',
44 | display: 'flex',
45 | alignItems: 'center',
46 | justifyContent: 'center',
47 | flexDirection: 'column',
48 | color: '#fff',
49 | margin: theme.spacing.unit
50 | },
51 | logo: {
52 | width: '100%'
53 | },
54 | title: {
55 | flex: 1
56 | }
57 | });
58 |
59 | class AddChannel extends React.Component {
60 | add = (name: string) => () => {
61 | window.location.replace(
62 | `${BaseUrl}/channels/${name}?streamId=${this.props.streamId}`
63 | );
64 | };
65 |
66 | render() {
67 | const { classes, open, hideAddChannel } = this.props;
68 |
69 | return (
70 |
133 | );
134 | }
135 | }
136 |
137 | export default withStyles(styles)(AddChannel);
138 |
139 | export interface IAddChannelStatusProps {
140 | open: boolean;
141 | streamId: number;
142 | }
143 |
144 | export interface IAddChannelDispatchProps {
145 | hideAddChannel: () => {};
146 | }
147 |
148 | type AddChannelProps = IAddChannelStatusProps &
149 | IAddChannelDispatchProps &
150 | WithStyles;
151 |
--------------------------------------------------------------------------------
/frontend/src/actions/channelActions.ts:
--------------------------------------------------------------------------------
1 | import { IChannel } from '../models';
2 |
3 | export enum ChannelActionTypes {
4 | ADD_CHANNEL = 'channels/Add',
5 | ADDING_CHANNEL = 'channels/addingChannel',
6 | ADDED_CHANNEL = 'channels/addedChannel',
7 | FETCH_CHANNEL_LIST = 'channels/fetch',
8 | FETCHING_CHANNEL_LIST = 'channels/fetchingList',
9 | FETCHED_CHANNEL_LIST = 'channels/fetchedList',
10 | SHOW_ADD_CHANNEL = 'channels/showAddChannel',
11 | HIDE_ADD_CHANNEL = 'channels/hideAddChannel',
12 | DELETE_CHANNEL = 'channels/deleteChannel',
13 | DELETE_CHANNEL_FAILURE = 'channels/deleteChannelFailure',
14 | DELETE_CHANNEL_SUCCESS = 'channels/deleteChannelSuccess'
15 | }
16 |
17 | export function addChannel(
18 | streamId: number,
19 | name: string,
20 | url: string,
21 | key: string
22 | ): IAddChannelAction {
23 | return {
24 | type: ChannelActionTypes.ADD_CHANNEL,
25 | payload: {
26 | streamId,
27 | name,
28 | url,
29 | key
30 | }
31 | };
32 | }
33 |
34 | export function addingChannel(): IAddingChannelAction {
35 | return { type: ChannelActionTypes.ADDING_CHANNEL };
36 | }
37 |
38 | export function addedChannel(): IAddedChannelAction {
39 | return { type: ChannelActionTypes.ADDED_CHANNEL };
40 | }
41 |
42 | export function fetchChannelList(streamId: string): IFetchChannelListAction {
43 | return {
44 | type: ChannelActionTypes.FETCH_CHANNEL_LIST,
45 | payload: {
46 | streamId
47 | }
48 | };
49 | }
50 |
51 | export function fetchingChannelList(): IFetchingChannelListAction {
52 | return {
53 | type: ChannelActionTypes.FETCHING_CHANNEL_LIST
54 | };
55 | }
56 |
57 | export function fetchedChannelList(
58 | channels: IChannel[]
59 | ): IFetchedChannelListAction {
60 | return {
61 | type: ChannelActionTypes.FETCHED_CHANNEL_LIST,
62 | payload: { channels }
63 | };
64 | }
65 |
66 | export function showAddChannel(): IShowAddChannelAction {
67 | return {
68 | type: ChannelActionTypes.SHOW_ADD_CHANNEL
69 | };
70 | }
71 |
72 | export function hideAddChannel(): IHideAddChannelAction {
73 | return {
74 | type: ChannelActionTypes.HIDE_ADD_CHANNEL
75 | };
76 | }
77 |
78 | export function deleteChannel(
79 | streamId: number,
80 | channelId: number
81 | ): IDeleteChannelAction {
82 | return {
83 | type: ChannelActionTypes.DELETE_CHANNEL,
84 | payload: {
85 | streamId,
86 | channelId
87 | }
88 | };
89 | }
90 |
91 | export function deleteChannelFailure(): IDeleteChannelFailureAction {
92 | return {
93 | type: ChannelActionTypes.DELETE_CHANNEL_FAILURE
94 | };
95 | }
96 |
97 | export function deleteChannelSuccess(
98 | streamId: number,
99 | channelId: number
100 | ): IDeleteChannelSuccessAction {
101 | return {
102 | type: ChannelActionTypes.DELETE_CHANNEL_SUCCESS,
103 | payload: { streamId, channelId }
104 | };
105 | }
106 |
107 | export interface IAddChannelAction {
108 | type: ChannelActionTypes.ADD_CHANNEL;
109 | payload: {
110 | streamId: number;
111 | name: string;
112 | url: string;
113 | key: string;
114 | };
115 | }
116 |
117 | export interface IAddingChannelAction {
118 | type: ChannelActionTypes.ADDING_CHANNEL;
119 | }
120 |
121 | export interface IAddedChannelAction {
122 | type: ChannelActionTypes.ADDED_CHANNEL;
123 | }
124 |
125 | export interface IFetchChannelListAction {
126 | type: ChannelActionTypes.FETCH_CHANNEL_LIST;
127 | payload: {
128 | streamId: string;
129 | };
130 | }
131 |
132 | export interface IFetchingChannelListAction {
133 | type: ChannelActionTypes.FETCHING_CHANNEL_LIST;
134 | }
135 |
136 | export interface IFetchedChannelListAction {
137 | type: ChannelActionTypes.FETCHED_CHANNEL_LIST;
138 | payload: {
139 | channels: IChannel[];
140 | };
141 | }
142 |
143 | export interface IShowAddChannelAction {
144 | type: ChannelActionTypes.SHOW_ADD_CHANNEL;
145 | }
146 |
147 | export interface IHideAddChannelAction {
148 | type: ChannelActionTypes.HIDE_ADD_CHANNEL;
149 | }
150 |
151 | export interface IDeleteChannelAction {
152 | type: ChannelActionTypes.DELETE_CHANNEL;
153 | payload: {
154 | streamId: number;
155 | channelId: number;
156 | };
157 | }
158 |
159 | export interface IDeleteChannelFailureAction {
160 | type: ChannelActionTypes.DELETE_CHANNEL_FAILURE;
161 | }
162 |
163 | export interface IDeleteChannelSuccessAction {
164 | type: ChannelActionTypes.DELETE_CHANNEL_SUCCESS;
165 | payload: {
166 | streamId: number;
167 | channelId: number;
168 | };
169 | }
170 |
171 | export type ChannelAction =
172 | | IAddChannelAction
173 | | IAddingChannelAction
174 | | IAddedChannelAction
175 | | IFetchChannelListAction
176 | | IFetchingChannelListAction
177 | | IFetchedChannelListAction
178 | | IShowAddChannelAction
179 | | IHideAddChannelAction
180 | | IDeleteChannelAction
181 | | IDeleteChannelFailureAction
182 | | IDeleteChannelSuccessAction;
183 |
--------------------------------------------------------------------------------
/controllers/app_context.go:
--------------------------------------------------------------------------------
1 | package controllers
2 |
3 | import (
4 | "context"
5 | "encoding/json"
6 | "errors"
7 | "fmt"
8 | "log"
9 | "net/http"
10 |
11 | jwt "github.com/dgrijalva/jwt-go"
12 | "github.com/go-chi/chi/middleware"
13 | "github.com/gomodule/redigo/redis"
14 | "github.com/rs/cors"
15 |
16 | "github.com/jinzhu/gorm"
17 | )
18 |
19 | // 813004830141-2leap7abkjf00bsofqcdn6a6po8l9348.apps.googleusercontent.com
20 |
21 | // ApplicationContext data which has to be passed to all the controllers.
22 | //
23 | // Note all the data you put into this are safe for concurrent access
24 | type ApplicationContext struct {
25 | DB *gorm.DB
26 | Hub *WSHub
27 | PubSub *PubSub
28 | RedisPool *redis.Pool
29 | }
30 |
31 | // HandlerFunc is a custom handler
32 | type HandlerFunc func(ResponseWriter, *http.Request)
33 |
34 | // ResponseWriter is our custom wrapper over http.ResponseWriter
35 | type ResponseWriter struct {
36 | http.ResponseWriter
37 | }
38 |
39 | // Response is the object used as a json template for http response
40 | type Response struct {
41 | Message string `json:"message"`
42 | Data interface{} `json:"data"`
43 | Error bool `json:"error"`
44 | }
45 |
46 | // Write calls http.ResponseWriter.Write
47 | // It will write bytes into response
48 | func (r ResponseWriter) Write(b []byte) (int, error) {
49 | return r.ResponseWriter.Write(b)
50 | }
51 |
52 | // WriteHeader calls http.ResponseWriter.WriteHeader
53 | // Sets http status code
54 | func (r ResponseWriter) WriteHeader(statusCode int) {
55 | r.ResponseWriter.WriteHeader(statusCode)
56 | }
57 |
58 | // Header calls http.ResponseWriter.Header
59 | // Returns http header
60 | func (r ResponseWriter) Header() http.Header {
61 | return r.ResponseWriter.Header()
62 | }
63 |
64 | // Status will set the http status code, and returns the same object
65 | func (r ResponseWriter) Status(statusCode int) ResponseWriter {
66 | r.ResponseWriter.WriteHeader(statusCode)
67 | return r
68 | }
69 |
70 | // SendJSON will write JSON data to response
71 | func (r ResponseWriter) SendJSON(data Response) error {
72 | if err := json.NewEncoder(r.ResponseWriter).Encode(data); err != nil {
73 | return err
74 | }
75 | return nil
76 | }
77 |
78 | // NewResponse ..
79 | func (c *ApplicationContext) NewResponse(w http.ResponseWriter) ResponseWriter {
80 | return ResponseWriter{w}
81 | }
82 |
83 | // RecoveryHandler returns 500 status when handler panics.
84 | // Writes error to application log
85 | func (c *ApplicationContext) RecoveryHandler(h http.Handler) http.Handler {
86 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
87 | var err error
88 | defer func() {
89 | if e := recover(); e != nil {
90 | switch t := e.(type) {
91 | case string:
92 | err = errors.New(t)
93 | case error:
94 | err = t
95 | default:
96 | err = errors.New("Unknown error")
97 | }
98 | log.Panicln(err.Error())
99 | http.Error(w, err.Error(), http.StatusInternalServerError)
100 | }
101 | }()
102 |
103 | h.ServeHTTP(w, r)
104 | })
105 | }
106 |
107 | // CORSHandler handles cors requests
108 | func (c *ApplicationContext) CORSHandler(h http.Handler) http.Handler {
109 | return cors.New(cors.Options{
110 | AllowedOrigins: []string{"*"},
111 | AllowedHeaders: []string{"authorization", "content-type"},
112 | AllowedMethods: []string{http.MethodGet, http.MethodPost, http.MethodPut, http.MethodDelete},
113 | }).Handler(h)
114 | }
115 |
116 | // LogHandler writes access log
117 | func (c *ApplicationContext) LogHandler(h http.Handler) http.Handler {
118 | return middleware.DefaultLogger(h)
119 | }
120 |
121 | // Authentication middleware decode the jwt token found in 'Authorization' header
122 | // and add the decoded information to the request context
123 | func (c *ApplicationContext) Authentication(next http.HandlerFunc) http.HandlerFunc {
124 | return func(w http.ResponseWriter, r *http.Request) {
125 | authToken := r.Header.Get("Authorization")
126 | if authToken == "" {
127 | authToken = getCookieByName(r.Cookies(), "token")
128 | }
129 |
130 | token, err := VerifyJWT(authToken)
131 | if err != nil || !token.Valid {
132 | c.NewResponse(w).Status(403).SendJSON(Response{
133 | Error: true,
134 | Message: "Unauthorized",
135 | })
136 | return
137 | }
138 |
139 | claims := token.Claims.(jwt.MapClaims)
140 | ctx := context.WithValue(r.Context(), "email", claims["email"])
141 | ctx = context.WithValue(ctx, "id", claims["id"])
142 | next(w, r.WithContext(ctx))
143 | }
144 | }
145 |
146 | // Logging logs the incoming requests
147 | func Logging(next http.HandlerFunc) http.HandlerFunc {
148 | return func(w http.ResponseWriter, r *http.Request) {
149 | fmt.Println(r.Method, r.RequestURI)
150 | next(w, r)
151 | }
152 | }
153 |
154 | func getCookieByName(cookie []*http.Cookie, name string) string {
155 | cookieLen := len(cookie)
156 | result := ""
157 | for i := 0; i < cookieLen; i++ {
158 | if cookie[i].Name == name {
159 | result = cookie[i].Value
160 | break
161 | }
162 | }
163 | return result
164 | }
165 |
--------------------------------------------------------------------------------
/frontend/src/actions/streamActions.ts:
--------------------------------------------------------------------------------
1 | import { IStream } from '../models';
2 |
3 | export enum StreamActionTypes {
4 | LOAD_STREAMS = 'streams/loadStreams',
5 | LOAD_STREAMS_FAILURE = 'streams/loadStreamsFailure',
6 | LOAD_STREAMS_SUCCESS = 'streams/loadStreamsSuccess',
7 | OPEN_NEW_STREAM_DIALOG = 'streams/openNewStreamDialog',
8 | CLOSE_NEW_STREAM_DIALOG = 'streams/closeNewStreamDialog',
9 | CREATE_STREAM = 'streams/createStream',
10 | CREATE_STREAM_FAILURE = 'streams/createStreamFailure',
11 | CREATE_SUCCESS_SUCCESS = 'streams/createStreamSuccess',
12 | SELECT_STREAM = 'streams/selectStream',
13 | DELETE_STREAM = 'streams/deleteStream',
14 | DELETE_STREAM_FAILURE = 'streams/deleteStreamFailure',
15 | DELETE_STREAM_SUCCESS = 'streams/deleteStreamSuccess'
16 | }
17 |
18 | export function loadStreams(): ILoadStreamsAction {
19 | return { type: StreamActionTypes.LOAD_STREAMS };
20 | }
21 |
22 | export function loadStreamsFailure(): ILoadStreamsFailureAction {
23 | return { type: StreamActionTypes.LOAD_STREAMS_FAILURE };
24 | }
25 |
26 | export function loadStreamsSuccess(
27 | streams: IStream[]
28 | ): ILoadStreamsSuccessAction {
29 | return { type: StreamActionTypes.LOAD_STREAMS_SUCCESS, payload: { streams } };
30 | }
31 |
32 | export function openNewStreamDialog() {
33 | return {
34 | type: StreamActionTypes.OPEN_NEW_STREAM_DIALOG
35 | };
36 | }
37 |
38 | export function closeNewStreamDialog() {
39 | return {
40 | type: StreamActionTypes.CLOSE_NEW_STREAM_DIALOG
41 | };
42 | }
43 |
44 | export function createStream(name: string): ICreateStreamAction {
45 | return {
46 | type: StreamActionTypes.CREATE_STREAM,
47 | payload: {
48 | name
49 | }
50 | };
51 | }
52 |
53 | export function createStreamFailure(): ICreateStreamFailureAction {
54 | return {
55 | type: StreamActionTypes.CREATE_STREAM_FAILURE
56 | };
57 | }
58 |
59 | export function createStreamSuccess(): ICreateStreamSuccessAction {
60 | return {
61 | type: StreamActionTypes.CREATE_SUCCESS_SUCCESS
62 | };
63 | }
64 |
65 | export function selectStream(streamId: string): ISelectStreamAction {
66 | return {
67 | type: StreamActionTypes.SELECT_STREAM,
68 | payload: {
69 | streamId: parseInt(streamId, 10)
70 | }
71 | };
72 | }
73 |
74 | export function deleteStream(streamId: number): IDeleteStreamAction {
75 | return {
76 | type: StreamActionTypes.DELETE_STREAM,
77 | payload: {
78 | streamId
79 | }
80 | };
81 | }
82 |
83 | export function deleteStreamFailure(
84 | streamId: number
85 | ): IDeleteStreamFailureAction {
86 | return {
87 | type: StreamActionTypes.DELETE_STREAM_FAILURE,
88 | payload: {
89 | streamId
90 | }
91 | };
92 | }
93 |
94 | export function deleteStreamSuccess(
95 | streamId: number
96 | ): IDeleteStreamSuccessAction {
97 | return {
98 | type: StreamActionTypes.DELETE_STREAM_SUCCESS,
99 | payload: {
100 | streamId
101 | }
102 | };
103 | }
104 |
105 | export interface ILoadStreamsAction {
106 | type: StreamActionTypes.LOAD_STREAMS;
107 | }
108 |
109 | export interface ILoadStreamsFailureAction {
110 | type: StreamActionTypes.LOAD_STREAMS_FAILURE;
111 | }
112 |
113 | export interface ILoadStreamsSuccessAction {
114 | type: StreamActionTypes.LOAD_STREAMS_SUCCESS;
115 | payload: {
116 | streams: IStream[];
117 | };
118 | }
119 |
120 | export interface IOpenNewStreamDialogAction {
121 | type: StreamActionTypes.OPEN_NEW_STREAM_DIALOG;
122 | }
123 |
124 | export interface ICloseNewStreamDialogAction {
125 | type: StreamActionTypes.CLOSE_NEW_STREAM_DIALOG;
126 | }
127 |
128 | export interface ICreateStreamAction {
129 | type: StreamActionTypes.CREATE_STREAM;
130 | payload: {
131 | name: string;
132 | };
133 | }
134 |
135 | export interface ICreateStreamFailureAction {
136 | type: StreamActionTypes.CREATE_STREAM_FAILURE;
137 | }
138 |
139 | export interface ICreateStreamSuccessAction {
140 | type: StreamActionTypes.CREATE_SUCCESS_SUCCESS;
141 | }
142 |
143 | export interface ISelectStreamAction {
144 | type: StreamActionTypes.SELECT_STREAM;
145 | payload: {
146 | streamId: number;
147 | };
148 | }
149 |
150 | export interface IDeleteStreamAction {
151 | type: StreamActionTypes.DELETE_STREAM;
152 | payload: {
153 | streamId: number;
154 | };
155 | }
156 |
157 | export interface IDeleteStreamFailureAction {
158 | type: StreamActionTypes.DELETE_STREAM_FAILURE;
159 | payload: {
160 | streamId: number;
161 | };
162 | }
163 |
164 | export interface IDeleteStreamSuccessAction {
165 | type: StreamActionTypes.DELETE_STREAM_SUCCESS;
166 | payload: {
167 | streamId: number;
168 | };
169 | }
170 |
171 | export type StreamAction =
172 | | ILoadStreamsAction
173 | | ILoadStreamsFailureAction
174 | | ILoadStreamsSuccessAction
175 | | IOpenNewStreamDialogAction
176 | | ICloseNewStreamDialogAction
177 | | ICreateStreamAction
178 | | ICreateStreamFailureAction
179 | | ICreateStreamSuccessAction
180 | | ISelectStreamAction
181 | | IDeleteStreamAction
182 | | IDeleteStreamFailureAction
183 | | IDeleteStreamSuccessAction;
184 |
--------------------------------------------------------------------------------
/scripts/wait-for-it.sh:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 | # Use this script to test if a given TCP host/port are available
3 |
4 | WAITFORIT_cmdname=${0##*/}
5 |
6 | echoerr() { if [[ $WAITFORIT_QUIET -ne 1 ]]; then echo "$@" 1>&2; fi }
7 |
8 | usage()
9 | {
10 | cat << USAGE >&2
11 | Usage:
12 | $WAITFORIT_cmdname host:port [-s] [-t timeout] [-- command args]
13 | -h HOST | --host=HOST Host or IP under test
14 | -p PORT | --port=PORT TCP port under test
15 | Alternatively, you specify the host and port as host:port
16 | -s | --strict Only execute subcommand if the test succeeds
17 | -q | --quiet Don't output any status messages
18 | -t TIMEOUT | --timeout=TIMEOUT
19 | Timeout in seconds, zero for no timeout
20 | -- COMMAND ARGS Execute command with args after the test finishes
21 | USAGE
22 | exit 1
23 | }
24 |
25 | wait_for()
26 | {
27 | if [[ $WAITFORIT_TIMEOUT -gt 0 ]]; then
28 | echoerr "$WAITFORIT_cmdname: waiting $WAITFORIT_TIMEOUT seconds for $WAITFORIT_HOST:$WAITFORIT_PORT"
29 | else
30 | echoerr "$WAITFORIT_cmdname: waiting for $WAITFORIT_HOST:$WAITFORIT_PORT without a timeout"
31 | fi
32 | WAITFORIT_start_ts=$(date +%s)
33 | while :
34 | do
35 | if [[ $WAITFORIT_ISBUSY -eq 1 ]]; then
36 | nc -z $WAITFORIT_HOST $WAITFORIT_PORT
37 | WAITFORIT_result=$?
38 | else
39 | (echo > /dev/tcp/$WAITFORIT_HOST/$WAITFORIT_PORT) >/dev/null 2>&1
40 | WAITFORIT_result=$?
41 | fi
42 | if [[ $WAITFORIT_result -eq 0 ]]; then
43 | WAITFORIT_end_ts=$(date +%s)
44 | echoerr "$WAITFORIT_cmdname: $WAITFORIT_HOST:$WAITFORIT_PORT is available after $((WAITFORIT_end_ts - WAITFORIT_start_ts)) seconds"
45 | break
46 | fi
47 | sleep 1
48 | done
49 | return $WAITFORIT_result
50 | }
51 |
52 | wait_for_wrapper()
53 | {
54 | # In order to support SIGINT during timeout: http://unix.stackexchange.com/a/57692
55 | if [[ $WAITFORIT_QUIET -eq 1 ]]; then
56 | timeout $WAITFORIT_BUSYTIMEFLAG $WAITFORIT_TIMEOUT $0 --quiet --child --host=$WAITFORIT_HOST --port=$WAITFORIT_PORT --timeout=$WAITFORIT_TIMEOUT &
57 | else
58 | timeout $WAITFORIT_BUSYTIMEFLAG $WAITFORIT_TIMEOUT $0 --child --host=$WAITFORIT_HOST --port=$WAITFORIT_PORT --timeout=$WAITFORIT_TIMEOUT &
59 | fi
60 | WAITFORIT_PID=$!
61 | trap "kill -INT -$WAITFORIT_PID" INT
62 | wait $WAITFORIT_PID
63 | WAITFORIT_RESULT=$?
64 | if [[ $WAITFORIT_RESULT -ne 0 ]]; then
65 | echoerr "$WAITFORIT_cmdname: timeout occurred after waiting $WAITFORIT_TIMEOUT seconds for $WAITFORIT_HOST:$WAITFORIT_PORT"
66 | fi
67 | return $WAITFORIT_RESULT
68 | }
69 |
70 | # process arguments
71 | while [[ $# -gt 0 ]]
72 | do
73 | case "$1" in
74 | *:* )
75 | WAITFORIT_hostport=(${1//:/ })
76 | WAITFORIT_HOST=${WAITFORIT_hostport[0]}
77 | WAITFORIT_PORT=${WAITFORIT_hostport[1]}
78 | shift 1
79 | ;;
80 | --child)
81 | WAITFORIT_CHILD=1
82 | shift 1
83 | ;;
84 | -q | --quiet)
85 | WAITFORIT_QUIET=1
86 | shift 1
87 | ;;
88 | -s | --strict)
89 | WAITFORIT_STRICT=1
90 | shift 1
91 | ;;
92 | -h)
93 | WAITFORIT_HOST="$2"
94 | if [[ $WAITFORIT_HOST == "" ]]; then break; fi
95 | shift 2
96 | ;;
97 | --host=*)
98 | WAITFORIT_HOST="${1#*=}"
99 | shift 1
100 | ;;
101 | -p)
102 | WAITFORIT_PORT="$2"
103 | if [[ $WAITFORIT_PORT == "" ]]; then break; fi
104 | shift 2
105 | ;;
106 | --port=*)
107 | WAITFORIT_PORT="${1#*=}"
108 | shift 1
109 | ;;
110 | -t)
111 | WAITFORIT_TIMEOUT="$2"
112 | if [[ $WAITFORIT_TIMEOUT == "" ]]; then break; fi
113 | shift 2
114 | ;;
115 | --timeout=*)
116 | WAITFORIT_TIMEOUT="${1#*=}"
117 | shift 1
118 | ;;
119 | --)
120 | shift
121 | WAITFORIT_CLI=("$@")
122 | break
123 | ;;
124 | --help)
125 | usage
126 | ;;
127 | *)
128 | echoerr "Unknown argument: $1"
129 | usage
130 | ;;
131 | esac
132 | done
133 |
134 | if [[ "$WAITFORIT_HOST" == "" || "$WAITFORIT_PORT" == "" ]]; then
135 | echoerr "Error: you need to provide a host and port to test."
136 | usage
137 | fi
138 |
139 | WAITFORIT_TIMEOUT=${WAITFORIT_TIMEOUT:-15}
140 | WAITFORIT_STRICT=${WAITFORIT_STRICT:-0}
141 | WAITFORIT_CHILD=${WAITFORIT_CHILD:-0}
142 | WAITFORIT_QUIET=${WAITFORIT_QUIET:-0}
143 |
144 | # check to see if timeout is from busybox?
145 | WAITFORIT_TIMEOUT_PATH=$(type -p timeout)
146 | WAITFORIT_TIMEOUT_PATH=$(realpath $WAITFORIT_TIMEOUT_PATH 2>/dev/null || readlink -f $WAITFORIT_TIMEOUT_PATH)
147 | if [[ $WAITFORIT_TIMEOUT_PATH =~ "busybox" ]]; then
148 | WAITFORIT_ISBUSY=1
149 | WAITFORIT_BUSYTIMEFLAG="-t"
150 |
151 | else
152 | WAITFORIT_ISBUSY=0
153 | WAITFORIT_BUSYTIMEFLAG=""
154 | fi
155 |
156 | if [[ $WAITFORIT_CHILD -gt 0 ]]; then
157 | wait_for
158 | WAITFORIT_RESULT=$?
159 | exit $WAITFORIT_RESULT
160 | else
161 | if [[ $WAITFORIT_TIMEOUT -gt 0 ]]; then
162 | wait_for_wrapper
163 | WAITFORIT_RESULT=$?
164 | else
165 | wait_for
166 | WAITFORIT_RESULT=$?
167 | fi
168 | fi
169 |
170 | if [[ $WAITFORIT_CLI != "" ]]; then
171 | if [[ $WAITFORIT_RESULT -ne 0 && $WAITFORIT_STRICT -eq 1 ]]; then
172 | echoerr "$WAITFORIT_cmdname: strict mode, refusing to execute subprocess"
173 | exit $WAITFORIT_RESULT
174 | fi
175 | exec "${WAITFORIT_CLI[@]}"
176 | else
177 | exit $WAITFORIT_RESULT
178 | fi
--------------------------------------------------------------------------------