mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-04 04:26:38 +08:00
feat: add cloud transfer and cache optimizations
This commit is contained in:
@@ -5,9 +5,12 @@ package handler
|
|||||||
import (
|
import (
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"github.com/ShukeBta/MediaStationGo/internal/model"
|
||||||
"github.com/ShukeBta/MediaStationGo/internal/service"
|
"github.com/ShukeBta/MediaStationGo/internal/service"
|
||||||
"github.com/ShukeBta/MediaStationGo/internal/service/cloud"
|
"github.com/ShukeBta/MediaStationGo/internal/service/cloud"
|
||||||
)
|
)
|
||||||
@@ -48,6 +51,84 @@ func cloudImportHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// cloudMountHandler creates or reuses a cloud:// media library for a cloud
|
||||||
|
// directory, then scans it recursively so cloud files become playable STRM/302
|
||||||
|
// media rows without copying bytes to local disk.
|
||||||
|
func cloudMountHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
typ := c.Param("type")
|
||||||
|
var in struct {
|
||||||
|
Dir string `json:"dir"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
MediaType string `json:"media_type"`
|
||||||
|
}
|
||||||
|
_ = c.ShouldBindJSON(&in)
|
||||||
|
if !cloud.IsCloudType(typ) {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported cloud provider"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := svc.StorageCfg.CloudProvider(c.Request.Context(), typ); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
path := "cloud://" + typ
|
||||||
|
if dir := strings.TrimSpace(in.Dir); dir != "" {
|
||||||
|
path += "/" + url.PathEscape(dir)
|
||||||
|
}
|
||||||
|
name := strings.TrimSpace(in.Name)
|
||||||
|
if name == "" {
|
||||||
|
name = cloudMountLibraryName(typ, strings.TrimSpace(in.Dir))
|
||||||
|
}
|
||||||
|
mediaType := strings.TrimSpace(in.MediaType)
|
||||||
|
if mediaType == "" {
|
||||||
|
mediaType = "movie"
|
||||||
|
}
|
||||||
|
libs, err := svc.Repo.Library.List(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var lib *model.Library
|
||||||
|
for i := range libs {
|
||||||
|
if libs[i].Path == path {
|
||||||
|
lib = &libs[i]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if lib == nil {
|
||||||
|
lib = &model.Library{Name: name, Path: path, Type: mediaType, Enabled: true}
|
||||||
|
if err := svc.Repo.Library.Create(c.Request.Context(), lib); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var scan any
|
||||||
|
if svc.Scan != nil {
|
||||||
|
res, err := svc.Scan.ScanLibrary(c.Request.Context(), lib.ID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error(), "library": lib})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
scan = res
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"library": lib, "scan": scan})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloudMountLibraryName(typ, dir string) string {
|
||||||
|
base := typ
|
||||||
|
switch typ {
|
||||||
|
case cloud.TypeQuark:
|
||||||
|
base = "夸克网盘"
|
||||||
|
case cloud.Type115:
|
||||||
|
base = "115 网盘"
|
||||||
|
}
|
||||||
|
if dir == "" || dir == "0" {
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
return base + " · " + dir
|
||||||
|
}
|
||||||
|
|
||||||
// cloud115QRStartHandler begins a 115 QR-code login and returns the session +
|
// cloud115QRStartHandler begins a 115 QR-code login and returns the session +
|
||||||
// QR image URL for the frontend to render.
|
// QR image URL for the frontend to render.
|
||||||
func cloud115QRStartHandler(svc *service.Container) gin.HandlerFunc {
|
func cloud115QRStartHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
@@ -102,7 +183,11 @@ func cloudPlayHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
// Proxy mode: the direct link needs auth headers the browser cannot
|
// Proxy mode: the direct link needs auth headers the browser cannot
|
||||||
// carry. Stream through with Range forwarding.
|
// carry. Stream through with Range forwarding.
|
||||||
req, err := http.NewRequestWithContext(c.Request.Context(), http.MethodGet, link.URL, nil)
|
method := c.Request.Method
|
||||||
|
if method == "" {
|
||||||
|
method = http.MethodGet
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(c.Request.Context(), method, link.URL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
@@ -125,6 +210,8 @@ func cloudPlayHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
c.Status(resp.StatusCode)
|
c.Status(resp.StatusCode)
|
||||||
_, _ = io.Copy(c.Writer, resp.Body)
|
if c.Request.Method != http.MethodHead {
|
||||||
|
_, _ = io.Copy(c.Writer, resp.Body)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,10 +35,12 @@ func registerAdminRoutes(api *gin.RouterGroup, cfg *config.Config, svc *service.
|
|||||||
admin.GET("/storage/:type", getStorageConfigHandler(svc))
|
admin.GET("/storage/:type", getStorageConfigHandler(svc))
|
||||||
admin.PUT("/storage/:type", saveStorageConfigHandler(svc))
|
admin.PUT("/storage/:type", saveStorageConfigHandler(svc))
|
||||||
admin.POST("/storage/:type/test", testStorageConfigHandler(svc))
|
admin.POST("/storage/:type/test", testStorageConfigHandler(svc))
|
||||||
|
admin.POST("/storage/:type/upload-local", storageUploadLocalHandler(svc))
|
||||||
|
|
||||||
// Cloud disk (115 / 夸克) browsing, QR login and 302 import.
|
// Cloud disk (115 / 夸克) browsing, QR login and 302 import.
|
||||||
admin.GET("/cloud/:type/list", cloudListHandler(svc))
|
admin.GET("/cloud/:type/list", cloudListHandler(svc))
|
||||||
admin.POST("/cloud/:type/import", cloudImportHandler(svc))
|
admin.POST("/cloud/:type/import", cloudImportHandler(svc))
|
||||||
|
admin.POST("/cloud/:type/mount", cloudMountHandler(svc))
|
||||||
admin.POST("/cloud/:type/qr/start", cloud115QRStartHandler(svc))
|
admin.POST("/cloud/:type/qr/start", cloud115QRStartHandler(svc))
|
||||||
admin.POST("/cloud/:type/qr/poll", cloud115QRPollHandler(svc))
|
admin.POST("/cloud/:type/qr/poll", cloud115QRPollHandler(svc))
|
||||||
|
|
||||||
|
|||||||
@@ -73,3 +73,24 @@ func testStorageConfigHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func storageUploadLocalHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
var req service.CloudUploadInput
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.Type = c.Param("type")
|
||||||
|
res, err := svc.StorageCfg.UploadLocal(c.Request.Context(), req)
|
||||||
|
if err != nil && res == nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"result": res, "error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"result": res})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -98,6 +98,20 @@ func schemaHandler(_ *service.Container) gin.HandlerFunc {
|
|||||||
{"key": "scrape.delay_max_ms", "type": "number", "label": "刮削最大间隔毫秒"},
|
{"key": "scrape.delay_max_ms", "type": "number", "label": "刮削最大间隔毫秒"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"key": "cloud-upload",
|
||||||
|
"label": "网盘转存",
|
||||||
|
"items": []gin.H{
|
||||||
|
{"key": "cloud.upload_auto_enabled", "type": "toggle", "label": "启用自动转存"},
|
||||||
|
{"key": "cloud.upload_provider", "type": "select", "label": "转存目标"},
|
||||||
|
{"key": "cloud.upload_source_dir", "type": "text", "label": "本地源目录"},
|
||||||
|
{"key": "cloud.upload_dest_path", "type": "text", "label": "网盘目标目录"},
|
||||||
|
{"key": "cloud.upload_recursive", "type": "toggle", "label": "递归扫描源目录"},
|
||||||
|
{"key": "cloud.upload_sidecars", "type": "toggle", "label": "同步 NFO / 海报 / 字幕"},
|
||||||
|
{"key": "cloud.upload_overwrite", "type": "toggle", "label": "覆盖远端同名文件"},
|
||||||
|
{"key": "cloud.upload_interval_seconds", "type": "number", "label": "自动转存间隔秒数"},
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"key": "adult",
|
"key": "adult",
|
||||||
"label": "Adult / NSFW",
|
"label": "Adult / NSFW",
|
||||||
|
|||||||
@@ -3,8 +3,10 @@ package cloud
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -62,6 +64,47 @@ func TestQuarkListAndResolve(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestQuarkListPaginates(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/file/sort" {
|
||||||
|
t.Fatalf("unexpected path %s", r.URL.Path)
|
||||||
|
}
|
||||||
|
page, _ := strconv.Atoi(r.URL.Query().Get("_page"))
|
||||||
|
w.Write([]byte(`{"status":200,"code":0,"data":{"list":[` + quarkPagePayload(page) + `]}}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
p, err := New(TypeQuark, map[string]any{"cookie": "kps=abc", "base": srv.URL}, srv.Client())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
entries, err := p.List(context.Background(), "0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list: %v", err)
|
||||||
|
}
|
||||||
|
if len(entries) != 101 {
|
||||||
|
t.Fatalf("entries = %d, want 101", len(entries))
|
||||||
|
}
|
||||||
|
if entries[100].ID != "f100" || entries[100].Name != "Movie.100.mkv" {
|
||||||
|
t.Fatalf("last entry wrong: %#v", entries[100])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func quarkPagePayload(page int) string {
|
||||||
|
count := 100
|
||||||
|
offset := 0
|
||||||
|
if page > 1 {
|
||||||
|
count = 1
|
||||||
|
offset = 100
|
||||||
|
}
|
||||||
|
items := make([]string, 0, count)
|
||||||
|
for i := 0; i < count; i++ {
|
||||||
|
n := offset + i
|
||||||
|
items = append(items, fmt.Sprintf(`{"fid":"f%d","file_name":"Movie.%03d.mkv","dir":false,"size":%d}`, n, n, n))
|
||||||
|
}
|
||||||
|
return strings.Join(items, ",")
|
||||||
|
}
|
||||||
|
|
||||||
func TestQuarkForce302(t *testing.T) {
|
func TestQuarkForce302(t *testing.T) {
|
||||||
p := newQuark(map[string]any{"cookie": "c", "force_302": "true"}, http.DefaultClient)
|
p := newQuark(map[string]any{"cookie": "c", "force_302": "true"}, http.DefaultClient)
|
||||||
if p.proxy {
|
if p.proxy {
|
||||||
@@ -128,6 +171,41 @@ func Test115ListAndResolve(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func Test115ListPaginates(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/files" {
|
||||||
|
t.Fatalf("unexpected path %s", r.URL.Path)
|
||||||
|
}
|
||||||
|
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
|
||||||
|
count := 100
|
||||||
|
if offset > 0 {
|
||||||
|
count = 1
|
||||||
|
}
|
||||||
|
items := make([]string, 0, count)
|
||||||
|
for i := 0; i < count; i++ {
|
||||||
|
n := offset + i
|
||||||
|
items = append(items, fmt.Sprintf(`{"fid":"%d","n":"Movie.%03d.mkv","s":%d,"pc":"pick%d"}`, n, n, n, n))
|
||||||
|
}
|
||||||
|
w.Write([]byte(`{"state":true,"data":[` + strings.Join(items, ",") + `]}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
p, err := New(Type115, map[string]any{"cookie": "UID=1; CID=2", "base": srv.URL}, srv.Client())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
entries, err := p.List(context.Background(), "0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list: %v", err)
|
||||||
|
}
|
||||||
|
if len(entries) != 101 {
|
||||||
|
t.Fatalf("entries = %d, want 101", len(entries))
|
||||||
|
}
|
||||||
|
if entries[100].ID != "100" || entries[100].PickCode != "pick100" {
|
||||||
|
t.Fatalf("last entry wrong: %#v", entries[100])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Test115DownURLEndpointAndError exercises the live fetchDownURLPayload path:
|
// Test115DownURLEndpointAndError exercises the live fetchDownURLPayload path:
|
||||||
// it must POST an m115-encrypted `data` body to /app/chrome/downurl?t=... and
|
// it must POST an m115-encrypted `data` body to /app/chrome/downurl?t=... and
|
||||||
// surface 115's error when state=false (no decryption needed for that branch).
|
// surface 115's error when state=false (no decryption needed for that branch).
|
||||||
|
|||||||
@@ -91,52 +91,59 @@ func (p *pan115Provider) List(ctx context.Context, dirID string) ([]FileEntry, e
|
|||||||
if dirID == "" {
|
if dirID == "" {
|
||||||
dirID = "0"
|
dirID = "0"
|
||||||
}
|
}
|
||||||
q := url.Values{}
|
const pageSize = 100
|
||||||
q.Set("aid", "1")
|
out := make([]FileEntry, 0, pageSize)
|
||||||
q.Set("cid", dirID)
|
for offset := 0; ; offset += pageSize {
|
||||||
q.Set("o", "user_ptime")
|
q := url.Values{}
|
||||||
q.Set("asc", "0")
|
q.Set("aid", "1")
|
||||||
q.Set("offset", "0")
|
q.Set("cid", dirID)
|
||||||
q.Set("show_dir", "1")
|
q.Set("o", "user_ptime")
|
||||||
q.Set("limit", "100")
|
q.Set("asc", "0")
|
||||||
q.Set("format", "json")
|
q.Set("offset", strconv.Itoa(offset))
|
||||||
resp, err := p.get(ctx, p.webBase+"/files?"+q.Encode())
|
q.Set("show_dir", "1")
|
||||||
if err != nil {
|
q.Set("limit", strconv.Itoa(pageSize))
|
||||||
return nil, err
|
q.Set("format", "json")
|
||||||
}
|
resp, err := p.get(ctx, p.webBase+"/files?"+q.Encode())
|
||||||
defer resp.Body.Close()
|
if err != nil {
|
||||||
var r struct {
|
return nil, err
|
||||||
State bool `json:"state"`
|
}
|
||||||
Error string `json:"error"`
|
var r struct {
|
||||||
Data []struct {
|
State bool `json:"state"`
|
||||||
Fid string `json:"fid"` // file id (files only)
|
Error string `json:"error"`
|
||||||
Cid string `json:"cid"` // category id (dirs use this)
|
Data []struct {
|
||||||
N string `json:"n"` // name
|
Fid string `json:"fid"` // file id (files only)
|
||||||
S json.Number `json:"s"` // size
|
Cid string `json:"cid"` // category id (dirs use this)
|
||||||
Pc string `json:"pc"` // pickcode
|
N string `json:"n"` // name
|
||||||
} `json:"data"`
|
S json.Number `json:"s"` // size
|
||||||
}
|
Pc string `json:"pc"` // pickcode
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&r); err != nil {
|
} `json:"data"`
|
||||||
return nil, fmt.Errorf("115: decode list: %w", err)
|
}
|
||||||
}
|
err = json.NewDecoder(resp.Body).Decode(&r)
|
||||||
if !r.State {
|
_ = resp.Body.Close()
|
||||||
return nil, fmt.Errorf("115: list failed: %s", r.Error)
|
if err != nil {
|
||||||
}
|
return nil, fmt.Errorf("115: decode list: %w", err)
|
||||||
out := make([]FileEntry, 0, len(r.Data))
|
}
|
||||||
for _, it := range r.Data {
|
if !r.State {
|
||||||
isDir := it.Fid == ""
|
return nil, fmt.Errorf("115: list failed: %s", r.Error)
|
||||||
id := it.Fid
|
}
|
||||||
if isDir {
|
for _, it := range r.Data {
|
||||||
id = it.Cid
|
isDir := it.Fid == ""
|
||||||
|
id := it.Fid
|
||||||
|
if isDir {
|
||||||
|
id = it.Cid
|
||||||
|
}
|
||||||
|
size, _ := it.S.Int64()
|
||||||
|
out = append(out, FileEntry{
|
||||||
|
ID: id,
|
||||||
|
Name: it.N,
|
||||||
|
IsDir: isDir,
|
||||||
|
Size: size,
|
||||||
|
PickCode: it.Pc,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if len(r.Data) < pageSize {
|
||||||
|
break
|
||||||
}
|
}
|
||||||
size, _ := it.S.Int64()
|
|
||||||
out = append(out, FileEntry{
|
|
||||||
ID: id,
|
|
||||||
Name: it.N,
|
|
||||||
IsDir: isDir,
|
|
||||||
Size: size,
|
|
||||||
PickCode: it.Pc,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -85,38 +86,54 @@ func (q *quarkProvider) List(ctx context.Context, dirID string) ([]FileEntry, er
|
|||||||
if dirID == "" {
|
if dirID == "" {
|
||||||
dirID = "0"
|
dirID = "0"
|
||||||
}
|
}
|
||||||
path := fmt.Sprintf("/file/sort?pr=ucpro&fr=pc&uc_param_str=&pdir_fid=%s&_page=1&_size=100&_fetch_total=1&_sort=file_type:asc,updated_at:desc", dirID)
|
const pageSize = 100
|
||||||
resp, err := q.do(ctx, http.MethodGet, path, nil)
|
out := make([]FileEntry, 0, pageSize)
|
||||||
if err != nil {
|
for page := 1; ; page++ {
|
||||||
return nil, err
|
query := url.Values{}
|
||||||
}
|
query.Set("pr", "ucpro")
|
||||||
defer resp.Body.Close()
|
query.Set("fr", "pc")
|
||||||
var r quarkResp
|
query.Set("uc_param_str", "")
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&r); err != nil {
|
query.Set("pdir_fid", dirID)
|
||||||
return nil, fmt.Errorf("quark: decode list: %w", err)
|
query.Set("_page", fmt.Sprint(page))
|
||||||
}
|
query.Set("_size", fmt.Sprint(pageSize))
|
||||||
if r.Code != 0 && r.Status != 200 {
|
query.Set("_fetch_total", "1")
|
||||||
return nil, fmt.Errorf("quark: list failed: %s", r.Message)
|
query.Set("_sort", "file_type:asc,updated_at:desc")
|
||||||
}
|
path := "/file/sort?" + query.Encode()
|
||||||
var data struct {
|
resp, err := q.do(ctx, http.MethodGet, path, nil)
|
||||||
List []struct {
|
if err != nil {
|
||||||
Fid string `json:"fid"`
|
return nil, err
|
||||||
FileName string `json:"file_name"`
|
}
|
||||||
Dir bool `json:"dir"`
|
var r quarkResp
|
||||||
Size int64 `json:"size"`
|
err = json.NewDecoder(resp.Body).Decode(&r)
|
||||||
} `json:"list"`
|
_ = resp.Body.Close()
|
||||||
}
|
if err != nil {
|
||||||
if err := json.Unmarshal(r.Data, &data); err != nil {
|
return nil, fmt.Errorf("quark: decode list: %w", err)
|
||||||
return nil, fmt.Errorf("quark: decode list data: %w", err)
|
}
|
||||||
}
|
if r.Code != 0 && r.Status != 200 {
|
||||||
out := make([]FileEntry, 0, len(data.List))
|
return nil, fmt.Errorf("quark: list failed: %s", r.Message)
|
||||||
for _, it := range data.List {
|
}
|
||||||
out = append(out, FileEntry{
|
var data struct {
|
||||||
ID: it.Fid,
|
List []struct {
|
||||||
Name: it.FileName,
|
Fid string `json:"fid"`
|
||||||
IsDir: it.Dir,
|
FileName string `json:"file_name"`
|
||||||
Size: it.Size,
|
Dir bool `json:"dir"`
|
||||||
})
|
Size int64 `json:"size"`
|
||||||
|
} `json:"list"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(r.Data, &data); err != nil {
|
||||||
|
return nil, fmt.Errorf("quark: decode list data: %w", err)
|
||||||
|
}
|
||||||
|
for _, it := range data.List {
|
||||||
|
out = append(out, FileEntry{
|
||||||
|
ID: it.Fid,
|
||||||
|
Name: it.FileName,
|
||||||
|
IsDir: it.Dir,
|
||||||
|
Size: it.Size,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if len(data.List) < pageSize {
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -423,14 +423,25 @@ func (e *EmbyService) episodeItems(ctx context.Context, rows []model.Media, p It
|
|||||||
func (e *EmbyService) payloadsForMedia(ctx context.Context, rows []model.Media, userID string) ([]map[string]any, error) {
|
func (e *EmbyService) payloadsForMedia(ctx context.Context, rows []model.Media, userID string) ([]map[string]any, error) {
|
||||||
userFavs := map[string]bool{}
|
userFavs := map[string]bool{}
|
||||||
userPos := map[string]int64{}
|
userPos := map[string]int64{}
|
||||||
if userID != "" {
|
if userID != "" && len(rows) > 0 {
|
||||||
|
mediaIDs := make([]string, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
if strings.TrimSpace(row.ID) != "" {
|
||||||
|
mediaIDs = append(mediaIDs, row.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(mediaIDs) == 0 {
|
||||||
|
mediaIDs = []string{"__none__"}
|
||||||
|
}
|
||||||
var favs []model.Favorite
|
var favs []model.Favorite
|
||||||
_ = e.repo.DB.WithContext(ctx).Where("user_id = ?", userID).Find(&favs).Error
|
favQuery := e.repo.DB.WithContext(ctx).Where("user_id = ?", userID).Where("media_id IN ?", mediaIDs)
|
||||||
|
_ = favQuery.Find(&favs).Error
|
||||||
for _, f := range favs {
|
for _, f := range favs {
|
||||||
userFavs[f.MediaID] = true
|
userFavs[f.MediaID] = true
|
||||||
}
|
}
|
||||||
var hist []model.PlaybackHistory
|
var hist []model.PlaybackHistory
|
||||||
_ = e.repo.DB.WithContext(ctx).Where("user_id = ?", userID).Find(&hist).Error
|
histQuery := e.repo.DB.WithContext(ctx).Where("user_id = ?", userID).Where("media_id IN ?", mediaIDs)
|
||||||
|
_ = histQuery.Find(&hist).Error
|
||||||
for _, h := range hist {
|
for _, h := range hist {
|
||||||
userPos[h.MediaID] = h.PositionMs
|
userPos[h.MediaID] = h.PositionMs
|
||||||
}
|
}
|
||||||
@@ -522,9 +533,18 @@ func (e *EmbyService) LatestItems(ctx context.Context, userID, parentID string,
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
favs := map[string]bool{}
|
favs := map[string]bool{}
|
||||||
if userID != "" {
|
if userID != "" && len(rows) > 0 {
|
||||||
|
mediaIDs := make([]string, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
if strings.TrimSpace(row.ID) != "" {
|
||||||
|
mediaIDs = append(mediaIDs, row.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(mediaIDs) == 0 {
|
||||||
|
mediaIDs = []string{"__none__"}
|
||||||
|
}
|
||||||
var fr []model.Favorite
|
var fr []model.Favorite
|
||||||
_ = e.repo.DB.WithContext(ctx).Where("user_id = ?", userID).Find(&fr).Error
|
_ = e.repo.DB.WithContext(ctx).Where("user_id = ? AND media_id IN ?", userID, mediaIDs).Find(&fr).Error
|
||||||
for _, f := range fr {
|
for _, f := range fr {
|
||||||
favs[f.MediaID] = true
|
favs[f.MediaID] = true
|
||||||
}
|
}
|
||||||
@@ -1231,9 +1251,12 @@ func (e *EmbyService) mediaSource(m *model.Media, asEmbedded, directOnly bool) m
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(m.STRMURL) != "" {
|
if strings.TrimSpace(m.STRMURL) != "" {
|
||||||
// STRM 重定向:客户端直接拉远端,跳过我们这一层。
|
// STRM / cloud:// media still plays through /Videos/{id}/stream.
|
||||||
|
// That route delegates to StreamService, which appends the caller's
|
||||||
|
// token to internal /api/cloud/play redirects and only then 302s to the
|
||||||
|
// provider/CDN. Returning m.STRMURL directly here would make Emby/Yamby
|
||||||
|
// clients hit /api/cloud/play without an auth token and fail with 401.
|
||||||
src["IsRemote"] = true
|
src["IsRemote"] = true
|
||||||
src["DirectStreamUrl"] = m.STRMURL
|
|
||||||
src["Path"] = m.STRMURL
|
src["Path"] = m.STRMURL
|
||||||
}
|
}
|
||||||
return src
|
return src
|
||||||
|
|||||||
@@ -249,6 +249,39 @@ func TestEmbyPlaybackInfoRespectsDirectPlayOnly(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestEmbyPlaybackInfoKeepsSTRMBehindStreamEndpoint(t *testing.T) {
|
||||||
|
svc := newTestEmbyService(t)
|
||||||
|
lib := model.Library{Name: "夸克网盘", Path: `cloud://quark/0`, Type: "movie", Enabled: true}
|
||||||
|
if err := svc.repo.Library.Create(t.Context(), &lib); err != nil {
|
||||||
|
t.Fatalf("create library: %v", err)
|
||||||
|
}
|
||||||
|
media := model.Media{
|
||||||
|
Base: model.Base{ID: "cloud-1"},
|
||||||
|
LibraryID: lib.ID,
|
||||||
|
Title: "Cloud Movie",
|
||||||
|
Path: `cloud://quark/f1`,
|
||||||
|
STRMURL: `/api/cloud/play/quark?ref=f1`,
|
||||||
|
}
|
||||||
|
if err := svc.repo.DB.Create(&media).Error; err != nil {
|
||||||
|
t.Fatalf("create media: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
pb, err := svc.PlaybackInfo(t.Context(), "cloud-1", "user-1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("playback info: %v", err)
|
||||||
|
}
|
||||||
|
src := pb["MediaSources"].([]map[string]any)[0]
|
||||||
|
if src["IsRemote"] != true {
|
||||||
|
t.Fatalf("strm media should be marked remote: %#v", src)
|
||||||
|
}
|
||||||
|
if src["DirectStreamUrl"] != "/Videos/cloud-1/stream" {
|
||||||
|
t.Fatalf("strm playback must stay behind token-aware stream endpoint: %#v", src)
|
||||||
|
}
|
||||||
|
if src["Path"] != "/api/cloud/play/quark?ref=f1" {
|
||||||
|
t.Fatalf("path should expose the strm target for diagnostics: %#v", src)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func newTestEmbyService(t *testing.T) *EmbyService {
|
func newTestEmbyService(t *testing.T) *EmbyService {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||||
|
|||||||
@@ -84,6 +84,12 @@ type ImageProxy struct {
|
|||||||
libRootsAt time.Time
|
libRootsAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
imageBrowserCacheControl = "public, max-age=2592000, immutable"
|
||||||
|
imagePlaceholderCacheControl = "public, max-age=3600"
|
||||||
|
imageNegativeCacheTTL = 6 * time.Hour
|
||||||
|
)
|
||||||
|
|
||||||
// NewImageProxy is the constructor.
|
// NewImageProxy is the constructor.
|
||||||
func NewImageProxy(cfg *config.Config, log *zap.Logger) *ImageProxy {
|
func NewImageProxy(cfg *config.Config, log *zap.Logger) *ImageProxy {
|
||||||
// Honor HTTP(S)_PROXY env vars so deployments behind GFW can pull
|
// Honor HTTP(S)_PROXY env vars so deployments behind GFW can pull
|
||||||
@@ -222,6 +228,13 @@ func servePlaceholder(w http.ResponseWriter) {
|
|||||||
_, _ = w.Write(transparent1x1PNG)
|
_, _ = w.Write(transparent1x1PNG)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func serveCachedPlaceholder(w http.ResponseWriter) {
|
||||||
|
w.Header().Set("Content-Type", "image/png")
|
||||||
|
w.Header().Set("Cache-Control", imagePlaceholderCacheControl)
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = w.Write(transparent1x1PNG)
|
||||||
|
}
|
||||||
|
|
||||||
// Serve writes the requested image to w. Caller is expected to validate
|
// Serve writes the requested image to w. Caller is expected to validate
|
||||||
// the JWT before invoking it.
|
// the JWT before invoking it.
|
||||||
func (p *ImageProxy) Serve(ctx context.Context, w http.ResponseWriter, r *http.Request, raw string) error {
|
func (p *ImageProxy) Serve(ctx context.Context, w http.ResponseWriter, r *http.Request, raw string) error {
|
||||||
@@ -244,7 +257,7 @@ func (p *ImageProxy) Serve(ctx context.Context, w http.ResponseWriter, r *http.R
|
|||||||
modTime = stat.ModTime()
|
modTime = stat.ModTime()
|
||||||
}
|
}
|
||||||
w.Header().Set("Content-Type", detectContentType(data))
|
w.Header().Set("Content-Type", detectContentType(data))
|
||||||
w.Header().Set("Cache-Control", "public, max-age=604800")
|
w.Header().Set("Cache-Control", imageBrowserCacheControl)
|
||||||
http.ServeContent(w, r, filepath.Base(path), modTime, bytes.NewReader(data))
|
http.ServeContent(w, r, filepath.Base(path), modTime, bytes.NewReader(data))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -261,11 +274,12 @@ func (p *ImageProxy) Serve(ctx context.Context, w http.ResponseWriter, r *http.R
|
|||||||
sum := sha1.Sum([]byte(raw))
|
sum := sha1.Sum([]byte(raw))
|
||||||
key := hex.EncodeToString(sum[:])
|
key := hex.EncodeToString(sum[:])
|
||||||
cachePath := filepath.Join(p.cacheDir, key)
|
cachePath := filepath.Join(p.cacheDir, key)
|
||||||
|
failPath := cachePath + ".fail"
|
||||||
|
|
||||||
// Cache hit.
|
// Cache hit.
|
||||||
if data, err := os.ReadFile(cachePath); err == nil && len(data) > 0 {
|
if data, err := os.ReadFile(cachePath); err == nil && len(data) > 0 {
|
||||||
w.Header().Set("Content-Type", detectContentType(data))
|
w.Header().Set("Content-Type", detectContentType(data))
|
||||||
w.Header().Set("Cache-Control", "public, max-age=604800")
|
w.Header().Set("Cache-Control", imageBrowserCacheControl)
|
||||||
stat, _ := os.Stat(cachePath)
|
stat, _ := os.Stat(cachePath)
|
||||||
modTime := time.Now()
|
modTime := time.Now()
|
||||||
if stat != nil {
|
if stat != nil {
|
||||||
@@ -274,6 +288,12 @@ func (p *ImageProxy) Serve(ctx context.Context, w http.ResponseWriter, r *http.R
|
|||||||
http.ServeContent(w, r, key, modTime, bytes.NewReader(data))
|
http.ServeContent(w, r, key, modTime, bytes.NewReader(data))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
if stat, err := os.Stat(failPath); err == nil && time.Since(stat.ModTime()) < imageNegativeCacheTTL {
|
||||||
|
serveCachedPlaceholder(w)
|
||||||
|
return nil
|
||||||
|
} else if err == nil {
|
||||||
|
_ = os.Remove(failPath)
|
||||||
|
}
|
||||||
|
|
||||||
// Cache miss → fetch upstream.
|
// Cache miss → fetch upstream.
|
||||||
if err := os.MkdirAll(p.cacheDir, 0o755); err != nil {
|
if err := os.MkdirAll(p.cacheDir, 0o755); err != nil {
|
||||||
@@ -298,14 +318,16 @@ func (p *ImageProxy) Serve(ctx context.Context, w http.ResponseWriter, r *http.R
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
p.log.Warn("imageproxy: upstream fetch failed",
|
p.log.Warn("imageproxy: upstream fetch failed",
|
||||||
zap.String("host", host), zap.Error(err))
|
zap.String("host", host), zap.Error(err))
|
||||||
servePlaceholder(w)
|
p.markImageFetchFailed(failPath)
|
||||||
|
serveCachedPlaceholder(w)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
if resp.StatusCode >= 400 {
|
if resp.StatusCode >= 400 {
|
||||||
p.log.Warn("imageproxy: upstream returned non-OK",
|
p.log.Warn("imageproxy: upstream returned non-OK",
|
||||||
zap.String("host", host), zap.String("status", resp.Status))
|
zap.String("host", host), zap.String("status", resp.Status))
|
||||||
servePlaceholder(w)
|
p.markImageFetchFailed(failPath)
|
||||||
|
serveCachedPlaceholder(w)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -313,7 +335,8 @@ func (p *ImageProxy) Serve(ctx context.Context, w http.ResponseWriter, r *http.R
|
|||||||
if err != nil || len(data) == 0 {
|
if err != nil || len(data) == 0 {
|
||||||
p.log.Warn("imageproxy: read upstream body failed",
|
p.log.Warn("imageproxy: read upstream body failed",
|
||||||
zap.String("host", host), zap.Error(err))
|
zap.String("host", host), zap.Error(err))
|
||||||
servePlaceholder(w)
|
p.markImageFetchFailed(failPath)
|
||||||
|
serveCachedPlaceholder(w)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -325,6 +348,8 @@ func (p *ImageProxy) Serve(ctx context.Context, w http.ResponseWriter, r *http.R
|
|||||||
tmp.Close()
|
tmp.Close()
|
||||||
if rerr := os.Rename(tmp.Name(), cachePath); rerr != nil {
|
if rerr := os.Rename(tmp.Name(), cachePath); rerr != nil {
|
||||||
_ = os.Remove(tmp.Name())
|
_ = os.Remove(tmp.Name())
|
||||||
|
} else {
|
||||||
|
_ = os.Remove(failPath)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
tmp.Close()
|
tmp.Close()
|
||||||
@@ -347,11 +372,20 @@ func (p *ImageProxy) Serve(ctx context.Context, w http.ResponseWriter, r *http.R
|
|||||||
if v := resp.Header.Get("Last-Modified"); v != "" {
|
if v := resp.Header.Get("Last-Modified"); v != "" {
|
||||||
w.Header().Set("Last-Modified", v)
|
w.Header().Set("Last-Modified", v)
|
||||||
}
|
}
|
||||||
w.Header().Set("Cache-Control", "public, max-age=604800")
|
w.Header().Set("Cache-Control", imageBrowserCacheControl)
|
||||||
http.ServeContent(w, r, key, time.Now(), bytes.NewReader(data))
|
http.ServeContent(w, r, key, time.Now(), bytes.NewReader(data))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (p *ImageProxy) markImageFetchFailed(failPath string) {
|
||||||
|
if err := os.MkdirAll(filepath.Dir(failPath), 0o755); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
_ = os.WriteFile(failPath, []byte(time.Now().Format(time.RFC3339Nano)), 0o644)
|
||||||
|
}
|
||||||
|
|
||||||
// Fetch 拉取远程图片并返回字节和 Content-Type(带缓存)。
|
// Fetch 拉取远程图片并返回字节和 Content-Type(带缓存)。
|
||||||
func (p *ImageProxy) Fetch(ctx context.Context, raw string) ([]byte, string, error) {
|
func (p *ImageProxy) Fetch(ctx context.Context, raw string) ([]byte, string, error) {
|
||||||
u, err := p.validateURL(raw)
|
u, err := p.validateURL(raw)
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
@@ -78,6 +81,46 @@ func TestImageProxyServesPosterUnderLibraryRoot(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestImageProxyCachesFailedRemoteImageFetch(t *testing.T) {
|
||||||
|
var calls int32
|
||||||
|
proxy := NewImageProxy(&config.Config{Cache: config.CacheConfig{CacheDir: filepath.Join(t.TempDir(), "cache")}}, zap.NewNop())
|
||||||
|
proxy.client = &http.Client{Transport: imageRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||||
|
atomic.AddInt32(&calls, 1)
|
||||||
|
return &http.Response{
|
||||||
|
StatusCode: http.StatusBadGateway,
|
||||||
|
Status: "502 Bad Gateway",
|
||||||
|
Header: make(http.Header),
|
||||||
|
Body: io.NopCloser(strings.NewReader("upstream unavailable")),
|
||||||
|
Request: req,
|
||||||
|
}, nil
|
||||||
|
})}
|
||||||
|
raw := "https://image.tmdb.org/t/p/w500/poster.jpg"
|
||||||
|
for i := 0; i < 2; i++ {
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
if err := proxy.Serve(t.Context(), rec, httptest.NewRequest(http.MethodGet, "/api/img", nil), raw); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", rec.Code)
|
||||||
|
}
|
||||||
|
if rec.Body.Len() != len(transparent1x1PNG) {
|
||||||
|
t.Fatalf("body length = %d, want placeholder %d", rec.Body.Len(), len(transparent1x1PNG))
|
||||||
|
}
|
||||||
|
if got := rec.Header().Get("Cache-Control"); got != imagePlaceholderCacheControl {
|
||||||
|
t.Fatalf("Cache-Control = %q, want %q", got, imagePlaceholderCacheControl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got := atomic.LoadInt32(&calls); got != 1 {
|
||||||
|
t.Fatalf("upstream calls = %d, want 1 due to negative cache", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type imageRoundTripFunc func(*http.Request) (*http.Response, error)
|
||||||
|
|
||||||
|
func (f imageRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||||
|
return f(req)
|
||||||
|
}
|
||||||
|
|
||||||
func TestIsPrivateHost(t *testing.T) {
|
func TestIsPrivateHost(t *testing.T) {
|
||||||
blocked := []string{"127.0.0.1", "10.0.0.5", "192.168.1.10", "169.254.169.254", "0.0.0.0", "::1", ""}
|
blocked := []string{"127.0.0.1", "10.0.0.5", "192.168.1.10", "169.254.169.254", "0.0.0.0", "::1", ""}
|
||||||
for _, h := range blocked {
|
for _, h := range blocked {
|
||||||
|
|||||||
@@ -92,6 +92,70 @@ func resolveAccessibleLibraryPath(path string) (string, error) {
|
|||||||
return "", fmt.Errorf("path is not an accessible directory: %s", abs)
|
return "", fmt.Errorf("path is not an accessible directory: %s", abs)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func resolveAccessibleMappedPath(path string) (string, os.FileInfo, error) {
|
||||||
|
input := strings.TrimSpace(path)
|
||||||
|
if input == "" {
|
||||||
|
return "", nil, errors.New("path required")
|
||||||
|
}
|
||||||
|
candidates := mappedPathCandidates(input)
|
||||||
|
for _, candidate := range candidates {
|
||||||
|
if info, err := os.Stat(candidate); err == nil {
|
||||||
|
return filepath.Clean(candidate), info, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
abs, err := filepath.Abs(input)
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, fmt.Errorf("invalid path: %w", err)
|
||||||
|
}
|
||||||
|
return "", nil, fmt.Errorf("path is not accessible: %s", abs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveMappedDestinationPath(path string) string {
|
||||||
|
path = strings.TrimSpace(path)
|
||||||
|
if path == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
clean := filepath.Clean(path)
|
||||||
|
if _, err := os.Stat(clean); err == nil {
|
||||||
|
return clean
|
||||||
|
}
|
||||||
|
for _, candidate := range mappedPathCandidates(clean) {
|
||||||
|
if candidate == clean {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return filepath.Clean(candidate)
|
||||||
|
}
|
||||||
|
return clean
|
||||||
|
}
|
||||||
|
|
||||||
|
func mappedPathCandidates(input string) []string {
|
||||||
|
var candidates []string
|
||||||
|
add := func(candidate string) {
|
||||||
|
candidate = filepath.Clean(filepath.FromSlash(strings.TrimSpace(candidate)))
|
||||||
|
if candidate == "" || candidate == "." {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, existing := range candidates {
|
||||||
|
if sameLibraryPath(existing, candidate) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
candidates = append(candidates, candidate)
|
||||||
|
}
|
||||||
|
clean := filepath.Clean(input)
|
||||||
|
add(clean)
|
||||||
|
for _, candidate := range dockerVolumePathCandidates(clean) {
|
||||||
|
add(candidate)
|
||||||
|
}
|
||||||
|
if abs, err := filepath.Abs(input); err == nil {
|
||||||
|
add(abs)
|
||||||
|
for _, candidate := range dockerVolumePathCandidates(abs) {
|
||||||
|
add(candidate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return candidates
|
||||||
|
}
|
||||||
|
|
||||||
func isAccessibleDir(path string) bool {
|
func isAccessibleDir(path string) bool {
|
||||||
info, err := os.Stat(path)
|
info, err := os.Stat(path)
|
||||||
return err == nil && info.IsDir()
|
return err == nil && info.IsDir()
|
||||||
|
|||||||
@@ -108,16 +108,15 @@ func (o *OrganizerService) defaultDestRoot(ctx context.Context, override string)
|
|||||||
// OrganizeDirectory organizes every video file found under opts.SourcePath into
|
// OrganizeDirectory organizes every video file found under opts.SourcePath into
|
||||||
// the destination root, applying dedup + 洗版 (resolution replacement).
|
// the destination root, applying dedup + 洗版 (resolution replacement).
|
||||||
func (o *OrganizerService) OrganizeDirectory(ctx context.Context, opts OrganizeOptions) (*OrganizeResult, error) {
|
func (o *OrganizerService) OrganizeDirectory(ctx context.Context, opts OrganizeOptions) (*OrganizeResult, error) {
|
||||||
source := strings.TrimSpace(o.defaultSourceRoot(ctx, opts.SourcePath))
|
requestedSource := strings.TrimSpace(o.defaultSourceRoot(ctx, opts.SourcePath))
|
||||||
if source == "" {
|
if requestedSource == "" {
|
||||||
return nil, errors.New("source path required")
|
return nil, errors.New("source path required")
|
||||||
}
|
}
|
||||||
source = filepath.Clean(source)
|
source, info, statErr := resolveAccessibleMappedPath(requestedSource)
|
||||||
info, statErr := os.Stat(source)
|
|
||||||
if statErr != nil {
|
if statErr != nil {
|
||||||
return nil, fmt.Errorf("source directory not accessible: %s", source)
|
return nil, fmt.Errorf("source directory not accessible: %s", filepath.Clean(requestedSource))
|
||||||
}
|
}
|
||||||
dest := filepath.Clean(o.defaultDestRoot(ctx, opts.DestPath))
|
dest := resolveMappedDestinationPath(o.defaultDestRoot(ctx, opts.DestPath))
|
||||||
if dest == "" || dest == "." {
|
if dest == "" || dest == "." {
|
||||||
return nil, errors.New("destination path required")
|
return nil, errors.New("destination path required")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,6 +100,44 @@ func TestOrganizeDirectoryUsesConfiguredSourceWhenRequestSourceEmpty(t *testing.
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestOrganizeDirectoryMapsConfiguredHostPathsToContainerPaths(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
hostDownloads := filepath.Join(root, "nas-host", "downloads")
|
||||||
|
hostMedia := filepath.Join(root, "nas-host", "media")
|
||||||
|
containerDownloads := filepath.Join(root, "container", "downloads")
|
||||||
|
containerMedia := filepath.Join(root, "container", "media")
|
||||||
|
containerSource := filepath.Join(containerDownloads, "国产剧")
|
||||||
|
writeOrgFile(t, filepath.Join(containerSource, "Some Show S01E01 2024 1080p.mkv"), "show-e01")
|
||||||
|
|
||||||
|
t.Setenv("MEDIASTATION_DOWNLOAD_DIR", hostDownloads)
|
||||||
|
t.Setenv("MEDIASTATION_DOWNLOAD_CONTAINER_DIR", containerDownloads)
|
||||||
|
t.Setenv("MEDIASTATION_MEDIA_DIR", hostMedia)
|
||||||
|
t.Setenv("MEDIASTATION_MEDIA_CONTAINER_DIR", containerMedia)
|
||||||
|
|
||||||
|
repos := newOrganizerTestRepo(t)
|
||||||
|
for key, value := range map[string]string{
|
||||||
|
"organize.source_dir": filepath.Join(hostDownloads, "国产剧"),
|
||||||
|
"organize.target_dir": hostMedia,
|
||||||
|
"organize.transfer_mode": "copy",
|
||||||
|
} {
|
||||||
|
if err := repos.Setting.Set(t.Context(), key, value); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
org := NewOrganizerService(&config.Config{}, zap.NewNop(), repos)
|
||||||
|
res, err := org.OrganizeDirectory(t.Context(), OrganizeOptions{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("organize mapped host paths: %v", err)
|
||||||
|
}
|
||||||
|
if res.SourcePath != filepath.Clean(containerSource) || res.DestPath != filepath.Clean(containerMedia) {
|
||||||
|
t.Fatalf("result paths = source %q dest %q, want %q -> %q", res.SourcePath, res.DestPath, containerSource, containerMedia)
|
||||||
|
}
|
||||||
|
want := filepath.Join(containerMedia, "电视剧", "国产剧", "Some Show", "Season 01", "Some Show - S01E01.mkv")
|
||||||
|
if _, err := os.Stat(want); err != nil {
|
||||||
|
t.Fatalf("expected organized file at %q: %v", want, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestOrganizeDirectoryAcceptsSingleVideoFileSource(t *testing.T) {
|
func TestOrganizeDirectoryAcceptsSingleVideoFileSource(t *testing.T) {
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
src := filepath.Join(root, "downloads", "Dune 2021 2160p WEB-DL.mkv")
|
src := filepath.Join(root, "downloads", "Dune 2021 2160p WEB-DL.mkv")
|
||||||
|
|||||||
@@ -64,11 +64,26 @@ func (p *PlaybackService) RecentHistory(ctx context.Context, userID string, limi
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
mediaIDs := make([]string, 0, len(rows))
|
||||||
|
for i := range rows {
|
||||||
|
if rows[i].MediaID != "" {
|
||||||
|
mediaIDs = append(mediaIDs, rows[i].MediaID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mediaByID := map[string]model.Media{}
|
||||||
|
if len(mediaIDs) > 0 {
|
||||||
|
var mediaRows []model.Media
|
||||||
|
if err := p.repo.DB.WithContext(ctx).Where("id IN ?", mediaIDs).Find(&mediaRows).Error; err == nil {
|
||||||
|
for _, media := range mediaRows {
|
||||||
|
mediaByID[media.ID] = media
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
items := make([]HistoryItem, 0, len(rows))
|
items := make([]HistoryItem, 0, len(rows))
|
||||||
for i := range rows {
|
for i := range rows {
|
||||||
var m model.Media
|
if m, ok := mediaByID[rows[i].MediaID]; ok {
|
||||||
if err := p.repo.DB.Where("id = ?", rows[i].MediaID).First(&m).Error; err == nil {
|
media := m
|
||||||
items = append(items, HistoryItem{PlaybackHistory: rows[i], Media: &m})
|
items = append(items, HistoryItem{PlaybackHistory: rows[i], Media: &media})
|
||||||
} else {
|
} else {
|
||||||
items = append(items, HistoryItem{PlaybackHistory: rows[i]})
|
items = append(items, HistoryItem{PlaybackHistory: rows[i]})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -49,6 +51,7 @@ type ScannerService struct {
|
|||||||
hub *Hub
|
hub *Hub
|
||||||
probe *FFprobeService
|
probe *FFprobeService
|
||||||
scraper *ScraperService
|
scraper *ScraperService
|
||||||
|
storage *StorageConfigService
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewScannerService is the constructor.
|
// NewScannerService is the constructor.
|
||||||
@@ -66,6 +69,13 @@ func NewScannerService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetStorageConfig wires cloud-disk storage access into the scanner. It is set
|
||||||
|
// after service construction because StorageConfigService depends on Crypto,
|
||||||
|
// while the scanner is needed earlier by watcher/download services.
|
||||||
|
func (s *ScannerService) SetStorageConfig(storage *StorageConfigService) {
|
||||||
|
s.storage = storage
|
||||||
|
}
|
||||||
|
|
||||||
// ScanResult summarises a scan run.
|
// ScanResult summarises a scan run.
|
||||||
type ScanResult struct {
|
type ScanResult struct {
|
||||||
LibraryID string `json:"library_id"`
|
LibraryID string `json:"library_id"`
|
||||||
@@ -88,6 +98,9 @@ func (s *ScannerService) scanLibrary(ctx context.Context, libraryID string, auto
|
|||||||
if err != nil || lib == nil {
|
if err != nil || lib == nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if typ, dirID, ok := parseCloudLibraryPath(lib.Path); ok {
|
||||||
|
return s.scanCloudLibrary(ctx, lib, typ, dirID, autoScrape)
|
||||||
|
}
|
||||||
res := &ScanResult{LibraryID: lib.ID}
|
res := &ScanResult{LibraryID: lib.ID}
|
||||||
seen := make(map[string]struct{})
|
seen := make(map[string]struct{})
|
||||||
seenInodes := make(map[string]string)
|
seenInodes := make(map[string]string)
|
||||||
@@ -161,6 +174,124 @@ func (s *ScannerService) IngestPath(ctx context.Context, libraryID, path string)
|
|||||||
return res.Added+res.Updated > 0, nil
|
return res.Added+res.Updated > 0, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *ScannerService) scanCloudLibrary(ctx context.Context, lib *model.Library, typ, rootDir string, autoScrape bool) (*ScanResult, error) {
|
||||||
|
res := &ScanResult{LibraryID: lib.ID}
|
||||||
|
if s.storage == nil {
|
||||||
|
return res, fmt.Errorf("cloud storage service unavailable")
|
||||||
|
}
|
||||||
|
seen := make(map[string]struct{})
|
||||||
|
visitedDirs := map[string]struct{}{}
|
||||||
|
var walkCloud func(string) error
|
||||||
|
walkCloud = func(dirID string) error {
|
||||||
|
if _, ok := visitedDirs[dirID]; ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
visitedDirs[dirID] = struct{}{}
|
||||||
|
entries, err := s.storage.CloudList(ctx, typ, dirID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, entry := range entries {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
if entry.IsDir {
|
||||||
|
if strings.TrimSpace(entry.ID) != "" {
|
||||||
|
if err := walkCloud(entry.ID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ext := strings.ToLower(filepath.Ext(entry.Name))
|
||||||
|
if _, ok := videoExtensions[ext]; !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ref := cloudEntryRef(typ, entry.ID, entry.PickCode)
|
||||||
|
if ref == "" {
|
||||||
|
res.Skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
path := cloudMediaPath(typ, ref)
|
||||||
|
seen[path] = struct{}{}
|
||||||
|
s.ingestCloudFile(ctx, lib, typ, ref, entry.Name, entry.Size, res)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := walkCloud(rootDir); err != nil {
|
||||||
|
return res, err
|
||||||
|
}
|
||||||
|
removed, err := s.pruneMissingCloudMedia(ctx, lib.ID, seen)
|
||||||
|
if err != nil {
|
||||||
|
s.log.Warn("prune missing cloud media failed", zap.String("library_id", lib.ID), zap.Error(err))
|
||||||
|
} else {
|
||||||
|
res.Removed = removed
|
||||||
|
}
|
||||||
|
s.hub.Publish("scan", map[string]any{
|
||||||
|
"library_id": lib.ID,
|
||||||
|
"finished": true,
|
||||||
|
"visited": res.Visited,
|
||||||
|
"added": res.Added,
|
||||||
|
"updated": res.Updated,
|
||||||
|
"removed": res.Removed,
|
||||||
|
"cloud": true,
|
||||||
|
})
|
||||||
|
if autoScrape && s.scraper != nil && s.scraper.AnyEnabled() && s.autoScrapeEnabled(ctx) {
|
||||||
|
go func(libID string) {
|
||||||
|
if _, err := s.scraper.EnrichLibrary(context.Background(), libID); err != nil {
|
||||||
|
s.log.Warn("scraper enrich failed", zap.Error(err))
|
||||||
|
}
|
||||||
|
}(lib.ID)
|
||||||
|
}
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ScannerService) ingestCloudFile(ctx context.Context, lib *model.Library, typ, ref, name string, size int64, res *ScanResult) {
|
||||||
|
res.Visited++
|
||||||
|
ext := strings.ToLower(filepath.Ext(name))
|
||||||
|
title, year := CleanQuery(name)
|
||||||
|
if title == "" {
|
||||||
|
title = strings.TrimSuffix(filepath.Base(name), ext)
|
||||||
|
}
|
||||||
|
if title == "" {
|
||||||
|
title = ref
|
||||||
|
}
|
||||||
|
path := cloudMediaPath(typ, ref)
|
||||||
|
isNewMedia := !s.mediaPathExists(ctx, path)
|
||||||
|
m := &model.Media{
|
||||||
|
LibraryID: lib.ID,
|
||||||
|
Title: title,
|
||||||
|
Year: year,
|
||||||
|
Path: path,
|
||||||
|
SizeBytes: size,
|
||||||
|
Container: strings.TrimPrefix(ext, "."),
|
||||||
|
STRMURL: "/api/cloud/play/" + typ + "?ref=" + url.QueryEscape(ref),
|
||||||
|
ScrapeStatus: "pending",
|
||||||
|
}
|
||||||
|
parsedSeason, parsedEpisode := ParseEpisode(name)
|
||||||
|
m.SeasonNum = parsedSeason
|
||||||
|
m.EpisodeNum = parsedEpisode
|
||||||
|
if err := s.repo.Media.Upsert(ctx, m); err != nil {
|
||||||
|
s.log.Warn("upsert cloud media failed", zap.String("path", path), zap.Error(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if isNewMedia {
|
||||||
|
res.Added++
|
||||||
|
} else {
|
||||||
|
res.Updated++
|
||||||
|
}
|
||||||
|
s.hub.Publish("scan", map[string]any{
|
||||||
|
"library_id": lib.ID,
|
||||||
|
"path": path,
|
||||||
|
"visited": res.Visited,
|
||||||
|
"added": res.Added,
|
||||||
|
"updated": res.Updated,
|
||||||
|
"cloud": true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// RemovePath deletes the media row for a path that has disappeared from disk
|
// RemovePath deletes the media row for a path that has disappeared from disk
|
||||||
// (incremental delete used by the watcher on Remove/Rename events).
|
// (incremental delete used by the watcher on Remove/Rename events).
|
||||||
func (s *ScannerService) RemovePath(ctx context.Context, path string) (int64, error) {
|
func (s *ScannerService) RemovePath(ctx context.Context, path string) (int64, error) {
|
||||||
@@ -326,6 +457,63 @@ func (s *ScannerService) pruneMissingMedia(ctx context.Context, libraryID string
|
|||||||
return removed, nil
|
return removed, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *ScannerService) pruneMissingCloudMedia(ctx context.Context, libraryID string, seen map[string]struct{}) (int64, error) {
|
||||||
|
var rows []model.Media
|
||||||
|
if err := s.repo.DB.WithContext(ctx).
|
||||||
|
Where("library_id = ? AND path LIKE ?", libraryID, "cloud://%").
|
||||||
|
Find(&rows).Error; err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
var removed int64
|
||||||
|
for _, row := range rows {
|
||||||
|
if _, ok := seen[row.Path]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
res := s.repo.DB.WithContext(ctx).
|
||||||
|
Where("id = ?", row.ID).
|
||||||
|
Delete(&model.Media{})
|
||||||
|
if res.Error != nil {
|
||||||
|
return removed, res.Error
|
||||||
|
}
|
||||||
|
removed += res.RowsAffected
|
||||||
|
}
|
||||||
|
return removed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseCloudLibraryPath(raw string) (typ, dirID string, ok bool) {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if !strings.HasPrefix(strings.ToLower(raw), "cloud://") {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
u, err := url.Parse(raw)
|
||||||
|
if err != nil || strings.ToLower(u.Scheme) != "cloud" {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
typ = strings.TrimSpace(u.Host)
|
||||||
|
if typ == "" {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
dirID = strings.Trim(strings.TrimSpace(u.Path), "/")
|
||||||
|
if qDir := strings.TrimSpace(u.Query().Get("dir")); qDir != "" {
|
||||||
|
dirID = qDir
|
||||||
|
}
|
||||||
|
if decoded, err := url.PathUnescape(dirID); err == nil {
|
||||||
|
dirID = decoded
|
||||||
|
}
|
||||||
|
return typ, dirID, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloudEntryRef(typ, id, pickCode string) string {
|
||||||
|
if typ == "cloud115" && strings.TrimSpace(pickCode) != "" {
|
||||||
|
return strings.TrimSpace(pickCode)
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloudMediaPath(typ, ref string) string {
|
||||||
|
return "cloud://" + strings.TrimSpace(typ) + "/" + strings.TrimSpace(ref)
|
||||||
|
}
|
||||||
|
|
||||||
func applyLocalMetadata(m *model.Media, local *LocalMetadata) {
|
func applyLocalMetadata(m *model.Media, local *LocalMetadata) {
|
||||||
if local.Title != "" {
|
if local.Title != "" {
|
||||||
m.Title = local.Title
|
m.Title = local.Title
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/glebarez/sqlite"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"github.com/ShukeBta/MediaStationGo/internal/config"
|
||||||
|
"github.com/ShukeBta/MediaStationGo/internal/model"
|
||||||
|
"github.com/ShukeBta/MediaStationGo/internal/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestScanCloudLibraryImportsRecursivePlayableMedia(t *testing.T) {
|
||||||
|
empty := false
|
||||||
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/file/sort" {
|
||||||
|
t.Fatalf("unexpected path %s", r.URL.Path)
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
if empty {
|
||||||
|
_, _ = w.Write([]byte(`{"status":200,"code":0,"data":{"list":[]}}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch r.URL.Query().Get("pdir_fid") {
|
||||||
|
case "0":
|
||||||
|
_, _ = w.Write([]byte(`{"status":200,"code":0,"data":{"list":[
|
||||||
|
{"fid":"d1","file_name":"Movies","dir":true,"size":0},
|
||||||
|
{"fid":"f1","file_name":"Root.Movie.2024.mkv","dir":false,"size":123}
|
||||||
|
]}}`))
|
||||||
|
case "d1":
|
||||||
|
_, _ = w.Write([]byte(`{"status":200,"code":0,"data":{"list":[
|
||||||
|
{"fid":"f2","file_name":"Nested.Show.S01E02.mp4","dir":false,"size":456}
|
||||||
|
]}}`))
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected pdir_fid %q", r.URL.Query().Get("pdir_fid"))
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer upstream.Close()
|
||||||
|
|
||||||
|
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := db.AutoMigrate(&model.Library{}, &model.Media{}, &model.Setting{}, &model.StorageConfig{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
repos := repository.New(db)
|
||||||
|
log := zap.NewNop()
|
||||||
|
storage := NewStorageConfigService(log, repos, NewCryptoService("", log))
|
||||||
|
if _, err := storage.Save(t.Context(), StorageInput{
|
||||||
|
Type: "quark",
|
||||||
|
Config: map[string]any{
|
||||||
|
"cookie": "kps=test",
|
||||||
|
"base": upstream.URL,
|
||||||
|
},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
lib := model.Library{Name: "夸克网盘", Path: "cloud://quark/0", Type: "tv", Enabled: true}
|
||||||
|
if err := repos.Library.Create(t.Context(), &lib); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
scanner := NewScannerService(&config.Config{}, log, repos, NewHub(log), nil, nil)
|
||||||
|
scanner.SetStorageConfig(storage)
|
||||||
|
|
||||||
|
res, err := scanner.ScanLibrary(t.Context(), lib.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("scan cloud: %v", err)
|
||||||
|
}
|
||||||
|
if res.Visited != 2 || res.Added != 2 {
|
||||||
|
t.Fatalf("scan result = %#v, want visited=2 added=2", res)
|
||||||
|
}
|
||||||
|
var rows []model.Media
|
||||||
|
if err := repos.DB.Order("path").Find(&rows).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(rows) != 2 {
|
||||||
|
t.Fatalf("media rows = %d, want 2: %#v", len(rows), rows)
|
||||||
|
}
|
||||||
|
if rows[0].Path != "cloud://quark/f1" || rows[0].STRMURL != "/api/cloud/play/quark?ref=f1" {
|
||||||
|
t.Fatalf("root media path/strm wrong: path=%q strm=%q", rows[0].Path, rows[0].STRMURL)
|
||||||
|
}
|
||||||
|
if rows[1].SeasonNum != 1 || rows[1].EpisodeNum != 2 || !strings.Contains(rows[1].STRMURL, "ref=f2") {
|
||||||
|
t.Fatalf("nested episode metadata wrong: %#v", rows[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
empty = true
|
||||||
|
res, err = scanner.ScanLibrary(t.Context(), lib.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("rescan cloud: %v", err)
|
||||||
|
}
|
||||||
|
if res.Removed != 2 {
|
||||||
|
t.Fatalf("removed = %d, want 2", res.Removed)
|
||||||
|
}
|
||||||
|
if got := countMedia(t, repos); got != 0 {
|
||||||
|
t.Fatalf("media count after prune = %d, want 0", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCloudLibraryPathParsing(t *testing.T) {
|
||||||
|
typ, dir, ok := parseCloudLibraryPath("cloud://cloud115/abc%20123?ignored=1")
|
||||||
|
if !ok || typ != "cloud115" || dir != "abc 123" {
|
||||||
|
t.Fatalf("parse path got typ=%q dir=%q ok=%v", typ, dir, ok)
|
||||||
|
}
|
||||||
|
typ, dir, ok = parseCloudLibraryPath("cloud://quark?dir=0")
|
||||||
|
if !ok || typ != "quark" || dir != "0" {
|
||||||
|
t.Fatalf("parse query got typ=%q dir=%q ok=%v", typ, dir, ok)
|
||||||
|
}
|
||||||
|
if ref := cloudEntryRef("cloud115", "fid", "pick"); ref != "pick" {
|
||||||
|
t.Fatalf("115 ref = %q, want pick", ref)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -44,6 +44,7 @@ type SchedulerService struct {
|
|||||||
scanner *ScannerService
|
scanner *ScannerService
|
||||||
transcoder *TranscoderService
|
transcoder *TranscoderService
|
||||||
organizer *OrganizerService
|
organizer *OrganizerService
|
||||||
|
storageCfg *StorageConfigService
|
||||||
hub *Hub
|
hub *Hub
|
||||||
cacheDir string
|
cacheDir string
|
||||||
|
|
||||||
@@ -70,6 +71,7 @@ func NewSchedulerService(
|
|||||||
scanner *ScannerService,
|
scanner *ScannerService,
|
||||||
transcoder *TranscoderService,
|
transcoder *TranscoderService,
|
||||||
organizer *OrganizerService,
|
organizer *OrganizerService,
|
||||||
|
storageCfg *StorageConfigService,
|
||||||
hub *Hub,
|
hub *Hub,
|
||||||
cacheDir string,
|
cacheDir string,
|
||||||
) *SchedulerService {
|
) *SchedulerService {
|
||||||
@@ -79,6 +81,7 @@ func NewSchedulerService(
|
|||||||
scanner: scanner,
|
scanner: scanner,
|
||||||
transcoder: transcoder,
|
transcoder: transcoder,
|
||||||
organizer: organizer,
|
organizer: organizer,
|
||||||
|
storageCfg: storageCfg,
|
||||||
hub: hub,
|
hub: hub,
|
||||||
cacheDir: cacheDir,
|
cacheDir: cacheDir,
|
||||||
stopCh: make(chan struct{}),
|
stopCh: make(chan struct{}),
|
||||||
@@ -93,6 +96,16 @@ func (s *SchedulerService) Start(ctx context.Context) {
|
|||||||
interval: 60 * time.Minute,
|
interval: 60 * time.Minute,
|
||||||
run: s.jobScanLibraries,
|
run: s.jobScanLibraries,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "cloud_sync",
|
||||||
|
interval: s.cloudSyncInterval(ctx),
|
||||||
|
run: s.jobSyncCloudLibraries,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "cloud_upload",
|
||||||
|
interval: s.cloudUploadInterval(ctx),
|
||||||
|
run: s.jobUploadLocalToCloud,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "organize_source",
|
name: "organize_source",
|
||||||
interval: s.organizeSourceInterval(ctx),
|
interval: s.organizeSourceInterval(ctx),
|
||||||
@@ -227,6 +240,137 @@ func (s *SchedulerService) jobScanLibraries(ctx context.Context) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// jobUploadLocalToCloud copies local media files into the configured external
|
||||||
|
// storage backend. It is opt-in and never deletes the local source files.
|
||||||
|
func (s *SchedulerService) jobUploadLocalToCloud(ctx context.Context) error {
|
||||||
|
manual, _ := ctx.Value(schedulerManualRunKey{}).(bool)
|
||||||
|
if s.storageCfg == nil || (!manual && !s.autoCloudUploadEnabled(ctx)) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
input := s.cloudUploadInput(ctx)
|
||||||
|
if strings.TrimSpace(input.Type) == "" || strings.TrimSpace(input.SourcePath) == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
res, err := s.storageCfg.UploadLocal(ctx, input)
|
||||||
|
if s.log != nil && res != nil {
|
||||||
|
s.log.Info("cloud upload finished",
|
||||||
|
zap.String("type", input.Type),
|
||||||
|
zap.String("source", res.SourcePath),
|
||||||
|
zap.String("dest", res.DestPath),
|
||||||
|
zap.Int("uploaded", res.Uploaded),
|
||||||
|
zap.Int("skipped", res.Skipped),
|
||||||
|
zap.Int64("bytes", res.Bytes),
|
||||||
|
zap.Int("errors", len(res.Errors)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SchedulerService) cloudUploadInput(ctx context.Context) CloudUploadInput {
|
||||||
|
get := func(key string) string {
|
||||||
|
if s.repo == nil || s.repo.Setting == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
v, _ := s.repo.Setting.Get(ctx, key)
|
||||||
|
return strings.TrimSpace(v)
|
||||||
|
}
|
||||||
|
return CloudUploadInput{
|
||||||
|
Type: get(CloudUploadProviderKey),
|
||||||
|
SourcePath: get(CloudUploadSourceDirKey),
|
||||||
|
DestPath: get(CloudUploadDestPathKey),
|
||||||
|
Recursive: parseBoolSetting(get(CloudUploadRecursiveKey), true),
|
||||||
|
IncludeSidecars: parseBoolSetting(get(CloudUploadSidecarsKey), true),
|
||||||
|
Overwrite: parseBoolSetting(get(CloudUploadOverwriteKey), false),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SchedulerService) autoCloudUploadEnabled(ctx context.Context) bool {
|
||||||
|
if s.repo == nil || s.repo.Setting == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
v, err := s.repo.Setting.Get(ctx, CloudUploadAutoEnabledKey)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return parseBoolSetting(v, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SchedulerService) cloudUploadInterval(ctx context.Context) time.Duration {
|
||||||
|
const fallback = time.Hour
|
||||||
|
if s.repo == nil || s.repo.Setting == nil {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
v, err := s.repo.Setting.Get(ctx, CloudUploadIntervalSecondsKey)
|
||||||
|
if err != nil {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
seconds, err := strconv.Atoi(strings.TrimSpace(v))
|
||||||
|
if err != nil || seconds <= 0 {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
if seconds < 300 {
|
||||||
|
seconds = 300
|
||||||
|
}
|
||||||
|
return time.Duration(seconds) * time.Second
|
||||||
|
}
|
||||||
|
|
||||||
|
// jobSyncCloudLibraries keeps mounted cloud:// libraries refreshed without
|
||||||
|
// enabling full disk scans. It imports remote cloud files as STRM-backed media
|
||||||
|
// rows; the actual bytes stay on the provider and playback continues through
|
||||||
|
// /api/cloud/play 302/proxy.
|
||||||
|
func (s *SchedulerService) jobSyncCloudLibraries(ctx context.Context) error {
|
||||||
|
manual, _ := ctx.Value(schedulerManualRunKey{}).(bool)
|
||||||
|
if s.scanner == nil || (!manual && !s.autoCloudSyncEnabled(ctx)) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
libs, err := s.repo.Library.List(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, l := range libs {
|
||||||
|
if !l.Enabled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, _, ok := parseCloudLibraryPath(l.Path); !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, err := s.scanner.ScanLibrary(ctx, l.ID); err != nil {
|
||||||
|
s.log.Warn("cloud sync failed", zap.String("library", l.ID), zap.Error(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SchedulerService) autoCloudSyncEnabled(ctx context.Context) bool {
|
||||||
|
if s.repo == nil || s.repo.Setting == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
v, err := s.repo.Setting.Get(ctx, "cloud.auto_sync_enabled")
|
||||||
|
if err != nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return parseBoolSetting(v, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SchedulerService) cloudSyncInterval(ctx context.Context) time.Duration {
|
||||||
|
const fallback = 30 * time.Minute
|
||||||
|
if s.repo == nil || s.repo.Setting == nil {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
v, err := s.repo.Setting.Get(ctx, "cloud.sync_interval_seconds")
|
||||||
|
if err != nil {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
seconds, err := strconv.Atoi(strings.TrimSpace(v))
|
||||||
|
if err != nil || seconds <= 0 {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
if seconds < 300 {
|
||||||
|
seconds = 300
|
||||||
|
}
|
||||||
|
return time.Duration(seconds) * time.Second
|
||||||
|
}
|
||||||
|
|
||||||
// periodicScanEnabled reports whether the operator opted into periodic full
|
// periodicScanEnabled reports whether the operator opted into periodic full
|
||||||
// library re-scans. Defaults to false so the incremental watcher is the only
|
// library re-scans. Defaults to false so the incremental watcher is the only
|
||||||
// thing touching the disk under normal operation.
|
// thing touching the disk under normal operation.
|
||||||
|
|||||||
@@ -1,14 +1,20 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/glebarez/sqlite"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
"github.com/ShukeBta/MediaStationGo/internal/config"
|
"github.com/ShukeBta/MediaStationGo/internal/config"
|
||||||
|
"github.com/ShukeBta/MediaStationGo/internal/model"
|
||||||
|
"github.com/ShukeBta/MediaStationGo/internal/repository"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestSchedulerOrganizeSourceDisabledByDefault(t *testing.T) {
|
func TestSchedulerOrganizeSourceDisabledByDefault(t *testing.T) {
|
||||||
@@ -30,7 +36,7 @@ func TestSchedulerOrganizeSourceDisabledByDefault(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
organizer := NewOrganizerService(&config.Config{}, zap.NewNop(), repos)
|
organizer := NewOrganizerService(&config.Config{}, zap.NewNop(), repos)
|
||||||
scheduler := NewSchedulerService(zap.NewNop(), repos, nil, nil, organizer, NewHub(zap.NewNop()), "")
|
scheduler := NewSchedulerService(zap.NewNop(), repos, nil, nil, organizer, nil, NewHub(zap.NewNop()), "")
|
||||||
if err := scheduler.jobOrganizeSource(t.Context()); err != nil {
|
if err := scheduler.jobOrganizeSource(t.Context()); err != nil {
|
||||||
t.Fatalf("disabled organize source job should be a no-op: %v", err)
|
t.Fatalf("disabled organize source job should be a no-op: %v", err)
|
||||||
}
|
}
|
||||||
@@ -60,7 +66,7 @@ func TestSchedulerOrganizeSourceUsesConfiguredSourceAndDestination(t *testing.T)
|
|||||||
}
|
}
|
||||||
|
|
||||||
organizer := NewOrganizerService(&config.Config{}, zap.NewNop(), repos)
|
organizer := NewOrganizerService(&config.Config{}, zap.NewNop(), repos)
|
||||||
scheduler := NewSchedulerService(zap.NewNop(), repos, nil, nil, organizer, NewHub(zap.NewNop()), "")
|
scheduler := NewSchedulerService(zap.NewNop(), repos, nil, nil, organizer, nil, NewHub(zap.NewNop()), "")
|
||||||
if err := scheduler.jobOrganizeSource(t.Context()); err != nil {
|
if err := scheduler.jobOrganizeSource(t.Context()); err != nil {
|
||||||
t.Fatalf("organize source job: %v", err)
|
t.Fatalf("organize source job: %v", err)
|
||||||
}
|
}
|
||||||
@@ -89,7 +95,7 @@ func TestSchedulerRunNowOrganizeSourceBypassesDisabledSwitch(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
organizer := NewOrganizerService(&config.Config{}, zap.NewNop(), repos)
|
organizer := NewOrganizerService(&config.Config{}, zap.NewNop(), repos)
|
||||||
scheduler := NewSchedulerService(zap.NewNop(), repos, nil, nil, organizer, NewHub(zap.NewNop()), "")
|
scheduler := NewSchedulerService(zap.NewNop(), repos, nil, nil, organizer, nil, NewHub(zap.NewNop()), "")
|
||||||
scheduler.jobs = []*scheduledJob{{
|
scheduler.jobs = []*scheduledJob{{
|
||||||
name: "organize_source",
|
name: "organize_source",
|
||||||
interval: time.Minute,
|
interval: time.Minute,
|
||||||
@@ -105,3 +111,53 @@ func TestSchedulerRunNowOrganizeSourceBypassesDisabledSwitch(t *testing.T) {
|
|||||||
t.Fatalf("expected run-now organize output at %q: %v", want, err)
|
t.Fatalf("expected run-now organize output at %q: %v", want, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSchedulerCloudSyncImportsMountedCloudLibrary(t *testing.T) {
|
||||||
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/file/sort" || r.URL.Query().Get("pdir_fid") != "0" {
|
||||||
|
t.Fatalf("unexpected cloud list request %s?%s", r.URL.Path, r.URL.RawQuery)
|
||||||
|
}
|
||||||
|
_, _ = w.Write([]byte(`{"status":200,"code":0,"data":{"list":[
|
||||||
|
{"fid":"f1","file_name":"Cloud.Movie.2026.mkv","dir":false,"size":1024}
|
||||||
|
]}}`))
|
||||||
|
}))
|
||||||
|
defer upstream.Close()
|
||||||
|
|
||||||
|
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := db.AutoMigrate(&model.Library{}, &model.Media{}, &model.Setting{}, &model.StorageConfig{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
repos := repository.New(db)
|
||||||
|
log := zap.NewNop()
|
||||||
|
storage := NewStorageConfigService(log, repos, NewCryptoService("", log))
|
||||||
|
if _, err := storage.Save(t.Context(), StorageInput{
|
||||||
|
Type: "quark",
|
||||||
|
Config: map[string]any{
|
||||||
|
"cookie": "kps=test",
|
||||||
|
"base": upstream.URL,
|
||||||
|
},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
lib := model.Library{Name: "夸克网盘", Path: "cloud://quark/0", Type: "movie", Enabled: true}
|
||||||
|
if err := repos.Library.Create(t.Context(), &lib); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
scanner := NewScannerService(&config.Config{}, log, repos, NewHub(log), nil, nil)
|
||||||
|
scanner.SetStorageConfig(storage)
|
||||||
|
scheduler := NewSchedulerService(log, repos, scanner, nil, nil, storage, NewHub(log), "")
|
||||||
|
|
||||||
|
if err := scheduler.jobSyncCloudLibraries(t.Context()); err != nil {
|
||||||
|
t.Fatalf("cloud sync: %v", err)
|
||||||
|
}
|
||||||
|
var media model.Media
|
||||||
|
if err := repos.DB.First(&media, "path = ?", "cloud://quark/f1").Error; err != nil {
|
||||||
|
t.Fatalf("cloud media not imported: %v", err)
|
||||||
|
}
|
||||||
|
if media.STRMURL != "/api/cloud/play/quark?ref=f1" {
|
||||||
|
t.Fatalf("strm url = %q", media.STRMURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -113,10 +113,11 @@ func New(cfg *config.Config, log *zap.Logger, repos *repository.Container) *Cont
|
|||||||
playProfiles := NewPlayProfileService(log, repos)
|
playProfiles := NewPlayProfileService(log, repos)
|
||||||
permissions := NewPermissionService(log, repos)
|
permissions := NewPermissionService(log, repos)
|
||||||
storageCfg := NewStorageConfigService(log, repos, crypto)
|
storageCfg := NewStorageConfigService(log, repos, crypto)
|
||||||
|
scanner.SetStorageConfig(storageCfg)
|
||||||
downloadClients := NewDownloadClientService(log, repos)
|
downloadClients := NewDownloadClientService(log, repos)
|
||||||
assistant := NewAssistantService(log, repos, ai)
|
assistant := NewAssistantService(log, repos, ai)
|
||||||
douban := NewDoubanProvider(cfg, log)
|
douban := NewDoubanProvider(cfg, log)
|
||||||
scheduler := NewSchedulerService(log, repos, scanner, transcoder, organizer, hub, cfg.Cache.CacheDir)
|
scheduler := NewSchedulerService(log, repos, scanner, transcoder, organizer, storageCfg, hub, cfg.Cache.CacheDir)
|
||||||
|
|
||||||
// 初始化认证相关服务
|
// 初始化认证相关服务
|
||||||
tokenSvc := NewTokenService(cfg, log, repos)
|
tokenSvc := NewTokenService(cfg, log, repos)
|
||||||
|
|||||||
@@ -319,7 +319,7 @@ func (s *StorageConfigService) CloudImport(ctx context.Context, typ, fileRef, na
|
|||||||
m := &model.Media{
|
m := &model.Media{
|
||||||
LibraryID: lib.ID,
|
LibraryID: lib.ID,
|
||||||
Title: title,
|
Title: title,
|
||||||
Path: "cloud://" + typ + "/" + fileRef,
|
Path: cloudMediaPath(typ, fileRef),
|
||||||
SizeBytes: size,
|
SizeBytes: size,
|
||||||
Container: container,
|
Container: container,
|
||||||
STRMURL: "/api/cloud/play/" + typ + "?ref=" + url.QueryEscape(fileRef),
|
STRMURL: "/api/cloud/play/" + typ + "?ref=" + url.QueryEscape(fileRef),
|
||||||
|
|||||||
@@ -0,0 +1,457 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
CloudUploadAutoEnabledKey = "cloud.upload_auto_enabled"
|
||||||
|
CloudUploadProviderKey = "cloud.upload_provider"
|
||||||
|
CloudUploadSourceDirKey = "cloud.upload_source_dir"
|
||||||
|
CloudUploadDestPathKey = "cloud.upload_dest_path"
|
||||||
|
CloudUploadRecursiveKey = "cloud.upload_recursive"
|
||||||
|
CloudUploadSidecarsKey = "cloud.upload_sidecars"
|
||||||
|
CloudUploadOverwriteKey = "cloud.upload_overwrite"
|
||||||
|
CloudUploadIntervalSecondsKey = "cloud.upload_interval_seconds"
|
||||||
|
CloudUploadUnsupportedProvider = "本地文件直传目前支持 Alist / WebDAV;115/夸克原生上传需要各自的分片上传私有接口,建议先用 Alist 挂载 115/夸克后选择 Alist 转存。"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CloudUploadInput struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
SourcePath string `json:"source_path"`
|
||||||
|
DestPath string `json:"dest_path"`
|
||||||
|
Recursive bool `json:"recursive"`
|
||||||
|
IncludeSidecars bool `json:"include_sidecars"`
|
||||||
|
Overwrite bool `json:"overwrite"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CloudUploadResult struct {
|
||||||
|
SourcePath string `json:"source_path"`
|
||||||
|
DestPath string `json:"dest_path"`
|
||||||
|
Uploaded int `json:"uploaded"`
|
||||||
|
Skipped int `json:"skipped"`
|
||||||
|
Bytes int64 `json:"bytes"`
|
||||||
|
Errors []string `json:"errors,omitempty"`
|
||||||
|
Items []CloudUploadResultItem `json:"items,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CloudUploadResultItem struct {
|
||||||
|
Source string `json:"source"`
|
||||||
|
Target string `json:"target"`
|
||||||
|
Action string `json:"action"` // upload / skip / error
|
||||||
|
Size int64 `json:"size,omitempty"`
|
||||||
|
Reason string `json:"reason,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type storageUploader interface {
|
||||||
|
ensureDir(ctx context.Context, remoteDir string) error
|
||||||
|
exists(ctx context.Context, remotePath string) (bool, error)
|
||||||
|
upload(ctx context.Context, localPath, remotePath string, size int64) error
|
||||||
|
}
|
||||||
|
|
||||||
|
var cloudUploadSidecarExtensions = map[string]struct{}{
|
||||||
|
".nfo": {}, ".jpg": {}, ".jpeg": {}, ".png": {}, ".webp": {},
|
||||||
|
".srt": {}, ".ass": {}, ".ssa": {}, ".vtt": {}, ".sub": {}, ".idx": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
// UploadLocal copies local media files into an external storage backend. It is
|
||||||
|
// intentionally conservative: it never deletes local files, skips existing
|
||||||
|
// remote targets unless Overwrite is set, and only uploads video + common
|
||||||
|
// sidecar metadata files.
|
||||||
|
func (s *StorageConfigService) UploadLocal(ctx context.Context, in CloudUploadInput) (*CloudUploadResult, error) {
|
||||||
|
in.Type = strings.TrimSpace(in.Type)
|
||||||
|
in.SourcePath = strings.TrimSpace(in.SourcePath)
|
||||||
|
in.DestPath = normalizeRemotePath(in.DestPath)
|
||||||
|
if in.SourcePath == "" {
|
||||||
|
return nil, errors.New("source_path required")
|
||||||
|
}
|
||||||
|
uploader, err := s.uploader(ctx, in.Type)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
info, err := os.Stat(in.SourcePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("source path not accessible: %w", err)
|
||||||
|
}
|
||||||
|
result := &CloudUploadResult{SourcePath: in.SourcePath, DestPath: in.DestPath}
|
||||||
|
if !info.IsDir() {
|
||||||
|
s.uploadOne(ctx, uploader, in, in.SourcePath, filepath.Base(in.SourcePath), info.Size(), result)
|
||||||
|
return result, firstUploadError(result)
|
||||||
|
}
|
||||||
|
root := filepath.Clean(in.SourcePath)
|
||||||
|
walkFn := func(localPath string, entryInfo os.FileInfo, walkErr error) error {
|
||||||
|
if walkErr != nil {
|
||||||
|
addUploadError(result, localPath, "", walkErr)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if entryInfo == nil || entryInfo.IsDir() {
|
||||||
|
if !in.Recursive && filepath.Clean(localPath) != root {
|
||||||
|
return filepath.SkipDir
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !eligibleCloudUploadFile(localPath, in.IncludeSidecars) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
rel, err := filepath.Rel(root, localPath)
|
||||||
|
if err != nil {
|
||||||
|
addUploadError(result, localPath, "", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
s.uploadOne(ctx, uploader, in, localPath, filepath.ToSlash(rel), entryInfo.Size(), result)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := filepath.Walk(in.SourcePath, walkFn); err != nil {
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
return result, firstUploadError(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *StorageConfigService) uploader(ctx context.Context, typ string) (storageUploader, error) {
|
||||||
|
view, err := s.Get(ctx, typ)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if view == nil || !view.Enabled {
|
||||||
|
return nil, fmt.Errorf("%s storage not configured", typ)
|
||||||
|
}
|
||||||
|
switch typ {
|
||||||
|
case "alist":
|
||||||
|
return newAlistUploader(view.Config), nil
|
||||||
|
case "webdav":
|
||||||
|
return newWebDAVUploader(view.Config), nil
|
||||||
|
case "s3":
|
||||||
|
return nil, errors.New("s3 local upload is not implemented yet")
|
||||||
|
case "cloud115", "quark":
|
||||||
|
return nil, errors.New(CloudUploadUnsupportedProvider)
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unsupported storage type %q", typ)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *StorageConfigService) uploadOne(ctx context.Context, uploader storageUploader, in CloudUploadInput, localPath, rel string, size int64, result *CloudUploadResult) {
|
||||||
|
remotePath := joinRemotePath(in.DestPath, rel)
|
||||||
|
if err := uploader.ensureDir(ctx, path.Dir(remotePath)); err != nil {
|
||||||
|
addUploadError(result, localPath, remotePath, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !in.Overwrite {
|
||||||
|
exists, err := uploader.exists(ctx, remotePath)
|
||||||
|
if err != nil {
|
||||||
|
addUploadError(result, localPath, remotePath, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if exists {
|
||||||
|
result.Skipped++
|
||||||
|
addUploadItem(result, CloudUploadResultItem{Source: localPath, Target: remotePath, Action: "skip", Size: size, Reason: "remote exists"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := uploader.upload(ctx, localPath, remotePath, size); err != nil {
|
||||||
|
addUploadError(result, localPath, remotePath, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result.Uploaded++
|
||||||
|
result.Bytes += size
|
||||||
|
addUploadItem(result, CloudUploadResultItem{Source: localPath, Target: remotePath, Action: "upload", Size: size})
|
||||||
|
}
|
||||||
|
|
||||||
|
func eligibleCloudUploadFile(localPath string, includeSidecars bool) bool {
|
||||||
|
ext := strings.ToLower(filepath.Ext(localPath))
|
||||||
|
if _, ok := videoExtensions[ext]; ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if includeSidecars {
|
||||||
|
_, ok := cloudUploadSidecarExtensions[ext]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func addUploadError(result *CloudUploadResult, source, target string, err error) {
|
||||||
|
result.Errors = append(result.Errors, fmt.Sprintf("%s: %v", source, err))
|
||||||
|
addUploadItem(result, CloudUploadResultItem{Source: source, Target: target, Action: "error", Reason: err.Error()})
|
||||||
|
}
|
||||||
|
|
||||||
|
func addUploadItem(result *CloudUploadResult, item CloudUploadResultItem) {
|
||||||
|
if len(result.Items) < 200 {
|
||||||
|
result.Items = append(result.Items, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstUploadError(result *CloudUploadResult) error {
|
||||||
|
if result.Uploaded > 0 || len(result.Errors) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return errors.New(result.Errors[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeRemotePath(p string) string {
|
||||||
|
p = strings.ReplaceAll(strings.TrimSpace(p), "\\", "/")
|
||||||
|
if p == "" || p == "." {
|
||||||
|
return "/"
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(p, "/") {
|
||||||
|
p = "/" + p
|
||||||
|
}
|
||||||
|
return path.Clean(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
func joinRemotePath(base, rel string) string {
|
||||||
|
parts := []string{normalizeRemotePath(base)}
|
||||||
|
for _, part := range strings.Split(strings.ReplaceAll(rel, "\\", "/"), "/") {
|
||||||
|
part = strings.TrimSpace(part)
|
||||||
|
if part != "" && part != "." {
|
||||||
|
parts = append(parts, part)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return path.Clean(path.Join(parts...))
|
||||||
|
}
|
||||||
|
|
||||||
|
type alistUploader struct {
|
||||||
|
server string
|
||||||
|
token string
|
||||||
|
client *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func newAlistUploader(cfg map[string]any) *alistUploader {
|
||||||
|
return &alistUploader{
|
||||||
|
server: strings.TrimRight(strr(cfg["server"]), "/"),
|
||||||
|
token: strr(cfg["token"]),
|
||||||
|
client: &http.Client{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *alistUploader) ensureDir(ctx context.Context, remoteDir string) error {
|
||||||
|
if a.server == "" {
|
||||||
|
return errors.New("alist missing server")
|
||||||
|
}
|
||||||
|
remoteDir = normalizeRemotePath(remoteDir)
|
||||||
|
if remoteDir == "/" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
current := ""
|
||||||
|
for _, part := range strings.Split(strings.Trim(remoteDir, "/"), "/") {
|
||||||
|
current = normalizeRemotePath(path.Join(current, part))
|
||||||
|
payload, _ := json.Marshal(map[string]string{"path": current})
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, a.server+"/api/fs/mkdir", bytes.NewReader(payload))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
a.auth(req)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
resp, err := a.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
err = a.checkJSON(resp, "alist mkdir")
|
||||||
|
if err != nil && !isAlreadyExistsMessage(err.Error()) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *alistUploader) exists(ctx context.Context, remotePath string) (bool, error) {
|
||||||
|
payload, _ := json.Marshal(map[string]string{"path": normalizeRemotePath(remotePath)})
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, a.server+"/api/fs/get", bytes.NewReader(payload))
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
a.auth(req)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
resp, err := a.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode == http.StatusNotFound {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
var out struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
_ = json.NewDecoder(resp.Body).Decode(&out)
|
||||||
|
return resp.StatusCode >= 200 && resp.StatusCode < 300 && out.Code == 200, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *alistUploader) upload(ctx context.Context, localPath, remotePath string, size int64) error {
|
||||||
|
f, err := os.Open(localPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPut, a.server+"/api/fs/put", f)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
a.auth(req)
|
||||||
|
req.ContentLength = size
|
||||||
|
req.Header.Set("Content-Type", "application/octet-stream")
|
||||||
|
req.Header.Set("File-Path", url.PathEscape(normalizeRemotePath(remotePath)))
|
||||||
|
resp, err := a.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return a.checkJSON(resp, "alist upload")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *alistUploader) auth(req *http.Request) {
|
||||||
|
if a.token != "" {
|
||||||
|
req.Header.Set("Authorization", a.token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *alistUploader) checkJSON(resp *http.Response, op string) error {
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
return fmt.Errorf("%s: http %d: %s", op, resp.StatusCode, strings.TrimSpace(string(body)))
|
||||||
|
}
|
||||||
|
var out struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
if len(bytes.TrimSpace(body)) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &out); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if out.Code != 0 && out.Code != 200 {
|
||||||
|
return fmt.Errorf("%s: %s", op, out.Message)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type webDAVUploader struct {
|
||||||
|
base *url.URL
|
||||||
|
username string
|
||||||
|
password string
|
||||||
|
client *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func newWebDAVUploader(cfg map[string]any) *webDAVUploader {
|
||||||
|
u, _ := url.Parse(strings.TrimRight(strr(cfg["url"]), "/"))
|
||||||
|
return &webDAVUploader{
|
||||||
|
base: u,
|
||||||
|
username: strr(cfg["username"]),
|
||||||
|
password: strr(cfg["password"]),
|
||||||
|
client: &http.Client{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *webDAVUploader) ensureDir(ctx context.Context, remoteDir string) error {
|
||||||
|
if w.base == nil || w.base.Scheme == "" || w.base.Host == "" {
|
||||||
|
return errors.New("webdav missing url")
|
||||||
|
}
|
||||||
|
remoteDir = normalizeRemotePath(remoteDir)
|
||||||
|
if remoteDir == "/" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
current := ""
|
||||||
|
for _, part := range strings.Split(strings.Trim(remoteDir, "/"), "/") {
|
||||||
|
current = normalizeRemotePath(path.Join(current, part))
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "MKCOL", w.urlFor(current), nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
w.auth(req)
|
||||||
|
resp, err := w.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, _ = io.Copy(io.Discard, resp.Body)
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if resp.StatusCode == http.StatusMethodNotAllowed || resp.StatusCode == http.StatusConflict {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return fmt.Errorf("webdav mkdir %s: http %d", current, resp.StatusCode)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *webDAVUploader) exists(ctx context.Context, remotePath string) (bool, error) {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodHead, w.urlFor(remotePath), nil)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
w.auth(req)
|
||||||
|
resp, err := w.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
_, _ = io.Copy(io.Discard, resp.Body)
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
if resp.StatusCode == http.StatusNotFound {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return resp.StatusCode >= 200 && resp.StatusCode < 300, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *webDAVUploader) upload(ctx context.Context, localPath, remotePath string, size int64) error {
|
||||||
|
f, err := os.Open(localPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPut, w.urlFor(remotePath), f)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
w.auth(req)
|
||||||
|
req.ContentLength = size
|
||||||
|
resp, err := w.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, _ = io.Copy(io.Discard, resp.Body)
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
return fmt.Errorf("webdav upload %s: http %d", remotePath, resp.StatusCode)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *webDAVUploader) auth(req *http.Request) {
|
||||||
|
if w.username != "" {
|
||||||
|
req.SetBasicAuth(w.username, w.password)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *webDAVUploader) urlFor(remotePath string) string {
|
||||||
|
u := *w.base
|
||||||
|
basePath := strings.TrimRight(u.EscapedPath(), "/")
|
||||||
|
segments := make([]string, 0)
|
||||||
|
if basePath != "" && basePath != "/" {
|
||||||
|
segments = append(segments, strings.Trim(basePath, "/"))
|
||||||
|
}
|
||||||
|
for _, part := range strings.Split(strings.Trim(normalizeRemotePath(remotePath), "/"), "/") {
|
||||||
|
if part != "" {
|
||||||
|
segments = append(segments, url.PathEscape(part))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
u.RawPath = ""
|
||||||
|
u.Path = "/" + strings.Join(segments, "/")
|
||||||
|
return u.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func isAlreadyExistsMessage(message string) bool {
|
||||||
|
message = strings.ToLower(message)
|
||||||
|
return strings.Contains(message, "exist") || strings.Contains(message, "已存在")
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/glebarez/sqlite"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"github.com/ShukeBta/MediaStationGo/internal/model"
|
||||||
|
"github.com/ShukeBta/MediaStationGo/internal/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestStorageConfigUploadLocalToAlist(t *testing.T) {
|
||||||
|
var uploaded []string
|
||||||
|
var authHeaders []string
|
||||||
|
alist := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/api/fs/mkdir":
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&map[string]string{})
|
||||||
|
_, _ = w.Write([]byte(`{"code":200,"message":"success"}`))
|
||||||
|
case "/api/fs/get":
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
_, _ = w.Write([]byte(`{"code":404,"message":"not found"}`))
|
||||||
|
case "/api/fs/put":
|
||||||
|
authHeaders = append(authHeaders, r.Header.Get("Authorization"))
|
||||||
|
decoded, err := url.PathUnescape(r.Header.Get("File-Path"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("decode file path: %v", err)
|
||||||
|
}
|
||||||
|
uploaded = append(uploaded, decoded)
|
||||||
|
_, _ = w.Write([]byte(`{"code":200,"message":"success"}`))
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected alist path %s", r.URL.Path)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer alist.Close()
|
||||||
|
|
||||||
|
repos, storage := newStorageUploadTestService(t)
|
||||||
|
if _, err := storage.Save(t.Context(), StorageInput{
|
||||||
|
Type: "alist",
|
||||||
|
Config: map[string]any{
|
||||||
|
"server": alist.URL,
|
||||||
|
"token": "alist-token",
|
||||||
|
},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
source := t.TempDir()
|
||||||
|
if err := os.WriteFile(filepath.Join(source, "Movie.2026.mkv"), []byte("movie"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(source, "Movie.2026.nfo"), []byte("nfo"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(source, "ignore.txt"), []byte("txt"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := storage.UploadLocal(t.Context(), CloudUploadInput{
|
||||||
|
Type: "alist",
|
||||||
|
SourcePath: source,
|
||||||
|
DestPath: "/backup",
|
||||||
|
Recursive: true,
|
||||||
|
IncludeSidecars: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("upload local: %v", err)
|
||||||
|
}
|
||||||
|
if res.Uploaded != 2 || res.Skipped != 0 || len(res.Errors) != 0 {
|
||||||
|
t.Fatalf("result = %+v", res)
|
||||||
|
}
|
||||||
|
sort.Strings(uploaded)
|
||||||
|
want := []string{"/backup/Movie.2026.mkv", "/backup/Movie.2026.nfo"}
|
||||||
|
if strings.Join(uploaded, "\n") != strings.Join(want, "\n") {
|
||||||
|
t.Fatalf("uploaded = %#v, want %#v", uploaded, want)
|
||||||
|
}
|
||||||
|
for _, header := range authHeaders {
|
||||||
|
if header != "alist-token" {
|
||||||
|
t.Fatalf("authorization header = %q", header)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got, _ := repos.StorageConfig.Get(t.Context(), "alist"); got == nil {
|
||||||
|
t.Fatalf("storage config should remain saved")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSchedulerCloudUploadUsesConfiguredLocalSource(t *testing.T) {
|
||||||
|
var uploaded []string
|
||||||
|
alist := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/api/fs/mkdir":
|
||||||
|
_, _ = w.Write([]byte(`{"code":200}`))
|
||||||
|
case "/api/fs/get":
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
case "/api/fs/put":
|
||||||
|
decoded, _ := url.PathUnescape(r.Header.Get("File-Path"))
|
||||||
|
uploaded = append(uploaded, decoded)
|
||||||
|
_, _ = w.Write([]byte(`{"code":200}`))
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected alist path %s", r.URL.Path)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer alist.Close()
|
||||||
|
|
||||||
|
repos, storage := newStorageUploadTestService(t)
|
||||||
|
if _, err := storage.Save(t.Context(), StorageInput{
|
||||||
|
Type: "alist",
|
||||||
|
Config: map[string]any{
|
||||||
|
"server": alist.URL,
|
||||||
|
"token": "token",
|
||||||
|
},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
source := t.TempDir()
|
||||||
|
if err := os.WriteFile(filepath.Join(source, "Show.S01E01.mkv"), []byte("episode"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for key, value := range map[string]string{
|
||||||
|
CloudUploadAutoEnabledKey: "true",
|
||||||
|
CloudUploadProviderKey: "alist",
|
||||||
|
CloudUploadSourceDirKey: source,
|
||||||
|
CloudUploadDestPathKey: "/cloud-media",
|
||||||
|
CloudUploadRecursiveKey: "true",
|
||||||
|
CloudUploadSidecarsKey: "false",
|
||||||
|
} {
|
||||||
|
if err := repos.Setting.Set(t.Context(), key, value); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
scheduler := NewSchedulerService(zap.NewNop(), repos, nil, nil, nil, storage, NewHub(zap.NewNop()), "")
|
||||||
|
if err := scheduler.jobUploadLocalToCloud(t.Context()); err != nil {
|
||||||
|
t.Fatalf("cloud upload job: %v", err)
|
||||||
|
}
|
||||||
|
if len(uploaded) != 1 || uploaded[0] != "/cloud-media/Show.S01E01.mkv" {
|
||||||
|
t.Fatalf("uploaded = %#v", uploaded)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newStorageUploadTestService(t *testing.T) (*repository.Container, *StorageConfigService) {
|
||||||
|
t.Helper()
|
||||||
|
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := db.AutoMigrate(&model.StorageConfig{}, &model.Setting{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
repos := repository.New(db)
|
||||||
|
log := zap.NewNop()
|
||||||
|
return repos, NewStorageConfigService(log, repos, NewCryptoService("", log))
|
||||||
|
}
|
||||||
@@ -99,6 +99,9 @@ func (w *WatcherService) Refresh(ctx context.Context) error {
|
|||||||
if !l.Enabled {
|
if !l.Enabled {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if _, _, ok := parseCloudLibraryPath(l.Path); ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
for _, dir := range listDirsForWatch(l.Path) {
|
for _, dir := range listDirsForWatch(l.Path) {
|
||||||
current[dir] = l.ID
|
current[dir] = l.ID
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,22 @@ export interface StorageConfig {
|
|||||||
updated_at: string
|
updated_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CloudUploadResult {
|
||||||
|
source_path: string
|
||||||
|
dest_path: string
|
||||||
|
uploaded: number
|
||||||
|
skipped: number
|
||||||
|
bytes: number
|
||||||
|
errors?: string[]
|
||||||
|
items?: Array<{
|
||||||
|
source: string
|
||||||
|
target: string
|
||||||
|
action: 'upload' | 'skip' | 'error'
|
||||||
|
size?: number
|
||||||
|
reason?: string
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
export const storageAPI = {
|
export const storageAPI = {
|
||||||
status: () =>
|
status: () =>
|
||||||
api
|
api
|
||||||
@@ -53,6 +69,20 @@ export const storageAPI = {
|
|||||||
config,
|
config,
|
||||||
})
|
})
|
||||||
.then((r) => r.data),
|
.then((r) => r.data),
|
||||||
|
|
||||||
|
uploadLocal: (
|
||||||
|
type: StorageType,
|
||||||
|
input: {
|
||||||
|
source_path: string
|
||||||
|
dest_path: string
|
||||||
|
recursive: boolean
|
||||||
|
include_sidecars: boolean
|
||||||
|
overwrite: boolean
|
||||||
|
},
|
||||||
|
) =>
|
||||||
|
api
|
||||||
|
.post<{ result: CloudUploadResult; error?: string }>(`/admin/storage/${type}/upload-local`, input)
|
||||||
|
.then((r) => r.data),
|
||||||
}
|
}
|
||||||
|
|
||||||
// cloudAPI drives 网盘 browsing, QR login and 302 import.
|
// cloudAPI drives 网盘 browsing, QR login and 302 import.
|
||||||
@@ -69,6 +99,11 @@ export const cloudAPI = {
|
|||||||
.post(`/admin/cloud/${type}/import`, { ref, name, size })
|
.post(`/admin/cloud/${type}/import`, { ref, name, size })
|
||||||
.then((r) => r.data),
|
.then((r) => r.data),
|
||||||
|
|
||||||
|
mount: (type: StorageType, dir = '', name = '', media_type = 'movie') =>
|
||||||
|
api
|
||||||
|
.post(`/admin/cloud/${type}/mount`, { dir, name, media_type })
|
||||||
|
.then((r) => r.data),
|
||||||
|
|
||||||
qrStart: (type: StorageType) =>
|
qrStart: (type: StorageType) =>
|
||||||
api.post<QRSession>(`/admin/cloud/${type}/qr/start`).then((r) => r.data),
|
api.post<QRSession>(`/admin/cloud/${type}/qr/start`).then((r) => r.data),
|
||||||
|
|
||||||
|
|||||||
@@ -281,6 +281,70 @@ const GROUPS: SettingGroup[] = [
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'cloud-upload',
|
||||||
|
label: '网盘转存',
|
||||||
|
description: '把本地媒体复制上传到外部存储。推荐:将 115/夸克挂载到 Alist 后,使用 Alist 作为转存目标。',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
key: 'cloud.upload_auto_enabled',
|
||||||
|
label: '启用自动转存',
|
||||||
|
type: 'toggle',
|
||||||
|
hint: '开启后后台会按间隔扫描本地源目录,把视频、NFO、海报、字幕复制到目标存储;不会删除本地源文件。',
|
||||||
|
defaultValue: 'false',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'cloud.upload_provider',
|
||||||
|
label: '转存目标',
|
||||||
|
type: 'select',
|
||||||
|
defaultValue: 'alist',
|
||||||
|
options: [
|
||||||
|
{ value: 'alist', label: 'Alist(推荐,可桥接 115/夸克)' },
|
||||||
|
{ value: 'webdav', label: 'WebDAV' },
|
||||||
|
{ value: 'cloud115', label: '115 原生(待接分片上传)' },
|
||||||
|
{ value: 'quark', label: '夸克原生(待接分片上传)' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'cloud.upload_source_dir',
|
||||||
|
label: '本地源目录',
|
||||||
|
type: 'text',
|
||||||
|
placeholder: '/media/电影 或 F:\\media\\Movies',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'cloud.upload_dest_path',
|
||||||
|
label: '网盘目标目录',
|
||||||
|
type: 'text',
|
||||||
|
defaultValue: '/MediaStationGo',
|
||||||
|
placeholder: '/MediaStationGo',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'cloud.upload_recursive',
|
||||||
|
label: '递归扫描源目录',
|
||||||
|
type: 'toggle',
|
||||||
|
defaultValue: 'true',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'cloud.upload_sidecars',
|
||||||
|
label: '同步 NFO / 海报 / 字幕',
|
||||||
|
type: 'toggle',
|
||||||
|
defaultValue: 'true',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'cloud.upload_overwrite',
|
||||||
|
label: '覆盖远端同名文件',
|
||||||
|
type: 'toggle',
|
||||||
|
defaultValue: 'false',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'cloud.upload_interval_seconds',
|
||||||
|
label: '自动转存间隔秒数',
|
||||||
|
type: 'number',
|
||||||
|
hint: '最小 300 秒,建议 3600 秒或更高,避免频繁读盘和触发网盘风控。',
|
||||||
|
defaultValue: '3600',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'adult',
|
key: 'adult',
|
||||||
label: 'Adult / NSFW',
|
label: 'Adult / NSFW',
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { FormEvent, useEffect, useMemo, useState } from 'react'
|
import { FormEvent, useEffect, useMemo, useState } from 'react'
|
||||||
import { Cloud, FileVideo, Folder, Loader2, QrCode, Save, Send } from 'lucide-react'
|
import { Cloud, FileVideo, Folder, Loader2, QrCode, Save, Send, Upload } from 'lucide-react'
|
||||||
import toast from 'react-hot-toast'
|
import toast from 'react-hot-toast'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -212,11 +212,125 @@ function StorageForm({ type }: { type: StorageType }) {
|
|||||||
保存
|
保存
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<StorageUploadPanel type={type} />
|
||||||
{isCloud(type) && <CloudBrowser type={type} />}
|
{isCloud(type) && <CloudBrowser type={type} />}
|
||||||
</form>
|
</form>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function StorageUploadPanel({ type }: { type: StorageType }) {
|
||||||
|
const [sourcePath, setSourcePath] = useState('')
|
||||||
|
const [destPath, setDestPath] = useState('/MediaStationGo')
|
||||||
|
const [recursive, setRecursive] = useState(true)
|
||||||
|
const [includeSidecars, setIncludeSidecars] = useState(true)
|
||||||
|
const [overwrite, setOverwrite] = useState(false)
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
|
||||||
|
const supported = type === 'alist' || type === 'webdav'
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
if (!supported) {
|
||||||
|
toast.error('本地直传目前支持 Alist / WebDAV。115/夸克建议先挂载到 Alist,再通过 Alist 转存。')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!sourcePath.trim()) {
|
||||||
|
toast.error('请填写本地源目录或文件路径')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setBusy(true)
|
||||||
|
try {
|
||||||
|
const { result, error } = await storageAPI.uploadLocal(type, {
|
||||||
|
source_path: sourcePath.trim(),
|
||||||
|
dest_path: destPath.trim() || '/',
|
||||||
|
recursive,
|
||||||
|
include_sidecars: includeSidecars,
|
||||||
|
overwrite,
|
||||||
|
})
|
||||||
|
const errText = error || (result.errors && result.errors.length > 0 ? ` · 错误 ${result.errors.length}` : '')
|
||||||
|
toast.success(`转存完成:上传 ${result.uploaded} · 跳过 ${result.skipped} · ${fmtBytes(result.bytes)}${errText}`)
|
||||||
|
} catch (err: unknown) {
|
||||||
|
toast.error((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '转存失败')
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-gray-200 bg-gray-50/70 p-4">
|
||||||
|
<div className="mb-3 flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="flex items-center gap-2 font-display text-base font-semibold text-ink-600">
|
||||||
|
<Upload size={16} /> 本地媒体转存到此存储
|
||||||
|
</h3>
|
||||||
|
<p className="mt-1 text-xs text-ink-50">
|
||||||
|
复制本地媒体文件到外部存储,保留本地源文件;自动跳过远端已存在文件。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{!supported && (
|
||||||
|
<span className="rounded-full bg-amber-100 px-2 py-1 text-xs text-amber-700">
|
||||||
|
直传待接
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{!supported && (
|
||||||
|
<p className="mb-3 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800">
|
||||||
|
115 / 夸克原生上传需要私有分片上传协议。本版本先支持 Alist / WebDAV:把 115 或夸克挂到 Alist 后,选择 Alist 即可把本地文件转存到对应网盘。
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="grid gap-3 lg:grid-cols-2">
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-1 block text-sm text-ink-100">本地源目录 / 文件</span>
|
||||||
|
<input
|
||||||
|
className="input-base"
|
||||||
|
placeholder="例如 /media/电影 或 F:\\media\\Movies"
|
||||||
|
value={sourcePath}
|
||||||
|
onChange={(event) => setSourcePath(event.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-1 block text-sm text-ink-100">网盘目标目录</span>
|
||||||
|
<input
|
||||||
|
className="input-base"
|
||||||
|
placeholder="/MediaStationGo"
|
||||||
|
value={destPath}
|
||||||
|
onChange={(event) => setDestPath(event.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 flex flex-wrap items-center gap-4 text-sm text-ink-100">
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input type="checkbox" className="h-4 w-4 accent-primary-400" checked={recursive} onChange={(event) => setRecursive(event.target.checked)} />
|
||||||
|
递归目录
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input type="checkbox" className="h-4 w-4 accent-primary-400" checked={includeSidecars} onChange={(event) => setIncludeSidecars(event.target.checked)} />
|
||||||
|
同步 NFO / 海报 / 字幕
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input type="checkbox" className="h-4 w-4 accent-primary-400" checked={overwrite} onChange={(event) => setOverwrite(event.target.checked)} />
|
||||||
|
覆盖远端同名文件
|
||||||
|
</label>
|
||||||
|
<button type="button" className="neon-button ml-auto" disabled={busy || !supported} onClick={submit}>
|
||||||
|
{busy ? <Loader2 size={16} className="animate-spin" /> : <Upload size={16} />}
|
||||||
|
{busy ? '转存中…' : '开始转存'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtBytes(value: number): string {
|
||||||
|
if (!value) return '0 B'
|
||||||
|
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||||
|
let size = value
|
||||||
|
let idx = 0
|
||||||
|
while (size >= 1024 && idx < units.length - 1) {
|
||||||
|
size /= 1024
|
||||||
|
idx++
|
||||||
|
}
|
||||||
|
return `${size.toFixed(size >= 10 || idx === 0 ? 0 : 1)} ${units[idx]}`
|
||||||
|
}
|
||||||
|
|
||||||
// QRLoginPanel drives the 115 QR-code login: start → render image → poll →
|
// QRLoginPanel drives the 115 QR-code login: start → render image → poll →
|
||||||
// fill the cookie field on confirmation.
|
// fill the cookie field on confirmation.
|
||||||
function QRLoginPanel({ type, onCookie }: { type: StorageType; onCookie: (c: string) => void }) {
|
function QRLoginPanel({ type, onCookie }: { type: StorageType; onCookie: (c: string) => void }) {
|
||||||
@@ -293,6 +407,8 @@ function CloudBrowser({ type }: { type: StorageType }) {
|
|||||||
const [stack, setStack] = useState<{ id: string; name: string }[]>([{ id: '', name: '根目录' }])
|
const [stack, setStack] = useState<{ id: string; name: string }[]>([{ id: '', name: '根目录' }])
|
||||||
const [items, setItems] = useState<CloudEntry[]>([])
|
const [items, setItems] = useState<CloudEntry[]>([])
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [mounting, setMounting] = useState(false)
|
||||||
|
const [mountMediaType, setMountMediaType] = useState('movie')
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
const cur = stack[stack.length - 1]
|
const cur = stack[stack.length - 1]
|
||||||
@@ -329,18 +445,56 @@ function CloudBrowser({ type }: { type: StorageType }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const mountCurrent = async () => {
|
||||||
|
setMounting(true)
|
||||||
|
try {
|
||||||
|
const label = TYPE_LABEL[type] ?? type
|
||||||
|
const name = cur.id ? `${label} · ${cur.name}` : label
|
||||||
|
const res = await cloudAPI.mount(type, cur.id, name, mountMediaType)
|
||||||
|
const scan = (res as { scan?: { added?: number; updated?: number; removed?: number; visited?: number } }).scan
|
||||||
|
toast.success(`已挂载为媒体库并扫描:新增 ${scan?.added ?? 0} · 更新 ${scan?.updated ?? 0} · 访问 ${scan?.visited ?? 0}`)
|
||||||
|
} catch (err: unknown) {
|
||||||
|
toast.error((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '挂载失败')
|
||||||
|
} finally {
|
||||||
|
setMounting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-2 rounded-lg border border-gray-200 p-3" onClick={(e) => e.preventDefault()}>
|
<div className="mt-2 rounded-lg border border-gray-200 p-3" onClick={(e) => e.preventDefault()}>
|
||||||
<div className="mb-2 flex flex-wrap items-center gap-1 text-xs text-ink-50">
|
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
|
||||||
<span className="text-ink-100">网盘资源:</span>
|
<div className="flex flex-wrap items-center gap-1 text-xs text-ink-50">
|
||||||
{stack.map((s, i) => (
|
<span className="text-ink-100">网盘资源:</span>
|
||||||
<span key={i}>
|
{stack.map((s, i) => (
|
||||||
<button type="button" className="hover:text-brand-500" onClick={() => goTo(i)}>
|
<span key={i}>
|
||||||
{s.name}
|
<button type="button" className="hover:text-brand-500" onClick={() => goTo(i)}>
|
||||||
</button>
|
{s.name}
|
||||||
{i < stack.length - 1 && <span className="mx-1">/</span>}
|
</button>
|
||||||
</span>
|
{i < stack.length - 1 && <span className="mx-1">/</span>}
|
||||||
))}
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<select
|
||||||
|
className="rounded border border-gray-200 bg-white px-2 py-0.5 text-xs text-ink-100"
|
||||||
|
value={mountMediaType}
|
||||||
|
onChange={(event) => setMountMediaType(event.target.value)}
|
||||||
|
>
|
||||||
|
<option value="movie">电影</option>
|
||||||
|
<option value="tv">剧集</option>
|
||||||
|
<option value="anime">动漫</option>
|
||||||
|
<option value="variety">综艺</option>
|
||||||
|
<option value="adult">成人</option>
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded border border-brand-400/40 px-2 py-0.5 text-xs text-brand-500 hover:bg-brand-400/10"
|
||||||
|
disabled={mounting || loading}
|
||||||
|
onClick={mountCurrent}
|
||||||
|
>
|
||||||
|
{mounting ? '挂载扫描中…' : '挂载当前目录为媒体库'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex justify-center py-4 text-ink-50">
|
<div className="flex justify-center py-4 text-ink-50">
|
||||||
|
|||||||
Reference in New Issue
Block a user