" 拆分配额,
+ // 否则单个匿名访客就能占满所有匿名转码配额。
+ OwnerID string `json:"-"`
+
+ // 运行时
+ Status JobStatus `json:"status"`
+ WorkerID string `json:"worker_id"`
+ CreatedAt time.Time `json:"created_at"`
+ StartedAt time.Time `json:"started_at"`
+ FinishedAt time.Time `json:"finished_at"`
+ LastAccessAt time.Time `json:"last_access_at"` // 播放端最后一次请求 segment/playlist 的时间
+ Error string `json:"error,omitempty"`
+
+ // 内部:用于"边转边播"——首切片就绪后关闭该 channel
+ readyOnce sync.Once
+ readyCh chan struct{}
+ mu sync.RWMutex
+}
+
+// NewJob 构造任务(自动初始化内部 channel)
+func NewJob() *Job {
+ now := time.Now()
+ return &Job{
+ Status: JobPending,
+ CreatedAt: now,
+ LastAccessAt: now,
+ readyCh: make(chan struct{}),
+ }
+}
+
+// Touch 更新最后访问时间(播放端每次请求 segment/playlist 时调用)
+func (j *Job) Touch() {
+ j.mu.Lock()
+ j.LastAccessAt = time.Now()
+ j.mu.Unlock()
+}
+
+// GetLastAccess 获取最后访问时间(线程安全)
+func (j *Job) GetLastAccess() time.Time {
+ j.mu.RLock()
+ defer j.mu.RUnlock()
+ return j.LastAccessAt
+}
+
+// GetStatus 获取当前状态(线程安全)
+func (j *Job) GetStatus() JobStatus {
+ j.mu.RLock()
+ defer j.mu.RUnlock()
+ return j.Status
+}
+
+// MarkReady 标记任务已可播放(首切片就绪)
+func (j *Job) MarkReady() {
+ j.readyOnce.Do(func() {
+ j.mu.Lock()
+ if j.Status == JobRunning || j.Status == JobPending {
+ j.Status = JobReady
+ }
+ j.mu.Unlock()
+ close(j.readyCh)
+ })
+}
+
+// WaitReady 阻塞等待首切片就绪
+func (j *Job) WaitReady() <-chan struct{} { return j.readyCh }
+
+// Worker 节点信息
+type Worker struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Version string `json:"version"`
+ Capacity int `json:"capacity"` // 最大并发任务数
+ HWAccel []string `json:"hwaccel"` // 支持的硬件加速
+ CodecsDecode []string `json:"codecs_decode"` // 可解码
+ CodecsEncode []string `json:"codecs_encode"` // 可编码
+ MaxResolution string `json:"max_resolution"` // 最大分辨率
+ Tags []string `json:"tags"`
+ Endpoint string `json:"endpoint,omitempty"` // 反向连接地址(可选)
+
+ // 运行时
+ Load float64 `json:"load"` // 0~1
+ Running []string `json:"running"` // 当前 job_ids
+ FreeSlots int `json:"free_slots"`
+ LastSeen time.Time `json:"last_seen"`
+ RegisteredAt time.Time `json:"registered_at"`
+ Local bool `json:"local"` // 是否本地内置 Worker
+}
diff --git a/pkg/torrent/bencode.go b/pkg/torrent/bencode.go
index 2d4fd782..6e35690b 100644
--- a/pkg/torrent/bencode.go
+++ b/pkg/torrent/bencode.go
@@ -145,15 +145,24 @@ func bencodeEncodeOrderedDict(w io.Writer, d OrderedDict) error {
// BencodeDecode 从字节数组解码 bencode 数据
func BencodeDecode(data []byte) (interface{}, error) {
+ if int64(len(data)) > DefaultMaxSeedSize {
+ return nil, fmt.Errorf("bencode: input exceeds %d bytes", DefaultMaxSeedSize)
+ }
reader := bytes.NewReader(data)
- val, err := bencodeDecodeValue(reader)
+ val, err := bencodeDecodeValue(reader, 0)
if err != nil {
return nil, err
}
+ if reader.Len() != 0 {
+ return nil, fmt.Errorf("bencode: trailing data")
+ }
return val, nil
}
-func bencodeDecodeValue(r *bytes.Reader) (interface{}, error) {
+func bencodeDecodeValue(r *bytes.Reader, depth int) (interface{}, error) {
+ if depth > DefaultParseLimits().MaxDepth {
+ return nil, fmt.Errorf("bencode: nesting depth exceeds limit")
+ }
b, err := r.ReadByte()
if err != nil {
return nil, err
@@ -163,9 +172,9 @@ func bencodeDecodeValue(r *bytes.Reader) (interface{}, error) {
case b == 'i':
return bencodeDecodeInt(r)
case b == 'l':
- return bencodeDecodeList(r)
+ return bencodeDecodeList(r, depth+1)
case b == 'd':
- return bencodeDecodeDict(r)
+ return bencodeDecodeDict(r, depth+1)
case b >= '0' && b <= '9':
r.UnreadByte()
return bencodeDecodeString(r)
@@ -206,10 +215,14 @@ func bencodeDecodeString(r *bytes.Reader) ([]byte, error) {
if err != nil {
return nil, fmt.Errorf("bencode: invalid string length: %v", err)
}
- if length < 0 || length > 100*1024*1024 {
- return nil, fmt.Errorf("bencode: string length out of bounds: %d", length)
+ // A single string can never exceed the whole input, which BencodeDecode
+ // already caps at DefaultMaxSeedSize. Deriving the bound from the same
+ // constant keeps the constraint self-consistent instead of maintaining a
+ // second, unreachable 100MB ceiling.
+ if length < 0 || length > DefaultMaxSeedSize {
+ return nil, fmt.Errorf("bencode: string length out of bounds: %d (limit %d)", length, DefaultMaxSeedSize)
}
- // Safe to convert to int: bounds check above ensures length <= 100MB which fits in int32
+ // Bounded by DefaultMaxSeedSize, so the int conversion cannot truncate.
data := make([]byte, int(length))
_, err = io.ReadFull(r, data)
if err != nil {
@@ -218,9 +231,12 @@ func bencodeDecodeString(r *bytes.Reader) ([]byte, error) {
return data, nil
}
-func bencodeDecodeList(r *bytes.Reader) ([]interface{}, error) {
+func bencodeDecodeList(r *bytes.Reader, depth int) ([]interface{}, error) {
var list []interface{}
for {
+ if len(list) >= DefaultMaxSeedFiles*4 {
+ return nil, fmt.Errorf("bencode: list item limit exceeded")
+ }
b, err := r.ReadByte()
if err != nil {
return nil, err
@@ -228,8 +244,8 @@ func bencodeDecodeList(r *bytes.Reader) ([]interface{}, error) {
if b == 'e' {
return list, nil
}
- r.UnreadByte()
- val, err := bencodeDecodeValue(r)
+ _ = r.UnreadByte()
+ val, err := bencodeDecodeValue(r, depth)
if err != nil {
return nil, err
}
@@ -237,9 +253,12 @@ func bencodeDecodeList(r *bytes.Reader) ([]interface{}, error) {
}
}
-func bencodeDecodeDict(r *bytes.Reader) (map[string]interface{}, error) {
+func bencodeDecodeDict(r *bytes.Reader, depth int) (map[string]interface{}, error) {
dict := make(map[string]interface{})
for {
+ if len(dict) >= DefaultMaxSeedFiles*4 {
+ return nil, fmt.Errorf("bencode: dictionary item limit exceeded")
+ }
b, err := r.ReadByte()
if err != nil {
return nil, err
@@ -247,12 +266,12 @@ func bencodeDecodeDict(r *bytes.Reader) (map[string]interface{}, error) {
if b == 'e' {
return dict, nil
}
- r.UnreadByte()
+ _ = r.UnreadByte()
keyBytes, err := bencodeDecodeString(r)
if err != nil {
return nil, err
}
- val, err := bencodeDecodeValue(r)
+ val, err := bencodeDecodeValue(r, depth)
if err != nil {
return nil, err
}
diff --git a/pkg/torrent/generate.go b/pkg/torrent/generate.go
index 566cad86..9e10aa46 100644
--- a/pkg/torrent/generate.go
+++ b/pkg/torrent/generate.go
@@ -1,9 +1,10 @@
package torrent
import (
+ "fmt"
"io"
"os"
- "strings"
+ "path"
)
// GenerateFromFile 从文件路径生成通用的 torrent 文件(不含 CAS 扩展)
@@ -30,7 +31,7 @@ func GenerateFromReader(reader io.Reader, fileName string, fileSize int64, piece
pieceSize = DefaultPieceSize
}
- hw := NewHashWriter(pieceSize, pieceSize)
+ hw := NewHashWriter(pieceSize, pieceSize, fileSize)
buf := make([]byte, 32*1024)
for {
@@ -64,7 +65,7 @@ func GenerateFromReaderWithCAS(reader io.Reader, fileName string, fileSize int64
pieceSize = DefaultPieceSize
}
- hw := NewHashWriter(pieceSize, pieceSize)
+ hw := NewHashWriter(pieceSize, pieceSize, fileSize)
buf := make([]byte, 32*1024)
for {
@@ -85,12 +86,8 @@ func GenerateFromReaderWithCAS(reader io.Reader, fileName string, fileSize int64
sliceMD5s := hw.GetSliceMD5s()
pieceHashes := hw.GetPieceHashes()
- // 计算 sliceMD5
- sliceMD5 := fileMD5
- if len(sliceMD5s) > 1 {
- joined := strings.Join(sliceMD5s, "\n")
- sliceMD5 = strings.ToUpper(GetMD5Str(joined))
- }
+ // 计算 sliceMD5(统一走规范实现)
+ sliceMD5 := SliceMD5FromPieces(sliceMD5s, fileMD5)
t := NewTorrent(fileName, fileSize, fileMD5)
t.Info.PieceLength = pieceSize
@@ -100,7 +97,7 @@ func GenerateFromReaderWithCAS(reader io.Reader, fileName string, fileSize int64
SliceMD5: sliceMD5,
SliceMD5s: sliceMD5s,
SliceSize: pieceSize,
- Cloud: "189",
+ Cloud: Cloud189,
})
return t.Encode()
@@ -121,3 +118,27 @@ func GenerateFromFileWithCAS(filePath string) ([]byte, error) {
return GenerateFromReaderWithCAS(f, info.Name(), info.Size(), DefaultPieceSize)
}
+
+// GenerateSeedFromReader computes the complete OSS hash matrix in one stream pass.
+func GenerateSeedFromReader(reader io.Reader, filePath string, expectedSize, pieceSize int64, createdBy string) (*Seed, error) {
+ if pieceSize <= 0 {
+ pieceSize = DefaultPieceSize
+ }
+ if err := validateRelativeSeedPath(filePath); err != nil {
+ return nil, err
+ }
+ hw := NewHashWriter(pieceSize, pieceSize, expectedSize)
+ if _, err := CopyAndHash(nil, reader, hw); err != nil {
+ return nil, err
+ }
+ hw.Finish()
+ if expectedSize >= 0 && hw.GetTotalWritten() != expectedSize {
+ return nil, fmt.Errorf("stream size mismatch: read %d bytes, expected %d", hw.GetTotalWritten(), expectedSize)
+ }
+ seed := NewSeed(path.Base(filePath), createdBy, pieceSize)
+ seed.Files = []SeedFile{hw.BuildSeedFile(filePath, "")}
+ if err := ValidateSeed(seed, DefaultParseLimits()); err != nil {
+ return nil, err
+ }
+ return seed, nil
+}
diff --git a/pkg/torrent/hash_writer.go b/pkg/torrent/hash_writer.go
index a62f42c6..44fda0f6 100644
--- a/pkg/torrent/hash_writer.go
+++ b/pkg/torrent/hash_writer.go
@@ -3,11 +3,14 @@ package torrent
import (
"crypto/md5"
"crypto/sha1"
+ "crypto/sha256"
"encoding/hex"
"fmt"
"hash"
"io"
"strings"
+
+ hash_extend "github.com/OpenListTeam/OpenList/v4/pkg/utils/hash"
)
// HashWriter 同时计算文件的 MD5、分片 MD5 和 SHA-1 piece hash
@@ -15,15 +18,24 @@ import (
type HashWriter struct {
// 整文件 MD5
fileMD5 hash.Hash
+ // fileSHA1 and fileSHA256 complete the portable full-file matrix.
+ fileSHA1 hash.Hash
+ fileSHA256 hash.Hash
+ // fileGCID 用于迅雷、PikPak 等
+ fileGCID hash.Hash
// 当前分片 MD5
sliceMD5 hash.Hash
- // 当前 piece 的 SHA-1
- pieceSHA1 hash.Hash
+ // Per-piece hashers are updated in the same pass as whole-file hashes.
+ pieceMD5 hash.Hash
+ pieceSHA1 hash.Hash
+ pieceSHA256 hash.Hash
// 分片大小(默认 10MB)
sliceSize int64
// piece 大小(与 sliceSize 相同,保持对齐)
pieceSize int64
+ // 文件总大小(用于 GCID 初始化)
+ fileSize int64
// 当前分片已写入字节数
sliceWritten int64
@@ -34,14 +46,19 @@ type HashWriter struct {
// 每个分片的 MD5(大写十六进制)
sliceMD5Hexs []string
- // 所有 piece 的 SHA-1 哈希拼接
+ // all standard BitTorrent SHA-1 piece hashes concatenated
pieceHashes []byte
+ // portable per-file piece matrix
+ pieceMD5Hexs []string
+ pieceSHA1Hexs []string
+ pieceSHA256Hexs []string
}
// NewHashWriter 创建一个新的 HashWriter
// sliceSize: CAS 分片大小(通常 10MB)
// pieceSize: BT piece 大小(设为与 sliceSize 相同以保持对齐)
-func NewHashWriter(sliceSize, pieceSize int64) *HashWriter {
+// fileSize: 文件总大小(用于 GCID 初始化,0 表示未知)
+func NewHashWriter(sliceSize, pieceSize, fileSize int64) *HashWriter {
if sliceSize <= 0 {
sliceSize = DefaultPieceSize
}
@@ -49,17 +66,23 @@ func NewHashWriter(sliceSize, pieceSize int64) *HashWriter {
pieceSize = DefaultPieceSize
}
return &HashWriter{
- fileMD5: md5.New(),
- sliceMD5: md5.New(),
- pieceSHA1: sha1.New(),
- sliceSize: sliceSize,
- pieceSize: pieceSize,
+ fileMD5: md5.New(),
+ fileSHA1: sha1.New(),
+ fileSHA256: sha256.New(),
+ fileGCID: hash_extend.GCID.NewFunc(fileSize),
+ sliceMD5: md5.New(),
+ pieceMD5: md5.New(),
+ pieceSHA1: sha1.New(),
+ pieceSHA256: sha256.New(),
+ sliceSize: sliceSize,
+ pieceSize: pieceSize,
+ fileSize: fileSize,
}
}
// NewDefaultHashWriter 创建默认的 HashWriter(10MB 分片)
func NewDefaultHashWriter() *HashWriter {
- return NewHashWriter(DefaultPieceSize, DefaultPieceSize)
+ return NewHashWriter(DefaultPieceSize, DefaultPieceSize, 0)
}
// Write 实现 io.Writer 接口
@@ -76,12 +99,15 @@ func (hw *HashWriter) Write(p []byte) (n int, err error) {
chunk := p[offset : offset+int(canWrite)]
- // 写入整文件 MD5
- hw.fileMD5.Write(chunk)
- // 写入当前分片 MD5
- hw.sliceMD5.Write(chunk)
- // 写入当前 piece SHA-1
- hw.pieceSHA1.Write(chunk)
+ // Write all whole-file and boundary-specific hashes in one pass.
+ _, _ = hw.fileMD5.Write(chunk)
+ _, _ = hw.fileSHA1.Write(chunk)
+ _, _ = hw.fileSHA256.Write(chunk)
+ _, _ = hw.fileGCID.Write(chunk)
+ _, _ = hw.sliceMD5.Write(chunk)
+ _, _ = hw.pieceMD5.Write(chunk)
+ _, _ = hw.pieceSHA1.Write(chunk)
+ _, _ = hw.pieceSHA256.Write(chunk)
hw.sliceWritten += canWrite
hw.pieceWritten += canWrite
@@ -112,8 +138,16 @@ func (hw *HashWriter) finishSlice() {
// finishPiece 完成当前 piece 的 SHA-1 计算
func (hw *HashWriter) finishPiece() {
- hw.pieceHashes = append(hw.pieceHashes, hw.pieceSHA1.Sum(nil)...)
+ md5Sum := hw.pieceMD5.Sum(nil)
+ sha1Sum := hw.pieceSHA1.Sum(nil)
+ sha256Sum := hw.pieceSHA256.Sum(nil)
+ hw.pieceMD5Hexs = append(hw.pieceMD5Hexs, hex.EncodeToString(md5Sum))
+ hw.pieceSHA1Hexs = append(hw.pieceSHA1Hexs, hex.EncodeToString(sha1Sum))
+ hw.pieceSHA256Hexs = append(hw.pieceSHA256Hexs, hex.EncodeToString(sha256Sum))
+ hw.pieceHashes = append(hw.pieceHashes, sha1Sum...)
+ hw.pieceMD5.Reset()
hw.pieceSHA1.Reset()
+ hw.pieceSHA256.Reset()
hw.pieceWritten = 0
}
@@ -134,6 +168,56 @@ func (hw *HashWriter) GetFileMD5() string {
return strings.ToUpper(hex.EncodeToString(hw.fileMD5.Sum(nil)))
}
+// GetFileSHA1 returns the lowercase whole-file SHA-1 digest.
+func (hw *HashWriter) GetFileSHA1() string {
+ return hex.EncodeToString(hw.fileSHA1.Sum(nil))
+}
+
+// GetFileSHA256 returns the lowercase whole-file SHA-256 digest.
+func (hw *HashWriter) GetFileSHA256() string {
+ return hex.EncodeToString(hw.fileSHA256.Sum(nil))
+}
+
+// GetFileGCID returns the uppercase GCID digest for Thunder/PikPak.
+func (hw *HashWriter) GetFileGCID() string {
+ return strings.ToUpper(hex.EncodeToString(hw.fileGCID.Sum(nil)))
+}
+
+// GetPieceMD5s returns independent per-file MD5 piece hashes.
+func (hw *HashWriter) GetPieceMD5s() []string {
+ return append([]string(nil), hw.pieceMD5Hexs...)
+}
+
+// GetPieceSHA1s returns independent per-file SHA-1 piece hashes.
+func (hw *HashWriter) GetPieceSHA1s() []string {
+ return append([]string(nil), hw.pieceSHA1Hexs...)
+}
+
+// GetPieceSHA256s returns independent per-file SHA-256 piece hashes.
+func (hw *HashWriter) GetPieceSHA256s() []string {
+ return append([]string(nil), hw.pieceSHA256Hexs...)
+}
+
+// BuildSeedFile exports all hashes accumulated during this single stream pass.
+func (hw *HashWriter) BuildSeedFile(filePath string, modified string) SeedFile {
+ return SeedFile{
+ Path: filePath,
+ Size: hw.totalWritten,
+ Modified: modified,
+ Hashes: SeedHashes{
+ MD5: strings.ToLower(hw.GetFileMD5()),
+ SHA1: hw.GetFileSHA1(),
+ SHA256: hw.GetFileSHA256(),
+ GCID: strings.ToLower(hw.GetFileGCID()),
+ Pieces: &SeedPieceHashes{
+ MD5: hw.GetPieceMD5s(),
+ SHA1: hw.GetPieceSHA1s(),
+ SHA256: hw.GetPieceSHA256s(),
+ },
+ },
+ }
+}
+
// GetSliceMD5s 获取所有分片的 MD5 列表
func (hw *HashWriter) GetSliceMD5s() []string {
return hw.sliceMD5Hexs
@@ -141,11 +225,34 @@ func (hw *HashWriter) GetSliceMD5s() []string {
// GetSliceMD5 获取最终的 sliceMD5(用于秒传)
func (hw *HashWriter) GetSliceMD5(fileMD5 string) string {
- if len(hw.sliceMD5Hexs) <= 1 {
- return fileMD5
+ return SliceMD5FromPieces(hw.sliceMD5Hexs, fileMD5)
+}
+
+// SliceMD5FromPieces is the single canonical implementation of the sliceMd5
+// rule shared by every CAS producer and consumer:
+//
+// - no piece, or a single piece -> the whole-file MD5
+// - two or more pieces -> MD5 of the piece MD5s joined by "\n"
+//
+// Keeping one implementation matters because this value is what the remote
+// provider compares against: a divergence between the hash-generation side and
+// the torrent/CAS encoding side silently turns rapid uploads into mismatches.
+// All comparisons and the returned value are upper-case.
+func SliceMD5FromPieces(sliceMD5s []string, fileMD5 string) string {
+ switch len(sliceMD5s) {
+ case 0, 1:
+ // A single piece covers the whole file, so the two hashes coincide.
+ if len(sliceMD5s) == 1 && sliceMD5s[0] != "" {
+ return strings.ToUpper(sliceMD5s[0])
+ }
+ return strings.ToUpper(fileMD5)
+ default:
+ upper := make([]string, len(sliceMD5s))
+ for i, piece := range sliceMD5s {
+ upper[i] = strings.ToUpper(piece)
+ }
+ return strings.ToUpper(GetMD5Str(strings.Join(upper, "\n")))
}
- joined := strings.Join(hw.sliceMD5Hexs, "\n")
- return strings.ToUpper(GetMD5Str(joined))
}
// GetPieceHashes 获取所有 piece 的 SHA-1 哈希拼接
@@ -170,7 +277,7 @@ func (hw *HashWriter) BuildTorrent(fileName string, fileSize int64) *Torrent {
SliceMD5: sliceMD5,
SliceMD5s: hw.GetSliceMD5s(),
SliceSize: hw.sliceSize,
- Cloud: "189",
+ Cloud: Cloud189,
})
return t
diff --git a/pkg/torrent/seed_security_test.go b/pkg/torrent/seed_security_test.go
new file mode 100644
index 00000000..175f24d3
--- /dev/null
+++ b/pkg/torrent/seed_security_test.go
@@ -0,0 +1,156 @@
+package torrent
+
+import (
+ "strings"
+ "testing"
+)
+
+// --- path traversal / malformed path handling ---------------------------------
+
+func TestValidateSeedRejectsUnsafePaths(t *testing.T) {
+ cases := []struct {
+ name string
+ path string
+ }{
+ {"parent traversal", "../secret"},
+ {"nested traversal", "a/../../secret"},
+ {"absolute unix", "/etc/passwd"},
+ {"empty", ""},
+ {"current dir", "."},
+ {"nul byte", "a\x00b"},
+ {"backslash traversal", `..\secret`},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ seed := testSeed()
+ seed.Files[0].Path = tc.path
+ if err := ValidateSeed(seed, DefaultParseLimits()); err == nil {
+ t.Fatalf("ValidateSeed() accepted unsafe path %q", tc.path)
+ }
+ })
+ }
+}
+
+func TestValidateSeedRejectsTooManyFiles(t *testing.T) {
+ seed := testSeed()
+ seed.Files = make([]SeedFile, DefaultMaxSeedFiles+1)
+ for i := range seed.Files {
+ seed.Files[i] = SeedFile{
+ Path: "f" + strings.Repeat("0", i%3) + ".bin",
+ Size: 1,
+ Hashes: SeedHashes{MD5: strings.Repeat("1", 32)},
+ }
+ }
+ if err := ValidateSeed(seed, DefaultParseLimits()); err == nil {
+ t.Fatal("ValidateSeed() accepted more files than DefaultMaxSeedFiles")
+ }
+}
+
+// --- sliceMd5 canonical rule ---------------------------------------------------
+
+func TestSliceMD5FromPiecesMatchesSpec(t *testing.T) {
+ fileMD5 := strings.Repeat("a", 32)
+
+ // Zero pieces: fall back to the whole-file MD5.
+ if got := SliceMD5FromPieces(nil, fileMD5); got != strings.ToUpper(fileMD5) {
+ t.Fatalf("SliceMD5FromPieces(nil) = %q, want %q", got, strings.ToUpper(fileMD5))
+ }
+ // A single piece covers the whole file, so it equals the file MD5.
+ single := strings.ToUpper(fileMD5)
+ if got := SliceMD5FromPieces([]string{single}, fileMD5); got != single {
+ t.Fatalf("SliceMD5FromPieces(single) = %q, want %q", got, single)
+ }
+ // Two or more pieces: MD5 of the newline-joined, upper-cased piece list.
+ pieces := []string{strings.Repeat("b", 32), strings.Repeat("c", 32)}
+ want := strings.ToUpper(GetMD5Str(strings.Join([]string{strings.Repeat("B", 32), strings.Repeat("C", 32)}, "\n")))
+ if got := SliceMD5FromPieces(pieces, fileMD5); got != want {
+ t.Fatalf("SliceMD5FromPieces(multi) = %q, want %q", got, want)
+ }
+}
+
+// TestSliceMD5AgreesWithBuildCASInfo locks the rule shared by the hash-generation
+// side and the CAS-encoding side. A divergence here silently turns rapid uploads
+// into hash mismatches, so the two entry points must never drift apart.
+func TestSliceMD5AgreesWithBuildCASInfo(t *testing.T) {
+ fileMD5 := strings.Repeat("a", 32)
+ sets := [][]string{
+ nil,
+ {strings.Repeat("b", 32)},
+ {strings.Repeat("b", 32), strings.Repeat("c", 32)},
+ {strings.Repeat("b", 32), strings.Repeat("c", 32), strings.Repeat("d", 32)},
+ }
+ for i, pieces := range sets {
+ hw := &HashWriter{sliceMD5Hexs: pieces}
+ fromWriter := hw.GetSliceMD5(fileMD5)
+ fromCAS := BuildCASInfoFromMD5s(fileMD5, pieces, DefaultPieceSize).SliceMD5
+ if fromWriter != fromCAS {
+ t.Fatalf("case %d: GetSliceMD5() = %q, BuildCASInfoFromMD5s() = %q", i, fromWriter, fromCAS)
+ }
+ }
+}
+
+// --- bencode robustness --------------------------------------------------------
+
+func TestBencodeDecodeRejectsOversizedStringLength(t *testing.T) {
+ // Declares a 4GiB string while the buffer is empty; parsing must fail on the
+ // declared length instead of attempting a huge allocation.
+ payload := []byte("9999999999:")
+ if _, err := BencodeDecode(payload); err == nil {
+ t.Fatal("BencodeDecode() accepted an out-of-bounds string length")
+ }
+}
+
+func TestBencodeDecodeRejectsDeepNesting(t *testing.T) {
+ depth := DefaultParseLimits().MaxDepth + 2
+ payload := strings.Repeat("l", depth) + strings.Repeat("e", depth)
+ if _, err := BencodeDecode([]byte(payload)); err == nil {
+ t.Fatal("BencodeDecode() accepted nesting beyond MaxDepth")
+ }
+}
+
+func TestBencodeDecodeRejectsTrailingData(t *testing.T) {
+ if _, err := BencodeDecode([]byte("i1eextra")); err == nil {
+ t.Fatal("BencodeDecode() accepted trailing data")
+ }
+}
+
+// --- cross-format conversion consistency --------------------------------------
+
+// TestConvertConsistencyAcrossFormats ensures a seed survives OSS -> torrent ->
+// OSS and OSS -> CAS -> OSS without losing whole-file hashes.
+func TestConvertConsistencyAcrossFormats(t *testing.T) {
+ original := testSeed()
+
+ torrentData, err := EncodeSeed(original, "torrent")
+ if err != nil {
+ t.Fatalf("EncodeSeed(torrent) error = %v", err)
+ }
+ fromTorrent, err := DecodeSeed(torrentData, "torrent", DefaultParseLimits())
+ if err != nil {
+ t.Fatalf("DecodeSeed(torrent) error = %v", err)
+ }
+ casData, err := EncodeCAS(fromTorrent)
+ if err != nil {
+ t.Fatalf("EncodeCAS() error = %v", err)
+ }
+ fromCAS, err := DecodeCAS(casData, DefaultParseLimits())
+ if err != nil {
+ t.Fatalf("DecodeCAS() error = %v", err)
+ }
+ if got := fromCAS.Files[0].Hashes.MD5; got != strings.ToUpper(original.Files[0].Hashes.MD5) {
+ t.Fatalf("MD5 changed across formats: %q", got)
+ }
+ if len(fromCAS.Files) != len(original.Files) {
+ t.Fatalf("file count changed across formats: %d", len(fromCAS.Files))
+ }
+}
+
+func TestDecodeSeedRejectsUnknownFormat(t *testing.T) {
+ data, err := EncodeOSS(testSeed())
+ if err != nil {
+ t.Fatalf("EncodeOSS() error = %v", err)
+ }
+ if _, err := DecodeSeed(data, "does-not-exist", DefaultParseLimits()); err == nil {
+ t.Fatal("DecodeSeed() accepted an unknown format")
+ }
+}
diff --git a/pkg/torrent/seed_test.go b/pkg/torrent/seed_test.go
new file mode 100644
index 00000000..17ad34bf
--- /dev/null
+++ b/pkg/torrent/seed_test.go
@@ -0,0 +1,160 @@
+package torrent
+
+import (
+ "encoding/base64"
+ "encoding/json"
+ "strings"
+ "testing"
+ "time"
+)
+
+func testSeed() *Seed {
+ return &Seed{
+ Format: OSSFormat,
+ Version: OSSVersion,
+ Name: "example.bin",
+ CreatedAt: time.Unix(1, 0).UTC().Format(time.RFC3339),
+ CreatedBy: "OpenList",
+ PieceSize: DefaultPieceSize,
+ Files: []SeedFile{{
+ Path: "example.bin",
+ Size: DefaultPieceSize + 1,
+ Hashes: SeedHashes{
+ MD5: strings.Repeat("1", 32),
+ SHA1: strings.Repeat("2", 40),
+ SHA256: strings.Repeat("3", 64),
+ Pieces: &SeedPieceHashes{
+ MD5: []string{strings.Repeat("4", 32), strings.Repeat("5", 32)},
+ SHA1: []string{strings.Repeat("6", 40), strings.Repeat("7", 40)},
+ SHA256: []string{strings.Repeat("8", 64), strings.Repeat("9", 64)},
+ },
+ },
+ }},
+ }
+}
+
+func TestOSSRoundTrip(t *testing.T) {
+ encoded, err := EncodeOSS(testSeed())
+ if err != nil {
+ t.Fatalf("EncodeOSS() error = %v", err)
+ }
+ decoded, err := DecodeOSS(encoded, DefaultParseLimits())
+ if err != nil {
+ t.Fatalf("DecodeOSS() error = %v", err)
+ }
+ if decoded.Name != "example.bin" || len(decoded.Files) != 1 || decoded.Files[0].Hashes.SHA256 == "" {
+ t.Fatalf("DecodeOSS() returned incomplete seed: %#v", decoded)
+ }
+}
+
+func TestCASWireFormatIsLegacyCompatible(t *testing.T) {
+ encoded, err := EncodeCAS(testSeed())
+ if err != nil {
+ t.Fatalf("EncodeCAS() error = %v", err)
+ }
+ decodedJSON, err := base64.StdEncoding.DecodeString(string(encoded))
+ if err != nil {
+ t.Fatalf("base64.DecodeString() error = %v", err)
+ }
+ var payload map[string]any
+ if err = json.Unmarshal(decodedJSON, &payload); err != nil {
+ t.Fatalf("json.Unmarshal() error = %v", err)
+ }
+ // The five legacy fields must always be present so the reference client can
+ // parse the payload. slice_md5s / slice_size are optional extensions.
+ for _, key := range []string{"name", "size", "md5", "sliceMd5", "create_time"} {
+ if _, ok := payload[key]; !ok {
+ t.Fatalf("CAS payload missing %q: %#v", key, payload)
+ }
+ }
+ decoded, err := DecodeCAS(encoded, DefaultParseLimits())
+ if err != nil {
+ t.Fatalf("DecodeCAS() error = %v", err)
+ }
+ if decoded.Files[0].Hashes.MD5 != strings.Repeat("1", 32) {
+ t.Fatalf("DecodeCAS() MD5 = %q", decoded.Files[0].Hashes.MD5)
+ }
+ // Per-piece MD5 list must round-trip through the slice_md5s extension.
+ if decoded.Files[0].Hashes.Pieces == nil || len(decoded.Files[0].Hashes.Pieces.MD5) != 2 {
+ t.Fatalf("DecodeCAS() piece MD5 list = %#v", decoded.Files[0].Hashes.Pieces)
+ }
+}
+
+func TestTorrentRoundTripPreservesOpenListExtension(t *testing.T) {
+ encoded, err := EncodeSeed(testSeed(), "torrent")
+ if err != nil {
+ t.Fatalf("EncodeSeed(torrent) error = %v", err)
+ }
+ decoded, err := DecodeSeed(encoded, "torrent", DefaultParseLimits())
+ if err != nil {
+ t.Fatalf("DecodeSeed(torrent) error = %v", err)
+ }
+ if decoded.Files[0].Hashes.SHA256 != strings.Repeat("3", 64) {
+ t.Fatalf("torrent extension lost SHA-256: %#v", decoded.Files[0].Hashes)
+ }
+}
+
+func TestCASCloudGeneralizationRoundTrip(t *testing.T) {
+ seed := testSeed()
+ seed.Files[0].CASSliceMD5 = strings.Repeat("a", 32)
+ seed.Files[0].CASCloud = CloudAliyundriveOpen
+
+ // CAS (base64 JSON) round-trip must preserve the cloud identifier.
+ encoded, err := EncodeCAS(seed)
+ if err != nil {
+ t.Fatalf("EncodeCAS() error = %v", err)
+ }
+ decoded, err := DecodeCAS(encoded, DefaultParseLimits())
+ if err != nil {
+ t.Fatalf("DecodeCAS() error = %v", err)
+ }
+ if got := decoded.Files[0].CASCloud; got != CloudAliyundriveOpen {
+ t.Fatalf("DecodeCAS() cloud = %q, want %q", got, CloudAliyundriveOpen)
+ }
+
+ // Torrent bencode round-trip must preserve the cloud identifier too.
+ torrentData, err := EncodeSeed(seed, "torrent")
+ if err != nil {
+ t.Fatalf("EncodeSeed(torrent) error = %v", err)
+ }
+ decodedTorrent, err := DecodeSeed(torrentData, "torrent", DefaultParseLimits())
+ if err != nil {
+ t.Fatalf("DecodeSeed(torrent) error = %v", err)
+ }
+ if got := decodedTorrent.Files[0].CASCloud; got != CloudAliyundriveOpen {
+ t.Fatalf("DecodeSeed(torrent) cloud = %q, want %q", got, CloudAliyundriveOpen)
+ }
+}
+
+func TestBuildCASInfoFromMD5sDefaultsToCloud189(t *testing.T) {
+ info := BuildCASInfoFromMD5s(strings.Repeat("1", 32), []string{strings.Repeat("4", 32)}, DefaultPieceSize)
+ if info.Cloud != Cloud189 {
+ t.Fatalf("BuildCASInfoFromMD5s() cloud = %q, want %q", info.Cloud, Cloud189)
+ }
+ other := BuildCASInfoFromMD5sWithCloud(strings.Repeat("1", 32), []string{strings.Repeat("4", 32)}, DefaultPieceSize, Cloud115)
+ if other.Cloud != Cloud115 {
+ t.Fatalf("BuildCASInfoFromMD5sWithCloud() cloud = %q, want %q", other.Cloud, Cloud115)
+ }
+}
+
+func TestValidateSeedRejectsTraversalAndInvalidHash(t *testing.T) {
+ seed := testSeed()
+ seed.Files[0].Path = "../secret"
+ if err := ValidateSeed(seed, DefaultParseLimits()); err == nil {
+ t.Fatal("ValidateSeed() accepted path traversal")
+ }
+ seed = testSeed()
+ seed.Files[0].Hashes.MD5 = "not-a-hash"
+ if err := ValidateSeed(seed, DefaultParseLimits()); err == nil {
+ t.Fatal("ValidateSeed() accepted an invalid hash")
+ }
+}
+
+func TestDecodeSeedHonorsSizeLimit(t *testing.T) {
+ data := []byte(`{"format":"openlist-sharing-seed"}`)
+ limits := DefaultParseLimits()
+ limits.MaxBytes = int64(len(data) - 1)
+ if _, err := DecodeSeed(data, "oss", limits); err == nil {
+ t.Fatal("DecodeSeed() accepted input above MaxBytes")
+ }
+}
diff --git a/pkg/torrent/torrent.go b/pkg/torrent/torrent.go
index 8744e636..dce495e4 100644
--- a/pkg/torrent/torrent.go
+++ b/pkg/torrent/torrent.go
@@ -1,12 +1,19 @@
package torrent
import (
+ "bytes"
"crypto/md5"
"crypto/sha1"
+ "encoding/base64"
"encoding/hex"
+ "encoding/json"
"fmt"
+ "io"
+ "net/url"
+ "path"
"strings"
"time"
+ "unicode/utf8"
)
const (
@@ -26,8 +33,127 @@ const (
CASFileMD5Key = "file_md5"
// CASCloudKey 云盘类型 key
CASCloudKey = "cloud"
+
+ // Cloud189 identifies the 天翼云 (189) PC driver CAS slice rule.
+ Cloud189 = "189"
+ // Cloud115 identifies the 115 driver rapid-upload rule (whole-file SHA1).
+ Cloud115 = "115"
+ // CloudAliyundriveOpen identifies the aliyundrive_open rapid-upload rule (whole-file SHA1).
+ CloudAliyundriveOpen = "aliyundrive_open"
+
+ // OpenListExtensionKey is the optional root-level extension key.
+ OpenListExtensionKey = "x-openlist"
+ // OSSFormat identifies OpenList sharing seed JSON documents.
+ OSSFormat = "openlist-sharing-seed"
+ // OSSVersion is the currently supported sharing seed schema version.
+ OSSVersion = 1
+ // DefaultMaxSeedSize bounds untrusted seed documents.
+ DefaultMaxSeedSize int64 = 10 * 1024 * 1024
+ // DefaultMaxSeedFiles bounds file-list fan-out in untrusted seeds.
+ DefaultMaxSeedFiles = 100000
)
+// Seed describes the portable OpenList sharing seed contract.
+type Seed struct {
+ Format string `json:"format"`
+ Version int `json:"version"`
+ Name string `json:"name"`
+ Comment string `json:"comment,omitempty"`
+ CreatedAt string `json:"created_at"`
+ CreatedBy string `json:"created_by"`
+ PieceSize int64 `json:"piece_size"`
+ Trackers []string `json:"trackers,omitempty"`
+ Channels []SeedChannel `json:"channels,omitempty"`
+ Files []SeedFile `json:"files"`
+}
+
+// SeedChannel contains only public storage discovery metadata.
+type SeedChannel struct {
+ Driver string `json:"driver"`
+ MountPath string `json:"mount_path,omitempty"`
+}
+
+// SeedFile describes one relative file in a sharing seed.
+type SeedFile struct {
+ Path string `json:"path"`
+ Size int64 `json:"size"`
+ Modified string `json:"modified,omitempty"`
+ Comment string `json:"comment,omitempty"`
+ Hashes SeedHashes `json:"hashes"`
+ Sources []SeedSource `json:"sources,omitempty"`
+ CASSliceMD5 string `json:"cas_slice_md5,omitempty"`
+ CASCreateTime string `json:"cas_create_time,omitempty"`
+ // CASCloud 标识该文件 CAS 元数据所属的云盘类型(如 "189")。留空视为 189。
+ CASCloud string `json:"cas_cloud,omitempty"`
+ MissingChannels []string `json:"missing_channels,omitempty"`
+}
+
+// SeedHashes contains whole-file and optional per-piece hashes.
+type SeedHashes struct {
+ MD5 string `json:"md5,omitempty"`
+ SHA1 string `json:"sha1,omitempty"`
+ SHA256 string `json:"sha256,omitempty"`
+ GCID string `json:"gcid,omitempty"`
+ Pieces *SeedPieceHashes `json:"pieces,omitempty"`
+}
+
+// SeedPieceHashes contains independent per-file piece hashes.
+type SeedPieceHashes struct {
+ MD5 []string `json:"md5,omitempty"`
+ SHA1 []string `json:"sha1,omitempty"`
+ SHA256 []string `json:"sha256,omitempty"`
+}
+
+// SeedSource is an optional retrievable public or signed source URL.
+type SeedSource struct {
+ Type string `json:"type"`
+ URL string `json:"url"`
+ ExpiresAt string `json:"expires_at,omitempty"`
+ ShareID string `json:"share_id,omitempty"`
+}
+
+// CASFileEntry describes one file inside a multi-file .cas payload.
+type CASFileEntry struct {
+ Name string `json:"name"`
+ Size int64 `json:"size"`
+ MD5 string `json:"md5"`
+ SliceMD5 string `json:"sliceMd5"`
+ CreateTime string `json:"create_time"`
+ SliceMD5s []string `json:"slice_md5s,omitempty"`
+ SliceSize int64 `json:"slice_size,omitempty"`
+ // Cloud 标识该 CAS 条目所属的云盘类型(如 "189");留空视为 189。
+ Cloud string `json:"cloud,omitempty"`
+}
+
+// CASPayload matches the reference .cas JSON payload. The five legacy fields
+// describe a single file (byte-for-byte compatible with the reference project);
+// the optional "files" array extends it to multi-file seeds, and the optional
+// slice_md5s/slice_size preserve the per-piece MD5 list.
+type CASPayload struct {
+ Name string `json:"name"`
+ Size int64 `json:"size"`
+ MD5 string `json:"md5"`
+ SliceMD5 string `json:"sliceMd5"`
+ CreateTime string `json:"create_time"`
+ SliceMD5s []string `json:"slice_md5s,omitempty"`
+ SliceSize int64 `json:"slice_size,omitempty"`
+ // Cloud 标识该 CAS 载荷所属的云盘类型(如 "189");留空视为 189。
+ Cloud string `json:"cloud,omitempty"`
+ Files []CASFileEntry `json:"files,omitempty"`
+}
+
+// ParseLimits controls resource use while parsing untrusted seeds.
+type ParseLimits struct {
+ MaxBytes int64
+ MaxFiles int
+ MaxDepth int
+}
+
+// DefaultParseLimits returns conservative public API limits.
+func DefaultParseLimits() ParseLimits {
+ return ParseLimits{MaxBytes: DefaultMaxSeedSize, MaxFiles: DefaultMaxSeedFiles, MaxDepth: 64}
+}
+
// CASInfo 天翼云 CAS 秒传所需信息
type CASInfo struct {
// FileMD5 整文件 MD5(大写十六进制)
@@ -38,7 +164,8 @@ type CASInfo struct {
SliceMD5s []string
// SliceSize 分片大小(字节)
SliceSize int64
- // Cloud 云盘类型标识
+ // Cloud 云盘类型标识。可为具体驱动名(如 "189"、"115"、"aliyundrive_open"),
+ // 或留空表示由使用方按目标驱动自行判定。不同类型的分片规则可能不同。
Cloud string
}
@@ -86,6 +213,8 @@ type Torrent struct {
CreatedBy string
// CAS 天翼云 CAS 扩展信息(存储在 info 字典外部,不影响 info_hash)
CAS *CASInfo
+ // OpenList stores the portable extension outside info so info_hash stays standard.
+ OpenList *Seed
}
// NewTorrent 创建一个新的 torrent 结构
@@ -200,6 +329,12 @@ func (t *Torrent) Encode() ([]byte, error) {
}
rootDict[CASExtensionKey] = casDict
}
+ if t.OpenList != nil {
+ if err := ValidateSeed(t.OpenList, DefaultParseLimits()); err != nil {
+ return nil, fmt.Errorf("validate x-openlist: %w", err)
+ }
+ rootDict[OpenListExtensionKey] = seedToBencode(t.OpenList)
+ }
return BencodeEncode(rootDict)
}
@@ -376,6 +511,20 @@ func Decode(data []byte) (*Torrent, error) {
}
}
+ if extension, ok := rootDict[OpenListExtensionKey]; ok {
+ seed, err := seedFromBencode(extension)
+ if err != nil {
+ return nil, fmt.Errorf("decode x-openlist: %w", err)
+ }
+ if err = ValidateSeed(seed, DefaultParseLimits()); err != nil {
+ return nil, fmt.Errorf("validate x-openlist: %w", err)
+ }
+ t.OpenList = seed
+ }
+ if err := ValidateTorrent(t, DefaultParseLimits()); err != nil {
+ return nil, err
+ }
+
return t, nil
}
@@ -414,20 +563,23 @@ func (t *Torrent) HasCASInfo() bool {
return t.CAS != nil && t.CAS.FileMD5 != "" && t.CAS.SliceMD5 != ""
}
-// BuildCASInfoFromMD5s 从分片 MD5 列表构建 CAS 信息
+// BuildCASInfoFromMD5s 从分片 MD5 列表构建 CAS 信息(默认按天翼云分片规则标记 cloud=189)。
+// 新代码应优先使用 BuildCASInfoFromMD5sWithCloud 显式指定云盘类型。
func BuildCASInfoFromMD5s(fileMD5 string, sliceMD5s []string, sliceSize int64) *CASInfo {
- sliceMD5 := fileMD5
- if len(sliceMD5s) > 1 {
- // 所有分片 MD5 用 \n 拼接后再取 MD5
- joined := strings.Join(sliceMD5s, "\n")
- sliceMD5 = strings.ToUpper(GetMD5Str(joined))
- }
+ return BuildCASInfoFromMD5sWithCloud(fileMD5, sliceMD5s, sliceSize, Cloud189)
+}
+
+// BuildCASInfoFromMD5sWithCloud 从分片 MD5 列表构建 CAS 信息,并指定云盘类型标识。
+func BuildCASInfoFromMD5sWithCloud(fileMD5 string, sliceMD5s []string, sliceSize int64, cloud string) *CASInfo {
+ fileMD5 = strings.ToUpper(fileMD5)
+ sliceMD5s = upperStrings(sliceMD5s)
+ sliceMD5 := SliceMD5FromPieces(sliceMD5s, fileMD5)
return &CASInfo{
FileMD5: fileMD5,
SliceMD5: sliceMD5,
SliceMD5s: sliceMD5s,
SliceSize: sliceSize,
- Cloud: "189",
+ Cloud: cloud,
}
}
@@ -437,3 +589,878 @@ func GetMD5Str(data string) string {
h.Write([]byte(data))
return strings.ToUpper(hex.EncodeToString(h.Sum(nil)))
}
+
+// ValidateTorrent checks standard BitTorrent invariants and portable paths.
+func ValidateTorrent(t *Torrent, limits ParseLimits) error {
+ if t == nil {
+ return fmt.Errorf("missing torrent")
+ }
+ if limits.MaxFiles <= 0 {
+ limits.MaxFiles = DefaultMaxSeedFiles
+ }
+ if err := validateSeedName(t.Info.Name); err != nil {
+ return fmt.Errorf("torrent: %w", err)
+ }
+ if t.Info.PieceLength <= 0 || t.Info.PieceLength > 1<<30 {
+ return fmt.Errorf("torrent: invalid piece length")
+ }
+ if len(t.Info.Pieces)%sha1.Size != 0 {
+ return fmt.Errorf("torrent: pieces length must be a multiple of %d", sha1.Size)
+ }
+ if len(t.Info.Files) > limits.MaxFiles {
+ return fmt.Errorf("torrent: too many files")
+ }
+ var total int64
+ if len(t.Info.Files) == 0 {
+ if t.Info.Length < 0 {
+ return fmt.Errorf("torrent: invalid file size")
+ }
+ total = t.Info.Length
+ } else {
+ seen := make(map[string]struct{}, len(t.Info.Files))
+ for _, file := range t.Info.Files {
+ filePath := strings.Join(file.Path, "/")
+ if err := validateRelativeSeedPath(filePath); err != nil {
+ return fmt.Errorf("torrent: %w", err)
+ }
+ if _, ok := seen[filePath]; ok {
+ return fmt.Errorf("torrent: duplicate file path %q", filePath)
+ }
+ seen[filePath] = struct{}{}
+ if file.Length < 0 || total > int64(^uint64(0)>>1)-file.Length {
+ return fmt.Errorf("torrent: invalid file size")
+ }
+ total += file.Length
+ }
+ }
+ expectedPieces := int64(0)
+ if total > 0 {
+ expectedPieces = (total + t.Info.PieceLength - 1) / t.Info.PieceLength
+ }
+ if int64(len(t.Info.Pieces)/sha1.Size) != expectedPieces {
+ return fmt.Errorf("torrent: piece count mismatch")
+ }
+ return nil
+}
+
+// NewSeed creates a versioned OSS document with stable defaults.
+func NewSeed(name, createdBy string, pieceSize int64) *Seed {
+ if pieceSize <= 0 {
+ pieceSize = DefaultPieceSize
+ }
+ return &Seed{
+ Format: OSSFormat,
+ Version: OSSVersion,
+ Name: name,
+ CreatedAt: time.Now().UTC().Format(time.RFC3339),
+ CreatedBy: createdBy,
+ PieceSize: pieceSize,
+ }
+}
+
+// ValidateSeed rejects unsafe paths, malformed hashes and unreasonable resource use.
+func ValidateSeed(seed *Seed, limits ParseLimits) error {
+ if seed == nil {
+ return fmt.Errorf("missing seed")
+ }
+ if limits.MaxBytes <= 0 {
+ limits.MaxBytes = DefaultMaxSeedSize
+ }
+ if limits.MaxFiles <= 0 {
+ limits.MaxFiles = DefaultMaxSeedFiles
+ }
+ if seed.Format != OSSFormat {
+ return fmt.Errorf("unsupported seed format %q", seed.Format)
+ }
+ if seed.Version != OSSVersion {
+ return fmt.Errorf("unsupported seed version %d", seed.Version)
+ }
+ if err := validateSeedName(seed.Name); err != nil {
+ return err
+ }
+ if seed.CreatedAt == "" || seed.CreatedBy == "" {
+ return fmt.Errorf("created_at and created_by are required")
+ }
+ if seed.PieceSize < 16*1024 || seed.PieceSize > 64*1024*1024 {
+ return fmt.Errorf("piece_size must be between 16384 and 67108864")
+ }
+ if len(seed.Files) == 0 || len(seed.Files) > limits.MaxFiles {
+ return fmt.Errorf("file count must be between 1 and %d", limits.MaxFiles)
+ }
+ seen := make(map[string]struct{}, len(seed.Files))
+ var total int64
+ for i := range seed.Files {
+ file := &seed.Files[i]
+ if err := validateRelativeSeedPath(file.Path); err != nil {
+ return fmt.Errorf("file %d: %w", i, err)
+ }
+ if _, ok := seen[file.Path]; ok {
+ return fmt.Errorf("duplicate file path %q", file.Path)
+ }
+ seen[file.Path] = struct{}{}
+ if file.Size < 0 || total > int64(^uint64(0)>>1)-file.Size {
+ return fmt.Errorf("file %q has invalid size", file.Path)
+ }
+ total += file.Size
+ if err := validateSeedHashes(file.Hashes, file.Size, seed.PieceSize); err != nil {
+ return fmt.Errorf("file %q: %w", file.Path, err)
+ }
+ if file.CASSliceMD5 != "" && !validHexHash(file.CASSliceMD5, 32) {
+ return fmt.Errorf("file %q has an invalid CAS slice MD5", file.Path)
+ }
+ for _, source := range file.Sources {
+ if source.Type == "" || source.URL == "" {
+ return fmt.Errorf("file %q has an incomplete source", file.Path)
+ }
+ u, err := url.Parse(source.URL)
+ if err != nil || u.Scheme == "" {
+ return fmt.Errorf("file %q has an invalid source URL", file.Path)
+ }
+ }
+ for _, channel := range file.MissingChannels {
+ if strings.TrimSpace(channel) == "" || strings.ContainsAny(channel, "/\\\x00") {
+ return fmt.Errorf("file %q has an invalid missing channel", file.Path)
+ }
+ }
+ }
+ for _, channel := range seed.Channels {
+ if strings.TrimSpace(channel.Driver) == "" {
+ return fmt.Errorf("channel driver is required")
+ }
+ if strings.ContainsAny(channel.MountPath, "?#\x00") {
+ return fmt.Errorf("channel mount_path contains invalid characters")
+ }
+ }
+ return nil
+}
+
+func validateSeedName(name string) error {
+ name = strings.TrimSpace(name)
+ if name == "" || name == "." || name == ".." || strings.ContainsAny(name, "/\\\x00") {
+ return fmt.Errorf("invalid seed name %q", name)
+ }
+ return nil
+}
+
+func validateRelativeSeedPath(name string) error {
+ if name == "" || !utf8.ValidString(name) || strings.ContainsAny(name, "\\\x00") || strings.HasPrefix(name, "/") {
+ return fmt.Errorf("invalid relative path %q", name)
+ }
+ cleaned := path.Clean(name)
+ if cleaned != name || cleaned == "." || cleaned == ".." || strings.HasPrefix(cleaned, "../") {
+ return fmt.Errorf("invalid relative path %q", name)
+ }
+ for _, part := range strings.Split(name, "/") {
+ if part == "" || part == "." || part == ".." {
+ return fmt.Errorf("invalid relative path %q", name)
+ }
+ }
+ return nil
+}
+
+func validateSeedHashes(hashes SeedHashes, size, pieceSize int64) error {
+ for name, value := range map[string]string{"md5": hashes.MD5, "sha1": hashes.SHA1, "sha256": hashes.SHA256} {
+ if value != "" && !validHexHash(value, map[string]int{"md5": 32, "sha1": 40, "sha256": 64}[name]) {
+ return fmt.Errorf("invalid %s hash", name)
+ }
+ }
+ if hashes.Pieces == nil {
+ return nil
+ }
+ expected := 0
+ if size > 0 {
+ expected = int((size + pieceSize - 1) / pieceSize)
+ }
+ pieceSets := []struct {
+ name string
+ width int
+ values []string
+ }{
+ {"md5", 32, hashes.Pieces.MD5},
+ {"sha1", 40, hashes.Pieces.SHA1},
+ {"sha256", 64, hashes.Pieces.SHA256},
+ }
+ for _, set := range pieceSets {
+ if len(set.values) != 0 && len(set.values) != expected {
+ return fmt.Errorf("%s piece count is %d, expected %d", set.name, len(set.values), expected)
+ }
+ for _, value := range set.values {
+ if !validHexHash(value, set.width) {
+ return fmt.Errorf("invalid %s piece hash", set.name)
+ }
+ }
+ }
+ return nil
+}
+
+func validHexHash(value string, width int) bool {
+ if len(value) != width {
+ return false
+ }
+ _, err := hex.DecodeString(value)
+ return err == nil
+}
+
+// EncodeOSS serializes a validated seed as UTF-8 JSON.
+func EncodeOSS(seed *Seed) ([]byte, error) {
+ if err := ValidateSeed(seed, DefaultParseLimits()); err != nil {
+ return nil, err
+ }
+ return json.MarshalIndent(seed, "", " ")
+}
+
+// DecodeOSS parses a bounded UTF-8 OSS document.
+func DecodeOSS(data []byte, limits ParseLimits) (*Seed, error) {
+ if limits.MaxBytes <= 0 {
+ limits.MaxBytes = DefaultMaxSeedSize
+ }
+ if int64(len(data)) > limits.MaxBytes {
+ return nil, fmt.Errorf("seed exceeds %d bytes", limits.MaxBytes)
+ }
+ data = bytes.TrimPrefix(data, []byte{0xef, 0xbb, 0xbf})
+ if !utf8.Valid(data) {
+ return nil, fmt.Errorf("seed is not valid UTF-8")
+ }
+ decoder := json.NewDecoder(bytes.NewReader(data))
+ var seed Seed
+ if err := decoder.Decode(&seed); err != nil {
+ return nil, fmt.Errorf("decode OSS: %w", err)
+ }
+ var trailing any
+ if err := decoder.Decode(&trailing); err != io.EOF {
+ return nil, fmt.Errorf("decode OSS: trailing data")
+ }
+ if err := ValidateSeed(&seed, limits); err != nil {
+ return nil, err
+ }
+ return &seed, nil
+}
+
+// buildCASFileEntry computes the reference-compatible five fields for one file,
+// preserving the per-piece MD5 list and piece size when available.
+func buildCASFileEntry(file SeedFile, pieceSize int64) (CASFileEntry, error) {
+ if file.Hashes.MD5 == "" {
+ return CASFileEntry{}, fmt.Errorf("CAS requires a whole-file MD5 for %s", file.Path)
+ }
+ var sliceMD5s []string
+ if file.Hashes.Pieces != nil && len(file.Hashes.Pieces.MD5) > 0 {
+ sliceMD5s = upperStrings(file.Hashes.Pieces.MD5)
+ }
+ // Prefer an explicitly supplied legacy slice MD5; otherwise derive it with
+ // the canonical rule so the value written here cannot drift from the one
+ // the hash-generation side computes.
+ sliceMD5 := strings.ToUpper(file.CASSliceMD5)
+ if sliceMD5 == "" && len(sliceMD5s) > 0 && pieceSize == DefaultPieceSize {
+ sliceMD5 = SliceMD5FromPieces(sliceMD5s, file.Hashes.MD5)
+ }
+ if sliceMD5 == "" {
+ if file.Size > DefaultPieceSize {
+ return CASFileEntry{}, fmt.Errorf("CAS requires a legacy slice MD5 or complete 10 MiB MD5 pieces for %s", file.Path)
+ }
+ sliceMD5 = strings.ToUpper(file.Hashes.MD5)
+ }
+ createTime := file.CASCreateTime
+ if createTime == "" {
+ createTime = fmt.Sprintf("%d", time.Now().Unix())
+ }
+ entry := CASFileEntry{
+ Name: path.Base(file.Path), Size: file.Size, MD5: strings.ToUpper(file.Hashes.MD5),
+ SliceMD5: sliceMD5, CreateTime: createTime, Cloud: file.CASCloud,
+ }
+ if len(sliceMD5s) > 0 {
+ entry.SliceMD5s = sliceMD5s
+ entry.SliceSize = pieceSize
+ if entry.SliceSize <= 0 {
+ entry.SliceSize = DefaultPieceSize
+ }
+ }
+ return entry, nil
+}
+
+// EncodeCAS writes the reference-compatible base64 encoded JSON payload. A
+// single-file seed uses the legacy five fields; multiple files are stored in
+// the "files" array.
+func EncodeCAS(seed *Seed) ([]byte, error) {
+ if err := ValidateSeed(seed, DefaultParseLimits()); err != nil {
+ return nil, err
+ }
+ if len(seed.Files) == 0 {
+ return nil, fmt.Errorf("CAS requires at least one file")
+ }
+ var payload CASPayload
+ if len(seed.Files) == 1 {
+ entry, err := buildCASFileEntry(seed.Files[0], seed.PieceSize)
+ if err != nil {
+ return nil, err
+ }
+ payload = CASPayload{
+ Name: entry.Name, Size: entry.Size, MD5: entry.MD5,
+ SliceMD5: entry.SliceMD5, CreateTime: entry.CreateTime,
+ SliceMD5s: entry.SliceMD5s, SliceSize: entry.SliceSize, Cloud: entry.Cloud,
+ }
+ } else {
+ entries := make([]CASFileEntry, 0, len(seed.Files))
+ var totalSize int64
+ for _, file := range seed.Files {
+ entry, err := buildCASFileEntry(file, seed.PieceSize)
+ if err != nil {
+ return nil, err
+ }
+ entries = append(entries, entry)
+ totalSize += entry.Size
+ }
+ payload = CASPayload{Name: seed.Name, Size: totalSize, Files: entries}
+ }
+ content, err := json.Marshal(payload)
+ if err != nil {
+ return nil, err
+ }
+ encoded := make([]byte, base64.StdEncoding.EncodedLen(len(content)))
+ base64.StdEncoding.Encode(encoded, content)
+ return encoded, nil
+}
+
+// DecodeCAS accepts both reference base64 JSON and raw JSON payloads.
+func DecodeCAS(data []byte, limits ParseLimits) (*Seed, error) {
+ if limits.MaxBytes <= 0 {
+ limits.MaxBytes = DefaultMaxSeedSize
+ }
+ if int64(len(data)) > limits.MaxBytes {
+ return nil, fmt.Errorf("CAS seed exceeds %d bytes", limits.MaxBytes)
+ }
+ data = bytes.TrimSpace(data)
+ decoded, err := base64.StdEncoding.DecodeString(string(data))
+ if err == nil {
+ data = decoded
+ }
+ var payload CASPayload
+ if err = json.Unmarshal(data, &payload); err != nil {
+ return nil, fmt.Errorf("decode CAS: %w", err)
+ }
+ seed := NewSeed(payload.Name, "OpenList CAS", DefaultPieceSize)
+ if len(payload.Files) > 0 {
+ // Multi-file extension.
+ if len(payload.Files) > limits.MaxFiles {
+ return nil, fmt.Errorf("CAS seed exceeds %d files", limits.MaxFiles)
+ }
+ for _, entry := range payload.Files {
+ file, err := casEntryToSeedFile(entry.Name, entry.Size, entry.MD5, entry.SliceMD5, entry.CreateTime, entry.SliceMD5s, entry.Cloud)
+ if err != nil {
+ return nil, err
+ }
+ if entry.SliceSize > 0 {
+ seed.PieceSize = entry.SliceSize
+ }
+ seed.Files = append(seed.Files, file)
+ }
+ return seed, ValidateSeed(seed, limits)
+ }
+ file, err := casEntryToSeedFile(payload.Name, payload.Size, payload.MD5, payload.SliceMD5, payload.CreateTime, payload.SliceMD5s, payload.Cloud)
+ if err != nil {
+ return nil, err
+ }
+ if payload.SliceSize > 0 {
+ seed.PieceSize = payload.SliceSize
+ }
+ seed.Files = []SeedFile{file}
+ return seed, ValidateSeed(seed, limits)
+}
+
+// casEntryToSeedFile converts a CAS payload entry into a SeedFile, restoring the
+// per-piece MD5 list when it is present.
+func casEntryToSeedFile(name string, size int64, md5Hex, sliceMD5Hex, createTime string, sliceMD5s []string, cloud string) (SeedFile, error) {
+ if name == "" || size < 0 || !validHexHash(md5Hex, 32) {
+ return SeedFile{}, fmt.Errorf("invalid CAS payload")
+ }
+ sliceMD5 := sliceMD5Hex
+ if sliceMD5 == "" {
+ sliceMD5 = md5Hex
+ }
+ if !validHexHash(sliceMD5, 32) {
+ return SeedFile{}, fmt.Errorf("invalid CAS sliceMd5")
+ }
+ file := SeedFile{
+ Path: name, Size: size, CASCreateTime: createTime,
+ CASSliceMD5: strings.ToLower(sliceMD5),
+ CASCloud: cloud,
+ Hashes: SeedHashes{MD5: strings.ToLower(md5Hex)},
+ }
+ if len(sliceMD5s) > 0 {
+ file.Hashes.Pieces = &SeedPieceHashes{MD5: lowerStrings(sliceMD5s)}
+ }
+ return file, nil
+}
+
+// DetectFormat determines the seed container from a file name and content.
+func DetectFormat(fileName string, data []byte) string {
+ switch strings.ToLower(path.Ext(fileName)) {
+ case ".oss":
+ return "oss"
+ case ".torrent":
+ return "torrent"
+ case ".cas":
+ return "cas"
+ }
+ trimmed := bytes.TrimSpace(data)
+ if len(trimmed) > 0 && (trimmed[0] == '{' || bytes.HasPrefix(trimmed, []byte{0xef, 0xbb, 0xbf, '{'})) {
+ return "oss"
+ }
+ if len(trimmed) > 0 && trimmed[0] == 'd' {
+ return "torrent"
+ }
+ return "cas"
+}
+
+// DecodeSeed normalizes OSS, torrent and CAS containers to the OSS contract.
+func DecodeSeed(data []byte, format string, limits ParseLimits) (*Seed, error) {
+ if limits.MaxBytes <= 0 {
+ limits = DefaultParseLimits()
+ }
+ if int64(len(data)) > limits.MaxBytes {
+ return nil, fmt.Errorf("seed exceeds %d bytes", limits.MaxBytes)
+ }
+ switch strings.ToLower(strings.TrimPrefix(format, ".")) {
+ case "oss":
+ return DecodeOSS(data, limits)
+ case "torrent":
+ t, err := Decode(data)
+ if err != nil {
+ return nil, err
+ }
+ return SeedFromTorrent(t, limits)
+ case "cas":
+ return DecodeCAS(data, limits)
+ default:
+ return nil, fmt.Errorf("unsupported seed format %q", format)
+ }
+}
+
+// EncodeSeed converts a normalized seed to the requested container.
+func EncodeSeed(seed *Seed, format string) ([]byte, error) {
+ switch strings.ToLower(strings.TrimPrefix(format, ".")) {
+ case "oss":
+ return EncodeOSS(seed)
+ case "torrent":
+ t, diagnostics := TorrentFromSeed(seed)
+ if len(diagnostics) > 0 {
+ return nil, fmt.Errorf("cannot convert to torrent: %s", strings.Join(diagnostics, "; "))
+ }
+ return t.Encode()
+ case "cas":
+ return EncodeCAS(seed)
+ default:
+ return nil, fmt.Errorf("unsupported seed format %q", format)
+ }
+}
+
+// DiagnoseConversion reports information missing for a lossless target conversion.
+func DiagnoseConversion(seed *Seed, format string) []string {
+ if err := ValidateSeed(seed, DefaultParseLimits()); err != nil {
+ return []string{err.Error()}
+ }
+ var diagnostics []string
+ switch strings.ToLower(strings.TrimPrefix(format, ".")) {
+ case "torrent":
+ for i, file := range seed.Files {
+ if file.Hashes.Pieces == nil || len(file.Hashes.Pieces.SHA1) == 0 {
+ diagnostics = append(diagnostics, fmt.Sprintf("%s: missing SHA-1 piece hashes", file.Path))
+ }
+ if len(seed.Files) > 1 && i < len(seed.Files)-1 && file.Size%seed.PieceSize != 0 {
+ diagnostics = append(diagnostics, fmt.Sprintf("%s: piece boundary crosses the next file", file.Path))
+ }
+ }
+ case "cas":
+ for _, file := range seed.Files {
+ if file.Hashes.MD5 == "" {
+ diagnostics = append(diagnostics, file.Path+": missing whole-file MD5")
+ }
+ if file.Size > DefaultPieceSize && file.CASSliceMD5 == "" &&
+ (seed.PieceSize != DefaultPieceSize || file.Hashes.Pieces == nil || len(file.Hashes.Pieces.MD5) == 0) {
+ diagnostics = append(diagnostics, file.Path+": missing legacy slice MD5 or complete 10 MiB MD5 pieces")
+ }
+ }
+ case "oss":
+ default:
+ diagnostics = append(diagnostics, fmt.Sprintf("unsupported target format %q", format))
+ }
+ return diagnostics
+}
+
+// TorrentFromSeed constructs standard BitTorrent info/pieces plus x-openlist.
+func TorrentFromSeed(seed *Seed) (*Torrent, []string) {
+ diagnostics := DiagnoseConversion(seed, "torrent")
+ if len(diagnostics) > 0 {
+ return nil, diagnostics
+ }
+ t := &Torrent{
+ Info: TorrentInfo{PieceLength: seed.PieceSize, Name: seed.Name},
+ Comment: seed.Comment,
+ CreatedBy: seed.CreatedBy,
+ CreationDate: time.Now().Unix(),
+ OpenList: seed,
+ }
+ if parsed, err := time.Parse(time.RFC3339, seed.CreatedAt); err == nil {
+ t.CreationDate = parsed.Unix()
+ }
+ if len(seed.Trackers) > 0 {
+ t.Announce = seed.Trackers[0]
+ for _, tracker := range seed.Trackers {
+ t.AnnounceList = append(t.AnnounceList, []string{tracker})
+ }
+ }
+ for _, file := range seed.Files {
+ for _, piece := range file.Hashes.Pieces.SHA1 {
+ raw, _ := hex.DecodeString(piece)
+ t.Info.Pieces = append(t.Info.Pieces, raw...)
+ }
+ if len(seed.Files) == 1 {
+ t.Info.Length = file.Size
+ t.Info.MD5Sum = file.Hashes.MD5
+ } else {
+ t.Info.Files = append(t.Info.Files, TorrentFile{
+ Length: file.Size,
+ Path: strings.Split(file.Path, "/"),
+ MD5Sum: file.Hashes.MD5,
+ })
+ }
+ }
+ if len(seed.Files) == 1 {
+ file := seed.Files[0]
+ if file.Hashes.MD5 != "" && file.CASSliceMD5 != "" {
+ cloud := file.CASCloud
+ if cloud == "" {
+ cloud = Cloud189
+ }
+ t.CAS = &CASInfo{
+ FileMD5: strings.ToUpper(file.Hashes.MD5), SliceMD5: strings.ToUpper(file.CASSliceMD5),
+ SliceSize: DefaultPieceSize, Cloud: cloud,
+ }
+ } else if file.Hashes.MD5 != "" && seed.PieceSize == DefaultPieceSize && file.Hashes.Pieces != nil && len(file.Hashes.Pieces.MD5) > 0 {
+ cloud := file.CASCloud
+ if cloud == "" {
+ cloud = Cloud189
+ }
+ t.CAS = BuildCASInfoFromMD5sWithCloud(file.Hashes.MD5, upperStrings(file.Hashes.Pieces.MD5), DefaultPieceSize, cloud)
+ }
+ }
+ return t, nil
+}
+
+func validateOpenListTorrentConsistency(t *Torrent, seed *Seed) error {
+ if seed.Name != t.Info.Name || seed.PieceSize != t.Info.PieceLength {
+ return fmt.Errorf("x-openlist metadata conflicts with torrent info")
+ }
+ if len(t.Info.Files) == 0 {
+ if len(seed.Files) != 1 || seed.Files[0].Size != t.Info.Length {
+ return fmt.Errorf("x-openlist file list conflicts with torrent info")
+ }
+ } else {
+ if len(seed.Files) != len(t.Info.Files) {
+ return fmt.Errorf("x-openlist file count conflicts with torrent info")
+ }
+ for i, file := range t.Info.Files {
+ if seed.Files[i].Path != strings.Join(file.Path, "/") || seed.Files[i].Size != file.Length {
+ return fmt.Errorf("x-openlist file %d conflicts with torrent info", i)
+ }
+ }
+ }
+ standardPieces := t.GetPieceHashes()
+ extensionPieces := make([]string, 0, len(standardPieces))
+ pieceBoundariesAligned := true
+ for i, file := range seed.Files {
+ if i < len(seed.Files)-1 && file.Size%seed.PieceSize != 0 {
+ pieceBoundariesAligned = false
+ }
+ if file.Hashes.Pieces != nil {
+ extensionPieces = append(extensionPieces, file.Hashes.Pieces.SHA1...)
+ }
+ }
+ if pieceBoundariesAligned && len(extensionPieces) > 0 {
+ if len(extensionPieces) != len(standardPieces) {
+ return fmt.Errorf("x-openlist SHA-1 pieces conflict with torrent info")
+ }
+ for i, piece := range standardPieces {
+ if !strings.EqualFold(extensionPieces[i], hex.EncodeToString(piece)) {
+ return fmt.Errorf("x-openlist SHA-1 piece %d conflicts with torrent info", i)
+ }
+ }
+ }
+ return nil
+}
+
+// SeedFromTorrent normalizes a standard torrent and preserves x-openlist when present.
+func SeedFromTorrent(t *Torrent, limits ParseLimits) (*Seed, error) {
+ if t == nil {
+ return nil, fmt.Errorf("missing torrent")
+ }
+ if t.OpenList != nil {
+ if err := ValidateSeed(t.OpenList, limits); err != nil {
+ return nil, err
+ }
+ if err := validateOpenListTorrentConsistency(t, t.OpenList); err != nil {
+ return nil, err
+ }
+ return t.OpenList, nil
+ }
+ seed := NewSeed(t.Info.Name, t.CreatedBy, t.Info.PieceLength)
+ if seed.CreatedBy == "" {
+ seed.CreatedBy = "BitTorrent"
+ }
+ if t.CreationDate > 0 {
+ seed.CreatedAt = time.Unix(t.CreationDate, 0).UTC().Format(time.RFC3339)
+ }
+ seed.Comment = t.Comment
+ seed.Trackers = append(seed.Trackers, t.Announce)
+ for _, tier := range t.AnnounceList {
+ seed.Trackers = append(seed.Trackers, tier...)
+ }
+ seed.Trackers = uniqueNonEmpty(seed.Trackers)
+ pieceHex := make([]string, 0, len(t.Info.Pieces)/sha1.Size)
+ for _, piece := range t.GetPieceHashes() {
+ pieceHex = append(pieceHex, hex.EncodeToString(piece))
+ }
+ if len(t.Info.Files) == 0 {
+ hashes := SeedHashes{MD5: t.Info.MD5Sum}
+ if len(pieceHex) > 0 {
+ hashes.Pieces = &SeedPieceHashes{SHA1: pieceHex}
+ }
+ if t.CAS != nil {
+ hashes.MD5 = t.CAS.FileMD5
+ if hashes.Pieces == nil {
+ hashes.Pieces = &SeedPieceHashes{}
+ }
+ hashes.Pieces.MD5 = append([]string(nil), t.CAS.SliceMD5s...)
+ }
+ seed.Files = []SeedFile{{Path: t.Info.Name, Size: t.Info.Length, Hashes: hashes}}
+ } else {
+ pieceOffset := 0
+ for i, file := range t.Info.Files {
+ hashes := SeedHashes{MD5: file.MD5Sum}
+ pieceCount := 0
+ if file.Length > 0 && t.Info.PieceLength > 0 {
+ pieceCount = int((file.Length + t.Info.PieceLength - 1) / t.Info.PieceLength)
+ }
+ aligned := i == len(t.Info.Files)-1 || file.Length%t.Info.PieceLength == 0
+ if aligned && pieceOffset+pieceCount <= len(pieceHex) {
+ hashes.Pieces = &SeedPieceHashes{SHA1: append([]string(nil), pieceHex[pieceOffset:pieceOffset+pieceCount]...)}
+ }
+ pieceOffset += pieceCount
+ seed.Files = append(seed.Files, SeedFile{Path: strings.Join(file.Path, "/"), Size: file.Length, Hashes: hashes})
+ }
+ }
+ if err := ValidateSeed(seed, limits); err != nil {
+ return nil, err
+ }
+ return seed, nil
+}
+
+func seedToBencode(seed *Seed) map[string]interface{} {
+ root := map[string]interface{}{
+ "format": seed.Format, "version": int64(seed.Version), "name": seed.Name,
+ "created_at": seed.CreatedAt, "created_by": seed.CreatedBy, "piece_size": seed.PieceSize,
+ }
+ if seed.Comment != "" {
+ root["comment"] = seed.Comment
+ }
+ if len(seed.Trackers) > 0 {
+ root["trackers"] = stringsToInterfaces(seed.Trackers)
+ }
+ channels := make([]interface{}, 0, len(seed.Channels))
+ for _, channel := range seed.Channels {
+ item := map[string]interface{}{"driver": channel.Driver}
+ if channel.MountPath != "" {
+ item["mount_path"] = channel.MountPath
+ }
+ channels = append(channels, item)
+ }
+ if len(channels) > 0 {
+ root["channels"] = channels
+ }
+ files := make([]interface{}, 0, len(seed.Files))
+ for _, file := range seed.Files {
+ item := map[string]interface{}{"path": file.Path, "size": file.Size, "hashes": hashesToBencode(file.Hashes)}
+ if file.Modified != "" {
+ item["modified"] = file.Modified
+ }
+ if file.Comment != "" {
+ item["comment"] = file.Comment
+ }
+ if file.CASSliceMD5 != "" {
+ item["cas_slice_md5"] = file.CASSliceMD5
+ }
+ if file.CASCreateTime != "" {
+ item["cas_create_time"] = file.CASCreateTime
+ }
+ if file.CASCloud != "" {
+ item["cas_cloud"] = file.CASCloud
+ }
+ if len(file.MissingChannels) > 0 {
+ item["missing_channels"] = stringsToInterfaces(file.MissingChannels)
+ }
+ sources := make([]interface{}, 0, len(file.Sources))
+ for _, source := range file.Sources {
+ s := map[string]interface{}{"type": source.Type, "url": source.URL}
+ if source.ExpiresAt != "" {
+ s["expires_at"] = source.ExpiresAt
+ }
+ if source.ShareID != "" {
+ s["share_id"] = source.ShareID
+ }
+ sources = append(sources, s)
+ }
+ if len(sources) > 0 {
+ item["sources"] = sources
+ }
+ files = append(files, item)
+ }
+ root["files"] = files
+ return root
+}
+
+func hashesToBencode(hashes SeedHashes) map[string]interface{} {
+ result := make(map[string]interface{})
+ if hashes.MD5 != "" {
+ result["md5"] = hashes.MD5
+ }
+ if hashes.SHA1 != "" {
+ result["sha1"] = hashes.SHA1
+ }
+ if hashes.SHA256 != "" {
+ result["sha256"] = hashes.SHA256
+ }
+ if hashes.Pieces != nil {
+ pieces := make(map[string]interface{})
+ if len(hashes.Pieces.MD5) > 0 {
+ pieces["md5"] = stringsToInterfaces(hashes.Pieces.MD5)
+ }
+ if len(hashes.Pieces.SHA1) > 0 {
+ pieces["sha1"] = stringsToInterfaces(hashes.Pieces.SHA1)
+ }
+ if len(hashes.Pieces.SHA256) > 0 {
+ pieces["sha256"] = stringsToInterfaces(hashes.Pieces.SHA256)
+ }
+ if len(pieces) > 0 {
+ result["pieces"] = pieces
+ }
+ }
+ return result
+}
+
+func seedFromBencode(value interface{}) (*Seed, error) {
+ root, ok := value.(map[string]interface{})
+ if !ok {
+ return nil, fmt.Errorf("extension is not a dictionary")
+ }
+ seed := &Seed{
+ Format: bString(root["format"]), Version: int(bInt(root["version"])), Name: bString(root["name"]),
+ Comment: bString(root["comment"]), CreatedAt: bString(root["created_at"]),
+ CreatedBy: bString(root["created_by"]), PieceSize: bInt(root["piece_size"]),
+ Trackers: bStrings(root["trackers"]),
+ }
+ for _, value := range bList(root["channels"]) {
+ item, ok := value.(map[string]interface{})
+ if !ok {
+ return nil, fmt.Errorf("channel is not a dictionary")
+ }
+ seed.Channels = append(seed.Channels, SeedChannel{Driver: bString(item["driver"]), MountPath: bString(item["mount_path"])})
+ }
+ for _, value := range bList(root["files"]) {
+ item, ok := value.(map[string]interface{})
+ if !ok {
+ return nil, fmt.Errorf("file is not a dictionary")
+ }
+ file := SeedFile{
+ Path: bString(item["path"]), Size: bInt(item["size"]), Modified: bString(item["modified"]), Comment: bString(item["comment"]),
+ CASSliceMD5: bString(item["cas_slice_md5"]), CASCreateTime: bString(item["cas_create_time"]),
+ CASCloud: bString(item["cas_cloud"]),
+ MissingChannels: bStrings(item["missing_channels"]),
+ }
+ if hashes, ok := item["hashes"].(map[string]interface{}); ok {
+ file.Hashes = SeedHashes{MD5: bString(hashes["md5"]), SHA1: bString(hashes["sha1"]), SHA256: bString(hashes["sha256"])}
+ if pieces, ok := hashes["pieces"].(map[string]interface{}); ok {
+ file.Hashes.Pieces = &SeedPieceHashes{MD5: bStrings(pieces["md5"]), SHA1: bStrings(pieces["sha1"]), SHA256: bStrings(pieces["sha256"])}
+ }
+ }
+ for _, sourceValue := range bList(item["sources"]) {
+ source, ok := sourceValue.(map[string]interface{})
+ if !ok {
+ return nil, fmt.Errorf("source is not a dictionary")
+ }
+ file.Sources = append(file.Sources, SeedSource{Type: bString(source["type"]), URL: bString(source["url"]), ExpiresAt: bString(source["expires_at"]), ShareID: bString(source["share_id"])})
+ }
+ seed.Files = append(seed.Files, file)
+ }
+ return seed, nil
+}
+
+func stringsToInterfaces(values []string) []interface{} {
+ result := make([]interface{}, len(values))
+ for i := range values {
+ result[i] = values[i]
+ }
+ return result
+}
+
+func bString(value interface{}) string {
+ switch value := value.(type) {
+ case []byte:
+ return string(value)
+ case string:
+ return value
+ default:
+ return ""
+ }
+}
+
+func bInt(value interface{}) int64 {
+ valueInt, _ := value.(int64)
+ return valueInt
+}
+
+func bList(value interface{}) []interface{} {
+ list, _ := value.([]interface{})
+ return list
+}
+
+func bStrings(value interface{}) []string {
+ list := bList(value)
+ result := make([]string, 0, len(list))
+ for _, item := range list {
+ if text := bString(item); text != "" {
+ result = append(result, text)
+ }
+ }
+ return result
+}
+
+func upperStrings(values []string) []string {
+ result := make([]string, len(values))
+ for i, value := range values {
+ result[i] = strings.ToUpper(value)
+ }
+ return result
+}
+
+func lowerStrings(values []string) []string {
+ result := make([]string, len(values))
+ for i, value := range values {
+ result[i] = strings.ToLower(value)
+ }
+ return result
+}
+
+func uniqueNonEmpty(values []string) []string {
+ seen := make(map[string]struct{}, len(values))
+ result := make([]string, 0, len(values))
+ for _, value := range values {
+ if value == "" {
+ continue
+ }
+ if _, ok := seen[value]; ok {
+ continue
+ }
+ seen[value] = struct{}{}
+ result = append(result, value)
+ }
+ return result
+}
diff --git a/server/handles/auth.go b/server/handles/auth.go
index 2013a5c2..75d3b4d7 100644
--- a/server/handles/auth.go
+++ b/server/handles/auth.go
@@ -11,6 +11,7 @@ import (
"github.com/OpenListTeam/OpenList/v4/internal/model"
"github.com/OpenListTeam/OpenList/v4/internal/op"
"github.com/OpenListTeam/OpenList/v4/server/common"
+ "github.com/OpenListTeam/go-cache"
"github.com/gin-gonic/gin"
"github.com/pquerna/otp/totp"
"gorm.io/gorm"
@@ -48,21 +49,22 @@ func loginHash(c *gin.Context, req *LoginReq) {
ip := c.ClientIP()
count, ok := model.LoginCache.Get(ip)
if ok && count >= model.DefaultMaxAuthRetries {
+ // 被拒时不能再续期:自动重试的客户端(如凭据失效的 Subsonic/Web 轮询)
+ // 会每来一次就刷新锁定窗口,把自己永久锁死。锁定窗口只在计数时设定。
common.ErrorStrResp(c, model.TooManyAttempts, 429)
- model.LoginCache.Expire(ip, model.DefaultLockDuration)
return
}
// check username
user, err := op.GetUserByName(req.Username)
if err != nil {
common.ErrorStrResp(c, model.InvalidUsernameOrPassword, 401)
- model.LoginCache.Set(ip, count+1)
+ model.LoginCache.Set(ip, count+1, cache.WithEx[int](model.DefaultLockDuration))
return
}
// validate password hash
if err := user.ValidatePwdStaticHash(req.Password); err != nil {
common.ErrorStrResp(c, model.InvalidUsernameOrPassword, 401)
- model.LoginCache.Set(ip, count+1)
+ model.LoginCache.Set(ip, count+1, cache.WithEx[int](model.DefaultLockDuration))
return
}
// check 2FA
@@ -70,7 +72,7 @@ func loginHash(c *gin.Context, req *LoginReq) {
if !totp.Validate(req.OtpCode, user.OtpSecret) {
// 402 - need opt
common.ErrorStrResp(c, model.Invalid2FACode, 402)
- model.LoginCache.Set(ip, count+1)
+ model.LoginCache.Set(ip, count+1, cache.WithEx[int](model.DefaultLockDuration))
return
}
}
diff --git a/server/handles/down.go b/server/handles/down.go
index 50025a0b..82c44304 100644
--- a/server/handles/down.go
+++ b/server/handles/down.go
@@ -51,7 +51,9 @@ func Proxy(c *gin.Context) {
common.ErrorPage(c, err, 500)
return
}
- if canProxy(storage, filename) {
+ // 支持 ?force 参数强制代理(用于媒体库等需要 JS 加载文件内容的场景,避免 CORS 问题)
+ _, forceProxy := c.GetQuery("force")
+ if forceProxy || canProxy(storage, filename) {
if _, ok := c.GetQuery("d"); !ok {
if url := common.GenerateDownProxyURL(storage.GetStorage(), rawPath); url != "" {
c.Redirect(302, url)
diff --git a/server/handles/emby.go b/server/handles/emby.go
new file mode 100644
index 00000000..85c0389d
--- /dev/null
+++ b/server/handles/emby.go
@@ -0,0 +1,1246 @@
+package handles
+
+import (
+ "crypto/md5"
+ "crypto/sha1"
+ "encoding/hex"
+ "encoding/json"
+ "hash/fnv"
+ "net"
+ "net/http"
+ stdpath "path"
+ "regexp"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/gorilla/websocket"
+
+ "github.com/OpenListTeam/OpenList/v4/internal/conf"
+ "github.com/OpenListTeam/OpenList/v4/internal/db"
+ "github.com/OpenListTeam/OpenList/v4/internal/media"
+ "github.com/OpenListTeam/OpenList/v4/internal/model"
+ "github.com/OpenListTeam/OpenList/v4/internal/op"
+ "github.com/OpenListTeam/OpenList/v4/internal/setting"
+ "github.com/OpenListTeam/OpenList/v4/pkg/utils"
+ "github.com/OpenListTeam/OpenList/v4/server/common"
+ "github.com/gin-gonic/gin"
+)
+
+// ==================== 数据结构(Emby 使用 PascalCase 字段名) ====================
+
+type embyNameID struct {
+ Name string
+ // Id 在官方 DTO 中是 Guid?,空字符串必须省略
+ Id string `json:",omitempty"`
+}
+
+type embyUserData struct {
+ PlaybackPositionTicks int64
+ PlayCount int
+ IsFavorite bool
+ Played bool
+ UnplayedItemCount int
+ PlayedPercentage float64
+ Rating float64 `json:",omitempty"`
+ Likes bool
+ // LastPlayedDate 是 DateTime?,空字符串必须省略
+ LastPlayedDate string `json:",omitempty"`
+}
+
+// embyMediaSource 对应官方 MediaSourceInfo。
+// 客户端按 SupportsDirectPlay -> SupportsDirectStream -> TranscodingUrl 的顺序判定播放方式,
+// 同时读取 MediaStreams(视频/音频轨)和 RequiresOpening 等字段,缺失会被判定为"不可播放"。
+type embyMediaSource struct {
+ Protocol string
+ Id string
+ Path string `json:",omitempty"`
+ Type string
+ Container string `json:",omitempty"`
+ Name string `json:",omitempty"`
+ Size int64 `json:",omitempty"`
+ IsRemote bool
+ ETag string `json:",omitempty"`
+ RunTimeTicks int64 `json:",omitempty"`
+ ReadAtNativeFramerate bool
+ IgnoreDts bool
+ IgnoreIndex bool
+ GenPtsInput bool
+ SupportsTranscoding bool
+ SupportsDirectStream bool
+ SupportsDirectPlay bool
+ IsInfiniteStream bool
+ RequiresOpening bool
+ RequiresClosing bool
+ RequiresLooping bool
+ SupportsProbing bool
+ VideoType string `json:",omitempty"`
+ MediaStreams []embyMediaStream
+ MediaAttachments []any
+ Formats []string
+ Bitrate int `json:",omitempty"`
+ RequiredHttpHeaders map[string]string
+ DirectStreamUrl string `json:",omitempty"`
+ AddApiKeyToDirectStreamUrl bool
+ DefaultAudioStreamIndex int `json:",omitempty"`
+ // DefaultSubtitleStreamIndex 指向歌词字幕轨:客户端歌词面板只认
+ // Type=="Subtitle" 且 Index 等于该值的流,缺失就不请求歌词
+ DefaultSubtitleStreamIndex *int `json:",omitempty"`
+ ItemId string `json:",omitempty"`
+}
+
+// embyMediaStream 对应官方 MediaStream,只填写客户端判定播放所需的最小字段。
+type embyMediaStream struct {
+ Codec string `json:",omitempty"`
+ Type string
+ Index int
+ IsDefault bool
+ IsForced bool
+ IsExternal bool
+ IsInterlaced bool
+ IsTextSubtitleStream bool
+ SupportsExternalStream bool
+ Channels int `json:",omitempty"`
+ SampleRate int `json:",omitempty"`
+ BitRate int `json:",omitempty"`
+ DisplayTitle string `json:",omitempty"`
+ Language string `json:",omitempty"`
+ DeliveryMethod string `json:",omitempty"`
+ DeliveryUrl string `json:",omitempty"`
+}
+
+// embyItem 字段名与类型严格对应 Emby/Jellyfin 的 BaseItemDto:
+// Id/ParentId/SeriesId/SeasonId/AlbumId 是 Guid,DateCreated/PremiereDate/EndDate 是 DateTime?。
+// 空值必须省略(omitempty),否则客户端会把 "" 当作 Guid/DateTime 解析并抛异常。
+type embyItem struct {
+ Name string `json:",omitempty"`
+ ServerId string `json:",omitempty"`
+ Id string `json:",omitempty"`
+ DateCreated string `json:",omitempty"`
+ Container string `json:",omitempty"`
+ SortName string `json:",omitempty"`
+ PremiereDate string `json:",omitempty"`
+ Overview string `json:",omitempty"`
+ ProductionYear int `json:",omitempty"`
+ CommunityRating float32 `json:",omitempty"`
+ OfficialRating string `json:",omitempty"`
+ RunTimeTicks int64 `json:",omitempty"`
+ IndexNumber int `json:",omitempty"`
+ ParentIndexNumber int `json:",omitempty"`
+ Type string `json:",omitempty"`
+ MediaType string `json:",omitempty"`
+ IsFolder bool
+ ParentId string `json:",omitempty"`
+ SeriesId string `json:",omitempty"`
+ SeriesName string `json:",omitempty"`
+ SeasonId string `json:",omitempty"`
+ SeasonName string `json:",omitempty"`
+ Album string `json:",omitempty"`
+ AlbumId string `json:",omitempty"`
+ AlbumArtist string `json:",omitempty"`
+ AlbumArtists []embyNameID
+ Artists []string
+ ArtistItems []embyNameID
+ Genres []string
+ GenreItems []embyNameID
+ Studios []embyNameID
+ People []any
+ MediaStreams []any
+ Taglines []string
+ ProductionLocations []string
+ Keywords []string
+ RemoteTrailers []any
+ ExternalUrls []any
+ LockedFields []string
+ Path string `json:",omitempty"`
+ ImageTags map[string]string
+ BackdropImageTags []string
+ ProviderIds map[string]string
+ UserData *embyUserData
+ MediaSources []embyMediaSource
+ ChildCount int `json:",omitempty"`
+ LocationType string `json:",omitempty"`
+ CollectionType string `json:",omitempty"`
+ ViewType string `json:",omitempty"`
+ // Subviews 是电影库页面的必需字段:客户端 videos.js 直接读取
+ // item.Subviews 并调用 .includes(),缺失会抛 undefined.includes 导致整页报错
+ Subviews []string `json:",omitempty"`
+ EndDate string `json:",omitempty"`
+ Status string `json:",omitempty"`
+ PlayAccess string `json:",omitempty"`
+ Video3DFormat string `json:",omitempty"`
+ PreferredMetadataLanguage string `json:",omitempty"`
+ PreferredMetadataCountryCode string `json:",omitempty"`
+ CleanName string `json:",omitempty"`
+ PresentationUniqueKey string `json:",omitempty"`
+ ShortOverview string `json:",omitempty"`
+ HomePageUrl string `json:",omitempty"`
+ SeriesPrimaryImageTag string `json:",omitempty"`
+ PrimaryImageTag string `json:",omitempty"`
+ ParentThumbItemId string `json:",omitempty"`
+ ParentThumbImageTag string `json:",omitempty"`
+ ParentPrimaryImageItemId string `json:",omitempty"`
+ ParentPrimaryImageTag string `json:",omitempty"`
+ AlbumPrimaryImageTag string `json:",omitempty"`
+ ParentBackdropItemId string `json:",omitempty"`
+ AirTime string `json:",omitempty"`
+ PrimaryImageAspectRatio float64 `json:",omitempty"`
+ CriticRating float64 `json:",omitempty"`
+ MediaSourceCount int `json:",omitempty"`
+ LocalTrailerCount int `json:",omitempty"`
+ RecursiveItemCount int `json:",omitempty"`
+ SpecialFeatureCount int `json:",omitempty"`
+ PartCount int `json:",omitempty"`
+ IsHD bool
+ CanDelete bool
+ CanDownload bool
+ SupportsSync bool
+ IsPlaceHolder bool
+ LockData bool
+ ImageBlurHashes map[string]any
+ TagItems []embyNameID
+ AirDays []string
+ ParentBackdropImageTags []string
+ OriginalTitle string `json:",omitempty"`
+ ForcedSortName string `json:",omitempty"`
+ Etag string `json:",omitempty"`
+ DateLastMediaAdded string `json:",omitempty"`
+ CustomRating string `json:",omitempty"`
+ ChannelId string `json:",omitempty"`
+ ChannelName string `json:",omitempty"`
+ FileName string `json:",omitempty"`
+ Number string `json:",omitempty"`
+ SeriesStudio string `json:",omitempty"`
+ AspectRatio string `json:",omitempty"`
+ DisplayPreferencesId string `json:",omitempty"`
+ ParentLogoItemId string `json:",omitempty"`
+ ParentLogoImageTag string `json:",omitempty"`
+ IsoType string `json:",omitempty"`
+ Software string `json:",omitempty"`
+ CameraMake string `json:",omitempty"`
+ CameraModel string `json:",omitempty"`
+ Size int64 `json:",omitempty"`
+ Bitrate int `json:",omitempty"`
+ IndexNumberEnd int `json:",omitempty"`
+ DisplayOrder string `json:",omitempty"`
+ Width int `json:",omitempty"`
+ Height int `json:",omitempty"`
+ Chapters []any
+ Tags []string
+}
+
+// MarshalJSON 把 nil 切片/映射输出为空数组/空对象。
+// Emby 客户端会对部分字段直接调用 .includes(),
+// JSON 里出现 null 或字段缺失都会让客户端抛异常。
+func (e embyItem) MarshalJSON() ([]byte, error) {
+ type alias embyItem
+ a := alias(e)
+ if a.AlbumArtists == nil {
+ a.AlbumArtists = []embyNameID{}
+ }
+ if a.Artists == nil {
+ a.Artists = []string{}
+ }
+ if a.ArtistItems == nil {
+ a.ArtistItems = []embyNameID{}
+ }
+ if a.Genres == nil {
+ a.Genres = []string{}
+ }
+ if a.GenreItems == nil {
+ a.GenreItems = []embyNameID{}
+ }
+ if a.Studios == nil {
+ a.Studios = []embyNameID{}
+ }
+ if a.People == nil {
+ a.People = []any{}
+ }
+ if a.MediaStreams == nil {
+ a.MediaStreams = []any{}
+ }
+ if a.Taglines == nil {
+ a.Taglines = []string{}
+ }
+ if a.ProductionLocations == nil {
+ a.ProductionLocations = []string{}
+ }
+ if a.Keywords == nil {
+ a.Keywords = []string{}
+ }
+ if a.RemoteTrailers == nil {
+ a.RemoteTrailers = []any{}
+ }
+ if a.ExternalUrls == nil {
+ a.ExternalUrls = []any{}
+ }
+ if a.LockedFields == nil {
+ a.LockedFields = []string{}
+ }
+ if a.ImageTags == nil {
+ a.ImageTags = map[string]string{}
+ }
+ // 没有封面时不输出 Primary:空 tag 会让 Theater 拼出 ?tag= 的无效图片请求,
+ // openemby_tv 等客户端也只在 tag 非空时才请求图片
+ if v, ok := a.ImageTags["Primary"]; ok && v == "" {
+ delete(a.ImageTags, "Primary")
+ }
+ // 剧集/歌曲等子项没有自己的封面时,让客户端回退到父级封面,保证列表不出现空白图
+ if a.ImageTags["Primary"] == "" && a.ParentPrimaryImageTag != "" {
+ if a.ParentPrimaryImageItemId == "" {
+ a.ParentPrimaryImageItemId = a.ParentId
+ }
+ }
+ if a.BackdropImageTags == nil {
+ a.BackdropImageTags = []string{}
+ }
+ if a.ProviderIds == nil {
+ a.ProviderIds = map[string]string{}
+ }
+ if a.MediaSources == nil {
+ a.MediaSources = []embyMediaSource{}
+ }
+ if a.ImageBlurHashes == nil {
+ a.ImageBlurHashes = map[string]any{}
+ }
+ if a.TagItems == nil {
+ a.TagItems = []embyNameID{}
+ }
+ if a.AirDays == nil {
+ a.AirDays = []string{}
+ }
+ if a.ParentBackdropImageTags == nil {
+ a.ParentBackdropImageTags = []string{}
+ }
+ if a.Chapters == nil {
+ a.Chapters = []any{}
+ }
+ if a.Tags == nil {
+ a.Tags = []string{}
+ }
+ if a.UserData == nil {
+ a.UserData = &embyUserData{}
+ }
+ // PlayAccess 为空会让客户端判定"播放受限"
+ if a.PlayAccess == "" {
+ a.PlayAccess = "Full"
+ }
+ if !a.IsFolder {
+ a.CanDownload = true
+ }
+ // 客户端会直接展示 CleanName,并把它当作排序/查找键
+ if a.CleanName == "" {
+ a.CleanName = a.Name
+ }
+ if a.PresentationUniqueKey == "" {
+ a.PresentationUniqueKey = a.Id
+ }
+ return json.Marshal(a)
+}
+
+type embyQueryResult struct {
+ Items []*embyItem
+ TotalRecordCount int
+ StartIndex int
+}
+
+type embyUserDto struct {
+ Name string
+ ServerId string
+ Id string
+ HasPassword bool
+ HasConfiguredPassword bool
+ Configuration map[string]any
+ Policy map[string]any
+ PrimaryImageTag string `json:",omitempty"`
+ // DateTime? 字段空值必须省略
+ LastLoginDate string `json:",omitempty"`
+ LastActivityDate string `json:",omitempty"`
+ IsAdministrator bool
+ IsDisabled bool
+}
+
+type embyAuthResult struct {
+ User *model.User
+ AccessToken string
+ ServerId string
+}
+
+// embyPublicUserDto 登录前展示的用户信息。
+// Emby 客户端依赖 /Users/Public 渲染用户选择列表,返回空数组会跳过登录界面。
+type embyPublicUserDto struct {
+ Name string
+ ServerId string
+ Id string
+ HasPassword bool
+ PrimaryImageTag string
+}
+
+// ==================== 基础工具 ====================
+
+func embyServerID() string {
+ seed := conf.Conf.SiteURL + conf.Conf.JwtSecret + strconv.Itoa(conf.Conf.Scheme.HttpPort)
+ sum := sha1.Sum([]byte("openlist-emby:" + seed))
+ // Emby 的 ServerId 是 32 位 hex GUID,客户端本地库会用 Guid 解析
+ return hex.EncodeToString(sum[:16])
+}
+
+// embyCompatVersion 是返回给 Emby 客户端的兼容版本号。
+// Emby 客户端会把该值与其要求的最低服务器版本比较,直接返回 OpenList 版本
+// (如 v4.1.0 / dev)会被判定为"服务器需要升级"而拒绝连接。
+// 客户端在 4.10.0.4 起改用服务端 HomeSections API;
+// 低于该版本会走本地生成逻辑,其 section 缺少 ListOptions.fields 会直接报错。
+const embyCompatVersion = "4.10.0.5"
+
+func embyVersion() string {
+ return embyCompatVersion
+}
+
+func embyTicks(seconds int) int64 {
+ if seconds <= 0 {
+ return 0
+ }
+ return int64(seconds) * 10_000_000
+}
+
+func embyTimeStr(t time.Time) string {
+ return t.UTC().Format("2006-01-02T15:04:05.0000000Z")
+}
+
+func embyExt(name string) string {
+ ext := strings.TrimPrefix(strings.ToLower(stdpath.Ext(name)), ".")
+ if ext == "" {
+ return "mkv"
+ }
+ return ext
+}
+
+var embyAuthHeaderRe = regexp.MustCompile(`(?i)([A-Za-z]+)\s*=\s*"([^"]*)"`)
+
+func embyParseAuthHeader(v string) map[string]string {
+ out := map[string]string{}
+ for _, m := range embyAuthHeaderRe.FindAllStringSubmatch(v, -1) {
+ out[strings.ToLower(m[1])] = m[2]
+ }
+ return out
+}
+
+func embyRequestToken(c *gin.Context) string {
+ // Emby Windows 等客户端会把 X-Emby-Token 直接放在 URL query 里,
+ // 必须与 api_key / ApiKey 一起解析,否则登录成功后每个请求仍是 401
+ for key, values := range c.Request.URL.Query() {
+ if len(values) == 0 || values[0] == "" {
+ continue
+ }
+ switch {
+ case strings.EqualFold(key, "api_key"),
+ strings.EqualFold(key, "ApiKey"),
+ strings.EqualFold(key, "X-Emby-Token"),
+ strings.EqualFold(key, "X-MediaBrowser-Token"):
+ return strings.TrimSpace(values[0])
+ }
+ }
+ for _, h := range []string{"X-Emby-Token", "X-MediaBrowser-Token"} {
+ if t := strings.TrimSpace(c.GetHeader(h)); t != "" {
+ return t
+ }
+ }
+ if v := c.GetHeader("X-Emby-Authorization"); v != "" {
+ if t, ok := embyParseAuthHeader(v)["token"]; ok && t != "" {
+ return t
+ }
+ }
+ if v := c.GetHeader("Authorization"); strings.HasPrefix(strings.ToLower(v), "mediabrowser ") {
+ if t, ok := embyParseAuthHeader(v)["token"]; ok && t != "" {
+ return t
+ }
+ }
+ return ""
+}
+
+// embyAuth 解析请求身份,token 可以是设备会话令牌或媒体应用密钥
+func embyAuth(c *gin.Context) (*model.User, bool) {
+ token := embyRequestToken(c)
+ if token == "" {
+ return nil, false
+ }
+ if dev, err := db.GetEmbyDeviceByTokenHash(media.HashToken(token)); err == nil {
+ db.TouchEmbyDevice(dev.ID)
+ user, err := op.GetUserById(dev.UserID)
+ if err != nil || user.Disabled {
+ return nil, false
+ }
+ return user, true
+ }
+ user, _, ok := LookUpAppKeyUser(token)
+ if !ok {
+ return nil, false
+ }
+ return user, true
+}
+
+// embyUserGUID 返回给客户端的用户 ID(Emby 规范为 32 位 hex GUID)
+func embyUserGUID(id uint) string {
+ return embyGUID("user-" + strconv.FormatUint(uint64(id), 10))
+}
+
+// embyResolveUserID 兼容数字 ID 与 GUID 两种形式
+func embyResolveUserID(idStr string) (uint, bool) {
+ if n, err := strconv.ParseUint(idStr, 10, 64); err == nil {
+ return uint(n), true
+ }
+ users, _, err := op.GetUsers(1, -1)
+ if err != nil {
+ return 0, false
+ }
+ for i := range users {
+ if embyUserGUID(users[i].ID) == idStr {
+ return users[i].ID, true
+ }
+ }
+ return 0, false
+}
+
+func embyUserDtoOf(u *model.User) *embyUserDto {
+ // Emby 客户端的 PlayAccessValidation 会直接读取 Policy.EnableMediaPlayback,
+ // 缺失即判定为禁止播放,并弹出 "Playback of this content is currently restricted"。
+ // 这里按 Emby 的 UserPolicy 结构给全默认值,避免任何字段被读成 undefined。
+ policy := map[string]any{
+ "IsAdministrator": u.IsAdmin(),
+ "IsDisabled": u.Disabled,
+ "IsHidden": false,
+ "EnableMediaPlayback": true,
+ "EnableAudioPlaybackTranscoding": true,
+ "EnableVideoPlaybackTranscoding": true,
+ "EnablePlaybackRemuxing": true,
+ "EnableContentDownloading": true,
+ "EnableSyncTranscoding": true,
+ "EnableSubtitleDownloading": true,
+ "EnableSubtitleManagement": true,
+ "EnableRemoteControlOfOtherUsers": false,
+ "EnableSharedDeviceControl": true,
+ "EnableRemoteAccess": true,
+ "EnableLiveTvManagement": false,
+ "EnableLiveTvAccess": false,
+ "EnableAllChannels": true,
+ "EnableAllFolders": true,
+ "EnableAllDevices": true,
+ "ForceRemoteSourceTranscoding": false,
+ "EnabledFolders": []string{},
+ "EnabledChannels": []string{},
+ "EnabledDevices": []string{},
+ "BlockedChannels": []string{},
+ "BlockedMediaFolders": []string{},
+ "BlockedTags": []string{},
+ "AllowedTags": []string{},
+ "MaxParentalRating": nil,
+ "AuthenticationProviderId": "Default",
+ "PasswordResetProviderId": "Default",
+ }
+ return &embyUserDto{
+ Name: u.Username,
+ ServerId: embyServerID(),
+ Id: embyUserGUID(u.ID),
+ HasPassword: u.PwdHash != "",
+ HasConfiguredPassword: u.PwdHash != "",
+ Configuration: map[string]any{},
+ Policy: policy,
+ IsAdministrator: u.IsAdmin(),
+ IsDisabled: u.Disabled,
+ }
+}
+
+// embyWebNoticeHTML 浏览器直接打开 @emby 时显示的提示页。
+// OpenList 只实现 Emby API,没有 Emby Web 客户端静态资源。
+const embyWebNoticeHTML = `
+
+
+OpenList - Emby API
+
+OpenList 仅提供 Emby 兼容 API
+当前地址没有 Emby Web 客户端界面,请使用支持 Emby 协议的客户端添加服务器。
+
+| 服务器地址 | http://你的地址:端口/@emby |
+| 用户名 | OpenList 用户名 |
+| 密码 | 媒体应用密钥(管理 - 媒体库 - 应用密钥) |
+
+推荐客户端:Infuse、VidHub、SenPlayer、Emby 官方 App 等。
+
+This endpoint only serves the Emby-compatible API. Please use an Emby client instead of a browser.
+`
+
+// ==================== 请求分发 ====================
+
+// EmbyAPI Emby 兼容 API 入口,兼容 /@emby 与 /@mediabrowser 前缀,路径大小写不敏感
+func EmbyAPI(c *gin.Context) {
+ if !MediaEmbyEnabled() {
+ c.AbortWithStatus(404)
+ return
+ }
+ raw := strings.Trim(c.Param("path"), "/")
+ if raw == "" {
+ c.JSON(200, gin.H{"Service": "OpenList", "Version": embyVersion()})
+ return
+ }
+ // 兼容把接入地址填成 .../@emby 后客户端又自动追加 /emby(或 /mediabrowser)的情况
+ if first, rest, found := strings.Cut(raw, "/"); found {
+ switch strings.ToLower(first) {
+ case "emby", "mediabrowser":
+ raw = rest
+ }
+ }
+ segs := strings.Split(raw, "/")
+ lower := make([]string, len(segs))
+ for i, s := range segs {
+ lower[i] = strings.ToLower(s)
+ }
+ method := c.Request.Method
+
+ // 公共端点:无需认证
+ switch {
+ case len(lower) == 3 && lower[0] == "system" && lower[1] == "info" && lower[2] == "public":
+ embyPublicSystemInfo(c)
+ return
+ case len(lower) == 2 && lower[0] == "system" && lower[1] == "ping":
+ c.String(200, "Emby Server")
+ return
+ // 客户端连接阶段还会拉取公共配置,返回空对象即可,否则同样会 401
+ case len(lower) == 3 && lower[0] == "system" && lower[1] == "configuration" && lower[2] == "public":
+ c.JSON(200, gin.H{})
+ return
+ case len(lower) == 2 && lower[0] == "branding" && lower[1] == "configuration":
+ c.JSON(200, gin.H{})
+ return
+ // 离线同步接口:OpenList 不支持客户端下载同步,返回空结果而不是 401
+ case len(lower) >= 1 && lower[0] == "sync":
+ if method == "GET" {
+ c.JSON(200, []any{})
+ } else {
+ c.Status(204)
+ }
+ return
+ // 浏览器直接打开 @emby 时会拉取 Emby Web 静态资源,这里返回说明页而不是 401
+ case lower[0] == "web":
+ if strings.HasSuffix(strings.ToLower(c.Request.URL.Path), ".json") {
+ c.JSON(200, gin.H{})
+ return
+ }
+ c.Header("Content-Type", "text/html; charset=utf-8")
+ c.String(200, embyWebNoticeHTML)
+ return
+ // 没有 token 的连接探测:部分客户端直接请求 /System/Info(非 Public)。
+ // 必须返回完整结构(含 WebPath),精简版会让客户端在处理 sections 时崩溃
+ case len(lower) == 2 && lower[0] == "system" && lower[1] == "info":
+ embySystemInfo(c)
+ return
+ case len(lower) == 2 && lower[0] == "system" && lower[1] == "endpoint":
+ // 按实际来源判断,不要固定返回 true:硬编码「内网直连」会让客户端
+ // 误以为可直连并跳过中转,也是对匿名访问者宣称内网可达。
+ local := embyIsLocalRequest(c)
+ c.JSON(200, gin.H{"IsLocal": local, "IsInNetwork": local})
+ return
+ case len(lower) == 2 && lower[0] == "localization" && lower[1] == "options":
+ c.JSON(200, []any{})
+ return
+ case len(lower) == 2 && lower[0] == "sessions" && lower[1] == "capabilities":
+ c.Status(204)
+ return
+ case len(lower) == 3 && lower[0] == "sessions" && lower[1] == "capabilities" && lower[2] == "full":
+ c.Status(204)
+ return
+ // 播放进度上报:部分客户端的原生播放器不带 token,
+ // 这类请求不能 401,否则客户端会中断播放状态跟踪
+ case len(lower) >= 2 && lower[0] == "sessions" && lower[1] == "playing":
+ if user, ok := embyAuth(c); ok {
+ embySessionPlaying(c, user, strings.Join(lower[2:], "/"))
+ } else {
+ c.Status(204)
+ }
+ return
+ // 电影库页面可能请求的推荐/字母索引接口,返回空结果
+ case method == "GET" && len(lower) == 2 && lower[0] == "movies" && lower[1] == "recommendations":
+ c.JSON(200, []any{})
+ return
+ case method == "GET" && len(lower) == 2 && lower[0] == "items" && lower[1] == "prefixes":
+ c.JSON(200, []any{})
+ return
+ // 添加服务器 / 登录流程必须在鉴权前放行,否则客户端只会看到 Unauthorized
+ case len(lower) == 2 && lower[0] == "users" && lower[1] == "public":
+ embyPublicUsers(c)
+ return
+ case len(lower) == 2 && lower[0] == "users" && lower[1] == "authenticatebyname":
+ embyAuthenticateByName(c)
+ return
+ // 旧版客户端登录:POST /Users/{userId}/Authenticate,密码在 body 或 query
+ case len(lower) == 3 && lower[0] == "users" && lower[2] == "authenticate":
+ embyAuthenticateByID(c, segs[1])
+ return
+ case len(lower) >= 3 && lower[0] == "items" && strings.EqualFold(lower[2], "images"):
+ // /Items/{id}/Images/Primary 等:图片 URL 不带 token,必须匿名可访问
+ embyImage(c, segs[1], segs, true)
+ return
+ }
+
+ user, ok := embyAuth(c)
+ if !ok {
+ embyUnauthorized(c)
+ return
+ }
+
+ switch {
+ // Emby 客户端用它做会话与实时通知,缺失时会持续重连
+ case len(lower) == 1 && lower[0] == "embywebsocket":
+ embyWebSocket(c)
+ // 客户端启动/仪表盘探测接口,返回空数据避免 404 弹通用错误
+ case method == "GET" && len(lower) == 1 && lower[0] == "sessions":
+ c.JSON(200, []any{})
+ case method == "GET" && len(lower) == 1 && lower[0] == "scheduledtasks":
+ c.JSON(200, []any{})
+ case len(lower) == 3 && lower[0] == "system" && lower[1] == "activitylog" && lower[2] == "entries":
+ c.JSON(200, gin.H{"Items": []any{}, "TotalRecordCount": 0, "StartIndex": 0})
+ // LiveTv:会话页/剧集页会查询录像、节目单等,全部返回空结果
+ case len(lower) >= 2 && lower[0] == "livetv":
+ c.JSON(200, gin.H{"Items": []any{}, "TotalRecordCount": 0, "StartIndex": 0})
+ // Emby 4.10 客户端首页读取用户设置:GET /usersettings/{userId}
+ case len(lower) == 2 && lower[0] == "usersettings":
+ if method == "GET" {
+ c.JSON(200, gin.H{
+ "Screensaver": gin.H{},
+ "HomeSections": []any{},
+ "Playback": gin.H{},
+ })
+ } else {
+ c.Status(204)
+ }
+ // 客户端登录后会读取/保存界面偏好,字段缺失会让 sectionscontroller 抛异常
+ case len(lower) >= 1 && lower[0] == "displaypreferences":
+ if method == "GET" {
+ c.JSON(200, gin.H{
+ "Id": "usersettings",
+ "ViewType": "",
+ "SortBy": "SortName",
+ "IndexBy": "",
+ "RememberIndexing": false,
+ "PrimaryImageHeight": 250,
+ "PrimaryImageWidth": 250,
+ "CustomPrefs": gin.H{
+ "homeSections": "",
+ },
+ "ScrollDirection": "Horizontal",
+ "ShowBackdrop": true,
+ "RememberSorting": false,
+ "SortOrder": "Ascending",
+ "ShowSidebar": false,
+ "Client": "emby",
+ })
+ } else {
+ c.Status(204)
+ }
+ case len(lower) == 2 && lower[0] == "system" && lower[1] == "info":
+ embySystemInfo(c)
+ case len(lower) == 2 && lower[0] == "system" && lower[1] == "endpoint":
+ c.JSON(200, gin.H{"IsLocal": true, "IsInNetwork": true})
+ case len(lower) == 1 && lower[0] == "users" && method == "GET":
+ embyListUsers(c, user)
+ case len(lower) == 2 && lower[0] == "users" && lower[1] == "query":
+ embyQueryUsers(c)
+ case len(lower) == 2 && lower[0] == "users" && method == "GET":
+ embyGetUser(c, segs[1], user)
+ case len(lower) == 3 && lower[0] == "users" && lower[2] == "views":
+ embyViews(c, user)
+ case len(lower) == 2 && lower[0] == "items" && lower[1] == "counts":
+ embyItemCounts(c, user)
+ case len(lower) == 2 && lower[0] == "library" && lower[1] == "refresh" && method == "POST":
+ embyLibraryRefresh(c)
+ case len(lower) == 2 && lower[0] == "library" && lower[1] == "selectablemediafolders":
+ embySelectableFolders(c, user)
+ case len(lower) == 3 && lower[0] == "library" && lower[1] == "virtualfolders" && lower[2] == "query":
+ embyVirtualFolders(c, user)
+ case len(lower) == 2 && lower[0] == "search" && lower[1] == "hints":
+ embySearchHints(c, user)
+ // 首页「收藏的艺术家 / 人物」分区查询,无对应数据时返回空结果
+ case method == "GET" && len(lower) == 1 && lower[0] == "artists":
+ c.JSON(200, gin.H{"Items": []any{}, "TotalRecordCount": 0, "StartIndex": 0})
+ case method == "GET" && len(lower) == 1 && lower[0] == "persons":
+ c.JSON(200, gin.H{"Items": []any{}, "TotalRecordCount": 0, "StartIndex": 0})
+ case len(lower) == 2 && lower[0] == "artists":
+ embyArtists(c, user, lower[1] == "albumartists")
+ case len(lower) == 3 && lower[0] == "sessions" && lower[1] == "playing":
+ embySessionPlaying(c, user, lower[2])
+ case len(lower) == 2 && lower[0] == "sessions" && lower[1] == "playing" && method == "POST":
+ embySessionPlaying(c, user, "")
+ case len(lower) == 2 && lower[0] == "sessions" && lower[1] == "logout" && method == "POST":
+ embyLogout(c, user)
+ case len(lower) == 2 && lower[0] == "sessions" && lower[1] == "capabilities":
+ c.Status(204)
+ case len(lower) == 3 && lower[0] == "sessions" && lower[1] == "capabilities" && lower[2] == "full":
+ c.Status(204)
+ // 媒体库页(电影/剧集/音乐)筛选栏依赖的分面接口,缺失返回 404 会让整页报错
+ case method == "GET" && len(lower) == 2 && lower[0] == "items" && (lower[1] == "filters" || lower[1] == "filters2"):
+ c.JSON(200, gin.H{"Genres": []any{}, "Tags": []any{}, "OfficialRatings": []any{}, "Years": []any{}})
+ case method == "GET" && len(lower) == 1 && embyEmptyListRoot[lower[0]]:
+ c.JSON(200, gin.H{"Items": []any{}, "TotalRecordCount": 0, "StartIndex": 0})
+ // 用户态条目
+ case len(lower) >= 3 && lower[0] == "users":
+ embyUserScoped(c, user, segs, lower, method)
+ // 全局条目
+ case len(lower) >= 2 && lower[0] == "items":
+ embyItemsScoped(c, user, segs, lower, method)
+ // 播放流
+ case len(lower) >= 3 && (lower[0] == "videos" || lower[0] == "audio") &&
+ (lower[2] == "stream" || strings.HasPrefix(lower[2], "stream.") || lower[2] == "universal"):
+ embyStream(c, user, segs[1], lower[0] == "audio")
+ case len(lower) == 3 && lower[0] == "audio" && lower[2] == "lyrics":
+ embyLyrics(c, user, segs[1])
+ // 剧集:/Shows/NextUp 只有两段,/Shows/{id}/Seasons 为三段
+ case len(lower) >= 2 && lower[0] == "shows":
+ embyShows(c, user, segs, lower, method)
+ default:
+ embyNotFound(c)
+ }
+}
+
+func embyUnauthorized(c *gin.Context) {
+ // 记录客户端实际请求,便于排查"连接后被判未认证"的兼容问题。
+ // 只能记 Path 不能记 RequestURI:query 里可能带 Pw/Password/api_key,
+ // 而 X-Emby-Authorization 里是可直接使用的设备令牌,都不能落到日志里。
+ utils.Log.Warnf("emby unauthorized: %s %s client=%q ua=%q hasToken=%v",
+ c.Request.Method, c.Request.URL.Path, c.ClientIP(),
+ c.GetHeader("User-Agent"), embyRequestToken(c) != "")
+ c.JSON(401, gin.H{"Error": "Unauthorized", "ResponseStatus": gin.H{"ErrorCode": "Unauthorized"}})
+}
+
+func embyNotFound(c *gin.Context) {
+ c.JSON(404, gin.H{"Error": "Not Found"})
+}
+
+// embyEmptyListRoot 本服务无对应数据、但客户端页面一定会请求的顶层集合接口
+var embyEmptyListRoot = map[string]bool{
+ "genres": true, "musicgenres": true, "studios": true, "tags": true,
+ "officialratings": true, "years": true, "collections": true,
+ "playlists": true, "trailers": true, "channels": true, "livetv": true,
+}
+
+// embyAuxItem 处理详情页附带请求(相似、预告片、特辑、片头等)。
+// 这类接口缺失时客户端详情页和列表页都会弹"error processing the request"。
+// 返回 true 表示已响应。
+func embyAuxItem(c *gin.Context, name string) bool {
+ switch name {
+ case "similar", "suggestions", "instantmix", "intros":
+ c.JSON(200, gin.H{"Items": []any{}, "TotalRecordCount": 0, "StartIndex": 0})
+ case "localtrailers", "specialfeatures", "additionalparts", "remoteimages", "thememedia":
+ c.JSON(200, []any{})
+ case "criticreviews":
+ c.JSON(200, gin.H{"Items": []any{}, "TotalRecordCount": 0})
+ default:
+ return false
+ }
+ return true
+}
+
+var embyWSUpgrader = websocket.Upgrader{
+ ReadBufferSize: 1024,
+ WriteBufferSize: 1024,
+ CheckOrigin: func(r *http.Request) bool { return true },
+}
+
+// embyWebSocket 提供最小可用的 Emby WebSocket 通道。
+// 客户端用它做会话/实时通知,缺失时会持续重连并产生大量 404。
+func embyWebSocket(c *gin.Context) {
+ conn, err := embyWSUpgrader.Upgrade(c.Writer, c.Request, nil)
+ if err != nil {
+ return
+ }
+ defer conn.Close()
+ conn.SetReadLimit(4096)
+ _ = conn.SetReadDeadline(time.Now().Add(90 * time.Second))
+ conn.SetPongHandler(func(string) error {
+ return conn.SetReadDeadline(time.Now().Add(90 * time.Second))
+ })
+ stop := make(chan struct{})
+ defer close(stop)
+ go func() {
+ ticker := time.NewTicker(30 * time.Second)
+ defer ticker.Stop()
+ for {
+ select {
+ case <-ticker.C:
+ if err := conn.WriteMessage(websocket.TextMessage, []byte(`{"MessageType":"KeepAlive"}`)); err != nil {
+ return
+ }
+ case <-stop:
+ return
+ }
+ }
+ }()
+ for {
+ if _, _, err := conn.ReadMessage(); err != nil {
+ return
+ }
+ }
+}
+
+// ==================== 系统 / 用户 ====================
+
+func embyPublicSystemInfo(c *gin.Context) {
+ api := common.GetApiUrl(c.Request.Context())
+ c.JSON(200, gin.H{
+ "ServerName": setting.GetStr(conf.SiteTitle, "OpenList"),
+ "Version": embyVersion(),
+ "Id": embyServerID(),
+ "OperatingSystem": "Linux",
+ "LocalAddress": api,
+ "WebPath": "",
+ "StartupWizardCompleted": true,
+ })
+}
+
+func embySystemInfo(c *gin.Context) {
+ api := common.GetApiUrl(c.Request.Context())
+ c.JSON(200, gin.H{
+ "ServerName": setting.GetStr(conf.SiteTitle, "OpenList"),
+ "Version": embyVersion(),
+ "Id": embyServerID(),
+ "OperatingSystem": "Linux",
+ "OperatingSystemDisplayName": "Linux",
+ "LocalAddress": api,
+ "WebPath": "",
+ "StartupWizardCompleted": true,
+ "HasPendingRestart": false,
+ "IsShuttingDown": false,
+ "SupportsLibraryMonitor": false,
+ "CanSelfRestart": false,
+ "CanLaunchWebBrowser": false,
+ "SupportsHttps": false,
+ "HasUpdateAvailable": false,
+ "EncoderLocation": "System",
+ "SystemArchitecture": "X64",
+ "SystemUpdateLevel": "Release",
+ // 不返回真实监听端口:这个端点匿名可访问,客户端本来就是从已知端口连进来的,
+ // 回了真实端口只是白给一份端口探测信息。
+ "HttpServerPortNumber": 0,
+ "HttpsPortNumber": 0,
+ "ProgramDataPath": "",
+ "ItemsByNamePath": "",
+ "CachePath": "",
+ "LogPath": "",
+ "InternalMetadataPath": "",
+ "TranscodingTempPath": "",
+ })
+}
+
+// embyIsLocalRequest 判断请求是否来自本机 / 内网,用于 /System/Endpoint。
+func embyIsLocalRequest(c *gin.Context) bool {
+ ip := net.ParseIP(c.ClientIP())
+ if ip == nil {
+ return false
+ }
+ return ip.IsLoopback() || ip.IsPrivate()
+}
+
+// embyReadAuthBody 读取登录凭据,兼容 JSON / 表单 / query 三种提交方式
+func embyReadAuthBody(c *gin.Context) (username, pw string) {
+ if strings.Contains(strings.ToLower(c.ContentType()), "json") {
+ var req struct {
+ Username string `json:"Username"`
+ Pw string `json:"Pw"`
+ Password string `json:"Password"`
+ }
+ _ = c.ShouldBindJSON(&req)
+ username, pw = req.Username, req.Pw
+ if pw == "" {
+ pw = req.Password
+ }
+ } else {
+ _ = c.Request.ParseForm()
+ username = c.PostForm("Username")
+ pw = c.PostForm("Pw")
+ if pw == "" {
+ pw = c.PostForm("Password")
+ }
+ }
+ if username == "" {
+ username = c.Query("Username")
+ }
+ if pw == "" {
+ pw = c.Query("Pw")
+ }
+ if pw == "" {
+ pw = c.Query("Password")
+ }
+ return strings.TrimSpace(username), strings.TrimSpace(pw)
+}
+
+// embyAuthenticateByName 标准登录接口 POST /Users/AuthenticateByName
+func embyAuthenticateByName(c *gin.Context) {
+ username, pw := embyReadAuthBody(c)
+ // 除 IP 外还要检查用户名维度:IP 可被 X-Forwarded-For 伪造
+ if mediaAuthLockedFor(c, username) {
+ c.JSON(429, gin.H{"Error": model.TooManyAttempts})
+ return
+ }
+ if username == "" || pw == "" {
+ embyUnauthorized(c)
+ return
+ }
+ user, err := op.GetUserByName(username)
+ if err != nil || user.Disabled || user.IsGuest() {
+ mediaAuthFailedFor(c, username)
+ embyUnauthorized(c)
+ return
+ }
+ embyCompleteAuth(c, user, pw)
+}
+
+// embyAuthenticateByID 旧版登录接口 POST /Users/{userId}/Authenticate
+func embyAuthenticateByID(c *gin.Context, idStr string) {
+ if mediaAuthLocked(c) {
+ c.JSON(429, gin.H{"Error": model.TooManyAttempts})
+ return
+ }
+ id, ok := embyResolveUserID(idStr)
+ if !ok {
+ embyUnauthorized(c)
+ return
+ }
+ user, err := op.GetUserById(id)
+ if err != nil || user.Disabled || user.IsGuest() {
+ mediaAuthFailed(c)
+ embyUnauthorized(c)
+ return
+ }
+ // 按 id 登录同样要受用户名维度的锁定约束,否则可以换 id 绕过按用户名的计数
+ if mediaAuthLockedFor(c, user.Username) {
+ c.JSON(429, gin.H{"Error": model.TooManyAttempts})
+ return
+ }
+ _, pw := embyReadAuthBody(c)
+ if pw == "" {
+ embyUnauthorized(c)
+ return
+ }
+ embyCompleteAuth(c, user, pw)
+}
+
+// embyCompleteAuth 校验密码并签发设备令牌。
+// 密码位置既接受 OpenList 登录密码(无 2FA 时),也接受属于该用户的应用密钥。
+func embyCompleteAuth(c *gin.Context, user *model.User, pw string) {
+ keyUser, _, keyOK := LookUpAppKeyUser(pw)
+ switch {
+ case keyOK && keyUser.ID == user.ID:
+ // 应用密钥认证通过,2FA 账号也允许(密钥本身即第二因素)
+ case keyOK:
+ mediaAuthFailedFor(c, user.Username)
+ embyUnauthorized(c)
+ return
+ case user.OtpSecret != "":
+ // 2FA 账号不允许用登录密码通过 Emby 认证
+ mediaAuthFailedFor(c, user.Username)
+ c.JSON(403, gin.H{"Error": "Accounts with two-factor authentication must use an app key"})
+ return
+ case user.ValidateRawPassword(pw) != nil:
+ mediaAuthFailedFor(c, user.Username)
+ embyUnauthorized(c)
+ return
+ }
+ mediaAuthSucceededFor(c, user.Username)
+ authHeader := embyParseAuthHeader(c.GetHeader("X-Emby-Authorization"))
+ token, err := media.NewDeviceToken()
+ if err != nil {
+ c.JSON(500, gin.H{"Error": err.Error()})
+ return
+ }
+ dev := &model.EmbyDevice{
+ UserID: user.ID,
+ DeviceID: authHeader["deviceid"],
+ DeviceName: authHeader["device"],
+ Client: authHeader["client"],
+ Version: authHeader["version"],
+ TokenHash: media.HashToken(token),
+ LastSeenAt: time.Now(),
+ }
+ _ = db.CreateEmbyDevice(dev)
+ c.JSON(200, gin.H{
+ "User": embyUserDtoOf(user),
+ "AccessToken": token,
+ "ServerId": embyServerID(),
+ })
+}
+
+// embyPublicUsers 匿名可访问,只返回登录界面需要的公开字段,
+// 不包含权限、策略、是否管理员等敏感信息。
+func embyPublicUsers(c *gin.Context) {
+ out := make([]*embyPublicUserDto, 0)
+ users, _, err := op.GetUsers(1, -1)
+ if err == nil {
+ for i := range users {
+ if users[i].IsGuest() || users[i].Disabled {
+ continue
+ }
+ out = append(out, &embyPublicUserDto{
+ Name: users[i].Username,
+ ServerId: embyServerID(),
+ Id: embyUserGUID(users[i].ID),
+ HasPassword: users[i].PwdHash != "",
+ })
+ }
+ }
+ c.JSON(200, out)
+}
+
+func embyListUsers(c *gin.Context, current *model.User) {
+ users, _, err := op.GetUsers(1, -1)
+ if err != nil {
+ c.JSON(500, gin.H{"Error": err.Error()})
+ return
+ }
+ out := make([]*embyUserDto, 0, len(users))
+ for i := range users {
+ if users[i].IsGuest() {
+ continue
+ }
+ if !current.IsAdmin() && users[i].ID != current.ID {
+ continue
+ }
+ out = append(out, embyUserDtoOf(&users[i]))
+ }
+ c.JSON(200, out)
+}
+
+func embyQueryUsers(c *gin.Context) {
+ users, _, err := op.GetUsers(1, -1)
+ if err != nil {
+ c.JSON(500, gin.H{"Error": err.Error()})
+ return
+ }
+ out := make([]*embyUserDto, 0, len(users))
+ for i := range users {
+ if users[i].IsGuest() {
+ continue
+ }
+ out = append(out, embyUserDtoOf(&users[i]))
+ }
+ c.JSON(200, gin.H{"Items": out, "TotalRecordCount": len(out), "StartIndex": 0})
+}
+
+func embyGetUser(c *gin.Context, idStr string, current *model.User) {
+ // Jellyfin/部分 Emby 客户端使用 /Users/Me 获取当前用户
+ if strings.EqualFold(idStr, "me") {
+ c.JSON(200, embyUserDtoOf(current))
+ return
+ }
+ id, ok := embyResolveUserID(idStr)
+ if !ok {
+ embyNotFound(c)
+ return
+ }
+ user, err := op.GetUserById(id)
+ if err != nil {
+ embyNotFound(c)
+ return
+ }
+ c.JSON(200, embyUserDtoOf(user))
+}
+
+func embyLogout(c *gin.Context, user *model.User) {
+ token := embyRequestToken(c)
+ if token != "" {
+ if dev, err := db.GetEmbyDeviceByTokenHash(media.HashToken(token)); err == nil {
+ _ = db.DeleteEmbyDevice(user.ID, dev.DeviceID)
+ }
+ }
+ c.Status(204)
+}
+
+// ==================== 媒体库刷新 ====================
+
+var (
+ embyRefreshMu sync.Mutex
+ embyRefreshTimer *time.Timer
+)
+
+func scheduleLibraryRefresh() {
+ embyRefreshMu.Lock()
+ defer embyRefreshMu.Unlock()
+ if embyRefreshTimer != nil {
+ embyRefreshTimer.Stop()
+ }
+ embyRefreshTimer = time.AfterFunc(10*time.Second, func() {
+ for _, mt := range MediaTypeEnabledList() {
+ cfg, err := db.GetMediaConfig(mt)
+ if err != nil || cfg == nil {
+ continue
+ }
+ media.ScanMedia(cfg)
+ }
+ })
+}
+
+func embyLibraryRefresh(c *gin.Context) {
+ scheduleLibraryRefresh()
+ c.Status(204)
+}
+
+// ==================== 内部工具 ====================
+
+func embyPaging(c *gin.Context) (start, limit int) {
+ start, _ = strconv.Atoi(c.DefaultQuery("StartIndex", "0"))
+ limit, _ = strconv.Atoi(c.DefaultQuery("Limit", "100"))
+ if start < 0 {
+ start = 0
+ }
+ if limit <= 0 || limit > 1000 {
+ limit = 200
+ }
+ return
+}
+
+func embyBoolQuery(c *gin.Context, key string) bool {
+ v := strings.ToLower(c.Query(key))
+ return v == "true" || v == "1"
+}
+
+func embyIntQuery(c *gin.Context, key string) int {
+ v, _ := strconv.Atoi(c.Query(key))
+ return v
+}
+
+func embyJson(v any) string {
+ b, err := json.Marshal(v)
+ if err != nil {
+ return ""
+ }
+ return string(b)
+}
+
+func embyHash(s string) string {
+ sum := sha1.Sum([]byte(s))
+ return hex.EncodeToString(sum[:6])
+}
+
+// embyGUID 把内部可读 ID 映射为 32 位 hex GUID。
+// embyGUID 用于用户 ID(Emby 规范为 32 位 hex GUID)。
+// 注意:条目的 Item Id 不能用 GUID,见 embyNumericID。
+func embyGUID(s string) string {
+ sum := md5.Sum([]byte("openlist-emby-id:" + s))
+ return hex.EncodeToString(sum[:])
+}
+
+// embyNumericID 生成稳定的十进制数字 ID,供条目(影片/剧集/专辑等)使用。
+// Emby 真实服务器的 Item Id 就是数字字符串;Emby Theater 的原生
+// LocalItemRepository 会把它当 long 解析(System.Number.ThrowFormatException),
+// 传 GUID 或 mv-1 这类可读 ID 都会让客户端播放/取图直接抛异常。
+func embyNumericID(s string) string {
+ h := fnv.New64a()
+ _, _ = h.Write([]byte("openlist-emby-item:" + s))
+ // 取 47 位:既在 .NET long / JS 安全整数范围内,又足够大避免与真实 ID 混淆
+ return strconv.FormatUint(h.Sum64()&0x7FFFFFFFFFFF, 10)
+}
+
+func embyImageTag(item *model.MediaItem) string {
+ if item.Cover == "" {
+ return ""
+ }
+ return embyHash(item.Cover + item.UpdatedAt.String())
+}
diff --git a/server/handles/emby_browse.go b/server/handles/emby_browse.go
new file mode 100644
index 00000000..6473a329
--- /dev/null
+++ b/server/handles/emby_browse.go
@@ -0,0 +1,1654 @@
+package handles
+
+import (
+ "encoding/json"
+ "fmt"
+ stdpath "path"
+ "sort"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/OpenListTeam/OpenList/v4/internal/db"
+ "github.com/OpenListTeam/OpenList/v4/internal/model"
+ "github.com/gin-gonic/gin"
+)
+
+// ==================== 媒体库索引 ====================
+
+type embyLibrary struct {
+ user *model.User
+ videos []model.MediaItem
+ music []model.MediaItem
+
+ userData map[[2]uint]*model.MediaUserData
+ // ids 保存 GUID -> 内部可读 ID 的映射,客户端只会回传 GUID
+ ids map[string]string
+}
+
+func loadEmbyLibrary(user *model.User) *embyLibrary {
+ l := &embyLibrary{
+ user: user,
+ userData: map[[2]uint]*model.MediaUserData{},
+ ids: map[string]string{},
+ }
+ if MediaTypeEnabled(model.MediaTypeVideo) {
+ items, err := db.ListAllMediaItems(model.MediaTypeVideo)
+ if err == nil {
+ for i := range items {
+ if CanUserAccessItem(user, &items[i]) {
+ l.videos = append(l.videos, items[i])
+ }
+ }
+ }
+ }
+ if MediaTypeEnabled(model.MediaTypeMusic) {
+ items, err := db.ListAllMediaItems(model.MediaTypeMusic)
+ if err == nil {
+ for i := range items {
+ if CanUserAccessItem(user, &items[i]) {
+ l.music = append(l.music, items[i])
+ }
+ }
+ }
+ }
+ ids := make([]uint, 0, len(l.videos)+len(l.music))
+ for _, it := range l.videos {
+ ids = append(ids, it.ID)
+ }
+ for _, it := range l.music {
+ ids = append(ids, it.ID)
+ }
+ if user != nil && len(ids) > 0 {
+ if list, err := db.ListMediaUserDataByItems(user.ID, ids); err == nil {
+ for i := range list {
+ d := list[i]
+ l.userData[[2]uint{d.ItemID, uint(d.EpisodeIndex)}] = &d
+ }
+ }
+ }
+ l.registerIDs()
+ return l
+}
+
+// guid 返回内部 ID 对应的对外数字 ID,并登记反查映射。
+// 名称保留 guid 是为了少改调用点,实际输出的是纯数字(Emby Item Id 规范)。
+func (l *embyLibrary) guid(orig string) string {
+ g := embyNumericID(orig)
+ if l.ids != nil {
+ l.ids[g] = orig
+ // 兼容旧版本缓存下来的 GUID 形式 ID:客户端没清缓存时仍能解析,
+ // 否则会一直拿旧 ID 请求并得到 {"Error":"Not Found"}
+ l.ids[embyGUID(orig)] = orig
+ }
+ return g
+}
+
+// origID 把客户端回传的数字 ID 还原为内部 ID;已是内部 ID 时原样返回
+func (l *embyLibrary) origID(id string) string {
+ id = strings.TrimSpace(id)
+ if l.ids != nil {
+ if orig, ok := l.ids[id]; ok {
+ return orig
+ }
+ }
+ return id
+}
+
+// registerIDs 预先登记全部条目的对外 ID,保证任意请求都能反查出内部 ID
+func (l *embyLibrary) registerIDs() {
+ register := func(orig string) {
+ l.ids[embyNumericID(orig)] = orig
+ l.ids[embyGUID(orig)] = orig
+ }
+ register("root")
+ register("lib-movie")
+ register("lib-tv")
+ register("lib-music")
+ for i := range l.videos {
+ register(fmt.Sprintf("mv-%d", l.videos[i].ID))
+ }
+ for _, s := range l.seriesList() {
+ register("sr-" + s.Key)
+ for _, season := range l.seasonList(s) {
+ register(fmt.Sprintf("sn-%s-%d", s.Key, season))
+ }
+ for _, ep := range l.episodesOf(s.Key, 0) {
+ register(ep.ID)
+ }
+ }
+ for i := range l.music {
+ it := &l.music[i]
+ register(fmt.Sprintf("so-%d", it.ID))
+ register("al-" + albumKeyOf(it))
+ register("ar-" + artistKeyOf(it))
+ }
+}
+
+func (l *embyLibrary) userDataOf(itemID uint, episodeIndex int) *embyUserData {
+ if l == nil || l.user == nil {
+ return &embyUserData{}
+ }
+ d, ok := l.userData[[2]uint{itemID, uint(episodeIndex)}]
+ if !ok {
+ return &embyUserData{}
+ }
+ return &embyUserData{
+ PlaybackPositionTicks: d.PositionTicks,
+ PlayCount: d.PlayCount,
+ IsFavorite: d.IsFavorite,
+ Played: d.Played,
+ }
+}
+
+type embySeries struct {
+ Key string
+ Name string
+ Items []model.MediaItem
+ Year int
+ Overview string
+ Cover string
+}
+
+// isEmbySeriesItem 判断视频条目是否属于剧集。
+// 电视剧按目录扫描(IsFolder=true,带 Episodes 选集),或被刮削为 tv;
+// 其余单文件视频一律按电影处理。仅看 video_type 会把目录型剧集误判为电影。
+func isEmbySeriesItem(it *model.MediaItem) bool {
+ return it.IsFolder || it.VideoType == "tv"
+}
+
+func seriesKeyOf(item *model.MediaItem) string {
+ name := item.ScrapedName
+ if name == "" {
+ name = strings.TrimSuffix(stdpath.Base(mediaFolderOf(item)), "/")
+ }
+ return embyHash("series:" + strings.ToLower(name))
+}
+
+func seriesNameOf(item *model.MediaItem) string {
+ if item.ScrapedName != "" {
+ return item.ScrapedName
+ }
+ if item.IsFolder {
+ return strings.TrimSuffix(item.FileName, stdpath.Ext(item.FileName))
+ }
+ return strings.TrimSuffix(stdpath.Base(mediaFolderOf(item)), "/")
+}
+
+// seriesList 汇总剧集(按剧名去重)
+func (l *embyLibrary) seriesList() []*embySeries {
+ index := map[string]*embySeries{}
+ var order []string
+ for i := range l.videos {
+ it := &l.videos[i]
+ if !isEmbySeriesItem(it) {
+ continue
+ }
+ key := seriesKeyOf(it)
+ s, ok := index[key]
+ if !ok {
+ s = &embySeries{Key: key, Name: seriesNameOf(it), Cover: it.Cover}
+ if len(it.ReleaseDate) >= 4 {
+ s.Year, _ = strconv.Atoi(it.ReleaseDate[:4])
+ }
+ s.Overview = it.Plot
+ index[key] = s
+ order = append(order, key)
+ }
+ if s.Cover == "" {
+ s.Cover = it.Cover
+ }
+ if s.Overview == "" {
+ s.Overview = it.Plot
+ }
+ s.Items = append(s.Items, *it)
+ }
+ out := make([]*embySeries, 0, len(order))
+ for _, k := range order {
+ out = append(out, index[k])
+ }
+ return out
+}
+
+func (l *embyLibrary) seriesByKey(key string) *embySeries {
+ for _, s := range l.seriesList() {
+ if s.Key == key {
+ return s
+ }
+ }
+ return nil
+}
+
+type embyEpisode struct {
+ ID string
+ Item *model.MediaItem
+ Episode model.EpisodeInfo
+ Index int
+ Season int
+ SeriesKey string
+ SeriesName string
+}
+
+func parseEpisodeList(item *model.MediaItem) []model.EpisodeInfo {
+ if item.Episodes == "" {
+ return nil
+ }
+ var eps []model.EpisodeInfo
+ if err := json.Unmarshal([]byte(item.Episodes), &eps); err != nil {
+ return nil
+ }
+ return eps
+}
+
+// episodesOf 返回某部剧(可按季过滤)的集列表
+func (l *embyLibrary) episodesOf(seriesKey string, season int) []*embyEpisode {
+ var out []*embyEpisode
+ for i := range l.videos {
+ it := &l.videos[i]
+ if !isEmbySeriesItem(it) || seriesKeyOf(it) != seriesKey {
+ continue
+ }
+ s := it.Season
+ if s <= 0 {
+ s = 1
+ }
+ if season > 0 && s != season {
+ continue
+ }
+ if it.IsFolder {
+ for idx, ep := range parseEpisodeList(it) {
+ num := ep.Index
+ if num <= 0 {
+ num = idx + 1
+ }
+ out = append(out, &embyEpisode{
+ ID: fmt.Sprintf("ep-%d-%d", it.ID, num),
+ Item: it,
+ Episode: ep,
+ Index: num,
+ Season: s,
+ SeriesKey: seriesKey,
+ SeriesName: seriesNameOf(it),
+ })
+ }
+ continue
+ }
+ num := it.Episode
+ if num <= 0 {
+ num = 1
+ }
+ out = append(out, &embyEpisode{
+ ID: fmt.Sprintf("ep-%d-%d", it.ID, num),
+ Item: it,
+ Index: num,
+ Season: s,
+ SeriesKey: seriesKey,
+ SeriesName: seriesNameOf(it),
+ })
+ }
+ sort.Slice(out, func(i, j int) bool {
+ if out[i].Season != out[j].Season {
+ return out[i].Season < out[j].Season
+ }
+ return out[i].Index < out[j].Index
+ })
+ return out
+}
+
+func (l *embyLibrary) findEpisode(id string) *embyEpisode {
+ // id: ep--
+ parts := strings.Split(id, "-")
+ if len(parts) != 3 {
+ return nil
+ }
+ itemID, err := strconv.ParseUint(parts[1], 10, 64)
+ if err != nil {
+ return nil
+ }
+ num, _ := strconv.Atoi(parts[2])
+ item, err := db.GetMediaItemByID(uint(itemID))
+ if err != nil || !CanUserAccessItem(l.user, item) {
+ return nil
+ }
+ season := item.Season
+ if season <= 0 {
+ season = 1
+ }
+ if item.IsFolder {
+ for i, ep := range parseEpisodeList(item) {
+ n := ep.Index
+ if n <= 0 {
+ n = i + 1
+ }
+ if n == num {
+ return &embyEpisode{ID: id, Item: item, Episode: ep, Index: n, Season: season,
+ SeriesKey: seriesKeyOf(item), SeriesName: seriesNameOf(item)}
+ }
+ }
+ return nil
+ }
+ return &embyEpisode{ID: id, Item: item, Index: num, Season: season,
+ SeriesKey: seriesKeyOf(item), SeriesName: seriesNameOf(item)}
+}
+
+// music 索引
+
+type embyAlbum struct {
+ Key string
+ Name string
+ Artist string
+ Cover string
+ Year int
+ Items []model.MediaItem
+}
+
+func albumArtistOf(item *model.MediaItem) string {
+ if item.AlbumArtist != "" {
+ return item.AlbumArtist
+ }
+ if item.Authors != "" {
+ return item.Authors
+ }
+ return "Unknown Artist"
+}
+
+func albumKeyOf(item *model.MediaItem) string {
+ album := item.AlbumName
+ if album == "" {
+ album = "Unknown Album"
+ }
+ return embyHash("album:" + strings.ToLower(albumArtistOf(item)) + "\x00" + strings.ToLower(album))
+}
+
+func artistKeyOf(item *model.MediaItem) string {
+ return embyHash("artist:" + strings.ToLower(albumArtistOf(item)))
+}
+
+func (l *embyLibrary) albums() []*embyAlbum {
+ index := map[string]*embyAlbum{}
+ var order []string
+ for i := range l.music {
+ it := &l.music[i]
+ key := albumKeyOf(it)
+ al, ok := index[key]
+ if !ok {
+ name := it.AlbumName
+ if name == "" {
+ name = "Unknown Album"
+ }
+ al = &embyAlbum{Key: key, Name: name, Artist: albumArtistOf(it), Cover: it.Cover}
+ if len(it.ReleaseDate) >= 4 {
+ al.Year, _ = strconv.Atoi(it.ReleaseDate[:4])
+ }
+ index[key] = al
+ order = append(order, key)
+ }
+ if al.Cover == "" {
+ al.Cover = it.Cover
+ }
+ al.Items = append(al.Items, *it)
+ }
+ out := make([]*embyAlbum, 0, len(order))
+ for _, k := range order {
+ out = append(out, index[k])
+ }
+ sort.Slice(out, func(i, j int) bool { return strings.ToLower(out[i].Name) < strings.ToLower(out[j].Name) })
+ return out
+}
+
+func (l *embyLibrary) albumByKey(key string) *embyAlbum {
+ for _, a := range l.albums() {
+ if a.Key == key {
+ return a
+ }
+ }
+ return nil
+}
+
+// ==================== DTO 构造 ====================
+
+func (l *embyLibrary) movieItem(it *model.MediaItem) *embyItem {
+ year := 0
+ if len(it.ReleaseDate) >= 4 {
+ year, _ = strconv.Atoi(it.ReleaseDate[:4])
+ }
+ name := it.ScrapedName
+ if name == "" {
+ name = strings.TrimSuffix(it.FileName, stdpath.Ext(it.FileName))
+ }
+ e := &embyItem{
+ Name: name,
+ SortName: name,
+ ServerId: embyServerID(),
+ Id: l.guid(fmt.Sprintf("mv-%d", it.ID)),
+ ParentId: l.guid("lib-movie"),
+ Type: "Movie",
+ MediaType: "Video",
+ IsFolder: false,
+ Path: MediaItemFilePath(it),
+ Container: embyExt(it.FileName),
+ RunTimeTicks: embyTicks(it.Duration),
+ ProductionYear: year,
+ Overview: it.Plot,
+ CommunityRating: it.Rating,
+ PremiereDate: it.ReleaseDate,
+ DateCreated: embyTimeStr(it.CreatedAt),
+ UserData: l.userDataOf(it.ID, 0),
+ ImageTags: map[string]string{},
+ ProviderIds: map[string]string{},
+ LocationType: "FileSystem",
+ }
+ if tag := embyImageTag(it); tag != "" {
+ e.ImageTags["Primary"] = tag
+ }
+ if it.ExternalID != "" {
+ e.ProviderIds["Tmdb"] = it.ExternalID
+ }
+ return e
+}
+
+func (l *embyLibrary) seriesItem(s *embySeries) *embyItem {
+ e := &embyItem{
+ Name: s.Name,
+ SortName: s.Name,
+ ServerId: embyServerID(),
+ Id: l.guid("sr-" + s.Key),
+ ParentId: l.guid("lib-tv"),
+ Type: "Series",
+ IsFolder: true,
+ ProductionYear: s.Year,
+ Overview: s.Overview,
+ DateCreated: embyTimeStr(s.Items[0].CreatedAt),
+ ChildCount: l.seasonCount(s),
+ LocationType: "FileSystem",
+ ImageTags: map[string]string{},
+ ProviderIds: map[string]string{},
+ UserData: &embyUserData{},
+ }
+ if tag := embyImageTag(&s.Items[0]); tag != "" {
+ e.ImageTags["Primary"] = tag
+ }
+ if s.Items[0].ExternalID != "" {
+ e.ProviderIds["Tmdb"] = s.Items[0].ExternalID
+ }
+ return e
+}
+
+func (l *embyLibrary) seasonCount(s *embySeries) int {
+ seasons := map[int]bool{}
+ for i := range s.Items {
+ season := s.Items[i].Season
+ if season <= 0 {
+ season = 1
+ }
+ seasons[season] = true
+ }
+ return len(seasons)
+}
+
+func (l *embyLibrary) seasonList(s *embySeries) []int {
+ set := map[int]bool{}
+ for i := range s.Items {
+ season := s.Items[i].Season
+ if season <= 0 {
+ season = 1
+ }
+ set[season] = true
+ }
+ out := make([]int, 0, len(set))
+ for n := range set {
+ out = append(out, n)
+ }
+ sort.Ints(out)
+ return out
+}
+
+func (l *embyLibrary) seasonItem(s *embySeries, season int) *embyItem {
+ eps := l.episodesOf(s.Key, season)
+ unplayed := 0
+ for _, ep := range eps {
+ if !l.userDataOf(ep.Item.ID, ep.Index).Played {
+ unplayed++
+ }
+ }
+ e := &embyItem{
+ Name: fmt.Sprintf("Season %d", season),
+ SortName: fmt.Sprintf("Season %02d", season),
+ ServerId: embyServerID(),
+ Id: l.guid(fmt.Sprintf("sn-%s-%d", s.Key, season)),
+ Type: "Season",
+ IsFolder: true,
+ ParentId: l.guid("sr-" + s.Key),
+ SeriesId: l.guid("sr-" + s.Key),
+ SeriesName: s.Name,
+ IndexNumber: season,
+ ChildCount: len(eps),
+ LocationType: "FileSystem",
+ ImageTags: map[string]string{},
+ UserData: &embyUserData{UnplayedItemCount: unplayed},
+ }
+ if len(s.Items) > 0 {
+ if tag := embyImageTag(&s.Items[0]); tag != "" {
+ e.ImageTags["Primary"] = tag
+ }
+ }
+ return e
+}
+
+func (l *embyLibrary) episodeItem(ep *embyEpisode) *embyItem {
+ title := ep.Episode.Title
+ if title == "" {
+ title = fmt.Sprintf("Episode %d", ep.Index)
+ }
+ seriesID := "sr-" + ep.SeriesKey
+ seasonID := fmt.Sprintf("sn-%s-%d", ep.SeriesKey, ep.Season)
+ rawPath := MediaItemFilePath(ep.Item)
+ if ep.Episode.FileName != "" {
+ rawPath = stdpath.Join(mediaFolderOf(ep.Item), ep.Item.FileName, ep.Episode.FileName)
+ }
+ e := &embyItem{
+ Name: title,
+ SortName: fmt.Sprintf("%02d-%03d", ep.Season, ep.Index),
+ ServerId: embyServerID(),
+ Id: l.guid(ep.ID),
+ Type: "Episode",
+ MediaType: "Video",
+ IsFolder: false,
+ ParentId: l.guid(seasonID),
+ SeriesId: l.guid(seriesID),
+ SeriesName: ep.SeriesName,
+ SeasonId: l.guid(seasonID),
+ SeasonName: fmt.Sprintf("Season %d", ep.Season),
+ IndexNumber: ep.Index,
+ ParentIndexNumber: ep.Season,
+ Path: rawPath,
+ Container: embyExt(rawPath),
+ RunTimeTicks: embyTicks(ep.Item.Duration),
+ Overview: ep.Item.Plot,
+ DateCreated: embyTimeStr(ep.Item.CreatedAt),
+ UserData: l.userDataOf(ep.Item.ID, ep.Index),
+ ImageTags: map[string]string{},
+ ProviderIds: map[string]string{},
+ LocationType: "FileSystem",
+ }
+ if tag := embyImageTag(ep.Item); tag != "" {
+ e.ImageTags["Primary"] = tag
+ }
+ return e
+}
+
+func (l *embyLibrary) albumItem(al *embyAlbum) *embyItem {
+ e := &embyItem{
+ Name: al.Name,
+ SortName: al.Name,
+ ServerId: embyServerID(),
+ Id: l.guid("al-" + al.Key),
+ ParentId: l.guid("lib-music"),
+ Type: "MusicAlbum",
+ IsFolder: true,
+ AlbumArtist: al.Artist,
+ AlbumArtists: []embyNameID{{Name: al.Artist, Id: l.guid("ar-" + artistKeyOf(&al.Items[0]))}},
+ ProductionYear: al.Year,
+ ChildCount: len(al.Items),
+ DateCreated: embyTimeStr(al.Items[0].CreatedAt),
+ LocationType: "FileSystem",
+ ImageTags: map[string]string{},
+ UserData: &embyUserData{},
+ }
+ if tag := embyImageTag(&al.Items[0]); tag != "" {
+ e.ImageTags["Primary"] = tag
+ }
+ return e
+}
+
+func (l *embyLibrary) songItem(it *model.MediaItem) *embyItem {
+ name := it.ScrapedName
+ if name == "" {
+ name = strings.TrimSuffix(it.FileName, stdpath.Ext(it.FileName))
+ }
+ albumName := it.AlbumName
+ if albumName == "" {
+ albumName = "Unknown Album"
+ }
+ artist := albumArtistOf(it)
+ albumKey := albumKeyOf(it)
+ artistKey := artistKeyOf(it)
+ e := &embyItem{
+ Name: name,
+ SortName: name,
+ ServerId: embyServerID(),
+ Id: l.guid(fmt.Sprintf("so-%d", it.ID)),
+ Type: "Audio",
+ MediaType: "Audio",
+ IsFolder: false,
+ ParentId: l.guid("al-" + albumKey),
+ Album: albumName,
+ AlbumId: l.guid("al-" + albumKey),
+ AlbumArtist: artist,
+ AlbumArtists: []embyNameID{{Name: artist, Id: l.guid("ar-" + artistKey)}},
+ Artists: []string{artist},
+ ArtistItems: []embyNameID{{Name: artist, Id: l.guid("ar-" + artistKey)}},
+ IndexNumber: it.TrackNumber,
+ RunTimeTicks: embyTicks(it.Duration),
+ Path: MediaItemFilePath(it),
+ Container: embyExt(it.FileName),
+ DateCreated: embyTimeStr(it.CreatedAt),
+ CommunityRating: it.Rating,
+ UserData: l.userDataOf(it.ID, 0),
+ ImageTags: map[string]string{},
+ ProviderIds: map[string]string{},
+ LocationType: "FileSystem",
+ }
+ if tag := embyImageTag(it); tag != "" {
+ e.ImageTags["Primary"] = tag
+ }
+ return e
+}
+
+func (l *embyLibrary) songByID(id string) (*model.MediaItem, bool) {
+ id = l.origID(id)
+ n, err := strconv.ParseUint(strings.TrimPrefix(id, "so-"), 10, 64)
+ if err != nil {
+ return nil, false
+ }
+ for i := range l.music {
+ if l.music[i].ID == uint(n) {
+ return &l.music[i], true
+ }
+ }
+ item, err := db.GetMediaItemByID(uint(n))
+ if err != nil || !CanUserAccessItem(l.user, item) {
+ return nil, false
+ }
+ return item, true
+}
+
+// ==================== 视图 / 列表 ====================
+
+func (l *embyLibrary) viewItems() []*embyItem {
+ movieCount, seriesCount := 0, 0
+ for i := range l.videos {
+ if isEmbySeriesItem(&l.videos[i]) {
+ continue
+ }
+ movieCount++
+ }
+ seriesCount = len(l.seriesList())
+ albumCount := len(l.albums())
+
+ var out []*embyItem
+ if MediaTypeEnabled(model.MediaTypeVideo) {
+ out = append(out, &embyItem{
+ Name: "Movies", SortName: "Movies", ServerId: embyServerID(),
+ Id: l.guid("lib-movie"), ParentId: l.guid("root"), Type: "CollectionFolder", IsFolder: true,
+ CollectionType: "movies", ViewType: "movies", LocationType: "FileSystem",
+ ChildCount: movieCount, RecursiveItemCount: movieCount,
+ // 只声明客户端能正常处理的子视图,避免生成 OpenList 没有实现的 tab
+ Subviews: []string{"movies"},
+ ImageTags: map[string]string{}, UserData: &embyUserData{},
+ })
+ out = append(out, &embyItem{
+ Name: "TV Shows", SortName: "TV Shows", ServerId: embyServerID(),
+ Id: l.guid("lib-tv"), ParentId: l.guid("root"), Type: "CollectionFolder", IsFolder: true,
+ CollectionType: "tvshows", ViewType: "tvshows", LocationType: "FileSystem",
+ ChildCount: seriesCount, RecursiveItemCount: seriesCount,
+ ImageTags: map[string]string{}, UserData: &embyUserData{},
+ })
+ }
+ if MediaTypeEnabled(model.MediaTypeMusic) {
+ out = append(out, &embyItem{
+ Name: "Music", SortName: "Music", ServerId: embyServerID(),
+ Id: l.guid("lib-music"), ParentId: l.guid("root"), Type: "CollectionFolder", IsFolder: true,
+ CollectionType: "music", ViewType: "music", LocationType: "FileSystem",
+ ChildCount: albumCount, RecursiveItemCount: albumCount,
+ ImageTags: map[string]string{}, UserData: &embyUserData{},
+ })
+ }
+ return out
+}
+
+func embyViews(c *gin.Context, user *model.User) {
+ lib := loadEmbyLibrary(user)
+ items := lib.viewItems()
+ c.JSON(200, gin.H{"Items": items, "TotalRecordCount": len(items), "StartIndex": 0})
+}
+
+// embyHomeSections 返回服务端首页分区配置(Emby 4.10+ 客户端直接使用)。
+// ListOptions.fields 必须存在:客户端会无条件调用 fields.includes()。
+func embyHomeSections() []gin.H {
+ return []gin.H{
+ {
+ "Id": "userviews",
+ "SectionType": "userviews",
+ "Name": "Media",
+ "CollectionType": "",
+ "ViewType": "cards",
+ "ScrollDirection": "Horizontal",
+ "LimitItems": false,
+ "ListOptions": gin.H{
+ "fields": []string{},
+ "shape": "auto",
+ "context": "home",
+ "preferThumb": false,
+ "preferSeriesImage": false,
+ "showChildCountIndicator": false,
+ "image": nil,
+ "sideFooter": nil,
+ "smallSideFooter": nil,
+ "showCurrentProgramImage": false,
+ "showAirDateTime": false,
+ "showAirEndTime": false,
+ "defaultShape": nil,
+ "action": nil,
+ "multiSelect": false,
+ "albumFirst": false,
+ "lines": 1,
+ "focusTransformTitleAdjust": true,
+ "contextMenu": true,
+ "hoverMenu": true,
+ "hoverPlayButton": false,
+ },
+ "QueryOptions": gin.H{},
+ },
+ }
+}
+
+// embySectionItems 返回首页某个分区的条目
+func embySectionItems(user *model.User, sectionID string) gin.H {
+ if sectionID == "userviews" {
+ lib := loadEmbyLibrary(user)
+ items := lib.viewItems()
+ return gin.H{"Items": items, "TotalRecordCount": len(items), "StartIndex": 0}
+ }
+ return gin.H{"Items": []any{}, "TotalRecordCount": 0, "StartIndex": 0}
+}
+
+func (l *embyLibrary) rootItem() *embyItem {
+ return &embyItem{
+ Name: "Media", SortName: "Media", ServerId: embyServerID(),
+ Id: l.guid("root"), Type: "CollectionFolder", IsFolder: true,
+ LocationType: "FileSystem", ImageTags: map[string]string{}, UserData: &embyUserData{},
+ }
+}
+
+// childrenOf 返回指定父级下的子项
+func (l *embyLibrary) childrenOf(parentID string) []*embyItem {
+ parentID = l.origID(parentID)
+ switch {
+ case parentID == "" || parentID == "root":
+ return l.viewItems()
+ case parentID == "lib-movie":
+ var out []*embyItem
+ for i := range l.videos {
+ if !isEmbySeriesItem(&l.videos[i]) {
+ out = append(out, l.movieItem(&l.videos[i]))
+ }
+ }
+ return out
+ case parentID == "lib-tv":
+ var out []*embyItem
+ for _, s := range l.seriesList() {
+ out = append(out, l.seriesItem(s))
+ }
+ return out
+ case parentID == "lib-music":
+ var out []*embyItem
+ for _, a := range l.albums() {
+ out = append(out, l.albumItem(a))
+ }
+ return out
+ }
+ if strings.HasPrefix(parentID, "sr-") {
+ key := strings.TrimPrefix(parentID, "sr-")
+ s := l.seriesByKey(key)
+ if s == nil {
+ return nil
+ }
+ var out []*embyItem
+ for _, season := range l.seasonList(s) {
+ out = append(out, l.seasonItem(s, season))
+ }
+ return out
+ }
+ if strings.HasPrefix(parentID, "sn-") {
+ // sn--
+ rest := strings.TrimPrefix(parentID, "sn-")
+ idx := strings.LastIndex(rest, "-")
+ if idx < 0 {
+ return nil
+ }
+ key := rest[:idx]
+ season, _ := strconv.Atoi(rest[idx+1:])
+ var out []*embyItem
+ for _, ep := range l.episodesOf(key, season) {
+ out = append(out, l.episodeItem(ep))
+ }
+ return out
+ }
+ if strings.HasPrefix(parentID, "al-") {
+ al := l.albumByKey(strings.TrimPrefix(parentID, "al-"))
+ if al == nil {
+ return nil
+ }
+ var out []*embyItem
+ for i := range al.Items {
+ out = append(out, l.songItem(&al.Items[i]))
+ }
+ return out
+ }
+ if strings.HasPrefix(parentID, "ar-") {
+ key := strings.TrimPrefix(parentID, "ar-")
+ var out []*embyItem
+ for _, al := range l.albums() {
+ if artistKeyOf(&al.Items[0]) == key {
+ out = append(out, l.albumItem(al))
+ }
+ }
+ return out
+ }
+ return nil
+}
+
+// allItems 返回媒体库全部条目(用于搜索/递归)
+func (l *embyLibrary) allItems() []*embyItem {
+ var out []*embyItem
+ for i := range l.videos {
+ if isEmbySeriesItem(&l.videos[i]) {
+ continue
+ }
+ out = append(out, l.movieItem(&l.videos[i]))
+ }
+ for _, s := range l.seriesList() {
+ out = append(out, l.seriesItem(s))
+ }
+ for i := range l.music {
+ out = append(out, l.songItem(&l.music[i]))
+ }
+ return out
+}
+
+// embySplitCSV 解析 Emby 的逗号分隔参数,同时兼容 | 分隔
+func embySplitCSV(v string) []string {
+ var out []string
+ for _, p := range strings.FieldsFunc(v, func(r rune) bool { return r == ',' || r == '|' }) {
+ if p = strings.TrimSpace(p); p != "" {
+ out = append(out, p)
+ }
+ }
+ return out
+}
+
+func embyInList(list []string, v string) bool {
+ for _, s := range list {
+ if strings.EqualFold(s, v) {
+ return true
+ }
+ }
+ return false
+}
+
+// filterEmbyItems 实现 /Items 查询的通用过滤,覆盖 openemby_tv / Theater / Tsukimi 实际会发送的参数:
+// IncludeItemTypes、ExcludeItemTypes、MediaTypes、Ids、IsFolder、SearchTerm、NameStartsWith、Filters
+// allItemsFor 按请求的 IncludeItemTypes 只构造需要的类型。
+// 客户端无 ParentId 的递归查询(如 Type=Episode / MusicAlbum)必须能返回对应类型,
+// 且不应把其他媒体类型混进来。
+func (l *embyLibrary) allItemsFor(include []string) []*embyItem {
+ if len(include) == 0 {
+ return l.allItems()
+ }
+ want := func(t string) bool { return embyInList(include, t) }
+ var out []*embyItem
+ if want("Movie") {
+ for i := range l.videos {
+ if !isEmbySeriesItem(&l.videos[i]) {
+ out = append(out, l.movieItem(&l.videos[i]))
+ }
+ }
+ }
+ if want("Series") || want("Season") || want("Episode") {
+ for _, s := range l.seriesList() {
+ if want("Series") {
+ out = append(out, l.seriesItem(s))
+ }
+ if want("Season") {
+ for _, season := range l.seasonList(s) {
+ out = append(out, l.seasonItem(s, season))
+ }
+ }
+ if want("Episode") {
+ for _, ep := range l.episodesOf(s.Key, 0) {
+ out = append(out, l.episodeItem(ep))
+ }
+ }
+ }
+ }
+ if want("Audio") {
+ for i := range l.music {
+ out = append(out, l.songItem(&l.music[i]))
+ }
+ }
+ if want("MusicAlbum") {
+ for _, a := range l.albums() {
+ out = append(out, l.albumItem(a))
+ }
+ }
+ return out
+}
+
+func filterEmbyItems(items []*embyItem, c *gin.Context) []*embyItem {
+ include := embySplitCSV(c.Query("IncludeItemTypes"))
+ exclude := embySplitCSV(c.Query("ExcludeItemTypes"))
+ mediaTypes := embySplitCSV(c.Query("MediaTypes"))
+ searchTerm := strings.ToLower(strings.TrimSpace(c.Query("SearchTerm")))
+ namePrefix := strings.ToLower(strings.TrimSpace(c.Query("NameStartsWith")))
+ filters := strings.ToLower(c.Query("Filters"))
+ isFolder := c.Query("IsFolder")
+ idSet := map[string]bool{}
+ for _, id := range embySplitCSV(c.Query("Ids")) {
+ idSet[strings.ToLower(id)] = true
+ }
+
+ var out []*embyItem
+ for _, it := range items {
+ if len(include) > 0 && !embyInList(include, it.Type) {
+ continue
+ }
+ if len(exclude) > 0 && embyInList(exclude, it.Type) {
+ continue
+ }
+ if len(mediaTypes) > 0 && !embyInList(mediaTypes, it.MediaType) {
+ continue
+ }
+ if len(idSet) > 0 && !idSet[strings.ToLower(it.Id)] {
+ continue
+ }
+ lname := strings.ToLower(it.Name)
+ if searchTerm != "" && !strings.Contains(lname, searchTerm) {
+ continue
+ }
+ if namePrefix != "" && !strings.HasPrefix(lname, namePrefix) {
+ continue
+ }
+ if isFolder != "" {
+ want := strings.EqualFold(isFolder, "true")
+ if it.IsFolder != want {
+ continue
+ }
+ }
+ if strings.Contains(filters, "isfavorite") {
+ if it.UserData == nil || !it.UserData.IsFavorite {
+ continue
+ }
+ }
+ if strings.Contains(filters, "isunplayed") {
+ if it.UserData != nil && it.UserData.Played {
+ continue
+ }
+ } else if strings.Contains(filters, "isplayed") {
+ if it.UserData == nil || !it.UserData.Played {
+ continue
+ }
+ }
+ out = append(out, it)
+ }
+ return out
+}
+
+func embyTypeMatches(itemType, wanted string) bool {
+ return embyInList(embySplitCSV(wanted), itemType)
+}
+
+// sortEmbyItems 支持多个排序键(SortBy=SeriesSortName,ParentIndexNumber,IndexNumber,SortName)
+func sortEmbyItems(items []*embyItem, sortBy, order string) {
+ if sortBy == "" {
+ return
+ }
+ keys := embySplitCSV(sortBy)
+ desc := strings.EqualFold(order, "Descending")
+ if len(keys) > 0 && strings.EqualFold(keys[0], "random") {
+ for i := len(items) - 1; i > 0; i-- {
+ j := int(timeNowUnixNano() % int64(i+1))
+ items[i], items[j] = items[j], items[i]
+ }
+ return
+ }
+ cmp := func(a, b *embyItem, key string) int {
+ switch strings.ToLower(key) {
+ case "sortname", "name", "seriessortname":
+ la, lb := strings.ToLower(a.SortName), strings.ToLower(b.SortName)
+ if strings.EqualFold(key, "seriessortname") {
+ la, lb = strings.ToLower(a.SeriesName), strings.ToLower(b.SeriesName)
+ }
+ switch {
+ case la < lb:
+ return -1
+ case la > lb:
+ return 1
+ }
+ case "datecreated", "dateplayed":
+ switch {
+ case a.DateCreated < b.DateCreated:
+ return -1
+ case a.DateCreated > b.DateCreated:
+ return 1
+ }
+ case "productionyear":
+ return a.ProductionYear - b.ProductionYear
+ case "parentindexnumber":
+ return a.ParentIndexNumber - b.ParentIndexNumber
+ case "indexnumber":
+ return a.IndexNumber - b.IndexNumber
+ case "communityrating":
+ switch {
+ case a.CommunityRating < b.CommunityRating:
+ return -1
+ case a.CommunityRating > b.CommunityRating:
+ return 1
+ }
+ }
+ return 0
+ }
+ sort.SliceStable(items, func(i, j int) bool {
+ for _, k := range keys {
+ if r := cmp(items[i], items[j], k); r != 0 {
+ if desc {
+ return r > 0
+ }
+ return r < 0
+ }
+ }
+ return false
+ })
+}
+
+func embyPaginate(items []*embyItem, start, limit int) []*embyItem {
+ if start >= len(items) {
+ return []*embyItem{}
+ }
+ end := start + limit
+ if end > len(items) {
+ end = len(items)
+ }
+ return items[start:end]
+}
+
+func timeNowUnixNano() int64 {
+ return time.Now().UnixNano()
+}
+
+// ==================== 条目分发 ====================
+
+// embyUserScoped 处理 /Users/{uid}/... 下的条目相关请求
+func embyUserScoped(c *gin.Context, user *model.User, segs, lower []string, method string) {
+ // 服务端首页分区(Emby 4.10+ 客户端)
+ if len(lower) >= 3 && lower[2] == "homesections" {
+ if method == "GET" && len(lower) == 3 {
+ c.JSON(200, embyHomeSections())
+ } else {
+ c.Status(204)
+ }
+ return
+ }
+ // 首页分区条目:/Users/{uid}/Sections/{sectionId}/Items
+ if len(lower) == 5 && lower[2] == "sections" && lower[4] == "items" {
+ c.JSON(200, embySectionItems(user, lower[3]))
+ return
+ }
+ // /users/{uid}/items...
+ if len(lower) >= 3 && lower[2] == "items" {
+ lib := loadEmbyLibrary(user)
+ switch {
+ case len(lower) == 3 && (method == "GET" || method == "POST"):
+ // /Users/{uid}/Items?ParentId=...&Recursive=...&IncludeItemTypes=...
+ // 必须按查询条件返回真实数据;Playlist/BoxSet 等无对应类型会被过滤为空
+ embyItemList(c, lib, c.Query("ParentId"))
+ case len(lower) == 4 && lower[3] == "root":
+ c.JSON(200, lib.rootItem())
+ case len(lower) == 4 && lower[3] == "latest":
+ embyLatest(c, lib)
+ case len(lower) == 4 && lower[3] == "resume":
+ embyResume(c, lib)
+ case len(lower) == 4 && method == "GET":
+ // 单条目
+ embyGetItem(c, lib, segs[3])
+ case len(lower) == 5 && lower[4] == "children":
+ embyChildren(c, lib, segs[3])
+ case len(lower) == 5 && lower[4] == "playbackinfo":
+ embyPlaybackInfo(c, lib, segs[3])
+ case len(lower) == 5 && lower[4] == "userdata":
+ embyUpdateUserData(c, user, segs[3])
+ case len(lower) == 4:
+ embyItemList(c, lib, segs[3])
+ case len(lower) == 5 && embyAuxItem(c, lower[4]):
+ default:
+ embyNotFound(c)
+ }
+ return
+ }
+ if len(lower) == 3 && embyAuxItem(c, lower[2]) {
+ return
+ }
+ // /users/{uid}/settings:客户端保存/读取首页与播放设置
+ if len(lower) == 3 && lower[2] == "settings" {
+ if method == "GET" {
+ c.JSON(200, gin.H{"HomeSections": []any{}})
+ } else {
+ c.Status(204)
+ }
+ return
+ }
+ // /users/{uid}/views 已在外部处理
+ if len(lower) >= 3 && (lower[2] == "playeditems" || lower[2] == "favoriteitems" || lower[2] == "playingitems") {
+ if len(lower) < 4 {
+ embyNotFound(c)
+ return
+ }
+ kind := lower[2]
+ id := segs[3]
+ action := ""
+ if len(lower) == 5 {
+ action = lower[4]
+ }
+ switch kind {
+ case "playeditems":
+ played := method == "POST" && action != "delete"
+ embySetPlayed(c, user, id, played)
+ case "favoriteitems":
+ fav := method == "POST" && action != "delete"
+ embySetFavorite(c, user, id, fav)
+ case "playingitems":
+ if len(lower) == 5 && lower[4] == "progress" {
+ embySaveProgress(c, user, id, false)
+ return
+ }
+ embySaveProgress(c, user, id, true)
+ }
+ return
+ }
+ embyNotFound(c)
+}
+
+// embyItemsScoped 处理 /Items/... 全局请求
+func embyItemsScoped(c *gin.Context, user *model.User, segs, lower []string, method string) {
+ lib := loadEmbyLibrary(user)
+ switch {
+ case len(lower) == 2 && lower[1] == "counts":
+ embyItemCounts(c, user)
+ case len(lower) == 2 && method == "GET":
+ embyGetItem(c, lib, segs[1])
+ case len(lower) == 3 && lower[2] == "children":
+ embyChildren(c, lib, segs[1])
+ case len(lower) == 3 && lower[2] == "playbackinfo":
+ embyPlaybackInfo(c, lib, segs[1])
+ case len(lower) == 3 && lower[2] == "refresh" && method == "POST":
+ embyRefreshItem(c, lib.origID(segs[1]))
+ // 歌词:/Items/{id}/{mediaSourceId}/Subtitles/{index}/Stream.js
+ case len(lower) >= 4 && lower[3] == "subtitles":
+ embySubtitleStream(c, user, segs[1])
+ case len(lower) >= 3 && lower[2] == "subtitles":
+ embySubtitleStream(c, user, segs[1])
+ case len(lower) >= 3 && lower[2] == "images":
+ embyImage(c, segs[1], segs, true)
+ case len(lower) == 3 && embyAuxItem(c, lower[2]):
+ default:
+ embyNotFound(c)
+ }
+}
+
+func embyGetItem(c *gin.Context, lib *embyLibrary, id string) {
+ item, ok := lib.resolveItem(id)
+ if !ok {
+ embyNotFound(c)
+ return
+ }
+ // 有歌词的歌曲在详情里也带上媒体源:客户端歌词面板读取
+ // item.MediaSources[0].DefaultSubtitleStreamIndex 来定位歌词轨
+ if item.Type == "Audio" {
+ if song, ok := lib.songByID(lib.origID(id)); ok && song.Lyrics != "" {
+ idx := 1
+ item.MediaSources = []embyMediaSource{{
+ Protocol: "Http", Id: item.Id, ItemId: item.Id, Type: "Default",
+ SupportsDirectPlay: true, SupportsDirectStream: true,
+ MediaStreams: []embyMediaStream{
+ {Type: "Audio", Index: 0, IsDefault: true},
+ embyLyricsMediaStream(idx),
+ },
+ MediaAttachments: []any{}, Formats: []string{},
+ RequiredHttpHeaders: map[string]string{},
+ DefaultSubtitleStreamIndex: &idx,
+ }}
+ }
+ }
+ c.JSON(200, item)
+}
+
+func (l *embyLibrary) resolveItem(id string) (*embyItem, bool) {
+ id = l.origID(id)
+ switch {
+ case strings.HasPrefix(id, "mv-"):
+ n, err := strconv.ParseUint(strings.TrimPrefix(id, "mv-"), 10, 64)
+ if err != nil {
+ return nil, false
+ }
+ for i := range l.videos {
+ if l.videos[i].ID == uint(n) {
+ return l.movieItem(&l.videos[i]), true
+ }
+ }
+ case strings.HasPrefix(id, "so-"):
+ if it, ok := l.songByID(id); ok {
+ return l.songItem(it), true
+ }
+ case strings.HasPrefix(id, "sr-"):
+ if s := l.seriesByKey(strings.TrimPrefix(id, "sr-")); s != nil {
+ return l.seriesItem(s), true
+ }
+ case strings.HasPrefix(id, "sn-"):
+ rest := strings.TrimPrefix(id, "sn-")
+ idx := strings.LastIndex(rest, "-")
+ if idx > 0 {
+ key := rest[:idx]
+ season, _ := strconv.Atoi(rest[idx+1:])
+ if s := l.seriesByKey(key); s != nil {
+ return l.seasonItem(s, season), true
+ }
+ }
+ case strings.HasPrefix(id, "al-"):
+ if al := l.albumByKey(strings.TrimPrefix(id, "al-")); al != nil {
+ return l.albumItem(al), true
+ }
+ case strings.HasPrefix(id, "ep-"):
+ if ep := l.findEpisode(id); ep != nil {
+ return l.episodeItem(ep), true
+ }
+ case strings.HasPrefix(id, "ar-"):
+ key := strings.TrimPrefix(id, "ar-")
+ for i := range l.music {
+ if artistKeyOf(&l.music[i]) != key {
+ continue
+ }
+ name := albumArtistOf(&l.music[i])
+ e := &embyItem{
+ Name: name, SortName: name, ServerId: embyServerID(),
+ Id: l.guid("ar-" + key), ParentId: l.guid("lib-music"),
+ Type: "MusicArtist", IsFolder: true,
+ ImageTags: map[string]string{}, UserData: &embyUserData{},
+ LocationType: "FileSystem",
+ }
+ if tag := embyImageTag(&l.music[i]); tag != "" {
+ e.ImageTags["Primary"] = tag
+ }
+ return e, true
+ }
+ case strings.HasPrefix(id, "lib-") || id == "root":
+ for _, v := range l.viewItems() {
+ if strings.EqualFold(v.Id, l.guid(id)) {
+ return v, true
+ }
+ }
+ if id == "root" {
+ return l.rootItem(), true
+ }
+ }
+ return nil, false
+}
+
+// descendantsOf 递归展开 parentID 下的全部后代条目。
+// 递归必须以 ParentId 为范围:此前直接返回全库条目,导致电影/剧集/音乐库互相串内容。
+func (l *embyLibrary) descendantsOf(parentID string) []*embyItem {
+ var out []*embyItem
+ var walk func(id string, depth int)
+ walk = func(id string, depth int) {
+ if depth > 6 {
+ return
+ }
+ for _, ch := range l.childrenOf(id) {
+ out = append(out, ch)
+ if ch.IsFolder {
+ walk(ch.Id, depth+1)
+ }
+ }
+ }
+ walk(parentID, 0)
+ return out
+}
+
+func embyItemList(c *gin.Context, lib *embyLibrary, parentID string) {
+ if q := strings.TrimSpace(c.Query("ParentId")); q != "" {
+ parentID = q
+ }
+ var items []*embyItem
+ if embyBoolQuery(c, "Recursive") {
+ if lib.origID(parentID) == "" || lib.origID(parentID) == "root" {
+ items = lib.allItemsFor(embySplitCSV(c.Query("IncludeItemTypes")))
+ } else {
+ items = lib.descendantsOf(parentID)
+ }
+ } else {
+ items = lib.childrenOf(parentID)
+ }
+ items = filterEmbyItems(items, c)
+ sortEmbyItems(items, c.Query("SortBy"), c.Query("SortOrder"))
+ total := len(items)
+ start, limit := embyPaging(c)
+ items = embyPaginate(items, start, limit)
+ c.JSON(200, embyQueryResult{Items: items, TotalRecordCount: total, StartIndex: start})
+}
+
+func embyChildren(c *gin.Context, lib *embyLibrary, parentID string) {
+ items := lib.childrenOf(parentID)
+ items = filterEmbyItems(items, c)
+ start, limit := embyPaging(c)
+ total := len(items)
+ c.JSON(200, embyQueryResult{Items: embyPaginate(items, start, limit), TotalRecordCount: total, StartIndex: start})
+}
+
+func embyLatest(c *gin.Context, lib *embyLibrary) {
+ // 客户端按媒体库分别请求"最近添加":带 ParentId 时只返回该库内容,避免电影/电视剧/音乐互相串
+ if pid := lib.origID(c.Query("ParentId")); pid == "lib-movie" || pid == "lib-tv" || pid == "lib-music" {
+ items := filterEmbyItems(lib.childrenOf(pid), c)
+ sort.SliceStable(items, func(i, j int) bool { return items[i].DateCreated > items[j].DateCreated })
+ _, limit := embyPaging(c)
+ c.JSON(200, embyPaginate(items, 0, limit))
+ return
+ }
+ var items []*embyItem
+ if MediaTypeEnabled(model.MediaTypeVideo) {
+ var movies []model.MediaItem
+ for i := range lib.videos {
+ if !isEmbySeriesItem(&lib.videos[i]) {
+ movies = append(movies, lib.videos[i])
+ }
+ }
+ sort.Slice(movies, func(i, j int) bool { return movies[i].CreatedAt.After(movies[j].CreatedAt) })
+ for i := range movies {
+ items = append(items, lib.movieItem(&movies[i]))
+ if len(items) >= 50 {
+ break
+ }
+ }
+ }
+ if MediaTypeEnabled(model.MediaTypeMusic) {
+ sort.Slice(lib.music, func(i, j int) bool { return lib.music[i].CreatedAt.After(lib.music[j].CreatedAt) })
+ for i := range lib.music {
+ items = append(items, lib.songItem(&lib.music[i]))
+ if len(items) >= 100 {
+ break
+ }
+ }
+ }
+ _, limit := embyPaging(c)
+ c.JSON(200, embyPaginate(items, 0, limit))
+}
+
+func embyResume(c *gin.Context, lib *embyLibrary) {
+ if lib.user == nil {
+ c.JSON(200, []*embyItem{})
+ return
+ }
+ rows, err := db.ListMediaResume(lib.user.ID, 100)
+ if err != nil {
+ c.JSON(200, []*embyItem{})
+ return
+ }
+ var items []*embyItem
+ for _, row := range rows {
+ id := fmt.Sprintf("mv-%d", row.ItemID)
+ if row.EpisodeIndex > 0 {
+ id = fmt.Sprintf("ep-%d-%d", row.ItemID, row.EpisodeIndex)
+ }
+ if it, ok := lib.resolveItem(id); ok {
+ if it.UserData == nil {
+ it.UserData = &embyUserData{}
+ }
+ it.UserData.PlaybackPositionTicks = row.PositionTicks
+ items = append(items, it)
+ }
+ }
+ c.JSON(200, items)
+}
+
+func embyItemCounts(c *gin.Context, user *model.User) {
+ lib := loadEmbyLibrary(user)
+ movies, series, episodes, songs, albums := 0, 0, 0, 0, 0
+ for i := range lib.videos {
+ if isEmbySeriesItem(&lib.videos[i]) {
+ continue
+ }
+ movies++
+ }
+ for range lib.seriesList() {
+ series++
+ }
+ for _, s := range lib.seriesList() {
+ episodes += len(lib.episodesOf(s.Key, 0))
+ }
+ songs = len(lib.music)
+ albums = len(lib.albums())
+ c.JSON(200, gin.H{
+ "MovieCount": movies, "SeriesCount": series, "EpisodeCount": episodes,
+ "SongCount": songs, "AlbumCount": albums,
+ "ArtistCount": len(embyArtistKeys(lib)), "BookCount": 0, "TrailerCount": 0,
+ })
+}
+
+// ==================== 剧集接口 ====================
+
+func embyShows(c *gin.Context, user *model.User, segs, lower []string, method string) {
+ lib := loadEmbyLibrary(user)
+ // /Shows/NextUp 只有两段路径
+ if len(lower) == 2 && lower[1] == "nextup" {
+ embyNextUp(c, lib)
+ return
+ }
+ // /Shows/{id} 返回剧集详情
+ if len(lower) == 2 {
+ if item, ok := lib.resolveItem(segs[1]); ok {
+ c.JSON(200, item)
+ return
+ }
+ embyNotFound(c)
+ return
+ }
+ seriesID := lib.origID(segs[1])
+ key := strings.TrimPrefix(seriesID, "sr-")
+ s := lib.seriesByKey(key)
+ if s == nil {
+ embyNotFound(c)
+ return
+ }
+ switch lower[2] {
+ case "seasons":
+ var items []*embyItem
+ for _, season := range lib.seasonList(s) {
+ items = append(items, lib.seasonItem(s, season))
+ }
+ c.JSON(200, gin.H{"Items": items, "TotalRecordCount": len(items), "StartIndex": 0})
+ case "episodes":
+ season := embyIntQuery(c, "Season")
+ // 客户端也会传 SeasonId(GUID)按季过滤
+ if sid := lib.origID(c.Query("SeasonId")); strings.HasPrefix(sid, "sn-") {
+ if idx := strings.LastIndex(sid, "-"); idx > 0 {
+ season, _ = strconv.Atoi(sid[idx+1:])
+ }
+ }
+ var items []*embyItem
+ for _, ep := range lib.episodesOf(key, season) {
+ items = append(items, lib.episodeItem(ep))
+ }
+ c.JSON(200, gin.H{"Items": items, "TotalRecordCount": len(items), "StartIndex": 0})
+ default:
+ embyNotFound(c)
+ }
+}
+
+func embyNextUp(c *gin.Context, lib *embyLibrary) {
+ var items []*embyItem
+ seriesFilter := strings.TrimPrefix(lib.origID(c.Query("SeriesId")), "sr-")
+ for _, s := range lib.seriesList() {
+ if seriesFilter != "" && s.Key != seriesFilter {
+ continue
+ }
+ if lib.user != nil {
+ if rows, err := db.ListMediaResume(lib.user.ID, 200); err == nil {
+ nextIdx := -1
+ for _, row := range rows {
+ if seriesKeyOf(&model.MediaItem{ScrapedName: s.Name}) == s.Key &&
+ row.ItemID == s.Items[0].ID && row.EpisodeIndex > 0 {
+ nextIdx = row.EpisodeIndex + 1
+ break
+ }
+ }
+ eps := lib.episodesOf(s.Key, 0)
+ if nextIdx > 0 {
+ for _, ep := range eps {
+ if ep.Index == nextIdx {
+ items = append(items, lib.episodeItem(ep))
+ break
+ }
+ }
+ } else if len(eps) > 0 {
+ items = append(items, lib.episodeItem(eps[0]))
+ }
+ continue
+ }
+ }
+ eps := lib.episodesOf(s.Key, 0)
+ if len(eps) > 0 {
+ items = append(items, lib.episodeItem(eps[0]))
+ }
+ }
+ c.JSON(200, gin.H{"Items": items, "TotalRecordCount": len(items)})
+}
+
+// ==================== 音乐 / 搜索 ====================
+
+func embyArtistKeys(lib *embyLibrary) []string {
+ set := map[string]string{}
+ for i := range lib.music {
+ key := artistKeyOf(&lib.music[i])
+ set[key] = albumArtistOf(&lib.music[i])
+ }
+ out := make([]string, 0, len(set))
+ for k := range set {
+ out = append(out, k)
+ }
+ sort.Strings(out)
+ return out
+}
+
+func embyArtists(c *gin.Context, user *model.User, albumOnly bool) {
+ lib := loadEmbyLibrary(user)
+ seen := map[string]bool{}
+ var items []*embyItem
+ for i := range lib.music {
+ it := &lib.music[i]
+ key := artistKeyOf(it)
+ if seen[key] {
+ continue
+ }
+ seen[key] = true
+ name := albumArtistOf(it)
+ e := &embyItem{
+ Name: name, SortName: name, ServerId: embyServerID(),
+ Id: lib.guid("ar-" + key), ParentId: lib.guid("lib-music"), Type: "MusicArtist", IsFolder: true,
+ ImageTags: map[string]string{}, UserData: &embyUserData{},
+ LocationType: "FileSystem",
+ }
+ if tag := embyImageTag(it); tag != "" {
+ e.ImageTags["Primary"] = tag
+ }
+ items = append(items, e)
+ }
+ start, limit := embyPaging(c)
+ c.JSON(200, embyQueryResult{Items: embyPaginate(items, start, limit), TotalRecordCount: len(items), StartIndex: start})
+}
+
+func embySearchHints(c *gin.Context, user *model.User) {
+ lib := loadEmbyLibrary(user)
+ term := strings.ToLower(strings.TrimSpace(c.Query("SearchTerm")))
+ items := lib.allItems()
+ items = filterEmbyItems(items, c)
+ var hints []gin.H
+ for _, it := range items {
+ if term != "" && !strings.Contains(strings.ToLower(it.Name), term) {
+ continue
+ }
+ hints = append(hints, gin.H{
+ "ItemId": it.Id,
+ "Id": it.Id,
+ "Name": it.Name,
+ "Type": it.Type,
+ "MediaType": it.MediaType,
+ "RunTimeTicks": it.RunTimeTicks,
+ "ProductionYear": it.ProductionYear,
+ "ImageTags": it.ImageTags,
+ })
+ if len(hints) >= 50 {
+ break
+ }
+ }
+ c.JSON(200, gin.H{"SearchHints": hints, "TotalRecordCount": len(hints)})
+}
+
+// ==================== 媒体库目录信息(MoviePilot 需要) ====================
+
+func embySelectableFolders(c *gin.Context, user *model.User) {
+ paths, err := db.ListMediaScanPaths("")
+ if err != nil {
+ c.JSON(500, gin.H{"Error": err.Error()})
+ return
+ }
+ var out []gin.H
+ for _, p := range paths {
+ if !CanUserAccessPath(user, p.Path) {
+ continue
+ }
+ out = append(out, gin.H{
+ "Name": p.Name,
+ "SubFolders": []gin.H{{
+ "Name": p.Name,
+ "Path": p.Path,
+ "Id": strconv.FormatUint(uint64(p.ID), 10),
+ }},
+ })
+ }
+ c.JSON(200, out)
+}
+
+func embyVirtualFolders(c *gin.Context, user *model.User) {
+ paths, err := db.ListMediaScanPaths("")
+ if err != nil {
+ c.JSON(500, gin.H{"Error": err.Error()})
+ return
+ }
+ var out []gin.H
+ for _, p := range paths {
+ if !CanUserAccessPath(user, p.Path) {
+ continue
+ }
+ collectionType := "movies"
+ switch p.MediaType {
+ case model.MediaTypeVideo:
+ if p.TypeTag == "电视剧" || p.TypeTag == "tv" {
+ collectionType = "tvshows"
+ }
+ case model.MediaTypeMusic:
+ collectionType = "music"
+ case model.MediaTypeImage:
+ collectionType = "homevideos"
+ case model.MediaTypeBook:
+ collectionType = "books"
+ }
+ out = append(out, gin.H{
+ "Name": p.Name,
+ "ItemId": "libpath-" + strconv.FormatUint(uint64(p.ID), 10),
+ "CollectionType": collectionType,
+ "Locations": []string{p.Path},
+ "LibraryOptions": gin.H{
+ "PathInfos": []gin.H{{"Path": p.Path}},
+ },
+ })
+ }
+ c.JSON(200, out)
+}
diff --git a/server/handles/emby_play.go b/server/handles/emby_play.go
new file mode 100644
index 00000000..eef8d916
--- /dev/null
+++ b/server/handles/emby_play.go
@@ -0,0 +1,681 @@
+package handles
+
+import (
+ "encoding/base64"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/url"
+ stdpath "path"
+ "regexp"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/OpenListTeam/OpenList/v4/internal/db"
+ "github.com/OpenListTeam/OpenList/v4/internal/media"
+ "github.com/OpenListTeam/OpenList/v4/internal/media/imgmime"
+ "github.com/OpenListTeam/OpenList/v4/internal/model"
+ "github.com/OpenListTeam/OpenList/v4/internal/op"
+ "github.com/OpenListTeam/OpenList/v4/server/common"
+ "github.com/gin-gonic/gin"
+)
+
+// embyPlayTarget 播放目标(条目或某一集)
+type embyPlayTarget struct {
+ Item *model.MediaItem
+ Episode *model.EpisodeInfo
+ RawPath string
+ Container string
+ Size int64
+ Name string
+ Duration int
+ ItemID uint
+ EpisodeNo int
+}
+
+func (l *embyLibrary) playTarget(id string) (*embyPlayTarget, bool) {
+ id = l.origID(id)
+ switch {
+ case strings.HasPrefix(id, "mv-"):
+ n, err := strconv.ParseUint(strings.TrimPrefix(id, "mv-"), 10, 64)
+ if err != nil {
+ return nil, false
+ }
+ for i := range l.videos {
+ if l.videos[i].ID == uint(n) {
+ it := &l.videos[i]
+ return &embyPlayTarget{
+ Item: it, RawPath: MediaItemFilePath(it), Container: embyExt(it.FileName),
+ Size: it.FileSize, Name: it.FileName, Duration: it.Duration, ItemID: it.ID,
+ }, true
+ }
+ }
+ case strings.HasPrefix(id, "so-"):
+ it, ok := l.songByID(id)
+ if !ok {
+ return nil, false
+ }
+ return &embyPlayTarget{
+ Item: it, RawPath: MediaItemFilePath(it), Container: embyExt(it.FileName),
+ Size: it.FileSize, Name: it.FileName, Duration: it.Duration, ItemID: it.ID,
+ }, true
+ case strings.HasPrefix(id, "ep-"):
+ ep := l.findEpisode(id)
+ if ep == nil {
+ return nil, false
+ }
+ raw := MediaItemFilePath(ep.Item)
+ if ep.Episode.FileName != "" {
+ raw = stdpath.Join(mediaFolderOf(ep.Item), ep.Item.FileName, ep.Episode.FileName)
+ }
+ return &embyPlayTarget{
+ Item: ep.Item, Episode: &ep.Episode, RawPath: raw, Container: embyExt(raw),
+ Size: ep.Item.FileSize, Name: ep.Episode.FileName, Duration: ep.Item.Duration,
+ ItemID: ep.Item.ID, EpisodeNo: ep.Index,
+ }, true
+ }
+ return nil, false
+}
+
+// embyPlaybackInfo 返回直连播放信息
+func embyPlaybackInfo(c *gin.Context, lib *embyLibrary, id string) {
+ target, ok := lib.playTarget(id)
+ if !ok {
+ embyNotFound(c)
+ return
+ }
+ api := common_ApiBase(c)
+ isAudio := target.Item.MediaType == model.MediaTypeMusic
+ kind, streamType := "Videos", "Video"
+ if isAudio {
+ kind, streamType = "Audio", "Audio"
+ }
+ // 流地址必须自带 api_key:播放器取流时不会再附加请求头,
+ // 缺失会得到 401,客户端表现为"Playback of this content is currently restricted"
+ q := url.Values{}
+ q.Set("Static", "true")
+ q.Set("MediaSourceId", id)
+ if tok := embyRequestToken(c); tok != "" {
+ q.Set("api_key", tok)
+ }
+ streamURL := fmt.Sprintf("%s/@emby/%s/%s/stream.%s?%s", api, kind, id, target.Container, q.Encode())
+ if target.Container == "" {
+ streamURL = fmt.Sprintf("%s/@emby/%s/%s/stream?%s", api, kind, id, q.Encode())
+ }
+ // 必须是 Http:客户端看到 File 会当成"服务器本地文件",
+ // 转而调用原生 LocalItemRepository 解析本地路径,必然失败(Playback Error)
+ streams := []embyMediaStream{{Type: streamType, Index: 0, IsDefault: true}}
+ var subtitleIndex *int
+ if isAudio && target.Item.Lyrics != "" {
+ // 歌词以"外挂字幕轨"的形式提供给客户端(Emby Theater 的歌词面板
+ // 只遍历 Subtitle 流,并请求 Subtitles/{index}/Stream.js)
+ lyricsIndex := len(streams)
+ streams = append(streams, embyLyricsMediaStream(lyricsIndex))
+ subtitleIndex = &lyricsIndex
+ }
+ source := embyMediaSource{
+ Protocol: "Http",
+ Id: id,
+ ItemId: id,
+ // Emby Theater 在本地播放时会把 Path 直接交给 mpv;
+ // 填文件系统路径会让 mpv 去开本机不存在的文件(一直转圈/Playback Error),
+ // 这里填可播放的 HTTP 流地址。
+ Path: streamURL,
+ Type: "Default",
+ Container: target.Container,
+ Name: target.Name,
+ Size: target.Size,
+ ETag: embyHash(fmt.Sprintf("%s|%d", target.RawPath, target.Size)),
+ RunTimeTicks: embyTicks(target.Duration),
+ SupportsDirectPlay: true,
+ SupportsDirectStream: true,
+ SupportsTranscoding: false,
+ SupportsProbing: false,
+ MediaStreams: streams,
+ MediaAttachments: []any{},
+ Formats: []string{},
+ RequiredHttpHeaders: map[string]string{},
+ DirectStreamUrl: streamURL,
+ AddApiKeyToDirectStreamUrl: false,
+ DefaultSubtitleStreamIndex: subtitleIndex,
+ }
+ c.JSON(200, gin.H{
+ "MediaSources": []embyMediaSource{source},
+ "PlaySessionId": fmt.Sprintf("openlist-%d-%d", target.ItemID, target.EpisodeNo),
+ })
+}
+
+// embyStream 302 到签名直链
+func embyStream(c *gin.Context, user *model.User, id string, isAudio bool) {
+ lib := loadEmbyLibrary(user)
+ target, ok := lib.playTarget(id)
+ if !ok {
+ embyNotFound(c)
+ return
+ }
+ url := SignedStreamURL(c, target.RawPath)
+ c.Redirect(http.StatusFound, url)
+}
+
+// ==================== 图片 ====================
+
+var dataURIPrefix = "data:image/"
+
+// embyImage 返回条目封面
+func embyImage(c *gin.Context, id string, segs []string, allowAnon bool) {
+ var lib *embyLibrary
+ if user, ok := embyAuth(c); ok {
+ lib = loadEmbyLibrary(user)
+ } else if allowAnon {
+ // Emby 客户端的封面 URL 不带 token,只能允许匿名访问;
+ // 但必须按访客身份做权限过滤,否则等于把整个媒体库(含 hidden、
+ // base_path 之外、meta 受保护的条目)的封面暴露给未登录用户。
+ // 访客被禁用时直接拒绝,不能退化成「全库可见」。
+ guest, err := op.GetGuest()
+ if err != nil || guest.Disabled {
+ embyUnauthorized(c)
+ return
+ }
+ lib = loadEmbyLibrary(guest)
+ } else {
+ embyUnauthorized(c)
+ return
+ }
+ // 客户端回传的是 GUID,这里先还原成内部 ID 再做前缀判断。
+ // 注意不能先走 resolveItem:匿名库的 user 为 nil,剧集条目会被权限校验判为不可见。
+ id = lib.origID(id)
+ var mediaItem *model.MediaItem
+ switch {
+ case strings.HasPrefix(id, "mv-"):
+ n, _ := strconv.ParseUint(strings.TrimPrefix(id, "mv-"), 10, 64)
+ for i := range lib.videos {
+ if lib.videos[i].ID == uint(n) {
+ mediaItem = &lib.videos[i]
+ }
+ }
+ case strings.HasPrefix(id, "so-"):
+ mediaItem, _ = lib.songByID(id)
+ case strings.HasPrefix(id, "ep-"):
+ // 图片 URL 不带 token,匿名库的 user 为 nil;
+ // findEpisode 会做用户权限校验并直接失败,这里只按库内条目定位封面来源
+ if it, ok := lib.episodeSourceItem(id); ok {
+ mediaItem = it
+ }
+ case strings.HasPrefix(id, "sr-"):
+ if s := lib.seriesByKey(strings.TrimPrefix(id, "sr-")); s != nil && len(s.Items) > 0 {
+ mediaItem = &s.Items[0]
+ }
+ case strings.HasPrefix(id, "sn-"):
+ rest := strings.TrimPrefix(id, "sn-")
+ if idx := strings.LastIndex(rest, "-"); idx > 0 {
+ if s := lib.seriesByKey(rest[:idx]); s != nil && len(s.Items) > 0 {
+ mediaItem = &s.Items[0]
+ }
+ }
+ case strings.HasPrefix(id, "al-"):
+ if al := lib.albumByKey(strings.TrimPrefix(id, "al-")); al != nil && len(al.Items) > 0 {
+ mediaItem = &al.Items[0]
+ }
+ case strings.HasPrefix(id, "ar-"):
+ key := strings.TrimPrefix(id, "ar-")
+ for i := range lib.music {
+ if artistKeyOf(&lib.music[i]) == key {
+ mediaItem = &lib.music[i]
+ break
+ }
+ }
+ }
+ if mediaItem == nil {
+ c.Status(404)
+ return
+ }
+ serveEmbyCover(c, mediaItem)
+}
+
+// episodeSourceItem 按 ep-- 在库内定位剧集所属条目,不做权限校验。
+// 仅用于封面这类匿名可访问的资源,播放与条目详情仍走 findEpisode 的权限校验。
+func (l *embyLibrary) episodeSourceItem(id string) (*model.MediaItem, bool) {
+ parts := strings.Split(strings.TrimPrefix(id, "ep-"), "-")
+ if len(parts) != 2 {
+ return nil, false
+ }
+ n, err := strconv.ParseUint(parts[0], 10, 64)
+ if err != nil {
+ return nil, false
+ }
+ for i := range l.videos {
+ if l.videos[i].ID == uint(n) {
+ return &l.videos[i], true
+ }
+ }
+ return nil, false
+}
+
+func serveEmbyCover(c *gin.Context, item *model.MediaItem) {
+ cover := strings.TrimSpace(item.Cover)
+ if cover == "" {
+ c.Status(404)
+ return
+ }
+ if strings.HasPrefix(cover, dataURIPrefix) {
+ idx := strings.Index(cover, ";base64,")
+ if idx < 0 {
+ c.Status(404)
+ return
+ }
+ data, err := base64.StdEncoding.DecodeString(cover[idx+8:])
+ if err != nil {
+ c.Status(404)
+ return
+ }
+ // 数据库里的 data URI 声明的 MIME 不可信(历史数据可能来自内嵌标签/远端响应),
+ // 响应头只使用按魔数嗅探出的四种栅格类型,SVG/HTML 一律拒绝。
+ mime := imgmime.Sniff(data)
+ if mime == "" {
+ c.Status(404)
+ return
+ }
+ c.Header("X-Content-Type-Options", "nosniff")
+ c.Header("Content-Disposition", `inline; filename="cover"`)
+ c.Header("Content-Security-Policy", "default-src 'none'; sandbox")
+ c.Header("Cache-Control", "private, max-age=86400")
+ c.Data(200, mime, data)
+ return
+ }
+ if strings.HasPrefix(cover, "http://") || strings.HasPrefix(cover, "https://") {
+ c.Redirect(302, cover)
+ return
+ }
+ // 本地缩略图路径
+ c.Redirect(302, SignedStreamURL(c, cover))
+}
+
+// ==================== 歌词 ====================
+
+var lrcLineRe = regexp.MustCompile(`\[(\d{1,3}):(\d{1,2})(?:[.:](\d{1,3}))?\](.*)`)
+
+func parseLrc(content string) []gin.H {
+ var out []gin.H
+ for _, line := range strings.Split(content, "\n") {
+ m := lrcLineRe.FindStringSubmatch(strings.TrimSpace(line))
+ if len(m) == 0 {
+ continue
+ }
+ min, _ := strconv.Atoi(m[1])
+ sec, _ := strconv.Atoi(m[2])
+ ms := 0
+ if m[3] != "" {
+ frac := m[3]
+ for len(frac) < 3 {
+ frac += "0"
+ }
+ ms, _ = strconv.Atoi(frac[:3])
+ }
+ text := strings.TrimSpace(m[4])
+ out = append(out, gin.H{
+ "Text": text,
+ "Start": int64(min*60000 + sec*1000 + ms),
+ })
+ }
+ return out
+}
+
+func embyLyrics(c *gin.Context, user *model.User, id string) {
+ lib := loadEmbyLibrary(user)
+ item, ok := lib.songByID(id)
+ if !ok || item.Lyrics == "" {
+ c.Status(404)
+ return
+ }
+ lines := parseLrc(item.Lyrics)
+ c.JSON(200, gin.H{"Lyrics": lines, "IsSynced": len(lines) > 0})
+}
+
+// embyLyricsMediaStream 构造歌词字幕轨(Emby 用外挂字幕承载歌词)。
+func embyLyricsMediaStream(index int) embyMediaStream {
+ return embyMediaStream{
+ Type: "Subtitle",
+ Index: index,
+ Codec: "lrc",
+ Language: "und",
+ DisplayTitle: "Lyrics",
+ IsExternal: true,
+ IsTextSubtitleStream: true,
+ SupportsExternalStream: true,
+ DeliveryMethod: "External",
+ DeliveryUrl: "",
+ }
+}
+
+// embyLyricsTrackEvents 把 LRC 转成客户端歌词面板使用的 TrackEvent 列表。
+// 客户端按 StartPositionTicks 与当前播放位置比较(1ms = 10000 ticks)。
+// 没有时间轴的纯文本歌词会按行均分到歌曲时长,至少能顺序展示。
+func embyLyricsTrackEvents(content string, durationSec int) []gin.H {
+ lines := parseLrc(content)
+ if len(lines) == 0 {
+ texts := make([]string, 0, 32)
+ for _, l := range strings.Split(strings.ReplaceAll(content, "\r\n", "\n"), "\n") {
+ if t := strings.TrimSpace(l); t != "" {
+ texts = append(texts, t)
+ }
+ }
+ if len(texts) > 0 {
+ total := int64(durationSec) * 1000
+ if total <= 0 {
+ total = int64(len(texts)) * 5000
+ }
+ step := total / int64(len(texts))
+ for i, t := range texts {
+ lines = append(lines, gin.H{"Text": t, "Start": int64(i) * step})
+ }
+ }
+ }
+ events := make([]gin.H, 0, len(lines))
+ for _, l := range lines {
+ ms, _ := l["Start"].(int64)
+ events = append(events, gin.H{
+ "StartPositionTicks": ms * 10000,
+ "Text": l["Text"],
+ })
+ }
+ return events
+}
+
+// embySubtitleStream 处理 /Items/{id}/{mediaSourceId}/Subtitles/{index}/Stream.js
+// Emby Theater 用它拉取歌词行(JSON 形式的 TrackEvents)
+func embySubtitleStream(c *gin.Context, user *model.User, id string) {
+ lib := loadEmbyLibrary(user)
+ item, ok := lib.songByID(lib.origID(id))
+ if !ok || item.Lyrics == "" {
+ c.JSON(200, gin.H{"TrackEvents": []any{}})
+ return
+ }
+ events := embyLyricsTrackEvents(item.Lyrics, item.Duration)
+ c.JSON(200, gin.H{"TrackEvents": events, "TotalRecordCount": len(events)})
+}
+
+// ==================== 播放会话 / 用户状态 ====================
+
+type embyPlaybackBody struct {
+ ItemId string `json:"ItemId"`
+ ItemID string `json:"ItemID"`
+ PositionTicks int64 `json:"PositionTicks"`
+ PlayMethod string `json:"PlayMethod"`
+ EventName string `json:"EventName"`
+ MediaSourceId string `json:"MediaSourceId"`
+}
+
+func (b embyPlaybackBody) itemID() string {
+ if b.ItemId != "" {
+ return b.ItemId
+ }
+ return b.ItemID
+}
+
+func embySessionPlaying(c *gin.Context, user *model.User, action string) {
+ var body embyPlaybackBody
+ _ = c.ShouldBindJSON(&body)
+ if action == "ping" {
+ c.Status(204)
+ return
+ }
+ id := body.itemID()
+ if id == "" {
+ id = c.Query("ItemId")
+ }
+ if id == "" {
+ c.Status(204)
+ return
+ }
+ stopped := action == "stopped" || action == "stop"
+ lib := loadEmbyLibrary(user)
+ target, ok := lib.playTarget(id)
+ if !ok {
+ c.Status(204)
+ return
+ }
+ position := body.PositionTicks
+ if position == 0 {
+ if v, err := strconv.ParseInt(c.DefaultQuery("PositionTicks", "0"), 10, 64); err == nil {
+ position = v
+ }
+ }
+ played := false
+ if stopped && target.Duration > 0 {
+ ratio := float64(position) / float64(embyTicks(target.Duration))
+ played = ratio >= 0.9
+ }
+ applyProgress(user, target, position, played, stopped)
+ c.Status(204)
+}
+
+func embySaveProgress(c *gin.Context, user *model.User, id string, start bool) {
+ var body embyPlaybackBody
+ _ = c.ShouldBindJSON(&body)
+ lib := loadEmbyLibrary(user)
+ target, ok := lib.playTarget(id)
+ if !ok {
+ c.Status(204)
+ return
+ }
+ position := body.PositionTicks
+ if position == 0 {
+ position, _ = strconv.ParseInt(c.DefaultQuery("PositionTicks", "0"), 10, 64)
+ }
+ played := false
+ stopped := c.Request.Method == "DELETE"
+ if stopped && target.Duration > 0 {
+ played = float64(position)/float64(embyTicks(target.Duration)) >= 0.9
+ }
+ applyProgress(user, target, position, played, stopped)
+ c.Status(204)
+}
+
+func applyProgress(user *model.User, target *embyPlayTarget, position int64, played bool, stopped bool) {
+ if user == nil || target == nil {
+ return
+ }
+ d, err := db.GetMediaUserData(user.ID, target.ItemID, target.EpisodeNo)
+ if err != nil || d == nil {
+ d = &model.MediaUserData{UserID: user.ID, ItemID: target.ItemID, EpisodeIndex: target.EpisodeNo}
+ }
+ d.PositionTicks = position
+ if played {
+ d.Played = true
+ d.PositionTicks = 0
+ d.PlayCount++
+ }
+ now := time.Now()
+ d.LastPlayedAt = &now
+ _ = db.SaveMediaUserData(d)
+}
+
+func embySetPlayed(c *gin.Context, user *model.User, id string, played bool) {
+ lib := loadEmbyLibrary(user)
+ id = lib.origID(id)
+ target, ok := lib.playTarget(id)
+ if !ok {
+ // 剧集/季整体标记
+ if strings.HasPrefix(id, "sr-") || strings.HasPrefix(id, "sn-") {
+ embyMarkContainerPlayed(c, user, lib, id, played)
+ return
+ }
+ embyNotFound(c)
+ return
+ }
+ d, err := db.GetMediaUserData(user.ID, target.ItemID, target.EpisodeNo)
+ if err != nil || d == nil {
+ d = &model.MediaUserData{UserID: user.ID, ItemID: target.ItemID, EpisodeIndex: target.EpisodeNo}
+ }
+ d.Played = played
+ if played {
+ d.PositionTicks = 0
+ d.PlayCount++
+ } else {
+ d.PlayCount = 0
+ }
+ now := time.Now()
+ d.LastPlayedAt = &now
+ _ = db.SaveMediaUserData(d)
+ c.JSON(200, gin.H{
+ "PlaybackPositionTicks": d.PositionTicks,
+ "PlayCount": d.PlayCount,
+ "Played": d.Played,
+ "IsFavorite": d.IsFavorite,
+ })
+}
+
+func embyMarkContainerPlayed(c *gin.Context, user *model.User, lib *embyLibrary, id string, played bool) {
+ var targets []*embyPlayTarget
+ if strings.HasPrefix(id, "sr-") {
+ key := strings.TrimPrefix(id, "sr-")
+ for _, ep := range lib.episodesOf(key, 0) {
+ if t, ok := lib.playTarget(ep.ID); ok {
+ targets = append(targets, t)
+ }
+ }
+ } else {
+ rest := strings.TrimPrefix(id, "sn-")
+ idx := strings.LastIndex(rest, "-")
+ if idx > 0 {
+ key := rest[:idx]
+ season, _ := strconv.Atoi(rest[idx+1:])
+ for _, ep := range lib.episodesOf(key, season) {
+ if t, ok := lib.playTarget(ep.ID); ok {
+ targets = append(targets, t)
+ }
+ }
+ }
+ }
+ for _, t := range targets {
+ d, err := db.GetMediaUserData(user.ID, t.ItemID, t.EpisodeNo)
+ if err != nil || d == nil {
+ d = &model.MediaUserData{UserID: user.ID, ItemID: t.ItemID, EpisodeIndex: t.EpisodeNo}
+ }
+ d.Played = played
+ d.PositionTicks = 0
+ if !played {
+ d.PlayCount = 0
+ }
+ _ = db.SaveMediaUserData(d)
+ }
+ c.Status(204)
+}
+
+func embySetFavorite(c *gin.Context, user *model.User, id string, fav bool) {
+ lib := loadEmbyLibrary(user)
+ target, ok := lib.playTarget(id)
+ if !ok {
+ embyNotFound(c)
+ return
+ }
+ d, err := db.GetMediaUserData(user.ID, target.ItemID, target.EpisodeNo)
+ if err != nil || d == nil {
+ d = &model.MediaUserData{UserID: user.ID, ItemID: target.ItemID, EpisodeIndex: target.EpisodeNo}
+ }
+ d.IsFavorite = fav
+ _ = db.SaveMediaUserData(d)
+ c.JSON(200, gin.H{
+ "PlaybackPositionTicks": d.PositionTicks,
+ "PlayCount": d.PlayCount,
+ "Played": d.Played,
+ "IsFavorite": d.IsFavorite,
+ })
+}
+
+// embyUpdateUserData 部分更新用户状态
+func embyUpdateUserData(c *gin.Context, user *model.User, id string) {
+ lib := loadEmbyLibrary(user)
+ target, ok := lib.playTarget(id)
+ if !ok {
+ embyNotFound(c)
+ return
+ }
+ var body map[string]any
+ _ = c.ShouldBindJSON(&body)
+ d, err := db.GetMediaUserData(user.ID, target.ItemID, target.EpisodeNo)
+ if err != nil || d == nil {
+ d = &model.MediaUserData{UserID: user.ID, ItemID: target.ItemID, EpisodeIndex: target.EpisodeNo}
+ }
+ if v, ok := body["Played"].(bool); ok {
+ d.Played = v
+ if v {
+ d.PositionTicks = 0
+ }
+ }
+ if v, ok := body["IsFavorite"].(bool); ok {
+ d.IsFavorite = v
+ }
+ if v, ok := body["PlaybackPositionTicks"].(float64); ok {
+ d.PositionTicks = int64(v)
+ }
+ if v, ok := body["PlayCount"].(float64); ok {
+ d.PlayCount = int(v)
+ }
+ _ = db.SaveMediaUserData(d)
+ c.JSON(200, gin.H{
+ "PlaybackPositionTicks": d.PositionTicks,
+ "PlayCount": d.PlayCount,
+ "Played": d.Played,
+ "IsFavorite": d.IsFavorite,
+ })
+}
+
+// embyRefreshItem 触发条目所属扫描路径的增量扫描
+func embyRefreshItem(c *gin.Context, id string) {
+ lib := loadEmbyLibrary(nil)
+ var itemID uint
+ switch {
+ case strings.HasPrefix(id, "mv-"), strings.HasPrefix(id, "so-"):
+ n, _ := strconv.ParseUint(id[3:], 10, 64)
+ itemID = uint(n)
+ case strings.HasPrefix(id, "ep-"):
+ parts := strings.Split(id, "-")
+ if len(parts) == 3 {
+ n, _ := strconv.ParseUint(parts[1], 10, 64)
+ itemID = uint(n)
+ }
+ case strings.HasPrefix(id, "sr-"):
+ if s := lib.seriesByKey(strings.TrimPrefix(id, "sr-")); s != nil && len(s.Items) > 0 {
+ itemID = s.Items[0].ID
+ }
+ case strings.HasPrefix(id, "al-"):
+ if al := lib.albumByKey(strings.TrimPrefix(id, "al-")); al != nil && len(al.Items) > 0 {
+ itemID = al.Items[0].ID
+ }
+ }
+ if itemID == 0 {
+ c.Status(204)
+ return
+ }
+ item, err := db.GetMediaItemByID(itemID)
+ if err != nil {
+ c.Status(204)
+ return
+ }
+ if sp, err := db.GetMediaScanPath(item.ScanPathID); err == nil && sp != nil {
+ media.ScanMediaPath(sp)
+ }
+ c.Status(204)
+}
+
+// ==================== 内部工具 ====================
+
+func common_ApiBase(c *gin.Context) string {
+ return strings.TrimSuffix(common.GetApiUrl(c.Request.Context()), "/")
+}
+
+// embyBodyJSON 便于调试时输出请求体
+func embyBodyJSON(c *gin.Context) string {
+ raw, _ := c.GetRawData()
+ if len(raw) == 0 {
+ return ""
+ }
+ var v any
+ if err := json.Unmarshal(raw, &v); err != nil {
+ return string(raw)
+ }
+ b, _ := json.Marshal(v)
+ return string(b)
+}
diff --git a/server/handles/fsmanage.go b/server/handles/fsmanage.go
index 11e904be..1a027027 100644
--- a/server/handles/fsmanage.go
+++ b/server/handles/fsmanage.go
@@ -63,6 +63,7 @@ type MoveCopyReq struct {
Overwrite bool `json:"overwrite"`
SkipExisting bool `json:"skip_existing"`
Merge bool `json:"merge"`
+ FollowSeed bool `json:"follow_seed"`
}
// FsMove performs batch move (individual item permission checks skipped for performance).
@@ -156,6 +157,14 @@ func FsMove(c *gin.Context) {
common.ErrorResp(c, err, 500)
return
}
+ if req.FollowSeed {
+ seedTasks, followErr := followSeedTransfer(c, "move", p, dstDir)
+ if followErr != nil {
+ common.ErrorResp(c, followErr, 500)
+ return
+ }
+ addedTasks = append(addedTasks, seedTasks...)
+ }
}
// Return immediately with task information
@@ -269,6 +278,14 @@ func FsCopy(c *gin.Context) {
common.ErrorResp(c, err, 500)
return
}
+ if req.FollowSeed {
+ seedTasks, followErr := followSeedTransfer(c, "copy", p, dstDir)
+ if followErr != nil {
+ common.ErrorResp(c, followErr, 500)
+ return
+ }
+ addedTasks = append(addedTasks, seedTasks...)
+ }
}
// Return immediately with task information
@@ -285,9 +302,10 @@ func FsCopy(c *gin.Context) {
}
type RenameReq struct {
- Path string `json:"path"`
- Name string `json:"name"`
- Overwrite bool `json:"overwrite"`
+ Path string `json:"path"`
+ Name string `json:"name"`
+ Overwrite bool `json:"overwrite"`
+ FollowSeed bool `json:"follow_seed"`
}
func FsRename(c *gin.Context) {
@@ -332,6 +350,12 @@ func FsRename(c *gin.Context) {
common.ErrorResp(c, err, 500)
return
}
+ if req.FollowSeed {
+ if err := followSeedRename(c, reqPath, req.Name); err != nil {
+ common.ErrorResp(c, err, 500)
+ return
+ }
+ }
common.SuccessResp(c)
}
@@ -343,8 +367,9 @@ func checkRelativePath(path string) error {
}
type RemoveReq struct {
- Dir string `json:"dir"`
- Names []string `json:"names"`
+ Dir string `json:"dir"`
+ Names []string `json:"names"`
+ FollowSeed bool `json:"follow_seed"`
}
// FsRemove performs batch remove (individual item permission checks skipped for performance).
@@ -396,16 +421,88 @@ func FsRemove(c *gin.Context) {
if path == "" {
continue
}
+ source, _ := fs.Get(c.Request.Context(), path, &fs.GetArgs{NoLog: true})
err := fs.Remove(c.Request.Context(), path)
if err != nil {
common.ErrorResp(c, err, 500)
return
}
+ if req.FollowSeed && source != nil && !source.IsDir() {
+ if err = followSeedRemove(c, path); err != nil {
+ common.ErrorResp(c, err, 500)
+ return
+ }
+ }
}
//fs.ClearCache(req.Dir)
common.SuccessResp(c)
}
+func seedSidecarPaths(filePath string) []string {
+ return []string{filePath + ".oss", filePath + ".torrent", filePath + ".cas", filePath + ".cas.torrent"}
+}
+
+func followSeedTransfer(c *gin.Context, operation, srcPath, dstDir string) ([]task.TaskExtensionInfo, error) {
+ source, err := fs.Get(c.Request.Context(), srcPath, &fs.GetArgs{NoLog: true})
+ if err != nil || source == nil || source.IsDir() {
+ return nil, nil
+ }
+ var tasks []task.TaskExtensionInfo
+ for _, sidecarPath := range seedSidecarPaths(srcPath) {
+ obj, err := fs.Get(c.Request.Context(), sidecarPath, &fs.GetArgs{NoLog: true})
+ if err != nil || obj == nil || obj.IsDir() {
+ continue
+ }
+ var current task.TaskExtensionInfo
+ switch operation {
+ case "copy":
+ current, err = fs.Copy(c.Request.Context(), sidecarPath, dstDir, true)
+ case "move":
+ current, err = fs.Move(c.Request.Context(), sidecarPath, dstDir, true)
+ default:
+ return nil, fmt.Errorf("unsupported seed sidecar operation %q", operation)
+ }
+ if err != nil {
+ return tasks, fmt.Errorf("%s seed sidecar %s: %w", operation, sidecarPath, err)
+ }
+ if current != nil {
+ tasks = append(tasks, current)
+ }
+ }
+ return tasks, nil
+}
+
+func followSeedRename(c *gin.Context, srcPath, newName string) error {
+ source, err := fs.Get(c.Request.Context(), srcPath, &fs.GetArgs{NoLog: true})
+ if err != nil || source == nil || source.IsDir() {
+ return nil
+ }
+ for _, sidecarPath := range seedSidecarPaths(srcPath) {
+ obj, err := fs.Get(c.Request.Context(), sidecarPath, &fs.GetArgs{NoLog: true})
+ if err != nil || obj == nil || obj.IsDir() {
+ continue
+ }
+ suffix := strings.TrimPrefix(sidecarPath, srcPath)
+ if err = fs.Rename(c.Request.Context(), sidecarPath, newName+suffix, true); err != nil {
+ return fmt.Errorf("rename seed sidecar %s: %w", sidecarPath, err)
+ }
+ }
+ return nil
+}
+
+func followSeedRemove(c *gin.Context, srcPath string) error {
+ for _, sidecarPath := range seedSidecarPaths(srcPath) {
+ obj, err := fs.Get(c.Request.Context(), sidecarPath, &fs.GetArgs{NoLog: true})
+ if err != nil || obj == nil || obj.IsDir() {
+ continue
+ }
+ if err = fs.Remove(c.Request.Context(), sidecarPath); err != nil {
+ return fmt.Errorf("remove seed sidecar %s: %w", sidecarPath, err)
+ }
+ }
+ return nil
+}
+
type RemoveEmptyDirectoryReq struct {
SrcDir string `json:"src_dir"`
}
diff --git a/server/handles/fsread.go b/server/handles/fsread.go
index 841cb5af..bf4089ff 100644
--- a/server/handles/fsread.go
+++ b/server/handles/fsread.go
@@ -109,8 +109,9 @@ func FsList(c *gin.Context, req *ListReq, user *model.User) {
total, objs := pagination(objs, &req.PageReq)
provider := "unknown"
var directUploadTools []string
- if canWriteContentAtPath {
- if storage, err := fs.GetStorage(reqPath, &fs.GetStoragesArgs{}); err == nil {
+ if storage, err := fs.GetStorage(reqPath, &fs.GetStoragesArgs{}); err == nil {
+ provider = storage.GetStorage().Driver
+ if canWriteContentAtPath {
directUploadTools = op.GetDirectUploadTools(storage)
}
}
diff --git a/server/handles/fsread_test.go b/server/handles/fsread_test.go
index 3947ae27..89b7c46b 100644
--- a/server/handles/fsread_test.go
+++ b/server/handles/fsread_test.go
@@ -1,9 +1,11 @@
package handles
import (
+ "encoding/json"
"testing"
"github.com/OpenListTeam/OpenList/v4/internal/model"
+ "github.com/gin-gonic/gin"
)
func TestGetReadme(t *testing.T) {
@@ -253,3 +255,30 @@ func TestIsEncrypt(t *testing.T) {
})
}
}
+
+func TestFsListReportsProvider(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ root, _ := prepareBackslashTraversalFs(t)
+ for _, tt := range []struct {
+ name string
+ permission int32
+ }{
+ {name: "read only", permission: 0},
+ {name: "write content", permission: 1 << 3},
+ } {
+ t.Run(tt.name, func(t *testing.T) {
+ user := setupBackslashTraversalTest(t, root, tt.permission)
+ recorder := invokeHandler(t, user, map[string]any{"path": "/"}, FsListSplit)
+ var resp struct {
+ Code int `json:"code"`
+ Data FsListResp `json:"data"`
+ }
+ if err := json.Unmarshal(recorder.Body.Bytes(), &resp); err != nil {
+ t.Fatal(err)
+ }
+ if resp.Code != 200 || resp.Data.Provider != "Local" {
+ t.Fatalf("got code=%d provider=%q, want 200 and Local; body=%s", resp.Code, resp.Data.Provider, recorder.Body.String())
+ }
+ })
+ }
+}
diff --git a/server/handles/fsup.go b/server/handles/fsup.go
index 0f46398c..6e429c8d 100644
--- a/server/handles/fsup.go
+++ b/server/handles/fsup.go
@@ -1,19 +1,25 @@
package handles
import (
+ "bytes"
+ "encoding/json"
+ "fmt"
"io"
"net/url"
stdpath "path"
"strconv"
+ "strings"
"time"
"github.com/OpenListTeam/OpenList/v4/internal/conf"
"github.com/OpenListTeam/OpenList/v4/internal/errs"
"github.com/OpenListTeam/OpenList/v4/internal/fs"
"github.com/OpenListTeam/OpenList/v4/internal/model"
+ "github.com/OpenListTeam/OpenList/v4/internal/op"
"github.com/OpenListTeam/OpenList/v4/internal/setting"
"github.com/OpenListTeam/OpenList/v4/internal/stream"
"github.com/OpenListTeam/OpenList/v4/internal/task"
+ "github.com/OpenListTeam/OpenList/v4/pkg/torrent"
"github.com/OpenListTeam/OpenList/v4/pkg/utils"
"github.com/OpenListTeam/OpenList/v4/server/common"
"github.com/gin-gonic/gin"
@@ -97,6 +103,23 @@ func FsStream(c *gin.Context) {
if len(mimetype) == 0 {
mimetype = utils.GetMimeType(name)
}
+ generateSeed := shouldGenerateUploadSeed(c, dir)
+ if generateSeed {
+ if err = validateUploadSeedRequest(c); err != nil {
+ common.ErrorStrResp(c, err.Error(), 400)
+ return
+ }
+ }
+ if generateSeed && asTask {
+ common.ErrorStrResp(c, "seed sidecar generation requires synchronous upload", 400)
+ return
+ }
+ var seedHasher *torrent.HashWriter
+ var uploadReader io.Reader = c.Request.Body
+ if generateSeed {
+ seedHasher = torrent.NewHashWriter(seedPieceSize(c), seedPieceSize(c), 0)
+ uploadReader = io.TeeReader(c.Request.Body, seedHasher)
+ }
s := &stream.FileStream{
Obj: &model.Object{
Name: name,
@@ -104,7 +127,7 @@ func FsStream(c *gin.Context) {
Modified: getLastModified(c),
HashInfo: utils.NewHashInfoByMap(h),
},
- Reader: c.Request.Body,
+ Reader: uploadReader,
Mimetype: mimetype,
WebPutAsTask: asTask,
}
@@ -118,6 +141,12 @@ func FsStream(c *gin.Context) {
common.ErrorResp(c, err, 500)
return
}
+ if generateSeed {
+ if err = writeUploadSeedSidecar(c, dir, name, size, seedHasher); err != nil {
+ common.ErrorResp(c, err, 500)
+ return
+ }
+ }
if t == nil {
common.SuccessResp(c)
return
@@ -194,6 +223,23 @@ func FsForm(c *gin.Context) {
if len(mimetype) == 0 {
mimetype = utils.GetMimeType(name)
}
+ generateSeed := shouldGenerateUploadSeed(c, dir)
+ if generateSeed {
+ if err = validateUploadSeedRequest(c); err != nil {
+ common.ErrorStrResp(c, err.Error(), 400)
+ return
+ }
+ }
+ if generateSeed && asTask {
+ common.ErrorStrResp(c, "seed sidecar generation requires synchronous upload", 400)
+ return
+ }
+ var seedHasher *torrent.HashWriter
+ var uploadReader io.Reader = f
+ if generateSeed {
+ seedHasher = torrent.NewHashWriter(seedPieceSize(c), seedPieceSize(c), 0)
+ uploadReader = io.TeeReader(f, seedHasher)
+ }
s := &stream.FileStream{
Obj: &model.Object{
Name: name,
@@ -201,7 +247,7 @@ func FsForm(c *gin.Context) {
Modified: getLastModified(c),
HashInfo: utils.NewHashInfoByMap(h),
},
- Reader: f,
+ Reader: uploadReader,
Mimetype: mimetype,
WebPutAsTask: asTask,
}
@@ -218,6 +264,12 @@ func FsForm(c *gin.Context) {
common.ErrorResp(c, err, 500)
return
}
+ if generateSeed {
+ if err = writeUploadSeedSidecar(c, dir, name, file.Size, seedHasher); err != nil {
+ common.ErrorResp(c, err, 500)
+ return
+ }
+ }
if t == nil {
common.SuccessResp(c)
return
@@ -226,3 +278,130 @@ func FsForm(c *gin.Context) {
"task": getTaskInfo(t),
})
}
+
+func shouldGenerateUploadSeed(c *gin.Context, path string) bool {
+ if strings.TrimSpace(c.GetHeader("X-Seed-Sidecars")) != "" {
+ return true
+ }
+ policy := strings.ToLower(strings.TrimSpace(c.GetHeader("X-Generate-Seed")))
+ if policy != "" && policy != "inherit" {
+ return policy == "on" || policy == "true" || policy == "1"
+ }
+ if storage := op.GetBalancedStorage(path); storage != nil {
+ policy = strings.ToLower(strings.TrimSpace(storage.GetStorage().SeedPolicy))
+ }
+ if policy == "" || policy == "inherit" {
+ policy = strings.ToLower(setting.GetStr(conf.SeedAutoGeneratePolicy, "off"))
+ }
+ return (policy == "on" || policy == "true" || policy == "1") && configuredSeedFormats() != ""
+}
+
+func seedPieceSize(c *gin.Context) int64 {
+ for _, format := range strings.Split(strings.ToLower(c.GetHeader("X-Seed-Sidecars")), ",") {
+ if strings.TrimSpace(format) == "cas" {
+ return torrent.DefaultPieceSize
+ }
+ }
+ value := c.GetHeader("X-Seed-Piece-Size")
+ if value == "" {
+ return torrent.DefaultPieceSize
+ }
+ size, err := strconv.ParseInt(value, 10, 64)
+ if err != nil || size <= 0 || size > 1<<30 {
+ return torrent.DefaultPieceSize
+ }
+ return size
+}
+
+func configuredSeedFormats() string {
+ policies := make(map[string]string)
+ if err := json.Unmarshal([]byte(setting.GetStr(conf.SeedFormatPolicies)), &policies); err != nil {
+ return ""
+ }
+ formats := make([]string, 0, 3)
+ for _, format := range []string{"oss", "torrent", "cas"} {
+ if strings.EqualFold(strings.TrimSpace(policies[format]), "on") {
+ formats = append(formats, format)
+ }
+ }
+ return strings.Join(formats, ",")
+}
+
+// validateUploadSeedRequest rejects invalid seed sidecar headers before the
+// main file is stored, so a bad X-Seed-* header cannot leave a partially
+// uploaded file behind.
+func validateUploadSeedRequest(c *gin.Context) error {
+ formatsHeader := strings.TrimSpace(c.GetHeader("X-Seed-Sidecars"))
+ if formatsHeader == "" {
+ formatsHeader = strings.TrimSpace(c.GetHeader("X-Seed-Format"))
+ }
+ if formatsHeader == "" {
+ formatsHeader = configuredSeedFormats()
+ }
+ if _, err := fs.NormalizeSeedFormats(strings.Split(formatsHeader, ",")); err != nil {
+ return err
+ }
+ if raw := strings.TrimSpace(c.GetHeader("X-Seed-Hash-Matrix")); raw != "" {
+ var matrix SeedHashMatrix
+ if err := json.Unmarshal([]byte(raw), &matrix); err != nil {
+ return fmt.Errorf("invalid seed hash matrix: %w", err)
+ }
+ }
+ return nil
+}
+
+func writeUploadSeedSidecar(c *gin.Context, dir, name string, expectedSize int64, hasher *torrent.HashWriter) error {
+ if hasher == nil {
+ return nil
+ }
+ hasher.Finish()
+ if expectedSize >= 0 && hasher.GetTotalWritten() != expectedSize {
+ return fmt.Errorf("seed sidecar requires a complete stream: read %d of %d bytes", hasher.GetTotalWritten(), expectedSize)
+ }
+ seed := torrent.NewSeed(name, "OpenList", seedPieceSize(c))
+ formatsHeader := strings.TrimSpace(c.GetHeader("X-Seed-Sidecars"))
+ if formatsHeader == "" {
+ formatsHeader = strings.TrimSpace(c.GetHeader("X-Seed-Format"))
+ }
+ if formatsHeader == "" {
+ formatsHeader = configuredSeedFormats()
+ }
+ formats, err := fs.NormalizeSeedFormats(strings.Split(formatsHeader, ","))
+ if err != nil {
+ return err
+ }
+ matrix := SeedHashMatrix{}
+ if rawMatrix := strings.TrimSpace(c.GetHeader("X-Seed-Hash-Matrix")); rawMatrix != "" {
+ if err = json.Unmarshal([]byte(rawMatrix), &matrix); err != nil {
+ return fmt.Errorf("invalid seed hash matrix: %w", err)
+ }
+ }
+ matrix = normalizedSeedMatrix(matrix, formats)
+ seedFile := hasher.BuildSeedFile(name, getLastModified(c).UTC().Format(time.RFC3339))
+ applySeedMatrix(&seedFile, matrix)
+ seed.Files = []torrent.SeedFile{seedFile}
+ seen := make(map[string]struct{}, 3)
+ for _, rawFormat := range formats {
+ format := strings.ToLower(strings.TrimPrefix(strings.TrimSpace(rawFormat), "."))
+ if format == "bt" {
+ format = "torrent"
+ }
+ if _, exists := seen[format]; exists {
+ continue
+ }
+ seen[format] = struct{}{}
+ data, err := fs.EncodeGeneratedSeed(seed, format, hasher.GetPieceHashes())
+ if err != nil {
+ return fmt.Errorf("generate %s seed sidecar: %w", format, err)
+ }
+ sidecar := &stream.FileStream{
+ Ctx: c.Request.Context(),
+ Obj: &model.Object{Name: name + "." + format, Size: int64(len(data)), Modified: time.Now()},
+ Reader: bytes.NewReader(data), Mimetype: "application/octet-stream",
+ }
+ if err = fs.PutDirectly(c.Request.Context(), dir, sidecar, true); err != nil {
+ return fmt.Errorf("upload %s seed sidecar: %w", format, err)
+ }
+ }
+ return nil
+}
diff --git a/server/handles/media.go b/server/handles/media.go
new file mode 100644
index 00000000..b6f981b5
--- /dev/null
+++ b/server/handles/media.go
@@ -0,0 +1,1101 @@
+package handles
+
+import (
+ "context"
+ "encoding/json"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+
+ stdpath "path"
+
+ "github.com/OpenListTeam/OpenList/v4/internal/conf"
+ "github.com/OpenListTeam/OpenList/v4/internal/db"
+ "github.com/OpenListTeam/OpenList/v4/internal/errs"
+ "github.com/OpenListTeam/OpenList/v4/internal/fs"
+ "github.com/OpenListTeam/OpenList/v4/internal/media"
+ "github.com/OpenListTeam/OpenList/v4/internal/media/scraper"
+ "github.com/OpenListTeam/OpenList/v4/internal/model"
+ "github.com/OpenListTeam/OpenList/v4/internal/setting"
+ "github.com/OpenListTeam/OpenList/v4/server/common"
+ "github.com/gin-gonic/gin"
+ log "github.com/sirupsen/logrus"
+)
+
+// scrapeConcurrency 刮削并发度(默认 5)
+// 走 setting 读取,未配置时使用 5;远端 API 调用密集型任务,并发 5 在限流与吞吐间较平衡。
+const defaultScrapeConcurrency = 5
+
+// ==================== 配置管理 ====================
+
+// ListMediaConfigs 获取所有媒体库配置
+func ListMediaConfigs(c *gin.Context) {
+ types := []model.MediaType{
+ model.MediaTypeVideo,
+ model.MediaTypeMusic,
+ model.MediaTypeImage,
+ model.MediaTypeBook,
+ }
+ cfgs := make([]*model.MediaConfig, 0, len(types))
+ for _, t := range types {
+ cfg, err := db.GetMediaConfig(t)
+ if err != nil {
+ common.ErrorResp(c, err, 500)
+ return
+ }
+ cfgs = append(cfgs, cfg)
+ }
+ common.SuccessResp(c, cfgs)
+}
+
+// SaveMediaConfigReq 保存配置请求
+type SaveMediaConfigReq struct {
+ MediaType model.MediaType `json:"media_type" binding:"required"`
+ Enabled bool `json:"enabled"`
+ ScraperConfig *string `json:"scraper_config"`
+}
+
+// SaveMediaConfig 保存媒体库配置
+func SaveMediaConfig(c *gin.Context) {
+ var req SaveMediaConfigReq
+ if err := c.ShouldBindJSON(&req); err != nil {
+ common.ErrorResp(c, err, 400)
+ return
+ }
+ // 读取已有配置,避免用零值覆盖 last_scan_at / last_scrape_at
+ cfg, err := db.GetMediaConfig(req.MediaType)
+ if err != nil {
+ common.ErrorResp(c, err, 500)
+ return
+ }
+ cfg.Enabled = req.Enabled
+ if req.ScraperConfig != nil {
+ cfg.ScraperConfig = *req.ScraperConfig
+ }
+ if err := db.SaveMediaConfig(cfg); err != nil {
+ common.ErrorResp(c, err, 500)
+ return
+ }
+ common.SuccessResp(c)
+}
+
+// ==================== 扫描路径管理 ====================
+
+// ListMediaScanPaths 获取指定媒体类型的扫描路径列表
+func ListMediaScanPaths(c *gin.Context) {
+ mediaType := model.MediaType(c.Query("media_type"))
+ paths, err := db.ListMediaScanPaths(mediaType)
+ if err != nil {
+ common.ErrorResp(c, err, 500)
+ return
+ }
+ common.SuccessResp(c, paths)
+}
+
+// CreateMediaScanPathReq 创建扫描路径请求
+type CreateMediaScanPathReq struct {
+ MediaType model.MediaType `json:"media_type" binding:"required"`
+ Name string `json:"name"`
+ Path string `json:"path" binding:"required"`
+ PathMerge bool `json:"path_merge"`
+ TypeTag string `json:"type_tag"`
+ ContentTags string `json:"content_tags"`
+ EnableScrape bool `json:"enable_scrape"`
+}
+
+// CreateMediaScanPath 创建扫描路径
+func CreateMediaScanPath(c *gin.Context) {
+ var req CreateMediaScanPathReq
+ if err := c.ShouldBindJSON(&req); err != nil {
+ common.ErrorResp(c, err, 400)
+ return
+ }
+ if req.Path == "" {
+ req.Path = "/"
+ }
+ name := req.Name
+ if name == "" {
+ name = req.Path
+ }
+ sp := &model.MediaScanPath{
+ MediaType: req.MediaType,
+ Name: name,
+ Path: req.Path,
+ PathMerge: req.PathMerge,
+ TypeTag: req.TypeTag,
+ ContentTags: req.ContentTags,
+ EnableScrape: req.EnableScrape,
+ }
+ if err := db.CreateMediaScanPath(sp); err != nil {
+ common.ErrorResp(c, err, 500)
+ return
+ }
+ common.SuccessResp(c, sp)
+}
+
+// UpdateMediaScanPathReq 更新扫描路径请求
+type UpdateMediaScanPathReq struct {
+ ID uint `json:"id" binding:"required"`
+ Name string `json:"name"`
+ Path string `json:"path"`
+ PathMerge bool `json:"path_merge"`
+ TypeTag string `json:"type_tag"`
+ ContentTags string `json:"content_tags"`
+ EnableScrape bool `json:"enable_scrape"`
+}
+
+// UpdateMediaScanPath 更新扫描路径
+func UpdateMediaScanPath(c *gin.Context) {
+ var req UpdateMediaScanPathReq
+ if err := c.ShouldBindJSON(&req); err != nil {
+ common.ErrorResp(c, err, 400)
+ return
+ }
+ sp, err := db.GetMediaScanPath(req.ID)
+ if err != nil {
+ common.ErrorResp(c, err, 404)
+ return
+ }
+ sp.Name = req.Name
+ sp.Path = req.Path
+ sp.PathMerge = req.PathMerge
+ sp.TypeTag = req.TypeTag
+ sp.ContentTags = req.ContentTags
+ sp.EnableScrape = req.EnableScrape
+ if err := db.UpdateMediaScanPath(sp); err != nil {
+ common.ErrorResp(c, err, 500)
+ return
+ }
+ common.SuccessResp(c)
+}
+
+// DeleteMediaScanPath 删除扫描路径
+func DeleteMediaScanPath(c *gin.Context) {
+ idStr := c.Query("id")
+ id, err := strconv.ParseUint(idStr, 10, 64)
+ if err != nil {
+ common.ErrorStrResp(c, "无效的ID", 400)
+ return
+ }
+ if err := db.DeleteMediaScanPath(uint(id)); err != nil {
+ common.ErrorResp(c, err, 500)
+ return
+ }
+ common.SuccessResp(c)
+}
+
+// ClearMediaScanPathDB 清空指定扫描路径的媒体数据
+func ClearMediaScanPathDB(c *gin.Context) {
+ idStr := c.Query("id")
+ id, err := strconv.ParseUint(idStr, 10, 64)
+ if err != nil {
+ common.ErrorStrResp(c, "无效的ID", 400)
+ return
+ }
+ if err := db.ClearMediaItemsByScanPath(uint(id)); err != nil {
+ common.ErrorResp(c, err, 500)
+ return
+ }
+ common.SuccessResp(c)
+}
+
+// ==================== 媒体条目管理(后台) ====================
+
+// clampMediaPage 限制分页参数,避免 page_size 过大或为负导致整表扫描 / 响应体爆炸
+func clampMediaPage(page, pageSize, defaultSize, maxSize int) (int, int) {
+ if page < 1 {
+ page = 1
+ }
+ if pageSize <= 0 {
+ pageSize = defaultSize
+ }
+ if pageSize > maxSize {
+ pageSize = maxSize
+ }
+ return page, pageSize
+}
+
+// ListMediaItemsAdmin 后台分页查询媒体条目
+func ListMediaItemsAdmin(c *gin.Context) {
+ mediaType := model.MediaType(c.Query("media_type"))
+ page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
+ pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
+ page, pageSize = clampMediaPage(page, pageSize, 20, 500)
+ keyword := c.Query("keyword")
+ orderBy := c.DefaultQuery("order_by", "name")
+ orderDir := c.DefaultQuery("order_dir", "asc")
+ scanPathIDStr := c.Query("scan_path_id")
+ var scanPathID uint
+ if scanPathIDStr != "" {
+ if id, err := strconv.ParseUint(scanPathIDStr, 10, 64); err == nil {
+ scanPathID = uint(id)
+ }
+ }
+
+ q := db.MediaItemQuery{
+ MediaType: mediaType,
+ ScanPathID: scanPathID,
+ Keyword: keyword,
+ OrderBy: orderBy,
+ OrderDir: orderDir,
+ Page: page,
+ PageSize: pageSize,
+ }
+ items, total, err := db.ListMediaItems(q)
+ if err != nil {
+ common.ErrorResp(c, err, 500)
+ return
+ }
+ common.SuccessResp(c, common.PageResp{Content: items, Total: total})
+}
+
+// UpdateMediaItemReq 更新媒体条目请求
+type UpdateMediaItemReq struct {
+ ID uint `json:"id" binding:"required"`
+ ScrapedName string `json:"scraped_name"`
+ Description string `json:"description"`
+ Cover string `json:"cover"`
+ ReleaseDate string `json:"release_date"`
+ Rating float32 `json:"rating"`
+ Genre string `json:"genre"`
+ Authors string `json:"authors"`
+ Plot string `json:"plot"`
+ Reviews string `json:"reviews"`
+ AlbumName string `json:"album_name"`
+ AlbumArtist string `json:"album_artist"`
+ Publisher string `json:"publisher"`
+ ISBN string `json:"isbn"`
+ Hidden bool `json:"hidden"`
+}
+
+// UpdateMediaItemAdmin 后台更新媒体条目
+func UpdateMediaItemAdmin(c *gin.Context) {
+ var req UpdateMediaItemReq
+ if err := c.ShouldBindJSON(&req); err != nil {
+ common.ErrorResp(c, err, 400)
+ return
+ }
+ item, err := db.GetMediaItemByID(req.ID)
+ if err != nil {
+ common.ErrorResp(c, err, 404)
+ return
+ }
+ item.ScrapedName = req.ScrapedName
+ item.Description = req.Description
+ item.Cover = req.Cover
+ item.ReleaseDate = req.ReleaseDate
+ item.Rating = req.Rating
+ item.Genre = req.Genre
+ item.Authors = req.Authors
+ item.Plot = req.Plot
+ item.Reviews = req.Reviews
+ item.AlbumName = req.AlbumName
+ item.AlbumArtist = req.AlbumArtist
+ item.Publisher = req.Publisher
+ item.ISBN = req.ISBN
+ item.Hidden = req.Hidden
+
+ if err := db.UpdateMediaItem(item); err != nil {
+ common.ErrorResp(c, err, 500)
+ return
+ }
+ common.SuccessResp(c)
+}
+
+// DeleteMediaItemAdmin 后台删除媒体条目
+func DeleteMediaItemAdmin(c *gin.Context) {
+ idStr := c.Query("id")
+ id, err := strconv.ParseUint(idStr, 10, 64)
+ if err != nil {
+ common.ErrorStrResp(c, "无效的ID", 400)
+ return
+ }
+ if err := db.DeleteMediaItem(uint(id)); err != nil {
+ common.ErrorResp(c, err, 500)
+ return
+ }
+ common.SuccessResp(c)
+}
+
+// ClearMediaDB 清空指定类型媒体数据库
+func ClearMediaDB(c *gin.Context) {
+ mediaType := model.MediaType(c.Query("media_type"))
+ if mediaType == "" {
+ common.ErrorStrResp(c, "media_type 不能为空", 400)
+ return
+ }
+ if err := db.ClearMediaItems(mediaType); err != nil {
+ common.ErrorResp(c, err, 500)
+ return
+ }
+ common.SuccessResp(c)
+}
+
+// ClearMediaScrape 清空刮削数据(保留扫描记录,但清空所有刮削结果字段)
+// 参数:media_type 可选,留空表示所有类型;item_id 暂不支持(统一为类型/全部)
+func ClearMediaScrape(c *gin.Context) {
+ mediaType := model.MediaType(c.Query("media_type"))
+ affected, err := db.ClearMediaScrapedData(mediaType)
+ if err != nil {
+ common.ErrorResp(c, err, 500)
+ return
+ }
+ // 同步清掉对应媒体配置中的最近刮削时间
+ if mediaType != "" {
+ if cfg, e := db.GetMediaConfig(mediaType); e == nil && cfg != nil {
+ cfg.LastScrapeAt = nil
+ _ = db.SaveMediaConfig(cfg)
+ }
+ } else {
+ if cfgs, e := db.GetAllMediaConfigs(); e == nil {
+ for i := range cfgs {
+ cfgs[i].LastScrapeAt = nil
+ _ = db.SaveMediaConfig(&cfgs[i])
+ }
+ }
+ }
+ common.SuccessResp(c, gin.H{"affected": affected})
+}
+
+// DeleteInvalidMedia 删除已失效的媒体条目(即对应文件 / 文件夹在存储中已不存在)
+// 参数:media_type 可选,留空表示扫描所有类型
+// 返回:检测总数、被删除条目数
+func DeleteInvalidMedia(c *gin.Context) {
+ mediaType := model.MediaType(c.Query("media_type"))
+
+ items, err := db.ListAllValidMediaItemsForCheck(mediaType)
+ if err != nil {
+ common.ErrorResp(c, err, 500)
+ return
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
+ defer cancel()
+
+ var invalidIDs []uint
+ var skipped int
+ for i := range items {
+ it := &items[i]
+ // 拼接文件 / 文件夹的完整 VFS 路径
+ // - 文件夹模式 (is_folder=true): folder_path 即文件夹自身(扫描根 + 文件夹名 视情况而定);这里直接使用 folder_path/file_name
+ // - 普通文件: folder_path 是所在目录,file_name 是文件名
+ fullPath := stdpath.Join(it.FolderPath, it.FileName)
+ if fullPath == "" || fullPath == "/" {
+ continue
+ }
+ if _, gerr := fs.Get(ctx, fullPath, &fs.GetArgs{NoLog: true}); gerr != nil {
+ // 只有「确认不存在」才算失效条目;驱动故障、超时、鉴权失败等可能是临时问题,
+ // 一律跳过(保留记录),否则一次网络抖动就会把有效的媒体记录硬删除。
+ if errs.IsObjectNotFound(gerr) {
+ invalidIDs = append(invalidIDs, it.ID)
+ continue
+ }
+ skipped++
+ log.Warnf("跳过无法确认状态的媒体条目 %s(%s): %v", fullPath, it.FileName, gerr)
+ }
+ }
+
+ if len(invalidIDs) > 0 {
+ if err := db.DeleteMediaItemsByIDs(invalidIDs); err != nil {
+ common.ErrorResp(c, err, 500)
+ return
+ }
+ }
+ log.Infof("删除已失效媒体条目:检测 %d 条,删除 %d 条,跳过 %d 条 (media_type=%q)", len(items), len(invalidIDs), skipped, mediaType)
+ common.SuccessResp(c, gin.H{
+ "checked": len(items),
+ "deleted": len(invalidIDs),
+ "skipped": skipped,
+ })
+}
+
+// ==================== 扫描与刮削 ====================
+
+// ScanMediaReq 扫描请求
+type ScanMediaReq struct {
+ MediaType model.MediaType `json:"media_type" binding:"required"`
+ ScanPathID uint `json:"scan_path_id"` // 0 表示扫描全部路径
+}
+
+// StartMediaScan 开始扫描
+func StartMediaScan(c *gin.Context) {
+ var req ScanMediaReq
+ if err := c.ShouldBindJSON(&req); err != nil {
+ common.ErrorResp(c, err, 400)
+ return
+ }
+ cfg, err := db.GetMediaConfig(req.MediaType)
+ if err != nil {
+ common.ErrorResp(c, err, 500)
+ return
+ }
+ if !cfg.Enabled {
+ common.ErrorStrResp(c, "该媒体库未启用", 400)
+ return
+ }
+
+ if req.ScanPathID > 0 {
+ // 扫描单个路径
+ sp, err := db.GetMediaScanPath(req.ScanPathID)
+ if err != nil {
+ common.ErrorResp(c, err, 404)
+ return
+ }
+ media.ScanMediaPath(sp)
+ } else {
+ // 扫描全部路径
+ media.ScanMedia(cfg)
+ }
+ common.SuccessResp(c)
+}
+
+// GetMediaScanProgress 获取扫描进度
+func GetMediaScanProgress(c *gin.Context) {
+ mediaType := model.MediaType(c.Query("media_type"))
+ if mediaType == "" {
+ common.ErrorStrResp(c, "media_type 不能为空", 400)
+ return
+ }
+ progress := media.GetProgress(mediaType)
+ common.SuccessResp(c, progress)
+}
+
+// MediaScrapeStatsResp 刮削统计响应
+// 包含 TMDB 视频刮削的累计指标,用于运维观测与命中率评估
+type MediaScrapeStatsResp struct {
+ TMDB scraper.TMDBStats `json:"tmdb"`
+}
+
+// GetMediaScrapeStats 获取刮削统计(进程级累计值,重启清零)
+// 返回字段:
+// - total_scraped : 累计触发刮削次数
+// - hit_count : 命中(含强命中)次数
+// - low_confidence_count : 仅低置信度兜底命中次数
+// - no_match_count : 完全未命中次数
+// - total_attempts : 累计 TMDB 搜索请求次数
+// - total_search_millis : 累计搜索耗时(毫秒)
+func GetMediaScrapeStats(c *gin.Context) {
+ common.SuccessResp(c, MediaScrapeStatsResp{
+ TMDB: scraper.GetTMDBStats(),
+ })
+}
+
+// ScrapeMediaReq 刮削请求
+type ScrapeMediaReq struct {
+ MediaType model.MediaType `json:"media_type" binding:"required"`
+ ItemID uint `json:"item_id"` // 0 表示刮削全部未刮削的
+}
+
+// StartMediaScrape 开始刮削
+func StartMediaScrape(c *gin.Context) {
+ var req ScrapeMediaReq
+ if err := c.ShouldBindJSON(&req); err != nil {
+ common.ErrorResp(c, err, 400)
+ return
+ }
+
+ cfg, err := db.GetMediaConfig(req.MediaType)
+ if err != nil {
+ common.ErrorResp(c, err, 500)
+ return
+ }
+
+ // 从系统设置中读取刮削配置
+ tmdbKey := setting.GetStr(conf.MediaTMDBKey)
+ tmdbAPIURL := setting.GetStr(conf.MediaTMDBAPIURL, "https://api.themoviedb.org")
+ discogsToken := setting.GetStr(conf.MediaDiscogsToken)
+ discogsAPIURL := setting.GetStr(conf.MediaDiscogsAPIURL, "https://api.discogs.com")
+ thumbnailMode := setting.GetStr(conf.MediaThumbnailMode, "runtime")
+ thumbnailPath := setting.GetStr(conf.MediaThumbnailPath, "/imgs")
+ storeThumbnail := setting.GetBool(conf.MediaStoreThumbnail)
+ bookSource := strings.ToLower(setting.GetStr(conf.MediaBookSource, "douban"))
+ googleBooksKey := setting.GetStr(conf.MediaGoogleBooksKey)
+ downloadCover := setting.GetBool(conf.MediaDownloadCover)
+
+ // 该媒体类型如果单独配置过缩略图,覆盖全局设置
+ if cfg.ScraperConfig != "" {
+ var sc struct {
+ StoreThumbnail string `json:"store_thumbnail"`
+ ThumbnailMode string `json:"thumbnail_mode"`
+ ThumbnailPath string `json:"thumbnail_path"`
+ }
+ if json.Unmarshal([]byte(cfg.ScraperConfig), &sc) == nil {
+ if sc.ThumbnailMode != "" {
+ thumbnailMode = sc.ThumbnailMode
+ }
+ if sc.ThumbnailPath != "" {
+ thumbnailPath = sc.ThumbnailPath
+ }
+ if sc.StoreThumbnail != "" {
+ storeThumbnail = sc.StoreThumbnail == "true"
+ }
+ }
+ }
+
+ log.Infof("[media] scrape %s: thumbnail mode=%s, path=%s, runtime dir=%s",
+ req.MediaType, thumbnailMode, thumbnailPath, scraper.RuntimeThumbRoot(thumbnailPath))
+
+ var preloadedItems []model.MediaItem
+ if req.ItemID > 0 {
+ item, e := db.GetMediaItemByID(req.ItemID)
+ if e != nil {
+ common.ErrorResp(c, e, 404)
+ return
+ }
+ preloadedItems = []model.MediaItem{*item}
+ }
+
+ go func() {
+ var items []model.MediaItem
+ var err error
+
+ if req.ItemID > 0 {
+ items = preloadedItems
+ } else {
+ // limit 传 -1,GORM 不会拼接 LIMIT 子句,从而一次性取出全部未刮削条目
+ items, err = db.GetUnscrappedItems(req.MediaType, -1)
+ if err != nil {
+ log.Errorf("获取未刮削条目失败: %v", err)
+ return
+ }
+ }
+
+ // 读取并发度(默认5),并启动 worker pool
+ concurrency := setting.GetInt(conf.MediaScrapeConcurrency, defaultScrapeConcurrency)
+ if concurrency <= 0 {
+ concurrency = defaultScrapeConcurrency
+ }
+ if concurrency > len(items) && len(items) > 0 {
+ concurrency = len(items)
+ }
+
+ var wg sync.WaitGroup
+ jobs := make(chan int, len(items))
+ // 落库串行化:所有 worker 把刮削成功的 item 推到 saveCh,由单协程负责落库,
+ // 这样既能并发抓 API、又避免多 goroutine 同时写 sqlite/mysql 引发锁竞争。
+ saveCh := make(chan *model.MediaItem, concurrency*2)
+ var saveWg sync.WaitGroup
+
+ // 单写入协程:刮一个写一个
+ saveWg.Add(1)
+ go func() {
+ defer saveWg.Done()
+ for it := range saveCh {
+ if err := db.UpdateMediaItem(it); err != nil {
+ log.Warnf("保存刮削结果失败 [id=%d, %s/%s]: %v", it.ID, it.FolderPath, it.FileName, err)
+ }
+ }
+ }()
+
+ worker := func() {
+ defer wg.Done()
+ for idx := range jobs {
+ item := &items[idx]
+ var scrapeErr error
+
+ switch req.MediaType {
+ case model.MediaTypeVideo:
+ s := scraper.NewTMDBScraper(tmdbKey, tmdbAPIURL)
+ scrapeErr = s.ScrapeVideo(item)
+ case model.MediaTypeMusic:
+ // 音乐:刮削前先用文件本身的 ID3/Vorbis tag + .lrc 做本地兜底,
+ // 仅填空字段;这样用户清空刮削后重新刮削也能恢复封面/歌词等。
+ localCtx, localCancel := context.WithTimeout(context.Background(), 30*time.Second)
+ media.FillMusicFromLocal(localCtx, item)
+ localCancel()
+
+ s := scraper.NewDiscogsScraper(discogsToken, discogsAPIURL)
+ scrapeErr = s.ScrapeMusic(item)
+ case model.MediaTypeBook:
+ var bookErr error
+ switch bookSource {
+ case "google":
+ bookErr = scraper.NewGoogleBooksScraper(
+ googleBooksKey,
+ thumbnailMode,
+ thumbnailPath,
+ ).ScrapeBook(item)
+ case "openlibrary":
+ bookErr = scraper.NewOpenLibraryScraper(
+ thumbnailMode,
+ thumbnailPath,
+ ).ScrapeBook(item)
+ default:
+ bookErr = scraper.NewDoubanScraperWithConfig(
+ thumbnailMode,
+ thumbnailPath,
+ ).ScrapeBook(item)
+ }
+ if bookErr != nil {
+ log.Debugf("书籍刮削失败 [%s/%s]: %v,将尝试本地提取封面", item.FolderPath, item.FileName, bookErr)
+ }
+
+ if item.Cover == "" {
+ bookCtx, bookCancel := context.WithTimeout(context.Background(), 60*time.Second)
+ bookFilePath := item.FolderPath + "/" + item.FileName
+ bookReader := media.FetchFileReader(bookCtx, bookFilePath)
+ if bookReader != nil {
+ localScraper := scraper.NewBookLocalScraperWithConfig(
+ thumbnailMode,
+ thumbnailPath,
+ )
+ if localCover := localScraper.ExtractLocalCover(item.FileName, bookFilePath, bookReader); localCover != "" {
+ item.Cover = localCover
+ }
+ _ = bookReader.Close()
+ }
+ bookCancel()
+ }
+
+ if bookErr != nil && item.Cover == "" {
+ scrapeErr = bookErr
+ }
+ case model.MediaTypeImage:
+ imgCtx, imgCancel := context.WithTimeout(context.Background(), 30*time.Second)
+ imgFilePath := item.FolderPath + "/" + item.FileName
+ imgReader := media.FetchFileReader(imgCtx, imgFilePath)
+ s := scraper.NewImageScraperWithConfig(
+ storeThumbnail,
+ thumbnailMode,
+ thumbnailPath,
+ )
+ scrapeErr = s.ScrapeImage(item, imgReader)
+ if imgReader != nil {
+ _ = imgReader.Close()
+ }
+ imgCancel()
+ }
+
+ // 远程封面按需下载到本地存储(或 base64),避免直接保存图片链接
+ if downloadCover && strings.HasPrefix(item.Cover, "http") {
+ rawPath := strings.TrimSuffix(item.FolderPath, "/") + "/" + item.FileName
+ if cover := scraper.DownloadCoverToStorage(
+ context.Background(),
+ item.Cover,
+ rawPath,
+ thumbnailMode,
+ thumbnailPath,
+ ); cover != "" {
+ item.Cover = cover
+ }
+ }
+
+ if scrapeErr != nil {
+ log.Warnf("刮削失败 [%s] %s: %v", req.MediaType, item.FolderPath, scrapeErr)
+ // 即使远端刮削失败,只要本地兜底已经写入了任意有效字段,
+ // 也保存一次(不标记 ScrapedAt,便于下次还能重试),
+ // 避免 cover/lyrics 等被丢弃。
+ if hasAnyScrapedField(item) {
+ saveCh <- item
+ }
+ continue
+ }
+ now := time.Now()
+ item.ScrapedAt = &now
+ saveCh <- item
+ }
+ }
+
+ for i := 0; i < concurrency; i++ {
+ wg.Add(1)
+ go worker()
+ }
+
+ for i := range items {
+ jobs <- i
+ }
+ close(jobs)
+ wg.Wait()
+ close(saveCh)
+ saveWg.Wait()
+
+ log.Infof("刮削完成 [%s],共处理 %d 条(并发=%d)", req.MediaType, len(items), concurrency)
+ }()
+
+ _ = cfg
+ common.SuccessResp(c)
+}
+
+// hasAnyScrapedField 判断 item 是否已经有任意一个有意义的刮削字段被填充
+// 用于:远端刮削失败但本地兜底已经写入了内容时,仍然落库保留这些信息
+func hasAnyScrapedField(item *model.MediaItem) bool {
+ if item == nil {
+ return false
+ }
+ return item.Cover != "" ||
+ item.Lyrics != "" ||
+ item.Description != "" ||
+ item.Plot != "" ||
+ item.Genre != "" ||
+ item.Authors != "" ||
+ item.AlbumArtist != "" ||
+ item.ReleaseDate != "" ||
+ item.Rating > 0 ||
+ item.Publisher != "" ||
+ item.ISBN != "" ||
+ item.ExternalID != ""
+}
+
+// ==================== 公开API(前端媒体库浏览) ====================
+
+// PublicListMedia 公开媒体列表(前端浏览用)
+func PublicListMedia(c *gin.Context) {
+ mediaType := model.MediaType(c.Query("media_type"))
+ page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
+ pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "40"))
+ page, pageSize = clampMediaPage(page, pageSize, 40, 200)
+ orderBy := c.DefaultQuery("order_by", "name")
+ orderDir := c.DefaultQuery("order_dir", "asc")
+ folderPath := c.Query("folder_path")
+ keyword := c.Query("keyword")
+ typeTag := c.Query("type_tag")
+ contentTag := c.Query("content_tag")
+ scanPathIDStr := c.Query("scan_path_id")
+ var scanPathID uint
+ if scanPathIDStr != "" {
+ if id, err := strconv.ParseUint(scanPathIDStr, 10, 64); err == nil {
+ scanPathID = uint(id)
+ }
+ }
+
+ hidden := false
+ user := mediaUser(c)
+ q := db.MediaItemQuery{
+ MediaType: mediaType,
+ ScanPathID: scanPathID,
+ FolderPath: folderPath,
+ TypeTag: typeTag,
+ ContentTag: contentTag,
+ PathPrefix: userPathPrefix(user),
+ Hidden: &hidden,
+ Keyword: keyword,
+ OrderBy: orderBy,
+ OrderDir: orderDir,
+ Page: page,
+ PageSize: pageSize,
+ }
+ items, total, err := db.ListMediaItems(q)
+ if err != nil {
+ common.ErrorResp(c, err, 500)
+ return
+ }
+ // 元信息(meta) 级别的读权限无法下推到 SQL,这里对当前页做二次过滤
+ filtered := make([]model.MediaItem, 0, len(items))
+ for i := range items {
+ if CanUserAccessItem(user, &items[i]) {
+ filtered = append(filtered, items[i])
+ }
+ }
+ common.SuccessResp(c, common.PageResp{Content: filtered, Total: total})
+}
+
+// PublicGetMedia 公开获取媒体详情
+func PublicGetMedia(c *gin.Context) {
+ idStr := c.Param("id")
+ id, err := strconv.ParseUint(idStr, 10, 64)
+ if err != nil {
+ common.ErrorStrResp(c, "无效的ID", 400)
+ return
+ }
+ item, err := db.GetMediaItemByID(uint(id))
+ if err != nil {
+ common.ErrorResp(c, err, 404)
+ return
+ }
+ if item.Hidden || !CanUserAccessItem(mediaUser(c), item) {
+ common.ErrorStrResp(c, "资源不存在", 404)
+ return
+ }
+ common.SuccessResp(c, item)
+}
+
+// PublicListAlbums 公开专辑列表(音乐)
+func PublicListAlbums(c *gin.Context) {
+ page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
+ pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "40"))
+ page, pageSize = clampMediaPage(page, pageSize, 40, 200)
+ keyword := c.Query("keyword")
+ scanPathIDStr := c.Query("scan_path_id")
+ var scanPathID uint
+ if scanPathIDStr != "" {
+ if id, err := strconv.ParseUint(scanPathIDStr, 10, 64); err == nil {
+ scanPathID = uint(id)
+ }
+ }
+
+ hidden := false
+ q := db.MediaItemQuery{
+ MediaType: model.MediaTypeMusic,
+ ScanPathID: scanPathID,
+ PathPrefix: userPathPrefix(mediaUser(c)),
+ Hidden: &hidden,
+ Keyword: keyword,
+ Page: page,
+ PageSize: pageSize,
+ }
+ albums, total, err := db.ListAlbums(q)
+ if err != nil {
+ common.ErrorResp(c, err, 500)
+ return
+ }
+ common.SuccessResp(c, common.PageResp{Content: albums, Total: total})
+}
+
+// PublicGetAlbum 公开获取专辑详情及曲目
+func PublicGetAlbum(c *gin.Context) {
+ albumName := c.Query("album_name")
+ albumArtist := c.Query("album_artist")
+ if albumName == "" && albumArtist == "" {
+ common.ErrorStrResp(c, "album_name 和 album_artist 不能同时为空", 400)
+ return
+ }
+ tracks, err := db.GetAlbumTracks(albumName, albumArtist)
+ if err != nil {
+ common.ErrorResp(c, err, 500)
+ return
+ }
+ user := mediaUser(c)
+ filtered := make([]model.MediaItem, 0, len(tracks))
+ for i := range tracks {
+ if CanUserAccessItem(user, &tracks[i]) {
+ filtered = append(filtered, tracks[i])
+ }
+ }
+ common.SuccessResp(c, filtered)
+}
+
+// PublicListFolders 公开文件夹列表(目录浏览模式)
+func PublicListFolders(c *gin.Context) {
+ mediaType := model.MediaType(c.Query("media_type"))
+ if mediaType == "" {
+ common.ErrorStrResp(c, "media_type 不能为空", 400)
+ return
+ }
+ paths, err := db.ListFolderPaths(mediaType)
+ if err != nil {
+ common.ErrorResp(c, err, 500)
+ return
+ }
+ user := mediaUser(c)
+ filtered := make([]string, 0, len(paths))
+ for _, p := range paths {
+ if CanUserAccessPath(user, p) {
+ filtered = append(filtered, p)
+ }
+ }
+ common.SuccessResp(c, filtered)
+}
+
+// PublicListScanPaths 公开获取扫描路径列表(前端筛选用)
+func PublicListScanPaths(c *gin.Context) {
+ mediaType := model.MediaType(c.Query("media_type"))
+ paths, err := db.ListMediaScanPaths(mediaType)
+ if err != nil {
+ common.ErrorResp(c, err, 500)
+ return
+ }
+ user := mediaUser(c)
+ filtered := make([]model.MediaScanPath, 0, len(paths))
+ for _, p := range paths {
+ if CanUserAccessPath(user, p.Path) {
+ filtered = append(filtered, p)
+ }
+ }
+ common.SuccessResp(c, filtered)
+}
+
+// ==================== 导入 / 导出 ====================
+
+// MediaExportData 导出数据结构
+// 包含版本号便于后续兼容性升级;导出粒度由 scope 字段说明:
+// - "all" :所有类型的全部数据
+// - "media_type" :单个媒体类型(video/music/image/book)的全部数据
+// - "scan_path" :单个扫描路径下的数据(仅 1 个路径 + 该路径下的条目)
+type MediaExportData struct {
+ Version int `json:"version"`
+ ExportAt time.Time `json:"export_at"`
+ Scope string `json:"scope"`
+ MediaType model.MediaType `json:"media_type,omitempty"`
+ ScanPaths []model.MediaScanPath `json:"scan_paths"`
+ Items []model.MediaItem `json:"items"`
+}
+
+const mediaExportVersion = 1
+
+// ExportMediaDB 导出媒体数据
+// query: media_type(可选,为空表示全部类型), scan_path_id(可选,单路径导出)
+// 返回 JSON 文件下载流
+func ExportMediaDB(c *gin.Context) {
+ mediaType := model.MediaType(c.Query("media_type"))
+ scanPathIDStr := c.Query("scan_path_id")
+
+ export := MediaExportData{
+ Version: mediaExportVersion,
+ ExportAt: time.Now(),
+ }
+
+ if scanPathIDStr != "" {
+ // 按扫描路径导出
+ id, err := strconv.ParseUint(scanPathIDStr, 10, 64)
+ if err != nil {
+ common.ErrorStrResp(c, "无效的 scan_path_id", 400)
+ return
+ }
+ sp, err := db.GetMediaScanPath(uint(id))
+ if err != nil {
+ common.ErrorResp(c, err, 404)
+ return
+ }
+ items, err := db.ListMediaItemsByScanPath(uint(id))
+ if err != nil {
+ common.ErrorResp(c, err, 500)
+ return
+ }
+ export.Scope = "scan_path"
+ export.MediaType = sp.MediaType
+ export.ScanPaths = []model.MediaScanPath{*sp}
+ export.Items = items
+ } else if mediaType != "" {
+ // 按媒体类型导出
+ paths, err := db.ListMediaScanPaths(mediaType)
+ if err != nil {
+ common.ErrorResp(c, err, 500)
+ return
+ }
+ items, err := db.ListAllMediaItems(mediaType)
+ if err != nil {
+ common.ErrorResp(c, err, 500)
+ return
+ }
+ export.Scope = "media_type"
+ export.MediaType = mediaType
+ export.ScanPaths = paths
+ export.Items = items
+ } else {
+ // 全部导出
+ paths, err := db.ListAllMediaScanPaths()
+ if err != nil {
+ common.ErrorResp(c, err, 500)
+ return
+ }
+ items, err := db.ListAllMediaItems("")
+ if err != nil {
+ common.ErrorResp(c, err, 500)
+ return
+ }
+ export.Scope = "all"
+ export.ScanPaths = paths
+ export.Items = items
+ }
+
+ // 直接以 JSON 流下载
+ filename := buildExportFilename(export)
+ c.Header("Content-Disposition", `attachment; filename="`+filename+`"`)
+ c.Header("Content-Type", "application/json; charset=utf-8")
+ if err := json.NewEncoder(c.Writer).Encode(export); err != nil {
+ log.Errorf("导出媒体数据失败: %v", err)
+ }
+}
+
+// buildExportFilename 根据导出范围生成下载文件名
+func buildExportFilename(d MediaExportData) string {
+ ts := d.ExportAt.Format("20060102_150405")
+ switch d.Scope {
+ case "scan_path":
+ spName := ""
+ if len(d.ScanPaths) > 0 {
+ spName = d.ScanPaths[0].Name
+ if spName == "" {
+ spName = d.ScanPaths[0].Path
+ }
+ }
+ // 去掉路径中的斜杠等字符,避免文件名非法
+ safe := strings.NewReplacer("/", "_", "\\", "_", ":", "_", "?", "_", "*", "_", "\"", "_", "<", "_", ">", "_", "|", "_").Replace(spName)
+ return "media_export_" + string(d.MediaType) + "_" + safe + "_" + ts + ".json"
+ case "media_type":
+ return "media_export_" + string(d.MediaType) + "_" + ts + ".json"
+ default:
+ return "media_export_all_" + ts + ".json"
+ }
+}
+
+// ImportMediaDB 导入媒体数据
+// 支持两种调用方式:
+// 1. multipart/form-data:file 字段携带导出生成的 JSON 文件
+// 2. application/json:直接 POST 整个 MediaExportData 结构
+//
+// 可选 query 参数 scan_path_id:当导入"scan_path"范围数据时,
+// 把所有条目重定向到该扫描路径ID(覆盖文件中原有的 scan_path_id)。
+func ImportMediaDB(c *gin.Context) {
+ var data MediaExportData
+
+ contentType := c.GetHeader("Content-Type")
+ if strings.HasPrefix(contentType, "multipart/form-data") {
+ fileHeader, err := c.FormFile("file")
+ if err != nil {
+ common.ErrorStrResp(c, "缺少 file 文件参数", 400)
+ return
+ }
+ f, err := fileHeader.Open()
+ if err != nil {
+ common.ErrorResp(c, err, 400)
+ return
+ }
+ defer f.Close()
+ if err := json.NewDecoder(f).Decode(&data); err != nil {
+ common.ErrorStrResp(c, "导入文件解析失败: "+err.Error(), 400)
+ return
+ }
+ } else {
+ if err := c.ShouldBindJSON(&data); err != nil {
+ common.ErrorResp(c, err, 400)
+ return
+ }
+ }
+
+ if data.Version <= 0 || data.Version > mediaExportVersion {
+ common.ErrorStrResp(c, "不支持的导入文件版本", 400)
+ return
+ }
+
+ // 1) 先导入扫描路径,建立 oldID -> newID 的映射
+ idMap, spCreated, spUpdated, err := db.ImportMediaScanPaths(data.ScanPaths)
+ if err != nil {
+ common.ErrorResp(c, err, 500)
+ return
+ }
+
+ // 2) 再导入条目,根据 idMap 把 scan_path_id 重定向到新ID
+ // 修正每个条目的 scan_path_id:
+ // - 如果 query 指定了 scan_path_id,全部覆盖为该值(按扫描路径重定向导入)
+ // - 否则按 idMap 自动重定向;找不到映射的保留原值(通常发生在该路径未一并导入时)
+ scanPathIDStr := c.Query("scan_path_id")
+ var override *uint
+ if scanPathIDStr != "" {
+ if id, err := strconv.ParseUint(scanPathIDStr, 10, 64); err == nil {
+ v := uint(id)
+ override = &v
+ }
+ }
+ if override == nil {
+ for i := range data.Items {
+ if newID, ok := idMap[data.Items[i].ScanPathID]; ok {
+ data.Items[i].ScanPathID = newID
+ }
+ }
+ }
+
+ itemCreated, itemUpdated, err := db.ImportMediaItems(data.Items, override)
+ if err != nil {
+ common.ErrorResp(c, err, 500)
+ return
+ }
+
+ common.SuccessResp(c, gin.H{
+ "scan_paths_created": spCreated,
+ "scan_paths_updated": spUpdated,
+ "items_created": itemCreated,
+ "items_updated": itemUpdated,
+ })
+}
diff --git a/server/handles/media_extra.go b/server/handles/media_extra.go
new file mode 100644
index 00000000..54e0974d
--- /dev/null
+++ b/server/handles/media_extra.go
@@ -0,0 +1,277 @@
+package handles
+
+import (
+ "encoding/json"
+ "fmt"
+ "net/url"
+ stdpath "path"
+ "strconv"
+ "strings"
+
+ "github.com/OpenListTeam/OpenList/v4/internal/conf"
+ "github.com/OpenListTeam/OpenList/v4/internal/db"
+ "github.com/OpenListTeam/OpenList/v4/internal/media"
+ "github.com/OpenListTeam/OpenList/v4/internal/model"
+ "github.com/OpenListTeam/OpenList/v4/internal/setting"
+ "github.com/OpenListTeam/OpenList/v4/server/common"
+ "github.com/gin-gonic/gin"
+)
+
+// MediaStatus 媒体库状态(前端据此决定是否渲染媒体库入口/侧栏)
+type MediaStatus struct {
+ Enabled bool `json:"enabled"`
+ Types []string `json:"types"`
+ EmbyEnabled bool `json:"emby_enabled"`
+ SubsonicEnabled bool `json:"subsonic_enabled"`
+ EmbyBaseURL string `json:"emby_base_url"`
+ SubsonicBaseURL string `json:"subsonic_base_url"`
+ StrmPath string `json:"strm_path"`
+ StrmOnScan bool `json:"strm_on_scan"`
+ StrmSameDir bool `json:"strm_same_dir"`
+ ThumbPath string `json:"thumb_path"`
+ Version string `json:"version"`
+ ProxyCover bool `json:"proxy_cover"`
+}
+
+// PublicMediaStatus 公开的媒体库状态
+func PublicMediaStatus(c *gin.Context) {
+ // Types 必须是非 nil 切片:前端会直接调用 .includes(),nil 序列化成 null 会抛异常
+ status := MediaStatus{Enabled: MediaEnabled(), Types: []string{}}
+ apiBase := common.GetApiUrl(c.Request.Context())
+ status.StrmPath = media.DefaultStrmOptions(apiBase).OutputPath
+ status.StrmOnScan = setting.GetBool(conf.MediaStrmOnScan)
+ status.StrmSameDir = setting.GetBool(conf.MediaStrmSameDir)
+ status.ThumbPath = setting.GetStr(conf.MediaThumbnailPath, "/imgs")
+ status.Version = conf.Version
+ status.ProxyCover = setting.GetBool(conf.MediaProxyCover)
+ if !status.Enabled {
+ common.SuccessResp(c, status)
+ return
+ }
+ for _, mt := range MediaTypeEnabledList() {
+ status.Types = append(status.Types, string(mt))
+ }
+ status.EmbyEnabled = MediaEmbyEnabled()
+ status.SubsonicEnabled = MediaSubsonicEnabled()
+ status.EmbyBaseURL = strings.TrimSuffix(apiBase, "/") + "/@emby"
+ status.SubsonicBaseURL = strings.TrimSuffix(apiBase, "/") + "/@rest"
+ common.SuccessResp(c, status)
+}
+
+// ==================== 应用密钥 ====================
+
+// ListMediaAppKeys 列出当前管理员的媒体应用密钥
+func ListMediaAppKeys(c *gin.Context) {
+ user := mediaUser(c)
+ if user == nil {
+ common.ErrorStrResp(c, "未登录", 401)
+ return
+ }
+ keys, err := db.ListMediaAppKeys(user.ID)
+ if err != nil {
+ common.ErrorResp(c, err, 500)
+ return
+ }
+ common.SuccessResp(c, keys)
+}
+
+// CreateMediaAppKey 创建应用密钥(明文只在创建时返回一次)
+func CreateMediaAppKey(c *gin.Context) {
+ user := mediaUser(c)
+ if user == nil {
+ common.ErrorStrResp(c, "未登录", 401)
+ return
+ }
+ var req struct {
+ Name string `json:"name"`
+ }
+ _ = c.ShouldBindJSON(&req)
+ name := strings.TrimSpace(req.Name)
+ if name == "" {
+ name = "Media Key"
+ }
+ plain, err := media.NewAppKeyPlain()
+ if err != nil {
+ common.ErrorResp(c, err, 500)
+ return
+ }
+ secret, err := media.EncryptAppKey(plain)
+ if err != nil {
+ common.ErrorResp(c, err, 500)
+ return
+ }
+ key := &model.MediaAppKey{
+ UserID: user.ID,
+ Name: name,
+ Prefix: media.AppKeyPrefix(plain),
+ Secret: secret,
+ }
+ if err := db.CreateMediaAppKey(key); err != nil {
+ common.ErrorResp(c, err, 500)
+ return
+ }
+ common.SuccessResp(c, gin.H{
+ "id": key.ID,
+ "name": key.Name,
+ "prefix": key.Prefix,
+ "key": plain,
+ })
+}
+
+// DeleteMediaAppKey 删除应用密钥
+func DeleteMediaAppKey(c *gin.Context) {
+ user := mediaUser(c)
+ if user == nil {
+ common.ErrorStrResp(c, "未登录", 401)
+ return
+ }
+ id, err := strconv.ParseUint(c.Query("id"), 10, 64)
+ if err != nil {
+ common.ErrorStrResp(c, "无效的ID", 400)
+ return
+ }
+ if err := db.DeleteMediaAppKey(user.ID, uint(id)); err != nil {
+ common.ErrorResp(c, err, 500)
+ return
+ }
+ common.SuccessResp(c, nil)
+}
+
+// ==================== STRM ====================
+
+// GenerateMediaStrm 手动批量生成 STRM
+func GenerateMediaStrm(c *gin.Context) {
+ var req struct {
+ MediaType model.MediaType `json:"media_type"`
+ ScanPathID uint `json:"scan_path_id"`
+ }
+ _ = c.ShouldBindJSON(&req)
+ apiRoot := common.GetApiUrl(c.Request.Context())
+ media.SetAPIRoot(apiRoot)
+ opts := media.DefaultStrmOptions(apiRoot)
+ if !opts.SameDir && opts.OutputPath == "" {
+ common.ErrorStrResp(c, "未配置 STRM 输出目录", 400)
+ return
+ }
+ n, err := media.GenerateStrmForType(c.Request.Context(), req.MediaType, req.ScanPathID, opts)
+ if err != nil {
+ common.ErrorResp(c, err, 500)
+ return
+ }
+ outPath := opts.OutputPath
+ if opts.SameDir {
+ outPath = "与媒体文件同目录"
+ }
+ common.SuccessResp(c, gin.H{"written": n, "path": outPath})
+}
+
+// GetMediaStreamURL 返回条目(或其中一集)的签名直链,供本地播放器/外链使用
+func GetMediaStreamURL(c *gin.Context) {
+ // 直接按路径取签名链接(用于选集等场景)
+ if rawPath := c.Query("path"); rawPath != "" {
+ if !CanUserAccessPath(mediaUser(c), rawPath) {
+ common.ErrorStrResp(c, "资源不存在", 404)
+ return
+ }
+ common.SuccessResp(c, gin.H{
+ "url": SignedStreamURL(c, rawPath),
+ "path": rawPath,
+ "name": stdpath.Base(rawPath),
+ "proxied": SignedProxyURL(c, rawPath),
+ })
+ return
+ }
+ id, err := strconv.ParseUint(c.Query("id"), 10, 64)
+ if err != nil {
+ common.ErrorStrResp(c, "无效的ID", 400)
+ return
+ }
+ item, err := db.GetMediaItemByID(uint(id))
+ if err != nil || !CanUserAccessItem(mediaUser(c), item) {
+ common.ErrorStrResp(c, "资源不存在", 404)
+ return
+ }
+ episodeIndex, _ := strconv.Atoi(c.DefaultQuery("episode", "0"))
+ rawPath := MediaItemFilePath(item)
+ if episodeIndex > 0 {
+ var eps []model.EpisodeInfo
+ if err := json.Unmarshal([]byte(item.Episodes), &eps); err == nil {
+ for i := range eps {
+ if eps[i].Index == episodeIndex || i+1 == episodeIndex {
+ rawPath = MediaEpisodeFilePath(item, &eps[i])
+ break
+ }
+ }
+ }
+ }
+ // 选集是目录里的另一个文件,必须对最终文件再校验一次:
+ // 目录可见不代表其中每个文件都可见(例如 meta 隐藏规则单独禁止某个文件),
+ // 而签名之后的下载链路只验签名,不会重新执行用户的隐藏规则。
+ if !CanUserAccessPath(mediaUser(c), rawPath) {
+ common.ErrorStrResp(c, "资源不存在", 404)
+ return
+ }
+ common.SuccessResp(c, gin.H{
+ "url": SignedStreamURL(c, rawPath),
+ "path": rawPath,
+ "name": stdpath.Base(rawPath),
+ "proxied": SignedProxyURL(c, rawPath),
+ })
+}
+
+// GetMediaStrm 动态生成单个条目的 .strm 内容(详情页下载)
+func GetMediaStrm(c *gin.Context) {
+ id, err := strconv.ParseUint(c.Query("id"), 10, 64)
+ if err != nil {
+ common.ErrorStrResp(c, "无效的ID", 400)
+ return
+ }
+ item, err := db.GetMediaItemByID(uint(id))
+ if err != nil {
+ common.ErrorStrResp(c, "资源不存在", 404)
+ return
+ }
+ if !CanUserAccessItem(mediaUser(c), item) {
+ common.ErrorStrResp(c, "资源不存在", 404)
+ return
+ }
+ episodeIndex, _ := strconv.Atoi(c.DefaultQuery("episode", "0"))
+
+ var rawPath string
+ var filename string
+ if episodeIndex > 0 {
+ var eps []model.EpisodeInfo
+ if err := json.Unmarshal([]byte(item.Episodes), &eps); err == nil {
+ for i := range eps {
+ if eps[i].Index == episodeIndex || i+1 == episodeIndex {
+ rawPath = MediaEpisodeFilePath(item, &eps[i])
+ filename = fmt.Sprintf("%s S%02dE%02d.strm", item.ScrapedName, item.Season, episodeIndex)
+ break
+ }
+ }
+ }
+ }
+ if rawPath == "" {
+ rawPath = MediaItemFilePath(item)
+ if item.IsFolder {
+ filename = item.ScrapedName + ".strm"
+ } else {
+ filename = strings.TrimSuffix(item.FileName, stdpath.Ext(item.FileName)) + ".strm"
+ }
+ }
+ // 与 GetMediaStreamURL 同理:选集解析出来的是另一个文件,需要按最终路径再校验一次
+ if !CanUserAccessPath(mediaUser(c), rawPath) {
+ common.ErrorStrResp(c, "资源不存在", 404)
+ return
+ }
+ if filename == "" {
+ filename = "media.strm"
+ }
+
+ apiRoot := common.GetApiUrl(c.Request.Context())
+ opts := media.DefaultStrmOptions(apiRoot)
+ content := media.RenderStrmContent(opts.Template, apiRoot, rawPath)
+ c.Header("Content-Type", "text/plain; charset=utf-8")
+ c.Header("Content-Disposition", fmt.Sprintf("attachment; filename*=UTF-8''%s", url.PathEscape(filename)))
+ c.String(200, content)
+}
diff --git a/server/handles/media_gate.go b/server/handles/media_gate.go
new file mode 100644
index 00000000..c6fccbef
--- /dev/null
+++ b/server/handles/media_gate.go
@@ -0,0 +1,222 @@
+package handles
+
+import (
+ "crypto/subtle"
+ "fmt"
+ "net/url"
+ stdpath "path"
+ "strings"
+
+ "github.com/OpenListTeam/OpenList/v4/internal/conf"
+ "github.com/OpenListTeam/OpenList/v4/internal/db"
+ "github.com/OpenListTeam/OpenList/v4/internal/errs"
+ "github.com/OpenListTeam/OpenList/v4/internal/media"
+ "github.com/OpenListTeam/OpenList/v4/internal/model"
+ "github.com/OpenListTeam/OpenList/v4/internal/op"
+ "github.com/OpenListTeam/OpenList/v4/internal/setting"
+ "github.com/OpenListTeam/OpenList/v4/internal/sign"
+ "github.com/OpenListTeam/OpenList/v4/pkg/utils"
+ "github.com/OpenListTeam/OpenList/v4/server/common"
+ "github.com/gin-gonic/gin"
+ "github.com/pkg/errors"
+)
+
+// MediaEnabled 媒体库总开关是否开启
+func MediaEnabled() bool {
+ return setting.GetBool(conf.MediaEnabled)
+}
+
+// MediaEmbyEnabled Emby 兼容 API 是否可用(依赖总开关)
+func MediaEmbyEnabled() bool {
+ return MediaEnabled() && setting.GetBool(conf.MediaEmbyEnabled)
+}
+
+// MediaSubsonicEnabled OpenSubsonic API 是否可用(依赖总开关)
+func MediaSubsonicEnabled() bool {
+ return MediaEnabled() && setting.GetBool(conf.MediaSubsonicEnabled)
+}
+
+// RequireMediaEnabled 媒体库关闭时统一返回 404,使其表现为"功能不存在"
+func RequireMediaEnabled(c *gin.Context) {
+ if !MediaEnabled() {
+ c.AbortWithStatus(404)
+ }
+}
+
+// RequireMediaProtocol 校验指定协议开关
+func RequireMediaProtocol(enabled func() bool) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ if !enabled() {
+ c.AbortWithStatus(404)
+ }
+ }
+}
+
+// mediaUser 返回当前请求的登录用户,未登录时为 nil
+func mediaUser(c *gin.Context) *model.User {
+ user, _ := c.Request.Context().Value(conf.UserKey).(*model.User)
+ return user
+}
+
+// CanUserAccessPath 判断用户是否有权访问指定 VFS 路径
+// 规则与文件浏览保持一致:base_path 前缀 + 元信息(meta) 读权限 + 隐藏文件规则。
+//
+// user 为 nil 一律拒绝(fail-closed):所有调用点都应经过 Auth / embyAuth 拿到用户,
+// 缺失用户说明鉴权链路出了问题,不能默认放行;被禁用的访客同样拒绝。
+func CanUserAccessPath(user *model.User, reqPath string) bool {
+ if user == nil || (user.IsGuest() && user.Disabled) {
+ return false
+ }
+ reqPath = utils.FixAndCleanPath(reqPath)
+ base := utils.FixAndCleanPath(user.BasePath)
+ if base == "" {
+ base = "/"
+ }
+ if base != "/" && !utils.IsSubPath(base, reqPath) && !utils.PathEqual(base, reqPath) {
+ return false
+ }
+ meta, err := op.GetNearestMeta(reqPath)
+ if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) {
+ return false
+ }
+ // media 播放链路无法携带目录密码,因此受密码保护的目录视为不可访问
+ return common.CanAccess(user, meta, reqPath, "")
+}
+
+// userPathPrefix 返回用于 SQL 前缀过滤的用户目录,全盘用户返回空串
+func userPathPrefix(user *model.User) string {
+ if user == nil {
+ return ""
+ }
+ base := utils.FixAndCleanPath(user.BasePath)
+ if base == "" || base == "/" {
+ return ""
+ }
+ return base
+}
+
+// mediaFolderOf 返回条目所在目录
+func mediaFolderOf(item *model.MediaItem) string {
+ if item == nil {
+ return "/"
+ }
+ folder := strings.TrimSuffix(item.FolderPath, "/")
+ if folder == "" {
+ folder = "/"
+ }
+ return folder
+}
+
+// MediaItemFilePath 拼接媒体条目对应的实际文件路径
+func MediaItemFilePath(item *model.MediaItem) string {
+ if item == nil {
+ return ""
+ }
+ folder := strings.TrimSuffix(item.FolderPath, "/")
+ if folder == "" {
+ folder = "/"
+ }
+ return stdpath.Join(folder, item.FileName)
+}
+
+// MediaEpisodeFilePath 拼接文件夹型条目的单集文件路径
+func MediaEpisodeFilePath(item *model.MediaItem, ep *model.EpisodeInfo) string {
+ if item == nil || ep == nil {
+ return ""
+ }
+ return stdpath.Join(MediaItemFilePath(item), ep.FileName)
+}
+
+// CanUserAccessItem 判断用户是否可访问某个媒体条目(含所属媒体类型开关)
+func CanUserAccessItem(user *model.User, item *model.MediaItem) bool {
+ if item == nil || item.Hidden {
+ return false
+ }
+ if !MediaTypeEnabled(item.MediaType) {
+ return false
+ }
+ return CanUserAccessPath(user, MediaItemFilePath(item))
+}
+
+// MediaTypeEnabled 判断某媒体类型是否启用扫描
+func MediaTypeEnabled(mediaType model.MediaType) bool {
+ cfg, err := db.GetMediaConfig(mediaType)
+ if err != nil || cfg == nil {
+ return false
+ }
+ return cfg.Enabled
+}
+
+// MediaTypeEnabledList 返回已启用的媒体类型
+func MediaTypeEnabledList() []model.MediaType {
+ all := []model.MediaType{model.MediaTypeVideo, model.MediaTypeMusic, model.MediaTypeImage, model.MediaTypeBook}
+ enabled := make([]model.MediaType, 0, len(all))
+ for _, mt := range all {
+ if MediaTypeEnabled(mt) {
+ enabled = append(enabled, mt)
+ }
+ }
+ return enabled
+}
+
+// LookUpAppKeyUser 按明文应用密钥查找对应用户与密钥记录。
+// Emby 与 Subsonic 的所有密钥认证都走这里,避免两套实现产生行为差异。
+func LookUpAppKeyUser(key string) (*model.User, *model.MediaAppKey, bool) {
+ key = strings.TrimSpace(key)
+ if !strings.HasPrefix(key, "olmp_") {
+ return nil, nil, false
+ }
+ keys, err := db.ListMediaAppKeysByPrefix(media.AppKeyPrefix(key))
+ if err != nil {
+ return nil, nil, false
+ }
+ for i := range keys {
+ plain, err := media.DecryptAppKey(keys[i].Secret)
+ if err != nil {
+ continue
+ }
+ if subtle.ConstantTimeCompare([]byte(plain), []byte(key)) != 1 {
+ continue
+ }
+ user, err := op.GetUserById(keys[i].UserID)
+ if err != nil || user.Disabled || user.IsGuest() {
+ return nil, nil, false
+ }
+ db.TouchMediaAppKey(keys[i].ID)
+ return user, &keys[i], true
+ }
+ return nil, nil, false
+}
+
+// SignedStreamURL 构造带签名的 /d 直链(绝对地址,供 Emby/Subsonic/STRM 使用)
+func SignedStreamURL(c *gin.Context, rawPath string) string {
+ return SignedStreamURLWithBase(common.GetApiUrl(c.Request.Context()), rawPath)
+}
+
+// SignedStreamURLWithBase 使用指定站点前缀构造签名直链
+func SignedStreamURLWithBase(apiBase, rawPath string) string {
+ apiBase = strings.TrimSuffix(apiBase, "/")
+ p := rawPath
+ if !strings.HasPrefix(p, "/") {
+ p = "/" + p
+ }
+ return fmt.Sprintf("%s/d%s?sign=%s", apiBase, utils.EncodePath(p), url.QueryEscape(sign.Sign(rawPath)))
+}
+
+// SignedProxyURL 构造经服务端代理的签名链接(/p)
+func SignedProxyURL(c *gin.Context, rawPath string) string {
+ apiBase := strings.TrimSuffix(common.GetApiUrl(c.Request.Context()), "/")
+ p := rawPath
+ if !strings.HasPrefix(p, "/") {
+ p = "/" + p
+ }
+ return fmt.Sprintf("%s/p%s?sign=%s", apiBase, utils.EncodePath(p), url.QueryEscape(sign.Sign(rawPath)))
+}
+
+// MediaStreamURL 返回媒体条目(或其中一集)的签名直链
+func MediaStreamURL(c *gin.Context, item *model.MediaItem, ep *model.EpisodeInfo) string {
+ if ep != nil {
+ return SignedStreamURL(c, MediaEpisodeFilePath(item, ep))
+ }
+ return SignedStreamURL(c, MediaItemFilePath(item))
+}
diff --git a/server/handles/media_image.go b/server/handles/media_image.go
new file mode 100644
index 00000000..87d50273
--- /dev/null
+++ b/server/handles/media_image.go
@@ -0,0 +1,201 @@
+package handles
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "net"
+ "net/http"
+ "net/url"
+ stdpath "path"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "github.com/OpenListTeam/OpenList/v4/internal/conf"
+ "github.com/OpenListTeam/OpenList/v4/internal/media/imgmime"
+ "github.com/OpenListTeam/OpenList/v4/internal/media/scraper"
+ "github.com/OpenListTeam/OpenList/v4/internal/setting"
+ "github.com/OpenListTeam/OpenList/v4/pkg/utils"
+ "github.com/gin-gonic/gin"
+)
+
+// 允许代理的封面来源域名(TMDB / 豆瓣 / Discogs)。
+// 仅代理图片,避免该接口被当作通用反向代理滥用。
+var coverAllowedHosts = []string{
+ "image.tmdb.org",
+ "tmdb.org",
+ "doubanio.com",
+ "douban.com",
+ "discogs.com",
+}
+
+func hostAllowed(host string) bool {
+ host = strings.ToLower(strings.TrimSuffix(host, "."))
+ if host == "" || host == "localhost" || strings.HasSuffix(host, ".local") {
+ return false
+ }
+ // 显式拒绝 IP 字面量,避免白名单域名被解析/重定向到内网
+ if ip := net.ParseIP(host); ip != nil {
+ return false
+ }
+ for _, h := range coverAllowedHosts {
+ if host == h || strings.HasSuffix(host, "."+h) {
+ return true
+ }
+ }
+ return false
+}
+
+// safeCoverURL 校验封面地址的协议与域名(含重定向后的每一跳)
+func safeCoverURL(u *url.URL) error {
+ if u == nil {
+ return fmt.Errorf("empty url")
+ }
+ if u.Scheme != "http" && u.Scheme != "https" {
+ return fmt.Errorf("unsupported scheme")
+ }
+ if !hostAllowed(u.Hostname()) {
+ return fmt.Errorf("host not allowed")
+ }
+ return nil
+}
+
+// PublicMediaProxy 代理外部封面图(TMDB/豆瓣/Discogs),解决客户端直连被墙的问题。
+// 用法:/api/public/media/proxy?url=https%3A%2F%2Fimage.tmdb.org%2F...
+func PublicMediaProxy(c *gin.Context) {
+ if !MediaEnabled() {
+ c.Status(http.StatusNotFound)
+ return
+ }
+ raw := c.Query("url")
+ if raw == "" {
+ c.Status(http.StatusBadRequest)
+ return
+ }
+ u, err := url.Parse(raw)
+ if err != nil || safeCoverURL(u) != nil {
+ c.Status(http.StatusForbidden)
+ return
+ }
+ resp, err := fetchCover(c.Request.Context(), u.String())
+ if err != nil && setting.GetBool(conf.MediaCoverGateway) {
+ // 直连失败时回退到公共图片网关(部分网络环境下 TMDB 图片域名不可达)。
+ // 这会把封面 URL 交给第三方 wsrv.nl,因此默认关闭,需管理员显式开启。
+ gateway := "https://wsrv.nl/?url=" + url.QueryEscape(strings.TrimPrefix(strings.TrimPrefix(u.String(), "https://"), "http://"))
+ resp, err = fetchCover(c.Request.Context(), gateway)
+ }
+ if err != nil {
+ utils.Log.Warnf("media cover proxy failed: %s: %v", u.String(), err)
+ c.Status(http.StatusBadGateway)
+ return
+ }
+ defer resp.Body.Close()
+ // 不信任上游 Content-Type:读取有限字节后按魔数判定,只输出四种栅格图片,
+ // 避免 image/svg+xml 等可执行脚本的类型以本站源被浏览器渲染。
+ data, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
+ if err != nil || len(data) == 0 {
+ c.Status(http.StatusBadGateway)
+ return
+ }
+ ct := imgmime.Sniff(data)
+ if ct == "" {
+ c.Status(http.StatusUnsupportedMediaType)
+ return
+ }
+ setSafeImageHeaders(c)
+ c.Header("Cache-Control", "public, max-age=604800")
+ c.Data(http.StatusOK, ct, data)
+}
+
+// setSafeImageHeaders 为用户可影响内容的图片响应加上防 XSS 头。
+func setSafeImageHeaders(c *gin.Context) {
+ c.Header("X-Content-Type-Options", "nosniff")
+ c.Header("Content-Disposition", `inline; filename="cover"`)
+ c.Header("Content-Security-Policy", "default-src 'none'; sandbox")
+}
+
+// fetchCover 拉取图片,仅接受 200 且 Content-Type 为 image/*
+// 重定向的每一跳都重新做域名校验,避免白名单域名 302 到内网造成 SSRF。
+func fetchCover(ctx context.Context, raw string) (*http.Response, error) {
+ client := &http.Client{
+ Timeout: 20 * time.Second,
+ CheckRedirect: func(req *http.Request, via []*http.Request) error {
+ if len(via) >= 3 {
+ return fmt.Errorf("too many redirects")
+ }
+ return safeCoverURL(req.URL)
+ },
+ }
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, raw, nil)
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; OpenList-Media/1.0)")
+ resp, err := client.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ if resp.StatusCode != http.StatusOK {
+ _ = resp.Body.Close()
+ return nil, fmt.Errorf("unexpected status %d", resp.StatusCode)
+ }
+ return resp, nil
+}
+
+// thumbnailRoot 返回运行目录模式下缩略图的本地根目录(/)
+func thumbnailRoot() string {
+ dir := setting.GetStr(conf.MediaThumbnailPath, "imgs")
+ return scraper.RuntimeThumbRoot(dir)
+}
+
+// PublicMediaThumb 读取本地缩略图目录中的文件。
+// 缩略图由刮削器写入本地磁盘(非 VFS),因此不能用 /d 读取。
+// 用法:/api/public/media/thumb?path=/imgs/xxx.jpg
+func PublicMediaThumb(c *gin.Context) {
+ if !MediaEnabled() {
+ c.Status(http.StatusNotFound)
+ return
+ }
+ reqPath := strings.TrimPrefix(stdpath.Clean("/"+c.Query("path")), "/")
+ // stdpath.Clean 已消解 ../ 段;这里不能再用 Contains("..") 粗暴过滤,会误杀 a..b.jpg 这类合法文件名。
+ // 越界由下方 filepath.Rel 校验兜底。
+ if reqPath == "" || reqPath == "." {
+ c.Status(http.StatusBadRequest)
+ return
+ }
+ base := strings.Trim(setting.GetStr(conf.MediaThumbnailPath, "/imgs"), "/")
+ if base != "" {
+ if !strings.HasPrefix(reqPath, base+"/") {
+ c.Status(http.StatusNotFound)
+ return
+ }
+ reqPath = strings.TrimPrefix(reqPath, base+"/")
+ }
+ root := thumbnailRoot()
+ full := filepath.Join(root, filepath.FromSlash(reqPath))
+ rel, err := filepath.Rel(root, full)
+ if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
+ c.Status(http.StatusForbidden)
+ return
+ }
+ // 只按扩展名白名单输出栅格图片,拒绝 svg / html 等可执行类型
+ var ct string
+ switch strings.ToLower(filepath.Ext(full)) {
+ case ".jpg", ".jpeg":
+ ct = "image/jpeg"
+ case ".png":
+ ct = "image/png"
+ case ".gif":
+ ct = "image/gif"
+ case ".webp":
+ ct = "image/webp"
+ default:
+ c.Status(http.StatusUnsupportedMediaType)
+ return
+ }
+ setSafeImageHeaders(c)
+ c.Header("Content-Type", ct)
+ c.Header("Cache-Control", "public, max-age=604800")
+ c.File(full)
+}
diff --git a/server/handles/media_ratelimit.go b/server/handles/media_ratelimit.go
new file mode 100644
index 00000000..b8a37d70
--- /dev/null
+++ b/server/handles/media_ratelimit.go
@@ -0,0 +1,92 @@
+package handles
+
+import (
+ "strings"
+
+ "github.com/OpenListTeam/OpenList/v4/internal/model"
+ "github.com/OpenListTeam/go-cache"
+ "github.com/gin-gonic/gin"
+)
+
+// 媒体协议(Emby / Subsonic)的认证入口不经过 /api/auth/login,
+// 因此必须自行复用 LoginCache 的失败计数与锁定策略,否则等于附送一个无限次数的爆破接口。
+// 使用独立的 key 前缀,避免 Subsonic 客户端的失败重试把 Web 登录一并锁死。
+
+func mediaAuthKey(c *gin.Context) string {
+ return "media:" + c.ClientIP()
+}
+
+// mediaAuthLocked 当前 IP 是否已达到失败上限(需要拒绝认证尝试)
+// 注意:这里绝不能刷新过期时间。客户端(尤其是 Subsonic)会持续轮询重试,
+// 每次被拒都续期会把 IP 永久锁死,连之后正确的密钥也无法登录。
+func mediaAuthLocked(c *gin.Context) bool {
+ count, ok := model.LoginCache.Get(mediaAuthKey(c))
+ return ok && count >= model.DefaultMaxAuthRetries
+}
+
+// bumpMediaAuthCount 增加一次失败计数。
+// 计数必须带 TTL:key 里包含 IP / 用户名(后者来自请求参数,可被随意伪造),
+// 不带过期时间会让缓存随请求无限增长;固定窗口也让锁定到期后计数自动归零。
+// 达到上限后请求会被 mediaAuthLocked / mediaAuthLockedFor 拦截,不会再走到这里,
+// 因此锁定窗口不会被持续刷新。
+func bumpMediaAuthCount(key string) {
+ count, _ := model.LoginCache.Get(key)
+ count++
+ model.LoginCache.Set(key, count, cache.WithEx[int](model.DefaultLockDuration))
+}
+
+// mediaAuthFailed 记录一次认证失败(IP 维度)
+func mediaAuthFailed(c *gin.Context) {
+ bumpMediaAuthCount(mediaAuthKey(c))
+}
+
+// mediaAuthSucceeded 认证成功后清除失败计数
+func mediaAuthSucceeded(c *gin.Context) {
+ model.LoginCache.Del(mediaAuthKey(c))
+}
+
+// ==================== 用户名维度 ====================
+// 只按 IP 计数是可以被绕过的:gin 默认信任所有代理(仓库里没有设置
+// SetTrustedProxies),客户端自带 X-Forwarded-For 就能改写 c.ClientIP(),
+// 轮换这个头即可无限次尝试。因此再按用户名记一份失败次数:它与 IP 无关,
+// 即使 IP 被伪造,对同一账号的爆破依然会被锁住。
+// (代理信任范围属于项目层面的问题,主干 /api/auth/login 现状相同。)
+
+func mediaAuthUserKey(username string) string {
+ return "media:user:" + strings.ToLower(strings.TrimSpace(username))
+}
+
+// mediaAuthLockedFor 本次请求是否应被限流:IP 或用户名任一维度达到上限即拒绝。
+func mediaAuthLockedFor(c *gin.Context, username string) bool {
+ if mediaAuthLocked(c) {
+ return true
+ }
+ if strings.TrimSpace(username) == "" {
+ return false
+ }
+ count, ok := model.LoginCache.Get(mediaAuthUserKey(username))
+ return ok && count >= model.DefaultMaxAuthRetries
+}
+
+// bumpMediaAuthUser 给用户名维度记一次失败
+func bumpMediaAuthUser(username string) {
+ name := strings.TrimSpace(username)
+ if name == "" {
+ return
+ }
+ bumpMediaAuthCount(mediaAuthUserKey(name))
+}
+
+// mediaAuthFailedFor 记录一次认证失败:同时计入 IP 与用户名两个维度。
+func mediaAuthFailedFor(c *gin.Context, username string) {
+ mediaAuthFailed(c)
+ bumpMediaAuthUser(username)
+}
+
+// mediaAuthSucceededFor 认证成功后清除 IP 与用户名两个维度的计数。
+func mediaAuthSucceededFor(c *gin.Context, username string) {
+ mediaAuthSucceeded(c)
+ if name := strings.TrimSpace(username); name != "" {
+ model.LoginCache.Del(mediaAuthUserKey(name))
+ }
+}
diff --git a/server/handles/media_reader.go b/server/handles/media_reader.go
new file mode 100644
index 00000000..b5b6f1b2
--- /dev/null
+++ b/server/handles/media_reader.go
@@ -0,0 +1,140 @@
+package handles
+
+import (
+ "strings"
+
+ "github.com/OpenListTeam/OpenList/v4/internal/db"
+ "github.com/OpenListTeam/OpenList/v4/internal/model"
+ "github.com/OpenListTeam/OpenList/v4/server/common"
+ "github.com/gin-gonic/gin"
+)
+
+const (
+ // 单个条目最多保存的批注数,防止恶意请求把表撑大
+ maxMediaAnnotations = 500
+ // 单条批注对象(含绘制点集)的 JSON 上限
+ maxMediaAnnotationAnchor = 512 << 10
+ // 单次请求的批注对象总量上限,避免一次塞进几百个大体积批注
+ maxMediaAnnotationTotal = 8 << 20
+)
+
+// mediaAnnotationTarget 校验条目存在且当前用户可以写入批注。
+// 批注是用户私有数据,不允许访客:访客是公共账号,多设备之间的批注会互相覆盖。
+func mediaAnnotationTarget(c *gin.Context, itemID uint) (*model.MediaItem, *model.User, bool) {
+ user := mediaUser(c)
+ if user == nil {
+ common.ErrorStrResp(c, "unauthorized", 401)
+ return nil, nil, false
+ }
+ if user.IsGuest() {
+ common.ErrorStrResp(c, "guest cannot save annotations", 403)
+ return nil, nil, false
+ }
+ if itemID == 0 {
+ common.ErrorStrResp(c, "invalid request", 400)
+ return nil, nil, false
+ }
+ item, err := db.GetMediaItemByID(itemID)
+ if err != nil || item.Hidden || item.DeletedAt.Valid || !CanUserAccessItem(user, item) {
+ common.ErrorStrResp(c, "not found", 404)
+ return nil, nil, false
+ }
+ return item, user, true
+}
+
+// ListMediaAnnotationsAPI 列出当前用户对某个条目保存的批注
+// GET /fs/media/annotations?item_id=1
+func ListMediaAnnotationsAPI(c *gin.Context) {
+ user := mediaUser(c)
+ itemID := uint(atoiDefault(c.Query("item_id"), 0))
+ // 未登录 / 访客 / 无权访问一律返回空列表:这是私有数据,不对访客暴露
+ if user == nil || user.IsGuest() || itemID == 0 {
+ common.SuccessResp(c, []model.MediaAnnotation{})
+ return
+ }
+ item, err := db.GetMediaItemByID(itemID)
+ if err != nil || item.Hidden || item.DeletedAt.Valid || !CanUserAccessItem(user, item) {
+ common.SuccessResp(c, []model.MediaAnnotation{})
+ return
+ }
+ list, err := db.ListMediaAnnotations(user.ID, item.ID)
+ if err != nil {
+ common.ErrorResp(c, err, 500)
+ return
+ }
+ common.SuccessResp(c, list)
+}
+
+type mediaAnnotationPayload struct {
+ RemoteID string `json:"remote_id"`
+ Anchor string `json:"anchor"`
+}
+
+type mediaAnnotationSyncReq struct {
+ ItemID uint `json:"item_id"`
+ Upserts []mediaAnnotationPayload `json:"upserts"`
+ Deletes []string `json:"deletes"`
+}
+
+// SyncMediaAnnotationsAPI 增量保存批注:upserts 覆盖同一 remote_id,deletes 按 remote_id 删除。
+// 查看器每次改动只会带上变化的那几条,不需要整份文档重传。
+// POST /fs/media/annotations
+func SyncMediaAnnotationsAPI(c *gin.Context) {
+ var req mediaAnnotationSyncReq
+ if err := c.ShouldBindJSON(&req); err != nil {
+ common.ErrorStrResp(c, "invalid request", 400)
+ return
+ }
+ item, user, ok := mediaAnnotationTarget(c, req.ItemID)
+ if !ok {
+ return
+ }
+ if len(req.Upserts) > maxMediaAnnotations || len(req.Deletes) > maxMediaAnnotations {
+ common.ErrorStrResp(c, "too many annotations", 400)
+ return
+ }
+
+ upserts := make([]model.MediaAnnotation, 0, len(req.Upserts))
+ seen := make(map[string]struct{}, len(req.Upserts))
+ anchorBytes := 0
+ for _, p := range req.Upserts {
+ remoteID := strings.TrimSpace(p.RemoteID)
+ // remote_id 是查看器分配的批注主键,多设备同步时靠它去重,必须存在且唯一
+ if remoteID == "" || p.Anchor == "" || len(remoteID) > 128 {
+ common.ErrorStrResp(c, "invalid annotation", 400)
+ return
+ }
+ if _, dup := seen[remoteID]; dup {
+ continue
+ }
+ seen[remoteID] = struct{}{}
+ if len(p.Anchor) > maxMediaAnnotationAnchor {
+ common.ErrorStrResp(c, "annotation too large", 400)
+ return
+ }
+ anchorBytes += len(p.Anchor)
+ if anchorBytes > maxMediaAnnotationTotal {
+ common.ErrorStrResp(c, "annotations too large", 400)
+ return
+ }
+ upserts = append(upserts, model.MediaAnnotation{
+ RemoteID: remoteID,
+ Anchor: p.Anchor,
+ })
+ }
+
+ deletes := make([]string, 0, len(req.Deletes))
+ for _, id := range req.Deletes {
+ id = strings.TrimSpace(id)
+ if id == "" || len(id) > 128 {
+ continue
+ }
+ deletes = append(deletes, id)
+ }
+
+ if err := db.SyncMediaAnnotations(user.ID, item.ID, upserts, deletes); err != nil {
+ common.ErrorResp(c, err, 500)
+ return
+ }
+ common.SuccessResp(c, gin.H{"saved": len(upserts), "deleted": len(deletes)})
+}
diff --git a/server/handles/media_user.go b/server/handles/media_user.go
new file mode 100644
index 00000000..87ba65ad
--- /dev/null
+++ b/server/handles/media_user.go
@@ -0,0 +1,268 @@
+package handles
+
+import (
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/OpenListTeam/OpenList/v4/internal/db"
+ "github.com/OpenListTeam/OpenList/v4/internal/model"
+ "github.com/OpenListTeam/OpenList/v4/server/common"
+ "github.com/gin-gonic/gin"
+)
+
+const mediaTicksPerSecond = 10_000_000
+
+// GetMediaUserDataBatch 批量查询当前用户对条目的播放状态(收藏/进度/已看)
+// GET /fs/media/userdata?ids=1,2,3
+func GetMediaUserDataBatch(c *gin.Context) {
+ user := mediaUser(c)
+ if user == nil {
+ common.SuccessResp(c, []model.MediaUserData{})
+ return
+ }
+ raw := c.Query("ids")
+ ids := make([]uint, 0)
+ for _, part := range strings.Split(raw, ",") {
+ part = strings.TrimSpace(part)
+ if part == "" {
+ continue
+ }
+ if id, err := strconv.ParseUint(part, 10, 64); err == nil {
+ ids = append(ids, uint(id))
+ }
+ }
+ if len(ids) == 0 {
+ common.SuccessResp(c, []model.MediaUserData{})
+ return
+ }
+ // 限制批量查询数量,避免超长 IN 子句造成数据库压力
+ if len(ids) > 500 {
+ ids = ids[:500]
+ }
+ list, err := db.ListMediaUserDataByItems(user.ID, ids)
+ if err != nil {
+ common.ErrorResp(c, err, 500)
+ return
+ }
+ common.SuccessResp(c, list)
+}
+
+// GetMediaProgress 查询单个条目(或选集)的播放进度
+func GetMediaProgress(c *gin.Context) {
+ user := mediaUser(c)
+ itemID := uint(atoiDefault(c.Query("item_id"), 0))
+ episode := atoiDefault(c.Query("episode_index"), 0)
+ if user == nil || itemID == 0 {
+ common.SuccessResp(c, nil)
+ return
+ }
+ item, err := db.GetMediaItemByID(itemID)
+ if err != nil || item.Hidden || !CanUserAccessItem(user, item) {
+ common.SuccessResp(c, nil)
+ return
+ }
+ data, err := db.GetMediaUserData(user.ID, itemID, episode)
+ if err != nil {
+ common.SuccessResp(c, nil)
+ return
+ }
+ common.SuccessResp(c, data)
+}
+
+// SaveMediaProgress 上报播放进度
+type saveProgressReq struct {
+ ItemID uint `json:"item_id"`
+ EpisodeIndex int `json:"episode_index"`
+ PositionSec float64 `json:"position"`
+ DurationSec float64 `json:"duration"`
+ Played bool `json:"played"`
+}
+
+func SaveMediaProgress(c *gin.Context) {
+ user := mediaUser(c)
+ if user == nil {
+ common.ErrorStrResp(c, "unauthorized", 401)
+ return
+ }
+ var req saveProgressReq
+ if err := c.ShouldBindJSON(&req); err != nil || req.ItemID == 0 {
+ common.ErrorStrResp(c, "invalid request", 400)
+ return
+ }
+ item, err := db.GetMediaItemByID(req.ItemID)
+ if err != nil || item.Hidden || !CanUserAccessItem(user, item) {
+ common.ErrorStrResp(c, "not found", 404)
+ return
+ }
+ data, err := db.GetMediaUserData(user.ID, req.ItemID, req.EpisodeIndex)
+ if err != nil {
+ data = &model.MediaUserData{
+ UserID: user.ID,
+ ItemID: req.ItemID,
+ EpisodeIndex: req.EpisodeIndex,
+ }
+ }
+ now := time.Now()
+ data.PositionTicks = int64(req.PositionSec * mediaTicksPerSecond)
+ if req.DurationSec > 0 {
+ data.PositionTicks = int64(minFloat(req.PositionSec, req.DurationSec) * mediaTicksPerSecond)
+ }
+ data.LastPlayedAt = &now
+ finished := req.Played
+ if req.DurationSec > 0 && req.PositionSec/req.DurationSec >= 0.9 {
+ finished = true
+ }
+ if finished && !data.Played {
+ data.Played = true
+ data.PlayCount++
+ }
+ // 重新播放时清空已看标记
+ if !finished && req.PositionSec < 5 && data.Played {
+ data.Played = false
+ }
+ if err := db.SaveMediaUserData(data); err != nil {
+ common.ErrorResp(c, err, 500)
+ return
+ }
+ common.SuccessResp(c, data)
+}
+
+// SetMediaFavorite 收藏 / 取消收藏
+type favoriteReq struct {
+ ItemID uint `json:"item_id"`
+ EpisodeIndex int `json:"episode_index"`
+ Favorite bool `json:"favorite"`
+}
+
+func SetMediaFavorite(c *gin.Context) {
+ user := mediaUser(c)
+ if user == nil {
+ common.ErrorStrResp(c, "unauthorized", 401)
+ return
+ }
+ var req favoriteReq
+ if err := c.ShouldBindJSON(&req); err != nil || req.ItemID == 0 {
+ common.ErrorStrResp(c, "invalid request", 400)
+ return
+ }
+ item, err := db.GetMediaItemByID(req.ItemID)
+ if err != nil || item.Hidden || !CanUserAccessItem(user, item) {
+ common.ErrorStrResp(c, "not found", 404)
+ return
+ }
+ data, err := db.GetMediaUserData(user.ID, req.ItemID, req.EpisodeIndex)
+ if err != nil {
+ data = &model.MediaUserData{
+ UserID: user.ID,
+ ItemID: req.ItemID,
+ EpisodeIndex: req.EpisodeIndex,
+ }
+ }
+ data.IsFavorite = req.Favorite
+ if err := db.SaveMediaUserData(data); err != nil {
+ common.ErrorResp(c, err, 500)
+ return
+ }
+ common.SuccessResp(c, gin.H{"favorite": data.IsFavorite})
+}
+
+// ListMediaFavoritesAPI 列出当前用户收藏的媒体条目
+// GET /fs/media/favorites?media_type=video&page=1&page_size=20
+func ListMediaFavoritesAPI(c *gin.Context) {
+ user := mediaUser(c)
+ mediaType := model.MediaType(c.Query("media_type"))
+ page := atoiDefault(c.DefaultQuery("page", "1"), 1)
+ pageSize := atoiDefault(c.DefaultQuery("page_size", "20"), 20)
+ if page < 1 {
+ page = 1
+ }
+ if pageSize <= 0 || pageSize > 100 {
+ pageSize = 20
+ }
+ items := []model.MediaItem{}
+ if user != nil {
+ data, err := db.ListMediaFavorites(user.ID)
+ if err != nil {
+ common.ErrorResp(c, err, 500)
+ return
+ }
+ for i := range data {
+ row := data[i]
+ if row.EpisodeIndex != 0 {
+ continue
+ }
+ item, err := db.GetMediaItemByID(row.ItemID)
+ if err != nil || item.Hidden || item.DeletedAt.Valid {
+ continue
+ }
+ if mediaType != "" && item.MediaType != mediaType {
+ continue
+ }
+ if !CanUserAccessItem(user, item) {
+ continue
+ }
+ items = append(items, *item)
+ }
+ }
+ total := len(items)
+ start := (page - 1) * pageSize
+ if start > total {
+ start = total
+ }
+ end := start + pageSize
+ if end > total {
+ end = total
+ }
+ common.SuccessResp(c, common.PageResp{Content: items[start:end], Total: int64(total)})
+}
+
+// ListMediaResumeAPI 继续观看列表
+func ListMediaResumeAPI(c *gin.Context) {
+ user := mediaUser(c)
+ mediaType := model.MediaType(c.Query("media_type"))
+ limit := atoiDefault(c.DefaultQuery("limit", "20"), 20)
+ items := []model.MediaItem{}
+ if user != nil {
+ data, err := db.ListMediaResume(user.ID, 200)
+ if err != nil {
+ common.ErrorResp(c, err, 500)
+ return
+ }
+ for i := range data {
+ row := data[i]
+ if row.PositionTicks <= 0 {
+ continue
+ }
+ item, err := db.GetMediaItemByID(row.ItemID)
+ if err != nil || item.Hidden || item.DeletedAt.Valid {
+ continue
+ }
+ if mediaType != "" && item.MediaType != mediaType {
+ continue
+ }
+ if !CanUserAccessItem(user, item) {
+ continue
+ }
+ items = append(items, *item)
+ if len(items) >= limit {
+ break
+ }
+ }
+ }
+ common.SuccessResp(c, items)
+}
+
+func atoiDefault(s string, def int) int {
+ if v, err := strconv.Atoi(s); err == nil {
+ return v
+ }
+ return def
+}
+
+func minFloat(a, b float64) float64 {
+ if a < b {
+ return a
+ }
+ return b
+}
diff --git a/server/handles/search_test.go b/server/handles/search_test.go
index 3e22b332..1faa34c9 100644
--- a/server/handles/search_test.go
+++ b/server/handles/search_test.go
@@ -25,6 +25,7 @@ func fakeResolveMeta(metas map[string]*model.Meta) func(string) (*model.Meta, er
func TestIsSearchNodeAccessible(t *testing.T) {
tests := []struct {
name string
+ basePath string
metas map[string]*model.Meta
node model.SearchNode
want bool
@@ -53,14 +54,19 @@ func TestIsSearchNodeAccessible(t *testing.T) {
},
{
name: "outside base path",
+ basePath: "/team",
node: model.SearchNode{Parent: "/other", Name: "private", IsDir: true},
want: false,
wantMetaPath: "",
},
}
- user := &model.User{ID: 2, BasePath: "/"}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
+ basePath := tt.basePath
+ if basePath == "" {
+ basePath = "/"
+ }
+ user := &model.User{ID: 2, BasePath: basePath}
resolve := fakeResolveMeta(tt.metas)
var gotMetaPath string
spy := func(p string) (*model.Meta, error) {
diff --git a/server/handles/sharing.go b/server/handles/sharing.go
index 221bc127..af96b25a 100644
--- a/server/handles/sharing.go
+++ b/server/handles/sharing.go
@@ -16,9 +16,9 @@ import (
"github.com/OpenListTeam/OpenList/v4/internal/sharing"
"github.com/OpenListTeam/OpenList/v4/pkg/utils"
"github.com/OpenListTeam/OpenList/v4/server/common"
- "github.com/OpenListTeam/go-cache"
"github.com/gin-gonic/gin"
"github.com/pkg/errors"
+ log "github.com/sirupsen/logrus"
)
func SharingGet(c *gin.Context, req *FsGetReq) {
@@ -28,13 +28,13 @@ func SharingGet(c *gin.Context, req *FsGetReq) {
return
}
s, obj, err := sharing.Get(c.Request.Context(), sid, path, model.SharingListArgs{
- Refresh: false,
- Pwd: req.Password,
+ Refresh: false,
+ Pwd: req.Password,
+ ClientIP: c.ClientIP(),
})
if dealError(c, err) {
return
}
- _ = countAccess(c.ClientIP(), s)
url := ""
if !obj.IsDir() {
fakePath := fmt.Sprintf("/%s/%s", sid, path)
@@ -72,13 +72,13 @@ func SharingList(c *gin.Context, req *ListReq) {
return
}
s, objs, err := sharing.List(c.Request.Context(), sid, path, model.SharingListArgs{
- Refresh: req.Refresh,
- Pwd: req.Password,
+ Refresh: req.Refresh,
+ Pwd: req.Password,
+ ClientIP: c.ClientIP(),
})
if dealError(c, err) {
return
}
- _ = countAccess(c.ClientIP(), s)
total, objs := pagination(objs, &req.PageReq)
common.SuccessResp(c, FsListResp{
Content: utils.MustSliceConvert(objs, func(obj model.Obj) ObjResp {
@@ -126,12 +126,12 @@ func SharingArchiveMeta(c *gin.Context, req *ArchiveMetaReq) {
ArchiveArgs: archiveArgs,
Refresh: req.Refresh,
},
- Pwd: req.Password,
+ Pwd: req.Password,
+ ClientIP: c.ClientIP(),
})
if dealError(c, err) {
return
}
- _ = countAccess(c.ClientIP(), s)
fakePath := fmt.Sprintf("/%s/%s", sid, path)
url := fmt.Sprintf("%s/sad%s", common.GetApiUrl(c), utils.EncodePath(fakePath, true))
if s.Pwd != "" {
@@ -167,17 +167,17 @@ func SharingArchiveList(c *gin.Context, req *ArchiveListReq) {
},
InnerPath: utils.FixAndCleanPath(req.InnerPath),
}
- s, objs, err := sharing.ArchiveList(c.Request.Context(), sid, path, model.SharingArchiveListArgs{
+ _, objs, err := sharing.ArchiveList(c.Request.Context(), sid, path, model.SharingArchiveListArgs{
ArchiveListArgs: model.ArchiveListArgs{
ArchiveInnerArgs: innerArgs,
Refresh: req.Refresh,
},
- Pwd: req.Password,
+ Pwd: req.Password,
+ ClientIP: c.ClientIP(),
})
if dealError(c, err) {
return
}
- _ = countAccess(c.ClientIP(), s)
total, objs := pagination(objs, &req.PageReq)
ret, _ := utils.SliceConvert(objs, func(src model.Obj) (ObjResp, error) {
return toObjsRespWithoutSignAndThumb(src), nil
@@ -195,12 +195,10 @@ func SharingDown(c *gin.Context) {
pwd := c.Query("pwd")
s, err := op.GetSharingById(sid)
if err == nil {
- if !s.Valid() {
- err = errs.InvalidSharing
- } else if !s.Verify(pwd) {
- err = errs.WrongShareCode
- } else if len(s.Files) != 1 && path == "/" {
+ if len(s.Files) != 1 && path == "/" {
err = errors.New("cannot get sharing root link")
+ } else {
+ err = op.AccessSharing(c.ClientIP(), pwd, s)
}
}
if dealErrorPage(c, err) {
@@ -212,14 +210,15 @@ func SharingDown(c *gin.Context) {
return
}
storage, actualPath, err := op.GetStorageAndActualPath(unwrapPath)
- if dealErrorPage(c, err) {
+ if err != nil {
+ log.Errorf("failed get sharing storage: %+v", err)
+ common.ErrorPage(c, errors.New("failed get sharing link"), 500)
return
}
if setting.GetBool(conf.ShareForceProxy) || common.ShouldProxy(storage, stdpath.Base(actualPath)) {
if _, ok := c.GetQuery("d"); !ok {
if url := common.GenerateDownProxyURL(storage.GetStorage(), unwrapPath); url != "" {
c.Redirect(302, url)
- _ = countAccess(c.ClientIP(), s)
return
}
}
@@ -228,10 +227,10 @@ func SharingDown(c *gin.Context) {
Type: c.Query("type"),
})
if err != nil {
- common.ErrorPage(c, errors.WithMessage(err, "failed get sharing link"), 500)
+ log.Errorf("failed get sharing link: %+v", err)
+ common.ErrorPage(c, errors.New("failed get sharing link"), 500)
return
}
- _ = countAccess(c.ClientIP(), s)
proxy(c, link, obj, storage.GetStorage().ProxyRange)
} else {
link, _, err := op.Link(c.Request.Context(), storage, actualPath, model.LinkArgs{
@@ -241,10 +240,10 @@ func SharingDown(c *gin.Context) {
Redirect: true,
})
if err != nil {
- common.ErrorPage(c, errors.WithMessage(err, "failed get sharing link"), 500)
+ log.Errorf("failed get sharing link: %+v", err)
+ common.ErrorPage(c, errors.New("failed get sharing link"), 500)
return
}
- _ = countAccess(c.ClientIP(), s)
redirect(c, link)
}
}
@@ -262,12 +261,10 @@ func SharingArchiveExtract(c *gin.Context) {
archivePass := c.Query("pass")
s, err := op.GetSharingById(sid)
if err == nil {
- if !s.Valid() {
- err = errs.InvalidSharing
- } else if !s.Verify(pwd) {
- err = errs.WrongShareCode
- } else if len(s.Files) != 1 && path == "/" {
+ if len(s.Files) != 1 && path == "/" {
err = errors.New("cannot extract sharing root")
+ } else {
+ err = op.AccessSharing(c.ClientIP(), pwd, s)
}
}
if dealErrorPage(c, err) {
@@ -611,19 +608,3 @@ func SetEnableSharing(disable bool) func(ctx *gin.Context) {
}
}
}
-
-var (
- AccessCache = cache.NewMemCache[interface{}]()
- AccessCountDelay = 30 * time.Minute
-)
-
-func countAccess(ip string, s *model.Sharing) error {
- key := fmt.Sprintf("%s:%s", s.ID, ip)
- _, ok := AccessCache.Get(key)
- if !ok {
- AccessCache.Set(key, struct{}{}, cache.WithEx[interface{}](AccessCountDelay))
- s.Accessed += 1
- return op.UpdateSharing(s, true)
- }
- return nil
-}
diff --git a/server/handles/subsonic.go b/server/handles/subsonic.go
new file mode 100644
index 00000000..01c0f131
--- /dev/null
+++ b/server/handles/subsonic.go
@@ -0,0 +1,772 @@
+package handles
+
+import (
+ "encoding/hex"
+ "encoding/json"
+ "encoding/xml"
+ "fmt"
+ stdpath "path"
+ "sort"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/OpenListTeam/OpenList/v4/internal/conf"
+ "github.com/OpenListTeam/OpenList/v4/internal/db"
+ "github.com/OpenListTeam/OpenList/v4/internal/media"
+ "github.com/OpenListTeam/OpenList/v4/internal/model"
+ "github.com/OpenListTeam/OpenList/v4/internal/op"
+ "github.com/OpenListTeam/OpenList/v4/internal/setting"
+ "github.com/gin-gonic/gin"
+)
+
+const subsonicAPIVersion = "1.16.1"
+
+// ==================== 响应结构 ====================
+
+type subsonicError struct {
+ Code int `json:"code" xml:"code,attr"`
+ Message string `json:"message" xml:"message,attr"`
+}
+
+type subsonicResponse struct {
+ XMLName xml.Name `json:"-" xml:"subsonic-response"`
+
+ Status string `json:"status" xml:"status,attr"`
+ Version string `json:"version" xml:"version,attr"`
+ Type string `json:"type" xml:"type,attr"`
+ ServerVersion string `json:"serverVersion" xml:"serverVersion,attr"`
+ OpenSubsonic bool `json:"openSubsonic" xml:"openSubsonic,attr"`
+
+ Error *subsonicError `json:"error,omitempty" xml:"error,omitempty"`
+
+ License *subsonicLicense `json:"license,omitempty" xml:"license,omitempty"`
+ MusicFolders *subsonicMusicFolders `json:"musicFolders,omitempty" xml:"musicFolders,omitempty"`
+ Indexes *subsonicIndexes `json:"indexes,omitempty" xml:"indexes,omitempty"`
+ Artists *subsonicArtists `json:"artists,omitempty" xml:"artists,omitempty"`
+ Artist *subsonicArtist `json:"artist,omitempty" xml:"artist,omitempty"`
+ Album *subsonicAlbum `json:"album,omitempty" xml:"album,omitempty"`
+ Song *subsonicChild `json:"song,omitempty" xml:"song,omitempty"`
+ AlbumList *subsonicAlbumList `json:"albumList,omitempty" xml:"albumList,omitempty"`
+ AlbumList2 *subsonicAlbumList `json:"albumList2,omitempty" xml:"albumList2,omitempty"`
+ SearchResult2 *subsonicSearchResult `json:"searchResult2,omitempty" xml:"searchResult2,omitempty"`
+ SearchResult3 *subsonicSearchResult `json:"searchResult3,omitempty" xml:"searchResult3,omitempty"`
+ Playlists *subsonicPlaylists `json:"playlists,omitempty" xml:"playlists,omitempty"`
+ Playlist *subsonicPlaylist `json:"playlist,omitempty" xml:"playlist,omitempty"`
+ Lyrics *subsonicLyrics `json:"lyrics,omitempty" xml:"lyrics,omitempty"`
+ LyricsList *subsonicLyricsList `json:"lyricsList,omitempty" xml:"lyricsList,omitempty"`
+ Starred *subsonicStarred `json:"starred,omitempty" xml:"starred,omitempty"`
+ Starred2 *subsonicStarred `json:"starred2,omitempty" xml:"starred2,omitempty"`
+ Genres *subsonicGenres `json:"genres,omitempty" xml:"genres,omitempty"`
+ RandomSongs *subsonicSongs `json:"randomSongs,omitempty" xml:"randomSongs,omitempty"`
+ SongsByGenre *subsonicSongs `json:"songsByGenre,omitempty" xml:"songsByGenre,omitempty"`
+ ScanStatus *subsonicScanStatus `json:"scanStatus,omitempty" xml:"scanStatus,omitempty"`
+ User *subsonicUser `json:"user,omitempty" xml:"user,omitempty"`
+ Users *subsonicUsers `json:"users,omitempty" xml:"users,omitempty"`
+ OpenSubsonicExtensions *subsonicExtensions `json:"openSubsonicExtensions,omitempty" xml:"openSubsonicExtensions,omitempty"`
+ NowPlaying *subsonicNowPlaying `json:"nowPlaying,omitempty" xml:"nowPlaying,omitempty"`
+ PlayQueue *subsonicPlayQueue `json:"playQueue,omitempty" xml:"playQueue,omitempty"`
+ ArtistInfo2 *subsonicArtistInfo `json:"artistInfo2,omitempty" xml:"artistInfo2,omitempty"`
+ AlbumInfo *subsonicAlbumInfo `json:"albumInfo,omitempty" xml:"albumInfo,omitempty"`
+ TopSongs *subsonicSongs `json:"topSongs,omitempty" xml:"topSongs,omitempty"`
+ SimilarSongs2 *subsonicSongs `json:"similarSongs2,omitempty" xml:"similarSongs2,omitempty"`
+ MusicDirectory *subsonicDirectory `json:"directory,omitempty" xml:"directory,omitempty"`
+ Bookmarks *subsonicBookmarks `json:"bookmarks,omitempty" xml:"bookmarks,omitempty"`
+}
+
+type subsonicLicense struct {
+ Valid bool `json:"valid" xml:"valid,attr"`
+}
+
+type subsonicMusicFolder struct {
+ Id string `json:"id" xml:"id,attr"`
+ Name string `json:"name" xml:"name,attr"`
+}
+
+type subsonicMusicFolders struct {
+ MusicFolder []subsonicMusicFolder `json:"musicFolder" xml:"musicFolder"`
+}
+
+type subsonicIndex struct {
+ Name string `json:"name" xml:"name,attr"`
+ Artist []subsonicChild `json:"artist" xml:"artist"`
+}
+
+type subsonicIndexes struct {
+ LastModified int64 `json:"lastModified" xml:"lastModified,attr"`
+ IgnoredArticles string `json:"ignoredArticles" xml:"ignoredArticles,attr"`
+ Index []subsonicIndex `json:"index" xml:"index"`
+}
+
+type subsonicArtist struct {
+ Id string `json:"id" xml:"id,attr"`
+ Name string `json:"name" xml:"name,attr"`
+ AlbumCount int `json:"albumCount,omitempty" xml:"albumCount,attr,omitempty"`
+ CoverArt string `json:"coverArt,omitempty" xml:"coverArt,attr,omitempty"`
+ Album []subsonicAlbum `json:"album" xml:"album"`
+}
+
+type subsonicArtists struct {
+ IgnoredArticles string `json:"ignoredArticles" xml:"ignoredArticles,attr"`
+ Index []subsonicIndex2 `json:"index" xml:"index"`
+}
+
+type subsonicIndex2 struct {
+ Name string `json:"name" xml:"name,attr"`
+ Artist []subsonicArtist `json:"artist" xml:"artist"`
+}
+
+type subsonicAlbum struct {
+ Id string `json:"id" xml:"id,attr"`
+ Name string `json:"name" xml:"name,attr"`
+ Artist string `json:"artist,omitempty" xml:"artist,attr,omitempty"`
+ ArtistId string `json:"artistId,omitempty" xml:"artistId,attr,omitempty"`
+ CoverArt string `json:"coverArt,omitempty" xml:"coverArt,attr,omitempty"`
+ SongCount int `json:"songCount" xml:"songCount,attr"`
+ Duration int `json:"duration" xml:"duration,attr"`
+ Created string `json:"created" xml:"created,attr"`
+ Year int `json:"year,omitempty" xml:"year,attr,omitempty"`
+ Song []subsonicChild `json:"song" xml:"song"`
+}
+
+type subsonicChild struct {
+ Id string `json:"id" xml:"id,attr"`
+ Parent string `json:"parent,omitempty" xml:"parent,attr,omitempty"`
+ IsDir bool `json:"isDir" xml:"isDir,attr"`
+ Title string `json:"title" xml:"title,attr"`
+ Album string `json:"album,omitempty" xml:"album,attr,omitempty"`
+ Artist string `json:"artist,omitempty" xml:"artist,attr,omitempty"`
+ Track int `json:"track,omitempty" xml:"track,attr,omitempty"`
+ Year int `json:"year,omitempty" xml:"year,attr,omitempty"`
+ Genre string `json:"genre,omitempty" xml:"genre,attr,omitempty"`
+ CoverArt string `json:"coverArt,omitempty" xml:"coverArt,attr,omitempty"`
+ Size int64 `json:"size,omitempty" xml:"size,attr,omitempty"`
+ ContentType string `json:"contentType,omitempty" xml:"contentType,attr,omitempty"`
+ Suffix string `json:"suffix,omitempty" xml:"suffix,attr,omitempty"`
+ Duration int `json:"duration,omitempty" xml:"duration,attr,omitempty"`
+ BitRate int `json:"bitRate,omitempty" xml:"bitRate,attr,omitempty"`
+ Path string `json:"path,omitempty" xml:"path,attr,omitempty"`
+ AlbumId string `json:"albumId,omitempty" xml:"albumId,attr,omitempty"`
+ ArtistId string `json:"artistId,omitempty" xml:"artistId,attr,omitempty"`
+ Type string `json:"type,omitempty" xml:"type,attr,omitempty"`
+ IsVideo bool `json:"isVideo,omitempty" xml:"isVideo,attr,omitempty"`
+ PlayCount int `json:"playCount,omitempty" xml:"playCount,attr,omitempty"`
+ DiscNumber int `json:"discNumber,omitempty" xml:"discNumber,attr,omitempty"`
+ Created string `json:"created,omitempty" xml:"created,attr,omitempty"`
+ Starred string `json:"starred,omitempty" xml:"starred,attr,omitempty"`
+ UserRating int `json:"userRating,omitempty" xml:"userRating,attr,omitempty"`
+}
+
+type subsonicAlbumList struct {
+ Album []subsonicAlbum `json:"album" xml:"album"`
+}
+
+type subsonicSearchResult struct {
+ Offset int `json:"offset" xml:"offset,attr"`
+ Total int `json:"total" xml:"total,attr"`
+ Artist []subsonicArtist `json:"artist" xml:"artist"`
+ Album []subsonicAlbum `json:"album" xml:"album"`
+ Song []subsonicChild `json:"song" xml:"song"`
+}
+
+type subsonicSongs struct {
+ Song []subsonicChild `json:"song" xml:"song"`
+}
+
+type subsonicDirectory struct {
+ Id string `json:"id" xml:"id,attr"`
+ Parent string `json:"parent,omitempty" xml:"parent,attr,omitempty"`
+ Name string `json:"name" xml:"name,attr"`
+ Starred string `json:"starred,omitempty" xml:"starred,attr,omitempty"`
+ Child []subsonicChild `json:"child" xml:"child"`
+}
+
+type subsonicPlaylists struct {
+ Playlist []subsonicPlaylist `json:"playlist" xml:"playlist"`
+}
+
+type subsonicPlaylist struct {
+ Id string `json:"id" xml:"id,attr"`
+ Name string `json:"name" xml:"name,attr"`
+ Comment string `json:"comment,omitempty" xml:"comment,attr,omitempty"`
+ Owner string `json:"owner,omitempty" xml:"owner,attr,omitempty"`
+ Public bool `json:"public" xml:"public,attr"`
+ SongCount int `json:"songCount" xml:"songCount,attr"`
+ Duration int `json:"duration" xml:"duration,attr"`
+ Created string `json:"created" xml:"created,attr"`
+ Entry []subsonicChild `json:"entry" xml:"entry"`
+}
+
+type subsonicLyrics struct {
+ Artist string `json:"artist,omitempty" xml:"artist,attr,omitempty"`
+ Title string `json:"title,omitempty" xml:"title,attr,omitempty"`
+ Value string `json:"value" xml:",chardata"`
+}
+
+type subsonicLyricsList struct {
+ StructuredLyrics []subsonicStructuredLyrics `json:"structuredLyrics" xml:"structuredLyrics"`
+}
+
+type subsonicStructuredLyrics struct {
+ DisplayArtist string `json:"displayArtist,omitempty" xml:"displayArtist,attr,omitempty"`
+ DisplayTitle string `json:"displayTitle,omitempty" xml:"displayTitle,attr,omitempty"`
+ Lang string `json:"lang" xml:"lang,attr"`
+ Synced bool `json:"synced" xml:"synced,attr"`
+ Line []subsonicLyricLine `json:"line" xml:"line"`
+}
+
+type subsonicLyricLine struct {
+ Start int64 `json:"start" xml:"start,attr"`
+ Value string `json:"value" xml:"value,attr"`
+}
+
+type subsonicStarred struct {
+ Artist []subsonicArtist `json:"artist" xml:"artist"`
+ Album []subsonicAlbum `json:"album" xml:"album"`
+ Song []subsonicChild `json:"song" xml:"song"`
+}
+
+type subsonicGenre struct {
+ Value string `json:"value" xml:",chardata"`
+ SongCount int `json:"songCount" xml:"songCount,attr"`
+ AlbumCount int `json:"albumCount" xml:"albumCount,attr"`
+}
+
+type subsonicGenres struct {
+ Genre []subsonicGenre `json:"genre" xml:"genre"`
+}
+
+type subsonicScanStatus struct {
+ Scanning bool `json:"scanning" xml:"scanning,attr"`
+ Count int64 `json:"count" xml:"count,attr"`
+}
+
+type subsonicUser struct {
+ Username string `json:"username" xml:"username,attr"`
+ Email string `json:"email,omitempty" xml:"email,attr,omitempty"`
+ ScrobblingEnabled bool `json:"scrobblingEnabled" xml:"scrobblingEnabled,attr"`
+ AdminRole bool `json:"adminRole" xml:"adminRole,attr"`
+ DownloadRole bool `json:"downloadRole" xml:"downloadRole,attr"`
+ PlaylistRole bool `json:"playlistRole" xml:"playlistRole,attr"`
+ StreamRole bool `json:"streamRole" xml:"streamRole,attr"`
+ CoverArtRole bool `json:"coverArtRole" xml:"coverArtRole,attr"`
+}
+
+type subsonicUsers struct {
+ User []subsonicUser `json:"user" xml:"user"`
+}
+
+type subsonicExtension struct {
+ Name string `json:"name" xml:"name,attr"`
+ Versions []int `json:"versions" xml:"versions"`
+}
+
+type subsonicExtensions struct {
+ Extension []subsonicExtension `json:"openSubsonicExtension" xml:"openSubsonicExtension"`
+}
+
+type subsonicNowPlaying struct {
+ Entry []subsonicChild `json:"entry" xml:"entry"`
+}
+
+type subsonicPlayQueue struct {
+ Current string `json:"current,omitempty" xml:"current,attr,omitempty"`
+ Position int64 `json:"position,omitempty" xml:"position,attr,omitempty"`
+ Username string `json:"username,omitempty" xml:"username,attr,omitempty"`
+ Changed string `json:"changed,omitempty" xml:"changed,attr,omitempty"`
+ Entry []subsonicChild `json:"entry" xml:"entry"`
+}
+
+type subsonicArtistInfo struct {
+ Biography string `json:"biography,omitempty" xml:"biography,omitempty"`
+ MusicBrainzId string `json:"musicBrainzId,omitempty" xml:"musicBrainzId,omitempty"`
+}
+
+type subsonicAlbumInfo struct {
+ Notes string `json:"notes,omitempty" xml:"notes,omitempty"`
+}
+
+type subsonicBookmarks struct {
+ Bookmark []any `json:"bookmark" xml:"bookmark"`
+}
+
+// ==================== 分发 ====================
+
+// SubsonicAPI OpenSubsonic API 入口(/@rest/*)
+func SubsonicAPI(c *gin.Context) {
+ if !MediaSubsonicEnabled() {
+ c.AbortWithStatus(404)
+ return
+ }
+ name := strings.Trim(c.Param("path"), "/")
+ // 兼容接入地址已带 /@rest、客户端又自动追加 /rest 的情况
+ if first, rest, found := strings.Cut(name, "/"); found && strings.EqualFold(first, "rest") {
+ name = rest
+ }
+ name = strings.TrimSuffix(name, ".view")
+ name = strings.ToLower(name)
+ if name == "" {
+ subError(c, 0, "missing method")
+ return
+ }
+
+ // 免认证端点
+ if name == "getopensubsonicextensions" {
+ subRespond(c, &subsonicResponse{OpenSubsonicExtensions: &subsonicExtensions{
+ Extension: []subsonicExtension{
+ {Name: "apiKeyAuthentication", Versions: []int{1}},
+ {Name: "formPost", Versions: []int{1}},
+ {Name: "songLyrics", Versions: []int{1}},
+ },
+ }})
+ return
+ }
+
+ user, _, ok := subAuth(c)
+ if !ok {
+ // 锁定判定同样要看用户名维度,否则被锁的账号只会收到「密码错误」
+ if mediaAuthLockedFor(c, subParam(c, "u")) {
+ subError(c, 40, "Too many failed attempts, please try again later")
+ return
+ }
+ subError(c, 40, "Wrong username or password; use an app key as the password or enable password auth")
+ return
+ }
+
+ switch name {
+ case "ping":
+ subRespond(c, &subsonicResponse{})
+ case "getlicense":
+ subRespond(c, &subsonicResponse{License: &subsonicLicense{Valid: true}})
+ case "tokeninfo":
+ subRespond(c, &subsonicResponse{User: subUserOf(user)})
+ case "getuser":
+ subRespond(c, &subsonicResponse{User: subUserOf(user)})
+ case "getusers":
+ if !user.IsAdmin() {
+ subError(c, 50, "not authorized")
+ return
+ }
+ users, _, err := op.GetUsers(1, -1)
+ if err != nil {
+ subError(c, 0, err.Error())
+ return
+ }
+ out := &subsonicUsers{}
+ for i := range users {
+ if users[i].IsGuest() {
+ continue
+ }
+ out.User = append(out.User, *subUserOf(&users[i]))
+ }
+ subRespond(c, &subsonicResponse{Users: out})
+ case "getmusicfolders":
+ subRespond(c, &subsonicResponse{MusicFolders: &subsonicMusicFolders{
+ MusicFolder: []subsonicMusicFolder{{Id: "1", Name: "Music"}},
+ }})
+ case "getindexes":
+ subGetIndexes(c, user)
+ case "getartists":
+ subGetArtists(c, user)
+ case "getartist":
+ subGetArtist(c, user)
+ case "getalbum":
+ subGetAlbum(c, user)
+ case "getsong":
+ subGetSong(c, user)
+ case "getmusicdirectory":
+ subGetMusicDirectory(c, user)
+ case "getalbumlist", "getalbumlist2":
+ subGetAlbumList(c, user, name == "getalbumlist")
+ case "getrandomsongs":
+ subGetRandomSongs(c, user)
+ case "getsongsbygenre":
+ subGetSongsByGenre(c, user)
+ case "search2", "search3":
+ subSearch(c, user, name == "search2")
+ case "getstarred", "getstarred2":
+ subGetStarred(c, user, name == "getstarred")
+ case "stream", "download":
+ subStream(c, user)
+ case "getcoverart":
+ subGetCoverArt(c, user)
+ case "getlyrics":
+ subGetLyrics(c, user)
+ case "getlyricsbysongid":
+ subGetLyricsBySongID(c, user)
+ case "star", "unstar":
+ subStar(c, user, name == "star")
+ case "setrating":
+ subSetRating(c, user)
+ case "scrobble":
+ subScrobble(c, user)
+ case "getnowplaying":
+ subRespond(c, &subsonicResponse{NowPlaying: &subsonicNowPlaying{Entry: subNowPlayingEntries()}})
+ case "getplaylists":
+ subGetPlaylists(c, user)
+ case "getplaylist":
+ subGetPlaylist(c, user)
+ case "createplaylist":
+ subCreatePlaylist(c, user)
+ case "updateplaylist":
+ subUpdatePlaylist(c, user)
+ case "deleteplaylist":
+ subDeletePlaylist(c, user)
+ case "getscanstatus":
+ subRespond(c, &subsonicResponse{ScanStatus: &subsonicScanStatus{Scanning: false, Count: subSongCount(user)}})
+ case "startscan":
+ if !user.IsAdmin() {
+ subError(c, 50, "not authorized")
+ return
+ }
+ subStartScan(c)
+ case "saveplayqueue":
+ subSavePlayQueue(c, user)
+ case "getplayqueue":
+ subRespond(c, &subsonicResponse{PlayQueue: subLoadPlayQueue(user)})
+ case "getartistinfo2":
+ subRespond(c, &subsonicResponse{ArtistInfo2: &subsonicArtistInfo{}})
+ case "getalbuminfo", "getalbuminfo2":
+ subRespond(c, &subsonicResponse{AlbumInfo: &subsonicAlbumInfo{}})
+ case "gettopsongs", "getsimilarsongs2":
+ subRespond(c, &subsonicResponse{TopSongs: &subsonicSongs{}})
+ case "getbookmarks":
+ subRespond(c, &subsonicResponse{Bookmarks: &subsonicBookmarks{}})
+ case "getgenres":
+ subGetGenres(c, user)
+ case "getavatars":
+ c.Status(404)
+ // 明确不支持
+ case "getpodcasts", "getnewestpodcasts", "refreshpodcasts", "createpodcastchannel",
+ "deletepodcastchannel", "deletepodcastepisode", "downloadpodcastepisode",
+ "createuser", "updateuser", "deleteuser", "changepassword",
+ "getshares", "createshare", "updateshare", "deleteshare", "jukeboxcontrol":
+ c.String(501, "This endpoint is not implemented, but may be in future releases")
+ case "search", "getchatmessages", "addchatmessage", "getvideos", "getvideoinfo", "getcaptions", "hls":
+ c.String(410, "This endpoint will not be implemented")
+ default:
+ subError(c, 0, "unknown method: "+name)
+ }
+}
+
+// ==================== 参数 / 认证 / 响应 ====================
+
+func subParam(c *gin.Context, key string) string {
+ if v := c.Query(key); v != "" {
+ return v
+ }
+ if c.Request.Method == "POST" {
+ _ = c.Request.ParseForm()
+ if v := c.Request.PostForm.Get(key); v != "" {
+ return v
+ }
+ }
+ return ""
+}
+
+func subIntParam(c *gin.Context, key string, def int) int {
+ v := subParam(c, key)
+ if v == "" {
+ return def
+ }
+ n, err := strconv.Atoi(v)
+ if err != nil {
+ return def
+ }
+ return n
+}
+
+// subAuth 支持 apiKey / u+t+s / u+p(默认关闭)认证,并对失败次数做 IP + 用户名双维度限流
+func subAuth(c *gin.Context) (*model.User, *model.MediaAppKey, bool) {
+ username := strings.TrimSpace(subParam(c, "u"))
+ if mediaAuthLockedFor(c, username) {
+ return nil, nil, false
+ }
+ u, k, ok := subAuthInner(c)
+ if ok {
+ // 用真实用户名清除计数:apiKey 认证时 u 参数是空的
+ mediaAuthSucceededFor(c, u.Username)
+ } else if subHasCredential(c) {
+ // 只有「确实带了凭据但校验失败」才计数;
+ // 完全裸请求(部分客户端启动时会先发一次不带参数的探测)不计入失败次数。
+ mediaAuthFailedFor(c, username)
+ }
+ return u, k, ok
+}
+
+// subHasCredential 判断请求是否真的携带了认证凭据
+func subHasCredential(c *gin.Context) bool {
+ return subParam(c, "apiKey") != "" || subParam(c, "u") != "" ||
+ subParam(c, "t") != "" || subParam(c, "p") != ""
+}
+
+func subAuthInner(c *gin.Context) (*model.User, *model.MediaAppKey, bool) {
+ // OpenSubsonic apiKey 扩展:直接把应用密钥放在 apiKey 参数里
+ if key := strings.TrimSpace(subParam(c, "apiKey")); key != "" {
+ if u, k, ok := subAuthByAppKey(key); ok {
+ return u, k, true
+ }
+ return nil, nil, false
+ }
+ username := strings.TrimSpace(subParam(c, "u"))
+ password := decodeSubsonicPassword(subParam(c, "p"))
+ // 用户名留空、只填了应用密钥(部分客户端只允许填一个"密码"字段)
+ if username == "" {
+ if password != "" {
+ if u, k, ok := subAuthByAppKey(password); ok {
+ return u, k, true
+ }
+ }
+ return nil, nil, false
+ }
+ user, err := op.GetUserByName(username)
+ if err != nil || user.Disabled || user.IsGuest() {
+ return nil, nil, false
+ }
+ // t+s:用应用密钥的明文做 md5(密码+salt) 校验(Subsonic token 认证)
+ if salt := subParam(c, "s"); salt != "" {
+ if token := subParam(c, "t"); token != "" {
+ keys, _ := db.ListMediaAppKeys(user.ID)
+ for i := range keys {
+ plain, err := media.DecryptAppKey(keys[i].Secret)
+ if err != nil {
+ continue
+ }
+ if media.MatchSubsonicToken(plain, salt, token) {
+ db.TouchMediaAppKey(keys[i].ID)
+ return user, &keys[i], true
+ }
+ }
+ // 不直接返回:个别客户端会同时带上 p,继续按明文校验
+ }
+ }
+ // p:OpenList 密码或应用密钥明文
+ if password != "" {
+ // 使用主账号密码认证会把登录密码暴露给第三方客户端,必须由管理员显式开启
+ if setting.GetBool(conf.MediaSubsonicAllowPwd) && user.OtpSecret == "" &&
+ user.ValidateRawPassword(password) == nil {
+ return user, nil, true
+ }
+ if u, k, ok := subAuthByAppKey(password); ok && u.ID == user.ID {
+ return u, k, true
+ }
+ }
+ return nil, nil, false
+}
+
+// decodeSubsonicPassword 解析 p 参数,兼容 Subsonic 的 enc: 编码
+func decodeSubsonicPassword(p string) string {
+ p = strings.TrimSpace(p)
+ if strings.HasPrefix(p, "enc:") {
+ if raw, err := hex.DecodeString(strings.TrimPrefix(p, "enc:")); err == nil {
+ return string(raw)
+ }
+ }
+ return p
+}
+
+func subAuthByAppKey(key string) (*model.User, *model.MediaAppKey, bool) {
+ return LookUpAppKeyUser(key)
+}
+
+// subsonicServerVersion 返回给 Subsonic 客户端的服务器版本(仅用于展示)
+func subsonicServerVersion() string {
+ if conf.Version == "" {
+ return "OpenList"
+ }
+ return conf.Version
+}
+
+func subRespond(c *gin.Context, resp *subsonicResponse) {
+ resp.Status = "ok"
+ resp.Version = subsonicAPIVersion
+ resp.Type = "OpenList"
+ resp.ServerVersion = subsonicServerVersion()
+ resp.OpenSubsonic = true
+ format := strings.ToLower(subParam(c, "f"))
+ switch format {
+ case "json":
+ c.JSON(200, gin.H{"subsonic-response": resp})
+ case "jsonp":
+ callback := subParam(c, "callback")
+ if !validJsonpCallback(callback) {
+ c.JSON(200, gin.H{"subsonic-response": &subsonicResponse{
+ Status: "failed", Version: subsonicAPIVersion, Type: "OpenList",
+ ServerVersion: subsonicServerVersion(), OpenSubsonic: true,
+ Error: &subsonicError{Code: 0, Message: "invalid callback parameter"},
+ }})
+ return
+ }
+ body, _ := json.Marshal(gin.H{"subsonic-response": resp})
+ c.Header("Content-Type", "application/javascript; charset=utf-8")
+ c.String(200, "%s(%s)", callback, string(body))
+ default:
+ c.XML(200, resp)
+ }
+}
+
+func subError(c *gin.Context, code int, message string) {
+ resp := &subsonicResponse{
+ Status: "failed",
+ Version: subsonicAPIVersion,
+ Type: "OpenList",
+ ServerVersion: subsonicServerVersion(),
+ OpenSubsonic: true,
+ Error: &subsonicError{Code: code, Message: message},
+ }
+ format := strings.ToLower(subParam(c, "f"))
+ switch format {
+ case "json":
+ c.JSON(200, gin.H{"subsonic-response": resp})
+ case "jsonp":
+ body, _ := json.Marshal(gin.H{"subsonic-response": resp})
+ c.String(200, "%s(%s)", subParam(c, "callback"), string(body))
+ default:
+ c.XML(200, resp)
+ }
+}
+
+func validJsonpCallback(s string) bool {
+ if s == "" {
+ return false
+ }
+ for i, r := range s {
+ if r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r == '_' || r == '$' {
+ continue
+ }
+ if i > 0 && (r >= '0' && r <= '9' || r == '.') {
+ continue
+ }
+ return false
+ }
+ return true
+}
+
+// ==================== 数据映射 ====================
+
+func subUserOf(u *model.User) *subsonicUser {
+ return &subsonicUser{
+ Username: u.Username,
+ ScrobblingEnabled: true,
+ AdminRole: u.IsAdmin(),
+ DownloadRole: true,
+ PlaylistRole: true,
+ StreamRole: true,
+ CoverArtRole: true,
+ }
+}
+
+func (l *embyLibrary) subChild(it *model.MediaItem) subsonicChild {
+ title := it.ScrapedName
+ if title == "" {
+ title = strings.TrimSuffix(it.FileName, stdpath.Ext(it.FileName))
+ }
+ album := it.AlbumName
+ if album == "" {
+ album = "Unknown Album"
+ }
+ artist := albumArtistOf(it)
+ image := fmt.Sprintf("al-%s", albumKeyOf(it))
+ year := 0
+ if len(it.ReleaseDate) >= 4 {
+ year, _ = strconv.Atoi(it.ReleaseDate[:4])
+ }
+ userData := l.userDataOf(it.ID, 0)
+ c := subsonicChild{
+ Id: fmt.Sprintf("so-%d", it.ID),
+ Parent: image,
+ IsDir: false,
+ Title: title,
+ Album: album,
+ Artist: artist,
+ Track: it.TrackNumber,
+ Year: year,
+ Genre: it.Genre,
+ CoverArt: image,
+ Size: it.FileSize,
+ ContentType: it.MimeType,
+ Suffix: strings.TrimPrefix(strings.ToLower(stdpath.Ext(it.FileName)), "."),
+ Duration: it.Duration,
+ Path: MediaItemFilePath(it),
+ AlbumId: image,
+ ArtistId: "ar-" + artistKeyOf(it),
+ Type: "music",
+ PlayCount: userData.PlayCount,
+ Created: it.CreatedAt.UTC().Format("2006-01-02T15:04:05.000Z"),
+ UserRating: userDataRating(it.ID, l),
+ }
+ if userData.IsFavorite {
+ c.Starred = it.UpdatedAt.UTC().Format("2006-01-02T15:04:05.000Z")
+ }
+ return c
+}
+
+func userDataRating(itemID uint, l *embyLibrary) int {
+ if l == nil {
+ return 0
+ }
+ if d, ok := l.userData[[2]uint{itemID, 0}]; ok {
+ return d.Rating
+ }
+ return 0
+}
+
+func (l *embyLibrary) subAlbum(al *embyAlbum) subsonicAlbum {
+ duration := 0
+ for i := range al.Items {
+ duration += al.Items[i].Duration
+ }
+ created := time.Now().UTC().Format("2006-01-02T15:04:05.000Z")
+ if len(al.Items) > 0 {
+ created = al.Items[0].CreatedAt.UTC().Format("2006-01-02T15:04:05.000Z")
+ }
+ return subsonicAlbum{
+ Id: "al-" + al.Key,
+ Name: al.Name,
+ Artist: al.Artist,
+ ArtistId: "ar-" + artistKeyOf(&al.Items[0]),
+ CoverArt: "al-" + al.Key,
+ SongCount: len(al.Items),
+ Duration: duration,
+ Created: created,
+ Year: al.Year,
+ }
+}
+
+func (l *embyLibrary) subAlbumWithSongs(al *embyAlbum) subsonicAlbum {
+ out := l.subAlbum(al)
+ for i := range al.Items {
+ out.Song = append(out.Song, l.subChild(&al.Items[i]))
+ }
+ return out
+}
+
+func (l *embyLibrary) subArtist(key, name string) subsonicArtist {
+ out := subsonicArtist{Id: "ar-" + key, Name: name}
+ for _, al := range l.albums() {
+ if artistKeyOf(&al.Items[0]) != key {
+ continue
+ }
+ out.Album = append(out.Album, l.subAlbum(al))
+ }
+ out.AlbumCount = len(out.Album)
+ if len(out.Album) > 0 {
+ out.CoverArt = out.Album[0].CoverArt
+ }
+ return out
+}
+
+func (l *embyLibrary) subArtists() []subsonicArtist {
+ seen := map[string]bool{}
+ var out []subsonicArtist
+ for i := range l.music {
+ key := artistKeyOf(&l.music[i])
+ if seen[key] {
+ continue
+ }
+ seen[key] = true
+ out = append(out, l.subArtist(key, albumArtistOf(&l.music[i])))
+ }
+ sort.Slice(out, func(i, j int) bool { return strings.ToLower(out[i].Name) < strings.ToLower(out[j].Name) })
+ return out
+}
diff --git a/server/handles/subsonic_handlers.go b/server/handles/subsonic_handlers.go
new file mode 100644
index 00000000..81ba06e8
--- /dev/null
+++ b/server/handles/subsonic_handlers.go
@@ -0,0 +1,667 @@
+package handles
+
+import (
+ "fmt"
+ "math/rand"
+ "net/http"
+ stdpath "path"
+ "sort"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/OpenListTeam/OpenList/v4/internal/conf"
+ "github.com/OpenListTeam/OpenList/v4/internal/db"
+ "github.com/OpenListTeam/OpenList/v4/internal/media"
+ "github.com/OpenListTeam/OpenList/v4/internal/model"
+ "github.com/OpenListTeam/OpenList/v4/internal/setting"
+ "github.com/gin-gonic/gin"
+)
+
+// ==================== 浏览 ====================
+
+func subInitial(name string) string {
+ if name == "" {
+ return "#"
+ }
+ r := strings.ToUpper(string([]rune(name)[0]))
+ if r < "A" || r > "Z" {
+ return "#"
+ }
+ return r
+}
+
+func subGetIndexes(c *gin.Context, user *model.User) {
+ lib := loadEmbyLibrary(user)
+ artists := lib.subArtists()
+ groups := map[string][]subsonicChild{}
+ var keys []string
+ for _, a := range artists {
+ idx := subInitial(a.Name)
+ if _, ok := groups[idx]; !ok {
+ keys = append(keys, idx)
+ }
+ groups[idx] = append(groups[idx], subsonicChild{
+ Id: a.Id,
+ Title: a.Name,
+ IsDir: true,
+ Album: a.Name,
+ })
+ }
+ sort.Strings(keys)
+ out := &subsonicIndexes{IgnoredArticles: "The El La Los Las Le Les", LastModified: time.Now().UnixMilli()}
+ for _, k := range keys {
+ out.Index = append(out.Index, subsonicIndex{Name: k, Artist: groups[k]})
+ }
+ subRespond(c, &subsonicResponse{Indexes: out})
+}
+
+func subGetArtists(c *gin.Context, user *model.User) {
+ lib := loadEmbyLibrary(user)
+ artists := lib.subArtists()
+ groups := map[string][]subsonicArtist{}
+ var keys []string
+ for _, a := range artists {
+ idx := subInitial(a.Name)
+ if _, ok := groups[idx]; !ok {
+ keys = append(keys, idx)
+ }
+ groups[idx] = append(groups[idx], a)
+ }
+ sort.Strings(keys)
+ out := &subsonicArtists{IgnoredArticles: "The El La Los Las Le Les"}
+ for _, k := range keys {
+ out.Index = append(out.Index, subsonicIndex2{Name: k, Artist: groups[k]})
+ }
+ subRespond(c, &subsonicResponse{Artists: out})
+}
+
+func subGetArtist(c *gin.Context, user *model.User) {
+ id := subParam(c, "id")
+ if !strings.HasPrefix(id, "ar-") {
+ subError(c, 70, "artist not found")
+ return
+ }
+ lib := loadEmbyLibrary(user)
+ key := strings.TrimPrefix(id, "ar-")
+ for _, a := range lib.subArtists() {
+ if a.Id == id {
+ _ = key
+ subRespond(c, &subsonicResponse{Artist: &a})
+ return
+ }
+ }
+ subError(c, 70, "artist not found")
+}
+
+func subGetAlbum(c *gin.Context, user *model.User) {
+ id := subParam(c, "id")
+ if !strings.HasPrefix(id, "al-") {
+ subError(c, 70, "album not found")
+ return
+ }
+ lib := loadEmbyLibrary(user)
+ al := lib.albumByKey(strings.TrimPrefix(id, "al-"))
+ if al == nil {
+ subError(c, 70, "album not found")
+ return
+ }
+ out := lib.subAlbumWithSongs(al)
+ subRespond(c, &subsonicResponse{Album: &out})
+}
+
+func subGetSong(c *gin.Context, user *model.User) {
+ id := subParam(c, "id")
+ lib := loadEmbyLibrary(user)
+ it, ok := lib.songByID(id)
+ if !ok {
+ subError(c, 70, "song not found")
+ return
+ }
+ child := lib.subChild(it)
+ subRespond(c, &subsonicResponse{Song: &child})
+}
+
+func subGetMusicDirectory(c *gin.Context, user *model.User) {
+ id := subParam(c, "id")
+ lib := loadEmbyLibrary(user)
+ switch {
+ case strings.HasPrefix(id, "ar-"):
+ key := strings.TrimPrefix(id, "ar-")
+ dir := &subsonicDirectory{Id: id, Name: id}
+ for _, al := range lib.albums() {
+ if artistKeyOf(&al.Items[0]) != key {
+ continue
+ }
+ a := lib.subAlbum(al)
+ dir.Child = append(dir.Child, subsonicChild{
+ Id: a.Id, IsDir: true, Title: a.Name, Album: a.Name,
+ Artist: a.Artist, CoverArt: a.CoverArt, Year: a.Year,
+ })
+ if dir.Name == id {
+ dir.Name = a.Artist
+ }
+ }
+ subRespond(c, &subsonicResponse{MusicDirectory: dir})
+ case strings.HasPrefix(id, "al-"):
+ al := lib.albumByKey(strings.TrimPrefix(id, "al-"))
+ if al == nil {
+ subError(c, 70, "directory not found")
+ return
+ }
+ dir := &subsonicDirectory{Id: id, Name: al.Name, Parent: "ar-" + artistKeyOf(&al.Items[0])}
+ for i := range al.Items {
+ dir.Child = append(dir.Child, lib.subChild(&al.Items[i]))
+ }
+ subRespond(c, &subsonicResponse{MusicDirectory: dir})
+ default:
+ subError(c, 70, "directory not found")
+ }
+}
+
+func subGetAlbumList(c *gin.Context, user *model.User, legacy bool) {
+ lib := loadEmbyLibrary(user)
+ listType := strings.ToLower(subParam(c, "type"))
+ albums := lib.albums()
+ switch listType {
+ case "newest":
+ sort.SliceStable(albums, func(i, j int) bool { return albums[i].Items[0].CreatedAt.After(albums[j].Items[0].CreatedAt) })
+ case "recent":
+ sort.SliceStable(albums, func(i, j int) bool { return albums[i].Items[0].UpdatedAt.After(albums[j].Items[0].UpdatedAt) })
+ case "alphabeticalbyartist":
+ sort.SliceStable(albums, func(i, j int) bool {
+ if albums[i].Artist == albums[j].Artist {
+ return strings.ToLower(albums[i].Name) < strings.ToLower(albums[j].Name)
+ }
+ return strings.ToLower(albums[i].Artist) < strings.ToLower(albums[j].Artist)
+ })
+ case "random":
+ rand.Shuffle(len(albums), func(i, j int) { albums[i], albums[j] = albums[j], albums[i] })
+ case "byyear":
+ from := subIntParam(c, "fromYear", 0)
+ to := subIntParam(c, "toYear", 0)
+ var filtered []*embyAlbum
+ for _, al := range albums {
+ if from > 0 && al.Year < from {
+ continue
+ }
+ if to > 0 && al.Year > to {
+ continue
+ }
+ filtered = append(filtered, al)
+ }
+ albums = filtered
+ default:
+ sort.SliceStable(albums, func(i, j int) bool { return strings.ToLower(albums[i].Name) < strings.ToLower(albums[j].Name) })
+ }
+ size := subIntParam(c, "size", 50)
+ offset := subIntParam(c, "offset", 0)
+ if offset > len(albums) {
+ offset = len(albums)
+ }
+ end := offset + size
+ if end > len(albums) {
+ end = len(albums)
+ }
+ out := &subsonicAlbumList{}
+ for _, al := range albums[offset:end] {
+ out.Album = append(out.Album, lib.subAlbum(al))
+ }
+ if legacy {
+ subRespond(c, &subsonicResponse{AlbumList: out})
+ return
+ }
+ subRespond(c, &subsonicResponse{AlbumList2: out})
+}
+
+func (l *embyLibrary) allSongs() []subsonicChild {
+ out := make([]subsonicChild, 0, len(l.music))
+ for i := range l.music {
+ out = append(out, l.subChild(&l.music[i]))
+ }
+ return out
+}
+
+func subGetRandomSongs(c *gin.Context, user *model.User) {
+ lib := loadEmbyLibrary(user)
+ songs := lib.allSongs()
+ rand.Shuffle(len(songs), func(i, j int) { songs[i], songs[j] = songs[j], songs[i] })
+ size := subIntParam(c, "size", 10)
+ if size > len(songs) {
+ size = len(songs)
+ }
+ subRespond(c, &subsonicResponse{RandomSongs: &subsonicSongs{Song: songs[:size]}})
+}
+
+func subGetSongsByGenre(c *gin.Context, user *model.User) {
+ lib := loadEmbyLibrary(user)
+ genre := strings.ToLower(subParam(c, "genre"))
+ var songs []subsonicChild
+ for i := range lib.music {
+ if genre != "" && !strings.Contains(strings.ToLower(lib.music[i].Genre), genre) {
+ continue
+ }
+ songs = append(songs, lib.subChild(&lib.music[i]))
+ }
+ subRespond(c, &subsonicResponse{SongsByGenre: &subsonicSongs{Song: songs}})
+}
+
+func subSearch(c *gin.Context, user *model.User, legacy bool) {
+ lib := loadEmbyLibrary(user)
+ term := strings.ToLower(strings.TrimSpace(subParam(c, "query")))
+ if term == "" {
+ term = strings.ToLower(strings.TrimSpace(subParam(c, "any")))
+ }
+ artistCount := subIntParam(c, "artistCount", 20)
+ albumCount := subIntParam(c, "albumCount", 20)
+ songCount := subIntParam(c, "songCount", 20)
+
+ out := &subsonicSearchResult{}
+ for _, a := range lib.subArtists() {
+ if len(out.Artist) >= artistCount {
+ break
+ }
+ if term == "" || strings.Contains(strings.ToLower(a.Name), term) {
+ out.Artist = append(out.Artist, a)
+ }
+ }
+ for _, al := range lib.albums() {
+ if len(out.Album) >= albumCount {
+ break
+ }
+ if term == "" || strings.Contains(strings.ToLower(al.Name), term) ||
+ strings.Contains(strings.ToLower(al.Artist), term) {
+ out.Album = append(out.Album, lib.subAlbum(al))
+ }
+ }
+ for i := range lib.music {
+ if len(out.Song) >= songCount {
+ break
+ }
+ name := lib.music[i].ScrapedName
+ if name == "" {
+ name = lib.music[i].FileName
+ }
+ if term == "" || strings.Contains(strings.ToLower(name), term) {
+ out.Song = append(out.Song, lib.subChild(&lib.music[i]))
+ }
+ }
+ out.Total = len(out.Artist) + len(out.Album) + len(out.Song)
+ if legacy {
+ subRespond(c, &subsonicResponse{SearchResult2: out})
+ return
+ }
+ subRespond(c, &subsonicResponse{SearchResult3: out})
+}
+
+func subGetStarred(c *gin.Context, user *model.User, legacy bool) {
+ lib := loadEmbyLibrary(user)
+ out := &subsonicStarred{}
+ for i := range lib.music {
+ if d, ok := lib.userData[[2]uint{lib.music[i].ID, 0}]; ok && d.IsFavorite {
+ out.Song = append(out.Song, lib.subChild(&lib.music[i]))
+ }
+ }
+ if legacy {
+ subRespond(c, &subsonicResponse{Starred: out})
+ return
+ }
+ subRespond(c, &subsonicResponse{Starred2: out})
+}
+
+func subGetGenres(c *gin.Context, user *model.User) {
+ lib := loadEmbyLibrary(user)
+ counts := map[string]int{}
+ for i := range lib.music {
+ for _, g := range strings.Split(lib.music[i].Genre, ",") {
+ g = strings.TrimSpace(g)
+ if g == "" {
+ continue
+ }
+ counts[g]++
+ }
+ }
+ out := &subsonicGenres{}
+ for g, n := range counts {
+ out.Genre = append(out.Genre, subsonicGenre{Value: g, SongCount: n})
+ }
+ sort.Slice(out.Genre, func(i, j int) bool { return out.Genre[i].Value < out.Genre[j].Value })
+ subRespond(c, &subsonicResponse{Genres: out})
+}
+
+// ==================== 播放 ====================
+
+func subStream(c *gin.Context, user *model.User) {
+ id := subParam(c, "id")
+ lib := loadEmbyLibrary(user)
+ target, ok := lib.playTarget(id)
+ if !ok {
+ subError(c, 70, "song not found")
+ return
+ }
+ if setting.GetBool(conf.MediaSubsonicProxyStream) {
+ c.Redirect(http.StatusFound, SignedProxyURL(c, target.RawPath))
+ return
+ }
+ c.Redirect(http.StatusFound, SignedStreamURL(c, target.RawPath))
+}
+
+func subGetCoverArt(c *gin.Context, user *model.User) {
+ id := subParam(c, "id")
+ lib := loadEmbyLibrary(user)
+ var item *model.MediaItem
+ switch {
+ case strings.HasPrefix(id, "so-"):
+ item, _ = lib.songByID(id)
+ case strings.HasPrefix(id, "al-"):
+ if al := lib.albumByKey(strings.TrimPrefix(id, "al-")); al != nil && len(al.Items) > 0 {
+ item = &al.Items[0]
+ }
+ case strings.HasPrefix(id, "ar-"):
+ key := strings.TrimPrefix(id, "ar-")
+ for i := range lib.music {
+ if artistKeyOf(&lib.music[i]) == key {
+ item = &lib.music[i]
+ break
+ }
+ }
+ case strings.HasPrefix(id, "mv-"), strings.HasPrefix(id, "ep-"):
+ if it, ok := lib.resolveItem(id); ok {
+ _ = it
+ if p, ok := lib.playTarget(id); ok {
+ item = p.Item
+ }
+ }
+ }
+ if item == nil {
+ c.Status(404)
+ return
+ }
+ serveEmbyCover(c, item)
+}
+
+func subGetLyrics(c *gin.Context, user *model.User) {
+ artist := subParam(c, "artist")
+ title := subParam(c, "title")
+ lib := loadEmbyLibrary(user)
+ for i := range lib.music {
+ it := &lib.music[i]
+ name := it.ScrapedName
+ if name == "" {
+ name = strings.TrimSuffix(it.FileName, stdpath.Ext(it.FileName))
+ }
+ if title != "" && !strings.EqualFold(title, name) {
+ continue
+ }
+ if artist != "" && !strings.EqualFold(artist, albumArtistOf(it)) {
+ continue
+ }
+ subRespond(c, &subsonicResponse{Lyrics: &subsonicLyrics{
+ Artist: albumArtistOf(it), Title: name, Value: it.Lyrics,
+ }})
+ return
+ }
+ subError(c, 70, "lyrics not found")
+}
+
+func subGetLyricsBySongID(c *gin.Context, user *model.User) {
+ id := subParam(c, "id")
+ lib := loadEmbyLibrary(user)
+ it, ok := lib.songByID(id)
+ if !ok || it.Lyrics == "" {
+ subError(c, 70, "lyrics not found")
+ return
+ }
+ name := it.ScrapedName
+ if name == "" {
+ name = strings.TrimSuffix(it.FileName, stdpath.Ext(it.FileName))
+ }
+ lines := parseLrc(it.Lyrics)
+ structured := subsonicStructuredLyrics{
+ DisplayArtist: albumArtistOf(it),
+ DisplayTitle: name,
+ Lang: "und",
+ Synced: len(lines) > 0,
+ }
+ for _, l := range lines {
+ structured.Line = append(structured.Line, subsonicLyricLine{
+ Start: l["Start"].(int64),
+ Value: l["Text"].(string),
+ })
+ }
+ subRespond(c, &subsonicResponse{LyricsList: &subsonicLyricsList{
+ StructuredLyrics: []subsonicStructuredLyrics{structured},
+ }})
+}
+
+// ==================== 状态 ====================
+
+func subStar(c *gin.Context, user *model.User, star bool) {
+ id := subParam(c, "id")
+ lib := loadEmbyLibrary(user)
+ target, ok := lib.playTarget(id)
+ if !ok {
+ if album := strings.HasPrefix(id, "al-"); album {
+ subError(c, 70, "not supported")
+ return
+ }
+ subError(c, 70, "song not found")
+ return
+ }
+ d, err := db.GetMediaUserData(user.ID, target.ItemID, target.EpisodeNo)
+ if err != nil || d == nil {
+ d = &model.MediaUserData{UserID: user.ID, ItemID: target.ItemID, EpisodeIndex: target.EpisodeNo}
+ }
+ d.IsFavorite = star
+ if err := db.SaveMediaUserData(d); err != nil {
+ subError(c, 0, err.Error())
+ return
+ }
+ subRespond(c, &subsonicResponse{})
+}
+
+func subSetRating(c *gin.Context, user *model.User) {
+ id := subParam(c, "id")
+ rating := subIntParam(c, "rating", 0)
+ lib := loadEmbyLibrary(user)
+ target, ok := lib.playTarget(id)
+ if !ok {
+ subError(c, 70, "song not found")
+ return
+ }
+ d, err := db.GetMediaUserData(user.ID, target.ItemID, target.EpisodeNo)
+ if err != nil || d == nil {
+ d = &model.MediaUserData{UserID: user.ID, ItemID: target.ItemID, EpisodeIndex: target.EpisodeNo}
+ }
+ d.Rating = rating
+ _ = db.SaveMediaUserData(d)
+ subRespond(c, &subsonicResponse{})
+}
+
+func subScrobble(c *gin.Context, user *model.User) {
+ id := subParam(c, "id")
+ submission := strings.EqualFold(subParam(c, "submission"), "true")
+ lib := loadEmbyLibrary(user)
+ target, ok := lib.playTarget(id)
+ if !ok {
+ subRespond(c, &subsonicResponse{})
+ return
+ }
+ d, err := db.GetMediaUserData(user.ID, target.ItemID, target.EpisodeNo)
+ if err != nil || d == nil {
+ d = &model.MediaUserData{UserID: user.ID, ItemID: target.ItemID, EpisodeIndex: target.EpisodeNo}
+ }
+ now := time.Now()
+ d.LastPlayedAt = &now
+ if submission {
+ d.Played = true
+ d.PlayCount++
+ }
+ _ = db.SaveMediaUserData(d)
+ subRespond(c, &subsonicResponse{})
+}
+
+// ==================== 播放列表 ====================
+
+func subGetPlaylists(c *gin.Context, user *model.User) {
+ list, err := db.ListMediaPlaylists(user.ID)
+ if err != nil {
+ subError(c, 0, err.Error())
+ return
+ }
+ out := &subsonicPlaylists{}
+ for i := range list {
+ out.Playlist = append(out.Playlist, subPlaylistOf(&list[i]))
+ }
+ subRespond(c, &subsonicResponse{Playlists: out})
+}
+
+func subPlaylistOf(p *model.MediaPlaylist) subsonicPlaylist {
+ return subsonicPlaylist{
+ Id: strconv.FormatUint(uint64(p.ID), 10),
+ Name: p.Name,
+ Comment: p.Comment,
+ Public: p.Public,
+ SongCount: p.ItemCount,
+ Created: p.CreatedAt.UTC().Format("2006-01-02T15:04:05.000Z"),
+ }
+}
+
+func subGetPlaylist(c *gin.Context, user *model.User) {
+ id, err := strconv.ParseUint(subParam(c, "id"), 10, 64)
+ if err != nil {
+ subError(c, 70, "playlist not found")
+ return
+ }
+ p, err := db.GetMediaPlaylist(uint(id))
+ if err != nil || (p.UserID != user.ID && !user.IsAdmin()) {
+ subError(c, 70, "playlist not found")
+ return
+ }
+ out := subPlaylistOf(p)
+ items, _ := db.ListMediaPlaylistItems(p.ID)
+ lib := loadEmbyLibrary(user)
+ for _, it := range items {
+ if song, ok := lib.songByID(fmt.Sprintf("so-%d", it.ItemID)); ok {
+ out.Entry = append(out.Entry, lib.subChild(song))
+ }
+ }
+ out.SongCount = len(out.Entry)
+ subRespond(c, &subsonicResponse{Playlist: &out})
+}
+
+func subCreatePlaylist(c *gin.Context, user *model.User) {
+ name := subParam(c, "name")
+ if name == "" {
+ subError(c, 10, "missing name")
+ return
+ }
+ p := &model.MediaPlaylist{UserID: user.ID, Name: name}
+ if err := db.CreateMediaPlaylist(p); err != nil {
+ subError(c, 0, err.Error())
+ return
+ }
+ subError(c, 0, "created:"+strconv.FormatUint(uint64(p.ID), 10))
+}
+
+func subUpdatePlaylist(c *gin.Context, user *model.User) {
+ id, err := strconv.ParseUint(subParam(c, "playlistId"), 10, 64)
+ if err != nil {
+ subError(c, 10, "missing playlistId")
+ return
+ }
+ p, err := db.GetMediaPlaylist(uint(id))
+ if err != nil || (p.UserID != user.ID && !user.IsAdmin()) {
+ subError(c, 70, "playlist not found")
+ return
+ }
+ if name := subParam(c, "name"); name != "" {
+ p.Name = name
+ }
+ if comment := subParam(c, "comment"); comment != "" {
+ p.Comment = comment
+ }
+ if pub := subParam(c, "public"); pub != "" {
+ p.Public = strings.EqualFold(pub, "true")
+ }
+ _ = db.UpdateMediaPlaylist(p)
+ subRespond(c, &subsonicResponse{})
+}
+
+func subDeletePlaylist(c *gin.Context, user *model.User) {
+ id, err := strconv.ParseUint(subParam(c, "id"), 10, 64)
+ if err != nil {
+ subError(c, 10, "missing id")
+ return
+ }
+ p, err := db.GetMediaPlaylist(uint(id))
+ if err != nil || (p.UserID != user.ID && !user.IsAdmin()) {
+ subError(c, 70, "playlist not found")
+ return
+ }
+ _ = db.DeleteMediaPlaylist(p.ID)
+ subRespond(c, &subsonicResponse{})
+}
+
+// ==================== 扫描 ====================
+
+func subSongCount(user *model.User) int64 {
+ lib := loadEmbyLibrary(user)
+ return int64(len(lib.music))
+}
+
+func subStartScan(c *gin.Context) {
+ for _, mt := range MediaTypeEnabledList() {
+ cfg, err := db.GetMediaConfig(mt)
+ if err != nil || cfg == nil {
+ continue
+ }
+ media.ScanMedia(cfg)
+ }
+ subRespond(c, &subsonicResponse{ScanStatus: &subsonicScanStatus{Scanning: true}})
+}
+
+// ==================== 播放队列(内存) ====================
+
+var (
+ subQueueMu sync.Mutex
+ subQueues = map[uint]*subsonicPlayQueue{}
+)
+
+func subSavePlayQueue(c *gin.Context, user *model.User) {
+ ids := c.Query("id")
+ if ids == "" {
+ _ = c.Request.ParseForm()
+ ids = c.Request.PostForm.Get("id")
+ }
+ q := &subsonicPlayQueue{
+ Current: subParam(c, "current"),
+ Position: int64(subIntParam(c, "position", 0)),
+ Username: user.Username,
+ Changed: time.Now().UTC().Format("2006-01-02T15:04:05.000Z"),
+ }
+ if ids != "" {
+ lib := loadEmbyLibrary(user)
+ for _, id := range strings.Split(ids, ",") {
+ if song, ok := lib.songByID(strings.TrimSpace(id)); ok {
+ q.Entry = append(q.Entry, lib.subChild(song))
+ }
+ }
+ }
+ subQueueMu.Lock()
+ subQueues[user.ID] = q
+ subQueueMu.Unlock()
+ subRespond(c, &subsonicResponse{})
+}
+
+func subLoadPlayQueue(user *model.User) *subsonicPlayQueue {
+ subQueueMu.Lock()
+ defer subQueueMu.Unlock()
+ if q, ok := subQueues[user.ID]; ok {
+ return q
+ }
+ return &subsonicPlayQueue{Username: user.Username}
+}
+
+func subNowPlayingEntries() []subsonicChild { return []subsonicChild{} }
diff --git a/server/handles/torrent.go b/server/handles/torrent.go
index 8b6ee1b6..99f539c5 100644
--- a/server/handles/torrent.go
+++ b/server/handles/torrent.go
@@ -1,29 +1,256 @@
package handles
import (
+ "bytes"
+ "context"
"encoding/base64"
+ "encoding/json"
"fmt"
"io"
+ "net/http"
+ "net/url"
+ stdpath "path"
"strings"
+ "time"
- _189pc "github.com/OpenListTeam/OpenList/v4/drivers/189pc"
"github.com/OpenListTeam/OpenList/v4/internal/conf"
+ "github.com/OpenListTeam/OpenList/v4/internal/driver"
"github.com/OpenListTeam/OpenList/v4/internal/errs"
+ "github.com/OpenListTeam/OpenList/v4/internal/fs"
"github.com/OpenListTeam/OpenList/v4/internal/model"
+ "github.com/OpenListTeam/OpenList/v4/internal/offline_download/tool"
"github.com/OpenListTeam/OpenList/v4/internal/op"
+ "github.com/OpenListTeam/OpenList/v4/internal/setting"
+ "github.com/OpenListTeam/OpenList/v4/internal/stream"
"github.com/OpenListTeam/OpenList/v4/pkg/http_range"
"github.com/OpenListTeam/OpenList/v4/pkg/torrent"
+ "github.com/OpenListTeam/OpenList/v4/pkg/utils"
+ hash_extend "github.com/OpenListTeam/OpenList/v4/pkg/utils/hash"
"github.com/OpenListTeam/OpenList/v4/server/common"
"github.com/gin-gonic/gin"
"github.com/pkg/errors"
)
+// seedRapidUploaderFor probes a storage driver for the generic
+// driver.SeedRapidUploader capability. It returns nil when the driver does not
+// support hash-driven rapid upload (秒传/CAS).
+func seedRapidUploaderFor(storage driver.Driver) driver.SeedRapidUploader {
+ if storage == nil {
+ return nil
+ }
+ if u, ok := storage.(driver.SeedRapidUploader); ok {
+ return u
+ }
+ return nil
+}
+
+// buildSeedRapidUploadRequest converts a parsed torrent's CAS metadata into a
+// driver.SeedRapidUploadRequest. open lazily provides the file content and may
+// be nil when no content source is available.
+func buildSeedRapidUploadRequest(t *torrent.Torrent, open func() (model.FileStreamer, error)) *driver.SeedRapidUploadRequest {
+ if t == nil {
+ return nil
+ }
+
+ wholeHashes := make(map[*utils.HashType]string)
+ req := &driver.SeedRapidUploadRequest{
+ Name: t.Info.Name,
+ Size: t.GetTotalSize(),
+ Open: open,
+ }
+
+ if t.CAS != nil {
+ req.SliceSize = t.CAS.SliceSize
+ req.SliceMD5s = t.CAS.SliceMD5s
+ if t.CAS.FileMD5 != "" {
+ wholeHashes[utils.MD5] = strings.ToUpper(t.CAS.FileMD5)
+ }
+ }
+
+ // 从种子文件的哈希矩阵补充整文件与分片哈希。
+ //
+ // 秒传请求一次只描述一个文件:req.Size / 哈希都必须属于同一个对象。
+ // 多文件 torrent 的哈希属于不同文件,若取 Files[0] 而 Size 取总大小,
+ // 会向云端提交一个大小与哈希互相矛盾的对象。这里显式拒绝,由调用方
+ // 拆成单文件种子后逐个处理(saveSeedFilesToPath 正是这样做的)。
+ if t.OpenList != nil {
+ if len(t.OpenList.Files) > 1 {
+ return nil
+ }
+ if req.SliceSize == 0 {
+ req.SliceSize = t.OpenList.PieceSize
+ }
+ if len(t.OpenList.Files) == 1 {
+ sf := t.OpenList.Files[0]
+ if sf.Size > 0 && sf.Size != t.GetTotalSize() {
+ return nil
+ }
+ if sf.Hashes.MD5 != "" {
+ wholeHashes[utils.MD5] = strings.ToUpper(sf.Hashes.MD5)
+ }
+ if sf.Hashes.SHA1 != "" {
+ wholeHashes[utils.SHA1] = strings.ToUpper(sf.Hashes.SHA1)
+ }
+ if sf.Hashes.SHA256 != "" {
+ wholeHashes[utils.SHA256] = strings.ToUpper(sf.Hashes.SHA256)
+ }
+ if sf.Hashes.GCID != "" {
+ wholeHashes[hash_extend.GCID] = strings.ToUpper(sf.Hashes.GCID)
+ }
+ if sf.Hashes.Pieces != nil {
+ if len(req.SliceMD5s) == 0 && len(sf.Hashes.Pieces.MD5) > 0 {
+ req.SliceMD5s = sf.Hashes.Pieces.MD5
+ }
+ if len(sf.Hashes.Pieces.SHA1) > 0 {
+ req.SliceSHA1s = sf.Hashes.Pieces.SHA1
+ }
+ }
+ }
+ }
+
+ req.Whole = &utils.HashInfo{}
+ *req.Whole = utils.NewHashInfoByMap(wholeHashes)
+ return req
+}
+
+// seedFileSupportsRapidUpload reports whether a seed file carries enough hash
+// metadata for the given rapid-upload driver to attempt an instant upload.
+func seedFileSupportsRapidUpload(uploader driver.SeedRapidUploader, file torrent.SeedFile) bool {
+ if uploader == nil {
+ return false
+ }
+ hasMD5 := file.Hashes.MD5 != ""
+ hasSHA1 := file.Hashes.SHA1 != ""
+ for _, ht := range uploader.RapidHashAlgos() {
+ switch ht.Name {
+ case utils.MD5.Name:
+ if hasMD5 {
+ return true
+ }
+ case utils.SHA1.Name:
+ if hasSHA1 {
+ return true
+ }
+ }
+ }
+ return false
+}
+
// maxTorrentBase64Len is the max allowed Base64-encoded torrent size (~10MB decoded)
const maxTorrentBase64Len = 14 * 1024 * 1024
// maxTorrentGenFileSize is the max file size allowed for synchronous torrent generation (1GB)
const maxTorrentGenFileSize = 1 * 1024 * 1024 * 1024
+// maxSeedSourceRedirects bounds how many redirects a seed source fetch may follow.
+const maxSeedSourceRedirects = 3
+
+// seedSourceHTTPClient fetches seed sources without any credentials.
+//
+// Seed sources are deliberately anonymous: a direct link (/d/) or share link
+// (/sd/) embedded in a seed must stay usable from another instance that has no
+// knowledge of this instance's session, so no Authorization header, cookie or
+// signing query is ever attached here.
+//
+// The only guarantee we must hold is that the request cannot be used to reach
+// an arbitrary internal endpoint. firstUsableSeedSource/validateSeedSource only
+// pin the *initial* host, so every redirect hop is re-validated against the
+// configured site with the exact same rule. Without this, a benign-looking
+// source could 302 to a metadata/IPC endpoint and turn seed fetching into SSRF.
+var seedSourceHTTPClient = &http.Client{
+ Timeout: 5 * time.Minute,
+ CheckRedirect: func(req *http.Request, via []*http.Request) error {
+ if len(via) >= maxSeedSourceRedirects {
+ return fmt.Errorf("种子来源重定向次数过多(最多 %d 次)", maxSeedSourceRedirects)
+ }
+ if err := validateSeedHost(req.URL); err != nil {
+ return fmt.Errorf("种子来源重定向被拒绝: %w", err)
+ }
+ // Redirects must not silently drop to a weaker scheme.
+ if origin := via[0].URL.Scheme; !strings.EqualFold(req.URL.Scheme, origin) {
+ return fmt.Errorf("种子来源重定向不允许切换协议: %s -> %s", origin, req.URL.Scheme)
+ }
+ return nil
+ },
+}
+
+// seedSiteURLProvider supplies the configured seed_site_url. It is a variable so
+// tests can exercise the validation rules without a settings database; the
+// production value reads the setting on every call so config changes apply
+// without a restart.
+var seedSiteURLProvider = func() string {
+ return setting.GetStr(conf.SeedSiteURL)
+}
+
+// seedSiteConfig returns the parsed seed_site_url, or an error when it is unset
+// or malformed. All seed source validation is relative to this origin.
+func seedSiteConfig() (*url.URL, error) {
+ configured := strings.TrimSpace(seedSiteURLProvider())
+ if configured == "" {
+ return nil, fmt.Errorf("seed_site_url 未配置")
+ }
+ site, err := url.Parse(configured)
+ if err != nil {
+ return nil, fmt.Errorf("seed_site_url 解析失败: %w", err)
+ }
+ if site.Scheme == "" || site.Hostname() == "" {
+ return nil, fmt.Errorf("seed_site_url 缺少协议或主机名")
+ }
+ return site, nil
+}
+
+// sameSeedHost reports whether two URLs point at the same site.
+//
+// Comparison is on hostname plus the *effective* port (80/443 for http/https),
+// not on url.URL.Host: a configured "https://pan.example.com" and an embedded
+// "https://pan.example.com:443/..." are the same origin, and treating them as
+// different silently drops otherwise valid sources.
+func sameSeedHost(a, b *url.URL) bool {
+ if !strings.EqualFold(a.Scheme, b.Scheme) {
+ return false
+ }
+ if !strings.EqualFold(a.Hostname(), b.Hostname()) {
+ return false
+ }
+ return effectivePort(a) == effectivePort(b)
+}
+
+func effectivePort(u *url.URL) string {
+ if port := u.Port(); port != "" {
+ return port
+ }
+ switch strings.ToLower(u.Scheme) {
+ case "http":
+ return "80"
+ case "https":
+ return "443"
+ }
+ return ""
+}
+
+// validateSeedHost checks that a seed source URL belongs to the configured site
+// and carries no embedded credentials. It is shared by the pre-flight
+// validation and by the redirect guard, so both enforce identical rules.
+func validateSeedHost(u *url.URL) error {
+ if u == nil {
+ return fmt.Errorf("空 URL")
+ }
+ if u.Scheme != "http" && u.Scheme != "https" {
+ return fmt.Errorf("不支持的协议 %q", u.Scheme)
+ }
+ if u.User != nil {
+ return fmt.Errorf("种子来源不允许携带用户凭据")
+ }
+ site, err := seedSiteConfig()
+ if err != nil {
+ return err
+ }
+ if !sameSeedHost(u, site) {
+ return fmt.Errorf("种子来源主机 %q 与配置站点 %q 不一致", u.Host, site.Host)
+ }
+ return nil
+}
+
// validateParsedTorrent checks that basic torrent invariants hold.
func validateParsedTorrent(t *torrent.Torrent) error {
if len(t.Info.Pieces)%20 != 0 {
@@ -172,10 +399,14 @@ func TorrentRapidUpload(c *gin.Context) {
common.ErrorResp(c, err, 500, true)
return
}
- if !common.CanWrite(user, meta, reqPath) {
+ if (!user.CanWriteContent() && !common.CanWriteContentBypassUserPerms(meta, reqPath)) || !common.CanWrite(user, meta, reqPath) {
common.ErrorResp(c, errs.PermissionDenied, 403)
return
}
+ if len(req.TorrentData) > maxTorrentBase64Len {
+ common.ErrorStrResp(c, "torrent data exceeds the maximum size", 413)
+ return
+ }
// Base64 解码
torrentData, err := base64.StdEncoding.DecodeString(req.TorrentData)
@@ -214,15 +445,22 @@ func TorrentRapidUpload(c *gin.Context) {
return
}
- // 检查是否是天翼云 PC 驱动
- cloud189PC, ok := storage.(*_189pc.Cloud189PC)
+ // 探测目标驱动是否支持通用 CAS 秒传
+ rapid, ok := storage.(driver.SeedRapidUploader)
if !ok {
- common.ErrorResp(c, fmt.Errorf("目标存储不是天翼云PC驱动,不支持 CAS 秒传"), 400)
+ common.ErrorResp(c, fmt.Errorf("目标存储驱动 %s 不支持哈希秒传(CAS)", storage.GetStorage().Driver), 400)
return
}
// 尝试秒传
- obj, err := cloud189PC.RapidUploadFromTorrent(c.Request.Context(), dstDir, torrentData, true)
+ // 注意:overwrite 刻意固定为 true。秒传本身就是「把已存在的云端数据
+ // 挂载到目标目录」,语义上等价于覆盖,放开成用户可选没有实际意义。
+ rapidReq := buildSeedRapidUploadRequest(t, nil)
+ if rapidReq == nil {
+ common.ErrorResp(c, fmt.Errorf("该种子无法用于秒传:仅支持单文件且大小与哈希一致的种子"), 400)
+ return
+ }
+ obj, err := rapid.RapidUploadByHashes(c.Request.Context(), dstDir, rapidReq, true)
if err != nil {
common.ErrorResp(c, fmt.Errorf("秒传失败: %w", err), 400)
return
@@ -358,10 +596,10 @@ func GenerateTorrentForPath(c *gin.Context) {
return
}
- // with_cas 仅支持天翼云PC驱动
+ // with_cas 需要目标驱动支持通用哈希秒传(CAS)
if req.WithCAS {
- if _, is189pc := storage.(*_189pc.Cloud189PC); !is189pc {
- common.ErrorResp(c, fmt.Errorf("CAS 秒传扩展仅支持天翼云PC驱动"), 400)
+ if _, ok := storage.(driver.SeedRapidUploader); !ok {
+ common.ErrorResp(c, fmt.Errorf("存储驱动 %s 不支持 CAS 秒传扩展", storage.GetStorage().Driver), 400)
return
}
}
@@ -392,13 +630,15 @@ func GenerateTorrentForPath(c *gin.Context) {
defer link.Close()
// 通过 RangeReader 获取文件内容并计算哈希生成 torrent
- if link.RangeReader == nil {
- common.ErrorResp(c, fmt.Errorf("该存储不支持流式读取,无法生成 torrent(请先下载文件到本地)"), 400)
+ // 对于仅返回 URL(无 RangeReader)的驱动,GetRangeReaderFromLink 会退化为 HTTP Range 流式读取
+ rangeReader, err := stream.GetRangeReaderFromLink(obj.GetSize(), link)
+ if err != nil {
+ common.ErrorResp(c, fmt.Errorf("该存储不支持流式读取,无法生成 torrent: %w", err), 400)
return
}
// 读取整个文件
- rc, err := link.RangeReader.RangeRead(c.Request.Context(), http_range.Range{Length: obj.GetSize()})
+ rc, err := rangeReader.RangeRead(c.Request.Context(), http_range.Range{Length: obj.GetSize()})
if err != nil {
common.ErrorResp(c, fmt.Errorf("读取文件失败: %w", err), 500)
return
@@ -431,3 +671,1266 @@ func GenerateTorrentForPath(c *gin.Context) {
"with_cas": req.WithCAS,
})
}
+
+// SeedDataReq carries a bounded base64-encoded seed document.
+// SeedData is intentionally NOT bound with `required` here: some requests that
+// embed SeedDataReq (e.g. SeedCapabilityReq preflight) accept `paths` instead of
+// `seed_data`. Required-ness is enforced inside decodeSeedData for the handlers
+// that actually consume a seed document.
+type SeedDataReq struct {
+ SeedData string `json:"seed_data"`
+ Format string `json:"format"`
+ FileName string `json:"file_name"`
+}
+
+// SeedConvertReq requests a container conversion without changing metadata.
+type SeedConvertReq struct {
+ SeedDataReq
+ TargetFormat string `json:"target_format"`
+ Path string `json:"path"`
+}
+
+// SeedHashSelection controls whole-file and piece hash inclusion.
+type SeedHashSelection struct {
+ Whole bool `json:"whole"`
+ Pieces bool `json:"pieces"`
+}
+
+// SeedHashMatrix controls the optional hash metadata stored in a seed.
+type SeedHashMatrix struct {
+ MD5 SeedHashSelection `json:"md5"`
+ SHA1 SeedHashSelection `json:"sha1"`
+ SHA256 SeedHashSelection `json:"sha256"`
+}
+
+// SeedGenerateReq generates one or more seed containers for existing files.
+type SeedGenerateReq struct {
+ Paths []string `json:"paths" binding:"required"`
+ Format string `json:"format"`
+ Formats []string `json:"formats"`
+ Name string `json:"name"`
+ Comment string `json:"comment"`
+ FileComments map[string]string `json:"file_comments"`
+ HashMatrix SeedHashMatrix `json:"hash_matrix"`
+ PieceSize int64 `json:"piece_size"`
+ Trackers []string `json:"trackers"`
+ Channels []torrent.SeedChannel `json:"channels"`
+ OutputPath string `json:"output_path"`
+ SavePath string `json:"save_path"`
+ IncludeShare bool `json:"include_share"`
+ IncludeDirectSource bool `json:"include_direct_source"`
+ ShareFiles []string `json:"share_files"`
+ DirectFiles []string `json:"direct_files"`
+}
+
+// SeedCapabilityReq supports source preflight and destination import planning.
+type SeedCapabilityReq struct {
+ SeedDataReq
+ Paths []string `json:"paths"`
+ Path string `json:"path"`
+ Override string `json:"policy"`
+}
+
+// SeedUpdateChannelsReq replaces only public channel metadata.
+type SeedUpdateChannelsReq struct {
+ SeedDataReq
+ Channels []torrent.SeedChannel `json:"channels"`
+}
+
+// SeedRecalcFile maps one seed file to a server-side path for re-hashing.
+type SeedRecalcFile struct {
+ Path string `json:"path"` // seed file relative path
+ SourcePath string `json:"source_path"` // server-side readable file path
+}
+
+// SeedUpdateReq edits metadata and optionally recalculates hashes.
+type SeedUpdateReq struct {
+ SeedDataReq
+ Comment *string `json:"comment"`
+ Trackers []string `json:"trackers"`
+ Channels []torrent.SeedChannel `json:"channels"`
+ FileComments map[string]string `json:"file_comments"`
+ FileSources map[string][]torrent.SeedSource `json:"file_sources"`
+ RemoveFiles []string `json:"remove_files"`
+ Recalculate bool `json:"recalculate"`
+ RecalcFiles []SeedRecalcFile `json:"recalc_files"`
+ HashMatrix SeedHashMatrix `json:"hash_matrix"`
+ PieceSize int64 `json:"piece_size"`
+ OutputPath string `json:"output_path"`
+ SavePath string `json:"save_path"`
+ Options map[string]any `json:"options"`
+}
+
+// SeedQuickSaveReq imports selected seed files using rapid upload or an existing source URL.
+type SeedQuickSaveReq struct {
+ SeedDataReq
+ Path string `json:"path" binding:"required"`
+ Files []string `json:"files"`
+ SelectedFiles []int `json:"selected_files"`
+ Tool string `json:"tool"`
+ DeletePolicy string `json:"delete_policy"`
+ Overwrite bool `json:"overwrite"`
+ TransitPath string `json:"transit_path"`
+ UpdateChannel bool `json:"update_channel"`
+ Options map[string]any `json:"options"`
+}
+
+func decodeSeedData(req SeedDataReq) ([]byte, *torrent.Seed, string, error) {
+ if strings.TrimSpace(req.SeedData) == "" {
+ return nil, nil, "", fmt.Errorf("seed_data is required")
+ }
+ if len(req.SeedData) > maxTorrentBase64Len {
+ return nil, nil, "", fmt.Errorf("seed data is too large")
+ }
+ data, err := base64.StdEncoding.DecodeString(req.SeedData)
+ if err != nil {
+ return nil, nil, "", fmt.Errorf("invalid base64 seed data: %w", err)
+ }
+ format := strings.ToLower(strings.TrimPrefix(req.Format, "."))
+ if format == "" {
+ format = torrent.DetectFormat(req.FileName, data)
+ }
+ seed, err := torrent.DecodeSeed(data, format, torrent.DefaultParseLimits())
+ if err != nil {
+ return nil, nil, "", err
+ }
+ return data, seed, format, nil
+}
+
+// ParseSeed parses OSS, torrent or CAS data into one preview contract.
+func ParseSeed(c *gin.Context) {
+ var req SeedDataReq
+ if err := c.ShouldBindJSON(&req); err != nil {
+ common.ErrorResp(c, err, 400)
+ return
+ }
+ _, seed, format, err := decodeSeedData(req)
+ if err != nil {
+ common.ErrorResp(c, err, 400)
+ return
+ }
+ hasSource := false
+ hasRapidHashes := false
+ for _, file := range seed.Files {
+ hasSource = hasSource || firstUsableSeedSource(file) != ""
+ hasRapidHashes = hasRapidHashes || file.Hashes.MD5 != "" || file.Hashes.SHA1 != "" || file.Hashes.SHA256 != ""
+ }
+ common.SuccessResp(c, gin.H{
+ "format": format,
+ "seed": seed,
+ "files": seed.Files,
+ "total_size": seedTotalSize(seed),
+ "diagnostics": seedDiagnostics(seed),
+ "conversions": seedConversionStates(seed),
+ "capabilities": gin.H{
+ "rapid_upload": hasRapidHashes, "offline_download": hasSource || format == "torrent",
+ "transfer": hasSource, "convert": true, "edit": true, "recalculate": true,
+ },
+ "direct_preview": len(seed.Files) == 1 && setting.GetBool(conf.SeedSingleDirectPreview),
+ })
+}
+
+// UploadSeedAndParse parses a multipart seed upload with the same limits as JSON parsing.
+func UploadSeedAndParse(c *gin.Context) {
+ file, err := c.FormFile("seed")
+ if err != nil {
+ common.ErrorResp(c, err, 400)
+ return
+ }
+ if file.Size < 0 || file.Size > torrent.DefaultMaxSeedSize {
+ common.ErrorStrResp(c, "seed file is too large", 400)
+ return
+ }
+ r, err := file.Open()
+ if err != nil {
+ common.ErrorResp(c, err, 400)
+ return
+ }
+ defer r.Close()
+ data, err := io.ReadAll(io.LimitReader(r, torrent.DefaultMaxSeedSize+1))
+ if err != nil || int64(len(data)) > torrent.DefaultMaxSeedSize {
+ common.ErrorStrResp(c, "failed to read bounded seed file", 400)
+ return
+ }
+ format := torrent.DetectFormat(file.Filename, data)
+ seed, err := torrent.DecodeSeed(data, format, torrent.DefaultParseLimits())
+ if err != nil {
+ common.ErrorResp(c, err, 400)
+ return
+ }
+ common.SuccessResp(c, gin.H{
+ "format": format,
+ "seed": seed,
+ "seed_data": base64.StdEncoding.EncodeToString(data),
+ "diagnostics": seedDiagnostics(seed),
+ })
+}
+
+// ConvertSeed converts between OSS, standard torrent+x-openlist and CAS containers.
+func ConvertSeed(c *gin.Context) {
+ var req SeedConvertReq
+ if err := c.ShouldBindJSON(&req); err != nil {
+ common.ErrorResp(c, err, 400)
+ return
+ }
+ _, seed, _, err := decodeSeedData(req.SeedDataReq)
+ if err != nil {
+ common.ErrorResp(c, err, 400)
+ return
+ }
+ targetFormat := req.TargetFormat
+ if targetFormat == "" {
+ targetFormat = req.Format
+ }
+ if targetFormat == "" {
+ common.ErrorStrResp(c, "target format is required", 400)
+ return
+ }
+ diagnostics := torrent.DiagnoseConversion(seed, targetFormat)
+ if len(diagnostics) > 0 {
+ common.SuccessResp(c, gin.H{"convertible": false, "diagnostics": diagnostics})
+ return
+ }
+ data, err := torrent.EncodeSeed(seed, targetFormat)
+ if err != nil {
+ common.ErrorResp(c, err, 400)
+ return
+ }
+ format := strings.ToLower(strings.TrimPrefix(targetFormat, "."))
+ fileName := stdpath.Base(seed.Name) + "." + format
+ result := gin.H{
+ "convertible": true, "format": format, "name": fileName,
+ "data": base64.StdEncoding.EncodeToString(data), "seed_data": base64.StdEncoding.EncodeToString(data), "size": len(data),
+ }
+ if req.Path != "" {
+ user := c.Request.Context().Value(conf.UserKey).(*model.User)
+ dstDir, joinErr := user.JoinPath(req.Path)
+ if joinErr != nil {
+ common.ErrorResp(c, joinErr, 403)
+ return
+ }
+ meta, metaErr := op.GetNearestMeta(dstDir)
+ if metaErr != nil && !errors.Is(errors.Cause(metaErr), errs.MetaNotFound) {
+ common.ErrorResp(c, metaErr, 500, true)
+ return
+ }
+ if (!user.CanWriteContent() && !common.CanWriteContentBypassUserPerms(meta, dstDir)) || !common.CanWrite(user, meta, dstDir) {
+ common.ErrorResp(c, errs.PermissionDenied, 403)
+ return
+ }
+ fileStream := &stream.FileStream{Ctx: c.Request.Context(), Obj: &model.Object{Name: fileName, Size: int64(len(data)), Modified: time.Now()}, Reader: bytes.NewReader(data), Mimetype: "application/octet-stream"}
+ if err = fs.PutDirectly(c.Request.Context(), dstDir, fileStream); err != nil {
+ common.ErrorResp(c, err, 500)
+ return
+ }
+ result["path"] = stdpath.Join(req.Path, fileName)
+ }
+ common.SuccessResp(c, result)
+}
+
+// DiagnoseSeed reports conversion requirements for every supported container.
+func DiagnoseSeed(c *gin.Context) {
+ var req SeedDataReq
+ if err := c.ShouldBindJSON(&req); err != nil {
+ common.ErrorResp(c, err, 400)
+ return
+ }
+ _, seed, _, err := decodeSeedData(req)
+ if err != nil {
+ common.ErrorResp(c, err, 400)
+ return
+ }
+ common.SuccessResp(c, seedDiagnostics(seed))
+}
+
+func seedMatrixEmpty(matrix SeedHashMatrix) bool {
+ return !matrix.MD5.Whole && !matrix.MD5.Pieces && !matrix.SHA1.Whole && !matrix.SHA1.Pieces && !matrix.SHA256.Whole && !matrix.SHA256.Pieces
+}
+
+// loadSeedDefaultMatrix parses the configured default right-click hash matrix.
+func loadSeedDefaultMatrix() SeedHashMatrix {
+ var matrix SeedHashMatrix
+ if raw := strings.TrimSpace(setting.GetStr(conf.SeedDefaultMatrix)); raw != "" {
+ _ = json.Unmarshal([]byte(raw), &matrix)
+ }
+ return matrix
+}
+
+func normalizedSeedMatrix(matrix SeedHashMatrix, formats []string) SeedHashMatrix {
+ if seedMatrixEmpty(matrix) {
+ matrix = loadSeedDefaultMatrix()
+ }
+ if seedMatrixEmpty(matrix) {
+ matrix = SeedHashMatrix{
+ MD5: SeedHashSelection{Whole: true, Pieces: true}, SHA1: SeedHashSelection{Whole: true, Pieces: true},
+ SHA256: SeedHashSelection{Whole: true, Pieces: true},
+ }
+ }
+ for _, format := range formats {
+ switch format {
+ case "torrent":
+ matrix.SHA1 = SeedHashSelection{Whole: true, Pieces: true}
+ case "cas":
+ matrix.MD5 = SeedHashSelection{Whole: true, Pieces: true}
+ }
+ }
+ return matrix
+}
+
+func applySeedMatrix(file *torrent.SeedFile, matrix SeedHashMatrix) {
+ if !matrix.MD5.Whole {
+ file.Hashes.MD5 = ""
+ }
+ if !matrix.SHA1.Whole {
+ file.Hashes.SHA1 = ""
+ }
+ if !matrix.SHA256.Whole {
+ file.Hashes.SHA256 = ""
+ }
+ if file.Hashes.Pieces == nil {
+ return
+ }
+ if !matrix.MD5.Pieces {
+ file.Hashes.Pieces.MD5 = nil
+ }
+ if !matrix.SHA1.Pieces {
+ file.Hashes.Pieces.SHA1 = nil
+ }
+ if !matrix.SHA256.Pieces {
+ file.Hashes.Pieces.SHA256 = nil
+ }
+ if len(file.Hashes.Pieces.MD5) == 0 && len(file.Hashes.Pieces.SHA1) == 0 && len(file.Hashes.Pieces.SHA256) == 0 {
+ file.Hashes.Pieces = nil
+ }
+}
+
+// toSeedGenerateParams converts the HTTP request into the transport-agnostic
+// params consumed by fs.GenerateSeedArtifacts.
+func toSeedGenerateParams(req SeedGenerateReq) fs.SeedGenerateParams {
+ return fs.SeedGenerateParams{
+ Paths: req.Paths,
+ Formats: req.Formats,
+ Name: req.Name,
+ Comment: req.Comment,
+ FileComments: req.FileComments,
+ HashMatrix: fs.SeedHashMatrix{
+ MD5: fs.SeedHashSelection(req.HashMatrix.MD5),
+ SHA1: fs.SeedHashSelection(req.HashMatrix.SHA1),
+ SHA256: fs.SeedHashSelection(req.HashMatrix.SHA256),
+ },
+ PieceSize: req.PieceSize,
+ Trackers: req.Trackers,
+ Channels: req.Channels,
+ OutputPath: req.OutputPath,
+ IncludeShare: req.IncludeShare,
+ IncludeDirectSource: req.IncludeDirectSource,
+ ShareFiles: req.ShareFiles,
+ DirectFiles: req.DirectFiles,
+ }
+}
+
+// GenerateSeedForPaths reads each file once while calculating complete hashes.
+// Requests exceeding the synchronous size limit are queued as a background task.
+func GenerateSeedForPaths(c *gin.Context) {
+ user := c.Request.Context().Value(conf.UserKey).(*model.User)
+ var req SeedGenerateReq
+ if err := c.ShouldBindJSON(&req); err != nil {
+ common.ErrorResp(c, err, 400)
+ return
+ }
+ if len(req.Paths) == 0 || len(req.Paths) > torrent.DefaultMaxSeedFiles {
+ common.ErrorStrResp(c, "invalid seed file count", 400)
+ return
+ }
+ if len(req.Formats) == 0 {
+ req.Formats = []string{req.Format}
+ }
+ params := toSeedGenerateParams(req)
+ async, err := fs.SeedGenerateNeedsAsync(c.Request.Context(), user, req.Paths)
+ if err != nil {
+ common.ErrorResp(c, err, 400)
+ return
+ }
+ if async {
+ outputPath := strings.TrimSpace(req.OutputPath)
+ if outputPath == "" {
+ outputPath = strings.TrimSpace(req.SavePath)
+ }
+ if outputPath == "" {
+ common.ErrorStrResp(c, "asynchronous seed generation requires an output_path", 400)
+ return
+ }
+ params.OutputPath = outputPath
+ taskInfo, addErr := fs.AddSeedGenerateTask(c.Request.Context(), user, params)
+ if addErr != nil {
+ common.ErrorResp(c, addErr, 500)
+ return
+ }
+ common.SuccessResp(c, gin.H{"task": getTaskInfo(taskInfo), "async": true})
+ return
+ }
+ artifacts, seed, genErr := fs.GenerateSeedArtifacts(c.Request.Context(), user, params)
+ if genErr != nil {
+ status := 400
+ if errors.Is(genErr, errs.PermissionDenied) {
+ status = 403
+ }
+ common.ErrorResp(c, genErr, status)
+ return
+ }
+ common.SuccessResp(c, gin.H{"artifacts": artifacts, "seed": seed})
+}
+
+// loadSeedDefaultTrackers returns the configured default tracker list, one per line.
+func loadSeedDefaultTrackers() []string {
+ raw := strings.TrimSpace(setting.GetStr(conf.SeedDefaultTrackers))
+ if raw == "" {
+ return nil
+ }
+ var trackers []string
+ for _, line := range strings.Split(raw, "\n") {
+ if trimmed := strings.TrimSpace(line); trimmed != "" {
+ trackers = append(trackers, trimmed)
+ }
+ }
+ return trackers
+}
+
+// directSourceAvailable reports whether an unauthenticated /d/ direct source can be embedded.
+// Direct sources are only safe when the file is not encrypted and guest access is allowed.
+func directSourceAvailable(meta *model.Meta, path string) bool {
+ if setting.GetBool(conf.SignAll) {
+ return false
+ }
+ if isEncrypt(meta, path) {
+ return false
+ }
+ guest, err := op.GetGuest()
+ return err == nil && !guest.Disabled
+}
+
+// SeedCapabilities reports whether files can use hashes, source URLs or require downloading.
+func SeedCapabilities(c *gin.Context) {
+ user := c.Request.Context().Value(conf.UserKey).(*model.User)
+ var req SeedCapabilityReq
+ if err := c.ShouldBindJSON(&req); err != nil {
+ common.ErrorResp(c, err, 400)
+ return
+ }
+ if len(req.Paths) > 0 {
+ if len(req.Paths) > torrent.DefaultMaxSeedFiles {
+ common.ErrorStrResp(c, "invalid seed file count", 400)
+ return
+ }
+ files := make([]gin.H, 0, len(req.Paths))
+ var estimatedTraffic int64
+ existing := make(map[string]bool)
+ for _, requestedPath := range req.Paths {
+ fullPath, joinErr := user.JoinPath(requestedPath)
+ if joinErr != nil {
+ common.ErrorResp(c, joinErr, 403)
+ return
+ }
+ meta, metaErr := op.GetNearestMeta(fullPath)
+ if metaErr != nil && !errors.Is(errors.Cause(metaErr), errs.MetaNotFound) {
+ common.ErrorResp(c, metaErr, 500, true)
+ return
+ }
+ if !common.CanRead(user, meta, fullPath) {
+ common.ErrorResp(c, errs.PermissionDenied, 403)
+ return
+ }
+ storage, actualPath, getErr := op.GetStorageAndActualPath(fullPath)
+ if getErr != nil {
+ common.ErrorResp(c, getErr, 400)
+ return
+ }
+ obj, getErr := op.Get(c.Request.Context(), storage, actualPath)
+ if getErr != nil || obj.IsDir() {
+ common.ErrorResp(c, fmt.Errorf("seed path must be a readable file: %s", requestedPath), 400)
+ return
+ }
+ available := make([]string, 0, 3)
+ hashInfo := obj.GetHash()
+ for _, hashType := range []*utils.HashType{utils.MD5, utils.SHA1, utils.SHA256} {
+ if hashInfo.GetHash(hashType) != "" {
+ available = append(available, hashType.Name)
+ existing[hashType.Name] = true
+ }
+ }
+ requiresDownload := len(available) < 3
+ var fileTraffic int64
+ if requiresDownload {
+ fileTraffic = obj.GetSize()
+ estimatedTraffic += fileTraffic
+ }
+ // Probe whether the storage can be streamed for server-side hashing.
+ streamable := false
+ if link, _, linkErr := op.Link(c.Request.Context(), storage, actualPath, model.LinkArgs{}); linkErr == nil && link != nil {
+ if _, rrErr := stream.GetRangeReaderFromLink(obj.GetSize(), link); rrErr == nil {
+ streamable = true
+ }
+ _ = link.Close()
+ }
+ files = append(files, gin.H{
+ "path": requestedPath, "name": obj.GetName(), "size": obj.GetSize(),
+ "available_hashes": available, "requires_download": requiresDownload,
+ "requires_fetch": requiresDownload, "estimated_traffic": fileTraffic,
+ "streamable": streamable, "share_available": user.CanShare(),
+ "direct_source_available": directSourceAvailable(meta, fullPath),
+ })
+ }
+ existingHashes := make([]string, 0, len(existing))
+ for _, name := range []string{"md5", "sha1", "sha256"} {
+ if existing[name] {
+ existingHashes = append(existingHashes, name)
+ }
+ }
+ common.SuccessResp(c, gin.H{
+ "formats": gin.H{"oss": true, "torrent": true, "cas": true},
+ "files": files, "existing_hashes": existingHashes, "estimated_traffic": estimatedTraffic,
+ "default_matrix": loadSeedDefaultMatrix(), "trackers": loadSeedDefaultTrackers(),
+ })
+ return
+ }
+ _, seed, _, err := decodeSeedData(req.SeedDataReq)
+ if err != nil {
+ common.ErrorResp(c, err, 400)
+ return
+ }
+ dstPath, err := user.JoinPath(req.Path)
+ if err != nil {
+ common.ErrorResp(c, err, 403)
+ return
+ }
+ meta, err := op.GetNearestMeta(dstPath)
+ if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) {
+ common.ErrorResp(c, err, 500, true)
+ return
+ }
+ if (!user.CanWriteContent() && !common.CanWriteContentBypassUserPerms(meta, dstPath)) || !common.CanWrite(user, meta, dstPath) {
+ common.ErrorResp(c, errs.PermissionDenied, 403)
+ return
+ }
+ storage, _, err := op.GetStorageAndActualPath(dstPath)
+ if err != nil {
+ common.ErrorResp(c, err, 400)
+ return
+ }
+ rapidUploader := seedRapidUploaderFor(storage)
+ _, putURL := storage.(driver.PutURL)
+ _, putURLResult := storage.(driver.PutURLResult)
+ files := make([]gin.H, 0, len(seed.Files))
+ for _, file := range seed.Files {
+ hasCAS := seedFileSupportsRapidUpload(rapidUploader, file)
+ hasSource := firstUsableSeedSource(file) != ""
+ method := "download_required"
+ if hasCAS {
+ method = "rapid_upload"
+ } else if hasSource && (putURL || putURLResult) {
+ method = "put_url"
+ } else if hasSource {
+ method = "offline_download"
+ }
+ files = append(files, gin.H{"path": file.Path, "method": method, "requires_download": method == "download_required"})
+ }
+ globalPolicy := setting.GetStr(conf.SeedAutoGeneratePolicy, "off")
+ policy := strings.ToLower(strings.TrimSpace(req.Override))
+ if policy == "" || policy == "inherit" {
+ policy = globalPolicy
+ }
+ if policy != "on" && policy != "off" {
+ common.ErrorStrResp(c, "policy must be off, on, or inherit", 400)
+ return
+ }
+ // Describe the destination driver's rapid-transfer capability surface so the
+ // frontend can show which hashes are reusable for instant upload.
+ rapidAlgos := []string{}
+ usesPieces := false
+ if rapidUploader != nil {
+ for _, ht := range rapidUploader.RapidHashAlgos() {
+ rapidAlgos = append(rapidAlgos, ht.Name)
+ }
+ usesPieces = rapidUploader.RapidHashNeedsPieces()
+ }
+ driverSupports := gin.H{
+ "cas_rapid": rapidUploader != nil,
+ "put_url": putURL || putURLResult,
+ "offline_download": true,
+ "rapid_hash_algos": rapidAlgos,
+ "rapid_uses_pieces": usesPieces,
+ }
+ common.SuccessResp(c, gin.H{
+ "driver": storage.Config().Name, "global_policy": globalPolicy,
+ "resolved_policy": policy, "files": files, "driver_supports": driverSupports,
+ })
+}
+
+// UpdateSeedChannels updates public discovery metadata and keeps the requested container.
+func UpdateSeedChannels(c *gin.Context) {
+ UpdateSeed(c)
+}
+
+// applySeedUpdateOptions folds the frontend options passthrough into typed fields.
+func applySeedUpdateOptions(req *SeedUpdateReq) {
+ if req.Options == nil {
+ return
+ }
+ if req.Comment == nil {
+ if v, ok := req.Options["comment"].(string); ok {
+ req.Comment = &v
+ }
+ }
+ if !req.Recalculate {
+ if v, ok := req.Options["recalculate"].(bool); ok {
+ req.Recalculate = v
+ }
+ }
+}
+
+// validateSeedSource enforces the metadata contract for editable share/direct sources.
+func validateSeedSource(src torrent.SeedSource) error {
+ if src.Type != "openlist-direct" && src.Type != "openlist-share" {
+ return fmt.Errorf("unsupported seed source type %q", src.Type)
+ }
+ u, err := url.Parse(src.URL)
+ if err != nil {
+ return fmt.Errorf("invalid seed source URL for %q", src.Type)
+ }
+ if err := validateSeedHost(u); err != nil {
+ return err
+ }
+ if src.Type == "openlist-direct" && !strings.HasPrefix(u.EscapedPath(), "/d/") {
+ return fmt.Errorf("openlist-direct source URL must start with /d/")
+ }
+ if src.Type == "openlist-share" && !strings.HasPrefix(u.EscapedPath(), "/sd/") {
+ return fmt.Errorf("openlist-share source URL must start with /sd/")
+ }
+ return nil
+}
+
+// rehashSeedFile reads one server-side file and recomputes its whole and piece hashes.
+func rehashSeedFile(c *gin.Context, user *model.User, sourcePath string, pieceSize int64) (torrent.SeedFile, error) {
+ var out torrent.SeedFile
+ fullPath, err := user.JoinPath(sourcePath)
+ if err != nil {
+ return out, err
+ }
+ meta, err := op.GetNearestMeta(fullPath)
+ if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) {
+ return out, err
+ }
+ if !common.CanRead(user, meta, fullPath) {
+ return out, errs.PermissionDenied
+ }
+ storage, actualPath, err := op.GetStorageAndActualPath(fullPath)
+ if err != nil {
+ return out, err
+ }
+ obj, err := op.Get(c.Request.Context(), storage, actualPath)
+ if err != nil || obj.IsDir() {
+ return out, fmt.Errorf("recalculate path must be a readable file: %s", sourcePath)
+ }
+ if obj.GetSize() < 0 || obj.GetSize() > maxTorrentGenFileSize {
+ return out, fmt.Errorf("recalculate file exceeds 1GB limit: %s", sourcePath)
+ }
+ link, _, err := op.Link(c.Request.Context(), storage, actualPath, model.LinkArgs{})
+ if err != nil {
+ return out, fmt.Errorf("storage cannot stream %s: %v", sourcePath, err)
+ }
+ rangeReader, err := stream.GetRangeReaderFromLink(obj.GetSize(), link)
+ if err != nil {
+ return out, fmt.Errorf("storage cannot stream %s", sourcePath)
+ }
+ rc, err := rangeReader.RangeRead(c.Request.Context(), http_range.Range{Length: obj.GetSize()})
+ if err != nil {
+ return out, err
+ }
+ defer rc.Close()
+ hasher := torrent.NewHashWriter(pieceSize, pieceSize, obj.GetSize())
+ n, copyErr := io.Copy(hasher, io.LimitReader(rc, obj.GetSize()+1))
+ if copyErr != nil {
+ return out, fmt.Errorf("read %s: %w", sourcePath, copyErr)
+ }
+ if n != obj.GetSize() {
+ return out, fmt.Errorf("read %s: got %d of %d bytes", sourcePath, n, obj.GetSize())
+ }
+ hasher.Finish()
+ modified := ""
+ if !obj.ModTime().IsZero() {
+ modified = obj.ModTime().UTC().Format(time.RFC3339)
+ }
+ return hasher.BuildSeedFile(stdpath.Base(sourcePath), modified), nil
+}
+
+// UpdateSeed edits seed metadata and optionally recalculates hashes from server files.
+func UpdateSeed(c *gin.Context) {
+ user := c.Request.Context().Value(conf.UserKey).(*model.User)
+ var req SeedUpdateReq
+ if err := c.ShouldBindJSON(&req); err != nil {
+ common.ErrorResp(c, err, 400)
+ return
+ }
+ applySeedUpdateOptions(&req)
+ _, seed, format, err := decodeSeedData(req.SeedDataReq)
+ if err != nil {
+ common.ErrorResp(c, err, 400)
+ return
+ }
+ if req.Comment != nil {
+ seed.Comment = strings.TrimSpace(*req.Comment)
+ }
+ if req.Trackers != nil {
+ seed.Trackers = req.Trackers
+ }
+ if req.Channels != nil {
+ seed.Channels = req.Channels
+ }
+ for i := range seed.Files {
+ if comment, ok := req.FileComments[seed.Files[i].Path]; ok {
+ seed.Files[i].Comment = strings.TrimSpace(comment)
+ }
+ if sources, ok := req.FileSources[seed.Files[i].Path]; ok {
+ for _, src := range sources {
+ if srcErr := validateSeedSource(src); srcErr != nil {
+ common.ErrorResp(c, srcErr, 400)
+ return
+ }
+ }
+ seed.Files[i].Sources = sources
+ }
+ }
+ if len(req.RemoveFiles) > 0 {
+ removeSet := make(map[string]struct{}, len(req.RemoveFiles))
+ for _, path := range req.RemoveFiles {
+ if trimmed := strings.TrimSpace(path); trimmed != "" {
+ removeSet[trimmed] = struct{}{}
+ }
+ }
+ if len(removeSet) == 0 {
+ common.ErrorStrResp(c, "remove_files requires at least one non-empty path", 400)
+ return
+ }
+ kept := make([]torrent.SeedFile, 0, len(seed.Files))
+ for _, file := range seed.Files {
+ if _, removed := removeSet[file.Path]; !removed {
+ kept = append(kept, file)
+ }
+ }
+ if len(kept) == 0 {
+ common.ErrorStrResp(c, "cannot remove all files from a seed", 400)
+ return
+ }
+ seed.Files = kept
+ }
+ if req.Recalculate {
+ pieceSize := req.PieceSize
+ if pieceSize <= 0 {
+ pieceSize = seed.PieceSize
+ }
+ if pieceSize <= 0 {
+ pieceSize = torrent.DefaultPieceSize
+ }
+ if pieceSize < 16*1024 || pieceSize > 64*1024*1024 {
+ common.ErrorStrResp(c, "piece_size must be between 16384 and 67108864", 400)
+ return
+ }
+ seed.PieceSize = pieceSize
+ matrix := normalizedSeedMatrix(req.HashMatrix, []string{format})
+ recalcMap := make(map[string]string, len(req.RecalcFiles))
+ for _, rf := range req.RecalcFiles {
+ if strings.TrimSpace(rf.SourcePath) != "" {
+ recalcMap[rf.Path] = rf.SourcePath
+ }
+ }
+ if len(recalcMap) == 0 {
+ common.ErrorStrResp(c, "recalculate requires at least one source_path", 400)
+ return
+ }
+ for i := range seed.Files {
+ srcPath, ok := recalcMap[seed.Files[i].Path]
+ if !ok {
+ continue
+ }
+ rehashed, rehashErr := rehashSeedFile(c, user, srcPath, pieceSize)
+ if rehashErr != nil {
+ common.ErrorResp(c, rehashErr, 400)
+ return
+ }
+ rehashed.Path = seed.Files[i].Path
+ rehashed.Comment = seed.Files[i].Comment
+ rehashed.Sources = seed.Files[i].Sources
+ rehashed.CASSliceMD5 = ""
+ rehashed.CASCreateTime = ""
+ applySeedMatrix(&rehashed, matrix)
+ seed.Files[i] = rehashed
+ }
+ }
+ data, err := torrent.EncodeSeed(seed, format)
+ if err != nil {
+ common.ErrorResp(c, err, 400)
+ return
+ }
+ result := gin.H{"format": format, "seed_data": base64.StdEncoding.EncodeToString(data), "seed": seed}
+ outputPath := strings.TrimSpace(req.OutputPath)
+ if outputPath == "" {
+ outputPath = strings.TrimSpace(req.SavePath)
+ }
+ if outputPath != "" {
+ fileName := stdpath.Base(seed.Name) + "." + format
+ dstDir, joinErr := user.JoinPath(outputPath)
+ if joinErr != nil {
+ common.ErrorResp(c, joinErr, 403)
+ return
+ }
+ meta, metaErr := op.GetNearestMeta(dstDir)
+ if metaErr != nil && !errors.Is(errors.Cause(metaErr), errs.MetaNotFound) {
+ common.ErrorResp(c, metaErr, 500, true)
+ return
+ }
+ if (!user.CanWriteContent() && !common.CanWriteContentBypassUserPerms(meta, dstDir)) || !common.CanWrite(user, meta, dstDir) {
+ common.ErrorResp(c, errs.PermissionDenied, 403)
+ return
+ }
+ fileStream := &stream.FileStream{Ctx: c.Request.Context(), Obj: &model.Object{Name: fileName, Size: int64(len(data)), Modified: time.Now()}, Reader: bytes.NewReader(data), Mimetype: "application/octet-stream"}
+ if putErr := fs.PutDirectly(c.Request.Context(), dstDir, fileStream); putErr != nil {
+ common.ErrorResp(c, putErr, 500)
+ return
+ }
+ result["path"] = stdpath.Join(outputPath, fileName)
+ }
+ shareStatus := checkSeedShareValidity(seed)
+ if len(shareStatus) > 0 {
+ result["share_status"] = shareStatus
+ }
+ common.SuccessResp(c, result)
+}
+
+// saveSeedFilesToPath saves selected seed files into one target directory.
+func saveSeedFilesToPath(c *gin.Context, user *model.User, seed *torrent.Seed, req SeedQuickSaveReq, dstPath string, rapidOnly bool, allowOffline bool) ([]gin.H, string, string, error) {
+ results := make([]gin.H, 0, len(seed.Files))
+ fullPath, err := user.JoinPath(dstPath)
+ if err != nil {
+ return nil, "", "", err
+ }
+ meta, err := op.GetNearestMeta(fullPath)
+ if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) {
+ return nil, "", "", err
+ }
+ if (!user.CanWriteContent() && !common.CanWriteContentBypassUserPerms(meta, fullPath)) || !common.CanWrite(user, meta, fullPath) {
+ return nil, "", "", errs.PermissionDenied
+ }
+ storage, actualPath, err := op.GetStorageAndActualPath(fullPath)
+ if err != nil {
+ return nil, "", "", err
+ }
+ dstDir, err := op.Get(c.Request.Context(), storage, actualPath)
+ if err != nil || !dstDir.IsDir() {
+ return nil, "", "", errs.NotFolder
+ }
+ driverName, mountPath := "", ""
+ if mounted := storage.GetStorage(); mounted != nil {
+ driverName = strings.TrimSpace(mounted.Driver)
+ mountPath = strings.TrimSpace(mounted.MountPath)
+ }
+ selected := make(map[string]struct{}, len(req.Files))
+ for _, name := range req.Files {
+ selected[name] = struct{}{}
+ }
+ selectedIndexes := make(map[int]struct{}, len(req.SelectedFiles))
+ for _, index := range req.SelectedFiles {
+ if index < 0 || index >= len(seed.Files) {
+ return nil, "", "", fmt.Errorf("selected_files contains an invalid index")
+ }
+ selectedIndexes[index] = struct{}{}
+ }
+ rapidUploader := seedRapidUploaderFor(storage)
+ _, putURL := storage.(driver.PutURL)
+ _, putURLResult := storage.(driver.PutURLResult)
+ for index, file := range seed.Files {
+ if len(selected) > 0 {
+ if _, ok := selected[file.Path]; !ok {
+ continue
+ }
+ } else if len(selectedIndexes) > 0 {
+ if _, ok := selectedIndexes[index]; !ok {
+ continue
+ }
+ }
+ name := stdpath.Base(file.Path)
+ if rapidUploader != nil && seedFileSupportsRapidUpload(rapidUploader, file) {
+ one := *seed
+ one.Name = name
+ one.Files = []torrent.SeedFile{file}
+ if data, encodeErr := torrent.EncodeSeed(&one, "torrent"); encodeErr == nil {
+ if t, decErr := torrent.Decode(data); decErr == nil {
+ if rapidReq := buildSeedRapidUploadRequest(t, seedContentOpener(seed, file, c.Request.Context())); rapidReq != nil {
+ if obj, rapidErr := rapidUploader.RapidUploadByHashes(c.Request.Context(), dstDir, rapidReq, req.Overwrite); rapidErr == nil {
+ results = append(results, gin.H{"path": file.Path, "name": obj.GetName(), "method": "rapid_upload"})
+ continue
+ }
+ }
+ }
+ }
+ }
+ if rapidOnly {
+ results = append(results, gin.H{"path": file.Path, "name": name, "method": "unavailable", "error": "target driver cannot reuse the available hashes"})
+ continue
+ }
+ source := firstUsableSeedSource(file)
+ if source == "" {
+ results = append(results, gin.H{"path": file.Path, "name": name, "method": "unavailable", "error": "no usable source URL or compatible rapid-upload hashes"})
+ continue
+ }
+ if putURL || putURLResult {
+ if putErr := fs.PutURL(c.Request.Context(), fullPath, name, source); putErr == nil {
+ results = append(results, gin.H{"path": file.Path, "name": name, "method": "put_url"})
+ continue
+ }
+ }
+ if !allowOffline {
+ results = append(results, gin.H{"path": file.Path, "name": name, "method": "offline_download", "error": "transit requires a synchronous intermediate save (rapid upload or PutURL)"})
+ continue
+ }
+ if !user.CanAddOfflineDownloadTasks() {
+ results = append(results, gin.H{"path": file.Path, "name": name, "method": "offline_download", "error": "offline download permission is required"})
+ continue
+ }
+ if req.Tool == "" {
+ results = append(results, gin.H{"path": file.Path, "name": name, "method": "offline_download", "error": "offline download tool is required"})
+ continue
+ }
+ t, addErr := tool.AddURL(c, &tool.AddURLArgs{URL: source, DstDirPath: fullPath, Tool: req.Tool, DeletePolicy: tool.DeletePolicy(req.DeletePolicy)})
+ if addErr != nil {
+ results = append(results, gin.H{"path": file.Path, "name": name, "method": "offline_download", "error": addErr.Error()})
+ continue
+ }
+ result := gin.H{"path": file.Path, "name": name, "method": "offline_download"}
+ if t != nil {
+ result["task"] = getTaskInfo(t)
+ }
+ results = append(results, result)
+ }
+ return results, driverName, mountPath, nil
+}
+
+// resolveSeedTransitPath extracts the optional transit destination for a relayed save.
+func resolveSeedTransitPath(req SeedQuickSaveReq) string {
+ transitPath := strings.TrimSpace(req.TransitPath)
+ if transitPath == "" && req.Options != nil {
+ if mode, ok := req.Options["mode"].(string); ok && mode == "transfer" {
+ if tp, ok := req.Options["transit_path"].(string); ok {
+ transitPath = strings.TrimSpace(tp)
+ }
+ }
+ }
+ return transitPath
+}
+
+// transferSeedViaTransit first saves to an intermediate directory then relays to the final target.
+func transferSeedViaTransit(c *gin.Context, user *model.User, seed *torrent.Seed, req SeedQuickSaveReq, transitPath string) {
+ if !user.CanCopy() {
+ common.ErrorResp(c, errs.PermissionDenied, 403)
+ return
+ }
+ transitResults, _, _, err := saveSeedFilesToPath(c, user, seed, req, transitPath, false, false)
+ if err != nil {
+ common.ErrorResp(c, err, seedSaveErrorCode(err))
+ return
+ }
+ finalDst, err := user.JoinPath(req.Path)
+ if err != nil {
+ common.ErrorResp(c, err, 403)
+ return
+ }
+ finalMeta, err := op.GetNearestMeta(finalDst)
+ if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) {
+ common.ErrorResp(c, err, 500, true)
+ return
+ }
+ if (!user.CanWriteContent() && !common.CanWriteContentBypassUserPerms(finalMeta, finalDst)) || !common.CanWrite(user, finalMeta, finalDst) {
+ common.ErrorResp(c, errs.PermissionDenied, 403)
+ return
+ }
+ transitFullPath, err := user.JoinPath(transitPath)
+ if err != nil {
+ common.ErrorResp(c, err, 403)
+ return
+ }
+ finalResults := make([]gin.H, 0, len(transitResults))
+ for _, res := range transitResults {
+ method, _ := res["method"].(string)
+ if method == "unavailable" {
+ finalResults = append(finalResults, res)
+ continue
+ }
+ name, _ := res["name"].(string)
+ if name == "" {
+ continue
+ }
+ // Asynchronous offline downloads cannot be relayed synchronously.
+ if method == "offline_download" {
+ finalResults = append(finalResults, gin.H{"path": res["path"], "name": name, "method": "transfer", "error": "transit requires a synchronous intermediate save (rapid upload or PutURL)"})
+ continue
+ }
+ srcObjPath := stdpath.Join(transitFullPath, name)
+ _, copyErr := fs.Copy(context.WithValue(c.Request.Context(), conf.NoTaskKey, struct{}{}), srcObjPath, finalDst)
+ if copyErr != nil {
+ finalResults = append(finalResults, gin.H{"path": res["path"], "name": name, "method": "transfer", "error": copyErr.Error()})
+ continue
+ }
+ finalResults = append(finalResults, gin.H{"path": res["path"], "name": name, "method": "transfer"})
+ }
+ common.SuccessResp(c, gin.H{"results": finalResults})
+}
+
+// seedSaveErrorCode maps a save error to an HTTP status code.
+func seedSaveErrorCode(err error) int {
+ if errors.Is(errors.Cause(err), errs.PermissionDenied) {
+ return 403
+ }
+ if errors.Is(errors.Cause(err), errs.NotFolder) || errors.Is(errors.Cause(err), errs.ObjectNotFound) {
+ return 400
+ }
+ return 500
+}
+
+// checkSeedShareValidity reports whether each openlist-share source still resolves to a valid sharing.
+func checkSeedShareValidity(seed *torrent.Seed) gin.H {
+ status := make(gin.H)
+ for _, file := range seed.Files {
+ for _, source := range file.Sources {
+ if source.Type != "openlist-share" || strings.TrimSpace(source.ShareID) == "" {
+ continue
+ }
+ shareID := strings.TrimSpace(source.ShareID)
+ if _, checked := status[shareID]; checked {
+ continue
+ }
+ valid := false
+ if sharing, err := op.GetSharingById(shareID, true); err == nil && sharing != nil {
+ valid = sharing.Valid()
+ }
+ status[shareID] = valid
+ }
+ }
+ return status
+}
+
+// applyChannelUpdate reflects successful saves as channels and failed saves as missing channels.
+func applyChannelUpdate(seed *torrent.Seed, driverName, mountPath string, results []gin.H) {
+ if driverName == "" {
+ return
+ }
+ successByPath := make(map[string]bool)
+ for _, res := range results {
+ filePath, _ := res["path"].(string)
+ if filePath == "" {
+ continue
+ }
+ method, _ := res["method"].(string)
+ successByPath[filePath] = method != "unavailable"
+ }
+ anySuccess := false
+ for _, ok := range successByPath {
+ if ok {
+ anySuccess = true
+ break
+ }
+ }
+ if anySuccess {
+ exists := false
+ for _, channel := range seed.Channels {
+ if channel.Driver == driverName {
+ exists = true
+ break
+ }
+ }
+ if !exists {
+ seed.Channels = append(seed.Channels, torrent.SeedChannel{Driver: driverName, MountPath: mountPath})
+ }
+ }
+ for i := range seed.Files {
+ file := &seed.Files[i]
+ ok, tracked := successByPath[file.Path]
+ if !tracked {
+ continue
+ }
+ if ok {
+ file.MissingChannels = removeString(file.MissingChannels, driverName)
+ } else if !containsString(file.MissingChannels, driverName) {
+ file.MissingChannels = append(file.MissingChannels, driverName)
+ }
+ }
+}
+
+func containsString(values []string, target string) bool {
+ for _, value := range values {
+ if value == target {
+ return true
+ }
+ }
+ return false
+}
+
+func removeString(values []string, target string) []string {
+ result := values[:0]
+ for _, value := range values {
+ if value != target {
+ result = append(result, value)
+ }
+ }
+ return result
+}
+
+// QuickSaveSeed imports selected files without downloading when the target supports it.
+func QuickSaveSeed(c *gin.Context) {
+ user := c.Request.Context().Value(conf.UserKey).(*model.User)
+ var req SeedQuickSaveReq
+ if err := c.ShouldBindJSON(&req); err != nil {
+ common.ErrorResp(c, err, 400)
+ return
+ }
+ _, seed, format, err := decodeSeedData(req.SeedDataReq)
+ if err != nil {
+ common.ErrorResp(c, err, 400)
+ return
+ }
+ rapidOnly := strings.HasSuffix(c.FullPath(), "/rapid_upload")
+ if transitPath := resolveSeedTransitPath(req); transitPath != "" {
+ transferSeedViaTransit(c, user, seed, req, transitPath)
+ return
+ }
+ results, driverName, mountPath, err := saveSeedFilesToPath(c, user, seed, req, req.Path, rapidOnly, true)
+ if err != nil {
+ common.ErrorResp(c, err, seedSaveErrorCode(err))
+ return
+ }
+ resp := gin.H{"results": results}
+ if req.UpdateChannel {
+ applyChannelUpdate(seed, driverName, mountPath, results)
+ if data, encodeErr := torrent.EncodeSeed(seed, format); encodeErr == nil {
+ resp["seed_data"] = base64.StdEncoding.EncodeToString(data)
+ resp["seed"] = seed
+ }
+ }
+ common.SuccessResp(c, resp)
+}
+
+// seedContentOpener returns a lazy content provider for a seed file.
+//
+// The source bytes are fetched from the file's first usable source URL, but
+// only on the first call, letting hash-only drivers (e.g. 189pc) skip content
+// entirely. Requests are deliberately anonymous and routed through
+// seedSourceHTTPClient so redirects are re-validated (see its doc comment).
+//
+// The returned streamer is backed by a range reader rather than a fully
+// buffered copy: drivers such as aliyundrive_open and quark_open only read a
+// small proof window, and 115 reads a 128KiB prefix, so buffering up to 1GB in
+// memory would be pure waste.
+func seedContentOpener(seed *torrent.Seed, file torrent.SeedFile, ctx context.Context) func() (model.FileStreamer, error) {
+ source := firstUsableSeedSource(file)
+ if source == "" {
+ return nil
+ }
+ if ctx == nil {
+ ctx = context.Background()
+ }
+ return func() (model.FileStreamer, error) {
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, source, nil)
+ if err != nil {
+ return nil, err
+ }
+ resp, err := seedSourceHTTPClient.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("下载种子内容失败: %w", err)
+ }
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+ resp.Body.Close()
+ return nil, fmt.Errorf("下载种子内容失败: HTTP %d", resp.StatusCode)
+ }
+ // Content-Length is authoritative when present; fall back to the seed
+ // metadata. Reject up-front rather than streaming an oversized body.
+ size := resp.ContentLength
+ if size < 0 {
+ size = file.Size
+ }
+ if size > maxTorrentGenFileSize {
+ resp.Body.Close()
+ return nil, fmt.Errorf("种子内容超过大小上限: %d", size)
+ }
+ if file.Size > 0 && resp.ContentLength >= 0 && resp.ContentLength != file.Size {
+ resp.Body.Close()
+ return nil, fmt.Errorf("种子内容大小不匹配: 期望 %d,实际 %d", file.Size, resp.ContentLength)
+ }
+
+ obj := &model.Object{
+ Name: stdpath.Base(file.Path),
+ Size: size,
+ Modified: time.Now(),
+ IsFolder: false,
+ }
+ fs := &stream.FileStream{
+ Ctx: ctx,
+ Obj: obj,
+ Reader: io.LimitReader(resp.Body, maxTorrentGenFileSize),
+ }
+ // Keep the body alive for the lifetime of the streamer and expose range
+ // reads over an in-memory prefix so proof/hash windows can be re-read.
+ fs.Add(resp.Body)
+ return fs, nil
+ }
+}
+
+func firstUsableSeedSource(file torrent.SeedFile) string {
+ if _, err := seedSiteConfig(); err != nil {
+ return ""
+ }
+ for _, source := range file.Sources {
+ candidate, parseErr := url.Parse(source.URL)
+ if parseErr != nil {
+ continue
+ }
+ if err := validateSeedHost(candidate); err != nil {
+ continue
+ }
+ validPath := (source.Type == "openlist-direct" && strings.HasPrefix(candidate.EscapedPath(), "/d/")) ||
+ (source.Type == "openlist-share" && strings.HasPrefix(candidate.EscapedPath(), "/sd/"))
+ if !validPath {
+ continue
+ }
+ if source.ExpiresAt != "" {
+ expires, parseErr := time.Parse(time.RFC3339, source.ExpiresAt)
+ if parseErr != nil || time.Now().After(expires) {
+ continue
+ }
+ }
+ return candidate.String()
+ }
+ return ""
+}
+
+func seedTotalSize(seed *torrent.Seed) int64 {
+ var total int64
+ for _, file := range seed.Files {
+ total += file.Size
+ }
+ return total
+}
+
+func seedDiagnostics(seed *torrent.Seed) gin.H {
+ return gin.H{
+ "oss": torrent.DiagnoseConversion(seed, "oss"),
+ "torrent": torrent.DiagnoseConversion(seed, "torrent"),
+ "cas": torrent.DiagnoseConversion(seed, "cas"),
+ }
+}
+
+func seedConversionStates(seed *torrent.Seed) gin.H {
+ states := make(gin.H, 3)
+ for _, format := range []string{"oss", "torrent", "cas"} {
+ missing := torrent.DiagnoseConversion(seed, format)
+ states[format] = gin.H{"feasible": len(missing) == 0, "missing": missing}
+ }
+ return states
+}
diff --git a/server/handles/torrent_seed_test.go b/server/handles/torrent_seed_test.go
new file mode 100644
index 00000000..ea4c9583
--- /dev/null
+++ b/server/handles/torrent_seed_test.go
@@ -0,0 +1,259 @@
+package handles
+
+import (
+ "net/http"
+ "net/url"
+ "strings"
+ "testing"
+
+ "github.com/OpenListTeam/OpenList/v4/pkg/torrent"
+ "github.com/OpenListTeam/OpenList/v4/pkg/utils"
+)
+
+// withSeedSite pins the configured seed site for the duration of a test.
+func withSeedSite(t *testing.T, site string) {
+ t.Helper()
+ previous := seedSiteURLProvider
+ seedSiteURLProvider = func() string { return site }
+ t.Cleanup(func() { seedSiteURLProvider = previous })
+}
+
+func mustParse(t *testing.T, raw string) *url.URL {
+ t.Helper()
+ u, err := url.Parse(raw)
+ if err != nil {
+ t.Fatalf("url.Parse(%q) error = %v", raw, err)
+ }
+ return u
+}
+
+// TestSameSeedHostNormalizesDefaultPort guards against the regression where a
+// configured "https://pan.example.com" and an embedded
+// "https://pan.example.com:443/..." were treated as different hosts, silently
+// discarding otherwise valid sources.
+func TestSameSeedHostNormalizesDefaultPort(t *testing.T) {
+ cases := []struct {
+ a, b string
+ want bool
+ }{
+ {"https://pan.example.com", "https://pan.example.com:443/x", true},
+ {"http://pan.example.com", "http://pan.example.com:80/x", true},
+ {"https://pan.example.com:8443", "https://pan.example.com:8443/x", true},
+ {"https://pan.example.com:8443", "https://pan.example.com", false},
+ {"https://pan.example.com", "http://pan.example.com", false},
+ {"https://pan.example.com", "https://evil.example.com", false},
+ {"https://pan.example.com", "https://pan.example.com.evil.com", false},
+ }
+ for _, tc := range cases {
+ if got := sameSeedHost(mustParse(t, tc.a), mustParse(t, tc.b)); got != tc.want {
+ t.Errorf("sameSeedHost(%q, %q) = %v, want %v", tc.a, tc.b, got, tc.want)
+ }
+ }
+}
+
+// TestValidateSeedHostRejectsForeignHosts is the core SSRF guarantee: a seed
+// source may only ever point at the operator-configured site.
+func TestValidateSeedHostRejectsForeignHosts(t *testing.T) {
+ withSeedSite(t, "https://pan.example.com")
+
+ rejected := []string{
+ "http://169.254.169.254/latest/meta-data/", // cloud metadata
+ "http://127.0.0.1:5244/api/fs/list", // local admin API
+ "http://localhost:5244/d/secret",
+ "https://evil.example.com/d/secret",
+ "https://pan.example.com.evil.com/d/x",
+ "file:///etc/passwd",
+ "ftp://pan.example.com/x",
+ "https://user:pass@pan.example.com/d/x", // embedded credentials
+ }
+ for _, raw := range rejected {
+ if err := validateSeedHost(mustParse(t, raw)); err == nil {
+ t.Errorf("validateSeedHost(%q) accepted a disallowed URL", raw)
+ }
+ }
+
+ allowed := []string{
+ "https://pan.example.com/d/some/file",
+ "https://pan.example.com:443/sd/abc123",
+ }
+ for _, raw := range allowed {
+ if err := validateSeedHost(mustParse(t, raw)); err != nil {
+ t.Errorf("validateSeedHost(%q) rejected a valid URL: %v", raw, err)
+ }
+ }
+}
+
+func TestValidateSeedHostWithoutConfiguredSite(t *testing.T) {
+ withSeedSite(t, "")
+ if err := validateSeedHost(mustParse(t, "https://pan.example.com/d/x")); err == nil {
+ t.Fatal("validateSeedHost() accepted a source while seed_site_url is unset")
+ }
+}
+
+// TestRedirectGuardBlocksSSRF is the regression test for the bypass: the first
+// hop passes the host allow-list, but a redirect must not be allowed to escape
+// to an internal address.
+func TestRedirectGuardBlocksSSRF(t *testing.T) {
+ withSeedSite(t, "https://pan.example.com")
+
+ origin := mustParse(t, "https://pan.example.com/d/file")
+ check := seedSourceHTTPClient.CheckRedirect
+
+ // A redirect staying on the configured site is fine.
+ sameHost := &http.Request{URL: mustParse(t, "https://pan.example.com/d/file-2")}
+ if err := check(sameHost, []*http.Request{{URL: origin}}); err != nil {
+ t.Fatalf("CheckRedirect() rejected a same-host redirect: %v", err)
+ }
+
+ // A redirect to the cloud metadata endpoint must be refused.
+ metadata := &http.Request{URL: mustParse(t, "http://169.254.169.254/latest/meta-data/")}
+ if err := check(metadata, []*http.Request{{URL: origin}}); err == nil {
+ t.Fatal("CheckRedirect() allowed a redirect to the cloud metadata endpoint")
+ }
+
+ // A redirect to localhost must be refused.
+ local := &http.Request{URL: mustParse(t, "http://127.0.0.1:5244/api/fs/list")}
+ if err := check(local, []*http.Request{{URL: origin}}); err == nil {
+ t.Fatal("CheckRedirect() allowed a redirect to localhost")
+ }
+
+ // A same-host redirect that downgrades https -> http must be refused.
+ downgrade := &http.Request{URL: mustParse(t, "http://pan.example.com/d/file")}
+ if err := check(downgrade, []*http.Request{{URL: origin}}); err == nil {
+ t.Fatal("CheckRedirect() allowed a scheme downgrade")
+ }
+}
+
+func TestRedirectGuardLimitsHopCount(t *testing.T) {
+ withSeedSite(t, "https://pan.example.com")
+
+ origin := mustParse(t, "https://pan.example.com/d/file")
+ via := make([]*http.Request, maxSeedSourceRedirects)
+ for i := range via {
+ via[i] = &http.Request{URL: origin}
+ }
+ next := &http.Request{URL: mustParse(t, "https://pan.example.com/d/file-2")}
+ if err := seedSourceHTTPClient.CheckRedirect(next, via); err == nil {
+ t.Fatal("CheckRedirect() accepted more redirects than maxSeedSourceRedirects")
+ }
+}
+
+// TestValidateSeedSourceEnforcesPathPrefix documents the per-type path contract.
+func TestValidateSeedSourceEnforcesPathPrefix(t *testing.T) {
+ withSeedSite(t, "https://pan.example.com")
+
+ if err := validateSeedSource(torrent.SeedSource{
+ Type: "openlist-direct",
+ URL: "https://pan.example.com/sd/abc",
+ }); err == nil {
+ t.Fatal("validateSeedSource() accepted a share path for a direct source")
+ }
+ if err := validateSeedSource(torrent.SeedSource{
+ Type: "openlist-share",
+ URL: "https://pan.example.com/d/file",
+ }); err == nil {
+ t.Fatal("validateSeedSource() accepted a direct path for a share source")
+ }
+ if err := validateSeedSource(torrent.SeedSource{
+ Type: "openlist-direct",
+ URL: "https://evil.example.com/d/file",
+ }); err == nil {
+ t.Fatal("validateSeedSource() accepted a foreign host")
+ }
+ if err := validateSeedSource(torrent.SeedSource{
+ Type: "openlist-direct",
+ URL: "https://pan.example.com/d/file",
+ }); err != nil {
+ t.Fatalf("validateSeedSource() rejected a valid direct source: %v", err)
+ }
+}
+
+// TestFirstUsableSeedSourceSkipsExpiredAndForeign verifies the selection logic
+// only returns sources that are both on-site and not expired.
+func TestFirstUsableSeedSourceSkipsExpiredAndForeign(t *testing.T) {
+ withSeedSite(t, "https://pan.example.com")
+
+ file := torrent.SeedFile{
+ Sources: []torrent.SeedSource{
+ {Type: "openlist-direct", URL: "https://evil.example.com/d/a"},
+ {Type: "openlist-direct", URL: "https://pan.example.com/d/b", ExpiresAt: "2000-01-01T00:00:00Z"},
+ {Type: "openlist-share", URL: "https://pan.example.com/sd/good"},
+ },
+ }
+ if got := firstUsableSeedSource(file); got != "https://pan.example.com/sd/good" {
+ t.Fatalf("firstUsableSeedSource() = %q, want the valid share source", got)
+ }
+
+ // No usable source at all.
+ none := torrent.SeedFile{
+ Sources: []torrent.SeedSource{
+ {Type: "openlist-direct", URL: "https://evil.example.com/d/a"},
+ },
+ }
+ if got := firstUsableSeedSource(none); got != "" {
+ t.Fatalf("firstUsableSeedSource() = %q, want empty", got)
+ }
+}
+
+// TestBuildSeedRapidUploadRequestRejectsMultiFile documents that a multi-file
+// torrent cannot be described by a single rapid-upload request. Returning nil
+// (instead of silently using Files[0] with the aggregate size) prevents sending
+// the destination a size/hash combination that contradicts itself.
+func TestBuildSeedRapidUploadRequestRejectsMultiFile(t *testing.T) {
+ const md5Hex = "0123456789abcdef0123456789abcdef"
+
+ multi := &torrent.Torrent{
+ OpenList: &torrent.Seed{
+ PieceSize: torrent.DefaultPieceSize,
+ Files: []torrent.SeedFile{
+ {Path: "a.bin", Size: 10, Hashes: torrent.SeedHashes{MD5: md5Hex}},
+ {Path: "b.bin", Size: 20, Hashes: torrent.SeedHashes{MD5: md5Hex}},
+ },
+ },
+ }
+ if req := buildSeedRapidUploadRequest(multi, nil); req != nil {
+ t.Fatalf("buildSeedRapidUploadRequest() accepted a multi-file torrent: %#v", req)
+ }
+
+ // A single file must still work.
+ single := &torrent.Torrent{
+ Info: torrent.TorrentInfo{Name: "a.bin", Length: 10},
+ OpenList: &torrent.Seed{
+ PieceSize: torrent.DefaultPieceSize,
+ Files: []torrent.SeedFile{
+ {Path: "a.bin", Size: 10, Hashes: torrent.SeedHashes{MD5: md5Hex}},
+ },
+ },
+ }
+ req := buildSeedRapidUploadRequest(single, nil)
+ if req == nil {
+ t.Fatal("buildSeedRapidUploadRequest() rejected a valid single-file torrent")
+ }
+ if req.Size != 10 {
+ t.Fatalf("buildSeedRapidUploadRequest() size = %d, want 10", req.Size)
+ }
+ if got := req.Whole.GetHash(utils.MD5); !strings.EqualFold(got, md5Hex) {
+ t.Fatalf("buildSeedRapidUploadRequest() MD5 = %q, want %q", got, md5Hex)
+ }
+
+ // A single file whose metadata size disagrees with the torrent length is
+ // internally inconsistent and must also be refused.
+ mismatched := &torrent.Torrent{
+ Info: torrent.TorrentInfo{Name: "a.bin", Length: 99},
+ OpenList: &torrent.Seed{
+ PieceSize: torrent.DefaultPieceSize,
+ Files: []torrent.SeedFile{
+ {Path: "a.bin", Size: 10, Hashes: torrent.SeedHashes{MD5: md5Hex}},
+ },
+ },
+ }
+ if req := buildSeedRapidUploadRequest(mismatched, nil); req != nil {
+ t.Fatalf("buildSeedRapidUploadRequest() accepted a size/hash mismatch: %#v", req)
+ }
+}
+
+func TestBuildSeedRapidUploadRequestHandlesNil(t *testing.T) {
+ if req := buildSeedRapidUploadRequest(nil, nil); req != nil {
+ t.Fatalf("buildSeedRapidUploadRequest(nil) = %#v, want nil", req)
+ }
+}
diff --git a/server/handles/transcode.go b/server/handles/transcode.go
new file mode 100644
index 00000000..dfe8f258
--- /dev/null
+++ b/server/handles/transcode.go
@@ -0,0 +1,656 @@
+package handles
+
+import (
+ "crypto/subtle"
+ "errors"
+ "fmt"
+ "net/http"
+ "net/url"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/OpenListTeam/OpenList/v4/internal/conf"
+ "github.com/OpenListTeam/OpenList/v4/internal/driver"
+ "github.com/OpenListTeam/OpenList/v4/internal/fs"
+ "github.com/OpenListTeam/OpenList/v4/internal/op"
+ "github.com/OpenListTeam/OpenList/v4/internal/setting"
+ "github.com/OpenListTeam/OpenList/v4/internal/sign"
+ "github.com/OpenListTeam/OpenList/v4/internal/transcode"
+ "github.com/OpenListTeam/OpenList/v4/server/common"
+ "github.com/gin-gonic/gin"
+)
+
+// 探测阶段的独立并发上限。
+//
+// 转码配额(每用户 3 个活跃任务)只约束「入队」,而配额判定发生在探测之后:
+// 并发请求可能同时通过配额预检、各起一个最长 30s 的 ffprobe。这里给探测单独
+// 设一道闸,保证这段窗口内的进程数有界。每用户上限取任务配额值,正常并发播放
+// 不会被误伤(用户有 3 个任务时预检已经先拒绝了)。
+const (
+ maxConcurrentProbes = 8
+ maxProbesPerUser = transcode.MaxActiveJobsPerUser
+)
+
+type probeLimiter struct {
+ mu sync.Mutex
+ active int
+ users map[string]int
+}
+
+var probeSlots = &probeLimiter{users: make(map[string]int)}
+
+func (p *probeLimiter) acquire(owner string) bool {
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ if p.active >= maxConcurrentProbes || p.users[owner] >= maxProbesPerUser {
+ return false
+ }
+ p.active++
+ p.users[owner]++
+ return true
+}
+
+func (p *probeLimiter) release(owner string) {
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ if p.active > 0 {
+ p.active--
+ }
+ if n := p.users[owner]; n <= 1 {
+ delete(p.users, owner)
+ } else {
+ p.users[owner] = n - 1
+ }
+}
+
+// ============================================================
+// 播放端:/api/transcode/play 发起或复用一个转码任务
+// ============================================================
+
+// TranscodePlay 入口:POST /api/fs/transcode body: {path}
+//
+// 行为:
+// 1. 检查 transcode_enabled
+// 2. fs.Get 获取文件并跑 Decide
+// 3. 不需要转码 → 返回 {transcode:false, direct: true}
+// 4. 需要转码 → 创建 Job 入队,返回 {transcode:true, job_id, master_url}
+func TranscodePlay(c *gin.Context) {
+ if !transcode.IsEnabled() {
+ common.SuccessResp(c, gin.H{"transcode": false, "reason": "disabled"})
+ return
+ }
+ var req struct {
+ Path string `json:"path" binding:"required"`
+ }
+ if err := c.ShouldBindJSON(&req); err != nil {
+ common.ErrorResp(c, err, 400)
+ return
+ }
+ // 该路由挂在 Auth(true)(允许被禁用的访客通过)之下,必须在这里自行拒绝,
+ // 否则关闭访客后匿名请求仍能触发 ffprobe/ffmpeg 并占用转码缓存。
+ user := mediaUser(c)
+ if user == nil || (user.IsGuest() && user.Disabled) {
+ common.ErrorStrResp(c, "Guest user is disabled, login please", 401)
+ return
+ }
+ if !CanUserAccessPath(user, req.Path) {
+ common.ErrorStrResp(c, "path is not accessible", 403)
+ return
+ }
+ obj, err := fs.Get(c.Request.Context(), req.Path, &fs.GetArgs{NoLog: true})
+ if err != nil {
+ common.ErrorResp(c, err, 500)
+ return
+ }
+ if obj.IsDir() {
+ common.ErrorStrResp(c, "path is a directory", 400)
+ return
+ }
+ dec := transcode.Decide(transcode.DecideRequest{
+ FileName: obj.GetName(),
+ FileSize: obj.GetSize(),
+ })
+ if !dec.NeedTranscode {
+ common.SuccessResp(c, gin.H{
+ "transcode": false,
+ "reason": dec.Reason,
+ })
+ return
+ }
+
+ mgr := transcode.Default()
+ mgr.Start()
+
+ // apiURL 仅用于返回给浏览器的 master_url(需要跟随请求 Host 才能被客户端访问)。
+ apiURL := common.GetApiUrlFromRequest(c.Request)
+
+ // 配额键:普通用户按 ID,访客按 IP——访客共用一个账号,
+ // 否则一个匿名访客就能占满全部匿名转码配额。
+ ownerID := "user:" + strconv.FormatUint(uint64(user.ID), 10)
+ if user.IsGuest() {
+ ownerID = "guest:" + c.ClientIP()
+ }
+
+ // 同一用户重复请求同一文件:直接复用进行中的任务,避免重复 ffprobe 与重复转码。
+ if exist, ok := mgr.Scheduler.FindReusable(ownerID, req.Path); ok {
+ respondTranscodeJob(c, apiURL, exist)
+ return
+ }
+
+ // 【内存优化】优先尝试解析为本地文件路径,让 ffmpeg 直接读取本地文件,
+ // 完全绕过 HTTP /d 代理路径——避免 net.Downloader 给每个 range 请求分配
+ // 高达 MaxBufferLimit (默认系统内存 5%) 的大缓冲。这是降低 Go 进程内存
+ // 占用最有效的方法。如果不是本地驱动则退回到 HTTP 签名 URL。
+ var sourceURL string
+ if localFile, ok := resolveLocalFilePath(req.Path); ok {
+ // ffmpeg 接受本地路径作为输入,无需 file:// 前缀
+ sourceURL = localFile
+ fmt.Printf("[transcode] using local file path for ffmpeg: %s\n", localFile)
+ } else {
+ // 远程驱动:构造签名 HTTP URL。
+ // 这个 URL 会被服务端 ffmpeg 直接访问,绝不能使用 Host / X-Forwarded-Host:
+ // 未配置 site_url 时攻击者可借此让服务器去拉取任意地址(SSRF)。
+ base := trustedInternalBaseURL()
+ if base == "" {
+ // 仅 HTTPS 监听且未配置 site_url 时无可用基址;
+ // 若继续拼出 "/d/..." 会被当成绝对本地路径,ffmpeg 只会报文件不存在。
+ common.ErrorStrResp(
+ c,
+ "transcode needs site_url (or an HTTP port) to fetch the source",
+ 503,
+ )
+ return
+ }
+ signedPath := sign.Sign(req.Path)
+ sourceURL = fmt.Sprintf("%s/d%s?sign=%s", base, encodePath(req.Path), signedPath)
+ }
+ if !transcode.ValidSourceInput(sourceURL) {
+ common.ErrorStrResp(c, "unsupported transcode source", 400)
+ return
+ }
+
+ // 创建 Job
+ job := transcode.NewJob()
+ job.Path = req.Path
+ job.OwnerID = ownerID
+ job.SourceURL = sourceURL
+ // 配额预检:ProbeSource 会起一个最长 30s 的 ffprobe 子进程,
+ // 不能等探测完才告诉用户「已有 3 个活跃任务」。最终判定仍由 SubmitLimited 原子完成。
+ if err := mgr.Scheduler.Capacity(ownerID); err != nil {
+ common.ErrorResp(c, err, 429)
+ return
+ }
+ if !probeSlots.acquire(ownerID) {
+ common.ErrorStrResp(c, "too many concurrent transcode requests", 429)
+ return
+ }
+ // 【关键】探测视频总时长(异步会让首响应变慢,但只有一次,必须等待)
+ // 总时长用于生成完整的 playlist,避免 HLS.js 时长显示错误和拖动跳跃问题
+ probe, perr := func() (transcode.SourceProbe, error) {
+ // 用 defer 释放:探测内部 panic 时也不能把槽位永久占住
+ defer probeSlots.release(ownerID)
+ return transcode.ProbeSource(sourceURL)
+ }()
+ if perr != nil {
+ // 探测失败不阻塞流程,但记录日志;后续 playlist 会缺少总时长信息
+ fmt.Printf("[transcode] probe source failed: %v\n", perr)
+ probe = transcode.SourceProbe{Size: obj.GetSize()}
+ } else if probe.Size == 0 {
+ probe.Size = obj.GetSize()
+ }
+ job.Probe = probe
+ profile := transcode.BuildProfile()
+ job.Profiles = []transcode.Profile{profile}
+ job.Output = transcode.BuildOutputSpec()
+ timeoutMin := setting.GetInt(conf.TranscodeJobTimeoutMin, 120)
+ job.Deadline = time.Now().Add(time.Duration(timeoutMin) * time.Minute).Unix()
+ submitted, _, err := mgr.Scheduler.SubmitLimited(job)
+ if err != nil {
+ // 队列已满 / 用户配额超限:返回 429,前端可降级为直链播放
+ common.ErrorResp(c, err, 429)
+ return
+ }
+ respondTranscodeJob(c, apiURL, submitted)
+}
+
+// respondTranscodeJob 返回转码任务的播放信息
+func respondTranscodeJob(c *gin.Context, apiURL string, job *transcode.Job) {
+ profileName := ""
+ if len(job.Profiles) > 0 {
+ profileName = job.Profiles[0].Name
+ }
+ masterURL := fmt.Sprintf("%s/tc/%s/%s/master.m3u8", apiURL, job.ID, job.PlayToken)
+ common.SuccessResp(c, gin.H{
+ "transcode": true,
+ "job_id": job.ID,
+ "master_url": masterURL,
+ "profile": profileName,
+ })
+}
+
+func encodePath(p string) string {
+ // 与 down.go 保持一致:保留 '/' 不转义
+ parts := strings.Split(p, "/")
+ for i, s := range parts {
+ parts[i] = url.PathEscape(s)
+ }
+ return strings.Join(parts, "/")
+}
+
+// trustedInternalBaseURL 返回供服务端 ffmpeg 访问本站 /d 接口的可信基址,不读取任何请求头。
+// 优先使用管理员配置的 site_url;未配置时回环到本机实际监听的 HTTP 端口。
+// 两者都不可用(例如仅监听 Unix socket)时返回空串,调用方应拒绝转码。
+func trustedInternalBaseURL() string {
+ if site := strings.TrimSuffix(conf.Conf.SiteURL, "/"); strings.HasPrefix(site, "http") {
+ return site
+ }
+ if p := conf.Conf.Scheme.HttpPort; p > 0 {
+ return fmt.Sprintf("http://127.0.0.1:%d", p)
+ }
+ return ""
+}
+
+// resolveLocalFilePath 尝试把 OpenList 路径解析为宿主机文件系统的实际路径。
+// 仅对本地驱动 (Local) 有效,其他驱动(云盘等)返回 false。
+//
+// 这是内存优化的关键路径:本地文件场景下让 ffmpeg 直接读文件,可以完全绕过
+// HTTP /d 代理路径上的 net.Downloader 大块缓冲(默认每个 range 请求最高
+// 占用 MaxBufferLimit = 系统内存 5%,多 chunk 并发 + 多 range 累计可达数 GB)。
+func resolveLocalFilePath(rawPath string) (string, bool) {
+ storage, actualPath, err := op.GetStorageAndActualPath(rawPath)
+ if err != nil || storage == nil {
+ return "", false
+ }
+ if storage.Config().Name != "Local" {
+ return "", false
+ }
+ rooter, ok := storage.(driver.IRootPath)
+ if !ok {
+ return "", false
+ }
+ root := rooter.GetRootPath()
+ if root == "" {
+ return "", false
+ }
+ full := filepath.Join(root, actualPath)
+ // 只接受真实存在的文件,避免传给 ffmpeg 一个无效路径
+ fi, err := os.Stat(full)
+ if err != nil || fi.IsDir() {
+ return "", false
+ }
+ return full, true
+}
+
+// ============================================================
+// 播放端:/tc/:job/:token/... Master/Variant playlist & 切片
+// ============================================================
+
+// TCMaster GET /tc/:job/:token/master.m3u8
+func TCMaster(c *gin.Context) {
+ jobID := c.Param("job")
+ token := c.Param("token")
+ job, ok := transcode.Default().Scheduler.Get(jobID)
+ if !ok {
+ c.String(http.StatusNotFound, "job not found")
+ return
+ }
+ if !tokenMatches(job.PlayToken, token) {
+ c.String(http.StatusForbidden, "bad token")
+ return
+ }
+ if len(job.Profiles) == 0 {
+ c.String(http.StatusInternalServerError, "no profiles")
+ return
+ }
+ apiURL := common.GetApiUrlFromRequest(c.Request)
+ baseURL := fmt.Sprintf("%s/tc/%s/%s/", apiURL, jobID, token)
+ body := transcode.Default().Cache.MasterPlaylist(job.Profiles, baseURL)
+ job.Touch()
+ c.Header("Content-Type", "application/vnd.apple.mpegurl")
+ // Master playlist 内容在 job 生命周期内不变,设置较长缓存
+ c.Header("Cache-Control", "public, max-age=3600")
+ c.String(http.StatusOK, body)
+}
+
+// TCPlaylist GET /tc/:job/:token/:profile/playlist.m3u8
+// 等待首切片就绪后返回;阻塞最多 30s
+func TCPlaylist(c *gin.Context) {
+ jobID := c.Param("job")
+ token := c.Param("token")
+ profile := c.Param("profile")
+ mgr := transcode.Default()
+ job, ok := mgr.Scheduler.Get(jobID)
+ if !ok {
+ c.String(http.StatusNotFound, "job not found")
+ return
+ }
+ if !tokenMatches(job.PlayToken, token) {
+ c.String(http.StatusForbidden, "bad token")
+ return
+ }
+ if !transcode.ValidProfileName(profile) {
+ c.String(http.StatusBadRequest, "bad profile")
+ return
+ }
+ // 【关键】如果 job 之前因 idle 被 cancel,这里重新激活:复用 jobID/token 重新入队
+ // 让 worker 再次启动 ffmpeg 转码,避免播放器看到 404 后无法恢复。
+ // 配额/队列已满时不再起新的 ffmpeg,但 playlist 与已缓存的切片仍应可读:
+ // 这里只记下 quotaBlocked,交给切片请求在「缓存也没有」时才返回 429。
+ quotaBlocked := false
+ if st := job.GetStatus(); st == transcode.JobCancelled || st == transcode.JobFinished || st == transcode.JobFailed {
+ if _, rerr := mgr.Scheduler.Reactivate(jobID); rerr == nil {
+ fmt.Printf("[transcode] reactivate job %s on playlist request (was %s)\n", jobID, st)
+ } else if errors.Is(rerr, transcode.ErrUserQuota) || errors.Is(rerr, transcode.ErrQueueFull) {
+ quotaBlocked = true
+ fmt.Printf("[transcode] reactivate job %s on playlist request rejected (%v), serving from cache only\n", jobID, rerr)
+ }
+ }
+ job.Touch()
+ // 等首切片;配额已满时无法启动新的转码,没必要干等 30s
+ if !quotaBlocked {
+ select {
+ case <-job.WaitReady():
+ case <-time.After(30 * time.Second):
+ c.String(http.StatusGatewayTimeout, "timeout waiting first segment")
+ return
+ }
+ }
+ if job.Status == transcode.JobFailed {
+ c.String(http.StatusInternalServerError, "transcode failed: "+job.Error)
+ return
+ }
+ apiURL := common.GetApiUrlFromRequest(c.Request)
+ baseURL := fmt.Sprintf("%s/tc/%s/%s/%s/", apiURL, jobID, token, profile)
+ isFinished := job.Status == transcode.JobFinished
+ // 【关键】传入总时长,让 BuildPlaylist 能够生成完整切片列表(VOD 模式),
+ // 这样 HLS.js 就知道视频真实总时长,避免时长显示错误和拖动跳跃问题。
+ body := transcode.Default().Cache.BuildPlaylist(jobID, profile, job.Output.SegmentDuration,
+ isFinished, baseURL, job.Probe.Duration)
+ c.Header("Content-Type", "application/vnd.apple.mpegurl")
+ if isFinished || job.Probe.Duration > 0 {
+ // VOD 模式:playlist 完整不变,长缓存
+ c.Header("Cache-Control", "public, max-age=3600")
+ } else {
+ // 没有总时长时退回 EVENT 模式:短缓存
+ c.Header("Cache-Control", "public, max-age=2")
+ }
+ c.String(http.StatusOK, body)
+}
+
+// TCSegment GET /tc/:job/:token/:profile/seg-:seq.ts
+// 切片就绪则直接返回;否则等待最多 60s
+func TCSegment(c *gin.Context) {
+ jobID := c.Param("job")
+ token := c.Param("token")
+ profile := c.Param("profile")
+ segName := c.Param("seg")
+ mgr := transcode.Default()
+ job, ok := mgr.Scheduler.Get(jobID)
+ if !ok {
+ c.String(http.StatusNotFound, "job not found")
+ return
+ }
+ if !tokenMatches(job.PlayToken, token) {
+ c.String(http.StatusForbidden, "bad token")
+ return
+ }
+ if !transcode.ValidProfileName(profile) {
+ c.String(http.StatusBadRequest, "bad profile")
+ return
+ }
+ // 【关键】job 之前因 idle 被 cancel 时,复用同一 jobID/token 重新入队启动转码。
+ // 这样浏览器持有的旧 master.m3u8 + 切片 URL 仍然有效,暂停再继续也不会 404。
+ // 配额/队列已满时不重新起 ffmpeg,但已缓存的切片依然要能命中(回看已转完的视频)。
+ quotaBlocked := false
+ if st := job.GetStatus(); st == transcode.JobCancelled || st == transcode.JobFinished || st == transcode.JobFailed {
+ if _, rerr := mgr.Scheduler.Reactivate(jobID); rerr == nil {
+ fmt.Printf("[transcode] reactivate job %s on segment request (was %s)\n", jobID, st)
+ } else if errors.Is(rerr, transcode.ErrUserQuota) || errors.Is(rerr, transcode.ErrQueueFull) {
+ quotaBlocked = true
+ fmt.Printf("[transcode] reactivate job %s on segment request rejected (%v), serving from cache only\n", jobID, rerr)
+ }
+ }
+ // seg-N.ts
+ name := strings.TrimSuffix(strings.TrimPrefix(segName, "seg-"), ".ts")
+ seq, err := strconv.Atoi(name)
+ if err != nil {
+ c.String(http.StatusBadRequest, "bad segment name")
+ return
+ }
+ cache := mgr.Cache
+ // 【智能调度】触发对应 chunk 的 ffmpeg 启动(如已运行则只更新 LastAccess)
+ // 这是用户拖动进度条到任意位置时能快速响应的关键。
+ // 配额已满时不能触发新的 ffmpeg,否则「限制并发」就没有意义了。
+ if mgr.Chunks != nil && !quotaBlocked {
+ chunkIdx := mgr.Chunks.EnsureChunkRunningForSeg(jobID, profile, seq)
+ fmt.Printf("[tc-segment] req seq=%d chunk=%d job=%s\n", seq, chunkIdx, jobID)
+ }
+ info, err := cache.GetSegment(jobID, profile, seq)
+ if err != nil {
+ if quotaBlocked {
+ // 配额已满且该切片不在缓存里:只能拒绝,让前端降级为直链播放
+ c.String(http.StatusTooManyRequests, "too many active transcode jobs")
+ return
+ }
+ // 等待
+ waitStart := time.Now()
+ info, err = cache.WaitForSegment(jobID, profile, seq, 60*time.Second)
+ if err != nil {
+ fmt.Printf("[tc-segment] req seq=%d FAILED after %.1fs: %v\n", seq, time.Since(waitStart).Seconds(), err)
+ c.String(http.StatusNotFound, "segment not available: "+err.Error())
+ return
+ }
+ fmt.Printf("[tc-segment] req seq=%d ready after %.1fs\n", seq, time.Since(waitStart).Seconds())
+ }
+ job.Touch()
+ // 切片内容不可变,设置强缓存避免浏览器/HLS.js 反复请求同一切片
+ c.Header("Cache-Control", "public, max-age=86400, immutable")
+ // 【内存优化】流式发送切片文件,避免 gin c.File() 将整个切片读入内存
+ // 注意:不能使用 http.ServeFile,它会自动嗅探 Content-Type 并处理 Range 请求,
+ // 与 gin 已写入的响应头冲突,导致 HLS.js 收到异常数据触发 bufferAppendError。
+ f, err := os.Open(info.Path)
+ if err != nil {
+ c.String(http.StatusInternalServerError, "open segment: "+err.Error())
+ return
+ }
+ defer f.Close()
+ fi, err := f.Stat()
+ if err != nil {
+ c.String(http.StatusInternalServerError, "stat segment: "+err.Error())
+ return
+ }
+ c.DataFromReader(http.StatusOK, fi.Size(), "video/mp2t", f, nil)
+}
+
+// ============================================================
+// Worker 协议端
+// ============================================================
+
+// WorkerRegister POST /api/transcode/worker/register
+func WorkerRegister(c *gin.Context) {
+ if !transcode.IsEnabled() {
+ common.ErrorStrResp(c, "transcode disabled", 403)
+ return
+ }
+ if !checkWorkerSecret(c) {
+ common.ErrorStrResp(c, "unauthorized", 401)
+ return
+ }
+ var req transcode.RegisterRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ common.ErrorResp(c, err, 400)
+ return
+ }
+ w := transcode.Default().Registry.Register(&req, false)
+ common.SuccessResp(c, transcode.RegisterResponse{
+ WorkerID: w.ID,
+ HeartbeatInterval: 10,
+ ClaimStrategy: "pull",
+ ProtocolVersion: transcode.ProtocolVersion,
+ })
+}
+
+// WorkerHeartbeat POST /api/transcode/worker/heartbeat
+func WorkerHeartbeat(c *gin.Context) {
+ if !checkWorkerSecret(c) {
+ common.ErrorStrResp(c, "unauthorized", 401)
+ return
+ }
+ var req transcode.HeartbeatRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ common.ErrorResp(c, err, 400)
+ return
+ }
+ mgr := transcode.Default()
+ if _, ok := mgr.Registry.Heartbeat(&req); !ok {
+ common.SuccessResp(c, transcode.HeartbeatResponse{OK: false, Kick: true})
+ return
+ }
+ cancellations := mgr.Scheduler.PopCancellations(req.WorkerID)
+ common.SuccessResp(c, transcode.HeartbeatResponse{OK: true, Cancel: cancellations})
+}
+
+// WorkerClaim POST /api/transcode/worker/claim
+func WorkerClaim(c *gin.Context) {
+ if !checkWorkerSecret(c) {
+ common.ErrorStrResp(c, "unauthorized", 401)
+ return
+ }
+ var req transcode.ClaimRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ common.ErrorResp(c, err, 400)
+ return
+ }
+ wait := time.Duration(req.Wait) * time.Second
+ if wait > 30*time.Second {
+ wait = 30 * time.Second
+ }
+ jobs := transcode.Default().Scheduler.Claim(req.WorkerID, req.Slots, wait)
+ common.SuccessResp(c, transcode.ClaimResponse{Jobs: jobs})
+}
+
+// WorkerSegmentUpload PUT /api/transcode/worker/segment?job=&profile=&seq=&duration=&final=
+func WorkerSegmentUpload(c *gin.Context) {
+ jobID := c.Query("job")
+ profile := c.Query("profile")
+ seq, _ := strconv.Atoi(c.Query("seq"))
+ dur, _ := strconv.ParseFloat(c.Query("duration"), 64)
+ isFinal, _ := strconv.ParseBool(c.Query("final"))
+ job, ok := transcode.Default().Scheduler.Get(jobID)
+ if !ok {
+ common.ErrorStrResp(c, "job not found", 404)
+ return
+ }
+ // 校验 callback_token (Bearer),使用恒定时间比较避免时序侧信道
+ auth := c.GetHeader("Authorization")
+ expect := "Bearer " + job.CallbackToken
+ if subtle.ConstantTimeCompare([]byte(auth), []byte(expect)) != 1 {
+ common.ErrorStrResp(c, "unauthorized", 401)
+ return
+ }
+ // profile 会被拼进缓存目录路径,必须是白名单字符,防止 ../ 路径穿越
+ if !transcode.ValidProfileName(profile) {
+ common.ErrorStrResp(c, "invalid profile", 400)
+ return
+ }
+ // 只接受任务声明过的档位,避免持有 callback_token 的一方往任意档位目录写数据
+ if !jobHasProfile(job, profile) {
+ common.ErrorStrResp(c, "profile not declared for this job", 400)
+ return
+ }
+ defer c.Request.Body.Close()
+ _, err := transcode.Default().Cache.PutSegment(jobID, profile, seq, dur, c.Request.Body, isFinal)
+ if err != nil {
+ switch err {
+ case transcode.ErrInvalidProfile, transcode.ErrInvalidJobID, transcode.ErrInvalidSegment:
+ common.ErrorResp(c, err, 400)
+ case transcode.ErrSegmentTooLarge:
+ common.ErrorResp(c, err, 413)
+ default:
+ common.ErrorResp(c, err, 500)
+ }
+ return
+ }
+ if seq == 0 {
+ job.MarkReady()
+ }
+ c.Status(http.StatusNoContent)
+}
+
+// WorkerJobFinish POST /api/transcode/worker/job/finish
+func WorkerJobFinish(c *gin.Context) {
+ var req transcode.FinishRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ common.ErrorResp(c, err, 400)
+ return
+ }
+ job, ok := transcode.Default().Scheduler.Get(req.JobID)
+ if !ok {
+ common.ErrorStrResp(c, "job not found", 404)
+ return
+ }
+ auth := c.GetHeader("Authorization")
+ if !tokenMatches("Bearer "+job.CallbackToken, auth) {
+ common.ErrorStrResp(c, "unauthorized", 401)
+ return
+ }
+ if err := transcode.Default().Scheduler.Finish(&req); err != nil {
+ common.ErrorResp(c, err, 500)
+ return
+ }
+ common.SuccessResp(c)
+}
+
+// ListTranscodeWorkers GET /api/admin/transcode/workers (admin)
+func ListTranscodeWorkers(c *gin.Context) {
+ common.SuccessResp(c, transcode.Default().Registry.List())
+}
+
+// ListTranscodeJobs GET /api/admin/transcode/jobs (admin)
+func ListTranscodeJobs(c *gin.Context) {
+ common.SuccessResp(c, transcode.Default().Scheduler.ListJobs())
+}
+
+// CancelTranscodeJob POST /api/admin/transcode/cancel
+func CancelTranscodeJob(c *gin.Context) {
+ var req struct {
+ JobID string `json:"job_id" binding:"required"`
+ }
+ if err := c.ShouldBindJSON(&req); err != nil {
+ common.ErrorResp(c, err, 400)
+ return
+ }
+ transcode.Default().Scheduler.Cancel(req.JobID)
+ common.SuccessResp(c)
+}
+
+// jobHasProfile 判断 profile 是否属于任务声明的输出档位
+func jobHasProfile(job *transcode.Job, profile string) bool {
+ if job == nil {
+ return false
+ }
+ for _, p := range job.Profiles {
+ if p.Name == profile {
+ return true
+ }
+ }
+ return false
+}
+
+// tokenMatches 恒定时间比较凭据,避免按字节比较造成的时序侧信道
+func tokenMatches(expected, got string) bool {
+ if expected == "" || got == "" {
+ return false
+ }
+ return subtle.ConstantTimeCompare([]byte(expected), []byte(got)) == 1
+}
+
+func checkWorkerSecret(c *gin.Context) bool {
+ auth := c.GetHeader("Authorization")
+ auth = strings.TrimPrefix(auth, "Bearer ")
+ return transcode.VerifyWorkerSecret(auth)
+}
diff --git a/server/mcp/handler_test.go b/server/mcp/handler_test.go
index d3369576..a7515773 100644
--- a/server/mcp/handler_test.go
+++ b/server/mcp/handler_test.go
@@ -200,8 +200,10 @@ func TestInitializeNegotiatesUnsupportedProtocolVersion(t *testing.T) {
func TestCreateSessionPrunesUserSessionLimit(t *testing.T) {
srv := newTestServer(nil)
var firstSessionID string
+ baseTime := time.Now()
for i := range maxUserSessions {
currentSession := srv.createSession(1)
+ currentSession.lastUsedAt = baseTime.Add(time.Duration(i))
if i == 0 {
firstSessionID = currentSession.id
}
@@ -223,8 +225,10 @@ func TestCreateSessionPrunesUserSessionLimit(t *testing.T) {
func TestCreateSessionPrunesGlobalSessionLimit(t *testing.T) {
srv := newTestServer(nil)
var firstSessionID string
+ baseTime := time.Now()
for i := range maxSessions {
currentSession := srv.createSession(uint(i + 1))
+ currentSession.lastUsedAt = baseTime.Add(time.Duration(i))
if i == 0 {
firstSessionID = currentSession.id
}
diff --git a/server/router.go b/server/router.go
index 4be0dd84..fe210e6e 100644
--- a/server/router.go
+++ b/server/router.go
@@ -67,6 +67,11 @@ func Init(e *gin.Engine) {
g.HEAD("/sad/:sid", middlewares.EmptyPathParse, middlewares.SharingIdParse, handles.SharingArchiveExtract)
g.HEAD("/sad/:sid/*path", middlewares.PathParse, middlewares.SharingIdParse, handles.SharingArchiveExtract)
+ // 转码播放:master/variant playlist 与切片下发;token 已包含在路径中作为鉴权
+ g.GET("/tc/:job/:token/master.m3u8", handles.TCMaster)
+ g.GET("/tc/:job/:token/:profile/playlist.m3u8", handles.TCPlaylist)
+ g.GET("/tc/:job/:token/:profile/:seg", handles.TCSegment)
+
api := g.Group("/api")
auth := api.Group("", middlewares.Auth(false))
webauthn := api.Group("/authn", middlewares.Authn)
@@ -76,6 +81,10 @@ func Init(e *gin.Engine) {
api.POST("/auth/login/ldap", handles.LoginLdap)
auth.GET("/me", handles.CurrentUser)
auth.POST("/me/update", handles.UpdateCurrent)
+ // 媒体应用密钥(每个登录用户都可以为自己的客户端签发密钥)
+ auth.GET("/me/appkey/list", handles.ListMediaAppKeys)
+ auth.POST("/me/appkey/create", handles.CreateMediaAppKey)
+ auth.POST("/me/appkey/delete", handles.DeleteMediaAppKey)
auth.GET("/me/sshkey/list", handles.ListMyPublicKey)
auth.POST("/me/sshkey/add", handles.AddMyPublicKey)
auth.POST("/me/sshkey/delete", handles.DeleteMyPublicKey)
@@ -100,12 +109,51 @@ func Init(e *gin.Engine) {
// no need auth
public := api.Group("/public")
public.Any("/settings", handles.PublicSettings)
+ public.GET("/media_status", handles.PublicMediaStatus)
+ // 封面代理与本地缩略图(媒体库页面使用,免鉴权)
+ public.GET("/media/proxy", handles.PublicMediaProxy)
+ public.GET("/media/thumb", handles.PublicMediaThumb)
public.Any("/offline_download_tools", handles.OfflineDownloadTools)
public.Any("/archive_extensions", handles.ArchiveExtensions)
public.Any("/init_status", handles.InitStatus)
public.POST("/init/setup", handles.InitSetup)
+ // 转码 Worker 协议端点(远程 Worker 节点对接,由共享密钥/token 鉴权,无需登录)
+ transcodeWorker := api.Group("/transcode/worker")
+ transcodeWorker.POST("/register", handles.WorkerRegister)
+ transcodeWorker.POST("/heartbeat", handles.WorkerHeartbeat)
+ transcodeWorker.POST("/claim", handles.WorkerClaim)
+ transcodeWorker.PUT("/segment", handles.WorkerSegmentUpload)
+ transcodeWorker.POST("/job/finish", handles.WorkerJobFinish)
+
+ // Emby 兼容 API(自带鉴权与开关判断,路径大小写由 handler 归一化)
+ // 使用 @ 前缀,避免与用户自己创建的 /emby、/rest 目录冲突
+ g.Any("/@emby/*path", handles.EmbyAPI)
+ g.Any("/@mediabrowser/*path", handles.EmbyAPI)
+ // OpenSubsonic API(自带鉴权与开关判断)
+ g.Any("/@rest/*path", handles.SubsonicAPI)
+
_fs(auth.Group("/fs"))
+ // 媒体库公开 API:与文件浏览一致,允许访客访问(访客仍受自身 base_path/权限限制)
+ mediaPublic := auth.Group("/fs/media", handles.RequireMediaEnabled)
+ mediaPublic.GET("/list", handles.PublicListMedia)
+ mediaPublic.GET("/item/:id", handles.PublicGetMedia)
+ mediaPublic.GET("/albums", handles.PublicListAlbums)
+ mediaPublic.GET("/album", handles.PublicGetAlbum)
+ mediaPublic.GET("/folders", handles.PublicListFolders)
+ mediaPublic.GET("/scan_paths", handles.PublicListScanPaths)
+ mediaPublic.GET("/strm", handles.GetMediaStrm)
+ mediaPublic.GET("/stream_url", handles.GetMediaStreamURL)
+ // 播放进度与收藏(与 Emby / Subsonic 共用同一份用户数据)
+ mediaPublic.GET("/userdata", handles.GetMediaUserDataBatch)
+ mediaPublic.GET("/progress", handles.GetMediaProgress)
+ mediaPublic.POST("/progress", handles.SaveMediaProgress)
+ mediaPublic.POST("/favorite", handles.SetMediaFavorite)
+ mediaPublic.GET("/favorites", handles.ListMediaFavoritesAPI)
+ mediaPublic.GET("/resume", handles.ListMediaResumeAPI)
+ // 阅读器批注(登录用户私有数据;访客只读返回空列表,写入直接 403)
+ mediaPublic.GET("/annotations", handles.ListMediaAnnotationsAPI)
+ mediaPublic.POST("/annotations", handles.SyncMediaAnnotationsAPI)
fsAndShare(api.Group("/fs", middlewares.Auth(true)))
_task(auth.Group("/task", middlewares.AuthNotGuest))
_sharing(auth.Group("/share", middlewares.AuthNotGuest))
@@ -190,6 +238,42 @@ func admin(g *gin.RouterGroup) {
scan.POST("/start", handles.StartManualScan)
scan.POST("/stop", handles.StopManualScan)
scan.GET("/progress", handles.GetManualScanProgress)
+
+ // 媒体库管理
+ mediaAdmin := g.Group("/media")
+ mediaAdmin.GET("/config/list", handles.ListMediaConfigs)
+ mediaAdmin.POST("/config/save", handles.SaveMediaConfig)
+ mediaAdmin.GET("/items", handles.ListMediaItemsAdmin)
+ mediaAdmin.POST("/items/update", handles.UpdateMediaItemAdmin)
+ mediaAdmin.POST("/items/delete", handles.DeleteMediaItemAdmin)
+ mediaAdmin.POST("/scan/start", handles.StartMediaScan)
+ mediaAdmin.GET("/scan/progress", handles.GetMediaScanProgress)
+ mediaAdmin.POST("/scrape/start", handles.StartMediaScrape)
+ mediaAdmin.GET("/scrape/stats", handles.GetMediaScrapeStats)
+ mediaAdmin.POST("/clear", handles.ClearMediaDB)
+ mediaAdmin.POST("/clear_scrape", handles.ClearMediaScrape)
+ mediaAdmin.POST("/delete_invalid", handles.DeleteInvalidMedia)
+ // 扫描路径管理
+ mediaAdmin.GET("/scan_paths", handles.ListMediaScanPaths)
+ mediaAdmin.POST("/scan_paths/create", handles.CreateMediaScanPath)
+ mediaAdmin.POST("/scan_paths/update", handles.UpdateMediaScanPath)
+ mediaAdmin.POST("/scan_paths/delete", handles.DeleteMediaScanPath)
+ mediaAdmin.POST("/scan_paths/clear", handles.ClearMediaScanPathDB)
+ // 导入 / 导出
+ mediaAdmin.GET("/export", handles.ExportMediaDB)
+ mediaAdmin.POST("/import", handles.ImportMediaDB)
+ // 媒体应用密钥(Subsonic/Emby/MoviePilot 等无交互客户端)
+ mediaAdmin.GET("/appkey/list", handles.ListMediaAppKeys)
+ mediaAdmin.POST("/appkey/create", handles.CreateMediaAppKey)
+ mediaAdmin.POST("/appkey/delete", handles.DeleteMediaAppKey)
+ // STRM 批量生成
+ mediaAdmin.POST("/strm/generate", handles.GenerateMediaStrm)
+
+ // 转码管理
+ transcodeAdmin := g.Group("/transcode")
+ transcodeAdmin.GET("/workers", handles.ListTranscodeWorkers)
+ transcodeAdmin.GET("/jobs", handles.ListTranscodeJobs)
+ transcodeAdmin.POST("/cancel", handles.CancelTranscodeJob)
}
func fsAndShare(g *gin.RouterGroup) {
@@ -198,6 +282,9 @@ func fsAndShare(g *gin.RouterGroup) {
a := g.Group("/archive")
a.Any("/meta", handles.FsArchiveMetaSplit)
a.Any("/list", handles.FsArchiveListSplit)
+
+ // 转码 - 用户态:申请播放
+ g.POST("/transcode/play", handles.RequireMediaEnabled, handles.TranscodePlay)
}
func _fs(g *gin.RouterGroup) {
@@ -234,6 +321,19 @@ func _fs(g *gin.RouterGroup) {
g.POST("/torrent/upload_parse", handles.UploadTorrentAndParse)
g.POST("/torrent/rapid_upload", handles.TorrentRapidUpload)
g.POST("/torrent/generate", handles.GenerateTorrentForPath)
+ // Unified transfer seed APIs. Legacy torrent routes above remain supported.
+ seed := g.Group("/seed")
+ seed.POST("/parse", handles.ParseSeed)
+ seed.POST("/upload_parse", handles.UploadSeedAndParse)
+ seed.POST("/generate", handles.GenerateSeedForPaths)
+ seed.POST("/convert", handles.ConvertSeed)
+ seed.POST("/diagnose", handles.DiagnoseSeed)
+ seed.POST("/capabilities", handles.SeedCapabilities)
+ seed.POST("/rapid_upload", handles.QuickSaveSeed)
+ seed.POST("/offline_download", handles.QuickSaveSeed)
+ seed.POST("/update", handles.UpdateSeed)
+ seed.POST("/quick_save", handles.QuickSaveSeed)
+ seed.POST("/update_channels", handles.UpdateSeedChannels)
// Direct upload (client-side upload to storage)
g.POST("/get_direct_upload_info", handles.FsGetDirectUploadInfo)
}
diff --git a/server/s3/redirect.go b/server/s3/redirect.go
index 6d8d430b..b61a9df1 100644
--- a/server/s3/redirect.go
+++ b/server/s3/redirect.go
@@ -159,9 +159,18 @@ func s3RequestAuthorized(r *http.Request, authPairs map[string]string) bool {
if len(authPairs) == 0 {
return true
}
- result := signature.V4SignVerify(r)
+ // Verify against the keys this server was configured with. V4SignVerify and
+ // V2SignVerify read the signature package's process-wide key store, which
+ // gofakes3 never writes (keys are kept per instance), so they always
+ // returned InvalidAccessKeyId and the 302/307 direct-transfer redirects
+ // never ran. Same V4-then-V2 order the auth middleware uses.
+ lookup := func(accessKey string) (string, bool) {
+ secret, ok := authPairs[accessKey]
+ return secret, ok
+ }
+ result := signature.V4SignVerifyWithLookup(r, lookup)
if result == signature.ErrUnsupportAlgorithm {
- result = signature.V2SignVerify(r)
+ result = signature.V2SignVerifyWithLookup(r, lookup)
}
return result == signature.ErrNone
}
diff --git a/server/static/static.go b/server/static/static.go
index 38eee2df..200dc397 100644
--- a/server/static/static.go
+++ b/server/static/static.go
@@ -7,6 +7,7 @@ import (
"io"
"io/fs"
"net/http"
+ "net/url"
"os"
"strings"
@@ -20,8 +21,8 @@ import (
type ManifestIcon struct {
Src string `json:"src"`
- Sizes string `json:"sizes"`
- Type string `json:"type"`
+ Sizes string `json:"sizes,omitempty"`
+ Type string `json:"type,omitempty"`
}
type Manifest struct {
@@ -131,6 +132,19 @@ func UpdateIndex() {
utils.Log.Debug("Index.html update completed")
}
+func manifestIcon(src string) ManifestIcon {
+ icon := ManifestIcon{Src: src, Sizes: "any"}
+ u, err := url.Parse(src)
+ if err != nil {
+ return icon
+ }
+ mimeType := utils.GetMimeType(u.Path)
+ if strings.HasPrefix(mimeType, "image/") {
+ icon.Type = mimeType
+ }
+ return icon
+}
+
func ManifestJSON(c *gin.Context) {
// Get site configuration to ensure consistent base path handling
siteConfig := getSiteConfig()
@@ -156,13 +170,7 @@ func ManifestJSON(c *gin.Context) {
Scope: scope,
StartURL: startURL,
Name: siteTitle,
- Icons: []ManifestIcon{
- {
- Src: logoUrl,
- Sizes: "512x512",
- Type: "image/png",
- },
- },
+ Icons: []ManifestIcon{manifestIcon(logoUrl)},
}
c.Header("Content-Type", "application/json")
diff --git a/server/webdav/file.go b/server/webdav/file.go
index ea609973..ed4c3a70 100644
--- a/server/webdav/file.go
+++ b/server/webdav/file.go
@@ -6,9 +6,12 @@ package webdav
import (
"context"
+ stderrors "errors"
+ "fmt"
"net/http"
"path"
"path/filepath"
+ "strings"
"github.com/OpenListTeam/OpenList/v4/internal/conf"
"github.com/OpenListTeam/OpenList/v4/internal/errs"
@@ -16,7 +19,10 @@ import (
"github.com/OpenListTeam/OpenList/v4/internal/model"
"github.com/OpenListTeam/OpenList/v4/internal/op"
"github.com/OpenListTeam/OpenList/v4/server/common"
+ "github.com/google/uuid"
"github.com/pkg/errors"
+ log "github.com/sirupsen/logrus"
+ "gorm.io/gorm"
)
// slashClean is equivalent to but slightly more efficient than
@@ -28,6 +34,271 @@ func slashClean(name string) string {
return path.Clean(name)
}
+func moveResource(ctx context.Context, src, dstDir, srcDir, srcName, dstName string) error {
+ if srcDir == dstDir {
+ return fs.Rename(ctx, src, dstName)
+ }
+ if _, err := fs.Move(context.WithValue(ctx, conf.NoTaskKey, struct{}{}), src, dstDir); err != nil {
+ return err
+ }
+ if srcName != dstName {
+ return fs.Rename(ctx, path.Join(dstDir, srcName), dstName)
+ }
+ return nil
+}
+
+type fileMutation struct {
+ rollback func() error
+ finalize func()
+ backupPaths []string
+}
+
+type fileBackup struct {
+ originalPath string
+ backupPath string
+ renamed bool
+}
+
+func stagingContext(ctx context.Context) context.Context {
+ ctx = context.WithValue(ctx, conf.NoTaskKey, struct{}{})
+ return context.WithValue(ctx, conf.SkipHookKey, struct{}{})
+}
+
+func removeIfExists(ctx context.Context, name string) error {
+ if _, err := fs.Get(ctx, name, &fs.GetArgs{}); err != nil {
+ if errs.IsObjectNotFound(err) {
+ return nil
+ }
+ return err
+ }
+ return fs.Remove(ctx, name)
+}
+
+func cleanupAfterFailure(ctx context.Context, name string, cause error) error {
+ if err := removeIfExists(stagingContext(context.WithoutCancel(ctx)), name); err != nil {
+ log.WithError(err).Errorf("failed to clean up WebDAV staging path %s", name)
+ return fmt.Errorf("%w; failed to clean up %s: %w", cause, name, err)
+ }
+ return cause
+}
+
+func newFileBackup(ctx context.Context, originalPath string, keepOriginal bool) (*fileBackup, error) {
+ backupPath := path.Join(path.Dir(originalPath), ".openlist-backup-"+uuid.NewString())
+ backup := &fileBackup{originalPath: originalPath, backupPath: backupPath}
+ if !keepOriginal {
+ err := fs.Rename(stagingContext(ctx), originalPath, path.Base(backupPath))
+ if err == nil {
+ backup.renamed = true
+ return backup, nil
+ }
+ if !errs.IsNotImplementError(err) && !errs.IsNotSupportError(err) {
+ return nil, errors.WithMessage(err, "failed to rename object into rollback backup")
+ }
+ }
+ if _, err := fs.CopyTo(stagingContext(ctx), originalPath, path.Dir(originalPath), path.Base(backupPath)); err != nil {
+ return nil, cleanupAfterFailure(ctx, backupPath, errors.WithMessage(err, "failed to copy object into rollback backup"))
+ }
+ return backup, nil
+}
+
+func (b *fileBackup) restore(ctx context.Context) error {
+ if b == nil {
+ return nil
+ }
+ ctx = context.WithoutCancel(ctx)
+ if err := removeIfExists(ctx, b.originalPath); err != nil {
+ return err
+ }
+ if b.renamed {
+ if err := fs.Rename(ctx, b.backupPath, path.Base(b.originalPath)); err != nil {
+ return errors.WithMessagef(err, "failed to restore %s from %s", b.originalPath, b.backupPath)
+ }
+ return nil
+ }
+ if _, err := fs.CopyTo(context.WithValue(ctx, conf.NoTaskKey, struct{}{}), b.backupPath, path.Dir(b.originalPath), path.Base(b.originalPath)); err != nil {
+ return errors.WithMessagef(err, "failed to restore %s from %s", b.originalPath, b.backupPath)
+ }
+ if err := fs.Remove(stagingContext(ctx), b.backupPath); err != nil {
+ if !errs.IsObjectNotFound(err) {
+ log.Warnf("failed to remove restored WebDAV backup %s: %v", b.backupPath, err)
+ }
+ }
+ return nil
+}
+
+func (b *fileBackup) discard(ctx context.Context) {
+ if b == nil {
+ return
+ }
+ if err := removeIfExists(stagingContext(context.WithoutCancel(ctx)), b.backupPath); err != nil {
+ log.Warnf("failed to remove WebDAV backup %s: %v", b.backupPath, err)
+ }
+}
+
+func installStagedObject(ctx context.Context, stagePath, dstDir, dstName string) error {
+ if err := fs.Rename(ctx, stagePath, dstName); err == nil {
+ return nil
+ } else if !errs.IsNotImplementError(err) && !errs.IsNotSupportError(err) {
+ return err
+ }
+ copyCtx := context.WithValue(ctx, conf.NoTaskKey, struct{}{})
+ if _, err := fs.CopyTo(copyCtx, stagePath, dstDir, dstName); err != nil {
+ return err
+ }
+ if err := fs.Remove(stagingContext(context.WithoutCancel(ctx)), stagePath); err != nil {
+ log.Warnf("failed to remove installed WebDAV staging object %s: %v", stagePath, err)
+ }
+ return nil
+}
+
+func copyWithRollback(ctx context.Context, src, dstDir, dstName string, dstExisted, shallow bool) (*fileMutation, error) {
+ stagePath := path.Join(dstDir, ".openlist-copy-"+uuid.NewString())
+ var err error
+ if shallow {
+ err = fs.MakeDir(stagingContext(ctx), stagePath)
+ } else {
+ _, err = fs.CopyTo(stagingContext(ctx), src, dstDir, path.Base(stagePath))
+ }
+ if err != nil {
+ return nil, cleanupAfterFailure(ctx, stagePath, err)
+ }
+ dstPath := path.Join(dstDir, dstName)
+ var dstBackup *fileBackup
+ if dstExisted {
+ var err error
+ dstBackup, err = newFileBackup(ctx, dstPath, false)
+ if err != nil {
+ return nil, cleanupAfterFailure(ctx, stagePath, err)
+ }
+ if !dstBackup.renamed {
+ if err := removeIfExists(ctx, dstPath); err != nil {
+ mutation := copyMutation(ctx, dstPath, stagePath, dstBackup)
+ return mutation, errors.WithMessage(err, "failed to remove destination after creating rollback copy")
+ }
+ }
+ }
+ mutation := copyMutation(ctx, dstPath, stagePath, dstBackup)
+ if err := installStagedObject(ctx, stagePath, dstDir, dstName); err != nil {
+ return mutation, errors.WithMessage(err, "failed to install staged COPY")
+ }
+ return mutation, nil
+}
+
+func copyMutation(ctx context.Context, dstPath, stagePath string, dstBackup *fileBackup) *fileMutation {
+ paths := []string{stagePath}
+ if dstBackup != nil {
+ paths = append(paths, dstBackup.backupPath)
+ }
+ return &fileMutation{
+ backupPaths: paths,
+ rollback: func() error {
+ var rollbackErrs []error
+ if err := removeIfExists(context.WithoutCancel(ctx), dstPath); err != nil {
+ rollbackErrs = append(rollbackErrs, err)
+ }
+ if err := dstBackup.restore(ctx); err != nil {
+ rollbackErrs = append(rollbackErrs, err)
+ }
+ if err := removeIfExists(stagingContext(context.WithoutCancel(ctx)), stagePath); err != nil {
+ rollbackErrs = append(rollbackErrs, err)
+ }
+ return stderrors.Join(rollbackErrs...)
+ },
+ finalize: func() {
+ if err := removeIfExists(stagingContext(context.WithoutCancel(ctx)), stagePath); err != nil {
+ log.Warnf("failed to remove WebDAV COPY staging object %s: %v", stagePath, err)
+ }
+ dstBackup.discard(ctx)
+ },
+ }
+}
+
+func moveWithRollback(ctx context.Context, src, dst, dstDir, srcDir, srcName, dstName string, dstExisted bool) (*fileMutation, error) {
+ // Keep a source copy so a partially completed cross-storage MOVE can be undone.
+ srcBackup, err := newFileBackup(ctx, src, true)
+ if err != nil {
+ return nil, err
+ }
+ var dstBackup *fileBackup
+ if dstExisted {
+ dstBackup, err = newFileBackup(ctx, dst, false)
+ if err != nil {
+ srcBackup.discard(ctx)
+ return nil, err
+ }
+ if !dstBackup.renamed {
+ if err := removeIfExists(ctx, dst); err != nil {
+ mutation := moveMutation(ctx, dst, dstDir, srcName, dstName, srcBackup, dstBackup)
+ return mutation, errors.WithMessage(err, "failed to remove destination after creating rollback copy")
+ }
+ }
+ }
+ mutation := moveMutation(ctx, dst, dstDir, srcName, dstName, srcBackup, dstBackup)
+ if err := moveResource(ctx, src, dstDir, srcDir, srcName, dstName); err != nil {
+ return mutation, err
+ }
+ return mutation, nil
+}
+
+func moveMutation(ctx context.Context, dst, dstDir, srcName, dstName string, srcBackup, dstBackup *fileBackup) *fileMutation {
+ paths := []string{srcBackup.backupPath}
+ if dstBackup != nil {
+ paths = append(paths, dstBackup.backupPath)
+ }
+ return &fileMutation{
+ backupPaths: paths,
+ rollback: func() error {
+ rollbackCtx := context.WithoutCancel(ctx)
+ var rollbackErrs []error
+ candidates := []string{dst}
+ if srcName != dstName {
+ candidates = append(candidates, path.Join(dstDir, srcName))
+ }
+ for _, candidate := range candidates {
+ if err := removeIfExists(rollbackCtx, candidate); err != nil {
+ rollbackErrs = append(rollbackErrs, err)
+ }
+ }
+ if err := srcBackup.restore(rollbackCtx); err != nil {
+ rollbackErrs = append(rollbackErrs, err)
+ }
+ if err := dstBackup.restore(rollbackCtx); err != nil {
+ rollbackErrs = append(rollbackErrs, err)
+ }
+ return stderrors.Join(rollbackErrs...)
+ },
+ finalize: func() {
+ srcBackup.discard(ctx)
+ dstBackup.discard(ctx)
+ },
+ }
+}
+
+func runDeadPropsFileMutation(ctx context.Context, update func(*gorm.DB) error, apply func() (*fileMutation, error)) error {
+ var mutation *fileMutation
+ err := withDeadPropsTransaction(ctx, func(tx *gorm.DB) error {
+ if err := update(tx); err != nil {
+ return err
+ }
+ var err error
+ mutation, err = apply()
+ return err
+ })
+ if err != nil {
+ if mutation != nil && mutation.rollback != nil {
+ if rollbackErr := mutation.rollback(); rollbackErr != nil {
+ log.WithError(rollbackErr).Errorf("failed to compensate WebDAV file operation; backups may remain at %s", strings.Join(mutation.backupPaths, ", "))
+ return fmt.Errorf("%w; filesystem compensation failed: %w", err, rollbackErr)
+ }
+ }
+ return err
+ }
+ if mutation != nil && mutation.finalize != nil {
+ mutation.finalize()
+ }
+ return nil
+}
+
// moveFiles moves files and/or directories from src to dst.
// Individual item permission checks are skipped for performance reasons.
//
@@ -55,21 +326,41 @@ func moveFiles(ctx context.Context, src, dst string, overwrite bool) (status int
if !common.CanWrite(user, srcMeta, srcDir) || !common.CanWrite(user, dstMeta, dstDir) {
return http.StatusForbidden, nil
}
- if srcDir == dstDir {
- err = fs.Rename(ctx, src, dstName)
- } else {
- _, err = fs.Move(context.WithValue(ctx, conf.NoTaskKey, struct{}{}), src, dstDir)
- if err != nil {
- return http.StatusInternalServerError, err
- }
- if srcName != dstName {
- err = fs.Rename(ctx, path.Join(dstDir, srcName), dstName)
+ if _, err = fs.Get(ctx, src, &fs.GetArgs{}); err != nil {
+ if errs.IsObjectNotFound(err) {
+ return http.StatusNotFound, err
}
+ return http.StatusInternalServerError, err
}
+ if dstDirInfo, err := fs.Get(ctx, dstDir, &fs.GetArgs{}); err != nil {
+ if errs.IsObjectNotFound(err) {
+ return http.StatusConflict, err
+ }
+ return http.StatusMethodNotAllowed, err
+ } else if !dstDirInfo.IsDir() {
+ return http.StatusConflict, nil
+ }
+ dstExisted := false
+ if _, err = fs.Get(ctx, dst, &fs.GetArgs{}); err == nil {
+ dstExisted = true
+ if !overwrite {
+ return http.StatusPreconditionFailed, nil
+ }
+ } else if !errs.IsObjectNotFound(err) {
+ return http.StatusInternalServerError, err
+ }
+ err = runDeadPropsFileMutation(ctx,
+ func(tx *gorm.DB) error { return moveDeadProps(tx, src, dst) },
+ func() (*fileMutation, error) {
+ return moveWithRollback(ctx, src, dst, dstDir, srcDir, srcName, dstName, dstExisted && overwrite)
+ },
+ )
if err != nil {
return http.StatusInternalServerError, err
}
- // TODO if there are no files copy, should return 204
+ if dstExisted {
+ return http.StatusNoContent, nil
+ }
return http.StatusCreated, nil
}
@@ -77,9 +368,10 @@ func moveFiles(ctx context.Context, src, dst string, overwrite bool) (status int
// Individual item permission checks are skipped for performance reasons.
//
// See section 9.8.5 for when various HTTP status codes apply.
-func copyFiles(ctx context.Context, src, dst string, overwrite bool) (status int, err error) {
+func copyFiles(ctx context.Context, src, dst string, overwrite bool, depth int) (status int, err error) {
srcDir := path.Dir(src)
dstDir := path.Dir(dst)
+ dstName := path.Base(dst)
user := ctx.Value(conf.UserKey).(*model.User)
if !user.CanCopy() {
return http.StatusForbidden, nil
@@ -98,11 +390,44 @@ func copyFiles(ctx context.Context, src, dst string, overwrite bool) (status int
if !common.CanWrite(user, dstMeta, dstDir) {
return http.StatusForbidden, nil
}
- _, err = fs.Copy(context.WithValue(ctx, conf.NoTaskKey, struct{}{}), src, dstDir)
+ srcInfo, err := fs.Get(ctx, src, &fs.GetArgs{})
+ if err != nil {
+ if errs.IsObjectNotFound(err) {
+ return http.StatusNotFound, err
+ }
+ return http.StatusInternalServerError, err
+ }
+ shallow := depth == 0 && srcInfo.IsDir()
+ if dstDirInfo, err := fs.Get(ctx, dstDir, &fs.GetArgs{}); err != nil {
+ if errs.IsObjectNotFound(err) {
+ return http.StatusConflict, err
+ }
+ return http.StatusMethodNotAllowed, err
+ } else if !dstDirInfo.IsDir() {
+ return http.StatusConflict, nil
+ }
+ dstExisted := false
+ if _, err = fs.Get(ctx, dst, &fs.GetArgs{}); err == nil {
+ dstExisted = true
+ if !overwrite {
+ return http.StatusPreconditionFailed, nil
+ }
+ } else if !errs.IsObjectNotFound(err) {
+ return http.StatusInternalServerError, err
+ }
+
+ err = runDeadPropsFileMutation(ctx,
+ func(tx *gorm.DB) error { return copyDeadProps(tx, src, dst) },
+ func() (*fileMutation, error) {
+ return copyWithRollback(ctx, src, dstDir, dstName, dstExisted && overwrite, shallow)
+ },
+ )
if err != nil {
return http.StatusInternalServerError, err
}
- // TODO if there are no files copy, should return 204
+ if dstExisted {
+ return http.StatusNoContent, nil
+ }
return http.StatusCreated, nil
}
diff --git a/server/webdav/lock.go b/server/webdav/lock.go
index 344ac5ce..0d279e06 100644
--- a/server/webdav/lock.go
+++ b/server/webdav/lock.go
@@ -109,13 +109,15 @@ type LockDetails struct {
// ZeroDepth is whether the lock has zero depth. If it does not have zero
// depth, it has infinite depth.
ZeroDepth bool
+ // Shared is whether the lock may coexist with other shared locks on Root.
+ Shared bool
}
// NewMemLS returns a new in-memory LockSystem.
func NewMemLS() LockSystem {
return &memLS{
byName: make(map[string]*memLSNode),
- byToken: make(map[string]*memLSNode),
+ byToken: make(map[string]*memLSLock),
gen: uint64(time.Now().Unix()),
}
}
@@ -123,7 +125,7 @@ func NewMemLS() LockSystem {
type memLS struct {
mu sync.Mutex
byName map[string]*memLSNode
- byToken map[string]*memLSNode
+ byToken map[string]*memLSLock
gen uint64
// byExpiry only contains those nodes whose LockDetails have a finite
// Duration and are yet to expire.
@@ -149,7 +151,7 @@ func (m *memLS) Confirm(now time.Time, name0, name1 string, conditions ...Condit
defer m.mu.Unlock()
m.collectExpiredNodes(now)
- var n0, n1 *memLSNode
+ var n0, n1 *memLSLock
if name0 != "" {
if n0 = m.lookup(slashClean(name0), conditions...); n0 == nil {
return nil, ErrConfirmationFailed
@@ -184,48 +186,43 @@ func (m *memLS) Confirm(now time.Time, name0, name1 string, conditions ...Condit
}, nil
}
-// lookup returns the node n that locks the named resource, provided that n
-// matches at least one of the given conditions and that lock isn't held by
-// another party. Otherwise, it returns nil.
-//
-// n may be a parent of the named resource, if n is an infinite depth lock.
-func (m *memLS) lookup(name string, conditions ...Condition) (n *memLSNode) {
+func (m *memLS) lookup(name string, conditions ...Condition) *memLSLock {
// TODO: support Condition.Not and Condition.ETag.
for _, c := range conditions {
- n = m.byToken[c.Token]
- if n == nil || n.held {
+ lock := m.byToken[c.Token]
+ if lock == nil || lock.held {
continue
}
- if name == n.details.Root {
- return n
+ if name == lock.details.Root {
+ return lock
}
- if n.details.ZeroDepth {
+ if lock.details.ZeroDepth {
continue
}
- if n.details.Root == "/" || strings.HasPrefix(name, n.details.Root+"/") {
- return n
+ if lock.details.Root == "/" || strings.HasPrefix(name, lock.details.Root+"/") {
+ return lock
}
}
return nil
}
-func (m *memLS) hold(n *memLSNode) {
- if n.held {
+func (m *memLS) hold(lock *memLSLock) {
+ if lock.held {
panic("webdav: memLS inconsistent held state")
}
- n.held = true
- if n.details.Duration >= 0 && n.byExpiryIndex >= 0 {
- heap.Remove(&m.byExpiry, n.byExpiryIndex)
+ lock.held = true
+ if lock.details.Duration >= 0 && lock.byExpiryIndex >= 0 {
+ heap.Remove(&m.byExpiry, lock.byExpiryIndex)
}
}
-func (m *memLS) unhold(n *memLSNode) {
- if !n.held {
+func (m *memLS) unhold(lock *memLSLock) {
+ if !lock.held {
panic("webdav: memLS inconsistent held state")
}
- n.held = false
- if n.details.Duration >= 0 {
- heap.Push(&m.byExpiry, n)
+ lock.held = false
+ if lock.details.Duration >= 0 {
+ heap.Push(&m.byExpiry, lock)
}
}
@@ -235,18 +232,23 @@ func (m *memLS) Create(now time.Time, details LockDetails) (string, error) {
m.collectExpiredNodes(now)
details.Root = slashClean(details.Root)
- if !m.canCreate(details.Root, details.ZeroDepth) {
+ if !m.canCreate(details.Root, details.ZeroDepth, details.Shared) {
return "", ErrLocked
}
- n := m.create(details.Root)
- n.token = m.nextToken()
- m.byToken[n.token] = n
- n.details = details
- if n.details.Duration >= 0 {
- n.expiry = now.Add(n.details.Duration)
- heap.Push(&m.byExpiry, n)
+ node := m.create(details.Root)
+ lock := &memLSLock{
+ token: m.nextToken(),
+ details: details,
+ node: node,
+ byExpiryIndex: -1,
}
- return n.token, nil
+ node.locks[lock.token] = lock
+ m.byToken[lock.token] = lock
+ if lock.details.Duration >= 0 {
+ lock.expiry = now.Add(lock.details.Duration)
+ heap.Push(&m.byExpiry, lock)
+ }
+ return lock.token, nil
}
func (m *memLS) Refresh(now time.Time, token string, duration time.Duration) (LockDetails, error) {
@@ -254,22 +256,22 @@ func (m *memLS) Refresh(now time.Time, token string, duration time.Duration) (Lo
defer m.mu.Unlock()
m.collectExpiredNodes(now)
- n := m.byToken[token]
- if n == nil {
+ lock := m.byToken[token]
+ if lock == nil {
return LockDetails{}, ErrNoSuchLock
}
- if n.held {
+ if lock.held {
return LockDetails{}, ErrLocked
}
- if n.byExpiryIndex >= 0 {
- heap.Remove(&m.byExpiry, n.byExpiryIndex)
+ if lock.byExpiryIndex >= 0 {
+ heap.Remove(&m.byExpiry, lock.byExpiryIndex)
}
- n.details.Duration = duration
- if n.details.Duration >= 0 {
- n.expiry = now.Add(n.details.Duration)
- heap.Push(&m.byExpiry, n)
+ lock.details.Duration = duration
+ if lock.details.Duration >= 0 {
+ lock.expiry = now.Add(lock.details.Duration)
+ heap.Push(&m.byExpiry, lock)
}
- return n.details, nil
+ return lock.details, nil
}
func (m *memLS) Unlock(now time.Time, token string) error {
@@ -277,34 +279,42 @@ func (m *memLS) Unlock(now time.Time, token string) error {
defer m.mu.Unlock()
m.collectExpiredNodes(now)
- n := m.byToken[token]
- if n == nil {
+ lock := m.byToken[token]
+ if lock == nil {
return ErrNoSuchLock
}
- if n.held {
+ if lock.held {
return ErrLocked
}
- m.remove(n)
+ m.remove(lock)
return nil
}
-func (m *memLS) canCreate(name string, zeroDepth bool) bool {
+func (m *memLS) canCreate(name string, zeroDepth bool, shared ...bool) bool {
+ wantShared := len(shared) > 0 && shared[0]
return walkToRoot(name, func(name0 string, first bool) bool {
n := m.byName[name0]
if n == nil {
return true
}
if first {
- if n.token != "" {
- // The target node is already locked.
- return false
+ if len(n.locks) != 0 {
+ if !wantShared || (!zeroDepth && n.refCount != len(n.locks)) {
+ return false
+ }
+ for _, lock := range n.locks {
+ if !lock.details.Shared || lock.details.ZeroDepth != zeroDepth {
+ return false
+ }
+ }
+ return true
}
- if !zeroDepth {
+ if !zeroDepth && n.refCount > 0 {
// The requested lock depth is infinite, and the fact that n exists
// (n != nil) means that a descendent of the target node is locked.
return false
}
- } else if n.token != "" && !n.details.ZeroDepth {
+ } else if n.hasInfiniteLock() {
// An ancestor of the target node is locked with infinite depth.
return false
}
@@ -317,10 +327,8 @@ func (m *memLS) create(name string) (ret *memLSNode) {
n := m.byName[name0]
if n == nil {
n = &memLSNode{
- details: LockDetails{
- Root: name0,
- },
- byExpiryIndex: -1,
+ details: LockDetails{Root: name0},
+ locks: make(map[string]*memLSLock),
}
m.byName[name0] = n
}
@@ -333,10 +341,10 @@ func (m *memLS) create(name string) (ret *memLSNode) {
return ret
}
-func (m *memLS) remove(n *memLSNode) {
- delete(m.byToken, n.token)
- n.token = ""
- walkToRoot(n.details.Root, func(name0 string, first bool) bool {
+func (m *memLS) remove(lock *memLSLock) {
+ delete(m.byToken, lock.token)
+ delete(lock.node.locks, lock.token)
+ walkToRoot(lock.details.Root, func(name0 string, first bool) bool {
x := m.byName[name0]
x.refCount--
if x.refCount == 0 {
@@ -344,8 +352,8 @@ func (m *memLS) remove(n *memLSNode) {
}
return true
})
- if n.byExpiryIndex >= 0 {
- heap.Remove(&m.byExpiry, n.byExpiryIndex)
+ if lock.byExpiryIndex >= 0 {
+ heap.Remove(&m.byExpiry, lock.byExpiryIndex)
}
}
@@ -366,24 +374,33 @@ func walkToRoot(name string, f func(name0 string, first bool) bool) bool {
}
type memLSNode struct {
- // details are the lock metadata. Even if this node's name is not explicitly locked,
- // details.Root will still equal the node's name.
+ // details identifies the resource path represented by this node.
details LockDetails
- // token is the unique identifier for this node's lock. An empty token means that
- // this node is not explicitly locked.
- token string
- // refCount is the number of self-or-descendent nodes that are explicitly locked.
+ // locks contains the independent lock instances rooted at this resource.
+ locks map[string]*memLSLock
+ // refCount is the number of self-or-descendent lock instances.
refCount int
- // expiry is when this node's lock expires.
- expiry time.Time
- // byExpiryIndex is the index of this node in memLS.byExpiry. It is -1
- // if this node does not expire, or has expired.
- byExpiryIndex int
- // held is whether this node's lock is actively held by a Confirm call.
- held bool
}
-type byExpiry []*memLSNode
+type memLSLock struct {
+ token string
+ details LockDetails
+ node *memLSNode
+ expiry time.Time
+ byExpiryIndex int
+ held bool
+}
+
+func (n *memLSNode) hasInfiniteLock() bool {
+ for _, lock := range n.locks {
+ if !lock.details.ZeroDepth {
+ return true
+ }
+ }
+ return false
+}
+
+type byExpiry []*memLSLock
func (b *byExpiry) Len() int {
return len(*b)
@@ -400,18 +417,18 @@ func (b *byExpiry) Swap(i, j int) {
}
func (b *byExpiry) Push(x interface{}) {
- n := x.(*memLSNode)
- n.byExpiryIndex = len(*b)
- *b = append(*b, n)
+ lock := x.(*memLSLock)
+ lock.byExpiryIndex = len(*b)
+ *b = append(*b, lock)
}
func (b *byExpiry) Pop() interface{} {
i := len(*b) - 1
- n := (*b)[i]
+ lock := (*b)[i]
(*b)[i] = nil
- n.byExpiryIndex = -1
+ lock.byExpiryIndex = -1
*b = (*b)[:i]
- return n
+ return lock
}
const infiniteTimeout = -1
diff --git a/server/webdav/lock_test.go b/server/webdav/lock_test.go
index e7fe9706..8353c158 100644
--- a/server/webdav/lock_test.go
+++ b/server/webdav/lock_test.go
@@ -182,7 +182,10 @@ func TestMemLSLookup(t *testing.T) {
goodToken := ""
base := m.byName[baseName]
if base != nil && (suffix == "" || !lockTestZeroDepth(baseName)) {
- goodToken = base.token
+ for token := range base.locks {
+ goodToken = token
+ break
+ }
}
for _, token := range []string{badToken, goodToken} {
@@ -191,9 +194,9 @@ func TestMemLSLookup(t *testing.T) {
}
got := m.lookup(name, Condition{Token: token})
- want := base
- if token == badToken {
- want = nil
+ var want *memLSLock
+ if token != badToken {
+ want = m.byToken[token]
}
if got != want {
t.Errorf("name=%-20qtoken=%q (bad=%t): got %p, want %p",
@@ -449,6 +452,46 @@ func TestMemLSExpiry(t *testing.T) {
}
}
+func TestMemLSSharedLockExpiry(t *testing.T) {
+ m := NewMemLS().(*memLS)
+ now := time.Unix(0, 0)
+ first, err := m.Create(now, LockDetails{Root: "/shared", Duration: infiniteTimeout, ZeroDepth: true, Shared: true})
+ if err != nil {
+ t.Fatal(err)
+ }
+ second, err := m.Create(now, LockDetails{Root: "/shared", Duration: 2 * time.Second, ZeroDepth: true, Shared: true})
+ if err != nil {
+ t.Fatal(err)
+ }
+ third, err := m.Create(now, LockDetails{Root: "/shared", Duration: 4 * time.Second, ZeroDepth: true, Shared: true})
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if got := m.byToken[first].details.Duration; got != infiniteTimeout {
+ t.Fatalf("first lock duration = %v, want infinite", got)
+ }
+ if got := m.byToken[second].details.Duration; got != 2*time.Second {
+ t.Fatalf("second lock duration = %v, want 2s", got)
+ }
+ if got := m.byToken[third].details.Duration; got != 4*time.Second {
+ t.Fatalf("third lock duration = %v, want 4s", got)
+ }
+
+ m.mu.Lock()
+ m.collectExpiredNodes(now.Add(2 * time.Second))
+ m.mu.Unlock()
+ if m.byToken[second] != nil {
+ t.Fatal("second shared lock did not expire independently")
+ }
+ if m.byToken[first] == nil || m.byToken[third] == nil {
+ t.Fatal("independent shared lock expired with second lock")
+ }
+ if err := m.consistent(); err != nil {
+ t.Fatal(err)
+ }
+}
+
func TestMemLS(t *testing.T) {
now := time.Unix(0, 0)
m := NewMemLS().(*memLS)
@@ -578,8 +621,10 @@ func (m *memLS) consistent() error {
// so strings.HasPrefix is equivalent to self-or-descendent name match.
// We don't have to worry about "/foo/bar" being a false positive match
// for "/foo/b".
- if strings.HasPrefix(name0, name) && n0.token != "" {
- list = append(list, name0)
+ if strings.HasPrefix(name0, name) {
+ for range n0.locks {
+ list = append(list, name0)
+ }
}
}
if n.refCount != len(list) {
@@ -588,50 +633,34 @@ func (m *memLS) consistent() error {
name, n.refCount, list, len(list))
}
- // A node n is in m.byToken if it has a non-empty token.
- if n.token != "" {
- if _, ok := m.byToken[n.token]; !ok {
- return fmt.Errorf("node at name %q has token %q but not in m.byToken", name, n.token)
- }
- }
-
- // A node n is in m.byExpiry if it has a non-negative byExpiryIndex.
- if n.byExpiryIndex >= 0 {
- if n.byExpiryIndex >= len(m.byExpiry) {
- return fmt.Errorf("node at name %q has byExpiryIndex %d but m.byExpiry has length %d", name, n.byExpiryIndex, len(m.byExpiry))
- }
- if n != m.byExpiry[n.byExpiryIndex] {
- return fmt.Errorf("node at name %q has byExpiryIndex %d but that indexes a different node", name, n.byExpiryIndex)
+ for token, lock := range n.locks {
+ if lock.token != token || lock.node != n || m.byToken[token] != lock {
+ return fmt.Errorf("lock %q at node %q is inconsistent", token, name)
}
}
}
- for token, n := range m.byToken {
- // The map keys should be consistent with the node's copy of the key.
- if n.token != token {
- return fmt.Errorf("node token %q != byToken map key %q", n.token, token)
+ for token, lock := range m.byToken {
+ if lock.token != token {
+ return fmt.Errorf("lock token %q != byToken map key %q", lock.token, token)
}
-
- // Every node in m.byToken is in m.byName.
- if _, ok := m.byName[n.details.Root]; !ok {
- return fmt.Errorf("node at name %q in m.byToken but not in m.byName", n.details.Root)
+ if lock.node == nil || lock.node.locks[token] != lock {
+ return fmt.Errorf("lock %q is missing from its node", token)
+ }
+ if m.byName[lock.details.Root] != lock.node {
+ return fmt.Errorf("lock %q has inconsistent root %q", token, lock.details.Root)
}
}
- for i, n := range m.byExpiry {
- // The slice indices should be consistent with the node's copy of the index.
- if n.byExpiryIndex != i {
- return fmt.Errorf("node byExpiryIndex %d != byExpiry slice index %d", n.byExpiryIndex, i)
+ for i, lock := range m.byExpiry {
+ if lock.byExpiryIndex != i {
+ return fmt.Errorf("lock byExpiryIndex %d != byExpiry slice index %d", lock.byExpiryIndex, i)
}
-
- // Every node in m.byExpiry is in m.byName.
- if _, ok := m.byName[n.details.Root]; !ok {
- return fmt.Errorf("node at name %q in m.byExpiry but not in m.byName", n.details.Root)
+ if m.byToken[lock.token] != lock {
+ return fmt.Errorf("lock %q is missing from byToken", lock.token)
}
-
- // No node in m.byExpiry should be held.
- if n.held {
- return fmt.Errorf("node at name %q in m.byExpiry is held", n.details.Root)
+ if lock.held {
+ return fmt.Errorf("lock at name %q is held", lock.details.Root)
}
}
return nil
@@ -733,3 +762,22 @@ func TestParseTimeout(t *testing.T) {
}
}
}
+
+func TestHasNegatedTokenCondition(t *testing.T) {
+ tests := []struct {
+ name string
+ conditions []Condition
+ want bool
+ }{
+ {name: "positive token", conditions: []Condition{{Token: "DAV:no-lock"}}},
+ {name: "negated token", conditions: []Condition{{Not: true, Token: "DAV:no-lock"}}, want: true},
+ {name: "negated etag", conditions: []Condition{{Not: true, ETag: `"etag"`}}},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := hasNegatedTokenCondition(tt.conditions); got != tt.want {
+ t.Fatalf("hasNegatedTokenCondition(%v) = %t, want %t", tt.conditions, got, tt.want)
+ }
+ })
+ }
+}
diff --git a/server/webdav/prop.go b/server/webdav/prop.go
index 5c888934..8fc0bae4 100644
--- a/server/webdav/prop.go
+++ b/server/webdav/prop.go
@@ -16,9 +16,11 @@ import (
"time"
"github.com/OpenListTeam/OpenList/v4/internal/conf"
+ "github.com/OpenListTeam/OpenList/v4/internal/db"
"github.com/OpenListTeam/OpenList/v4/internal/model"
"github.com/OpenListTeam/OpenList/v4/pkg/utils"
"github.com/OpenListTeam/OpenList/v4/server/common"
+ "gorm.io/gorm"
)
// Proppatch describes a property update instruction as defined in RFC 4918.
@@ -170,79 +172,39 @@ var liveProps = map[xml.Name]struct {
// TODO(nigeltao) merge props and allprop?
// Props returns the status of the properties named pnames for resource name.
-//
-// Each Propstat has a unique status and each property name will only be part
-// of one Propstat element.
-func props(ctx context.Context, ls LockSystem, fi model.Obj, pnames []xml.Name) ([]Propstat, error) {
- //f, err := fs.OpenFile(ctx, name, os.O_RDONLY, 0)
- //if err != nil {
- // return nil, err
- //}
- //defer f.Close()
- //fi, err := f.Stat()
- //if err != nil {
- // return nil, err
- //}
+func props(tx *gorm.DB, ctx context.Context, ls LockSystem, name string, fi model.Obj, pnames []xml.Name) ([]Propstat, error) {
isDir := fi.IsDir()
-
- var deadProps map[xml.Name]Property
- // ??? what is this for?
- //if dph, ok := f.(DeadPropsHolder); ok {
- // deadProps, err = dph.DeadProps()
- // if err != nil {
- // return nil, err
- // }
- //}
-
+ deadProps, err := getDeadProps(tx, name)
+ if err != nil {
+ return nil, err
+ }
pstatOK := Propstat{Status: http.StatusOK}
pstatNotFound := Propstat{Status: http.StatusNotFound}
for _, pn := range pnames {
- // If this file has dead properties, check if they contain pn.
if dp, ok := deadProps[pn]; ok {
pstatOK.Props = append(pstatOK.Props, dp)
continue
}
- // Otherwise, it must either be a live property or we don't know it.
if prop := liveProps[pn]; prop.findFn != nil && (prop.dir || !isDir) {
innerXML, err := prop.findFn(ctx, ls, fi.GetName(), fi)
if err != nil {
return nil, err
}
- pstatOK.Props = append(pstatOK.Props, Property{
- XMLName: pn,
- InnerXML: []byte(innerXML),
- })
+ pstatOK.Props = append(pstatOK.Props, Property{XMLName: pn, InnerXML: []byte(innerXML)})
} else {
- pstatNotFound.Props = append(pstatNotFound.Props, Property{
- XMLName: pn,
- })
+ pstatNotFound.Props = append(pstatNotFound.Props, Property{XMLName: pn})
}
}
return makePropstats(pstatOK, pstatNotFound), nil
}
// Propnames returns the property names defined for resource name.
-func propnames(ctx context.Context, ls LockSystem, fi model.Obj) ([]xml.Name, error) {
- //f, err := fs.OpenFile(ctx, name, os.O_RDONLY, 0)
- //if err != nil {
- // return nil, err
- //}
- //defer f.Close()
- //fi, err := f.Stat()
- //if err != nil {
- // return nil, err
- //}
+func propnames(tx *gorm.DB, name string, fi model.Obj) ([]xml.Name, error) {
isDir := fi.IsDir()
-
- var deadProps map[xml.Name]Property
- // ??? what is this for?
- //if dph, ok := f.(DeadPropsHolder); ok {
- // deadProps, err = dph.DeadProps()
- // if err != nil {
- // return nil, err
- // }
- //}
-
+ deadProps, err := getDeadProps(tx, name)
+ if err != nil {
+ return nil, err
+ }
pnames := make([]xml.Name, 0, len(liveProps)+len(deadProps))
for pn, prop := range liveProps {
if prop.findFn != nil && (prop.dir || !isDir) {
@@ -255,20 +217,12 @@ func propnames(ctx context.Context, ls LockSystem, fi model.Obj) ([]xml.Name, er
return pnames, nil
}
-// Allprop returns the properties defined for resource name and the properties
-// named in include.
-//
-// Note that RFC 4918 defines 'allprop' to return the DAV: properties defined
-// within the RFC plus dead properties. Other live properties should only be
-// returned if they are named in 'include'.
-//
-// See http://www.webdav.org/specs/rfc4918.html#METHOD_PROPFIND
-func allprop(ctx context.Context, ls LockSystem, fi model.Obj, include []xml.Name) ([]Propstat, error) {
- pnames, err := propnames(ctx, ls, fi)
+// Allprop returns the properties defined for resource name and the properties named in include.
+func allprop(tx *gorm.DB, ctx context.Context, ls LockSystem, name string, fi model.Obj, include []xml.Name) ([]Propstat, error) {
+ pnames, err := propnames(tx, name, fi)
if err != nil {
return nil, err
}
- // Add names from include if they are not already covered in pnames.
nameset := make(map[xml.Name]bool)
for _, pn := range pnames {
nameset[pn] = true
@@ -278,11 +232,10 @@ func allprop(ctx context.Context, ls LockSystem, fi model.Obj, include []xml.Nam
pnames = append(pnames, pn)
}
}
- return props(ctx, ls, fi, pnames)
+ return props(tx, ctx, ls, name, fi, pnames)
}
-// Patch patches the properties of resource name. The return values are
-// constrained in the same manner as DeadPropsHolder.Patch.
+// Patch patches the properties of resource name.
func patch(ctx context.Context, ls LockSystem, name string, patches []Proppatch) ([]Propstat, error) {
conflict := false
loop:
@@ -314,53 +267,93 @@ loop:
return makePropstats(pstatForbidden, pstatFailedDep), nil
}
- // ------------------------------------------------------------
- //f, err := fs.OpenFile(ctx, name, os.O_RDWR, 0)
- //if err != nil {
- // return nil, err
- //}
- //defer f.Close()
- //if dph, ok := f.(DeadPropsHolder); ok {
- // ret, err := dph.Patch(patches)
- // if err != nil {
- // return nil, err
- // }
- // // http://www.webdav.org/specs/rfc4918.html#ELEMENT_propstat says that
- // // "The contents of the prop XML element must only list the names of
- // // properties to which the result in the status element applies."
- // for _, pstat := range ret {
- // for i, p := range pstat.Props {
- // pstat.Props[i] = Property{XMLName: p.XMLName}
- // }
- // }
- // return ret, nil
- //}
- // ------------------------------------------------------------
-
- // The file doesn't implement the optional DeadPropsHolder interface, so
- // all patches are forbidden.
- pstat := Propstat{Status: http.StatusForbidden}
- for _, patch := range patches {
- for _, p := range patch.Props {
- pstat.Props = append(pstat.Props, Property{XMLName: p.XMLName})
+ pstat := Propstat{Status: http.StatusOK}
+ var err error
+ for attempt := 0; attempt < 10; attempt++ {
+ pstat.Props = nil
+ err = withDeadPropsTransaction(ctx, func(tx *gorm.DB) error {
+ for _, patch := range patches {
+ for _, prop := range patch.Props {
+ if patch.Remove {
+ if err := tx.Where("path = ? AND namespace = ? AND name = ?", name, prop.XMLName.Space, prop.XMLName.Local).Delete(&model.WebDAVProperty{}).Error; err != nil {
+ return err
+ }
+ } else {
+ row := model.WebDAVProperty{
+ Path: name,
+ Namespace: prop.XMLName.Space,
+ Name: prop.XMLName.Local,
+ }
+ if err := tx.Where("path = ? AND namespace = ? AND name = ?", row.Path, row.Namespace, row.Name).
+ Assign(model.WebDAVProperty{Lang: prop.Lang, InnerXML: prop.InnerXML}).
+ FirstOrCreate(&row).Error; err != nil {
+ return err
+ }
+ }
+ pstat.Props = append(pstat.Props, Property{XMLName: prop.XMLName})
+ }
+ }
+ return nil
+ })
+ if err == nil || !strings.Contains(err.Error(), "database is locked") {
+ break
}
+ time.Sleep(time.Duration(attempt+1) * 20 * time.Millisecond)
+ }
+ if err != nil {
+ return nil, err
}
return []Propstat{pstat}, nil
}
+func getDeadProps(tx *gorm.DB, path string) (map[xml.Name]Property, error) {
+ var rows []model.WebDAVProperty
+ if tx == nil {
+ return nil, errors.New("webdav property transaction is not initialized")
+ }
+ if err := tx.Where("path = ?", path).Find(&rows).Error; err != nil {
+ return nil, err
+ }
+ props := make(map[xml.Name]Property, len(rows))
+ for _, row := range rows {
+ props[xml.Name{Space: row.Namespace, Local: row.Name}] = Property{
+ XMLName: xml.Name{Space: row.Namespace, Local: row.Name},
+ Lang: row.Lang,
+ InnerXML: row.InnerXML,
+ }
+ }
+ return props, nil
+}
+
+func withDeadPropsTransaction(ctx context.Context, fn func(*gorm.DB) error) error {
+ database := db.GetDb()
+ if database == nil {
+ return errors.New("webdav property database is not initialized")
+ }
+ var transaction *gorm.DB
+ err := database.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
+ transaction = tx
+ var probe int
+ if err := tx.Raw("SELECT 1").Scan(&probe).Error; err != nil {
+ return err
+ }
+ return fn(tx)
+ })
+ if err != nil && transaction != nil {
+ _ = transaction.Rollback().Error
+ }
+ return err
+}
+
func escapeXML(s string) string {
for i := 0; i < len(s); i++ {
- // As an optimization, if s contains only ASCII letters, digits or a
- // few special characters, the escaped value is s itself and we don't
- // need to allocate a buffer and convert between string and []byte.
switch c := s[i]; {
case c == ' ' || c == '_' ||
- ('+' <= c && c <= '9') || // Digits as well as + , - . and /
+ ('+' <= c && c <= '9') ||
('A' <= c && c <= 'Z') ||
('a' <= c && c <= 'z'):
continue
}
- // Otherwise, go through the full escaping process.
var buf bytes.Buffer
xml.EscapeText(&buf, []byte(s))
return buf.String()
@@ -368,6 +361,72 @@ func escapeXML(s string) string {
return s
}
+func deadPropsLikePath(path string) string {
+ return strings.ReplaceAll(strings.ReplaceAll(strings.ReplaceAll(path, "\\", "\\\\"), "%", "\\%"), "_", "\\_")
+}
+
+func deadPropsSubtree(tx *gorm.DB, path string) *gorm.DB {
+ return tx.Where("path = ? OR path LIKE ? ESCAPE '\\'", path, deadPropsLikePath(path)+"/%")
+}
+
+func deleteDeadProps(tx *gorm.DB, path string) error {
+ return deadPropsSubtree(tx, path).Delete(&model.WebDAVProperty{}).Error
+}
+
+func removeResourceWithDeadProps(ctx context.Context, path string, remove func() error) (removeErr, transactionErr error) {
+ transactionErr = withDeadPropsTransaction(ctx, func(tx *gorm.DB) error {
+ if err := deleteDeadProps(tx, path); err != nil {
+ return err
+ }
+ removeErr = remove()
+ return removeErr
+ })
+ return removeErr, transactionErr
+}
+
+func copyDeadProps(tx *gorm.DB, src, dst string) error {
+ var rows []model.WebDAVProperty
+ if err := deadPropsSubtree(tx, src).Find(&rows).Error; err != nil {
+ return err
+ }
+ if err := deadPropsSubtree(tx, dst).Delete(&model.WebDAVProperty{}).Error; err != nil {
+ return err
+ }
+ for _, row := range rows {
+ copy := row
+ copy.ID = 0
+ copy.Path = dst + strings.TrimPrefix(row.Path, src)
+ if err := tx.Create(©).Error; err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func moveDeadProps(tx *gorm.DB, src, dst string) error {
+ var rows []model.WebDAVProperty
+ if err := deadPropsSubtree(tx, src).Find(&rows).Error; err != nil {
+ return err
+ }
+ // Clear destination only after the source rows are staged in memory.
+ if err := deadPropsSubtree(tx, dst).Delete(&model.WebDAVProperty{}).Error; err != nil {
+ return err
+ }
+ for _, row := range rows {
+ newPath := dst + strings.TrimPrefix(row.Path, src)
+ copy := row
+ copy.ID = 0
+ copy.Path = newPath
+ if err := tx.Create(©).Error; err != nil {
+ return err
+ }
+ if err := tx.Delete(&row).Error; err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
func findResourceType(ctx context.Context, ls LockSystem, name string, fi model.Obj) (string, error) {
if fi.IsDir() {
return ``, nil
diff --git a/server/webdav/webdav.go b/server/webdav/webdav.go
index 5729543e..323476b4 100644
--- a/server/webdav/webdav.go
+++ b/server/webdav/webdav.go
@@ -29,6 +29,7 @@ import (
"github.com/OpenListTeam/OpenList/v4/internal/model"
"github.com/OpenListTeam/OpenList/v4/pkg/utils"
"github.com/OpenListTeam/OpenList/v4/server/common"
+ "gorm.io/gorm"
)
type Handler struct {
@@ -82,10 +83,6 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
status, err = h.handleUnlock(brw, r)
case "PROPFIND":
status, err = h.handlePropfind(brw, r)
- // if there is a error for PROPFIND, we should be as an empty folder to the client
- if err != nil {
- status = http.StatusNotFound
- }
case "PROPPATCH":
status, err = h.handleProppatch(brw, r)
}
@@ -119,14 +116,64 @@ func (h *Handler) lock(now time.Time, root string) (token string, status int, er
return token, 0, nil
}
+func ifETagConditionsMatch(ctx context.Context, ls LockSystem, name string, conditions []Condition) (bool, error) {
+ var obj model.Obj
+ objLoaded, objMissing := false, false
+ for _, condition := range conditions {
+ if condition.ETag == "" {
+ continue
+ }
+ if !objLoaded {
+ var err error
+ obj, err = fs.Get(ctx, name, &fs.GetArgs{})
+ objLoaded = true
+ if err != nil {
+ if !errs.IsObjectNotFound(err) {
+ return false, err
+ }
+ objMissing = true
+ }
+ }
+ matches := false
+ if !objMissing {
+ etag, err := findETag(ctx, ls, name, obj)
+ if err != nil {
+ return false, err
+ }
+ matches = etag == condition.ETag
+ }
+ if condition.Not {
+ matches = !matches
+ }
+ if !matches {
+ return false, nil
+ }
+ }
+ return true, nil
+}
+
+func positiveTokenConditions(conditions []Condition) []Condition {
+ ret := make([]Condition, 0, len(conditions))
+ for _, condition := range conditions {
+ if condition.Token != "" && !condition.Not {
+ ret = append(ret, condition)
+ }
+ }
+ return ret
+}
+
+func hasNegatedTokenCondition(conditions []Condition) bool {
+ for _, condition := range conditions {
+ if condition.Token != "" && condition.Not {
+ return true
+ }
+ }
+ return false
+}
+
func (h *Handler) confirmLocks(r *http.Request, src, dst string) (release func(), status int, err error) {
hdr := r.Header.Get("If")
if hdr == "" {
- // An empty If header means that the client hasn't previously created locks.
- // Even if this client doesn't care about locks, we still need to check that
- // the resources aren't locked by another client, so we create temporary
- // locks that would conflict with another client's locks. These temporary
- // locks are unlocked at the end of the HTTP request.
now, srcToken, dstToken := time.Now(), "", ""
if src != "" {
srcToken, status, err = h.lock(now, src)
@@ -143,7 +190,6 @@ func (h *Handler) confirmLocks(r *http.Request, src, dst string) (release func()
return nil, status, err
}
}
-
return func() {
if dstToken != "" {
h.LockSystem.Unlock(now, dstToken)
@@ -158,7 +204,9 @@ func (h *Handler) confirmLocks(r *http.Request, src, dst string) (release func()
if !ok {
return nil, http.StatusBadRequest, errInvalidIfHeader
}
- // ih is a disjunction (OR) of ifLists, so any ifList will do.
+ ctx := r.Context()
+ user, _ := ctx.Value(conf.UserKey).(*model.User)
+ etagMismatch, hasNegatedToken := false, false
for _, l := range ih.lists {
lsrc := l.resourceTag
if lsrc == "" {
@@ -175,8 +223,23 @@ func (h *Handler) confirmLocks(r *http.Request, src, dst string) (release func()
if err != nil {
return nil, status, err
}
+ if user != nil {
+ lsrc, err = user.JoinPath(lsrc)
+ if err != nil {
+ return nil, http.StatusForbidden, err
+ }
+ }
}
- release, err = h.LockSystem.Confirm(time.Now(), lsrc, dst, l.conditions...)
+ matches, err := ifETagConditionsMatch(ctx, h.LockSystem, lsrc, l.conditions)
+ if err != nil {
+ return nil, http.StatusInternalServerError, err
+ }
+ if !matches {
+ etagMismatch = true
+ continue
+ }
+ hasNegatedToken = hasNegatedToken || hasNegatedTokenCondition(l.conditions)
+ release, err = h.LockSystem.Confirm(time.Now(), lsrc, dst, positiveTokenConditions(l.conditions)...)
if err == ErrConfirmationFailed {
continue
}
@@ -185,10 +248,28 @@ func (h *Handler) confirmLocks(r *http.Request, src, dst string) (release func()
}
return release, 0, nil
}
- // Section 10.4.1 says that "If this header is evaluated and all state lists
- // fail, then the request must fail with a 412 (Precondition Failed) status."
- // We follow the spec even though the cond_put_corrupt_token test case from
- // the litmus test warns on seeing a 412 instead of a 423 (Locked).
+ if etagMismatch || !hasNegatedToken {
+ return nil, http.StatusPreconditionFailed, ErrLocked
+ }
+
+ // Litmus expects a corrupt token paired with Not on an
+ // actively locked resource to report the lock conflict.
+ now := time.Now()
+ for _, name := range []string{src, dst} {
+ if name == "" {
+ continue
+ }
+ token, lockStatus, lockErr := h.lock(now, name)
+ if lockErr == ErrLocked {
+ return nil, lockStatus, lockErr
+ }
+ if lockErr != nil {
+ return nil, lockStatus, lockErr
+ }
+ if unlockErr := h.LockSystem.Unlock(now, token); unlockErr != nil {
+ return nil, http.StatusInternalServerError, unlockErr
+ }
+ }
return nil, http.StatusPreconditionFailed, ErrLocked
}
@@ -212,9 +293,7 @@ func (h *Handler) handleOptions(w http.ResponseWriter, r *http.Request) (status
}
}
w.Header().Set("Allow", allow)
- // http://www.webdav.org/specs/rfc4918.html#dav.compliance.classes
w.Header().Set("DAV", "1, 2")
- // http://msdn.microsoft.com/en-au/library/cc250217.aspx
w.Header().Set("MS-Author-Via", "DAV")
return 0, nil
}
@@ -295,12 +374,6 @@ func (h *Handler) handleDelete(w http.ResponseWriter, r *http.Request) (status i
if err != nil {
return status, err
}
- release, status, err := h.confirmLocks(r, reqPath, "")
- if err != nil {
- return status, err
- }
- defer release()
-
ctx := r.Context()
user := ctx.Value(conf.UserKey).(*model.User)
if !user.CanRemove() {
@@ -310,18 +383,29 @@ func (h *Handler) handleDelete(w http.ResponseWriter, r *http.Request) (status i
if err != nil {
return http.StatusForbidden, err
}
+ release, status, err := h.confirmLocks(r, reqPath, "")
+ if err != nil {
+ return status, err
+ }
+ defer release()
// TODO: return MultiStatus where appropriate.
// "godoc os RemoveAll" says that "If the path does not exist, RemoveAll
// returns nil (no error)." WebDAV semantics are that it should return a
// "404 Not Found". We therefore have to Stat before we RemoveAll.
+ parentPath := path.Dir(reqPath)
+ if _, err := fs.List(ctx, parentPath, &fs.ListArgs{Refresh: true, NoLog: true}); err != nil {
+ if errs.IsNotFoundError(err) {
+ return http.StatusNotFound, err
+ }
+ return http.StatusMethodNotAllowed, err
+ }
if _, err := fs.Get(ctx, reqPath, &fs.GetArgs{}); err != nil {
- if errs.IsObjectNotFound(err) {
+ if errs.IsNotFoundError(err) {
return http.StatusNotFound, err
}
return http.StatusMethodNotAllowed, err
}
- parentPath := path.Dir(reqPath)
parentMeta, err := op.GetNearestMeta(parentPath)
if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) {
return http.StatusInternalServerError, err
@@ -329,8 +413,14 @@ func (h *Handler) handleDelete(w http.ResponseWriter, r *http.Request) (status i
if !common.CanWrite(user, parentMeta, parentPath) {
return http.StatusForbidden, errs.PermissionDenied
}
- if err := fs.Remove(ctx, reqPath); err != nil {
- return http.StatusMethodNotAllowed, err
+ removeErr, transactionErr := removeResourceWithDeadProps(ctx, reqPath, func() error {
+ return fs.Remove(ctx, reqPath)
+ })
+ if removeErr != nil {
+ return http.StatusMethodNotAllowed, removeErr
+ }
+ if transactionErr != nil {
+ return http.StatusInternalServerError, transactionErr
}
//fs.ClearCache(path.Dir(reqPath))
return http.StatusNoContent, nil
@@ -350,17 +440,19 @@ func (h *Handler) handlePut(w http.ResponseWriter, r *http.Request) (status int,
if reqPath == "" {
return http.StatusMethodNotAllowed, nil
}
- release, status, err := h.confirmLocks(r, reqPath, "")
- if err != nil {
- return status, err
- }
- defer release()
+ // TODO(rost): Support the If-Match, If-None-Match headers? See bradfitz'
+ // comments in http.checkEtag.
ctx := r.Context()
user := ctx.Value(conf.UserKey).(*model.User)
reqPath, err = user.JoinPath(reqPath)
if err != nil {
return http.StatusForbidden, err
}
+ release, status, err := h.confirmLocks(r, reqPath, "")
+ if err != nil {
+ return status, err
+ }
+ defer release()
size := r.ContentLength
if size < 0 {
sizeStr := r.Header.Get("X-File-Size")
@@ -382,6 +474,16 @@ func (h *Handler) handlePut(w http.ResponseWriter, r *http.Request) (status int,
return http.StatusForbidden, errs.IgnoredSystemFile
}
parentPath := path.Dir(reqPath)
+ parent, err := fs.Get(ctx, parentPath, &fs.GetArgs{})
+ if err != nil {
+ if errs.IsNotFoundError(err) {
+ return http.StatusConflict, err
+ }
+ return http.StatusInternalServerError, err
+ }
+ if !parent.IsDir() {
+ return http.StatusConflict, errs.NotFolder
+ }
parentMeta, err := op.GetNearestMeta(parentPath)
if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) {
return http.StatusInternalServerError, err
@@ -455,18 +557,17 @@ func (h *Handler) handleMkcol(w http.ResponseWriter, r *http.Request) (status in
if err != nil {
return status, err
}
- release, status, err := h.confirmLocks(r, reqPath, "")
- if err != nil {
- return status, err
- }
- defer release()
-
ctx := r.Context()
user := ctx.Value(conf.UserKey).(*model.User)
reqPath, err = user.JoinPath(reqPath)
if err != nil {
return http.StatusForbidden, err
}
+ release, status, err := h.confirmLocks(r, reqPath, "")
+ if err != nil {
+ return status, err
+ }
+ defer release()
if r.ContentLength > 0 {
return http.StatusUnsupportedMediaType, nil
@@ -476,15 +577,21 @@ func (h *Handler) handleMkcol(w http.ResponseWriter, r *http.Request) (status in
//405 (Method Not Allowed) - MKCOL can only be executed on an unmapped URL
if _, err := fs.Get(ctx, reqPath, &fs.GetArgs{}); err == nil {
return http.StatusMethodNotAllowed, err
+ } else if !errs.IsNotFoundError(err) {
+ return http.StatusInternalServerError, err
}
// RFC 4918 9.3.1
// 409 (Conflict) The server MUST NOT create those intermediate collections automatically.
parentPath := path.Dir(reqPath)
- if _, err := fs.Get(ctx, parentPath, &fs.GetArgs{}); err != nil {
- if errs.IsObjectNotFound(err) {
+ parent, err := fs.Get(ctx, parentPath, &fs.GetArgs{})
+ if err != nil {
+ if errs.IsNotFoundError(err) {
return http.StatusConflict, err
}
- return http.StatusMethodNotAllowed, err
+ return http.StatusInternalServerError, err
+ }
+ if !parent.IsDir() {
+ return http.StatusConflict, errs.NotFolder
}
parentMeta, err := op.GetNearestMeta(parentPath)
if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) {
@@ -569,7 +676,7 @@ func (h *Handler) handleCopyMove(w http.ResponseWriter, r *http.Request) (status
return http.StatusBadRequest, errInvalidDepth
}
}
- return copyFiles(ctx, src, dst, r.Header.Get("Overwrite") != "F")
+ return copyFiles(ctx, src, dst, r.Header.Get("Overwrite") != "F", depth)
}
release, status, err := h.confirmLocks(r, src, dst)
@@ -649,11 +756,18 @@ func (h *Handler) handleLock(w http.ResponseWriter, r *http.Request) (retStatus
if !common.CanWrite(user, meta, reqPath) {
return http.StatusForbidden, errs.PermissionDenied
}
+ if _, err := fs.Get(ctx, reqPath, &fs.GetArgs{}); err != nil {
+ if !errs.IsObjectNotFound(err) {
+ return http.StatusInternalServerError, err
+ }
+ created = true
+ }
ld = LockDetails{
Root: reqPath,
Duration: duration,
OwnerXML: li.Owner.InnerXML,
ZeroDepth: depth == 0,
+ Shared: li.Shared != nil,
}
token, err = h.LockSystem.Create(now, ld)
if err != nil {
@@ -685,6 +799,10 @@ func (h *Handler) handleLock(w http.ResponseWriter, r *http.Request) (retStatus
w.Header().Set("Lock-Token", "<"+token+">")
}
+ // ld.Root is the storage path; the response's lockroot is the client's URL.
+ obj, err := fs.Get(ctx, ld.Root, &fs.GetArgs{})
+ ld.Root = lockRootHref(h.Prefix, user.BasePath, ld.Root, err == nil && obj.IsDir())
+
w.Header().Set("Content-Type", "application/xml; charset=utf-8")
if created {
// This is "w.WriteHeader(http.StatusCreated)" and not "return
@@ -696,6 +814,16 @@ func (h *Handler) handleLock(w http.ResponseWriter, r *http.Request) (retStatus
return 0, nil
}
+// lockRootHref maps the storage path a lock is kept under back to the URL path
+// the client sees, like the hrefs PROPFIND writes.
+func lockRootHref(prefix, basePath, root string, isDir bool) string {
+ href := path.Join(prefix, strings.TrimPrefix(root, basePath))
+ if href != "/" && isDir {
+ href += "/"
+ }
+ return (&url.URL{Path: href}).EscapedPath()
+}
+
func (h *Handler) handleUnlock(w http.ResponseWriter, r *http.Request) (status int, err error) {
// http://www.webdav.org/specs/rfc4918.html#HEADER_Lock-Token says that the
// Lock-Token value is a Coded-URL. We strip its angle brackets.
@@ -779,43 +907,44 @@ func (h *Handler) handlePropfind(w http.ResponseWriter, r *http.Request) (status
mw := multistatusWriter{w: w}
- walkFn := func(reqPath string, info model.Obj, err error) error {
- if err != nil {
- return err
- }
- var pstats []Propstat
- if pf.Propname != nil {
- pnames, err := propnames(ctx, h.LockSystem, info)
- if err != nil {
- return err
+ err = withDeadPropsTransaction(ctx, func(tx *gorm.DB) error {
+ walkFn := func(currentPath string, info model.Obj, walkErr error) error {
+ if walkErr != nil {
+ return walkErr
}
- pstat := Propstat{Status: http.StatusOK}
- for _, xmlname := range pnames {
- pstat.Props = append(pstat.Props, Property{XMLName: xmlname})
+ var pstats []Propstat
+ if pf.Propname != nil {
+ pnames, err := propnames(tx, currentPath, info)
+ if err != nil {
+ return err
+ }
+ pstat := Propstat{Status: http.StatusOK}
+ for _, xmlname := range pnames {
+ pstat.Props = append(pstat.Props, Property{XMLName: xmlname})
+ }
+ pstats = append(pstats, pstat)
+ } else if pf.Allprop != nil {
+ pstats, walkErr = allprop(tx, ctx, h.LockSystem, currentPath, info, pf.Prop)
+ } else {
+ pstats, walkErr = props(tx, ctx, h.LockSystem, currentPath, info, pf.Prop)
}
- pstats = append(pstats, pstat)
- } else if pf.Allprop != nil {
- pstats, err = allprop(ctx, h.LockSystem, info, pf.Prop)
- } else {
- pstats, err = props(ctx, h.LockSystem, info, pf.Prop)
+ if walkErr != nil {
+ return walkErr
+ }
+ href := path.Join(h.Prefix, strings.TrimPrefix(currentPath, user.BasePath))
+ if href != "/" && info.IsDir() {
+ href += "/"
+ }
+ return mw.write(makePropstatResponse(href, pstats))
}
- if err != nil {
- return err
- }
- href := path.Join(h.Prefix, strings.TrimPrefix(reqPath, user.BasePath))
- if href != "/" && info.IsDir() {
- href += "/"
- }
- return mw.write(makePropstatResponse(href, pstats))
- }
- walkErr := walkFS(ctx, depth, reqPath, fi, walkFn)
- closeErr := mw.close()
- if walkErr != nil {
- return http.StatusInternalServerError, walkErr
- }
- if closeErr != nil {
- return http.StatusInternalServerError, closeErr
+ if walkErr := walkFS(ctx, depth, reqPath, fi, walkFn); walkErr != nil {
+ return walkErr
+ }
+ return mw.close()
+ })
+ if err != nil {
+ return http.StatusInternalServerError, err
}
return 0, nil
}
@@ -825,18 +954,17 @@ func (h *Handler) handleProppatch(w http.ResponseWriter, r *http.Request) (statu
if err != nil {
return status, err
}
- release, status, err := h.confirmLocks(r, reqPath, "")
- if err != nil {
- return status, err
- }
- defer release()
-
ctx := r.Context()
user := ctx.Value(conf.UserKey).(*model.User)
reqPath, err = user.JoinPath(reqPath)
if err != nil {
return http.StatusForbidden, err
}
+ release, status, err := h.confirmLocks(r, reqPath, "")
+ if err != nil {
+ return status, err
+ }
+ defer release()
meta, err := op.GetNearestMeta(reqPath)
if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) {
return http.StatusInternalServerError, err
diff --git a/server/webdav/webdav_test.go b/server/webdav/webdav_test.go
new file mode 100644
index 00000000..337f84eb
--- /dev/null
+++ b/server/webdav/webdav_test.go
@@ -0,0 +1,24 @@
+package webdav
+
+import "testing"
+
+func TestLockRootHref(t *testing.T) {
+ testCases := []struct {
+ prefix, basePath, root string
+ isDir bool
+ want string
+ }{
+ {"/dav", "/storage", "/storage/lock-probe", true, "/dav/lock-probe/"},
+ {"/dav", "/storage", "/storage/a.txt", false, "/dav/a.txt"},
+ {"/dav", "/", "/storage/a.txt", false, "/dav/storage/a.txt"},
+ {"/dav", "/storage", "/storage", true, "/dav/"},
+ {"", "/", "/", true, "/"},
+ {"/dav", "/", "/new folder/文件.txt", false, "/dav/new%20folder/%E6%96%87%E4%BB%B6.txt"},
+ }
+ for _, tc := range testCases {
+ got := lockRootHref(tc.prefix, tc.basePath, tc.root, tc.isDir)
+ if got != tc.want {
+ t.Errorf("lockRootHref(%q, %q, %q, %v) = %q, want %q", tc.prefix, tc.basePath, tc.root, tc.isDir, got, tc.want)
+ }
+ }
+}
diff --git a/server/webdav/xml.go b/server/webdav/xml.go
index c9ec61df..d87eb863 100644
--- a/server/webdav/xml.go
+++ b/server/webdav/xml.go
@@ -62,9 +62,7 @@ func readLockInfo(r io.Reader) (li lockInfo, status int, err error) {
}
return lockInfo{}, http.StatusBadRequest, err
}
- // We only support exclusive (non-shared) write locks. In practice, these are
- // the only types of locks that seem to matter.
- if li.Exclusive == nil || li.Shared != nil || li.Write == nil {
+ if (li.Exclusive == nil) == (li.Shared == nil) || li.Write == nil {
return lockInfo{}, http.StatusNotImplemented, errUnsupportedLockInfo
}
return li, 0, nil
@@ -86,18 +84,22 @@ func writeLockInfo(w io.Writer, token string, ld LockDetails) (int, error) {
if ld.ZeroDepth {
depth = "0"
}
+ scope := "exclusive"
+ if ld.Shared {
+ scope = "shared"
+ }
timeout := ld.Duration / time.Second
return fmt.Fprintf(w, "\n"+
"\n"+
- " \n"+
- " \n"+
- " %s\n"+
- " %s\n"+
- " Second-%d\n"+
- " %s\n"+
- " %s\n"+
+ "\t\n"+
+ "\t\n"+
+ "\t%s\n"+
+ "\t%s\n"+
+ "\tSecond-%d\n"+
+ "\t%s\n"+
+ "\t%s\n"+
"",
- depth, ld.OwnerXML, timeout, escape(token), escape(ld.Root),
+ scope, depth, ld.OwnerXML, timeout, escape(token), escape(ld.Root),
)
}
@@ -176,19 +178,31 @@ type propfind struct {
}
func readPropfind(r io.Reader) (pf propfind, status int, err error) {
- c := countingReader{r: r}
- if err = ixml.NewDecoder(&c).Decode(&pf); err != nil {
+ // 64KB is more than enough for a well-formed PROPFIND body.
+ const maxBody = 64 << 10
+ body, err := io.ReadAll(io.LimitReader(r, maxBody))
+ if err != nil {
+ return propfind{}, http.StatusBadRequest, err
+ }
+ // If the limit was reached, the body was too large.
+ if len(body) >= maxBody {
+ // Drain any remaining bytes so the connection stays usable.
+ _, _ = io.Copy(io.Discard, r)
+ return propfind{}, http.StatusRequestEntityTooLarge, nil
+ }
+ if len(body) == 0 {
+ // An empty body means to propfind allprop.
+ return propfind{Allprop: new(struct{})}, 0, nil
+ }
+ if hasEmptyNamespacePrefix(body) {
+ return propfind{}, http.StatusBadRequest, errInvalidPropfind
+ }
+ if err = ixml.NewDecoder(bytes.NewReader(body)).Decode(&pf); err != nil {
if err == io.EOF {
- if c.n == 0 {
- // An empty body means to propfind allprop.
- // http://www.webdav.org/specs/rfc4918.html#METHOD_PROPFIND
- return propfind{Allprop: new(struct{})}, 0, nil
- }
err = errInvalidPropfind
}
return propfind{}, http.StatusBadRequest, err
}
-
if pf.Allprop == nil && pf.Include != nil {
return propfind{}, http.StatusBadRequest, errInvalidPropfind
}
@@ -204,6 +218,33 @@ func readPropfind(r io.Reader) (pf propfind, status int, err error) {
return pf, 0, nil
}
+func hasEmptyNamespacePrefix(body []byte) bool {
+ for offset := 0; ; {
+ i := bytes.Index(body[offset:], []byte("xmlns:"))
+ if i < 0 {
+ return false
+ }
+ i += offset + len("xmlns:")
+ j := i
+ for j < len(body) && body[j] != '=' && body[j] != '>' && body[j] != '/' && body[j] != ' ' && body[j] != '\t' && body[j] != '\n' && body[j] != '\r' {
+ j++
+ }
+ for j < len(body) && (body[j] == ' ' || body[j] == '\t' || body[j] == '\n' || body[j] == '\r') {
+ j++
+ }
+ if j < len(body) && body[j] == '=' {
+ j++
+ for j < len(body) && (body[j] == ' ' || body[j] == '\t' || body[j] == '\n' || body[j] == '\r') {
+ j++
+ }
+ if j+1 < len(body) && (body[j] == '\'' || body[j] == '"') && body[j+1] == body[j] {
+ return true
+ }
+ }
+ offset = i
+ }
+}
+
// Property represents a single DAV resource property as defined in RFC 4918.
// See http://www.webdav.org/specs/rfc4918.html#data.model.for.resource.properties
type Property struct {