mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-01 20:16:36 +08:00
feat: add clouddrive2 storage bridge
This commit is contained in:
@@ -122,6 +122,8 @@ func cloudMountLibraryName(typ, dir string) string {
|
||||
base = "夸克网盘"
|
||||
case cloud.Type115:
|
||||
base = "115 网盘"
|
||||
case cloud.TypeCloudDrive2:
|
||||
base = "CloudDrive2"
|
||||
}
|
||||
if dir == "" || dir == "0" {
|
||||
return base
|
||||
@@ -133,6 +135,10 @@ func cloudMountLibraryName(typ, dir string) string {
|
||||
// QR image URL for the frontend to render.
|
||||
func cloud115QRStartHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if c.Param("type") != cloud.Type115 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "qr login is only supported for 115"})
|
||||
return
|
||||
}
|
||||
sess, err := cloud.QRStart(c.Request.Context(), nil)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
@@ -146,6 +152,10 @@ func cloud115QRStartHandler(svc *service.Container) gin.HandlerFunc {
|
||||
// session cookie so the frontend can save it as the storage credential.
|
||||
func cloud115QRPollHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if c.Param("type") != cloud.Type115 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "qr login is only supported for 115"})
|
||||
return
|
||||
}
|
||||
var sess cloud.QRSession
|
||||
if err := c.ShouldBindJSON(&sess); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
|
||||
@@ -104,7 +104,13 @@ func schemaHandler(_ *service.Container) gin.HandlerFunc {
|
||||
"label": "网盘转存",
|
||||
"items": []gin.H{
|
||||
{"key": "cloud.upload_auto_enabled", "type": "toggle", "label": "启用自动转存"},
|
||||
{"key": "cloud.upload_provider", "type": "select", "label": "转存目标"},
|
||||
{"key": "cloud.upload_provider", "type": "select", "label": "转存目标", "options": []gin.H{
|
||||
{"value": "clouddrive2", "label": "CloudDrive2(推荐,可桥接 115/123/阿里/夸克)"},
|
||||
{"value": "alist", "label": "Alist(可桥接多网盘)"},
|
||||
{"value": "webdav", "label": "WebDAV"},
|
||||
{"value": "cloud115", "label": "115 原生(待接分片上传)"},
|
||||
{"value": "quark", "label": "夸克原生(待接分片上传)"},
|
||||
}},
|
||||
{"key": "cloud.upload_source_dir", "type": "text", "label": "本地源目录"},
|
||||
{"key": "cloud.upload_dest_path", "type": "text", "label": "网盘目标目录"},
|
||||
{"key": "cloud.upload_recursive", "type": "toggle", "label": "递归扫描源目录"},
|
||||
|
||||
@@ -27,8 +27,9 @@ var timeNow = time.Now
|
||||
|
||||
// Provider types recognised by the registry.
|
||||
const (
|
||||
TypeQuark = "quark" // 夸克网盘
|
||||
Type115 = "cloud115" // 115 网盘
|
||||
TypeQuark = "quark" // 夸克网盘
|
||||
Type115 = "cloud115" // 115 网盘
|
||||
TypeCloudDrive2 = "clouddrive2" // CloudDrive2 桥接网盘
|
||||
)
|
||||
|
||||
// ErrUnsupported is returned for an unknown provider type.
|
||||
@@ -81,6 +82,8 @@ func New(typ string, cfg map[string]any, client *http.Client) (Provider, error)
|
||||
return newQuark(cfg, client), nil
|
||||
case Type115:
|
||||
return new115(cfg, client), nil
|
||||
case TypeCloudDrive2:
|
||||
return newCloudDrive2(cfg, client), nil
|
||||
default:
|
||||
return nil, ErrUnsupported
|
||||
}
|
||||
@@ -88,7 +91,7 @@ func New(typ string, cfg map[string]any, client *http.Client) (Provider, error)
|
||||
|
||||
// IsCloudType reports whether typ is a cloud-disk provider.
|
||||
func IsCloudType(typ string) bool {
|
||||
return typ == TypeQuark || typ == Type115
|
||||
return typ == TypeQuark || typ == Type115 || typ == TypeCloudDrive2
|
||||
}
|
||||
|
||||
// str coerces a config value to a trimmed string.
|
||||
|
||||
@@ -303,6 +303,75 @@ func Test115QRFlow(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloudDrive2WebDAVListAndResolve(t *testing.T) {
|
||||
var gotAuth, gotDepth string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotAuth = r.Header.Get("Authorization")
|
||||
gotDepth = r.Header.Get("Depth")
|
||||
if r.Method != "PROPFIND" {
|
||||
t.Fatalf("unexpected method %s", r.Method)
|
||||
}
|
||||
if r.URL.Path != "/dav" {
|
||||
t.Fatalf("unexpected path %s", r.URL.Path)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
w.WriteHeader(http.StatusMultiStatus)
|
||||
_, _ = w.Write([]byte(`<?xml version="1.0" encoding="utf-8"?>
|
||||
<d:multistatus xmlns:d="DAV:">
|
||||
<d:response>
|
||||
<d:href>/dav/</d:href>
|
||||
<d:propstat><d:prop><d:resourcetype><d:collection/></d:resourcetype></d:prop></d:propstat>
|
||||
</d:response>
|
||||
<d:response>
|
||||
<d:href>/dav/115/</d:href>
|
||||
<d:propstat><d:prop><d:displayname>115</d:displayname><d:resourcetype><d:collection/></d:resourcetype></d:prop></d:propstat>
|
||||
</d:response>
|
||||
<d:response>
|
||||
<d:href>/dav/123/Movie.mkv</d:href>
|
||||
<d:propstat><d:prop><d:displayname>Movie.mkv</d:displayname><d:getcontentlength>789</d:getcontentlength><d:resourcetype/></d:prop></d:propstat>
|
||||
</d:response>
|
||||
</d:multistatus>`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
p, err := New(TypeCloudDrive2, map[string]any{"url": srv.URL + "/dav", "username": "u", "password": "p"}, srv.Client())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
entries, err := p.List(context.Background(), "")
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if gotDepth != "1" {
|
||||
t.Fatalf("Depth = %q, want 1", gotDepth)
|
||||
}
|
||||
if !strings.HasPrefix(gotAuth, "Basic ") {
|
||||
t.Fatalf("missing basic auth: %q", gotAuth)
|
||||
}
|
||||
if len(entries) != 2 {
|
||||
t.Fatalf("entries = %#v", entries)
|
||||
}
|
||||
if !entries[0].IsDir || entries[0].ID != "/115" {
|
||||
t.Fatalf("dir entry wrong: %#v", entries[0])
|
||||
}
|
||||
if entries[1].IsDir || entries[1].ID != "/123/Movie.mkv" || entries[1].Size != 789 {
|
||||
t.Fatalf("file entry wrong: %#v", entries[1])
|
||||
}
|
||||
link, err := p.Resolve(context.Background(), entries[1].ID)
|
||||
if err != nil {
|
||||
t.Fatalf("resolve: %v", err)
|
||||
}
|
||||
if link.URL != srv.URL+"/dav/123/Movie.mkv" {
|
||||
t.Fatalf("bad url: %s", link.URL)
|
||||
}
|
||||
if !link.Proxy {
|
||||
t.Fatalf("clouddrive2 should default to proxy mode")
|
||||
}
|
||||
if !strings.HasPrefix(link.Headers["Authorization"], "Basic ") {
|
||||
t.Fatalf("resolve must carry basic auth: %#v", link.Headers)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnsupportedProvider(t *testing.T) {
|
||||
if _, err := New("dropbox", nil, nil); err != ErrUnsupported {
|
||||
t.Fatalf("want ErrUnsupported, got %v", err)
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
package cloud
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// cloudDrive2Provider bridges CloudDrive2 through its WebDAV endpoint.
|
||||
//
|
||||
// CloudDrive2 already integrates many cloud disks (115 / 123 / Aliyun / Quark
|
||||
// and more). Treating it as a WebDAV-backed cloud provider lets MediaStationGo
|
||||
// browse, mount and upload to those disks without carrying every provider's
|
||||
// private chunk-upload protocol in this project.
|
||||
type cloudDrive2Provider struct {
|
||||
base *url.URL
|
||||
username string
|
||||
password string
|
||||
token string
|
||||
ua string
|
||||
client *http.Client
|
||||
proxy bool
|
||||
}
|
||||
|
||||
func newCloudDrive2(cfg map[string]any, client *http.Client) *cloudDrive2Provider {
|
||||
rawURL := str(cfg["url"])
|
||||
if rawURL == "" {
|
||||
rawURL = str(cfg["server"])
|
||||
}
|
||||
u, _ := url.Parse(strings.TrimRight(rawURL, "/"))
|
||||
ua := str(cfg["ua"])
|
||||
if ua == "" {
|
||||
ua = defaultUA
|
||||
}
|
||||
proxy := true
|
||||
if _, ok := cfg["force_302"]; ok && boolish(cfg["force_302"]) {
|
||||
proxy = false
|
||||
}
|
||||
return &cloudDrive2Provider{
|
||||
base: u,
|
||||
username: str(cfg["username"]),
|
||||
password: str(cfg["password"]),
|
||||
token: str(cfg["token"]),
|
||||
ua: ua,
|
||||
client: client,
|
||||
proxy: proxy,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *cloudDrive2Provider) Type() string { return TypeCloudDrive2 }
|
||||
|
||||
func (p *cloudDrive2Provider) Ping(ctx context.Context) error {
|
||||
_, err := p.List(ctx, "")
|
||||
return err
|
||||
}
|
||||
|
||||
func (p *cloudDrive2Provider) List(ctx context.Context, dir string) ([]FileEntry, error) {
|
||||
if err := p.validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
target := normalizeCloudDAVPath(dir)
|
||||
req, err := http.NewRequestWithContext(ctx, "PROPFIND", p.urlFor(target), strings.NewReader(cloudDAVPropfindBody))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.auth(req)
|
||||
req.Header.Set("Depth", "1")
|
||||
req.Header.Set("Content-Type", "application/xml; charset=utf-8")
|
||||
req.Header.Set("Accept", "application/xml,text/xml,*/*")
|
||||
resp, err := p.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("clouddrive2: list %s returned http %d", target, resp.StatusCode)
|
||||
}
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
||||
var multi cloudDAVMultiStatus
|
||||
if err := xml.Unmarshal(body, &multi); err != nil {
|
||||
return nil, fmt.Errorf("clouddrive2: decode webdav: %w", err)
|
||||
}
|
||||
basePath := strings.TrimRight(p.base.EscapedPath(), "/")
|
||||
currentID := normalizeCloudDAVPath(target)
|
||||
out := make([]FileEntry, 0, len(multi.Responses))
|
||||
for _, item := range multi.Responses {
|
||||
entryPath, err := p.entryIDFromHref(item.Href, basePath)
|
||||
if err != nil || entryPath == "" || sameCloudDAVPath(entryPath, currentID) {
|
||||
continue
|
||||
}
|
||||
name := firstNonEmpty(item.PropStat.Prop.DisplayName, path.Base(strings.TrimRight(entryPath, "/")))
|
||||
if decoded, err := url.PathUnescape(name); err == nil {
|
||||
name = decoded
|
||||
}
|
||||
if name == "" || name == "." || name == "/" {
|
||||
continue
|
||||
}
|
||||
out = append(out, FileEntry{
|
||||
ID: entryPath,
|
||||
Name: name,
|
||||
IsDir: item.PropStat.Prop.ResourceType.Collection != nil || strings.HasSuffix(item.Href, "/"),
|
||||
Size: parseDAVSize(item.PropStat.Prop.ContentLength),
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (p *cloudDrive2Provider) Resolve(ctx context.Context, fileRef string) (*DirectLink, error) {
|
||||
if err := p.validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ref := normalizeCloudDAVPath(fileRef)
|
||||
if ref == "/" {
|
||||
return nil, errors.New("clouddrive2: file reference required")
|
||||
}
|
||||
headers := map[string]string{
|
||||
"User-Agent": p.ua,
|
||||
}
|
||||
if p.token != "" {
|
||||
headers["Authorization"] = p.token
|
||||
} else if p.username != "" {
|
||||
headers["Authorization"] = "Basic " + base64.StdEncoding.EncodeToString([]byte(p.username+":"+p.password))
|
||||
}
|
||||
return &DirectLink{URL: p.urlFor(ref), Headers: headers, Proxy: p.proxy}, nil
|
||||
}
|
||||
|
||||
func (p *cloudDrive2Provider) validate() error {
|
||||
if p.base == nil || p.base.Scheme == "" || p.base.Host == "" {
|
||||
return errors.New("clouddrive2: missing WebDAV URL")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *cloudDrive2Provider) auth(req *http.Request) {
|
||||
req.Header.Set("User-Agent", p.ua)
|
||||
if p.token != "" {
|
||||
req.Header.Set("Authorization", p.token)
|
||||
return
|
||||
}
|
||||
if p.username != "" {
|
||||
req.SetBasicAuth(p.username, p.password)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *cloudDrive2Provider) urlFor(remotePath string) string {
|
||||
u := *p.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(normalizeCloudDAVPath(remotePath), "/"), "/") {
|
||||
if part != "" {
|
||||
segments = append(segments, url.PathEscape(part))
|
||||
}
|
||||
}
|
||||
u.RawPath = ""
|
||||
u.Path = "/" + strings.Join(segments, "/")
|
||||
return u.String()
|
||||
}
|
||||
|
||||
func (p *cloudDrive2Provider) entryIDFromHref(href, basePath string) (string, error) {
|
||||
if href == "" {
|
||||
return "", nil
|
||||
}
|
||||
parsed, err := url.Parse(href)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
hrefPath := parsed.EscapedPath()
|
||||
if hrefPath == "" {
|
||||
hrefPath = href
|
||||
}
|
||||
if basePath != "" && basePath != "/" {
|
||||
hrefPath = strings.TrimPrefix(hrefPath, basePath)
|
||||
}
|
||||
if decoded, err := url.PathUnescape(hrefPath); err == nil {
|
||||
hrefPath = decoded
|
||||
}
|
||||
return normalizeCloudDAVPath(hrefPath), nil
|
||||
}
|
||||
|
||||
const cloudDAVPropfindBody = `<?xml version="1.0" encoding="utf-8"?>
|
||||
<d:propfind xmlns:d="DAV:">
|
||||
<d:prop>
|
||||
<d:displayname/>
|
||||
<d:getcontentlength/>
|
||||
<d:resourcetype/>
|
||||
</d:prop>
|
||||
</d:propfind>`
|
||||
|
||||
type cloudDAVMultiStatus struct {
|
||||
Responses []cloudDAVResponse `xml:"response"`
|
||||
}
|
||||
|
||||
type cloudDAVResponse struct {
|
||||
Href string `xml:"href"`
|
||||
PropStat cloudDAVPropStat `xml:"propstat"`
|
||||
}
|
||||
|
||||
type cloudDAVPropStat struct {
|
||||
Prop cloudDAVProp `xml:"prop"`
|
||||
}
|
||||
|
||||
type cloudDAVProp struct {
|
||||
DisplayName string `xml:"displayname"`
|
||||
ContentLength string `xml:"getcontentlength"`
|
||||
ResourceType cloudDAVResourceType `xml:"resourcetype"`
|
||||
}
|
||||
|
||||
type cloudDAVResourceType struct {
|
||||
Collection *struct{} `xml:"collection"`
|
||||
}
|
||||
|
||||
func normalizeCloudDAVPath(p string) string {
|
||||
p = strings.ReplaceAll(strings.TrimSpace(p), "\\", "/")
|
||||
if p == "" || p == "." {
|
||||
return "/"
|
||||
}
|
||||
if !strings.HasPrefix(p, "/") {
|
||||
p = "/" + p
|
||||
}
|
||||
cleaned := path.Clean(p)
|
||||
if cleaned == "." {
|
||||
return "/"
|
||||
}
|
||||
return cleaned
|
||||
}
|
||||
|
||||
func sameCloudDAVPath(a, b string) bool {
|
||||
return strings.TrimRight(normalizeCloudDAVPath(a), "/") == strings.TrimRight(normalizeCloudDAVPath(b), "/")
|
||||
}
|
||||
|
||||
func parseDAVSize(raw string) int64 {
|
||||
n, _ := strconv.ParseInt(strings.TrimSpace(raw), 10, 64)
|
||||
return n
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, v := range values {
|
||||
if strings.TrimSpace(v) != "" {
|
||||
return strings.TrimSpace(v)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -86,7 +86,7 @@ func (s *StorageConfigService) List(ctx context.Context) ([]StorageView, error)
|
||||
var cfg map[string]any
|
||||
_ = json.Unmarshal([]byte(plain), &cfg)
|
||||
// Redact secrets when listing.
|
||||
for _, k := range []string{"password", "secret_key", "token"} {
|
||||
for _, k := range []string{"password", "secret_key", "token", "cookie", "access_key"} {
|
||||
if v, ok := cfg[k]; ok && fmt.Sprint(v) != "" {
|
||||
cfg[k] = "********"
|
||||
}
|
||||
@@ -184,7 +184,7 @@ func (s *StorageConfigService) Test(ctx context.Context, in StorageInput) error
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
return nil
|
||||
case cloud.TypeQuark, cloud.Type115:
|
||||
case cloud.TypeQuark, cloud.Type115, cloud.TypeCloudDrive2:
|
||||
p, err := cloud.New(in.Type, cfg, s.client)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -268,6 +268,8 @@ func cloudLibraryName(typ string) string {
|
||||
return "夸克网盘"
|
||||
case cloud.Type115:
|
||||
return "115 网盘"
|
||||
case cloud.TypeCloudDrive2:
|
||||
return "CloudDrive2"
|
||||
default:
|
||||
return typ
|
||||
}
|
||||
@@ -333,7 +335,7 @@ func (s *StorageConfigService) CloudImport(ctx context.Context, typ, fileRef, na
|
||||
|
||||
func validStorageType(t string) bool {
|
||||
switch t {
|
||||
case "alist", "s3", "webdav", cloud.TypeQuark, cloud.Type115:
|
||||
case "alist", "s3", "webdav", cloud.TypeQuark, cloud.Type115, cloud.TypeCloudDrive2:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
|
||||
@@ -24,7 +24,7 @@ const (
|
||||
CloudUploadSidecarsKey = "cloud.upload_sidecars"
|
||||
CloudUploadOverwriteKey = "cloud.upload_overwrite"
|
||||
CloudUploadIntervalSecondsKey = "cloud.upload_interval_seconds"
|
||||
CloudUploadUnsupportedProvider = "本地文件直传目前支持 Alist / WebDAV;115/夸克原生上传需要各自的分片上传私有接口,建议先用 Alist 挂载 115/夸克后选择 Alist 转存。"
|
||||
CloudUploadUnsupportedProvider = "本地文件直传目前支持 Alist / WebDAV / CloudDrive2;115/夸克原生上传需要各自的分片上传私有接口,建议先用 CloudDrive2 或 Alist 桥接后转存。"
|
||||
)
|
||||
|
||||
type CloudUploadInput struct {
|
||||
@@ -131,6 +131,8 @@ func (s *StorageConfigService) uploader(ctx context.Context, typ string) (storag
|
||||
return newAlistUploader(view.Config), nil
|
||||
case "webdav":
|
||||
return newWebDAVUploader(view.Config), nil
|
||||
case "clouddrive2":
|
||||
return newWebDAVUploader(view.Config), nil
|
||||
case "s3":
|
||||
return nil, errors.New("s3 local upload is not implemented yet")
|
||||
case "cloud115", "quark":
|
||||
|
||||
@@ -146,6 +146,52 @@ func TestSchedulerCloudUploadUsesConfiguredLocalSource(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestStorageConfigUploadLocalToCloudDrive2(t *testing.T) {
|
||||
var uploaded []string
|
||||
dav := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case "MKCOL":
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
case http.MethodHead:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
case http.MethodPut:
|
||||
uploaded = append(uploaded, r.URL.Path)
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
default:
|
||||
t.Fatalf("unexpected method %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer dav.Close()
|
||||
|
||||
_, storage := newStorageUploadTestService(t)
|
||||
if _, err := storage.Save(t.Context(), StorageInput{
|
||||
Type: "clouddrive2",
|
||||
Config: map[string]any{
|
||||
"url": dav.URL + "/dav",
|
||||
"username": "user",
|
||||
"password": "pass",
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
source := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(source, "Movie.mkv"), []byte("movie"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res, err := storage.UploadLocal(t.Context(), CloudUploadInput{
|
||||
Type: "clouddrive2",
|
||||
SourcePath: source,
|
||||
DestPath: "/MediaStationGo",
|
||||
Recursive: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("upload local: %v", err)
|
||||
}
|
||||
if res.Uploaded != 1 || len(uploaded) != 1 || uploaded[0] != "/dav/MediaStationGo/Movie.mkv" {
|
||||
t.Fatalf("result = %+v uploaded=%#v", res, uploaded)
|
||||
}
|
||||
}
|
||||
|
||||
func newStorageUploadTestService(t *testing.T) (*repository.Container, *StorageConfigService) {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
|
||||
Reference in New Issue
Block a user