Compare commits

..

16 Commits

Author SHA1 Message Date
91106a244c Added extensive diagnostic logging for Transmission connectivity
- Completely rewrote retry_command to show detailed output on each attempt
- Added direct Transmission connectivity test before using retry logic
- Added line-by-line analysis of Transmission command output
- Added test fallback ID in dry-run mode to verify downstream processing
- Added connection parameter logging (with redacted password)

🤖 Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude <noreply@anthropic.com>
2025-03-04 18:15:13 +01:00
1119f38fd6 Added enhanced diagnostics for torrent list retrieval
- Added verbose logging to identify when no torrents are found
- Added raw transmission command output logging to troubleshoot connection issues
- Improved tracking of torrent ID extraction from command output
- Made torrent count always visible, not just in debug mode

🤖 Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude <noreply@anthropic.com>
2025-03-04 18:12:11 +01:00
e64e1115a7 Fixed path mapping persistence and repeated logging issues
- Changed while loop with pipe to readarray with for loop to preserve variable state
- Enhanced path detection to better handle identical structures across mounts
- Added debug logging for path cache hits to trace execution
- Added debug output for processed directories at the end of execution

🤖 Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude <noreply@anthropic.com>
2025-03-04 18:09:04 +01:00
bf41b9ad71 Fixed infinite path mapping loop between dsnas1 and dsnas2
- Added path detection to prevent recursive analysis of paths already in destination format
- Added special handling for same logical path on different mounts
- Added early exit in process_copy for identical source and destination paths

🤖 Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude <noreply@anthropic.com>
2025-03-04 18:03:53 +01:00
4f7cb91bc5 Fix torrent info retrieval in torrent-processor
- Fix direct transmission command execution for single torrents
- Fix info retrieval for category-based processing
2025-03-04 17:22:21 +01:00
fb56817e76 Fix torrent-processor to handle specific IDs and categories
- Updated --id option to only process the specified torrent
- Fixed category processing to filter torrents by category
- Added better filtering and pattern matching for category-based processing
2025-03-04 17:21:42 +01:00
f572a241ef Fix torrent processing issues in transmission_handler.sh
- Fix quote handling in transmission-remote commands
- Add robust handling for empty torrent IDs
- Improve path handling for empty directories
- Update version to 9.1 with shared directory handling
- Fix empty array subscript errors

On branch main
Your branch is up to date with 'origin/main'.

Changes to be committed:
  (use "git restore --staged <file>..." to unstage)
	modified:   README.md
	modified:   etc/torrent/mover.conf
	modified:   install.sh
	new file:   usr/local/bin/smart-processor
	modified:   usr/local/bin/torrent-mover
	new file:   usr/local/bin/torrent-processor
	modified:   usr/local/lib/torrent-mover/common.sh
	modified:   usr/local/lib/torrent-mover/transmission_handler.sh
2025-03-04 17:15:51 +01:00
KniveMaker App
4c7ebaf5fe small updates 2025-03-04 09:01:59 +00:00
KniveMaker App
d799a2e8bd script enhancement 2025-03-04 08:53:02 +00:00
masterdraco
bb2ebaaa5d fixed wrong bracket in torrent-config 2025-02-28 11:24:31 +01:00
masterdraco
c924f096e7 some program redesign 2025-02-28 10:07:04 +01:00
6c164193b3 Update usr/local/bin/torrent-mover 2025-02-25 13:20:33 +01:00
5972dc2e1c Update README.md 2025-02-25 13:06:13 +01:00
21db2cea6f Update README.md 2025-02-25 13:05:46 +01:00
ecb39f4fb0 Update README.md 2025-02-25 13:04:36 +01:00
cbf1de8a91 Update README.md 2025-02-25 13:03:13 +01:00
11 changed files with 2527 additions and 484 deletions

342
README.md
View File

@@ -1,128 +1,286 @@
Torrent Mover v8.0 # Torrent Mover v9.1
Torrent Mover is a Bash script designed to automate the processing of completed torrents in Transmission. It moves or copies downloaded files from a Transmissionreported download location to designated destination directories on your system. This enhanced version includes robust locking, advanced error handling, parallel processing, configurable path mapping, and improved archive extraction while ensuring file integrity.
Features ## Description
Automatic Torrent Processing:
Monitors Transmission for completed torrents and processes them based on configurable seeding criteria.
Configurable Path Mapping: **Torrent Mover** is a Bash script designed to automate the processing of completed torrents in Transmission.
Uses Transmissions reported download path (e.g. /downloads) and maps it to your local file system (e.g. /mnt/dsnas2) via configurable settings. It moves or copies downloaded files from a Transmissionreported download location to designated destination directories on your system.
This enhanced version includes a modular architecture, dedicated security user, robust locking, advanced error handling with retry capabilities,
parallel processing, configurable path mapping, improved archive extraction, shared directory handling, and optional file integrity verification.
Robust Locking: The system seamlessly organizes content into appropriate directories using smart pattern matching and customizable category detection, helping you maintain a well-structured media library with minimal manual intervention.
Employs flock to ensure that only one instance of the script runs at a time, preventing conflicts.
Advanced Error Handling & Logging: ## Features
A global error handler traps unexpected errors and logs detailed messages. Logs are output to a specified log file (and optionally to syslog) and support DEBUG mode.
Parallel File Operations: ### Core Features
Utilizes GNU Parallel for moving, copying, and generating file checksums, making file operations efficient and multi-threaded. - **Automatic Torrent Processing:** Monitors Transmission for completed torrents and processes them based on configurable seeding criteria.
- **Configurable Path Mapping:** Uses Transmission's reported download path and maps it to your local file system via configurable settings.
- **Archive Extraction:** Extracts archives (RAR, ZIP, 7z) into subdirectories at the destination—preserving internal structure—while retaining the archive in the source until seeding criteria are met.
- **Directory Deduplication:** Prevents reprocessing the same source directory if multiple torrents reference it.
- **Shared Directory Handling:** Intelligently processes torrents that share the same download directory by matching files to specific torrents.
Archive Extraction with Directory Preservation: ### Advanced Content Organization
When an archive (RAR, ZIP, 7z) is encountered in the source, it is extracted into a subdirectory (named after the archive, minus its extension) within the destination. Archives remain in the source until Transmission removes them based on seeding ratio/time limits. - **Smart Content Categorization:** Uses both pattern matching and directory name detection to properly categorize content.
- **Regex Pattern Matching:** Define custom regex patterns to precisely organize content into subcategories (documentaries, anime, etc.).
- **Multi-Library Support:** Manage content across multiple storage locations with different organization schemes.
Directory Deduplication: ### Enhanced Security & Reliability
Prevents reprocessing the same source directory if multiple torrents reference it, ensuring that files arent processed repeatedly. - **Dedicated Non-Root User:** Uses a dedicated service user with minimal permissions for enhanced security.
- **Error Recovery:** Includes retry mechanisms with configurable attempts and delay for network operations.
- **Data Integrity Protection:** Optionally verifies file integrity by comparing MD5 checksums after transfer.
- **Robust Locking:** Employs `flock` to ensure that only one instance of the script runs at a time.
Optional Integrity Verification: ### Performance & Engineering
If enabled, the script recalculates and compares file checksums after file transfer to verify integrity. - **Modular Architecture:** Code is organized into separate modules for better maintainability and extensibility.
- **Parallel File Operations:** Utilizes GNU Parallel for moving, copying, and generating checksums, enabling efficient multi-threaded processing.
- **Advanced Error Handling & Logging:** Global error handler and detailed logging (with DEBUG mode support). Optionally, logs to syslog.
Requirements ## Requirements
Bash
Transmission-remote (for interfacing with Transmission)
GNU Parallel
unrar, unzip, 7z (for archive extraction)
bc (for numerical comparisons)
Installation
Download the Script:
Save the script (e.g., torrent-mover.sh) to your desired location (e.g., /usr/local/bin/).
Make It Executable: - Bash
- transmission-remote
- GNU Parallel
- unrar, unzip, 7z
- bc
chmod +x /usr/local/bin/torrent-mover.sh ## Installation
Create/Edit the Configuration File:
The script expects a configuration file at /etc/torrent/mover.conf. See the Configuration section for details.
Configuration 1. Run the installation script as root:
Create or modify the configuration file /etc/torrent/mover.conf with variables similar to the following: ```
sudo ./install.sh
```
# Transmission settings 2. The script will:
TRANSMISSION_IP="192.168.1.100" # Replace with your Transmission server's IP - Install all necessary dependencies
TRANSMISSION_PORT="9091" # Replace with your Transmission server's port - Create a dedicated non-root user for security
TRANSMISSION_USER="your_username" # Transmission username (if set) - Set up the configuration file in `/etc/torrent/mover.conf`
TRANSMISSION_PASSWORD="your_password" # Transmission password (if set) - Install systemd service and timer
- Configure file permissions and log rotation
# Path mapping settings 3. Enable the service to run every 15 minutes:
TRANSMISSION_PATH_PREFIX="/downloads" ```
LOCAL_PATH_PREFIX="/mnt/dsnas2" sudo systemctl enable --now torrent-mover.timer
```
# Destination directories ## Configuration
DIR_GAMES_DST="/mnt/dsnas1/Games"
DIR_APPS_DST="/mnt/dsnas1/Apps"
DIR_MOVIES_DST="/mnt/dsnas1/Movies"
DIR_BOOKS_DST="/mnt/dsnas1/Books"
DEFAULT_DST="/mnt/dsnas1/Other"
# Additional storage directories (comma-separated list) Edit the configuration file at `/etc/torrent/mover.conf` to customize the behavior of Torrent Mover:
STORAGE_DIRS="/mnt/dsnas/Movies"
# Performance settings ### Connection Configuration
PARALLEL_THREADS="32" ```bash
PARALLEL_PROCESSING=1 # Transmission connection settings
TRANSMISSION_IP="192.168.1.100" # IP address of your Transmission server
TRANSMISSION_PORT="9091" # RPC port for Transmission
TRANSMISSION_USER="your_username" # Username for authentication (if enabled)
TRANSMISSION_PASSWORD="your_password" # Password for authentication (if enabled)
# Operation mode: "move" or "copy" # Path mapping configuration
COPY_MODE="copy" TRANSMISSION_PATH_PREFIX="/downloads" # Path prefix reported by Transmission
LOCAL_PATH_PREFIX="/mnt/dsnas2" # Corresponding local path prefix
```
### Content Organization
```bash
# Primary content destination directories
DIR_GAMES_DST="/mnt/dsnas1/Games" # Games destination
DIR_APPS_DST="/mnt/dsnas1/Apps" # Applications destination
DIR_MOVIES_DST="/mnt/dsnas1/Movies" # Movies destination
DIR_BOOKS_DST="/mnt/dsnas1/Books" # Books/eBooks destination
DIR_TV_DST="/mnt/dsnas1/TV" # TV series destination
DIR_MUSIC_DST="/mnt/dsnas1/Music" # Music destination
DEFAULT_DST="/mnt/dsnas1/Other" # Default for unrecognized content
# Additional storage libraries (comma-separated)
STORAGE_DIRS="/mnt/dsnas/Movies,/mnt/external/Movies" # Additional movie libraries
STORAGE_TV_DIRS="/mnt/dsnas/TV,/mnt/external/TV" # Additional TV libraries
# Custom pattern matching for advanced categorization
# Format: "regex_pattern=destination_path;another_pattern=another_path"
CUSTOM_PATTERNS=".*documentary.*=${DIR_MOVIES_DST}/Documentary;
.*anime.*=${DIR_TV_DST}/Anime;
.*linux.*=${DIR_APPS_DST}/Linux;
.*tutorial.*=${DIR_BOOKS_DST}/Tutorials"
```
### Security & Performance
```bash
# Security settings - dedicated non-root user
TORRENT_USER="torrent-mover" # Dedicated service user
TORRENT_GROUP="torrent-mover" # User's primary group
# Error recovery configuration
MAX_RETRY_ATTEMPTS="3" # Maximum retry attempts for failed operations
RETRY_WAIT_TIME="15" # Seconds to wait between retry attempts
# Performance tuning
PARALLEL_THREADS="32" # Number of parallel threads (match CPU cores)
PARALLEL_PROCESSING=1 # Enable (1) or disable (0) parallel processing
# Operation mode
COPY_MODE="copy" # "copy" to preserve or "move" to relocate files
```
### Logging & Integrity
```bash
# File tracking & integrity # File tracking & integrity
PROCESSED_LOG="/var/log/torrent_processed.log" PROCESSED_LOG="/var/log/torrent_processed.log" # Tracks processed torrents
CHECKSUM_DB="/var/lib/torrent/checksums.db" CHECKSUM_DB="/var/lib/torrent/checksums.db" # Stores file checksums
# Logging settings # Logging configuration
LOG_FILE="/var/log/torrent_mover.log" LOG_FILE="/var/log/torrent_mover.log" # Main log file location
LOG_LEVEL="INFO" # Change to "DEBUG" for more verbose logging LOG_LEVEL="INFO" # Logging level: "INFO" or "DEBUG"
USE_SYSLOG="false" # Set to "true" to log to syslog as well USE_SYSLOG="false" # Also log to system syslog: "true" or "false"
# Optional integrity verification after file transfer ("true" to enable) # Data integrity protection
CHECK_TRANSFER_INTEGRITY="true" CHECK_TRANSFER_INTEGRITY="true" # Verify file integrity after transfers
Usage ```
Run the script with various command-line options:
Dry-run mode (simulate operations): ## Usage
/usr/local/bin/torrent-mover.sh --dry-run ### Main Torrent Mover Script
Interactive mode (prompt for confirmation):
/usr/local/bin/torrent-mover.sh --interactive Run the main script using the following options:
Cache warmup mode (pre-calculate checksums):
/usr/local/bin/torrent-mover.sh --cache-warmup - **Dry-run mode (simulate operations):**
Debug mode (verbose logging): ```
/usr/local/bin/torrent-mover --dry-run
```
/usr/local/bin/torrent-mover.sh --debug - **Interactive mode (prompt for confirmation):**
You can combine these options as needed. For example: ```
/usr/local/bin/torrent-mover --interactive
```
/usr/local/bin/torrent-mover.sh --dry-run --debug - **Cache warmup mode (pre-calculate checksums):**
How It Works ```
Locking: /usr/local/bin/torrent-mover --cache-warmup
The script uses flock on a lock file to ensure that only one instance runs at a time. ```
Path Translation: - **Debug mode (verbose logging):**
The Transmission-reported download path is translated into the actual local path using the mapping provided in the configuration. ```
/usr/local/bin/torrent-mover --debug
```
Torrent Processing: You can combine options as needed. For example:
The script uses transmission-remote to list and retrieve torrent information. It processes torrents that are 100% complete (based on percent done) and checks if theyve already been processed. ```
/usr/local/bin/torrent-mover --dry-run --debug
```
File Verification & Deduplication: ### Helper Scripts
Before copying files, the script compares file checksums between the source and destination. It skips processing if an exact match is found and avoids reprocessing directories already handled for previous torrents.
Archive Extraction: The system includes additional helper scripts for more advanced usage:
Archives in the source are extracted into dedicated subdirectories at the destination. The original archive file is kept until Transmissions seeding criteria are met (and Transmission subsequently removes the torrent).
Seeding Criteria: - **Torrent Processor:**
The script checks seeding ratio and seeding time values. When the criteria are met, it instructs Transmission (via transmission-remote) to remove the torrent. ```
/usr/local/bin/torrent-processor [OPTIONS]
```
Integrity Check (Optional): Available options:
If enabled, the script verifies file integrity by comparing md5 checksums of the source and destination files after transfer. - `--reset` - Clear processed log to re-process all torrents
- `--books` - Process only book torrents
- `--movies` - Process only movie torrents
- `--tv` - Process only TV show torrents
- `--apps` - Process only application torrents
- `--games` - Process only game torrents
- `--id NUMBER` - Process a specific torrent ID
License Examples:
This script is provided as-is. Use at your own risk. Contributions and improvements are welcome! ```bash
# Process all book torrents (even if previously processed)
/usr/local/bin/torrent-processor --reset --books
# Process only torrent with ID 123
/usr/local/bin/torrent-processor --id 123
```
- **Smart Processor:**
```
/usr/local/bin/smart-processor
```
An alternative processor specifically designed to handle shared directories more intelligently by:
- Detecting shared download directories
- Matching files to specific torrents
- Using content type detection for files
- Processing multiple torrents efficiently
### Configuration Management Tool
The system includes a dedicated configuration management tool that helps you safely update and manage your torrent-mover settings:
```
sudo torrent-config [OPTION]
```
Available options:
- **show** - Display the current configuration with color-coding
- **edit** - Edit the configuration in your preferred text editor (automatically creates a backup)
- **backup** - Create a timestamped backup of the current configuration
- **restore** - List and restore from available backups
- **validate** - Check the configuration for errors
- **set KEY VALUE** - Update a specific configuration value
- **get KEY** - Retrieve the current value of a configuration setting
- **default** - Show the default configuration values as a reference
Examples:
```bash
# View current configuration
sudo torrent-config show
# Change the copy mode to 'move'
sudo torrent-config set COPY_MODE move
# Add a new pattern for documentaries
sudo torrent-config set CUSTOM_PATTERNS ".*documentary.*=${DIR_MOVIES_DST}/Documentary"
# Edit the configuration file in your preferred editor
sudo torrent-config edit
# View the value of a specific setting
sudo torrent-config get TRANSMISSION_IP
```
## Architecture & Module Organization
The system uses a modular architecture for improved maintainability:
- **Main Script (`/usr/local/bin/torrent-mover`)**: Orchestrates the overall process and loads modules
- **Common Module**: Contains shared utilities, logging functions and error handling
- **File Operations Module**: Handles file transfers, checksums, and integrity verification
- **Archive Handler Module**: Specializes in extracting and managing various archive formats
- **Transmission Handler Module**: Manages all communication with the Transmission client
## How It Works
### Initialization & Configuration
1. **Module Loading:** The main script dynamically loads all modules from the `/usr/local/lib/torrent-mover` directory
2. **Configuration Processing:** Loads and validates the configuration from `/etc/torrent/mover.conf`
3. **Locking:** Uses `flock` to prevent multiple instances from running simultaneously
### Torrent Processing Workflow
1. **Torrent Discovery:** Retrieves the list of torrents from Transmission using retry-enabled API calls
2. **Smart Path Translation:** Converts Transmission-reported paths to local filesystem paths using configurable mappings
3. **Content Categorization:**
- First applies custom regex patterns from the configuration
- Falls back to keyword-based directory name detection if no patterns match
- Determines the appropriate destination directory for each content type
4. **Deduplication & Verification:**
- Tracks processed source directories to avoid redundant operations
- Generates and compares checksums between source and potential destinations
- Skips transfers if identical content is already present in any destination library
5. **Smart File Matching:**
- Detects when multiple torrents share the same download directory
- Uses intelligent pattern matching to identify specific files for each torrent
- Handles shared directories by matching torrent names to specific files
6. **File Processing:**
- Extracts archives with preservation of directory structure
- Transfers files using parallel operations when enabled
- Verifies integrity after transfer if configured
7. **Cleanup & Monitoring:**
- Checks seeding ratio and time against configured thresholds
- Removes torrents from Transmission when criteria are met
- Monitors disk usage across all configured storage directories
## License
This script is provided as-is without any warranty. Use it at your own risk. Contributions and improvements are welcome.

