5 Commits

Author SHA1 Message Date
nianzhibai dc7d2a5de3 Release v0.1.3 for ArtPlayer video detail updates 2026-06-07 15:24:57 +08:00
nianzhibai 2f2bfbfcdc Improve video detail player controls and layout 2026-06-07 15:17:08 +08:00
nianzhibai 9def08b0c5 Enhance video detail player experience
Add ArtPlayer/HLS playback, resume prompts, mobile gestures, orientation toggle, and theme-aware controls. Hide author metadata from video detail headers.
2026-06-07 00:15:32 +08:00
nianzhibai c87208117e Fix scanner cancellation and shorts UI 2026-06-06 08:37:00 +00:00
nianzhibai a770b3af6b Support local STRM files 2026-06-06 07:50:43 +00:00
27 changed files with 3246 additions and 498 deletions
+17 -1
View File
@@ -1444,7 +1444,15 @@ func (a *App) runScanWithTaskContext(ctx context.Context, driveID string) {
log.Printf("[scan] drive=%s start=%s skip_dirs=%d", driveID, startID, len(d.SkipDirIDs))
stats, err := sc.Run(ctx, startID)
if err != nil {
log.Printf("[scan] drive=%s error: %v", driveID, err)
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
log.Printf("[scan] drive=%s canceled: %v", driveID, err)
} else {
log.Printf("[scan] drive=%s error: %v", driveID, err)
}
return
}
if err := ctx.Err(); err != nil {
log.Printf("[scan] drive=%s canceled after scan: %v", driveID, err)
return
}
log.Printf("[scan] drive=%s done scanned=%d added=%d errors=%d", driveID, stats.Scanned, stats.Added, stats.Errors)
@@ -1461,12 +1469,20 @@ func (a *App) runScanWithTaskContext(ctx context.Context, driveID string) {
} else {
removed, err := a.cleanupMissingDriveVideos(ctx, driveID, stats.SeenFileIDs, stats.VisitedDirIDs, startID == drv.RootID())
if err != nil {
if ctxErr := ctx.Err(); ctxErr != nil {
log.Printf("[cleanup] canceled stale cleanup drive=%s kind=%s: %v", driveID, drv.Kind(), ctxErr)
return
}
log.Printf("[cleanup] stale cleanup drive=%s kind=%s error: %v", driveID, drv.Kind(), err)
} else if removed > 0 {
log.Printf("[cleanup] removed %d stale videos for drive=%s kind=%s", removed, driveID, drv.Kind())
}
}
}
if err := ctx.Err(); err != nil {
log.Printf("[scan] drive=%s canceled before enqueue generation: %v", driveID, err)
return
}
a.scheduleFingerprintBackfill(ctx, driveID, fingerprintWorker)
a.enqueueDriveGeneration(ctx, driveID, worker, thumbWorker)
}
+3 -1
View File
@@ -33,7 +33,7 @@ scanner:
# 单次扫描每家网盘目录递归层数上限
max_depth: 5
# 被扫描的扩展名
video_extensions: [".mp4", ".mkv", ".mov", ".webm", ".avi"]
video_extensions: [".mp4", ".mkv", ".mov", ".webm", ".avi", ".strm"]
nightly:
# 凌晨流水线触发整点(0-23),默认 1 即每天 01:00。流程:
@@ -80,5 +80,7 @@ preview:
# name: "本地视频目录"
# root_id: "/"
# params:
# # Docker 部署时这里和 .strm 里的绝对路径都必须使用容器内路径。
# # 例如宿主机 /mnt/videos 挂载为 /media,就填写 /media。
# path: "/mnt/videos"
drives: []
+24 -1
View File
@@ -16,6 +16,11 @@ const (
DefaultAdminPassword = "admin123"
)
var (
legacyDefaultVideoExtensions = []string{".mp4", ".mkv", ".mov", ".webm", ".avi"}
defaultVideoExtensions = []string{".mp4", ".mkv", ".mov", ".webm", ".avi", ".strm"}
)
type Config struct {
Server Server `yaml:"server"`
Storage Storage `yaml:"storage"`
@@ -247,7 +252,9 @@ func (c *Config) applyDefaults() {
c.Scanner.MaxDepth = 5
}
if len(c.Scanner.VideoExtensions) == 0 {
c.Scanner.VideoExtensions = []string{".mp4", ".mkv", ".mov", ".webm", ".avi"}
c.Scanner.VideoExtensions = append([]string{}, defaultVideoExtensions...)
} else if isLegacyDefaultVideoExtensions(c.Scanner.VideoExtensions) {
c.Scanner.VideoExtensions = append(c.Scanner.VideoExtensions, ".strm")
}
if c.Preview.FFmpegPath == "" {
c.Preview.FFmpegPath = "ffmpeg"
@@ -276,3 +283,19 @@ func (c *Config) applyDefaults() {
c.Nightly.CronHour = 1
}
}
func isLegacyDefaultVideoExtensions(exts []string) bool {
if len(exts) != len(legacyDefaultVideoExtensions) {
return false
}
seen := make(map[string]struct{}, len(exts))
for _, ext := range exts {
seen[strings.ToLower(strings.TrimSpace(ext))] = struct{}{}
}
for _, ext := range legacyDefaultVideoExtensions {
if _, ok := seen[ext]; !ok {
return false
}
}
return true
}
+62
View File
@@ -3,6 +3,7 @@ package config
import (
"os"
"path/filepath"
"strings"
"testing"
)
@@ -50,3 +51,64 @@ storage:
t.Fatalf("db path = %q, want preserved value", cfg.Storage.DBPath)
}
}
func TestLoadDefaultScannerVideoExtensionsIncludeSTRM(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.yaml")
if err := os.WriteFile(path, []byte(`{}`), 0o644); err != nil {
t.Fatalf("write config: %v", err)
}
cfg, err := Load(path)
if err != nil {
t.Fatalf("load config: %v", err)
}
if !hasVideoExtension(cfg.Scanner.VideoExtensions, ".strm") {
t.Fatalf("video extensions = %#v, want .strm", cfg.Scanner.VideoExtensions)
}
}
func TestLoadLegacyDefaultScannerVideoExtensionsIncludeSTRM(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.yaml")
if err := os.WriteFile(path, []byte(`
scanner:
video_extensions: [".mp4", ".mkv", ".mov", ".webm", ".avi"]
`), 0o644); err != nil {
t.Fatalf("write config: %v", err)
}
cfg, err := Load(path)
if err != nil {
t.Fatalf("load config: %v", err)
}
if !hasVideoExtension(cfg.Scanner.VideoExtensions, ".strm") {
t.Fatalf("video extensions = %#v, want .strm appended for legacy default list", cfg.Scanner.VideoExtensions)
}
}
func TestLoadCustomScannerVideoExtensionsArePreserved(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.yaml")
if err := os.WriteFile(path, []byte(`
scanner:
video_extensions: [".mp4"]
`), 0o644); err != nil {
t.Fatalf("write config: %v", err)
}
cfg, err := Load(path)
if err != nil {
t.Fatalf("load config: %v", err)
}
if len(cfg.Scanner.VideoExtensions) != 1 || cfg.Scanner.VideoExtensions[0] != ".mp4" {
t.Fatalf("video extensions = %#v, want custom list preserved", cfg.Scanner.VideoExtensions)
}
}
func hasVideoExtension(exts []string, want string) bool {
want = strings.ToLower(strings.TrimSpace(want))
for _, ext := range exts {
if strings.ToLower(strings.TrimSpace(ext)) == want {
return true
}
}
return false
}
+144 -1
View File
@@ -7,6 +7,7 @@ import (
"errors"
"fmt"
"io"
"net/url"
"os"
"path/filepath"
"strings"
@@ -17,6 +18,8 @@ import (
const Kind = "localstorage"
const maxSTRMBytes = 64 * 1024
type Config struct {
ID string
RootPath string
@@ -122,7 +125,13 @@ func (d *Driver) StreamURL(ctx context.Context, fileID string) (*drives.StreamLi
if err != nil {
return nil, err
}
if info.IsDir() || !info.Mode().IsRegular() || info.Size() <= 0 {
if info.IsDir() || !info.Mode().IsRegular() {
return nil, os.ErrNotExist
}
if strings.EqualFold(filepath.Ext(p), ".strm") {
return d.streamURLFromSTRM(ctx, p)
}
if info.Size() <= 0 {
return nil, os.ErrNotExist
}
return &drives.StreamLink{
@@ -131,6 +140,115 @@ func (d *Driver) StreamURL(ctx context.Context, fileID string) (*drives.StreamLi
}, nil
}
func (d *Driver) streamURLFromSTRM(ctx context.Context, strmPath string) (*drives.StreamLink, error) {
target, err := readSTRMTarget(strmPath)
if err != nil {
return nil, err
}
if err := ctx.Err(); err != nil {
return nil, err
}
if filepath.IsAbs(target) {
return d.localSTRMLink(strmPath, target)
}
u, err := url.Parse(target)
if err == nil {
switch strings.ToLower(u.Scheme) {
case "http", "https":
if u.Host == "" {
return nil, fmt.Errorf("localstorage: invalid strm url %q", target)
}
return &drives.StreamLink{
URL: target,
Expires: time.Now().Add(24 * time.Hour),
}, nil
case "file":
if u.Host != "" && !strings.EqualFold(u.Host, "localhost") {
return nil, fmt.Errorf("localstorage: unsupported strm file url host %q", u.Host)
}
return d.localSTRMLink(strmPath, u.Path)
case "":
// Local path below.
default:
return nil, fmt.Errorf("localstorage: unsupported strm target scheme %q", u.Scheme)
}
} else if strings.Contains(target, "://") {
return nil, fmt.Errorf("localstorage: invalid strm url %q: %w", target, err)
}
return d.localSTRMLink(strmPath, target)
}
func readSTRMTarget(path string) (string, error) {
f, err := os.Open(path)
if err != nil {
return "", err
}
defer f.Close()
data, err := io.ReadAll(io.LimitReader(f, maxSTRMBytes+1))
if err != nil {
return "", err
}
if len(data) > maxSTRMBytes {
return "", errors.New("localstorage: strm file is too large")
}
lines := strings.Split(string(data), "\n")
for i, line := range lines {
if i == 0 {
line = strings.TrimPrefix(line, "\ufeff")
}
line = strings.TrimSpace(line)
if line != "" {
return line, nil
}
}
return "", errors.New("localstorage: empty strm target")
}
func (d *Driver) localSTRMLink(strmPath, target string) (*drives.StreamLink, error) {
target = strings.TrimSpace(target)
if target == "" {
return nil, errors.New("localstorage: empty strm target")
}
var p string
if filepath.IsAbs(target) {
p = filepath.Clean(target)
} else {
p = filepath.Join(filepath.Dir(strmPath), filepath.FromSlash(target))
}
p, err := filepath.Abs(p)
if err != nil {
return nil, err
}
root, err := d.root()
if err != nil {
return nil, err
}
realPath, within, err := realPathWithinRoot(root, p)
if err != nil {
return nil, err
}
if !within {
return nil, errors.New("localstorage: strm target escapes root")
}
if strings.EqualFold(filepath.Ext(p), ".strm") || strings.EqualFold(filepath.Ext(realPath), ".strm") {
return nil, errors.New("localstorage: nested strm target is not supported")
}
info, err := os.Stat(realPath)
if err != nil {
return nil, err
}
if info.IsDir() || !info.Mode().IsRegular() || info.Size() <= 0 {
return nil, os.ErrNotExist
}
return &drives.StreamLink{
URL: realPath,
Expires: time.Now().Add(24 * time.Hour),
}, nil
}
func (d *Driver) Upload(context.Context, string, string, io.Reader, int64) (string, error) {
return "", drives.ErrNotSupported
}
@@ -177,6 +295,11 @@ func (d *Driver) pathForID(id string) (string, string, error) {
if !pathWithinRoot(root, p) {
return "", "", errors.New("localstorage: path escapes root")
}
if _, within, err := realPathWithinRoot(root, p); err != nil {
return "", "", err
} else if !within {
return "", "", errors.New("localstorage: path escapes root")
}
return p, rel, nil
}
@@ -188,6 +311,26 @@ func pathWithinRoot(root, path string) bool {
return rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(os.PathSeparator)))
}
func realPathWithinRoot(root, path string) (string, bool, error) {
realRoot, err := filepath.EvalSymlinks(root)
if err != nil {
return "", false, err
}
realRoot, err = filepath.Abs(realRoot)
if err != nil {
return "", false, err
}
realPath, err := filepath.EvalSymlinks(path)
if err != nil {
return "", false, err
}
realPath, err = filepath.Abs(realPath)
if err != nil {
return "", false, err
}
return realPath, pathWithinRoot(realRoot, realPath), nil
}
func localStoragePathHint(configured string) string {
cwd, _ := os.Getwd()
parts := []string{}
@@ -58,6 +58,159 @@ func TestListEncodesRelativePathsAndStreamURLResolvesFile(t *testing.T) {
}
}
func TestStreamURLResolvesHTTPSTRM(t *testing.T) {
root := t.TempDir()
strmPath := filepath.Join(root, "movie.strm")
target := "https://media.example/clip.mp4?token=abc"
if err := os.WriteFile(strmPath, []byte("\ufeff\n "+target+"\n"), 0o644); err != nil {
t.Fatalf("write strm: %v", err)
}
drv := New(Config{ID: "local", RootPath: root})
link, err := drv.StreamURL(context.Background(), encodeRel("movie.strm"))
if err != nil {
t.Fatalf("stream url: %v", err)
}
if link.URL != target {
t.Fatalf("url = %q, want %q", link.URL, target)
}
}
func TestStreamURLResolvesRelativeLocalSTRM(t *testing.T) {
root := t.TempDir()
if err := os.MkdirAll(filepath.Join(root, "links"), 0o755); err != nil {
t.Fatalf("mkdir links: %v", err)
}
if err := os.MkdirAll(filepath.Join(root, "media"), 0o755); err != nil {
t.Fatalf("mkdir media: %v", err)
}
videoPath := filepath.Join(root, "media", "clip.mp4")
if err := os.WriteFile(videoPath, []byte("video"), 0o644); err != nil {
t.Fatalf("write video: %v", err)
}
if err := os.WriteFile(filepath.Join(root, "links", "movie.strm"), []byte("../media/clip.mp4\n"), 0o644); err != nil {
t.Fatalf("write strm: %v", err)
}
drv := New(Config{ID: "local", RootPath: root})
link, err := drv.StreamURL(context.Background(), encodeRel("links/movie.strm"))
if err != nil {
t.Fatalf("stream url: %v", err)
}
if link.URL != videoPath {
t.Fatalf("url = %q, want %q", link.URL, videoPath)
}
}
func TestStreamURLRejectsInvalidSTRMTargets(t *testing.T) {
tests := []struct {
name string
setup func(t *testing.T, root string) string
want string
}{
{
name: "empty",
setup: func(t *testing.T, root string) string {
t.Helper()
writeLocalStorageTestFile(t, filepath.Join(root, "empty.strm"), []byte("\n \r\n"))
return "empty.strm"
},
want: "empty strm target",
},
{
name: "escapes root",
setup: func(t *testing.T, root string) string {
t.Helper()
writeLocalStorageTestFile(t, filepath.Join(filepath.Dir(root), "outside.mp4"), []byte("video"))
writeLocalStorageTestFile(t, filepath.Join(root, "escape.strm"), []byte("../outside.mp4\n"))
return "escape.strm"
},
want: "escapes root",
},
{
name: "nested",
setup: func(t *testing.T, root string) string {
t.Helper()
writeLocalStorageTestFile(t, filepath.Join(root, "nested.strm"), []byte("https://media.example/clip.mp4\n"))
writeLocalStorageTestFile(t, filepath.Join(root, "outer.strm"), []byte("nested.strm\n"))
return "outer.strm"
},
want: "nested strm target",
},
{
name: "unsupported scheme",
setup: func(t *testing.T, root string) string {
t.Helper()
writeLocalStorageTestFile(t, filepath.Join(root, "ftp.strm"), []byte("ftp://media.example/clip.mp4\n"))
return "ftp.strm"
},
want: "unsupported strm target scheme",
},
{
name: "too large",
setup: func(t *testing.T, root string) string {
t.Helper()
writeLocalStorageTestFile(t, filepath.Join(root, "large.strm"), []byte(strings.Repeat("x", maxSTRMBytes+1)))
return "large.strm"
},
want: "strm file is too large",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
root := t.TempDir()
rel := tt.setup(t, root)
drv := New(Config{ID: "local", RootPath: root})
_, err := drv.StreamURL(context.Background(), encodeRel(rel))
if err == nil || !strings.Contains(err.Error(), tt.want) {
t.Fatalf("error = %v, want contain %q", err, tt.want)
}
})
}
}
func TestStreamURLRejectsSTRMTargetEscapingRootThroughSymlink(t *testing.T) {
root := t.TempDir()
outside := t.TempDir()
writeLocalStorageTestFile(t, filepath.Join(outside, "secret.mp4"), []byte("secret"))
if err := os.MkdirAll(filepath.Join(root, "links"), 0o755); err != nil {
t.Fatalf("mkdir links: %v", err)
}
if err := os.MkdirAll(filepath.Join(root, "real"), 0o755); err != nil {
t.Fatalf("mkdir real: %v", err)
}
if err := os.Symlink(outside, filepath.Join(root, "real", "outside")); err != nil {
t.Fatalf("symlink: %v", err)
}
writeLocalStorageTestFile(t, filepath.Join(root, "links", "movie.strm"), []byte("../real/outside/secret.mp4\n"))
drv := New(Config{ID: "local", RootPath: root})
_, err := drv.StreamURL(context.Background(), encodeRel("links/movie.strm"))
if err == nil || !strings.Contains(err.Error(), "strm target escapes root") {
t.Fatalf("error = %v, want strm target escapes root", err)
}
}
func TestStreamURLRejectsSymlinkFileIDEscapingRoot(t *testing.T) {
root := t.TempDir()
outside := t.TempDir()
writeLocalStorageTestFile(t, filepath.Join(outside, "secret.mp4"), []byte("secret"))
if err := os.Symlink(filepath.Join(outside, "secret.mp4"), filepath.Join(root, "link.mp4")); err != nil {
t.Fatalf("symlink: %v", err)
}
drv := New(Config{ID: "local", RootPath: root})
_, err := drv.StreamURL(context.Background(), encodeRel("link.mp4"))
if err == nil || !strings.Contains(err.Error(), "path escapes root") {
t.Fatalf("error = %v, want path escapes root", err)
}
}
func TestStreamURLRejectsEscapingID(t *testing.T) {
drv := New(Config{ID: "local", RootPath: t.TempDir()})
escaped := base64.RawURLEncoding.EncodeToString([]byte("../secret.mp4"))
@@ -100,6 +253,45 @@ func TestPathForIDAllowsRootPathSlash(t *testing.T) {
}
}
func TestScannerPersistsLocalStorageSTRM(t *testing.T) {
ctx := context.Background()
root := t.TempDir()
if err := os.MkdirAll(filepath.Join(root, "collection"), 0o755); err != nil {
t.Fatalf("mkdir collection: %v", err)
}
if err := os.WriteFile(filepath.Join(root, "collection", "clip.strm"), []byte("https://media.example/clip.mp4\n"), 0o644); err != nil {
t.Fatalf("write strm: %v", err)
}
cat, err := catalog.Open(filepath.Join(t.TempDir(), "catalog.db"))
if err != nil {
t.Fatalf("open catalog: %v", err)
}
t.Cleanup(func() {
if err := cat.Close(); err != nil {
t.Fatalf("close catalog: %v", err)
}
})
drv := New(Config{ID: "local", RootPath: root})
sc := scanner.New(cat, drv, []string{".strm"}, nil, nil)
stats, err := sc.Run(ctx, drv.RootID())
if err != nil {
t.Fatalf("scan: %v", err)
}
if stats.Added != 1 {
t.Fatalf("added = %d, want 1", stats.Added)
}
fileID := encodeRel("collection/clip.strm")
got, err := cat.GetVideo(ctx, Kind+"-local-"+fileID)
if err != nil {
t.Fatalf("get video: %v", err)
}
if got.Ext != "strm" || got.FileID != fileID || got.Category != "collection" {
t.Fatalf("video = %#v, want local strm video in collection", got)
}
}
func TestScannerPersistsLocalStorageVideo(t *testing.T) {
ctx := context.Background()
root := t.TempDir()
@@ -138,3 +330,10 @@ func TestScannerPersistsLocalStorageVideo(t *testing.T) {
t.Fatalf("video = %#v, want local drive video in collection", got)
}
}
func writeLocalStorageTestFile(t *testing.T, path string, data []byte) {
t.Helper()
if err := os.WriteFile(path, data, 0o644); err != nil {
t.Fatalf("write %s: %v", path, err)
}
}
+36
View File
@@ -127,6 +127,9 @@ func (s *Scanner) walk(ctx context.Context, dirID, dirName string, stats *Stats,
}
for _, e := range entries {
if err := ctx.Err(); err != nil {
return err
}
if e.IsDir {
// 跳过 previews 目录,避免扫到自己生成的预览视频
if strings.EqualFold(e.Name, "previews") {
@@ -137,6 +140,9 @@ func (s *Scanner) walk(ctx context.Context, dirID, dirName string, stats *Stats,
continue
}
if err := s.walk(ctx, e.ID, e.Name, stats, progress); err != nil {
if ctxErr := ctx.Err(); ctxErr != nil {
return ctxErr
}
stats.Errors++
log.Printf("[scanner] walk %s error: %v", e.Name, err)
}
@@ -155,6 +161,9 @@ func (s *Scanner) walk(ctx context.Context, dirID, dirName string, stats *Stats,
id := s.Drive.Kind() + "-" + s.Drive.ID() + "-" + e.ID
if deleted, err := s.Catalog.IsDeletedVideoCandidate(ctx, id, s.Drive.ID(), e.ID, e.Hash, e.Name, e.Size); err != nil {
if ctxErr := ctx.Err(); ctxErr != nil {
return ctxErr
}
stats.Errors++
log.Printf("[scanner] check deleted video %s error: %v", id, err)
continue
@@ -170,11 +179,20 @@ func (s *Scanner) walk(ctx context.Context, dirID, dirName string, stats *Stats,
if matched, err := s.Catalog.MatchTags(ctx, e.Name+" "+dirName+" "+parsed.Author); err == nil {
tags = mergeTags(tags, matched)
}
if err := ctx.Err(); err != nil {
return err
}
if label, ok, err := s.Catalog.EnsureCollectionTag(ctx, dirName); err == nil && ok {
tags = mergeTags(tags, []string{label})
}
if err := ctx.Err(); err != nil {
return err
}
existing, _ := s.Catalog.GetVideo(ctx, id)
if err := ctx.Err(); err != nil {
return err
}
if existing != nil {
patch := catalog.VideoMetaPatch{}
if e.Hash != "" && existing.ContentHash == "" {
@@ -191,12 +209,21 @@ func (s *Scanner) walk(ctx context.Context, dirID, dirName string, stats *Stats,
}
if patch.Category != "" || patch.ContentHash != "" || patch.FileName != "" {
_ = s.Catalog.UpdateVideoMeta(ctx, id, patch)
if err := ctx.Err(); err != nil {
return err
}
}
if dup := s.findDuplicate(ctx, e.Hash, e.Name, e.Size, id); dup != nil {
continue
}
if err := ctx.Err(); err != nil {
return err
}
if !sameTags(existing.Tags, tags) {
_ = s.Catalog.SetAutoVideoTags(ctx, id, tags)
if err := ctx.Err(); err != nil {
return err
}
}
continue
}
@@ -204,6 +231,9 @@ func (s *Scanner) walk(ctx context.Context, dirID, dirName string, stats *Stats,
if dup := s.findDuplicate(ctx, e.Hash, e.Name, e.Size, id); dup != nil {
continue
}
if err := ctx.Err(); err != nil {
return err
}
now := time.Now()
v := &catalog.Video{
@@ -226,9 +256,15 @@ func (s *Scanner) walk(ctx context.Context, dirID, dirName string, stats *Stats,
UpdatedAt: now,
}
if err := s.Catalog.UpsertVideo(ctx, v); err != nil {
if ctxErr := ctx.Err(); ctxErr != nil {
return ctxErr
}
log.Printf("[scanner] upsert %s error: %v", v.Title, err)
continue
}
if err := ctx.Err(); err != nil {
return err
}
stats.Added++
if s.OnNewVideo != nil {
s.OnNewVideo(v)
+45
View File
@@ -3,6 +3,7 @@ package scanner
import (
"context"
"database/sql"
"errors"
"fmt"
"io"
"log"
@@ -90,6 +91,50 @@ func TestRunIgnoresZeroSizeVideoFiles(t *testing.T) {
}
}
func TestRunStopsWhenContextCanceledDuringFileLoop(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cat, err := catalog.Open(t.TempDir() + "/catalog.db")
if err != nil {
t.Fatalf("open catalog: %v", err)
}
t.Cleanup(func() {
if err := cat.Close(); err != nil {
t.Fatalf("close catalog: %v", err)
}
})
drv := &scannerFakeDrive{
entries: []drives.Entry{
{ID: "file-1", Name: "one.mp4", Size: 123},
{ID: "file-2", Name: "two.mp4", Size: 123},
{ID: "file-3", Name: "three.mp4", Size: 123},
},
}
callbacks := 0
sc := New(cat, drv, []string{".mp4"}, nil, func(*catalog.Video) {
callbacks++
cancel()
})
stats, err := sc.Run(ctx, "")
if !errors.Is(err, context.Canceled) {
t.Fatalf("scan error = %v, want context.Canceled", err)
}
if stats.Added != 1 || callbacks != 1 {
t.Fatalf("added=%d callbacks=%d, want exactly one video before cancellation", stats.Added, callbacks)
}
if _, err := cat.GetVideo(context.Background(), "fake-drive-file-1"); err != nil {
t.Fatalf("first video should be persisted before cancellation: %v", err)
}
if _, err := cat.GetVideo(context.Background(), "fake-drive-file-2"); err != sql.ErrNoRows {
t.Fatalf("second video lookup error = %v, want sql.ErrNoRows", err)
}
if _, err := cat.GetVideo(context.Background(), "fake-drive-file-3"); err != sql.ErrNoRows {
t.Fatalf("third video lookup error = %v, want sql.ErrNoRows", err)
}
}
func TestRunSkipsAdminDeletedVideo(t *testing.T) {
ctx := context.Background()
cat, err := catalog.Open(t.TempDir() + "/catalog.db")
+37 -2
View File
@@ -1,14 +1,16 @@
{
"name": "video-site",
"version": "0.1.0",
"version": "0.1.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "video-site",
"version": "0.1.0",
"version": "0.1.3",
"license": "MIT",
"dependencies": {
"artplayer": "^5.4.0",
"hls.js": "^1.6.16",
"lucide-react": "0.453.0",
"react": "18.3.1",
"react-dom": "18.3.1",
@@ -475,6 +477,15 @@
}
}
},
"node_modules/artplayer": {
"version": "5.4.0",
"resolved": "https://registry.npmjs.org/artplayer/-/artplayer-5.4.0.tgz",
"integrity": "sha512-2B+plbx8N2yNsjK4nJU3+EOG8TULm1LRZk/QPkWRAMEX2Ee/MSnZG/WJYz8kcoZxZuLKcQ3uXifqLuPxZOH29A==",
"license": "MIT",
"dependencies": {
"option-validator": "^2.0.6"
}
},
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@@ -525,12 +536,27 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/hls.js": {
"version": "1.6.16",
"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.16.tgz",
"integrity": "sha512-VSIRpLfRwlAAdGL4wiTucx2ScRipo0ed1FBatWkyt832jC4CReKstga6yIhYVwGu9LOBjuX9wzmRMeQdBJtzEA==",
"license": "Apache-2.0"
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"license": "MIT"
},
"node_modules/kind-of": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz",
"integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/lightningcss": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
@@ -832,6 +858,15 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/option-validator": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/option-validator/-/option-validator-2.0.6.tgz",
"integrity": "sha512-tmZDan2LRIRQyhUGvkff68/O0R8UmF+Btmiiz0SmSw2ng3CfPZB9wJlIjHpe/MKUZqyIZkVIXCrwr1tIN+0Dzg==",
"license": "MIT",
"dependencies": {
"kind-of": "^6.0.3"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+3 -1
View File
@@ -2,7 +2,7 @@
"name": "video-site",
"private": true,
"license": "MIT",
"version": "0.1.0",
"version": "0.1.3",
"type": "module",
"scripts": {
"dev": "vite",
@@ -13,6 +13,8 @@
"test": "node --import tsx --test tests/*.test.ts"
},
"dependencies": {
"artplayer": "^5.4.0",
"hls.js": "^1.6.16",
"lucide-react": "0.453.0",
"react": "18.3.1",
"react-dom": "18.3.1",
+1 -1
View File
@@ -148,7 +148,7 @@ export function credentialHelp(kind: Kind, isEdit: boolean): string {
case "googledrive":
return `按 OpenList 在线 API 挂载,只需要 Google Drive refresh_token;保存时会自动刷新并保存 token。播放不走 302,会由后端带 Authorization 代理转发。${note}`;
case "localstorage":
return `服务器上的一个已有目录作为视频来源扫描。填写绝对路径,例如 /mnt/videos系统会读取该目录及子目录中的视频,并生成封面、预览视频和指纹${note}`;
return `填写服务器可访问的本地目录绝对路径,例如 /mnt/videos系统会扫描该目录及子目录中的视频文件和 .strm 文件;.strm 可指向 HTTP/HTTPS 直链,或指向本地存储根目录内的真实视频路径。Docker 部署时请填写容器内路径${note}`;
case "spider91":
return "91 爬虫会把定时抓取到的视频和封面先保存到本机,并作为一个视频来源接入站点;可按服务器网络情况单独配置代理。后续流水线会把较早的视频上传到你选择的 115 / PikPak / OneDrive 目标盘。";
default:
+76 -6
View File
@@ -1,4 +1,4 @@
import { ReactNode } from "react";
import { ReactNode, useEffect, useState } from "react";
import { TopBar } from "./TopBar";
import { MainNav } from "./MainNav";
import { SubNav } from "./SubNav";
@@ -7,14 +7,84 @@ import { BackToTop } from "./BackToTop";
type Props = {
children: ReactNode;
mobileAutoHideNav?: boolean;
};
export function AppShell({ children }: Props) {
const MOBILE_NAV_QUERY = "(max-width: 768px)";
const SCROLL_DELTA_THRESHOLD = 6;
const HIDE_AFTER_SCROLL_Y = 56;
export function AppShell({ children, mobileAutoHideNav = false }: Props) {
const [mobileNavHidden, setMobileNavHidden] = useState(false);
useEffect(() => {
if (!mobileAutoHideNav) {
setMobileNavHidden(false);
return;
}
const mediaQuery = window.matchMedia(MOBILE_NAV_QUERY);
let lastScrollY = Math.max(window.scrollY, 0);
let ticking = false;
const showNav = () => setMobileNavHidden(false);
const updateNavVisibility = () => {
ticking = false;
const currentScrollY = Math.max(window.scrollY, 0);
if (!mediaQuery.matches || currentScrollY <= 0) {
showNav();
lastScrollY = currentScrollY;
return;
}
const delta = currentScrollY - lastScrollY;
if (Math.abs(delta) < SCROLL_DELTA_THRESHOLD) return;
if (delta > 0 && currentScrollY > HIDE_AFTER_SCROLL_Y) {
setMobileNavHidden(true);
} else if (delta < 0) {
showNav();
}
lastScrollY = currentScrollY;
};
const handleScroll = () => {
if (ticking) return;
ticking = true;
window.requestAnimationFrame(updateNavVisibility);
};
const handleMediaChange = () => {
lastScrollY = Math.max(window.scrollY, 0);
showNav();
};
handleMediaChange();
window.addEventListener("scroll", handleScroll, { passive: true });
mediaQuery.addEventListener("change", handleMediaChange);
return () => {
window.removeEventListener("scroll", handleScroll);
mediaQuery.removeEventListener("change", handleMediaChange);
};
}, [mobileAutoHideNav]);
const className = [
"app-shell",
mobileAutoHideNav ? "app-shell--mobile-auto-hide-nav" : "",
mobileNavHidden ? "is-mobile-nav-hidden" : "",
].filter(Boolean).join(" ");
return (
<div className="app-shell">
<TopBar />
<MainNav />
<SubNav />
<div className={className}>
<div className="app-shell__nav-stack">
<TopBar />
<MainNav />
<SubNav />
</div>
<main className="app-shell__main">{children}</main>
<Footer />
<BackToTop />
+5 -7
View File
@@ -38,13 +38,11 @@ export function RecommendedRail({ videos }: Props) {
return (
<aside className="vd-rail" aria-label="推荐视频">
<header className="vd-rail__head">
<span className="vd-rail__head-bar" aria-hidden="true" />
<div className="vd-rail__head-text">
<h2 className="vd-rail__head-title"></h2>
<span className="vd-rail__head-sub">
· {videos.length}
</span>
</div>
<span className="vd-rail__head-icon" aria-hidden="true">
<span />
<span />
</span>
<h2 className="vd-rail__head-title"></h2>
</header>
<ul className="vd-rail__list">
{videos.map((v) => (
+19 -6
View File
@@ -1,4 +1,4 @@
import { useState } from "react";
import { useEffect, useState } from "react";
import { EyeOff, ThumbsDown, ThumbsUp } from "lucide-react";
import type { VideoDetail } from "@/types";
import { formatCount } from "@/lib/format";
@@ -12,11 +12,11 @@ type Props = {
/**
* 视频操作工具条。
* - 整体是一张浮起的圆角玻璃卡,比上一版的横线分隔更"成体"。
* - 点赞 + 点踩组成一个胶囊(中间一道竖线分隔),两侧分别带计数
* - 点赞 + 点踩是两个独立按钮
* - "不再显示" 单独成一个次要按钮,hover 时露出 danger 色。
*
* 功能没变:
* - 后端只有点赞接口POST /api/video/:id/like,点踩仅本地 state。
* - 后端只有点赞计数接口,点踩仅本地 state。
* - 失败回滚已经处理。
*/
export function VideoActions({ video, onHideVideo, hideSaving }: Props) {
@@ -25,11 +25,20 @@ export function VideoActions({ video, onHideVideo, hideSaving }: Props) {
const [bursting, setBursting] = useState(false);
const [liked, setLiked] = useState(false);
const [disliked, setDisliked] = useState(false);
const [likeSubmitted, setLikeSubmitted] = useState(false);
useEffect(() => {
setLikes(video.likes ?? 0);
setDislikes(video.dislikes ?? 0);
setBursting(false);
setLiked(false);
setDisliked(false);
setLikeSubmitted(false);
}, [video.id, video.likes, video.dislikes]);
async function handleLike() {
if (liked) return;
setLiked(true);
setLikes((n) => n + 1);
setBursting(true);
window.setTimeout(() => setBursting(false), 320);
@@ -38,6 +47,11 @@ export function VideoActions({ video, onHideVideo, hideSaving }: Props) {
setDislikes((n) => Math.max(0, n - 1));
}
if (likeSubmitted) return;
setLikeSubmitted(true);
setLikes((n) => n + 1);
try {
const res = await fetch(
`/api/video/${encodeURIComponent(video.id)}/like`,
@@ -51,6 +65,7 @@ export function VideoActions({ video, onHideVideo, hideSaving }: Props) {
} catch {
setLikes((n) => Math.max(0, n - 1));
setLiked(false);
setLikeSubmitted(false);
}
}
@@ -64,7 +79,6 @@ export function VideoActions({ video, onHideVideo, hideSaving }: Props) {
setDislikes((n) => n + 1);
if (liked) {
setLiked(false);
setLikes((n) => Math.max(0, n - 1));
}
}
@@ -83,7 +97,6 @@ export function VideoActions({ video, onHideVideo, hideSaving }: Props) {
<ThumbsUp size={18} fill={liked ? "currentColor" : "none"} />
<span className="vd-actions__count">{formatCount(likes)}</span>
</button>
<span className="vd-actions__divider" aria-hidden="true" />
<button
type="button"
className={`vd-actions__pill vd-actions__dislike${
+3 -3
View File
@@ -1,5 +1,5 @@
import { useMemo, useState } from "react";
import { Hash, Pencil, X } from "lucide-react";
import { Pencil, Tag, X } from "lucide-react";
import type { TagItem, VideoDetail } from "@/types";
type Props = {
@@ -17,7 +17,7 @@ type Props = {
* 视觉上和上一版的"两张分离卡"相比,整体感更强:
* - 一张大卡内分两个小区块,区块之间用细分隔线
* - 简介区块加 "简介" 标题前缀
* - 标签区块加 # 图标暗示
* - 标签区块加标签轮廓图标暗示
*/
export function VideoInfoPanel({
video,
@@ -99,7 +99,7 @@ export function VideoInfoPanel({
<div className="vd-info__tags">
<div className="vd-info__section-head">
<span className="vd-info__section-title">
<Hash size={14} aria-hidden="true" />
<Tag size={15} strokeWidth={2} aria-hidden="true" />
</span>
{onTagsChange && (
+21 -19
View File
@@ -1,3 +1,4 @@
import { CalendarDays, Clock3, Eye } from "lucide-react";
import type { VideoDetail } from "@/types";
import { formatCount } from "@/lib/format";
@@ -9,12 +10,11 @@ type Props = {
* 详情页标题块。
*
* 视觉:
* - 标题:大、粗、最高两行
* - meta:作者首字头像 + 名字 + 一组小胶囊(来源、画质、时长、观看数、发布时间)
* - meta:一组小胶囊(来源、画质、时长、观看数、发布时间)
* 每个胶囊有自己的语义色彩,避免传统 "·" 分隔列表的列表感。
* - 标题:大、粗、最高两行,位于 meta 下方
*/
export function VideoMetaHeader({ video }: Props) {
const author = (video.author ?? "").trim();
const source = (video.sourceLabel ?? "").trim();
const quality = (video.quality ?? "").trim();
const duration = (video.duration ?? "").trim();
@@ -23,20 +23,7 @@ export function VideoMetaHeader({ video }: Props) {
return (
<header className="vd-header">
<h1 className="vd-header__title" title={video.title}>
{video.title}
</h1>
<div className="vd-header__row">
{author && (
<div className="vd-author" aria-label={`作者 ${author}`}>
<span className="vd-author__avatar" aria-hidden="true">
{author.slice(0, 1)}
</span>
<span className="vd-author__name">{author}</span>
</div>
)}
<ul className="vd-meta" aria-label="视频信息">
{source && (
<li className="vd-meta__chip" data-tone={sourceKind || "neutral"}>
@@ -52,13 +39,28 @@ export function VideoMetaHeader({ video }: Props) {
{quality}
</li>
)}
{duration && <li className="vd-meta__chip">{duration}</li>}
<li className="vd-meta__chip">
{duration && (
<li className="vd-meta__chip vd-meta__chip--plain">
<Clock3 size={14} aria-hidden="true" />
{duration}
</li>
)}
<li className="vd-meta__chip vd-meta__chip--plain">
<Eye size={14} aria-hidden="true" />
<strong>{formatCount(video.views)}</strong>
</li>
{published && <li className="vd-meta__chip">{published}</li>}
{published && (
<li className="vd-meta__chip vd-meta__chip--plain">
<CalendarDays size={14} aria-hidden="true" />
{published}
</li>
)}
</ul>
</div>
<h1 className="vd-header__title" title={video.title}>
{video.title}
</h1>
</header>
);
}
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -105,7 +105,7 @@ export default function HomePage() {
const latest = latestVideos.slice(0, displayCount);
return (
<AppShell>
<AppShell mobileAutoHideNav>
<div className="container page-section">
<PromoStrip />
<SearchPanel />
+17 -3
View File
@@ -809,12 +809,25 @@ function ShortsSlide({
}
}, [isMarkedHidden]);
// 监听 video 的时长 / 进度 / 缓冲状态 / 音量物理键变化
// 监听 video 的时长 / 进度 / 缓冲状态 / 音量物理键变化
// MOUNT_RADIUS 会让第三屏以后的 slide 先以海报占位,之后才挂载 video;
// 因此这里必须跟随 shouldMount 重新绑定,否则后续视频没有 timeupdate 事件。
useEffect(() => {
if (!shouldMount) {
setDuration(0);
setCurrentTime(0);
setIsBuffering(false);
return;
}
const video = localRef.current;
if (!video) return;
const handleLoaded = () => {
if (Number.isFinite(video.duration)) setDuration(video.duration);
if (Number.isFinite(video.duration) && video.duration > 0) {
setDuration(video.duration);
} else {
setDuration(0);
}
if (!scrubbingRef.current) setCurrentTime(video.currentTime || 0);
};
const handleTime = () => {
// 拖动期间不要被 timeupdate 覆盖 UI
@@ -838,6 +851,7 @@ function ShortsSlide({
};
handleLoaded();
handleTime();
video.addEventListener("loadedmetadata", handleLoaded);
video.addEventListener("durationchange", handleLoaded);
video.addEventListener("timeupdate", handleTime);
@@ -860,7 +874,7 @@ function ShortsSlide({
video.removeEventListener("canplay", handlePlayingOrCanPlay);
video.removeEventListener("volumechange", handleVolumeChange);
};
}, [muted, volume, setMuted, setVolume]);
}, [shouldMount, item.id, muted, volume, setMuted, setVolume]);
// 长按 2 倍速:直接绑原生事件
useEffect(() => {
+69 -13
View File
@@ -84,14 +84,66 @@ export default function VideoDetailPage() {
if (loading) {
return (
<AppShell>
<AppShell mobileAutoHideNav>
<div className="vd-page">
<div className="vd-ambient" aria-hidden="true" />
<div className="container vd-page__inner">
<div className="vd-skeleton">
<div className="vd-skeleton__player" />
<div className="vd-skeleton__title" />
<div className="vd-skeleton__meta" />
<div
className="vd-layout vd-skeleton"
aria-busy="true"
aria-label="视频详情加载中"
>
<div className="vd-main">
<div className="vd-skeleton__player" />
<div className="vd-skeleton__summary">
<div className="vd-skeleton__chips">
<span className="vd-skeleton__chip vd-skeleton__chip--source" />
<span className="vd-skeleton__chip" />
<span className="vd-skeleton__chip vd-skeleton__chip--plain" />
<span className="vd-skeleton__chip vd-skeleton__chip--plain" />
</div>
<div className="vd-skeleton__title" />
<div className="vd-skeleton__actions">
<span />
<span />
<span />
</div>
</div>
<div className="vd-skeleton__info">
<span className="vd-skeleton__section-head" />
<span className="vd-skeleton__line" />
<span className="vd-skeleton__line vd-skeleton__line--short" />
<div className="vd-skeleton__tag-row">
<span />
<span />
<span />
</div>
</div>
</div>
<aside className="vd-rail vd-skeleton__rail">
<div className="vd-rail__head">
<span className="vd-rail__head-icon" aria-hidden="true">
<span />
<span />
</span>
<span className="vd-skeleton__rail-head" />
</div>
<ul className="vd-rail__list vd-skeleton__rail-list">
{Array.from({ length: 6 }).map((_, index) => (
<li key={index} className="vd-skeleton__rail-item">
<span className="vd-skeleton__rail-thumb" />
<span className="vd-skeleton__rail-body">
<span className="vd-skeleton__rail-title" />
<span className="vd-skeleton__rail-title vd-skeleton__rail-title--short" />
<span className="vd-skeleton__rail-meta" />
</span>
</li>
))}
</ul>
</aside>
</div>
</div>
</div>
@@ -101,7 +153,7 @@ export default function VideoDetailPage() {
if (!detail) {
return (
<AppShell>
<AppShell mobileAutoHideNav>
<div className="vd-page">
<div className="container vd-page__inner">
<div className="vd-empty"></div>
@@ -112,7 +164,7 @@ export default function VideoDetailPage() {
}
return (
<AppShell>
<AppShell mobileAutoHideNav>
<div className="vd-page">
{/* Ambient 背景层:用海报作模糊底色,叠加渐变过渡到页面背景 */}
<div
@@ -131,21 +183,25 @@ export default function VideoDetailPage() {
<div className="vd-player-wrap">
<div className="vd-player">
<VideoPlayer
id={detail.id}
src={detail.videoSrc}
poster={detail.poster}
previewSrc={detail.previewSrc}
title={detail.title}
onFirstPlay={handleFirstPlay}
/>
</div>
</div>
<VideoMetaHeader video={detail} />
<section className="vd-summary" aria-label="当前视频">
<VideoMetaHeader video={detail} />
<VideoActions
video={detail}
onHideVideo={handleHideVideo}
hideSaving={hideSaving}
/>
<VideoActions
video={detail}
onHideVideo={handleHideVideo}
hideSaving={hideSaving}
/>
</section>
<VideoInfoPanel
video={detail}
+4
View File
@@ -10,6 +10,10 @@
font-size: var(--font-base);
}
.app-shell__nav-stack {
flex: 0 0 auto;
}
.app-shell__main {
flex: 1;
width: 100%;
+18
View File
@@ -179,6 +179,24 @@
/* ----- 响应式 ----- */
@media (max-width: 768px) {
.app-shell--mobile-auto-hide-nav .app-shell__nav-stack {
position: sticky;
top: 0;
z-index: var(--z-nav);
transform: translateY(0);
transition: transform 220ms var(--ease-out);
will-change: transform;
}
.app-shell--mobile-auto-hide-nav.is-mobile-nav-hidden .app-shell__nav-stack {
transform: translateY(-100%);
}
.app-shell--mobile-auto-hide-nav .main-nav {
position: relative;
z-index: auto;
}
.main-nav__inner {
height: 56px;
gap: var(--space-3);
+1 -5
View File
@@ -392,11 +392,7 @@
line-height: 1.4;
font-weight: 600;
color: #fff;
/* 多层阴影叠加:近距离锐边 + 远距离弥散,浅色视频上也清楚 */
text-shadow:
0 1px 1px rgba(0, 0, 0, 0.9),
0 2px 5px rgba(0, 0, 0, 0.8),
0 4px 15px rgba(0, 0, 0, 0.6);
text-shadow: none;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
File diff suppressed because it is too large Load Diff
+12
View File
@@ -31,6 +31,18 @@ test("shorts progress dragging uses immediate pointer state", () => {
assert.match(shortsPageSource, /onLostPointerCapture=\{handleProgressPointerEnd\}/);
});
test("shorts progress listeners rebind when deferred videos mount", () => {
assert.match(
shortsPageSource,
/MOUNT_RADIUS 会让第三屏以后的 slide 先以海报占位/
);
assert.match(shortsPageSource, /if \(!shouldMount\) \{\s*setDuration\(0\);\s*setCurrentTime\(0\);/);
assert.match(
shortsPageSource,
/\}, \[shouldMount, item\.id, muted, volume, setMuted, setVolume\]\);/
);
});
test("shorts fullscreen changes preserve the active slide", () => {
assert.match(shortsPageSource, /const activeIndexRef = useRef\(0\)/);
assert.match(shortsPageSource, /const ignoreIntersectionUntilRef = useRef\(0\)/);
+33
View File
@@ -0,0 +1,33 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
const actionsSource = readFileSync(
new URL("../src/components/VideoActions.tsx", import.meta.url),
"utf8"
);
const detailCss = readFileSync(
new URL("../src/styles/video-detail.css", import.meta.url),
"utf8"
);
test("detail dislike does not locally decrement persisted likes", () => {
const match = /function handleDislike\(\) \{([\s\S]*?)\n return \(/.exec(
actionsSource
);
assert.ok(match, "handleDislike block should be present");
assert.match(match[1], /setDisliked\(true\)/);
assert.doesNotMatch(match[1], /setLikes/);
});
test("detail like and dislike buttons are visually separated", () => {
assert.doesNotMatch(actionsSource, /vd-actions__divider/);
assert.match(
detailCss,
/\.vd-actions__group\s*\{[^}]*gap:\s*var\(--space-2\)/s
);
assert.match(
detailCss,
/\.vd-actions__pill\s*\{[^}]*border:\s*1px solid var\(--border-subtle\)[^}]*border-radius:\s*var\(--radius-sm\)/s
);
});
+155
View File
@@ -0,0 +1,155 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
const detailCss = readFileSync(
new URL("../src/styles/video-detail.css", import.meta.url),
"utf8"
);
const playerSource = readFileSync(
new URL("../src/components/VideoPlayer.tsx", import.meta.url),
"utf8"
);
const detailPageSource = readFileSync(
new URL("../src/pages/VideoDetailPage.tsx", import.meta.url),
"utf8"
);
test("detail player poster uses full-frame contain scaling", () => {
assert.match(
detailCss,
/\.video-player \.art-poster\s*\{[^}]*background-position:\s*center[^}]*background-repeat:\s*no-repeat[^}]*background-size:\s*contain/s
);
});
test("detail player does not keep playback resume state", () => {
assert.doesNotMatch(playerSource, /ResumePrompt/);
assert.doesNotMatch(playerSource, /PlaybackRecord/);
assert.doesNotMatch(playerSource, /PLAYBACK_KEY_PREFIX/);
assert.doesNotMatch(playerSource, /maybeOfferResume/);
assert.doesNotMatch(playerSource, /savePlaybackRecord/);
assert.doesNotMatch(playerSource, /clearPlaybackRecord/);
assert.doesNotMatch(playerSource, /video-player__resume/);
assert.doesNotMatch(detailCss, /video-player__resume/);
});
test("detail loading skeleton matches current desktop video page layout", () => {
assert.match(detailPageSource, /className="vd-layout vd-skeleton"/);
assert.match(detailPageSource, /className="vd-skeleton__summary"/);
assert.match(detailPageSource, /className="vd-skeleton__info"/);
assert.match(detailPageSource, /className="vd-rail vd-skeleton__rail"/);
assert.match(detailPageSource, /Array\.from\(\{ length: 6 \}\)/);
assert.doesNotMatch(detailPageSource, /className="vd-skeleton__meta"/);
assert.match(
detailCss,
/\.vd-skeleton__player\s*\{[^}]*aspect-ratio:\s*16 \/ 9[^}]*border-radius:\s*0/s
);
assert.match(
detailCss,
/\.vd-skeleton__summary,\s*\.vd-skeleton__info\s*\{[^}]*border:\s*1px solid var\(--border-default\)[^}]*border-radius:\s*var\(--radius-md\)/s
);
assert.match(
detailCss,
/\.vd-skeleton__rail-item\s*\{[^}]*grid-template-columns:\s*150px minmax\(0,\s*1fr\)/s
);
assert.doesNotMatch(
detailCss,
/\.vd-skeleton__player\s*\{[^}]*box-shadow:\s*var\(--shadow-lg\)/s
);
});
test("detail video title uses a restrained size", () => {
assert.match(
detailCss,
/\.vd-header__title\s*\{[^}]*font-size:\s*var\(--font-xl\)[^}]*line-height:\s*1\.34/s
);
assert.doesNotMatch(
detailCss,
/\.vd-header__title\s*\{[^}]*font-size:\s*var\(--font-2xl\)/s
);
assert.match(
detailCss,
/@media \(max-width:\s*480px\)\s*\{[\s\S]*\.vd-header__title\s*\{[^}]*font-size:\s*var\(--font-base\)/s
);
});
test("detail player uses custom mobile gestures instead of ArtPlayer native gestures", () => {
assert.match(playerSource, /gesture:\s*false/);
assert.match(playerSource, /fastForward:\s*false/);
assert.match(playerSource, /function bindMobilePlayerGestures/);
assert.match(playerSource, /let suppressNextClick = false/);
assert.match(playerSource, /endPress\(true\)/);
assert.match(playerSource, /event\.stopImmediatePropagation\(\)/);
assert.match(playerSource, /addEventListener\("click", handleClick, true\)/);
assert.match(playerSource, /state\.mode = "seek"/);
assert.match(playerSource, /state\.side === "right" \? "volume" : "brightness"/);
assert.doesNotMatch(playerSource, /function isPlayerLandscapeExpanded/);
assert.doesNotMatch(playerSource, /getEffectivePlayerOrientation\(art\) === "landscape"/);
assert.match(playerSource, /if \(!isPlayerExpanded\(art\)\) \{\s*resetGesture\(\);/);
assert.match(playerSource, /if \(!isPlayerExpanded\(art\)\) return;\s*onGestureHud\(seekGestureLabel/);
assert.match(playerSource, /const FAST_RATE_CLASS = "art-fast-rate-active"/);
assert.match(playerSource, /const FAST_RATE_HINT_CLASS = "video-player__art-rate-hint"/);
assert.match(playerSource, /const PLAYER_GESTURE_HUD_CLASS = "video-player__art-gesture-hud"/);
assert.match(playerSource, /setPlayerFastRateHint\(art, active\)/);
assert.match(playerSource, /player\.appendChild\(hint\)/);
assert.match(playerSource, /showPlayerGestureHud\(art, "volume", formatPercent\(normalized\)\)/);
assert.match(playerSource, /showPlayerGestureHud\(art, "brightness", formatBrightnessPercent\(nextBrightness\)\)/);
assert.match(playerSource, /stroke-width="1\.7"/);
assert.match(playerSource, /M15\.4 9\.2a4\.2 4\.2 0 0 1 0 5\.6/);
assert.match(playerSource, /M4\.8 9\.7h3l4\.3-3\.6v11\.8l-4\.3-3\.6h-3/);
assert.doesNotMatch(playerSource, /stroke-width="2\.2"/);
assert.doesNotMatch(playerSource, /onGestureHud\(`音量 /);
assert.doesNotMatch(playerSource, /onGestureHud\(`亮度 /);
assert.match(playerSource, /fullscreen:\s*true/);
assert.match(playerSource, /fullscreenWeb:\s*!enableOrientationControl/);
assert.doesNotMatch(playerSource, /addTextTrack\("captions", "Playback rate"/);
assert.doesNotMatch(playerSource, /new VTTCue\(/);
assert.doesNotMatch(playerSource, /onGestureHud\(`\$\{FAST_RATE\}x`/);
assert.match(playerSource, /addEventListener\("touchmove", handleTouchMove, \{ passive: false \}\)/);
});
test("detail player fullscreen long-press rate hint lives inside ArtPlayer", () => {
assert.match(
detailCss,
/\.video-player__rate-hint,\s*\.video-player__art-rate-hint\s*\{[\s\S]*position:\s*absolute[\s\S]*top:\s*12px/s
);
assert.match(
detailCss,
/\.video-player__art-rate-hint\s*\{[^}]*z-index:\s*130/s
);
assert.match(
detailCss,
/\.art-video-player\.art-fullscreen \.video-player__art-rate-hint,[\s\S]*\.art-video-player\.art-fullscreen-web \.video-player__art-rate-hint,[\s\S]*position:\s*fixed/s
);
});
test("detail player mobile brightness gesture only filters the video surface", () => {
assert.match(
detailCss,
/\.video-player \.art-video,\s*\.video-player \.art-poster\s*\{[^}]*filter:\s*brightness\(var\(--video-player-brightness, 1\)\)/s
);
assert.match(
detailCss,
/@media \(hover: none\) and \(pointer: coarse\)\s*\{[\s\S]*\.video-player \.art-video-player,[\s\S]*touch-action:\s*pan-y/s
);
assert.match(
detailCss,
/\.video-player \.art-video-player\.art-fullscreen,[\s\S]*\.video-player \.art-video-player\.art-fullscreen-web,[\s\S]*touch-action:\s*none/s
);
assert.match(
detailCss,
/\.video-player__art-gesture-hud\s*\{[^}]*top:\s*16%[^}]*background:\s*rgba\(18,\s*18,\s*20,\s*0\.8\)[^}]*font-size:\s*18px/s
);
assert.match(
detailCss,
/\.video-player__art-gesture-hud-icon\s*\{[^}]*width:\s*18px[^}]*height:\s*18px[^}]*transform:\s*translateY\(-1px\)/s
);
assert.match(
detailCss,
/\.video-player__art-gesture-hud-icon svg\s*\{[^}]*width:\s*18px[^}]*height:\s*18px/s
);
assert.match(
detailCss,
/\.art-video-player\.art-fullscreen \.video-player__art-gesture-hud,[\s\S]*\.art-video-player\.art-manual-orientation \.video-player__art-gesture-hud\s*\{[^}]*position:\s*fixed/s
);
});