mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 11:16:37 +08:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d489c1608e | |||
| 1ea29cca9e | |||
| ec5f81e6cb |
@@ -19,6 +19,7 @@ require (
|
||||
github.com/ulikunitz/xz v0.5.12
|
||||
go.uber.org/zap v1.27.0
|
||||
golang.org/x/crypto v0.49.0
|
||||
golang.org/x/image v0.37.0
|
||||
golang.org/x/sys v0.42.0
|
||||
golang.org/x/time v0.15.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
@@ -86,7 +87,6 @@ require (
|
||||
go.uber.org/multierr v1.10.0 // indirect
|
||||
golang.org/x/arch v0.25.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
|
||||
golang.org/x/image v0.37.0 // indirect
|
||||
golang.org/x/net v0.52.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/text v0.35.0 // indirect
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
@@ -359,6 +361,7 @@ func newPlaybackScopeTestRouter(t *testing.T) (*gin.Engine, *service.Container,
|
||||
Auth: auth,
|
||||
Media: service.NewMediaService(cfg, log, repos),
|
||||
Stream: service.NewStreamService(cfg, log, repos, nil),
|
||||
Subtitle: service.NewSubtitleService(cfg, log, repos),
|
||||
Permissions: permissions,
|
||||
}
|
||||
if err := repos.User.Create(t.Context(), &model.User{
|
||||
@@ -440,6 +443,38 @@ func TestPlaybackInfoForSTRMMediaIncludesHLS(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubtitleListOnlyProbesEmbeddedTracksForHLS(t *testing.T) {
|
||||
router, svc, secret := newPlaybackScopeTestRouter(t)
|
||||
loginToken := signedTestToken(t, secret)
|
||||
resolveCalls := 0
|
||||
svc.Subtitle.SetStrmPlayTargetResolver(func(context.Context, string) (*service.StrmPlayResult, error) {
|
||||
resolveCalls++
|
||||
return nil, errors.New("probe resolver called")
|
||||
})
|
||||
|
||||
request := func(path string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(http.MethodGet, "http://nas.local"+path, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+loginToken)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
return w
|
||||
}
|
||||
|
||||
if w := request("/api/media/media-1/subtitles"); w.Code != http.StatusOK {
|
||||
t.Fatalf("direct subtitle status = %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if resolveCalls != 0 {
|
||||
t.Fatalf("direct subtitle request resolved cloud media %d times, want 0", resolveCalls)
|
||||
}
|
||||
|
||||
if w := request("/api/media/media-1/subtitles?include_embedded=true"); w.Code != http.StatusOK {
|
||||
t.Fatalf("HLS subtitle status = %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if resolveCalls != 1 {
|
||||
t.Fatalf("HLS subtitle request resolved cloud media %d times, want 1", resolveCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHLSPlaylistForRemoteEmbyMediaDisabled(t *testing.T) {
|
||||
router, svc, secret := newPlaybackScopeTestRouter(t)
|
||||
svc.EmbyRemote = &service.EmbyRemoteService{}
|
||||
|
||||
@@ -16,7 +16,13 @@ func listSubtitlesHandler(svc *service.Container) gin.HandlerFunc {
|
||||
c.JSON(http.StatusOK, gin.H{"tracks": []service.SubtitleTrack{}})
|
||||
return
|
||||
}
|
||||
tracks, err := svc.Subtitle.Discover(c.Request.Context(), id)
|
||||
var tracks []service.SubtitleTrack
|
||||
var err error
|
||||
if c.Query("include_embedded") == "true" {
|
||||
tracks, err = svc.Subtitle.Discover(c.Request.Context(), id)
|
||||
} else {
|
||||
tracks, err = svc.Subtitle.DiscoverExternalOnly(c.Request.Context(), id)
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/metatube-community/metatube-sdk-go/detector"
|
||||
_ "golang.org/x/image/webp" // register WebP decoder for AVE covers
|
||||
)
|
||||
|
||||
const adultPosterRatio = 2.0 / 3.0
|
||||
|
||||
@@ -2,6 +2,7 @@ package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/draw"
|
||||
@@ -58,6 +59,27 @@ func TestCropAdultCoverPosterWideLandscape(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCropAdultCoverPosterDecodesWebP(t *testing.T) {
|
||||
wideWebP, err := base64.StdEncoding.DecodeString("UklGRjwAAABXRUJQVlA4IDAAAADQAQCdASoJAAYAAUAmJaACdLoB+AADsAD+8ut//NgVzXPv9//S4P0uD9Lg/9KQAAA=")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
croppedBytes, ctype, err := CropAdultCoverPoster(wideWebP)
|
||||
if err != nil {
|
||||
t.Fatalf("crop WebP: %v", err)
|
||||
}
|
||||
if ctype != "image/jpeg" {
|
||||
t.Fatalf("cropped WebP type = %q, want image/jpeg", ctype)
|
||||
}
|
||||
cropped, _, err := image.Decode(bytes.NewReader(croppedBytes))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := float64(cropped.Bounds().Dx()) / float64(cropped.Bounds().Dy()); got < 0.65 || got > 0.68 {
|
||||
t.Fatalf("cropped WebP ratio = %.3f, want 2:3", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCropAdultCoverPosterCentersDetectedFace(t *testing.T) {
|
||||
originalDetector := findPrimaryFaceAxisRatio
|
||||
findPrimaryFaceAxisRatio = func(image.Image, float64, bool) (float64, bool) {
|
||||
|
||||
@@ -73,7 +73,7 @@ func isLocalImagePath(raw string) bool {
|
||||
}
|
||||
ext := strings.ToLower(filepath.Ext(raw))
|
||||
switch ext {
|
||||
case ".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp", ".tbn":
|
||||
case ".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp", ".tbn", ".img":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
|
||||
@@ -13,6 +13,12 @@ import (
|
||||
"github.com/truewhile/MeBox/internal/config"
|
||||
)
|
||||
|
||||
func TestIsLocalImagePathAcceptsLegacyImgSidecar(t *testing.T) {
|
||||
if !isLocalImagePath(filepath.Join(t.TempDir(), "movie-poster.img")) {
|
||||
t.Fatal("legacy .img sidecar should remain displayable")
|
||||
}
|
||||
}
|
||||
|
||||
var testJPEG = []byte{
|
||||
0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 'J', 'F', 'I', 'F',
|
||||
0x00, 0x01, 0x01, 0x00, 0x00, 0x01, 0x00, 0x01,
|
||||
@@ -117,12 +123,12 @@ func TestIsPrivateHost(t *testing.T) {
|
||||
// Hostnames must NOT be blocked even though GFW DNS poisoning may resolve
|
||||
// them to private/loopback IPs — blocking them broke legitimate posters.
|
||||
allowed := []string{"image.tmdb.org", "lain.bgm.tv", "example.com", "8.8.8.8"}
|
||||
for _, h := range allowed {
|
||||
if isPrivateHost(h) {
|
||||
t.Errorf("isPrivateHost(%q) = true, want false", h)
|
||||
}
|
||||
for _, h := range allowed {
|
||||
if isPrivateHost(h) {
|
||||
t.Errorf("isPrivateHost(%q) = true, want false", h)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestImageProxyAllowedRemoteHostBypassesPrivateCheck(t *testing.T) {
|
||||
proxy := NewImageProxy(&config.Config{Cache: config.CacheConfig{CacheDir: filepath.Join(t.TempDir(), "cache")}}, zap.NewNop())
|
||||
|
||||
@@ -91,10 +91,22 @@ func shouldCropAdultPoster(media *model.Media, lib *model.Library) bool {
|
||||
// downloadArtworkToPathWithOptions fetches an artwork URL via the image proxy cache and
|
||||
// writes it under dir/<name>.<ext>. For adult posters, it crops the right half of the cover.
|
||||
func (s *ScraperService) downloadArtworkToPathWithOptions(ctx context.Context, dir, name, raw string, cropAdultPoster bool) string {
|
||||
if !isHTTPish(raw) {
|
||||
var (
|
||||
data []byte
|
||||
ctype string
|
||||
err error
|
||||
)
|
||||
switch {
|
||||
case isHTTPish(raw):
|
||||
data, ctype, err = s.images.Fetch(ctx, raw)
|
||||
case isLocalImagePath(raw):
|
||||
data, err = os.ReadFile(sanitizeLocalPath(resolveMappedDestinationPath(raw)))
|
||||
if err == nil {
|
||||
ctype = detectContentType(data)
|
||||
}
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
data, ctype, err := s.images.Fetch(ctx, raw)
|
||||
if err != nil || len(data) == 0 {
|
||||
s.log.Warn("scrape artwork download failed",
|
||||
zap.String("name", name),
|
||||
@@ -105,6 +117,12 @@ func (s *ScraperService) downloadArtworkToPathWithOptions(ctx context.Context, d
|
||||
if !isImageContentType(ctype) || isTransparentPlaceholderData(data) {
|
||||
return ""
|
||||
}
|
||||
// Upstreams sometimes report non-standard values such as image/jpg.
|
||||
// Use the decoded bytes as the source of truth so sidecars receive a
|
||||
// standard extension instead of the legacy .img fallback.
|
||||
if detected := detectContentType(data); isImageContentType(detected) {
|
||||
ctype = detected
|
||||
}
|
||||
if cropAdultPoster {
|
||||
if cropped, croppedType, err := CropAdultCoverPoster(data); err == nil && len(cropped) > 0 {
|
||||
data = cropped
|
||||
@@ -166,7 +184,7 @@ func (s *ScraperService) writeArtworkDataToPath(dir, name, ctype string, data []
|
||||
// extension that could confuse media players.
|
||||
func imageExtForContentType(ctype string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(strings.Split(ctype, ";")[0])) {
|
||||
case "image/jpeg", "image/pjpeg":
|
||||
case "image/jpeg", "image/jpg", "image/pjpeg":
|
||||
return ".jpg"
|
||||
case "image/png", "image/x-png":
|
||||
return ".png"
|
||||
|
||||
@@ -88,6 +88,7 @@ func TestShouldCropAdultPosterUsesLibraryType(t *testing.T) {
|
||||
func TestImageExtForContentType(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"image/jpeg": ".jpg",
|
||||
"image/jpg": ".jpg",
|
||||
"image/pjpeg": ".jpg",
|
||||
"image/png": ".png",
|
||||
"image/webp": ".webp",
|
||||
@@ -103,6 +104,32 @@ func TestImageExtForContentType(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadArtworkMigratesLocalImgToCanonicalCroppedJPEG(t *testing.T) {
|
||||
scraper := &ScraperService{log: zap.NewNop()}
|
||||
dir := t.TempDir()
|
||||
legacy := filepath.Join(dir, "movie-poster.img")
|
||||
wide := createTestImage(900, 600, color.RGBA{R: 255, A: 255}, color.RGBA{B: 255, A: 255})
|
||||
if err := os.WriteFile(legacy, wide, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
dst := scraper.downloadArtworkToPathWithOptions(t.Context(), dir, "movie-poster", legacy, true)
|
||||
if filepath.Base(dst) != "movie-poster.jpg" {
|
||||
t.Fatalf("destination = %q, want canonical JPEG sidecar", dst)
|
||||
}
|
||||
data, err := os.ReadFile(dst)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cropped, _, err := image.Decode(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := float64(cropped.Bounds().Dx()) / float64(cropped.Bounds().Dy()); got < 0.65 || got > 0.68 {
|
||||
t.Fatalf("migrated poster ratio = %.3f, want 2:3", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWriteMediaArtworkFilesAfterScrapeSkipsCloud verifies that cloud media
|
||||
// never has artwork written anywhere.
|
||||
func TestWriteMediaArtworkFilesAfterScrapeSkipsCloud(t *testing.T) {
|
||||
|
||||
@@ -94,28 +94,28 @@ func (s *SubtitleService) Discover(ctx context.Context, mediaID string) ([]Subti
|
||||
// DiscoverExternalOnly 只返回媒体旁边的外挂字幕文件,不含容器内嵌字幕轨。
|
||||
// Emby 字幕接口(/Videos/:id/Subtitles/...)用。
|
||||
func (s *SubtitleService) DiscoverExternalOnly(ctx context.Context, mediaID string) ([]SubtitleTrack, error) {
|
||||
tracks, err := s.discover(ctx, mediaID)
|
||||
cacheKey := "external:" + mediaID
|
||||
if tracks, ok := s.cachedDiscovery(cacheKey); ok {
|
||||
return tracks, nil
|
||||
}
|
||||
tracks, err := s.discoverExternalUncached(ctx, mediaID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]SubtitleTrack, 0, len(tracks))
|
||||
for _, track := range tracks {
|
||||
if track.Source != "embedded" {
|
||||
out = append(out, track)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
s.rememberDiscovery(cacheKey, tracks)
|
||||
return tracks, nil
|
||||
}
|
||||
|
||||
func (s *SubtitleService) discover(ctx context.Context, mediaID string) ([]SubtitleTrack, error) {
|
||||
if tracks, ok := s.cachedDiscovery(mediaID); ok {
|
||||
cacheKey := "all:" + mediaID
|
||||
if tracks, ok := s.cachedDiscovery(cacheKey); ok {
|
||||
return tracks, nil
|
||||
}
|
||||
tracks, err := s.discoverUncached(ctx, mediaID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.rememberDiscovery(mediaID, tracks)
|
||||
s.rememberDiscovery(cacheKey, tracks)
|
||||
return tracks, nil
|
||||
}
|
||||
|
||||
@@ -159,6 +159,30 @@ func (s *SubtitleService) discoverUncached(ctx context.Context, mediaID string)
|
||||
if m == nil {
|
||||
return nil, errors.New("media not found")
|
||||
}
|
||||
tracks := discoverExternalSubtitleTracks(m)
|
||||
embedded, err := s.discoverEmbedded(ctx, m)
|
||||
if err != nil {
|
||||
if s.log != nil {
|
||||
s.log.Debug("discover embedded subtitles failed", zap.String("media_id", mediaID), zap.Error(err))
|
||||
}
|
||||
} else {
|
||||
tracks = append(tracks, embedded...)
|
||||
}
|
||||
return tracks, nil
|
||||
}
|
||||
|
||||
func (s *SubtitleService) discoverExternalUncached(ctx context.Context, mediaID string) ([]SubtitleTrack, error) {
|
||||
m, err := s.repo.Media.FindByID(ctx, mediaID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if m == nil {
|
||||
return nil, errors.New("media not found")
|
||||
}
|
||||
return discoverExternalSubtitleTracks(m), nil
|
||||
}
|
||||
|
||||
func discoverExternalSubtitleTracks(m *model.Media) []SubtitleTrack {
|
||||
dir := filepath.Dir(m.Path)
|
||||
bases := mediaSidecarBaseVariants(m.Path)
|
||||
if len(bases) == 0 {
|
||||
@@ -214,15 +238,7 @@ func (s *SubtitleService) discoverUncached(ctx context.Context, mediaID string)
|
||||
})
|
||||
}
|
||||
}
|
||||
embedded, err := s.discoverEmbedded(ctx, m)
|
||||
if err != nil {
|
||||
if s.log != nil {
|
||||
s.log.Debug("discover embedded subtitles failed", zap.String("media_id", mediaID), zap.Error(err))
|
||||
}
|
||||
} else {
|
||||
tracks = append(tracks, embedded...)
|
||||
}
|
||||
return tracks, nil
|
||||
return tracks
|
||||
}
|
||||
|
||||
type embeddedSubtitleProbe struct {
|
||||
|
||||
@@ -2,7 +2,9 @@ package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -48,6 +50,43 @@ func TestSubtitleDiscoverNoTracksReturnsEmptySlice(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscoverExternalOnlyDoesNotResolveOrProbeCloudMedia(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.Library{}, &model.Media{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
media := model.Media{
|
||||
Title: "Cloud Media",
|
||||
Path: "cloud://115/example/video.mkv",
|
||||
STRMURL: "cloud://115/example/video.mkv",
|
||||
}
|
||||
if err := db.Create(&media).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
svc := NewSubtitleService(&config.Config{}, zap.NewNop(), repository.New(db))
|
||||
resolveCalls := 0
|
||||
svc.SetStrmPlayTargetResolver(func(context.Context, string) (*StrmPlayResult, error) {
|
||||
resolveCalls++
|
||||
return nil, errors.New("resolver must not be called")
|
||||
})
|
||||
|
||||
tracks, err := svc.DiscoverExternalOnly(t.Context(), media.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resolveCalls != 0 {
|
||||
t.Fatalf("STRM resolver called %d times, want 0", resolveCalls)
|
||||
}
|
||||
if len(tracks) != 0 {
|
||||
t.Fatalf("len(tracks) = %d, want 0", len(tracks))
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbeddedSubtitleProbeClassifiesTextAndBitmapTracks(t *testing.T) {
|
||||
var probe embeddedSubtitleProbe
|
||||
raw := []byte(`{"streams":[
|
||||
|
||||
@@ -13,9 +13,11 @@ export interface SubtitleTrack {
|
||||
}
|
||||
|
||||
export const subtitlesAPI = {
|
||||
list: (mediaId: string) =>
|
||||
list: (mediaId: string, includeEmbedded = false) =>
|
||||
api
|
||||
.get<{ tracks: SubtitleTrack[] | null }>(`/media/${mediaId}/subtitles`)
|
||||
.get<{ tracks: SubtitleTrack[] | null }>(`/media/${mediaId}/subtitles`, {
|
||||
params: includeEmbedded ? { include_embedded: 'true' } : undefined,
|
||||
})
|
||||
.then((r) => r.data.tracks ?? []),
|
||||
|
||||
url: (mediaId: string, path: string) => {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Layers } from 'lucide-react'
|
||||
import { Cloud, Layers } from 'lucide-react'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
import type { Media } from '../types'
|
||||
import { mediaVersionLabel, mediaVersionsOf } from '../utils/mediaVersion'
|
||||
import { isStrmMedia, mediaVersionLabel, mediaVersionsOf } from '../utils/mediaVersion'
|
||||
|
||||
type MediaVersionSwitcherProps = {
|
||||
media: Media
|
||||
@@ -31,14 +31,30 @@ export function MediaVersionSwitcher({
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{versions.map((version) => {
|
||||
const active = version.id === media.id
|
||||
const isStrm = isStrmMedia(version)
|
||||
const label = mediaVersionLabel(version)
|
||||
const className = dark
|
||||
const btnClass = dark
|
||||
? active
|
||||
? 'border-white/40 bg-white/20 text-white'
|
||||
: 'border-white/15 bg-white/5 text-white/80 hover:bg-white/15'
|
||||
? 'border-white/50 bg-white/20 text-white shadow-sm ring-1 ring-white/20'
|
||||
: 'border-white/15 bg-white/5 text-white/80 hover:bg-white/15 hover:text-white'
|
||||
: active
|
||||
? 'border-brand-500/40 bg-brand-50 text-[#b07d35]'
|
||||
? 'border-brand-500/40 bg-brand-50 text-[#b07d35] font-bold shadow-sm'
|
||||
: 'border-gray-200 bg-white text-ink-100 hover:border-brand-500/30 hover:bg-brand-50/40'
|
||||
|
||||
const content = (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
{isStrm && (
|
||||
<Cloud
|
||||
size={12}
|
||||
className={dark ? (active ? 'text-brand-300' : 'text-white/60') : (active ? 'text-brand-500' : 'text-sand-400')}
|
||||
aria-label="云端 / STRM"
|
||||
/>
|
||||
)}
|
||||
<span>{label}</span>
|
||||
{!dark && active ? <span className="opacity-75">· 当前</span> : null}
|
||||
</span>
|
||||
)
|
||||
|
||||
if (mode === 'player') {
|
||||
return (
|
||||
<button
|
||||
@@ -46,10 +62,10 @@ export function MediaVersionSwitcher({
|
||||
type="button"
|
||||
disabled={active}
|
||||
onClick={() => onSelect?.(version)}
|
||||
className={`rounded-xl border px-3 py-1.5 text-xs font-semibold transition disabled:cursor-default ${className}`}
|
||||
title={version.path}
|
||||
className={`rounded-xl border px-3 py-1.5 text-xs font-semibold transition disabled:cursor-default ${btnClass}`}
|
||||
title={version.path || version.strm_url}
|
||||
>
|
||||
{label}
|
||||
{content}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -58,11 +74,10 @@ export function MediaVersionSwitcher({
|
||||
key={version.id}
|
||||
to={`/play/${version.id}`}
|
||||
state={{ from: `/media/${media.id}` }}
|
||||
className={`rounded-xl border px-3 py-1.5 text-xs font-semibold transition ${className}`}
|
||||
title={version.path}
|
||||
className={`rounded-xl border px-3 py-1.5 text-xs font-semibold transition ${btnClass}`}
|
||||
title={version.path || version.strm_url}
|
||||
>
|
||||
{label}
|
||||
{active ? ' · 当前' : ''}
|
||||
{content}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -237,8 +237,22 @@ export function PlayerPage() {
|
||||
// 404 / 无权限等:给出可见错误提示,避免永久「加载中」
|
||||
setLoadError(`无法加载该媒体:${apiErrorMessage(err)}`)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [id, modeParam, directOnly])
|
||||
|
||||
// Wire up the actual <video> element when we know the mode.
|
||||
// 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
|
||||
|
||||
// 直连播放只发现外挂字幕;只有 HLS 模式需要探测可烧录的内嵌字幕。
|
||||
useEffect(() => {
|
||||
if (!mediaId) return
|
||||
let cancelled = false
|
||||
subtitlesAPI
|
||||
.list(id)
|
||||
.list(mediaId, mode === 'hls')
|
||||
.then((tracks) => {
|
||||
if (cancelled) return
|
||||
const list = tracks ?? []
|
||||
@@ -249,16 +263,13 @@ export function PlayerPage() {
|
||||
.catch(() => {
|
||||
if (cancelled) return
|
||||
setSubs([])
|
||||
setSubtitleIndex(-1)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [id, modeParam, directOnly])
|
||||
}, [mediaId, mode])
|
||||
|
||||
// Wire up the actual <video> element when we know the mode.
|
||||
// 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 selectedSubtitle = subtitleIndex >= 0 ? subs[subtitleIndex] : undefined
|
||||
const burnedSubtitleStream =
|
||||
selectedSubtitle?.delivery === 'burn' ? selectedSubtitle.stream_index : undefined
|
||||
|
||||
+134
-25
@@ -1,33 +1,62 @@
|
||||
import type { Media } from '../types'
|
||||
|
||||
/** 从路径 / strm URL 推断容器扩展名(mkv/mp4…)。 */
|
||||
const VIDEO_EXTENSIONS = new Set([
|
||||
'mkv', 'mp4', 'm4v', 'avi', 'mov', 'webm', 'flv', 'wmv', 'ts', 'm2ts', 'mts', 'vob', 'rmvb', 'rm', 'iso',
|
||||
])
|
||||
|
||||
export function isStrmMedia(media?: Media | null): boolean {
|
||||
if (!media) return false
|
||||
const path = (media.path || '').toLowerCase()
|
||||
const strmUrl = (media.strm_url || '').toLowerCase()
|
||||
return path.endsWith('.strm') || path.startsWith('cloud://') || Boolean(strmUrl.trim())
|
||||
}
|
||||
|
||||
/** 从路径 / strm URL 推断真实视频容器扩展名(mkv/mp4…)。 */
|
||||
export function mediaVersionContainer(media: Media): string {
|
||||
const explicit = (media.container || '').trim().toLowerCase().replace(/^\./, '')
|
||||
if (explicit && explicit !== 'strm' && VIDEO_EXTENSIONS.has(explicit)) {
|
||||
return explicit
|
||||
}
|
||||
|
||||
const path = (media.path || '').replace(/\\/g, '/')
|
||||
const base = path.split('/').pop() || ''
|
||||
const ext = base.includes('.') ? base.slice(base.lastIndexOf('.')).toLowerCase() : ''
|
||||
let name = ext ? base.slice(0, -ext.length) : base
|
||||
const name = ext ? base.slice(0, -ext.length) : base
|
||||
|
||||
if (ext === '.strm') {
|
||||
const second = name.includes('.') ? name.slice(name.lastIndexOf('.')).toLowerCase() : ''
|
||||
if (second && second !== '.strm') {
|
||||
return second.replace(/^\./, '')
|
||||
const second = name.includes('.') ? name.slice(name.lastIndexOf('.')).toLowerCase().replace(/^\./, '') : ''
|
||||
if (second && VIDEO_EXTENSIONS.has(second)) {
|
||||
return second
|
||||
}
|
||||
|
||||
const strm = (media.strm_url || '').toLowerCase()
|
||||
const marker = '/video.'
|
||||
const idx = strm.lastIndexOf(marker)
|
||||
if (idx >= 0) {
|
||||
let rest = strm.slice(idx + marker.length)
|
||||
const end = rest.search(/[?#&/]/)
|
||||
if (end >= 0) rest = rest.slice(0, end)
|
||||
rest = rest.replace(/^\./, '').trim()
|
||||
if (rest) return rest
|
||||
if (strm) {
|
||||
// 1. /video.mkv 或 /video.mp4 模式
|
||||
const marker = '/video.'
|
||||
const idx = strm.lastIndexOf(marker)
|
||||
if (idx >= 0) {
|
||||
let rest = strm.slice(idx + marker.length)
|
||||
const end = rest.search(/[?#&/]/)
|
||||
if (end >= 0) rest = rest.slice(0, end)
|
||||
rest = rest.replace(/^\./, '').trim()
|
||||
if (rest && VIDEO_EXTENSIONS.has(rest)) return rest
|
||||
}
|
||||
|
||||
// 2. 从 query 参数(如 ref=/movies/abc.mkv 或 path=...)或 pathname 里匹配扩展名
|
||||
const match = strm.match(/\.(mkv|mp4|m4v|avi|mov|webm|flv|wmv|ts|m2ts|iso)(?=[?#&/]|$)/i)
|
||||
if (match?.[1]) {
|
||||
return match[1].toLowerCase()
|
||||
}
|
||||
}
|
||||
return 'strm'
|
||||
}
|
||||
return ext.replace(/^\./, '')
|
||||
|
||||
const cleanExt = ext.replace(/^\./, '')
|
||||
return VIDEO_EXTENSIONS.has(cleanExt) ? cleanExt : cleanExt
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (!bytes || bytes < 1024) return bytes ? `${bytes} B` : ''
|
||||
if (!bytes || bytes < 1024) return ''
|
||||
const units = ['KB', 'MB', 'GB', 'TB']
|
||||
let value = bytes
|
||||
let unit = -1
|
||||
@@ -38,19 +67,99 @@ function formatSize(bytes: number): string {
|
||||
return `${value.toFixed(1)} ${units[unit]}`
|
||||
}
|
||||
|
||||
/** 版本切换展示名:分辨率 · 容器 · 编码 · 体积,缺省回退文件名。 */
|
||||
/** 从文件名/路径中提取质量或规格标签(如 4K, 2160p, REMUX, HDR, 1080p, 60fps 等) */
|
||||
function extractQualityKeywords(text: string): string[] {
|
||||
const result: string[] = []
|
||||
const upper = text.toUpperCase()
|
||||
|
||||
// 分辨率
|
||||
if (/\b(4K|2160P|UHD)\b/i.test(upper)) result.push('4K')
|
||||
else if (/\b(1080P|FHD)\b/i.test(upper)) result.push('1080p')
|
||||
else if (/\b(720P|HD)\b/i.test(upper)) result.push('720p')
|
||||
|
||||
// 格式/版本规格
|
||||
if (/\bREMUX\b/i.test(upper)) result.push('REMUX')
|
||||
else if (/\b(BLURAY|BLU-RAY|BD)\b/i.test(upper)) result.push('BluRay')
|
||||
else if (/\b(WEB-?DL|WEBRIP)\b/i.test(upper)) result.push('WEB-DL')
|
||||
|
||||
// 动态范围/画质技术
|
||||
if (/\b(DV|DOVI|DOLBY[\s._-]?VISION)\b/i.test(upper)) result.push('DV')
|
||||
if (/\b(HDR10\+|HDR10PLUS)\b/i.test(upper)) result.push('HDR10+')
|
||||
else if (/\bHDR\b/i.test(upper) && !result.includes('DV')) result.push('HDR')
|
||||
|
||||
// 帧率
|
||||
if (/\b(60FPS|120FPS)\b/i.test(upper)) {
|
||||
const fpsMatch = upper.match(/\b(60FPS|120FPS)\b/i)
|
||||
if (fpsMatch) result.push(fpsMatch[1])
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/** 清理文件名以用作干净的兜底展示 */
|
||||
function cleanFallbackFilename(raw: string): string {
|
||||
let name = raw.replace(/\\/g, '/').split('/').pop() || ''
|
||||
// 去除 .strm 及前缀扩展名
|
||||
name = name.replace(/\.strm$/i, '')
|
||||
name = name.replace(/\.(mkv|mp4|m4v|avi|mov|webm|flv|wmv|ts|iso)$/i, '')
|
||||
// 替换连续的点或下划线为空格
|
||||
name = name.replace(/[._]+/g, ' ').trim()
|
||||
return name
|
||||
}
|
||||
|
||||
/** 版本切换展示名:分辨率 · 容器 · 编码 · 体积 / 规格 / 来源标签。 */
|
||||
export function mediaVersionLabel(media: Media): string {
|
||||
const isStrm = isStrmMedia(media)
|
||||
const parts: string[] = []
|
||||
if (media.height > 0) parts.push(`${media.height}p`)
|
||||
else if (media.width > 0) parts.push(`${media.width}w`)
|
||||
|
||||
// 1. 分辨率(优先从媒体元数据获取,其次从路径中提取)
|
||||
if (media.height > 0) {
|
||||
if (media.height >= 2100) parts.push('4K')
|
||||
else parts.push(`${media.height}p`)
|
||||
} else if (media.width > 0) {
|
||||
if (media.width >= 3800) parts.push('4K')
|
||||
else parts.push(`${media.width}w`)
|
||||
}
|
||||
|
||||
// 2. 容器
|
||||
const container = mediaVersionContainer(media)
|
||||
if (container && container !== 'strm') parts.push(container.toUpperCase())
|
||||
if (media.video_codec?.trim()) parts.push(media.video_codec.trim().toUpperCase())
|
||||
const size = formatSize(media.size_bytes)
|
||||
if (size) parts.push(size)
|
||||
if (parts.length > 0) return parts.join(' · ')
|
||||
const base = (media.path || '').replace(/\\/g, '/').split('/').pop()
|
||||
return base || media.title || media.id
|
||||
if (container && container !== 'strm') {
|
||||
parts.push(container.toUpperCase())
|
||||
}
|
||||
|
||||
// 3. 视频编码
|
||||
if (media.video_codec?.trim()) {
|
||||
parts.push(media.video_codec.trim().toUpperCase())
|
||||
}
|
||||
|
||||
// 4. 从文件名/路径补充规格特征(针对 STRM 缺乏探测数据时的关键画质提示)
|
||||
const pathKeywords = extractQualityKeywords(`${media.path || ''} ${media.strm_url || ''}`)
|
||||
for (const kw of pathKeywords) {
|
||||
// 避免重复(如已经有了 4K / 1080p)
|
||||
if (!parts.some((p) => p.toUpperCase() === kw.toUpperCase())) {
|
||||
parts.push(kw)
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 文件大小:过滤掉本地 STRM 占位小文件(< 1MB 的 STRM 索引通常不是真实视频大小)
|
||||
const isPlaceholderSize = isStrm && media.size_bytes > 0 && media.size_bytes < 1024 * 1024
|
||||
if (media.size_bytes > 0 && !isPlaceholderSize) {
|
||||
const size = formatSize(media.size_bytes)
|
||||
if (size) parts.push(size)
|
||||
}
|
||||
|
||||
// 6. 如果仍然没有解析出足够特征,标记来源(云盘 / STRM)
|
||||
if (isStrm && !parts.some((p) => ['4K', '1080P', '720P', 'REMUX', 'WEB-DL', 'MKV', 'MP4'].includes(p.toUpperCase()))) {
|
||||
parts.push('云端直链')
|
||||
}
|
||||
|
||||
if (parts.length > 0) {
|
||||
return parts.join(' · ')
|
||||
}
|
||||
|
||||
// 兜底:使用清理后的文件名
|
||||
const cleaned = cleanFallbackFilename(media.path || '')
|
||||
return cleaned || media.title || media.id
|
||||
}
|
||||
|
||||
export function mediaVersionsOf(media: Media | null | undefined): Media[] {
|
||||
|
||||
Reference in New Issue
Block a user