Files
MeBox/internal/service/storage_upload.go
T
2026-06-25 00:00:20 +08:00

279 lines
9.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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...))
}