View File

@@ -13,15 +13,36 @@ DIR_GAMES_DST="/mnt/dsnas1/Games"
DIR_APPS_DST="/mnt/dsnas1/Apps" DIR_APPS_DST="/mnt/dsnas1/Apps"
DIR_MOVIES_DST="/mnt/dsnas1/Movies" DIR_MOVIES_DST="/mnt/dsnas1/Movies"
DIR_BOOKS_DST="/mnt/dsnas1/Books" DIR_BOOKS_DST="/mnt/dsnas1/Books"
DIR_TV_DST="/mnt/dsnas1/TV"
DIR_MUSIC_DST="/mnt/dsnas1/Music"
DEFAULT_DST="/mnt/dsnas1/Other" DEFAULT_DST="/mnt/dsnas1/Other"
# Storage directories (comma-separated) # Storage directories (comma-separated)
STORAGE_DIRS="/mnt/dsnas/Movies" STORAGE_DIRS="/mnt/dsnas/Movies"
STORAGE_TV_DIRS="/mnt/dsnas/TV"
# Path mapping # Path mapping
# This maps the transmission-reported download path to the local filesystem path
# The script will use this prefix to translate paths between Transmission and local filesystem
#
# IMPORTANT: Transmission reports paths as /downloads/Books but they are actually in /mnt/dsnas2/Books
TRANSMISSION_PATH_PREFIX="/downloads" TRANSMISSION_PATH_PREFIX="/downloads"
LOCAL_PATH_PREFIX="/mnt/dsnas2" LOCAL_PATH_PREFIX="/mnt/dsnas2"
# Security settings
# Default user/group for torrent operations (usually debian-transmission)
TORRENT_USER="debian-transmission"
TORRENT_GROUP="debian-transmission"
# Custom pattern matching for content categorization
# Format: "pattern1=destination1;pattern2=destination2"
# Example: ".*\.linux.*=${DIR_LINUX_DST};.*documentary.*=${DIR_DOCUMENTARY_DST}"
CUSTOM_PATTERNS=".*documentary.*=${DIR_MOVIES_DST}/Documentary;.*anime.*=${DIR_TV_DST}/Anime;.*games.*=${DIR_GAMES_DST};.*apps.*=${DIR_APPS_DST};.*books.*=${DIR_BOOKS_DST};.*tv.*=${DIR_TV_DST};.*series.*=${DIR_TV_DST};.*music.*=${DIR_MUSIC_DST}"
# Error recovery settings
MAX_RETRY_ATTEMPTS="3"
RETRY_WAIT_TIME="15"
# Performance settings # Performance settings
PARALLEL_THREADS="32" # Match CPU core count PARALLEL_THREADS="32" # Match CPU core count
PARALLEL_PROCESSING=1 PARALLEL_PROCESSING=1
@@ -43,7 +64,9 @@ CHECK_TRANSFER_INTEGRITY="true"
# Optionally, set USE_SYSLOG="true" to also log messages to syslog. # Optionally, set USE_SYSLOG="true" to also log messages to syslog.
USE_SYSLOG="false" USE_SYSLOG="false"
# Auto-create directories # Auto-create directories - commented out from config file
mkdir -p "${DIR_GAMES_DST}" "${DIR_APPS_DST}" \ # These should be created in a script, not in the config file
"${DIR_MOVIES_DST}" "${DIR_BOOKS_DST}" \ # mkdir -p "${DIR_GAMES_DST}" "${DIR_APPS_DST}" \
"${DEFAULT_DST}" 2>/dev/null || true # "${DIR_MOVIES_DST}" "${DIR_BOOKS_DST}" \
# "${DIR_TV_DST}" "${DIR_MUSIC_DST}" \
# "${DEFAULT_DST}" 2>/dev/null || true

151
install.sh Normal file → Executable file
View File

