mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 11:16:37 +08:00
279 lines
9.2 KiB
Go
279 lines
9.2 KiB
Go
package service
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"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"
|
||
CloudUploadTransferModeKey = "cloud.upload_transfer_mode"
|
||
CloudUploadIntervalSecondsKey = "cloud.upload_interval_seconds"
|
||
CloudUploadUnsupportedProvider = "本地文件直传目前支持 Alist / OpenList / WebDAV / CloudDrive2;115 原生上传需要分片上传私有接口,建议先用 CloudDrive2、OpenList 或 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"`
|
||
TransferMode string `json:"transfer_mode"`
|
||
}
|
||
|
||
type CloudUploadResult struct {
|
||
SourcePath string `json:"source_path"`
|
||
DestPath string `json:"dest_path"`
|
||
Uploaded int `json:"uploaded"`
|
||
Moved int `json:"moved,omitempty"`
|
||
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 / move / 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 or moves local media files into an external storage
|
||
// backend. Cloud writes are intentionally gated by the per-storage
|
||
// transfer_enabled switch so mounting/scanning a cloud provider does not
|
||
// accidentally make it writable.
|
||
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")
|
||
}
|
||
view, err := s.Get(ctx, in.Type)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if view == nil || !view.Enabled {
|
||
return nil, fmt.Errorf("%s storage not configured", in.Type)
|
||
}
|
||
if !parseBoolSetting(strr(view.Config["transfer_enabled"]), false) {
|
||
return nil, errors.New("cloud transfer is disabled for this storage; enable transfer in external storage settings before writing to cloud")
|
||
}
|
||
mode := resolveCloudUploadTransferMode(in.TransferMode, strr(view.Config["transfer_mode"]), s.settingValue(ctx, CloudUploadTransferModeKey))
|
||
if mode != TransferCopy && mode != TransferMove {
|
||
return nil, errors.New("transfer_mode must be copy or move")
|
||
}
|
||
in.TransferMode = string(mode)
|
||
uploader, err := s.uploaderForView(in.Type, view)
|
||
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)
|
||
}
|
||
return s.uploaderForView(typ, view)
|
||
}
|
||
|
||
func (s *StorageConfigService) uploaderForView(typ string, view *StorageView) (storageUploader, error) {
|
||
if view == nil || !view.Enabled {
|
||
return nil, fmt.Errorf("%s storage not configured", typ)
|
||
}
|
||
switch typ {
|
||
case "alist":
|
||
return newAlistUploader(view.Config), nil
|
||
case "openlist":
|
||
if strings.TrimSpace(strr(view.Config["server"])) != "" {
|
||
return newNamedAlistUploader("openlist", view.Config), nil
|
||
}
|
||
return newWebDAVUploader(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":
|
||
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
|
||
action := "upload"
|
||
mode := resolveCloudUploadTransferMode(in.TransferMode)
|
||
if mode == TransferMove {
|
||
if err := os.Remove(localPath); err != nil {
|
||
addUploadError(result, localPath, remotePath, fmt.Errorf("uploaded but failed to remove local source: %w", err))
|
||
addUploadItem(result, CloudUploadResultItem{Source: localPath, Target: remotePath, Action: "upload", Size: size, Reason: "source remove failed"})
|
||
return
|
||
}
|
||
result.Moved++
|
||
action = "move"
|
||
}
|
||
addUploadItem(result, CloudUploadResultItem{Source: localPath, Target: remotePath, Action: action, Size: size})
|
||
}
|
||
|
||
func (s *StorageConfigService) settingValue(ctx context.Context, key string) string {
|
||
if s == nil || s.repo == nil || s.repo.Setting == nil {
|
||
return ""
|
||
}
|
||
v, _ := s.repo.Setting.Get(ctx, key)
|
||
return strings.TrimSpace(v)
|
||
}
|
||
|
||
func resolveCloudUploadTransferMode(values ...string) TransferMode {
|
||
for _, value := range values {
|
||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||
case "move", "移动":
|
||
return TransferMove
|
||
case "copy", "复制":
|
||
return TransferCopy
|
||
}
|
||
}
|
||
return TransferCopy
|
||
}
|
||
|
||
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...))
|
||
}
|