Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 17 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,10 @@ services:
DEST_PLEX_TOKEN: "your-destination-plex-token"

# SSH Configuration (choose password OR key-based auth)
OPT_SSH_USER: "your-ssh-user"
OPT_SSH_PASSWORD: "your-ssh-password" # For password auth
# OPT_SSH_KEY_PATH: "/keys/id_rsa" # For key-based auth
OPT_SSH_PORT: "22"
SSH_USER: "your-ssh-user"
SSH_PASSWORD: "your-ssh-password" # For password auth
# SSH_KEY_PATH: "/keys/id_rsa" # For key-based auth
SSH_PORT: "22"

# Sync Configuration
SYNC_LABEL: "Sync2Secondary" # Label to identify content to sync
Expand All @@ -46,13 +46,15 @@ services:
DRY_RUN: "false" # Set to "true" for testing

# Path Mapping
SOURCE_REPLACE_FROM: "/data/Media" # Source path prefix to replace
SOURCE_REPLACE_TO: "/media/source" # Local container path
SOURCE_REPLACE_FROM: "/data/Media" # Source path prefix to strip for destination
SOURCE_REPLACE_TO: "/media/source" # Local container path (or leave empty for same-volume mounting)
DEST_ROOT_DIR: "/mnt/data" # Destination server root path

volumes:
# Mount your media directories (adjust paths as needed)
- "/path/to/your/media:/media/source:ro" # Read-only source media
# Alternative: Same-volume mounting (leave SOURCE_REPLACE_TO empty)
# - "/data/Media:/data/Media:ro"

# For SSH key authentication (uncomment if using keys)
# - "/path/to/ssh/keys:/keys:ro"
Expand Down Expand Up @@ -129,12 +131,12 @@ services:

| Variable | Description | Example | Required |
|----------|-------------|---------|----------|
| `OPT_SSH_USER` | SSH username | `mediauser` | βœ… |
| `OPT_SSH_PASSWORD` | SSH password (for password auth) | `secretpass` | ❌* |
| `OPT_SSH_KEY_PATH` | SSH private key path (for key auth) | `/keys/id_rsa` | ❌* |
| `OPT_SSH_PORT` | SSH port | `22` | ❌ |
| `SSH_USER` | SSH username | `mediauser` | βœ… |
| `SSH_PASSWORD` | SSH password (for password auth) | `secretpass` | ❌* |
| `SSH_KEY_PATH` | SSH private key path (for key auth) | `/keys/id_rsa` | ❌* |
| `SSH_PORT` | SSH port | `22` | ❌ |

*Either password or key path is required
*Either password or key path is required. Password auth requires `sshpass` to be installed for both SCP and rsync transfers.

### Sync Configuration

Expand All @@ -150,8 +152,8 @@ services:

| Variable | Description | Example | Required |
|----------|-------------|---------|----------|
| `SOURCE_REPLACE_FROM` | Source path prefix to replace | `/data/Media` | ❌ |
| `SOURCE_REPLACE_TO` | Container path for source media | `/media/source` | ❌ |
| `SOURCE_REPLACE_FROM` | Source path prefix to strip for destination mapping | `/data/Media` | ❌ |
| `SOURCE_REPLACE_TO` | Container path for source media (leave empty for same-volume mounting) | `/media/source` | ❌ |
| `DEST_ROOT_DIR` | Destination server root directory | `/mnt/data` | βœ… |

</details>
Expand Down Expand Up @@ -407,8 +409,8 @@ We welcome contributions! Here's how to get started:
- Verify SSH credentials are correct
- Ensure SSH user has access to destination paths
- Test SSH connection manually: `ssh user@destination-server`
- For password auth: Ensure `OPT_SSH_PASSWORD` is set
- For key auth: Ensure private key is mounted and `OPT_SSH_KEY_PATH` is correct
- For password auth: Ensure `SSH_PASSWORD` is set and `sshpass` is installed
- For key auth: Ensure private key is mounted and `SSH_KEY_PATH` is correct

### Rsync Not Found