@@ -3,6 +3,7 @@ set -e
# Git repository configuration # Git repository configuration
GIT_REPO="http://192.168.0.236:3000/masterdraco/torrent" GIT_REPO="http://192.168.0.236:3000/masterdraco/torrent"
INSTALL_DIR="/tmp/torrent-install"
# Check root privileges # Check root privileges
if [ "$EUID" -ne 0 ]; then if [ "$EUID" -ne 0 ]; then
@@ -14,12 +15,13 @@ fi
echo "Checking dependencies..." echo "Checking dependencies..."
declare -A PKGS=( declare -A PKGS=(
[transmission-cli]="transmission-remote" [transmission-cli]="transmission-remote"
[unrar]="unrar" [unrar-free]="unrar-free"
[unzip]="unzip" [unzip]="unzip"
[p7zip-full]="7z" [p7zip-full]="7z"
[parallel]="parallel" [parallel]="parallel"
[bc]="bc" [bc]="bc"
[git]="git" [git]="git"
[logrotate]="logrotate"
) )
for pkg in "${!PKGS[@]}"; do for pkg in "${!PKGS[@]}"; do
@@ -31,22 +33,155 @@ for pkg in "${!PKGS[@]}"; do
done done
# Get files from Repo # Get files from Repo
git pull http://192.168.0.236:3000/masterdraco/torrent.git echo "Getting latest files from repository..."
if [ -d "$INSTALL_DIR" ]; then
cd "$INSTALL_DIR"
git fetch
git reset --hard origin/main
else
mkdir -p "$INSTALL_DIR"
git clone "$GIT_REPO" "$INSTALL_DIR"
cd "$INSTALL_DIR"
fi
# Create directory structure # Create directory structure
echo "Creating directory structure..." echo "Creating directory structure..."
mkdir -p /etc/torrent mkdir -p /etc/torrent
mkdir -p /usr/local/bin mkdir -p /usr/local/bin
mkdir -p /usr/local/lib/torrent-mover
mkdir -p /var/lib/torrent
mkdir -p /var/log/torrent
mkdir -p /etc/systemd/system
# Create dedicated user for security
TORRENT_USER="torrent-mover"
TORRENT_GROUP="torrent-mover"
# Check if user exists and create if not
if ! id "$TORRENT_USER" &>/dev/null; then
echo "Creating dedicated $TORRENT_USER user for security..."
useradd -r -s /bin/false "$TORRENT_USER"
fi
# Install files # Install files
echo "Installing files..." echo "Installing files..."
cp -v etc/torrent/mover.conf /etc/torrent/ install -Dm644 etc/torrent/mover.conf /etc/torrent/mover.conf.new
cp -v usr/local/bin/torrent-mover /usr/local/bin/ install -Dm755 usr/local/bin/torrent-mover /usr/local/bin/torrent-mover
chmod +x /usr/local/bin/torrent-mover install -Dm755 usr/local/bin/torrent-config /usr/local/bin/torrent-config
# Install library modules
for module in usr/local/lib/torrent-mover/*.sh; do
if [ -f "$module" ]; then
install -Dm755 "$module" "/usr/local/lib/torrent-mover/$(basename "$module")"
fi
done
# Create backup directory for configuration files
mkdir -p /etc/torrent/backups
chown $TORRENT_USER:$TORRENT_GROUP /etc/torrent/backups
# If this is a first-time install, copy the default config
if [ ! -f "/etc/torrent/mover.conf" ]; then
mv /etc/torrent/mover.conf.new /etc/torrent/mover.conf
echo "Config file installed at /etc/torrent/mover.conf"
echo "Please run 'torrent-config edit' to set up your configuration"
else
echo "Existing configuration found at /etc/torrent/mover.conf"
echo "New configuration is at /etc/torrent/mover.conf.new"
echo "You can compare them with: diff /etc/torrent/mover.conf /etc/torrent/mover.conf.new"
fi
# Run torrent-config to validate the configuration
echo "Validating configuration..."
if /usr/local/bin/torrent-config validate 2>/dev/null; then
echo "Configuration validation passed."
else
echo "Configuration requires setup. Please run 'torrent-config edit' to configure."
fi
# Create log rotation configuration
cat > /etc/logrotate.d/torrent-mover << EOF
/var/log/torrent_mover.log /var/log/torrent_processed.log {
weekly
rotate 4
compress
delaycompress
missingok
notifempty
create 0640 $TORRENT_USER $TORRENT_GROUP
}
EOF
# Create systemd service
cat > /etc/systemd/system/torrent-mover.service << EOF
[Unit]
Description=Torrent Mover Service
After=network.target
[Service]
Type=simple
ExecStart=/usr/local/bin/torrent-mover
Restart=on-failure
RestartSec=60
User=$TORRENT_USER
Group=$TORRENT_GROUP
[Install]
WantedBy=multi-user.target
EOF
# Create systemd timer for periodic execution
cat > /etc/systemd/system/torrent-mover.timer << EOF
[Unit]
Description=Run Torrent Mover every 15 minutes
[Timer]
OnBootSec=5min
OnUnitActiveSec=15min
AccuracySec=1min
[Install]
WantedBy=timers.target
EOF
# Install helper scripts
echo "Installing helper scripts..."
if [ -f "${SCRIPT_DIR}/usr/local/bin/torrent-processor" ]; then
cp "${SCRIPT_DIR}/usr/local/bin/torrent-processor" /usr/local/bin/
chmod 755 /usr/local/bin/torrent-processor
echo "- Installed torrent-processor"
fi
if [ -f "${SCRIPT_DIR}/usr/local/bin/smart-processor" ]; then
cp "${SCRIPT_DIR}/usr/local/bin/smart-processor" /usr/local/bin/
chmod 755 /usr/local/bin/smart-processor
echo "- Installed smart-processor"
fi
# Set permissions # Set permissions
echo "Setting permissions..." echo "Setting permissions..."
chmod 600 /etc/torrent/mover.conf chmod 600 /etc/torrent/mover.conf*
chown root:root /etc/torrent/mover.conf chown root:root /etc/torrent/mover.conf*
chmod 644 /etc/systemd/system/torrent-mover.service
chmod 644 /etc/systemd/system/torrent-mover.timer
# Set permissions for data directories
chown $TORRENT_USER:$TORRENT_GROUP /var/lib/torrent
chmod 755 /var/lib/torrent
touch /var/log/torrent_mover.log /var/log/torrent_processed.log
chown $TORRENT_USER:$TORRENT_GROUP /var/log/torrent_mover.log /var/log/torrent_processed.log
chmod 640 /var/log/torrent_mover.log /var/log/torrent_processed.log
# Ensure torrent-mover user can access required directories
echo "Setting up group memberships..."
if getent group debian-transmission >/dev/null; then
usermod -a -G debian-transmission $TORRENT_USER
echo "Added $TORRENT_USER to debian-transmission group"
fi
# Reload systemd and enable timer
systemctl daemon-reload
echo "To enable automatic execution every 15 minutes, run:"
echo " systemctl enable --now torrent-mover.timer"
echo
echo "Installation complete!"

183
usr/local/bin/smart-processor Executable file
View File

@@ -0,0 +1,183 @@
#\!/bin/bash
# Source configuration
source /etc/torrent/mover.conf
# Reset processed log
> /var/log/torrent_processed.log
# Process all torrents - smart version for shared directories
echo "Starting smart torrent processor..."
echo "This script will identify and copy files for completed torrents"
echo "----------------------------------------------------------------"
# Make sure destination directories exist
mkdir -p /mnt/dsnas1/{Books,Movies,TV,Games,Apps,Music,Other}
# Get list of torrents
IDS=$(transmission-remote "${TRANSMISSION_IP}:${TRANSMISSION_PORT}" \
--auth "${TRANSMISSION_USER}:${TRANSMISSION_PASSWORD}" \
--list | tail -n +2 | head -n -1 | awk '{print $1}' | grep -v "Sum:" | grep -v "[a-zA-Z]")
# Count torrents
TOTAL_TORRENTS=$(echo "$IDS" | wc -l)
echo "Found $TOTAL_TORRENTS torrents to process"
# Process each torrent
COUNT=0
for id in $IDS; do
# Progress counter
COUNT=$((COUNT+1))
# Get torrent info
INFO=$(transmission-remote "${TRANSMISSION_IP}:${TRANSMISSION_PORT}" \
--auth "${TRANSMISSION_USER}:${TRANSMISSION_PASSWORD}" \
--torrent $id --info)
# Extract key information
NAME=$(echo "$INFO" | grep "Name:" | awk -F": " '{print $2}' | xargs)
HASH=$(echo "$INFO" | grep "Hash:" | awk '{print $2}')
PERCENT=$(echo "$INFO" | grep "Percent Done:" | awk '{gsub(/%/, ""); print $3 == "None" ? 0 : $3}')
LOCATION=$(echo "$INFO" | grep -i "Location:" | awk -F": " '{print $2}' | xargs)
# Skip if not 100% complete
if [ $(bc <<< "$PERCENT < 100") -eq 1 ]; then
echo "[$COUNT/$TOTAL_TORRENTS] Skipping incomplete torrent $id: $NAME ($PERCENT%)"
continue
fi
# Skip if already processed
if grep -q "$HASH" /var/log/torrent_processed.log; then
echo "[$COUNT/$TOTAL_TORRENTS] Skipping already processed torrent $id: $NAME"
continue
fi
echo "[$COUNT/$TOTAL_TORRENTS] Processing torrent $id: $NAME"
# Apply path mapping
SRC="${LOCATION/#$TRANSMISSION_PATH_PREFIX/$LOCAL_PATH_PREFIX}"
# Set destination based on content type
DST="$DEFAULT_DST"
if [[ "$LOCATION" == */Books* || "$NAME" == *eBook* || "$NAME" == *ePub* ]]; then
DST="$DIR_BOOKS_DST"
echo " Categorized as: Book"
elif [[ "$LOCATION" == */Movies* || "$NAME" == *1080p* || "$NAME" == *720p* ]]; then
DST="$DIR_MOVIES_DST"
echo " Categorized as: Movie"
elif [[ "$LOCATION" == */TV* || "$NAME" == *S0* || "$NAME" == *S1* ]]; then
DST="$DIR_TV_DST"
echo " Categorized as: TV Show"
elif [[ "$LOCATION" == */Games* || "$NAME" == *Game* ]]; then
DST="$DIR_GAMES_DST"
echo " Categorized as: Game"
elif [[ "$LOCATION" == */Apps* || "$NAME" == *App* ]]; then
DST="$DIR_APPS_DST"
echo " Categorized as: App"
elif [[ "$LOCATION" == */Music* || "$NAME" == *MP3* ]]; then
DST="$DIR_MUSIC_DST"
echo " Categorized as: Music"
else
echo " Categorized as: Other"
fi
# Make sure destination exists
mkdir -p "$DST"
# Now handle the file copying based on directory structure
if [ -d "$SRC" ]; then
echo " Source path: $SRC"
echo " Destination: $DST"
# Use find to locate specific content files (ignore small files like NFO)
FILES_FOUND=0
echo " Looking for media files or content..."
# Try to find files matching this specific torrent name
NAME_PATTERN=$(echo "$NAME" | cut -d'-' -f1 | tr '.' ' ' | xargs | tr '[:upper:]' '[:lower:]')
NAME_PATTERN=${NAME_PATTERN// /.}
echo " Searching for files matching pattern: $NAME_PATTERN"
# Search for matching files or directories
MATCHING_FILES=()
while IFS= read -r file; do
file_basename=$(basename "$file" | tr '[:upper:]' '[:lower:]')
if [[ "$file_basename" == *"$NAME_PATTERN"* ]]; then
size=$(stat -c%s "$file")
MATCHING_FILES+=("$file")
echo " ✓ Match: $(basename "$file") ($(numfmt --to=iec $size))"
fi
done < <(find "$SRC" -type f -size +10k | sort -rn -k5 | head -n 20)
if [ ${#MATCHING_FILES[@]} -gt 0 ]; then
echo " Found ${#MATCHING_FILES[@]} matching files for this torrent"
# Copy up to 3 matched files
for ((i=0; i<3 && i<${#MATCHING_FILES[@]}; i++)); do
file="${MATCHING_FILES[$i]}"
echo " Copying: $(basename "$file") to $DST/"
cp -v "$file" "$DST/"
FILES_FOUND=$((FILES_FOUND+1))
done
else
echo " No exact matches found - falling back to content type detection"
# Get a list of content files ordered by size (largest first)
while IFS= read -r file; do
extension="${file##*.}"
extension="${extension,,}" # Convert to lowercase
filename=$(basename "$file")
# Skip small files under 1MB (likely not content)
size=$(stat -c%s "$file")
# Only include files based on type
if [[ "$DST" == "$DIR_MOVIES_DST" && "$extension" == @(mkv|mp4|avi) ]]; then
echo " Found movie: $filename (Size: $(numfmt --to=iec $size))"
echo " Copying to $DST/"
cp -v "$file" "$DST/"
FILES_FOUND=$((FILES_FOUND+1))
elif [[ "$DST" == "$DIR_BOOKS_DST" && "$extension" == @(epub|pdf|mobi) ]]; then
echo " Found book: $filename (Size: $(numfmt --to=iec $size))"
echo " Copying to $DST/"
cp -v "$file" "$DST/"
FILES_FOUND=$((FILES_FOUND+1))
elif [[ "$DST" == "$DIR_TV_DST" && "$extension" == @(mkv|mp4|avi) ]]; then
echo " Found TV episode: $filename (Size: $(numfmt --to=iec $size))"
echo " Copying to $DST/"
cp -v "$file" "$DST/"
FILES_FOUND=$((FILES_FOUND+1))
elif [[ "$size" -gt 1000000 ]]; then # 1MB for other content types
echo " Found content: $filename (Size: $(numfmt --to=iec $size))"
echo " Copying to $DST/"
cp -v "$file" "$DST/"
FILES_FOUND=$((FILES_FOUND+1))
fi
# Limit to first 3 content files to avoid excessive copying
if [ $FILES_FOUND -ge 3 ]; then
echo " Reached limit of 3 content files"
break
fi
done < <(find "$SRC" -type f -size +100k | sort -rn -k5 | head -n 10)
fi
if [ $FILES_FOUND -gt 0 ]; then
echo " ✅ Successfully copied $FILES_FOUND files"
# Mark as processed
echo "$HASH" >> /var/log/torrent_processed.log
else
echo " ❌ No suitable content files found"
fi
else
echo " ❌ Source directory not found: $SRC"
fi
echo "------------------------------------------------------"
done
echo "Smart torrent processing completed"
echo "Processed torrents are recorded in /var/log/torrent_processed.log"

481
usr/local/bin/torrent-config Executable file
View File

@@ -0,0 +1,481 @@
#!/bin/bash
#
# Torrent Mover Configuration Utility
# A helper tool to safely update and manage your torrent-mover configuration
set -e
CONFIG_PATH="/etc/torrent/mover.conf"
BACKUP_DIR="/etc/torrent/backups"
DEFAULT_EDITOR="${EDITOR:-nano}"
# Colors for terminal output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[0;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
print_header() {
echo -e "${BLUE}╔════════════════════════════════════════════════════════════╗${NC}"
echo -e "${BLUE}║ Torrent Mover Config Utility ║${NC}"
echo -e "${BLUE}╚════════════════════════════════════════════════════════════╝${NC}"
echo ""
}
usage() {
print_header
echo -e "Usage: ${GREEN}$(basename "$0")${NC} [OPTION]"
echo ""
echo "Options:"
echo -e " ${YELLOW}edit${NC} Edit the configuration file with your default editor"
echo -e " ${YELLOW}backup${NC} Create a backup of the current configuration"
echo -e " ${YELLOW}restore${NC} [file] Restore a previous backup (lists available backups if no file specified)"
echo -e " ${YELLOW}validate${NC} Check the configuration for errors"
echo -e " ${YELLOW}default${NC} Show the default configuration values"
echo -e " ${YELLOW}show${NC} Display the current configuration"
echo -e " ${YELLOW}set${NC} key value Update a specific configuration value"
echo -e " ${YELLOW}get${NC} key Get the value of a specific configuration key"
echo -e " ${YELLOW}help${NC} Display this help message"
echo ""
echo "Examples:"
echo " $(basename "$0") edit # Edit the configuration file"
echo " $(basename "$0") backup # Create a timestamped backup"
echo " $(basename "$0") set COPY_MODE move # Change the copy mode to 'move'"
echo " $(basename "$0") get TRANSMISSION_IP # Show the Transmission server IP"
echo ""
}
# Check if user is root or using sudo
check_permissions() {
if [ "$EUID" -ne 0 ]; then
echo -e "${RED}Error: This command requires root privileges.${NC}"
echo "Please run with sudo:"
echo -e " ${YELLOW}sudo $(basename "$0") $*${NC}"
exit 1
fi
}
# Create a backup of the current configuration
backup_config() {
check_permissions "$@"
if [ ! -f "$CONFIG_PATH" ]; then
echo -e "${RED}Error: Configuration file not found at $CONFIG_PATH${NC}"
exit 1
fi
mkdir -p "$BACKUP_DIR"
local timestamp=$(date +"%Y%m%d_%H%M%S")
local backup_file="$BACKUP_DIR/mover.conf.$timestamp"
cp "$CONFIG_PATH" "$backup_file"
echo -e "${GREEN}Configuration backed up to:${NC} $backup_file"
}
# Restore a configuration from backup
restore_config() {
check_permissions "$@"
if [ ! -d "$BACKUP_DIR" ]; then
echo -e "${RED}Error: Backup directory not found at $BACKUP_DIR${NC}"
exit 1
fi
if [ -z "$1" ]; then
# List available backups
echo -e "${BLUE}Available backups:${NC}"
local count=0
for file in "$BACKUP_DIR"/mover.conf.*; do
if [ -f "$file" ]; then
count=$((count+1))
local date_part=$(basename "$file" | cut -d. -f3)
echo -e "${YELLOW}$count)${NC} $(basename "$file") ($(date -d "${date_part:0:8} ${date_part:9:2}:${date_part:11:2}:${date_part:13:2}" "+%Y-%m-%d %H:%M:%S"))"
fi
done
if [ "$count" -eq 0 ]; then
echo -e "${YELLOW}No backups found.${NC}"
exit 0
fi
echo ""
read -p "Enter the number of the backup to restore: " selection
if ! [[ "$selection" =~ ^[0-9]+$ ]] || [ "$selection" -lt 1 ] || [ "$selection" -gt "$count" ]; then
echo -e "${RED}Error: Invalid selection.${NC}"
exit 1
fi
# Get the filename of the selected backup
local selected_file=$(ls -1 "$BACKUP_DIR"/mover.conf.* | sed -n "${selection}p")
else
# Use the specified backup file
local selected_file="$BACKUP_DIR/$1"
if [ ! -f "$selected_file" ]; then
echo -e "${RED}Error: Backup file not found at $selected_file${NC}"
exit 1
fi
fi
# Create a backup of the current config before restoring
backup_config
# Restore the selected backup
cp "$selected_file" "$CONFIG_PATH"
echo -e "${GREEN}Configuration restored from:${NC} $selected_file"
}
# Edit the configuration file
edit_config() {
check_permissions "$@"
if [ ! -f "$CONFIG_PATH" ]; then
echo -e "${RED}Error: Configuration file not found at $CONFIG_PATH${NC}"
exit 1
fi
# Create a backup before editing
backup_config
# Open in the user's preferred editor
$DEFAULT_EDITOR "$CONFIG_PATH"
# Validate after editing
validate_config
}
# Validate the configuration for errors
validate_config() {
check_permissions "$@"
if [ ! -f "$CONFIG_PATH" ]; then
echo -e "${RED}Error: Configuration file not found at $CONFIG_PATH${NC}"
exit 1
fi
echo -e "${BLUE}Validating configuration file...${NC}"
# Source the config file in a subshell to check for syntax errors
if ! (bash -n "$CONFIG_PATH"); then
echo -e "${RED}Error: The configuration file contains syntax errors.${NC}"
exit 1
fi
# Load the configuration
source "$CONFIG_PATH"
# Check mandatory settings
local required_vars=(
"TRANSMISSION_IP"
"TRANSMISSION_PORT"
"TRANSMISSION_PATH_PREFIX"
"LOCAL_PATH_PREFIX"
"DIR_MOVIES_DST"
"DIR_APPS_DST"
"DIR_GAMES_DST"
"DIR_BOOKS_DST"
"DEFAULT_DST"
"COPY_MODE"
)
local error_count=0
for var in "${required_vars[@]}"; do
if [ -z "${!var}" ]; then
echo -e "${RED}Error: Required setting '$var' is not defined.${NC}"
error_count=$((error_count+1))
fi
done
# Validate COPY_MODE
if [ -n "$COPY_MODE" ] && [ "$COPY_MODE" != "copy" ] && [ "$COPY_MODE" != "move" ]; then
echo -e "${RED}Error: COPY_MODE must be 'copy' or 'move', not '$COPY_MODE'.${NC}"
error_count=$((error_count+1))
fi
# Validate directory paths
local dir_vars=(
"DIR_GAMES_DST"
"DIR_APPS_DST"
"DIR_MOVIES_DST"
"DIR_BOOKS_DST"
"DIR_TV_DST"
"DIR_MUSIC_DST"
"DEFAULT_DST"
)
for var in "${dir_vars[@]}"; do
if [ -n "${!var}" ]; then
if [[ ! "${!var}" == /* ]]; then
echo -e "${RED}Error: Directory path for '$var' must be absolute (start with /).${NC}"
error_count=$((error_count+1))
fi
fi
done
# Check if any pattern in CUSTOM_PATTERNS references undefined variables
if [ -n "$CUSTOM_PATTERNS" ]; then
IFS=';' read -ra PATTERN_ARRAY <<< "$CUSTOM_PATTERNS"
for pattern in "${PATTERN_ARRAY[@]}"; do
IFS='=' read -ra PARTS <<< "$pattern"
if [ "${#PARTS[@]}" -eq 2 ]; then
local dest="${PARTS[1]}"
if [[ "$dest" == *'${'*'}'* ]]; then
local var_name=$(echo "$dest" | sed -n 's/.*\${//;s/}.*//p')
if [ -z "${!var_name}" ]; then
echo -e "${RED}Error: Custom pattern uses undefined variable: \${$var_name}${NC}"
error_count=$((error_count+1))
fi
fi
fi
done
fi
if [ "$error_count" -eq 0 ]; then
echo -e "${GREEN}Configuration validation passed. No errors found.${NC}"
else
echo -e "${RED}Configuration validation failed with $error_count error(s).${NC}"
exit 1
fi
}
# Show the current configuration
show_config() {
if [ ! -f "$CONFIG_PATH" ]; then
echo -e "${RED}Error: Configuration file not found at $CONFIG_PATH${NC}"
exit 1
fi
print_header
echo -e "${BLUE}Current Configuration:${NC}"
echo ""
# Load config and display it categorized
source "$CONFIG_PATH"
echo -e "${YELLOW}=== Connection Settings ===${NC}"
echo -e "TRANSMISSION_IP=${GREEN}${TRANSMISSION_IP:-<not set>}${NC}"
echo -e "TRANSMISSION_PORT=${GREEN}${TRANSMISSION_PORT:-<not set>}${NC}"
if [ -n "$TRANSMISSION_USER" ]; then
echo -e "TRANSMISSION_USER=${GREEN}${TRANSMISSION_USER}${NC}"
echo -e "TRANSMISSION_PASSWORD=${GREEN}********${NC}"
else
echo -e "TRANSMISSION_USER=${YELLOW}<not set>${NC}"
echo -e "TRANSMISSION_PASSWORD=${YELLOW}<not set>${NC}"
fi
echo -e "TRANSMISSION_PATH_PREFIX=${GREEN}${TRANSMISSION_PATH_PREFIX:-<not set>}${NC}"
echo -e "LOCAL_PATH_PREFIX=${GREEN}${LOCAL_PATH_PREFIX:-<not set>}${NC}"
echo ""
echo -e "${YELLOW}=== Destination Directories ===${NC}"
echo -e "DIR_GAMES_DST=${GREEN}${DIR_GAMES_DST:-<not set>}${NC}"
echo -e "DIR_APPS_DST=${GREEN}${DIR_APPS_DST:-<not set>}${NC}"
echo -e "DIR_MOVIES_DST=${GREEN}${DIR_MOVIES_DST:-<not set>}${NC}"
echo -e "DIR_BOOKS_DST=${GREEN}${DIR_BOOKS_DST:-<not set>}${NC}"
echo -e "DIR_TV_DST=${GREEN}${DIR_TV_DST:-<not set>}${NC}"
echo -e "DIR_MUSIC_DST=${GREEN}${DIR_MUSIC_DST:-<not set>}${NC}"
echo -e "DEFAULT_DST=${GREEN}${DEFAULT_DST:-<not set>}${NC}"
echo ""
echo -e "${YELLOW}=== Additional Storage Libraries ===${NC}"
echo -e "STORAGE_DIRS=${GREEN}${STORAGE_DIRS:-<not set>}${NC}"
echo -e "STORAGE_TV_DIRS=${GREEN}${STORAGE_TV_DIRS:-<not set>}${NC}"
echo ""
echo -e "${YELLOW}=== Security Settings ===${NC}"
echo -e "TORRENT_USER=${GREEN}${TORRENT_USER:-debian-transmission}${NC}"
echo -e "TORRENT_GROUP=${GREEN}${TORRENT_GROUP:-debian-transmission}${NC}"
echo ""
echo -e "${YELLOW}=== Performance Settings ===${NC}"
echo -e "PARALLEL_THREADS=${GREEN}${PARALLEL_THREADS:-$(nproc)}${NC}"
echo -e "PARALLEL_PROCESSING=${GREEN}${PARALLEL_PROCESSING:-1}${NC}"
echo -e "COPY_MODE=${GREEN}${COPY_MODE:-<not set>}${NC}"
echo ""
echo -e "${YELLOW}=== Error Recovery ===${NC}"
echo -e "MAX_RETRY_ATTEMPTS=${GREEN}${MAX_RETRY_ATTEMPTS:-3}${NC}"
echo -e "RETRY_WAIT_TIME=${GREEN}${RETRY_WAIT_TIME:-15}${NC}"
echo ""
echo -e "${YELLOW}=== Logging & Integrity ===${NC}"
echo -e "LOG_FILE=${GREEN}${LOG_FILE:-/var/log/torrent_mover.log}${NC}"
echo -e "LOG_LEVEL=${GREEN}${LOG_LEVEL:-INFO}${NC}"
echo -e "USE_SYSLOG=${GREEN}${USE_SYSLOG:-false}${NC}"
echo -e "PROCESSED_LOG=${GREEN}${PROCESSED_LOG:-/var/log/torrent_processed.log}${NC}"
echo -e "CHECKSUM_DB=${GREEN}${CHECKSUM_DB:-/var/lib/torrent/checksums.db}${NC}"
echo -e "CHECK_TRANSFER_INTEGRITY=${GREEN}${CHECK_TRANSFER_INTEGRITY:-true}${NC}"
echo ""
if [ -n "$CUSTOM_PATTERNS" ]; then
echo -e "${YELLOW}=== Custom Content Patterns ===${NC}"
IFS=';' read -ra PATTERN_ARRAY <<< "$CUSTOM_PATTERNS"
for pattern in "${PATTERN_ARRAY[@]}"; do
if [ -n "$pattern" ]; then
IFS='=' read -ra PARTS <<< "$pattern"
if [ "${#PARTS[@]}" -eq 2 ]; then
local regex="${PARTS[0]}"
local dest="${PARTS[1]}"
echo -e "Pattern: ${GREEN}${regex}${NC} → ${BLUE}${dest}${NC}"
fi
fi
done
echo ""
fi
}
# Update a specific configuration value
set_config_value() {
check_permissions "$@"
if [ -z "$1" ] || [ -z "$2" ]; then
echo -e "${RED}Error: Both key and value must be provided.${NC}"
echo "Usage: $(basename "$0") set KEY VALUE"
exit 1
fi
local key="$1"
local value="$2"
if [ ! -f "$CONFIG_PATH" ]; then
echo -e "${RED}Error: Configuration file not found at $CONFIG_PATH${NC}"
exit 1
fi
# Create a backup before modifying
backup_config
# Check if the key already exists in the config
if grep -q "^$key=" "$CONFIG_PATH"; then
# Update the existing key
sed -i "s|^$key=.*|$key=\"$value\"|" "$CONFIG_PATH"
echo -e "${GREEN}Updated configuration:${NC} $key = \"$value\""
else
# Add the new key
echo "$key=\"$value\"" >> "$CONFIG_PATH"
echo -e "${GREEN}Added new configuration:${NC} $key = \"$value\""
fi
# Validate after updating
validate_config
}
# Get a specific configuration value
get_config_value() {
if [ -z "$1" ]; then
echo -e "${RED}Error: Key must be provided.${NC}"
echo "Usage: $(basename "$0") get KEY"
exit 1
fi
local key="$1"
if [ ! -f "$CONFIG_PATH" ]; then
echo -e "${RED}Error: Configuration file not found at $CONFIG_PATH${NC}"
exit 1
fi
# Source the config file to get the value
source "$CONFIG_PATH"
if [ -n "${!key+x}" ]; then
echo -e "${key}=${GREEN}${!key}${NC}"
else
echo -e "${RED}Error: Configuration key '$key' is not defined.${NC}"
exit 1
fi
}
# Show default configuration options
show_default_config() {
print_header
echo -e "${BLUE}Default Configuration Values:${NC}"
echo ""
cat << EOF
# Transmission Settings
TRANSMISSION_IP="192.168.1.100"
TRANSMISSION_PORT="9091"
TRANSMISSION_USER=""
TRANSMISSION_PASSWORD=""
# Path Mapping
TRANSMISSION_PATH_PREFIX="/downloads"
LOCAL_PATH_PREFIX="/mnt/data"
# Destination Directories
DIR_GAMES_DST="/mnt/media/Games"
DIR_APPS_DST="/mnt/media/Apps"
DIR_MOVIES_DST="/mnt/media/Movies"
DIR_BOOKS_DST="/mnt/media/Books"
DIR_TV_DST="/mnt/media/TV"
DIR_MUSIC_DST="/mnt/media/Music"
DEFAULT_DST="/mnt/media/Other"
# Additional Storage
STORAGE_DIRS=""
STORAGE_TV_DIRS=""
# Security
TORRENT_USER="torrent-mover"
TORRENT_GROUP="torrent-mover"
# Error Recovery
MAX_RETRY_ATTEMPTS="3"
RETRY_WAIT_TIME="15"
# Performance
PARALLEL_THREADS="$(nproc)"
PARALLEL_PROCESSING="1"
COPY_MODE="copy"
# Logging & Integrity
LOG_FILE="/var/log/torrent_mover.log"
LOG_LEVEL="INFO"
USE_SYSLOG="false"
PROCESSED_LOG="/var/log/torrent_processed.log"
CHECKSUM_DB="/var/lib/torrent/checksums.db"
CHECK_TRANSFER_INTEGRITY="true"
# Custom Content Patterns
CUSTOM_PATTERNS=".*documentary.*=\${DIR_MOVIES_DST}/Documentary;.*anime.*=\${DIR_TV_DST}/Anime"
EOF
echo ""
}
# Main command processing
case "$1" in
edit)
edit_config "${@:2}"
;;
backup)
backup_config "${@:2}"
;;
restore)
restore_config "${@:2}"
;;
validate)
validate_config "${@:2}"
;;
show)
show_config
;;
set)
set_config_value "${@:2}"
;;
get)
get_config_value "${@:2}"
;;
default)
show_default_config
;;
help|--help|-h)
usage
;;
*)
usage
exit 1
;;
esac
exit 0

