init
This commit is contained in:
19
server/Dockerfile
Normal file
19
server/Dockerfile
Normal file
@@ -0,0 +1,19 @@
|
||||
FROM golang:1.26-alpine AS build
|
||||
|
||||
WORKDIR /src
|
||||
COPY server/go.mod server/go.sum* ./
|
||||
RUN go mod download
|
||||
|
||||
COPY server/ ./
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -o /out/synctv-server ./cmd/synctv-server
|
||||
|
||||
FROM alpine:3.22
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=build /out/synctv-server /app/synctv-server
|
||||
COPY config/server.json /app/config/server.json
|
||||
|
||||
ENV SYNCTV_CONFIG=/app/config/server.json
|
||||
EXPOSE 8080
|
||||
|
||||
CMD ["/app/synctv-server"]
|
||||
77
server/cmd/synctv-server/main.go
Normal file
77
server/cmd/synctv-server/main.go
Normal file
@@ -0,0 +1,77 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"synctv/server/internal/config"
|
||||
"synctv/server/internal/httpapi"
|
||||
"synctv/server/internal/proxy"
|
||||
"synctv/server/internal/room"
|
||||
"synctv/server/internal/store"
|
||||
"synctv/server/internal/ws"
|
||||
)
|
||||
|
||||
func main() {
|
||||
cfg, err := config.Load("")
|
||||
if err != nil {
|
||||
log.Fatalf("load config: %v", err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
redisStore := store.NewRedis(cfg.Redis.Addr, cfg.Redis.Password, cfg.Redis.DB, time.Duration(cfg.Room.EmptyTTL))
|
||||
defer redisStore.Close()
|
||||
if err := redisStore.Ping(ctx); err != nil {
|
||||
log.Fatalf("connect redis: %v", err)
|
||||
}
|
||||
|
||||
roomService := room.NewService(
|
||||
redisStore,
|
||||
cfg.Room.CodeLength,
|
||||
time.Duration(cfg.Room.EmptyTTL),
|
||||
time.Duration(cfg.Room.OwnerReconnectHold),
|
||||
)
|
||||
hub := ws.NewHub(
|
||||
roomService,
|
||||
cfg.HTTP.AllowedOrigins,
|
||||
time.Duration(cfg.WebSocket.PingInterval),
|
||||
time.Duration(cfg.WebSocket.PongTimeout),
|
||||
time.Duration(cfg.WebSocket.WriteTimeout),
|
||||
)
|
||||
streamProxy := proxy.New(
|
||||
roomService,
|
||||
cfg.Proxy.Enabled,
|
||||
time.Duration(cfg.Proxy.RequestTimeout),
|
||||
cfg.Proxy.MaxIdleConns,
|
||||
cfg.Proxy.ResponseHeaderBytes,
|
||||
)
|
||||
api := httpapi.New(roomService, hub, streamProxy)
|
||||
|
||||
server := &http.Server{
|
||||
Addr: cfg.HTTP.Addr,
|
||||
Handler: api.Routes(),
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
}
|
||||
|
||||
go func() {
|
||||
log.Printf("synctv server listening on %s", cfg.HTTP.Addr)
|
||||
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
log.Fatalf("listen: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
stop := make(chan os.Signal, 1)
|
||||
signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
|
||||
<-stop
|
||||
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
if err := server.Shutdown(shutdownCtx); err != nil {
|
||||
log.Printf("shutdown: %v", err)
|
||||
}
|
||||
}
|
||||
13
server/go.mod
Normal file
13
server/go.mod
Normal file
@@ -0,0 +1,13 @@
|
||||
module synctv/server
|
||||
|
||||
go 1.26
|
||||
|
||||
require (
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/redis/go-redis/v9 v9.17.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
)
|
||||
12
server/go.sum
Normal file
12
server/go.sum
Normal file
@@ -0,0 +1,12 @@
|
||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/redis/go-redis/v9 v9.17.1 h1:7tl732FjYPRT9H9aNfyTwKg9iTETjWjGKEJ2t/5iWTs=
|
||||
github.com/redis/go-redis/v9 v9.17.1/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
|
||||
123
server/internal/config/config.go
Normal file
123
server/internal/config/config.go
Normal file
@@ -0,0 +1,123 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
HTTP HTTPConfig `json:"http"`
|
||||
Redis RedisConfig `json:"redis"`
|
||||
Room RoomConfig `json:"room"`
|
||||
Proxy ProxyConfig `json:"proxy"`
|
||||
WebSocket WebSocketConfig `json:"websocket"`
|
||||
Source SourceConfig `json:"source"`
|
||||
}
|
||||
|
||||
type HTTPConfig struct {
|
||||
Addr string `json:"addr"`
|
||||
AllowedOrigins []string `json:"allowedOrigins"`
|
||||
}
|
||||
|
||||
type RedisConfig struct {
|
||||
Addr string `json:"addr"`
|
||||
Password string `json:"password"`
|
||||
DB int `json:"db"`
|
||||
}
|
||||
|
||||
type RoomConfig struct {
|
||||
CodeLength int `json:"codeLength"`
|
||||
EmptyTTL Duration `json:"emptyTtl"`
|
||||
OwnerReconnectHold Duration `json:"ownerReconnectHold"`
|
||||
}
|
||||
|
||||
type ProxyConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
RequestTimeout Duration `json:"requestTimeout"`
|
||||
MaxIdleConns int `json:"maxIdleConns"`
|
||||
ResponseHeaderBytes int64 `json:"responseHeaderBytes"`
|
||||
}
|
||||
|
||||
type WebSocketConfig struct {
|
||||
PingInterval Duration `json:"pingInterval"`
|
||||
PongTimeout Duration `json:"pongTimeout"`
|
||||
WriteTimeout Duration `json:"writeTimeout"`
|
||||
}
|
||||
|
||||
type SourceConfig struct {
|
||||
DefaultMode string `json:"defaultMode"`
|
||||
}
|
||||
|
||||
func Load(path string) (Config, error) {
|
||||
if path == "" {
|
||||
path = os.Getenv("SYNCTV_CONFIG")
|
||||
}
|
||||
if path == "" {
|
||||
path = "config/config.json"
|
||||
}
|
||||
|
||||
cfg := Default()
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if err := json.Unmarshal(b, &cfg); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func Default() Config {
|
||||
return Config{
|
||||
HTTP: HTTPConfig{
|
||||
Addr: ":8080",
|
||||
AllowedOrigins: []string{"*"},
|
||||
},
|
||||
Redis: RedisConfig{
|
||||
Addr: "localhost:6379",
|
||||
},
|
||||
Room: RoomConfig{
|
||||
CodeLength: 6,
|
||||
EmptyTTL: Duration(30 * time.Minute),
|
||||
OwnerReconnectHold: Duration(30 * time.Second),
|
||||
},
|
||||
Proxy: ProxyConfig{
|
||||
Enabled: true,
|
||||
RequestTimeout: 0,
|
||||
MaxIdleConns: 256,
|
||||
ResponseHeaderBytes: 1 << 20,
|
||||
},
|
||||
WebSocket: WebSocketConfig{
|
||||
PingInterval: Duration(25 * time.Second),
|
||||
PongTimeout: Duration(60 * time.Second),
|
||||
WriteTimeout: Duration(10 * time.Second),
|
||||
},
|
||||
Source: SourceConfig{
|
||||
DefaultMode: "direct",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (c Config) Validate() error {
|
||||
if c.HTTP.Addr == "" {
|
||||
return errors.New("http.addr is required")
|
||||
}
|
||||
if c.Redis.Addr == "" {
|
||||
return errors.New("redis.addr is required")
|
||||
}
|
||||
if c.Room.CodeLength < 4 {
|
||||
return errors.New("room.codeLength must be at least 4")
|
||||
}
|
||||
if c.Room.EmptyTTL <= 0 {
|
||||
return errors.New("room.emptyTtl must be positive")
|
||||
}
|
||||
if c.Source.DefaultMode != "direct" && c.Source.DefaultMode != "proxy" {
|
||||
return errors.New("source.defaultMode must be direct or proxy")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
31
server/internal/config/duration.go
Normal file
31
server/internal/config/duration.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (d *Duration) UnmarshalJSON(b []byte) error {
|
||||
var s string
|
||||
if err := json.Unmarshal(b, &s); err == nil {
|
||||
v, parseErr := time.ParseDuration(s)
|
||||
if parseErr != nil {
|
||||
return parseErr
|
||||
}
|
||||
*d = Duration(v)
|
||||
return nil
|
||||
}
|
||||
|
||||
var n int64
|
||||
if err := json.Unmarshal(b, &n); err != nil {
|
||||
return err
|
||||
}
|
||||
*d = Duration(time.Duration(n))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d Duration) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(time.Duration(d).String())
|
||||
}
|
||||
|
||||
type Duration time.Duration
|
||||
120
server/internal/httpapi/server.go
Normal file
120
server/internal/httpapi/server.go
Normal file
@@ -0,0 +1,120 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"synctv/server/internal/room"
|
||||
"synctv/server/internal/store"
|
||||
)
|
||||
|
||||
type Rooms interface {
|
||||
Create(ctx context.Context) (room.Room, error)
|
||||
Get(ctx context.Context, code string) (room.Room, error)
|
||||
SetSource(ctx context.Context, code, deviceID string, src room.Source) (room.Room, error)
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
rooms Rooms
|
||||
hub http.Handler
|
||||
proxy http.Handler
|
||||
}
|
||||
|
||||
func New(rooms Rooms, hub http.Handler, proxy http.Handler) *Server {
|
||||
return &Server{rooms: rooms, hub: hub, proxy: proxy}
|
||||
}
|
||||
|
||||
func (s *Server) Routes() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /health", s.health)
|
||||
mux.HandleFunc("POST /api/rooms", s.createRoom)
|
||||
mux.HandleFunc("GET /api/rooms/{code}", s.getRoom)
|
||||
mux.HandleFunc("POST /api/rooms/{code}/source", s.setSource)
|
||||
mux.Handle("GET /api/rooms/{code}/stream", s.proxy)
|
||||
mux.Handle("GET /ws", s.hub)
|
||||
return withCORS(mux)
|
||||
}
|
||||
|
||||
func (s *Server) health(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (s *Server) createRoom(w http.ResponseWriter, r *http.Request) {
|
||||
rm, err := s.rooms.Create(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, map[string]any{"room": rm})
|
||||
}
|
||||
|
||||
func (s *Server) getRoom(w http.ResponseWriter, r *http.Request) {
|
||||
code := r.PathValue("code")
|
||||
deviceID := r.URL.Query().Get("deviceId")
|
||||
rm, err := s.rooms.Get(r.Context(), code)
|
||||
if err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
status = http.StatusNotFound
|
||||
}
|
||||
writeError(w, status, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, room.Snapshot{
|
||||
Room: rm.PublicFor(deviceID),
|
||||
IsOwner: deviceID != "" && rm.OwnerDeviceID == deviceID,
|
||||
DeviceID: deviceID,
|
||||
ServerNow: room.NowMS(),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) setSource(w http.ResponseWriter, r *http.Request) {
|
||||
code := r.PathValue("code")
|
||||
deviceID := r.Header.Get("X-Device-Id")
|
||||
if strings.TrimSpace(deviceID) == "" {
|
||||
writeError(w, http.StatusBadRequest, "X-Device-Id is required")
|
||||
return
|
||||
}
|
||||
var src room.Source
|
||||
if err := json.NewDecoder(r.Body).Decode(&src); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
rm, err := s.rooms.SetSource(r.Context(), code, deviceID, src)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, room.Snapshot{
|
||||
Room: rm.PublicFor(deviceID),
|
||||
IsOwner: rm.OwnerDeviceID == deviceID,
|
||||
DeviceID: deviceID,
|
||||
ServerNow: room.NowMS(),
|
||||
})
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, status int, message string) {
|
||||
writeJSON(w, status, map[string]any{"error": message})
|
||||
}
|
||||
|
||||
func withCORS(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, X-Device-Id")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
112
server/internal/proxy/proxy.go
Normal file
112
server/internal/proxy/proxy.go
Normal file
@@ -0,0 +1,112 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"synctv/server/internal/room"
|
||||
)
|
||||
|
||||
type Rooms interface {
|
||||
Get(ctx context.Context, code string) (room.Room, error)
|
||||
}
|
||||
|
||||
type Proxy struct {
|
||||
rooms Rooms
|
||||
enabled bool
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func New(rooms Rooms, enabled bool, timeout time.Duration, maxIdleConns int, responseHeaderBytes int64) *Proxy {
|
||||
return &Proxy{
|
||||
rooms: rooms,
|
||||
enabled: enabled,
|
||||
client: &http.Client{
|
||||
Timeout: timeout,
|
||||
Transport: &http.Transport{
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
MaxIdleConns: maxIdleConns,
|
||||
ResponseHeaderTimeout: timeout,
|
||||
MaxResponseHeaderBytes: responseHeaderBytes,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Proxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
if !p.enabled {
|
||||
http.Error(w, "proxy is disabled", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
code := r.PathValue("code")
|
||||
rm, err := p.rooms.Get(r.Context(), code)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if rm.Source == nil || rm.Source.Mode != room.SourceModeProxy {
|
||||
http.Error(w, "room source is not in proxy mode", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := p.stream(w, r, *rm.Source); err != nil {
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
http.Error(w, err.Error(), http.StatusBadGateway)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Proxy) stream(w http.ResponseWriter, r *http.Request, src room.Source) error {
|
||||
req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, src.URL, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
copyRequestHeader(req.Header, r.Header, "Range", "User-Agent", "Accept", "Accept-Encoding")
|
||||
for k, v := range src.Headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
if src.Username != "" || src.Password != "" {
|
||||
req.Header.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(src.Username+":"+src.Password)))
|
||||
}
|
||||
|
||||
resp, err := p.client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
copyResponseHeaders(w.Header(), resp.Header)
|
||||
w.WriteHeader(resp.StatusCode)
|
||||
_, err = io.Copy(w, resp.Body)
|
||||
return err
|
||||
}
|
||||
|
||||
func copyRequestHeader(dst, src http.Header, keys ...string) {
|
||||
for _, key := range keys {
|
||||
value := src.Get(key)
|
||||
if strings.TrimSpace(value) != "" {
|
||||
dst.Set(key, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func copyResponseHeaders(dst, src http.Header) {
|
||||
for _, key := range []string{
|
||||
"Content-Type",
|
||||
"Content-Length",
|
||||
"Content-Range",
|
||||
"Accept-Ranges",
|
||||
"Cache-Control",
|
||||
"Last-Modified",
|
||||
"ETag",
|
||||
} {
|
||||
for _, value := range src.Values(key) {
|
||||
dst.Add(key, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
80
server/internal/room/model.go
Normal file
80
server/internal/room/model.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package room
|
||||
|
||||
import "time"
|
||||
|
||||
const (
|
||||
PlaybackIdle = "idle"
|
||||
PlaybackPlaying = "playing"
|
||||
PlaybackPaused = "paused"
|
||||
|
||||
SourceModeDirect = "direct"
|
||||
SourceModeProxy = "proxy"
|
||||
)
|
||||
|
||||
type Room struct {
|
||||
Code string `json:"code"`
|
||||
OwnerDeviceID string `json:"ownerDeviceId,omitempty"`
|
||||
OwnerLastSeenAt int64 `json:"ownerLastSeenAt,omitempty"`
|
||||
OnlineCount int `json:"onlineCount"`
|
||||
Source *Source `json:"source,omitempty"`
|
||||
Playback Playback `json:"playback"`
|
||||
CreatedAt int64 `json:"createdAt"`
|
||||
UpdatedAt int64 `json:"updatedAt"`
|
||||
ExpiresAt int64 `json:"expiresAt"`
|
||||
}
|
||||
|
||||
type Source struct {
|
||||
Type string `json:"type"`
|
||||
Mode string `json:"mode"`
|
||||
URL string `json:"url"`
|
||||
IsLive bool `json:"isLive"`
|
||||
Headers map[string]string `json:"headers,omitempty"`
|
||||
Username string `json:"username,omitempty"`
|
||||
Password string `json:"password,omitempty"`
|
||||
ProxyPath string `json:"proxyPath,omitempty"`
|
||||
}
|
||||
|
||||
type Playback struct {
|
||||
State string `json:"state"`
|
||||
PositionMS int64 `json:"positionMs"`
|
||||
ServerUpdatedAt int64 `json:"serverUpdatedAt"`
|
||||
TargetLatencyMS int64 `json:"targetLatencyMs,omitempty"`
|
||||
}
|
||||
|
||||
type Snapshot struct {
|
||||
Room Room `json:"room"`
|
||||
IsOwner bool `json:"isOwner"`
|
||||
DeviceID string `json:"deviceId"`
|
||||
ServerNow int64 `json:"serverNow"`
|
||||
}
|
||||
|
||||
func New(code string, ttl time.Duration) Room {
|
||||
now := time.Now()
|
||||
return Room{
|
||||
Code: code,
|
||||
Playback: Playback{State: PlaybackIdle, ServerUpdatedAt: now.UnixMilli()},
|
||||
CreatedAt: now.UnixMilli(),
|
||||
UpdatedAt: now.UnixMilli(),
|
||||
ExpiresAt: now.Add(ttl).UnixMilli(),
|
||||
OnlineCount: 0,
|
||||
}
|
||||
}
|
||||
|
||||
func (r Room) PublicFor(deviceID string) Room {
|
||||
out := r
|
||||
if out.Source != nil {
|
||||
src := *out.Source
|
||||
if src.Mode == SourceModeProxy {
|
||||
src.URL = src.ProxyPath
|
||||
src.Headers = nil
|
||||
src.Username = ""
|
||||
src.Password = ""
|
||||
}
|
||||
out.Source = &src
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func NowMS() int64 {
|
||||
return time.Now().UnixMilli()
|
||||
}
|
||||
139
server/internal/room/service.go
Normal file
139
server/internal/room/service.go
Normal file
@@ -0,0 +1,139 @@
|
||||
package room
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"math/big"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
||||
|
||||
type Store interface {
|
||||
CreateRoom(ctx context.Context, r Room) error
|
||||
GetRoom(ctx context.Context, code string) (Room, error)
|
||||
SaveRoom(ctx context.Context, r Room) error
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
store Store
|
||||
codeLength int
|
||||
emptyTTL time.Duration
|
||||
ownerReconnectHold time.Duration
|
||||
}
|
||||
|
||||
func NewService(st Store, codeLength int, emptyTTL, ownerReconnectHold time.Duration) *Service {
|
||||
return &Service{
|
||||
store: st,
|
||||
codeLength: codeLength,
|
||||
emptyTTL: emptyTTL,
|
||||
ownerReconnectHold: ownerReconnectHold,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) Create(ctx context.Context) (Room, error) {
|
||||
for i := 0; i < 10; i++ {
|
||||
code, err := randomCode(s.codeLength)
|
||||
if err != nil {
|
||||
return Room{}, err
|
||||
}
|
||||
r := New(code, s.emptyTTL)
|
||||
if err := s.store.CreateRoom(ctx, r); err == nil {
|
||||
return r, nil
|
||||
}
|
||||
}
|
||||
return Room{}, errors.New("failed to allocate room code")
|
||||
}
|
||||
|
||||
func (s *Service) Join(ctx context.Context, code, deviceID string, onlineCount int) (Room, bool, error) {
|
||||
code = strings.ToUpper(strings.TrimSpace(code))
|
||||
if code == "" || deviceID == "" {
|
||||
return Room{}, false, errors.New("room code and device id are required")
|
||||
}
|
||||
|
||||
r, err := s.store.GetRoom(ctx, code)
|
||||
if err != nil {
|
||||
return Room{}, false, err
|
||||
}
|
||||
now := NowMS()
|
||||
if r.OwnerDeviceID == "" {
|
||||
r.OwnerDeviceID = deviceID
|
||||
}
|
||||
if r.OwnerDeviceID == deviceID {
|
||||
r.OwnerLastSeenAt = now
|
||||
}
|
||||
r.OnlineCount = onlineCount
|
||||
if err := s.store.SaveRoom(ctx, r); err != nil {
|
||||
return Room{}, false, err
|
||||
}
|
||||
return r, r.OwnerDeviceID == deviceID, nil
|
||||
}
|
||||
|
||||
func (s *Service) Get(ctx context.Context, code string) (Room, error) {
|
||||
return s.store.GetRoom(ctx, strings.ToUpper(strings.TrimSpace(code)))
|
||||
}
|
||||
|
||||
func (s *Service) SetOnlineCount(ctx context.Context, code string, count int) error {
|
||||
r, err := s.Get(ctx, code)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
r.OnlineCount = count
|
||||
return s.store.SaveRoom(ctx, r)
|
||||
}
|
||||
|
||||
func (s *Service) SetSource(ctx context.Context, code, deviceID string, src Source) (Room, error) {
|
||||
r, err := s.Get(ctx, code)
|
||||
if err != nil {
|
||||
return Room{}, err
|
||||
}
|
||||
if r.OwnerDeviceID != deviceID {
|
||||
return Room{}, errors.New("only room owner can configure source")
|
||||
}
|
||||
if src.Mode == "" {
|
||||
src.Mode = SourceModeDirect
|
||||
}
|
||||
if src.Mode != SourceModeDirect && src.Mode != SourceModeProxy {
|
||||
return Room{}, errors.New("source mode must be direct or proxy")
|
||||
}
|
||||
if strings.TrimSpace(src.URL) == "" {
|
||||
return Room{}, errors.New("source url is required")
|
||||
}
|
||||
if src.Mode == SourceModeProxy {
|
||||
src.ProxyPath = "/api/rooms/" + r.Code + "/stream"
|
||||
}
|
||||
r.Source = &src
|
||||
r.Playback = Playback{State: PlaybackIdle, ServerUpdatedAt: NowMS()}
|
||||
if err := s.store.SaveRoom(ctx, r); err != nil {
|
||||
return Room{}, err
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func (s *Service) UpdatePlayback(ctx context.Context, code string, p Playback) (Room, error) {
|
||||
r, err := s.Get(ctx, code)
|
||||
if err != nil {
|
||||
return Room{}, err
|
||||
}
|
||||
p.ServerUpdatedAt = NowMS()
|
||||
r.Playback = p
|
||||
if err := s.store.SaveRoom(ctx, r); err != nil {
|
||||
return Room{}, err
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func randomCode(n int) (string, error) {
|
||||
var b strings.Builder
|
||||
b.Grow(n)
|
||||
for i := 0; i < n; i++ {
|
||||
x, err := rand.Int(rand.Reader, big.NewInt(int64(len(alphabet))))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
b.WriteByte(alphabet[x.Int64()])
|
||||
}
|
||||
return b.String(), nil
|
||||
}
|
||||
82
server/internal/store/redis.go
Normal file
82
server/internal/store/redis.go
Normal file
@@ -0,0 +1,82 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
"synctv/server/internal/room"
|
||||
)
|
||||
|
||||
var ErrNotFound = errors.New("room not found")
|
||||
|
||||
type RedisStore struct {
|
||||
client *redis.Client
|
||||
ttl time.Duration
|
||||
}
|
||||
|
||||
func NewRedis(addr, password string, db int, ttl time.Duration) *RedisStore {
|
||||
return &RedisStore{
|
||||
client: redis.NewClient(&redis.Options{
|
||||
Addr: addr,
|
||||
Password: password,
|
||||
DB: db,
|
||||
}),
|
||||
ttl: ttl,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *RedisStore) Ping(ctx context.Context) error {
|
||||
return s.client.Ping(ctx).Err()
|
||||
}
|
||||
|
||||
func (s *RedisStore) Close() error {
|
||||
return s.client.Close()
|
||||
}
|
||||
|
||||
func (s *RedisStore) GetRoom(ctx context.Context, code string) (room.Room, error) {
|
||||
b, err := s.client.Get(ctx, roomKey(code)).Bytes()
|
||||
if errors.Is(err, redis.Nil) {
|
||||
return room.Room{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return room.Room{}, err
|
||||
}
|
||||
var r room.Room
|
||||
if err := json.Unmarshal(b, &r); err != nil {
|
||||
return room.Room{}, err
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func (s *RedisStore) SaveRoom(ctx context.Context, r room.Room) error {
|
||||
r.UpdatedAt = room.NowMS()
|
||||
r.ExpiresAt = time.Now().Add(s.ttl).UnixMilli()
|
||||
b, err := json.Marshal(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.client.Set(ctx, roomKey(r.Code), b, s.ttl).Err()
|
||||
}
|
||||
|
||||
func (s *RedisStore) CreateRoom(ctx context.Context, r room.Room) error {
|
||||
b, err := json.Marshal(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ok, err := s.client.SetNX(ctx, roomKey(r.Code), b, s.ttl).Result()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
return errors.New("room code already exists")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func roomKey(code string) string {
|
||||
return fmt.Sprintf("room:%s", code)
|
||||
}
|
||||
285
server/internal/ws/hub.go
Normal file
285
server/internal/ws/hub.go
Normal file
@@ -0,0 +1,285 @@
|
||||
package ws
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
"synctv/server/internal/room"
|
||||
)
|
||||
|
||||
type RoomService interface {
|
||||
Join(ctx context.Context, code, deviceID string, onlineCount int) (room.Room, bool, error)
|
||||
SetOnlineCount(ctx context.Context, code string, count int) error
|
||||
SetSource(ctx context.Context, code, deviceID string, src room.Source) (room.Room, error)
|
||||
UpdatePlayback(ctx context.Context, code string, p room.Playback) (room.Room, error)
|
||||
}
|
||||
|
||||
type Event struct {
|
||||
Type string `json:"type"`
|
||||
Payload json.RawMessage `json:"payload,omitempty"`
|
||||
}
|
||||
|
||||
type Hub struct {
|
||||
service RoomService
|
||||
upgrader websocket.Upgrader
|
||||
pingInterval time.Duration
|
||||
pongTimeout time.Duration
|
||||
writeTimeout time.Duration
|
||||
|
||||
mu sync.RWMutex
|
||||
rooms map[string]map[*Client]struct{}
|
||||
}
|
||||
|
||||
type Client struct {
|
||||
hub *Hub
|
||||
conn *websocket.Conn
|
||||
roomCode string
|
||||
deviceID string
|
||||
send chan any
|
||||
}
|
||||
|
||||
func NewHub(service RoomService, allowedOrigins []string, pingInterval, pongTimeout, writeTimeout time.Duration) *Hub {
|
||||
originAllowed := map[string]struct{}{}
|
||||
allowAll := false
|
||||
for _, origin := range allowedOrigins {
|
||||
if origin == "*" {
|
||||
allowAll = true
|
||||
break
|
||||
}
|
||||
originAllowed[origin] = struct{}{}
|
||||
}
|
||||
|
||||
return &Hub{
|
||||
service: service,
|
||||
upgrader: websocket.Upgrader{
|
||||
CheckOrigin: func(r *http.Request) bool {
|
||||
if allowAll {
|
||||
return true
|
||||
}
|
||||
_, ok := originAllowed[r.Header.Get("Origin")]
|
||||
return ok
|
||||
},
|
||||
},
|
||||
pingInterval: pingInterval,
|
||||
pongTimeout: pongTimeout,
|
||||
writeTimeout: writeTimeout,
|
||||
rooms: make(map[string]map[*Client]struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Hub) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
roomCode := r.URL.Query().Get("roomCode")
|
||||
deviceID := r.URL.Query().Get("deviceId")
|
||||
if roomCode == "" || deviceID == "" {
|
||||
http.Error(w, "roomCode and deviceId are required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
conn, err := h.upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
c := &Client{
|
||||
hub: h,
|
||||
conn: conn,
|
||||
roomCode: roomCode,
|
||||
deviceID: deviceID,
|
||||
send: make(chan any, 32),
|
||||
}
|
||||
|
||||
h.register(c)
|
||||
count := h.count(roomCode)
|
||||
rm, isOwner, err := h.service.Join(r.Context(), roomCode, deviceID, count)
|
||||
if err != nil {
|
||||
c.writeJSON(map[string]any{"type": "error", "message": err.Error()})
|
||||
h.unregister(c)
|
||||
_ = conn.Close()
|
||||
return
|
||||
}
|
||||
c.send <- map[string]any{
|
||||
"type": "roomSnapshot",
|
||||
"payload": room.Snapshot{
|
||||
Room: rm.PublicFor(deviceID),
|
||||
IsOwner: isOwner,
|
||||
DeviceID: deviceID,
|
||||
ServerNow: room.NowMS(),
|
||||
},
|
||||
}
|
||||
h.broadcast(roomCode, map[string]any{"type": "presenceChanged", "payload": map[string]any{"onlineCount": count}}, c)
|
||||
|
||||
go c.writePump()
|
||||
go c.readPump()
|
||||
}
|
||||
|
||||
func (h *Hub) register(c *Client) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if h.rooms[c.roomCode] == nil {
|
||||
h.rooms[c.roomCode] = make(map[*Client]struct{})
|
||||
}
|
||||
h.rooms[c.roomCode][c] = struct{}{}
|
||||
}
|
||||
|
||||
func (h *Hub) unregister(c *Client) {
|
||||
h.mu.Lock()
|
||||
if clients := h.rooms[c.roomCode]; clients != nil {
|
||||
delete(clients, c)
|
||||
if len(clients) == 0 {
|
||||
delete(h.rooms, c.roomCode)
|
||||
}
|
||||
}
|
||||
h.mu.Unlock()
|
||||
close(c.send)
|
||||
|
||||
count := h.count(c.roomCode)
|
||||
if err := h.service.SetOnlineCount(context.Background(), c.roomCode, count); err != nil {
|
||||
log.Printf("update online count: %v", err)
|
||||
}
|
||||
h.broadcast(c.roomCode, map[string]any{"type": "presenceChanged", "payload": map[string]any{"onlineCount": count}}, nil)
|
||||
}
|
||||
|
||||
func (h *Hub) count(roomCode string) int {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
return len(h.rooms[roomCode])
|
||||
}
|
||||
|
||||
func (h *Hub) broadcast(roomCode string, msg any, except *Client) {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
for c := range h.rooms[roomCode] {
|
||||
if c == except {
|
||||
continue
|
||||
}
|
||||
select {
|
||||
case c.send <- msg:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) readPump() {
|
||||
defer func() {
|
||||
c.hub.unregister(c)
|
||||
_ = c.conn.Close()
|
||||
}()
|
||||
c.conn.SetReadLimit(1 << 20)
|
||||
_ = c.conn.SetReadDeadline(time.Now().Add(c.hub.pongTimeout))
|
||||
c.conn.SetPongHandler(func(string) error {
|
||||
return c.conn.SetReadDeadline(time.Now().Add(c.hub.pongTimeout))
|
||||
})
|
||||
|
||||
for {
|
||||
var event Event
|
||||
if err := c.conn.ReadJSON(&event); err != nil {
|
||||
return
|
||||
}
|
||||
c.handle(event)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) writePump() {
|
||||
ticker := time.NewTicker(c.hub.pingInterval)
|
||||
defer func() {
|
||||
ticker.Stop()
|
||||
_ = c.conn.Close()
|
||||
}()
|
||||
|
||||
for {
|
||||
select {
|
||||
case msg, ok := <-c.send:
|
||||
_ = c.conn.SetWriteDeadline(time.Now().Add(c.hub.writeTimeout))
|
||||
if !ok {
|
||||
_ = c.conn.WriteMessage(websocket.CloseMessage, nil)
|
||||
return
|
||||
}
|
||||
if err := c.conn.WriteJSON(msg); err != nil {
|
||||
return
|
||||
}
|
||||
case <-ticker.C:
|
||||
_ = c.conn.SetWriteDeadline(time.Now().Add(c.hub.writeTimeout))
|
||||
if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) writeJSON(v any) {
|
||||
_ = c.conn.SetWriteDeadline(time.Now().Add(c.hub.writeTimeout))
|
||||
_ = c.conn.WriteJSON(v)
|
||||
}
|
||||
|
||||
func (c *Client) handle(event Event) {
|
||||
switch event.Type {
|
||||
case "setSource":
|
||||
var src room.Source
|
||||
if err := json.Unmarshal(event.Payload, &src); err != nil {
|
||||
c.sendError(err)
|
||||
return
|
||||
}
|
||||
rm, err := c.hub.service.SetSource(context.Background(), c.roomCode, c.deviceID, src)
|
||||
if err != nil {
|
||||
c.sendError(err)
|
||||
return
|
||||
}
|
||||
c.hub.broadcastSnapshot(c.roomCode, "sourceChanged", rm)
|
||||
case "play", "pause", "seek", "syncProgress", "syncToLive":
|
||||
var p room.Playback
|
||||
if err := json.Unmarshal(event.Payload, &p); err != nil {
|
||||
c.sendError(err)
|
||||
return
|
||||
}
|
||||
if event.Type == "play" {
|
||||
p.State = room.PlaybackPlaying
|
||||
}
|
||||
if event.Type == "pause" {
|
||||
p.State = room.PlaybackPaused
|
||||
}
|
||||
rm, err := c.hub.service.UpdatePlayback(context.Background(), c.roomCode, p)
|
||||
if err != nil {
|
||||
c.sendError(err)
|
||||
return
|
||||
}
|
||||
c.hub.broadcast(c.roomCode, map[string]any{
|
||||
"type": "playbackChanged",
|
||||
"payload": map[string]any{
|
||||
"playback": rm.Playback,
|
||||
"serverNow": room.NowMS(),
|
||||
},
|
||||
}, nil)
|
||||
case "heartbeat":
|
||||
c.send <- map[string]any{"type": "heartbeatAck", "payload": map[string]any{"serverNow": room.NowMS()}}
|
||||
default:
|
||||
c.sendErrorString("unknown event type: " + event.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) sendError(err error) {
|
||||
c.sendErrorString(err.Error())
|
||||
}
|
||||
|
||||
func (c *Client) sendErrorString(message string) {
|
||||
c.send <- map[string]any{"type": "error", "payload": map[string]any{"message": message}}
|
||||
}
|
||||
|
||||
func (h *Hub) broadcastSnapshot(roomCode, eventType string, rm room.Room) {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
for c := range h.rooms[roomCode] {
|
||||
c.send <- map[string]any{
|
||||
"type": eventType,
|
||||
"payload": room.Snapshot{
|
||||
Room: rm.PublicFor(c.deviceID),
|
||||
IsOwner: rm.OwnerDeviceID == c.deviceID,
|
||||
DeviceID: c.deviceID,
|
||||
ServerNow: room.NowMS(),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user