mirror of
https://github.com/nianzhibai/91.git
synced 2026-06-15 08:45:41 +08:00
Support local STRM files
This commit is contained in:
@@ -33,7 +33,7 @@ scanner:
|
|||||||
# 单次扫描每家网盘目录递归层数上限
|
# 单次扫描每家网盘目录递归层数上限
|
||||||
max_depth: 5
|
max_depth: 5
|
||||||
# 被扫描的扩展名
|
# 被扫描的扩展名
|
||||||
video_extensions: [".mp4", ".mkv", ".mov", ".webm", ".avi"]
|
video_extensions: [".mp4", ".mkv", ".mov", ".webm", ".avi", ".strm"]
|
||||||
|
|
||||||
nightly:
|
nightly:
|
||||||
# 凌晨流水线触发整点(0-23),默认 1 即每天 01:00。流程:
|
# 凌晨流水线触发整点(0-23),默认 1 即每天 01:00。流程:
|
||||||
@@ -80,5 +80,7 @@ preview:
|
|||||||
# name: "本地视频目录"
|
# name: "本地视频目录"
|
||||||
# root_id: "/"
|
# root_id: "/"
|
||||||
# params:
|
# params:
|
||||||
|
# # Docker 部署时这里和 .strm 里的绝对路径都必须使用容器内路径。
|
||||||
|
# # 例如宿主机 /mnt/videos 挂载为 /media,就填写 /media。
|
||||||
# path: "/mnt/videos"
|
# path: "/mnt/videos"
|
||||||
drives: []
|
drives: []
|
||||||
|
|||||||
@@ -16,6 +16,11 @@ const (
|
|||||||
DefaultAdminPassword = "admin123"
|
DefaultAdminPassword = "admin123"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
legacyDefaultVideoExtensions = []string{".mp4", ".mkv", ".mov", ".webm", ".avi"}
|
||||||
|
defaultVideoExtensions = []string{".mp4", ".mkv", ".mov", ".webm", ".avi", ".strm"}
|
||||||
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Server Server `yaml:"server"`
|
Server Server `yaml:"server"`
|
||||||
Storage Storage `yaml:"storage"`
|
Storage Storage `yaml:"storage"`
|
||||||
@@ -247,7 +252,9 @@ func (c *Config) applyDefaults() {
|
|||||||
c.Scanner.MaxDepth = 5
|
c.Scanner.MaxDepth = 5
|
||||||
}
|
}
|
||||||
if len(c.Scanner.VideoExtensions) == 0 {
|
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 == "" {
|
if c.Preview.FFmpegPath == "" {
|
||||||
c.Preview.FFmpegPath = "ffmpeg"
|
c.Preview.FFmpegPath = "ffmpeg"
|
||||||
@@ -276,3 +283,19 @@ func (c *Config) applyDefaults() {
|
|||||||
c.Nightly.CronHour = 1
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package config
|
|||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -50,3 +51,64 @@ storage:
|
|||||||
t.Fatalf("db path = %q, want preserved value", cfg.Storage.DBPath)
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -17,6 +18,8 @@ import (
|
|||||||
|
|
||||||
const Kind = "localstorage"
|
const Kind = "localstorage"
|
||||||
|
|
||||||
|
const maxSTRMBytes = 64 * 1024
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
ID string
|
ID string
|
||||||
RootPath string
|
RootPath string
|
||||||
@@ -122,7 +125,13 @@ func (d *Driver) StreamURL(ctx context.Context, fileID string) (*drives.StreamLi
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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 nil, os.ErrNotExist
|
||||||
}
|
}
|
||||||
return &drives.StreamLink{
|
return &drives.StreamLink{
|
||||||
@@ -131,6 +140,115 @@ func (d *Driver) StreamURL(ctx context.Context, fileID string) (*drives.StreamLi
|
|||||||
}, nil
|
}, 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) {
|
func (d *Driver) Upload(context.Context, string, string, io.Reader, int64) (string, error) {
|
||||||
return "", drives.ErrNotSupported
|
return "", drives.ErrNotSupported
|
||||||
}
|
}
|
||||||
@@ -177,6 +295,11 @@ func (d *Driver) pathForID(id string) (string, string, error) {
|
|||||||
if !pathWithinRoot(root, p) {
|
if !pathWithinRoot(root, p) {
|
||||||
return "", "", errors.New("localstorage: path escapes root")
|
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
|
return p, rel, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,6 +311,26 @@ func pathWithinRoot(root, path string) bool {
|
|||||||
return rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(os.PathSeparator)))
|
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 {
|
func localStoragePathHint(configured string) string {
|
||||||
cwd, _ := os.Getwd()
|
cwd, _ := os.Getwd()
|
||||||
parts := []string{}
|
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) {
|
func TestStreamURLRejectsEscapingID(t *testing.T) {
|
||||||
drv := New(Config{ID: "local", RootPath: t.TempDir()})
|
drv := New(Config{ID: "local", RootPath: t.TempDir()})
|
||||||
escaped := base64.RawURLEncoding.EncodeToString([]byte("../secret.mp4"))
|
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) {
|
func TestScannerPersistsLocalStorageVideo(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
@@ -138,3 +330,10 @@ func TestScannerPersistsLocalStorageVideo(t *testing.T) {
|
|||||||
t.Fatalf("video = %#v, want local drive video in collection", got)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -148,7 +148,7 @@ export function credentialHelp(kind: Kind, isEdit: boolean): string {
|
|||||||
case "googledrive":
|
case "googledrive":
|
||||||
return `按 OpenList 在线 API 挂载,只需要 Google Drive refresh_token;保存时会自动刷新并保存 token。播放不走 302,会由后端带 Authorization 代理转发。${note}`;
|
return `按 OpenList 在线 API 挂载,只需要 Google Drive refresh_token;保存时会自动刷新并保存 token。播放不走 302,会由后端带 Authorization 代理转发。${note}`;
|
||||||
case "localstorage":
|
case "localstorage":
|
||||||
return `把服务器上的一个已有目录作为视频来源扫描。填写绝对路径,例如 /mnt/videos;系统会读取该目录及子目录中的视频,并生成封面、预览视频和指纹。${note}`;
|
return `填写服务器可访问的本地目录绝对路径,例如 /mnt/videos。系统会扫描该目录及子目录中的视频文件和 .strm 文件;.strm 可指向 HTTP/HTTPS 直链,或指向本地存储根目录内的真实视频路径。Docker 部署时请填写容器内路径。${note}`;
|
||||||
case "spider91":
|
case "spider91":
|
||||||
return "91 爬虫会把定时抓取到的视频和封面先保存到本机,并作为一个视频来源接入站点;可按服务器网络情况单独配置代理。后续流水线会把较早的视频上传到你选择的 115 / PikPak / OneDrive 目标盘。";
|
return "91 爬虫会把定时抓取到的视频和封面先保存到本机,并作为一个视频来源接入站点;可按服务器网络情况单独配置代理。后续流水线会把较早的视频上传到你选择的 115 / PikPak / OneDrive 目标盘。";
|
||||||
default:
|
default:
|
||||||
|
|||||||
Reference in New Issue
Block a user