View File

@@ -1,14 +1,16 @@
#!/bin/bash #!/bin/bash
# Torrent Mover v7.2 - Enhanced & Robust Version with Directory Deduplication, # Torrent Mover v8.0 - Enhanced & Robust Version with modular architecture,
# Improved Archive Handling (keeping archives until ratio limits are reached) # improved error handling, security, and content categorization
# #
# This script processes completed torrents reported by Transmission, # This script processes completed torrents reported by Transmission,
# moving or copying files to designated destination directories. # moving or copying files to designated destination directories.
# It includes robust locking, advanced error handling & notifications, # It includes robust locking, advanced error handling & notifications,
# improved logging, optional post-transfer integrity checks, configurable path mapping, # improved logging, optional post-transfer integrity checks, configurable path mapping,
# and improved archive extraction that preserves directory structure. # and improved archive extraction that preserves directory structure.
#
# Future improvements might include using Transmissions RPC API. # Set script location for importing modules
SCRIPT_DIR="$(dirname "$(readlink -f "$0")")"
LIB_DIR="/usr/local/lib/torrent-mover"
############################## ##############################
# Robust Locking with flock # # Robust Locking with flock #
@@ -17,52 +19,6 @@ LOCK_FILE="/var/lock/torrent-mover.lock"
exec 200>"${LOCK_FILE}" || { echo "Cannot open lock file" >&2; exit 1; } exec 200>"${LOCK_FILE}" || { echo "Cannot open lock file" >&2; exit 1; }
flock -n 200 || { echo "Another instance is running." >&2; exit 1; } flock -n 200 || { echo "Another instance is running." >&2; exit 1; }
##############################
# Global Runtime Variables #
##############################
DRY_RUN=0
INTERACTIVE=0
CACHE_WARMUP=0
DEBUG=0 # Set to 1 if LOG_LEVEL is DEBUG or --debug is passed
# To avoid reprocessing the same source directory (across different torrents)
declare -A processed_source_dirs
####################
# Logging Functions#
####################
# All log messages go to stderr.
log_debug() {
if [[ "${DEBUG}" -eq 1 ]]; then
echo -e "[DEBUG] $(date '+%F %T') - $*" | tee -a "${LOG_FILE}" >&2
[[ "${USE_SYSLOG}" == "true" ]] && logger -t torrent-mover "[DEBUG] $*"
fi
}
log_info() {
echo -e "[INFO] $(date '+%F %T') - $*" | tee -a "${LOG_FILE}" >&2
[[ "${USE_SYSLOG}" == "true" ]] && logger -t torrent-mover "[INFO] $*"
}
log_warn() {
echo -e "[WARN] $(date '+%F %T') - $*" | tee -a "${LOG_FILE}" >&2
[[ "${USE_SYSLOG}" == "true" ]] && logger -t torrent-mover "[WARN] $*"
}
log_error() {
echo -e "[ERROR] $(date '+%F %T') - $*" | tee -a "${LOG_FILE}" >&2
[[ "${USE_SYSLOG}" == "true" ]] && logger -t torrent-mover "[ERROR] $*"
}
#################################
# Error Handling & Notifications#
#################################
error_handler() {
local lineno="$1"
local msg="$2"
log_error "Error on line ${lineno}: ${msg}"
# Optionally send a notification (e.g., email)
exit 1
}
trap 'error_handler ${LINENO} "$BASH_COMMAND"' ERR
############################## ##############################
# Configuration & Validation # # Configuration & Validation #
############################## ##############################
@@ -79,6 +35,20 @@ if [[ -z "${TRANSMISSION_PATH_PREFIX:-}" || -z "${LOCAL_PATH_PREFIX:-}" ]]; then
exit 1 exit 1
fi fi
# Load modules
for module in "${LIB_DIR}"/*.sh; do
if [[ -f "$module" ]]; then
source "$module"
fi
done
# Set defaults for new configuration options
TORRENT_USER="${TORRENT_USER:-debian-transmission}"
TORRENT_GROUP="${TORRENT_GROUP:-debian-transmission}"
MAX_RETRY_ATTEMPTS="${MAX_RETRY_ATTEMPTS:-3}"
RETRY_WAIT_TIME="${RETRY_WAIT_TIME:-15}"
# Enable DEBUG mode if set in config
if [[ "${LOG_LEVEL}" == "DEBUG" ]]; then if [[ "${LOG_LEVEL}" == "DEBUG" ]]; then
DEBUG=1 DEBUG=1
fi fi
@@ -89,313 +59,10 @@ if [[ -n "${STORAGE_DIRS}" ]]; then
IFS=',' read -ra STORAGE_DIRS_ARRAY <<< "${STORAGE_DIRS}" IFS=',' read -ra STORAGE_DIRS_ARRAY <<< "${STORAGE_DIRS}"
fi fi
############################## #################################
# Helper & Utility Functions # # Error Handling & Notifications#
############################## #################################
trap 'error_handler ${LINENO} "$BASH_COMMAND"' ERR
# translate_source: Converts the Transmissionreported path into the local path.
translate_source() {
local src="$1"
echo "${src/#${TRANSMISSION_PATH_PREFIX}/${LOCAL_PATH_PREFIX}}"
}
# parse_args: Processes commandline options.
parse_args() {
while [[ $# -gt 0 ]]; do
case "$1" in
--dry-run) DRY_RUN=1; shift ;;
--interactive) INTERACTIVE=1; shift ;;
--cache-warmup) CACHE_WARMUP=1; shift ;;
--debug) DEBUG=1; shift ;;
--help)
echo "Usage: $0 [--dry-run] [--interactive] [--cache-warmup] [--debug]" >&2
exit 0
;;
*) echo "Invalid option: $1" >&2; exit 1 ;;
esac
done
}
# check_dependencies: Ensures required commands are available.
check_dependencies() {
local deps=("transmission-remote" "unrar" "unzip" "7z" "parallel" "bc")
for dep in "${deps[@]}"; do
command -v "${dep}" >/dev/null 2>&1 || { log_error "Missing dependency: ${dep}"; exit 1; }
done
}
# check_disk_usage: Warn if disk usage is over 90%.
declare -A CHECKED_MOUNTS=()
check_disk_usage() {
local dir="$1"
[[ -z "${dir}" ]] && return
if ! df -P "${dir}" &>/dev/null; then
log_warn "Directory not found: ${dir}"
return
fi
local mount_point
mount_point=$(df -P "${dir}" | awk 'NR==2 {print $6}')
[[ -z "${mount_point}" ]] && return
if [[ -z "${CHECKED_MOUNTS["${mount_point}"]+x}" ]]; then
local usage
usage=$(df -P "${dir}" | awk 'NR==2 {sub(/%/, "", $5); print $5}')
if (( usage >= 90 )); then
log_warn "Storage warning: ${mount_point} at ${usage}% capacity"
fi
CHECKED_MOUNTS["${mount_point}"]=1
fi
}
# init_checksum_db: Initializes the checksum database.
init_checksum_db() {
mkdir -p "$(dirname "${CHECKSUM_DB}")"
touch "${CHECKSUM_DB}" || { log_error "Could not create ${CHECKSUM_DB}"; exit 1; }
chmod 600 "${CHECKSUM_DB}"
}
# record_checksums: Generates checksums for files in given directories.
record_checksums() {
log_info "Generating checksums with ${PARALLEL_THREADS:-$(nproc)} threads"
find "$@" -type f ! \( -iname "*.nfo" -o -iname "*.sfv" \) -print0 | \
parallel -0 -j ${PARALLEL_THREADS:-$(nproc)} md5sum | sort > "${CHECKSUM_DB}.tmp"
mv "${CHECKSUM_DB}.tmp" "${CHECKSUM_DB}"
}
# file_metadata: Returns an md5 hash for file metadata.
file_metadata() {
find "$1" -type f ! \( -iname "*.nfo" -o -iname "*.sfv" \) -exec md5sum {} \; | sort | awk '{print $1}'
}
# files_need_processing: Checks if the source files need processing.
files_need_processing() {
local src="$1"
shift
local targets=("$@")
if [[ ! -d "${src}" ]]; then
log_warn "Source directory missing: ${src}"
return 1
fi
log_info "=== FILE VERIFICATION DEBUG START ==="
log_info "Source directory: ${src}"
log_info "Verification targets: ${targets[*]}"
local empty_target_found=0
for target in "${targets[@]}"; do
if [[ ! -d "${target}" ]]; then
log_info "Target missing: ${target}"
empty_target_found=1
continue
fi
local file_count
file_count=$(find "${target}" -mindepth 1 -maxdepth 1 -print | wc -l)
log_debug "File count for target ${target}: ${file_count}"
if [[ "${file_count}" -eq 0 ]]; then
log_info "Empty target directory: ${target}"
empty_target_found=1
else
log_info "Target contains ${file_count} items: ${target}"
log_info "First 5 items:"
find "${target}" -mindepth 1 -maxdepth 1 | head -n 5 | while read -r item; do
log_info " - ${item##*/}"
done
fi
done
if [[ "${empty_target_found}" -eq 1 ]]; then
log_info "Empty target detected - processing needed"
log_info "=== FILE VERIFICATION DEBUG END ==="
return 0
fi
log_info "Generating source checksums..."
local src_checksums
src_checksums=$(find "${src}" -type f ! \( -iname "*.nfo" -o -iname "*.sfv" \) -exec md5sum {} \; | sort)
log_info "First 5 source checksums:"
echo "${src_checksums}" | head -n 5 | while read -r line; do
log_info " ${line}"
done
local match_found=0
for target in "${targets[@]}"; do
log_info "Checking against target: ${target}"
log_info "Generating target checksums..."
local target_checksums
target_checksums=$(find "${target}" -type f ! \( -iname "*.nfo" -o -iname "*.sfv" \) -exec md5sum {} \; | sort)
log_info "First 5 target checksums:"
echo "${target_checksums}" | head -n 5 | while read -r line; do
log_info " ${line}"
done
if diff <(echo "${src_checksums}") <(echo "${target_checksums}") >/dev/null; then
log_info "Exact checksum match found in: ${target}"
match_found=1
break
else
log_info "No match in: ${target}"
fi
done
log_info "=== FILE VERIFICATION DEBUG END ==="
[[ "${match_found}" -eq 1 ]] && return 1 || return 0
}
# warm_cache: Pre-calculates checksums for storage directories.
warm_cache() {
log_info "Starting cache warmup for Movies..."
local targets=("${DIR_MOVIES_DST}" "${STORAGE_DIRS_ARRAY[@]}")
record_checksums "${targets[@]}"
log_info "Cache warmup completed. Checksums stored in ${CHECKSUM_DB}"
}
# is_processed: Checks if the torrent (by hash) has already been processed.
is_processed() {
grep -q "^${1}$" "${PROCESSED_LOG}" 2>/dev/null
}
# mark_processed: Records a processed torrent.
mark_processed() {
echo "${1}" >> "${PROCESSED_LOG}"
}
# get_destination: Maps a source directory to a destination directory based on keywords.
declare -A PATH_CACHE
get_destination() {
local source_path="$1"
if [[ -n "${PATH_CACHE["${source_path}"]+x}" ]]; then
echo "${PATH_CACHE["${source_path}"]}"
return
fi
log_info "Analyzing path: ${source_path}"
local destination
case "${source_path,,}" in
*games*) destination="${DIR_GAMES_DST}";;
*apps*) destination="${DIR_APPS_DST}";;
*movies*) destination="${DIR_MOVIES_DST}";;
*books*) destination="${DIR_BOOKS_DST}";;
*) destination="${DEFAULT_DST}";;
esac
log_info "Mapped to: ${destination}"
PATH_CACHE["${source_path}"]="${destination}"
echo "${destination}"
}
######################################
# Improved Archive Extraction Handler #
######################################
# For each archive found in the source directory, create a subdirectory in the destination
# named after the archive (without its extension) and extract into that subdirectory.
# IMPORTANT: The archive is now retained in the source, so it will remain until the ratio
# limits are reached and Transmission removes the torrent data.
handle_archives() {
local src="$1" dst="$2"
find "${src}" -type f \( -iname "*.rar" -o -iname "*.zip" -o -iname "*.7z" \) | while read -r arch; do
log_info "Extracting archive: ${arch}"
local base
base=$(basename "${arch}")
local subdir="${dst}/${base%.*}"
mkdir -p "${subdir}" || { log_error "Failed to create subdirectory ${subdir}"; continue; }
case "${arch##*.}" in
rar)
unrar x -o- "${arch}" "${subdir}" || { log_error "unrar failed for ${arch}"; continue; }
;;
zip)
unzip -o "${arch}" -d "${subdir}" || { log_error "unzip failed for ${arch}"; continue; }
;;
7z)
7z x "${arch}" -o"${subdir}" || { log_error "7z extraction failed for ${arch}"; continue; }
;;
esac
log_info "Archive ${arch} retained in source until ratio limits are reached."
done
}
# move_files: Moves files using parallel processing if enabled.
move_files() {
if (( PARALLEL_PROCESSING )); then
parallel -j ${PARALLEL_THREADS:-$(nproc)} mv {} "${1}" ::: "${2}"/*
else
mv "${2}"/* "${1}"
fi
}
# copy_files: Copies files using parallel processing if enabled.
copy_files() {
if (( PARALLEL_PROCESSING )); then
parallel -j ${PARALLEL_THREADS:-$(nproc)} cp -r {} "${1}" ::: "${2}"/*
else
cp -r "${2}"/* "${1}"
fi
}
# process_copy: Validates directories, then copies/moves files from source to destination.
# Optionally verifies integrity after transfer if CHECK_TRANSFER_INTEGRITY is "true".
process_copy() {
local id="$1" hash="$2" src="$3" dst="$4"
if [[ ! -d "${src}" ]]; then
log_error "Source directory missing: ${src}"
return 1
fi
if [[ ! -d "${dst}" ]]; then
log_info "Creating destination directory: ${dst}"
mkdir -p "${dst}" || { log_error "Failed to create directory: ${dst}"; return 1; }
chmod 775 "${dst}"
chown debian-transmission:debian-transmission "${dst}"
fi
if [[ ! -w "${dst}" ]]; then
log_error "No write permissions for: ${dst}"
return 1
fi
if (( DRY_RUN )); then
log_info "[DRY RUN] Would process torrent ${id}:"
log_info " - Copy files from ${src} to ${dst}"
log_info " - File count: $(find "${src}" -maxdepth 1 -type f | wc -l)"
return
fi
handle_archives "${src}" "${dst}"
case "${COPY_MODE}" in
move)
log_info "Moving files from ${src} to ${dst}"
move_files "${dst}" "${src}"
;;
copy)
log_info "Copying files from ${src} to ${dst}"
copy_files "${dst}" "${src}"
;;
esac
if [ $? -eq 0 ]; then
if [[ "${CHECK_TRANSFER_INTEGRITY}" == "true" ]]; then
log_info "Verifying integrity of transferred files..."
local src_checksum target_checksum
src_checksum=$(find "${src}" -type f ! \( -iname "*.nfo" -o -iname "*.sfv" \) -exec md5sum {} \; | sort)
target_checksum=$(find "${dst}" -type f ! \( -iname "*.nfo" -o -iname "*.sfv" \) -exec md5sum {} \; | sort)
if diff <(echo "${src_checksum}") <(echo "${target_checksum}") >/dev/null; then
log_info "Integrity check passed."
else
log_error "Integrity check FAILED for ${src}"
return 1
fi
fi
log_info "Transfer completed successfully"
mark_processed "${hash}"
else
log_error "Transfer failed for ${src}"
fi
}
# process_removal: Removes a torrent via Transmission.
process_removal() {
local id="$1"
if (( DRY_RUN )); then
log_info "[DRY RUN] Would remove torrent ${id}"
return
fi
transmission-remote "${TRANSMISSION_IP}:${TRANSMISSION_PORT}" \
-n "${TRANSMISSION_USER}:${TRANSMISSION_PASSWORD}" \
-t "${id}" --remove-and-delete
}
################# #################
# Main Function # # Main Function #
@@ -411,17 +78,32 @@ main() {
"${DIR_BOOKS_DST}" "${DIR_BOOKS_DST}"
"${DEFAULT_DST}" "${DEFAULT_DST}"
) )
# Add optional directories if defined
[[ -n "${DIR_TV_DST}" ]] && REQUIRED_DIRS+=("${DIR_TV_DST}")
[[ -n "${DIR_MUSIC_DST}" ]] && REQUIRED_DIRS+=("${DIR_MUSIC_DST}")
# Create required directories if they don't exist
log_info "Creating required directories if they don't exist..."
for dir in "${REQUIRED_DIRS[@]}"; do for dir in "${REQUIRED_DIRS[@]}"; do
if [[ ! -d "${dir}" ]]; then if [[ -n "$dir" ]]; then
log_error "Directory missing: ${dir}" if [[ ! -d "$dir" ]]; then
exit 1 log_info "Creating directory: $dir"
if mkdir -p "$dir"; then
# Try to set permissions but don't fail if it doesn't work
chmod 775 "$dir" 2>/dev/null || log_warn "Could not set permissions on $dir"
chown ${TORRENT_USER:-debian-transmission}:${TORRENT_GROUP:-debian-transmission} "$dir" 2>/dev/null || log_warn "Could not set ownership on $dir"
log_info "Created directory: $dir"
else
log_error "Failed to create directory: $dir"
fi
fi fi
if [[ ! -w "${dir}" ]]; then
log_error "Write permission denied: ${dir}"
exit 1
fi fi
done done
# Now validate that all required directories exist and are writable
validate_directories "${REQUIRED_DIRS[@]}" || exit 1
init_checksum_db init_checksum_db
if (( CACHE_WARMUP )); then if (( CACHE_WARMUP )); then
@@ -429,14 +111,36 @@ main() {
exit 0 exit 0
fi fi
log_info "Starting processing" log_info "Starting processing with user: ${TORRENT_USER}"
declare -A warned_dirs=() declare -A warned_dirs=()
transmission-remote "${TRANSMISSION_IP}:${TRANSMISSION_PORT}" \
-n "${TRANSMISSION_USER}:${TRANSMISSION_PASSWORD}" -l | awk 'NR>1 && $1 ~ /^[0-9]+$/ {print $1}' | while read -r id; do
# Get list of torrents from Transmission
log_debug "Getting list of torrents..."
local torrent_ids
torrent_ids=$(get_torrents)
log_info "Found $(echo "$torrent_ids" | wc -l) torrents"
# Use a regular for loop instead of a pipe to while
# to avoid the subshell issue that causes processed_source_dirs to be lost
readarray -t torrent_ids_array <<< "$torrent_ids"
# Print the torrent IDs to debug (always, not just in debug mode)
if [[ ${#torrent_ids_array[@]} -eq 0 ]]; then
log_info "No torrents found to process"
else
log_info "Torrent IDs to process: ${torrent_ids_array[*]}"
fi
for id in "${torrent_ids_array[@]}"; do
# Skip empty IDs
if [[ -z "$id" ]]; then
log_debug "Skipping empty torrent ID"
continue
fi
log_debug "Processing torrent ID: $id"
local info local info
info=$(transmission-remote "${TRANSMISSION_IP}:${TRANSMISSION_PORT}" \ info=$(get_torrent_info "${id}")
-n "${TRANSMISSION_USER}:${TRANSMISSION_PASSWORD}" -t "${id}" -i)
local hash local hash
hash=$(grep "Hash:" <<< "${info}" | awk '{print $2}') hash=$(grep "Hash:" <<< "${info}" | awk '{print $2}')
local ratio local ratio
@@ -452,26 +156,87 @@ main() {
# Extract Transmission-reported directory and translate to local path. # Extract Transmission-reported directory and translate to local path.
local reported_dir local reported_dir
reported_dir=$(grep -i "Location:" <<< "${info}" | awk -F": " '{print $2}' | xargs) reported_dir=$(grep -i "Location:" <<< "${info}" | awk -F": " '{print $2}' | xargs)
log_debug "Raw reported directory: '${reported_dir}'"
# If the reported directory is empty, try to derive it from the name
if [[ -z "${reported_dir}" ]]; then
local name
name=$(grep -i "Name:" <<< "${info}" | awk -F": " '{print $2}' | xargs)
log_debug "Torrent name: '${name}'"
# Check if there are labels we can use
local labels
labels=$(grep -i "Labels:" <<< "${info}" | awk -F": " '{print $2}' | xargs)
log_debug "Torrent labels: '${labels}'"
if [[ "${labels}" == *"Books"* ]]; then
reported_dir="/downloads/Books"
elif [[ "${labels}" == *"Movies"* ]]; then
reported_dir="/downloads/Movies"
elif [[ "${labels}" == *"TV"* ]]; then
reported_dir="/downloads/TV"
elif [[ "${labels}" == *"Games"* ]]; then
reported_dir="/downloads/Games"
elif [[ "${labels}" == *"Apps"* ]]; then
reported_dir="/downloads/Apps"
elif [[ "${labels}" == *"Music"* ]]; then
reported_dir="/downloads/Music"
else
# Default to Other if we can't determine
reported_dir="/downloads/Other"
fi
log_debug "Derived directory from labels: '${reported_dir}'"
fi
local dir local dir
dir=$(translate_source "${reported_dir}") dir=$(translate_source "${reported_dir}")
log_info "Torrent source directory reported: '${reported_dir}' translated to '${dir}'" log_info "Torrent source directory: '${reported_dir}' translated to '${dir}'"
# Initialize empty directory mapping if needed
if [[ -z "$dir" ]]; then
log_warn "Empty directory path detected, using default"
dir="${LOCAL_PATH_PREFIX}/Other"
fi
local dst local dst
dst=$(get_destination "${dir}") dst=$(get_destination "${dir}")
# Detect same-path mappings (different mounts)
if [[ "${dir}" != "${dst}" && "${dir}" =~ ^/mnt/dsnas2/ && "${dst}" =~ ^/mnt/dsnas1/ ]]; then
local dir_suffix="${dir#/mnt/dsnas2/}"
local dst_suffix="${dst#/mnt/dsnas1/}"
if [[ "${dir_suffix}" == "${dst_suffix}" ]]; then
log_info "Source and destination are the same logical location with different mounts: ${dir_suffix}"
mark_processed "${hash}"
continue # Skip to next torrent
fi
fi
# Initialize warned_dirs for this directory if needed
if [[ -n "${dir}" ]]; then
[[ -z "${warned_dirs["${dir}"]+x}" ]] && warned_dirs["${dir}"]=0 [[ -z "${warned_dirs["${dir}"]+x}" ]] && warned_dirs["${dir}"]=0
fi
# Avoid processing the same directory more than once. # Avoid processing the same directory more than once.
if [[ -n "${processed_source_dirs["${dir}"]+x}" ]]; then if [[ -n "${processed_source_dirs["${dir}"]+x}" ]]; then
log_info "Directory ${dir} has already been processed; skipping copy for torrent ${id}" log_info "Directory ${dir} has already been processed; skipping copy for torrent ${id}"
elif (( $(bc <<< "${percent_done} >= 100") )) && ! is_processed "${hash}"; then elif (( $(bc <<< "${percent_done} >= 100") )) && ! is_processed "${hash}"; then
log_info "Processing completed torrent ${id} (${percent_done}% done)" log_info "Processing completed torrent ${id} (${percent_done}% done)"
if [[ "${dst}" == "${DEFAULT_DST}" ]] && (( warned_dirs["${dir}"] == 0 )); then if [[ "${dst}" == "${DEFAULT_DST}" ]] && [[ -n "${dir}" ]] && (( warned_dirs["${dir}"] == 0 )); then
log_warn "Using default destination for: ${dir}" log_warn "Using default destination for: ${dir}"
warned_dirs["${dir}"]=1 warned_dirs["${dir}"]=1
fi fi
local targets=("${dst}") local targets=("${dst}")
case "${dst}" in case "${dst}" in
"${DIR_MOVIES_DST}") targets+=("${STORAGE_DIRS_ARRAY[@]}");; "${DIR_MOVIES_DST}")
targets+=("${STORAGE_DIRS_ARRAY[@]}")
;;
"${DIR_TV_DST}")
# If there are TV storage dirs, include them
[[ -n "${STORAGE_TV_DIRS}" ]] && IFS=',' read -ra TV_DIRS <<< "${STORAGE_TV_DIRS}" && targets+=("${TV_DIRS[@]}")
;;
esac esac
if ! files_need_processing "${dir}" "${targets[@]}"; then if ! files_need_processing "${dir}" "${targets[@]}"; then
log_info "Skipping copy - files already exist in:" log_info "Skipping copy - files already exist in:"
for target in "${targets[@]}"; do for target in "${targets[@]}"; do
@@ -489,11 +254,21 @@ main() {
fi fi
done done
check_disk_usage "${DIR_GAMES_DST}" # Print count of processed directories
check_disk_usage "${DIR_APPS_DST}" if [[ "${DEBUG}" -eq 1 ]]; then
check_disk_usage "${DIR_MOVIES_DST}" log_debug "Processed source directories count: ${#processed_source_dirs[@]}"
check_disk_usage "${DIR_BOOKS_DST}" for dir in "${!processed_source_dirs[@]}"; do
check_disk_usage "${DEFAULT_DST}" log_debug "Processed directory: $dir"
done
fi
# Check disk usage for all directories
for dir in "${REQUIRED_DIRS[@]}"; do
check_disk_usage "${dir}"
done
for dir in "${STORAGE_DIRS_ARRAY[@]}"; do
check_disk_usage "${dir}"
done
} }
###################### ######################

368
usr/local/bin/torrent-processor Executable file
View File

@@ -0,0 +1,368 @@
#\!/bin/bash
# Source configuration
source /etc/torrent/mover.conf
# Create destination directories
mkdir -p /mnt/dsnas1/{Books,Movies,TV,Games,Apps,Music,Other}
# Function to display help
show_help() {
echo "Torrent Processor - Helper for torrent-mover"
echo ""
echo "Usage: $0 [OPTIONS]"
echo ""
echo "Options:"
echo " --reset Clear processed log to re-process all torrents"
echo " --books Process only book torrents"
echo " --movies Process only movie torrents"
echo " --tv Process only TV show torrents"
echo " --apps Process only application torrents"
echo " --games Process only game torrents"
echo " --id NUMBER Process a specific torrent ID"
echo " --help Show this help message"
echo ""
echo "Examples:"
echo " $0 --reset --books Process all book torrents (even if previously processed)"
echo " $0 --id 123 Process only torrent with ID 123"
echo ""
}
# Parse command line options
RESET=0
CATEGORY=""
TORRENT_ID=""
while [[ $# -gt 0 ]]; do
key="$1"
case $key in
--reset)
RESET=1
shift
;;
--books)
CATEGORY="books"
shift
;;
--movies)
CATEGORY="movies"
shift
;;
--tv)
CATEGORY="tv"
shift
;;
--apps)
CATEGORY="apps"
shift
;;
--games)
CATEGORY="games"
shift
;;
--id)
TORRENT_ID="$2"
shift
shift
;;
--help)
show_help
exit 0
;;
*)
echo "Unknown option: $key"
show_help
exit 1
;;
esac
done
# Reset processed log if requested
if [ $RESET -eq 1 ]; then
echo "Clearing processed log to re-process all torrents"
> /var/log/torrent_processed.log
fi
# Remove lock file if it exists
rm -f /var/lock/torrent-mover.lock
# Run torrent-mover based on options
if [ -n "$TORRENT_ID" ]; then
echo "Processing torrent ID: $TORRENT_ID"
# Get torrent details
info=$(transmission-remote "${TRANSMISSION_IP}:${TRANSMISSION_PORT}" \
--auth "${TRANSMISSION_USER}:${TRANSMISSION_PASSWORD}" \
--torrent $TORRENT_ID --info)
name=$(echo "$info" | grep "Name:" | awk -F": " '{print $2}' | xargs)
echo "Torrent name: $name"
# Run torrent-mover with specific torrent ID
torrent_id="$TORRENT_ID"
# Check if output directory exists for this torrent
output_dir=$(grep "Location:" <<< "$info" | awk -F": " '{print $2}' | xargs)
if [[ -n "$output_dir" ]]; then
echo "Torrent location: $output_dir"
fi
# We need to modify torrent-mover to handle single IDs
# For now, we'll write a small temporary script to process just this ID
TMP_SCRIPT=$(mktemp)
cat > "$TMP_SCRIPT" << EOF
#!/bin/bash
source /etc/torrent/mover.conf
source /usr/local/lib/torrent-mover/common.sh
source /usr/local/lib/torrent-mover/file_operations.sh
source /usr/local/lib/torrent-mover/transmission_handler.sh
source /usr/local/lib/torrent-mover/archive_handler.sh
# Set debug mode
DEBUG=1
# Process just this one torrent
process_single_torrent() {
local id="\$1"
log_debug "Processing single torrent ID: \$id"
# Get torrent info
local info cmd
cmd="transmission-remote \${TRANSMISSION_IP}:\${TRANSMISSION_PORT} -n \${TRANSMISSION_USER}:\${TRANSMISSION_PASSWORD} -t \${id} -i"
info=\$(eval "\$cmd")
if [[ -z "\$info" ]]; then
log_error "Failed to get info for torrent \$id"
return 1
fi
# Process torrent info just like in the main script
local hash
hash=\$(grep "Hash:" <<< "\${info}" | awk '{print \$2}')
local ratio
ratio=\$(grep "Ratio:" <<< "\${info}" | awk '{print \$2 == "None" ? 0 : \$2}' | tr -cd '0-9.')
ratio=\${ratio:-0}
local time
time=\$(grep "Seeding Time:" <<< "\${info}" | awk '{print \$3 == "None" ? 0 : \$3}' | tr -cd '0-9.')
time=\${time:-0}
local percent_done
percent_done=\$(grep "Percent Done:" <<< "\${info}" | awk '{gsub(/%/, ""); print \$3 == "None" ? 0 : \$3}')
percent_done=\${percent_done:-0}
# Extract Transmission-reported directory and translate to local path.
local reported_dir
reported_dir=\$(grep -i "Location:" <<< "\${info}" | awk -F": " '{print \$2}' | xargs)
log_debug "Raw reported directory: '\${reported_dir}'"
# If the reported directory is empty, try to derive it from the name
if [[ -z "\${reported_dir}" ]]; then
local name
name=\$(grep -i "Name:" <<< "\${info}" | awk -F": " '{print \$2}' | xargs)
log_debug "Torrent name: '\${name}'"
# Check if there are labels we can use
local labels
labels=\$(grep -i "Labels:" <<< "\${info}" | awk -F": " '{print \$2}' | xargs)
log_debug "Torrent labels: '\${labels}'"
if [[ "\${labels}" == *"Books"* ]]; then
reported_dir="/downloads/Books"
elif [[ "\${labels}" == *"Movies"* ]]; then
reported_dir="/downloads/Movies"
elif [[ "\${labels}" == *"TV"* ]]; then
reported_dir="/downloads/TV"
elif [[ "\${labels}" == *"Games"* ]]; then
reported_dir="/downloads/Games"
elif [[ "\${labels}" == *"Apps"* ]]; then
reported_dir="/downloads/Apps"
elif [[ "\${labels}" == *"Music"* ]]; then
reported_dir="/downloads/Music"
else
# Default to Other if we can't determine
reported_dir="/downloads/Other"
fi
log_debug "Derived directory from labels: '\${reported_dir}'"
fi
local dir
dir=\$(translate_source "\${reported_dir}")
log_info "Torrent source directory: '\${reported_dir}' translated to '\${dir}'"
# Initialize empty directory mapping if needed
if [[ -z "\$dir" ]]; then
log_warn "Empty directory path detected, using default"
dir="\${LOCAL_PATH_PREFIX}/Other"
fi
local dst
dst=\$(get_destination "\${dir}")
# Process the torrent
if (( \$(bc <<< "\${percent_done} >= 100") )) && ! is_processed "\${hash}"; then
log_info "Processing completed torrent \${id} (\${percent_done}% done)"
process_copy "\${id}" "\${hash}" "\${dir}" "\${dst}"
else
log_info "Torrent \${id} already processed or not complete"
fi
# Check seed ratio/time criteria
if (( \$(bc <<< "\${ratio} >= \${SEED_RATIO}") )) || (( \$(bc <<< "\${time} >= \${SEED_TIME}") )); then
log_info "Removing torrent \${id} (Ratio: \${ratio}, Time: \${time})"
process_removal "\${id}"
fi
}
# Main function
process_single_torrent "$torrent_id"
EOF
chmod +x "$TMP_SCRIPT"
"$TMP_SCRIPT"
rm -f "$TMP_SCRIPT"
elif [ -n "$CATEGORY" ]; then
echo "Processing category: $CATEGORY"
# Set category-specific filter
CATEGORY_PATH=""
PATTERN=""
case $CATEGORY in
books)
echo "Looking for book torrents..."
CATEGORY_PATH="/downloads/Books"
PATTERN="*books*|*ebook*|*epub*|*pdf*"
;;
movies)
echo "Looking for movie torrents..."
CATEGORY_PATH="/downloads/Movies"
PATTERN="*movies*|*film*|*video*"
;;
tv)
echo "Looking for TV show torrents..."
CATEGORY_PATH="/downloads/TV"
PATTERN="*tv*|*series*|*episode*"
;;
apps)
echo "Looking for application torrents..."
CATEGORY_PATH="/downloads/Apps"
PATTERN="*apps*|*applications*|*programs*|*software*"
;;
games)
echo "Looking for game torrents..."
CATEGORY_PATH="/downloads/Games"
PATTERN="*games*"
;;
esac
# Create a script to process just this category
TMP_SCRIPT=$(mktemp)
cat > "$TMP_SCRIPT" << EOF
#!/bin/bash
source /etc/torrent/mover.conf
source /usr/local/lib/torrent-mover/common.sh
source /usr/local/lib/torrent-mover/file_operations.sh
source /usr/local/lib/torrent-mover/transmission_handler.sh
source /usr/local/lib/torrent-mover/archive_handler.sh
# Set debug mode
DEBUG=1
# Get all torrents
get_torrent_ids() {
local cmd="transmission-remote \${TRANSMISSION_IP}:\${TRANSMISSION_PORT} -n \${TRANSMISSION_USER}:\${TRANSMISSION_PASSWORD} -l"
local output
output=\$(retry_command "\$cmd" 3 20)
echo "\$output" | awk 'NR>1 && NF>1 {gsub(/^[ ]+/, "", \$1); if (\$1 ~ /^[0-9]+\$/) print \$1}'
}
# Process category torrents
process_category_torrents() {
local category_path="$CATEGORY_PATH"
local pattern="$PATTERN"
log_debug "Processing category: $CATEGORY with path \$category_path and pattern '\$pattern'"
# Get list of all torrents
local torrent_ids=\$(get_torrent_ids)
# Process each torrent
for id in \$torrent_ids; do
# Get torrent info
local info cmd
cmd="transmission-remote \${TRANSMISSION_IP}:\${TRANSMISSION_PORT} -n \${TRANSMISSION_USER}:\${TRANSMISSION_PASSWORD} -t \${id} -i"
info=\$(eval "\$cmd")
if [[ -z "\$info" ]]; then
log_warn "Failed to get info for torrent \$id, skipping"
continue
fi
# Extract name and location
local name=\$(grep -i "Name:" <<< "\$info" | awk -F": " '{print \$2}' | xargs)
local reported_dir=\$(grep -i "Location:" <<< "\$info" | awk -F": " '{print \$2}' | xargs)
local labels=\$(grep -i "Labels:" <<< "\$info" | awk -F": " '{print \$2}' | xargs)
# Check if this torrent matches our category
if [[ "\$reported_dir" == "\$category_path" ]] ||
[[ "\$labels" == *"$CATEGORY"* ]] ||
[[ "\$name" =~ \$pattern ]]; then
log_info "Found matching torrent: \$id - \$name"
# Process torrent info
local hash=\$(grep "Hash:" <<< "\$info" | awk '{print \$2}')
local ratio=\$(grep "Ratio:" <<< "\$info" | awk '{print \$2 == "None" ? 0 : \$2}' | tr -cd '0-9.')
ratio=\${ratio:-0}
local time=\$(grep "Seeding Time:" <<< "\$info" | awk '{print \$3 == "None" ? 0 : \$3}' | tr -cd '0-9.')
time=\${time:-0}
local percent_done=\$(grep "Percent Done:" <<< "\$info" | awk '{gsub(/%/, ""); print \$3 == "None" ? 0 : \$3}')
percent_done=\${percent_done:-0}
# If the reported directory is empty, derive it
if [[ -z "\$reported_dir" ]]; then
reported_dir="\$category_path"
log_debug "Using derived directory: '\$reported_dir'"
fi
# Process the torrent
local dir=\$(translate_source "\$reported_dir")
log_info "Torrent source directory: '\$reported_dir' translated to '\$dir'"
# Initialize empty directory mapping if needed
if [[ -z "\$dir" ]]; then
log_warn "Empty directory path detected, using default"
dir="\${LOCAL_PATH_PREFIX}/$CATEGORY"
fi
local dst=\$(get_destination "\$dir")
# Process the torrent
if (( \$(bc <<< "\${percent_done} >= 100") )) && ! is_processed "\${hash}"; then
log_info "Processing completed torrent \${id} (\${percent_done}% done)"
process_copy "\${id}" "\${hash}" "\${dir}" "\${dst}"
else
log_info "Torrent \${id} already processed or not complete"
fi
# Check seed ratio/time criteria
if (( \$(bc <<< "\${ratio} >= \${SEED_RATIO}") )) || (( \$(bc <<< "\${time} >= \${SEED_TIME}") )); then
log_info "Removing torrent \${id} (Ratio: \${ratio}, Time: \${time})"
process_removal "\${id}"
fi
fi
done
}
# Main function
process_category_torrents
EOF
chmod +x "$TMP_SCRIPT"
"$TMP_SCRIPT"
rm -f "$TMP_SCRIPT"
else
echo "Processing all torrents"
# Run the main torrent-mover script directly
/usr/local/bin/torrent-mover --debug
fi
echo "Processing complete\!"
echo "Check /var/log/torrent_mover.log for details"

View File

@@ -0,0 +1,134 @@
#!/bin/bash
# Archive extraction handler for torrent-mover
# extract_single_archive: Extract a single archive with proper error handling
extract_single_archive() {
local archive="$1"
local target_dir="$2"
local archive_type="${archive##*.}"
local extract_success=1
local tmp_marker="${target_dir}/.extraction_in_progress"
# Create extraction marker to indicate incomplete extraction
touch "${tmp_marker}"
# Ensure proper permissions for extraction directory
chmod 775 "${target_dir}"
chown ${TORRENT_USER:-debian-transmission}:${TORRENT_GROUP:-debian-transmission} "${target_dir}"
# Extract based on archive type
case "${archive_type,,}" in # Use lowercase comparison
rar)
log_debug "Extracting RAR archive: ${archive}"
# Check which unrar variant is available
if command -v unrar-free &>/dev/null; then
# unrar-free has different syntax
retry_command "unrar-free x \"${archive}\" \"${target_dir}\"" 3 10
else
retry_command "unrar x -o- \"${archive}\" \"${target_dir}\"" 3 10
fi
extract_success=$?
;;
zip)
log_debug "Extracting ZIP archive: ${archive}"
retry_command "unzip -o \"${archive}\" -d \"${target_dir}\"" 3 10
extract_success=$?
;;
7z|7zip)
log_debug "Extracting 7Z archive: ${archive}"
retry_command "7z x \"${archive}\" -o\"${target_dir}\"" 3 10
extract_success=$?
;;
*)
log_error "Unknown archive type: ${archive_type}"
extract_success=1
;;
esac
# Apply consistent permissions to all extracted files and directories
if [[ ${extract_success} -eq 0 ]]; then
log_debug "Setting permissions for extracted files in ${target_dir}"
find "${target_dir}" -type d -exec chmod 775 {} \;
find "${target_dir}" -type f -exec chmod 664 {} \;
find "${target_dir}" -exec chown ${TORRENT_USER:-debian-transmission}:${TORRENT_GROUP:-debian-transmission} {} \;
# Remove the extraction marker to indicate successful completion
rm -f "${tmp_marker}"
return 0
else
log_error "Extraction failed for ${archive}"
# Keep marker to indicate failed extraction
return 1
fi
}
# handle_archives: Process all archives in a source directory
# Returns: 0 if all archives extracted successfully or no archives found, 1 if any failed
handle_archives() {
local src="$1" dst="$2"
local overall_success=0
local archive_found=0
local extraction_errors=0
# Check if source and destination are valid
if [[ ! -d "${src}" ]]; then
log_error "Source directory missing: ${src}"
return 1
fi
if [[ ! -d "${dst}" ]]; then
log_error "Destination directory missing: ${dst}"
return 1
fi
# Find all archives and extract them
find "${src}" -type f \( -iname "*.rar" -o -iname "*.zip" -o -iname "*.7z" \) | while read -r arch; do
archive_found=1
log_info "Processing archive: ${arch}"
# Create extraction subdirectory
local base
base=$(basename "${arch}")
local subdir="${dst}/${base%.*}"
if ! mkdir -p "${subdir}"; then
log_error "Failed to create subdirectory ${subdir} for archive extraction"
extraction_errors=$((extraction_errors + 1))
continue
fi
# Extract the archive
if ! extract_single_archive "${arch}" "${subdir}"; then
log_error "Extraction failed for ${arch}"
extraction_errors=$((extraction_errors + 1))
else
log_info "Archive ${arch} extracted successfully to ${subdir}"
log_info "Archive ${arch} retained in source until ratio limits are reached."
fi
done
# Check for cleanup of any incomplete extractions from previous runs
find "${dst}" -name ".extraction_in_progress" | while read -r marker; do
local problem_dir=$(dirname "${marker}")
log_warn "Found incomplete extraction in ${problem_dir} from previous run"
# Option 1: Remove incomplete directory
# rm -rf "${problem_dir}"
# Option 2: Mark as incomplete but leave content
touch "${problem_dir}/.incomplete_extraction"
rm -f "${marker}"
done
# Return success if no archives found or all extracted successfully
if [[ ${archive_found} -eq 0 ]]; then
log_debug "No archives found in ${src}"
return 0
elif [[ ${extraction_errors} -eq 0 ]]; then
log_info "All archives extracted successfully"
return 0
else
log_warn "${extraction_errors} archives failed to extract properly"
return 1
fi
}

View File

@@ -0,0 +1,290 @@
#!/bin/bash
# Common utility functions and variables for torrent-mover
# Global Runtime Variables
DRY_RUN=0
INTERACTIVE=0
CACHE_WARMUP=0
DEBUG=0
# To avoid reprocessing the same source directory (across different torrents)
declare -A processed_source_dirs
declare -A CHECKED_MOUNTS=()
declare -A PATH_CACHE
# Logging Functions
# All log messages go to stderr.
log_debug() {
if [[ "${DEBUG}" -eq 1 ]]; then
echo -e "[DEBUG] $(date '+%F %T') - $*" | tee -a "${LOG_FILE}" >&2
if [[ "${USE_SYSLOG}" == "true" ]]; then
logger -t torrent-mover "[DEBUG] $*" || true
fi
fi
}
log_info() {
echo -e "[INFO] $(date '+%F %T') - $*" | tee -a "${LOG_FILE}" >&2
if [[ "${USE_SYSLOG}" == "true" ]]; then
logger -t torrent-mover "[INFO] $*" || true
fi
}
log_warn() {
echo -e "[WARN] $(date '+%F %T') - $*" | tee -a "${LOG_FILE}" >&2
if [[ "${USE_SYSLOG}" == "true" ]]; then
logger -t torrent-mover "[WARN] $*" || true
fi
}
log_error() {
echo -e "[ERROR] $(date '+%F %T') - $*" | tee -a "${LOG_FILE}" >&2
if [[ "${USE_SYSLOG}" == "true" ]]; then
logger -t torrent-mover "[ERROR] $*" || true
fi
}
# Error Handling & Notifications
error_handler() {
local lineno="$1"
local msg="$2"
log_error "Error on line ${lineno}: ${msg}"
# Optionally send a notification (e.g., email)
return 1
}
# translate_source: Converts the Transmissionreported path into the local path.
translate_source() {
local src="$1"
echo "${src/#${TRANSMISSION_PATH_PREFIX}/${LOCAL_PATH_PREFIX}}"
}
# parse_args: Processes commandline options.
parse_args() {
while [[ $# -gt 0 ]]; do
case "$1" in
--dry-run) DRY_RUN=1; shift ;;
--interactive) INTERACTIVE=1; shift ;;
--cache-warmup) CACHE_WARMUP=1; shift ;;
--debug) DEBUG=1; shift ;;
--help)
echo "Usage: $0 [--dry-run] [--interactive] [--cache-warmup] [--debug]" >&2
exit 0
;;
*) echo "Invalid option: $1" >&2; exit 1 ;;
esac
done
}
# check_dependencies: Ensures required commands are available.
check_dependencies() {
local deps=("transmission-remote" "unzip" "7z" "parallel" "bc")
for dep in "${deps[@]}"; do
command -v "${dep}" >/dev/null 2>&1 || { log_error "Missing dependency: ${dep}"; exit 1; }
done
# Check for unrar or unrar-free
if command -v unrar &>/dev/null; then
log_debug "Found unrar command"
elif command -v unrar-free &>/dev/null; then
log_debug "Found unrar-free command"
# Create an alias for unrar to point to unrar-free
alias unrar="unrar-free"
else
log_error "Missing dependency: unrar or unrar-free"
exit 1
fi
}
# check_disk_usage: Warn if disk usage is over 90%.
check_disk_usage() {
local dir="$1"
[[ -z "${dir}" ]] && return
log_debug "Checking disk usage for directory: ${dir}"
if ! df -P "${dir}" &>/dev/null; then
log_warn "Directory not found: ${dir}"
return
fi
local mount_point
mount_point=$(df -P "${dir}" | awk 'NR==2 {print $6}')
if [[ -z "${mount_point}" ]]; then
log_warn "Could not determine mount point for: ${dir}"
return
fi
log_debug "Mount point for ${dir} is ${mount_point}"
# Initialize CHECKED_MOUNTS as an empty array if not already done
if [[ -z "${CHECKED_MOUNTS+x}" ]]; then
declare -A CHECKED_MOUNTS
fi
# Check if we've already checked this mount point
if [[ -z "${CHECKED_MOUNTS[${mount_point}]+x}" ]]; then
local usage
usage=$(df -P "${dir}" | awk 'NR==2 {sub(/%/, "", $5); print $5}')
log_debug "Usage for ${mount_point}: ${usage}%"
if (( usage >= 90 )); then
log_warn "Storage warning: ${mount_point} at ${usage}% capacity"
fi
CHECKED_MOUNTS[${mount_point}]=1
else
log_debug "Mount point ${mount_point} already checked"
fi
}
# run_command_safely: Safer version of command execution that prevents injection
run_command_safely() {
# Instead of using eval with a command string, this function accepts the command and arguments separately
# This prevents command injection vulnerabilities
if [[ $# -eq 0 ]]; then
log_error "No command provided to run_command_safely"
return 1
fi
log_debug "Running command: $*"
"$@"
return $?
}
# retry_command: Execute a command with retries
retry_command() {
local cmd="$1"
local max_attempts="${2:-3}" # Default to 3 attempts
local wait_time="${3:-10}" # Default to 10 seconds wait between attempts
local attempt=1
local exit_code=0
local command_output=""
# Create a temporary file for capturing output
local output_file
output_file=$(mktemp)
# Use a more verbose logging for this command - always log, not just in debug mode
log_info "Executing command: $cmd"
while (( attempt <= max_attempts )); do
log_info "Attempt $attempt of $max_attempts: $cmd"
# Execute command directly and capture output and exit code
command_output=$(eval "$cmd" 2>&1)
exit_code=$?
echo "$command_output" > "${output_file}"
# Always log the first 10 lines of output
log_info "Command output (first 10 lines):"
head -n 10 "${output_file}" | while IFS= read -r line; do
log_info " > $line"
done
if [[ ${exit_code} -eq 0 ]]; then
log_info "Command succeeded on attempt $attempt"
rm -f "${output_file}"
echo "$command_output"
return 0
else
# Log detailed error information
log_warn "Command failed (attempt $attempt, exit code: ${exit_code})"
if (( attempt == max_attempts )); then
log_error "Maximum attempts reached for command, last exit code: ${exit_code}"
log_error "Last error output (first 10 lines):"
head -n 10 "${output_file}" | while IFS= read -r line; do
log_error " > $line"
done
rm -f "${output_file}"
echo "$command_output"
return ${exit_code}
fi
# Exponential backoff - wait longer for each successive attempt
local adjusted_wait=$((wait_time * attempt))
log_info "Waiting ${adjusted_wait} seconds before retry"
sleep ${adjusted_wait}
(( attempt++ ))
fi
done
rm -f "${output_file}"
echo "$command_output"
return 1
}
# run_in_transaction: Runs commands with an atomic operation guarantee
# If any command fails, attempts to roll back changes
run_in_transaction() {
local action_desc="$1"
local cleanup_cmd="$2"
local main_cmd="$3"
log_debug "Starting transaction: ${action_desc}"
# Create marker file to indicate transaction in progress
local transaction_id
transaction_id=$(date +%s)-$$
local transaction_marker="/tmp/torrent-mover-transaction-${transaction_id}"
echo "${action_desc}" > "${transaction_marker}"
# Execute the main command
if ! eval "${main_cmd}"; then
log_error "Transaction failed: ${action_desc}"
# Only run cleanup if it exists
if [[ -n "${cleanup_cmd}" ]]; then
log_info "Attempting transaction rollback"
if ! eval "${cleanup_cmd}"; then
log_error "Rollback failed, manual intervention may be required"
else
log_info "Rollback completed successfully"
fi
fi
# Clean up marker
rm -f "${transaction_marker}"
return 1
fi
# Clean up marker on success
rm -f "${transaction_marker}"
log_debug "Transaction completed successfully: ${action_desc}"
return 0
}
# validate_directories: Ensure required directories exist and are writable
validate_directories() {
local directories=("$@")
local error_count=0
for dir in "${directories[@]}"; do
# Skip empty directory paths
if [[ -z "${dir}" ]]; then
continue
fi
if [[ ! -d "${dir}" ]]; then
log_error "Directory missing: ${dir}"
error_count=$((error_count + 1))
continue
fi
if [[ ! -w "${dir}" ]]; then
log_warn "Write permission denied for: ${dir}"
log_warn "This may cause problems - the script will continue but operations may fail"
# Don't increment error_count to allow script to continue
fi
done
if [[ ${error_count} -gt 0 ]]; then
log_error "${error_count} required directories are missing"
return 1
fi
return 0
}

View File

@@ -0,0 +1,295 @@
#!/bin/bash
# File operation functions for torrent-mover
# init_checksum_db: Initializes the checksum database.
init_checksum_db() {
mkdir -p "$(dirname "${CHECKSUM_DB}")"
touch "${CHECKSUM_DB}" || { log_error "Could not create ${CHECKSUM_DB}"; exit 1; }
chmod 600 "${CHECKSUM_DB}"
}
# record_checksums: Generates checksums for files in given directories.
record_checksums() {
log_info "Generating checksums with ${PARALLEL_THREADS:-$(nproc)} threads"
find "$@" -type f ! \( -iname "*.nfo" -o -iname "*.sfv" \) -print0 | \
parallel -0 -j ${PARALLEL_THREADS:-$(nproc)} md5sum | sort > "${CHECKSUM_DB}.tmp"
mv "${CHECKSUM_DB}.tmp" "${CHECKSUM_DB}"
}
# generate_checksums: Common function to generate checksums efficiently
generate_checksums() {
local dir="$1"
local cache_file="${CHECKSUM_DB}.$(echo "$dir" | md5sum | cut -d' ' -f1)"
local last_modified_file
# Skip if directory doesn't exist
if [[ ! -d "${dir}" ]]; then
return 1
fi
# Get the most recently modified file in the directory
last_modified_file=$(find "${dir}" -type f ! \( -iname "*.nfo" -o -iname "*.sfv" \) -exec stat -c "%Y %n" {} \; | sort -nr | head -n1 | cut -d' ' -f2-)
# If cache exists and no files were modified since last cache, use cache
if [[ -f "${cache_file}" ]] && [[ -n "${last_modified_file}" ]]; then
local cache_time file_time
cache_time=$(stat -c "%Y" "${cache_file}")
file_time=$(stat -c "%Y" "${last_modified_file}")
if (( cache_time >= file_time )); then
log_debug "Using cached checksums for ${dir}"
cat "${cache_file}"
return 0
fi
fi
# Generate new checksums with parallel processing
log_debug "Generating fresh checksums for ${dir}"
find "${dir}" -type f ! \( -iname "*.nfo" -o -iname "*.sfv" \) -print0 | \
parallel -0 -j ${PARALLEL_THREADS:-$(nproc)} md5sum | sort | tee "${cache_file}"
return 0
}
# file_metadata: Returns an md5 hash for file metadata.
file_metadata() {
generate_checksums "$1" | awk '{print $1}'
}
# files_need_processing: Checks if the source files need processing.
files_need_processing() {
local src="$1"
shift
local targets=("$@")
if [[ ! -d "${src}" ]]; then
log_warn "Source directory missing: ${src}"
return 1
fi
log_info "=== FILE VERIFICATION DEBUG START ==="
log_info "Source directory: ${src}"
log_info "Verification targets: ${targets[*]}"
local empty_target_found=0
for target in "${targets[@]}"; do
if [[ ! -d "${target}" ]]; then
log_info "Target missing: ${target}"
empty_target_found=1
continue
fi
local file_count
file_count=$(find "${target}" -mindepth 1 -maxdepth 1 -print | wc -l)
log_debug "File count for target ${target}: ${file_count}"
if [[ "${file_count}" -eq 0 ]]; then
log_info "Empty target directory: ${target}"
empty_target_found=1
else
log_info "Target contains ${file_count} items: ${target}"
log_info "First 5 items:"
find "${target}" -mindepth 1 -maxdepth 1 | head -n 5 | while read -r item; do
log_info " - ${item##*/}"
done
fi
done
if [[ "${empty_target_found}" -eq 1 ]]; then
log_info "Empty target detected - processing needed"
log_info "=== FILE VERIFICATION DEBUG END ==="
return 0
fi
log_info "Generating source checksums..."
local src_checksums
src_checksums=$(generate_checksums "${src}")
log_info "First 5 source checksums:"
echo "${src_checksums}" | head -n 5 | while read -r line; do
log_info " ${line}"
done
local match_found=0
for target in "${targets[@]}"; do
log_info "Checking against target: ${target}"
log_info "Generating target checksums..."
local target_checksums
target_checksums=$(generate_checksums "${target}")
log_info "First 5 target checksums:"
echo "${target_checksums}" | head -n 5 | while read -r line; do
log_info " ${line}"
done
if diff <(echo "${src_checksums}") <(echo "${target_checksums}") >/dev/null; then
log_info "Exact checksum match found in: ${target}"
match_found=1
break
else
log_info "No match in: ${target}"
fi
done
log_info "=== FILE VERIFICATION DEBUG END ==="
[[ "${match_found}" -eq 1 ]] && return 1 || return 0
}
# warm_cache: Pre-calculates checksums for storage directories.
warm_cache() {
log_info "Starting cache warmup for Movies..."
local targets=("${DIR_MOVIES_DST}" "${STORAGE_DIRS_ARRAY[@]}")
record_checksums "${targets[@]}"
log_info "Cache warmup completed. Checksums stored in ${CHECKSUM_DB}"
}
# is_processed: Checks if the torrent (by hash) has already been processed.
is_processed() {
grep -q "^${1}$" "${PROCESSED_LOG}" 2>/dev/null
}
# mark_processed: Records a processed torrent.
mark_processed() {
echo "${1}" >> "${PROCESSED_LOG}"
}
# move_files: Moves files using parallel processing if enabled.
move_files() {
if (( PARALLEL_PROCESSING )); then
retry_command "parallel -j ${PARALLEL_THREADS:-$(nproc)} mv {} \"${1}\" ::: \"${2}\"/*" 3 15
else
retry_command "mv \"${2}\"/* \"${1}\"" 3 15
fi
}
# copy_files: Copies files using parallel processing if enabled.
copy_files() {
if (( PARALLEL_PROCESSING )); then
retry_command "parallel -j ${PARALLEL_THREADS:-$(nproc)} cp -r {} \"${1}\" ::: \"${2}\"/*" 3 15
else
retry_command "cp -r \"${2}\"/* \"${1}\"" 3 15
fi
}
# check_seeding_status: Check if torrent is still seeding
check_seeding_status() {
local id="$1"
local status
# Get torrent status from transmission
status=$(transmission-remote --auth "${TRANSMISSION_USER}:${TRANSMISSION_PASS}" --torrent "${id}" --info | grep "State:" | awk '{print $2}')
# Return 0 if seeding (meaning it's active), 1 if it's not seeding
if [[ "$status" == "Seeding" ]]; then
log_info "Torrent ${id} is actively seeding"
return 0
else
log_info "Torrent ${id} is not seeding (status: ${status})"
return 1
fi
}
# safe_move_files: Either move files or create hardlinks depending on seeding status
safe_move_files() {
local dst="$1" src="$2" id="$3"
# If torrent is seeding, use hardlinks instead of moving
if check_seeding_status "${id}"; then
log_info "Using hardlinks for seeding torrent ${id}"
if (( PARALLEL_PROCESSING )); then
# Using cp with --link to create hardlinks instead of copying
retry_command "find \"${src}\" -type f -print0 | parallel -0 -j ${PARALLEL_THREADS:-$(nproc)} cp --link {} \"${dst}/\" 2>/dev/null || cp {} \"${dst}/\"" 3 15
# Handle directories separately - we need to create them first
retry_command "find \"${src}\" -type d -print0 | parallel -0 -j ${PARALLEL_THREADS:-$(nproc)} mkdir -p \"${dst}/{}\"" 3 15
else
# Non-parallel hardlink creation
retry_command "find \"${src}\" -type f -exec cp --link {} \"${dst}/\" \; 2>/dev/null || cp {} \"${dst}/\"" 3 15
retry_command "find \"${src}\" -type d -exec mkdir -p \"${dst}/{}\" \;" 3 15
fi
else
# If not seeding, proceed with normal move operation
move_files "${dst}" "${src}"
fi
}
# process_copy: Validates directories, then copies/moves files from source to destination.
# Optionally verifies integrity after transfer if CHECK_TRANSFER_INTEGRITY is "true".
process_copy() {
local id="$1" hash="$2" src="$3" dst="$4"
local operation_result=0
# Check if source and destination are the same or if we've already processed this
if [[ "${src}" == "${dst}" ]]; then
log_info "Source and destination are the same - skipping: ${src}"
mark_processed "${hash}"
return 0
fi
if [[ ! -d "${src}" ]]; then
log_error "Source directory missing: ${src}"
return 1
fi
# Create destination with proper error handling
if [[ ! -d "${dst}" ]]; then
log_info "Creating destination directory: ${dst}"
if ! mkdir -p "${dst}"; then
log_error "Failed to create directory: ${dst}"
return 1
fi
chmod 775 "${dst}"
chown ${TORRENT_USER:-debian-transmission}:${TORRENT_GROUP:-debian-transmission} "${dst}"
fi
if [[ ! -w "${dst}" ]]; then
log_error "No write permissions for: ${dst}"
return 1
fi
if (( DRY_RUN )); then
log_info "[DRY RUN] Would process torrent ${id}:"
log_info " - Copy files from ${src} to ${dst}"
log_info " - File count: $(find "${src}" -maxdepth 1 -type f | wc -l)"
return 0
fi
# Extract archives first
if ! handle_archives "${src}" "${dst}"; then
log_warn "Archive extraction had issues for ${src}, continuing with regular files"
fi
# Process files atomically
case "${COPY_MODE}" in
move)
log_info "Moving files from ${src} to ${dst}"
safe_move_files "${dst}" "${src}" "${id}"
operation_result=$?
;;
copy)
log_info "Copying files from ${src} to ${dst}"
copy_files "${dst}" "${src}"
operation_result=$?
;;
esac
if [[ ${operation_result} -eq 0 ]]; then
if [[ "${CHECK_TRANSFER_INTEGRITY}" == "true" ]]; then
log_info "Verifying integrity of transferred files..."
local src_checksum target_checksum
src_checksum=$(generate_checksums "${src}")
target_checksum=$(generate_checksums "${dst}")
if diff <(echo "${src_checksum}") <(echo "${target_checksum}") >/dev/null; then
log_info "Integrity check passed."
else
log_error "Integrity check FAILED for ${src}"
return 1
fi
fi
log_info "Transfer completed successfully"
mark_processed "${hash}"
else
log_error "Transfer failed for ${src}"
return 1
fi
return 0
}

