You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
synctv/server/handlers/proxy/m3u8.go

151 lines
4.6 KiB
Go

package proxy
import (
"errors"
"fmt"
"io"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
"github.com/synctv-org/synctv/cmd/flags"
"github.com/synctv-org/synctv/internal/conf"
"github.com/synctv-org/synctv/server/model"
"github.com/synctv-org/synctv/utils"
"github.com/synctv-org/synctv/utils/m3u8"
"github.com/zijiren233/go-uhc"
"github.com/zijiren233/livelib/protocol/hls"
"github.com/zijiren233/stream"
)
type M3u8TargetClaims struct {
jwt.RegisteredClaims
RoomID string `json:"r"`
MovieID string `json:"m"`
TargetURL string `json:"t"`
IsM3u8File bool `json:"f"`
}
func GetM3u8Target(token string) (*M3u8TargetClaims, error) {
t, err := jwt.ParseWithClaims(token, &M3u8TargetClaims{}, func(token *jwt.Token) (any, error) {
return stream.StringToBytes(conf.Conf.Jwt.Secret), nil
})
if err != nil || !t.Valid {
return nil, errors.New("auth failed")
}
claims, ok := t.Claims.(*M3u8TargetClaims)
if !ok {
return nil, errors.New("auth failed")
}
return claims, nil
}
func NewM3u8TargetToken(targetURL, roomID, movieID string, isM3u8File bool) (string, error) {
claims := &M3u8TargetClaims{
RoomID: roomID,
MovieID: movieID,
TargetURL: targetURL,
IsM3u8File: isM3u8File,
RegisteredClaims: jwt.RegisteredClaims{
NotBefore: jwt.NewNumericDate(time.Now()),
},
}
return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(stream.StringToBytes(conf.Conf.Jwt.Secret))
}
const maxM3u8FileSize = 3 * 1024 * 1024 //
func M3u8Data(ctx *gin.Context, data []byte, baseURL string, token, roomID, movieID string) error {
hasM3u8File := false
err := m3u8.RangeM3u8SegmentsWithBaseURL(stream.BytesToString(data), baseURL, func(segmentUrl string) (bool, error) {
if utils.IsM3u8Url(segmentUrl) {
hasM3u8File = true
return false, nil
}
return true, nil
})
if err != nil {
ctx.AbortWithStatusJSON(http.StatusBadRequest,
model.NewAPIErrorStringResp(
fmt.Sprintf("range m3u8 segments with base url error: %v", err),
),
)
return fmt.Errorf("range m3u8 segments with base url error: %w", err)
}
m3u8Str, err := m3u8.ReplaceM3u8SegmentsWithBaseURL(stream.BytesToString(data), baseURL, func(segmentUrl string) (string, error) {
targetToken, err := NewM3u8TargetToken(segmentUrl, roomID, movieID, hasM3u8File)
if err != nil {
return "", err
}
return fmt.Sprintf("/api/room/movie/proxy/%s/m3u8/%s?token=%s&roomId=%s", movieID, targetToken, token, roomID), nil
})
if err != nil {
ctx.AbortWithStatusJSON(http.StatusBadRequest,
model.NewAPIErrorStringResp(
fmt.Sprintf("replace m3u8 segments with base url error: %v", err),
),
)
return fmt.Errorf("replace m3u8 segments with base url error: %w", err)
}
ctx.Data(http.StatusOK, hls.M3U8ContentType, stream.StringToBytes(m3u8Str))
return nil
}
// only cache non-m3u8 files
func M3u8(ctx *gin.Context, u string, headers map[string]string, isM3u8File bool, token, roomID, movieID string, opts ...Option) error {
if !isM3u8File {
return URL(ctx, u, headers, opts...)
}
if flags.Global.Dev {
ctx.Header(proxyURLHeader, u)
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
ctx.AbortWithStatusJSON(http.StatusBadRequest,
model.NewAPIErrorStringResp(
fmt.Sprintf("new request error: %v", err),
),
)
return fmt.Errorf("new request error: %w", err)
}
for k, v := range headers {
req.Header.Set(k, v)
}
if req.Header.Get("User-Agent") == "" {
req.Header.Set("User-Agent", utils.UA)
}
resp, err := uhc.Do(req)
if err != nil {
ctx.AbortWithStatusJSON(http.StatusBadRequest,
model.NewAPIErrorStringResp(
fmt.Sprintf("do request error: %v", err),
),
)
return fmt.Errorf("do request error: %w", err)
}
defer resp.Body.Close()
// if contentType := resp.Header.Get("Content-Type"); !strings.HasPrefix(contentType, "application/vnd.apple.mpegurl") {
// return fmt.Errorf("m3u8 file is not a valid m3u8 file, content type: %s", contentType)
// }
if resp.ContentLength > maxM3u8FileSize {
ctx.AbortWithStatusJSON(http.StatusBadRequest,
model.NewAPIErrorStringResp(
fmt.Sprintf("m3u8 file is too large: %d, max: %d (3MB)", resp.ContentLength, maxM3u8FileSize),
),
)
return fmt.Errorf("m3u8 file is too large: %d, max: %d (3MB)", resp.ContentLength, maxM3u8FileSize)
}
b, err := io.ReadAll(io.LimitReader(resp.Body, maxM3u8FileSize))
if err != nil {
ctx.AbortWithStatusJSON(http.StatusBadRequest,
model.NewAPIErrorStringResp(
fmt.Sprintf("read response body error: %v", err),
),
)
return fmt.Errorf("read response body error: %w", err)
}
return M3u8Data(ctx, b, u, token, roomID, movieID)
}