Compare commits

..

No commits in common. "feature/improved-transmission-connection" and "main" have entirely different histories.

64 changed files with 14769 additions and 4364 deletions

8
.env.install Normal file
View File

@ -0,0 +1,8 @@
export TRANSMISSION_REMOTE=true
export TRANSMISSION_HOST="192.168.5.19"
export TRANSMISSION_PORT="9091"
export TRANSMISSION_USER=""
export TRANSMISSION_PASS=""
export TRANSMISSION_RPC_PATH="/transmission/rpc"
export REMOTE_DOWNLOAD_DIR="/downloads"
export LOCAL_DOWNLOAD_DIR="/media"

30
.gitignore vendored Normal file
View File

@ -0,0 +1,30 @@
# Node.js dependencies
node_modules/
package-lock.json
# Environment and configuration
.env
config.json
# Log files
logs/
*.log
npm-debug.log*
# Temporary and build files
temp/
dist/
build/
.DS_Store
.vscode/
.idea/
# Data files
data/
rss-items.json
rss-feeds.json
# Authentication
*.pem
*.key
*.crt

51
CLAUDE.md Normal file
View File

@ -0,0 +1,51 @@
# Transmission RSS Manager - Development Guide
## Commands
- Install dependencies: `npm install` (needed for rss-feed-manager.js)
- Setup: `./main-installer.sh` (main installation script)
- Run application: `node modules/rss-feed-manager.js`
## Code Style Guidelines
### JavaScript
- Indentation: 2 spaces
- Naming: camelCase for variables/functions, PascalCase for classes
- Semicolons: required
- Imports: group standard libraries first, then custom modules
- Error handling: use try/catch with descriptive error messages
- Functions: prefer arrow functions for callbacks
- String formatting: use template literals (`${variable}`)
### Bash Scripts
- Indentation: 2 spaces
- Function definition: use `function name() {}`
- Comments: add descriptive comments before functions
- Error handling: check return codes and provide meaningful feedback
- Organization: follow modular approach (each script handles specific tasks)
### HTML/CSS
- Indentation: 4 spaces
- CSS: use variables for consistent styling
- Layout: ensure mobile-responsive design
- HTML: use semantic elements when appropriate
## TODO List
### Next Steps
- [ ] Test system with actual RSS feeds and torrents
- [ ] Implement automated testing for key components
- [ ] Add advanced content detection features
- [ ] Enhance UI with visual download statistics
- [ ] Add more notification options (email, messaging platforms)
### Improvements
- [ ] Add user preference settings for automatic downloads
- [ ] Implement batch operations for torrent management
- [ ] Create detailed logging system with rotation
- [ ] Add support for multiple transmission instances
- [ ] Improve error recovery mechanisms
- [ ] Create a mobile-friendly responsive design
- [ ] Add dark mode support
- [ ] Implement content filtering based on regex patterns
- [ ] Add scheduling options for RSS checks
- [ ] Create dashboard with download metrics

581
README.md
View File

@ -1,115 +1,580 @@
# Transmission RSS Manager # Transmission RSS Manager v2.0.12
A C# application for managing RSS feeds and automatically downloading torrents via Transmission BitTorrent client. A comprehensive web-based tool to automate and manage your Transmission torrent downloads with RSS feed integration, intelligent media organization, and enhanced security features. Now with automatic updates and easy installation!
## Update System Requirements
To use the automatic update system, the following requirements must be met:
1. **Git must be installed:** The update system uses Git to fetch the latest version.
2. **Installation must be a Git repository:** Your installation directory must be a Git repository clone.
3. **Internet connectivity:** The server must be able to connect to the Git repository.
If you installed using the bootstrap installer, these requirements should be met automatically. If you experience issues with the update system, please ensure Git is properly installed and accessible to the application.
## Changelog
### v2.0.12 (2025-03-10)
- **Fixed**: Removed persistent floating update notification that wouldn't disappear
- **Fixed**: Major localStorage cleanup to prevent notification state persistence
- **Improved**: Complete rewrite of update notification system to use dashboard-only alerts
- **Improved**: Added DOM cleanup to remove any rogue notification elements
### v2.0.11 (2025-03-10)
- **Fixed**: Fixed update button persistence with advanced floating notification
- **Fixed**: Resolved version display issues with direct package.json reading
- **Fixed**: Improved update process for better version reporting
- **Fixed**: Resolved conflict between test mode and actual update status
- **Added**: Refresh button on update notification for easier version checking
- **Added**: Clear test mode indicators to prevent confusion
- **Improved**: Enhanced cache busting for more reliable version checking
- **Improved**: Better user feedback during update process
### v2.0.10 (2025-03-10)
- **Fixed**: Fixed "fs.existsSync is not a function" error in update check
- **Improved**: Better error handling for git repository checks
- **Improved**: More robust file system operations for update detection
### v2.0.9 (2025-03-07)
- **Fixed**: Update button now appears properly on dashboard
- **Fixed**: Remote Transmission connection issues resolved
- **Fixed**: Improved connection test with better error handling
- **Added**: System status and update endpoints for version checking
- **Improved**: Update detection and notification on dashboard
- **Improved**: Better error handling for git operations
### v2.0.8 (2025-03-07)
- **Fixed**: Module import issues on fresh installations with proper file extension handling
- **Fixed**: Adding compatibility symlinks for different module naming styles
- **Improved**: Server.js now uses consistent module import paths with .js extensions
- **Improved**: More robust module file handling in the installer
### v2.0.7 (2025-03-07)
- **Fixed**: Installation directory handling with prompt for choosing install path
- **Fixed**: Bootstrap-installer now defaults to /opt/trans-install with user configuration option
- **Improved**: Better detection and usage of existing installation directories during updates
- **Improved**: Updated documentation to clarify default installation paths
### v2.0.6 (2025-03-05)
- **Added**: Non-interactive mode support for scripted installations
- **Improved**: Remote Transmission configuration collection in install-script.sh
- **Improved**: Better handling of piped input for remote configuration details
- **Improved**: Added debug output to help troubleshoot configuration issues
- **Note**: When updating an existing installation, manual configuration of remote settings through the web interface may still be required
### v2.0.5 (2025-03-05)
- **Fixed**: Config file now properly stored in /etc/transmission-rss-manager directory
- **Fixed**: Remote Transmission detection in install-script.sh
- **Improved**: Enhanced symlink handling between installation dir and config dir
- **Improved**: Better environment variable passing between install scripts
### v2.0.4 (2025-03-05)
- **Fixed**: Remote transmission detection in installer
- **Fixed**: Configuration directory creation and permissions
- **Fixed**: Config file symlink now ensures application finds correct config location
### v2.0.3 (2025-03-05)
- **New**: Configuration file now stored in `/etc/transmission-rss-manager/`
- **Improved**: Installer creates config directory with proper permissions
- **Improved**: Application now stores path to installation directory for easier updates
### v2.0.2 (2025-03-05)
- **Fixed**: Bug in installer that would prompt to install Transmission for remote configurations
- **Improved**: Better detection of remote vs local Transmission installations
- **Improved**: Installation process now checks the TRANSMISSION_REMOTE flag correctly
### v2.0.1 (2025-03-05)
- **New**: Automatic detection of existing installations for seamless updates
- **Improved**: Enhanced update process that preserves existing configurations
- **Improved**: Installer now dynamically reads version from package.json
### v2.0.0 (2025-03-05)
- **Major**: Completely redesigned installation system using git
- **New**: One-click update feature with version checking
- **New**: Automatic Transmission detection and installation
- **New**: System status dashboard with update notifications
- **New**: About page with developer information
- **Improved**: Better error handling for Transmission connection
- **Fixed**: Various UI bugs and responsiveness issues
### v1.2.0 (2025-02-15)
- **New**: Enhanced media organization with better content detection
- **New**: Improved post-processor with configurable filtering
- **Improved**: Transmission client integration with better error handling
- **Improved**: UI enhancements for better usability
- **Fixed**: RSS feed parsing issues
- **Fixed**: Authentication problems in certain configurations
### v1.0.0 (2025-01-10)
- **Initial Release**: Basic functionality for RSS feed monitoring
- **New**: Integration with Transmission BitTorrent client
- **New**: Web interface for managing torrents and feeds
- **New**: Post-processing capability for downloaded content
- **New**: Basic content categorization by media type
## Features ## Features
- Monitor multiple RSS feeds for new torrents - 🔄 **RSS Feed Integration**: Automatically download torrents from RSS feeds with customizable filters
- Apply regex-based rules to automatically match and download content - 📊 **Torrent Management**: Monitor and control your Transmission torrents from a clean web interface
- Manage Transmission torrents through a user-friendly web interface - 📚 **Intelligent Media Organization**: Automatically categorize and organize downloads by media type
- Post-processing of completed downloads (extract archives, organize media files) - 📖 **Book & Magazine Sorting**: Specialized processing for e-books and magazines with metadata extraction
- Responsive web UI for desktop and mobile use - 📂 **Post-Processing**: Extract archives, rename files, and move content to appropriate directories
- 🔄 **Remote Support**: Connect to remote Transmission instances with local path mapping
## Requirements - 🔒 **Enhanced Security**: Authentication, HTTPS support, and secure password storage
- 📱 **Mobile-Friendly UI**: Responsive design works on desktop and mobile devices
- .NET 7.0 or higher - 🔧 **One-Click Updates**: Built-in version checking and automated update system
- Transmission BitTorrent client (with remote access enabled) - 🚀 **Automatic Transmission Installation**: Installs and configures Transmission if needed
- Linux OS (tested on Ubuntu, Debian, Fedora, Arch)
- Dependencies: unzip, p7zip, unrar (for post-processing)
## Installation ## Installation
### Known Issues
- When updating an existing installation with Remote Transmission settings, you may need to manually configure the remote settings through the web interface after installation.
- The installer will properly detect and collect remote Transmission settings, but they may not be applied to the configuration file in update mode.
### Prerequisites
- Ubuntu/Debian-based system (may work on other Linux distributions)
- Git 2.25.0 or later (will be automatically installed by the bootstrap installer if needed)
- Required for the automatic update system
- Must be available in the PATH of the user running the application
- Must have proper permissions to access the installation directory
- Internet connection (for downloading and updates)
- Required for Git operations during updates (fetching latest code)
- Outbound access to git.powerdata.dk repository server
### System Requirements
- Memory: 512MB minimum, 1GB recommended
- CPU: Any modern processor (1GHz+)
- Disk: At least 200MB for the application, plus storage space for your media
- Network: Internet connection for RSS feed fetching and torrent downloading
### Installation Directory
**Note**: By default, the application will be installed to `/opt/trans-install`. During installation, you'll be prompted to choose a different directory if needed. If you're updating an existing installation, the installer will detect and use your current installation path.
### Automatic Installation ### Automatic Installation
Run the installer script: The easiest way to install Transmission RSS Manager is with the bootstrap installer:
```bash ```bash
curl -sSL https://raw.githubusercontent.com/yourusername/transmission-rss-manager/main/install-script.sh | bash # Download the bootstrap installer
wget https://git.powerdata.dk/masterdraco/transmission-rss-manager/raw/main/bootstrap-installer.sh
# Make it executable
chmod +x bootstrap-installer.sh
# Run it with sudo
sudo ./bootstrap-installer.sh
``` ```
Or if you've cloned the repository: The bootstrap installer will:
1. Install git if needed
2. Clone the latest version from the repository
3. Run the main installer which guides you through configuration
4. Set up the service and web interface
```bash **New in v2.0.0:**
./src/Infrastructure/install-script.sh - The installer now detects if Transmission is installed and offers to install and configure it automatically if needed.
``` - The installer automatically detects existing installations and runs in update mode, preserving your existing configuration.
- When updating, only necessary files are modified while maintaining your custom settings and preferences.
### Manual Installation ### Manual Installation
1. Install .NET 7.0 SDK from [Microsoft's website](https://dotnet.microsoft.com/download) If you prefer to install manually:
2. Clone the repository:
1. Clone the repository:
```bash ```bash
git clone https://github.com/yourusername/transmission-rss-manager.git git clone https://git.powerdata.dk/masterdraco/transmission-rss-manager.git
cd transmission-rss-manager cd transmission-rss-manager
``` ```
3. Build and run the application:
2. Install dependencies:
```bash ```bash
dotnet build -c Release npm install
dotnet run ```
3. Configure settings:
```bash
cp config.example.json config.json
nano config.json
```
4. Start the server:
```bash
# Using the convenience script (recommended)
./scripts/test-and-start.sh
# Or start with debug logging
./scripts/test-and-start.sh --debug
# Or run in foreground mode
./scripts/test-and-start.sh --foreground
# Or start directly
node server.js
``` ```
4. Open a web browser and navigate to: `http://localhost:5000`
## Configuration ## Configuration
After starting the application for the first time, a configuration file will be created at `~/.config/transmission-rss-manager/config.json`. ### Main Configuration Options
You can configure the application through the web interface or by directly editing the configuration file. The system can be configured through the web interface or by editing the configuration file, which is located at `/etc/transmission-rss-manager/config.json` (or at the application's installation directory as a fallback):
### Key configuration options ```json
{
"installPath": "/opt/transmission-rss-manager",
"transmissionConfig": {
"host": "localhost",
"port": 9091,
"username": "transmission",
"password": "password",
"path": "/transmission/rpc"
},
"destinationPaths": {
"movies": "/mnt/media/movies",
"tvShows": "/mnt/media/tvshows",
"music": "/mnt/media/music",
"books": "/mnt/media/books",
"magazines": "/mnt/media/magazines",
"software": "/mnt/media/software"
},
"processingOptions": {
"enableBookSorting": true,
"extractArchives": true,
"renameFiles": true,
"ignoreSample": true
}
}
```
- **Transmission settings**: Host, port, username, password ### Remote Transmission Setup
- **RSS feed checking interval**
- **Auto-download settings** For remote Transmission instances, configure the directory mapping:
- **Post-processing options**
- **Download and media library directories** ```json
"remoteConfig": {
"isRemote": true,
"directoryMapping": {
"/var/lib/transmission-daemon/downloads": "/mnt/transmission-downloads"
}
}
```
This maps paths between your remote Transmission server and the local directories.
## Usage ## Usage
### Managing RSS Feeds ### Web Interface
1. Add RSS feeds through the web interface The web interface provides access to all functionality and is available at:
2. Create regex rules for each feed to match desired content ```
3. Enable auto-download for feeds you want to process automatically http://your-server-ip
```
### RSS Feed Management
1. Go to the "RSS Feeds" tab in the web interface
2. Click "Add Feed" and enter the RSS feed URL
3. Configure optional filters for automatic downloads
4. The system will periodically check feeds and download matching items
### Managing Torrents ### Managing Torrents
- Add torrents manually via URL or magnet link From the "Torrents" tab, you can:
- View, start, stop, and remove torrents - Add new torrents via URL or magnet link
- Process completed torrents to extract archives and organize media - Start, stop, or delete existing torrents
- Monitor download progress and stats
## Development ### Media Organization
### Building from source The post-processor automatically:
1. Waits for torrents to complete and meet seeding requirements
2. Identifies the media type based on content analysis
3. Extracts archives if needed
4. Moves files to the appropriate category directory
5. Renames files according to media type conventions
6. Updates the media library for browsing
### Book & Magazine Sorting
When enabled, the system can:
- Differentiate between books and magazines
- Extract author information from book filenames
- Organize magazines by title and issue number
- Create appropriate folder structures
## Detailed Features
### Automatic Media Detection and Processing
The system uses sophisticated detection to categorize downloads:
- **Movies**: Recognizes common patterns such as resolution (1080p, 720p) and release year
- **TV Shows**: Identifies season/episode patterns (S01E01) and TV-specific naming
- **Music**: Detects audio formats like MP3, FLAC, album folders
- **Books**: Identifies e-book formats (EPUB, MOBI, PDF) and author-title patterns
- **Magazines**: Recognizes magazine naming patterns, issues, volumes, and publication dates
- **Software**: Detects software installers, ISOs, and other program files
### Enhanced Post-Processing
The post-processor automatically processes completed torrents that have met seeding requirements:
- **Smart File Categorization**: Automatically detects media type based on content analysis
- **Intelligent Folder Organization**: Creates category-specific directories and file structures
- **Archive Extraction**: Automatically extracts compressed files (.zip, .rar, .7z, etc.)
- **File Renaming**: Cleans up filenames by removing dots, underscores, and other unwanted characters
- **Quality Management**: Optionally replace existing files with better quality versions
- **Seeding Requirements**: Configurable minimum ratio and seeding time before processing
### Robust Transmission Integration
The improved Transmission client integration provides:
- **Enhanced Error Handling**: Automatic retry on connection failures
- **Media Information**: Deep analysis of torrent content for better categorization
- **Remote Support**: Comprehensive path mapping between remote and local systems
- **Torrent Management**: Complete control over torrents (add, start, stop, remove)
- **Performance Monitoring**: Track download/upload speeds and other performance metrics
### RSS Feed Filtering
Powerful filtering options for RSS feeds:
- **Title matching**: Regular expression support for title patterns
- **Category filtering**: Filter by feed categories
- **Size limits**: Set minimum and maximum size requirements
- **Custom rules**: Combine multiple criteria for precise matching
### Remote Transmission Support
Full support for remote Transmission instances:
- **Secure authentication**: Username/password protection
- **Path mapping**: Configure how remote paths map to local directories
- **Full API support**: Complete control via the web interface
## Updating
### Using the Web Interface (New in v2.0.0)
The easiest way to update is through the web interface:
1. Navigate to the Dashboard
2. Look for the "System Status" section
3. If an update is available, an "Update Now" button will appear
4. Click the button to automatically update to the latest version
The system will:
- Back up your configuration
- Pull the latest code from the repository
- Install any new dependencies
- Restore your configuration
- Restart the service automatically
### Troubleshooting Update Issues
If you encounter "Failed to connect to server" or similar errors when checking for updates:
1. **Verify Git Installation**: Ensure Git is properly installed
```bash
which git
git --version # Should be 2.25.0 or higher
```
2. **Check Repository Status**: Verify the installation directory is a Git repository
```bash
cd /opt/transmission-rss-manager # Or your installation directory
git status
```
3. **Check Internet Connectivity**: Make sure the server can connect to git.powerdata.dk
```bash
ping git.powerdata.dk
curl -I https://git.powerdata.dk
```
4. **Check Permissions**: Ensure the application user has access to the Git repository
```bash
# Check ownership of the .git directory
ls -la /opt/transmission-rss-manager/.git
# If needed, fix permissions
sudo chown -R www-data:www-data /opt/transmission-rss-manager # Adjust user as needed
```
5. **Manual Update**: If the web update still fails, try the manual update method from the command line
### Using the Installer
You can also update by running the installer again:
```bash ```bash
dotnet build # Navigate to the installation directory
cd /opt/transmission-rss-manager
# Run the installer with sudo
sudo ./main-installer.sh
``` ```
### Running in development mode The installer will:
1. Detect your existing installation automatically
2. Run in update mode
3. Preserve all your existing configurations
4. Update only the necessary files
5. Restart the service with your updated installation
### Using the Command Line
If you prefer to update via command line:
```bash ```bash
dotnet run cd /opt/transmission-rss-manager
sudo scripts/update.sh
``` ```
### Creating a release Use the `--force` flag to force an update of dependencies even if no code changes are detected:
```bash ```bash
dotnet publish -c Release sudo scripts/update.sh --force
``` ```
## Architecture ### Manual Update
The application is built using ASP.NET Core with the following components: Alternatively, you can download and run the update script:
- **Web API**: REST endpoints for the web interface ```bash
- **Background Services**: RSS feed checking and post-processing wget https://git.powerdata.dk/masterdraco/transmission-rss-manager/raw/main/scripts/update.sh
- **Core Services**: Configuration, Transmission communication, RSS parsing chmod +x update.sh
sudo ./update.sh
```
## File Structure
```
transmission-rss-manager/
├── server.js # Main application server
├── modules/ # Modular components
│ ├── post-processor.js # Media processing module
│ ├── rss-feed-manager.js # RSS feed management module
│ ├── transmission-client.js # Transmission API integration
│ ├── config-module.sh # Installation configuration
│ ├── dependencies-module.sh # Dependency installation
│ ├── file-creator-module.sh # File creation for installation
│ ├── service-setup-module.sh # Service setup
│ └── utils-module.sh # Utility functions
├── bootstrap-installer.sh # Minimal installer that clones the repository
├── main-installer.sh # Main installation script
├── config.json # Configuration file
├── scripts/ # Utility scripts
│ ├── update.sh # Update script
│ └── test-and-start.sh # Test and start script
├── public/ # Web interface files
│ ├── index.html # Main web interface
│ ├── js/ # JavaScript files
│ │ ├── app.js # Core application logic
│ │ └── utils.js # Utility functions
│ └── css/ # CSS stylesheets
│ └── styles.css # Main stylesheet
└── README.md # This file
```
## Modules
### Post-Processor
The Post-Processor module handles:
- Monitoring completed torrents
- Categorizing content by type
- Extracting archives
- Organizing files into the correct directories
- Renaming files according to conventions
### RSS Feed Manager
The RSS Feed Manager module provides:
- Regular checking of configured RSS feeds
- Filtering of feed items based on rules
- Automated downloading of matching content
- History tracking of downloaded items
## Advanced Configuration
### Seeding Requirements
Set minimum seeding requirements before processing:
```json
"seedingRequirements": {
"minRatio": 1.0,
"minTimeMinutes": 60,
"checkIntervalSeconds": 300
}
```
### Security Settings
Enable authentication and HTTPS for secure access:
```json
"securitySettings": {
"authEnabled": true,
"httpsEnabled": true,
"sslCertPath": "/path/to/ssl/cert.pem",
"sslKeyPath": "/path/to/ssl/key.pem",
"users": [
{
"username": "admin",
"password": "your-hashed-password",
"role": "admin"
},
{
"username": "user",
"password": "your-hashed-password",
"role": "user"
}
]
}
```
*Note: Passwords are automatically hashed on first login if provided in plain text.*
### Processing Options
Customize how files are processed:
```json
"processingOptions": {
"enableBookSorting": true,
"extractArchives": true,
"deleteArchives": true,
"createCategoryFolders": true,
"ignoreSample": true,
"ignoreExtras": true,
"renameFiles": true,
"autoReplaceUpgrades": true,
"removeDuplicates": true,
"keepOnlyBestVersion": true
}
```
## Contributing
Contributions are welcome! Here's how you can help:
1. Fork the repository
2. Create a feature branch (`git checkout -b feature/amazing-feature`)
3. Commit your changes (`git commit -m 'Add some amazing feature'`)
4. Push to the branch (`git push origin feature/amazing-feature`)
5. Open a Pull Request
## License ## License
This project is licensed under the MIT License - see the LICENSE file for details. This project is licensed under the MIT License.
## Acknowledgments ## Acknowledgments
- [Transmission](https://transmissionbt.com/) - BitTorrent client - [Transmission](https://transmissionbt.com/) for the excellent BitTorrent client
- [ASP.NET Core](https://docs.microsoft.com/en-us/aspnet/core/) - Web framework - [Node.js](https://nodejs.org/) and the npm community for the foundation libraries
- [Bootstrap](https://getbootstrap.com/) - UI framework - All contributors who have helped improve this project

View File

@ -1,25 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net7.0</TargetFramework>
<RootNamespace>TransmissionRssManager</RootNamespace>
<ImplicitUsings>disable</ImplicitUsings>
<Nullable>enable</Nullable>
<Version>1.0.0</Version>
<Authors>TransmissionRssManager</Authors>
<Description>A C# application to manage RSS feeds and automatically download torrents via Transmission</Description>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="7.0.7" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" />
<PackageReference Include="System.ServiceModel.Syndication" Version="7.0.0" />
</ItemGroup>
<ItemGroup>
<None Update="wwwroot\**\*">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>

92
bootstrap-installer.sh Executable file
View File

@ -0,0 +1,92 @@
#!/bin/bash
# Transmission RSS Manager - Bootstrap Installer
# This script downloads the latest version from git and runs the setup
# Color and formatting
GREEN='\033[0;32m'
RED='\033[0;31m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
BOLD='\033[1m'
# Installation directory
DEFAULT_INSTALL_DIR="/opt/trans-install"
REPO_URL="https://git.powerdata.dk/masterdraco/transmission-rss-manager.git"
# Ask for installation directory
echo -e "${YELLOW}Where would you like to install Transmission RSS Manager?${NC}"
read -p "Installation directory [$DEFAULT_INSTALL_DIR]: " input_install_dir
INSTALL_DIR=${input_install_dir:-$DEFAULT_INSTALL_DIR}
# Check if running as root
if [ "$EUID" -ne 0 ]; then
echo -e "${RED}This script must be run as root or with sudo privileges.${NC}"
exit 1
fi
# Display welcome message
echo -e "${GREEN}${BOLD}Transmission RSS Manager - Bootstrap Installer${NC}"
echo -e "This script will install the latest version from the git repository."
echo -e "The default installation directory is ${BOLD}/opt/trans-install${NC}, but you can choose a different location."
echo
# Check for git installation
echo -e "${YELLOW}Checking dependencies...${NC}"
if ! command -v git &> /dev/null; then
echo -e "Git not found. Installing git..."
apt-get update
apt-get install -y git
fi
# Check if installation directory exists
if [ -d "$INSTALL_DIR" ]; then
echo -e "${YELLOW}Installation directory already exists.${NC}"
read -p "Do you want to remove it and perform a fresh install? (y/n): " choice
if [[ "$choice" =~ ^[Yy]$ ]]; then
echo "Removing existing installation..."
rm -rf "$INSTALL_DIR"
else
echo -e "${RED}Installation aborted.${NC}"
exit 1
fi
fi
# Create installation directory
echo -e "${YELLOW}Creating installation directory...${NC}"
mkdir -p "$INSTALL_DIR"
# Clone the repository
echo -e "${YELLOW}Cloning the latest version from git...${NC}"
git clone "$REPO_URL" "$INSTALL_DIR"
if [ $? -ne 0 ]; then
echo -e "${RED}Failed to clone the repository.${NC}"
exit 1
fi
# Run the main installer
echo -e "${YELLOW}Running the main installer...${NC}"
cd "$INSTALL_DIR"
chmod +x main-installer.sh
# Let the user know what's about to happen
echo -e "${YELLOW}You will now be asked for configuration options including:${NC}"
echo -e "- Installation directory"
echo -e "- User to run the service as"
echo -e "- Whether Transmission is running on a remote server"
echo -e "- Transmission connection details"
echo -e "- Media organization preferences"
echo
# Add small delay to ensure user sees this message
sleep 2
# Force the installer to run with the right environment
env -i PATH="$PATH" TERM="$TERM" USER="$USER" HOME="$HOME" SUDO_USER="$SUDO_USER" ./main-installer.sh
# Installation complete
echo -e "${GREEN}${BOLD}Bootstrap installation complete!${NC}"
echo -e "Transmission RSS Manager has been installed in $INSTALL_DIR"
echo -e "You can access the web interface at http://localhost:3000"
echo
echo -e "To update in the future, use the update button in the System Status section of the web interface."

582
main-installer.sh Executable file
View File

@ -0,0 +1,582 @@
#!/bin/bash
# Transmission RSS Manager Modular Installer
# Modified to work with the git-based approach
# Set script to exit on error
set -e
# Load installation environment variables if they exist
if [ -f "$(dirname "$0")/.env.install" ]; then
source "$(dirname "$0")/.env.install"
echo "Loaded TRANSMISSION_REMOTE=$TRANSMISSION_REMOTE from environment file"
fi
# Text formatting
BOLD='\033[1m'
GREEN='\033[0;32m'
YELLOW='\033[0;33m'
RED='\033[0;31m'
NC='\033[0m' # No Color
# Get current directory
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )"
# Source the utils module first to make the log function available
source "${SCRIPT_DIR}/modules/utils-module.sh"
# Print header
echo -e "${BOLD}==================================================${NC}"
echo -e "${BOLD} Transmission RSS Manager Installer ${NC}"
VERSION=$(grep -oP '"version": "\K[^"]+' "${SCRIPT_DIR}/package.json" 2>/dev/null || echo "Unknown")
# Check if package.json exists, if not suggest creating it
if [ ! -f "${SCRIPT_DIR}/package.json" ]; then
echo -e "${YELLOW}Warning: package.json not found. You may need to run 'npm init' first.${NC}"
fi
echo -e "${BOLD} Version ${VERSION} - Git Edition ${NC}"
echo -e "${BOLD}==================================================${NC}"
echo
# Check if script is run with sudo
if [ "$EUID" -ne 0 ]; then
echo -e "${RED}Please run as root (use sudo)${NC}"
exit 1
fi
# Check for installation type
IS_UPDATE=false
INSTALLATION_DETECTED=false
# Check if we have existing config info from install-script.sh
if [ -n "$EXISTING_INSTALL_DIR" ] && [ -n "$EXISTING_CONFIG_PATH" ]; then
INSTALLATION_DETECTED=true
IS_UPDATE=true
# Use the existing installation directory as our target
INSTALL_DIR="$EXISTING_INSTALL_DIR"
CONFIG_FILE="$EXISTING_CONFIG_PATH"
log "INFO" "Using existing installation at $INSTALL_DIR detected by install-script.sh"
export INSTALL_DIR
else
# Check for config.json file (primary indicator)
POSSIBLE_CONFIG_LOCATIONS=(
"${SCRIPT_DIR}/config.json"
"/opt/transmission-rss-manager/config.json"
"/etc/transmission-rss-manager/config.json"
)
for CONFIG_PATH in "${POSSIBLE_CONFIG_LOCATIONS[@]}"; do
if [ -f "$CONFIG_PATH" ]; then
INSTALLATION_DETECTED=true
IS_UPDATE=true
INSTALL_DIR="$(dirname "$CONFIG_PATH")"
CONFIG_FILE="$CONFIG_PATH"
log "INFO" "Found existing installation at $INSTALL_DIR"
export INSTALL_DIR
break
fi
done
# Check for service file (secondary indicator) if no config file found
if [ "$INSTALLATION_DETECTED" = "false" ] && [ -f "/etc/systemd/system/transmission-rss-manager.service" ]; then
INSTALLATION_DETECTED=true
IS_UPDATE=true
# Extract the installation directory from the service file
SERVICE_INSTALL_DIR=$(grep "WorkingDirectory=" "/etc/systemd/system/transmission-rss-manager.service" | cut -d'=' -f2)
if [ -n "$SERVICE_INSTALL_DIR" ]; then
INSTALL_DIR="$SERVICE_INSTALL_DIR"
log "INFO" "Found existing installation at $INSTALL_DIR from service file"
export INSTALL_DIR
# Check for config file in the detected installation directory
if [ -f "$INSTALL_DIR/config.json" ]; then
CONFIG_FILE="$INSTALL_DIR/config.json"
fi
fi
fi
# Check for data directory (tertiary indicator)
if [ "$INSTALLATION_DETECTED" = "false" ] && [ -d "${SCRIPT_DIR}/data" ] && [ "$(ls -A "${SCRIPT_DIR}/data" 2>/dev/null)" ]; then
INSTALLATION_DETECTED=true
fi
fi
# Provide clear feedback about the installation type
if [ "$IS_UPDATE" = "true" ]; then
log "INFO" "Running in UPDATE mode - will preserve existing configuration"
log "INFO" "Target installation directory: $INSTALL_DIR"
if [ -n "$CONFIG_FILE" ]; then
log "INFO" "Using configuration file: $CONFIG_FILE"
fi
# Make sure the variables are set correctly
echo -e "${YELLOW}Existing installation detected. Running in update mode.${NC}"
echo -e "${GREEN}Your existing configuration will be preserved.${NC}"
echo -e "${GREEN}Only application files will be updated.${NC}"
else
log "INFO" "Running in FRESH INSTALL mode"
echo -e "${GREEN}Fresh installation. Will create new configuration.${NC}"
fi
export IS_UPDATE
# Check if required module files exist
REQUIRED_MODULES=(
"${SCRIPT_DIR}/modules/config-module.sh"
"${SCRIPT_DIR}/modules/utils-module.sh"
"${SCRIPT_DIR}/modules/dependencies-module.sh"
"${SCRIPT_DIR}/modules/service-setup-module.sh"
"${SCRIPT_DIR}/modules/file-creator-module.sh"
)
for module in "${REQUIRED_MODULES[@]}"; do
if [ ! -f "$module" ]; then
echo -e "${RED}Error: Required module file not found: $module${NC}"
echo -e "${YELLOW}The module files should be included in the git repository.${NC}"
exit 1
fi
done
# Source the remaining module files
source "${SCRIPT_DIR}/modules/config-module.sh"
source "${SCRIPT_DIR}/modules/dependencies-module.sh"
# Check if the updated service module exists, use it if available
if [ -f "${SCRIPT_DIR}/modules/service-setup-module-updated.sh" ]; then
log "INFO" "Using updated service setup module"
source "${SCRIPT_DIR}/modules/service-setup-module-updated.sh"
else
log "INFO" "Using standard service setup module"
source "${SCRIPT_DIR}/modules/service-setup-module.sh"
fi
source "${SCRIPT_DIR}/modules/file-creator-module.sh"
# Function to handle cleanup on error
function cleanup_on_error() {
log "ERROR" "Installation failed: $1"
log "INFO" "Cleaning up..."
# Add any cleanup steps here if needed
log "INFO" "You can try running the installer again after fixing the issues."
exit 1
}
# Set trap for error handling
trap 'cleanup_on_error "$BASH_COMMAND"' ERR
# Execute the installation steps in sequence
log "INFO" "Starting installation process..."
# Set defaults for key variables
export TRANSMISSION_REMOTE=false
export CONFIG_DIR=${CONFIG_DIR:-"/etc/transmission-rss-manager"}
export USER=${USER:-$(logname || echo $SUDO_USER)}
if [ "$IS_UPDATE" = true ]; then
log "INFO" "Running in update mode - preserving existing configuration..."
# First, let's check if we already have this value from the environment
# This allows for non-interactive usage in scripts
if [ -n "$TRANSMISSION_REMOTE" ]; then
is_remote=$([ "$TRANSMISSION_REMOTE" = true ] && echo "Remote" || echo "Local")
log "INFO" "Using Transmission mode from environment: $is_remote"
# Set the input_remote variable based on the environment variable
# This ensures consistent behavior with the rest of the script
if [ "$TRANSMISSION_REMOTE" = true ]; then
input_remote="y"
else
input_remote="n"
fi
else
# Directly ask about Transmission
# This is a direct approach that bypasses any potential sourcing issues
log "INFO" "Configuring Transmission connection..."
echo -e "${BOLD}Transmission Configuration:${NC}"
echo -e "Configure connection to your Transmission client:"
echo
# If stdin is not a terminal (pipe or redirect), assume default
if [ ! -t 0 ]; then
input_remote="n" # Default to no
log "INFO" "Non-interactive mode detected, using default: local Transmission"
else
read -p "Is Transmission running on a remote server? (y/n) [n]: " input_remote
fi
log "INFO" "DEBUG: Input received for remote: '$input_remote'"
fi
# More explicit check for "y" or "Y" input
if [ "$input_remote" = "y" ] || [ "$input_remote" = "Y" ]; then
export TRANSMISSION_REMOTE=true
log "INFO" "Remote Transmission selected."
# Update the config file directly to set remote mode
if [ -f "$CONFIG_DIR/config.json" ]; then
log "INFO" "Updating configuration file for remote Transmission..."
# Log all environment variables we have for debugging
log "INFO" "DEBUG: Environment variables for remote configuration:"
log "INFO" "DEBUG: TRANSMISSION_HOST=${TRANSMISSION_HOST:-'not set'}"
log "INFO" "DEBUG: TRANSMISSION_PORT=${TRANSMISSION_PORT:-'not set'}"
log "INFO" "DEBUG: REMOTE_DOWNLOAD_DIR=${REMOTE_DOWNLOAD_DIR:-'not set'}"
log "INFO" "DEBUG: LOCAL_DOWNLOAD_DIR=${LOCAL_DOWNLOAD_DIR:-'not set'}"
# Check if we already have the remote configuration details from the environment
if [ -n "$TRANSMISSION_HOST" ] && [ -n "$TRANSMISSION_PORT" ] && [ -n "$REMOTE_DOWNLOAD_DIR" ] && [ -n "$LOCAL_DOWNLOAD_DIR" ]; then
log "INFO" "Using remote Transmission configuration from environment"
# Values are already set from the environment, no need to ask again
else
# Get and validate hostname
read -p "Remote Transmission host [localhost]: " input_trans_host
TRANSMISSION_HOST=${input_trans_host:-"localhost"}
# Get and validate port
read -p "Remote Transmission port [9091]: " input_trans_port
TRANSMISSION_PORT=${input_trans_port:-9091}
# Get credentials
read -p "Remote Transmission username []: " input_trans_user
TRANSMISSION_USER=${input_trans_user:-""}
# Use read -s for password to avoid showing it on screen
read -s -p "Remote Transmission password []: " input_trans_pass
echo # Add a newline after the password input
TRANSMISSION_PASS=${input_trans_pass:-""}
read -p "Remote Transmission RPC path [/transmission/rpc]: " input_trans_path
TRANSMISSION_RPC_PATH=${input_trans_path:-"/transmission/rpc"}
# Configure directory mapping for remote setup
echo
echo -e "${YELLOW}Directory Mapping Configuration${NC}"
echo -e "When using a remote Transmission server, you need to map paths between servers."
echo -e "For each directory on the remote server, specify the corresponding local directory."
echo
# Get remote download directory
read -p "Remote Transmission download directory [/var/lib/transmission-daemon/downloads]: " REMOTE_DOWNLOAD_DIR
REMOTE_DOWNLOAD_DIR=${REMOTE_DOWNLOAD_DIR:-"/var/lib/transmission-daemon/downloads"}
# Get local directory that corresponds to remote download directory
read -p "Local directory that corresponds to the remote download directory [/mnt/transmission-downloads]: " LOCAL_DOWNLOAD_DIR
LOCAL_DOWNLOAD_DIR=${LOCAL_DOWNLOAD_DIR:-"/mnt/transmission-downloads"}
fi
# Create mapping JSON
TRANSMISSION_DIR_MAPPING=$(cat <<EOF
{
"$REMOTE_DOWNLOAD_DIR": "$LOCAL_DOWNLOAD_DIR"
}
EOF
)
# Create the local directory
mkdir -p "$LOCAL_DOWNLOAD_DIR"
chown -R $USER:$USER "$LOCAL_DOWNLOAD_DIR"
# Update the config file with the new remote settings
log "INFO" "Updating configuration file with remote Transmission settings..."
# Backup the original config file
cp "$CONFIG_DIR/config.json" "$CONFIG_DIR/config.json.bak.$(date +%Y%m%d%H%M%S)"
# Update the isRemote setting
sed -i 's/"isRemote": false/"isRemote": true/' "$CONFIG_DIR/config.json"
# Update the host setting
sed -i "s/\"host\": \"[^\"]*\"/\"host\": \"$TRANSMISSION_HOST\"/" "$CONFIG_DIR/config.json"
# Update the port setting
sed -i "s/\"port\": [0-9]*/\"port\": $TRANSMISSION_PORT/" "$CONFIG_DIR/config.json"
# Update the username setting
sed -i "s/\"username\": \"[^\"]*\"/\"username\": \"$TRANSMISSION_USER\"/" "$CONFIG_DIR/config.json"
# Update the password setting
sed -i "s/\"password\": \"[^\"]*\"/\"password\": \"$TRANSMISSION_PASS\"/" "$CONFIG_DIR/config.json"
# Update the RPC path setting
sed -i "s|\"path\": \"[^\"]*\"|\"path\": \"$TRANSMISSION_RPC_PATH\"|" "$CONFIG_DIR/config.json"
# Update the directory mapping
# Use a more complex approach since it's a JSON object
# This is a simplification and might need improvement for complex JSON handling
sed -i "/\"directoryMapping\":/c\\ \"directoryMapping\": $TRANSMISSION_DIR_MAPPING" "$CONFIG_DIR/config.json"
log "INFO" "Configuration updated for remote Transmission."
fi
else
export TRANSMISSION_REMOTE=false
log "INFO" "Local Transmission selected."
# Update the config file directly to set local mode
if [ -f "$CONFIG_DIR/config.json" ]; then
log "INFO" "Updating configuration file for local Transmission..."
# Backup the original config file
cp "$CONFIG_DIR/config.json" "$CONFIG_DIR/config.json.bak.$(date +%Y%m%d%H%M%S)"
# Update the isRemote setting
sed -i 's/"isRemote": true/"isRemote": false/' "$CONFIG_DIR/config.json"
# Update the host setting
sed -i 's/"host": "[^"]*"/"host": "localhost"/' "$CONFIG_DIR/config.json"
log "INFO" "Configuration updated for local Transmission."
fi
fi
# Step 1: Check dependencies (but don't reconfigure)
log "INFO" "Checking dependencies..."
install_dependencies || {
log "ERROR" "Dependency check failed"
exit 1
}
# Step the service configuration (will preserve existing settings)
log "INFO" "Updating service configuration..."
setup_service || {
log "ERROR" "Service update failed"
exit 1
}
# Install npm dependencies using our common function
ensure_npm_packages "$INSTALL_DIR" || {
log "ERROR" "NPM installation failed"
exit 1
}
# Copy JavaScript module files during update as well
log "INFO" "Copying JavaScript module files..."
copy_module_files || {
log "ERROR" "Failed to copy JavaScript module files"
exit 1
}
else
# This is a fresh installation - run all steps
# Step 1: First, let's check if we already have this value from the environment
# This allows for non-interactive usage in scripts
if [ -n "$TRANSMISSION_REMOTE" ]; then
is_remote=$([ "$TRANSMISSION_REMOTE" = true ] && echo "Remote" || echo "Local")
log "INFO" "Using Transmission mode from environment: $is_remote"
# Set the input_remote variable based on the environment variable
# This ensures consistent behavior with the rest of the script
if [ "$TRANSMISSION_REMOTE" = true ]; then
input_remote="y"
else
input_remote="n"
fi
else
# Directly ask about Transmission
# This is a direct approach that bypasses any potential sourcing issues
log "INFO" "Configuring Transmission connection..."
echo -e "${BOLD}Transmission Configuration:${NC}"
echo -e "Configure connection to your Transmission client:"
echo
# If stdin is not a terminal (pipe or redirect), assume default
if [ ! -t 0 ]; then
input_remote="n" # Default to no
log "INFO" "Non-interactive mode detected, using default: local Transmission"
else
read -p "Is Transmission running on a remote server? (y/n) [n]: " input_remote
fi
log "INFO" "DEBUG: Input received for remote: '$input_remote'"
fi
# More explicit check for "y" or "Y" input
if [ "$input_remote" = "y" ] || [ "$input_remote" = "Y" ]; then
export TRANSMISSION_REMOTE=true
log "INFO" "Remote Transmission selected."
else
export TRANSMISSION_REMOTE=false
log "INFO" "Local Transmission selected."
fi
# Now gather the rest of the configuration
log "INFO" "Gathering remaining configuration..."
gather_configuration || {
log "ERROR" "Configuration gathering failed"
exit 1
}
# Debug: Verify TRANSMISSION_REMOTE is set
log "INFO" "After configuration gathering, TRANSMISSION_REMOTE=$TRANSMISSION_REMOTE"
# Step 2: Install dependencies
log "INFO" "Installing dependencies..."
install_dependencies || {
log "ERROR" "Dependency installation failed"
exit 1
}
# Step 3: Create installation directories
log "INFO" "Creating directories..."
# Make sure CONFIG_DIR is set and exported
export CONFIG_DIR=${CONFIG_DIR:-"/etc/transmission-rss-manager"}
# Call our new create_directories function
create_directories || {
log "ERROR" "Directory creation failed"
exit 1
}
# Step 4: Create configuration files only (no application files since they're from git)
log "INFO" "Creating configuration files..."
create_config_files || {
log "ERROR" "Configuration file creation failed"
exit 1
}
# Step 5: Create service files and install the service
log "INFO" "Setting up service..."
setup_service || {
log "ERROR" "Service setup failed"
exit 1
}
# Step 6: Install npm dependencies using our common function
ensure_npm_packages "$INSTALL_DIR" || {
log "ERROR" "NPM installation failed"
exit 1
}
fi
# Step 7: Set up update script
log "INFO" "Setting up update script..."
mkdir -p "${SCRIPT_DIR}/scripts"
# Check if update script exists - don't copy it to itself
if [ ! -f "${SCRIPT_DIR}/scripts/update.sh" ]; then
# First, check if we have an update script to copy
if [ -f "${SCRIPT_DIR}/update.sh" ]; then
cp "${SCRIPT_DIR}/update.sh" "${SCRIPT_DIR}/scripts/update.sh"
log "INFO" "Copied update script from root to scripts directory"
else
# Create the update script since it doesn't exist
cat > "${SCRIPT_DIR}/scripts/update.sh" << 'EOL'
#!/bin/bash
# Transmission RSS Manager - Update Script
# This script pulls the latest version from git and runs necessary updates
# Color and formatting
GREEN='\033[0;32m'
RED='\033[0;31m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
BOLD='\033[1m'
# Installation directory (should be current directory)
INSTALL_DIR=$(pwd)
# Check if we're in the right directory
if [ ! -f "$INSTALL_DIR/package.json" ] || [ ! -d "$INSTALL_DIR/modules" ]; then
echo -e "${RED}Error: This script must be run from the installation directory.${NC}"
exit 1
fi
# Get the current version
CURRENT_VERSION=$(grep -oP '"version": "\K[^"]+' package.json)
echo -e "${YELLOW}Current version: ${BOLD}$CURRENT_VERSION${NC}"
# Check for git repository
if [ ! -d ".git" ]; then
echo -e "${RED}Error: This installation was not set up using git.${NC}"
echo -e "Please use the bootstrap installer to perform a fresh installation."
exit 1
fi
# Stash any local changes
echo -e "${YELLOW}Backing up any local configuration changes...${NC}"
git stash -q
# Pull the latest changes
echo -e "${YELLOW}Pulling latest updates from git...${NC}"
git pull
if [ $? -ne 0 ]; then
echo -e "${RED}Failed to pull updates. Restoring original state...${NC}"
git stash pop -q
exit 1
fi
# Get the new version
NEW_VERSION=$(grep -oP '"version": "\K[^"]+' package.json)
echo -e "${GREEN}New version: ${BOLD}$NEW_VERSION${NC}"
# Check if update is needed
if [ "$CURRENT_VERSION" == "$NEW_VERSION" ]; then
echo -e "${GREEN}You already have the latest version.${NC}"
exit 0
fi
# Install any new npm dependencies
echo -e "${YELLOW}Installing dependencies...${NC}"
npm install
# Apply any local configuration changes
if git stash list | grep -q "stash@{0}"; then
echo -e "${YELLOW}Restoring local configuration changes...${NC}"
git stash pop -q
# Handle conflicts if any
if [ $? -ne 0 ]; then
echo -e "${RED}There were conflicts when restoring your configuration.${NC}"
echo -e "Please check the files and resolve conflicts manually."
echo -e "Your original configuration is saved in .git/refs/stash"
fi
fi
# Restart the service
echo -e "${YELLOW}Restarting service...${NC}"
if command -v systemctl &> /dev/null; then
sudo systemctl restart transmission-rss-manager
else
echo -e "${RED}Could not restart service automatically.${NC}"
echo -e "Please restart the service manually."
fi
# Update complete
echo -e "${GREEN}${BOLD}Update complete!${NC}"
echo -e "Updated from version $CURRENT_VERSION to $NEW_VERSION"
echo -e "Changes will take effect immediately."
EOL
chmod +x "${SCRIPT_DIR}/scripts/update.sh"
log "INFO" "Created update script: ${SCRIPT_DIR}/scripts/update.sh"
fi
fi
# Step 8: Final setup and permissions
log "INFO" "Finalizing setup..."
finalize_setup || {
log "ERROR" "Setup finalization failed"
exit 1
}
# Installation complete
echo
echo -e "${BOLD}${GREEN}==================================================${NC}"
if [ "$IS_UPDATE" = true ]; then
echo -e "${BOLD}${GREEN} Update Complete! ${NC}"
else
echo -e "${BOLD}${GREEN} Installation Complete! ${NC}"
fi
echo -e "${BOLD}${GREEN}==================================================${NC}"
echo -e "You can access the web interface at: ${BOLD}http://localhost:$PORT${NC} or ${BOLD}http://your-server-ip:$PORT${NC}"
echo -e "You may need to configure your firewall to allow access to port $PORT"
echo
echo -e "${BOLD}Useful Commands:${NC}"
echo -e " To check the service status: ${YELLOW}systemctl status $SERVICE_NAME${NC}"
echo -e " To view logs: ${YELLOW}journalctl -u $SERVICE_NAME${NC}"
echo -e " To restart the service: ${YELLOW}systemctl restart $SERVICE_NAME${NC}"
echo -e " To update the application: ${YELLOW}Use the Update button in the System Status section${NC}"
echo
if [ "$IS_UPDATE" = true ]; then
echo -e "Thank you for updating Transmission RSS Manager!"
echo -e "The service has been restarted with the new version."
else
echo -e "Thank you for installing Transmission RSS Manager!"
fi
echo -e "${BOLD}==================================================${NC}"

382
modules/config-module.sh Normal file
View File

@ -0,0 +1,382 @@
#!/bin/bash
# Configuration module for Transmission RSS Manager Installation
# Configuration variables with defaults
INSTALL_DIR=${INSTALL_DIR:-"/opt/trans-install"}
CONFIG_DIR="/etc/transmission-rss-manager"
SERVICE_NAME="transmission-rss-manager"
PORT=3000
# Get default user safely - avoid using root
get_default_user() {
local default_user
# Try logname first to get the user who invoked sudo
if command -v logname &> /dev/null; then
default_user=$(logname 2>/dev/null)
fi
# If logname failed, try SUDO_USER
if [ -z "$default_user" ] && [ -n "$SUDO_USER" ]; then
default_user="$SUDO_USER"
fi
# Fallback to current user if both methods failed
if [ -z "$default_user" ]; then
default_user="$(whoami)"
fi
# Ensure the user is not root
if [ "$default_user" = "root" ]; then
echo "nobody"
else
echo "$default_user"
fi
}
# Initialize default user
USER=$(get_default_user)
# Transmission configuration variables
TRANSMISSION_REMOTE=false
TRANSMISSION_HOST="localhost"
TRANSMISSION_PORT=9091
TRANSMISSION_USER=""
TRANSMISSION_PASS=""
TRANSMISSION_RPC_PATH="/transmission/rpc"
TRANSMISSION_DOWNLOAD_DIR="/var/lib/transmission-daemon/downloads"
TRANSMISSION_DIR_MAPPING="{}"
# Media path defaults
MEDIA_DIR="/mnt/media"
ENABLE_BOOK_SORTING=true
# Helper function to validate port number
validate_port() {
local port="$1"
if [[ "$port" =~ ^[0-9]+$ ]] && [ "$port" -ge 1 ] && [ "$port" -le 65535 ]; then
return 0
else
return 1
fi
}
# Helper function to validate URL hostname
validate_hostname() {
local hostname="$1"
if [[ "$hostname" =~ ^[a-zA-Z0-9]([a-zA-Z0-9\-\.]+[a-zA-Z0-9])?$ ]]; then
return 0
elif [[ "$hostname" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
return 0
else
return 1
fi
}
function gather_configuration() {
log "INFO" "Starting configuration gathering"
# Initialize default values for Transmission mode
export TRANSMISSION_REMOTE=false
# Set flag to indicate that configuration was gathered
export CONFIG_GATHERED=true
echo -e "${BOLD}Installation Configuration:${NC}"
echo -e "Please provide the following configuration parameters:"
echo
read -p "Installation directory [$INSTALL_DIR]: " input_install_dir
if [ -n "$input_install_dir" ]; then
# Validate installation directory
if [[ ! "$input_install_dir" =~ ^/ ]]; then
log "WARN" "Installation directory must be an absolute path. Using default."
else
INSTALL_DIR="$input_install_dir"
fi
fi
# Using fixed port 3000 to avoid permission issues with ports below 1024
log "INFO" "Using port 3000 for the web interface"
log "INFO" "This is to avoid permission issues with ports below 1024 (like port 80)"
PORT=3000
# Get user
read -p "Run as user [$USER]: " input_user
if [ -n "$input_user" ]; then
# Check if user exists
if id "$input_user" &>/dev/null; then
USER="$input_user"
else
log "WARN" "User $input_user does not exist. Using $USER instead."
fi
fi
echo
echo -e "${BOLD}Transmission Configuration:${NC}"
echo -e "Configure connection to your Transmission client:"
echo
# Don't ask about remote again - this is now handled in the main installer
# Just log the current setting for clarity
log "INFO" "Using previously selected Transmission mode: $([ "$TRANSMISSION_REMOTE" = true ] && echo "Remote" || echo "Local")"
if [ "$TRANSMISSION_REMOTE" = true ]; then
# Get and validate hostname
while true; do
read -p "Remote Transmission host [localhost]: " input_trans_host
if [ -z "$input_trans_host" ]; then
break
elif validate_hostname "$input_trans_host"; then
TRANSMISSION_HOST="$input_trans_host"
break
else
log "WARN" "Invalid hostname format."
fi
done
# Get and validate port
while true; do
read -p "Remote Transmission port [9091]: " input_trans_port
if [ -z "$input_trans_port" ]; then
break
elif validate_port "$input_trans_port"; then
TRANSMISSION_PORT="$input_trans_port"
break
else
log "WARN" "Invalid port number. Port must be between 1 and 65535."
fi
done
# Get credentials
read -p "Remote Transmission username []: " input_trans_user
TRANSMISSION_USER=${input_trans_user:-$TRANSMISSION_USER}
# Use read -s for password to avoid showing it on screen
read -s -p "Remote Transmission password []: " input_trans_pass
echo # Add a newline after the password input
if [ -n "$input_trans_pass" ]; then
# TODO: In a production environment, consider encrypting this password
TRANSMISSION_PASS="$input_trans_pass"
fi
read -p "Remote Transmission RPC path [/transmission/rpc]: " input_trans_path
if [ -n "$input_trans_path" ]; then
# Ensure path starts with / for consistency
if [[ ! "$input_trans_path" =~ ^/ ]]; then
input_trans_path="/$input_trans_path"
fi
TRANSMISSION_RPC_PATH="$input_trans_path"
fi
# Configure directory mapping for remote setup
echo
echo -e "${YELLOW}Directory Mapping Configuration${NC}"
echo -e "When using a remote Transmission server, you need to map paths between servers."
echo -e "For each directory on the remote server, specify the corresponding local directory."
echo
# Get remote download directory
read -p "Remote Transmission download directory: " REMOTE_DOWNLOAD_DIR
REMOTE_DOWNLOAD_DIR=${REMOTE_DOWNLOAD_DIR:-"/var/lib/transmission-daemon/downloads"}
# Get local directory that corresponds to remote download directory
read -p "Local directory that corresponds to the remote download directory: " LOCAL_DOWNLOAD_DIR
LOCAL_DOWNLOAD_DIR=${LOCAL_DOWNLOAD_DIR:-"/mnt/transmission-downloads"}
# Create mapping JSON - use proper JSON escaping for directory paths
REMOTE_DOWNLOAD_DIR_ESCAPED=$(echo "$REMOTE_DOWNLOAD_DIR" | sed 's/\\/\\\\/g' | sed 's/"/\\"/g')
LOCAL_DOWNLOAD_DIR_ESCAPED=$(echo "$LOCAL_DOWNLOAD_DIR" | sed 's/\\/\\\\/g' | sed 's/"/\\"/g')
TRANSMISSION_DIR_MAPPING="{\"$REMOTE_DOWNLOAD_DIR_ESCAPED\": \"$LOCAL_DOWNLOAD_DIR_ESCAPED\"}"
# Create the local directory
if ! mkdir -p "$LOCAL_DOWNLOAD_DIR"; then
log "ERROR" "Failed to create local download directory: $LOCAL_DOWNLOAD_DIR"
else
if ! chown -R "$USER:$USER" "$LOCAL_DOWNLOAD_DIR"; then
log "ERROR" "Failed to set permissions on local download directory: $LOCAL_DOWNLOAD_DIR"
fi
fi
# Ask if want to add more mappings
while true; do
read -p "Add another directory mapping? (y/n) [n]: " add_another
if [[ ! $add_another =~ ^[Yy]$ ]]; then
break
fi
read -p "Remote directory path: " remote_dir
read -p "Corresponding local directory path: " local_dir
if [ -n "$remote_dir" ] && [ -n "$local_dir" ]; then
# Escape directory paths for JSON
remote_dir_escaped=$(echo "$remote_dir" | sed 's/\\/\\\\/g' | sed 's/"/\\"/g')
local_dir_escaped=$(echo "$local_dir" | sed 's/\\/\\\\/g' | sed 's/"/\\"/g')
# Update mapping JSON (proper JSON manipulation)
# Remove the closing brace, add a comma and the new mapping, then close with brace
TRANSMISSION_DIR_MAPPING="${TRANSMISSION_DIR_MAPPING%\}}, \"$remote_dir_escaped\": \"$local_dir_escaped\"}"
# Create the local directory
if ! mkdir -p "$local_dir"; then
log "ERROR" "Failed to create directory: $local_dir"
else
if ! chown -R "$USER:$USER" "$local_dir"; then
log "WARN" "Failed to set permissions on directory: $local_dir"
fi
log "INFO" "Mapping added: $remote_dir$local_dir"
fi
fi
done
# Set Transmission download dir for configuration
TRANSMISSION_DOWNLOAD_DIR=$REMOTE_DOWNLOAD_DIR
else
# Local Transmission selected - this part was already set in main-installer.sh
echo -e "${YELLOW}You've selected to use a local Transmission installation.${NC}"
# Check if Transmission is already installed
if command -v transmission-daemon &> /dev/null; then
echo -e "${GREEN}Transmission is already installed on this system.${NC}"
else
echo -e "${YELLOW}NOTE: Transmission does not appear to be installed on this system.${NC}"
echo -e "${YELLOW}You will be prompted to install it during the dependency installation step.${NC}"
fi
# Get and validate port
while true; do
read -p "Local Transmission port [9091]: " input_trans_port
if [ -z "$input_trans_port" ]; then
break
elif validate_port "$input_trans_port"; then
TRANSMISSION_PORT="$input_trans_port"
break
else
log "WARN" "Invalid port number. Port must be between 1 and 65535."
fi
done
# Get credentials if any
read -p "Local Transmission username (leave empty if authentication is disabled) []: " input_trans_user
TRANSMISSION_USER=${input_trans_user:-$TRANSMISSION_USER}
if [ -n "$input_trans_user" ]; then
# Use read -s for password to hide it
read -s -p "Local Transmission password []: " input_trans_pass
echo # Add a newline after the password input
if [ -n "$input_trans_pass" ]; then
TRANSMISSION_PASS="$input_trans_pass"
fi
fi
read -p "Transmission download directory [/var/lib/transmission-daemon/downloads]: " input_trans_dir
if [ -n "$input_trans_dir" ]; then
if [[ ! "$input_trans_dir" =~ ^/ ]]; then
log "WARN" "Download directory must be an absolute path. Using default."
else
TRANSMISSION_DOWNLOAD_DIR="$input_trans_dir"
fi
fi
fi
echo
echo -e "${BOLD}Media Destination Configuration:${NC}"
read -p "Media destination base directory [/mnt/media]: " input_media_dir
if [ -n "$input_media_dir" ]; then
if [[ ! "$input_media_dir" =~ ^/ ]]; then
log "WARN" "Media directory must be an absolute path. Using default."
else
MEDIA_DIR="$input_media_dir"
fi
fi
# Ask about enabling book/magazine sorting
echo
echo -e "${BOLD}Content Type Configuration:${NC}"
read -p "Enable book and magazine sorting? (y/n) [y]: " input_book_sorting
if [[ $input_book_sorting =~ ^[Nn]$ ]]; then
ENABLE_BOOK_SORTING=false
else
ENABLE_BOOK_SORTING=true
fi
# Security configuration
echo
echo -e "${BOLD}Security Configuration:${NC}"
# Ask about enabling authentication
read -p "Enable authentication? (y/n) [n]: " input_auth_enabled
AUTH_ENABLED=false
ADMIN_USERNAME=""
ADMIN_PASSWORD=""
if [[ $input_auth_enabled =~ ^[Yy]$ ]]; then
AUTH_ENABLED=true
# Get admin username and password
read -p "Admin username [admin]: " input_admin_username
ADMIN_USERNAME=${input_admin_username:-"admin"}
# Use read -s for password to avoid showing it on screen
read -s -p "Admin password: " input_admin_password
echo # Add a newline after the password input
if [ -z "$input_admin_password" ]; then
# Generate a random password if none provided
ADMIN_PASSWORD=$(openssl rand -base64 12)
echo -e "${YELLOW}Generated random admin password: $ADMIN_PASSWORD${NC}"
echo -e "${YELLOW}Please save this password somewhere safe!${NC}"
else
ADMIN_PASSWORD="$input_admin_password"
fi
fi
# Ask about enabling HTTPS
read -p "Enable HTTPS? (requires SSL certificate) (y/n) [n]: " input_https_enabled
HTTPS_ENABLED=false
SSL_CERT_PATH=""
SSL_KEY_PATH=""
if [[ $input_https_enabled =~ ^[Yy]$ ]]; then
HTTPS_ENABLED=true
# Get SSL certificate paths
read -p "SSL certificate path: " input_ssl_cert_path
if [ -n "$input_ssl_cert_path" ]; then
# Check if file exists
if [ -f "$input_ssl_cert_path" ]; then
SSL_CERT_PATH="$input_ssl_cert_path"
else
log "WARN" "SSL certificate file not found. HTTPS will be disabled."
HTTPS_ENABLED=false
fi
else
log "WARN" "SSL certificate path not provided. HTTPS will be disabled."
HTTPS_ENABLED=false
fi
# Only ask for key if cert was found
if [ "$HTTPS_ENABLED" = true ]; then
read -p "SSL key path: " input_ssl_key_path
if [ -n "$input_ssl_key_path" ]; then
# Check if file exists
if [ -f "$input_ssl_key_path" ]; then
SSL_KEY_PATH="$input_ssl_key_path"
else
log "WARN" "SSL key file not found. HTTPS will be disabled."
HTTPS_ENABLED=false
fi
else
log "WARN" "SSL key path not provided. HTTPS will be disabled."
HTTPS_ENABLED=false
fi
fi
fi
echo
log "INFO" "Configuration gathering complete"
echo -e "${GREEN}Configuration complete!${NC}"
echo
}

260
modules/dependencies-module.sh Executable file
View File

@ -0,0 +1,260 @@
#!/bin/bash
# Dependencies module for Transmission RSS Manager Installation
function install_dependencies() {
log "INFO" "Installing dependencies..."
# Make sure TRANSMISSION_REMOTE variable is set and honored
# First check for environment variable that might have been directly set
# Then check the .env.install file in various locations
# Try relative path first
ENV_FILE="$(dirname "$(dirname "$0")")/.env.install"
if [ -f "$ENV_FILE" ]; then
source "$ENV_FILE"
log "INFO" "Loaded transmission settings from environment file: TRANSMISSION_REMOTE=$TRANSMISSION_REMOTE"
# Try absolute path as fallback
elif [ -f "/opt/develop/transmission-rss-manager/.env.install" ]; then
source "/opt/develop/transmission-rss-manager/.env.install"
log "INFO" "Loaded transmission settings from absolute path: TRANSMISSION_REMOTE=$TRANSMISSION_REMOTE"
fi
# If we're in update mode, try to load the remote status from existing config
if [ "$IS_UPDATE" = "true" ] && [ -n "$EXISTING_CONFIG_PATH" ]; then
log "INFO" "Update mode detected with config at $EXISTING_CONFIG_PATH, checking Transmission remote setting"
if [ -f "$EXISTING_CONFIG_PATH" ]; then
# Try to extract the isRemote setting from the config file
if command -v grep &> /dev/null; then
IS_REMOTE=$(grep -o '"isRemote":[^,}]*' "$EXISTING_CONFIG_PATH" | grep -o 'true\|false')
if [ "$IS_REMOTE" = "true" ]; then
export TRANSMISSION_REMOTE=true
log "INFO" "Detected remote Transmission configuration from existing config"
elif [ "$IS_REMOTE" = "false" ]; then
export TRANSMISSION_REMOTE=false
log "INFO" "Detected local Transmission configuration from existing config"
fi
fi
fi
fi
# Always prompt if we didn't get TRANSMISSION_REMOTE from environment or previous steps
if [ -z "$TRANSMISSION_REMOTE" ]; then
log "WARN" "TRANSMISSION_REMOTE variable was not set, asking now..."
# Directly ask about remote Transmission
echo -e "${BOLD}Transmission Configuration:${NC}"
echo -e "Configure connection to your Transmission client:"
echo
read -p "Is Transmission running on a remote server? (y/n) [n]: " input_remote
if [[ $input_remote =~ ^[Yy]$ ]]; then
export TRANSMISSION_REMOTE=true
log "INFO" "Remote Transmission selected."
else
export TRANSMISSION_REMOTE=false
log "INFO" "Local Transmission selected."
fi
# Save this choice to environment file for other scripts
echo "export TRANSMISSION_REMOTE=$TRANSMISSION_REMOTE" > "$(dirname "$(dirname "$0")")/.env.install"
chmod +x "$(dirname "$(dirname "$0")")/.env.install"
else
log "INFO" "Using previously set TRANSMISSION_REMOTE=$TRANSMISSION_REMOTE"
fi
log "INFO" "Proceeding with Transmission mode: $([ "$TRANSMISSION_REMOTE" = true ] && echo "Remote" || echo "Local")"
# Check for package manager
if command -v apt-get &> /dev/null; then
# Update package index
apt-get update
# Install Node.js and npm if not already installed
if ! command_exists node; then
log "INFO" "Installing Node.js and npm..."
apt-get install -y ca-certificates curl gnupg
mkdir -p /etc/apt/keyrings
# Check if download succeeds
if ! curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg; then
log "ERROR" "Failed to download Node.js GPG key"
exit 1
fi
echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_18.x nodistro main" > /etc/apt/sources.list.d/nodesource.list
# Update again after adding repo
apt-get update
# Install nodejs
if ! apt-get install -y nodejs; then
log "ERROR" "Failed to install Node.js"
exit 1
fi
else
log "INFO" "Node.js is already installed."
fi
# Check if we need to install Transmission (only if local transmission was selected and not in update mode)
if [ "$TRANSMISSION_REMOTE" = false ] && [ "$IS_UPDATE" != "true" ]; then
if ! command_exists transmission-daemon; then
log "INFO" "Local Transmission installation selected, but transmission-daemon is not installed."
log "INFO" "You selected to use a local Transmission installation during configuration."
read -p "Would you like to install Transmission now? (y/n): " install_transmission
if [[ "$install_transmission" =~ ^[Yy]$ ]]; then
log "INFO" "Installing Transmission..."
if ! apt-get install -y transmission-daemon; then
log "ERROR" "Failed to install Transmission"
log "WARN" "You will need to install Transmission manually before using this application."
else
# Stop transmission-daemon to allow configuration changes
systemctl stop transmission-daemon
# Set default settings
TRANSMISSION_SETTINGS_DIR="/var/lib/transmission-daemon/info"
if [ -f "$TRANSMISSION_SETTINGS_DIR/settings.json" ]; then
# Backup original settings
cp "$TRANSMISSION_SETTINGS_DIR/settings.json" "$TRANSMISSION_SETTINGS_DIR/settings.json.bak"
# Update RPC settings to allow our app to connect
sed -i 's/"rpc-authentication-required": true,/"rpc-authentication-required": false,/g' "$TRANSMISSION_SETTINGS_DIR/settings.json"
sed -i 's/"rpc-whitelist-enabled": true,/"rpc-whitelist-enabled": false,/g' "$TRANSMISSION_SETTINGS_DIR/settings.json"
log "INFO" "Transmission has been configured for local access."
else
log "WARN" "Could not find Transmission settings file. You may need to configure Transmission manually."
fi
# Start transmission-daemon
systemctl start transmission-daemon
log "INFO" "Transmission has been installed and started."
fi
else
log "WARN" "Transmission installation skipped. You will need to install it manually."
fi
else
log "INFO" "Transmission is already installed."
fi
fi
# Install additional dependencies
log "INFO" "Installing additional dependencies..."
# Try to install unrar-free if unrar is not available
if ! apt-get install -y unrar 2>/dev/null; then
log "INFO" "unrar not available, trying unrar-free instead..."
apt-get install -y unrar-free
fi
# Install other dependencies
apt-get install -y unzip p7zip-full
# Try to install nginx
apt-get install -y nginx || log "WARN" "Nginx installation failed, web interface may not be accessible"
else
log "ERROR" "This installer requires apt-get package manager"
log "INFO" "Please install the following dependencies manually:"
log "INFO" "- Node.js (v18.x)"
log "INFO" "- npm"
log "INFO" "- unrar"
log "INFO" "- unzip"
log "INFO" "- p7zip-full"
log "INFO" "- nginx"
if [ "$TRANSMISSION_HOST" = "localhost" ] || [ "$TRANSMISSION_HOST" = "127.0.0.1" ]; then
log "INFO" "- transmission-daemon"
fi
exit 1
fi
# Check if all dependencies were installed successfully
local dependencies=("node" "npm" "unzip" "nginx")
local missing_deps=()
# Add transmission to dependencies check if local installation was selected
if [ "$TRANSMISSION_REMOTE" = false ]; then
dependencies+=("transmission-daemon")
fi
for dep in "${dependencies[@]}"; do
if ! command_exists "$dep"; then
missing_deps+=("$dep")
fi
done
# Check for either unrar or unrar-free
if ! command_exists "unrar" && ! command_exists "unrar-free"; then
missing_deps+=("unrar")
fi
# Check for either 7z or 7za (from p7zip-full)
if ! command_exists "7z" && ! command_exists "7za"; then
missing_deps+=("p7zip")
fi
if [ ${#missing_deps[@]} -eq 0 ]; then
log "INFO" "All dependencies installed successfully."
else
log "ERROR" "Failed to install some dependencies: ${missing_deps[*]}"
log "WARN" "Please install them manually and rerun this script."
# More helpful information based on which deps are missing
if [[ " ${missing_deps[*]} " =~ " node " ]]; then
log "INFO" "To install Node.js manually, visit: https://nodejs.org/en/download/"
fi
if [[ " ${missing_deps[*]} " =~ " nginx " ]]; then
log "INFO" "To install nginx manually: sudo apt-get install nginx"
fi
if [[ " ${missing_deps[*]} " =~ " transmission-daemon " ]]; then
log "INFO" "To install Transmission manually: sudo apt-get install transmission-daemon"
log "INFO" "After installation, you may need to configure it by editing /var/lib/transmission-daemon/info/settings.json"
fi
exit 1
fi
}
function create_directories() {
log "INFO" "Creating installation directories..."
# Check if INSTALL_DIR is defined
if [ -z "$INSTALL_DIR" ]; then
log "ERROR" "INSTALL_DIR is not defined"
exit 1
fi
# Check if CONFIG_DIR is defined
if [ -z "$CONFIG_DIR" ]; then
log "ERROR" "CONFIG_DIR is not defined"
exit 1
fi
# Create directories and check for errors
DIRECTORIES=(
"$INSTALL_DIR"
"$INSTALL_DIR/logs"
"$INSTALL_DIR/public/js"
"$INSTALL_DIR/public/css"
"$INSTALL_DIR/modules"
"$INSTALL_DIR/data"
"$CONFIG_DIR"
)
for dir in "${DIRECTORIES[@]}"; do
if ! mkdir -p "$dir"; then
log "ERROR" "Failed to create directory: $dir"
exit 1
fi
done
# Set permissions for configuration directory
chown -R "$USER:$USER" "$CONFIG_DIR"
chmod 755 "$CONFIG_DIR"
# Create a symlink from the installation directory to the config directory
# This ensures the application can find the config regardless of where it looks
ln -sf "$CONFIG_DIR/config.json" "$INSTALL_DIR/config.json"
log "INFO" "Directories created successfully."
}

1942
modules/file-creator-module.sh Executable file

File diff suppressed because it is too large Load Diff

1
modules/post-processor Symbolic link
View File

@ -0,0 +1 @@
post-processor.js

517
modules/post-processor.js Normal file
View File

@ -0,0 +1,517 @@
/**
* Post-Processor Module
* Handles the organization and processing of completed downloads
*/
const fs = require('fs').promises;
const path = require('path');
const util = require('util');
const exec = util.promisify(require('child_process').exec);
const crypto = require('crypto');
class PostProcessor {
constructor(config, transmissionClient) {
if (!config) {
throw new Error('Configuration is required for Post Processor');
}
if (!transmissionClient) {
throw new Error('Transmission client is required for Post Processor');
}
this.config = config;
this.transmissionClient = transmissionClient;
this.isProcessing = false;
this.processingQueue = [];
this.processIntervalId = null;
this.checkIntervalSeconds = config.seedingRequirements?.checkIntervalSeconds || 300;
this.destinationPaths = config.destinationPaths || {};
this.processingOptions = config.processingOptions || {};
}
/**
* Start the post-processor
* @returns {boolean} Whether the processor started successfully
*/
start() {
if (this.processIntervalId) {
console.log('Post-processor is already running');
return false;
}
console.log(`Starting post-processor, check interval: ${this.checkIntervalSeconds} seconds`);
// Run immediately
this.checkCompletedDownloads();
// Then set up interval
this.processIntervalId = setInterval(() => {
this.checkCompletedDownloads();
}, this.checkIntervalSeconds * 1000);
return true;
}
/**
* Stop the post-processor
* @returns {boolean} Whether the processor stopped successfully
*/
stop() {
if (!this.processIntervalId) {
console.log('Post-processor is not running');
return false;
}
clearInterval(this.processIntervalId);
this.processIntervalId = null;
console.log('Post-processor stopped');
return true;
}
/**
* Check for completed downloads that meet seeding requirements
*/
async checkCompletedDownloads() {
if (this.isProcessing) {
console.log('Post-processor is already running a processing cycle, skipping');
return;
}
this.isProcessing = true;
try {
console.log('Checking for completed downloads...');
// Get all torrents
const torrentsResult = await this.transmissionClient.getTorrents();
if (!torrentsResult.success) {
console.error('Failed to get torrents from Transmission:', torrentsResult.error);
this.isProcessing = false;
return;
}
const torrents = torrentsResult.torrents;
// Filter completed torrents
const completedTorrents = torrents.filter(torrent =>
torrent.percentDone === 1 && // Fully downloaded
torrent.status !== 0 && // Not stopped
torrent.doneDate > 0 // Has a completion date
);
console.log(`Found ${completedTorrents.length} completed torrents`);
// Check each completed torrent against requirements
for (const torrent of completedTorrents) {
// Skip already processed torrents
if (this.processingQueue.includes(torrent.id)) {
continue;
}
// Check if it meets seeding requirements
const reqResult = await this.transmissionClient.verifyTorrentSeedingRequirements(
torrent.id,
this.config.seedingRequirements || {}
);
if (!reqResult.success) {
console.error(`Error checking requirements for ${torrent.name}:`, reqResult.error);
continue;
}
if (reqResult.requirementsMet) {
console.log(`Torrent ${torrent.name} has met seeding requirements, queuing for processing`);
// Add to processing queue
this.processingQueue.push(torrent.id);
// Process the torrent
await this.processTorrent(reqResult.torrent);
// Remove from queue after processing
this.processingQueue = this.processingQueue.filter(id => id !== torrent.id);
} else {
const { currentRatio, currentSeedingTimeMinutes } = reqResult;
const { minRatio, minTimeMinutes } = this.config.seedingRequirements || { minRatio: 1.0, minTimeMinutes: 60 };
console.log(`Torrent ${torrent.name} has not met seeding requirements yet:`);
console.log(`- Ratio: ${currentRatio.toFixed(2)} / ${minRatio} (${reqResult.ratioMet ? 'Met' : 'Not Met'})`);
console.log(`- Time: ${Math.floor(currentSeedingTimeMinutes)} / ${minTimeMinutes} minutes (${reqResult.timeMet ? 'Met' : 'Not Met'})`);
}
}
} catch (error) {
console.error('Error in post-processor cycle:', error);
} finally {
this.isProcessing = false;
}
}
/**
* Process a completed torrent
* @param {Object} torrent - Torrent object
*/
async processTorrent(torrent) {
console.log(`Processing torrent: ${torrent.name}`);
try {
// Get detailed info with file analysis
const details = await this.transmissionClient.getTorrentDetails(torrent.id);
if (!details.success) {
console.error(`Failed to get details for torrent ${torrent.name}:`, details.error);
return;
}
torrent = details.torrent;
const mediaInfo = torrent.mediaInfo || { type: 'unknown' };
console.log(`Detected media type: ${mediaInfo.type}`);
// Determine destination path based on content type
let destinationDir = this.getDestinationPath(mediaInfo.type);
if (!destinationDir) {
console.error(`No destination directory configured for media type: ${mediaInfo.type}`);
return;
}
// Create the destination directory if it doesn't exist
await this.createDirectoryIfNotExists(destinationDir);
// If we're creating category folders, add category-specific subdirectory
if (this.processingOptions.createCategoryFolders) {
const categoryFolder = this.getCategoryFolder(torrent, mediaInfo);
if (categoryFolder) {
destinationDir = path.join(destinationDir, categoryFolder);
await this.createDirectoryIfNotExists(destinationDir);
}
}
console.log(`Processing to destination: ${destinationDir}`);
// Process files based on content type
if (mediaInfo.type === 'archive' && this.processingOptions.extractArchives) {
await this.processArchives(torrent, mediaInfo, destinationDir);
} else {
await this.processStandardFiles(torrent, mediaInfo, destinationDir);
}
console.log(`Finished processing torrent: ${torrent.name}`);
} catch (error) {
console.error(`Error processing torrent ${torrent.name}:`, error);
}
}
/**
* Get the appropriate destination path for a media type
* @param {string} mediaType - Type of media
* @returns {string} Destination path
*/
getDestinationPath(mediaType) {
switch (mediaType) {
case 'movie':
return this.destinationPaths.movies;
case 'tvshow':
return this.destinationPaths.tvShows;
case 'audio':
return this.destinationPaths.music;
case 'book':
return this.destinationPaths.books;
case 'magazine':
return this.destinationPaths.magazines;
default:
return this.destinationPaths.software;
}
}
/**
* Generate a category folder name based on the content
* @param {Object} torrent - Torrent object
* @param {Object} mediaInfo - Media information
* @returns {string} Folder name
*/
getCategoryFolder(torrent, mediaInfo) {
const name = torrent.name;
switch (mediaInfo.type) {
case 'movie': {
// For movies, use the first letter of the title
const firstLetter = name.replace(/^[^a-zA-Z0-9]+/, '').charAt(0).toUpperCase();
return firstLetter || '#';
}
case 'tvshow': {
// For TV shows, extract the show name
const showName = name.replace(/[sS]\d{2}[eE]\d{2}.*$/, '').trim();
return showName;
}
case 'audio': {
// For music, try to extract artist name
const artistMatch = name.match(/^(.*?)\s*-\s*/);
return artistMatch ? artistMatch[1].trim() : 'Unsorted';
}
case 'book': {
// For books, use the first letter of title or author names
const firstLetter = name.replace(/^[^a-zA-Z0-9]+/, '').charAt(0).toUpperCase();
return firstLetter || '#';
}
case 'magazine': {
// For magazines, use the magazine name if possible
const magazineMatch = name.match(/^(.*?)\s*(?:Issue|Vol|Volume)/i);
return magazineMatch ? magazineMatch[1].trim() : 'Unsorted';
}
default:
return null;
}
}
/**
* Process archive files (extract them)
* @param {Object} torrent - Torrent object
* @param {Object} mediaInfo - Media information
* @param {string} destinationDir - Destination directory
*/
async processArchives(torrent, mediaInfo, destinationDir) {
console.log(`Processing archives in ${torrent.name}`);
const archiveFiles = mediaInfo.archiveFiles;
const torrentDir = torrent.downloadDir;
for (const file of archiveFiles) {
const filePath = path.join(torrentDir, file.name);
try {
// Create a unique extraction directory
const extractionDirName = path.basename(file.name, path.extname(file.name));
const extractionDir = path.join(destinationDir, extractionDirName);
await this.createDirectoryIfNotExists(extractionDir);
console.log(`Extracting ${filePath} to ${extractionDir}`);
// Extract the archive based on type
if (/\.zip$/i.test(file.name)) {
await exec(`unzip -o "${filePath}" -d "${extractionDir}"`);
} else if (/\.rar$/i.test(file.name)) {
await exec(`unrar x -o+ "${filePath}" "${extractionDir}"`);
} else if (/\.7z$/i.test(file.name)) {
await exec(`7z x "${filePath}" -o"${extractionDir}"`);
} else if (/\.tar(\.(gz|bz2|xz))?$/i.test(file.name)) {
await exec(`tar -xf "${filePath}" -C "${extractionDir}"`);
} else {
console.log(`Unknown archive format for ${file.name}, skipping extraction`);
continue;
}
console.log(`Successfully extracted ${file.name}`);
// Delete archive if option is enabled
if (this.processingOptions.deleteArchives) {
try {
console.log(`Deleting archive after extraction: ${filePath}`);
await fs.unlink(filePath);
} catch (deleteError) {
console.error(`Failed to delete archive ${filePath}:`, deleteError);
}
}
} catch (error) {
console.error(`Error extracting archive ${filePath}:`, error);
}
}
}
/**
* Process standard (non-archive) files
* @param {Object} torrent - Torrent object
* @param {Object} mediaInfo - Media information
* @param {string} destinationDir - Destination directory
*/
async processStandardFiles(torrent, mediaInfo, destinationDir) {
console.log(`Processing standard files in ${torrent.name}`);
const torrentDir = torrent.downloadDir;
const allFiles = [];
// Collect all files based on media type
switch (mediaInfo.type) {
case 'movie':
case 'tvshow':
allFiles.push(...mediaInfo.videoFiles);
break;
case 'audio':
allFiles.push(...mediaInfo.audioFiles);
break;
case 'book':
case 'magazine':
allFiles.push(...mediaInfo.documentFiles);
break;
default:
// For unknown/software, add all files except samples if enabled
for (const type of Object.keys(mediaInfo)) {
if (Array.isArray(mediaInfo[type])) {
allFiles.push(...mediaInfo[type]);
}
}
}
// Filter out sample files if option is enabled
let filesToProcess = allFiles;
if (this.processingOptions.ignoreSample) {
filesToProcess = allFiles.filter(file => !file.isSample);
console.log(`Filtered out ${allFiles.length - filesToProcess.length} sample files`);
}
// Process each file
for (const file of filesToProcess) {
const sourceFilePath = path.join(torrentDir, file.name);
let destFileName = file.name;
// Generate a better filename if rename option is enabled
if (this.processingOptions.renameFiles) {
destFileName = this.generateBetterFilename(file.name, mediaInfo.type);
}
const destFilePath = path.join(destinationDir, destFileName);
try {
// Check if destination file already exists with the same name
const fileExists = await this.fileExists(destFilePath);
if (fileExists) {
if (this.processingOptions.autoReplaceUpgrades) {
// Compare file sizes to see if the new one is larger (potentially higher quality)
const existingStats = await fs.stat(destFilePath);
if (file.size > existingStats.size) {
console.log(`Replacing existing file with larger version: ${destFilePath}`);
await fs.copyFile(sourceFilePath, destFilePath);
} else {
console.log(`Skipping ${file.name}, existing file is same or better quality`);
}
} else {
// Generate a unique filename
const uniqueDestFilePath = this.makeFilenameUnique(destFilePath);
console.log(`Copying ${file.name} to ${uniqueDestFilePath}`);
await fs.copyFile(sourceFilePath, uniqueDestFilePath);
}
} else {
// File doesn't exist, simple copy
console.log(`Copying ${file.name} to ${destFilePath}`);
await fs.copyFile(sourceFilePath, destFilePath);
}
} catch (error) {
console.error(`Error processing file ${file.name}:`, error);
}
}
}
/**
* Generate a better filename based on content type
* @param {string} originalFilename - Original filename
* @param {string} mediaType - Media type
* @returns {string} Improved filename
*/
generateBetterFilename(originalFilename, mediaType) {
// Get the file extension
const ext = path.extname(originalFilename);
const basename = path.basename(originalFilename, ext);
// Clean up common issues in filenames
let cleanName = basename
.replace(/\[.*?\]|\(.*?\)/g, '') // Remove content in brackets/parentheses
.replace(/\._/g, '.') // Remove underscore after dots
.replace(/\./g, ' ') // Replace dots with spaces
.replace(/_/g, ' ') // Replace underscores with spaces
.replace(/\s{2,}/g, ' ') // Replace multiple spaces with a single one
.trim();
// Media type specific formatting
switch (mediaType) {
case 'movie':
// Keep (year) format for movies if present
const yearMatch = basename.match(/\(*(19|20)\d{2}\)*$/);
if (yearMatch) {
const year = yearMatch[0].replace(/[()]/g, '');
// Remove any year that might have been part of the clean name already
cleanName = cleanName.replace(/(19|20)\d{2}/g, '').trim();
// Add the year in a consistent format
cleanName = `${cleanName} (${year})`;
}
break;
case 'tvshow':
// Keep season and episode info for TV shows
const episodeMatch = basename.match(/[sS](\d{1,2})[eE](\d{1,2})/);
if (episodeMatch) {
const seasonNum = parseInt(episodeMatch[1], 10);
const episodeNum = parseInt(episodeMatch[2], 10);
// First, remove any existing season/episode info from clean name
cleanName = cleanName.replace(/[sS]\d{1,2}[eE]\d{1,2}/g, '').trim();
// Add back the season/episode in a consistent format
cleanName = `${cleanName} S${seasonNum.toString().padStart(2, '0')}E${episodeNum.toString().padStart(2, '0')}`;
}
break;
case 'audio':
// Try to organize as "Artist - Title" for music
const musicMatch = basename.match(/^(.*?)\s*-\s*(.*?)$/);
if (musicMatch && musicMatch[1] && musicMatch[2]) {
const artist = musicMatch[1].trim();
const title = musicMatch[2].trim();
cleanName = `${artist} - ${title}`;
}
break;
}
return cleanName + ext;
}
/**
* Make a filename unique by adding a suffix
* @param {string} filepath - Original filepath
* @returns {string} Unique filepath
*/
makeFilenameUnique(filepath) {
const ext = path.extname(filepath);
const basename = path.basename(filepath, ext);
const dirname = path.dirname(filepath);
// Add a timestamp to make it unique
const timestamp = new Date().toISOString().replace(/[-:]/g, '').replace('T', '_').substring(0, 15);
return path.join(dirname, `${basename}_${timestamp}${ext}`);
}
/**
* Create a directory if it doesn't exist
* @param {string} dirPath - Directory path
*/
async createDirectoryIfNotExists(dirPath) {
try {
await fs.mkdir(dirPath, { recursive: true });
} catch (error) {
// Ignore error if directory already exists
if (error.code !== 'EEXIST') {
throw error;
}
}
}
/**
* Check if a file exists
* @param {string} filePath - File path
* @returns {Promise<boolean>} Whether the file exists
*/
async fileExists(filePath) {
try {
await fs.access(filePath);
return true;
} catch {
return false;
}
}
}
module.exports = PostProcessor;

1
modules/postProcessor Symbolic link
View File

@ -0,0 +1 @@
post-processor.js

1
modules/postProcessor.js Symbolic link
View File

@ -0,0 +1 @@
post-processor.js

1
modules/rss-feed-manager Symbolic link
View File

@ -0,0 +1 @@
rss-feed-manager.js

787
modules/rss-feed-manager.js Normal file
View File

@ -0,0 +1,787 @@
// rss-feed-manager.js - Handles RSS feed fetching, parsing, and torrent management
const fs = require('fs').promises;
const path = require('path');
const fetch = require('node-fetch');
const xml2js = require('xml2js');
const crypto = require('crypto');
class RssFeedManager {
constructor(config) {
if (!config) {
throw new Error('Configuration is required');
}
this.config = config;
this.feeds = config.feeds || [];
this.items = [];
this.updateIntervalId = null;
this.updateIntervalMinutes = config.updateIntervalMinutes || 60;
this.parser = new xml2js.Parser({ explicitArray: false });
// Set up the data path - first check if a data path was provided in the config
if (config.dataPath) {
this.dataPath = config.dataPath;
} else {
// Otherwise, use the default path relative to this module
this.dataPath = path.join(__dirname, '..', 'data');
// Log the data path for debugging
console.log(`Data directory path set to: ${this.dataPath}`);
}
// We'll always ensure the data directory exists regardless of where it's set
// Use synchronous operation to ensure directory exists immediately upon construction
try {
const fsSync = require('fs');
if (!fsSync.existsSync(this.dataPath)) {
fsSync.mkdirSync(this.dataPath, { recursive: true });
console.log(`Created data directory synchronously: ${this.dataPath}`);
}
} catch (err) {
console.warn(`Warning: Could not create data directory synchronously: ${err.message}`);
// Will try again asynchronously in ensureDataDirectory when start() is called
}
// Maximum items to keep in memory to prevent memory leaks
this.maxItemsInMemory = config.maxItemsInMemory || 5000;
}
async start() {
if (this.updateIntervalId) {
return;
}
try {
// Make sure the data directory exists first
await this.ensureDataDirectory();
console.log(`Using data directory: ${this.dataPath}`);
// Load existing feeds and items
await this.loadFeeds();
await this.loadItems();
// Run update immediately
await this.updateAllFeeds().catch(error => {
console.error('Error in initial feed update:', error);
});
// Then set up interval
this.updateIntervalId = setInterval(async () => {
await this.updateAllFeeds().catch(error => {
console.error('Error in scheduled feed update:', error);
});
}, this.updateIntervalMinutes * 60 * 1000);
console.log(`RSS feed manager started, interval: ${this.updateIntervalMinutes} minutes`);
} catch (error) {
console.error('Failed to start RSS feed manager:', error);
throw error;
}
}
stop() {
if (this.updateIntervalId) {
clearInterval(this.updateIntervalId);
this.updateIntervalId = null;
console.log('RSS feed manager stopped');
return true;
}
return false;
}
async updateAllFeeds() {
console.log('Updating all RSS feeds...');
const results = [];
// Check if feeds array is valid
if (!Array.isArray(this.feeds)) {
console.error('Feeds is not an array:', this.feeds);
this.feeds = [];
return results;
}
for (const feed of this.feeds) {
if (!feed || !feed.id || !feed.url) {
console.error('Invalid feed object:', feed);
continue;
}
try {
const result = await this.updateFeed(feed);
results.push({
feedId: feed.id,
success: true,
newItems: result.newItems
});
} catch (error) {
console.error(`Error updating feed ${feed.id} (${feed.url}):`, error.message);
results.push({
feedId: feed.id,
success: false,
error: error.message
});
}
}
try {
// Save updated items and truncate if necessary
this.trimItemsIfNeeded();
await this.saveItems();
await this.saveFeeds();
} catch (error) {
console.error('Error saving data after feed update:', error);
}
console.log('RSS feed update completed');
return results;
}
// Trim items to prevent memory bloat
trimItemsIfNeeded() {
if (this.items.length > this.maxItemsInMemory) {
console.log(`Trimming items from ${this.items.length} to ${this.maxItemsInMemory}`);
// Sort by date (newest first) and keep only the newest maxItemsInMemory items
this.items.sort((a, b) => new Date(b.added) - new Date(a.added));
this.items = this.items.slice(0, this.maxItemsInMemory);
}
}
async updateFeed(feed) {
if (!feed || !feed.url) {
throw new Error('Invalid feed configuration');
}
console.log(`Updating feed: ${feed.name || 'Unnamed'} (${feed.url})`);
try {
// Get version from package.json if available, fallback to environment or hardcoded
const version = process.env.APP_VERSION || require('../package.json').version || '2.0.9';
const response = await fetch(feed.url, {
timeout: 30000, // 30 second timeout
headers: {
'User-Agent': `Transmission-RSS-Manager/${version}`
}
});
if (!response.ok) {
throw new Error(`HTTP error ${response.status}: ${response.statusText}`);
}
const xml = await response.text();
if (!xml || xml.trim() === '') {
throw new Error('Empty feed content');
}
const result = await this.parseXml(xml);
if (!result) {
throw new Error('Failed to parse XML feed');
}
const rssItems = this.extractItems(result, feed);
const newItems = this.processNewItems(rssItems, feed);
console.log(`Found ${rssItems.length} items, ${newItems.length} new items in feed: ${feed.name || 'Unnamed'}`);
return {
totalItems: rssItems.length,
newItems: newItems.length
};
} catch (error) {
console.error(`Error updating feed ${feed.url}:`, error);
throw error;
}
}
parseXml(xml) {
if (!xml || typeof xml !== 'string') {
return Promise.reject(new Error('Invalid XML input'));
}
return new Promise((resolve, reject) => {
this.parser.parseString(xml, (error, result) => {
if (error) {
reject(error);
} else {
resolve(result);
}
});
});
}
extractItems(parsedXml, feed) {
if (!parsedXml || !feed) {
console.error('Invalid parsed XML or feed');
return [];
}
try {
// Handle standard RSS 2.0
if (parsedXml.rss && parsedXml.rss.channel) {
const channel = parsedXml.rss.channel;
if (!channel.item) {
return [];
}
const items = Array.isArray(channel.item)
? channel.item.filter(Boolean)
: (channel.item ? [channel.item] : []);
return items.map(item => this.normalizeRssItem(item, feed));
}
// Handle Atom
if (parsedXml.feed && parsedXml.feed.entry) {
const entries = Array.isArray(parsedXml.feed.entry)
? parsedXml.feed.entry.filter(Boolean)
: (parsedXml.feed.entry ? [parsedXml.feed.entry] : []);
return entries.map(entry => this.normalizeAtomItem(entry, feed));
}
return [];
} catch (error) {
console.error('Error extracting items from XML:', error);
return [];
}
}
normalizeRssItem(item, feed) {
if (!item || !feed) {
console.error('Invalid RSS item or feed');
return null;
}
try {
// Create a unique ID for the item
const title = item.title || 'Untitled';
const pubDate = item.pubDate || '';
const link = item.link || '';
const idContent = `${feed.id}:${title}:${pubDate}:${link}`;
const id = crypto.createHash('md5').update(idContent).digest('hex');
// Extract enclosure (torrent link)
let torrentLink = link;
let fileSize = 0;
if (item.enclosure) {
if (item.enclosure.$) {
torrentLink = item.enclosure.$.url || torrentLink;
fileSize = parseInt(item.enclosure.$.length || 0, 10);
} else if (typeof item.enclosure === 'object') {
torrentLink = item.enclosure.url || torrentLink;
fileSize = parseInt(item.enclosure.length || 0, 10);
}
}
// Handle custom namespaces (common in torrent feeds)
let category = '';
let size = fileSize;
if (item.category) {
category = Array.isArray(item.category) ? item.category[0] : item.category;
// Handle if category is an object with a value property
if (typeof category === 'object' && category._) {
category = category._;
}
}
// Some feeds use torrent:contentLength
if (item['torrent:contentLength']) {
const contentLength = parseInt(item['torrent:contentLength'], 10);
if (!isNaN(contentLength)) {
size = contentLength;
}
}
return {
id,
feedId: feed.id,
title,
link,
torrentLink,
pubDate: pubDate || new Date().toISOString(),
category: category || '',
description: item.description || '',
size: !isNaN(size) ? size : 0,
downloaded: false,
ignored: false,
added: new Date().toISOString()
};
} catch (error) {
console.error('Error normalizing RSS item:', error);
return null;
}
}
normalizeAtomItem(entry, feed) {
if (!entry || !feed) {
console.error('Invalid Atom entry or feed');
return null;
}
try {
// Create a unique ID for the item
const title = entry.title || 'Untitled';
const updated = entry.updated || '';
const entryId = entry.id || '';
const idContent = `${feed.id}:${title}:${updated}:${entryId}`;
const id = crypto.createHash('md5').update(idContent).digest('hex');
// Extract link
let link = '';
let torrentLink = '';
if (entry.link) {
if (Array.isArray(entry.link)) {
const links = entry.link.filter(l => l && l.$);
const alternateLink = links.find(l => l.$ && l.$.rel === 'alternate');
const torrentTypeLink = links.find(l => l.$ && l.$.type && l.$.type.includes('torrent'));
link = alternateLink && alternateLink.$ && alternateLink.$.href ?
alternateLink.$.href :
(links[0] && links[0].$ && links[0].$.href ? links[0].$.href : '');
torrentLink = torrentTypeLink && torrentTypeLink.$ && torrentTypeLink.$.href ?
torrentTypeLink.$.href : link;
} else if (entry.link.$ && entry.link.$.href) {
link = entry.link.$.href;
torrentLink = link;
}
}
// Extract category
let category = '';
if (entry.category && entry.category.$ && entry.category.$.term) {
category = entry.category.$.term;
}
// Extract content
let description = '';
if (entry.summary) {
description = entry.summary;
} else if (entry.content) {
description = entry.content;
}
return {
id,
feedId: feed.id,
title,
link,
torrentLink,
pubDate: entry.updated || entry.published || new Date().toISOString(),
category,
description,
size: 0, // Atom doesn't typically include file size
downloaded: false,
ignored: false,
added: new Date().toISOString()
};
} catch (error) {
console.error('Error normalizing Atom item:', error);
return null;
}
}
processNewItems(rssItems, feed) {
if (!Array.isArray(rssItems) || !feed) {
console.error('Invalid RSS items array or feed');
return [];
}
const newItems = [];
// Filter out null items
const validItems = rssItems.filter(item => item !== null);
for (const item of validItems) {
// Check if item already exists in our list
const existingItem = this.items.find(i => i.id === item.id);
if (!existingItem) {
// Add new item to our list
this.items.push(item);
newItems.push(item);
// Auto-download if enabled and matches filters
if (feed.autoDownload && this.matchesFilters(item, feed.filters)) {
this.queueItemForDownload(item);
}
}
}
return newItems;
}
matchesFilters(item, filters) {
if (!item) return false;
if (!filters || !Array.isArray(filters) || filters.length === 0) {
return true;
}
// Check if the item matches any of the filters
return filters.some(filter => {
if (!filter) return true;
// Title check
if (filter.title && typeof item.title === 'string' &&
!item.title.toLowerCase().includes(filter.title.toLowerCase())) {
return false;
}
// Category check
if (filter.category && typeof item.category === 'string' &&
!item.category.toLowerCase().includes(filter.category.toLowerCase())) {
return false;
}
// Size checks
if (filter.minSize && typeof item.size === 'number' && item.size < filter.minSize) {
return false;
}
if (filter.maxSize && typeof item.size === 'number' && item.size > filter.maxSize) {
return false;
}
// All checks passed
return true;
});
}
queueItemForDownload(item) {
if (!item) return;
// Mark the item as queued for download
console.log(`Auto-downloading item: ${item.title}`);
// This would be implemented to add to Transmission
// But we need a reference to the Transmission client
// In a real implementation, this might publish to a queue that's consumed elsewhere
item.downloadQueued = true;
}
async saveItems() {
try {
// Ensure data directory exists
await this.ensureDataDirectory();
// Save items to file
await fs.writeFile(
path.join(this.dataPath, 'rss-items.json'),
JSON.stringify(this.items, null, 2),
'utf8'
);
console.log(`Saved ${this.items.length} RSS items to disk`);
return true;
} catch (error) {
console.error('Error saving RSS items:', error);
return false;
}
}
async saveFeeds() {
try {
// Ensure data directory exists
await this.ensureDataDirectory();
// Save feeds to file
await fs.writeFile(
path.join(this.dataPath, 'rss-feeds.json'),
JSON.stringify(this.feeds, null, 2),
'utf8'
);
console.log(`Saved ${this.feeds.length} RSS feeds to disk`);
return true;
} catch (error) {
console.error('Error saving RSS feeds:', error);
return false;
}
}
/**
* Ensures the data directory exists, using a consistent approach across the application
* @returns {Promise<boolean>} true if directory exists or was created
*/
async ensureDataDirectory() {
try {
// Create data directory with recursive option (creates all parent directories if they don't exist)
await fs.mkdir(this.dataPath, { recursive: true });
console.log(`Ensured data directory exists at: ${this.dataPath}`);
return true;
} catch (error) {
// Log the error details for debugging
console.error(`Error creating data directory ${this.dataPath}:`, error);
// Try an alternate approach if the first method fails
try {
const { execSync } = require('child_process');
execSync(`mkdir -p "${this.dataPath}"`);
console.log(`Created data directory using fallback method: ${this.dataPath}`);
return true;
} catch (fallbackError) {
console.error('All methods for creating data directory failed:', fallbackError);
throw new Error(`Failed to create data directory: ${this.dataPath}. Original error: ${error.message}`);
}
}
}
async loadItems() {
try {
const filePath = path.join(this.dataPath, 'rss-items.json');
// Check if file exists
try {
await fs.access(filePath);
} catch (error) {
console.log('No saved RSS items found');
this.items = [];
return false;
}
// Load items from file
const data = await fs.readFile(filePath, 'utf8');
if (!data || data.trim() === '') {
console.log('Empty RSS items file');
this.items = [];
return false;
}
try {
const items = JSON.parse(data);
if (Array.isArray(items)) {
this.items = items;
console.log(`Loaded ${this.items.length} RSS items from disk`);
return true;
} else {
console.error('RSS items file does not contain an array');
this.items = [];
return false;
}
} catch (parseError) {
console.error('Error parsing RSS items JSON:', parseError);
this.items = [];
return false;
}
} catch (error) {
console.error('Error loading RSS items:', error);
this.items = [];
return false;
}
}
async loadFeeds() {
try {
const filePath = path.join(this.dataPath, 'rss-feeds.json');
// Check if file exists
try {
await fs.access(filePath);
} catch (error) {
console.log('No saved RSS feeds found, using config feeds');
return false;
}
// Load feeds from file
const data = await fs.readFile(filePath, 'utf8');
if (!data || data.trim() === '') {
console.log('Empty RSS feeds file, using config feeds');
return false;
}
try {
const feeds = JSON.parse(data);
if (Array.isArray(feeds)) {
this.feeds = feeds;
console.log(`Loaded ${this.feeds.length} RSS feeds from disk`);
return true;
} else {
console.error('RSS feeds file does not contain an array');
return false;
}
} catch (parseError) {
console.error('Error parsing RSS feeds JSON:', parseError);
return false;
}
} catch (error) {
console.error('Error loading RSS feeds:', error);
return false;
}
}
// Public API methods
getAllFeeds() {
return Array.isArray(this.feeds) ? this.feeds : [];
}
addFeed(feedData) {
if (!feedData || !feedData.url) {
throw new Error('Feed URL is required');
}
// Generate an ID for the feed
const id = crypto.randomBytes(8).toString('hex');
const newFeed = {
id,
name: feedData.name || 'Unnamed Feed',
url: feedData.url,
autoDownload: !!feedData.autoDownload,
filters: Array.isArray(feedData.filters) ? feedData.filters : [],
added: new Date().toISOString()
};
if (!Array.isArray(this.feeds)) {
this.feeds = [];
}
this.feeds.push(newFeed);
// Save the updated feeds
this.saveFeeds().catch(err => {
console.error('Error saving feeds after adding new feed:', err);
});
console.log(`Added new feed: ${newFeed.name} (${newFeed.url})`);
return newFeed;
}
updateFeedConfig(feedId, updates) {
if (!feedId || !updates) {
return false;
}
if (!Array.isArray(this.feeds)) {
console.error('Feeds is not an array');
return false;
}
const feedIndex = this.feeds.findIndex(f => f && f.id === feedId);
if (feedIndex === -1) {
console.error(`Feed with ID ${feedId} not found`);
return false;
}
// Update the feed, preserving the id and added date
this.feeds[feedIndex] = {
...this.feeds[feedIndex],
...updates,
id: feedId,
added: this.feeds[feedIndex].added
};
// Save the updated feeds
this.saveFeeds().catch(err => {
console.error('Error saving feeds after updating feed:', err);
});
console.log(`Updated feed: ${this.feeds[feedIndex].name}`);
return true;
}
removeFeed(feedId) {
if (!feedId || !Array.isArray(this.feeds)) {
return false;
}
const initialLength = this.feeds.length;
this.feeds = this.feeds.filter(f => f && f.id !== feedId);
if (this.feeds.length !== initialLength) {
// Save the updated feeds
this.saveFeeds().catch(err => {
console.error('Error saving feeds after removing feed:', err);
});
return true;
}
return false;
}
getAllItems() {
return Array.isArray(this.items) ? this.items : [];
}
getUndownloadedItems() {
if (!Array.isArray(this.items)) {
return [];
}
return this.items.filter(item => item && !item.downloaded && !item.ignored);
}
filterItems(filters) {
if (!filters || !Array.isArray(this.items)) {
return [];
}
return this.items.filter(item => item && this.matchesFilters(item, [filters]));
}
async downloadItem(item, transmissionClient) {
if (!item || !item.torrentLink) {
return {
success: false,
message: 'Invalid item or missing torrent link'
};
}
if (!transmissionClient) {
return {
success: false,
message: 'Transmission client not available'
};
}
return new Promise((resolve) => {
transmissionClient.addUrl(item.torrentLink, async (err, result) => {
if (err) {
console.error(`Error adding torrent for ${item.title}:`, err);
resolve({
success: false,
message: `Error adding torrent: ${err.message}`,
result: null
});
return;
}
// Mark the item as downloaded
item.downloaded = true;
item.downloadDate = new Date().toISOString();
// Save the updated items
try {
await this.saveItems();
} catch (err) {
console.error('Error saving items after download:', err);
}
console.log(`Successfully added torrent for item: ${item.title}`);
resolve({
success: true,
message: 'Torrent added successfully',
result
});
});
});
}
}
module.exports = RssFeedManager;

1
modules/rssFeedManager Symbolic link
View File

@ -0,0 +1 @@
rss-feed-manager.js

1
modules/rssFeedManager.js Symbolic link
View File

@ -0,0 +1 @@
rss-feed-manager.js

View File

@ -0,0 +1,324 @@
#!/bin/bash
# Service setup module for Transmission RSS Manager Installation
# Setup systemd service
function setup_service() {
log "INFO" "Setting up systemd service..."
# Ensure required variables are set
if [ -z "$SERVICE_NAME" ]; then
log "ERROR" "SERVICE_NAME variable is not set"
exit 1
fi
if [ -z "$USER" ]; then
log "ERROR" "USER variable is not set"
exit 1
fi
if [ -z "$INSTALL_DIR" ]; then
log "ERROR" "INSTALL_DIR variable is not set"
exit 1
fi
if [ -z "$CONFIG_DIR" ]; then
log "ERROR" "CONFIG_DIR variable is not set"
exit 1
fi
if [ -z "$PORT" ]; then
log "ERROR" "PORT variable is not set"
exit 1
fi
# Check if systemd is available
if ! command -v systemctl &> /dev/null; then
log "ERROR" "systemd is not available on this system"
log "INFO" "Please set up the service manually using your system's service manager"
return 1
fi
# Ensure the test-and-start script exists and is executable
TEST_START_SCRIPT="$INSTALL_DIR/scripts/test-and-start.sh"
mkdir -p "$(dirname "$TEST_START_SCRIPT")"
cat > "$TEST_START_SCRIPT" << 'EOF'
#!/bin/bash
# Script to ensure data directory exists and start the application
# Define paths
APP_DIR="$(dirname "$(dirname "$(readlink -f "$0")")")"
DATA_DIR="$APP_DIR/data"
echo "Starting Transmission RSS Manager..."
echo "Application directory: $APP_DIR"
echo "Data directory: $DATA_DIR"
# Ensure the data directory exists
if [ ! -d "$DATA_DIR" ]; then
echo "Creating data directory: $DATA_DIR"
mkdir -p "$DATA_DIR"
if [ $? -ne 0 ]; then
echo "Failed to create data directory. Trying alternative method..."
# Try alternative method if standard mkdir fails
cd "$APP_DIR" && mkdir -p data
if [ $? -ne 0 ]; then
echo "ERROR: Both methods to create data directory failed. Please check permissions."
exit 1
fi
fi
fi
# Set permissions
chmod -R 755 "$DATA_DIR"
# Check for RSS files
if [ ! -f "$DATA_DIR/rss-feeds.json" ]; then
echo "Creating initial empty rss-feeds.json file"
echo "[]" > "$DATA_DIR/rss-feeds.json"
fi
if [ ! -f "$DATA_DIR/rss-items.json" ]; then
echo "Creating initial empty rss-items.json file"
echo "[]" > "$DATA_DIR/rss-items.json"
fi
# Find the node executable path
NODE_PATH=$(which node 2>/dev/null)
if [ -z "$NODE_PATH" ]; then
# If node is not in PATH, try common locations
for path in /usr/bin/node /usr/local/bin/node /opt/node/bin/node /usr/lib/node; do
if [ -x "$path" ]; then
NODE_PATH="$path"
break
fi
done
# If we still can't find node, use the default path
if [ -z "$NODE_PATH" ]; then
NODE_PATH="/usr/bin/node"
echo "Warning: Node.js not found in PATH, using default path: $NODE_PATH"
fi
fi
# Start the application
cd "$APP_DIR" || { echo "Failed to change to application directory"; exit 1; }
echo "Starting node.js application with: $NODE_PATH $APP_DIR/server.js"
exec "$NODE_PATH" "$APP_DIR/server.js"
EOF
chmod +x "$TEST_START_SCRIPT"
log "INFO" "Created test-and-start script at $TEST_START_SCRIPT"
# Check if service file already exists
SERVICE_FILE="/etc/systemd/system/$SERVICE_NAME.service"
if [ -f "$SERVICE_FILE" ] && [ "$IS_UPDATE" = true ]; then
log "INFO" "Service file already exists. Preserving existing service configuration."
# Extract existing JWT_SECRET if present to maintain session consistency
EXISTING_JWT_SECRET=$(grep "Environment=JWT_SECRET=" "$SERVICE_FILE" | cut -d'=' -f3)
# Extract existing PORT if it differs from the configured one
EXISTING_PORT=$(grep "Environment=PORT=" "$SERVICE_FILE" | cut -d'=' -f3)
if [ -n "$EXISTING_PORT" ] && [ "$EXISTING_PORT" != "$PORT" ]; then
log "INFO" "Using existing port configuration: $EXISTING_PORT"
PORT=$EXISTING_PORT
fi
# Create backup of existing service file
backup_file "$SERVICE_FILE"
# Update the service file while preserving key settings
cat > "$SERVICE_FILE" << EOF
[Unit]
Description=Transmission RSS Manager
After=network.target transmission-daemon.service
Wants=network-online.target
[Service]
Type=simple
User=$USER
WorkingDirectory=$INSTALL_DIR
ExecStart=$TEST_START_SCRIPT
Restart=always
RestartSec=10
StandardOutput=journal
StandardError=journal
Environment=PORT=$PORT
Environment=NODE_ENV=production
Environment=DEBUG_ENABLED=false
Environment=LOG_FILE=$INSTALL_DIR/logs/transmission-rss-manager.log
Environment=CONFIG_DIR=$CONFIG_DIR
EOF
# Preserve the existing JWT_SECRET if available
if [ -n "$EXISTING_JWT_SECRET" ]; then
echo "Environment=JWT_SECRET=$EXISTING_JWT_SECRET" >> "$SERVICE_FILE"
else
echo "# Generate a random JWT secret for security" >> "$SERVICE_FILE"
echo "Environment=JWT_SECRET=$(openssl rand -hex 32)" >> "$SERVICE_FILE"
fi
# Close the service file definition
cat >> "$SERVICE_FILE" << EOF
[Install]
WantedBy=multi-user.target
EOF
else
# For fresh installations, create a new service file
log "INFO" "Creating new service file"
# Create backup of existing service file if it exists
if [ -f "$SERVICE_FILE" ]; then
backup_file "$SERVICE_FILE"
fi
# Create systemd service file
cat > "$SERVICE_FILE" << EOF
[Unit]
Description=Transmission RSS Manager
After=network.target transmission-daemon.service
Wants=network-online.target
[Service]
Type=simple
User=$USER
WorkingDirectory=$INSTALL_DIR
ExecStart=$TEST_START_SCRIPT
Restart=always
RestartSec=10
StandardOutput=journal
StandardError=journal
Environment=PORT=$PORT
Environment=NODE_ENV=production
Environment=DEBUG_ENABLED=false
Environment=LOG_FILE=$INSTALL_DIR/logs/transmission-rss-manager.log
Environment=CONFIG_DIR=$CONFIG_DIR
# Generate a random JWT secret for security
Environment=JWT_SECRET=$(openssl rand -hex 32)
[Install]
WantedBy=multi-user.target
EOF
fi
# Create logs directory
mkdir -p "$INSTALL_DIR/logs"
chown -R $USER:$USER "$INSTALL_DIR/logs"
# Check if file was created successfully
if [ ! -f "$SERVICE_FILE" ]; then
log "ERROR" "Failed to create systemd service file"
return 1
fi
log "INFO" "Setting up Nginx reverse proxy..."
# Check if nginx is installed
if ! command -v nginx &> /dev/null; then
log "ERROR" "Nginx is not installed"
log "INFO" "Skipping Nginx configuration. Please configure your web server manually."
# Reload systemd and enable service
systemctl daemon-reload
systemctl enable "$SERVICE_NAME"
log "INFO" "Systemd service has been created and enabled."
log "INFO" "The service will start automatically after installation."
return 0
fi
# Detect nginx configuration directory
NGINX_AVAILABLE_DIR=""
NGINX_ENABLED_DIR=""
if [ -d "/etc/nginx/sites-available" ] && [ -d "/etc/nginx/sites-enabled" ]; then
# Debian/Ubuntu style
NGINX_AVAILABLE_DIR="/etc/nginx/sites-available"
NGINX_ENABLED_DIR="/etc/nginx/sites-enabled"
elif [ -d "/etc/nginx/conf.d" ]; then
# CentOS/RHEL style
NGINX_AVAILABLE_DIR="/etc/nginx/conf.d"
NGINX_ENABLED_DIR="/etc/nginx/conf.d"
else
log "WARN" "Unable to determine Nginx configuration directory"
log "INFO" "Please configure Nginx manually"
# Reload systemd and enable service
systemctl daemon-reload
systemctl enable "$SERVICE_NAME"
log "INFO" "Systemd service has been created and enabled."
log "INFO" "The service will start automatically after installation."
return 0
fi
# Check if default nginx file exists, back it up if it does
if [ -f "$NGINX_ENABLED_DIR/default" ]; then
backup_file "$NGINX_ENABLED_DIR/default"
if [ -f "$NGINX_ENABLED_DIR/default.bak" ]; then
log "INFO" "Backed up default nginx configuration."
fi
fi
# Create nginx configuration file
NGINX_CONFIG_FILE="$NGINX_AVAILABLE_DIR/$SERVICE_NAME.conf"
cat > "$NGINX_CONFIG_FILE" << EOF
server {
listen 80;
server_name _;
location / {
proxy_pass http://127.0.0.1:$PORT;
proxy_http_version 1.1;
proxy_set_header Upgrade \$http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host \$host;
proxy_cache_bypass \$http_upgrade;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \$scheme;
}
}
EOF
log "INFO" "Nginx configured to proxy connections from port 80 to port $PORT"
log "INFO" "You can access Transmission RSS Manager at http://your-server-ip/ (port 80) via Nginx"
# Check if Debian/Ubuntu style (need symlink between available and enabled)
if [ "$NGINX_AVAILABLE_DIR" != "$NGINX_ENABLED_DIR" ]; then
# Create symbolic link to enable the site (if it doesn't already exist)
if [ ! -h "$NGINX_ENABLED_DIR/$SERVICE_NAME.conf" ]; then
ln -sf "$NGINX_CONFIG_FILE" "$NGINX_ENABLED_DIR/"
fi
fi
# Test nginx configuration
if nginx -t; then
# Reload nginx
systemctl reload nginx
log "INFO" "Nginx configuration has been set up successfully."
else
log "ERROR" "Nginx configuration test failed. Please check the configuration manually."
log "WARN" "You may need to correct the configuration before the web interface will be accessible."
fi
# Check for port conflicts
if ss -lnt | grep ":$PORT " &> /dev/null; then
log "WARN" "Port $PORT is already in use. This may cause conflicts with the service."
log "WARN" "The service will fail to start. Please stop any service using port $PORT and try again."
else
log "INFO" "You can access the web interface at: http://localhost:$PORT or http://your-server-ip:$PORT"
log "INFO" "You may need to configure your firewall to allow access to port $PORT"
fi
# Reload systemd
systemctl daemon-reload
# Enable the service to start on boot
systemctl enable "$SERVICE_NAME"
log "INFO" "Systemd service has been created and enabled."
log "INFO" "The service will start automatically after installation."
}

View File

@ -0,0 +1,253 @@
#!/bin/bash
# Service setup module for Transmission RSS Manager Installation
# Setup systemd service
function setup_service() {
log "INFO" "Setting up systemd service..."
# Ensure required variables are set
if [ -z "$SERVICE_NAME" ]; then
log "ERROR" "SERVICE_NAME variable is not set"
exit 1
fi
if [ -z "$USER" ]; then
log "ERROR" "USER variable is not set"
exit 1
fi
if [ -z "$INSTALL_DIR" ]; then
log "ERROR" "INSTALL_DIR variable is not set"
exit 1
fi
if [ -z "$CONFIG_DIR" ]; then
log "ERROR" "CONFIG_DIR variable is not set"
exit 1
fi
if [ -z "$PORT" ]; then
log "ERROR" "PORT variable is not set"
exit 1
fi
# Check if systemd is available
if ! command -v systemctl &> /dev/null; then
log "ERROR" "systemd is not available on this system"
log "INFO" "Please set up the service manually using your system's service manager"
return 1
fi
# Check if service file already exists
SERVICE_FILE="/etc/systemd/system/$SERVICE_NAME.service"
if [ -f "$SERVICE_FILE" ] && [ "$IS_UPDATE" = true ]; then
log "INFO" "Service file already exists. Preserving existing service configuration."
# Extract existing JWT_SECRET if present to maintain session consistency
EXISTING_JWT_SECRET=$(grep "Environment=JWT_SECRET=" "$SERVICE_FILE" | cut -d'=' -f3)
# Extract existing PORT if it differs from the configured one
EXISTING_PORT=$(grep "Environment=PORT=" "$SERVICE_FILE" | cut -d'=' -f3)
if [ -n "$EXISTING_PORT" ] && [ "$EXISTING_PORT" != "$PORT" ]; then
log "INFO" "Using existing port configuration: $EXISTING_PORT"
PORT=$EXISTING_PORT
fi
# Create backup of existing service file
backup_file "$SERVICE_FILE"
# Update the service file while preserving key settings
cat > "$SERVICE_FILE" << EOF
[Unit]
Description=Transmission RSS Manager
After=network.target transmission-daemon.service
Wants=network-online.target
[Service]
Type=simple
User=$USER
WorkingDirectory=$INSTALL_DIR
ExecStart=/usr/bin/node $INSTALL_DIR/server.js
Restart=always
RestartSec=10
StandardOutput=journal
StandardError=journal
Environment=PORT=$PORT
Environment=NODE_ENV=production
Environment=DEBUG_ENABLED=false
Environment=LOG_FILE=$INSTALL_DIR/logs/transmission-rss-manager.log
Environment=CONFIG_DIR=$CONFIG_DIR
EOF
# Preserve the existing JWT_SECRET if available
if [ -n "$EXISTING_JWT_SECRET" ]; then
echo "Environment=JWT_SECRET=$EXISTING_JWT_SECRET" >> "$SERVICE_FILE"
else
echo "# Generate a random JWT secret for security" >> "$SERVICE_FILE"
echo "Environment=JWT_SECRET=$(openssl rand -hex 32)" >> "$SERVICE_FILE"
fi
# Close the service file definition
cat >> "$SERVICE_FILE" << EOF
[Install]
WantedBy=multi-user.target
EOF
else
# For fresh installations, create a new service file
log "INFO" "Creating new service file"
# Create backup of existing service file if it exists
if [ -f "$SERVICE_FILE" ]; then
backup_file "$SERVICE_FILE"
fi
# Create systemd service file
cat > "$SERVICE_FILE" << EOF
[Unit]
Description=Transmission RSS Manager
After=network.target transmission-daemon.service
Wants=network-online.target
[Service]
Type=simple
User=$USER
WorkingDirectory=$INSTALL_DIR
ExecStart=/usr/bin/node $INSTALL_DIR/server.js
Restart=always
RestartSec=10
StandardOutput=journal
StandardError=journal
Environment=PORT=$PORT
Environment=NODE_ENV=production
Environment=DEBUG_ENABLED=false
Environment=LOG_FILE=$INSTALL_DIR/logs/transmission-rss-manager.log
Environment=CONFIG_DIR=$CONFIG_DIR
# Generate a random JWT secret for security
Environment=JWT_SECRET=$(openssl rand -hex 32)
[Install]
WantedBy=multi-user.target
EOF
fi
# Create logs directory
mkdir -p "$INSTALL_DIR/logs"
chown -R $USER:$USER "$INSTALL_DIR/logs"
# Check if file was created successfully
if [ ! -f "$SERVICE_FILE" ]; then
log "ERROR" "Failed to create systemd service file"
return 1
fi
log "INFO" "Setting up Nginx reverse proxy..."
# Check if nginx is installed
if ! command -v nginx &> /dev/null; then
log "ERROR" "Nginx is not installed"
log "INFO" "Skipping Nginx configuration. Please configure your web server manually."
# Reload systemd and enable service
systemctl daemon-reload
systemctl enable "$SERVICE_NAME"
log "INFO" "Systemd service has been created and enabled."
log "INFO" "The service will start automatically after installation."
return 0
fi
# Detect nginx configuration directory
NGINX_AVAILABLE_DIR=""
NGINX_ENABLED_DIR=""
if [ -d "/etc/nginx/sites-available" ] && [ -d "/etc/nginx/sites-enabled" ]; then
# Debian/Ubuntu style
NGINX_AVAILABLE_DIR="/etc/nginx/sites-available"
NGINX_ENABLED_DIR="/etc/nginx/sites-enabled"
elif [ -d "/etc/nginx/conf.d" ]; then
# CentOS/RHEL style
NGINX_AVAILABLE_DIR="/etc/nginx/conf.d"
NGINX_ENABLED_DIR="/etc/nginx/conf.d"
else
log "WARN" "Unable to determine Nginx configuration directory"
log "INFO" "Please configure Nginx manually"
# Reload systemd and enable service
systemctl daemon-reload
systemctl enable "$SERVICE_NAME"
log "INFO" "Systemd service has been created and enabled."
log "INFO" "The service will start automatically after installation."
return 0
fi
# Check if default nginx file exists, back it up if it does
if [ -f "$NGINX_ENABLED_DIR/default" ]; then
backup_file "$NGINX_ENABLED_DIR/default"
if [ -f "$NGINX_ENABLED_DIR/default.bak" ]; then
log "INFO" "Backed up default nginx configuration."
fi
fi
# Create nginx configuration file
NGINX_CONFIG_FILE="$NGINX_AVAILABLE_DIR/$SERVICE_NAME.conf"
cat > "$NGINX_CONFIG_FILE" << EOF
server {
listen 80;
server_name _;
location / {
proxy_pass http://127.0.0.1:$PORT;
proxy_http_version 1.1;
proxy_set_header Upgrade \$http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host \$host;
proxy_cache_bypass \$http_upgrade;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \$scheme;
}
}
EOF
log "INFO" "Nginx configured to proxy connections from port 80 to port $PORT"
log "INFO" "You can access Transmission RSS Manager at http://your-server-ip/ (port 80) via Nginx"
# Check if Debian/Ubuntu style (need symlink between available and enabled)
if [ "$NGINX_AVAILABLE_DIR" != "$NGINX_ENABLED_DIR" ]; then
# Create symbolic link to enable the site (if it doesn't already exist)
if [ ! -h "$NGINX_ENABLED_DIR/$SERVICE_NAME.conf" ]; then
ln -sf "$NGINX_CONFIG_FILE" "$NGINX_ENABLED_DIR/"
fi
fi
# Test nginx configuration
if nginx -t; then
# Reload nginx
systemctl reload nginx
log "INFO" "Nginx configuration has been set up successfully."
else
log "ERROR" "Nginx configuration test failed. Please check the configuration manually."
log "WARN" "You may need to correct the configuration before the web interface will be accessible."
fi
# Check for port conflicts
if ss -lnt | grep ":$PORT " &> /dev/null; then
log "WARN" "Port $PORT is already in use. This may cause conflicts with the service."
log "WARN" "The service will fail to start. Please stop any service using port $PORT and try again."
else
log "INFO" "You can access the web interface at: http://localhost:$PORT or http://your-server-ip:$PORT"
log "INFO" "You may need to configure your firewall to allow access to port $PORT"
fi
# Reload systemd
systemctl daemon-reload
# Enable the service to start on boot
systemctl enable "$SERVICE_NAME"
log "INFO" "Systemd service has been created and enabled."
log "INFO" "The service will start automatically after installation."
}

1
modules/transmission-client Symbolic link
View File

@ -0,0 +1 @@
transmission-client.js

View File

@ -0,0 +1,540 @@
/**
* Transmission Client Module
* Enhanced integration with Transmission BitTorrent client
*/
const Transmission = require('transmission-promise');
const fs = require('fs').promises;
const path = require('path');
const util = require('util');
const exec = util.promisify(require('child_process').exec);
class TransmissionClient {
constructor(config) {
if (!config) {
throw new Error('Configuration is required for Transmission client');
}
this.config = config;
this.client = null;
this.dirMappings = null;
this.lastSessionId = null;
this.connectRetries = 0;
this.maxRetries = 5;
this.retryDelay = 5000; // 5 seconds
// Initialize directory mappings if remote
if (config.remoteConfig && config.remoteConfig.isRemote && config.remoteConfig.directoryMapping) {
this.dirMappings = config.remoteConfig.directoryMapping;
}
// Initialize the connection - but don't throw if it fails initially
// This allows the object to be created even if the connection fails
try {
this.initializeConnection();
} catch (error) {
console.error("Failed to initialize Transmission connection:", error.message);
// Don't throw - allow methods to handle connection retry logic
}
}
/**
* Initialize the connection to Transmission
*/
initializeConnection() {
const { host, port, username, password, path: rpcPath } = this.config.transmissionConfig;
try {
// Only default to localhost if host is empty/null/undefined
const connectionHost = (host === undefined || host === null || host === '') ? 'localhost' : host;
this.client = new Transmission({
host: connectionHost,
port: port || 9091,
username: username || '',
password: password || '',
path: rpcPath || '/transmission/rpc',
timeout: 30000 // 30 seconds
});
console.log(`Initialized Transmission client connection to ${connectionHost}:${port}${rpcPath}`);
} catch (error) {
console.error('Failed to initialize Transmission client:', error);
throw error;
}
}
/**
* Get client status and session information
* @returns {Promise<Object>} Status information
*/
async getStatus() {
try {
// Use the session-stats method for basic connectivity check
const sessionInfo = await this.client.sessionStats();
// Use the session-get method to get version info
// Note: In transmission-promise, this is 'session' not 'sessionGet'
const session = await this.client.session();
return {
connected: true,
version: session.version || "Unknown",
rpcVersion: session['rpc-version'] || "Unknown",
downloadSpeed: sessionInfo.downloadSpeed,
uploadSpeed: sessionInfo.uploadSpeed,
torrentCount: sessionInfo.torrentCount,
activeTorrentCount: sessionInfo.activeTorrentCount
};
} catch (error) {
console.error('Error getting Transmission status:', error);
if (error.message.includes('Connection refused') && this.connectRetries < this.maxRetries) {
this.connectRetries++;
console.log(`Retrying connection (${this.connectRetries}/${this.maxRetries})...`);
return new Promise((resolve) => {
setTimeout(async () => {
this.initializeConnection();
try {
const status = await this.getStatus();
this.connectRetries = 0; // Reset retries on success
resolve(status);
} catch (retryError) {
resolve({
connected: false,
error: retryError.message
});
}
}, this.retryDelay);
});
}
return {
connected: false,
error: error.message
};
}
}
/**
* Add a torrent from a URL or magnet link
* @param {string} url - Torrent URL or magnet link
* @param {Object} options - Additional options
* @returns {Promise<Object>} Result with torrent ID
*/
async addTorrent(url, options = {}) {
try {
// Verify client is initialized
if (!this.client) {
await this.initializeConnection();
if (!this.client) {
throw new Error("Failed to initialize Transmission client");
}
}
const downloadDir = options.downloadDir || null;
const result = await this.client.addUrl(url, {
"download-dir": downloadDir,
paused: options.paused || false
});
console.log(`Added torrent from ${url}, ID: ${result.id}`);
return {
success: true,
id: result.id,
name: result.name,
hashString: result.hashString
};
} catch (error) {
console.error(`Error adding torrent from ${url}:`, error);
return {
success: false,
error: error.message
};
}
}
/**
* Get all torrents with detailed information
* @param {Array} ids - Optional array of torrent IDs to filter
* @returns {Promise<Array>} Array of torrent objects
*/
async getTorrents(ids = null) {
try {
const torrents = await this.client.get(ids);
// Map remote paths to local paths if needed
if (this.dirMappings && Object.keys(this.dirMappings).length > 0) {
torrents.torrents = torrents.torrents.map(torrent => {
torrent.downloadDir = this.mapRemotePathToLocal(torrent.downloadDir);
return torrent;
});
}
return {
success: true,
torrents: torrents.torrents
};
} catch (error) {
console.error('Error getting torrents:', error);
return {
success: false,
error: error.message,
torrents: []
};
}
}
/**
* Stop torrents by IDs
* @param {Array|number} ids - Torrent ID(s) to stop
* @returns {Promise<Object>} Result
*/
async stopTorrents(ids) {
try {
await this.client.stop(ids);
return {
success: true,
message: 'Torrents stopped successfully'
};
} catch (error) {
console.error(`Error stopping torrents ${ids}:`, error);
return {
success: false,
error: error.message
};
}
}
/**
* Start torrents by IDs
* @param {Array|number} ids - Torrent ID(s) to start
* @returns {Promise<Object>} Result
*/
async startTorrents(ids) {
try {
await this.client.start(ids);
return {
success: true,
message: 'Torrents started successfully'
};
} catch (error) {
console.error(`Error starting torrents ${ids}:`, error);
return {
success: false,
error: error.message
};
}
}
/**
* Remove torrents by IDs
* @param {Array|number} ids - Torrent ID(s) to remove
* @param {boolean} deleteLocalData - Whether to delete local data
* @returns {Promise<Object>} Result
*/
async removeTorrents(ids, deleteLocalData = false) {
try {
await this.client.remove(ids, deleteLocalData);
return {
success: true,
message: `Torrents removed successfully${deleteLocalData ? ' with data' : ''}`
};
} catch (error) {
console.error(`Error removing torrents ${ids}:`, error);
return {
success: false,
error: error.message
};
}
}
/**
* Get detailed information for a specific torrent
* @param {number} id - Torrent ID
* @returns {Promise<Object>} Torrent details
*/
async getTorrentDetails(id) {
try {
const fields = [
'id', 'name', 'status', 'hashString', 'downloadDir', 'totalSize',
'percentDone', 'addedDate', 'doneDate', 'uploadRatio', 'rateDownload',
'rateUpload', 'downloadedEver', 'uploadedEver', 'seedRatioLimit',
'error', 'errorString', 'files', 'fileStats', 'peers', 'peersFrom',
'pieces', 'trackers', 'trackerStats', 'labels'
];
const result = await this.client.get(id, fields);
if (!result.torrents || result.torrents.length === 0) {
return {
success: false,
error: 'Torrent not found'
};
}
let torrent = result.torrents[0];
// Map download directory if needed
if (this.dirMappings) {
torrent.downloadDir = this.mapRemotePathToLocal(torrent.downloadDir);
}
// Process files for extra information if available
if (torrent.files && torrent.files.length > 0) {
torrent.mediaInfo = await this.analyzeMediaFiles(torrent.files, torrent.downloadDir);
}
return {
success: true,
torrent
};
} catch (error) {
console.error(`Error getting torrent details for ID ${id}:`, error);
return {
success: false,
error: error.message
};
}
}
/**
* Map a remote path to a local path
* @param {string} remotePath - Path on the remote server
* @returns {string} Local path
*/
mapRemotePathToLocal(remotePath) {
if (!this.dirMappings || !remotePath) {
return remotePath;
}
for (const [remote, local] of Object.entries(this.dirMappings)) {
if (remotePath.startsWith(remote)) {
return remotePath.replace(remote, local);
}
}
return remotePath;
}
/**
* Analyze media files in a torrent
* @param {Array} files - Torrent files
* @param {string} baseDir - Base directory of the torrent
* @returns {Promise<Object>} Media info
*/
async analyzeMediaFiles(files, baseDir) {
try {
const mediaInfo = {
type: 'unknown',
videoFiles: [],
audioFiles: [],
imageFiles: [],
documentFiles: [],
archiveFiles: [],
otherFiles: [],
totalVideoSize: 0,
totalAudioSize: 0,
totalImageSize: 0,
totalDocumentSize: 0,
totalArchiveSize: 0,
totalOtherSize: 0
};
// File type patterns
const videoPattern = /\.(mp4|mkv|avi|mov|wmv|flv|webm|m4v|mpg|mpeg|3gp|ts)$/i;
const audioPattern = /\.(mp3|flac|wav|aac|ogg|m4a|wma|opus)$/i;
const imagePattern = /\.(jpg|jpeg|png|gif|bmp|tiff|webp|svg)$/i;
const documentPattern = /\.(pdf|doc|docx|xls|xlsx|ppt|pptx|txt|rtf|odt|ods|odp|epub|mobi|azw3)$/i;
const archivePattern = /\.(zip|rar|7z|tar|gz|bz2|xz|iso)$/i;
const subtitlePattern = /\.(srt|sub|sbv|vtt|ass|ssa)$/i;
const samplePattern = /sample|trailer/i;
// Count files by category
for (const file of files) {
const fileName = path.basename(file.name).toLowerCase();
const fileSize = file.length;
const fileInfo = {
name: file.name,
size: fileSize,
extension: path.extname(file.name).substr(1).toLowerCase(),
isSample: samplePattern.test(fileName)
};
if (videoPattern.test(fileName)) {
mediaInfo.videoFiles.push(fileInfo);
mediaInfo.totalVideoSize += fileSize;
} else if (audioPattern.test(fileName)) {
mediaInfo.audioFiles.push(fileInfo);
mediaInfo.totalAudioSize += fileSize;
} else if (imagePattern.test(fileName)) {
mediaInfo.imageFiles.push(fileInfo);
mediaInfo.totalImageSize += fileSize;
} else if (documentPattern.test(fileName)) {
mediaInfo.documentFiles.push(fileInfo);
mediaInfo.totalDocumentSize += fileSize;
} else if (archivePattern.test(fileName)) {
mediaInfo.archiveFiles.push(fileInfo);
mediaInfo.totalArchiveSize += fileSize;
} else if (!subtitlePattern.test(fileName)) {
mediaInfo.otherFiles.push(fileInfo);
mediaInfo.totalOtherSize += fileSize;
}
}
// Determine content type based on file distribution
if (mediaInfo.videoFiles.length > 0 &&
mediaInfo.totalVideoSize > (mediaInfo.totalAudioSize + mediaInfo.totalDocumentSize)) {
mediaInfo.type = 'video';
// Determine if it's a movie or TV show
const tvEpisodePattern = /(s\d{1,2}e\d{1,2}|\d{1,2}x\d{1,2})/i;
const movieYearPattern = /\(?(19|20)\d{2}\)?/;
let tvShowMatch = false;
for (const file of mediaInfo.videoFiles) {
if (tvEpisodePattern.test(file.name)) {
tvShowMatch = true;
break;
}
}
if (tvShowMatch) {
mediaInfo.type = 'tvshow';
} else if (movieYearPattern.test(files[0].name)) {
mediaInfo.type = 'movie';
}
} else if (mediaInfo.audioFiles.length > 0 &&
mediaInfo.totalAudioSize > (mediaInfo.totalVideoSize + mediaInfo.totalDocumentSize)) {
mediaInfo.type = 'audio';
} else if (mediaInfo.documentFiles.length > 0 &&
mediaInfo.totalDocumentSize > (mediaInfo.totalVideoSize + mediaInfo.totalAudioSize)) {
// Check if it's a book or magazine
const magazinePattern = /(magazine|issue|volume|vol\.)\s*\d+/i;
let isMagazine = false;
for (const file of mediaInfo.documentFiles) {
if (magazinePattern.test(file.name)) {
isMagazine = true;
break;
}
}
mediaInfo.type = isMagazine ? 'magazine' : 'book';
} else if (mediaInfo.archiveFiles.length > 0 &&
mediaInfo.totalArchiveSize > (mediaInfo.totalVideoSize + mediaInfo.totalAudioSize + mediaInfo.totalDocumentSize)) {
// If archives dominate, we need to check their content
mediaInfo.type = 'archive';
}
return mediaInfo;
} catch (error) {
console.error('Error analyzing media files:', error);
return { type: 'unknown', error: error.message };
}
}
/**
* Get session stats from Transmission
* @returns {Promise<Object>} Stats
*/
async getSessionStats() {
try {
const stats = await this.client.sessionStats();
return {
success: true,
stats
};
} catch (error) {
console.error('Error getting session stats:', error);
return {
success: false,
error: error.message
};
}
}
/**
* Set session parameters
* @param {Object} params - Session parameters
* @returns {Promise<Object>} Result
*/
async setSessionParams(params) {
try {
// In transmission-promise, the method is sessionUpdate not sessionSet
await this.client.sessionUpdate(params);
return {
success: true,
message: 'Session parameters updated successfully'
};
} catch (error) {
console.error('Error setting session parameters:', error);
return {
success: false,
error: error.message
};
}
}
/**
* Verify if a torrent has met seeding requirements
* @param {number} id - Torrent ID
* @param {Object} requirements - Seeding requirements
* @returns {Promise<Object>} Whether requirements are met
*/
async verifyTorrentSeedingRequirements(id, requirements) {
try {
const { minRatio = 1.0, minTimeMinutes = 60 } = requirements;
const details = await this.getTorrentDetails(id);
if (!details.success) {
return {
success: false,
error: details.error
};
}
const torrent = details.torrent;
// Check if download is complete
if (torrent.percentDone < 1.0) {
return {
success: true,
requirementsMet: false,
reason: 'Download not complete',
torrent
};
}
// Check ratio requirement
const ratioMet = torrent.uploadRatio >= minRatio;
// Check time requirement (doneDate is unix timestamp in seconds)
const seedingTimeMinutes = (Date.now() / 1000 - torrent.doneDate) / 60;
const timeMet = seedingTimeMinutes >= minTimeMinutes;
return {
success: true,
requirementsMet: ratioMet && timeMet,
ratioMet,
timeMet,
currentRatio: torrent.uploadRatio,
currentSeedingTimeMinutes: seedingTimeMinutes,
torrent
};
} catch (error) {
console.error(`Error checking torrent seeding requirements for ID ${id}:`, error);
return {
success: false,
error: error.message
};
}
}
}
module.exports = TransmissionClient;

1
modules/transmissionClient Symbolic link
View File

@ -0,0 +1 @@
transmission-client.js

View File

@ -0,0 +1 @@
transmission-client.js

312
modules/utils-module.sh Normal file
View File

@ -0,0 +1,312 @@
#!/bin/bash
# Utilities module for Transmission RSS Manager Installation
# Function to log a message with timestamp
function log() {
local level=$1
local message=$2
local timestamp=$(date '+%Y-%m-%d %H:%M:%S')
case $level in
"INFO")
echo -e "${timestamp} ${GREEN}[INFO]${NC} $message"
;;
"WARN")
echo -e "${timestamp} ${YELLOW}[WARN]${NC} $message"
;;
"ERROR")
echo -e "${timestamp} ${RED}[ERROR]${NC} $message"
;;
"DEBUG")
if [ "${DEBUG_ENABLED}" = "true" ]; then
echo -e "${timestamp} ${BOLD}[DEBUG]${NC} $message"
fi
;;
*)
echo -e "${timestamp} [LOG] $message"
;;
esac
# If log file is specified, also write to log file
if [ -n "${LOG_FILE}" ]; then
echo "${timestamp} [${level}] ${message}" >> "${LOG_FILE}"
fi
}
# Function to check if a command exists
function command_exists() {
command -v "$1" &> /dev/null
}
# Function to backup a file before modifying it
function backup_file() {
local file=$1
if [ -f "$file" ]; then
local backup="${file}.bak.$(date +%Y%m%d%H%M%S)"
cp "$file" "$backup"
log "INFO" "Created backup of $file at $backup"
echo "$backup"
fi
}
# Function to manage config file updates
function update_config_file() {
local config_file=$1
local is_update=$2
if [ "$is_update" = true ] && [ -f "$config_file" ]; then
# Backup the existing config file
local backup_file=$(backup_file "$config_file")
log "INFO" "Existing configuration backed up to $backup_file"
# We'll let the server.js handle merging the config
log "INFO" "Existing configuration will be preserved"
# Update the config version if needed
local current_version=$(grep -o '"version": "[^"]*"' "$config_file" | cut -d'"' -f4)
if [ -n "$current_version" ]; then
local new_version="2.0.0"
if [ "$current_version" != "$new_version" ]; then
log "INFO" "Updating config version from $current_version to $new_version"
sed -i "s/\"version\": \"$current_version\"/\"version\": \"$new_version\"/" "$config_file"
fi
fi
return 0
else
# New installation, config file will be created by finalize_setup
log "INFO" "New configuration will be created"
return 1
fi
}
# Function to create a directory if it doesn't exist
function create_dir_if_not_exists() {
local dir=$1
local owner=$2
if [ ! -d "$dir" ]; then
mkdir -p "$dir"
log "INFO" "Created directory: $dir"
if [ -n "$owner" ]; then
chown -R "$owner" "$dir"
log "INFO" "Set ownership of $dir to $owner"
fi
fi
}
# Function to finalize the setup (permissions, etc.)
# Function to ensure NPM packages are properly installed
function ensure_npm_packages() {
local install_dir=$1
# First ensure the installation directory exists
if [ ! -d "$install_dir" ]; then
log "INFO" "Creating installation directory: $install_dir"
mkdir -p "$install_dir"
if [ $? -ne 0 ]; then
log "ERROR" "Failed to create installation directory: $install_dir"
return 1
fi
fi
# Ensure data directory exists
if [ ! -d "$install_dir/data" ]; then
log "INFO" "Creating data directory: $install_dir/data"
mkdir -p "$install_dir/data"
if [ $? -ne 0 ]; then
log "ERROR" "Failed to create data directory: $install_dir/data"
return 1
fi
# Initialize empty data files
echo "[]" > "$install_dir/data/rss-feeds.json"
echo "[]" > "$install_dir/data/rss-items.json"
log "INFO" "Initialized empty data files"
fi
# Ensure package.json exists in the installation directory
if [ ! -f "$install_dir/package.json" ]; then
log "INFO" "Copying package.json to installation directory..."
cp "$SCRIPT_DIR/package.json" "$install_dir/package.json"
if [ $? -ne 0 ]; then
log "ERROR" "Failed to copy package.json to installation directory"
return 1
fi
fi
# Install NPM packages if not already installed or if it's an update
if [ ! -d "$install_dir/node_modules" ] || [ "$IS_UPDATE" = "true" ]; then
log "INFO" "Installing NPM packages in $install_dir..."
# Save current directory
local current_dir=$(pwd)
# Change to install directory and install packages
cd "$install_dir"
if [ $? -ne 0 ]; then
log "ERROR" "Failed to change to installation directory: $install_dir"
return 1
fi
npm install
if [ $? -ne 0 ]; then
log "ERROR" "NPM installation failed in $install_dir"
cd "$current_dir" # Return to original directory
return 1
fi
# Return to original directory
cd "$current_dir"
log "INFO" "NPM packages successfully installed in $install_dir"
else
log "INFO" "NPM packages appear to be already installed in $install_dir, skipping"
fi
return 0
}
function finalize_setup() {
log "INFO" "Setting up final permissions and configurations..."
# Ensure logs directory exists
mkdir -p "$INSTALL_DIR/logs"
log "INFO" "Created logs directory: $INSTALL_DIR/logs"
# Ensure CONFIG_DIR exists
if [ ! -d "$CONFIG_DIR" ]; then
mkdir -p "$CONFIG_DIR"
log "INFO" "Created configuration directory: $CONFIG_DIR"
chown -R "$USER:$USER" "$CONFIG_DIR"
fi
# Check if the config symlink exists, create it if not
if [ ! -L "$INSTALL_DIR/config.json" ] || [ ! -e "$INSTALL_DIR/config.json" ]; then
# If there's a real file at INSTALL_DIR/config.json (not a symlink), move it to CONFIG_DIR
if [ -f "$INSTALL_DIR/config.json" ] && [ ! -L "$INSTALL_DIR/config.json" ]; then
log "INFO" "Moving existing config.json to $CONFIG_DIR"
mv "$INSTALL_DIR/config.json" "$CONFIG_DIR/config.json"
fi
# Create the symlink
ln -sf "$CONFIG_DIR/config.json" "$INSTALL_DIR/config.json"
log "INFO" "Created symlink from $INSTALL_DIR/config.json to $CONFIG_DIR/config.json"
fi
# Set proper ownership for the installation directory
chown -R $USER:$USER $INSTALL_DIR
# Create media directories with correct permissions
create_dir_if_not_exists "$MEDIA_DIR/movies" "$USER:$USER"
create_dir_if_not_exists "$MEDIA_DIR/tvshows" "$USER:$USER"
create_dir_if_not_exists "$MEDIA_DIR/music" "$USER:$USER"
create_dir_if_not_exists "$MEDIA_DIR/software" "$USER:$USER"
# Create book/magazine directories if enabled
if [ "$ENABLE_BOOK_SORTING" = true ]; then
create_dir_if_not_exists "$MEDIA_DIR/books" "$USER:$USER"
create_dir_if_not_exists "$MEDIA_DIR/magazines" "$USER:$USER"
fi
# Install npm packages
ensure_npm_packages "$INSTALL_DIR" || {
log "ERROR" "Failed to install NPM packages"
}
# Handle configuration file
if ! update_config_file "$CONFIG_DIR/config.json" "$IS_UPDATE"; then
log "INFO" "Creating default configuration file..."
# Create the users array content for JSON
USER_JSON=""
if [ "${AUTH_ENABLED}" = "true" ] && [ -n "${ADMIN_USERNAME}" ]; then
USER_JSON="{ \"username\": \"${ADMIN_USERNAME}\", \"password\": \"${ADMIN_PASSWORD}\", \"role\": \"admin\" }"
fi
# Make sure CONFIG_DIR exists
mkdir -p "$CONFIG_DIR"
# Get version from package.json dynamically
VERSION=$(grep -oP '"version": "\K[^"]+' "${SCRIPT_DIR}/package.json" 2>/dev/null || echo "2.0.9")
cat > $CONFIG_DIR/config.json << EOF
{
"version": "$VERSION",
"transmissionConfig": {
"host": "${TRANSMISSION_HOST}",
"port": ${TRANSMISSION_PORT},
"username": "${TRANSMISSION_USER}",
"password": "${TRANSMISSION_PASS}",
"path": "${TRANSMISSION_RPC_PATH}"
},
"remoteConfig": {
"isRemote": ${TRANSMISSION_REMOTE},
"directoryMapping": ${TRANSMISSION_DIR_MAPPING}
},
"destinationPaths": {
"movies": "${MEDIA_DIR}/movies",
"tvShows": "${MEDIA_DIR}/tvshows",
"music": "${MEDIA_DIR}/music",
"books": "${MEDIA_DIR}/books",
"magazines": "${MEDIA_DIR}/magazines",
"software": "${MEDIA_DIR}/software"
},
"seedingRequirements": {
"minRatio": 1.0,
"minTimeMinutes": 60,
"checkIntervalSeconds": 300
},
"processingOptions": {
"enableBookSorting": ${ENABLE_BOOK_SORTING},
"extractArchives": true,
"deleteArchives": true,
"createCategoryFolders": true,
"ignoreSample": true,
"ignoreExtras": true,
"renameFiles": true,
"autoReplaceUpgrades": true,
"removeDuplicates": true,
"keepOnlyBestVersion": true
},
"securitySettings": {
"authEnabled": ${AUTH_ENABLED:-false},
"httpsEnabled": ${HTTPS_ENABLED:-false},
"sslCertPath": "${SSL_CERT_PATH:-""}",
"sslKeyPath": "${SSL_KEY_PATH:-""}",
"users": [
${USER_JSON}
]
},
"rssFeeds": [],
"rssUpdateIntervalMinutes": 60,
"autoProcessing": false,
"port": ${PORT},
"logLevel": "info"
}
EOF
# Set ownership for the config file
chown $USER:$USER $CONFIG_DIR/config.json
# Ensure symlink exists from INSTALL_DIR to CONFIG_DIR
ln -sf "$CONFIG_DIR/config.json" "$INSTALL_DIR/config.json"
log "INFO" "Created symlink from $INSTALL_DIR/config.json to $CONFIG_DIR/config.json"
log "INFO" "Default configuration created successfully"
fi
# Start the service
log "INFO" "Starting the service..."
systemctl daemon-reload
systemctl enable $SERVICE_NAME
systemctl start $SERVICE_NAME
# Check if service started successfully
sleep 2
if systemctl is-active --quiet $SERVICE_NAME; then
log "INFO" "Service started successfully!"
else
log "ERROR" "Service failed to start. Check logs with: journalctl -u $SERVICE_NAME"
fi
log "INFO" "Setup finalized!"
}

46
package.json Normal file
View File

@ -0,0 +1,46 @@
{
"name": "transmission-rss-manager",
"version": "2.0.12",
"description": "A comprehensive web-based tool to automate and manage your Transmission torrent downloads with RSS feed integration and intelligent media organization",
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js",
"lint": "eslint --fix --ext .js,.jsx .",
"test": "jest"
},
"repository": {
"type": "git",
"url": "git+https://git.powerdata.dk/masterdraco/transmission-rss-manager.git"
},
"keywords": [
"transmission",
"rss",
"torrent",
"automation",
"media",
"manager"
],
"author": "Your Name",
"license": "MIT",
"dependencies": {
"bcrypt": "^5.1.0",
"body-parser": "^1.20.2",
"cors": "^2.8.5",
"express": "^4.18.2",
"jsonwebtoken": "^9.0.0",
"morgan": "^1.10.0",
"node-fetch": "^2.6.11",
"semver": "^7.5.4",
"transmission-promise": "^1.1.5",
"xml2js": "^0.5.0"
},
"devDependencies": {
"eslint": "^8.42.0",
"jest": "^29.5.0",
"nodemon": "^2.0.22"
},
"engines": {
"node": ">=14.0.0"
}
}

731
public/css/styles.css Normal file
View File

@ -0,0 +1,731 @@
/* Main Styles for Transmission RSS Manager */
:root {
--primary-color: #3498db;
--primary-dark: #2980b9;
--secondary-color: #2ecc71;
--secondary-dark: #27ae60;
--warning-color: #f39c12;
--danger-color: #e74c3c;
--background-color: #f8f9fa;
--dark-background: #1a1a1a;
--card-background: #ffffff;
--dark-card-background: #2a2a2a;
--text-color: #333333;
--dark-text-color: #f5f5f5;
--border-color: #dddddd;
--dark-border-color: #444444;
--success-background: #d4edda;
--success-text: #155724;
--error-background: #f8d7da;
--error-text: #721c24;
--input-background: #ffffff;
--dark-input-background: #333333;
}
/* Dark mode styles */
[data-theme="dark"] {
--background-color: var(--dark-background);
--card-background: var(--dark-card-background);
--text-color: var(--dark-text-color);
--border-color: var(--dark-border-color);
--input-background: var(--dark-input-background);
}
* {
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
line-height: 1.6;
color: var(--text-color);
background-color: var(--background-color);
margin: 0;
padding: 0;
transition: background-color 0.3s ease;
}
/* Typography */
h1, h2, h3, h4, h5, h6 {
font-weight: 600;
margin-top: 0;
}
a {
color: var(--primary-color);
text-decoration: none;
transition: color 0.3s ease;
}
a:hover {
color: var(--primary-dark);
text-decoration: underline;
}
/* Buttons */
.btn {
cursor: pointer;
border: none;
border-radius: 4px;
padding: 8px 16px;
font-size: 14px;
background-color: var(--primary-color);
color: white;
transition: background-color 0.3s ease, transform 0.2s ease;
margin: 2px;
display: inline-flex;
align-items: center;
justify-content: center;
}
.btn:hover {
opacity: 0.9;
transform: translateY(-1px);
}
.btn:active {
transform: translateY(0);
}
.btn i, .btn svg {
margin-right: 8px;
}
.btn.btn-sm {
padding: 4px 8px;
font-size: 12px;
}
.btn.btn-lg {
padding: 12px 20px;
font-size: 16px;
}
.btn.btn-primary {
background-color: var(--primary-color);
}
.btn.btn-success {
background-color: var(--secondary-color);
}
.btn.btn-warning {
background-color: var(--warning-color);
}
.btn.btn-danger {
background-color: var(--danger-color);
}
.btn.btn-outline {
background-color: transparent;
border: 1px solid var(--primary-color);
color: var(--primary-color);
}
.btn.btn-outline:hover {
background-color: var(--primary-color);
color: white;
}
/* Layout */
.container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
.row {
display: flex;
flex-wrap: wrap;
margin: -10px;
}
.col {
flex: 1;
padding: 10px;
}
.col-25 {
flex: 0 0 25%;
max-width: 25%;
padding: 10px;
}
.col-50 {
flex: 0 0 50%;
max-width: 50%;
padding: 10px;
}
.col-75 {
flex: 0 0 75%;
max-width: 75%;
padding: 10px;
}
/* Header and Navigation */
header {
background-color: var(--primary-color);
color: white;
padding: 1rem;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
position: sticky;
top: 0;
z-index: 1000;
}
.navbar {
display: flex;
align-items: center;
justify-content: space-between;
}
.navbar-brand {
font-size: 1.5rem;
font-weight: bold;
color: white;
text-decoration: none;
display: flex;
align-items: center;
}
.navbar-brand i, .navbar-brand svg {
margin-right: 8px;
}
.navbar-menu {
display: flex;
list-style: none;
margin: 0;
padding: 0;
}
.navbar-item {
margin: 0 10px;
}
.navbar-link {
color: rgba(255, 255, 255, 0.85);
text-decoration: none;
transition: color 0.3s ease;
display: flex;
align-items: center;
}
.navbar-link i, .navbar-link svg {
margin-right: 5px;
}
.navbar-link:hover,
.navbar-link.active {
color: white;
}
.navbar-right {
display: flex;
align-items: center;
}
.theme-toggle {
background: none;
border: none;
color: white;
cursor: pointer;
font-size: 1.2rem;
margin-left: 1rem;
}
/* Cards */
.card {
background-color: var(--card-background);
border-radius: 8px;
border: 1px solid var(--border-color);
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
margin-bottom: 1.5rem;
transition: box-shadow 0.3s ease, transform 0.3s ease;
}
.card:hover {
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1);
}
.card-header {
padding: 1rem 1.5rem;
border-bottom: 1px solid var(--border-color);
display: flex;
align-items: center;
justify-content: space-between;
}
.card-header h2, .card-header h3 {
margin: 0;
}
.card-body {
padding: 1.5rem;
}
.card-footer {
padding: 1rem 1.5rem;
border-top: 1px solid var(--border-color);
display: flex;
justify-content: flex-end;
}
/* Forms */
.form-group {
margin-bottom: 1rem;
}
.form-label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
}
.form-control {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid var(--border-color);
border-radius: 4px;
background-color: var(--input-background);
color: var(--text-color);
font-size: 1rem;
transition: border-color 0.3s ease, box-shadow 0.3s ease;
}
.form-control:focus {
border-color: var(--primary-color);
outline: none;
box-shadow: 0 0 0 3px rgba(52, 152, 219, 0.2);
}
.form-check {
display: flex;
align-items: center;
margin-bottom: 0.5rem;
}
.form-check-input {
margin-right: 0.5rem;
}
/* Tables */
.table {
width: 100%;
border-collapse: collapse;
margin-bottom: 1.5rem;
}
.table th,
.table td {
padding: 0.75rem;
text-align: left;
border-bottom: 1px solid var(--border-color);
}
.table th {
background-color: rgba(0, 0, 0, 0.05);
font-weight: 600;
}
.table tr:hover {
background-color: rgba(0, 0, 0, 0.025);
}
.table-responsive {
overflow-x: auto;
}
/* Progress Bar */
.progress {
height: 0.75rem;
background-color: var(--border-color);
border-radius: 0.375rem;
overflow: hidden;
}
.progress-bar {
height: 100%;
background-color: var(--primary-color);
transition: width 0.3s ease;
}
.progress-bar-success {
background-color: var(--secondary-color);
}
.progress-bar-warning {
background-color: var(--warning-color);
}
.progress-bar-danger {
background-color: var(--danger-color);
}
/* Alerts */
.alert {
padding: 0.75rem 1.25rem;
margin-bottom: 1rem;
border-radius: 4px;
border: 1px solid transparent;
}
.alert-success {
background-color: var(--success-background);
border-color: #c3e6cb;
color: var(--success-text);
}
.alert-danger {
background-color: var(--error-background);
border-color: #f5c6cb;
color: var(--error-text);
}
.alert-warning {
background-color: #fff3cd;
border-color: #ffeeba;
color: #856404;
}
.alert-info {
background-color: #d1ecf1;
border-color: #bee5eb;
color: #0c5460;
}
/* Badges */
.badge {
display: inline-block;
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
font-weight: 600;
border-radius: 0.25rem;
text-align: center;
white-space: nowrap;
vertical-align: baseline;
}
.badge-primary {
background-color: var(--primary-color);
color: white;
}
.badge-success {
background-color: var(--secondary-color);
color: white;
}
.badge-warning {
background-color: var(--warning-color);
color: white;
}
.badge-danger {
background-color: var(--danger-color);
color: white;
}
/* Modals */
.modal-backdrop {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1050;
opacity: 0;
visibility: hidden;
transition: opacity 0.3s ease, visibility 0.3s ease;
}
.modal-backdrop.show, #about-modal[style*="flex"] {
opacity: 1;
visibility: visible;
}
.modal {
background-color: var(--card-background);
border-radius: 8px;
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3);
width: 100%;
max-width: 500px;
max-height: 90vh;
overflow-y: auto;
opacity: 0;
transform: translateY(-20px);
transition: opacity 0.3s ease, transform 0.3s ease;
}
#about-modal .modal {
max-width: 800px;
opacity: 1;
transform: none;
animation: modalFadeIn 0.3s;
}
@keyframes modalFadeIn {
from { opacity: 0; transform: translateY(-30px); }
to { opacity: 1; transform: translateY(0); }
}
.modal-backdrop.show .modal, #about-modal[style*="flex"] .modal {
opacity: 1;
transform: translateY(0);
}
.modal-header {
padding: 1rem;
border-bottom: 1px solid var(--border-color);
display: flex;
align-items: center;
justify-content: space-between;
}
.modal-header h2 {
margin: 0;
}
.modal-close {
border: none;
background: none;
font-size: 1.5rem;
line-height: 1;
cursor: pointer;
color: var(--text-color);
}
.modal-body {
padding: 1rem;
}
#about-modal .modal-body {
padding: 1.5rem;
}
.modal-footer {
padding: 1rem;
border-top: 1px solid var(--border-color);
display: flex;
justify-content: flex-end;
}
/* About modal specific styles */
#about-modal .version-history {
border-left: 3px solid var(--primary-color);
padding-left: 20px;
margin-bottom: 20px;
}
#about-modal .version {
margin-bottom: 15px;
}
#about-modal .version h5 {
color: var(--primary-color);
margin-bottom: 5px;
}
#about-modal .features-list {
list-style-type: none;
padding-left: 0;
}
#about-modal .features-list li {
padding: 8px 0;
border-bottom: 1px solid var(--border-color);
}
#about-modal .features-list li:last-child {
border-bottom: none;
}
#about-modal .lead {
font-size: 1.1rem;
line-height: 1.6;
}
/* Tabs */
.tabs {
display: flex;
border-bottom: 1px solid var(--border-color);
margin-bottom: 1.5rem;
}
.tab {
padding: 0.75rem 1.5rem;
cursor: pointer;
border-bottom: 2px solid transparent;
transition: border-color 0.3s ease, color 0.3s ease;
font-weight: 500;
}
.tab:hover {
color: var(--primary-color);
}
.tab.active {
border-bottom-color: var(--primary-color);
color: var(--primary-color);
}
.tab-content {
display: none;
}
.tab-content.active {
display: block;
}
/* Dashboard Widgets */
.stats-container {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
}
.stat-card {
background-color: var(--card-background);
border-radius: 8px;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
padding: 1.5rem;
display: flex;
align-items: center;
}
.stat-icon {
width: 3rem;
height: 3rem;
border-radius: 50%;
background-color: rgba(52, 152, 219, 0.1);
display: flex;
align-items: center;
justify-content: center;
margin-right: 1rem;
font-size: 1.5rem;
color: var(--primary-color);
}
.stat-info h3 {
margin: 0;
font-size: 2rem;
font-weight: 700;
}
.stat-info p {
margin: 0;
color: #777;
font-size: 0.875rem;
}
/* Testing Controls */
.testing-controls {
opacity: 0.5;
font-size: 0.8rem;
}
.testing-controls:hover {
opacity: 1;
}
.testing-controls a {
text-decoration: underline;
color: var(--text-color);
}
/* Utilities */
.text-center { text-align: center; }
.text-right { text-align: right; }
.text-left { text-align: left; }
.font-weight-bold { font-weight: bold; }
.text-muted { color: #6c757d; }
.mt-1 { margin-top: 0.5rem; }
.mt-2 { margin-top: 1rem; }
.mt-3 { margin-top: 1.5rem; }
.mb-1 { margin-bottom: 0.5rem; }
.mb-2 { margin-bottom: 1rem; }
.mb-3 { margin-bottom: 1.5rem; }
.ml-1 { margin-left: 0.5rem; }
.ml-2 { margin-left: 1rem; }
.ml-3 { margin-left: 1.5rem; }
.mr-1 { margin-right: 0.5rem; }
.mr-2 { margin-right: 1rem; }
.mr-3 { margin-right: 1.5rem; }
.pt-1 { padding-top: 0.5rem; }
.pt-2 { padding-top: 1rem; }
.pt-3 { padding-top: 1.5rem; }
.pb-1 { padding-bottom: 0.5rem; }
.pb-2 { padding-bottom: 1rem; }
.pb-3 { padding-bottom: 1.5rem; }
.pl-1 { padding-left: 0.5rem; }
.pl-2 { padding-left: 1rem; }
.pl-3 { padding-left: 1.5rem; }
.pr-1 { padding-right: 0.5rem; }
.pr-2 { padding-right: 1rem; }
.pr-3 { padding-right: 1.5rem; }
.d-none { display: none; }
.d-block { display: block; }
.d-flex { display: flex; }
.flex-wrap { flex-wrap: wrap; }
.align-items-center { align-items: center; }
.justify-content-center { justify-content: center; }
.justify-content-between { justify-content: space-between; }
.justify-content-end { justify-content: flex-end; }
/* Media Queries */
@media (max-width: 768px) {
.navbar {
flex-direction: column;
align-items: stretch;
}
.navbar-menu {
flex-direction: column;
margin-top: 1rem;
}
.navbar-item {
margin: 0.25rem 0;
}
.navbar-right {
margin-top: 0.5rem;
justify-content: flex-end;
}
.stats-container {
grid-template-columns: 1fr;
}
.col-25, .col-50, .col-75 {
flex: 0 0 100%;
max-width: 100%;
}
}
@media (max-width: 576px) {
.card-header {
flex-direction: column;
align-items: flex-start;
}
.card-header > .btn {
margin-top: 0.5rem;
}
}

762
public/index.html Normal file
View File

@ -0,0 +1,762 @@
<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Transmission RSS Manager</title>
<meta name="description" content="Web interface for the Transmission RSS Manager, automating torrent downloads from RSS feeds">
<link rel="stylesheet" href="/css/styles.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.2.1/css/all.min.css">
<!-- Favicon -->
<link rel="icon" href="/favicon.ico" type="image/x-icon">
<!-- Add Apple touch icon for iOS devices -->
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
<!-- Theme color for browser UI -->
<meta name="theme-color" content="#3498db">
</head>
<body>
<!-- Loading Spinner -->
<div id="loading-spinner" class="loader-overlay">
<div class="loader"></div>
</div>
<!-- Authentication Form (Hidden by default) -->
<div id="login-container" class="d-none">
<div class="auth-container">
<div class="card">
<div class="card-header">
<h2><i class="fas fa-sign-in-alt"></i> Login</h2>
</div>
<div class="card-body">
<form id="login-form">
<div class="form-group">
<label class="form-label" for="username">Username</label>
<input type="text" id="username" class="form-control" required>
</div>
<div class="form-group">
<label class="form-label" for="password">Password</label>
<input type="password" id="password" class="form-control" required>
</div>
<div class="mt-3 text-center">
<button type="submit" class="btn btn-primary btn-lg">
<i class="fas fa-unlock-alt"></i> Login
</button>
</div>
</form>
</div>
</div>
</div>
</div>
<!-- Main App Content -->
<div id="app-container">
<header>
<div class="navbar">
<a href="#" class="navbar-brand">
<i class="fas fa-rss"></i> Transmission RSS Manager
</a>
<ul class="navbar-menu">
<li class="navbar-item">
<a href="#" class="navbar-link nav-link active" data-tab="home-tab">
<i class="fas fa-home"></i> Dashboard
</a>
</li>
<li class="navbar-item">
<a href="#" class="navbar-link nav-link" data-tab="rss-tab">
<i class="fas fa-rss"></i> RSS Feeds
</a>
</li>
<li class="navbar-item">
<a href="#" class="navbar-link nav-link" data-tab="torrents-tab">
<i class="fas fa-download"></i> Torrents
</a>
</li>
<li class="navbar-item">
<a href="#" class="navbar-link nav-link" data-tab="media-tab">
<i class="fas fa-photo-video"></i> Media Library
</a>
</li>
<li class="navbar-item">
<a href="#" class="navbar-link nav-link" data-tab="settings-tab">
<i class="fas fa-cog"></i> Settings
</a>
</li>
</ul>
<div class="navbar-right">
<span id="user-info"></span>
<button id="theme-toggle" class="theme-toggle" aria-label="Toggle Theme">
<i id="theme-toggle-icon" class="fas fa-moon"></i>
</button>
</div>
</div>
</header>
<div class="container">
<!-- Tab Content -->
<div id="home-tab" class="tab-content active">
<div class="card">
<div class="card-header">
<h2><i class="fas fa-tachometer-alt"></i> Dashboard</h2>
<button id="btn-refresh-status" class="btn btn-outline">
<i class="fas fa-sync-alt"></i> Refresh
</button>
</div>
<div class="card-body">
<div id="status-container">
<!-- System Status Card -->
<div class="row">
<div class="col-md-4 mb-3">
<div class="card">
<div class="card-header">
<h4><i class="fas fa-info-circle"></i> System Status</h4>
</div>
<div class="card-body">
<div class="status-item">
<span class="status-label">Version:</span>
<span id="system-version" class="status-value">Loading...</span>
</div>
<div class="status-item">
<span class="status-label">Running since:</span>
<span id="uptime" class="status-value">Loading...</span>
</div>
<div class="status-item">
<span class="status-label">Transmission:</span>
<span id="transmission-status" class="status-value">
<i class="fas fa-circle-notch fa-spin"></i> Checking...
</span>
</div>
<div class="status-item">
<span class="status-label">Update:</span>
<span id="update-status" class="status-value">
<i class="fas fa-circle-notch fa-spin"></i> Checking...
</span>
</div>
<!-- Testing Controls (hidden in production) -->
<div class="mt-2 testing-controls">
<small><a href="#" id="toggle-test-update-button">Toggle Test Update</a></small>
</div>
<div id="update-available" class="mt-3">
<div class="alert alert-info update-alert" style="display: none;">
<i class="fas fa-arrow-circle-up"></i>
<span>A new version is available!</span>
<button id="btn-update-now" class="btn btn-sm btn-primary ml-2">
<i class="fas fa-download"></i> Update Now
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Original Status Content -->
<p>Loading system status...</p>
</div>
<h3 class="mt-3">Quick Actions</h3>
<div class="row">
<div class="col-md-4 mb-3">
<div class="card">
<div class="card-header">
<h4><i class="fas fa-rss"></i> RSS Manager</h4>
</div>
<div class="card-body">
<p>Manage your RSS feeds and automatic downloads</p>
</div>
<div class="card-footer">
<button id="btn-update-feeds" class="btn btn-success">
<i class="fas fa-sync"></i> Update Feeds Now
</button>
</div>
</div>
</div>
<div class="col-md-4 mb-3">
<div class="card">
<div class="card-header">
<h4><i class="fas fa-cogs"></i> Post-Processing</h4>
</div>
<div class="card-body">
<p>Process completed downloads to your media library</p>
</div>
<div class="card-footer">
<button id="btn-start-processor" class="btn btn-success">
<i class="fas fa-play"></i> Start
</button>
<button id="btn-stop-processor" class="btn btn-warning">
<i class="fas fa-stop"></i> Stop
</button>
</div>
</div>
</div>
<div class="col-md-4 mb-3">
<div class="card">
<div class="card-header">
<h4><i class="fas fa-magnet"></i> Add New Torrent</h4>
</div>
<div class="card-body">
<form id="add-torrent-form">
<div class="form-group">
<label class="form-label" for="torrent-url">Torrent URL or Magnet Link</label>
<input type="text" id="torrent-url" class="form-control" placeholder="Enter torrent URL or magnet link">
</div>
<div class="form-group">
<button type="submit" class="btn btn-success">
<i class="fas fa-plus"></i> Add Torrent
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- RSS Tab -->
<div id="rss-tab" class="tab-content">
<div class="card">
<div class="card-header">
<h2><i class="fas fa-rss"></i> RSS Feeds</h2>
<button id="btn-add-feed" class="btn btn-success">
<i class="fas fa-plus"></i> Add New Feed
</button>
</div>
<div class="card-body">
<div id="feeds-container">
<p>Loading feeds...</p>
</div>
</div>
</div>
<div class="card mt-3">
<div class="card-header">
<h2><i class="fas fa-list"></i> Available Items</h2>
<div>
<label class="form-check form-check-inline">
<input type="radio" name="item-filter" value="all" checked class="form-check-input">
<span class="form-check-label">All Items</span>
</label>
<label class="form-check form-check-inline">
<input type="radio" name="item-filter" value="undownloaded" class="form-check-input">
<span class="form-check-label">Undownloaded Only</span>
</label>
</div>
</div>
<div class="card-body">
<div id="items-container">
<p>Loading items...</p>
</div>
</div>
</div>
</div>
<!-- Torrents Tab -->
<div id="torrents-tab" class="tab-content">
<div class="card">
<div class="card-header">
<h2><i class="fas fa-download"></i> Active Torrents</h2>
<button id="refresh-torrents" class="btn btn-primary">
<i class="fas fa-sync"></i> Refresh
</button>
</div>
<div class="card-body">
<div id="torrents-container">
<p>Loading torrents...</p>
</div>
</div>
</div>
</div>
<!-- Media Library Tab -->
<div id="media-tab" class="tab-content">
<div class="card">
<div class="card-header">
<h2><i class="fas fa-photo-video"></i> Media Library</h2>
</div>
<div class="card-body">
<div class="row mb-3">
<div class="col-md-8">
<div class="form-group">
<label for="library-search" class="form-label">Search Library</label>
<div class="input-group">
<span class="input-group-text"><i class="fas fa-search"></i></span>
<input type="text" id="library-search" class="form-control" placeholder="Search your library...">
</div>
</div>
</div>
<div class="col-md-4">
<label class="form-label">Filter by Category</label>
<div class="form-group">
<label class="form-check">
<input type="radio" name="library-filter" value="all" checked class="form-check-input">
<span class="form-check-label">All</span>
</label>
<label class="form-check">
<input type="radio" name="library-filter" value="movies" class="form-check-input">
<span class="form-check-label">Movies</span>
</label>
<label class="form-check">
<input type="radio" name="library-filter" value="tvShows" class="form-check-input">
<span class="form-check-label">TV Shows</span>
</label>
<label class="form-check">
<input type="radio" name="library-filter" value="music" class="form-check-input">
<span class="form-check-label">Music</span>
</label>
<label class="form-check">
<input type="radio" name="library-filter" value="books" class="form-check-input">
<span class="form-check-label">Books</span>
</label>
<label class="form-check">
<input type="radio" name="library-filter" value="magazines" class="form-check-input">
<span class="form-check-label">Magazines</span>
</label>
<label class="form-check">
<input type="radio" name="library-filter" value="software" class="form-check-input">
<span class="form-check-label">Software</span>
</label>
</div>
</div>
</div>
<div id="library-container">
<p>Loading media library...</p>
</div>
</div>
</div>
</div>
<!-- Settings Tab -->
<div id="settings-tab" class="tab-content">
<form id="settings-form">
<div class="row">
<div class="col-md-6 mb-3">
<div class="card">
<div class="card-header">
<h2><i class="fas fa-exchange-alt"></i> Transmission Settings</h2>
</div>
<div class="card-body">
<div class="form-group">
<label class="form-label" for="transmission-host">Host:</label>
<input type="text" id="transmission-host" class="form-control" placeholder="localhost">
</div>
<div class="form-group">
<label class="form-label" for="transmission-port">Port:</label>
<input type="number" id="transmission-port" class="form-control" placeholder="9091">
</div>
<div class="form-group">
<label class="form-label" for="transmission-user">Username:</label>
<input type="text" id="transmission-user" class="form-control">
</div>
<div class="form-group">
<label class="form-label" for="transmission-pass">Password:</label>
<input type="password" id="transmission-pass" class="form-control">
</div>
</div>
<div class="card-footer">
<button type="button" id="test-connection" class="btn btn-outline" onclick="testTransmissionConnection()">
<i class="fas fa-plug"></i> Test Connection
</button>
</div>
</div>
</div>
<div class="col-md-6 mb-3">
<div class="card">
<div class="card-header">
<h2><i class="fas fa-shield-alt"></i> Security Settings</h2>
</div>
<div class="card-body">
<div class="form-check mb-3">
<input type="checkbox" id="https-enabled" class="form-check-input">
<label class="form-check-label" for="https-enabled">
Enable HTTPS
</label>
<small class="text-muted d-block mt-1">
Requires valid SSL certificate (not self-signed)
</small>
</div>
<div class="form-check mb-3">
<input type="checkbox" id="auth-enabled" class="form-check-input">
<label class="form-check-label" for="auth-enabled">
Enable Authentication
</label>
<small class="text-muted d-block mt-1">
Requires login to access the application
</small>
</div>
<div id="auth-settings" class="mt-3 d-none">
<div class="form-group">
<label class="form-label" for="admin-username">Admin Username:</label>
<input type="text" id="admin-username" class="form-control" placeholder="admin">
</div>
<div class="form-group">
<label class="form-label" for="admin-password">Admin Password:</label>
<input type="password" id="admin-password" class="form-control">
</div>
</div>
</div>
</div>
</div>
</div>
<div class="card mb-3">
<div class="card-header">
<h2><i class="fas fa-cogs"></i> Post-Processing Settings</h2>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-4">
<h3>Seeding Requirements</h3>
<div class="form-group">
<label class="form-label" for="seeding-ratio">Minimum Seeding Ratio:</label>
<input type="number" id="seeding-ratio" class="form-control" min="0" step="0.1" placeholder="1.0">
</div>
<div class="form-group">
<label class="form-label" for="seeding-time">Minimum Seeding Time (minutes):</label>
<input type="number" id="seeding-time" class="form-control" min="0" placeholder="60">
</div>
<div class="form-group">
<label class="form-label" for="check-interval">Check Interval (seconds):</label>
<input type="number" id="check-interval" class="form-control" min="30" placeholder="300">
</div>
</div>
<div class="col-md-4">
<h3>Media Paths</h3>
<div class="form-group">
<label class="form-label" for="movies-path">Movies Path:</label>
<input type="text" id="movies-path" class="form-control" placeholder="/mnt/media/movies">
</div>
<div class="form-group">
<label class="form-label" for="tvshows-path">TV Shows Path:</label>
<input type="text" id="tvshows-path" class="form-control" placeholder="/mnt/media/tvshows">
</div>
<div class="form-group">
<label class="form-label" for="music-path">Music Path:</label>
<input type="text" id="music-path" class="form-control" placeholder="/mnt/media/music">
</div>
<div class="form-group">
<label class="form-label" for="books-path">Books Path:</label>
<input type="text" id="books-path" class="form-control" placeholder="/mnt/media/books">
</div>
<div class="form-group">
<label class="form-label" for="magazines-path">Magazines Path:</label>
<input type="text" id="magazines-path" class="form-control" placeholder="/mnt/media/magazines">
</div>
<div class="form-group">
<label class="form-label" for="software-path">Software Path:</label>
<input type="text" id="software-path" class="form-control" placeholder="/mnt/media/software">
</div>
</div>
<div class="col-md-4">
<h3>Processing Options</h3>
<div class="form-check mb-2">
<input type="checkbox" id="extract-archives" class="form-check-input">
<label class="form-check-label" for="extract-archives">
Extract Archives
</label>
</div>
<div class="form-check mb-2">
<input type="checkbox" id="delete-archives" class="form-check-input">
<label class="form-check-label" for="delete-archives">
Delete Archives After Extraction
</label>
</div>
<div class="form-check mb-2">
<input type="checkbox" id="create-category-folders" class="form-check-input">
<label class="form-check-label" for="create-category-folders">
Create Category Folders
</label>
</div>
<div class="form-check mb-2">
<input type="checkbox" id="rename-files" class="form-check-input">
<label class="form-check-label" for="rename-files">
Rename Files
</label>
</div>
<div class="form-check mb-2">
<input type="checkbox" id="ignore-sample" class="form-check-input">
<label class="form-check-label" for="ignore-sample">
Ignore Sample Files
</label>
</div>
<div class="form-check mb-2">
<input type="checkbox" id="ignore-extras" class="form-check-input">
<label class="form-check-label" for="ignore-extras">
Ignore Extras Files
</label>
</div>
</div>
</div>
</div>
</div>
<div class="card mb-3">
<div class="card-header">
<h2><i class="fas fa-rss"></i> RSS Settings</h2>
</div>
<div class="card-body">
<div class="form-group">
<label class="form-label" for="rss-interval">Update Interval (minutes):</label>
<input type="number" id="rss-interval" class="form-control" min="5" placeholder="60">
</div>
<div class="form-group">
<label class="form-label" for="notification-type">Notifications:</label>
<select id="notification-type" class="form-control">
<option value="none">None</option>
<option value="email">Email</option>
<option value="telegram">Telegram</option>
<option value="discord">Discord</option>
</select>
</div>
<div id="notification-settings" class="d-none mt-3">
<!-- This will be populated dynamically based on notification type -->
</div>
</div>
</div>
<div class="card mb-3">
<div class="card-footer text-right">
<button type="submit" class="btn btn-success btn-lg">
<i class="fas fa-save"></i> Save All Settings
</button>
</div>
</div>
</form>
</div>
</div>
<!-- Footer -->
<footer class="footer mt-3">
<div class="container">
<div class="row">
<div class="col-md-6">
<p>Transmission RSS Manager <span id="footer-version">v2.0.10</span></p>
</div>
<div class="col-md-6 text-right">
<p><a href="https://git.powerdata.dk/masterdraco/transmission-rss-manager" target="_blank" rel="noopener noreferrer">GitHub</a> | <a href="#" id="show-about-modal">About</a></p>
</div>
</div>
</div>
</footer>
</div>
<!-- About Modal -->
<div class="modal-backdrop" id="about-modal" style="display: none;">
<div class="modal">
<div class="modal-header">
<h2><i class="fas fa-info-circle"></i> About Transmission RSS Manager</h2>
<button class="modal-close" id="close-about-modal">&times;</button>
</div>
<div class="modal-body">
<div class="about-logo text-center mb-4">
<img src="https://powerdata.dk/wp-content/uploads/2023/08/PowerData-logo-1.png" alt="PowerData Logo" style="max-width: 200px;">
</div>
<h3>Developed by PowerData.dk</h3>
<p class="lead mb-4">Created by Michael Bay-Laursen, a passionate developer with a dedication to creating elegant, powerful automation solutions.</p>
<div class="card mb-4">
<div class="card-header">
<h4>About the Developer</h4>
</div>
<div class="card-body">
<p>Michael Bay-Laursen is the talented founder of PowerData.dk, bringing years of experience in system automation, network management, and web application development to create tools that simplify complex tasks.</p>
<p>With a focus on user experience and robust functionality, Michael has designed this application to make media management accessible to everyone while providing the power and flexibility that advanced users demand.</p>
</div>
</div>
<h4>Key Features</h4>
<ul class="features-list">
<li><strong>Intelligent RSS Integration</strong> - Automatically download content based on customizable filters</li>
<li><strong>Smart Media Organization</strong> - Categorize and organize your downloads automatically</li>
<li><strong>One-Click Updates</strong> - Stay current with the latest features and improvements</li>
<li><strong>Responsive Interface</strong> - Manage your downloads from any device</li>
<li><strong>Advanced Content Detection</strong> - Automatic categorization of various media types</li>
<li><strong>Transmission Integration</strong> - Seamless control of your Transmission client</li>
</ul>
<h4>Version History</h4>
<div class="version-history">
<div class="version">
<h5>v2.0.11 - March 2025</h5>
<ul>
<li><strong>Fixed</strong>: Update button persistence with floating notification</li>
<li><strong>Fixed</strong>: Version display issues with direct package.json reading</li>
<li><strong>Fixed</strong>: Update process for better version reporting</li>
<li><strong>Fixed</strong>: Conflict between test mode and actual update status</li>
<li><strong>Added</strong>: Refresh button on update notification</li>
<li><strong>Added</strong>: Clear test mode indicators to prevent confusion</li>
<li><strong>Improved</strong>: Enhanced cache busting for version checking</li>
<li><strong>Improved</strong>: Better user feedback during update process</li>
</ul>
</div>
<div class="version">
<h5>v2.0.10 - March 2025</h5>
<ul>
<li><strong>Fixed</strong>: fs.existsSync error in update check</li>
<li><strong>Fixed</strong>: Update button now stays visible when update is available</li>
<li><strong>Fixed</strong>: Footer version now shows correct running version</li>
<li><strong>Improved</strong>: Better error handling for git repository checks</li>
<li><strong>Improved</strong>: More robust file system operations for update detection</li>
</ul>
</div>
<div class="version">
<h5>v2.0.9 - March 2025</h5>
<ul>
<li><strong>Fixed</strong>: Update button now appears properly on dashboard</li>
<li><strong>Fixed</strong>: Remote Transmission connection issues resolved</li>
<li><strong>Fixed</strong>: Improved connection test with better error handling</li>
<li><strong>Added</strong>: System status and update endpoints for version checking</li>
<li><strong>Improved</strong>: Update detection and notification on dashboard</li>
</ul>
</div>
<div class="version">
<h5>v2.0.6 - March 2025</h5>
<ul>
<li><strong>Fixed</strong>: Remote Transmission configuration storage and application</li>
<li><strong>Fixed</strong>: Config directory issues in /etc/transmission-rss-manager</li>
<li><strong>Fixed</strong>: Input handling for automated/piped installations</li>
<li><strong>Improved</strong>: Non-interactive mode for scripted installations</li>
<li><strong>Added</strong>: Enhanced debug logging for installation process</li>
</ul>
</div>
<div class="version">
<h5>v2.0.0 - March 2025</h5>
<ul>
<li><strong>Major</strong>: Completely redesigned installation system using git</li>
<li><strong>New</strong>: One-click update feature with version checking</li>
<li><strong>New</strong>: Automatic Transmission detection and installation</li>
<li><strong>New</strong>: System status dashboard with update notifications</li>
<li><strong>New</strong>: About page with developer information</li>
<li><strong>Improved</strong>: Better error handling for Transmission connection</li>
<li><strong>Fixed</strong>: Various UI bugs and responsiveness issues</li>
</ul>
</div>
<div class="version">
<h5>v1.2.0 - February 2025</h5>
<ul>
<li><strong>New</strong>: Enhanced media organization with better content detection</li>
<li><strong>New</strong>: Improved post-processor with configurable filtering</li>
<li><strong>Improved</strong>: Transmission client integration with better error handling</li>
<li><strong>Improved</strong>: UI enhancements for better usability</li>
<li><strong>Fixed</strong>: RSS feed parsing issues</li>
<li><strong>Fixed</strong>: Authentication problems in certain configurations</li>
</ul>
</div>
<div class="version">
<h5>v1.0.0 - January 2025</h5>
<ul>
<li><strong>Initial Release</strong>: Basic functionality for RSS feed monitoring</li>
<li><strong>New</strong>: Integration with Transmission BitTorrent client</li>
<li><strong>New</strong>: Web interface for managing torrents and feeds</li>
<li><strong>New</strong>: Post-processing capability for downloaded content</li>
<li><strong>New</strong>: Basic content categorization by media type</li>
</ul>
</div>
</div>
<div class="text-center mt-4">
<p><strong id="about-version">Transmission RSS Manager v2.0.11</strong></p>
<p>© 2025 PowerData.dk - All Rights Reserved</p>
<p><a href="https://powerdata.dk" target="_blank">Visit PowerData.dk</a></p>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-primary" id="close-about-btn">Close</button>
</div>
</div>
</div>
<!-- Update Alert Custom Styles -->
<style>
/* Custom styles for update alert to ensure it's visible */
.update-alert {
display: none;
margin-top: 10px !important;
border: 2px solid #007bff !important;
background-color: #cce5ff !important;
color: #004085 !important;
font-weight: bold !important;
box-shadow: 0 2px 5px rgba(0,0,0,0.2) !important;
position: relative !important;
z-index: 100 !important;
}
.update-alert span {
color: #004085 !important;
font-weight: bold !important;
}
/* Floating update notification that's impossible to miss */
#floating-update-notification {
display: none;
position: fixed;
top: 20px;
right: 20px;
width: 300px;
padding: 15px;
background-color: #ff5555;
color: white;
border: 3px solid #cc0000;
border-radius: 5px;
box-shadow: 0 0 20px rgba(0,0,0,0.5);
z-index: 10000;
font-weight: bold;
text-align: center;
}
#floating-update-notification button {
margin-top: 10px;
padding: 5px 10px;
background-color: white;
color: #cc0000;
border: none;
border-radius: 3px;
font-weight: bold;
cursor: pointer;
}
#floating-update-notification button:hover {
background-color: #eeeeee;
}
</style>
<!-- Removed floating notification completely -->
<!-- The floating notification was removed to fix persistent display issues -->
<!-- JavaScript Files -->
<script src="/js/system-status.js"></script>
<script src="/js/app.js"></script>
<!-- About Modal Script -->
<script>
document.addEventListener('DOMContentLoaded', function() {
// Show about modal
document.getElementById('show-about-modal').addEventListener('click', function(e) {
e.preventDefault();
document.getElementById('about-modal').style.display = 'flex';
});
// Close modal handlers
document.getElementById('close-about-modal').addEventListener('click', closeAboutModal);
document.getElementById('close-about-btn').addEventListener('click', closeAboutModal);
function closeAboutModal() {
document.getElementById('about-modal').style.display = 'none';
}
});
</script>
</body>
</html>

1655
public/js/app.js Normal file

File diff suppressed because it is too large Load Diff

784
public/js/system-status.js Normal file
View File

@ -0,0 +1,784 @@
/**
* Transmission RSS Manager - System Status Module
* @description Functionality for system status display and updates
*/
// System status and updates functionality
function initSystemStatus() {
// Elements
const versionElement = document.getElementById('system-version');
const uptimeElement = document.getElementById('uptime');
const transmissionStatusElement = document.getElementById('transmission-status');
const updateStatusElement = document.getElementById('update-status');
const updateAvailableDiv = document.getElementById('update-available');
const updateButton = document.getElementById('btn-update-now');
const refreshButton = document.getElementById('btn-refresh-status');
// Load system status
function loadSystemStatus() {
// Add cache-busting parameter
const cacheBuster = `?_=${new Date().getTime()}`;
fetch('/api/system/status' + cacheBuster, {
headers: authHeaders()
})
.then(handleResponse)
.then(data => {
if (data.status === 'success') {
// Update version display
versionElement.textContent = data.data.version;
uptimeElement.textContent = data.data.uptime;
// Also update footer version
const footerVersion = document.getElementById('footer-version');
if (footerVersion) {
footerVersion.textContent = 'v' + data.data.version;
}
// Update version in about modal too if it exists
const aboutVersionElement = document.getElementById('about-version');
if (aboutVersionElement) {
aboutVersionElement.textContent = 'Transmission RSS Manager v' + data.data.version;
}
// Update transmission status with icon
if (data.data.transmissionStatus === 'Connected') {
transmissionStatusElement.innerHTML = '<i class="fas fa-check-circle text-success"></i> Connected';
} else {
transmissionStatusElement.innerHTML = '<i class="fas fa-times-circle text-danger"></i> Disconnected';
}
} else {
showNotification('Failed to load system status', 'danger');
}
})
.catch(error => {
console.error('Error fetching system status:', error);
showNotification('Failed to connect to server', 'danger');
});
}
// More robust update check status tracking
const UPDATE_KEY = 'trm_update_available';
const CURRENT_VERSION_KEY = 'trm_current_version';
const REMOTE_VERSION_KEY = 'trm_remote_version';
// Force clear any existing update notification state
localStorage.removeItem(UPDATE_KEY);
localStorage.removeItem(CURRENT_VERSION_KEY);
localStorage.removeItem(REMOTE_VERSION_KEY);
let updateCheckInProgress = false;
// Function to show update alert
function showUpdateAlert(currentVersion, remoteVersion) {
// Set status text in the system status panel
updateStatusElement.innerHTML = '<i class="fas fa-exclamation-circle text-warning"></i> Update available';
// Show only the original alert box in the dashboard
try {
const alertBox = updateAvailableDiv.querySelector('.alert');
if (alertBox) {
alertBox.style.display = 'block';
const spanElement = alertBox.querySelector('span');
if (spanElement) {
spanElement.textContent = `A new version is available: ${currentVersion}${remoteVersion}`;
}
}
} catch (e) {
console.error('Error showing original alert box:', e);
}
// We've removed the floating notification entirely, so this part is skipped
console.log('Update alert shown in dashboard:', currentVersion, '->', remoteVersion);
// Store in localStorage
localStorage.setItem(UPDATE_KEY, 'true');
localStorage.setItem(CURRENT_VERSION_KEY, currentVersion);
localStorage.setItem(REMOTE_VERSION_KEY, remoteVersion);
}
// Function to hide update alert
function hideUpdateAlert() {
// Hide original alert
try {
const alertBox = updateAvailableDiv.querySelector('.alert');
if (alertBox) {
alertBox.style.display = 'none';
}
} catch (e) {
console.error('Error hiding original alert:', e);
}
// Clear localStorage
localStorage.removeItem(UPDATE_KEY);
localStorage.removeItem(CURRENT_VERSION_KEY);
localStorage.removeItem(REMOTE_VERSION_KEY);
console.log('Update alert hidden');
}
// Check localStorage on init and set up MutationObserver to prevent hiding
(function checkStoredUpdateStatus() {
const isUpdateAvailable = localStorage.getItem(UPDATE_KEY) === 'true';
if (isUpdateAvailable) {
const currentVersion = localStorage.getItem(CURRENT_VERSION_KEY);
const remoteVersion = localStorage.getItem(REMOTE_VERSION_KEY);
if (currentVersion && remoteVersion) {
showUpdateAlert(currentVersion, remoteVersion);
// Set up mutation observer to detect and revert any attempts to hide the update alert
const alertBox = updateAvailableDiv.querySelector('.alert');
if (alertBox) {
const observer = new MutationObserver(function(mutations) {
mutations.forEach(function(mutation) {
if (mutation.type === 'attributes' &&
(mutation.attributeName === 'style' ||
mutation.attributeName === 'class')) {
// If display is being changed to hide the element, force it back to visible
if (alertBox.style.display !== 'block' ||
alertBox.classList.contains('d-none') ||
alertBox.style.visibility === 'hidden' ||
alertBox.style.opacity === '0') {
console.log('Detected attempt to hide update button, forcing display');
showUpdateAlert(currentVersion, remoteVersion);
}
}
});
});
// Observe style and class attribute changes
observer.observe(alertBox, {
attributes: true,
attributeFilter: ['style', 'class']
});
// Store observer in window object to prevent garbage collection
window._updateButtonObserver = observer;
}
}
}
})();
// Check for updates
function checkForUpdates() {
updateStatusElement.innerHTML = '<i class="fas fa-circle-notch fa-spin"></i> Checking...';
updateCheckInProgress = true;
// Add test=true parameter to force update availability for testing
const testMode = localStorage.getItem('showUpdateButton') === 'true';
const cacheBuster = `_=${new Date().getTime()}`;
const url = testMode
? `/api/system/check-updates?test=true&${cacheBuster}`
: `/api/system/check-updates?${cacheBuster}`;
// Set a timeout to detect network issues
const timeoutId = setTimeout(() => {
updateStatusElement.innerHTML = '<i class="fas fa-times-circle text-danger"></i> Check timed out';
updateCheckInProgress = false;
showNotification('Update check timed out. Please try again later.', 'warning');
}, 10000); // 10 second timeout
// Create a timeout controller
const controller = new AbortController();
const timeoutId2 = setTimeout(() => controller.abort(), 15000);
fetch(url, {
headers: authHeaders(),
// Add a fetch timeout using abort controller
signal: controller.signal // 15 second timeout
})
.then(response => {
clearTimeout(timeoutId2);
clearTimeout(timeoutId);
return response;
})
.catch(error => {
clearTimeout(timeoutId2);
clearTimeout(timeoutId);
throw error;
})
.then(response => {
// Better error checking
if (!response.ok) {
return response.json().then(data => {
throw new Error(data.message || `Server error: ${response.status}`);
}).catch(e => {
if (e instanceof SyntaxError) {
throw new Error(`Server error: ${response.status}`);
}
throw e;
});
}
return response.json();
})
.then(data => {
updateCheckInProgress = false;
if (data.status === 'success') {
if (data.data && data.data.updateAvailable) {
// Show update alert with version info
showUpdateAlert(data.data.currentVersion, data.data.remoteVersion);
// Log to console for debugging
console.log('Update available detected:', data.data.currentVersion, '->', data.data.remoteVersion);
} else {
// No update available
updateStatusElement.innerHTML = '<i class="fas fa-check-circle text-success"></i> Up to date';
hideUpdateAlert();
// Force reload system status to ensure version is current
setTimeout(() => loadSystemStatus(), 1000);
}
} else {
// Error status but with a response
updateStatusElement.innerHTML = '<i class="fas fa-times-circle text-danger"></i> Check failed';
showNotification(data.message || 'Failed to check for updates', 'danger');
// Don't clear update status on error - keep any previous update notification
}
})
.catch(error => {
updateCheckInProgress = false;
clearTimeout(timeoutId);
console.error('Error checking for updates:', error);
updateStatusElement.innerHTML = '<i class="fas fa-times-circle text-danger"></i> Check failed';
// More specific error message based on the error type
if (error.name === 'AbortError') {
showNotification('Update check timed out. Please try again later.', 'warning');
} else if (error.message.includes('Failed to fetch') || error.message.includes('NetworkError')) {
showNotification('Network error. Please check your connection and try again.', 'danger');
} else {
showNotification(error.message || 'Failed to connect to server', 'danger');
}
// Don't clear update status on error - keep any previous update notification
});
}
// Apply update
function applyUpdate() {
// Show confirmation dialog
if (!confirm('Are you sure you want to update the application? The service will restart.')) {
return;
}
// Disable test mode whenever we try to apply an update
localStorage.setItem('showUpdateButton', 'false');
// Update toggle button text if it exists
const testToggle = document.getElementById('toggle-test-update-button');
if (testToggle) {
testToggle.innerText = 'Enable Test Update';
}
// Show loading state on both update buttons
// Original button
if (updateButton) {
updateButton.disabled = true;
updateButton.innerHTML = '<i class="fas fa-circle-notch fa-spin"></i> Updating...';
}
// Floating notification button
const floatingButton = document.getElementById('floating-update-button');
if (floatingButton) {
floatingButton.disabled = true;
floatingButton.textContent = 'Updating...';
}
showNotification('Applying update. Please wait...', 'info');
// Set a timeout for the update process
const updateTimeoutId = setTimeout(() => {
// Re-enable original button
if (updateButton) {
updateButton.disabled = false;
updateButton.innerHTML = '<i class="fas fa-download"></i> Update Now';
}
// Re-enable floating button
if (floatingButton) {
floatingButton.disabled = false;
floatingButton.textContent = 'Update Now';
}
showNotification('Update process timed out. Please try again or check server logs.', 'warning');
}, 60000); // 60 second timeout for the entire update process
// Create a timeout controller
const updateController = new AbortController();
const updateTimeoutId2 = setTimeout(() => updateController.abort(), 45000);
fetch('/api/system/update', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...authHeaders()
},
signal: updateController.signal // 45 second timeout
})
.then(response => {
clearTimeout(updateTimeoutId2);
return response;
})
.catch(error => {
clearTimeout(updateTimeoutId2);
throw error;
})
.then(response => {
// Better error checking
if (!response.ok) {
return response.json().then(data => {
throw new Error(data.message || `Server error: ${response.status}`);
}).catch(e => {
if (e instanceof SyntaxError) {
throw new Error(`Server error: ${response.status}`);
}
throw e;
});
}
return response.json();
})
.then(data => {
clearTimeout(updateTimeoutId);
if (data.status === 'success') {
// Check if there's an update message to determine if an update was actually applied
const updateApplied = data.message && data.message.includes('Update applied successfully');
const noNewUpdate = data.data && data.data.output && data.data.output.includes('already have the latest version');
// Hide update notification
hideUpdateAlert();
if (noNewUpdate) {
// If no update was needed, show a different message
showNotification('You already have the latest version. No update was needed.', 'info');
// Re-enable both buttons
if (updateButton) {
updateButton.disabled = false;
updateButton.innerHTML = '<i class="fas fa-download"></i> Check Again';
}
const floatingButton = document.getElementById('floating-update-button');
if (floatingButton) {
floatingButton.disabled = false;
floatingButton.textContent = 'Check Again';
}
// Update page to show current version without reloading
loadSystemStatus();
// Double-check system status again after a delay to ensure version is updated
setTimeout(() => {
loadSystemStatus();
checkForUpdates(); // Run check again to update status text
}, 2000);
return;
}
// Show success notification
showNotification('Update applied successfully. The page will reload in 30 seconds.', 'success');
// Update both buttons with countdown
let secondsLeft = 30;
// Function to update the countdown text
function updateCountdown() {
// Update original button if it exists
if (updateButton) {
updateButton.innerHTML = `<i class="fas fa-sync"></i> Reloading in ${secondsLeft}s...`;
}
// Update floating button if it exists
const floatingButton = document.getElementById('floating-update-button');
if (floatingButton) {
floatingButton.textContent = `Reloading in ${secondsLeft}s...`;
}
}
// Initial text update
updateCountdown();
// Start countdown
const countdownInterval = setInterval(() => {
secondsLeft--;
updateCountdown();
if (secondsLeft <= 0) {
clearInterval(countdownInterval);
// Clear localStorage to ensure a clean reload
localStorage.removeItem(UPDATE_KEY);
localStorage.removeItem(CURRENT_VERSION_KEY);
localStorage.removeItem(REMOTE_VERSION_KEY);
// Also ensure floating notification is completely removed
const floatingNotification = document.getElementById('floating-update-notification');
if (floatingNotification) {
floatingNotification.style.display = 'none';
floatingNotification.removeAttribute('style');
}
// Force a clean reload
window.location.href = window.location.href.split('#')[0] + '?t=' + new Date().getTime();
}
}, 1000);
// Set a timer to reload the page after the service has time to restart
setTimeout(() => {
clearInterval(countdownInterval);
// Clear localStorage to ensure a clean reload
localStorage.removeItem(UPDATE_KEY);
localStorage.removeItem(CURRENT_VERSION_KEY);
localStorage.removeItem(REMOTE_VERSION_KEY);
// Also ensure floating notification is completely removed
const floatingNotification = document.getElementById('floating-update-notification');
if (floatingNotification) {
floatingNotification.style.display = 'none';
floatingNotification.removeAttribute('style');
}
// Force a clean reload with cache-busting parameter
window.location.href = window.location.href.split('#')[0] + '?t=' + new Date().getTime();
}, 30000);
} else {
// Enable both buttons on failure
if (updateButton) {
updateButton.disabled = false;
updateButton.innerHTML = '<i class="fas fa-download"></i> Update Now';
}
const floatingButton = document.getElementById('floating-update-button');
if (floatingButton) {
floatingButton.disabled = false;
floatingButton.textContent = 'Update Now';
}
showNotification(data.message || 'Failed to apply update', 'danger');
}
})
.catch(error => {
clearTimeout(updateTimeoutId);
console.error('Error applying update:', error);
// Re-enable both buttons on error
if (updateButton) {
updateButton.disabled = false;
updateButton.innerHTML = '<i class="fas fa-download"></i> Update Now';
}
const floatingButton = document.getElementById('floating-update-button');
if (floatingButton) {
floatingButton.disabled = false;
floatingButton.textContent = 'Update Now';
}
// More specific error message based on the error type
if (error.name === 'AbortError') {
showNotification('Update request timed out. The server might still be processing the update.', 'warning');
} else if (error.message.includes('Failed to fetch') || error.message.includes('NetworkError')) {
showNotification('Network error. Please check your connection and try again.', 'danger');
} else {
showNotification(error.message || 'Failed to connect to server', 'danger');
}
});
}
// Event listeners
if (refreshButton) {
refreshButton.addEventListener('click', () => {
loadSystemStatus();
checkForUpdates();
});
}
if (updateButton) {
updateButton.addEventListener('click', applyUpdate);
}
// Add handler for floating refresh button
const floatingRefreshButton = document.getElementById('floating-refresh-button');
if (floatingRefreshButton) {
floatingRefreshButton.addEventListener('click', () => {
// Force a hard refresh of everything
floatingRefreshButton.textContent = 'Refreshing...';
floatingRefreshButton.disabled = true;
// Force reload system status
loadSystemStatus();
// Force a check without the test parameter to get real status
const realCheckUrl = `/api/system/check-updates?_=${new Date().getTime()}`;
fetch(realCheckUrl, { headers: authHeaders() })
.then(response => response.json())
.then(data => {
console.log('Manual refresh result:', data);
if (data.status === 'success') {
// Check if we're in test mode
const isTestMode = localStorage.getItem('showUpdateButton') === 'true';
// If test mode is enabled but update says no update available, disable test mode
if (isTestMode && data.data && !data.data.updateAvailable) {
localStorage.setItem('showUpdateButton', 'false');
testToggle.innerText = 'Enable Test Update';
showNotification('Test mode has been disabled - no real update is available', 'info');
hideUpdateAlert();
showNotification(`Current version: ${data.data.currentVersion}. You are up to date.`, 'success');
}
// Regular update handling
else if (data.data && data.data.updateAvailable) {
showUpdateAlert(data.data.currentVersion, data.data.remoteVersion);
showNotification(`Update is available: ${data.data.currentVersion}${data.data.remoteVersion}`, 'info');
} else {
hideUpdateAlert();
showNotification(`Current version: ${data.data.currentVersion}. You are up to date.`, 'success');
}
}
// Re-enable button
floatingRefreshButton.textContent = 'Refresh Status';
floatingRefreshButton.disabled = false;
})
.catch(error => {
console.error('Error during manual refresh:', error);
floatingRefreshButton.textContent = 'Refresh Status';
floatingRefreshButton.disabled = false;
showNotification('Error checking update status', 'danger');
});
});
}
// Test mode toggle (for developers)
const testToggle = document.getElementById('toggle-test-update-button');
if (testToggle) {
// Initialize based on current localStorage setting
const isTestMode = localStorage.getItem('showUpdateButton') === 'true';
// If test mode is enabled but we have a version mismatch, update the stored version
if (isTestMode && versionElement && versionElement.textContent) {
const currentVersion = versionElement.textContent.trim();
if (localStorage.getItem(CURRENT_VERSION_KEY) !== currentVersion) {
localStorage.setItem(CURRENT_VERSION_KEY, currentVersion);
}
}
// Update toggle text
testToggle.innerText = isTestMode ? 'Disable Test Update' : 'Enable Test Update';
// Add click handler
testToggle.addEventListener('click', (e) => {
e.preventDefault();
const currentSetting = localStorage.getItem('showUpdateButton') === 'true';
const newSetting = !currentSetting;
localStorage.setItem('showUpdateButton', newSetting);
testToggle.innerText = newSetting ? 'Disable Test Update' : 'Enable Test Update';
if (newSetting) {
// Get the current version from the version element
let currentVersion = '2.0.11'; // Default fallback
if (versionElement && versionElement.textContent) {
currentVersion = versionElement.textContent.trim();
}
// If enabling test mode, force show update button
showUpdateAlert(currentVersion, '2.1.0-test');
updateStatusElement.innerHTML = '<i class="fas fa-exclamation-circle text-warning"></i> Update available (TEST MODE)';
// Add test mode indicator to floating notification
const floatingNotification = document.getElementById('floating-update-notification');
if (floatingNotification) {
const testBadge = document.createElement('div');
testBadge.style.backgroundColor = 'orange';
testBadge.style.color = 'black';
testBadge.style.padding = '3px 8px';
testBadge.style.borderRadius = '4px';
testBadge.style.fontWeight = 'bold';
testBadge.style.marginBottom = '5px';
testBadge.style.fontSize = '12px';
testBadge.textContent = 'TEST MODE - NOT A REAL UPDATE';
// Insert at the top of the notification
floatingNotification.insertBefore(testBadge, floatingNotification.firstChild);
}
showNotification('TEST MODE ENABLED - This is not a real update', 'warning');
} else {
// If disabling test mode, check for real updates
hideUpdateAlert();
// Force a check without the test parameter to get real status
const realCheckUrl = '/api/system/check-updates';
fetch(realCheckUrl, { headers: authHeaders() })
.then(response => response.json())
.then(data => {
console.log('Real update check result:', data);
if (data.status === 'success' && data.data && !data.data.updateAvailable) {
showNotification('No actual updates are available.', 'info');
} else if (data.status === 'success' && data.data && data.data.updateAvailable) {
showUpdateAlert(data.data.currentVersion, data.data.remoteVersion);
showNotification(`A real update is available: ${data.data.currentVersion}${data.data.remoteVersion}`, 'info');
}
})
.catch(error => console.error('Error checking for real updates:', error));
showNotification('Test update button disabled', 'info');
}
});
}
// Persistent update button - force display every second if update is available
function forceShowUpdateButton() {
const isUpdateAvailable = localStorage.getItem(UPDATE_KEY) === 'true';
if (isUpdateAvailable) {
// Get the most current version
let currentVersion = localStorage.getItem(CURRENT_VERSION_KEY);
const remoteVersion = localStorage.getItem(REMOTE_VERSION_KEY);
// If we have the version element on screen, use that as the source of truth
if (versionElement && versionElement.textContent) {
const displayedVersion = versionElement.textContent.trim();
// Update stored version if different
if (displayedVersion !== currentVersion) {
localStorage.setItem(CURRENT_VERSION_KEY, displayedVersion);
currentVersion = displayedVersion;
}
}
if (currentVersion && remoteVersion) {
// Check floating notification
const floatingNotification = document.getElementById('floating-update-notification');
if (floatingNotification && floatingNotification.style.display !== 'block') {
console.log('Forcing floating update notification display');
// Set the version text
const versionElement = document.getElementById('floating-update-version');
if (versionElement) {
versionElement.textContent = `Version ${currentVersion}${remoteVersion}`;
}
// Apply strong styling - make sure to completely override any previous styles
floatingNotification.setAttribute('style', ''); // Clear any previous styles first
floatingNotification.setAttribute('style', ''); // Clear any previous styles first
floatingNotification.setAttribute('style',
'display: block !important; ' +
'visibility: visible !important; ' +
'opacity: 1 !important; ' +
'position: fixed !important; ' +
'top: 20px !important; ' +
'right: 20px !important; ' +
'width: 300px !important; ' +
'padding: 15px !important; ' +
'background-color: #ff5555 !important; ' +
'color: white !important; ' +
'border: 3px solid #cc0000 !important; ' +
'border-radius: 5px !important; ' +
'box-shadow: 0 0 20px rgba(0,0,0,0.5) !important; ' +
'z-index: 10000 !important; ' +
'font-weight: bold !important; ' +
'text-align: center !important;'
);
// Ensure button has correct event handler
const updateButton = document.getElementById('floating-update-button');
if (updateButton) {
// Remove any existing listeners
updateButton.removeEventListener('click', applyUpdate);
// Add new listener
updateButton.addEventListener('click', applyUpdate);
}
}
// Still try the original alert as a fallback
try {
const alertBox = updateAvailableDiv.querySelector('.alert');
if (alertBox && alertBox.style.display !== 'block') {
alertBox.style.display = 'block';
updateAvailableDiv.style.display = 'block';
// Update message
const spanElement = alertBox.querySelector('span');
if (spanElement) {
spanElement.textContent = `A new version is available: ${currentVersion}${remoteVersion}`;
}
}
} catch (e) {
console.error('Error forcing original update button:', e);
}
}
}
}
// Initialize
loadSystemStatus();
// SUPER EMERGENCY FIX: Force hide and remove all update notifications
const emergencyFix = () => {
// Hard reset all update states
localStorage.clear(); // Clear ALL localStorage to be absolutely safe
// Find and destroy any floating notification elements
document.querySelectorAll('[id*="notification"], [id*="update"], [class*="notification"], [class*="update"]').forEach(el => {
try {
if (el.id !== 'update-status' && !el.id.includes('refresh')) {
el.style.cssText = 'display: none !important; visibility: hidden !important; opacity: 0 !important';
el.removeAttribute('style');
if (el.parentNode) {
el.parentNode.removeChild(el);
console.log('Element removed from DOM:', el.id || el.className || 'unnamed element');
}
}
} catch (e) {
console.error('Error removing element:', e);
}
});
// Add a MutationObserver to keep killing any notification elements that might reappear
if (!window._notificationKiller) {
const observer = new MutationObserver(mutations => {
mutations.forEach(mutation => {
mutation.addedNodes.forEach(node => {
if (node.nodeType === 1) { // Element node
if ((node.id && (node.id.includes('notification') || node.id.includes('update'))) ||
(node.className && (node.className.includes('notification') || node.className.includes('update')))) {
if (node.id !== 'update-status' && !node.id.includes('refresh')) {
node.style.cssText = 'display: none !important';
if (node.parentNode) {
node.parentNode.removeChild(node);
console.log('Dynamically added notification killed');
}
}
}
}
});
});
});
observer.observe(document.body, {
childList: true,
subtree: true
});
window._notificationKiller = observer;
}
console.log('Super emergency notification cleanup complete');
};
// Run immediately
emergencyFix();
// Run again after delays to ensure it works
setTimeout(emergencyFix, 100);
setTimeout(emergencyFix, 500);
setTimeout(emergencyFix, 1000);
// Set interval to refresh uptime every minute
setInterval(loadSystemStatus, 60000);
}

637
public/js/utils.js Normal file
View File

@ -0,0 +1,637 @@
/**
* Utility functions for Transmission RSS Manager
*/
/**
* Format a byte value to a human-readable string
* @param {number} bytes - Bytes to format
* @param {number} decimals - Number of decimal places to show
* @returns {string} - Formatted string (e.g., "1.5 MB")
*/
export function formatBytes(bytes, decimals = 2) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
}
/**
* Create a debounced version of a function
* @param {Function} func - Function to debounce
* @param {number} wait - Milliseconds to wait
* @returns {Function} - Debounced function
*/
export function debounce(func, wait) {
let timeout;
return function(...args) {
const context = this;
clearTimeout(timeout);
timeout = setTimeout(() => func.apply(context, args), wait);
};
}
/**
* Create a throttled version of a function
* @param {Function} func - Function to throttle
* @param {number} limit - Milliseconds to throttle
* @returns {Function} - Throttled function
*/
export function throttle(func, limit) {
let inThrottle;
return function(...args) {
const context = this;
if (!inThrottle) {
func.apply(context, args);
inThrottle = true;
setTimeout(() => { inThrottle = false; }, limit);
}
};
}
/**
* Safely parse JSON with error handling
* @param {string} json - JSON string to parse
* @param {*} fallback - Fallback value if parsing fails
* @returns {*} - Parsed object or fallback
*/
export function safeJsonParse(json, fallback = {}) {
try {
return JSON.parse(json);
} catch (e) {
console.error('Error parsing JSON:', e);
return fallback;
}
}
/**
* Escape HTML special characters
* @param {string} html - String potentially containing HTML
* @returns {string} - Escaped string
*/
export function escapeHtml(html) {
if (!html) return '';
const entities = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#x27;',
'/': '&#x2F;'
};
return String(html).replace(/[&<>"'/]/g, match => entities[match]);
}
/**
* Get URL query parameters as an object
* @returns {Object} - Object containing query parameters
*/
export function getQueryParams() {
const params = {};
new URLSearchParams(window.location.search).forEach((value, key) => {
params[key] = value;
});
return params;
}
/**
* Add query parameters to a URL
* @param {string} url - Base URL
* @param {Object} params - Parameters to add
* @returns {string} - URL with parameters
*/
export function addQueryParams(url, params) {
const urlObj = new URL(url, window.location.origin);
Object.keys(params).forEach(key => {
if (params[key] !== null && params[key] !== undefined) {
urlObj.searchParams.append(key, params[key]);
}
});
return urlObj.toString();
}
/**
* Create a simple hash of a string
* @param {string} str - String to hash
* @returns {number} - Numeric hash
*/
export function simpleHash(str) {
let hash = 0;
if (str.length === 0) return hash;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash; // Convert to 32bit integer
}
return hash;
}
/**
* Generate a random string of specified length
* @param {number} length - Length of the string
* @returns {string} - Random string
*/
export function randomString(length = 8) {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
let result = '';
for (let i = 0; i < length; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length));
}
return result;
}
/**
* Format a date to a readable string
* @param {string|Date} date - Date to format
* @param {boolean} includeTime - Whether to include time
* @returns {string} - Formatted date string
*/
export function formatDate(date, includeTime = false) {
try {
const d = new Date(date);
const options = {
year: 'numeric',
month: 'short',
day: 'numeric',
...(includeTime ? { hour: '2-digit', minute: '2-digit' } : {})
};
return d.toLocaleDateString(undefined, options);
} catch (e) {
console.error('Error formatting date:', e);
return '';
}
}
/**
* Check if a date is today
* @param {string|Date} date - Date to check
* @returns {boolean} - True if date is today
*/
export function isToday(date) {
const d = new Date(date);
const today = new Date();
return d.getDate() === today.getDate() &&
d.getMonth() === today.getMonth() &&
d.getFullYear() === today.getFullYear();
}
/**
* Get file extension from path
* @param {string} path - File path
* @returns {string} - File extension
*/
export function getFileExtension(path) {
if (!path) return '';
return path.split('.').pop().toLowerCase();
}
/**
* Check if file is an image based on extension
* @param {string} path - File path
* @returns {boolean} - True if file is an image
*/
export function isImageFile(path) {
const ext = getFileExtension(path);
return ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg'].includes(ext);
}
/**
* Check if file is a video based on extension
* @param {string} path - File path
* @returns {boolean} - True if file is a video
*/
export function isVideoFile(path) {
const ext = getFileExtension(path);
return ['mp4', 'mkv', 'avi', 'mov', 'webm', 'wmv', 'flv', 'm4v'].includes(ext);
}
/**
* Check if file is an audio file based on extension
* @param {string} path - File path
* @returns {boolean} - True if file is audio
*/
export function isAudioFile(path) {
const ext = getFileExtension(path);
return ['mp3', 'wav', 'ogg', 'flac', 'm4a', 'aac'].includes(ext);
}
/**
* Extract base filename without extension
* @param {string} path - File path
* @returns {string} - Base filename
*/
export function getBaseName(path) {
if (!path) return '';
const fileName = path.split('/').pop();
return fileName.substring(0, fileName.lastIndexOf('.')) || fileName;
}
/**
* Copy text to clipboard
* @param {string} text - Text to copy
* @returns {Promise<boolean>} - Success status
*/
export async function copyToClipboard(text) {
try {
await navigator.clipboard.writeText(text);
return true;
} catch (err) {
console.error('Failed to copy text: ', err);
return false;
}
}
/**
* Download data as a file
* @param {string} content - Content to download
* @param {string} fileName - Name of the file
* @param {string} contentType - MIME type of the file
*/
export function downloadFile(content, fileName, contentType = 'text/plain') {
const a = document.createElement('a');
const file = new Blob([content], { type: contentType });
a.href = URL.createObjectURL(file);
a.download = fileName;
a.click();
URL.revokeObjectURL(a.href);
}
/**
* Sort array of objects by a property
* @param {Array} array - Array to sort
* @param {string} property - Property to sort by
* @param {boolean} ascending - Sort direction
* @returns {Array} - Sorted array
*/
export function sortArrayByProperty(array, property, ascending = true) {
const sortFactor = ascending ? 1 : -1;
return [...array].sort((a, b) => {
if (a[property] < b[property]) return -1 * sortFactor;
if (a[property] > b[property]) return 1 * sortFactor;
return 0;
});
}
/**
* Filter array by a search term across multiple properties
* @param {Array} array - Array to filter
* @param {string} search - Search term
* @param {Array<string>} properties - Properties to search in
* @returns {Array} - Filtered array
*/
export function filterArrayBySearch(array, search, properties) {
if (!search || !properties || properties.length === 0) return array;
const term = search.toLowerCase();
return array.filter(item => {
return properties.some(prop => {
const value = item[prop];
if (typeof value === 'string') {
return value.toLowerCase().includes(term);
}
return false;
});
});
}
/**
* Deep clone an object
* @param {Object} obj - Object to clone
* @returns {Object} - Cloned object
*/
export function deepClone(obj) {
if (!obj) return obj;
return JSON.parse(JSON.stringify(obj));
}
/**
* Get readable torrent status
* @param {number} status - Transmission status code
* @returns {string} - Human-readable status
*/
export function getTorrentStatus(status) {
const statusMap = {
0: 'Stopped',
1: 'Check Waiting',
2: 'Checking',
3: 'Download Waiting',
4: 'Downloading',
5: 'Seed Waiting',
6: 'Seeding'
};
return statusMap[status] || 'Unknown';
}
/**
* Get appropriate CSS class for a torrent status badge
* @param {number} status - Torrent status code
* @returns {string} - CSS class
*/
export function getBadgeClassForStatus(status) {
switch (status) {
case 0: return 'badge-danger'; // Stopped
case 1: case 2: case 3: return 'badge-warning'; // Checking/Waiting
case 4: return 'badge-primary'; // Downloading
case 5: case 6: return 'badge-success'; // Seeding
default: return 'badge-secondary';
}
}
/**
* Get appropriate CSS class for a torrent progress bar
* @param {number} status - Torrent status code
* @returns {string} - CSS class
*/
export function getProgressBarClassForStatus(status) {
switch (status) {
case 0: return 'bg-danger'; // Stopped
case 4: return 'bg-primary'; // Downloading
case 5: case 6: return 'bg-success'; // Seeding
default: return '';
}
}
/**
* Get cookie value by name
* @param {string} name - Cookie name
* @returns {string|null} - Cookie value or null
*/
export function getCookie(name) {
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) return parts.pop().split(';').shift();
return null;
}
/**
* Set a cookie
* @param {string} name - Cookie name
* @param {string} value - Cookie value
* @param {number} days - Days until expiry
*/
export function setCookie(name, value, days = 30) {
const date = new Date();
date.setTime(date.getTime() + (days * 24 * 60 * 60 * 1000));
const expires = `expires=${date.toUTCString()}`;
document.cookie = `${name}=${value};${expires};path=/;SameSite=Strict`;
}
/**
* Delete a cookie
* @param {string} name - Cookie name
*/
export function deleteCookie(name) {
document.cookie = `${name}=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/;SameSite=Strict`;
}
/**
* Handle common API response with error checking
* @param {Response} response - Fetch API response
* @returns {Promise} - Resolves to response data
*/
export function handleApiResponse(response) {
if (!response.ok) {
// Try to get error message from response
return response.json()
.then(data => {
throw new Error(data.message || `HTTP error ${response.status}`);
})
.catch(e => {
// If JSON parsing fails, throw generic error
if (e instanceof SyntaxError) {
throw new Error(`HTTP error ${response.status}`);
}
throw e;
});
}
return response.json();
}
/**
* Encrypt a string using AES (for client-side only, not secure)
* @param {string} text - Text to encrypt
* @param {string} key - Encryption key
* @returns {string} - Encrypted text
*/
export function encrypt(text, key) {
// This is a simple XOR "encryption" - NOT SECURE!
// Only for basic obfuscation
let result = '';
for (let i = 0; i < text.length; i++) {
result += String.fromCharCode(text.charCodeAt(i) ^ key.charCodeAt(i % key.length));
}
return btoa(result); // Base64 encode
}
/**
* Decrypt a string encrypted with the encrypt function
* @param {string} encrypted - Encrypted text
* @param {string} key - Encryption key
* @returns {string} - Decrypted text
*/
export function decrypt(encrypted, key) {
try {
const text = atob(encrypted); // Base64 decode
let result = '';
for (let i = 0; i < text.length; i++) {
result += String.fromCharCode(text.charCodeAt(i) ^ key.charCodeAt(i % key.length));
}
return result;
} catch (e) {
console.error('Decryption error:', e);
return '';
}
}
/**
* Get the title display name for a media category
* @param {string} category - Category key
* @returns {string} - Formatted category title
*/
export function getCategoryTitle(category) {
switch(category) {
case 'movies': return 'Movies';
case 'tvShows': return 'TV Shows';
case 'music': return 'Music';
case 'books': return 'Books';
case 'magazines': return 'Magazines';
case 'software': return 'Software';
default: return category.charAt(0).toUpperCase() + category.slice(1);
}
}
/**
* Wait for an element to exist in the DOM
* @param {string} selector - CSS selector
* @param {number} timeout - Timeout in milliseconds
* @returns {Promise<Element>} - Element when found
*/
export function waitForElement(selector, timeout = 5000) {
return new Promise((resolve, reject) => {
const element = document.querySelector(selector);
if (element) return resolve(element);
const observer = new MutationObserver((mutations) => {
const element = document.querySelector(selector);
if (element) {
observer.disconnect();
resolve(element);
}
});
observer.observe(document.body, {
childList: true,
subtree: true
});
setTimeout(() => {
observer.disconnect();
reject(new Error(`Element ${selector} not found within ${timeout}ms`));
}, timeout);
});
}
/**
* Create a notification message
* @param {string} message - Message to display
* @param {string} type - Type of notification (success, danger, warning, info)
* @param {number} duration - Display duration in milliseconds
*/
export function showNotification(message, type = 'info', duration = 5000) {
// Create notifications container if it doesn't exist
let container = document.getElementById('notifications-container');
if (!container) {
container = document.createElement('div');
container.id = 'notifications-container';
container.style.position = 'fixed';
container.style.top = '20px';
container.style.right = '20px';
container.style.zIndex = '1060';
document.body.appendChild(container);
}
// Create notification element
const notification = document.createElement('div');
notification.className = `alert alert-${type}`;
notification.innerHTML = message;
notification.style.opacity = '0';
notification.style.transform = 'translateY(-20px)';
notification.style.transition = 'opacity 0.3s ease, transform 0.3s ease';
container.appendChild(notification);
// Fade in
setTimeout(() => {
notification.style.opacity = '1';
notification.style.transform = 'translateY(0)';
}, 10);
// Auto-remove after the specified duration
setTimeout(() => {
notification.style.opacity = '0';
notification.style.transform = 'translateY(-20px)';
setTimeout(() => {
notification.remove();
}, 300);
}, duration);
}
/**
* Create authorization headers for API requests
* @param {string} token - Auth token
* @returns {Object} - Headers object
*/
export function createAuthHeaders(token) {
return token ? {
'Authorization': `Bearer ${token}`
} : {};
}
/**
* Validate common input types
*/
export const validator = {
/**
* Validate email
* @param {string} email - Email to validate
* @returns {boolean} - True if valid
*/
isEmail: (email) => {
const re = /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
return re.test(String(email).toLowerCase());
},
/**
* Validate URL
* @param {string} url - URL to validate
* @returns {boolean} - True if valid
*/
isUrl: (url) => {
try {
new URL(url);
return true;
} catch (e) {
return false;
}
},
/**
* Validate number
* @param {string|number} value - Value to validate
* @returns {boolean} - True if valid
*/
isNumeric: (value) => {
return !isNaN(parseFloat(value)) && isFinite(value);
},
/**
* Validate field is not empty
* @param {string} value - Value to validate
* @returns {boolean} - True if not empty
*/
isRequired: (value) => {
return value !== null && value !== undefined && value !== '';
},
/**
* Validate file path
* @param {string} path - Path to validate
* @returns {boolean} - True if valid
*/
isValidPath: (path) => {
// Simple path validation - should start with / for Unix-like systems
return /^(\/[\w.-]+)+\/?$/.test(path);
},
/**
* Validate password complexity
* @param {string} password - Password to validate
* @returns {boolean} - True if valid
*/
isStrongPassword: (password) => {
return password && password.length >= 8 &&
/[A-Z]/.test(password) &&
/[a-z]/.test(password) &&
/[0-9]/.test(password);
},
/**
* Validate a value is in range
* @param {number} value - Value to validate
* @param {number} min - Minimum value
* @param {number} max - Maximum value
* @returns {boolean} - True if in range
*/
isInRange: (value, min, max) => {
const num = parseFloat(value);
return !isNaN(num) && num >= min && num <= max;
}
};

View File

@ -1,38 +0,0 @@
#!/bin/bash
# Reset and run the Transmission RSS Manager application with network access
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
NC='\033[0m' # No Color
# Clean up existing test directory
echo -e "${YELLOW}Removing existing test directory...${NC}"
rm -rf "$HOME/transmission-rss-test"
# Create and prepare test directory
echo -e "${GREEN}Creating fresh test directory...${NC}"
TEST_DIR="$HOME/transmission-rss-test"
mkdir -p "$TEST_DIR"
# Create appsettings.json to listen on all interfaces
mkdir -p "$TEST_DIR/Properties"
cat > "$TEST_DIR/Properties/launchSettings.json" << 'EOL'
{
"profiles": {
"TransmissionRssManager": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://0.0.0.0:5000",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Production"
}
}
}
}
EOL
# Copy all other files from the original reset-and-run.sh
bash /opt/develop/transmission-rss-manager/reset-and-run.sh

File diff suppressed because it is too large Load Diff

View File

@ -1,27 +0,0 @@
#!/bin/bash
# Simple script to run the Transmission RSS Manager application
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
NC='\033[0m' # No Color
# Check if the app directory exists
APP_DIR="$HOME/transmission-rss-test"
if [ ! -d "$APP_DIR" ]; then
echo -e "${YELLOW}Application directory not found. Did you run the test installer?${NC}"
echo -e "${YELLOW}Running test installer first...${NC}"
bash /opt/develop/transmission-rss-manager/test-installer.sh
exit 0
fi
# Navigate to the app directory
cd "$APP_DIR"
# Run the application
echo -e "${GREEN}Starting Transmission RSS Manager...${NC}"
echo -e "${GREEN}The web interface will be available at: http://localhost:5000${NC}"
echo -e "${YELLOW}Press Ctrl+C to stop the application${NC}"
dotnet run

212
scripts/create-module-links.sh Executable file
View File

@ -0,0 +1,212 @@
#!/bin/bash
# Script to create symlinks for all modules in different naming styles
# This ensures compatibility with different module import styles
APP_DIR="$(dirname "$(dirname "$(readlink -f "$0")")")"
MODULE_DIR="$APP_DIR/modules"
echo "Creating module symlinks for compatibility..."
echo "Module directory: $MODULE_DIR"
# Create a function to make bidirectional symlinks
create_module_symlinks() {
if [ ! -d "$MODULE_DIR" ]; then
echo "Error: Module directory not found at $MODULE_DIR"
mkdir -p "$MODULE_DIR"
echo "Created module directory: $MODULE_DIR"
fi
# Check if any .js files exist in the module directory
js_file_count=$(find "$MODULE_DIR" -maxdepth 1 -name "*.js" -type f | wc -l)
if [ "$js_file_count" -eq 0 ]; then
echo "Warning: No JavaScript module files found in $MODULE_DIR"
echo "Skipping symlink creation as there are no modules to link"
return 0
fi
# Create symlinks for hyphenated modules
for module in "$MODULE_DIR"/*-*.js; do
if [ -f "$module" ]; then
# Convert hyphenated to camelCase
BASE_NAME=$(basename "$module")
CAMEL_NAME=$(echo "$BASE_NAME" | sed -E 's/-([a-z])/\U\1/g')
# Create camelCase symlink if needed
if [ ! -f "$MODULE_DIR/$CAMEL_NAME" ] && [ ! -L "$MODULE_DIR/$CAMEL_NAME" ]; then
if ln -sf "$BASE_NAME" "$MODULE_DIR/$CAMEL_NAME"; then
echo "Created symlink: $CAMEL_NAME -> $BASE_NAME"
else
echo "Error: Failed to create symlink $CAMEL_NAME"
fi
fi
# Create extension-less symlink for both versions
NO_EXT_BASE="${BASE_NAME%.js}"
if [ ! -f "$MODULE_DIR/$NO_EXT_BASE" ] && [ ! -L "$MODULE_DIR/$NO_EXT_BASE" ]; then
if ln -sf "$BASE_NAME" "$MODULE_DIR/$NO_EXT_BASE"; then
echo "Created symlink: $NO_EXT_BASE -> $BASE_NAME"
else
echo "Error: Failed to create symlink $NO_EXT_BASE"
fi
fi
NO_EXT_CAMEL="${CAMEL_NAME%.js}"
if [ ! -f "$MODULE_DIR/$NO_EXT_CAMEL" ] && [ ! -L "$MODULE_DIR/$NO_EXT_CAMEL" ]; then
if ln -sf "$BASE_NAME" "$MODULE_DIR/$NO_EXT_CAMEL"; then
echo "Created symlink: $NO_EXT_CAMEL -> $BASE_NAME"
else
echo "Error: Failed to create symlink $NO_EXT_CAMEL"
fi
fi
fi
done
# Create symlinks for camelCase modules (only non-symlinked files)
for module in "$MODULE_DIR"/[a-z]*[A-Z]*.js; do
if [ -f "$module" ] && [ ! -L "$module" ]; then
# Convert camelCase to hyphenated
BASE_NAME=$(basename "$module")
HYPHEN_NAME=$(echo "$BASE_NAME" | sed -E 's/([a-z])([A-Z])/\1-\L\2/g')
# Create hyphenated symlink if needed
if [ ! -f "$MODULE_DIR/$HYPHEN_NAME" ] && [ ! -L "$MODULE_DIR/$HYPHEN_NAME" ]; then
if ln -sf "$BASE_NAME" "$MODULE_DIR/$HYPHEN_NAME"; then
echo "Created symlink: $HYPHEN_NAME -> $BASE_NAME"
else
echo "Error: Failed to create symlink $HYPHEN_NAME"
fi
fi
# Create extension-less symlink for both versions
NO_EXT_BASE="${BASE_NAME%.js}"
if [ ! -f "$MODULE_DIR/$NO_EXT_BASE" ] && [ ! -L "$MODULE_DIR/$NO_EXT_BASE" ]; then
if ln -sf "$BASE_NAME" "$MODULE_DIR/$NO_EXT_BASE"; then
echo "Created symlink: $NO_EXT_BASE -> $BASE_NAME"
else
echo "Error: Failed to create symlink $NO_EXT_BASE"
fi
fi
NO_EXT_HYPHEN="${HYPHEN_NAME%.js}"
if [ ! -f "$MODULE_DIR/$NO_EXT_HYPHEN" ] && [ ! -L "$MODULE_DIR/$NO_EXT_HYPHEN" ]; then
if ln -sf "$BASE_NAME" "$MODULE_DIR/$NO_EXT_HYPHEN"; then
echo "Created symlink: $NO_EXT_HYPHEN -> $BASE_NAME"
else
echo "Error: Failed to create symlink $NO_EXT_HYPHEN"
fi
fi
fi
done
echo "Module symlinks created successfully"
}
# Setup production directory if needed
setup_production_dir() {
# Check if this is running in development environment
DEV_DIR="/opt/develop/transmission-rss-manager"
# Check systemd service file to determine the correct production directory
PROD_DIR="/opt/transmission-rss-manager"
SERVICE_FILE="/etc/systemd/system/transmission-rss-manager.service"
if [ -f "$SERVICE_FILE" ]; then
# Extract the WorkingDirectory from the service file
WORKING_DIR=$(grep "WorkingDirectory=" "$SERVICE_FILE" | cut -d'=' -f2)
if [ -n "$WORKING_DIR" ]; then
PROD_DIR="$WORKING_DIR"
echo "Found production directory from service file: $PROD_DIR"
fi
fi
if [ "$APP_DIR" == "$DEV_DIR" ] && [ -d "$DEV_DIR" ]; then
echo "Setting up production directory symlinks at $PROD_DIR..."
# Create the production directory if it doesn't exist
if [ ! -d "$PROD_DIR" ]; then
if mkdir -p "$PROD_DIR"; then
echo "Created production directory: $PROD_DIR"
else
echo "Error: Failed to create production directory $PROD_DIR"
return 1
fi
fi
# Create the modules directory in production if it doesn't exist
if [ ! -d "$PROD_DIR/modules" ]; then
if mkdir -p "$PROD_DIR/modules"; then
echo "Created production modules directory: $PROD_DIR/modules"
else
echo "Error: Failed to create production modules directory"
return 1
fi
fi
# Check for JavaScript modules in dev directory
js_file_count=$(find "$MODULE_DIR" -maxdepth 1 -name "*.js" -type f | wc -l)
if [ "$js_file_count" -eq 0 ]; then
echo "Warning: No JavaScript module files found in $MODULE_DIR"
echo "Skipping production symlink creation"
else
# Create symlinks from development modules to production modules
for module in "$MODULE_DIR"/*.js; do
if [ -f "$module" ] && [ ! -L "$module" ]; then
MODULE_NAME=$(basename "$module")
# Create symlink in production directory
if ln -sf "$module" "$PROD_DIR/modules/$MODULE_NAME"; then
echo "Created production symlink: $PROD_DIR/modules/$MODULE_NAME -> $module"
else
echo "Error: Failed to create production symlink for $MODULE_NAME"
fi
fi
done
fi
# Copy server.js to production if it doesn't exist or needs updating
if [ -f "$DEV_DIR/server.js" ]; then
if [ ! -f "$PROD_DIR/server.js" ] || [ "$DEV_DIR/server.js" -nt "$PROD_DIR/server.js" ]; then
if cp "$DEV_DIR/server.js" "$PROD_DIR/server.js"; then
echo "Copied server.js to production directory"
else
echo "Error: Failed to copy server.js to production"
fi
fi
else
echo "Warning: server.js not found in development directory"
fi
# Create data directory in production if it doesn't exist
if mkdir -p "$PROD_DIR/data"; then
echo "Ensured data directory exists in production"
else
echo "Error: Failed to create production data directory"
fi
# Make sure scripts directory exists in production
if mkdir -p "$PROD_DIR/scripts"; then
echo "Ensured scripts directory exists in production"
else
echo "Error: Failed to create production scripts directory"
fi
# Copy test-and-start.sh to production
if [ -f "$DEV_DIR/scripts/test-and-start.sh" ]; then
if cp "$DEV_DIR/scripts/test-and-start.sh" "$PROD_DIR/scripts/test-and-start.sh"; then
chmod +x "$PROD_DIR/scripts/test-and-start.sh"
echo "Copied test-and-start.sh script to production"
else
echo "Error: Failed to copy test-and-start.sh to production"
fi
else
echo "Warning: test-and-start.sh not found in development scripts directory"
fi
echo "Production directory setup complete"
fi
}
# Execute the symlink creation function
create_module_symlinks
# Setup production directory if needed
setup_production_dir

140
scripts/test-and-start.sh Executable file
View File

@ -0,0 +1,140 @@
#!/bin/bash
# Script to ensure data directory exists and start the application
# Define paths
APP_DIR="$(dirname "$(dirname "$(readlink -f "$0")")")"
DATA_DIR="$APP_DIR/data"
echo "Starting Transmission RSS Manager..."
echo "Application directory: $APP_DIR"
echo "Data directory: $DATA_DIR"
# Ensure the data directory exists
if [ ! -d "$DATA_DIR" ]; then
echo "Creating data directory: $DATA_DIR"
mkdir -p "$DATA_DIR"
if [ $? -ne 0 ]; then
echo "Failed to create data directory. Trying alternative method..."
# Try alternative method if standard mkdir fails
cd "$APP_DIR" && mkdir -p data
if [ $? -ne 0 ]; then
echo "ERROR: Both methods to create data directory failed. Please check permissions."
exit 1
fi
fi
fi
# Set permissions
chmod -R 755 "$DATA_DIR" || {
echo "Warning: Failed to set permissions on data directory"
}
# Check for RSS files
if [ ! -f "$DATA_DIR/rss-feeds.json" ]; then
echo "Creating initial empty rss-feeds.json file"
echo "[]" > "$DATA_DIR/rss-feeds.json" || {
echo "ERROR: Failed to create rss-feeds.json file"
exit 1
}
fi
if [ ! -f "$DATA_DIR/rss-items.json" ]; then
echo "Creating initial empty rss-items.json file"
echo "[]" > "$DATA_DIR/rss-items.json" || {
echo "ERROR: Failed to create rss-items.json file"
exit 1
}
fi
# Find the node executable path
NODE_PATH=$(which node 2>/dev/null)
if [ -z "$NODE_PATH" ]; then
# If node is not in PATH, try common locations
for path in /usr/bin/node /usr/local/bin/node /opt/node/bin/node /usr/lib/node; do
if [ -x "$path" ]; then
NODE_PATH="$path"
break
fi
done
# If we still can't find node, use the default path
if [ -z "$NODE_PATH" ]; then
NODE_PATH="/usr/bin/node"
echo "Warning: Node.js not found in PATH, using default path: $NODE_PATH"
fi
fi
# Create module symlinks to ensure compatibility
echo "Creating module symlinks for compatibility..."
MODULE_DIR="$APP_DIR/modules"
# Create a function to make bidirectional symlinks
create_module_symlinks() {
if [ -d "$MODULE_DIR" ]; then
# Create symlinks for hyphenated modules
for module in "$MODULE_DIR"/*-*.js; do
if [ -f "$module" ]; then
# Convert hyphenated to camelCase
BASE_NAME=$(basename "$module")
CAMEL_NAME=$(echo "$BASE_NAME" | sed -E 's/-([a-z])/\U\1/g')
# Create camelCase symlink if needed
if [ ! -f "$MODULE_DIR/$CAMEL_NAME" ]; then
ln -sf "$BASE_NAME" "$MODULE_DIR/$CAMEL_NAME"
echo "Created symlink: $CAMEL_NAME -> $BASE_NAME"
fi
# Create extension-less symlink for both versions
NO_EXT_BASE="${BASE_NAME%.js}"
if [ ! -f "$MODULE_DIR/$NO_EXT_BASE" ]; then
ln -sf "$BASE_NAME" "$MODULE_DIR/$NO_EXT_BASE"
echo "Created symlink: $NO_EXT_BASE -> $BASE_NAME"
fi
NO_EXT_CAMEL="${CAMEL_NAME%.js}"
if [ ! -f "$MODULE_DIR/$NO_EXT_CAMEL" ]; then
ln -sf "$BASE_NAME" "$MODULE_DIR/$NO_EXT_CAMEL"
echo "Created symlink: $NO_EXT_CAMEL -> $BASE_NAME"
fi
fi
done
# Create symlinks for camelCase modules
for module in "$MODULE_DIR"/[a-z]*[A-Z]*.js; do
if [ -f "$module" ]; then
# Convert camelCase to hyphenated
BASE_NAME=$(basename "$module")
HYPHEN_NAME=$(echo "$BASE_NAME" | sed -E 's/([a-z])([A-Z])/\1-\L\2/g')
# Create hyphenated symlink if needed
if [ ! -f "$MODULE_DIR/$HYPHEN_NAME" ]; then
ln -sf "$BASE_NAME" "$MODULE_DIR/$HYPHEN_NAME"
echo "Created symlink: $HYPHEN_NAME -> $BASE_NAME"
fi
# Create extension-less symlink for both versions
NO_EXT_BASE="${BASE_NAME%.js}"
if [ ! -f "$MODULE_DIR/$NO_EXT_BASE" ]; then
ln -sf "$BASE_NAME" "$MODULE_DIR/$NO_EXT_BASE"
echo "Created symlink: $NO_EXT_BASE -> $BASE_NAME"
fi
NO_EXT_HYPHEN="${HYPHEN_NAME%.js}"
if [ ! -f "$MODULE_DIR/$NO_EXT_HYPHEN" ]; then
ln -sf "$BASE_NAME" "$MODULE_DIR/$NO_EXT_HYPHEN"
echo "Created symlink: $NO_EXT_HYPHEN -> $BASE_NAME"
fi
fi
done
else
echo "Warning: Module directory not found at $MODULE_DIR"
fi
}
# Execute the symlink creation function
create_module_symlinks
# Start the application
cd "$APP_DIR" || { echo "Failed to change to application directory"; exit 1; }
echo "Starting node.js application with: $NODE_PATH $APP_DIR/server.js"
exec "$NODE_PATH" "$APP_DIR/server.js"

89
scripts/update.sh Executable file
View File

@ -0,0 +1,89 @@
#!/bin/bash
# Transmission RSS Manager - Update Script
# This script pulls the latest version from git and runs necessary updates
# Color and formatting
GREEN='\033[0;32m'
RED='\033[0;31m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
BOLD='\033[1m'
# Installation directory (should be current directory)
INSTALL_DIR=$(pwd)
# Check if we're in the right directory
if [ ! -f "$INSTALL_DIR/package.json" ] || [ ! -d "$INSTALL_DIR/modules" ]; then
echo -e "${RED}Error: This script must be run from the installation directory.${NC}"
exit 1
fi
# Get the current version
CURRENT_VERSION=$(grep -oP '"version": "\K[^"]+' package.json)
echo -e "${YELLOW}Current version: ${BOLD}$CURRENT_VERSION${NC}"
# Check for git repository
if [ ! -d ".git" ]; then
echo -e "${RED}Error: This installation was not set up using git.${NC}"
echo -e "Please use the bootstrap installer to perform a fresh installation."
exit 1
fi
# Stash any local changes
echo -e "${YELLOW}Backing up any local configuration changes...${NC}"
git stash -q
# Pull the latest changes
echo -e "${YELLOW}Pulling latest updates from git...${NC}"
git pull
if [ $? -ne 0 ]; then
echo -e "${RED}Failed to pull updates. Restoring original state...${NC}"
git stash pop -q
exit 1
fi
# Get the new version
NEW_VERSION=$(grep -oP '"version": "\K[^"]+' package.json)
echo -e "${GREEN}New version: ${BOLD}$NEW_VERSION${NC}"
# Check if update is needed
if [ "$CURRENT_VERSION" == "$NEW_VERSION" ]; then
echo -e "${GREEN}You already have the latest version.${NC}"
exit 0
fi
# Install any new npm dependencies
echo -e "${YELLOW}Installing dependencies...${NC}"
npm install
if [ $? -ne 0 ]; then
echo -e "${RED}Failed to install npm dependencies. Update aborted.${NC}"
echo -e "Please check the error messages above and try again."
exit 1
fi
# Apply any local configuration changes
if git stash list | grep -q "stash@{0}"; then
echo -e "${YELLOW}Restoring local configuration changes...${NC}"
git stash pop -q
# Handle conflicts if any
if [ $? -ne 0 ]; then
echo -e "${RED}There were conflicts when restoring your configuration.${NC}"
echo -e "Please check the files and resolve conflicts manually."
echo -e "Your original configuration is saved in .git/refs/stash"
fi
fi
# Restart the service
echo -e "${YELLOW}Restarting service...${NC}"
if command -v systemctl &> /dev/null; then
sudo systemctl restart transmission-rss-manager
else
echo -e "${RED}Could not restart service automatically.${NC}"
echo -e "Please restart the service manually."
fi
# Update complete
echo -e "${GREEN}${BOLD}Update complete!${NC}"
echo -e "Updated from version $CURRENT_VERSION to $NEW_VERSION"
echo -e "Changes will take effect immediately."

167
server-endpoints.js Normal file
View File

@ -0,0 +1,167 @@
// Version and update endpoints for server.js
// Add these imports at the top of server.js
const { exec } = require('child_process');
const { promisify } = require('util');
const execAsync = promisify(exec);
const fs = require('fs');
const path = require('path');
const semver = require('semver'); // Add this - for semantic version comparison
// Add these endpoints
// Get system status including version and uptime
app.get('/api/system/status', async (req, res) => {
try {
// Get package.json for version info
const packageJson = JSON.parse(fs.readFileSync(path.join(__dirname, 'package.json'), 'utf8'));
const version = packageJson.version;
// Get system uptime
const uptimeSeconds = Math.floor(process.uptime());
const hours = Math.floor(uptimeSeconds / 3600);
const minutes = Math.floor((uptimeSeconds % 3600) / 60);
const seconds = uptimeSeconds % 60;
const uptime = `${hours}h ${minutes}m ${seconds}s`;
// Check transmission connection
let transmissionStatus = 'Connected';
try {
await transmissionClient.sessionGet();
} catch (err) {
transmissionStatus = 'Disconnected';
}
res.json({
status: 'success',
data: {
version,
uptime,
transmissionStatus
}
});
} catch (error) {
console.error('Error getting system status:', error);
res.status(500).json({
status: 'error',
message: 'Failed to get system status'
});
}
});
// Check for updates
app.get('/api/system/check-updates', async (req, res) => {
try {
// Check if git is available and if this is a git repository
const isGitRepo = fs.existsSync(path.join(__dirname, '.git'));
if (!isGitRepo) {
return res.json({
status: 'error',
message: 'This installation is not set up for updates. Please use the bootstrap installer.'
});
}
// Get current version from the global APP_VERSION constant
const currentVersion = APP_VERSION;
// Check for test mode flag which forces update availability for testing
const testMode = req.query.test === 'true';
if (testMode) {
// In test mode, always return that an update is available
return res.json({
status: 'success',
data: {
updateAvailable: true,
currentVersion,
remoteVersion: '2.1.0-test',
commitsBehind: 1,
testMode: true
}
});
}
// Normal mode - fetch latest updates without applying them
await execAsync('git fetch');
// Check if we're behind the remote repository
const { stdout } = await execAsync('git rev-list HEAD..origin/main --count');
const behindCount = parseInt(stdout.trim());
if (behindCount > 0) {
// Get the new version from the remote package.json
const { stdout: remotePackageJson } = await execAsync('git show origin/main:package.json');
const remotePackage = JSON.parse(remotePackageJson);
const remoteVersion = remotePackage.version;
// Compare versions semantically - only consider it an update if remote version is higher
const isNewerVersion = semver.gt(remoteVersion, currentVersion);
return res.json({
status: 'success',
data: {
updateAvailable: isNewerVersion,
currentVersion,
remoteVersion,
commitsBehind: behindCount,
newerVersion: isNewerVersion
}
});
} else {
return res.json({
status: 'success',
data: {
updateAvailable: false,
currentVersion
}
});
}
} catch (error) {
console.error('Error checking for updates:', error);
res.status(500).json({
status: 'error',
message: 'Failed to check for updates'
});
}
});
// Apply updates
app.post('/api/system/update', async (req, res) => {
try {
// Check if git is available and if this is a git repository
const isGitRepo = fs.existsSync(path.join(__dirname, '.git'));
if (!isGitRepo) {
return res.status(400).json({
status: 'error',
message: 'This installation is not set up for updates. Please use the bootstrap installer.'
});
}
// Run the update script
const updateScriptPath = path.join(__dirname, 'scripts', 'update.sh');
// Make sure the update script is executable
await execAsync(`chmod +x ${updateScriptPath}`);
// Execute the update script
const { stdout, stderr } = await execAsync(updateScriptPath);
// If we get here, the update was successful
// The service will be restarted by the update script
res.json({
status: 'success',
message: 'Update applied successfully. The service will restart.',
data: {
output: stdout,
errors: stderr
}
});
} catch (error) {
console.error('Error applying update:', error);
res.status(500).json({
status: 'error',
message: 'Failed to apply update',
error: error.message
});
}
});

1529
server.js Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,63 +0,0 @@
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using TransmissionRssManager.Core;
namespace TransmissionRssManager.Api.Controllers
{
[ApiController]
[Route("api/[controller]")]
public class ConfigController : ControllerBase
{
private readonly ILogger<ConfigController> _logger;
private readonly IConfigService _configService;
public ConfigController(
ILogger<ConfigController> logger,
IConfigService configService)
{
_logger = logger;
_configService = configService;
}
[HttpGet]
public IActionResult GetConfig()
{
var config = _configService.GetConfiguration();
// Create a sanitized config without sensitive information
var sanitizedConfig = new
{
transmission = new
{
host = config.Transmission.Host,
port = config.Transmission.Port,
useHttps = config.Transmission.UseHttps,
hasCredentials = !string.IsNullOrEmpty(config.Transmission.Username)
},
autoDownloadEnabled = config.AutoDownloadEnabled,
checkIntervalMinutes = config.CheckIntervalMinutes,
downloadDirectory = config.DownloadDirectory,
mediaLibraryPath = config.MediaLibraryPath,
postProcessing = config.PostProcessing
};
return Ok(sanitizedConfig);
}
[HttpPut]
public async Task<IActionResult> UpdateConfig([FromBody] AppConfig config)
{
var currentConfig = _configService.GetConfiguration();
// If password is empty, keep the existing one
if (string.IsNullOrEmpty(config.Transmission.Password) && !string.IsNullOrEmpty(currentConfig.Transmission.Password))
{
config.Transmission.Password = currentConfig.Transmission.Password;
}
await _configService.SaveConfigurationAsync(config);
return Ok(new { success = true });
}
}
}

View File

@ -1,84 +0,0 @@
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using TransmissionRssManager.Core;
namespace TransmissionRssManager.Api.Controllers
{
[ApiController]
[Route("api/[controller]")]
public class FeedsController : ControllerBase
{
private readonly ILogger<FeedsController> _logger;
private readonly IRssFeedManager _rssFeedManager;
public FeedsController(
ILogger<FeedsController> logger,
IRssFeedManager rssFeedManager)
{
_logger = logger;
_rssFeedManager = rssFeedManager;
}
[HttpGet]
public async Task<IActionResult> GetFeeds()
{
var feeds = await _rssFeedManager.GetFeedsAsync();
return Ok(feeds);
}
[HttpGet("items")]
public async Task<IActionResult> GetAllItems()
{
var items = await _rssFeedManager.GetAllItemsAsync();
return Ok(items);
}
[HttpGet("matched")]
public async Task<IActionResult> GetMatchedItems()
{
var items = await _rssFeedManager.GetMatchedItemsAsync();
return Ok(items);
}
[HttpPost]
public async Task<IActionResult> AddFeed([FromBody] RssFeed feed)
{
await _rssFeedManager.AddFeedAsync(feed);
return Ok(feed);
}
[HttpPut("{id}")]
public async Task<IActionResult> UpdateFeed(string id, [FromBody] RssFeed feed)
{
if (id != feed.Id)
{
return BadRequest("Feed ID mismatch");
}
await _rssFeedManager.UpdateFeedAsync(feed);
return Ok(feed);
}
[HttpDelete("{id}")]
public async Task<IActionResult> DeleteFeed(string id)
{
await _rssFeedManager.RemoveFeedAsync(id);
return Ok();
}
[HttpPost("refresh")]
public async Task<IActionResult> RefreshFeeds()
{
await _rssFeedManager.RefreshFeedsAsync(HttpContext.RequestAborted);
return Ok(new { success = true });
}
[HttpPost("download/{id}")]
public async Task<IActionResult> DownloadItem(string id)
{
await _rssFeedManager.MarkItemAsDownloadedAsync(id);
return Ok(new { success = true });
}
}
}

View File

@ -1,89 +0,0 @@
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using TransmissionRssManager.Core;
using TransmissionRssManager.Services;
namespace TransmissionRssManager.Api.Controllers
{
[ApiController]
[Route("api/[controller]")]
public class TorrentsController : ControllerBase
{
private readonly ILogger<TorrentsController> _logger;
private readonly ITransmissionClient _transmissionClient;
private readonly IConfigService _configService;
private readonly IPostProcessor _postProcessor;
public TorrentsController(
ILogger<TorrentsController> logger,
ITransmissionClient transmissionClient,
IConfigService configService,
IPostProcessor postProcessor)
{
_logger = logger;
_transmissionClient = transmissionClient;
_configService = configService;
_postProcessor = postProcessor;
}
[HttpGet]
public async Task<IActionResult> GetTorrents()
{
var torrents = await _transmissionClient.GetTorrentsAsync();
return Ok(torrents);
}
[HttpPost]
public async Task<IActionResult> AddTorrent([FromBody] AddTorrentRequest request)
{
var config = _configService.GetConfiguration();
string downloadDir = request.DownloadDir ?? config.DownloadDirectory;
var torrentId = await _transmissionClient.AddTorrentAsync(request.Url, downloadDir);
return Ok(new { id = torrentId });
}
[HttpDelete("{id}")]
public async Task<IActionResult> RemoveTorrent(int id, [FromQuery] bool deleteLocalData = false)
{
await _transmissionClient.RemoveTorrentAsync(id, deleteLocalData);
return Ok();
}
[HttpPost("{id}/start")]
public async Task<IActionResult> StartTorrent(int id)
{
await _transmissionClient.StartTorrentAsync(id);
return Ok();
}
[HttpPost("{id}/stop")]
public async Task<IActionResult> StopTorrent(int id)
{
await _transmissionClient.StopTorrentAsync(id);
return Ok();
}
[HttpPost("{id}/process")]
public async Task<IActionResult> ProcessTorrent(int id)
{
var torrents = await _transmissionClient.GetTorrentsAsync();
var torrent = torrents.Find(t => t.Id == id);
if (torrent == null)
{
return NotFound();
}
await _postProcessor.ProcessTorrentAsync(torrent);
return Ok();
}
}
public class AddTorrentRequest
{
public string Url { get; set; }
public string DownloadDir { get; set; }
}
}

View File

@ -1,38 +0,0 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using TransmissionRssManager.Core;
using TransmissionRssManager.Services;
var builder = WebApplication.CreateBuilder(args);
// Add services to the container
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
// Add custom services
builder.Services.AddSingleton<IConfigService, ConfigService>();
builder.Services.AddSingleton<ITransmissionClient, TransmissionClient>();
builder.Services.AddSingleton<IRssFeedManager, RssFeedManager>();
builder.Services.AddSingleton<IPostProcessor, PostProcessor>();
// Add background services
builder.Services.AddHostedService<RssFeedBackgroundService>();
builder.Services.AddHostedService<PostProcessingBackgroundService>();
var app = builder.Build();
// Configure middleware
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseStaticFiles();
app.UseRouting();
app.UseAuthorization();
app.MapControllers();
app.Run();

View File

@ -1,104 +0,0 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace TransmissionRssManager.Core
{
public class RssFeedItem
{
public string Id { get; set; }
public string Title { get; set; }
public string Link { get; set; }
public string Description { get; set; }
public DateTime PublishDate { get; set; }
public string TorrentUrl { get; set; }
public bool IsDownloaded { get; set; }
public bool IsMatched { get; set; }
public string MatchedRule { get; set; }
}
public class TorrentInfo
{
public int Id { get; set; }
public string Name { get; set; }
public string Status { get; set; }
public double PercentDone { get; set; }
public long TotalSize { get; set; }
public string DownloadDir { get; set; }
public bool IsFinished => PercentDone >= 1.0;
}
public class RssFeed
{
public string Id { get; set; }
public string Url { get; set; }
public string Name { get; set; }
public List<string> Rules { get; set; } = new List<string>();
public bool AutoDownload { get; set; }
public DateTime LastChecked { get; set; }
}
public class AppConfig
{
public TransmissionConfig Transmission { get; set; } = new TransmissionConfig();
public List<RssFeed> Feeds { get; set; } = new List<RssFeed>();
public bool AutoDownloadEnabled { get; set; }
public int CheckIntervalMinutes { get; set; } = 30;
public string DownloadDirectory { get; set; }
public string MediaLibraryPath { get; set; }
public PostProcessingConfig PostProcessing { get; set; } = new PostProcessingConfig();
}
public class TransmissionConfig
{
public string Host { get; set; } = "localhost";
public int Port { get; set; } = 9091;
public string Username { get; set; }
public string Password { get; set; }
public bool UseHttps { get; set; } = false;
public string Url => $"{(UseHttps ? "https" : "http")}://{Host}:{Port}/transmission/rpc";
}
public class PostProcessingConfig
{
public bool Enabled { get; set; } = false;
public bool ExtractArchives { get; set; } = true;
public bool OrganizeMedia { get; set; } = true;
public int MinimumSeedRatio { get; set; } = 1;
public List<string> MediaExtensions { get; set; } = new List<string> { ".mp4", ".mkv", ".avi" };
}
public interface IConfigService
{
AppConfig GetConfiguration();
Task SaveConfigurationAsync(AppConfig config);
}
public interface ITransmissionClient
{
Task<List<TorrentInfo>> GetTorrentsAsync();
Task<int> AddTorrentAsync(string torrentUrl, string downloadDir);
Task RemoveTorrentAsync(int id, bool deleteLocalData);
Task StartTorrentAsync(int id);
Task StopTorrentAsync(int id);
}
public interface IRssFeedManager
{
Task<List<RssFeedItem>> GetAllItemsAsync();
Task<List<RssFeedItem>> GetMatchedItemsAsync();
Task<List<RssFeed>> GetFeedsAsync();
Task AddFeedAsync(RssFeed feed);
Task RemoveFeedAsync(string feedId);
Task UpdateFeedAsync(RssFeed feed);
Task RefreshFeedsAsync(CancellationToken cancellationToken);
Task MarkItemAsDownloadedAsync(string itemId);
}
public interface IPostProcessor
{
Task ProcessCompletedDownloadsAsync(CancellationToken cancellationToken);
Task ProcessTorrentAsync(TorrentInfo torrent);
}
}

View File

@ -1,212 +0,0 @@
#!/bin/bash
# TransmissionRssManager Installer Script for Linux
# This script installs the TransmissionRssManager application and its dependencies
# Colors for output
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
NC='\033[0m' # No Color
# Error handling
set -e
trap 'echo -e "${RED}An error occurred. Installation failed.${NC}"; exit 1' ERR
# Check if script is run as root
if [ "$EUID" -eq 0 ]; then
echo -e "${YELLOW}Warning: It's recommended to run this script as a regular user with sudo privileges, not as root.${NC}"
read -p "Continue anyway? (y/n) " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
exit 1
fi
fi
# Detect Linux distribution
if [ -f /etc/os-release ]; then
. /etc/os-release
DISTRO=$ID
else
echo -e "${RED}Cannot detect Linux distribution. Exiting.${NC}"
exit 1
fi
echo -e "${GREEN}Installing TransmissionRssManager on $PRETTY_NAME...${NC}"
# Install .NET SDK and runtime
install_dotnet() {
echo -e "${GREEN}Installing .NET SDK...${NC}"
case $DISTRO in
ubuntu|debian|linuxmint)
# Add Microsoft package repository
wget -O packages-microsoft-prod.deb https://packages.microsoft.com/config/$DISTRO/$VERSION_ID/packages-microsoft-prod.deb
sudo dpkg -i packages-microsoft-prod.deb
rm packages-microsoft-prod.deb
# Install .NET SDK
sudo apt-get update
sudo apt-get install -y apt-transport-https
sudo apt-get update
sudo apt-get install -y dotnet-sdk-7.0
;;
fedora|rhel|centos)
# Add Microsoft package repository
sudo rpm -Uvh https://packages.microsoft.com/config/centos/7/packages-microsoft-prod.rpm
# Install .NET SDK
sudo yum install -y dotnet-sdk-7.0
;;
opensuse*|sles)
# Install .NET SDK from zypper
sudo zypper install -y dotnet-sdk-7.0
;;
arch|manjaro)
# Install .NET SDK from pacman
sudo pacman -Sy dotnet-sdk aspnet-runtime --noconfirm
;;
*)
echo -e "${YELLOW}Unsupported distribution for automatic .NET installation.${NC}"
echo -e "${YELLOW}Please install .NET SDK 7.0 manually from https://dotnet.microsoft.com/download${NC}"
read -p "Press Enter to continue once .NET SDK is installed..."
;;
esac
# Verify .NET installation
dotnet --version
if [ $? -ne 0 ]; then
echo -e "${RED}.NET SDK installation failed. Please install .NET SDK 7.0 manually.${NC}"
exit 1
fi
}
# Install dependencies
install_dependencies() {
echo -e "${GREEN}Installing dependencies...${NC}"
case $DISTRO in
ubuntu|debian|linuxmint)
sudo apt-get update
sudo apt-get install -y unzip p7zip-full unrar-free libssl-dev zlib1g-dev libicu-dev build-essential
;;
fedora|rhel|centos)
sudo yum install -y unzip p7zip unrar openssl-devel zlib-devel libicu-devel gcc-c++ make
;;
opensuse*|sles)
sudo zypper install -y unzip p7zip unrar libopenssl-devel zlib-devel libicu-devel gcc-c++ make
;;
arch|manjaro)
sudo pacman -Sy unzip p7zip unrar openssl zlib icu gcc make --noconfirm
;;
*)
echo -e "${YELLOW}Unsupported distribution for automatic dependency installation.${NC}"
echo -e "${YELLOW}Please make sure the following are installed: unzip, p7zip, unrar, ssl, zlib, icu, gcc/g++ and make.${NC}"
;;
esac
# Install Entity Framework Core CLI tools if needed (version 7.x)
if ! command -v dotnet-ef &> /dev/null; then
echo -e "${GREEN}Installing Entity Framework Core tools compatible with .NET 7...${NC}"
dotnet tool install --global dotnet-ef --version 7.0.15
fi
}
# Check if .NET is already installed
if command -v dotnet >/dev/null 2>&1; then
dotnet_version=$(dotnet --version)
echo -e "${GREEN}.NET SDK version $dotnet_version is already installed.${NC}"
else
install_dotnet
fi
# Install dependencies
install_dependencies
# Create installation directory
INSTALL_DIR="$HOME/.local/share/transmission-rss-manager"
mkdir -p "$INSTALL_DIR"
# Clone or download the application
echo -e "${GREEN}Downloading TransmissionRssManager...${NC}"
if [ -d "/opt/develop/transmission-rss-manager/TransmissionRssManager" ]; then
# We're running from the development directory
cp -r /opt/develop/transmission-rss-manager/TransmissionRssManager/* "$INSTALL_DIR/"
else
# Download and extract release
wget -O transmission-rss-manager.zip https://github.com/yourusername/transmission-rss-manager/releases/latest/download/transmission-rss-manager.zip
unzip transmission-rss-manager.zip -d "$INSTALL_DIR"
rm transmission-rss-manager.zip
fi
# Install required NuGet packages (with versions compatible with .NET 7)
echo -e "${GREEN}Installing required NuGet packages...${NC}"
cd "$INSTALL_DIR"
dotnet add package Microsoft.AspNetCore.OpenApi --version 7.0.13
dotnet add package Swashbuckle.AspNetCore --version 6.5.0
dotnet add package System.ServiceModel.Syndication --version 7.0.0
# Build the application
echo -e "${GREEN}Building TransmissionRssManager...${NC}"
dotnet build -c Release
# Create configuration directory
CONFIG_DIR="$HOME/.config/transmission-rss-manager"
mkdir -p "$CONFIG_DIR"
# Create desktop entry
DESKTOP_FILE="$HOME/.local/share/applications/transmission-rss-manager.desktop"
echo "[Desktop Entry]
Name=Transmission RSS Manager
Comment=RSS Feed Manager for Transmission BitTorrent Client
Exec=dotnet $INSTALL_DIR/bin/Release/net7.0/TransmissionRssManager.dll
Icon=transmission
Terminal=false
Type=Application
Categories=Network;P2P;" > "$DESKTOP_FILE"
# Create systemd service for user
SERVICE_DIR="$HOME/.config/systemd/user"
mkdir -p "$SERVICE_DIR"
echo "[Unit]
Description=Transmission RSS Manager
After=network.target
[Service]
ExecStart=dotnet $INSTALL_DIR/bin/Release/net7.0/TransmissionRssManager.dll
Restart=on-failure
RestartSec=10
SyslogIdentifier=transmission-rss-manager
[Install]
WantedBy=default.target" > "$SERVICE_DIR/transmission-rss-manager.service"
# Reload systemd
systemctl --user daemon-reload
# Create launcher script
LAUNCHER="$HOME/.local/bin/transmission-rss-manager"
mkdir -p "$HOME/.local/bin"
echo "#!/bin/bash
dotnet $INSTALL_DIR/bin/Release/net7.0/TransmissionRssManager.dll" > "$LAUNCHER"
chmod +x "$LAUNCHER"
echo -e "${GREEN}Installation completed!${NC}"
echo -e "${GREEN}You can run TransmissionRssManager in these ways:${NC}"
echo -e " * Command: ${YELLOW}transmission-rss-manager${NC}"
echo -e " * Service: ${YELLOW}systemctl --user start transmission-rss-manager${NC}"
echo -e " * Enable service on startup: ${YELLOW}systemctl --user enable transmission-rss-manager${NC}"
echo -e " * Web interface will be available at: ${YELLOW}http://localhost:5000${NC}"
# Start the application
read -p "Do you want to start the application now? (y/n) " -n 1 -r
echo
if [[ $REPLY =~ ^[Yy]$ ]]; then
systemctl --user start transmission-rss-manager
echo -e "${GREEN}TransmissionRssManager service started.${NC}"
echo -e "${GREEN}Open http://localhost:5000 in your browser.${NC}"
else
echo -e "${YELLOW}You can start the application later using: systemctl --user start transmission-rss-manager${NC}"
fi

View File

@ -1,112 +0,0 @@
using System;
using System.IO;
using System.Text.Json;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using TransmissionRssManager.Core;
namespace TransmissionRssManager.Services
{
public class ConfigService : IConfigService
{
private readonly ILogger<ConfigService> _logger;
private readonly string _configPath;
private AppConfig _cachedConfig;
public ConfigService(ILogger<ConfigService> logger)
{
_logger = logger;
// Get config directory
string homeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
string configDir = Path.Combine(homeDir, ".config", "transmission-rss-manager");
// Ensure directory exists
if (!Directory.Exists(configDir))
{
Directory.CreateDirectory(configDir);
}
_configPath = Path.Combine(configDir, "config.json");
_cachedConfig = LoadConfiguration();
}
public AppConfig GetConfiguration()
{
return _cachedConfig;
}
public async Task SaveConfigurationAsync(AppConfig config)
{
_cachedConfig = config;
var options = new JsonSerializerOptions
{
WriteIndented = true
};
string json = JsonSerializer.Serialize(config, options);
await File.WriteAllTextAsync(_configPath, json);
_logger.LogInformation("Configuration saved successfully");
}
private AppConfig LoadConfiguration()
{
if (!File.Exists(_configPath))
{
_logger.LogInformation("No configuration file found, creating default");
var defaultConfig = CreateDefaultConfig();
SaveConfigurationAsync(defaultConfig).Wait();
return defaultConfig;
}
try
{
string json = File.ReadAllText(_configPath);
var config = JsonSerializer.Deserialize<AppConfig>(json);
if (config == null)
{
_logger.LogWarning("Failed to deserialize config, creating default");
return CreateDefaultConfig();
}
return config;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error loading configuration");
return CreateDefaultConfig();
}
}
private AppConfig CreateDefaultConfig()
{
string homeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
return new AppConfig
{
Transmission = new TransmissionConfig
{
Host = "localhost",
Port = 9091,
Username = "",
Password = ""
},
AutoDownloadEnabled = false,
CheckIntervalMinutes = 30,
DownloadDirectory = Path.Combine(homeDir, "Downloads"),
MediaLibraryPath = Path.Combine(homeDir, "Media"),
PostProcessing = new PostProcessingConfig
{
Enabled = false,
ExtractArchives = true,
OrganizeMedia = true,
MinimumSeedRatio = 1,
MediaExtensions = new System.Collections.Generic.List<string> { ".mp4", ".mkv", ".avi" }
}
};
}
}
}

View File

@ -1,272 +0,0 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Hosting;
using TransmissionRssManager.Core;
namespace TransmissionRssManager.Services
{
public class PostProcessor : IPostProcessor
{
private readonly ILogger<PostProcessor> _logger;
private readonly IConfigService _configService;
private readonly ITransmissionClient _transmissionClient;
public PostProcessor(
ILogger<PostProcessor> logger,
IConfigService configService,
ITransmissionClient transmissionClient)
{
_logger = logger;
_configService = configService;
_transmissionClient = transmissionClient;
}
public async Task ProcessCompletedDownloadsAsync(CancellationToken cancellationToken)
{
var config = _configService.GetConfiguration();
if (!config.PostProcessing.Enabled)
{
return;
}
_logger.LogInformation("Processing completed downloads");
var torrents = await _transmissionClient.GetTorrentsAsync();
var completedTorrents = torrents.Where(t => t.IsFinished).ToList();
foreach (var torrent in completedTorrents)
{
if (cancellationToken.IsCancellationRequested)
{
_logger.LogInformation("Post-processing cancelled");
return;
}
try
{
await ProcessTorrentAsync(torrent);
}
catch (Exception ex)
{
_logger.LogError(ex, $"Error processing torrent: {torrent.Name}");
}
}
}
public async Task ProcessTorrentAsync(TorrentInfo torrent)
{
_logger.LogInformation($"Processing completed torrent: {torrent.Name}");
var config = _configService.GetConfiguration();
var downloadDir = torrent.DownloadDir;
var torrentPath = Path.Combine(downloadDir, torrent.Name);
// Check if the file/directory exists
if (!Directory.Exists(torrentPath) && !File.Exists(torrentPath))
{
_logger.LogWarning($"Downloaded path not found: {torrentPath}");
return;
}
// Handle archives if enabled
if (config.PostProcessing.ExtractArchives && IsArchive(torrentPath))
{
await ExtractArchiveAsync(torrentPath, downloadDir);
}
// Organize media files if enabled
if (config.PostProcessing.OrganizeMedia)
{
await OrganizeMediaAsync(torrentPath, config.MediaLibraryPath);
}
}
private bool IsArchive(string path)
{
if (!File.Exists(path))
{
return false;
}
var extension = Path.GetExtension(path).ToLowerInvariant();
return extension == ".rar" || extension == ".zip" || extension == ".7z";
}
private async Task ExtractArchiveAsync(string archivePath, string outputDir)
{
_logger.LogInformation($"Extracting archive: {archivePath}");
try
{
var extension = Path.GetExtension(archivePath).ToLowerInvariant();
var extractDir = Path.Combine(outputDir, Path.GetFileNameWithoutExtension(archivePath));
// Create extraction directory if it doesn't exist
if (!Directory.Exists(extractDir))
{
Directory.CreateDirectory(extractDir);
}
var processStartInfo = new ProcessStartInfo
{
FileName = extension switch
{
".rar" => "unrar",
".zip" => "unzip",
".7z" => "7z",
_ => throw new Exception($"Unsupported archive format: {extension}")
},
Arguments = extension switch
{
".rar" => $"x -o+ \"{archivePath}\" \"{extractDir}\"",
".zip" => $"-o \"{archivePath}\" -d \"{extractDir}\"",
".7z" => $"x \"{archivePath}\" -o\"{extractDir}\"",
_ => throw new Exception($"Unsupported archive format: {extension}")
},
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true
};
using var process = new Process
{
StartInfo = processStartInfo
};
process.Start();
await process.WaitForExitAsync();
if (process.ExitCode != 0)
{
var error = await process.StandardError.ReadToEndAsync();
_logger.LogError($"Error extracting archive: {error}");
return;
}
_logger.LogInformation($"Archive extracted to: {extractDir}");
}
catch (Exception ex)
{
_logger.LogError(ex, $"Error extracting archive: {archivePath}");
}
}
private async Task OrganizeMediaAsync(string path, string mediaLibraryPath)
{
_logger.LogInformation($"Organizing media: {path}");
var config = _configService.GetConfiguration();
var mediaExtensions = config.PostProcessing.MediaExtensions;
// Ensure media library path exists
if (!Directory.Exists(mediaLibraryPath))
{
Directory.CreateDirectory(mediaLibraryPath);
}
try
{
if (File.Exists(path))
{
// Single file
var extension = Path.GetExtension(path).ToLowerInvariant();
if (mediaExtensions.Contains(extension))
{
await CopyFileToMediaLibraryAsync(path, mediaLibraryPath);
}
}
else if (Directory.Exists(path))
{
// Directory - find all media files recursively
var mediaFiles = Directory.GetFiles(path, "*.*", SearchOption.AllDirectories)
.Where(f => mediaExtensions.Contains(Path.GetExtension(f).ToLowerInvariant()))
.ToList();
foreach (var mediaFile in mediaFiles)
{
await CopyFileToMediaLibraryAsync(mediaFile, mediaLibraryPath);
}
}
}
catch (Exception ex)
{
_logger.LogError(ex, $"Error organizing media: {path}");
}
}
private async Task CopyFileToMediaLibraryAsync(string filePath, string mediaLibraryPath)
{
var fileName = Path.GetFileName(filePath);
var destinationPath = Path.Combine(mediaLibraryPath, fileName);
// If destination file already exists, add a unique identifier
if (File.Exists(destinationPath))
{
var fileNameWithoutExt = Path.GetFileNameWithoutExtension(fileName);
var extension = Path.GetExtension(fileName);
var uniqueId = Guid.NewGuid().ToString().Substring(0, 8);
destinationPath = Path.Combine(mediaLibraryPath, $"{fileNameWithoutExt}_{uniqueId}{extension}");
}
_logger.LogInformation($"Copying media file to library: {destinationPath}");
try
{
using var sourceStream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read, 4096, true);
using var destinationStream = new FileStream(destinationPath, FileMode.Create, FileAccess.Write, FileShare.None, 4096, true);
await sourceStream.CopyToAsync(destinationStream);
}
catch (Exception ex)
{
_logger.LogError(ex, $"Error copying file to media library: {filePath}");
}
}
}
public class PostProcessingBackgroundService : BackgroundService
{
private readonly ILogger<PostProcessingBackgroundService> _logger;
private readonly IPostProcessor _postProcessor;
private readonly IConfigService _configService;
public PostProcessingBackgroundService(
ILogger<PostProcessingBackgroundService> logger,
IPostProcessor postProcessor,
IConfigService configService)
{
_logger = logger;
_postProcessor = postProcessor;
_configService = configService;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("Post-processing background service started");
while (!stoppingToken.IsCancellationRequested)
{
try
{
await _postProcessor.ProcessCompletedDownloadsAsync(stoppingToken);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error processing completed downloads");
}
// Check every 5 minutes
await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken);
}
}
}
}

View File

@ -1,350 +0,0 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.ServiceModel.Syndication;
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using System.Xml;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Hosting;
using TransmissionRssManager.Core;
namespace TransmissionRssManager.Services
{
public class RssFeedManager : IRssFeedManager
{
private readonly ILogger<RssFeedManager> _logger;
private readonly IConfigService _configService;
private readonly ITransmissionClient _transmissionClient;
private readonly HttpClient _httpClient;
private readonly string _dataPath;
private List<RssFeedItem> _items = new List<RssFeedItem>();
public RssFeedManager(
ILogger<RssFeedManager> logger,
IConfigService configService,
ITransmissionClient transmissionClient)
{
_logger = logger;
_configService = configService;
_transmissionClient = transmissionClient;
_httpClient = new HttpClient();
// Create data directory
string homeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
string dataDir = Path.Combine(homeDir, ".local", "share", "transmission-rss-manager");
if (!Directory.Exists(dataDir))
{
Directory.CreateDirectory(dataDir);
}
_dataPath = Path.Combine(dataDir, "rss-items.json");
LoadItems();
}
public Task<List<RssFeedItem>> GetAllItemsAsync()
{
return Task.FromResult(_items.OrderByDescending(i => i.PublishDate).ToList());
}
public Task<List<RssFeedItem>> GetMatchedItemsAsync()
{
return Task.FromResult(_items.Where(i => i.IsMatched).OrderByDescending(i => i.PublishDate).ToList());
}
public Task<List<RssFeed>> GetFeedsAsync()
{
var config = _configService.GetConfiguration();
return Task.FromResult(config.Feeds);
}
public async Task AddFeedAsync(RssFeed feed)
{
feed.Id = Guid.NewGuid().ToString();
feed.LastChecked = DateTime.MinValue;
var config = _configService.GetConfiguration();
config.Feeds.Add(feed);
await _configService.SaveConfigurationAsync(config);
// Initial fetch of feed items
await FetchFeedAsync(feed);
}
public async Task RemoveFeedAsync(string feedId)
{
var config = _configService.GetConfiguration();
var feed = config.Feeds.FirstOrDefault(f => f.Id == feedId);
if (feed != null)
{
config.Feeds.Remove(feed);
await _configService.SaveConfigurationAsync(config);
// Remove items from this feed
_items.RemoveAll(i => i.Id.StartsWith(feedId));
await SaveItemsAsync();
}
}
public async Task UpdateFeedAsync(RssFeed feed)
{
var config = _configService.GetConfiguration();
var index = config.Feeds.FindIndex(f => f.Id == feed.Id);
if (index != -1)
{
config.Feeds[index] = feed;
await _configService.SaveConfigurationAsync(config);
}
}
public async Task RefreshFeedsAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("Starting RSS feed refresh");
var config = _configService.GetConfiguration();
foreach (var feed in config.Feeds)
{
if (cancellationToken.IsCancellationRequested)
{
_logger.LogInformation("RSS refresh cancelled");
return;
}
try
{
await FetchFeedAsync(feed);
// Update last checked time
feed.LastChecked = DateTime.Now;
await _configService.SaveConfigurationAsync(config);
}
catch (Exception ex)
{
_logger.LogError(ex, $"Error refreshing feed: {feed.Name}");
}
}
// Check for matches and auto-download if enabled
await ProcessMatchesAsync();
}
public async Task MarkItemAsDownloadedAsync(string itemId)
{
var item = _items.FirstOrDefault(i => i.Id == itemId);
if (item != null)
{
item.IsDownloaded = true;
await SaveItemsAsync();
}
}
private async Task FetchFeedAsync(RssFeed feed)
{
_logger.LogInformation($"Fetching feed: {feed.Name}");
try
{
var response = await _httpClient.GetStringAsync(feed.Url);
using var stringReader = new StringReader(response);
using var xmlReader = XmlReader.Create(stringReader);
var syndicationFeed = SyndicationFeed.Load(xmlReader);
foreach (var item in syndicationFeed.Items)
{
var link = item.Links.FirstOrDefault()?.Uri.ToString() ?? "";
var torrentUrl = ExtractTorrentUrl(link, item.Title.Text);
// Create a unique ID for this item
var itemId = $"{feed.Id}:{item.Id ?? Guid.NewGuid().ToString()}";
// Check if we already have this item
if (_items.Any(i => i.Id == itemId))
{
continue;
}
var feedItem = new RssFeedItem
{
Id = itemId,
Title = item.Title.Text,
Link = link,
Description = item.Summary?.Text ?? "",
PublishDate = item.PublishDate.DateTime,
TorrentUrl = torrentUrl,
IsDownloaded = false
};
// Check if this item matches any rules
CheckForMatches(feedItem, feed.Rules);
_items.Add(feedItem);
}
await SaveItemsAsync();
}
catch (Exception ex)
{
_logger.LogError(ex, $"Error fetching feed: {feed.Name}");
throw;
}
}
private string ExtractTorrentUrl(string link, string title)
{
// Try to find a .torrent link
if (link.EndsWith(".torrent", StringComparison.OrdinalIgnoreCase))
{
return link;
}
// If it's a magnet link, return it
if (link.StartsWith("magnet:", StringComparison.OrdinalIgnoreCase))
{
return link;
}
// Return the link as is, we'll try to find the torrent on the page
return link;
}
private void CheckForMatches(RssFeedItem item, List<string> rules)
{
foreach (var rule in rules)
{
try
{
if (Regex.IsMatch(item.Title, rule, RegexOptions.IgnoreCase))
{
item.IsMatched = true;
item.MatchedRule = rule;
break;
}
}
catch (Exception ex)
{
_logger.LogError(ex, $"Invalid regex rule: {rule}");
}
}
}
private async Task ProcessMatchesAsync()
{
var config = _configService.GetConfiguration();
if (!config.AutoDownloadEnabled)
{
return;
}
var matchedItems = _items.Where(i => i.IsMatched && !i.IsDownloaded).ToList();
foreach (var item in matchedItems)
{
try
{
_logger.LogInformation($"Auto-downloading: {item.Title}");
var torrentId = await _transmissionClient.AddTorrentAsync(
item.TorrentUrl,
config.DownloadDirectory);
item.IsDownloaded = true;
}
catch (Exception ex)
{
_logger.LogError(ex, $"Error downloading torrent: {item.Title}");
}
}
await SaveItemsAsync();
}
private void LoadItems()
{
if (!File.Exists(_dataPath))
{
_items = new List<RssFeedItem>();
return;
}
try
{
var json = File.ReadAllText(_dataPath);
var items = JsonSerializer.Deserialize<List<RssFeedItem>>(json);
_items = items ?? new List<RssFeedItem>();
}
catch (Exception ex)
{
_logger.LogError(ex, "Error loading RSS items");
_items = new List<RssFeedItem>();
}
}
private async Task SaveItemsAsync()
{
try
{
var options = new JsonSerializerOptions
{
WriteIndented = true
};
var json = JsonSerializer.Serialize(_items, options);
await File.WriteAllTextAsync(_dataPath, json);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error saving RSS items");
}
}
}
public class RssFeedBackgroundService : BackgroundService
{
private readonly ILogger<RssFeedBackgroundService> _logger;
private readonly IRssFeedManager _rssFeedManager;
private readonly IConfigService _configService;
public RssFeedBackgroundService(
ILogger<RssFeedBackgroundService> logger,
IRssFeedManager rssFeedManager,
IConfigService configService)
{
_logger = logger;
_rssFeedManager = rssFeedManager;
_configService = configService;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("RSS feed background service started");
while (!stoppingToken.IsCancellationRequested)
{
try
{
await _rssFeedManager.RefreshFeedsAsync(stoppingToken);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error refreshing RSS feeds");
}
var config = _configService.GetConfiguration();
var interval = TimeSpan.FromMinutes(config.CheckIntervalMinutes);
_logger.LogInformation($"Next refresh in {interval.TotalMinutes} minutes");
await Task.Delay(interval, stoppingToken);
}
}
}
}

View File

@ -1,309 +0,0 @@
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
using System.Linq;
using Microsoft.Extensions.Logging;
using TransmissionRssManager.Core;
namespace TransmissionRssManager.Services
{
public class TransmissionClient : ITransmissionClient
{
private readonly ILogger<TransmissionClient> _logger;
private readonly IConfigService _configService;
private readonly HttpClient _httpClient;
private string _sessionId = string.Empty;
public TransmissionClient(ILogger<TransmissionClient> logger, IConfigService configService)
{
_logger = logger;
_configService = configService;
// Configure the main HttpClient with handler that ignores certificate errors
var handler = new HttpClientHandler
{
ServerCertificateCustomValidationCallback = (sender, cert, chain, sslPolicyErrors) => true
};
_httpClient = new HttpClient(handler);
_httpClient.Timeout = TimeSpan.FromSeconds(10);
_logger.LogInformation("TransmissionClient initialized with certificate validation disabled");
}
public async Task<List<TorrentInfo>> GetTorrentsAsync()
{
var config = _configService.GetConfiguration();
var request = new
{
method = "torrent-get",
arguments = new
{
fields = new[] { "id", "name", "status", "percentDone", "totalSize", "downloadDir" }
}
};
var response = await SendRequestAsync<TorrentGetResponse>(config.Transmission.Url, request);
_logger.LogInformation($"Transmission torrent response: {response != null}, Arguments: {response?.Arguments != null}, Result: {response?.Result}");
if (response?.Arguments?.Torrents == null)
{
_logger.LogWarning("No torrents found in response");
return new List<TorrentInfo>();
}
_logger.LogInformation($"Found {response.Arguments.Torrents.Count} torrents in response");
var torrents = new List<TorrentInfo>();
foreach (var torrent in response.Arguments.Torrents)
{
_logger.LogInformation($"Processing torrent: {torrent.Id} - {torrent.Name}");
torrents.Add(new TorrentInfo
{
Id = torrent.Id,
Name = torrent.Name,
Status = GetStatusText(torrent.Status),
PercentDone = torrent.PercentDone,
TotalSize = torrent.TotalSize,
DownloadDir = torrent.DownloadDir
});
}
return torrents;
}
public async Task<int> AddTorrentAsync(string torrentUrl, string downloadDir)
{
var config = _configService.GetConfiguration();
var request = new
{
method = "torrent-add",
arguments = new
{
filename = torrentUrl,
downloadDir = downloadDir
}
};
var response = await SendRequestAsync<TorrentAddResponse>(config.Transmission.Url, request);
if (response?.Arguments?.TorrentAdded != null)
{
return response.Arguments.TorrentAdded.Id;
}
else if (response?.Arguments?.TorrentDuplicate != null)
{
return response.Arguments.TorrentDuplicate.Id;
}
throw new Exception("Failed to add torrent");
}
public async Task RemoveTorrentAsync(int id, bool deleteLocalData)
{
var config = _configService.GetConfiguration();
var request = new
{
method = "torrent-remove",
arguments = new
{
ids = new[] { id },
deleteLocalData = deleteLocalData
}
};
await SendRequestAsync<object>(config.Transmission.Url, request);
}
public async Task StartTorrentAsync(int id)
{
var config = _configService.GetConfiguration();
var request = new
{
method = "torrent-start",
arguments = new
{
ids = new[] { id }
}
};
await SendRequestAsync<object>(config.Transmission.Url, request);
}
public async Task StopTorrentAsync(int id)
{
var config = _configService.GetConfiguration();
var request = new
{
method = "torrent-stop",
arguments = new
{
ids = new[] { id }
}
};
await SendRequestAsync<object>(config.Transmission.Url, request);
}
private async Task<T> SendRequestAsync<T>(string url, object requestData)
{
var config = _configService.GetConfiguration();
var jsonContent = JsonSerializer.Serialize(requestData);
var content = new StringContent(jsonContent, Encoding.UTF8, "application/json");
// Always create a fresh HttpClient to avoid connection issues
using var httpClient = new HttpClient(new HttpClientHandler
{
ServerCertificateCustomValidationCallback = (sender, cert, chain, sslPolicyErrors) => true
});
// Ensure we have a valid URL by reconstructing it explicitly
var protocol = config.Transmission.UseHttps ? "https" : "http";
var serverUrl = $"{protocol}://{config.Transmission.Host}:{config.Transmission.Port}/transmission/rpc";
var request = new HttpRequestMessage(HttpMethod.Post, serverUrl)
{
Content = content
};
// Add session ID if we have one
if (!string.IsNullOrEmpty(_sessionId))
{
request.Headers.Add("X-Transmission-Session-Id", _sessionId);
}
// Add authentication if provided
if (!string.IsNullOrEmpty(config.Transmission.Username) && !string.IsNullOrEmpty(config.Transmission.Password))
{
var credentials = Convert.ToBase64String(Encoding.ASCII.GetBytes($"{config.Transmission.Username}:{config.Transmission.Password}"));
request.Headers.Authorization = new AuthenticationHeaderValue("Basic", credentials);
}
try
{
// Set timeout to avoid hanging indefinitely on connection issues
httpClient.Timeout = TimeSpan.FromSeconds(10);
_logger.LogInformation($"Connecting to Transmission at {serverUrl} with auth: {!string.IsNullOrEmpty(config.Transmission.Username)}");
var response = await httpClient.SendAsync(request);
// Check if we need a new session ID
if (response.StatusCode == System.Net.HttpStatusCode.Conflict)
{
if (response.Headers.TryGetValues("X-Transmission-Session-Id", out var sessionIds))
{
_sessionId = sessionIds.FirstOrDefault() ?? string.Empty;
_logger.LogInformation($"Got new Transmission session ID: {_sessionId}");
// Retry request with new session ID
return await SendRequestAsync<T>(url, requestData);
}
}
response.EnsureSuccessStatusCode();
var resultContent = await response.Content.ReadAsStringAsync();
_logger.LogInformation($"Received successful response from Transmission: {resultContent.Substring(0, Math.Min(resultContent.Length, 500))}");
// Configure JSON deserializer to be case insensitive
var options = new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
};
return JsonSerializer.Deserialize<T>(resultContent, options);
}
catch (Exception ex)
{
_logger.LogError(ex, $"Error communicating with Transmission at {serverUrl}: {ex.Message}");
throw new Exception($"Failed to connect to Transmission at {config.Transmission.Host}:{config.Transmission.Port}. Error: {ex.Message}", ex);
}
}
private string GetStatusText(int status)
{
return status switch
{
0 => "Stopped",
1 => "Queued",
2 => "Verifying",
3 => "Downloading",
4 => "Seeding",
5 => "Queued",
6 => "Checking",
_ => "Unknown"
};
}
// Transmission response classes with proper JSON attribute names
private class TorrentGetResponse
{
[System.Text.Json.Serialization.JsonPropertyName("arguments")]
public TorrentGetArguments Arguments { get; set; }
[System.Text.Json.Serialization.JsonPropertyName("result")]
public string Result { get; set; }
}
private class TorrentGetArguments
{
[System.Text.Json.Serialization.JsonPropertyName("torrents")]
public List<TransmissionTorrent> Torrents { get; set; }
}
private class TransmissionTorrent
{
[System.Text.Json.Serialization.JsonPropertyName("id")]
public int Id { get; set; }
[System.Text.Json.Serialization.JsonPropertyName("name")]
public string Name { get; set; }
[System.Text.Json.Serialization.JsonPropertyName("status")]
public int Status { get; set; }
[System.Text.Json.Serialization.JsonPropertyName("percentDone")]
public double PercentDone { get; set; }
[System.Text.Json.Serialization.JsonPropertyName("totalSize")]
public long TotalSize { get; set; }
[System.Text.Json.Serialization.JsonPropertyName("downloadDir")]
public string DownloadDir { get; set; }
}
private class TorrentAddResponse
{
[System.Text.Json.Serialization.JsonPropertyName("arguments")]
public TorrentAddArguments Arguments { get; set; }
[System.Text.Json.Serialization.JsonPropertyName("result")]
public string Result { get; set; }
}
private class TorrentAddArguments
{
[System.Text.Json.Serialization.JsonPropertyName("torrent-added")]
public TorrentAddInfo TorrentAdded { get; set; }
[System.Text.Json.Serialization.JsonPropertyName("torrent-duplicate")]
public TorrentAddInfo TorrentDuplicate { get; set; }
}
private class TorrentAddInfo
{
[System.Text.Json.Serialization.JsonPropertyName("id")]
public int Id { get; set; }
[System.Text.Json.Serialization.JsonPropertyName("name")]
public string Name { get; set; }
[System.Text.Json.Serialization.JsonPropertyName("hashString")]
public string HashString { get; set; }
}
}
}

View File

@ -1,171 +0,0 @@
:root {
--primary-color: #0d6efd;
--secondary-color: #6c757d;
--dark-color: #212529;
--light-color: #f8f9fa;
--success-color: #198754;
--danger-color: #dc3545;
--warning-color: #ffc107;
--info-color: #0dcaf0;
}
body {
padding-bottom: 2rem;
}
.navbar {
margin-bottom: 1rem;
}
.page-content {
animation: fadeIn 0.3s ease;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.card {
margin-bottom: 1rem;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.card-header {
background-color: var(--light-color);
font-weight: 500;
}
.table {
margin-bottom: 0;
}
.progress {
height: 10px;
}
.badge {
padding: 0.35em 0.65em;
}
.badge-downloading {
background-color: var(--info-color);
color: var(--dark-color);
}
.badge-seeding {
background-color: var(--success-color);
}
.badge-stopped {
background-color: var(--secondary-color);
}
.badge-checking {
background-color: var(--warning-color);
color: var(--dark-color);
}
.badge-queued {
background-color: var(--secondary-color);
}
.btn-icon {
padding: 0.25rem 0.5rem;
font-size: 0.875rem;
}
.feed-item {
border-left: 3px solid transparent;
padding: 10px;
margin-bottom: 10px;
background-color: #f8f9fa;
border-radius: 4px;
}
.feed-item:hover {
background-color: #e9ecef;
}
.feed-item.matched {
border-left-color: var(--success-color);
}
.feed-item.downloaded {
opacity: 0.7;
}
.feed-item-title {
font-weight: 500;
margin-bottom: 5px;
}
.feed-item-date {
font-size: 0.85rem;
color: var(--secondary-color);
}
.feed-item-buttons {
margin-top: 10px;
}
.torrent-item {
margin-bottom: 15px;
padding: 15px;
border-radius: 4px;
background-color: #f8f9fa;
}
.torrent-item-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.torrent-item-title {
font-weight: 500;
margin-right: 10px;
}
.torrent-item-progress {
margin: 10px 0;
}
.torrent-item-details {
display: flex;
justify-content: space-between;
font-size: 0.9rem;
color: var(--secondary-color);
}
.torrent-item-buttons {
margin-top: 10px;
}
/* Responsive tweaks */
@media (max-width: 768px) {
.container {
padding-left: 10px;
padding-right: 10px;
}
.card-body {
padding: 1rem;
}
.torrent-item-header {
flex-direction: column;
align-items: flex-start;
}
.torrent-item-buttons {
display: flex;
flex-wrap: wrap;
gap: 5px;
}
.torrent-item-buttons .btn {
flex: 1;
}
}

View File

@ -1,283 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Transmission RSS Manager</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css">
<link rel="stylesheet" href="css/styles.css">
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
<div class="container">
<a class="navbar-brand" href="#">Transmission RSS Manager</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav">
<li class="nav-item"><a class="nav-link" href="#" data-page="dashboard">Dashboard</a></li>
<li class="nav-item"><a class="nav-link" href="#" data-page="feeds">RSS Feeds</a></li>
<li class="nav-item"><a class="nav-link" href="#" data-page="torrents">Torrents</a></li>
<li class="nav-item"><a class="nav-link" href="#" data-page="settings">Settings</a></li>
</ul>
</div>
</div>
</nav>
<div class="container mt-4">
<div id="page-dashboard" class="page-content">
<h2>Dashboard</h2>
<div class="row mt-4">
<div class="col-md-6">
<div class="card">
<div class="card-header">System Status</div>
<div class="card-body">
<div id="system-status">Loading...</div>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card">
<div class="card-header">Recent Matches</div>
<div class="card-body">
<div id="recent-matches">Loading...</div>
</div>
</div>
</div>
</div>
<div class="row mt-4">
<div class="col-md-12">
<div class="card">
<div class="card-header">Active Torrents</div>
<div class="card-body">
<div id="active-torrents">Loading...</div>
</div>
</div>
</div>
</div>
</div>
<div id="page-feeds" class="page-content d-none">
<h2>RSS Feeds</h2>
<div class="mb-3">
<button id="btn-add-feed" class="btn btn-primary">Add Feed</button>
<button id="btn-refresh-feeds" class="btn btn-secondary">Refresh Feeds</button>
</div>
<div id="feeds-list">Loading...</div>
<div class="mt-4">
<h3>Feed Items</h3>
<ul class="nav nav-tabs" id="feedTabs">
<li class="nav-item">
<a class="nav-link active" data-bs-toggle="tab" href="#all-items">All Items</a>
</li>
<li class="nav-item">
<a class="nav-link" data-bs-toggle="tab" href="#matched-items">Matched Items</a>
</li>
</ul>
<div class="tab-content mt-2">
<div class="tab-pane fade show active" id="all-items">
<div id="all-items-list">Loading...</div>
</div>
<div class="tab-pane fade" id="matched-items">
<div id="matched-items-list">Loading...</div>
</div>
</div>
</div>
</div>
<div id="page-torrents" class="page-content d-none">
<h2>Torrents</h2>
<div class="mb-3">
<button id="btn-add-torrent" class="btn btn-primary">Add Torrent</button>
<button id="btn-refresh-torrents" class="btn btn-secondary">Refresh Torrents</button>
</div>
<div id="torrents-list">Loading...</div>
</div>
<div id="page-settings" class="page-content d-none">
<h2>Settings</h2>
<form id="settings-form">
<div class="card mb-4">
<div class="card-header">Transmission Settings</div>
<div class="card-body">
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label for="transmission-host" class="form-label">Host</label>
<input type="text" class="form-control" id="transmission-host" name="transmission.host">
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label for="transmission-port" class="form-label">Port</label>
<input type="number" class="form-control" id="transmission-port" name="transmission.port">
</div>
</div>
</div>
<div class="row">
<div class="col-md-12">
<div class="mb-3">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="transmission-use-https" name="transmission.useHttps">
<label class="form-check-label" for="transmission-use-https">Use HTTPS</label>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label for="transmission-username" class="form-label">Username</label>
<input type="text" class="form-control" id="transmission-username" name="transmission.username">
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label for="transmission-password" class="form-label">Password</label>
<input type="password" class="form-control" id="transmission-password" name="transmission.password">
</div>
</div>
</div>
</div>
</div>
<div class="card mb-4">
<div class="card-header">RSS Settings</div>
<div class="card-body">
<div class="mb-3">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="auto-download-enabled" name="autoDownloadEnabled">
<label class="form-check-label" for="auto-download-enabled">Enable Auto Download</label>
</div>
</div>
<div class="mb-3">
<label for="check-interval" class="form-label">Check Interval (minutes)</label>
<input type="number" class="form-control" id="check-interval" name="checkIntervalMinutes">
</div>
</div>
</div>
<div class="card mb-4">
<div class="card-header">Directories</div>
<div class="card-body">
<div class="mb-3">
<label for="download-directory" class="form-label">Download Directory</label>
<input type="text" class="form-control" id="download-directory" name="downloadDirectory">
</div>
<div class="mb-3">
<label for="media-library" class="form-label">Media Library Path</label>
<input type="text" class="form-control" id="media-library" name="mediaLibraryPath">
</div>
</div>
</div>
<div class="card mb-4">
<div class="card-header">Post Processing</div>
<div class="card-body">
<div class="mb-3">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="post-processing-enabled" name="postProcessing.enabled">
<label class="form-check-label" for="post-processing-enabled">Enable Post Processing</label>
</div>
</div>
<div class="mb-3">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="extract-archives" name="postProcessing.extractArchives">
<label class="form-check-label" for="extract-archives">Extract Archives</label>
</div>
</div>
<div class="mb-3">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="organize-media" name="postProcessing.organizeMedia">
<label class="form-check-label" for="organize-media">Organize Media</label>
</div>
</div>
<div class="mb-3">
<label for="minimum-seed-ratio" class="form-label">Minimum Seed Ratio</label>
<input type="number" class="form-control" id="minimum-seed-ratio" name="postProcessing.minimumSeedRatio">
</div>
<div class="mb-3">
<label for="media-extensions" class="form-label">Media Extensions (comma separated)</label>
<input type="text" class="form-control" id="media-extensions" name="mediaExtensions">
</div>
</div>
</div>
<button type="submit" class="btn btn-primary">Save Settings</button>
</form>
</div>
</div>
<!-- Modals -->
<div class="modal fade" id="add-feed-modal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Add RSS Feed</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="add-feed-form">
<div class="mb-3">
<label for="feed-name" class="form-label">Name</label>
<input type="text" class="form-control" id="feed-name" required>
</div>
<div class="mb-3">
<label for="feed-url" class="form-label">URL</label>
<input type="url" class="form-control" id="feed-url" required>
</div>
<div class="mb-3">
<label for="feed-rules" class="form-label">Match Rules (one per line)</label>
<textarea class="form-control" id="feed-rules" rows="5"></textarea>
<div class="form-text">Use regular expressions to match feed items.</div>
</div>
<div class="mb-3">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="feed-auto-download">
<label class="form-check-label" for="feed-auto-download">Auto Download</label>
</div>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="save-feed-btn">Add Feed</button>
</div>
</div>
</div>
</div>
<div class="modal fade" id="add-torrent-modal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Add Torrent</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="add-torrent-form">
<div class="mb-3">
<label for="torrent-url" class="form-label">Torrent URL or Magnet Link</label>
<input type="text" class="form-control" id="torrent-url" required>
</div>
<div class="mb-3">
<label for="torrent-download-dir" class="form-label">Download Directory (optional)</label>
<input type="text" class="form-control" id="torrent-download-dir">
<div class="form-text">Leave empty to use default download directory.</div>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="save-torrent-btn">Add Torrent</button>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script src="js/app.js"></script>
</body>
</html>

View File

@ -1,916 +0,0 @@
document.addEventListener('DOMContentLoaded', function() {
// Initialize navigation
initNavigation();
// Initialize event listeners
initEventListeners();
// Load initial dashboard data
loadDashboardData();
// Initialize Bootstrap tooltips
const tooltips = document.querySelectorAll('[data-bs-toggle="tooltip"]');
tooltips.forEach(tooltip => new bootstrap.Tooltip(tooltip));
});
function initNavigation() {
const navLinks = document.querySelectorAll('.navbar-nav .nav-link');
navLinks.forEach(link => {
link.addEventListener('click', function(e) {
e.preventDefault();
const page = this.getAttribute('data-page');
showPage(page);
});
});
// Set active page from URL hash or default to dashboard
const hash = window.location.hash.substring(1);
showPage(hash || 'dashboard');
}
function showPage(page) {
// Hide all pages
const pages = document.querySelectorAll('.page-content');
pages.forEach(p => p.classList.add('d-none'));
// Remove active class from all nav links
const navLinks = document.querySelectorAll('.navbar-nav .nav-link');
navLinks.forEach(link => link.classList.remove('active'));
// Show selected page
const selectedPage = document.getElementById(`page-${page}`);
if (selectedPage) {
selectedPage.classList.remove('d-none');
// Set active class on nav link
const activeNav = document.querySelector(`.nav-link[data-page="${page}"]`);
if (activeNav) {
activeNav.classList.add('active');
}
// Update URL hash
window.location.hash = page;
// Load page-specific data
loadPageData(page);
}
}
function loadPageData(page) {
switch (page) {
case 'dashboard':
loadDashboardData();
break;
case 'feeds':
loadFeeds();
loadAllItems();
loadMatchedItems();
break;
case 'torrents':
loadTorrents();
break;
case 'settings':
loadSettings();
break;
}
}
function initEventListeners() {
// RSS Feeds page
document.getElementById('btn-add-feed').addEventListener('click', showAddFeedModal);
document.getElementById('btn-refresh-feeds').addEventListener('click', refreshFeeds);
document.getElementById('save-feed-btn').addEventListener('click', saveFeed);
// Torrents page
document.getElementById('btn-add-torrent').addEventListener('click', showAddTorrentModal);
document.getElementById('btn-refresh-torrents').addEventListener('click', loadTorrents);
document.getElementById('save-torrent-btn').addEventListener('click', saveTorrent);
// Settings page
document.getElementById('settings-form').addEventListener('submit', saveSettings);
}
// Dashboard
function loadDashboardData() {
loadSystemStatus();
loadRecentMatches();
loadActiveTorrents();
}
function loadSystemStatus() {
const statusElement = document.getElementById('system-status');
statusElement.innerHTML = '<div class="d-flex justify-content-center"><div class="spinner-border spinner-border-sm text-primary" role="status"></div></div>';
fetch('/api/config')
.then(response => response.json())
.then(config => {
// Create system status HTML
let html = '<ul class="list-group">';
html += `<li class="list-group-item d-flex justify-content-between align-items-center">Auto Download <span class="badge ${config.autoDownloadEnabled ? 'bg-success' : 'bg-danger'}">${config.autoDownloadEnabled ? 'Enabled' : 'Disabled'}</span></li>`;
html += `<li class="list-group-item d-flex justify-content-between align-items-center">Check Interval <span class="badge bg-primary">${config.checkIntervalMinutes} minutes</span></li>`;
html += `<li class="list-group-item d-flex justify-content-between align-items-center">Transmission Connection <span class="badge ${config.transmission.host ? 'bg-success' : 'bg-warning'}">${config.transmission.host ? config.transmission.host + ':' + config.transmission.port : 'Not configured'}</span></li>`;
html += `<li class="list-group-item d-flex justify-content-between align-items-center">Post Processing <span class="badge ${config.postProcessing.enabled ? 'bg-success' : 'bg-danger'}">${config.postProcessing.enabled ? 'Enabled' : 'Disabled'}</span></li>`;
html += '</ul>';
statusElement.innerHTML = html;
})
.catch(error => {
console.error('Error loading system status:', error);
statusElement.innerHTML = '<div class="alert alert-danger">Error loading system status</div>';
});
}
function loadRecentMatches() {
const matchesElement = document.getElementById('recent-matches');
matchesElement.innerHTML = '<div class="d-flex justify-content-center"><div class="spinner-border spinner-border-sm text-primary" role="status"></div></div>';
fetch('/api/feeds/matched')
.then(response => response.json())
.then(items => {
// Sort by publish date descending and take the first 5
const recentItems = items.sort((a, b) => new Date(b.publishDate) - new Date(a.publishDate)).slice(0, 5);
if (recentItems.length === 0) {
matchesElement.innerHTML = '<div class="alert alert-info">No matched items yet</div>';
return;
}
let html = '<div class="list-group">';
recentItems.forEach(item => {
const date = new Date(item.publishDate);
html += `<a href="${item.link}" target="_blank" class="list-group-item list-group-item-action">
<div class="d-flex w-100 justify-content-between">
<h6 class="mb-1">${item.title}</h6>
<small>${formatDate(date)}</small>
</div>
<small class="d-flex justify-content-between">
<span>Matched rule: ${item.matchedRule}</span>
<span class="badge ${item.isDownloaded ? 'bg-success' : 'bg-warning'}">${item.isDownloaded ? 'Downloaded' : 'Not Downloaded'}</span>
</small>
</a>`;
});
html += '</div>';
matchesElement.innerHTML = html;
})
.catch(error => {
console.error('Error loading recent matches:', error);
matchesElement.innerHTML = '<div class="alert alert-danger">Error loading recent matches</div>';
});
}
function loadActiveTorrents() {
const torrentsElement = document.getElementById('active-torrents');
torrentsElement.innerHTML = '<div class="d-flex justify-content-center"><div class="spinner-border spinner-border-sm text-primary" role="status"></div></div>';
fetch('/api/torrents')
.then(response => response.json())
.then(torrents => {
console.log('Dashboard torrents:', torrents);
// Sort by progress ascending and filter for active torrents
const activeTorrents = torrents
.filter(t => t && t.status && (t.status === 'Downloading' || t.status === 'Seeding'))
.sort((a, b) => (a.percentDone || 0) - (b.percentDone || 0));
if (activeTorrents.length === 0) {
torrentsElement.innerHTML = '<div class="alert alert-info">No active torrents</div>';
return;
}
let html = '<div class="list-group">';
activeTorrents.forEach(torrent => {
// Handle potential null or undefined values
if (!torrent || !torrent.name) {
return;
}
// Safely calculate percentages and sizes with error handling
let progressPercent = 0;
try {
progressPercent = Math.round((torrent.percentDone || 0) * 100);
} catch (e) {
console.warn('Error calculating progress percent:', e);
}
let sizeInGB = '0.00';
try {
if (torrent.totalSize && torrent.totalSize > 0) {
sizeInGB = (torrent.totalSize / 1073741824).toFixed(2);
}
} catch (e) {
console.warn('Error calculating size in GB:', e);
}
const torrentStatus = torrent.status || 'Unknown';
const statusClass = torrentStatus.toLowerCase().replace(/\s+/g, '-');
html += `<div class="list-group-item">
<div class="d-flex w-100 justify-content-between">
<h6 class="mb-1">${torrent.name}</h6>
<span class="badge bg-${statusClass === 'seeding' ? 'success' : 'primary'}">${torrentStatus}</span>
</div>
<div class="progress mt-2">
<div class="progress-bar ${torrentStatus === 'Seeding' ? 'bg-success' : 'bg-primary'}" role="progressbar" style="width: ${progressPercent}%">${progressPercent}%</div>
</div>
<small class="d-block mt-1 text-muted">Size: ${sizeInGB} GB</small>
</div>`;
});
html += '</div>';
torrentsElement.innerHTML = html;
})
.catch(error => {
console.error('Error loading active torrents:', error);
torrentsElement.innerHTML = '<div class="alert alert-danger">Error loading active torrents</div>';
});
}
// RSS Feeds
function loadFeeds() {
const feedsElement = document.getElementById('feeds-list');
feedsElement.innerHTML = '<div class="d-flex justify-content-center"><div class="spinner-border spinner-border-sm text-primary" role="status"></div></div>';
fetch('/api/feeds')
.then(response => response.json())
.then(feeds => {
if (feeds.length === 0) {
feedsElement.innerHTML = '<div class="alert alert-info">No feeds added yet</div>';
return;
}
let html = '<div class="list-group">';
feeds.forEach(feed => {
const lastChecked = feed.lastChecked ? new Date(feed.lastChecked) : null;
html += `<div class="list-group-item list-group-item-action" data-feed-id="${feed.id}">
<div class="d-flex w-100 justify-content-between">
<h5 class="mb-1">${feed.name}</h5>
<small>${lastChecked ? 'Last checked: ' + formatDate(lastChecked) : 'Never checked'}</small>
</div>
<p class="mb-1"><a href="${feed.url}" target="_blank">${feed.url}</a></p>
<div class="d-flex justify-content-between align-items-center">
<small>${feed.rules.length} rules</small>
<div>
<button class="btn btn-sm btn-outline-primary me-2 btn-edit-feed" data-feed-id="${feed.id}">Edit</button>
<button class="btn btn-sm btn-outline-danger btn-delete-feed" data-feed-id="${feed.id}">Delete</button>
</div>
</div>
</div>`;
});
html += '</div>';
feedsElement.innerHTML = html;
// Add event listeners
document.querySelectorAll('.btn-edit-feed').forEach(btn => {
btn.addEventListener('click', function() {
const feedId = this.getAttribute('data-feed-id');
editFeed(feedId);
});
});
document.querySelectorAll('.btn-delete-feed').forEach(btn => {
btn.addEventListener('click', function() {
const feedId = this.getAttribute('data-feed-id');
deleteFeed(feedId);
});
});
})
.catch(error => {
console.error('Error loading feeds:', error);
feedsElement.innerHTML = '<div class="alert alert-danger">Error loading feeds</div>';
});
}
function loadAllItems() {
const itemsElement = document.getElementById('all-items-list');
itemsElement.innerHTML = '<div class="d-flex justify-content-center"><div class="spinner-border spinner-border-sm text-primary" role="status"></div></div>';
fetch('/api/feeds/items')
.then(response => response.json())
.then(items => {
if (items.length === 0) {
itemsElement.innerHTML = '<div class="alert alert-info">No feed items yet</div>';
return;
}
let html = '<div class="feed-items-container">';
items.forEach(item => {
const date = new Date(item.publishDate);
const classes = `feed-item ${item.isMatched ? 'matched' : ''} ${item.isDownloaded ? 'downloaded' : ''}`;
html += `<div class="${classes}" data-item-id="${item.id}">
<div class="feed-item-title"><a href="${item.link}" target="_blank">${item.title}</a></div>
<div class="feed-item-date">${formatDate(date)}</div>
${item.isMatched ? `<div class="text-success small">Matched rule: ${item.matchedRule}</div>` : ''}
${item.isDownloaded ? '<div class="text-muted small">Downloaded</div>' : ''}
${!item.isDownloaded && item.isMatched ?
`<div class="feed-item-buttons">
<button class="btn btn-sm btn-primary btn-download-item" data-item-id="${item.id}">Download</button>
</div>` : ''
}
</div>`;
});
html += '</div>';
itemsElement.innerHTML = html;
// Add event listeners
document.querySelectorAll('.btn-download-item').forEach(btn => {
btn.addEventListener('click', function() {
const itemId = this.getAttribute('data-item-id');
downloadItem(itemId);
});
});
})
.catch(error => {
console.error('Error loading feed items:', error);
itemsElement.innerHTML = '<div class="alert alert-danger">Error loading feed items</div>';
});
}
function loadMatchedItems() {
const matchedElement = document.getElementById('matched-items-list');
matchedElement.innerHTML = '<div class="d-flex justify-content-center"><div class="spinner-border spinner-border-sm text-primary" role="status"></div></div>';
fetch('/api/feeds/matched')
.then(response => response.json())
.then(items => {
if (items.length === 0) {
matchedElement.innerHTML = '<div class="alert alert-info">No matched items yet</div>';
return;
}
let html = '<div class="feed-items-container">';
items.forEach(item => {
const date = new Date(item.publishDate);
const classes = `feed-item matched ${item.isDownloaded ? 'downloaded' : ''}`;
html += `<div class="${classes}" data-item-id="${item.id}">
<div class="feed-item-title"><a href="${item.link}" target="_blank">${item.title}</a></div>
<div class="feed-item-date">${formatDate(date)}</div>
<div class="text-success small">Matched rule: ${item.matchedRule}</div>
${item.isDownloaded ? '<div class="text-muted small">Downloaded</div>' : ''}
${!item.isDownloaded ?
`<div class="feed-item-buttons">
<button class="btn btn-sm btn-primary btn-download-matched-item" data-item-id="${item.id}">Download</button>
</div>` : ''
}
</div>`;
});
html += '</div>';
matchedElement.innerHTML = html;
// Add event listeners
document.querySelectorAll('.btn-download-matched-item').forEach(btn => {
btn.addEventListener('click', function() {
const itemId = this.getAttribute('data-item-id');
downloadItem(itemId);
});
});
})
.catch(error => {
console.error('Error loading matched items:', error);
matchedElement.innerHTML = '<div class="alert alert-danger">Error loading matched items</div>';
});
}
function showAddFeedModal() {
// Clear form
document.getElementById('feed-name').value = '';
document.getElementById('feed-url').value = '';
document.getElementById('feed-rules').value = '';
document.getElementById('feed-auto-download').checked = false;
// Update modal title and button text
document.querySelector('#add-feed-modal .modal-title').textContent = 'Add RSS Feed';
document.getElementById('save-feed-btn').textContent = 'Add Feed';
// Remove feed ID data attribute
document.getElementById('save-feed-btn').removeAttribute('data-feed-id');
// Show modal
const modal = new bootstrap.Modal(document.getElementById('add-feed-modal'));
modal.show();
}
function editFeed(feedId) {
// Fetch feed data
fetch(`/api/feeds`)
.then(response => response.json())
.then(feeds => {
const feed = feeds.find(f => f.id === feedId);
if (!feed) {
alert('Feed not found');
return;
}
// Populate form
document.getElementById('feed-name').value = feed.name;
document.getElementById('feed-url').value = feed.url;
document.getElementById('feed-rules').value = feed.rules.join('\n');
document.getElementById('feed-auto-download').checked = feed.autoDownload;
// Update modal title and button text
document.querySelector('#add-feed-modal .modal-title').textContent = 'Edit RSS Feed';
document.getElementById('save-feed-btn').textContent = 'Save Changes';
// Add feed ID data attribute
document.getElementById('save-feed-btn').setAttribute('data-feed-id', feedId);
// Show modal
const modal = new bootstrap.Modal(document.getElementById('add-feed-modal'));
modal.show();
})
.catch(error => {
console.error('Error fetching feed:', error);
alert('Error fetching feed');
});
}
function saveFeed() {
const name = document.getElementById('feed-name').value.trim();
const url = document.getElementById('feed-url').value.trim();
const rulesText = document.getElementById('feed-rules').value.trim();
const autoDownload = document.getElementById('feed-auto-download').checked;
if (!name || !url) {
alert('Please enter a name and URL');
return;
}
// Parse rules (split by new line and remove empty lines)
const rules = rulesText.split('\n').filter(rule => rule.trim() !== '');
const feedId = document.getElementById('save-feed-btn').getAttribute('data-feed-id');
const isEditing = !!feedId;
const feedData = {
name: name,
url: url,
rules: rules,
autoDownload: autoDownload
};
if (isEditing) {
feedData.id = feedId;
// Update existing feed
fetch(`/api/feeds/${feedId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(feedData)
})
.then(response => {
if (!response.ok) {
throw new Error('Failed to update feed');
}
// Close modal
const modal = bootstrap.Modal.getInstance(document.getElementById('add-feed-modal'));
modal.hide();
// Refresh feeds
loadFeeds();
})
.catch(error => {
console.error('Error updating feed:', error);
alert('Error updating feed');
});
} else {
// Add new feed
fetch('/api/feeds', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(feedData)
})
.then(response => {
if (!response.ok) {
throw new Error('Failed to add feed');
}
// Close modal
const modal = bootstrap.Modal.getInstance(document.getElementById('add-feed-modal'));
modal.hide();
// Refresh feeds
loadFeeds();
// Also refresh items since a new feed might have new items
loadAllItems();
loadMatchedItems();
})
.catch(error => {
console.error('Error adding feed:', error);
alert('Error adding feed');
});
}
}
function deleteFeed(feedId) {
if (!confirm('Are you sure you want to delete this feed?')) {
return;
}
fetch(`/api/feeds/${feedId}`, {
method: 'DELETE'
})
.then(response => {
if (!response.ok) {
throw new Error('Failed to delete feed');
}
// Refresh feeds
loadFeeds();
// Also refresh items since items from this feed should be removed
loadAllItems();
loadMatchedItems();
})
.catch(error => {
console.error('Error deleting feed:', error);
alert('Error deleting feed');
});
}
function refreshFeeds() {
const btn = document.getElementById('btn-refresh-feeds');
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> Refreshing...';
fetch('/api/feeds/refresh', {
method: 'POST'
})
.then(response => {
if (!response.ok) {
throw new Error('Failed to refresh feeds');
}
// Re-enable button
btn.disabled = false;
btn.textContent = 'Refresh Feeds';
// Refresh feed items
loadFeeds();
loadAllItems();
loadMatchedItems();
})
.catch(error => {
console.error('Error refreshing feeds:', error);
alert('Error refreshing feeds');
// Re-enable button
btn.disabled = false;
btn.textContent = 'Refresh Feeds';
});
}
function downloadItem(itemId) {
fetch(`/api/feeds/download/${itemId}`, {
method: 'POST'
})
.then(response => {
if (!response.ok) {
throw new Error('Failed to download item');
}
// Refresh items
loadAllItems();
loadMatchedItems();
// Also refresh torrents since a new torrent should be added
loadTorrents();
})
.catch(error => {
console.error('Error downloading item:', error);
alert('Error downloading item');
});
}
// Torrents
function loadTorrents() {
const torrentsElement = document.getElementById('torrents-list');
torrentsElement.innerHTML = '<div class="d-flex justify-content-center"><div class="spinner-border spinner-border-sm text-primary" role="status"></div></div>';
fetch('/api/torrents')
.then(response => response.json())
.then(torrents => {
console.log('Loaded torrents:', torrents);
if (torrents.length === 0) {
torrentsElement.innerHTML = '<div class="alert alert-info">No torrents</div>';
return;
}
let html = '<div class="torrents-container">';
torrents.forEach(torrent => {
// Handle potential null or undefined values
if (!torrent || !torrent.name) {
console.warn('Invalid torrent data:', torrent);
return;
}
// Safely calculate percentages and sizes with error handling
let progressPercent = 0;
try {
progressPercent = Math.round((torrent.percentDone || 0) * 100);
} catch (e) {
console.warn('Error calculating progress percent:', e);
}
let sizeInGB = '0.00';
try {
if (torrent.totalSize && torrent.totalSize > 0) {
sizeInGB = (torrent.totalSize / 1073741824).toFixed(2);
}
} catch (e) {
console.warn('Error calculating size in GB:', e);
}
const torrentStatus = torrent.status || 'Unknown';
const statusClass = torrentStatus.toLowerCase().replace(/\s+/g, '-');
html += `<div class="torrent-item" data-torrent-id="${torrent.id || 0}">
<div class="torrent-item-header">
<div class="torrent-item-title">${torrent.name}</div>
<span class="badge bg-${statusClass === 'seeding' ? 'success' : statusClass === 'downloading' ? 'primary' : statusClass === 'stopped' ? 'secondary' : 'info'}">${torrentStatus}</span>
</div>
<div class="torrent-item-progress">
<div class="progress">
<div class="progress-bar ${torrentStatus === 'Seeding' ? 'bg-success' : 'bg-primary'}" role="progressbar" style="width: ${progressPercent}%">${progressPercent}%</div>
</div>
</div>
<div class="torrent-item-details">
<span>Size: ${sizeInGB} GB</span>
<span>Location: ${torrent.downloadDir || 'Unknown'}</span>
</div>
<div class="torrent-item-buttons">
${torrentStatus === 'Stopped' ?
`<button class="btn btn-sm btn-success me-2 btn-start-torrent" data-torrent-id="${torrent.id || 0}">Start</button>` :
`<button class="btn btn-sm btn-warning me-2 btn-stop-torrent" data-torrent-id="${torrent.id || 0}">Stop</button>`
}
<button class="btn btn-sm btn-danger me-2 btn-remove-torrent" data-torrent-id="${torrent.id || 0}">Remove</button>
${progressPercent >= 100 ?
`<button class="btn btn-sm btn-info btn-process-torrent" data-torrent-id="${torrent.id || 0}">Process</button>` : ''
}
</div>
</div>`;
});
html += '</div>';
torrentsElement.innerHTML = html;
// Add event listeners
document.querySelectorAll('.btn-start-torrent').forEach(btn => {
btn.addEventListener('click', function() {
const torrentId = parseInt(this.getAttribute('data-torrent-id'));
startTorrent(torrentId);
});
});
document.querySelectorAll('.btn-stop-torrent').forEach(btn => {
btn.addEventListener('click', function() {
const torrentId = parseInt(this.getAttribute('data-torrent-id'));
stopTorrent(torrentId);
});
});
document.querySelectorAll('.btn-remove-torrent').forEach(btn => {
btn.addEventListener('click', function() {
const torrentId = parseInt(this.getAttribute('data-torrent-id'));
removeTorrent(torrentId);
});
});
document.querySelectorAll('.btn-process-torrent').forEach(btn => {
btn.addEventListener('click', function() {
const torrentId = parseInt(this.getAttribute('data-torrent-id'));
processTorrent(torrentId);
});
});
})
.catch(error => {
console.error('Error loading torrents:', error);
torrentsElement.innerHTML = '<div class="alert alert-danger">Error loading torrents</div>';
});
}
function showAddTorrentModal() {
// Clear form
document.getElementById('torrent-url').value = '';
document.getElementById('torrent-download-dir').value = '';
// Show modal
const modal = new bootstrap.Modal(document.getElementById('add-torrent-modal'));
modal.show();
}
function saveTorrent() {
const url = document.getElementById('torrent-url').value.trim();
const downloadDir = document.getElementById('torrent-download-dir').value.trim();
if (!url) {
alert('Please enter a torrent URL or magnet link');
return;
}
const torrentData = {
url: url
};
if (downloadDir) {
torrentData.downloadDir = downloadDir;
}
fetch('/api/torrents', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(torrentData)
})
.then(response => {
if (!response.ok) {
throw new Error('Failed to add torrent');
}
// Close modal
const modal = bootstrap.Modal.getInstance(document.getElementById('add-torrent-modal'));
modal.hide();
// Refresh torrents
loadTorrents();
})
.catch(error => {
console.error('Error adding torrent:', error);
alert('Error adding torrent');
});
}
function startTorrent(torrentId) {
fetch(`/api/torrents/${torrentId}/start`, {
method: 'POST'
})
.then(response => {
if (!response.ok) {
throw new Error('Failed to start torrent');
}
// Refresh torrents
loadTorrents();
})
.catch(error => {
console.error('Error starting torrent:', error);
alert('Error starting torrent');
});
}
function stopTorrent(torrentId) {
fetch(`/api/torrents/${torrentId}/stop`, {
method: 'POST'
})
.then(response => {
if (!response.ok) {
throw new Error('Failed to stop torrent');
}
// Refresh torrents
loadTorrents();
})
.catch(error => {
console.error('Error stopping torrent:', error);
alert('Error stopping torrent');
});
}
function removeTorrent(torrentId) {
if (!confirm('Are you sure you want to remove this torrent? The downloaded files will be kept.')) {
return;
}
fetch(`/api/torrents/${torrentId}`, {
method: 'DELETE'
})
.then(response => {
if (!response.ok) {
throw new Error('Failed to remove torrent');
}
// Refresh torrents
loadTorrents();
})
.catch(error => {
console.error('Error removing torrent:', error);
alert('Error removing torrent');
});
}
function processTorrent(torrentId) {
fetch(`/api/torrents/${torrentId}/process`, {
method: 'POST'
})
.then(response => {
if (!response.ok) {
throw new Error('Failed to process torrent');
}
alert('Torrent processing started');
})
.catch(error => {
console.error('Error processing torrent:', error);
alert('Error processing torrent');
});
}
// Settings
function loadSettings() {
const form = document.getElementById('settings-form');
fetch('/api/config')
.then(response => response.json())
.then(config => {
// Transmission settings
document.getElementById('transmission-host').value = config.transmission.host;
document.getElementById('transmission-port').value = config.transmission.port;
document.getElementById('transmission-use-https').checked = config.transmission.useHttps;
document.getElementById('transmission-username').value = '';
document.getElementById('transmission-password').value = '';
// RSS settings
document.getElementById('auto-download-enabled').checked = config.autoDownloadEnabled;
document.getElementById('check-interval').value = config.checkIntervalMinutes;
// Directory settings
document.getElementById('download-directory').value = config.downloadDirectory;
document.getElementById('media-library').value = config.mediaLibraryPath;
// Post processing settings
document.getElementById('post-processing-enabled').checked = config.postProcessing.enabled;
document.getElementById('extract-archives').checked = config.postProcessing.extractArchives;
document.getElementById('organize-media').checked = config.postProcessing.organizeMedia;
document.getElementById('minimum-seed-ratio').value = config.postProcessing.minimumSeedRatio;
document.getElementById('media-extensions').value = config.postProcessing.mediaExtensions.join(', ');
})
.catch(error => {
console.error('Error loading settings:', error);
alert('Error loading settings');
});
}
function saveSettings(e) {
e.preventDefault();
const config = {
transmission: {
host: document.getElementById('transmission-host').value.trim(),
port: parseInt(document.getElementById('transmission-port').value),
useHttps: document.getElementById('transmission-use-https').checked,
username: document.getElementById('transmission-username').value.trim(),
password: document.getElementById('transmission-password').value.trim()
},
autoDownloadEnabled: document.getElementById('auto-download-enabled').checked,
checkIntervalMinutes: parseInt(document.getElementById('check-interval').value),
downloadDirectory: document.getElementById('download-directory').value.trim(),
mediaLibraryPath: document.getElementById('media-library').value.trim(),
postProcessing: {
enabled: document.getElementById('post-processing-enabled').checked,
extractArchives: document.getElementById('extract-archives').checked,
organizeMedia: document.getElementById('organize-media').checked,
minimumSeedRatio: parseInt(document.getElementById('minimum-seed-ratio').value),
mediaExtensions: document.getElementById('media-extensions').value.split(',').map(ext => ext.trim())
}
};
fetch('/api/config', {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(config)
})
.then(response => {
if (!response.ok) {
throw new Error('Failed to save settings');
}
alert('Settings saved successfully');
})
.catch(error => {
console.error('Error saving settings:', error);
alert('Error saving settings');
});
}
// Helper functions
function formatDate(date) {
if (!date) return 'N/A';
// Format as "YYYY-MM-DD HH:MM"
return `${date.getFullYear()}-${padZero(date.getMonth() + 1)}-${padZero(date.getDate())} ${padZero(date.getHours())}:${padZero(date.getMinutes())}`;
}
function padZero(num) {
return num.toString().padStart(2, '0');
}

48
temp_work/SUMMARY.md Normal file
View File

@ -0,0 +1,48 @@
# Changes for Moving Config File to /etc/transmission-rss-manager
## Overview
This update moves the configuration file from the application directory to `/etc/transmission-rss-manager/config.json` for better system organization, while maintaining backward compatibility by checking the original location as a fallback.
## Changes Made
### 1. Server.js Updates
- Changed default config location to `/etc/transmission-rss-manager/config.json`
- Added fallback location (`path.join(__dirname, 'config.json')`) for backward compatibility
- Enhanced `loadConfig()` function to try primary location first, then fallback location
- Updated `saveConfig()` function to try saving to primary location first, then fallback
- Added `installPath` property to configuration to store the application installation path for easier updates
### 2. Installer Updates
- Added `CONFIG_DIR="/etc/transmission-rss-manager"` variable to config-module.sh
- Updated `create_directories()` in dependencies-module.sh to create the config directory
- Updated permission settings to ensure proper access to config directory
- Added checks for the CONFIG_DIR variable to ensure it's set
- Modified service-setup-module.sh to include CONFIG_DIR in the environment variables for the systemd service
- Updated file-creator-module.sh to write the initial config.json to the new location
### 3. Documentation Updates
- Updated README.md to reflect new config file location
- Added entry to the changelog for version 2.0.3
- Updated code examples in README
- Bumped version from 2.0.2 to 2.0.3 in both README.md and package.json
## Technical Details
### Config File Loading Process
1. Try to load config from primary location (`/etc/transmission-rss-manager/config.json`)
2. If not found, try fallback location (`<install_dir>/config.json`)
3. If neither exists, create a new config file at primary location
4. If primary location is not writable, fall back to application directory
### Configuration Storage Logic
- Primary storage location: `/etc/transmission-rss-manager/config.json`
- Fallback location: `<install_dir>/config.json`
- Automatic migration from fallback to primary when possible
- Always try to write to primary first, then fallback if needed
## Benefits
- More standard Linux configuration location in /etc
- Easier to find and edit configuration
- Clear separation between code and configuration
- Maintains backward compatibility with existing installations
- Simplified update process by storing installation path

131
temp_work/app-update.js Normal file
View File

@ -0,0 +1,131 @@
// Client-side JavaScript for system status and updates
// Add this to the public/js/app.js file
// System status and updates functionality
function initSystemStatus() {
// Elements
const versionElement = document.getElementById('system-version');
const uptimeElement = document.getElementById('uptime');
const transmissionStatusElement = document.getElementById('transmission-status');
const updateStatusElement = document.getElementById('update-status');
const updateAvailableDiv = document.getElementById('update-available');
const updateButton = document.getElementById('btn-update-now');
const refreshButton = document.getElementById('btn-refresh-status');
// Load system status
function loadSystemStatus() {
fetch('/api/system/status')
.then(response => response.json())
.then(data => {
if (data.status === 'success') {
versionElement.textContent = data.data.version;
uptimeElement.textContent = data.data.uptime;
// Update transmission status with icon
if (data.data.transmissionStatus === 'Connected') {
transmissionStatusElement.innerHTML = '<i class="fas fa-check-circle text-success"></i> Connected';
} else {
transmissionStatusElement.innerHTML = '<i class="fas fa-times-circle text-danger"></i> Disconnected';
}
} else {
showToast('error', 'Failed to load system status');
}
})
.catch(error => {
console.error('Error fetching system status:', error);
showToast('error', 'Failed to connect to server');
});
}
// Check for updates
function checkForUpdates() {
updateStatusElement.innerHTML = '<i class="fas fa-circle-notch fa-spin"></i> Checking...';
updateAvailableDiv.classList.add('d-none');
fetch('/api/system/check-updates')
.then(response => response.json())
.then(data => {
if (data.status === 'success') {
if (data.data.updateAvailable) {
updateStatusElement.innerHTML = '<i class="fas fa-exclamation-circle text-warning"></i> Update available';
updateAvailableDiv.classList.remove('d-none');
updateAvailableDiv.querySelector('span').textContent =
`A new version is available: ${data.data.currentVersion}${data.data.remoteVersion}`;
} else {
updateStatusElement.innerHTML = '<i class="fas fa-check-circle text-success"></i> Up to date';
}
} else {
updateStatusElement.innerHTML = '<i class="fas fa-times-circle text-danger"></i> Check failed';
showToast('error', data.message || 'Failed to check for updates');
}
})
.catch(error => {
console.error('Error checking for updates:', error);
updateStatusElement.innerHTML = '<i class="fas fa-times-circle text-danger"></i> Check failed';
showToast('error', 'Failed to connect to server');
});
}
// Apply update
function applyUpdate() {
// Show confirmation dialog
if (!confirm('Are you sure you want to update the application? The service will restart.')) {
return;
}
// Show loading state
updateButton.disabled = true;
updateButton.innerHTML = '<i class="fas fa-circle-notch fa-spin"></i> Updating...';
showToast('info', 'Applying update. Please wait...');
fetch('/api/system/update', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
})
.then(response => response.json())
.then(data => {
if (data.status === 'success') {
showToast('success', 'Update applied successfully. The page will reload in 30 seconds.');
// Set a timer to reload the page after the service has time to restart
setTimeout(() => {
window.location.reload();
}, 30000);
} else {
updateButton.disabled = false;
updateButton.innerHTML = '<i class="fas fa-download"></i> Update Now';
showToast('error', data.message || 'Failed to apply update');
}
})
.catch(error => {
console.error('Error applying update:', error);
updateButton.disabled = false;
updateButton.innerHTML = '<i class="fas fa-download"></i> Update Now';
showToast('error', 'Failed to connect to server');
});
}
// Event listeners
if (refreshButton) {
refreshButton.addEventListener('click', () => {
loadSystemStatus();
checkForUpdates();
});
}
if (updateButton) {
updateButton.addEventListener('click', applyUpdate);
}
// Initialize
loadSystemStatus();
checkForUpdates();
// Set interval to refresh uptime every minute
setInterval(loadSystemStatus, 60000);
}
// Call this function from the main init function
// Add this line to your document ready or DOMContentLoaded handler:
// initSystemStatus();

View File

@ -0,0 +1,72 @@
#!/bin/bash
# Transmission RSS Manager - Bootstrap Installer
# This script downloads the latest version from git and runs the setup
# Color and formatting
GREEN='\033[0;32m'
RED='\033[0;31m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
BOLD='\033[1m'
# Installation directory
INSTALL_DIR="/opt/trans-install"
REPO_URL="https://git.powerdata.dk/masterdraco/transmission-rss-manager.git"
# Check if running as root
if [ "$EUID" -ne 0 ]; then
echo -e "${RED}This script must be run as root or with sudo privileges.${NC}"
exit 1
fi
# Display welcome message
echo -e "${GREEN}${BOLD}Transmission RSS Manager - Bootstrap Installer${NC}"
echo -e "This script will install the latest version from the git repository."
echo
# Check for git installation
echo -e "${YELLOW}Checking dependencies...${NC}"
if ! command -v git &> /dev/null; then
echo -e "Git not found. Installing git..."
apt-get update
apt-get install -y git
fi
# Check if installation directory exists
if [ -d "$INSTALL_DIR" ]; then
echo -e "${YELLOW}Installation directory already exists.${NC}"
read -p "Do you want to remove it and perform a fresh install? (y/n): " choice
if [[ "$choice" =~ ^[Yy]$ ]]; then
echo "Removing existing installation..."
rm -rf "$INSTALL_DIR"
else
echo -e "${RED}Installation aborted.${NC}"
exit 1
fi
fi
# Create installation directory
echo -e "${YELLOW}Creating installation directory...${NC}"
mkdir -p "$INSTALL_DIR"
# Clone the repository
echo -e "${YELLOW}Cloning the latest version from git...${NC}"
git clone "$REPO_URL" "$INSTALL_DIR"
if [ $? -ne 0 ]; then
echo -e "${RED}Failed to clone the repository.${NC}"
exit 1
fi
# Run the main installer
echo -e "${YELLOW}Running the main installer...${NC}"
cd "$INSTALL_DIR"
chmod +x main-installer.sh
./main-installer.sh
# Installation complete
echo -e "${GREEN}${BOLD}Bootstrap installation complete!${NC}"
echo -e "Transmission RSS Manager has been installed in $INSTALL_DIR"
echo -e "You can access the web interface at http://localhost:3000"
echo
echo -e "To update in the future, use the update button in the System Status section of the web interface."

View File

@ -0,0 +1,374 @@
#!/bin/bash
# Configuration module for Transmission RSS Manager Installation
# Configuration variables with defaults
INSTALL_DIR="/opt/transmission-rss-manager"
SERVICE_NAME="transmission-rss-manager"
PORT=3000
# Get default user safely - avoid using root
get_default_user() {
local default_user
# Try logname first to get the user who invoked sudo
if command -v logname &> /dev/null; then
default_user=$(logname 2>/dev/null)
fi
# If logname failed, try SUDO_USER
if [ -z "$default_user" ] && [ -n "$SUDO_USER" ]; then
default_user="$SUDO_USER"
fi
# Fallback to current user if both methods failed
if [ -z "$default_user" ]; then
default_user="$(whoami)"
fi
# Ensure the user is not root
if [ "$default_user" = "root" ]; then
echo "nobody"
else
echo "$default_user"
fi
}
# Initialize default user
USER=$(get_default_user)
# Transmission configuration variables
TRANSMISSION_REMOTE=false
TRANSMISSION_HOST="localhost"
TRANSMISSION_PORT=9091
TRANSMISSION_USER=""
TRANSMISSION_PASS=""
TRANSMISSION_RPC_PATH="/transmission/rpc"
TRANSMISSION_DOWNLOAD_DIR="/var/lib/transmission-daemon/downloads"
TRANSMISSION_DIR_MAPPING="{}"
# Media path defaults
MEDIA_DIR="/mnt/media"
ENABLE_BOOK_SORTING=true
# Helper function to validate port number
validate_port() {
local port="$1"
if [[ "$port" =~ ^[0-9]+$ ]] && [ "$port" -ge 1 ] && [ "$port" -le 65535 ]; then
return 0
else
return 1
fi
}
# Helper function to validate URL hostname
validate_hostname() {
local hostname="$1"
if [[ "$hostname" =~ ^[a-zA-Z0-9]([a-zA-Z0-9\-\.]+[a-zA-Z0-9])?$ ]]; then
return 0
elif [[ "$hostname" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
return 0
else
return 1
fi
}
function gather_configuration() {
log "INFO" "Starting configuration gathering"
echo -e "${BOLD}Installation Configuration:${NC}"
echo -e "Please provide the following configuration parameters:"
echo
read -p "Installation directory [$INSTALL_DIR]: " input_install_dir
if [ -n "$input_install_dir" ]; then
# Validate installation directory
if [[ ! "$input_install_dir" =~ ^/ ]]; then
log "WARN" "Installation directory must be an absolute path. Using default."
else
INSTALL_DIR="$input_install_dir"
fi
fi
# Using fixed port 3000 to avoid permission issues with ports below 1024
log "INFO" "Using port 3000 for the web interface"
log "INFO" "This is to avoid permission issues with ports below 1024 (like port 80)"
PORT=3000
# Get user
read -p "Run as user [$USER]: " input_user
if [ -n "$input_user" ]; then
# Check if user exists
if id "$input_user" &>/dev/null; then
USER="$input_user"
else
log "WARN" "User $input_user does not exist. Using $USER instead."
fi
fi
echo
echo -e "${BOLD}Transmission Configuration:${NC}"
echo -e "Configure connection to your Transmission client:"
echo
# Ask if Transmission is remote
read -p "Is Transmission running on a remote server? (y/n) [n]: " input_remote
if [[ $input_remote =~ ^[Yy]$ ]]; then
TRANSMISSION_REMOTE=true
# Get and validate hostname
while true; do
read -p "Remote Transmission host [localhost]: " input_trans_host
if [ -z "$input_trans_host" ]; then
break
elif validate_hostname "$input_trans_host"; then
TRANSMISSION_HOST="$input_trans_host"
break
else
log "WARN" "Invalid hostname format."
fi
done
# Get and validate port
while true; do
read -p "Remote Transmission port [9091]: " input_trans_port
if [ -z "$input_trans_port" ]; then
break
elif validate_port "$input_trans_port"; then
TRANSMISSION_PORT="$input_trans_port"
break
else
log "WARN" "Invalid port number. Port must be between 1 and 65535."
fi
done
# Get credentials
read -p "Remote Transmission username []: " input_trans_user
TRANSMISSION_USER=${input_trans_user:-$TRANSMISSION_USER}
# Use read -s for password to avoid showing it on screen
read -s -p "Remote Transmission password []: " input_trans_pass
echo # Add a newline after the password input
if [ -n "$input_trans_pass" ]; then
# TODO: In a production environment, consider encrypting this password
TRANSMISSION_PASS="$input_trans_pass"
fi
read -p "Remote Transmission RPC path [/transmission/rpc]: " input_trans_path
if [ -n "$input_trans_path" ]; then
# Ensure path starts with / for consistency
if [[ ! "$input_trans_path" =~ ^/ ]]; then
input_trans_path="/$input_trans_path"
fi
TRANSMISSION_RPC_PATH="$input_trans_path"
fi
# Configure directory mapping for remote setup
echo
echo -e "${YELLOW}Directory Mapping Configuration${NC}"
echo -e "When using a remote Transmission server, you need to map paths between servers."
echo -e "For each directory on the remote server, specify the corresponding local directory."
echo
# Get remote download directory
read -p "Remote Transmission download directory: " REMOTE_DOWNLOAD_DIR
REMOTE_DOWNLOAD_DIR=${REMOTE_DOWNLOAD_DIR:-"/var/lib/transmission-daemon/downloads"}
# Get local directory that corresponds to remote download directory
read -p "Local directory that corresponds to the remote download directory: " LOCAL_DOWNLOAD_DIR
LOCAL_DOWNLOAD_DIR=${LOCAL_DOWNLOAD_DIR:-"/mnt/transmission-downloads"}
# Create mapping JSON - use proper JSON escaping for directory paths
REMOTE_DOWNLOAD_DIR_ESCAPED=$(echo "$REMOTE_DOWNLOAD_DIR" | sed 's/\\/\\\\/g' | sed 's/"/\\"/g')
LOCAL_DOWNLOAD_DIR_ESCAPED=$(echo "$LOCAL_DOWNLOAD_DIR" | sed 's/\\/\\\\/g' | sed 's/"/\\"/g')
TRANSMISSION_DIR_MAPPING="{\"$REMOTE_DOWNLOAD_DIR_ESCAPED\": \"$LOCAL_DOWNLOAD_DIR_ESCAPED\"}"
# Create the local directory
if ! mkdir -p "$LOCAL_DOWNLOAD_DIR"; then
log "ERROR" "Failed to create local download directory: $LOCAL_DOWNLOAD_DIR"
else
if ! chown -R "$USER:$USER" "$LOCAL_DOWNLOAD_DIR"; then
log "ERROR" "Failed to set permissions on local download directory: $LOCAL_DOWNLOAD_DIR"
fi
fi
# Ask if want to add more mappings
while true; do
read -p "Add another directory mapping? (y/n) [n]: " add_another
if [[ ! $add_another =~ ^[Yy]$ ]]; then
break
fi
read -p "Remote directory path: " remote_dir
read -p "Corresponding local directory path: " local_dir
if [ -n "$remote_dir" ] && [ -n "$local_dir" ]; then
# Escape directory paths for JSON
remote_dir_escaped=$(echo "$remote_dir" | sed 's/\\/\\\\/g' | sed 's/"/\\"/g')
local_dir_escaped=$(echo "$local_dir" | sed 's/\\/\\\\/g' | sed 's/"/\\"/g')
# Update mapping JSON (proper JSON manipulation)
# Remove the closing brace, add a comma and the new mapping, then close with brace
TRANSMISSION_DIR_MAPPING="${TRANSMISSION_DIR_MAPPING%\}}, \"$remote_dir_escaped\": \"$local_dir_escaped\"}"
# Create the local directory
if ! mkdir -p "$local_dir"; then
log "ERROR" "Failed to create directory: $local_dir"
else
if ! chown -R "$USER:$USER" "$local_dir"; then
log "WARN" "Failed to set permissions on directory: $local_dir"
fi
log "INFO" "Mapping added: $remote_dir$local_dir"
fi
fi
done
# Set Transmission download dir for configuration
TRANSMISSION_DOWNLOAD_DIR=$REMOTE_DOWNLOAD_DIR
else
# Local Transmission selected
echo -e "${YELLOW}You've selected to use a local Transmission installation.${NC}"
# Check if Transmission is already installed
if command -v transmission-daemon &> /dev/null; then
echo -e "${GREEN}Transmission is already installed on this system.${NC}"
else
echo -e "${YELLOW}NOTE: Transmission does not appear to be installed on this system.${NC}"
echo -e "${YELLOW}You will be prompted to install it during the dependency installation step.${NC}"
fi
# Get and validate port
while true; do
read -p "Local Transmission port [9091]: " input_trans_port
if [ -z "$input_trans_port" ]; then
break
elif validate_port "$input_trans_port"; then
TRANSMISSION_PORT="$input_trans_port"
break
else
log "WARN" "Invalid port number. Port must be between 1 and 65535."
fi
done
# Get credentials if any
read -p "Local Transmission username (leave empty if authentication is disabled) []: " input_trans_user
TRANSMISSION_USER=${input_trans_user:-$TRANSMISSION_USER}
if [ -n "$input_trans_user" ]; then
# Use read -s for password to hide it
read -s -p "Local Transmission password []: " input_trans_pass
echo # Add a newline after the password input
if [ -n "$input_trans_pass" ]; then
TRANSMISSION_PASS="$input_trans_pass"
fi
fi
read -p "Transmission download directory [/var/lib/transmission-daemon/downloads]: " input_trans_dir
if [ -n "$input_trans_dir" ]; then
if [[ ! "$input_trans_dir" =~ ^/ ]]; then
log "WARN" "Download directory must be an absolute path. Using default."
else
TRANSMISSION_DOWNLOAD_DIR="$input_trans_dir"
fi
fi
fi
echo
echo -e "${BOLD}Media Destination Configuration:${NC}"
read -p "Media destination base directory [/mnt/media]: " input_media_dir
if [ -n "$input_media_dir" ]; then
if [[ ! "$input_media_dir" =~ ^/ ]]; then
log "WARN" "Media directory must be an absolute path. Using default."
else
MEDIA_DIR="$input_media_dir"
fi
fi
# Ask about enabling book/magazine sorting
echo
echo -e "${BOLD}Content Type Configuration:${NC}"
read -p "Enable book and magazine sorting? (y/n) [y]: " input_book_sorting
if [[ $input_book_sorting =~ ^[Nn]$ ]]; then
ENABLE_BOOK_SORTING=false
else
ENABLE_BOOK_SORTING=true
fi
# Security configuration
echo
echo -e "${BOLD}Security Configuration:${NC}"
# Ask about enabling authentication
read -p "Enable authentication? (y/n) [n]: " input_auth_enabled
AUTH_ENABLED=false
ADMIN_USERNAME=""
ADMIN_PASSWORD=""
if [[ $input_auth_enabled =~ ^[Yy]$ ]]; then
AUTH_ENABLED=true
# Get admin username and password
read -p "Admin username [admin]: " input_admin_username
ADMIN_USERNAME=${input_admin_username:-"admin"}
# Use read -s for password to avoid showing it on screen
read -s -p "Admin password: " input_admin_password
echo # Add a newline after the password input
if [ -z "$input_admin_password" ]; then
# Generate a random password if none provided
ADMIN_PASSWORD=$(openssl rand -base64 12)
echo -e "${YELLOW}Generated random admin password: $ADMIN_PASSWORD${NC}"
echo -e "${YELLOW}Please save this password somewhere safe!${NC}"
else
ADMIN_PASSWORD="$input_admin_password"
fi
fi
# Ask about enabling HTTPS
read -p "Enable HTTPS? (requires SSL certificate) (y/n) [n]: " input_https_enabled
HTTPS_ENABLED=false
SSL_CERT_PATH=""
SSL_KEY_PATH=""
if [[ $input_https_enabled =~ ^[Yy]$ ]]; then
HTTPS_ENABLED=true
# Get SSL certificate paths
read -p "SSL certificate path: " input_ssl_cert_path
if [ -n "$input_ssl_cert_path" ]; then
# Check if file exists
if [ -f "$input_ssl_cert_path" ]; then
SSL_CERT_PATH="$input_ssl_cert_path"
else
log "WARN" "SSL certificate file not found. HTTPS will be disabled."
HTTPS_ENABLED=false
fi
else
log "WARN" "SSL certificate path not provided. HTTPS will be disabled."
HTTPS_ENABLED=false
fi
# Only ask for key if cert was found
if [ "$HTTPS_ENABLED" = true ]; then
read -p "SSL key path: " input_ssl_key_path
if [ -n "$input_ssl_key_path" ]; then
# Check if file exists
if [ -f "$input_ssl_key_path" ]; then
SSL_KEY_PATH="$input_ssl_key_path"
else
log "WARN" "SSL key file not found. HTTPS will be disabled."
HTTPS_ENABLED=false
fi
else
log "WARN" "SSL key path not provided. HTTPS will be disabled."
HTTPS_ENABLED=false
fi
fi
fi
echo
log "INFO" "Configuration gathering complete"
echo -e "${GREEN}Configuration complete!${NC}"
echo
}

View File

@ -0,0 +1,184 @@
#!/bin/bash
# Dependencies module for Transmission RSS Manager Installation
function install_dependencies() {
log "INFO" "Installing dependencies..."
# Check for package manager
if command -v apt-get &> /dev/null; then
# Update package index
apt-get update
# Install Node.js and npm if not already installed
if ! command_exists node; then
log "INFO" "Installing Node.js and npm..."
apt-get install -y ca-certificates curl gnupg
mkdir -p /etc/apt/keyrings
# Check if download succeeds
if ! curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg; then
log "ERROR" "Failed to download Node.js GPG key"
exit 1
fi
echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_18.x nodistro main" > /etc/apt/sources.list.d/nodesource.list
# Update again after adding repo
apt-get update
# Install nodejs
if ! apt-get install -y nodejs; then
log "ERROR" "Failed to install Node.js"
exit 1
fi
else
log "INFO" "Node.js is already installed."
fi
# Check if we need to install Transmission (only if local transmission was selected and not remote)
if [ "$TRANSMISSION_REMOTE" = false ] && ([ "$TRANSMISSION_HOST" = "localhost" ] || [ "$TRANSMISSION_HOST" = "127.0.0.1" ]); then
if ! command_exists transmission-daemon; then
log "INFO" "Local Transmission installation selected, but transmission-daemon is not installed."
read -p "Would you like to install Transmission now? (y/n): " install_transmission
if [[ "$install_transmission" =~ ^[Yy]$ ]]; then
log "INFO" "Installing Transmission..."
if ! apt-get install -y transmission-daemon; then
log "ERROR" "Failed to install Transmission"
log "WARN" "You will need to install Transmission manually before using this application."
else
# Stop transmission-daemon to allow configuration changes
systemctl stop transmission-daemon
# Set default settings
TRANSMISSION_SETTINGS_DIR="/var/lib/transmission-daemon/info"
if [ -f "$TRANSMISSION_SETTINGS_DIR/settings.json" ]; then
# Backup original settings
cp "$TRANSMISSION_SETTINGS_DIR/settings.json" "$TRANSMISSION_SETTINGS_DIR/settings.json.bak"
# Update RPC settings to allow our app to connect
sed -i 's/"rpc-authentication-required": true,/"rpc-authentication-required": false,/g' "$TRANSMISSION_SETTINGS_DIR/settings.json"
sed -i 's/"rpc-whitelist-enabled": true,/"rpc-whitelist-enabled": false,/g' "$TRANSMISSION_SETTINGS_DIR/settings.json"
log "INFO" "Transmission has been configured for local access."
else
log "WARN" "Could not find Transmission settings file. You may need to configure Transmission manually."
fi
# Start transmission-daemon
systemctl start transmission-daemon
log "INFO" "Transmission has been installed and started."
fi
else
log "WARN" "Transmission installation skipped. You will need to install it manually."
fi
else
log "INFO" "Transmission is already installed."
fi
fi
# Install additional dependencies
log "INFO" "Installing additional dependencies..."
# Try to install unrar-free if unrar is not available
if ! apt-get install -y unrar 2>/dev/null; then
log "INFO" "unrar not available, trying unrar-free instead..."
apt-get install -y unrar-free
fi
# Install other dependencies
apt-get install -y unzip p7zip-full
# Try to install nginx
apt-get install -y nginx || log "WARN" "Nginx installation failed, web interface may not be accessible"
else
log "ERROR" "This installer requires apt-get package manager"
log "INFO" "Please install the following dependencies manually:"
log "INFO" "- Node.js (v18.x)"
log "INFO" "- npm"
log "INFO" "- unrar"
log "INFO" "- unzip"
log "INFO" "- p7zip-full"
log "INFO" "- nginx"
if [ "$TRANSMISSION_REMOTE" = false ] && ([ "$TRANSMISSION_HOST" = "localhost" ] || [ "$TRANSMISSION_HOST" = "127.0.0.1" ]); then
log "INFO" "- transmission-daemon"
fi
exit 1
fi
# Check if all dependencies were installed successfully
local dependencies=("node" "npm" "unzip" "nginx")
local missing_deps=()
# Add transmission to dependencies check if local installation was selected and not remote
if [ "$TRANSMISSION_REMOTE" = false ] && ([ "$TRANSMISSION_HOST" = "localhost" ] || [ "$TRANSMISSION_HOST" = "127.0.0.1" ]); then
dependencies+=("transmission-daemon")
fi
for dep in "${dependencies[@]}"; do
if ! command_exists "$dep"; then
missing_deps+=("$dep")
fi
done
# Check for either unrar or unrar-free
if ! command_exists "unrar" && ! command_exists "unrar-free"; then
missing_deps+=("unrar")
fi
# Check for either 7z or 7za (from p7zip-full)
if ! command_exists "7z" && ! command_exists "7za"; then
missing_deps+=("p7zip")
fi
if [ ${#missing_deps[@]} -eq 0 ]; then
log "INFO" "All dependencies installed successfully."
else
log "ERROR" "Failed to install some dependencies: ${missing_deps[*]}"
log "WARN" "Please install them manually and rerun this script."
# More helpful information based on which deps are missing
if [[ " ${missing_deps[*]} " =~ " node " ]]; then
log "INFO" "To install Node.js manually, visit: https://nodejs.org/en/download/"
fi
if [[ " ${missing_deps[*]} " =~ " nginx " ]]; then
log "INFO" "To install nginx manually: sudo apt-get install nginx"
fi
if [[ " ${missing_deps[*]} " =~ " transmission-daemon " ]]; then
log "INFO" "To install Transmission manually: sudo apt-get install transmission-daemon"
log "INFO" "After installation, you may need to configure it by editing /var/lib/transmission-daemon/info/settings.json"
fi
exit 1
fi
}
function create_directories() {
log "INFO" "Creating installation directories..."
# Check if INSTALL_DIR is defined
if [ -z "$INSTALL_DIR" ]; then
log "ERROR" "INSTALL_DIR is not defined"
exit 1
fi
# Create directories and check for errors
DIRECTORIES=(
"$INSTALL_DIR"
"$INSTALL_DIR/logs"
"$INSTALL_DIR/public/js"
"$INSTALL_DIR/public/css"
"$INSTALL_DIR/modules"
"$INSTALL_DIR/data"
)
for dir in "${DIRECTORIES[@]}"; do
if ! mkdir -p "$dir"; then
log "ERROR" "Failed to create directory: $dir"
exit 1
fi
done
log "INFO" "Directories created successfully."
}

View File

@ -0,0 +1,241 @@
#!/bin/bash
# Transmission RSS Manager Modular Installer
# Modified to work with the git-based approach
# Set script to exit on error
set -e
# Text formatting
BOLD='\033[1m'
GREEN='\033[0;32m'
YELLOW='\033[0;33m'
RED='\033[0;31m'
NC='\033[0m' # No Color
# Print header
echo -e "${BOLD}==================================================${NC}"
echo -e "${BOLD} Transmission RSS Manager Installer ${NC}"
echo -e "${BOLD} Version 1.2.0 - Git Edition ${NC}"
echo -e "${BOLD}==================================================${NC}"
echo
# Check if script is run with sudo
if [ "$EUID" -ne 0 ]; then
echo -e "${RED}Please run as root (use sudo)${NC}"
exit 1
fi
# Get current directory
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )"
# Check for installation type
IS_UPDATE=false
if [ -f "${SCRIPT_DIR}/config.json" ]; then
IS_UPDATE=true
echo -e "${YELLOW}Existing installation detected. Running in update mode.${NC}"
echo -e "${GREEN}Your existing configuration will be preserved.${NC}"
else
echo -e "${GREEN}Fresh installation. Will create new configuration.${NC}"
fi
export IS_UPDATE
# Check if required module files exist
REQUIRED_MODULES=(
"${SCRIPT_DIR}/modules/config-module.sh"
"${SCRIPT_DIR}/modules/utils-module.sh"
"${SCRIPT_DIR}/modules/dependencies-module.sh"
"${SCRIPT_DIR}/modules/service-setup-module.sh"
)
for module in "${REQUIRED_MODULES[@]}"; do
if [ ! -f "$module" ]; then
echo -e "${RED}Error: Required module file not found: $module${NC}"
echo -e "${YELLOW}The module files should be included in the git repository.${NC}"
exit 1
fi
done
# Source the module files
source "${SCRIPT_DIR}/modules/utils-module.sh" # Load utilities first for logging
source "${SCRIPT_DIR}/modules/config-module.sh"
source "${SCRIPT_DIR}/modules/dependencies-module.sh"
source "${SCRIPT_DIR}/modules/service-setup-module.sh"
# Function to handle cleanup on error
function cleanup_on_error() {
log "ERROR" "Installation failed: $1"
log "INFO" "Cleaning up..."
# Add any cleanup steps here if needed
log "INFO" "You can try running the installer again after fixing the issues."
exit 1
}
# Set trap for error handling
trap 'cleanup_on_error "$BASH_COMMAND"' ERR
# Execute the installation steps in sequence
log "INFO" "Starting installation process..."
# Step 1: Gather configuration from user
log "INFO" "Gathering configuration..."
gather_configuration || {
log "ERROR" "Configuration gathering failed"
exit 1
}
# Step 2: Install dependencies
log "INFO" "Installing dependencies..."
install_dependencies || {
log "ERROR" "Dependency installation failed"
exit 1
}
# Step 3: Create installation directories
log "INFO" "Creating directories..."
create_directories || {
log "ERROR" "Directory creation failed"
exit 1
}
# Step 4: Create configuration files only (no application files since they're from git)
log "INFO" "Creating configuration files..."
create_config_files || {
log "ERROR" "Configuration file creation failed"
exit 1
}
# Step 5: Create service files and install the service
log "INFO" "Setting up service..."
setup_service || {
log "ERROR" "Service setup failed"
exit 1
}
# Step 6: Install npm dependencies
log "INFO" "Installing npm dependencies..."
cd "$SCRIPT_DIR"
npm install || {
log "ERROR" "NPM installation failed"
exit 1
}
# Step 7: Set up update script
log "INFO" "Setting up update script..."
mkdir -p "${SCRIPT_DIR}/scripts"
cp "${SCRIPT_DIR}/scripts/update.sh" "${SCRIPT_DIR}/scripts/update.sh" 2>/dev/null || {
# If copy fails, it probably doesn't exist, so we'll create it
cat > "${SCRIPT_DIR}/scripts/update.sh" << 'EOL'
#!/bin/bash
# Transmission RSS Manager - Update Script
# This script pulls the latest version from git and runs necessary updates
# Color and formatting
GREEN='\033[0;32m'
RED='\033[0;31m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
BOLD='\033[1m'
# Installation directory (should be current directory)
INSTALL_DIR=$(pwd)
# Check if we're in the right directory
if [ ! -f "$INSTALL_DIR/package.json" ] || [ ! -d "$INSTALL_DIR/modules" ]; then
echo -e "${RED}Error: This script must be run from the installation directory.${NC}"
exit 1
fi
# Get the current version
CURRENT_VERSION=$(grep -oP '"version": "\K[^"]+' package.json)
echo -e "${YELLOW}Current version: ${BOLD}$CURRENT_VERSION${NC}"
# Check for git repository
if [ ! -d ".git" ]; then
echo -e "${RED}Error: This installation was not set up using git.${NC}"
echo -e "Please use the bootstrap installer to perform a fresh installation."
exit 1
fi
# Stash any local changes
echo -e "${YELLOW}Backing up any local configuration changes...${NC}"
git stash -q
# Pull the latest changes
echo -e "${YELLOW}Pulling latest updates from git...${NC}"
git pull
if [ $? -ne 0 ]; then
echo -e "${RED}Failed to pull updates. Restoring original state...${NC}"
git stash pop -q
exit 1
fi
# Get the new version
NEW_VERSION=$(grep -oP '"version": "\K[^"]+' package.json)
echo -e "${GREEN}New version: ${BOLD}$NEW_VERSION${NC}"
# Check if update is needed
if [ "$CURRENT_VERSION" == "$NEW_VERSION" ]; then
echo -e "${GREEN}You already have the latest version.${NC}"
exit 0
fi
# Install any new npm dependencies
echo -e "${YELLOW}Installing dependencies...${NC}"
npm install
# Apply any local configuration changes
if git stash list | grep -q "stash@{0}"; then
echo -e "${YELLOW}Restoring local configuration changes...${NC}"
git stash pop -q
# Handle conflicts if any
if [ $? -ne 0 ]; then
echo -e "${RED}There were conflicts when restoring your configuration.${NC}"
echo -e "Please check the files and resolve conflicts manually."
echo -e "Your original configuration is saved in .git/refs/stash"
fi
fi
# Restart the service
echo -e "${YELLOW}Restarting service...${NC}"
if command -v systemctl &> /dev/null; then
sudo systemctl restart transmission-rss-manager
else
echo -e "${RED}Could not restart service automatically.${NC}"
echo -e "Please restart the service manually."
fi
# Update complete
echo -e "${GREEN}${BOLD}Update complete!${NC}"
echo -e "Updated from version $CURRENT_VERSION to $NEW_VERSION"
echo -e "Changes will take effect immediately."
EOL
chmod +x "${SCRIPT_DIR}/scripts/update.sh"
}
# Step 8: Final setup and permissions
log "INFO" "Finalizing setup..."
finalize_setup || {
log "ERROR" "Setup finalization failed"
exit 1
}
# Installation complete
echo
echo -e "${BOLD}${GREEN}==================================================${NC}"
echo -e "${BOLD}${GREEN} Installation Complete! ${NC}"
echo -e "${BOLD}${GREEN}==================================================${NC}"
echo -e "You can access the web interface at: ${BOLD}http://localhost:$PORT${NC} or ${BOLD}http://your-server-ip:$PORT${NC}"
echo -e "You may need to configure your firewall to allow access to port $PORT"
echo
echo -e "${BOLD}Useful Commands:${NC}"
echo -e " To check the service status: ${YELLOW}systemctl status $SERVICE_NAME${NC}"
echo -e " To view logs: ${YELLOW}journalctl -u $SERVICE_NAME${NC}"
echo -e " To restart the service: ${YELLOW}systemctl restart $SERVICE_NAME${NC}"
echo -e " To update the application: ${YELLOW}Use the Update button in the System Status section${NC}"
echo
echo -e "Thank you for installing Transmission RSS Manager!"
echo -e "${BOLD}==================================================${NC}"

View File

@ -0,0 +1,84 @@
#!/bin/bash
# Transmission RSS Manager - Update Script
# This script pulls the latest version from git and runs necessary updates
# Color and formatting
GREEN='\033[0;32m'
RED='\033[0;31m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
BOLD='\033[1m'
# Installation directory (should be current directory)
INSTALL_DIR=$(pwd)
# Check if we're in the right directory
if [ ! -f "$INSTALL_DIR/package.json" ] || [ ! -d "$INSTALL_DIR/modules" ]; then
echo -e "${RED}Error: This script must be run from the installation directory.${NC}"
exit 1
fi
# Get the current version
CURRENT_VERSION=$(grep -oP '"version": "\K[^"]+' package.json)
echo -e "${YELLOW}Current version: ${BOLD}$CURRENT_VERSION${NC}"
# Check for git repository
if [ ! -d ".git" ]; then
echo -e "${RED}Error: This installation was not set up using git.${NC}"
echo -e "Please use the bootstrap installer to perform a fresh installation."
exit 1
fi
# Stash any local changes
echo -e "${YELLOW}Backing up any local configuration changes...${NC}"
git stash -q
# Pull the latest changes
echo -e "${YELLOW}Pulling latest updates from git...${NC}"
git pull
if [ $? -ne 0 ]; then
echo -e "${RED}Failed to pull updates. Restoring original state...${NC}"
git stash pop -q
exit 1
fi
# Get the new version
NEW_VERSION=$(grep -oP '"version": "\K[^"]+' package.json)
echo -e "${GREEN}New version: ${BOLD}$NEW_VERSION${NC}"
# Check if update is needed
if [ "$CURRENT_VERSION" == "$NEW_VERSION" ]; then
echo -e "${GREEN}You already have the latest version.${NC}"
exit 0
fi
# Install any new npm dependencies
echo -e "${YELLOW}Installing dependencies...${NC}"
npm install
# Apply any local configuration changes
if git stash list | grep -q "stash@{0}"; then
echo -e "${YELLOW}Restoring local configuration changes...${NC}"
git stash pop -q
# Handle conflicts if any
if [ $? -ne 0 ]; then
echo -e "${RED}There were conflicts when restoring your configuration.${NC}"
echo -e "Please check the files and resolve conflicts manually."
echo -e "Your original configuration is saved in .git/refs/stash"
fi
fi
# Restart the service
echo -e "${YELLOW}Restarting service...${NC}"
if command -v systemctl &> /dev/null; then
sudo systemctl restart transmission-rss-manager
else
echo -e "${RED}Could not restart service automatically.${NC}"
echo -e "Please restart the service manually."
fi
# Update complete
echo -e "${GREEN}${BOLD}Update complete!${NC}"
echo -e "Updated from version $CURRENT_VERSION to $NEW_VERSION"
echo -e "Changes will take effect immediately."

View File

@ -0,0 +1,146 @@
// Version and update endpoints to be added to server.js
// Add these imports at the top of server.js
const { exec } = require('child_process');
const { promisify } = require('util');
const execAsync = promisify(exec);
const fs = require('fs');
const path = require('path');
// Add these endpoints
// Get system status including version and uptime
app.get('/api/system/status', async (req, res) => {
try {
// Get package.json for version info
const packageJson = JSON.parse(fs.readFileSync(path.join(__dirname, 'package.json'), 'utf8'));
const version = packageJson.version;
// Get system uptime
const uptimeSeconds = Math.floor(process.uptime());
const hours = Math.floor(uptimeSeconds / 3600);
const minutes = Math.floor((uptimeSeconds % 3600) / 60);
const seconds = uptimeSeconds % 60;
const uptime = `${hours}h ${minutes}m ${seconds}s`;
// Check transmission connection
let transmissionStatus = 'Connected';
try {
await transmissionClient.sessionGet();
} catch (err) {
transmissionStatus = 'Disconnected';
}
res.json({
status: 'success',
data: {
version,
uptime,
transmissionStatus
}
});
} catch (error) {
console.error('Error getting system status:', error);
res.status(500).json({
status: 'error',
message: 'Failed to get system status'
});
}
});
// Check for updates
app.get('/api/system/check-updates', async (req, res) => {
try {
// Check if git is available and if this is a git repository
const isGitRepo = fs.existsSync(path.join(__dirname, '.git'));
if (!isGitRepo) {
return res.json({
status: 'error',
message: 'This installation is not set up for updates. Please use the bootstrap installer.'
});
}
// Get current version
const packageJson = JSON.parse(fs.readFileSync(path.join(__dirname, 'package.json'), 'utf8'));
const currentVersion = packageJson.version;
// Fetch latest updates without applying them
await execAsync('git fetch');
// Check if we're behind the remote repository
const { stdout } = await execAsync('git rev-list HEAD..origin/main --count');
const behindCount = parseInt(stdout.trim());
if (behindCount > 0) {
// Get the new version from the remote package.json
const { stdout: remotePackageJson } = await execAsync('git show origin/main:package.json');
const remotePackage = JSON.parse(remotePackageJson);
const remoteVersion = remotePackage.version;
return res.json({
status: 'success',
data: {
updateAvailable: true,
currentVersion,
remoteVersion,
commitsBehind: behindCount
}
});
} else {
return res.json({
status: 'success',
data: {
updateAvailable: false,
currentVersion
}
});
}
} catch (error) {
console.error('Error checking for updates:', error);
res.status(500).json({
status: 'error',
message: 'Failed to check for updates'
});
}
});
// Apply updates
app.post('/api/system/update', async (req, res) => {
try {
// Check if git is available and if this is a git repository
const isGitRepo = fs.existsSync(path.join(__dirname, '.git'));
if (!isGitRepo) {
return res.status(400).json({
status: 'error',
message: 'This installation is not set up for updates. Please use the bootstrap installer.'
});
}
// Run the update script
const updateScriptPath = path.join(__dirname, 'scripts', 'update.sh');
// Make sure the update script is executable
await execAsync(`chmod +x ${updateScriptPath}`);
// Execute the update script
const { stdout, stderr } = await execAsync(updateScriptPath);
// If we get here, the update was successful
// The service will be restarted by the update script
res.json({
status: 'success',
message: 'Update applied successfully. The service will restart.',
data: {
output: stdout,
errors: stderr
}
});
} catch (error) {
console.error('Error applying update:', error);
res.status(500).json({
status: 'error',
message: 'Failed to apply update',
error: error.message
});
}
});

View File

@ -0,0 +1,41 @@
<!-- This HTML block will be inserted into the index.html dashboard -->
<div class="row">
<div class="col-md-4 mb-3">
<div class="card">
<div class="card-header">
<h4><i class="fas fa-info-circle"></i> System Status</h4>
</div>
<div class="card-body">
<div class="status-item">
<span class="status-label">Version:</span>
<span id="system-version" class="status-value">Loading...</span>
</div>
<div class="status-item">
<span class="status-label">Running since:</span>
<span id="uptime" class="status-value">Loading...</span>
</div>
<div class="status-item">
<span class="status-label">Transmission:</span>
<span id="transmission-status" class="status-value">
<i class="fas fa-circle-notch fa-spin"></i> Checking...
</span>
</div>
<div class="status-item">
<span class="status-label">Update:</span>
<span id="update-status" class="status-value">
<i class="fas fa-circle-notch fa-spin"></i> Checking...
</span>
</div>
<div id="update-available" class="mt-3 d-none">
<div class="alert alert-info">
<i class="fas fa-arrow-circle-up"></i>
<span>A new version is available!</span>
<button id="btn-update-now" class="btn btn-sm btn-primary ml-2">
<i class="fas fa-download"></i> Update Now
</button>
</div>
</div>
</div>
</div>
</div>
</div>

84
temp_work/update.sh Normal file
View File

@ -0,0 +1,84 @@
#!/bin/bash
# Transmission RSS Manager - Update Script
# This script pulls the latest version from git and runs necessary updates
# Color and formatting
GREEN='\033[0;32m'
RED='\033[0;31m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
BOLD='\033[1m'
# Installation directory (should be current directory)
INSTALL_DIR=$(pwd)
# Check if we're in the right directory
if [ ! -f "$INSTALL_DIR/package.json" ] || [ ! -d "$INSTALL_DIR/modules" ]; then
echo -e "${RED}Error: This script must be run from the installation directory.${NC}"
exit 1
fi
# Get the current version
CURRENT_VERSION=$(grep -oP '"version": "\K[^"]+' package.json)
echo -e "${YELLOW}Current version: ${BOLD}$CURRENT_VERSION${NC}"
# Check for git repository
if [ ! -d ".git" ]; then
echo -e "${RED}Error: This installation was not set up using git.${NC}"
echo -e "Please use the bootstrap installer to perform a fresh installation."
exit 1
fi
# Stash any local changes
echo -e "${YELLOW}Backing up any local configuration changes...${NC}"
git stash -q
# Pull the latest changes
echo -e "${YELLOW}Pulling latest updates from git...${NC}"
git pull
if [ $? -ne 0 ]; then
echo -e "${RED}Failed to pull updates. Restoring original state...${NC}"
git stash pop -q
exit 1
fi
# Get the new version
NEW_VERSION=$(grep -oP '"version": "\K[^"]+' package.json)
echo -e "${GREEN}New version: ${BOLD}$NEW_VERSION${NC}"
# Check if update is needed
if [ "$CURRENT_VERSION" == "$NEW_VERSION" ]; then
echo -e "${GREEN}You already have the latest version.${NC}"
exit 0
fi
# Install any new npm dependencies
echo -e "${YELLOW}Installing dependencies...${NC}"
npm install
# Apply any local configuration changes
if git stash list | grep -q "stash@{0}"; then
echo -e "${YELLOW}Restoring local configuration changes...${NC}"
git stash pop -q
# Handle conflicts if any
if [ $? -ne 0 ]; then
echo -e "${RED}There were conflicts when restoring your configuration.${NC}"
echo -e "Please check the files and resolve conflicts manually."
echo -e "Your original configuration is saved in .git/refs/stash"
fi
fi
# Restart the service
echo -e "${YELLOW}Restarting service...${NC}"
if command -v systemctl &> /dev/null; then
sudo systemctl restart transmission-rss-manager
else
echo -e "${RED}Could not restart service automatically.${NC}"
echo -e "Please restart the service manually."
fi
# Update complete
echo -e "${GREEN}${BOLD}Update complete!${NC}"
echo -e "Updated from version $CURRENT_VERSION to $NEW_VERSION"
echo -e "Changes will take effect immediately."

View File

@ -1,165 +0,0 @@
#!/bin/bash
# Working network version with proper static file configuration
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
NC='\033[0m' # No Color
# Clean up existing test directory
echo -e "${YELLOW}Removing existing test directory...${NC}"
rm -rf "$HOME/transmission-rss-test"
# Create and prepare test directory
echo -e "${GREEN}Creating fresh test directory...${NC}"
TEST_DIR="$HOME/transmission-rss-test"
mkdir -p "$TEST_DIR"
mkdir -p "$TEST_DIR/wwwroot/css"
mkdir -p "$TEST_DIR/wwwroot/js"
# Copy web static files directly to wwwroot
cp -rv /opt/develop/transmission-rss-manager/TransmissionRssManager/src/Web/wwwroot/* "$TEST_DIR/wwwroot/"
# Create Program.cs with fixed static file configuration
cat > "$TEST_DIR/Program.cs" << 'EOL'
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.Logging;
using System;
using System.IO;
using TransmissionRssManager.Core;
using TransmissionRssManager.Services;
var builder = WebApplication.CreateBuilder(args);
// Add services to the container
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
// Add custom services
builder.Services.AddSingleton<IConfigService, ConfigService>();
builder.Services.AddSingleton<ITransmissionClient, TransmissionClient>();
builder.Services.AddSingleton<IRssFeedManager, RssFeedManager>();
builder.Services.AddSingleton<IPostProcessor, PostProcessor>();
// Add background services
builder.Services.AddHostedService<RssFeedBackgroundService>();
builder.Services.AddHostedService<PostProcessingBackgroundService>();
var app = builder.Build();
// Configure middleware
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
// Configure static files
var wwwrootPath = Path.Combine(Directory.GetCurrentDirectory(), "wwwroot");
app.UseStaticFiles(new StaticFileOptions
{
FileProvider = new PhysicalFileProvider(wwwrootPath),
RequestPath = ""
});
// Create default route to serve index.html
app.MapGet("/", context =>
{
context.Response.ContentType = "text/html";
context.Response.Redirect("/index.html");
return System.Threading.Tasks.Task.CompletedTask;
});
app.UseRouting();
app.UseAuthorization();
app.MapControllers();
// Log where static files are being served from
app.Logger.LogInformation($"Static files are served from: {wwwrootPath}");
app.Run();
EOL
# Create project file with System.IO
cat > "$TEST_DIR/TransmissionRssManager.csproj" << 'EOL'
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net7.0</TargetFramework>
<RootNamespace>TransmissionRssManager</RootNamespace>
<ImplicitUsings>disable</ImplicitUsings>
<Nullable>enable</Nullable>
<Version>1.0.0</Version>
<Authors>TransmissionRssManager</Authors>
<Description>A C# application to manage RSS feeds and automatically download torrents via Transmission</Description>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="7.0.13" />
<PackageReference Include="Microsoft.Extensions.FileProviders.Physical" Version="7.0.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" />
<PackageReference Include="System.ServiceModel.Syndication" Version="7.0.0" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="7.0.0" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="7.0.0" />
</ItemGroup>
</Project>
EOL
# Create source directories
mkdir -p "$TEST_DIR/src/Core"
mkdir -p "$TEST_DIR/src/Services"
mkdir -p "$TEST_DIR/src/Api/Controllers"
# Copy core interfaces
cp -v /opt/develop/transmission-rss-manager/TransmissionRssManager/src/Core/Interfaces.cs "$TEST_DIR/src/Core/"
# Copy service implementations
cp -v /opt/develop/transmission-rss-manager/TransmissionRssManager/src/Services/ConfigService.cs "$TEST_DIR/src/Services/"
cp -v /opt/develop/transmission-rss-manager/TransmissionRssManager/src/Services/TransmissionClient.cs "$TEST_DIR/src/Services/"
cp -v /opt/develop/transmission-rss-manager/TransmissionRssManager/src/Services/RssFeedManager.cs "$TEST_DIR/src/Services/"
cp -v /opt/develop/transmission-rss-manager/TransmissionRssManager/src/Services/PostProcessor.cs "$TEST_DIR/src/Services/"
# Copy API controllers
cp -v /opt/develop/transmission-rss-manager/TransmissionRssManager/src/Api/Controllers/ConfigController.cs "$TEST_DIR/src/Api/Controllers/"
cp -v /opt/develop/transmission-rss-manager/TransmissionRssManager/src/Api/Controllers/FeedsController.cs "$TEST_DIR/src/Api/Controllers/"
cp -v /opt/develop/transmission-rss-manager/TransmissionRssManager/src/Api/Controllers/TorrentsController.cs "$TEST_DIR/src/Api/Controllers/"
# Fix namespaces for Services
sed -i 's/using Microsoft.Extensions.Hosting;/using Microsoft.Extensions.Hosting;\nusing System.Linq;/g' "$TEST_DIR/src/Services/RssFeedManager.cs"
sed -i 's/using Microsoft.Extensions.Hosting;/using Microsoft.Extensions.Hosting;\nusing System.Linq;/g' "$TEST_DIR/src/Services/PostProcessor.cs"
# Get server IP
SERVER_IP=$(hostname -I | awk '{print $1}')
echo -e "${GREEN}Server IP: $SERVER_IP${NC}"
# Build the application
cd "$TEST_DIR"
echo -e "${GREEN}Setting up NuGet packages...${NC}"
dotnet restore
if [ $? -ne 0 ]; then
echo -e "${RED}Failed to restore NuGet packages.${NC}"
exit 1
fi
echo -e "${GREEN}Building application...${NC}"
dotnet build
if [ $? -ne 0 ]; then
echo -e "${RED}Build failed.${NC}"
exit 1
fi
# Run with explicit host binding
echo -e "${GREEN}Starting application on all interfaces with explicit binding...${NC}"
echo -e "${GREEN}The web interface will be available at:${NC}"
echo -e "${GREEN}- Local: http://localhost:5000${NC}"
echo -e "${GREEN}- Network: http://${SERVER_IP}:5000${NC}"
echo -e "${YELLOW}Press Ctrl+C to stop the application${NC}"
cd "$TEST_DIR"
dotnet run --urls="http://0.0.0.0:5000"