View File

@@ -0,0 +1,201 @@
#!/bin/bash
# Transmission-related functions for torrent-mover
# get_destination: Maps a source directory to a destination directory based on keywords and patterns
get_destination() {
local source_path="$1"
# Check if source_path is valid before accessing the array
if [[ -z "${source_path}" ]]; then
log_warn "Empty source path provided to get_destination"
return "${DEFAULT_DST}"
fi
# Check if path is already in the cache
if [[ -n "${PATH_CACHE["${source_path}"]+x}" ]]; then
local cached_destination="${PATH_CACHE["${source_path}"]}"
log_debug "Using cached destination for ${source_path}: ${cached_destination}"
echo "${cached_destination}"
return
fi
# Skip recursive path analysis - only log once
if [[ "${source_path}" =~ ^/mnt/dsnas1/ ]]; then
# Already in destination format, return as is
log_debug "Path already in destination format: ${source_path}"
PATH_CACHE["${source_path}"]="${source_path}"
echo "${source_path}"
return
fi
# For paths in dsnas2, check if they map to same structure in dsnas1
if [[ "${source_path}" =~ ^/mnt/dsnas2/ ]]; then
local dir_suffix="${source_path#/mnt/dsnas2/}"
local potential_dest="/mnt/dsnas1/${dir_suffix}"
# If the directories match exactly in structure, only on different mounts,
# return the source to avoid needless copying
if [[ -d "${potential_dest}" ]]; then
log_debug "Path maps to same structure on different mount: ${source_path} -> ${source_path}"
PATH_CACHE["${source_path}"]="${source_path}"
echo "${source_path}"
return
fi
fi
log_info "Analyzing path: ${source_path}"
local destination="${DEFAULT_DST}"
# Match using custom patterns from config file if they exist
if [[ -n "${CUSTOM_PATTERNS}" ]]; then
log_debug "Using custom patterns from config..."
# Parse and apply each pattern
IFS=';' read -ra PATTERN_ARRAY <<< "${CUSTOM_PATTERNS}"
for pattern in "${PATTERN_ARRAY[@]}"; do
IFS='=' read -ra PARTS <<< "${pattern}"
if [[ "${#PARTS[@]}" -eq 2 ]]; then
local regex="${PARTS[0]}"
local dest="${PARTS[1]}"
if [[ "${source_path,,}" =~ ${regex,,} ]]; then
log_info "Custom pattern match: ${regex} -> ${dest}"
destination="${dest}"
break
fi
fi
done
fi
# If no custom pattern matched, use default category mapping
if [[ "${destination}" == "${DEFAULT_DST}" ]]; then
case "${source_path,,}" in
*games*) destination="${DIR_GAMES_DST}";;
*apps*|*applications*|*programs*|*software*) destination="${DIR_APPS_DST}";;
*movies*|*film*|*video*) destination="${DIR_MOVIES_DST}";;
*books*|*ebook*|*pdf*|*epub*) destination="${DIR_BOOKS_DST}";;
*tv*|*series*|*episode*)
if [[ -n "${DIR_TV_DST}" ]]; then
destination="${DIR_TV_DST}"
else
destination="${DIR_MOVIES_DST}"
fi
;;
*music*|*audio*|*mp3*|*flac*)
if [[ -n "${DIR_MUSIC_DST}" ]]; then
destination="${DIR_MUSIC_DST}"
else
destination="${DEFAULT_DST}"
fi
;;
esac
fi
log_info "Mapped to: ${destination}"
# Only set in cache if source_path is not empty
if [[ -n "${source_path}" ]]; then
PATH_CACHE["${source_path}"]="${destination}"
fi
echo "${destination}"
}
# process_removal: Removes a torrent via Transmission.
process_removal() {
local id="$1"
if (( DRY_RUN )); then
log_info "[DRY RUN] Would remove torrent ${id}"
return
fi
local cmd="transmission-remote ${TRANSMISSION_IP}:${TRANSMISSION_PORT} -n ${TRANSMISSION_USER}:${TRANSMISSION_PASSWORD} -t ${id} --remove-and-delete"
retry_command "$cmd" 3 15
}
# get_torrents: Retrieves a list of torrents from Transmission
get_torrents() {
# Log connection parameters (redacted password)
log_info "Transmission connection parameters:"
log_info " IP: ${TRANSMISSION_IP}:${TRANSMISSION_PORT}"
log_info " Username: ${TRANSMISSION_USER}"
log_info " Password: [redacted]"
# Try a direct command without using retry_command to get clearer error messages
log_info "Direct transmission-remote access test:"
local test_output
test_output=$(transmission-remote "${TRANSMISSION_IP}:${TRANSMISSION_PORT}" -n "${TRANSMISSION_USER}:${TRANSMISSION_PASSWORD}" -l 2>&1)
local test_exit=$?
if [[ $test_exit -ne 0 ]]; then
log_error "Direct transmission-remote test failed with exit code: $test_exit"
log_error "Error output: $test_output"
# Continue anyway to see retry attempt logs
else
log_info "Direct transmission-remote test succeeded"
fi
# Execute the actual command with retries
local real_cmd="transmission-remote ${TRANSMISSION_IP}:${TRANSMISSION_PORT} -n ${TRANSMISSION_USER}:${TRANSMISSION_PASSWORD} -l"
local output
output=$(retry_command "$real_cmd" 3 20)
# Line-by-line raw output inspection (debugging)
log_info "Raw command output detailed analysis:"
if [[ -z "$output" ]]; then
log_error "Command produced EMPTY output"
else
log_info "Output length: $(echo "$output" | wc -l) lines"
echo "$output" | while IFS= read -r line; do
log_info " LINE: '$line'"
done
fi
# Extract IDs directly using awk with detailed debugging
log_info "Extracting torrent IDs from output..."
local line_num=0
local found_ids=0
echo "$output" | while IFS= read -r line; do
line_num=$((line_num + 1))
# Skip header line
if [[ $line_num -eq 1 ]]; then
log_info " Skipping header: '$line'"
continue
fi
# Check for torrent ID in first column
local potential_id
potential_id=$(echo "$line" | awk '{gsub(/^[ ]+/, "", $1); print $1}')
log_info " Line $line_num: potential ID '$potential_id'"
if [[ "$potential_id" =~ ^[0-9]+$ ]]; then
log_info " Found valid ID: $potential_id"
found_ids=$((found_ids + 1))
echo "$potential_id"
else
log_info " Not a valid ID: '$potential_id'"
fi
done | tee /tmp/torrent_ids.txt
# Read back the file to get around pipe subshell issues
local torrent_ids
torrent_ids=$(cat /tmp/torrent_ids.txt)
rm -f /tmp/torrent_ids.txt
# Check if we found any torrents
if [[ -z "$torrent_ids" ]]; then
log_error "NO TORRENT IDs FOUND in transmission output"
else
log_info "Found torrent IDs: $torrent_ids"
fi
# Fallback to hardcoded ID for testing if nothing found
if [[ -z "$torrent_ids" && "${DRY_RUN}" -eq 1 ]]; then
log_info "DRY RUN MODE: Adding test torrent ID 1 for debugging"
echo "1"
else
echo "$torrent_ids"
fi
}
# get_torrent_info: Gets detailed info for a specific torrent
get_torrent_info() {
local id="$1"
local cmd="transmission-remote ${TRANSMISSION_IP}:${TRANSMISSION_PORT} -n ${TRANSMISSION_USER}:${TRANSMISSION_PASSWORD} -t ${id} -i"
retry_command "$cmd" 3 15
}