diff --git a/backend/config.example.yaml b/backend/config.example.yaml index ff96cd3..8318de2 100644 --- a/backend/config.example.yaml +++ b/backend/config.example.yaml @@ -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: [] diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 1e80483..74a4295 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -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 +} diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go index eeaee1d..9d35fe9 100644 --- a/backend/internal/config/config_test.go +++ b/backend/internal/config/config_test.go @@ -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 +} diff --git a/backend/internal/drives/localstorage/driver.go b/backend/internal/drives/localstorage/driver.go index e2c2cf3..a37c1a9 100644 --- a/backend/internal/drives/localstorage/driver.go +++ b/backend/internal/drives/localstorage/driver.go @@ -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{} diff --git a/backend/internal/drives/localstorage/driver_test.go b/backend/internal/drives/localstorage/driver_test.go index 56c4f3d..8cd6d37 100644 --- a/backend/internal/drives/localstorage/driver_test.go +++ b/backend/internal/drives/localstorage/driver_test.go @@ -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) + } +} diff --git a/src/admin/drive/constants.ts b/src/admin/drive/constants.ts index c8f637e..9593803 100644 --- a/src/admin/drive/constants.ts +++ b/src/admin/drive/constants.ts @@ -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: