Skip to content

Commit a802e5b

Browse files
authored
Merge pull request #12 from nullable-eth/fix-transfers
fix: transfers and standardize file checks
2 parents 4222671 + 84cba77 commit a802e5b

File tree

9 files changed

+548
-925
lines changed

9 files changed

+548
-925
lines changed

README.md

Lines changed: 17 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -34,10 +34,10 @@ services:
3434
DEST_PLEX_TOKEN: "your-destination-plex-token"
3535

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

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

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

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

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

130132
| Variable | Description | Example | Required |
131133
|----------|-------------|---------|----------|
132-
| `OPT_SSH_USER` | SSH username | `mediauser` | ✅ |
133-
| `OPT_SSH_PASSWORD` | SSH password (for password auth) | `secretpass` | ❌* |
134-
| `OPT_SSH_KEY_PATH` | SSH private key path (for key auth) | `/keys/id_rsa` | ❌* |
135-
| `OPT_SSH_PORT` | SSH port | `22` | ❌ |
134+
| `SSH_USER` | SSH username | `mediauser` | ✅ |
135+
| `SSH_PASSWORD` | SSH password (for password auth) | `secretpass` | ❌* |
136+
| `SSH_KEY_PATH` | SSH private key path (for key auth) | `/keys/id_rsa` | ❌* |
137+
| `SSH_PORT` | SSH port | `22` | ❌ |
136138

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

139141
### Sync Configuration
140142

@@ -150,8 +152,8 @@ services:
150152

151153
| Variable | Description | Example | Required |
152154
|----------|-------------|---------|----------|
153-
| `SOURCE_REPLACE_FROM` | Source path prefix to replace | `/data/Media` | ❌ |
154-
| `SOURCE_REPLACE_TO` | Container path for source media | `/media/source` | ❌ |
155+
| `SOURCE_REPLACE_FROM` | Source path prefix to strip for destination mapping | `/data/Media` | ❌ |
156+
| `SOURCE_REPLACE_TO` | Container path for source media (leave empty for same-volume mounting) | `/media/source` | ❌ |
155157
| `DEST_ROOT_DIR` | Destination server root directory | `/mnt/data` | ✅ |
156158

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

413415
### Rsync Not Found
414416

internal/config/config.go

Lines changed: 84 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package config
33
import (
44
"fmt"
55
"os"
6+
"path/filepath"
67
"strconv"
78
"strings"
89
"time"
@@ -13,9 +14,10 @@ type Config struct {
1314
Source PlexServerConfig `json:"source"`
1415
Destination PlexServerConfig `json:"destination"`
1516
SyncLabel string `json:"syncLabel"`
16-
SourceReplaceFrom string `json:"sourceReplaceFrom"` // Optional: Source path pattern to replace (e.g., "/data/Movies")
17-
SourceReplaceTo string `json:"sourceReplaceTo"` // Optional: Local path replacement (e.g., "M:\\Movies")
17+
SourceReplaceFrom string `json:"sourceReplaceFrom"` // Optional: Source path prefix to strip (e.g., "/data/Movies")
18+
SourceReplaceTo string `json:"sourceReplaceTo"` // Optional: Local path replacement (e.g., "/media/source"). Leave empty for same-volume mounting
1819
DestRootDir string `json:"destRootDir"` // Required: Destination root path (e.g., "/mnt/data/Movies")
20+
TransferMethod string `json:"transferMethod"` // Optional: Force transfer method ("rsync" or "scp"), auto-detected if empty
1921
Interval time.Duration `json:"interval"`
2022
SSH SSHConfig `json:"ssh"`
2123
Performance PerformanceConfig `json:"performance"`
@@ -82,11 +84,12 @@ func LoadConfig() (*Config, error) {
8284
SourceReplaceFrom: getEnvWithDefault("SOURCE_REPLACE_FROM", ""),
8385
SourceReplaceTo: getEnvWithDefault("SOURCE_REPLACE_TO", ""),
8486
DestRootDir: getEnvWithDefault("DEST_ROOT_DIR", ""),
87+
TransferMethod: strings.ToLower(getEnvWithDefault("TRANSFER_METHOD", "")), // rsync, scp, or empty for auto-detection
8588
SSH: SSHConfig{
86-
User: getEnvWithDefault("OPT_SSH_USER", ""),
87-
Password: getEnvWithDefault("OPT_SSH_PASSWORD", ""),
88-
Port: getEnvWithDefault("OPT_SSH_PORT", "22"),
89-
KeyPath: getEnvWithDefault("OPT_SSH_KEY_PATH", ""), // Keep for future use
89+
User: getEnvWithDefault("SSH_USER", ""),
90+
Password: getEnvWithDefault("SSH_PASSWORD", ""),
91+
Port: getEnvWithDefault("SSH_PORT", "22"),
92+
KeyPath: getEnvWithDefault("SSH_KEY_PATH", ""), // Keep for future use
9093
},
9194
DryRun: parseBoolEnv("DRY_RUN", false),
9295
LogLevel: getEnvWithDefault("LOG_LEVEL", "INFO"),
@@ -242,3 +245,78 @@ func parseFloatEnv(key string, defaultValue float64) float64 {
242245
}
243246
return defaultValue
244247
}
248+
249+
// MapSourcePathToLocal converts a source Plex server path to a local filesystem path
250+
func (c *Config) MapSourcePathToLocal(sourcePath string) (string, error) {
251+
if sourcePath == "" {
252+
return "", fmt.Errorf("source path is empty")
253+
}
254+
255+
// If no source replacement configured, use the Plex path as-is
256+
if c.SourceReplaceFrom == "" {
257+
return filepath.FromSlash(sourcePath), nil
258+
}
259+
260+
// If SourceReplaceFrom is set but SourceReplaceTo is empty,
261+
// use source path as-is (same volume mounting scenario)
262+
if c.SourceReplaceTo == "" {
263+
return filepath.FromSlash(sourcePath), nil
264+
}
265+
266+
// Apply source replacement pattern
267+
sourcePathNorm := filepath.ToSlash(sourcePath)
268+
sourceReplaceFromNorm := filepath.ToSlash(c.SourceReplaceFrom)
269+
270+
if !strings.HasPrefix(sourcePathNorm, sourceReplaceFromNorm) {
271+
return "", fmt.Errorf("source path %s does not start with replacement pattern %s", sourcePath, c.SourceReplaceFrom)
272+
}
273+
274+
relativePath := strings.TrimPrefix(sourcePathNorm, sourceReplaceFromNorm)
275+
relativePath = strings.TrimPrefix(relativePath, "/")
276+
277+
localPath := filepath.Join(c.SourceReplaceTo, relativePath)
278+
return localPath, nil
279+
}
280+
281+
// MapLocalPathToDest converts a local filesystem path to a destination server path
282+
func (c *Config) MapLocalPathToDest(localPath string) (string, error) {
283+
if localPath == "" {
284+
return "", fmt.Errorf("local path is empty")
285+
}
286+
287+
if c.DestRootDir == "" {
288+
return "", fmt.Errorf("destination root directory not configured")
289+
}
290+
291+
var relativePath string
292+
293+
if c.SourceReplaceTo != "" {
294+
// Standard case: strip SourceReplaceTo prefix from local path
295+
localPathNorm := filepath.ToSlash(localPath)
296+
sourceReplaceToNorm := filepath.ToSlash(c.SourceReplaceTo)
297+
298+
if !strings.HasPrefix(localPathNorm, sourceReplaceToNorm) {
299+
return "", fmt.Errorf("local path %s does not start with source replacement root %s", localPath, c.SourceReplaceTo)
300+
}
301+
302+
relativePath = strings.TrimPrefix(localPathNorm, sourceReplaceToNorm)
303+
relativePath = strings.TrimPrefix(relativePath, "/")
304+
} else if c.SourceReplaceFrom != "" {
305+
// Same volume mounting: strip SourceReplaceFrom prefix to get relative path
306+
localPathNorm := filepath.ToSlash(localPath)
307+
sourceReplaceFromNorm := filepath.ToSlash(c.SourceReplaceFrom)
308+
309+
if !strings.HasPrefix(localPathNorm, sourceReplaceFromNorm) {
310+
return "", fmt.Errorf("local path %s does not start with source replacement pattern %s", localPath, c.SourceReplaceFrom)
311+
}
312+
313+
relativePath = strings.TrimPrefix(localPathNorm, sourceReplaceFromNorm)
314+
relativePath = strings.TrimPrefix(relativePath, "/")
315+
} else {
316+
// Fallback: use just the filename (preserves original behavior)
317+
relativePath = filepath.Base(localPath)
318+
}
319+
320+
destPath := strings.TrimSuffix(c.DestRootDir, "/") + "/" + relativePath
321+
return destPath, nil
322+
}

internal/config/config_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,8 @@ func TestLoadConfig(t *testing.T) {
1919
"DEST_PLEX_PROTOCOL": "http",
2020
"SYNC_LABEL": "test-sync",
2121
"SYNC_INTERVAL": "30",
22-
"OPT_SSH_USER": "testuser",
23-
"OPT_SSH_KEY_PATH": "/test/keys/id_rsa",
22+
"SSH_USER": "testuser",
23+
"SSH_KEY_PATH": "/test/keys/id_rsa",
2424
"DEST_ROOT_DIR": "/test/dest",
2525
"LOG_LEVEL": "DEBUG",
2626
"DRY_RUN": "true",

internal/orchestrator/sync.go

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -56,16 +56,33 @@ func NewSyncOrchestrator(cfg *config.Config, log *logger.Logger) (*SyncOrchestra
5656
// Initialize content discovery (Phase 1 & 2)
5757
orchestrator.contentDiscovery = discovery.NewContentDiscovery(sourceClient, cfg.SyncLabel, log)
5858

59-
// Phase 3: Transfer Files - Auto-detect optimal transfer method
59+
// Phase 3: Transfer Files - Use configured or auto-detect optimal transfer method
6060
if isSSHConfigured(cfg.SSH, log) {
61-
// Auto-detect optimal transfer method (rsync preferred for performance)
62-
transferMethod := transfer.GetOptimalTransferMethod(log)
61+
var transferMethod transfer.TransferMethod
62+
63+
// Check if user specified a transfer method via environment variable
64+
if cfg.TransferMethod != "" {
65+
switch cfg.TransferMethod {
66+
case "rsync":
67+
transferMethod = transfer.TransferMethodRsync
68+
log.WithField("method", "rsync").Info("Using user-configured transfer method")
69+
case "scp":
70+
transferMethod = transfer.TransferMethodSCP
71+
log.WithField("method", "scp").Info("Using user-configured transfer method")
72+
default:
73+
log.WithField("invalid_method", cfg.TransferMethod).Warn("Invalid TRANSFER_METHOD specified, falling back to auto-detection")
74+
transferMethod = transfer.GetOptimalTransferMethod(log)
75+
}
76+
} else {
77+
// Auto-detect optimal method (rsync preferred for performance)
78+
transferMethod = transfer.GetOptimalTransferMethod(log)
79+
}
80+
6381
fileTransfer, err := transfer.NewTransferrer(transferMethod, cfg, log)
6482
if err != nil {
6583
return nil, fmt.Errorf("failed to create file transferrer: %w", err)
6684
}
6785
orchestrator.fileTransfer = fileTransfer
68-
log.WithField("transfer_method", string(transferMethod)).Info("High-performance file transfer enabled")
6986
} else {
7087
log.Info("SSH not configured - running in metadata-only sync mode")
7188
}
@@ -254,7 +271,7 @@ func (s *SyncOrchestrator) transferEnhancedItemFiles(enhancedItem *discovery.Enh
254271
}
255272

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

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

314331
// Map source path to local path for directory listing
315-
localDir, err := s.fileTransfer.MapSourcePathToLocal(dir)
332+
localDir, err := s.config.MapSourcePathToLocal(dir)
316333
if err != nil {
317334
s.logger.WithError(err).WithField("source_dir", dir).Debug("Failed to map source directory to local path")
318335
return allPaths

internal/plex/types.go

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"encoding/json"
55
"encoding/xml"
66
"fmt"
7+
"strconv"
78
)
89

910
// Library represents a Plex library
@@ -120,7 +121,7 @@ type TVShow struct {
120121
UseOriginalTitle int `json:"useOriginalTitle,omitempty"`
121122
AudioLanguage string `json:"audioLanguage,omitempty"`
122123
SubtitleLanguage string `json:"subtitleLanguage,omitempty"`
123-
SubtitleMode int `json:"subtitleMode,omitempty"`
124+
SubtitleMode FlexibleInt `json:"subtitleMode,omitempty"`
124125
AutoDeletionItemPolicyUnwatchedLibrary int `json:"autoDeletionItemPolicyUnwatchedLibrary,omitempty"`
125126
AutoDeletionItemPolicyWatchedLibrary int `json:"autoDeletionItemPolicyWatchedLibrary,omitempty"`
126127
Slug string `json:"slug,omitempty"`
@@ -269,6 +270,39 @@ func (fr FlexibleRating) MarshalJSON() ([]byte, error) {
269270
return json.Marshal(fr.Value)
270271
}
271272

273+
// FlexibleInt can handle both string and integer values
274+
type FlexibleInt struct {
275+
Value int
276+
}
277+
278+
// UnmarshalJSON implements custom JSON unmarshaling for FlexibleInt
279+
func (fi *FlexibleInt) UnmarshalJSON(data []byte) error {
280+
// Try to unmarshal as an integer first
281+
var intValue int
282+
if err := json.Unmarshal(data, &intValue); err == nil {
283+
fi.Value = intValue
284+
return nil
285+
}
286+
287+
// Try to unmarshal as a string and parse it as an integer
288+
var stringValue string
289+
if err := json.Unmarshal(data, &stringValue); err == nil {
290+
if parsedInt, parseErr := strconv.Atoi(stringValue); parseErr == nil {
291+
fi.Value = parsedInt
292+
return nil
293+
}
294+
}
295+
296+
// If both fail, set to 0
297+
fi.Value = 0
298+
return nil
299+
}
300+
301+
// MarshalJSON implements custom JSON marshaling for FlexibleInt
302+
func (fi FlexibleInt) MarshalJSON() ([]byte, error) {
303+
return json.Marshal(fi.Value)
304+
}
305+
272306
// MediaContainer holds metadata for movies or TV shows
273307
type MediaContainer struct {
274308
Size int `json:"size"`

0 commit comments

Comments
 (0)