diff --git a/internal/handler/cloud115_playback.go b/internal/handler/cloud115_playback.go new file mode 100644 index 0000000..37bd010 --- /dev/null +++ b/internal/handler/cloud115_playback.go @@ -0,0 +1,148 @@ +package handler + +import ( + "errors" + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + + "github.com/truewhile/MeBox/internal/service" +) + +func mediaPlaybackInfoHandler(svc *service.Container) gin.HandlerFunc { + return func(c *gin.Context) { + if svc == nil || svc.Cloud115 == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "cloud115 playback unavailable"}) + return + } + m, err := svc.Media.GetMedia(c.Request.Context(), c.Param("id")) + if err != nil || m == nil || !mediaVisibleForRequest(c, svc, m) { + c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + return + } + if !enforceScopedPlaybackToken(c, m.ID) { + return + } + quality, _ := strconv.Atoi(c.Query("quality")) + info, err := svc.Cloud115.PlaybackInfo(c.Request.Context(), m.ID, quality) + if err != nil { + if errors.Is(err, service.ErrMediaNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + return + } + c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, info) + } +} + +func mediaTranscodeHandler(svc *service.Container) gin.HandlerFunc { + return func(c *gin.Context) { + if svc == nil || svc.Cloud115 == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "cloud115 playback unavailable"}) + return + } + m, err := svc.Media.GetMedia(c.Request.Context(), c.Param("id")) + if err != nil || m == nil || !mediaVisibleForRequest(c, svc, m) { + c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + return + } + if !enforceScopedPlaybackToken(c, m.ID) { + return + } + var req struct { + Definition int `json:"definition"` + } + _ = c.ShouldBindJSON(&req) + info, err := svc.Cloud115.StartTranscode(c.Request.Context(), m.ID, req.Definition) + if err != nil { + c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, info) + } +} + +func cloud115HLSMasterHandler(svc *service.Container) gin.HandlerFunc { + return func(c *gin.Context) { + if svc == nil || svc.Cloud115 == nil || svc.Cloud115.HLSProxy() == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "cloud115 playback unavailable"}) + return + } + m, err := svc.Media.GetMedia(c.Request.Context(), c.Param("id")) + if err != nil || m == nil || !mediaVisibleForRequest(c, svc, m) { + c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + return + } + if !enforceScopedPlaybackToken(c, m.ID) { + return + } + definition, _ := strconv.Atoi(c.Query("definition")) + err = svc.Cloud115.HLSProxy().ServeMaster(c.Request.Context(), c.Writer, c.Request, m.ID, definition) + if err == nil { + return + } + if c.Writer.Written() { + return + } + if errors.Is(err, service.ErrCloud115TranscodePending) { + retryAfter := 5 + message := "115 正在转码,请稍候" + var info *service.PlaybackInfo + if latest, infoErr := svc.Cloud115.PlaybackInfo(c.Request.Context(), m.ID, definition); infoErr == nil && latest != nil { + info = latest + if latest.Transcode.Message != "" { + message = latest.Transcode.Message + } + if latest.Transcode.RetryAfterSec > 0 { + retryAfter = latest.Transcode.RetryAfterSec + } + } + c.JSON(http.StatusConflict, gin.H{ + "code": "transcode_pending", + "message": message, + "retry_after_sec": retryAfter, + "playback": info, + }) + return + } + c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) + } +} + +func cloud115HLSSessionHandler(svc *service.Container) gin.HandlerFunc { + return func(c *gin.Context) { + if svc == nil || svc.Cloud115 == nil || svc.Cloud115.HLSProxy() == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "cloud115 playback unavailable"}) + return + } + mediaID := c.Query("media_id") + if mediaID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "missing media_id"}) + return + } + if !enforceScopedPlaybackToken(c, mediaID) { + return + } + err := svc.Cloud115.HLSProxy().ServeChild( + c.Request.Context(), + c.Writer, + c.Request, + c.Param("session"), + c.Param("key"), + ) + if err == nil || c.Writer.Written() { + return + } + switch { + case errors.Is(err, service.ErrCloud115HLSSessionNotFound): + c.JSON(http.StatusNotFound, gin.H{"error": "hls session not found"}) + case errors.Is(err, service.ErrCloud115HLSUpstreamExpired): + c.JSON(http.StatusGone, gin.H{"error": "hls upstream expired"}) + default: + c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) + } + } +} diff --git a/internal/handler/routes_authenticated_core.go b/internal/handler/routes_authenticated_core.go index ea1ca35..81d8a95 100644 --- a/internal/handler/routes_authenticated_core.go +++ b/internal/handler/routes_authenticated_core.go @@ -44,6 +44,8 @@ func registerAuthedLibraryRoutes(authed *gin.RouterGroup, svc *service.Container func registerAuthedMediaRoutes(authed *gin.RouterGroup, svc *service.Container) { authed.GET("/media/:id", getMediaHandler(svc)) authed.GET("/media/:id/episodes", listMediaEpisodesHandler(svc)) + authed.GET("/media/:id/playback", mediaPlaybackInfoHandler(svc)) + authed.POST("/media/:id/transcode", mediaTranscodeHandler(svc)) authed.GET("/media", searchMediaHandler(svc)) authed.PATCH("/media/:id/metadata", middleware.AdminRequired(), updateMediaMetadataHandler(svc)) authed.POST("/media/:id/scrape", middleware.AdminRequired(), scrapeOneHandler(svc)) @@ -65,6 +67,10 @@ func registerAuthedPlaybackAndProxyRoutes(authed *gin.RouterGroup, svc *service. authed.GET("/hls/:id/index.m3u8", hlsPlaylistHandler(svc)) authed.GET("/hls/:id/:seg", hlsSegmentHandler(svc)) authed.DELETE("/hls/:id", stopTranscodeHandler(svc)) + authed.GET("/cloud115/media/:id/master.m3u8", cloud115HLSMasterHandler(svc)) + authed.HEAD("/cloud115/media/:id/master.m3u8", cloud115HLSMasterHandler(svc)) + authed.GET("/cloud115/hls/:session/:key", cloud115HLSSessionHandler(svc)) + authed.HEAD("/cloud115/hls/:session/:key", cloud115HLSSessionHandler(svc)) authed.GET("/img", imageProxyHandler(svc)) } diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go index 6f3041b..5370ab4 100644 --- a/internal/middleware/auth.go +++ b/internal/middleware/auth.go @@ -125,6 +125,8 @@ func externalPlaybackTokenAllowedPath(c *gin.Context, mediaID string) bool { return len(segments) >= 3 && strings.EqualFold(segments[2], "play") && strings.TrimSpace(c.Query("media_id")) == mediaID + case "cloud115": + return strings.TrimSpace(c.Query("media_id")) == mediaID } } return false diff --git a/internal/service/cloud115/video.go b/internal/service/cloud115/video.go new file mode 100644 index 0000000..98573d4 --- /dev/null +++ b/internal/service/cloud115/video.go @@ -0,0 +1,186 @@ +// 115 开放平台视频播放/转码 API。 +// +// 播放接口返回的是 115 云端转码后的 HLS(m3u8)地址,不是原文件 +// downurl;未完成转码时接口会返回 state=false,可再调用 video_push +// 请求加速转码。 +package cloud115 + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "sort" + "strconv" + "strings" +) + +// FlexString 兼容 115 接口同字段在不同设备端返回字符串或数字的情况。 +type FlexString string + +func (s *FlexString) UnmarshalJSON(data []byte) error { + raw := bytes.TrimSpace(data) + if len(raw) == 0 || bytes.Equal(raw, []byte("null")) { + *s = "" + return nil + } + var str string + if err := json.Unmarshal(raw, &str); err == nil { + *s = FlexString(str) + return nil + } + var num json.Number + if err := json.Unmarshal(raw, &num); err == nil { + *s = FlexString(num.String()) + return nil + } + return fmt.Errorf("115: 无法解析字符串/数字字段 %s", string(raw)) +} + +func (s FlexString) String() string { + return string(s) +} + +func (s FlexString) Int64() int64 { + v, _ := strconv.ParseInt(strings.TrimSpace(string(s)), 10, 64) + return v +} + +// MultiTrack 是 115 返回的一条音轨信息。 +type MultiTrack struct { + Title string `json:"title"` + IsSelected string `json:"is_selected"` + SyncTime string `json:"sync_time"` +} + +// MultiTrackList 兼容 115 接口把 multitrack_list 返回成对象(键为音轨序号) +// 或数组的两种形态。 +type MultiTrackList []MultiTrack + +func (l *MultiTrackList) UnmarshalJSON(data []byte) error { + raw := bytes.TrimSpace(data) + if len(raw) == 0 || bytes.Equal(raw, []byte("null")) || bytes.Equal(raw, []byte("{}")) { + *l = nil + return nil + } + if raw[0] == '[' { + var arr []MultiTrack + if err := json.Unmarshal(raw, &arr); err != nil { + return err + } + *l = arr + return nil + } + if raw[0] == '{' { + var m map[string]MultiTrack + if err := json.Unmarshal(raw, &m); err != nil { + return err + } + keys := make([]int, 0, len(m)) + for key := range m { + idx, err := strconv.Atoi(strings.TrimSpace(key)) + if err != nil { + continue + } + keys = append(keys, idx) + } + sort.Ints(keys) + out := make([]MultiTrack, 0, len(keys)) + for _, idx := range keys { + out = append(out, m[strconv.Itoa(idx)]) + } + *l = out + return nil + } + return fmt.Errorf("115: multitrack_list 既不是对象也不是数组") +} + +// VideoURLItem 是 115 返回的一条清晰度播放地址。 +type VideoURLItem struct { + URL string `json:"url"` + Height int `json:"height"` + Width int `json:"width"` + Definition int `json:"definition"` + Title string `json:"title"` + DefinitionN int `json:"definition_n"` +} + +// VideoPlayData 是 /open/video/play 的 data 字段。 +type VideoPlayData struct { + FileID string `json:"file_id"` + ParentID string `json:"parent_id"` + FileName string `json:"file_name"` + FileSize FlexString `json:"file_size"` + FileSha1 string `json:"file_sha1"` + FileType string `json:"file_type"` + IsPrivate FlexString `json:"is_private"` + PlayLong FlexString `json:"play_long"` + UserDef int `json:"user_def"` + UserRotate int `json:"user_rotate"` + UserTurn int `json:"user_turn"` + MultitrackList MultiTrackList `json:"multitrack_list"` + DefinitionList map[string]string `json:"definition_list"` + DefinitionListNew map[string]string `json:"definition_list_new"` + VideoURL []VideoURLItem `json:"video_url"` + VideoPushState *bool `json:"video_push_state"` +} + +// GetVideoPlayInfo 调用 115 视频在线播放接口。 +// +// 注意:state=false(例如视频尚未转码)时 doAuthJSON 会返回 error,但仍会 +// 返回已解析的 RespBase/VideoPlayData。调用方需要同时检查两者:有 data 时 +// 可用于判断需要等待还是触发加速转码。 +func (c *OpenClient) GetVideoPlayInfo(ctx context.Context, pickCode, ua string) (*RespBase, *VideoPlayData, error) { + pickCode = strings.TrimSpace(pickCode) + if pickCode == "" { + return nil, nil, fmt.Errorf("115: pick_code 为空") + } + resp, err := c.doAuthJSONWithUA( + ctx, + http.MethodGet, + ProAPIBase+"/open/video/play", + map[string]string{"pick_code": pickCode}, + 1, + ua, + ) + if resp == nil { + return nil, nil, err + } + data := &VideoPlayData{} + if len(resp.Data) > 0 { + trimmed := bytes.TrimSpace(resp.Data) + if !bytes.Equal(trimmed, []byte("null")) { + if decodeErr := json.Unmarshal(trimmed, data); decodeErr != nil && err == nil { + err = fmt.Errorf("115: 解析视频播放信息失败:%w", decodeErr) + } + } + } + return resp, data, err +} + +// SubmitVideoPush 提交 115 加速转码请求。 +// +// op 支持 vip_push(按 VIP 等级加速)和 pay_push(消耗枫币)。调用方必须 +// 明确选择 op,默认空值按 vip_push 处理,避免误消费枫币。 +func (c *OpenClient) SubmitVideoPush(ctx context.Context, pickCode, op string) error { + pickCode = strings.TrimSpace(pickCode) + if pickCode == "" { + return fmt.Errorf("115: pick_code 为空") + } + op = strings.TrimSpace(op) + if op == "" { + op = "vip_push" + } + _, err := c.doAuthJSON( + ctx, + http.MethodPost, + ProAPIBase+"/open/video/video_push", + map[string]string{ + "pick_code": pickCode, + "op": op, + }, + 1, + ) + return err +} diff --git a/internal/service/cloud115_hls_proxy.go b/internal/service/cloud115_hls_proxy.go new file mode 100644 index 0000000..626dea1 --- /dev/null +++ b/internal/service/cloud115_hls_proxy.go @@ -0,0 +1,361 @@ +package service + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "sync" + "time" + + "github.com/truewhile/MeBox/internal/service/cloud115" +) + +var ( + ErrCloud115HLSSessionNotFound = errors.New("115 hls session not found") + ErrCloud115HLSUpstreamExpired = errors.New("115 hls upstream expired") +) + +const ( + cloud115HLSSessionTTL = 30 * time.Minute + cloud115HLSMaxSessions = 256 + cloud115HLSMaxManifest = 8 << 20 +) + +type cloud115HLSSession struct { + ID string + MediaID string + Definition int + CreatedAt time.Time + ExpiresAt time.Time + + mu sync.Mutex + entries map[string]string + next int +} + +// Cloud115HLSProxy 把 115 云端 HLS 转成 MeBox 同源 HLS。 +// +// 浏览器不能直接请求 115 的 m3u8:master/variant/分片的 CORS 只允许 +// https://115.com,且 master 还是 HTTP。代理在服务端拉取并重写播放列表, +// 分片按 Range 流式转发。 +type Cloud115HLSProxy struct { + service *Cloud115PlaybackService + client *http.Client + + mu sync.Mutex + sessions map[string]*cloud115HLSSession +} + +func newCloud115HLSProxy(service *Cloud115PlaybackService) *Cloud115HLSProxy { + return &Cloud115HLSProxy{ + service: service, + client: &http.Client{ + Timeout: 0, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + if len(via) >= 6 { + return errors.New("stopped after 6 redirects") + } + return nil + }, + }, + sessions: make(map[string]*cloud115HLSSession), + } +} + +// ServeMaster 解析指定清晰度并返回重写后的 master.m3u8。 +func (p *Cloud115HLSProxy) ServeMaster(ctx context.Context, w http.ResponseWriter, r *http.Request, mediaID string, definition int) error { + if p == nil || p.service == nil { + return ErrCloud115NotApplicable + } + upstream, _, err := p.service.ResolveCloud115URL(ctx, mediaID, definition) + if err != nil { + return err + } + session := p.newSession(mediaID, definition) + fetchCtx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + resp, err := p.fetchUpstream(fetchCtx, upstream, "") + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("115 云端播放列表返回 HTTP %d", resp.StatusCode) + } + body, err := io.ReadAll(io.LimitReader(resp.Body, cloud115HLSMaxManifest)) + if err != nil { + return err + } + baseURL := upstream + if resp.Request != nil && resp.Request.URL != nil { + baseURL = resp.Request.URL.String() + } + rewritten := p.rewriteManifest(session, string(body), baseURL, r.URL.RawQuery) + p.storeSession(session) + + w.Header().Set("Content-Type", "application/vnd.apple.mpegurl") + w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0") + w.Header().Set("Content-Length", strconv.Itoa(len(rewritten))) + if r.Method != http.MethodHead { + _, err = io.WriteString(w, rewritten) + } + return err +} + +// ServeChild 代理 variant/分片;variant 播放列表会继续重写为同源地址。 +func (p *Cloud115HLSProxy) ServeChild(ctx context.Context, w http.ResponseWriter, r *http.Request, sessionID, key string) error { + session, upstream, ok := p.lookup(sessionID, key) + if !ok { + return ErrCloud115HLSSessionNotFound + } + resp, err := p.fetchUpstream(ctx, upstream, r.Header.Get("Range")) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusGone { + p.deleteSession(sessionID) + return ErrCloud115HLSUpstreamExpired + } + + contentType := strings.ToLower(strings.TrimSpace(resp.Header.Get("Content-Type"))) + isPlaylist := strings.Contains(contentType, "mpegurl") || + strings.Contains(contentType, "application/vnd.apple.mpegurl") + + if !isPlaylist { + body, readErr := io.ReadAll(io.LimitReader(resp.Body, 4096)) + if readErr != nil { + return readErr + } + if strings.HasPrefix(strings.TrimSpace(string(body)), "#EXTM3U") { + isPlaylist = true + resp.Body = io.NopCloser(io.MultiReader(strings.NewReader(string(body)), resp.Body)) + } else { + resp.Body = io.NopCloser(io.MultiReader(strings.NewReader(string(body)), resp.Body)) + } + } + + copyUpstreamHeaders(w, resp, isPlaylist) + if isPlaylist { + body, err := io.ReadAll(io.LimitReader(resp.Body, cloud115HLSMaxManifest)) + if err != nil { + return err + } + baseURL := upstream + if resp.Request != nil && resp.Request.URL != nil { + baseURL = resp.Request.URL.String() + } + rewritten := p.rewriteManifest(session, string(body), baseURL, r.URL.RawQuery) + w.Header().Set("Content-Type", "application/vnd.apple.mpegurl") + w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0") + w.Header().Set("Content-Length", strconv.Itoa(len(rewritten))) + w.WriteHeader(resp.StatusCode) + if r.Method != http.MethodHead { + _, err = io.WriteString(w, rewritten) + } + return err + } + + w.WriteHeader(resp.StatusCode) + if r.Method != http.MethodHead { + _, err = io.Copy(w, resp.Body) + } + return err +} + +func (p *Cloud115HLSProxy) newSession(mediaID string, definition int) *cloud115HLSSession { + now := time.Now() + return &cloud115HLSSession{ + ID: cloud115.RandomString(24), + MediaID: mediaID, + Definition: definition, + CreatedAt: now, + ExpiresAt: now.Add(cloud115HLSSessionTTL), + entries: make(map[string]string), + } +} + +func (p *Cloud115HLSProxy) storeSession(session *cloud115HLSSession) { + if p == nil || session == nil { + return + } + p.mu.Lock() + defer p.mu.Unlock() + now := time.Now() + if len(p.sessions) >= cloud115HLSMaxSessions { + for id, existing := range p.sessions { + if now.After(existing.ExpiresAt) { + delete(p.sessions, id) + } + } + } + if len(p.sessions) >= cloud115HLSMaxSessions { + for id := range p.sessions { + delete(p.sessions, id) + break + } + } + session.ExpiresAt = now.Add(cloud115HLSSessionTTL) + p.sessions[session.ID] = session +} + +func (p *Cloud115HLSProxy) lookup(sessionID, key string) (*cloud115HLSSession, string, bool) { + if p == nil { + return nil, "", false + } + p.mu.Lock() + session := p.sessions[sessionID] + if session != nil && time.Now().After(session.ExpiresAt) { + delete(p.sessions, sessionID) + session = nil + } + p.mu.Unlock() + if session == nil { + return nil, "", false + } + session.mu.Lock() + upstream, ok := session.entries[key] + session.mu.Unlock() + return session, upstream, ok +} + +func (p *Cloud115HLSProxy) deleteSession(sessionID string) { + if p == nil { + return + } + p.mu.Lock() + delete(p.sessions, sessionID) + p.mu.Unlock() +} + +func (p *Cloud115HLSProxy) fetchUpstream(ctx context.Context, rawURL, rangeHeader string) (*http.Response, error) { + parsed, err := url.Parse(strings.TrimSpace(rawURL)) + if err != nil || parsed == nil || !isAllowed115UpstreamHost(parsed.Hostname()) { + return nil, fmt.Errorf("115 hls upstream host not allowed: %s", rawURL) + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) + if err != nil { + return nil, err + } + req.Header.Set("User-Agent", cloud115.DefaultUA) + if strings.TrimSpace(rangeHeader) != "" { + req.Header.Set("Range", rangeHeader) + } + return p.client.Do(req) +} + +func isAllowed115UpstreamHost(host string) bool { + host = strings.ToLower(strings.TrimSpace(host)) + if host == "" { + return false + } + for _, suffix := range []string{".115.com", ".115cdn.com", ".115cdn.net"} { + if strings.HasSuffix(host, suffix) { + return true + } + } + return host == "115.com" || host == "115cdn.com" || host == "115cdn.net" +} + +func (p *Cloud115HLSProxy) rewriteManifest(session *cloud115HLSSession, text, baseURL, rawQuery string) string { + if session == nil { + return text + } + lines := strings.SplitAfter(text, "\n") + for i, line := range lines { + trimmed := strings.TrimSpace(line) + if trimmed == "" { + continue + } + if strings.HasPrefix(trimmed, "#") { + if strings.HasPrefix(trimmed, "#EXT-X-MEDIA:") || + strings.HasPrefix(trimmed, "#EXT-X-KEY:") || + strings.HasPrefix(trimmed, "#EXT-X-MAP:") { + lines[i] = replaceManifestURI(line, func(uri string) string { + return p.proxyURL(session, resolveManifestURL(baseURL, uri), rawQuery) + }) + } + continue + } + lines[i] = p.proxyURL(session, resolveManifestURL(baseURL, trimmed), rawQuery) + lineEnding(line) + } + return strings.Join(lines, "") +} + +func (p *Cloud115HLSProxy) proxyURL(session *cloud115HLSSession, upstream, rawQuery string) string { + session.mu.Lock() + session.next++ + key := "e" + strconv.Itoa(session.next) + session.entries[key] = upstream + mediaID := session.MediaID + session.mu.Unlock() + + query := childProxyQuery(rawQuery, mediaID) + return "/api/cloud115/hls/" + url.PathEscape(session.ID) + "/" + url.PathEscape(key) + "?" + query +} + +func childProxyQuery(rawQuery, mediaID string) string { + values, _ := url.ParseQuery(rawQuery) + keep := url.Values{} + for _, key := range []string{"token", "api_key", "apiKey", "ApiKey", "profile_id", "profile_pin_token"} { + if value := strings.TrimSpace(values.Get(key)); value != "" { + keep.Set(key, value) + } + } + if strings.TrimSpace(mediaID) != "" { + keep.Set("media_id", mediaID) + } + return keep.Encode() +} + +func replaceManifestURI(line string, replace func(string) string) string { + const marker = `URI="` + idx := strings.Index(line, marker) + if idx < 0 { + return line + } + start := idx + len(marker) + end := strings.Index(line[start:], `"`) + if end < 0 { + return line + } + end += start + return line[:start] + replace(line[start:end]) + line[end:] +} + +func resolveManifestURL(baseURL, raw string) string { + base, baseErr := url.Parse(strings.TrimSpace(baseURL)) + ref, refErr := url.Parse(strings.TrimSpace(raw)) + if baseErr != nil || refErr != nil || base == nil || ref == nil { + return raw + } + return base.ResolveReference(ref).String() +} + +func lineEnding(line string) string { + if strings.HasSuffix(line, "\r\n") { + return "\r\n" + } + if strings.HasSuffix(line, "\n") { + return "\n" + } + return "" +} + +func copyUpstreamHeaders(w http.ResponseWriter, resp *http.Response, playlist bool) { + for _, key := range []string{"Content-Type", "Content-Length", "Content-Range", "Accept-Ranges", "ETag", "Last-Modified"} { + if value := resp.Header.Get(key); value != "" { + w.Header().Set(key, value) + } + } + if playlist { + w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0") + } else if w.Header().Get("Cache-Control") == "" { + w.Header().Set("Cache-Control", "public, max-age=3600") + } +} diff --git a/internal/service/cloud115_hls_proxy_test.go b/internal/service/cloud115_hls_proxy_test.go new file mode 100644 index 0000000..72225b1 --- /dev/null +++ b/internal/service/cloud115_hls_proxy_test.go @@ -0,0 +1,151 @@ +package service + +import ( + "bytes" + "context" + "io" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "testing" + "time" +) + +func TestCloud115HLSProxyRewriteManifest(t *testing.T) { + proxy := &Cloud115HLSProxy{sessions: map[string]*cloud115HLSSession{}} + session := &cloud115HLSSession{ + ID: "sess", + MediaID: "media-1", + entries: map[string]string{}, + } + manifest := "#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=1800000,RESOLUTION=1280x720\nhttps://cpats01.115.com/a.m3u8?u=1&se=2\n" + rewritten := proxy.rewriteManifest(session, manifest, "http://videoplay.115.com/m3u8/pc", "token=t&media_id=media-1") + if strings.Contains(rewritten, "cpats01.115.com") { + t.Fatalf("upstream URL leaked into rewritten manifest: %s", rewritten) + } + if !strings.Contains(rewritten, "/api/cloud115/hls/sess/e1?") { + t.Fatalf("proxy URL missing: %s", rewritten) + } + if !strings.Contains(rewritten, "media_id=media-1") || !strings.Contains(rewritten, "token=t") { + t.Fatalf("auth/media query missing: %s", rewritten) + } + if len(session.entries) != 1 { + t.Fatalf("session entries = %d, want 1", len(session.entries)) + } +} + +func TestCloud115HLSProxyRewriteKeyURI(t *testing.T) { + proxy := &Cloud115HLSProxy{sessions: map[string]*cloud115HLSSession{}} + session := &cloud115HLSSession{ + ID: "sess", + MediaID: "media-1", + entries: map[string]string{}, + } + manifest := `#EXTM3U +#EXT-X-KEY:METHOD=AES-128,URI="https://cpats01.115.com/key?k=1" +#EXTINF:10.0, +https://cpats01.115.com/seg.ts?x=1 +` + rewritten := proxy.rewriteManifest(session, manifest, "https://cpats01.115.com/v.m3u8", "token=t&media_id=media-1") + if strings.Contains(rewritten, "cpats01.115.com") { + t.Fatalf("upstream URL leaked into rewritten key manifest: %s", rewritten) + } + if len(session.entries) != 2 { + t.Fatalf("session entries = %d, want 2", len(session.entries)) + } +} + +func TestIsAllowed115UpstreamHost(t *testing.T) { + for _, host := range []string{"videoplay.115.com", "cpats01.115.com", "cdn.115cdn.net"} { + if !isAllowed115UpstreamHost(host) { + t.Fatalf("host %q should be allowed", host) + } + } + for _, host := range []string{"", "evil.example.com", "115.com.evil.example.com"} { + if isAllowed115UpstreamHost(host) { + t.Fatalf("host %q should be rejected", host) + } + } +} + +func TestCloud115HLSProxyServeChildRewritesVariant(t *testing.T) { + proxy := &Cloud115HLSProxy{ + sessions: map[string]*cloud115HLSSession{}, + } + proxy.client = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + body := "#EXTM3U\n#EXT-X-TARGETDURATION:10\nhttps://cpats01.115.com/seg.ts?x=1\n" + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{ + "Content-Type": []string{"application/vnd.apple.mpegurl"}, + }, + Body: io.NopCloser(strings.NewReader(body)), + Request: req, + }, nil + })} + session := &cloud115HLSSession{ + ID: "sess", + MediaID: "media-1", + ExpiresAt: time.Now().Add(time.Hour), + entries: map[string]string{"e1": "https://cpats01.115.com/v.m3u8"}, + next: 1, + } + proxy.sessions[session.ID] = session + req := httptest.NewRequest(http.MethodGet, "/api/cloud115/hls/sess/e1?media_id=media-1&token=t", nil) + rec := httptest.NewRecorder() + if err := proxy.ServeChild(context.Background(), rec, req, session.ID, "e1"); err != nil { + t.Fatalf("ServeChild: %v", err) + } + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + if !strings.Contains(rec.Body.String(), "/api/cloud115/hls/sess/e2?") { + t.Fatalf("rewritten variant missing proxy segment: %s", rec.Body.String()) + } + if strings.Contains(rec.Body.String(), "cpats01.115.com") { + t.Fatalf("upstream URL leaked in variant: %s", rec.Body.String()) + } +} + +func TestCloud115HLSProxyServeChildStreamsRange(t *testing.T) { + payload := []byte("segment-bytes") + proxy := &Cloud115HLSProxy{ + sessions: map[string]*cloud115HLSSession{}, + } + proxy.client = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusPartialContent, + Header: http.Header{ + "Content-Type": []string{"video/mp2t"}, + "Content-Range": []string{"bytes 0-12/100"}, + "Content-Length": []string{strconv.Itoa(len(payload))}, + }, + Body: io.NopCloser(bytes.NewReader(payload)), + Request: req, + }, nil + })} + session := &cloud115HLSSession{ + ID: "sess", + MediaID: "media-1", + ExpiresAt: time.Now().Add(time.Hour), + entries: map[string]string{"e1": "https://cpats01.115.com/seg.ts?x=1"}, + next: 1, + } + proxy.sessions[session.ID] = session + req := httptest.NewRequest(http.MethodGet, "/api/cloud115/hls/sess/e1?media_id=media-1&token=t", nil) + req.Header.Set("Range", "bytes=0-12") + rec := httptest.NewRecorder() + if err := proxy.ServeChild(context.Background(), rec, req, session.ID, "e1"); err != nil { + t.Fatalf("ServeChild: %v", err) + } + if rec.Code != http.StatusPartialContent { + t.Fatalf("status = %d, want 206", rec.Code) + } + if rec.Header().Get("Content-Range") != "bytes 0-12/100" { + t.Fatalf("content-range = %q", rec.Header().Get("Content-Range")) + } + if !bytes.Equal(rec.Body.Bytes(), payload) { + t.Fatalf("payload = %q, want %q", rec.Body.Bytes(), payload) + } +} diff --git a/internal/service/cloud115_playback.go b/internal/service/cloud115_playback.go new file mode 100644 index 0000000..8f24059 --- /dev/null +++ b/internal/service/cloud115_playback.go @@ -0,0 +1,411 @@ +package service + +import ( + "context" + "errors" + "fmt" + "net/url" + "strconv" + "strings" + "sync" + "time" + + "go.uber.org/zap" + + "github.com/truewhile/MeBox/internal/config" + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/repository" + "github.com/truewhile/MeBox/internal/service/cloud" + "github.com/truewhile/MeBox/internal/service/cloud115" +) + +var ( + ErrCloud115TranscodePending = errors.New("115 cloud transcode pending") + ErrCloud115NotApplicable = errors.New("115 cloud playback not applicable") +) + +// Cloud115TranscodeState 描述当前请求清晰度的云端转码状态。 +type Cloud115TranscodeState struct { + State string `json:"state"` // idle / ready / transcoding / unavailable + Definition string `json:"definition,omitempty"` + Message string `json:"message,omitempty"` + RetryAfterSec int `json:"retry_after_sec,omitempty"` + StartedAt int64 `json:"started_at,omitempty"` +} + +// PlaybackInfo 是播放器加载时获取的统一播放能力描述。 +type PlaybackInfo struct { + MediaID string `json:"media_id"` + Provider string `json:"provider"` + Fallback []string `json:"fallback"` + DefaultQuality string `json:"default_quality"` + CloudQualities []PlaybackQuality `json:"cloud_qualities,omitempty"` + LocalQualities []PlaybackQuality `json:"local_qualities"` + Transcode Cloud115TranscodeState `json:"transcode"` +} + +type cloud115PushAttempt struct { + at time.Time + success bool + message string +} + +// Cloud115PlaybackService 负责 115 云端播放、清晰度列表和云端转码触发。 +// 实际的 HLS 代理在 Cloud115HLSProxy 中。 +type Cloud115PlaybackService struct { + cfg *config.Config + log *zap.Logger + repo *repository.Container + strm *StrmService + + mu sync.Mutex + pushState map[string]cloud115PushAttempt + + proxy *Cloud115HLSProxy +} + +func NewCloud115PlaybackService(cfg *config.Config, log *zap.Logger, repo *repository.Container, strm *StrmService) *Cloud115PlaybackService { + svc := &Cloud115PlaybackService{ + cfg: cfg, + log: log, + repo: repo, + strm: strm, + pushState: make(map[string]cloud115PushAttempt), + } + svc.proxy = newCloud115HLSProxy(svc) + return svc +} + +// HLSProxy 返回 115 云 HLS 反向代理。 +func (s *Cloud115PlaybackService) HLSProxy() *Cloud115HLSProxy { + if s == nil { + return nil + } + return s.proxy +} + +// PlaybackInfo 返回媒体在 direct/115-cloud/local 三种模式下的能力描述。 +// +// requestedDefinition 为空或 0 时默认选择 1080P;该档位不可用时返回 +// transcoding 状态(并只触发一次加速转码,避免轮询风暴)。 +func (s *Cloud115PlaybackService) PlaybackInfo(ctx context.Context, mediaID string, requestedDefinition int) (*PlaybackInfo, error) { + if s == nil || s.repo == nil || s.repo.Media == nil { + return nil, ErrMediaNotFound + } + m, err := s.repo.Media.FindByID(ctx, mediaID) + if err != nil { + return nil, err + } + if m == nil { + return nil, ErrMediaNotFound + } + + provider := MediaPlaybackProvider(m) + localQualities := LocalQualityOptions(m) + localDefault := DefaultLocalHLSQualityID(m) + info := &PlaybackInfo{ + MediaID: m.ID, + Provider: provider, + DefaultQuality: localDefault, + LocalQualities: localQualities, + Transcode: Cloud115TranscodeState{State: "idle"}, + } + if provider != model.StrmProvider115 { + info.Fallback = []string{"direct", "local_hls"} + return info, nil + } + + account, pickCode, client, err := s.resolve115Target(ctx, m) + if err != nil { + info.Fallback = []string{"direct", "local_hls"} + info.Transcode = Cloud115TranscodeState{State: "unavailable", Message: err.Error()} + return info, nil + } + + resp, data, playErr := client.GetVideoPlayInfo(ctx, pickCode, cloud115.DefaultUA) + if cloud115PlayInfoFatal(resp, playErr) { + info.Fallback = []string{"direct", "local_hls"} + info.Transcode = Cloud115TranscodeState{ + State: "unavailable", + Message: cloud115PlayErrorMessage(playErr, resp), + } + return info, nil + } + + cloudQualities := Cloud115QualityOptions(m, data) + info.CloudQualities = cloudQualities + info.Fallback = []string{"direct", "cloud_hls", "local_hls"} + info.DefaultQuality = DefaultCloud115Quality(cloudQualities) + + requested := strings.TrimSpace(strconv.Itoa(requestedDefinition)) + if requestedDefinition <= 0 || requested == "0" { + requested = info.DefaultQuality + } + selected, ok := findPlaybackQuality(cloudQualities, requested) + if !ok { + info.Transcode = Cloud115TranscodeState{ + State: "unavailable", + Definition: requested, + Message: "115 不支持该清晰度", + } + return info, nil + } + if selected.Source == "original" { + info.Transcode = Cloud115TranscodeState{State: "ready", Definition: selected.ID} + return info, nil + } + if selected.Available { + info.Transcode = Cloud115TranscodeState{State: "ready", Definition: selected.ID} + return info, nil + } + + info.Transcode = s.ensureCloudTranscode(ctx, account.ID, pickCode, selected, client) + return info, nil +} + +// StartTranscode 显式触发某个清晰度的云端转码,返回最新状态。 +func (s *Cloud115PlaybackService) StartTranscode(ctx context.Context, mediaID string, definition int) (*PlaybackInfo, error) { + return s.PlaybackInfo(ctx, mediaID, definition) +} + +// ResolveCloud115URL 返回指定清晰度的 115 云端 m3u8 地址。 +// 档位尚未转码完成时返回 ErrCloud115TranscodePending。 +func (s *Cloud115PlaybackService) ResolveCloud115URL(ctx context.Context, mediaID string, definition int) (string, *cloud115.VideoPlayData, error) { + m, err := s.repo.Media.FindByID(ctx, mediaID) + if err != nil { + return "", nil, err + } + if m == nil { + return "", nil, ErrMediaNotFound + } + if MediaPlaybackProvider(m) != model.StrmProvider115 { + return "", nil, ErrCloud115NotApplicable + } + _, pickCode, client, err := s.resolve115Target(ctx, m) + if err != nil { + return "", nil, err + } + resp, data, playErr := client.GetVideoPlayInfo(ctx, pickCode, cloud115.DefaultUA) + if cloud115PlayInfoFatal(resp, playErr) { + return "", data, fmt.Errorf("115 云端播放不可用:%s", cloud115PlayErrorMessage(playErr, resp)) + } + requested := definition + if requested <= 0 { + requested = 4 + } + item := findVideoURL(data, requested) + if item == nil || strings.TrimSpace(item.URL) == "" { + return "", data, ErrCloud115TranscodePending + } + return strings.TrimSpace(item.URL), data, nil +} + +func (s *Cloud115PlaybackService) ensureCloudTranscode( + ctx context.Context, + accountID, pickCode string, + quality PlaybackQuality, + client *cloud115.OpenClient, +) Cloud115TranscodeState { + key := accountID + ":" + pickCode + now := time.Now() + + s.mu.Lock() + if last, ok := s.pushState[key]; ok { + ttl := 2 * time.Minute + if last.success { + ttl = 3 * time.Hour + } + if now.Sub(last.at) < ttl { + s.mu.Unlock() + return Cloud115TranscodeState{ + State: "transcoding", + Definition: quality.ID, + Message: last.message, + RetryAfterSec: 5, + StartedAt: last.at.Unix(), + } + } + } + s.mu.Unlock() + + err := client.SubmitVideoPush(ctx, pickCode, "vip_push") + attempt := cloud115PushAttempt{at: now, success: err == nil} + if err != nil { + attempt.message = fmt.Sprintf("115 云端转码排队中(加速请求未成功:%s)", cloud115PlayErrorMessage(err, nil)) + } else { + attempt.message = fmt.Sprintf("已触发 115 云端转码,正在等待 %s…", quality.Label) + } + + s.mu.Lock() + s.pushState[key] = attempt + s.mu.Unlock() + + return Cloud115TranscodeState{ + State: "transcoding", + Definition: quality.ID, + Message: attempt.message, + RetryAfterSec: 5, + StartedAt: now.Unix(), + } +} + +func (s *Cloud115PlaybackService) resolve115Target(ctx context.Context, m *model.Media) (*model.StrmAccount, string, *cloud115.OpenClient, error) { + if s == nil || s.strm == nil || s.repo == nil || s.repo.StrmAccount == nil { + return nil, "", nil, ErrCloud115NotApplicable + } + raw := strings.TrimSpace(m.STRMURL) + if raw == "" && strings.HasSuffix(strings.ToLower(strings.TrimSpace(m.Path)), ".strm") { + parsed, err := readLocalSTRMTarget(m.Path) + if err != nil { + return nil, "", nil, err + } + raw = strings.TrimSpace(parsed) + } + if raw == "" { + return nil, "", nil, fmt.Errorf("缺少 STRM 播放目标") + } + u, err := url.Parse(raw) + if err != nil { + return nil, "", nil, err + } + if !strings.Contains(strings.ToLower(u.Path), "/api/strm/play/cloud115/") && + !strings.Contains(strings.ToLower(u.Path), "/api/cloud/play/cloud115") { + return nil, "", nil, ErrCloud115NotApplicable + } + accountID := strings.TrimSpace(u.Query().Get("acct")) + if accountID == "" { + return nil, "", nil, fmt.Errorf("缺少 115 账号 ID") + } + pickCode := strings.TrimSpace(u.Query().Get("pickcode")) + if pickCode == "" { + return nil, "", nil, fmt.Errorf("缺少 115 pickcode") + } + account, err := s.repo.StrmAccount.FindByID(ctx, accountID) + if err != nil { + return nil, "", nil, err + } + if account == nil || !account.Enabled { + return nil, "", nil, fmt.Errorf("115 账号不存在或已禁用") + } + if account.Provider != model.StrmProvider115 { + return nil, "", nil, ErrCloud115NotApplicable + } + provider, err := s.strm.providerFor(ctx, account) + if err != nil { + return nil, "", nil, err + } + openProvider, ok := provider.(cloud.OpenAPI115Provider) + if !ok || openProvider.OpenClient() == nil { + return nil, "", nil, ErrCloud115NotApplicable + } + return account, pickCode, openProvider.OpenClient(), nil +} + +// MediaPlaybackProvider 返回媒体行的播放提供方(用于前端降级顺序)。 +func MediaPlaybackProvider(m *model.Media) string { + if m == nil { + return model.StrmProviderLocal + } + raw := strings.TrimSpace(m.STRMURL) + if raw == "" && strings.HasSuffix(strings.ToLower(strings.TrimSpace(m.Path)), ".strm") { + if target, err := readLocalSTRMTarget(m.Path); err == nil { + raw = strings.TrimSpace(target) + } + } + if raw != "" { + if u, err := url.Parse(raw); err == nil { + path := strings.ToLower(u.Path) + if idx := strings.Index(path, "/api/strm/play/"); idx >= 0 { + rest := strings.Trim(path[idx+len("/api/strm/play/"):], "/") + if provider, _, ok := strings.Cut(rest, "/"); ok && provider != "" { + return provider + } + } + if idx := strings.Index(path, "/api/cloud/play/"); idx >= 0 { + provider := strings.Trim(path[idx+len("/api/cloud/play/"):], "/") + if provider != "" { + return provider + } + } + } + } + if IsStrmMediaRow(m) { + return "strm" + } + return model.StrmProviderLocal +} + +func findPlaybackQuality(options []PlaybackQuality, id string) (PlaybackQuality, bool) { + id = strings.TrimSpace(id) + for _, option := range options { + if option.ID == id { + return option, true + } + } + return PlaybackQuality{}, false +} + +func findVideoURL(data *cloud115.VideoPlayData, definition int) *cloud115.VideoURLItem { + if data == nil || definition <= 0 { + return nil + } + for i := range data.VideoURL { + item := &data.VideoURL[i] + if item.DefinitionN == definition || item.Definition == definition { + return item + } + } + return nil +} + +func cloud115PlayInfoFatal(resp *cloud115.RespBase, err error) bool { + if resp == nil { + return true + } + if bool(resp.State) { + return false + } + switch resp.Code { + case cloud115.AccessTokenAuthFail, + cloud115.AccessTokenExpiryCode, + cloud115.AccessAuthInvalid, + cloud115.AccessTokenFormatInvalid, + cloud115.RefreshTokenInvalid, + cloud115.TokenRefreshFail, + cloud115.RequestMaxLimitCode, + cloud115.RequestRateLimitCode: + return true + } + // 非鉴权/限流错误一律当作“尚未转码完成”处理,继续轮询。 + _ = err + return false +} + +func cloud115PlayErrorMessage(err error, resp *cloud115.RespBase) string { + if err != nil { + var apiErr *cloud115.OpenAPIError + if errors.As(err, &apiErr) { + switch apiErr.Code { + case cloud115.AccessTokenAuthFail, + cloud115.AccessTokenExpiryCode, + cloud115.AccessAuthInvalid, + cloud115.AccessTokenFormatInvalid, + cloud115.RefreshTokenInvalid: + return "115 授权已过期,请重新授权账号" + } + if strings.TrimSpace(apiErr.Message) != "" { + return apiErr.Message + } + } + return err.Error() + } + if resp != nil { + if msg := strings.TrimSpace(resp.Message); msg != "" { + return msg + } + if msg := strings.TrimSpace(resp.Error); msg != "" { + return msg + } + } + return "115 云端播放不可用" +} diff --git a/internal/service/cloud115_playback_test.go b/internal/service/cloud115_playback_test.go new file mode 100644 index 0000000..2e1d451 --- /dev/null +++ b/internal/service/cloud115_playback_test.go @@ -0,0 +1,203 @@ +package service + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + + "go.uber.org/zap" + + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/repository" + "github.com/truewhile/MeBox/internal/service/cloud115" +) + +func TestCloud115PlaybackInfoTriggersTranscodeOnce(t *testing.T) { + var playCalls int32 + var pushCalls int32 + var ready int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/open/video/play": + atomic.AddInt32(&playCalls, 1) + if atomic.LoadInt32(&ready) == 0 { + _ = json.NewEncoder(w).Encode(map[string]any{ + "state": false, + "code": 0, + "message": "转码中", + "data": map[string]any{}, + }) + return + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "state": true, + "code": 0, + "message": "", + "data": map[string]any{ + "file_name": "test.mkv", + "definition_list_new": map[string]string{ + "4": "1080P", + }, + "video_url": []map[string]any{ + { + "url": "http://cdn.example/master.m3u8", + "definition": 4, + "definition_n": 4, + "width": 1920, + "height": 1080, + "title": "1080P", + }, + }, + }, + }) + case "/open/video/video_push": + atomic.AddInt32(&pushCalls, 1) + _ = json.NewEncoder(w).Encode(map[string]any{"state": true, "code": 0, "data": []any{}}) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + oldBase := cloud115.ProAPIBase + cloud115.ProAPIBase = server.URL + defer func() { cloud115.ProAPIBase = oldBase }() + + db := newServiceTestDB(t, &model.StrmAccount{}, &model.Media{}) + repos := repository.New(db) + strm := NewStrmService(nil, zap.NewNop(), repos, NewCryptoService("test-secret", zap.NewNop())) + ctx := context.Background() + account, err := strm.CreateStrmAccount(ctx, "115", model.StrmProvider115, map[string]string{ + "app_id": "100195129", + "access_token": "at", + "refresh_token": "rt", + }) + if err != nil { + t.Fatalf("create account: %v", err) + } + media := &model.Media{ + Base: model.Base{ID: "media-1"}, + Title: "test", + Path: "/media/test.strm", + Container: "strm", + Height: 1080, + STRMURL: "/api/strm/play/cloud115/video.mkv?acct=" + account.ID + "&pickcode=pc", + } + if err := db.Create(media).Error; err != nil { + t.Fatalf("create media: %v", err) + } + svc := NewCloud115PlaybackService(nil, zap.NewNop(), repos, strm) + + info, err := svc.PlaybackInfo(ctx, media.ID, 0) + if err != nil { + t.Fatalf("first playback info: %v", err) + } + if info.Provider != model.StrmProvider115 || info.DefaultQuality != "4" { + t.Fatalf("unexpected playback info: %#v", info) + } + if info.Transcode.State != "transcoding" { + t.Fatalf("transcode state = %q, want transcoding", info.Transcode.State) + } + if got := atomic.LoadInt32(&pushCalls); got != 1 { + t.Fatalf("push calls = %d, want 1", got) + } + + if _, err := svc.PlaybackInfo(ctx, media.ID, 0); err != nil { + t.Fatalf("second playback info: %v", err) + } + if got := atomic.LoadInt32(&pushCalls); got != 1 { + t.Fatalf("push calls after polling = %d, want 1", got) + } + + atomic.StoreInt32(&ready, 1) + info, err = svc.PlaybackInfo(ctx, media.ID, 0) + if err != nil { + t.Fatalf("ready playback info: %v", err) + } + if info.Transcode.State != "ready" { + t.Fatalf("transcode state = %q, want ready", info.Transcode.State) + } + url, _, err := svc.ResolveCloud115URL(ctx, media.ID, 4) + if err != nil { + t.Fatalf("resolve cloud url: %v", err) + } + if url != "http://cdn.example/master.m3u8" { + t.Fatalf("url = %q", url) + } + if got := atomic.LoadInt32(&playCalls); got < 3 { + t.Fatalf("play calls = %d, want >= 3", got) + } +} + +func TestCloud115PlaybackInfoTranscodePendingBeforeReady(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/open/video/play": + _ = json.NewEncoder(w).Encode(map[string]any{"state": false, "code": 0, "message": "转码中", "data": map[string]any{}}) + case "/open/video/video_push": + _ = json.NewEncoder(w).Encode(map[string]any{"state": true, "code": 0, "data": []any{}}) + } + })) + defer server.Close() + oldBase := cloud115.ProAPIBase + cloud115.ProAPIBase = server.URL + defer func() { cloud115.ProAPIBase = oldBase }() + + db := newServiceTestDB(t, &model.StrmAccount{}, &model.Media{}) + repos := repository.New(db) + strm := NewStrmService(nil, zap.NewNop(), repos, NewCryptoService("test-secret", zap.NewNop())) + ctx := context.Background() + account, err := strm.CreateStrmAccount(ctx, "115", model.StrmProvider115, map[string]string{ + "app_id": "100195129", + "access_token": "at", + "refresh_token": "rt", + }) + if err != nil { + t.Fatalf("create account: %v", err) + } + media := &model.Media{ + Base: model.Base{ID: "media-2"}, + Title: "test", + Path: "/media/test2.strm", + Container: "strm", + Height: 1080, + STRMURL: "/api/strm/play/cloud115/video.mkv?acct=" + account.ID + "&pickcode=pc2", + } + if err := db.Create(media).Error; err != nil { + t.Fatalf("create media: %v", err) + } + svc := NewCloud115PlaybackService(nil, zap.NewNop(), repos, strm) + if _, _, err := svc.ResolveCloud115URL(ctx, media.ID, 4); !errors.Is(err, ErrCloud115TranscodePending) { + t.Fatalf("resolve pending err = %v, want ErrCloud115TranscodePending", err) + } +} + +func TestMediaPlaybackProvider(t *testing.T) { + cases := []struct { + name string + raw string + want string + }{ + {"115", "/api/strm/play/cloud115/video.mkv?acct=1&pickcode=pc", model.StrmProvider115}, + {"openlist", "/api/strm/play/openlist/video.mkv?acct=1&ref=/a.mkv", model.StrmProviderOpenList}, + {"local", "/media/local.mkv", model.StrmProviderLocal}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + media := &model.Media{Path: tc.raw} + if tc.name != "local" { + media.STRMURL = tc.raw + } + got := MediaPlaybackProvider(media) + if got != tc.want { + t.Fatalf("provider = %q, want %q", got, tc.want) + } + }) + } +} diff --git a/internal/service/playback_quality.go b/internal/service/playback_quality.go new file mode 100644 index 0000000..7cf3f6d --- /dev/null +++ b/internal/service/playback_quality.go @@ -0,0 +1,277 @@ +package service + +import ( + "regexp" + "sort" + "strconv" + "strings" + + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/service/cloud115" +) + +// PlaybackQuality 是返回给播放器的统一画质选项。 +// +// Source: +// - cloud: 115 云端转码清晰度 +// - original: 115 原文件(直连,可能仍是 MKV/HEVC/PGS) +// - local: MeBox 本地 HLS 重新编码清晰度 +type PlaybackQuality struct { + ID string `json:"id"` + Label string `json:"label"` + Height int `json:"height,omitempty"` + Source string `json:"source"` + Available bool `json:"available"` + RequiresTranscode bool `json:"requires_transcode,omitempty"` + RequiresVIP bool `json:"requires_vip,omitempty"` + Note string `json:"note,omitempty"` +} + +type cloud115QualitySpec struct { + ID string + Label string + Height int + VIP bool +} + +var cloud115QualityLadder = []cloud115QualitySpec{ + {ID: "1", Label: "标清", Height: 480}, + {ID: "2", Label: "高清", Height: 540}, + {ID: "3", Label: "超清", Height: 720}, + {ID: "4", Label: "1080P", Height: 1080}, + {ID: "5", Label: "4K", Height: 2160, VIP: true}, +} + +var resolutionTokenRE = regexp.MustCompile(`(?i)(\d{3,4})p`) + +func mediaSourceHeight(m *model.Media) int { + if m == nil { + return 0 + } + if m.Height > 0 { + return m.Height + } + for _, text := range []string{m.OriginalName, m.Path, m.STRMURL} { + if h := parseHeightToken(text); h > 0 { + return h + } + } + return 0 +} + +func parseHeightToken(text string) int { + text = strings.TrimSpace(text) + if text == "" { + return 0 + } + upper := strings.ToUpper(text) + if strings.Contains(upper, "4K") || strings.Contains(upper, "2160P") { + return 2160 + } + for _, match := range resolutionTokenRE.FindAllStringSubmatch(text, -1) { + if len(match) < 2 { + continue + } + height, err := strconv.Atoi(match[1]) + if err != nil || height < 240 || height > 4320 { + continue + } + return height + } + return 0 +} + +func cloud115AvailableDefinitions(data *cloud115.VideoPlayData) map[string]bool { + available := map[string]bool{} + if data == nil { + return available + } + for key := range data.DefinitionListNew { + key = strings.TrimSpace(key) + if key != "" { + available[key] = true + } + } + for key := range data.DefinitionList { + key = strings.TrimSpace(key) + if key != "" { + available[key] = true + } + } + for _, item := range data.VideoURL { + id := item.DefinitionN + if id <= 0 { + id = item.Definition + } + if id > 0 { + available[strconv.Itoa(id)] = true + } + } + return available +} + +func cloud115SourceHeight(m *model.Media, data *cloud115.VideoPlayData) int { + if height := mediaSourceHeight(m); height > 0 { + return height + } + maxHeight := 0 + if data != nil { + for _, item := range data.VideoURL { + if item.Height > maxHeight { + maxHeight = item.Height + } + } + } + if maxHeight > 0 { + return maxHeight + } + return 1080 +} + +// Cloud115QualityOptions 返回 115 已转码和“按源分辨率可转码”的清晰度。 +// 未出现在 definition_list_new/video_url 中的档位会标记为需要云端转码。 +func Cloud115QualityOptions(m *model.Media, data *cloud115.VideoPlayData) []PlaybackQuality { + sourceHeight := cloud115SourceHeight(m, data) + available := cloud115AvailableDefinitions(data) + out := make([]PlaybackQuality, 0, len(cloud115QualityLadder)+1) + + out = append(out, PlaybackQuality{ + ID: "100", + Label: "原画", + Height: sourceHeight, + Source: "original", + Available: true, + Note: "播放原始文件,可能仍是 MKV/HEVC/PGS", + }) + + for _, spec := range cloud115QualityLadder { + if sourceHeight > 0 && spec.Height > sourceHeight && !available[spec.ID] { + continue + } + label := spec.Label + if data != nil && strings.TrimSpace(data.DefinitionListNew[spec.ID]) != "" { + label = strings.TrimSpace(data.DefinitionListNew[spec.ID]) + } + isAvailable := available[spec.ID] + item := PlaybackQuality{ + ID: spec.ID, + Label: label, + Height: spec.Height, + Source: "cloud", + Available: isAvailable, + RequiresTranscode: !isAvailable, + RequiresVIP: spec.VIP, + } + if !isAvailable { + item.Note = "需要 115 云端转码" + } + if spec.VIP { + if item.Note != "" { + item.Note += "," + } + item.Note += "需要年费 VIP" + } + out = append(out, item) + } + + sort.SliceStable(out, func(i, j int) bool { + if out[i].Source != out[j].Source { + return out[i].Source == "original" + } + if out[i].Height != out[j].Height { + return out[i].Height > out[j].Height + } + return out[i].ID < out[j].ID + }) + return out +} + +// DefaultCloud115Quality 默认优先 1080P;没有 1080P 时退回最高的云端档位。 +func DefaultCloud115Quality(options []PlaybackQuality) string { + for _, option := range options { + if option.ID == "4" && option.Source == "cloud" { + return option.ID + } + } + best := "" + bestHeight := -1 + for _, option := range options { + if option.Source != "cloud" { + continue + } + if option.Height > bestHeight { + best = option.ID + bestHeight = option.Height + } + } + if best != "" { + return best + } + return "4" +} + +// LocalQualityOptions 返回 MeBox 本地 HLS 可用的画质档位。 +func LocalQualityOptions(m *model.Media) []PlaybackQuality { + sourceHeight := mediaSourceHeight(m) + presets := []PlaybackQuality{ + {ID: "source", Label: "原画", Height: sourceHeight, Source: "local", Available: true, Note: "本地 HLS,保持源分辨率"}, + {ID: "1080", Label: "1080P", Height: 1080, Source: "local", Available: true}, + {ID: "720", Label: "720P", Height: 720, Source: "local", Available: true}, + {ID: "480", Label: "480P", Height: 480, Source: "local", Available: true}, + } + out := make([]PlaybackQuality, 0, len(presets)) + for _, preset := range presets { + if preset.ID != "source" && sourceHeight > 0 && preset.Height > sourceHeight { + continue + } + out = append(out, preset) + } + sort.SliceStable(out, func(i, j int) bool { + if out[i].ID == "source" { + return true + } + if out[j].ID == "source" { + return false + } + return out[i].Height > out[j].Height + }) + return out +} + +// LocalHLSQuality 是传给 ffmpeg 的本地 HLS 画质参数。 +type LocalHLSQuality struct { + ID string + Label string + Height int + VideoBitrate string + MaxRate string + BufSize string +} + +var localHLSQualities = map[string]LocalHLSQuality{ + "source": {ID: "source", Label: "原画", Height: 0, VideoBitrate: "8000k", MaxRate: "10000k", BufSize: "12000k"}, + "1080": {ID: "1080", Label: "1080P", Height: 1080, VideoBitrate: "4000k", MaxRate: "4500k", BufSize: "6000k"}, + "720": {ID: "720", Label: "720P", Height: 720, VideoBitrate: "2500k", MaxRate: "2800k", BufSize: "4000k"}, + "480": {ID: "480", Label: "480P", Height: 480, VideoBitrate: "1200k", MaxRate: "1400k", BufSize: "2000k"}, +} + +func LocalHLSQualityByID(id string) (LocalHLSQuality, bool) { + quality, ok := localHLSQualities[strings.TrimSpace(id)] + return quality, ok +} + +func DefaultLocalHLSQualityID(m *model.Media) string { + sourceHeight := mediaSourceHeight(m) + switch { + case sourceHeight >= 1080: + return "1080" + case sourceHeight >= 720: + return "720" + case sourceHeight >= 480: + return "480" + case sourceHeight > 0: + return "source" + default: + return "720" + } +} diff --git a/internal/service/playback_quality_test.go b/internal/service/playback_quality_test.go new file mode 100644 index 0000000..cd42732 --- /dev/null +++ b/internal/service/playback_quality_test.go @@ -0,0 +1,109 @@ +package service + +import ( + "strings" + "testing" + + "github.com/truewhile/MeBox/internal/config" + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/service/cloud115" +) + +func TestCloud115QualityOptionsDefault1080AndTranscode(t *testing.T) { + media := &model.Media{Height: 1080} + data := &cloud115.VideoPlayData{ + DefinitionListNew: map[string]string{"3": "超清"}, + VideoURL: []cloud115.VideoURLItem{ + {Definition: 3, DefinitionN: 3, Height: 720, Width: 1280, Title: "超清"}, + }, + } + options := Cloud115QualityOptions(media, data) + if got := DefaultCloud115Quality(options); got != "4" { + t.Fatalf("default quality = %q, want 4", got) + } + quality4, ok := findPlaybackQuality(options, "4") + if !ok { + t.Fatal("1080P option missing") + } + if quality4.Available || !quality4.RequiresTranscode { + t.Fatalf("1080P should require transcode: %#v", quality4) + } + quality3, ok := findPlaybackQuality(options, "3") + if !ok || !quality3.Available || quality3.RequiresTranscode { + t.Fatalf("超清 should be available: %#v", quality3) + } + if quality4K, ok := findPlaybackQuality(options, "5"); ok { + t.Fatalf("1080P source should not list 4K: %#v", quality4K) + } +} + +func TestCloud115QualityOptionsListsAvailableOnlyOnce(t *testing.T) { + media := &model.Media{Height: 2160} + data := &cloud115.VideoPlayData{ + DefinitionListNew: map[string]string{"4": "1080P", "5": "4K"}, + VideoURL: []cloud115.VideoURLItem{ + {Definition: 4, DefinitionN: 4, Height: 1080}, + {Definition: 5, DefinitionN: 5, Height: 2160}, + }, + } + options := Cloud115QualityOptions(media, data) + for _, id := range []string{"4", "5"} { + quality, ok := findPlaybackQuality(options, id) + if !ok || !quality.Available { + t.Fatalf("quality %s should be available: %#v", id, quality) + } + } + quality5, _ := findPlaybackQuality(options, "5") + if !quality5.RequiresVIP { + t.Fatalf("4K should be marked VIP: %#v", quality5) + } +} + +func TestLocalQualityOptionsFollowSourceHeight(t *testing.T) { + media := &model.Media{Height: 720} + options := LocalQualityOptions(media) + if _, ok := findPlaybackQuality(options, "1080"); ok { + t.Fatal("720p source should not list 1080P") + } + for _, id := range []string{"source", "720", "480"} { + if _, ok := findPlaybackQuality(options, id); !ok { + t.Fatalf("local quality %s missing", id) + } + } + if got := DefaultLocalHLSQualityID(media); got != "720" { + t.Fatalf("default local quality = %q, want 720", got) + } +} + +func TestLocalHLSQualityFFmpegArgs(t *testing.T) { + cfg := &config.Config{} + cfg.Transcoder.MaxHeight = 720 + cfg.Transcoder.SegmentSeconds = 4 + cfg.Transcoder.Realtime = false + cfg.Transcoder.HardwareAccel = false + + quality480, _ := LocalHLSQualityByID("480") + args := buildFFmpegArgsForInput(cfg, transcodeInput{Source: "/x.mkv", Quality: &quality480}, "/o/x.m3u8", "/o/seg_%05d.ts") + joined := strings.Join(args, " ") + if !strings.Contains(joined, "scale=-2:min(480\\,ih)") { + t.Fatalf("480P scale filter missing: %s", joined) + } + if !strings.Contains(joined, "-b:v 1200k") { + t.Fatalf("480P bitrate missing: %s", joined) + } + + cfg.Transcoder.HardwareAccel = true + cfg.Transcoder.Encoder = "nvenc" + qualitySource, _ := LocalHLSQualityByID("source") + args = buildFFmpegArgsForInput(cfg, transcodeInput{Source: "/x.mkv", Quality: &qualitySource}, "/o/x.m3u8", "/o/seg_%05d.ts") + joined = strings.Join(args, " ") + if strings.Contains(joined, "h264_nvenc") { + t.Fatalf("source quality should not use hardware scale path: %s", joined) + } + if strings.Contains(joined, "scale=-2:min(") { + t.Fatalf("source quality should not downscale: %s", joined) + } + if !strings.Contains(joined, "libx264") { + t.Fatalf("source quality should use software x264: %s", joined) + } +} diff --git a/internal/service/service.go b/internal/service/service.go index 53582bd..4490e82 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -64,6 +64,7 @@ type Container struct { RecognitionWords *RecognitionWordsService Danmaku *DanmakuService Strm *StrmService + Cloud115 *Cloud115PlaybackService Database *DatabaseAdminService FFTools *FFmpegToolsService diff --git a/internal/service/service_builder.go b/internal/service/service_builder.go index 926739d..73e571b 100644 --- a/internal/service/service_builder.go +++ b/internal/service/service_builder.go @@ -28,13 +28,13 @@ func newServiceContainer(cfg *config.Config, log *zap.Logger, repos *repository. cfg: cfg, log: log, repos: repos, - version: normalizeSystemUpdateVersion(version), - c: &Container{ - Version: normalizeSystemUpdateVersion(version), - Cfg: cfg, - Log: log, - Repo: repos, - }, + version: normalizeSystemUpdateVersion(version), + c: &Container{ + Version: normalizeSystemUpdateVersion(version), + Cfg: cfg, + Log: log, + Repo: repos, + }, } builder.startRealtimeServices() builder.initProviderServices() @@ -119,6 +119,7 @@ func (b *serviceContainerBuilder) initContentServices() { b.c.Profile = NewProfileService(b.log, b.repos) b.c.Audit = NewAuditService(b.log, b.repos) b.c.Strm = NewStrmService(b.cfg, b.log, b.repos, b.c.Crypto) + b.c.Cloud115 = NewCloud115PlaybackService(b.cfg, b.log, b.repos, b.c.Strm) // ffmpeg/ffprobe 一键下载安装(data/tools/ffmpeg/)。 b.c.FFTools = NewFFmpegToolsService(b.cfg, b.log, b.repos) // 弹幕 hash 识别需要把 strm 指向解析成可拉取的直链/本地路径。 diff --git a/internal/service/stream_hls.go b/internal/service/stream_hls.go index 8a5d9e1..81416a8 100644 --- a/internal/service/stream_hls.go +++ b/internal/service/stream_hls.go @@ -26,7 +26,8 @@ func (s *StreamService) ServeHLSPlaylist(w http.ResponseWriter, r *http.Request, startSec := parseHLSStartSec(r) seekGen := parseHLSSeekGen(r) subtitleStream := parseHLSSubtitleStream(r) - if _, err := s.transcoder.EnsureJobFromSubtitle(r.Context(), mediaID, startSec, seekGen, subtitleStream); err != nil { + quality := parseHLSQuality(r) + if _, err := s.transcoder.EnsureJobFromSubtitleQuality(r.Context(), mediaID, startSec, seekGen, subtitleStream, quality); err != nil { return err } s.transcoder.TouchJob(mediaID) @@ -76,6 +77,20 @@ func parseHLSSubtitleStream(r *http.Request) int { return v } +func parseHLSQuality(r *http.Request) string { + if r == nil { + return "" + } + quality := strings.TrimSpace(r.URL.Query().Get("quality")) + if quality == "" { + return "" + } + if _, ok := LocalHLSQualityByID(quality); !ok { + return "" + } + return quality +} + func parseHLSStartSec(r *http.Request) float64 { if r == nil { return 0 diff --git a/internal/service/transcoder.go b/internal/service/transcoder.go index 1f3522c..a460020 100644 --- a/internal/service/transcoder.go +++ b/internal/service/transcoder.go @@ -47,8 +47,8 @@ type TranscoderService struct { repo *repository.Container hub *Hub - mu sync.Mutex - jobs map[string]*hlsJob + mu sync.Mutex + jobs map[string]*hlsJob // startGates serializes EnsureJobFrom / StopJob per media so concurrent // playlist hits cannot spawn multiple ffmpeg writers into one HLS dir. startGates sync.Map // mediaID -> *sync.Mutex @@ -74,6 +74,8 @@ type hlsJob struct { // subtitleStream is an absolute ffmpeg stream index to burn into the // picture, or -1 when no bitmap subtitle is selected. subtitleStream int + // quality is the requested local HLS quality profile ("" = global config). + quality string // done is closed when the ffmpeg goroutine fully exits (after process death). done chan struct{} } @@ -130,12 +132,27 @@ func (t *TranscoderService) EnsureJobFrom(ctx context.Context, mediaID string, s // EnsureJobFromSubtitle is EnsureJobFrom with an optional bitmap subtitle // stream. Selecting or closing a burned subtitle creates a new HLS generation. func (t *TranscoderService) EnsureJobFromSubtitle(ctx context.Context, mediaID string, startSec float64, seekGen int64, subtitleStream int) (string, error) { + return t.EnsureJobFromSubtitleQuality(ctx, mediaID, startSec, seekGen, subtitleStream, "") +} + +// EnsureJobFromSubtitleQuality is EnsureJobFromSubtitle with an explicit local +// HLS quality profile. Empty qualityID keeps the global transcoder config. +func (t *TranscoderService) EnsureJobFromSubtitleQuality(ctx context.Context, mediaID string, startSec float64, seekGen int64, subtitleStream int, qualityID string) (string, error) { if !t.cfg.Transcoder.Enabled { return "", ErrTranscodeDisabled } if startSec < 0 { startSec = 0 } + var quality *LocalHLSQuality + if requested := strings.TrimSpace(qualityID); requested != "" { + profile, ok := LocalHLSQualityByID(requested) + if !ok { + return "", fmt.Errorf("未知的 HLS 画质:%s", requested) + } + quality = &profile + qualityID = profile.ID + } m, err := t.repo.Media.FindByID(ctx, mediaID) if err != nil { return "", err @@ -150,12 +167,12 @@ func (t *TranscoderService) EnsureJobFromSubtitle(ctx context.Context, mediaID s t.mu.Lock() if existing, ok := t.jobs[mediaID]; ok { - if sameHLSConfiguration(existing, startSec, subtitleStream) { + if sameHLSConfiguration(existing, startSec, subtitleStream, qualityID) { t.touchJobLocked(mediaID) t.mu.Unlock() return t.PlaylistPath(mediaID), nil } - if !shouldReplaceHLSJobConfiguration(existing, startSec, seekGen, subtitleStream) { + if !shouldReplaceHLSJobConfigurationQuality(existing, startSec, seekGen, subtitleStream, qualityID) { t.touchJobLocked(mediaID) t.mu.Unlock() return t.PlaylistPath(mediaID), nil @@ -175,6 +192,7 @@ func (t *TranscoderService) EnsureJobFromSubtitle(ctx context.Context, mediaID s if subtitleStream >= 0 { input.SubtitleStream = &subtitleStream } + input.Quality = quality t.maybeFillDuration(ctx, m, input) if _, err := t.resolveFFmpegPath(); err != nil { return "", err @@ -189,12 +207,12 @@ func (t *TranscoderService) EnsureJobFromSubtitle(ctx context.Context, mediaID s t.mu.Lock() if existing, ok := t.jobs[mediaID]; ok { - if sameHLSConfiguration(existing, startSec, subtitleStream) { + if sameHLSConfiguration(existing, startSec, subtitleStream, qualityID) { t.touchJobLocked(mediaID) t.mu.Unlock() return t.PlaylistPath(mediaID), nil } - if !shouldReplaceHLSJobConfiguration(existing, startSec, seekGen, subtitleStream) { + if !shouldReplaceHLSJobConfigurationQuality(existing, startSec, seekGen, subtitleStream, qualityID) { t.touchJobLocked(mediaID) t.mu.Unlock() return t.PlaylistPath(mediaID), nil @@ -224,6 +242,7 @@ func (t *TranscoderService) EnsureJobFromSubtitle(ctx context.Context, mediaID s startSec: startSec, seekGen: seekGen, subtitleStream: subtitleStream, + quality: qualityID, done: make(chan struct{}), } t.jobs[mediaID] = job @@ -250,10 +269,14 @@ func shouldReplaceHLSJob(existing *hlsJob, startSec float64, seekGen int64) bool } func shouldReplaceHLSJobConfiguration(existing *hlsJob, startSec float64, seekGen int64, subtitleStream int) bool { + return shouldReplaceHLSJobConfigurationQuality(existing, startSec, seekGen, subtitleStream, "") +} + +func shouldReplaceHLSJobConfigurationQuality(existing *hlsJob, startSec float64, seekGen int64, subtitleStream int, quality string) bool { if existing == nil { return true } - if sameHLSConfiguration(existing, startSec, subtitleStream) { + if sameHLSConfiguration(existing, startSec, subtitleStream, quality) { return false } return newerHLSGenerationMayReplace(existing, seekGen) @@ -270,9 +293,10 @@ func newerHLSGenerationMayReplace(existing *hlsJob, seekGen int64) bool { return true } -func sameHLSConfiguration(existing *hlsJob, startSec float64, subtitleStream int) bool { +func sameHLSConfiguration(existing *hlsJob, startSec float64, subtitleStream int, quality string) bool { return existing != nil && existing.subtitleStream == subtitleStream && + existing.quality == quality && sameHLSStart(existing.startSec, startSec) } diff --git a/internal/service/transcoder_ffmpeg_args.go b/internal/service/transcoder_ffmpeg_args.go index 6f6eedb..a76c132 100644 --- a/internal/service/transcoder_ffmpeg_args.go +++ b/internal/service/transcoder_ffmpeg_args.go @@ -15,6 +15,7 @@ type transcodeInput struct { Headers map[string]string StartSec float64 SubtitleStream *int + Quality *LocalHLSQuality } type ffmpegArgSettings struct { @@ -28,6 +29,7 @@ type ffmpegArgSettings struct { realtime bool threads int vaapiDevice string + noScale bool } type ffmpegVideoPlan struct { @@ -45,7 +47,11 @@ func buildFFmpegArgs(cfg *config.Config, source, playlist, segments string) []st } func buildFFmpegArgsForInput(cfg *config.Config, input transcodeInput, playlist, segments string) []string { - settings := ffmpegArgSettingsFromConfig(cfg) + settings := ffmpegArgSettingsFromConfig(cfg, input.Quality) + if settings.noScale { + // 原画档位不做硬件缩放的兼容处理,直接走软件编码,保持源分辨率。 + settings.encoder = "" + } video := ffmpegVideoPlanForSettings(settings) if input.SubtitleStream != nil { // Bitmap subtitles must be composited in software. Keeping CUDA/QSV/ @@ -64,7 +70,7 @@ func buildFFmpegArgsForInput(cfg *config.Config, input transcodeInput, playlist, return args } -func ffmpegArgSettingsFromConfig(cfg *config.Config) ffmpegArgSettings { +func ffmpegArgSettingsFromConfig(cfg *config.Config, quality *LocalHLSQuality) ffmpegArgSettings { settings := ffmpegArgSettings{ bitrate: ffmpegDefaultString(cfg.Transcoder.VideoBitrate, "1500k"), maxrate: ffmpegDefaultString(cfg.Transcoder.MaxRate, "1800k"), @@ -79,7 +85,23 @@ func ffmpegArgSettingsFromConfig(cfg *config.Config) ffmpegArgSettings { if cfg.Transcoder.HardwareAccel { settings.encoder = normalizedHardwareEncoder(cfg.Transcoder.Encoder) } - if settings.height <= 0 { + if quality != nil { + if strings.TrimSpace(quality.VideoBitrate) != "" { + settings.bitrate = strings.TrimSpace(quality.VideoBitrate) + } + if strings.TrimSpace(quality.MaxRate) != "" { + settings.maxrate = strings.TrimSpace(quality.MaxRate) + } + if strings.TrimSpace(quality.BufSize) != "" { + settings.bufsize = strings.TrimSpace(quality.BufSize) + } + if quality.Height > 0 { + settings.height = quality.Height + } else { + settings.noScale = true + } + } + if settings.height <= 0 && !settings.noScale { settings.height = 720 } if settings.segmentSeconds <= 0 { @@ -118,8 +140,12 @@ func ffmpegVideoPlanForSettings(settings ffmpegArgSettings) ffmpegVideoPlan { codec: "h264_vaapi", } default: + filter := "" + if !settings.noScale { + filter = fmt.Sprintf("scale=-2:min(%d\\,ih)", settings.height) + } return ffmpegVideoPlan{ - filter: fmt.Sprintf("scale=-2:min(%d\\,ih)", settings.height), + filter: filter, codec: "libx264", preset: settings.preset, } @@ -149,14 +175,18 @@ func appendInputAndVideoArgs(args []string, input transcodeInput, settings ffmpe } args = append(args, "-i", input.Source) if input.SubtitleStream != nil { - filter := fmt.Sprintf( - "[0:v:0][0:%d]overlay=0:0:eof_action=pass,scale=-2:min(%d\\,ih)[v]", - *input.SubtitleStream, - settings.height, - ) + filter := fmt.Sprintf("[0:v:0][0:%d]overlay=0:0:eof_action=pass", *input.SubtitleStream) + if !settings.noScale { + filter += fmt.Sprintf(",scale=-2:min(%d\\,ih)", settings.height) + } + filter += "[v]" args = append(args, "-filter_complex", filter, "-map", "[v]", "-map", "0:a:0?", "-c:v", video.codec) } else { - args = append(args, "-map", "0:v:0?", "-map", "0:a:0?", "-vf", video.filter, "-c:v", video.codec) + args = append(args, "-map", "0:v:0?", "-map", "0:a:0?") + if video.filter != "" { + args = append(args, "-vf", video.filter) + } + args = append(args, "-c:v", video.codec) } if settings.threads > 0 && video.codec == "libx264" { args = append(args, "-threads", strconv.Itoa(settings.threads)) diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 2db6843..c5c1ab5 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -147,7 +147,7 @@ export function streamURL(mediaId: string): string { // hlsURL returns the m3u8 playlist URL fed into hls.js. // startSec > 0 asks the server to (re)start ffmpeg from that source offset. -export function hlsURL(mediaId: string, startSec = 0, subtitleStream?: number): string { +export function hlsURL(mediaId: string, startSec = 0, subtitleStream?: number, quality?: string): string { const safeStart = Math.max(0, Math.round(startSec * 1000) / 1000) // Always send start= (including 0) so the server can tell an intentional // restart-from-head apart from a missing query on a stale refresh. @@ -159,7 +159,17 @@ export function hlsURL(mediaId: string, startSec = 0, subtitleStream?: number): subtitleStream !== undefined && subtitleStream >= 0 ? `&subtitle=${encodeURIComponent(String(subtitleStream))}` : '' - return `/api/hls/${encodeURIComponent(mediaId)}/index.m3u8?${tokenQuery()}${profileQuery()}${start}${subtitle}${bust}` + const qualityQuery = quality ? `&quality=${encodeURIComponent(quality)}` : '' + return `/api/hls/${encodeURIComponent(mediaId)}/index.m3u8?${tokenQuery()}${profileQuery()}${start}${subtitle}${qualityQuery}${bust}` +} + +// cloudHlsURL returns the MeBox-proxied 115 cloud HLS master playlist URL. +// A browser cannot fetch the 115 CDN directly because its CORS policy only +// allows https://115.com; the backend rewrites all child URLs to this +// same-origin proxy. +export function cloudHlsURL(mediaId: string, definition: string): string { + const quality = definition ? `&definition=${encodeURIComponent(definition)}` : '' + return `/api/cloud115/media/${encodeURIComponent(mediaId)}/master.m3u8?${tokenQuery()}${profileQuery()}${quality}&media_id=${encodeURIComponent(mediaId)}` } // Stop an on-demand HLS job. keepalive makes the request survive page diff --git a/web/src/api/library.ts b/web/src/api/library.ts index 3217208..84970fb 100644 --- a/web/src/api/library.ts +++ b/web/src/api/library.ts @@ -1,5 +1,5 @@ import { api, BATCH_REQUEST_TIMEOUT, LONG_REQUEST_TIMEOUT } from './client' -import type { Library, LibraryRoot, Media, ScanResult } from '../types' +import type { Library, LibraryRoot, Media, PlaybackInfo, ScanResult } from '../types' import type { SeriesCard } from '../utils/groupSeries' export interface MediaPage { @@ -233,6 +233,18 @@ export const mediaAPI = { get: (id: string) => api.get(`/media/${id}`).then((r) => r.data), + playbackInfo: (id: string, quality?: number) => + api + .get(`/media/${id}/playback`, { + params: quality && quality > 0 ? { quality } : undefined, + }) + .then((r) => r.data), + + startCloudTranscode: (id: string, definition: number) => + api + .post(`/media/${id}/transcode`, { definition }) + .then((r) => r.data), + getEpisodes: (id: string) => api.get<{ items: Media[]; total: number }>(`/media/${id}/episodes`).then((r) => r.data), diff --git a/web/src/components/PlayerControls.tsx b/web/src/components/PlayerControls.tsx index b71d3da..e537cb6 100644 --- a/web/src/components/PlayerControls.tsx +++ b/web/src/components/PlayerControls.tsx @@ -3,8 +3,12 @@ import { createPortal } from 'react-dom' import { Captions, CaptionsOff, + Check, FastForward, + Gauge, ListVideo, + Loader2, + Lock, Maximize, MessageSquareText, Minimize, @@ -18,6 +22,7 @@ import { VolumeX, } from 'lucide-react' import type { SubtitleTrack } from '../api/subtitles' +import type { PlaybackQuality } from '../types' import type { SubtitleChineseMode } from '../utils/subtitleChinese' import { SUBTITLE_POSITION_OPTIONS, @@ -86,6 +91,10 @@ type PlayerControlsProps = { playlistOpen?: boolean hasPlaylist?: boolean onTogglePlaylist?: () => void + qualities?: PlaybackQuality[] + selectedQuality?: string + onSelectQuality?: (quality: PlaybackQuality) => void + showQuality?: boolean /** Media metadata duration (seconds). Used when HLS only knows transcoded length. */ knownDuration?: number /** Absolute source offset of the current HLS session (seconds). */ @@ -122,6 +131,10 @@ export function PlayerControls({ playlistOpen = false, hasPlaylist = false, onTogglePlaylist, + qualities = [], + selectedQuality = '', + onSelectQuality, + showQuality = false, knownDuration = 0, streamOffset = 0, onSeekAbsolute, @@ -144,10 +157,13 @@ export function PlayerControls({ const [scrubValue, setScrubValue] = useState(null) const [subtitleMenuOpen, setSubtitleMenuOpen] = useState(false) const subtitleMenuRef = useRef(null) + const [qualityMenuOpen, setQualityMenuOpen] = useState(false) + const qualityMenuRef = useRef(null) const hideTimerRef = useRef | null>(null) const controlsHoveredRef = useRef(false) const isScrubbingRef = useRef(false) const subtitleMenuOpenRef = useRef(false) + const qualityMenuOpenRef = useRef(false) const danmakuOpenRef = useRef(false) const playlistOpenRef = useRef(false) const pendingSeekRef = useRef(null) @@ -172,6 +188,10 @@ export function PlayerControls({ subtitleMenuOpenRef.current = subtitleMenuOpen }, [subtitleMenuOpen]) + useEffect(() => { + qualityMenuOpenRef.current = qualityMenuOpen + }, [qualityMenuOpen]) + useEffect(() => { danmakuOpenRef.current = danmakuOpen }, [danmakuOpen]) @@ -199,17 +219,20 @@ export function PlayerControls({ setMuted(next === 0) }, [video, volumeProp]) - // 点击控制栏外部时关闭字幕菜单 + // 点击控制栏外部时关闭字幕/画质菜单 useEffect(() => { - if (!subtitleMenuOpen) return + if (!subtitleMenuOpen && !qualityMenuOpen) return const onDocClick = (e: MouseEvent) => { if (subtitleMenuRef.current && !subtitleMenuRef.current.contains(e.target as Node)) { setSubtitleMenuOpen(false) } + if (qualityMenuRef.current && !qualityMenuRef.current.contains(e.target as Node)) { + setQualityMenuOpen(false) + } } document.addEventListener('mousedown', onDocClick) return () => document.removeEventListener('mousedown', onDocClick) - }, [subtitleMenuOpen]) + }, [subtitleMenuOpen, qualityMenuOpen]) // 播放时 3 秒无操作自动隐藏控制栏;暂停/悬停/拖动进度条/打开菜单时保持显示。 // 监听挂在整个播放器舞台容器(data-player-stage)上,避免光标移到控制栏时因离开视频画面而误触发 mouseleave。 @@ -225,6 +248,7 @@ export function PlayerControls({ !controlsHoveredRef.current && !isScrubbingRef.current && !subtitleMenuOpenRef.current && + !qualityMenuOpenRef.current && !danmakuOpenRef.current && !playlistOpenRef.current ) { @@ -233,6 +257,7 @@ export function PlayerControls({ !controlsHoveredRef.current && !isScrubbingRef.current && !subtitleMenuOpenRef.current && + !qualityMenuOpenRef.current && !danmakuOpenRef.current && !playlistOpenRef.current ) { @@ -335,7 +360,7 @@ export function PlayerControls({ // 当悬停或菜单状态改变时,更新控制栏计时器 useEffect(() => { - if (controlsHovered || isScrubbing || subtitleMenuOpen || danmakuOpen || playlistOpen) { + if (controlsHovered || isScrubbing || subtitleMenuOpen || qualityMenuOpen || danmakuOpen || playlistOpen) { onUiVisibleChange(true) if (hideTimerRef.current) clearTimeout(hideTimerRef.current) } else { @@ -345,7 +370,7 @@ export function PlayerControls({ hideTimerRef.current = setTimeout(() => onUiVisibleChange(false), 3000) } } - }, [controlsHovered, isScrubbing, subtitleMenuOpen, danmakuOpen, playlistOpen, onUiVisibleChange, video]) + }, [controlsHovered, isScrubbing, subtitleMenuOpen, qualityMenuOpen, danmakuOpen, playlistOpen, onUiVisibleChange, video]) const togglePlay = () => { const el = video() @@ -409,6 +434,7 @@ export function PlayerControls({ controlsHoveredRef.current || isScrubbingRef.current || subtitleMenuOpenRef.current || + qualityMenuOpenRef.current || danmakuOpenRef.current || playlistOpenRef.current ) { @@ -419,6 +445,7 @@ export function PlayerControls({ !controlsHoveredRef.current && !isScrubbingRef.current && !subtitleMenuOpenRef.current && + !qualityMenuOpenRef.current && !danmakuOpenRef.current && !playlistOpenRef.current ) { @@ -549,6 +576,11 @@ export function PlayerControls({ (selectedSubtitle.delivery === 'webvtt' || selectedSubtitle.delivery === 'ass') const canAdjustSelectedSubtitle = selectedSubtitle?.delivery === 'webvtt' const usesOriginalASS = selectedSubtitle?.delivery === 'ass' + const selectedQualityLabel = qualities.find((quality) => quality.id === selectedQuality)?.label ?? '' + const qualityGroups = [ + { key: 'cloud', label: '115 云端', items: qualities.filter((quality) => quality.source !== 'local') }, + { key: 'local', label: '本地 HLS', items: qualities.filter((quality) => quality.source === 'local') }, + ].filter((group) => group.items.length > 0) const seekOverlay = seekHint && stageEl ? createPortal( @@ -660,10 +692,75 @@ export function PlayerControls({ )} + {showQuality && qualities.length > 0 && onSelectQuality && ( +
+ + {qualityMenuOpen && ( +
+ {qualityGroups.map((group) => ( +
+

+ {group.label} +

+ {group.items.map((quality) => { + const current = quality.id === selectedQuality + return ( + + ) + })} +
+ ))} +
+ )} +
+ )} + {subs.length > 0 && (
) -} \ No newline at end of file +} diff --git a/web/src/pages/PlayerPage.tsx b/web/src/pages/PlayerPage.tsx index e64d68e..d15c085 100644 --- a/web/src/pages/PlayerPage.tsx +++ b/web/src/pages/PlayerPage.tsx @@ -4,7 +4,7 @@ import type Hls from 'hls.js' import toast from 'react-hot-toast' import { mediaAPI, libraryAPI } from '../api/library' -import { hlsURL, postPlaybackProgressKeepalive, stopHLSJob, streamURL } from '../api/client' +import { cloudHlsURL, hlsURL, postPlaybackProgressKeepalive, stopHLSJob, streamURL } from '../api/client' import { danmakuAPI, type DanmakuAnime, @@ -17,7 +17,7 @@ import { subtitlesAPI, type SubtitleTrack } from '../api/subtitles' import { systemAPI } from '../api/system' import { profileAPI } from '../api/profile' import { useAuthStore } from '../stores/auth' -import type { Media } from '../types' +import type { Media, PlaybackInfo, PlaybackQuality } from '../types' import { getSeriesKey, seriesTitleFromPath } from '../utils/groupSeries' import { isRemoteEmbyID } from '../utils/remoteEmby' import { @@ -124,6 +124,16 @@ export function PlayerPage() { const [initialSeekDone, setInitialSeekDone] = useState(false) // HLS session source offset: playlist t=0 maps to this absolute second. const [hlsStartSec, setHlsStartSec] = useState(0) + // 统一播放能力:115 提供 direct → cloud_hls → local_hls,其它源 direct → local_hls。 + const [playbackInfo, setPlaybackInfo] = useState(null) + const [hlsSource, setHlsSource] = useState<'cloud' | 'local'>('local') + const [selectedQuality, setSelectedQuality] = useState('') + const [cloudWaiting, setCloudWaiting] = useState(false) + const [cloudWaitMessage, setCloudWaitMessage] = useState('') + const [cloudWaitStartedAt, setCloudWaitStartedAt] = useState(0) + const cloudPollTimerRef = useRef | null>(null) + const cloudRetryRef = useRef(5) + const pendingSeekRef = useRef(null) // 弹幕控制:状态来自 /api/danmaku/config 初始值,用户在面板里实时调整。 const [danmakuOpen, setDanmakuOpen] = useState(false) @@ -407,6 +417,17 @@ export function PlayerPage() { setMedia(null) setLoadError('') setHlsStartSec(0) + setPlaybackInfo(null) + setHlsSource('local') + setSelectedQuality('') + setCloudWaiting(false) + setCloudWaitMessage('') + setCloudWaitStartedAt(0) + pendingSeekRef.current = null + if (cloudPollTimerRef.current) { + clearTimeout(cloudPollTimerRef.current) + cloudPollTimerRef.current = null + } setDanmakuEpisodeId(null) setDanmakuCandidates([]) setDanmakuAlternatives([]) @@ -478,6 +499,117 @@ export function PlayerPage() { // Depend on media.id (not the media object): refreshing duration after // MANIFEST_PARSED must not remount HLS or it storms EnsureJob / DELETE. const mediaId = media?.id + const playbackProvider = playbackInfo?.provider + + // 加载统一播放能力:115 云端清晰度 + 本地 HLS 清晰度。 + useEffect(() => { + if (!mediaId) return + let cancelled = false + mediaAPI + .playbackInfo(mediaId) + .then((info) => { + if (cancelled) return + setPlaybackInfo(info) + cloudRetryRef.current = Math.max(3, info.transcode.retry_after_sec || 5) + setSelectedQuality((prev) => { + if (prev && findPlaybackQualityById(info, prev)) return prev + return info.default_quality || info.local_qualities?.[0]?.id || '' + }) + }) + .catch(() => { + if (!cancelled) setPlaybackInfo(null) + }) + return () => { + cancelled = true + } + }, [mediaId]) + + useEffect(() => { + cloudRetryRef.current = Math.max(3, playbackInfo?.transcode.retry_after_sec || 5) + }, [playbackInfo]) + + const switchToLocalHLS = useCallback( + (position = 0) => { + setHlsSource('local') + setCloudWaiting(false) + setSelectedQuality((prev) => { + if (playbackInfo?.local_qualities?.some((quality) => quality.id === prev)) return prev + return defaultLocalQualityId(playbackInfo) + }) + if (position > 2) setHlsStartSec(position) + setPlaybackMode('hls') + }, + [playbackInfo, setPlaybackMode], + ) + + const startCloudTranscode = useCallback( + async (definition: number) => { + if (!mediaId) return + try { + const info = await mediaAPI.startCloudTranscode(mediaId, definition) + setPlaybackInfo(info) + if (info.transcode.state === 'ready') { + setCloudWaiting(false) + setCloudWaitMessage('') + setHlsSource('cloud') + setPlaybackMode('hls') + return + } + setCloudWaiting(true) + setCloudWaitMessage(info.transcode.message || '115 正在转码…') + setCloudWaitStartedAt(Date.now()) + } catch { + switchToLocalHLS() + toast.error('115 云端转码触发失败,已切换本地转码') + } + }, + [mediaId, setPlaybackMode, switchToLocalHLS], + ) + + // 云端转码等待:按后端建议间隔轮询,完成后自动切到云 HLS。 + useEffect(() => { + if (!cloudWaiting || !mediaId) return + let cancelled = false + const poll = async () => { + if (cancelled) return + if (cloudWaitStartedAt > 0 && Date.now() - cloudWaitStartedAt > 10 * 60 * 1000) { + switchToLocalHLS() + toast.error('115 云端转码等待超时,已切换本地转码') + return + } + try { + const definition = Number(selectedQuality) || undefined + const info = await mediaAPI.playbackInfo(mediaId, definition) + if (cancelled) return + setPlaybackInfo(info) + cloudRetryRef.current = Math.max(3, info.transcode.retry_after_sec || 5) + if (info.transcode.state === 'ready') { + setCloudWaiting(false) + setCloudWaitMessage('') + setHlsSource('cloud') + setPlaybackMode('hls') + return + } + if (info.transcode.state === 'unavailable') { + switchToLocalHLS() + toast.error(info.transcode.message || '115 云端转码不可用,已切换本地转码') + return + } + setCloudWaitMessage(info.transcode.message || '115 正在转码…') + cloudPollTimerRef.current = setTimeout(poll, cloudRetryRef.current * 1000) + } catch { + cloudPollTimerRef.current = setTimeout(poll, 5000) + } + } + cloudPollTimerRef.current = setTimeout(poll, cloudRetryRef.current * 1000) + return () => { + cancelled = true + if (cloudPollTimerRef.current) { + clearTimeout(cloudPollTimerRef.current) + cloudPollTimerRef.current = null + } + } + }, [cloudWaiting, cloudWaitStartedAt, mediaId, selectedQuality, setPlaybackMode, switchToLocalHLS]) // 直连播放只发现外挂字幕;只有 HLS 模式需要探测可烧录的内嵌字幕。 useEffect(() => { @@ -524,7 +656,14 @@ export function PlayerPage() { const video = ref.current const durationSec = currentMedia.duration_sec || 0 if (mode === 'hls') { - const url = hlsURL(mediaId, hlsStartSec, activeBurnedSubtitleStream) + if (hlsSource === 'cloud' && cloudWaiting) { + teardownHls() + return + } + const url = + hlsSource === 'cloud' + ? cloudHlsURL(mediaId, selectedQuality) + : hlsURL(mediaId, hlsStartSec, activeBurnedSubtitleStream, selectedQuality) void import('hls.js').then(({ default: HlsCtor }) => { if (cancelled || !ref.current) return if (HlsCtor.isSupported()) { @@ -545,6 +684,15 @@ export function PlayerPage() { hls.loadSource(url) hls.attachMedia(video) hls.on(HlsCtor.Events.MANIFEST_PARSED, () => { + if (pendingSeekRef.current !== null) { + const target = pendingSeekRef.current + pendingSeekRef.current = null + try { + video.currentTime = target + } catch { + // ignore + } + } void video.play().catch(() => undefined) // .strm 入库时常缺 duration;转码启动时会补探测,这里刷新一次给进度条总时长。 if (durationSec > 0) return @@ -558,7 +706,19 @@ export function PlayerPage() { }) hls.on(HlsCtor.Events.ERROR, (_, data) => { if (data.fatal) { + if (hlsSource === 'cloud') { + const position = ref.current?.currentTime || 0 + setHlsUnavailable(false) + switchToLocalHLS(position) + toast.error('115 云端播放失败,切换本地转码') + return + } setHlsUnavailable(true) + if (playbackProvider === 'cloud115') { + setPlayerError('115 云端和本地转码均不可用,请检查账号授权或稍后重试。') + toast.error('115 云端和本地转码均不可用') + return + } setPlayerError('HLS 转码不可用,正在尝试直接播放原始文件。若出现有画面无声音,通常是 MKV/AC3/EAC3 音轨需要配置本机 ffmpeg 转码为 AAC。') toast.error('HLS 转码失败,尝试切换到直接播放') setPlaybackMode('direct') @@ -614,31 +774,36 @@ export function PlayerPage() { }, [ activeBurnedSubtitleStream, clearFallbackTimer, + cloudWaiting, + hlsSource, hlsUnavailable, hlsStartSec, mediaId, mode, + playbackProvider, + selectedQuality, setPlaybackMode, + switchToLocalHLS, teardownHls, ]) // Stop host ffmpeg when leaving this HLS player. The keepalive request also // survives route navigation while the component is being torn down. useEffect(() => { - if (!mediaId || mode !== 'hls') return + if (!mediaId || mode !== 'hls' || hlsSource !== 'local') return return () => { stopHLSJob(mediaId) } - }, [mediaId, mode]) + }, [hlsSource, mediaId, mode]) // React cleanup is not guaranteed when a tab/window closes. pagehide fires // while the document can still dispatch a keepalive request. useEffect(() => { - if (!mediaId || mode !== 'hls') return + if (!mediaId || mode !== 'hls' || hlsSource !== 'local') return const stopOnPageExit = () => stopHLSJob(mediaId) window.addEventListener('pagehide', stopOnPageExit) return () => window.removeEventListener('pagehide', stopOnPageExit) - }, [mediaId, mode]) + }, [hlsSource, mediaId, mode]) // 自动拉取已有的播放进度并恢复播放位置 useEffect(() => { @@ -944,6 +1109,11 @@ export function PlayerPage() { }, [goBack, prevEpisode, nextEpisode, handlePrevEpisode, handleNextEpisode, playlistOpen, danmakuOpen]) const isDirectStream = isDirectStreamMedia(media) + const qualityOptions = + playbackInfo?.provider === 'cloud115' + ? [...(playbackInfo.cloud_qualities ?? []), ...(playbackInfo.local_qualities ?? [])] + : (playbackInfo?.local_qualities ?? []) + const showQuality = mode !== 'direct' && qualityOptions.length > 0 // 没有外挂字幕且第一条内嵌字幕是图片时,默认轨需要通过 HLS 烧录。 useEffect(() => { @@ -975,9 +1145,36 @@ export function PlayerPage() { const next = mode === 'hls' ? 'direct' : 'hls' if (next === 'hls') { setHlsStartSec(0) + const preferred = + playbackInfo?.provider === 'cloud115' + ? findPlaybackQualityById(playbackInfo, selectedQuality) ?? + findPlaybackQualityById(playbackInfo, playbackInfo.default_quality) + : undefined + if (preferred?.source === 'cloud') { + setHlsSource('cloud') + if (preferred.available) { + setCloudWaiting(false) + } else { + setCloudWaiting(true) + setCloudWaitMessage(preferred.note || `正在等待 115 转码 ${preferred.label}…`) + void startCloudTranscode(Number(preferred.id) || 4) + } + } else { + switchToLocalHLS() + } + } else { + setCloudWaiting(false) } setPlaybackMode(next) - }, [isDirectStream, mode, setPlaybackMode]) + }, [ + isDirectStream, + mode, + playbackInfo, + selectedQuality, + setPlaybackMode, + startCloudTranscode, + switchToLocalHLS, + ]) const handleSeekAbsolute = useCallback( (absoluteSec: number) => { @@ -1019,6 +1216,10 @@ export function PlayerPage() { toast.error('图片字幕需要开启 HLS 转码后才能显示') return } + if (nextTrack?.delivery === 'burn') { + // PGS 等图片字幕必须走本地 FFmpeg 烧录,不能交给云端 HLS。 + switchToLocalHLS(ref.current?.currentTime || 0) + } const burnChanged = oldTrack?.delivery === 'burn' || nextTrack?.delivery === 'burn' if (burnChanged && mode === 'hls' && ref.current) { @@ -1029,7 +1230,42 @@ export function PlayerPage() { setHlsStartSec(ref.current?.currentTime || 0) setPlaybackMode('hls') } - }, [directOnly, hlsStartSec, isDirectStream, mode, setPlaybackMode, subs, subtitleIndex]) + }, [directOnly, hlsStartSec, isDirectStream, mode, setPlaybackMode, subs, subtitleIndex, switchToLocalHLS]) + + const selectPlaybackQuality = useCallback( + (quality: PlaybackQuality) => { + const video = ref.current + const position = video?.currentTime || 0 + if (quality.source === 'original') { + setCloudWaiting(false) + setHlsSource('local') + setPlaybackMode('direct') + return + } + if (quality.source === 'cloud') { + setSelectedQuality(quality.id) + setHlsSource('cloud') + if (quality.available) { + setCloudWaiting(false) + setCloudWaitMessage('') + if (position > 2) pendingSeekRef.current = position + setPlaybackMode('hls') + } else { + setCloudWaiting(true) + setCloudWaitMessage(quality.note || `正在等待 115 转码 ${quality.label}…`) + void startCloudTranscode(Number(quality.id) || 4) + } + return + } + setSelectedQuality(quality.id) + setHlsSource('local') + setCloudWaiting(false) + setCloudWaitMessage('') + if (position > 2) setHlsStartSec(position) + setPlaybackMode('hls') + }, + [setPlaybackMode, startCloudTranscode], + ) const changeSubtitleChineseMode = useCallback( (nextMode: SubtitleChineseMode) => { @@ -1097,18 +1333,45 @@ export function PlayerPage() { } else if (directOnly) { setPlayerError('直接播放失败。当前为「客户端直连解码」模式,宿主机不转码;请使用支持该编码/封装的播放器(如 Infuse / VLC / Emby 客户端)播放,或关闭直连解码模式。') toast.error('直接播放失败(客户端直连解码模式)') + } else if (playbackInfo?.provider === 'cloud115') { + const preferred = + findPlaybackQualityById(playbackInfo, selectedQuality) ?? + findPlaybackQualityById(playbackInfo, playbackInfo.default_quality) + if (preferred?.source === 'cloud' && preferred.available) { + setCloudWaiting(false) + setHlsSource('cloud') + setPlaybackMode('hls') + } else if (preferred?.source === 'cloud') { + setHlsSource('cloud') + setCloudWaiting(true) + setCloudWaitMessage(preferred.note || `正在等待 115 转码 ${preferred.label}…`) + void startCloudTranscode(Number(preferred.id) || 4) + } else { + switchToLocalHLS(current?.currentTime || 0) + } } else if (hlsUnavailable) { setPlayerError('直接播放失败,且 HLS 转码不可用。请检查文件是否存在,或配置本机 ffmpeg 后使用 HLS 转码播放。') toast.error('直接播放失败,HLS 转码不可用') } else { toast.error('直接播放失败,切换到 HLS 转码') - setPlaybackMode('hls') + switchToLocalHLS(current?.currentTime || 0) } } clearFallbackTimer() fallbackTimerRef.current = setTimeout(fallbackDirectPlay, 1500) - }, [clearFallbackTimer, directOnly, hlsUnavailable, mediaId, mode, setPlaybackMode]) + }, [ + clearFallbackTimer, + directOnly, + hlsUnavailable, + mediaId, + mode, + playbackInfo, + selectedQuality, + setPlaybackMode, + startCloudTranscode, + switchToLocalHLS, + ]) const changeSubtitlePosition = useCallback((nextPosition: SubtitlePosition) => { setSubtitlePosition(nextPosition) @@ -1183,8 +1446,14 @@ export function PlayerPage() { hasPlaylist={playlistEpisodes.length > 0} onTogglePlaylist={togglePlaylistOpen} knownDuration={media?.duration_sec || 0} - streamOffset={mode === 'hls' ? hlsStartSec : 0} - onSeekAbsolute={mode === 'hls' ? handleSeekAbsolute : undefined} + streamOffset={mode === 'hls' && hlsSource === 'local' ? hlsStartSec : 0} + onSeekAbsolute={mode === 'hls' && hlsSource === 'local' ? handleSeekAbsolute : undefined} + qualities={qualityOptions} + selectedQuality={selectedQuality} + onSelectQuality={selectPlaybackQuality} + showQuality={showQuality} + waiting={cloudWaiting} + waitingMessage={cloudWaitMessage} playlistPanel={ 0 ? `第 ${ep.episode_num} 集` : mediaTitle || title || '未命名' } +function findPlaybackQualityById(info: PlaybackInfo | null, id: string): PlaybackQuality | undefined { + if (!info || !id) return undefined + return [...(info.cloud_qualities ?? []), ...(info.local_qualities ?? [])].find( + (quality) => quality.id === id, + ) +} + +function defaultLocalQualityId(info: PlaybackInfo | null): string { + const qualities = info?.local_qualities ?? [] + for (const id of ['1080', '720', '480', 'source']) { + if (qualities.some((quality) => quality.id === id)) return id + } + return qualities[0]?.id ?? '720' +} + function looksLikeSeriesTitle(ep: Media, title: string, siblings: Media[]): boolean { const normalized = normalizeEpisodeTitle(title) if (!normalized) return true diff --git a/web/src/pages/PlayerVideoStage.tsx b/web/src/pages/PlayerVideoStage.tsx index 4e83552..bfd0d87 100644 --- a/web/src/pages/PlayerVideoStage.tsx +++ b/web/src/pages/PlayerVideoStage.tsx @@ -1,9 +1,10 @@ import { useEffect, useRef, useState } from 'react' import type { CSSProperties, PointerEvent, ReactNode, RefObject } from 'react' +import { Loader2 } from 'lucide-react' import { subtitlesAPI, type SubtitleTrack } from '../api/subtitles' import { type DanmakuAnime, type DanmakuLoadedInfo } from '../api/danmaku' -import type { Media } from '../types' +import type { Media, PlaybackQuality } from '../types' import { loadSubtitleChineseConverter, type SubtitleChineseMode, @@ -178,6 +179,12 @@ type PlayerVideoStageProps = { knownDuration?: number streamOffset?: number onSeekAbsolute?: (seconds: number) => boolean + qualities?: PlaybackQuality[] + selectedQuality?: string + onSelectQuality?: (quality: PlaybackQuality) => void + showQuality?: boolean + waiting?: boolean + waitingMessage?: string } export function PlayerVideoStage({ @@ -224,6 +231,12 @@ export function PlayerVideoStage({ knownDuration, streamOffset, onSeekAbsolute, + qualities = [], + selectedQuality = '', + onSelectQuality, + showQuality = false, + waiting = false, + waitingMessage = '', }: PlayerVideoStageProps) { const stageRef = useRef(null) const [videoRatio, setVideoRatio] = useState(null) @@ -666,6 +679,10 @@ export function PlayerVideoStage({ knownDuration={knownDuration} streamOffset={streamOffset} onSeekAbsolute={onSeekAbsolute} + qualities={qualities} + selectedQuality={selectedQuality} + onSelectQuality={onSelectQuality} + showQuality={showQuality} /> {danmakuPanel} {playlistPanel} @@ -680,6 +697,15 @@ export function PlayerVideoStage({ {playerError} ) : null} + {waiting ? ( +
+
+ +

{waitingMessage || '115 正在转码…'}

+

转码完成后会自动播放

+
+
+ ) : null} ) } diff --git a/web/src/types/index.ts b/web/src/types/index.ts index abf1dab..a810dcd 100644 --- a/web/src/types/index.ts +++ b/web/src/types/index.ts @@ -7,4 +7,5 @@ export * from './history' export * from './library' export * from './media' export * from './notifications' +export * from './playback' export * from './playProfiles' diff --git a/web/src/types/playback.ts b/web/src/types/playback.ts new file mode 100644 index 0000000..9e70b35 --- /dev/null +++ b/web/src/types/playback.ts @@ -0,0 +1,31 @@ +export type PlaybackQualitySource = 'cloud' | 'original' | 'local' + +export interface PlaybackQuality { + id: string + label: string + height?: number + source: PlaybackQualitySource + available: boolean + requires_transcode?: boolean + requires_vip?: boolean + note?: string +} + +export interface PlaybackTranscodeState { + state: 'idle' | 'ready' | 'transcoding' | 'unavailable' + definition?: string + message?: string + retry_after_sec?: number + started_at?: number +} + +export interface PlaybackInfo { + media_id: string + provider: string + fallback: string[] + default_quality: string + cloud_qualities?: PlaybackQuality[] + local_qualities: PlaybackQuality[] + transcode: PlaybackTranscodeState +} +