Expand Down
90 changes: 84 additions & 6 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package config
import (
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"time"
Expand All @@ -13,9 +14,10 @@ type Config struct {
Source PlexServerConfig `json:"source"`
Destination PlexServerConfig `json:"destination"`
SyncLabel string `json:"syncLabel"`
SourceReplaceFrom string `json:"sourceReplaceFrom"` // Optional: Source path pattern to replace (e.g., "/data/Movies")
SourceReplaceTo string `json:"sourceReplaceTo"` // Optional: Local path replacement (e.g., "M:\\Movies")
SourceReplaceFrom string `json:"sourceReplaceFrom"` // Optional: Source path prefix to strip (e.g., "/data/Movies")
SourceReplaceTo string `json:"sourceReplaceTo"` // Optional: Local path replacement (e.g., "/media/source"). Leave empty for same-volume mounting
DestRootDir string `json:"destRootDir"` // Required: Destination root path (e.g., "/mnt/data/Movies")
TransferMethod string `json:"transferMethod"` // Optional: Force transfer method ("rsync" or "scp"), auto-detected if empty
Interval time.Duration `json:"interval"`
SSH SSHConfig `json:"ssh"`
Performance PerformanceConfig `json:"performance"`
Expand Down Expand Up @@ -82,11 +84,12 @@ func LoadConfig() (*Config, error) {
SourceReplaceFrom: getEnvWithDefault("SOURCE_REPLACE_FROM", ""),
SourceReplaceTo: getEnvWithDefault("SOURCE_REPLACE_TO", ""),
DestRootDir: getEnvWithDefault("DEST_ROOT_DIR", ""),
TransferMethod: strings.ToLower(getEnvWithDefault("TRANSFER_METHOD", "")), // rsync, scp, or empty for auto-detection
SSH: SSHConfig{
User: getEnvWithDefault("OPT_SSH_USER", ""),
Password: getEnvWithDefault("OPT_SSH_PASSWORD", ""),
Port: getEnvWithDefault("OPT_SSH_PORT", "22"),
KeyPath: getEnvWithDefault("OPT_SSH_KEY_PATH", ""), // Keep for future use
User: getEnvWithDefault("SSH_USER", ""),
Password: getEnvWithDefault("SSH_PASSWORD", ""),
Port: getEnvWithDefault("SSH_PORT", "22"),
KeyPath: getEnvWithDefault("SSH_KEY_PATH", ""), // Keep for future use
},
DryRun: parseBoolEnv("DRY_RUN", false),
LogLevel: getEnvWithDefault("LOG_LEVEL", "INFO"),
Expand Down Expand Up @@ -242,3 +245,78 @@ func parseFloatEnv(key string, defaultValue float64) float64 {
}
return defaultValue
}

// MapSourcePathToLocal converts a source Plex server path to a local filesystem path
func (c *Config) MapSourcePathToLocal(sourcePath string) (string, error) {
if sourcePath == "" {
return "", fmt.Errorf("source path is empty")
}

// If no source replacement configured, use the Plex path as-is
if c.SourceReplaceFrom == "" {
return filepath.FromSlash(sourcePath), nil
}

// If SourceReplaceFrom is set but SourceReplaceTo is empty,
// use source path as-is (same volume mounting scenario)
if c.SourceReplaceTo == "" {
return filepath.FromSlash(sourcePath), nil
}

// Apply source replacement pattern
sourcePathNorm := filepath.ToSlash(sourcePath)
sourceReplaceFromNorm := filepath.ToSlash(c.SourceReplaceFrom)

if !strings.HasPrefix(sourcePathNorm, sourceReplaceFromNorm) {
return "", fmt.Errorf("source path %s does not start with replacement pattern %s", sourcePath, c.SourceReplaceFrom)
}

relativePath := strings.TrimPrefix(sourcePathNorm, sourceReplaceFromNorm)
relativePath = strings.TrimPrefix(relativePath, "/")

localPath := filepath.Join(c.SourceReplaceTo, relativePath)
return localPath, nil
}

// MapLocalPathToDest converts a local filesystem path to a destination server path
func (c *Config) MapLocalPathToDest(localPath string) (string, error) {
if localPath == "" {
return "", fmt.Errorf("local path is empty")
}

if c.DestRootDir == "" {
return "", fmt.Errorf("destination root directory not configured")
}

var relativePath string

if c.SourceReplaceTo != "" {
// Standard case: strip SourceReplaceTo prefix from local path
localPathNorm := filepath.ToSlash(localPath)
sourceReplaceToNorm := filepath.ToSlash(c.SourceReplaceTo)

if !strings.HasPrefix(localPathNorm, sourceReplaceToNorm) {
return "", fmt.Errorf("local path %s does not start with source replacement root %s", localPath, c.SourceReplaceTo)
}

relativePath = strings.TrimPrefix(localPathNorm, sourceReplaceToNorm)
relativePath = strings.TrimPrefix(relativePath, "/")
} else if c.SourceReplaceFrom != "" {
// Same volume mounting: strip SourceReplaceFrom prefix to get relative path
localPathNorm := filepath.ToSlash(localPath)
sourceReplaceFromNorm := filepath.ToSlash(c.SourceReplaceFrom)

if !strings.HasPrefix(localPathNorm, sourceReplaceFromNorm) {
return "", fmt.Errorf("local path %s does not start with source replacement pattern %s", localPath, c.SourceReplaceFrom)
}

relativePath = strings.TrimPrefix(localPathNorm, sourceReplaceFromNorm)
relativePath = strings.TrimPrefix(relativePath, "/")
} else {
// Fallback: use just the filename (preserves original behavior)
relativePath = filepath.Base(localPath)
}

destPath := strings.TrimSuffix(c.DestRootDir, "/") + "/" + relativePath
return destPath, nil
}
4 changes: 2 additions & 2 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ func TestLoadConfig(t *testing.T) {
"DEST_PLEX_PROTOCOL": "http",
"SYNC_LABEL": "test-sync",
"SYNC_INTERVAL": "30",
"OPT_SSH_USER": "testuser",
"OPT_SSH_KEY_PATH": "/test/keys/id_rsa",
"SSH_USER": "testuser",
"SSH_KEY_PATH": "/test/keys/id_rsa",
"DEST_ROOT_DIR": "/test/dest",
"LOG_LEVEL": "DEBUG",
"DRY_RUN": "true",
Expand Down
31 changes: 24 additions & 7 deletions internal/orchestrator/sync.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,16 +56,33 @@ func NewSyncOrchestrator(cfg *config.Config, log *logger.Logger) (*SyncOrchestra
// Initialize content discovery (Phase 1 & 2)
orchestrator.contentDiscovery = discovery.NewContentDiscovery(sourceClient, cfg.SyncLabel, log)

// Phase 3: Transfer Files - Auto-detect optimal transfer method
// Phase 3: Transfer Files - Use configured or auto-detect optimal transfer method
if isSSHConfigured(cfg.SSH, log) {
// Auto-detect optimal transfer method (rsync preferred for performance)
transferMethod := transfer.GetOptimalTransferMethod(log)
var transferMethod transfer.TransferMethod

// Check if user specified a transfer method via environment variable
if cfg.TransferMethod != "" {
switch cfg.TransferMethod {
case "rsync":
transferMethod = transfer.TransferMethodRsync
log.WithField("method", "rsync").Info("Using user-configured transfer method")
case "scp":
transferMethod = transfer.TransferMethodSCP
log.WithField("method", "scp").Info("Using user-configured transfer method")
default:
log.WithField("invalid_method", cfg.TransferMethod).Warn("Invalid TRANSFER_METHOD specified, falling back to auto-detection")
transferMethod = transfer.GetOptimalTransferMethod(log)
}
} else {
// Auto-detect optimal method (rsync preferred for performance)
transferMethod = transfer.GetOptimalTransferMethod(log)
}

fileTransfer, err := transfer.NewTransferrer(transferMethod, cfg, log)
if err != nil {
return nil, fmt.Errorf("failed to create file transferrer: %w", err)
}
orchestrator.fileTransfer = fileTransfer
log.WithField("transfer_method", string(transferMethod)).Info("High-performance file transfer enabled")
} else {
log.Info("SSH not configured - running in metadata-only sync mode")
}
Expand Down Expand Up @@ -254,7 +271,7 @@ func (s *SyncOrchestrator) transferEnhancedItemFiles(enhancedItem *discovery.Enh
}

// Map source Plex path to local path
localPath, err := s.fileTransfer.MapSourcePathToLocal(sourcePath)
localPath, err := s.config.MapSourcePathToLocal(sourcePath)
if err != nil {
s.logger.WithError(err).WithField("source_path", sourcePath).Error("Failed to map source path to local path")
continue
Expand All @@ -267,7 +284,7 @@ func (s *SyncOrchestrator) transferEnhancedItemFiles(enhancedItem *discovery.Enh
}

// Map local path to destination path
destPath, err := s.fileTransfer.MapLocalPathToDest(localPath)
destPath, err := s.config.MapLocalPathToDest(localPath)
if err != nil {
s.logger.WithError(err).WithField("local_path", localPath).Error("Failed to map local path to destination path")
continue
Expand Down Expand Up @@ -312,7 +329,7 @@ func (s *SyncOrchestrator) findRelatedFiles(mainFilePath string) []string {
prefix := filename[:dotIndex]

// Map source path to local path for directory listing
localDir, err := s.fileTransfer.MapSourcePathToLocal(dir)
localDir, err := s.config.MapSourcePathToLocal(dir)
if err != nil {
s.logger.WithError(err).WithField("source_dir", dir).Debug("Failed to map source directory to local path")
return allPaths
Expand Down
36 changes: 35 additions & 1 deletion internal/plex/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"encoding/json"
"encoding/xml"
"fmt"
"strconv"
)

// Library represents a Plex library
Expand Down Expand Up @@ -120,7 +121,7 @@ type TVShow struct {
UseOriginalTitle int `json:"useOriginalTitle,omitempty"`
AudioLanguage string `json:"audioLanguage,omitempty"`
SubtitleLanguage string `json:"subtitleLanguage,omitempty"`
SubtitleMode int `json:"subtitleMode,omitempty"`
SubtitleMode FlexibleInt `json:"subtitleMode,omitempty"`
AutoDeletionItemPolicyUnwatchedLibrary int `json:"autoDeletionItemPolicyUnwatchedLibrary,omitempty"`
AutoDeletionItemPolicyWatchedLibrary int `json:"autoDeletionItemPolicyWatchedLibrary,omitempty"`
Slug string `json:"slug,omitempty"`
Expand Down Expand Up @@ -269,6 +270,39 @@ func (fr FlexibleRating) MarshalJSON() ([]byte, error) {
return json.Marshal(fr.Value)
}

// FlexibleInt can handle both string and integer values
type FlexibleInt struct {
Value int
}

// UnmarshalJSON implements custom JSON unmarshaling for FlexibleInt
func (fi *FlexibleInt) UnmarshalJSON(data []byte) error {
// Try to unmarshal as an integer first
var intValue int
if err := json.Unmarshal(data, &intValue); err == nil {
fi.Value = intValue
return nil
}

// Try to unmarshal as a string and parse it as an integer
var stringValue string
if err := json.Unmarshal(data, &stringValue); err == nil {
if parsedInt, parseErr := strconv.Atoi(stringValue); parseErr == nil {
fi.Value = parsedInt
return nil
}
}

// If both fail, set to 0
fi.Value = 0
return nil
}

// MarshalJSON implements custom JSON marshaling for FlexibleInt
func (fi FlexibleInt) MarshalJSON() ([]byte, error) {
return json.Marshal(fi.Value)
}

// MediaContainer holds metadata for movies or TV shows
type MediaContainer struct {
Size int `json:"size"`
Expand Down
Loading
Loading