diff --git a/internal/handler/media.go b/internal/handler/media.go index afd095e..d2272e7 100644 --- a/internal/handler/media.go +++ b/internal/handler/media.go @@ -722,7 +722,9 @@ func streamHandler(svc *service.Container) gin.HandlerFunc { c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) return } - if mount.ProxyPlay { + // 远程 Emby 条目的同源转发:VR 全景需要浏览器读帧(见 stream_proxy.go), + // 与挂载账号的 proxy_play 开关等价。 + if mount.ProxyPlay || wantSameOriginProxy(c) { if err := svc.Emby.ProxyRemoteVideoStream(ctx, c.Writer, c.Request, mountID, remoteID); err != nil { if !c.Writer.Written() { c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) @@ -758,6 +760,15 @@ func streamHandler(svc *service.Container) gin.HandlerFunc { if !enforceScopedPlaybackToken(c, m.ID) { return } + // ?proxy=1:把网盘/STRM 直链改为服务端同源转发(网页端读帧、VR 全景用), + // 画质与原文件一致,不触发转码。 + if wantSameOriginProxy(c) { + handled, proxyErr := proxySTRMStream(c, svc, m) + if handled { + writeProxyError(c, proxyErr) + return + } + } err = svc.Stream.ServeFile(c.Writer, c.Request, c.Param("id")) if errors.Is(err, service.ErrMediaNotFound) { c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) diff --git a/internal/handler/stream_proxy.go b/internal/handler/stream_proxy.go new file mode 100644 index 0000000..0356c9c --- /dev/null +++ b/internal/handler/stream_proxy.go @@ -0,0 +1,56 @@ +// 直连流的同源转发:网页端需要读取视频帧的场景(VR 全景渲染走 WebGL 纹理) +// 不能使用会跳到网盘 CDN 的跨域直链,这里把直链改为服务端转发。 +// +// 只在客户端显式带上 ?proxy=1 时生效,普通播放仍走原来的 302 直连, +// 避免把网盘流量无谓地压到服务器上。 +package handler + +import ( + "errors" + "net/http" + "strings" + + "github.com/gin-gonic/gin" + + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/service" +) + +// wantSameOriginProxy 客户端是否要求把直连流改为服务端同源转发。 +func wantSameOriginProxy(c *gin.Context) bool { + if c == nil { + return false + } + switch strings.ToLower(strings.TrimSpace(c.Query("proxy"))) { + case "1", "true", "yes": + return true + default: + return false + } +} + +// proxySTRMStream 处理 STRM/网盘媒体的同源转发请求。返回 handled=false 表示 +// 该媒体不需要(或无法)代理,调用方继续按原有逻辑处理。 +func proxySTRMStream(c *gin.Context, svc *service.Container, m *model.Media) (bool, error) { + if c == nil || svc == nil || svc.Strm == nil || m == nil { + return false, nil + } + if !service.IsStrmMediaRow(m) { + return false, nil + } + if err := svc.Strm.ProxyMediaDirect(c.Request.Context(), c.Writer, c.Request, m); err != nil { + if errors.Is(err, service.ErrStrmProxyNotApplicable) { + return false, nil + } + return true, err + } + return true, nil +} + +// writeProxyError 在尚未写入任何响应内容时回一个明确的网关错误。 +func writeProxyError(c *gin.Context, err error) { + if c == nil || err == nil || c.Writer.Written() { + return + } + c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) +} diff --git a/internal/service/strm_play.go b/internal/service/strm_play.go index 61f3803..8220ed8 100644 --- a/internal/service/strm_play.go +++ b/internal/service/strm_play.go @@ -174,38 +174,111 @@ func (s *StrmService) firstEnabledAccountOf(ctx context.Context, provider string return nil, nil } +// ErrStrmProxyNotApplicable 表示该媒体没有需要服务端转发的直链(本地文件本身同源)。 +var ErrStrmProxyNotApplicable = errors.New("strm proxy not applicable") + +// ProxyMediaDirect 把媒体行的网盘/STRM 直链解析为真实地址后由服务端反向代理给 +// 客户端,让浏览器拿到「同源」数据。画质与原文件完全一致,不触发任何转码。 +// +// 用途:VR 全景播放要把视频帧读进 WebGL 纹理,而跨域直链(网盘 302 跳到 CDN) +// 在浏览器里属于被污染的资源,WebGL 读取会抛 SecurityError;把流量经服务端转发 +// 是「原画 + VR」唯一可行的办法。 +func (s *StrmService) ProxyMediaDirect(ctx context.Context, w http.ResponseWriter, r *http.Request, m *model.Media) error { + if s == nil || m == nil { + return ErrStrmProxyNotApplicable + } + raw := strings.TrimSpace(m.STRMURL) + if raw == "" { + path := strings.TrimSpace(m.Path) + if !strings.HasSuffix(strings.ToLower(path), ".strm") { + return ErrStrmProxyNotApplicable + } + target, err := readLocalSTRMTarget(path) + if err != nil { + return err + } + raw = strings.TrimSpace(target) + } + if raw == "" { + return ErrStrmProxyNotApplicable + } + result, err := s.ResolvePlayTarget(ctx, raw) + if err != nil { + return err + } + switch { + case result.Link != nil && result.Link.URL != "": + return s.ProxyDirect(ctx, w, r, result.Link) + case result.RedirectURL != "": + return s.ProxyDirect(ctx, w, r, &cloud.DirectLink{URL: result.RedirectURL}) + default: + // 本地文件(LocalPath)由静态文件处理器提供,本身就是同源。 + return ErrStrmProxyNotApplicable + } +} + // ProxyDirect 反向代理渲染直链内容(保留 Range 请求头以支持拖动播放)。 func (s *StrmService) ProxyDirect(ctx context.Context, w http.ResponseWriter, r *http.Request, link *cloud.DirectLink) error { if link == nil || link.URL == "" { return errors.New("空直链") } - req, err := http.NewRequestWithContext(ctx, http.MethodGet, link.URL, nil) + method := http.MethodGet + if r != nil && r.Method == http.MethodHead { + method = http.MethodHead + } + req, err := http.NewRequestWithContext(ctx, method, link.URL, nil) if err != nil { return err } for k, v := range link.Headers { req.Header.Set(k, v) } - if rangeHeader := r.Header.Get("Range"); rangeHeader != "" { - req.Header.Set("Range", rangeHeader) + if r != nil { + if rangeHeader := r.Header.Get("Range"); rangeHeader != "" { + req.Header.Set("Range", rangeHeader) + } + // 部分网盘直链按 UA 防盗链;解析时未绑定 UA 的直链沿用浏览器 UA 更稳。 + if ua := strings.TrimSpace(r.Header.Get("User-Agent")); ua != "" && req.Header.Get("User-Agent") == "" { + req.Header.Set("User-Agent", ua) + } } resp, err := s.http.Do(req) if err != nil { return err } defer resp.Body.Close() - for _, header := range []string{"Content-Type", "Content-Length", "Content-Range", "Accept-Ranges", "ETag"} { + for _, header := range []string{"Content-Type", "Content-Length", "Content-Range", "Accept-Ranges", "ETag", "Last-Modified"} { if value := resp.Header.Get(header); value != "" { w.Header().Set(header, value) } } - if resp.StatusCode >= 200 && resp.StatusCode < 300 { - w.WriteHeader(http.StatusOK) - } else { - w.WriteHeader(resp.StatusCode) + // 原样透传上游状态码:Range 请求必须回 206,改写成 200 会让浏览器误判 + // 响应长度,拖动进度条时反复重新拉流。 + w.WriteHeader(resp.StatusCode) + if resp.StatusCode != http.StatusPartialContent && resp.StatusCode != http.StatusOK { + return nil } - if resp.StatusCode == http.StatusPartialContent || resp.StatusCode == http.StatusOK { - _, _ = io.Copy(w, resp.Body) + if method == http.MethodHead { + return nil } - return nil + // 边转发边 flush,避免大体积视频被 net/http 的写缓冲切成一段段卡顿。 + writer := io.Writer(w) + if flusher, ok := w.(http.Flusher); ok { + writer = &flushWriter{writer: w, flusher: flusher} + } + _, err = io.Copy(writer, resp.Body) + return err +} + +type flushWriter struct { + writer io.Writer + flusher http.Flusher +} + +func (f *flushWriter) Write(p []byte) (int, error) { + n, err := f.writer.Write(p) + if f.flusher != nil { + f.flusher.Flush() + } + return n, err } diff --git a/internal/service/strm_proxy_test.go b/internal/service/strm_proxy_test.go new file mode 100644 index 0000000..105cb9a --- /dev/null +++ b/internal/service/strm_proxy_test.go @@ -0,0 +1,74 @@ +package service + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "path/filepath" + "testing" + + "github.com/truewhile/MeBox/internal/model" +) + +// 同源转发必须原样保留 Range 语义:把 206 改写成 200 会让浏览器误判响应长度, +// 拖动进度条时反复重新拉流。 +func TestProxyMediaDirectForwardsRangeAndStatus(t *testing.T) { + var gotRange string + var gotUA string + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotRange = r.Header.Get("Range") + gotUA = r.Header.Get("User-Agent") + w.Header().Set("Content-Type", "video/mp4") + w.Header().Set("Accept-Ranges", "bytes") + w.Header().Set("Content-Range", "bytes 10-19/100") + w.WriteHeader(http.StatusPartialContent) + _, _ = w.Write([]byte("0123456789")) + })) + defer upstream.Close() + + svc := testStrmService(t) + media := &model.Media{STRMURL: upstream.URL + "/video.mp4"} + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/stream/media-1?proxy=1", nil) + req.Header.Set("Range", "bytes=10-19") + req.Header.Set("User-Agent", "MeBoxTest/1.0") + + if err := svc.ProxyMediaDirect(context.Background(), rec, req, media); err != nil { + t.Fatalf("ProxyMediaDirect: %v", err) + } + if rec.Code != http.StatusPartialContent { + t.Fatalf("status = %d, want 206", rec.Code) + } + if gotRange != "bytes=10-19" { + t.Fatalf("upstream Range = %q, want bytes=10-19", gotRange) + } + if gotUA != "MeBoxTest/1.0" { + t.Fatalf("upstream User-Agent = %q, want MeBoxTest/1.0", gotUA) + } + if rec.Header().Get("Content-Range") != "bytes 10-19/100" { + t.Fatalf("Content-Range = %q", rec.Header().Get("Content-Range")) + } + if rec.Body.String() != "0123456789" { + t.Fatalf("body = %q", rec.Body.String()) + } +} + +// 本地文件本身就是同源资源,不应该被代理(调用方按原静态文件逻辑处理)。 +func TestProxyMediaDirectSkipsLocalFile(t *testing.T) { + svc := testStrmService(t) + dir := t.TempDir() + path := filepath.Join(dir, "movie.mkv") + writeFile(t, path, "not-a-video") + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/stream/media-1?proxy=1", nil) + err := svc.ProxyMediaDirect(context.Background(), rec, req, &model.Media{Path: path}) + if !errors.Is(err, ErrStrmProxyNotApplicable) { + t.Fatalf("err = %v, want ErrStrmProxyNotApplicable", err) + } + if rec.Body.Len() != 0 { + t.Fatalf("local file must not be proxied, body = %q", rec.Body.String()) + } +} diff --git a/web/src/api/client.ts b/web/src/api/client.ts index c5c1ab5..faec0dd 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -141,8 +141,13 @@ const profileQuery = () => { // streamURL returns a direct-play URL for