Compare commits

...

3 Commits

Author SHA1 Message Date
truewhile fa4c90d47a bug处理 2026-09-14 21:40:13 +08:00
truewhile 1ec8303fec bug处理 2026-09-14 21:16:34 +08:00
truewhile b28c0d9247 添加115云端解码功能 2026-09-14 17:57:51 +08:00
37 changed files with 3207 additions and 135 deletions
+148
View File
@@ -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()})
}
}
}
@@ -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))
}
+1 -1
View File
@@ -89,7 +89,7 @@ func listLibrarySeriesHandler(svc *service.Container) gin.HandlerFunc {
writeInternalOrCanceled(c, err)
return
}
cards = service.SortSeriesCards(cards, sortSpec.Field, sortSpec.Order, history)
cards = sortRemoteSeriesCards(cards, sortSpec, history)
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("page_size", "500"))
if page < 1 {
+23
View File
@@ -0,0 +1,23 @@
package handler
import (
"slices"
"time"
"github.com/truewhile/MeBox/internal/service"
)
// sortRemoteSeriesCards keeps the DateLastContentAdded order returned by the
// remote Emby server when LastAddedAt is not exposed in the Series DTO.
func sortRemoteSeriesCards(cards []service.SeriesCard, spec service.MediaSortSpec, history map[string]time.Time) []service.SeriesCard {
if spec.Field != "updated_at" {
return service.SortSeriesCards(cards, spec.Field, spec.Order, history)
}
out := append([]service.SeriesCard(nil), cards...)
// RemoteSeriesCards always fetches DateLastContentAdded in descending order.
if spec.Order == "asc" {
slices.Reverse(out)
}
return service.SortSeriesCards(out, spec.Field, spec.Order, history)
}
+21
View File
@@ -0,0 +1,21 @@
package handler
import (
"testing"
"github.com/truewhile/MeBox/internal/service"
)
func TestSortRemoteSeriesCardsUpdatedAtUsesRemoteOrder(t *testing.T) {
cards := []service.SeriesCard{{Key: "newest"}, {Key: "middle"}, {Key: "oldest"}}
desc := sortRemoteSeriesCards(cards, service.MediaSortSpec{Field: "updated_at", Order: "desc"}, nil)
if desc[0].Key != "newest" || desc[1].Key != "middle" || desc[2].Key != "oldest" {
t.Fatalf("descending order = %q, %q, %q", desc[0].Key, desc[1].Key, desc[2].Key)
}
asc := sortRemoteSeriesCards(cards, service.MediaSortSpec{Field: "updated_at", Order: "asc"}, nil)
if asc[0].Key != "oldest" || asc[1].Key != "middle" || asc[2].Key != "newest" {
t.Fatalf("ascending order = %q, %q, %q", asc[0].Key, asc[1].Key, asc[2].Key)
}
}
+2
View File
@@ -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
+186
View File
@@ -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
}
+361
View File
@@ -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")
}
}
+151
View File
@@ -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)
}
}
+411
View File
@@ -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 云端播放不可用"
}
+203
View File
@@ -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)
}
})
}
}
+1 -1
View File
@@ -218,7 +218,7 @@ func (e *EmbyService) loadLatestItems(ctx context.Context, userID, parentID stri
if mount == nil || acct == nil || !EmbyMountLibraryAllowed(e.mediaVisibility(ctx, userID), mount) {
return embyLatestCacheValue{Items: []map[string]any{}}, nil
}
out, err := e.remote.RemoteLatest(ctx, mount, acct, remoteParent, limit)
out, err := e.remote.RemoteLatestForDisplay(ctx, mount, acct, remoteParent, limit)
if err != nil {
return embyLatestCacheValue{}, err
}
+156
View File
@@ -860,6 +860,162 @@ func (r *EmbyRemoteService) RemoteLatest(ctx context.Context, mount *model.EmbyM
return out, nil
}
// RemoteLatestForDisplay 返回可直接展示的最新媒体条目。剧集库优先取 Series;
// 远程 Latest 只返回 Episode 时,按 SeriesId 归并,避免调用方拿到单集 ID
// 后无法在 Series 卡片列表中找到对应条目。
func (r *EmbyRemoteService) RemoteLatestForDisplay(ctx context.Context, mount *model.EmbyMount, acct *model.StrmAccount, remoteViewID string, limit int) ([]map[string]any, error) {
if remoteCollectionLooksEpisodic(mount.CollectionType) {
return r.RemoteLatestSeries(ctx, mount, acct, remoteViewID, limit)
}
items, err := r.RemoteLatest(ctx, mount, acct, remoteViewID, limit)
if err != nil {
return nil, err
}
if remoteItemsContainEpisodes(items) {
return remoteSeriesItemsFromLatest(mount, items, limit), nil
}
return items, nil
}
// RemoteLatestSeries 拉取剧集库最近更新的 Series。部分 Emby 服务不支持
// DateLastContentAdded 或过滤 Series,此时回退到 Latest 并把 Episode 归并到
// 对应 Series。
func (r *EmbyRemoteService) RemoteLatestSeries(ctx context.Context, mount *model.EmbyMount, acct *model.StrmAccount, remoteViewID string, limit int) ([]map[string]any, error) {
if limit <= 0 || limit > 100 {
limit = 20
}
cfg, err := r.remoteConfigWithToken(ctx, acct)
if err != nil {
return nil, err
}
q := url.Values{}
q.Set("ParentId", remoteViewID)
q.Set("IncludeItemTypes", "Series")
q.Set("Recursive", "false")
q.Set("SortBy", "DateLastContentAdded")
q.Set("SortOrder", "Descending")
q.Set("Limit", strconv.Itoa(limit))
q.Set("Fields", "Overview,Genres,ProviderIds,Path,RecursiveItemCount,SeriesPrimaryImage,DateCreated,DateLastMediaAdded,PremiereDate,ProductionYear,CommunityRating,CriticRating")
var body struct {
Items []map[string]any `json:"Items"`
}
if err := r.doGet(ctx, acct, cfg, "/Users/"+url.PathEscape(r.remoteUserID(cfg))+"/Items", q, &body); err == nil && len(body.Items) > 0 {
RewriteEmbyRemoteIDs(body.Items, mount.ID)
return body.Items, nil
}
items, err := r.RemoteLatest(ctx, mount, acct, remoteViewID, limit)
if err != nil {
return nil, err
}
return remoteSeriesItemsFromLatest(mount, items, limit), nil
}
func remoteCollectionLooksEpisodic(collectionType string) bool {
switch strings.ToLower(strings.TrimSpace(collectionType)) {
case "tvshows", "tv":
return true
default:
return false
}
}
func remoteItemsContainEpisodes(items []map[string]any) bool {
for _, item := range items {
if strings.EqualFold(strings.TrimSpace(remoteItemString(item, "Type")), "Episode") {
return true
}
}
return false
}
func remoteSeriesItemsFromLatest(mount *model.EmbyMount, items []map[string]any, limit int) []map[string]any {
if limit <= 0 || limit > 100 {
limit = 20
}
out := make([]map[string]any, 0, len(items))
seen := make(map[string]struct{}, len(items))
for _, item := range items {
if len(out) >= limit {
break
}
if !strings.EqualFold(strings.TrimSpace(remoteItemString(item, "Type")), "Episode") {
id := strings.TrimSpace(remoteItemString(item, "Id"))
if id == "" {
continue
}
if _, exists := seen[id]; exists {
continue
}
seen[id] = struct{}{}
out = append(out, item)
continue
}
seriesID := remoteItemSeriesID(item, mount)
if seriesID == "" {
continue
}
if _, exists := seen[seriesID]; exists {
continue
}
seen[seriesID] = struct{}{}
out = append(out, remoteSeriesPayloadFromEpisode(mount, item, seriesID))
}
return out
}
func remoteItemSeriesID(item map[string]any, mount *model.EmbyMount) string {
seriesID := strings.TrimSpace(remoteItemString(item, "SeriesId"))
if seriesID == "" {
return ""
}
if _, rawID, ok := DecodeEmbyRemoteID(seriesID); ok {
if mount == nil {
return rawID
}
return EncodeEmbyRemoteID(mount.ID, rawID)
}
if mount == nil {
return seriesID
}
return EncodeEmbyRemoteID(mount.ID, seriesID)
}
func remoteSeriesPayloadFromEpisode(mount *model.EmbyMount, episode map[string]any, seriesID string) map[string]any {
name := strings.TrimSpace(remoteItemString(episode, "SeriesName"))
if name == "" {
name = strings.TrimSpace(remoteItemString(episode, "Name"))
}
out := map[string]any{
"Id": seriesID,
"Name": name,
"Type": "Series",
}
if mount != nil && strings.TrimSpace(mount.RemoteViewID) != "" {
out["ParentId"] = EncodeEmbyRemoteID(mount.ID, mount.RemoteViewID)
}
if tag := strings.TrimSpace(remoteItemString(episode, "SeriesPrimaryImageTag")); tag != "" {
out["ImageTags"] = map[string]any{"Primary": tag}
}
year := remoteItemInt(episode, "SeriesProductionYear")
if year == 0 {
year = remoteItemInt(episode, "SeriesYear")
}
if year == 0 {
year = remoteItemInt(episode, "ProductionYear")
}
if year > 0 {
out["ProductionYear"] = year
}
for _, key := range []string{"Genres", "ProviderIds", "DateLastMediaAdded", "CommunityRating", "CriticRating", "OfficialRating"} {
if value, ok := episode[key]; ok {
out[key] = value
}
}
return out
}
// RemotePlaybackInfo 拉取远程 PlaybackInfo,并按挂载的 proxy_play 配置重写
// 播放 URL:不代理=指向远程绝对地址(播放字节不过 MeBox);代理=指向 MeBox
// 本地 /Videos/{encodedID} 端点(由 ProxyVideoStream 反代)。
+54 -72
View File
@@ -180,31 +180,31 @@ func (r *EmbyRemoteService) MapRemoteItemToMedia(ctx context.Context, mount *mod
if _, rid, ok := DecodeEmbyRemoteID(seriesID); ok {
seriesID = rid
}
rating := remoteItemFloat(item, "CommunityRating")
if rating == 0 {
rating = remoteItemFloat(item, "CriticRating")
}
year := remoteItemInt(item, "ProductionYear")
if year == 0 {
year = remoteItemInt(item, "Year")
}
if year == 0 {
year = remoteItemInt(item, "SeriesProductionYear")
}
if year == 0 {
year = remoteItemInt(item, "SeriesYear")
}
media := model.Media{
Base: model.Base{ID: EncodeEmbyRemoteID(encodeScope, remoteID)},
Title: remoteItemString(item, "Name"),
OriginalName: remoteItemString(item, "OriginalTitle"),
Overview: remoteItemString(item, "Overview"),
Year: year,
Rating: float32(rating),
Path: remoteItemString(item, "Path"),
Genres: remoteItemGenres(item),
ScrapeStatus: "done",
}
rating := remoteItemFloat(item, "CommunityRating")
if rating == 0 {
rating = remoteItemFloat(item, "CriticRating")
}
year := remoteItemInt(item, "ProductionYear")
if year == 0 {
year = remoteItemInt(item, "Year")
}
if year == 0 {
year = remoteItemInt(item, "SeriesProductionYear")
}
if year == 0 {
year = remoteItemInt(item, "SeriesYear")
}
media := model.Media{
Base: model.Base{ID: EncodeEmbyRemoteID(encodeScope, remoteID)},
Title: remoteItemString(item, "Name"),
OriginalName: remoteItemString(item, "OriginalTitle"),
Overview: remoteItemString(item, "Overview"),
Year: year,
Rating: float32(rating),
Path: remoteItemString(item, "Path"),
Genres: remoteItemGenres(item),
ScrapeStatus: "done",
}
if date, ok := parseEmbyRemoteDate(remoteItemString(item, "DateCreated")); ok {
media.CreatedAt = date
media.UpdatedAt = date
@@ -509,6 +509,11 @@ func (r *EmbyRemoteService) remoteEpisodesOf(ctx context.Context, mount *model.E
return items, total, nil
}
// remoteSeriesPageSize 是远程剧集分页拉取的每页条数:Fields 带 Overview/Path
// 等重字段,单页 1000 条时载荷会超过 doGet 的 8MB 截断上限,JSON 被静默截断
// 直接解析失败,因此按 200 条翻页拉全量。
const remoteSeriesPageSize = 200
// RemoteSeriesCards 远程剧集库的系列卡片(ChildCount 作为集数)。
//
// 远程 Emby 的 Series DTO 不会返回 DateLastMediaAdded 字段(即使请求 Fields
@@ -532,16 +537,14 @@ func (r *EmbyRemoteService) RemoteSeriesCards(ctx context.Context, mount *model.
q.Set("Recursive", "false")
q.Set("SortBy", "DateLastContentAdded")
q.Set("SortOrder", "Descending")
// 每页 200:Fields 带全量重字段(Overview/MediaStreams 等)时单页 1000
// 条的载荷会超过 doGet 的 8MB 截断上限,JSON 被静默截断直接解析失败。
q.Set("Limit", "200")
q.Set("Limit", strconv.Itoa(remoteSeriesPageSize))
q.Set("Fields", "Overview,Genres,ProviderIds,Path,RecursiveItemCount,SeriesPrimaryImage,DateCreated,DateLastMediaAdded,PremiereDate,ProductionYear,CommunityRating,CriticRating")
var body struct {
Items []map[string]any `json:"Items"`
TotalRecordCount int64 `json:"TotalRecordCount"`
}
cards := make([]SeriesCard, 0)
for startIndex := 0; ; startIndex += 200 {
for startIndex := 0; ; startIndex += remoteSeriesPageSize {
q.Set("StartIndex", strconv.Itoa(startIndex))
body.Items = nil
if err := r.doGet(ctx, acct, cfg, "/Users/"+url.PathEscape(r.remoteUserID(cfg))+"/Items", q, &body); err != nil {
@@ -565,9 +568,16 @@ func (r *EmbyRemoteService) RemoteSeriesCards(ctx context.Context, mount *model.
if date, ok := parseEmbyRemoteDate(remoteItemString(it, "DateLastMediaAdded")); ok {
lastAdded = &date
}
cards = append(cards, SeriesCard{Key: m.ID, Rep: m, LinkMedia: m, Count: count, LastAddedAt: lastAdded})
cards = append(cards, SeriesCard{
Key: m.ID,
Rep: m,
LinkMedia: m,
Count: count,
IsSeries: true,
LastAddedAt: lastAdded,
})
}
if int64(len(cards)) >= body.TotalRecordCount || len(body.Items) < 1000 {
if int64(len(cards)) >= body.TotalRecordCount || len(body.Items) < remoteSeriesPageSize {
break
}
}
@@ -589,52 +599,14 @@ func (r *EmbyRemoteService) RemoteLatestCards(ctx context.Context, mount *model.
return cached, nil
}
// 剧集类媒体库:直接拉取最新入库/更新的 Series 剧集本身(按上次添加集日期倒序)。
// 避免 Emby /Items/Latest 默认返回无年份/无系列海报的单集(Episode)。
if mount != nil && (mount.CollectionType == "tvshows" || mount.CollectionType == "tv") {
q := url.Values{}
q.Set("ParentId", remoteViewID)
q.Set("IncludeItemTypes", "Series")
q.Set("Recursive", "false")
q.Set("SortBy", "DateLastContentAdded")
q.Set("SortOrder", "Descending")
q.Set("Limit", strconv.Itoa(limit))
q.Set("Fields", "Overview,Genres,ProviderIds,Path,RecursiveItemCount,SeriesPrimaryImage,DateCreated,DateLastMediaAdded,PremiereDate,ProductionYear,CommunityRating,CriticRating")
var body struct {
Items []map[string]any `json:"Items"`
}
if err := r.doGet(ctx, acct, cfg, "/Users/"+url.PathEscape(r.remoteUserID(cfg))+"/Items", q, &body); err == nil && len(body.Items) > 0 {
cards := make([]SeriesCard, 0, len(body.Items))
for _, it := range body.Items {
RewriteEmbyRemoteIDs(it, mount.ID)
m := r.MapRemoteItemToMedia(ctx, mount, acct, cfg, it)
count := remoteItemInt(it, "RecursiveItemCount")
if count == 0 {
count = remoteItemInt(it, "ChildCount")
}
if count == 0 {
count = 1
}
var lastAdded *time.Time
if date, ok := parseEmbyRemoteDate(remoteItemString(it, "DateLastMediaAdded")); ok {
lastAdded = &date
}
cards = append(cards, SeriesCard{Key: m.ID, Rep: m, LinkMedia: m, Count: count, LastAddedAt: lastAdded})
}
if r.cache != nil {
r.cache.SetJSON(ctx, cacheKey, cards, r.remoteMediaCacheTTL())
}
return cards, nil
}
}
items, err := r.RemoteLatest(ctx, mount, acct, remoteViewID, limit)
items, err := r.RemoteLatestForDisplay(ctx, mount, acct, remoteViewID, limit)
if err != nil {
return nil, err
}
cards := make([]SeriesCard, 0, len(items))
for _, it := range items {
m := r.MapRemoteItemToMedia(ctx, mount, acct, cfg, it)
isSeries := strings.EqualFold(strings.TrimSpace(remoteItemString(it, "Type")), "Series")
var lastAdded *time.Time
if !m.UpdatedAt.IsZero() {
t := m.UpdatedAt
@@ -647,7 +619,17 @@ func (r *EmbyRemoteService) RemoteLatestCards(ctx context.Context, mount *model.
if count == 0 {
count = remoteItemInt(it, "ChildCount")
}
cards = append(cards, SeriesCard{Key: m.ID, Rep: m, LinkMedia: m, Count: count, LastAddedAt: lastAdded})
if count == 0 {
count = 1
}
cards = append(cards, SeriesCard{
Key: m.ID,
Rep: m,
LinkMedia: m,
Count: count,
IsSeries: isSeries,
LastAddedAt: lastAdded,
})
}
if r.cache != nil {
r.cache.SetJSON(ctx, cacheKey, cards, r.remoteMediaCacheTTL())
+346
View File
@@ -5,6 +5,7 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"sync/atomic"
"testing"
@@ -465,6 +466,9 @@ func TestRemoteLatestCardsTvShowsYearAndPoster(t *testing.T) {
if cards[0].Count != 12 {
t.Fatalf("expected Count 12, got %d", cards[0].Count)
}
if !cards[0].IsSeries {
t.Fatalf("expected remote latest card to be marked as series")
}
if cards[0].Rep.PosterURL == "" {
t.Fatalf("expected PosterURL not empty")
}
@@ -476,6 +480,348 @@ func TestRemoteLatestCardsTvShowsYearAndPoster(t *testing.T) {
}
}
func TestRemoteLatestCardsTvShowsGroupsEpisodeFallbackBySeries(t *testing.T) {
var seriesQueryCalled atomic.Bool
var latestCalled atomic.Bool
var requestedSeriesType atomic.Value
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch strings.TrimPrefix(r.URL.Path, "/emby") {
case "/Users/remote-user/Items":
seriesQueryCalled.Store(true)
requestedSeriesType.Store(r.URL.Query().Get("IncludeItemTypes"))
_ = json.NewEncoder(w).Encode(map[string]any{
"Items": []map[string]any{},
"TotalRecordCount": 0,
})
case "/Users/remote-user/Items/Latest":
latestCalled.Store(true)
_ = json.NewEncoder(w).Encode([]map[string]any{
{
"Id": "episode-2",
"Name": "第二集",
"Type": "Episode",
"SeriesId": "series-b",
"SeriesName": "剧集 B",
"SeriesProductionYear": 2025,
"SeriesPrimaryImageTag": "poster-b",
},
{
"Id": "episode-1",
"Name": "第一集",
"Type": "Episode",
"SeriesId": "series-a",
"SeriesName": "剧集 A",
"SeriesProductionYear": 2024,
},
{
"Id": "episode-1b",
"Name": "第一集下",
"Type": "Episode",
"SeriesId": "series-a",
"SeriesName": "剧集 A",
},
})
default:
http.NotFound(w, r)
}
}))
defer server.Close()
db := newServiceTestDB(t, &model.StrmAccount{}, &model.EmbyMount{})
repos := repository.New(db)
svc := NewEmbyRemoteService(&config.Config{}, zap.NewNop(), repos, NewCryptoService("", zap.NewNop()))
rawConfig, _ := json.Marshal(map[string]string{
"url": server.URL,
"token": "fake-token",
"remote_user_id": "remote-user",
})
acct := &model.StrmAccount{
Base: model.Base{ID: "acct-tv-fallback"},
Name: "tv-emby",
Provider: model.StrmProviderEmbyRemote,
Config: string(rawConfig),
Enabled: true,
}
if err := repos.StrmAccount.Create(t.Context(), acct); err != nil {
t.Fatalf("create account: %v", err)
}
mount := &model.EmbyMount{
Base: model.Base{ID: "mount-tv-fallback"},
AccountID: acct.ID,
RemoteViewID: "view-tv",
RemoteViewName: "剧集库",
CollectionType: "tvshows",
Enabled: true,
}
if err := repos.EmbyMount.Create(t.Context(), mount); err != nil {
t.Fatalf("create mount: %v", err)
}
cards, err := svc.RemoteLatestCards(t.Context(), mount, acct, mount.RemoteViewID, 10)
if err != nil {
t.Fatalf("RemoteLatestCards failed: %v", err)
}
if !seriesQueryCalled.Load() || !latestCalled.Load() {
t.Fatalf("expected both Series query and Latest fallback, series=%v latest=%v", seriesQueryCalled.Load(), latestCalled.Load())
}
if got, _ := requestedSeriesType.Load().(string); got != "Series" {
t.Fatalf("series query IncludeItemTypes = %q, want Series", got)
}
if len(cards) != 2 {
t.Fatalf("expected 2 deduplicated series cards, got %d: %#v", len(cards), cards)
}
wantKeys := []string{
EncodeEmbyRemoteID(mount.ID, "series-b"),
EncodeEmbyRemoteID(mount.ID, "series-a"),
}
for i, want := range wantKeys {
if cards[i].Key != want {
t.Fatalf("card[%d].Key = %q, want %q", i, cards[i].Key, want)
}
if !cards[i].IsSeries {
t.Fatalf("card[%d] should be marked as series", i)
}
}
}
func TestEmbyLatestItemsRemoteTvShowsUseSeriesIDs(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch strings.TrimPrefix(r.URL.Path, "/emby") {
case "/Users/remote-user/Items":
_ = json.NewEncoder(w).Encode(map[string]any{
"Items": []map[string]any{},
"TotalRecordCount": 0,
})
case "/Users/remote-user/Items/Latest":
_ = json.NewEncoder(w).Encode([]map[string]any{
{
"Id": "episode-10",
"Name": "第十集",
"Type": "Episode",
"SeriesId": "series-10",
"SeriesName": "远程剧集",
},
})
default:
http.NotFound(w, r)
}
}))
defer server.Close()
db := newServiceTestDB(t,
&model.Setting{},
&model.User{},
&model.Favorite{},
&model.PlaybackHistory{},
&model.StrmAccount{},
&model.EmbyMount{},
)
repos := repository.New(db)
remoteSvc := NewEmbyRemoteService(&config.Config{}, zap.NewNop(), repos, NewCryptoService("", zap.NewNop()))
svc := NewEmbyService(&config.Config{}, zap.NewNop(), repos).SetEmbyRemote(remoteSvc)
rawConfig, _ := json.Marshal(map[string]string{
"url": server.URL,
"token": "fake-token",
"remote_user_id": "remote-user",
})
acct := &model.StrmAccount{
Base: model.Base{ID: "acct-emby-latest"},
Name: "tv-emby",
Provider: model.StrmProviderEmbyRemote,
Config: string(rawConfig),
Enabled: true,
}
if err := repos.StrmAccount.Create(t.Context(), acct); err != nil {
t.Fatalf("create account: %v", err)
}
mount := &model.EmbyMount{
Base: model.Base{ID: "mount-emby-latest"},
AccountID: acct.ID,
RemoteViewID: "view-tv",
RemoteViewName: "剧集库",
CollectionType: "tvshows",
Enabled: true,
}
if err := repos.EmbyMount.Create(t.Context(), mount); err != nil {
t.Fatalf("create mount: %v", err)
}
items, err := svc.LatestItems(t.Context(), "user-1", EncodeEmbyRemoteID(mount.ID, mount.RemoteViewID), 10)
if err != nil {
t.Fatalf("LatestItems failed: %v", err)
}
if len(items) != 1 {
t.Fatalf("expected 1 series item, got %d: %#v", len(items), items)
}
if items[0]["Type"] != "Series" {
t.Fatalf("latest item Type = %#v, want Series", items[0]["Type"])
}
wantID := EncodeEmbyRemoteID(mount.ID, "series-10")
if items[0]["Id"] != wantID {
t.Fatalf("latest item Id = %#v, want %q", items[0]["Id"], wantID)
}
}
// 远程剧集库分页拉全量:超过一页时必须继续翻页,否则第 201 条之后的剧集
// 既不会出现在媒体库列表里,首页深链过来的 ?series= 也无从命中。
func TestRemoteSeriesCardsFetchAllPages(t *testing.T) {
const totalSeries = remoteSeriesPageSize*2 + 50
var requests atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
query := r.URL.Query()
startIndex, _ := strconv.Atoi(query.Get("StartIndex"))
limit, _ := strconv.Atoi(query.Get("Limit"))
requests.Add(1)
items := make([]map[string]any, 0, limit)
for i := startIndex; i < totalSeries && len(items) < limit; i++ {
items = append(items, map[string]any{
"Id": "series-" + strconv.Itoa(i),
"Name": "剧集 " + strconv.Itoa(i),
"Type": "Series",
"RecursiveItemCount": 12,
})
}
_ = json.NewEncoder(w).Encode(map[string]any{
"Items": items,
"TotalRecordCount": totalSeries,
})
}))
defer server.Close()
db := newServiceTestDB(t, &model.StrmAccount{}, &model.EmbyMount{})
repos := repository.New(db)
svc := NewEmbyRemoteService(&config.Config{}, zap.NewNop(), repos, NewCryptoService("", zap.NewNop()))
rawConfig, _ := json.Marshal(map[string]string{
"url": server.URL,
"token": "fake-token",
"remote_user_id": "remote-user",
})
acct := &model.StrmAccount{
Base: model.Base{ID: "acct-series-pages"},
Name: "tv-emby",
Provider: model.StrmProviderEmbyRemote,
Config: string(rawConfig),
Enabled: true,
}
if err := repos.StrmAccount.Create(t.Context(), acct); err != nil {
t.Fatalf("create account: %v", err)
}
mount := &model.EmbyMount{
Base: model.Base{ID: "mount-series-pages"},
AccountID: acct.ID,
RemoteViewID: "view-tv",
RemoteViewName: "电视剧",
CollectionType: "tvshows",
Enabled: true,
}
if err := repos.EmbyMount.Create(t.Context(), mount); err != nil {
t.Fatalf("create mount: %v", err)
}
cards, err := svc.RemoteSeriesCards(t.Context(), mount, acct, mount.RemoteViewID)
if err != nil {
t.Fatalf("RemoteSeriesCards failed: %v", err)
}
if len(cards) != totalSeries {
t.Fatalf("expected all %d series, got %d", totalSeries, len(cards))
}
if got := requests.Load(); got != 3 {
t.Fatalf("expected 3 paged requests, got %d", got)
}
lastKey := EncodeEmbyRemoteID(mount.ID, "series-"+strconv.Itoa(totalSeries-1))
if cards[len(cards)-1].Key != lastKey {
t.Fatalf("last card key = %q, want %q", cards[len(cards)-1].Key, lastKey)
}
}
// 首页「最新条目」卡片 key 必须与媒体库剧集列表的 key 一致,否则点击后
// 媒体库页找不到目标剧集,只能退回整库列表。
func TestRemoteLatestCardsKeyMatchesSeriesList(t *testing.T) {
var seriesQueryCalls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if strings.TrimPrefix(r.URL.Path, "/emby") != "/Users/remote-user/Items" {
http.NotFound(w, r)
return
}
seriesQueryCalls.Add(1)
_ = json.NewEncoder(w).Encode(map[string]any{
"Items": []map[string]any{
{
"Id": "series-latest",
"Name": "刚刚更新",
"Type": "Series",
"RecursiveItemCount": 24,
},
},
"TotalRecordCount": 1,
})
}))
defer server.Close()
db := newServiceTestDB(t, &model.StrmAccount{}, &model.EmbyMount{})
repos := repository.New(db)
svc := NewEmbyRemoteService(&config.Config{}, zap.NewNop(), repos, NewCryptoService("", zap.NewNop()))
rawConfig, _ := json.Marshal(map[string]string{
"url": server.URL,
"token": "fake-token",
"remote_user_id": "remote-user",
})
acct := &model.StrmAccount{
Base: model.Base{ID: "acct-latest-key"},
Name: "tv-emby",
Provider: model.StrmProviderEmbyRemote,
Config: string(rawConfig),
Enabled: true,
}
if err := repos.StrmAccount.Create(t.Context(), acct); err != nil {
t.Fatalf("create account: %v", err)
}
mount := &model.EmbyMount{
Base: model.Base{ID: "mount-latest-key"},
AccountID: acct.ID,
RemoteViewID: "view-tv",
RemoteViewName: "电视剧",
CollectionType: "tvshows",
Enabled: true,
}
if err := repos.EmbyMount.Create(t.Context(), mount); err != nil {
t.Fatalf("create mount: %v", err)
}
latestCards, err := svc.RemoteLatestCards(t.Context(), mount, acct, mount.RemoteViewID, 10)
if err != nil {
t.Fatalf("RemoteLatestCards failed: %v", err)
}
if len(latestCards) != 1 || !latestCards[0].IsSeries {
t.Fatalf("expected 1 series latest card, got %#v", latestCards)
}
seriesCards, err := svc.RemoteSeriesCards(t.Context(), mount, acct, mount.RemoteViewID)
if err != nil {
t.Fatalf("RemoteSeriesCards failed: %v", err)
}
if seriesQueryCalls.Load() < 2 {
t.Fatalf("expected both latest and series list queries, got %d", seriesQueryCalls.Load())
}
found := false
for _, card := range seriesCards {
if card.Key == latestCards[0].Key {
found = true
break
}
}
if !found {
t.Fatalf("latest card key %q not present in series list keys %#v", latestCards[0].Key, seriesCards)
}
}
func TestRemoteLatestFields(t *testing.T) {
var requestedFields string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+3
View File
@@ -33,6 +33,7 @@ type SeriesCard struct {
Rep model.Media `json:"rep"`
LinkMedia model.Media `json:"linkMedia"`
Count int `json:"count"`
IsSeries bool `json:"is_series,omitempty"`
LastAddedAt *time.Time `json:"last_added_at,omitempty"`
}
@@ -45,6 +46,7 @@ type SeriesCardView struct {
Rep model.Media `json:"rep"`
LinkLibraryID string `json:"linkLibraryId,omitempty"`
Count int `json:"count"`
IsSeries bool `json:"is_series,omitempty"`
LastAddedAt *time.Time `json:"last_added_at,omitempty"`
}
@@ -59,6 +61,7 @@ func NewSeriesCardViews(cards []SeriesCard) []SeriesCardView {
Rep: card.Rep,
LinkLibraryID: mediaTargetLibraryID(card.LinkMedia),
Count: card.Count,
IsSeries: card.IsSeries,
LastAddedAt: card.LastAddedAt,
}
}
+4 -2
View File
@@ -70,7 +70,9 @@ func SortSeriesCards(cards []SeriesCard, field, order string, history map[string
}
sort.SliceStable(out, func(i, j int) bool {
cmp := compareSeriesByField(out[i], out[j], spec, history)
if cmp == 0 {
// updated_at 表示最后一集添加日期。远程卡片可能没有该字段,此时
// 保持调用方给定的顺序,不要用 ID 再次打乱。
if cmp == 0 && spec.Field != "updated_at" {
cmp = compareID(out[i].Key, out[j].Key)
}
return cmp < 0
@@ -173,7 +175,7 @@ func seriesUpdatedTime(card SeriesCard) (time.Time, bool) {
if card.LastAddedAt != nil && !card.LastAddedAt.IsZero() {
return *card.LastAddedAt, true
}
return mediaUpdatedTime(card.Rep)
return time.Time{}, false
}
func seriesLastPlayed(card SeriesCard, history map[string]time.Time) (time.Time, bool) {
+43
View File
@@ -2,6 +2,7 @@ package service
import (
"testing"
"time"
"github.com/truewhile/MeBox/internal/model"
)
@@ -40,3 +41,45 @@ func TestSortSeriesCardsTitleAsc(t *testing.T) {
t.Fatalf("series order = %q, %q", got[0].Key, got[1].Key)
}
}
func TestSortSeriesCardsUpdatedAtDoesNotFallbackToMediaDate(t *testing.T) {
olderMediaDate := time.Date(2023, time.January, 1, 0, 0, 0, 0, time.UTC)
newerMediaDate := time.Date(2025, time.January, 1, 0, 0, 0, 0, time.UTC)
cards := []SeriesCard{
{
Key: "remote-first",
Rep: model.Media{Base: model.Base{
ID: "remote-first",
CreatedAt: olderMediaDate,
UpdatedAt: olderMediaDate,
}},
},
{
Key: "remote-second",
Rep: model.Media{Base: model.Base{
ID: "remote-second",
CreatedAt: newerMediaDate,
UpdatedAt: newerMediaDate,
}},
},
}
got := SortSeriesCards(cards, "updated_at", "desc", nil)
if got[0].Key != "remote-first" || got[1].Key != "remote-second" {
t.Fatalf("series order = %q, %q, want remote-provided order", got[0].Key, got[1].Key)
}
}
func TestSortSeriesCardsUpdatedAtSortsKnownLastEpisodeDates(t *testing.T) {
older := time.Date(2024, time.January, 1, 0, 0, 0, 0, time.UTC)
newer := time.Date(2025, time.January, 1, 0, 0, 0, 0, time.UTC)
cards := []SeriesCard{
{Key: "older", LastAddedAt: &older},
{Key: "newer", LastAddedAt: &newer},
}
got := SortSeriesCards(cards, "updated_at", "desc", nil)
if got[0].Key != "newer" || got[1].Key != "older" {
t.Fatalf("series order = %q, %q", got[0].Key, got[1].Key)
}
}
+277
View File
@@ -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"
}
}
+109
View File
@@ -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)
}
}
+1
View File
@@ -64,6 +64,7 @@ type Container struct {
RecognitionWords *RecognitionWordsService
Danmaku *DanmakuService
Strm *StrmService
Cloud115 *Cloud115PlaybackService
Database *DatabaseAdminService
FFTools *FFmpegToolsService
+8 -7
View File
@@ -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 指向解析成可拉取的直链/本地路径。
+16 -1
View File
@@ -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
+32 -8
View File
@@ -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)
}
+40 -10
View File
@@ -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))
+12 -2
View File
@@ -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
+13 -1
View File
@@ -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>(`/media/${id}`).then((r) => r.data),
playbackInfo: (id: string, quality?: number) =>
api
.get<PlaybackInfo>(`/media/${id}/playback`, {
params: quality && quality > 0 ? { quality } : undefined,
})
.then((r) => r.data),
startCloudTranscode: (id: string, definition: number) =>
api
.post<PlaybackInfo>(`/media/${id}/transcode`, { definition })
.then((r) => r.data),
getEpisodes: (id: string) =>
api.get<{ items: Media[]; total: number }>(`/media/${id}/episodes`).then((r) => r.data),
+104 -7
View File
@@ -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<number | null>(null)
const [subtitleMenuOpen, setSubtitleMenuOpen] = useState(false)
const subtitleMenuRef = useRef<HTMLDivElement | null>(null)
const [qualityMenuOpen, setQualityMenuOpen] = useState(false)
const qualityMenuRef = useRef<HTMLDivElement | null>(null)
const hideTimerRef = useRef<ReturnType<typeof setTimeout> | 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<number | null>(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({
</button>
)}
{showQuality && qualities.length > 0 && onSelectQuality && (
<div className="relative" ref={qualityMenuRef}>
<button
onClick={() => {
setSubtitleMenuOpen(false)
setQualityMenuOpen((v) => !v)
}}
className="flex items-center gap-1 rounded-full p-1.5 transition hover:bg-white/15"
title="画质"
>
<Gauge size={18} className={qualityMenuOpen ? 'text-rose-400' : 'text-white/80'} />
<span className="hidden text-[10px] font-medium text-white/80 sm:inline">
{selectedQualityLabel || '画质'}
</span>
</button>
{qualityMenuOpen && (
<div className="absolute bottom-11 right-0 z-30 min-w-48 overflow-hidden rounded-xl border border-white/15 bg-black/85 p-1 shadow-2xl backdrop-blur">
{qualityGroups.map((group) => (
<div key={group.key}>
<p className="px-3 pb-1 pt-2 text-[10px] uppercase tracking-wide text-white/40">
{group.label}
</p>
{group.items.map((quality) => {
const current = quality.id === selectedQuality
return (
<button
key={`${quality.source}-${quality.id}`}
type="button"
onClick={() => {
onSelectQuality(quality)
setQualityMenuOpen(false)
}}
className={`flex w-full items-center gap-2 rounded-lg px-3 py-1.5 text-left text-xs transition ${
current
? 'text-rose-400 hover:bg-white/10'
: quality.requires_vip
? 'text-white/55 hover:bg-white/10'
: 'text-white/85 hover:bg-white/10'
}`}
title={quality.note || quality.label}
>
<span className="truncate">{quality.label}</span>
{quality.requires_transcode && (
<span className="ml-auto flex items-center gap-1 rounded bg-white/10 px-1.5 py-0.5 text-[9px] text-amber-300">
<Loader2 size={10} />
转码
</span>
)}
{quality.requires_vip && <Lock size={11} className="ml-auto text-amber-300" />}
{current && !quality.requires_transcode && (
<Check size={13} className="ml-auto text-rose-400" />
)}
</button>
)
})}
</div>
))}
</div>
)}
</div>
)}
{subs.length > 0 && (
<div className="relative" ref={subtitleMenuRef}>
<button
onClick={() => setSubtitleMenuOpen((v) => !v)}
onClick={() => {
setQualityMenuOpen(false)
setSubtitleMenuOpen((v) => !v)
}}
className="rounded-full p-1.5 transition hover:bg-white/15"
title="字幕"
>
@@ -852,4 +949,4 @@ export function PlayerControls({
</div>
</>
)
}
}
+5 -1
View File
@@ -124,6 +124,7 @@ export function LibraryPage() {
})
const {
resolvingSeries,
selectedEpisodes,
visibleEpisodes,
selectedSeriesEpisodes,
@@ -132,6 +133,7 @@ export function LibraryPage() {
handleSeriesClick,
clearSelectedSeries,
} = useLibrarySeriesSelection({
libraryID: id,
items,
seriesEpisodeItems,
isSeriesLibrary,
@@ -209,7 +211,9 @@ export function LibraryPage() {
return <>{actions}</>
}, [canFavorite, favouriteBusyID, handleToggleFavourite, isFavourite, movieActions])
if (loading) {
// resolvingSeries:深链目标剧集不在已加载的一页卡片里,正在按 key
// 单独解析。此时先显示加载态,避免先渲染整个媒体库列表再跳走。
if (loading || resolvingSeries) {
return (
<div className="flex items-center justify-center py-32">
<motion.div animate={{ opacity: [0.4, 1, 0.4] }} transition={{ repeat: Infinity, duration: 2 }} className="flex items-center gap-3">
+297 -13
View File
@@ -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<PlaybackInfo | null>(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<ReturnType<typeof setTimeout> | null>(null)
const cloudRetryRef = useRef(5)
const pendingSeekRef = useRef<number | null>(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={
<PlayerPlaylistPanel
open={playlistOpen}
@@ -1248,6 +1517,21 @@ function formatEpisodeDisplay(ep: Media, siblings: Media[]): string {
return ep.episode_num > 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
+27 -1
View File
@@ -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<HTMLDivElement>(null)
const [videoRatio, setVideoRatio] = useState<number | null>(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}
</div>
) : null}
{waiting ? (
<div className="absolute inset-0 z-40 flex items-center justify-center bg-black/60 px-4 backdrop-blur-sm">
<div className="max-w-md rounded-xl border border-white/15 bg-black/85 px-6 py-5 text-center text-white shadow-2xl">
<Loader2 className="mx-auto mb-3 animate-spin text-rose-400" size={28} />
<p className="text-sm font-medium">{waitingMessage || '115 正在转码…'}</p>
<p className="mt-1 text-xs text-white/60">转码完成后会自动播放</p>
</div>
</div>
) : null}
</div>
)
}
+51 -6
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo } from 'react'
import { useEffect, useMemo, useState } from 'react'
import type { Media } from '../types'
import {
@@ -9,6 +9,7 @@ import {
specialSectionForMedia,
type SeriesCard,
} from '../utils/groupSeries'
import { resolveSeriesCardByKey } from '../utils/seriesCardResolve'
type SeasonEpisodes = {
season: number
@@ -16,6 +17,7 @@ type SeasonEpisodes = {
}
type UseLibrarySeriesSelectionOptions = {
libraryID: string
items: Media[]
seriesEpisodeItems: Media[]
isSeriesLibrary: boolean
@@ -32,6 +34,7 @@ type UseLibrarySeriesSelectionOptions = {
}
export function useLibrarySeriesSelection({
libraryID,
items,
seriesEpisodeItems,
isSeriesLibrary,
@@ -46,6 +49,14 @@ export function useLibrarySeriesSelection({
setSelectedSeason,
onClearSeriesState,
}: UseLibrarySeriesSelectionOptions) {
// 深链(首页最新条目 / 海报墙 ?series=<key>)指向的剧集不在已加载的
// 一页卡片里时,需要单独按 key 解析;resolvingSeries 让页面在这段时间
// 显示加载态,而不是先闪一下整个媒体库列表。
const [resolvingSeries, setResolvingSeries] = useState(false)
const [pendingSeriesKey, setPendingSeriesKey] = useState('')
// 当前 URL 请求的剧集 key(非剧集库 / 加载中 / 没带参数时为空)。
const requestedSeriesKey = !loading && isSeries ? (searchParams.get('series') ?? '') : ''
const selectedEpisodes = useMemo(() => {
const sourceItems = isSeriesLibrary ? seriesEpisodeItems : items
if (!selectedSeries || sourceItems.length === 0) return []
@@ -92,20 +103,53 @@ export function useLibrarySeriesSelection({
useEffect(() => {
if (loading) return
if (!isSeries) {
setPendingSeriesKey('')
setSelectedSeries(null)
setSelectedSeason(null)
return
}
const key = searchParams.get('series')
if (!key) {
if (!requestedSeriesKey) {
setPendingSeriesKey('')
setSelectedSeries(null)
return
}
const next = seriesCards.find((card) => card.key === key)
setSelectedSeries(next ?? null)
}, [isSeries, loading, searchParams, seriesCards, setSelectedSeason, setSelectedSeries])
const next = seriesCards.find((card) => card.key === requestedSeriesKey)
if (next) {
setPendingSeriesKey('')
setSelectedSeries(next)
return
}
// 目标剧集尚未加载到(远程库分页 / 大库分页):登记待解析 key。
setPendingSeriesKey((prev) => (prev === requestedSeriesKey ? prev : requestedSeriesKey))
}, [isSeries, loading, requestedSeriesKey, seriesCards, setSelectedSeason, setSelectedSeries])
useEffect(() => {
// pendingSeriesKey 必须仍然是 URL 上请求的 key:切换媒体库/清空参数
// 时直接丢弃待解析状态,避免把别的库的剧集解析进来。
if (!pendingSeriesKey || pendingSeriesKey !== requestedSeriesKey) {
setResolvingSeries(false)
return
}
let cancelled = false
setResolvingSeries(true)
resolveSeriesCardByKey(libraryID, pendingSeriesKey)
.then((card) => {
if (cancelled) return
setResolvingSeries(false)
setSelectedSeries(card)
})
.catch(() => {
if (cancelled) return
setResolvingSeries(false)
setSelectedSeries(null)
})
return () => {
cancelled = true
}
}, [libraryID, pendingSeriesKey, requestedSeriesKey, setSelectedSeries])
useEffect(() => {
if (!selectedSeries || selectedEpisodes.length === 0) {
@@ -135,6 +179,7 @@ export function useLibrarySeriesSelection({
}
return {
resolvingSeries,
selectedEpisodes,
visibleEpisodes,
selectedSeriesEpisodes,
+1
View File
@@ -7,4 +7,5 @@ export * from './history'
export * from './library'
export * from './media'
export * from './notifications'
export * from './playback'
export * from './playProfiles'
+31
View File
@@ -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
}
+3 -1
View File
@@ -36,6 +36,7 @@ export type SeriesCard = {
linkMedia?: Media
linkLibraryId?: string
count: number
is_series?: boolean
last_added_at?: string
}
@@ -223,6 +224,7 @@ export function seasonLabel(season: number): string {
export function isSeriesCard(card: SeriesCard): boolean {
const linkMedia = card.linkMedia ?? card.rep
return (
card.is_series === true ||
card.count > 1 ||
isEpisodeLike(card.rep) ||
isEpisodeLike(linkMedia) ||
@@ -458,7 +460,7 @@ export function groupSeries(items: Media[] = []): SeriesCard[] {
? compactSeriesKey(externalKey)
: getSeriesKey(m)
const mAddedAt = m.created_at || m.updated_at || ''
const mAddedAt = m.updated_at || m.created_at || ''
const g = groups.get(key)
if (!g) {
groups.set(key, { key, rep: m, linkMedia: m, count: 1, last_added_at: mAddedAt })
+1 -1
View File
@@ -114,7 +114,7 @@ export function sortMediaList(
case 'created_at':
return compareDates(a.created_at, b.created_at, order) || compareStrings(a.title, b.title, 'asc')
case 'updated_at':
return compareDates(a.created_at || a.updated_at, b.created_at || b.updated_at, order) || compareStrings(a.title, b.title, 'asc')
return compareDates(a.updated_at || a.created_at, b.updated_at || b.created_at, order) || compareStrings(a.title, b.title, 'asc')
case 'rating':
case 'imdb_rating': {
const cmp = compareNumbers(a.rating || 0, b.rating || 0, order)
+59
View File
@@ -0,0 +1,59 @@
import { libraryAPI, mediaAPI } from '../api/library'
import type { Media } from '../types'
import { groupSeries, type SeriesCard } from './groupSeries'
import { isRemoteEmbyID } from './remoteEmby'
/**
* 按 series key 反解出剧集卡片。
*
* 首页「最新条目」、海报墙等入口点击后带 ?series=<key> 深链进媒体库页,
* 但媒体库页每次只加载一页剧集卡片(远程 Emby 库 100 条),目标剧集不在
* 这一页时列表里找不到对应卡片,页面就退化成整个媒体库列表。这里按 key
* 单独解析目标剧集,与分页无关:
*
* - 远程 Emby 的 key 本身就是(伪装后的)剧集 ID,直接取该条目详情;
* - 本地 key 是分组 hash,改取该剧集的集列表,再折叠出代表条目。
*
* key 失效(剧集已删除等)时返回 null,调用方保持"整库列表"原状态。
*/
export async function resolveSeriesCardByKey(libraryID: string, key: string): Promise<SeriesCard | null> {
if (!libraryID || !key) return null
if (isRemoteEmbyID(key)) {
const rep = await fetchRemoteSeriesMedia(key)
if (rep) {
return { key, rep, linkMedia: rep, count: 0, is_series: true }
}
}
const episodes = await fetchSeriesEpisodes(libraryID, key)
if (episodes.length === 0) return null
// 与列表页同一套折叠规则,选出海报/元数据最好的那一集做代表卡片。
const [grouped] = groupSeries(episodes)
const rep = grouped?.rep ?? episodes[0]
return {
key,
rep,
linkMedia: grouped?.linkMedia ?? rep,
count: episodes.length,
is_series: true,
}
}
async function fetchRemoteSeriesMedia(id: string): Promise<Media | null> {
try {
const media = await mediaAPI.get(id)
return media?.id ? media : null
} catch {
return null
}
}
async function fetchSeriesEpisodes(libraryID: string, key: string): Promise<Media[]> {
try {
const page = await libraryAPI.listSeriesEpisodes(libraryID, key)
return Array.isArray(page.items) ? page.items : []
} catch {
return []
}
}