Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f804ca51d3 |
-30
@@ -1,30 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,488 +1,115 @@
|
|||||||
# Transmission RSS Manager v2.0.6
|
# Transmission RSS Manager
|
||||||
|
|
||||||
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!
|
A C# application for managing RSS feeds and automatically downloading torrents via Transmission BitTorrent client.
|
||||||
|
|
||||||
## Changelog
|
|
||||||
|
|
||||||
### 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
|
||||||
|
|
||||||
- 🔄 **RSS Feed Integration**: Automatically download torrents from RSS feeds with customizable filters
|
- Monitor multiple RSS feeds for new torrents
|
||||||
- 📊 **Torrent Management**: Monitor and control your Transmission torrents from a clean web interface
|
- Apply regex-based rules to automatically match and download content
|
||||||
- 📚 **Intelligent Media Organization**: Automatically categorize and organize downloads by media type
|
- Manage Transmission torrents through a user-friendly web interface
|
||||||
- 📖 **Book & Magazine Sorting**: Specialized processing for e-books and magazines with metadata extraction
|
- Post-processing of completed downloads (extract archives, organize media files)
|
||||||
- 📂 **Post-Processing**: Extract archives, rename files, and move content to appropriate directories
|
- Responsive web UI for desktop and mobile use
|
||||||
- 🔄 **Remote Support**: Connect to remote Transmission instances with local path mapping
|
|
||||||
- 🔒 **Enhanced Security**: Authentication, HTTPS support, and secure password storage
|
## Requirements
|
||||||
- 📱 **Mobile-Friendly UI**: Responsive design works on desktop and mobile devices
|
|
||||||
- 🔧 **One-Click Updates**: Built-in version checking and automated update system
|
- .NET 7.0 or higher
|
||||||
- 🚀 **Automatic Transmission Installation**: Installs and configures Transmission if needed
|
- Transmission BitTorrent client (with remote access enabled)
|
||||||
|
- 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 (will be automatically installed by the bootstrap installer if needed)
|
|
||||||
- Internet connection (for downloading and updates)
|
|
||||||
|
|
||||||
### 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
|
|
||||||
|
|
||||||
### Automatic Installation
|
### Automatic Installation
|
||||||
|
|
||||||
The easiest way to install Transmission RSS Manager is with the bootstrap installer:
|
Run the installer script:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Download the bootstrap installer
|
curl -sSL https://raw.githubusercontent.com/yourusername/transmission-rss-manager/main/install-script.sh | bash
|
||||||
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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The bootstrap installer will:
|
Or if you've cloned the repository:
|
||||||
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
|
|
||||||
|
|
||||||
**New in v2.0.0:**
|
```bash
|
||||||
- The installer now detects if Transmission is installed and offers to install and configure it automatically if needed.
|
./src/Infrastructure/install-script.sh
|
||||||
- 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
|
||||||
|
|
||||||
If you prefer to install manually:
|
1. Install .NET 7.0 SDK from [Microsoft's website](https://dotnet.microsoft.com/download)
|
||||||
|
2. Clone the repository:
|
||||||
1. Clone the repository:
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://git.powerdata.dk/masterdraco/transmission-rss-manager.git
|
git clone https://github.com/yourusername/transmission-rss-manager.git
|
||||||
cd transmission-rss-manager
|
cd transmission-rss-manager
|
||||||
```
|
```
|
||||||
|
3. Build and run the application:
|
||||||
2. Install dependencies:
|
|
||||||
```bash
|
```bash
|
||||||
npm install
|
dotnet build -c Release
|
||||||
```
|
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
|
||||||
|
|
||||||
### Main Configuration Options
|
After starting the application for the first time, a configuration file will be created at `~/.config/transmission-rss-manager/config.json`.
|
||||||
|
|
||||||
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):
|
You can configure the application through the web interface or by directly editing the configuration file.
|
||||||
|
|
||||||
```json
|
### Key configuration options
|
||||||
{
|
|
||||||
"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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Remote Transmission Setup
|
- **Transmission settings**: Host, port, username, password
|
||||||
|
- **RSS feed checking interval**
|
||||||
For remote Transmission instances, configure the directory mapping:
|
- **Auto-download settings**
|
||||||
|
- **Post-processing options**
|
||||||
```json
|
- **Download and media library directories**
|
||||||
"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
|
||||||
|
|
||||||
### Web Interface
|
### Managing RSS Feeds
|
||||||
|
|
||||||
The web interface provides access to all functionality and is available at:
|
1. Add RSS feeds through the web interface
|
||||||
```
|
2. Create regex rules for each feed to match desired content
|
||||||
http://your-server-ip
|
3. Enable auto-download for feeds you want to process automatically
|
||||||
```
|
|
||||||
|
|
||||||
### 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
|
||||||
|
|
||||||
From the "Torrents" tab, you can:
|
- Add torrents manually via URL or magnet link
|
||||||
- Add new torrents via URL or magnet link
|
- View, start, stop, and remove torrents
|
||||||
- Start, stop, or delete existing torrents
|
- Process completed torrents to extract archives and organize media
|
||||||
- Monitor download progress and stats
|
|
||||||
|
|
||||||
### Media Organization
|
## Development
|
||||||
|
|
||||||
The post-processor automatically:
|
### Building from source
|
||||||
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
|
|
||||||
|
|
||||||
### Using the Installer
|
|
||||||
|
|
||||||
You can also update by running the installer again:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Navigate to the installation directory
|
dotnet build
|
||||||
cd /opt/transmission-rss-manager
|
|
||||||
|
|
||||||
# Run the installer with sudo
|
|
||||||
sudo ./main-installer.sh
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The installer will:
|
### Running in development mode
|
||||||
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
|
||||||
cd /opt/transmission-rss-manager
|
dotnet run
|
||||||
sudo scripts/update.sh
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Use the `--force` flag to force an update of dependencies even if no code changes are detected:
|
### Creating a release
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo scripts/update.sh --force
|
dotnet publish -c Release
|
||||||
```
|
```
|
||||||
|
|
||||||
### Manual Update
|
## Architecture
|
||||||
|
|
||||||
Alternatively, you can download and run the update script:
|
The application is built using ASP.NET Core with the following components:
|
||||||
|
|
||||||
```bash
|
- **Web API**: REST endpoints for the web interface
|
||||||
wget https://git.powerdata.dk/masterdraco/transmission-rss-manager/raw/main/scripts/update.sh
|
- **Background Services**: RSS feed checking and post-processing
|
||||||
chmod +x update.sh
|
- **Core Services**: Configuration, Transmission communication, RSS parsing
|
||||||
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.
|
This project is licensed under the MIT License - see the LICENSE file for details.
|
||||||
|
|
||||||
## Acknowledgments
|
## Acknowledgments
|
||||||
|
|
||||||
- [Transmission](https://transmissionbt.com/) for the excellent BitTorrent client
|
- [Transmission](https://transmissionbt.com/) - BitTorrent client
|
||||||
- [Node.js](https://nodejs.org/) and the npm community for the foundation libraries
|
- [ASP.NET Core](https://docs.microsoft.com/en-us/aspnet/core/) - Web framework
|
||||||
- All contributors who have helped improve this project
|
- [Bootstrap](https://getbootstrap.com/) - UI framework
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<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>
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
#!/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
|
|
||||||
|
|
||||||
# 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."
|
|
||||||
+79
-18
@@ -28,14 +28,44 @@ SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )"
|
|||||||
# Create modules directory if it doesn't exist
|
# Create modules directory if it doesn't exist
|
||||||
mkdir -p "${SCRIPT_DIR}/modules"
|
mkdir -p "${SCRIPT_DIR}/modules"
|
||||||
|
|
||||||
# Check for installation type
|
# Check for installation type in multiple locations
|
||||||
IS_UPDATE=false
|
IS_UPDATE=false
|
||||||
if [ -f "${SCRIPT_DIR}/config.json" ]; then
|
POSSIBLE_CONFIG_LOCATIONS=(
|
||||||
IS_UPDATE=true
|
"${SCRIPT_DIR}/config.json"
|
||||||
echo -e "${YELLOW}Existing installation detected. Running in update mode.${NC}"
|
"/opt/transmission-rss-manager/config.json"
|
||||||
echo -e "${GREEN}Your existing configuration will be preserved.${NC}"
|
"/etc/transmission-rss-manager/config.json"
|
||||||
else
|
)
|
||||||
echo -e "${GREEN}Fresh installation. Will create new configuration.${NC}"
|
|
||||||
|
# Also check for service file - secondary indicator
|
||||||
|
if [ -f "/etc/systemd/system/transmission-rss-manager.service" ]; then
|
||||||
|
# Extract install directory from service file if it exists
|
||||||
|
SERVICE_INSTALL_DIR=$(grep "WorkingDirectory=" "/etc/systemd/system/transmission-rss-manager.service" | cut -d'=' -f2)
|
||||||
|
if [ -n "$SERVICE_INSTALL_DIR" ]; then
|
||||||
|
echo -e "${YELLOW}Found existing service at: $SERVICE_INSTALL_DIR${NC}"
|
||||||
|
POSSIBLE_CONFIG_LOCATIONS+=("$SERVICE_INSTALL_DIR/config.json")
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check all possible locations
|
||||||
|
for CONFIG_PATH in "${POSSIBLE_CONFIG_LOCATIONS[@]}"; do
|
||||||
|
if [ -f "$CONFIG_PATH" ]; then
|
||||||
|
IS_UPDATE=true
|
||||||
|
echo -e "${YELLOW}Existing installation detected at: $CONFIG_PATH${NC}"
|
||||||
|
echo -e "${YELLOW}Running in update mode.${NC}"
|
||||||
|
echo -e "${GREEN}Your existing configuration will be preserved.${NC}"
|
||||||
|
|
||||||
|
# If the config is not in the current directory, store its location
|
||||||
|
if [ "$CONFIG_PATH" != "${SCRIPT_DIR}/config.json" ]; then
|
||||||
|
export EXISTING_CONFIG_PATH="$CONFIG_PATH"
|
||||||
|
export EXISTING_INSTALL_DIR="$(dirname "$CONFIG_PATH")"
|
||||||
|
echo -e "${YELLOW}Will update installation at: $EXISTING_INSTALL_DIR${NC}"
|
||||||
|
fi
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "$IS_UPDATE" = "false" ]; then
|
||||||
|
echo -e "${GREEN}No existing installation detected. Will create new configuration.${NC}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Check if modules exist, if not, extract them
|
# Check if modules exist, if not, extract them
|
||||||
@@ -1167,14 +1197,37 @@ fi
|
|||||||
# Launch the main installer
|
# Launch the main installer
|
||||||
echo -e "${GREEN}Launching main installer...${NC}"
|
echo -e "${GREEN}Launching main installer...${NC}"
|
||||||
|
|
||||||
# Ask about remote Transmission before launching main installer
|
# Skip Transmission configuration if we're in update mode
|
||||||
# This ensures the TRANSMISSION_REMOTE variable is set correctly
|
if [ "$IS_UPDATE" = "true" ] && [ -n "$EXISTING_CONFIG_PATH" ]; then
|
||||||
echo -e "${BOLD}Transmission Configuration:${NC}"
|
echo -e "${GREEN}Existing configuration detected, skipping Transmission configuration...${NC}"
|
||||||
echo -e "Configure connection to your Transmission client:"
|
|
||||||
echo
|
|
||||||
|
|
||||||
# If stdin is not a terminal (pipe or redirect), read from stdin
|
# Extract Transmission remote setting from existing config
|
||||||
if [ ! -t 0 ]; then
|
if [ -f "$EXISTING_CONFIG_PATH" ]; then
|
||||||
|
# Try to extract remoteConfig.isRemote value from config.json
|
||||||
|
if command -v grep &> /dev/null && command -v sed &> /dev/null; then
|
||||||
|
IS_REMOTE=$(grep -o '"isRemote":[^,}]*' "$EXISTING_CONFIG_PATH" | sed 's/"isRemote"://; s/[[:space:]]//g')
|
||||||
|
if [ "$IS_REMOTE" = "true" ]; then
|
||||||
|
export TRANSMISSION_REMOTE=true
|
||||||
|
echo -e "${GREEN}Using existing remote Transmission configuration.${NC}"
|
||||||
|
else
|
||||||
|
export TRANSMISSION_REMOTE=false
|
||||||
|
echo -e "${GREEN}Using existing local Transmission configuration.${NC}"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
# Default to false if we can't extract it
|
||||||
|
export TRANSMISSION_REMOTE=false
|
||||||
|
echo -e "${YELLOW}Could not determine Transmission remote setting, using local configuration.${NC}"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
# Ask about remote Transmission before launching main installer
|
||||||
|
# This ensures the TRANSMISSION_REMOTE variable is set correctly
|
||||||
|
echo -e "${BOLD}Transmission Configuration:${NC}"
|
||||||
|
echo -e "Configure connection to your Transmission client:"
|
||||||
|
echo
|
||||||
|
|
||||||
|
# If stdin is not a terminal (pipe or redirect), read from stdin
|
||||||
|
if [ ! -t 0 ]; then
|
||||||
# Save all input to a temporary file
|
# Save all input to a temporary file
|
||||||
INPUT_FILE=$(mktemp)
|
INPUT_FILE=$(mktemp)
|
||||||
cat > "$INPUT_FILE"
|
cat > "$INPUT_FILE"
|
||||||
@@ -1195,12 +1248,13 @@ if [ "$input_remote" = "y" ] || [ "$input_remote" = "Y" ]; then
|
|||||||
export TRANSMISSION_REMOTE=true
|
export TRANSMISSION_REMOTE=true
|
||||||
echo -e "${GREEN}Remote Transmission selected.${NC}"
|
echo -e "${GREEN}Remote Transmission selected.${NC}"
|
||||||
else
|
else
|
||||||
export TRANSMISSION_REMOTE=false
|
export TRANSMISSION_REMOTE=false
|
||||||
echo -e "${GREEN}Local Transmission selected.${NC}"
|
echo -e "${GREEN}Local Transmission selected.${NC}"
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# If remote mode is selected, collect remote details here and pass to main installer
|
# If remote mode is selected and not an update, collect remote details here and pass to main installer
|
||||||
if [ "$TRANSMISSION_REMOTE" = "true" ]; then
|
if [ "$TRANSMISSION_REMOTE" = "true" ] && [ "$IS_UPDATE" != "true" ]; then
|
||||||
# Get remote transmission details
|
# Get remote transmission details
|
||||||
if [ ! -t 0 ]; then
|
if [ ! -t 0 ]; then
|
||||||
# Non-interactive mode - we already have input saved to INPUT_FILE
|
# Non-interactive mode - we already have input saved to INPUT_FILE
|
||||||
@@ -1282,6 +1336,13 @@ chmod +x "${SCRIPT_DIR}/.env.install"
|
|||||||
# Ensure the environment file is world-readable to avoid permission issues
|
# Ensure the environment file is world-readable to avoid permission issues
|
||||||
chmod 644 "${SCRIPT_DIR}/.env.install"
|
chmod 644 "${SCRIPT_DIR}/.env.install"
|
||||||
|
|
||||||
|
# If we're in update mode, add the existing installation path to the environment file
|
||||||
|
if [ "$IS_UPDATE" = "true" ] && [ -n "$EXISTING_CONFIG_PATH" ]; then
|
||||||
|
echo "export EXISTING_CONFIG_PATH=\"$EXISTING_CONFIG_PATH\"" >> "${SCRIPT_DIR}/.env.install"
|
||||||
|
echo "export EXISTING_INSTALL_DIR=\"$EXISTING_INSTALL_DIR\"" >> "${SCRIPT_DIR}/.env.install"
|
||||||
|
echo "export IS_UPDATE=true" >> "${SCRIPT_DIR}/.env.install"
|
||||||
|
fi
|
||||||
|
|
||||||
# Force inclusion in the main installer - modify the main installer temporarily if needed
|
# Force inclusion in the main installer - modify the main installer temporarily if needed
|
||||||
if ! grep -q "source.*\.env\.install" "${SCRIPT_DIR}/main-installer.sh"; then
|
if ! grep -q "source.*\.env\.install" "${SCRIPT_DIR}/main-installer.sh"; then
|
||||||
# Backup the main installer
|
# Backup the main installer
|
||||||
|
|||||||
@@ -1,510 +0,0 @@
|
|||||||
#!/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 )"
|
|
||||||
|
|
||||||
# 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")
|
|
||||||
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 for config.json file (primary indicator)
|
|
||||||
if [ -f "${SCRIPT_DIR}/config.json" ]; then
|
|
||||||
INSTALLATION_DETECTED=true
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Check for service file (secondary indicator)
|
|
||||||
if [ -f "/etc/systemd/system/transmission-rss-manager.service" ]; then
|
|
||||||
INSTALLATION_DETECTED=true
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Check for data directory (tertiary indicator)
|
|
||||||
if [ -d "${SCRIPT_DIR}/data" ] && [ "$(ls -A "${SCRIPT_DIR}/data" 2>/dev/null)" ]; then
|
|
||||||
INSTALLATION_DETECTED=true
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "$INSTALLATION_DETECTED" = true ]; 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}"
|
|
||||||
echo -e "${GREEN}Only application files will be updated.${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"
|
|
||||||
"${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 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"
|
|
||||||
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
|
|
||||||
log "INFO" "Updating npm dependencies..."
|
|
||||||
cd "$SCRIPT_DIR"
|
|
||||||
npm install || {
|
|
||||||
log "ERROR" "NPM installation failed"
|
|
||||||
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"}
|
|
||||||
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
|
|
||||||
}
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 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}"
|
|
||||||
|
|
||||||
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}"
|
|
||||||
@@ -1,382 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
# Configuration module for Transmission RSS Manager Installation
|
|
||||||
|
|
||||||
# Configuration variables with defaults
|
|
||||||
INSTALL_DIR="/opt/transmission-rss-manager"
|
|
||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,242 +0,0 @@
|
|||||||
#!/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
|
|
||||||
|
|
||||||
# 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)
|
|
||||||
if [ "$TRANSMISSION_REMOTE" = false ]; 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."
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,517 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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,741 +0,0 @@
|
|||||||
// 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 });
|
|
||||||
|
|
||||||
// Ensure dataPath is properly defined
|
|
||||||
this.dataPath = path.join(__dirname, '..', 'data');
|
|
||||||
|
|
||||||
// Maximum items to keep in memory to prevent memory leaks
|
|
||||||
this.maxItemsInMemory = config.maxItemsInMemory || 5000;
|
|
||||||
}
|
|
||||||
|
|
||||||
async start() {
|
|
||||||
if (this.updateIntervalId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 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 {
|
|
||||||
const response = await fetch(feed.url, {
|
|
||||||
timeout: 30000, // 30 second timeout
|
|
||||||
headers: {
|
|
||||||
'User-Agent': 'Transmission-RSS-Manager/1.2.0'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async ensureDataDirectory() {
|
|
||||||
try {
|
|
||||||
await fs.mkdir(this.dataPath, { recursive: true });
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error creating data directory:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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,253 +0,0 @@
|
|||||||
#!/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,517 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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
|
|
||||||
this.initializeConnection();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize the connection to Transmission
|
|
||||||
*/
|
|
||||||
initializeConnection() {
|
|
||||||
const { host, port, username, password, path: rpcPath } = this.config.transmissionConfig;
|
|
||||||
|
|
||||||
try {
|
|
||||||
this.client = new Transmission({
|
|
||||||
host: host || 'localhost',
|
|
||||||
port: port || 9091,
|
|
||||||
username: username || '',
|
|
||||||
password: password || '',
|
|
||||||
path: rpcPath || '/transmission/rpc',
|
|
||||||
timeout: 30000 // 30 seconds
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(`Initialized Transmission client connection to ${host}:${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 {
|
|
||||||
const sessionInfo = await this.client.sessionStats();
|
|
||||||
const version = await this.client.sessionGet();
|
|
||||||
|
|
||||||
return {
|
|
||||||
connected: true,
|
|
||||||
version: version.version,
|
|
||||||
rpcVersion: version['rpc-version'],
|
|
||||||
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 {
|
|
||||||
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 {
|
|
||||||
await this.client.sessionSet(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,238 +0,0 @@
|
|||||||
#!/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 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
|
|
||||||
log "INFO" "Installing NPM packages..."
|
|
||||||
cd $INSTALL_DIR && npm install
|
|
||||||
|
|
||||||
# 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"
|
|
||||||
|
|
||||||
cat > $CONFIG_DIR/config.json << EOF
|
|
||||||
{
|
|
||||||
"version": "1.2.0",
|
|
||||||
"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!"
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "transmission-rss-manager",
|
|
||||||
"version": "2.0.6",
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,731 +0,0 @@
|
|||||||
/* 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,665 +0,0 @@
|
|||||||
<!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 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>
|
|
||||||
<!-- 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 v2.0.0</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">×</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.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>Transmission RSS Manager v2.0.0</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>
|
|
||||||
|
|
||||||
<!-- 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
File diff suppressed because it is too large
Load Diff
@@ -1,163 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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() {
|
|
||||||
fetch('/api/system/status', {
|
|
||||||
headers: authHeaders()
|
|
||||||
})
|
|
||||||
.then(handleResponse)
|
|
||||||
.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 {
|
|
||||||
showNotification('Failed to load system status', 'danger');
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(error => {
|
|
||||||
console.error('Error fetching system status:', error);
|
|
||||||
showNotification('Failed to connect to server', 'danger');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for updates
|
|
||||||
function checkForUpdates() {
|
|
||||||
updateStatusElement.innerHTML = '<i class="fas fa-circle-notch fa-spin"></i> Checking...';
|
|
||||||
updateAvailableDiv.classList.add('d-none');
|
|
||||||
|
|
||||||
// Add test=true parameter to force update availability for testing
|
|
||||||
const testMode = localStorage.getItem('showUpdateButton') === 'true';
|
|
||||||
const url = testMode ? '/api/system/check-updates?test=true' : '/api/system/check-updates';
|
|
||||||
|
|
||||||
fetch(url, {
|
|
||||||
headers: authHeaders()
|
|
||||||
})
|
|
||||||
.then(handleResponse)
|
|
||||||
.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';
|
|
||||||
showNotification(data.message || 'Failed to check for updates', 'danger');
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(error => {
|
|
||||||
console.error('Error checking for updates:', error);
|
|
||||||
updateStatusElement.innerHTML = '<i class="fas fa-times-circle text-danger"></i> Check failed';
|
|
||||||
showNotification('Failed to connect to server', 'danger');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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...';
|
|
||||||
showNotification('Applying update. Please wait...', 'info');
|
|
||||||
|
|
||||||
fetch('/api/system/update', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
...authHeaders()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.then(handleResponse)
|
|
||||||
.then(data => {
|
|
||||||
if (data.status === 'success') {
|
|
||||||
showNotification('Update applied successfully. The page will reload in 30 seconds.', 'success');
|
|
||||||
// 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';
|
|
||||||
showNotification(data.message || 'Failed to apply update', 'danger');
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(error => {
|
|
||||||
console.error('Error applying update:', error);
|
|
||||||
updateButton.disabled = false;
|
|
||||||
updateButton.innerHTML = '<i class="fas fa-download"></i> Update Now';
|
|
||||||
showNotification('Failed to connect to server', 'danger');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Event listeners
|
|
||||||
if (refreshButton) {
|
|
||||||
refreshButton.addEventListener('click', () => {
|
|
||||||
loadSystemStatus();
|
|
||||||
checkForUpdates();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (updateButton) {
|
|
||||||
updateButton.addEventListener('click', applyUpdate);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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';
|
|
||||||
|
|
||||||
// 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';
|
|
||||||
|
|
||||||
// Re-check for updates with new setting
|
|
||||||
checkForUpdates();
|
|
||||||
|
|
||||||
showNotification(`Test update button ${newSetting ? 'enabled' : 'disabled'}`, 'info');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize
|
|
||||||
loadSystemStatus();
|
|
||||||
checkForUpdates();
|
|
||||||
|
|
||||||
// Set interval to refresh uptime every minute
|
|
||||||
setInterval(loadSystemStatus, 60000);
|
|
||||||
}
|
|
||||||
@@ -1,637 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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 = {
|
|
||||||
'&': '&',
|
|
||||||
'<': '<',
|
|
||||||
'>': '>',
|
|
||||||
'"': '"',
|
|
||||||
"'": ''',
|
|
||||||
'/': '/'
|
|
||||||
};
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
Executable
+38
@@ -0,0 +1,38 @@
|
|||||||
|
#!/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
|
||||||
Executable
+1048
File diff suppressed because it is too large
Load Diff
Executable
+27
@@ -0,0 +1,27 @@
|
|||||||
|
#!/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
|
||||||
@@ -1,165 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
# Test and start script for Transmission RSS Manager
|
|
||||||
# This script checks the installation, dependencies, and starts the application
|
|
||||||
|
|
||||||
# Text formatting
|
|
||||||
BOLD='\033[1m'
|
|
||||||
GREEN='\033[0;32m'
|
|
||||||
YELLOW='\033[0;33m'
|
|
||||||
RED='\033[0;31m'
|
|
||||||
NC='\033[0m' # No Color
|
|
||||||
|
|
||||||
# Get directory of this script
|
|
||||||
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )"
|
|
||||||
APP_DIR="$(dirname "$SCRIPT_DIR")"
|
|
||||||
|
|
||||||
# Function to check if a command exists
|
|
||||||
command_exists() {
|
|
||||||
command -v "$1" &> /dev/null
|
|
||||||
}
|
|
||||||
|
|
||||||
# Check Node.js and npm
|
|
||||||
check_node() {
|
|
||||||
echo -e "${BOLD}Checking Node.js and npm...${NC}"
|
|
||||||
|
|
||||||
if command_exists node; then
|
|
||||||
NODE_VERSION=$(node -v)
|
|
||||||
echo -e "${GREEN}Node.js is installed: $NODE_VERSION${NC}"
|
|
||||||
else
|
|
||||||
echo -e "${RED}Node.js is not installed. Please install Node.js 14 or later.${NC}"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if command_exists npm; then
|
|
||||||
NPM_VERSION=$(npm -v)
|
|
||||||
echo -e "${GREEN}npm is installed: $NPM_VERSION${NC}"
|
|
||||||
else
|
|
||||||
echo -e "${RED}npm is not installed. Please install npm.${NC}"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# Check if Transmission is running
|
|
||||||
check_transmission() {
|
|
||||||
echo -e "${BOLD}Checking Transmission...${NC}"
|
|
||||||
|
|
||||||
# Try to get the status of the transmission-daemon service
|
|
||||||
if command_exists systemctl; then
|
|
||||||
if systemctl is-active --quiet transmission-daemon; then
|
|
||||||
echo -e "${GREEN}Transmission daemon is running${NC}"
|
|
||||||
else
|
|
||||||
echo -e "${YELLOW}Warning: Transmission daemon does not appear to be running${NC}"
|
|
||||||
echo -e "${YELLOW}You may need to start it with: sudo systemctl start transmission-daemon${NC}"
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
# Try a different method if systemctl is not available
|
|
||||||
if pgrep -x "transmission-daemon" > /dev/null; then
|
|
||||||
echo -e "${GREEN}Transmission daemon is running${NC}"
|
|
||||||
else
|
|
||||||
echo -e "${YELLOW}Warning: Transmission daemon does not appear to be running${NC}"
|
|
||||||
echo -e "${YELLOW}Please start Transmission daemon before using this application${NC}"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# Check dependencies in package.json
|
|
||||||
check_dependencies() {
|
|
||||||
echo -e "${BOLD}Checking dependencies...${NC}"
|
|
||||||
|
|
||||||
# Check if node_modules exists
|
|
||||||
if [ ! -d "$APP_DIR/node_modules" ]; then
|
|
||||||
echo -e "${YELLOW}Node modules not found. Installing dependencies...${NC}"
|
|
||||||
cd "$APP_DIR" && npm install
|
|
||||||
|
|
||||||
if [ $? -ne 0 ]; then
|
|
||||||
echo -e "${RED}Failed to install dependencies.${NC}"
|
|
||||||
exit 1
|
|
||||||
else
|
|
||||||
echo -e "${GREEN}Dependencies installed successfully${NC}"
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
echo -e "${GREEN}Dependencies are already installed${NC}"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# Check if config.json exists
|
|
||||||
check_config() {
|
|
||||||
echo -e "${BOLD}Checking configuration...${NC}"
|
|
||||||
|
|
||||||
if [ ! -f "$APP_DIR/config.json" ]; then
|
|
||||||
echo -e "${RED}Configuration file not found: $APP_DIR/config.json${NC}"
|
|
||||||
echo -e "${YELLOW}Please run the installer or create a config.json file${NC}"
|
|
||||||
exit 1
|
|
||||||
else
|
|
||||||
echo -e "${GREEN}Configuration file found${NC}"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# Start the application
|
|
||||||
start_app() {
|
|
||||||
echo -e "${BOLD}Starting Transmission RSS Manager...${NC}"
|
|
||||||
|
|
||||||
# Check if running as a service
|
|
||||||
if command_exists systemctl; then
|
|
||||||
if systemctl is-active --quiet transmission-rss-manager; then
|
|
||||||
echo -e "${YELLOW}Transmission RSS Manager is already running as a service${NC}"
|
|
||||||
echo -e "${YELLOW}To restart it, use: sudo systemctl restart transmission-rss-manager${NC}"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Start the application
|
|
||||||
cd "$APP_DIR"
|
|
||||||
|
|
||||||
# Parse arguments
|
|
||||||
FOREGROUND=false
|
|
||||||
DEBUG=false
|
|
||||||
|
|
||||||
while [[ "$#" -gt 0 ]]; do
|
|
||||||
case $1 in
|
|
||||||
--foreground|-f) FOREGROUND=true ;;
|
|
||||||
--debug|-d) DEBUG=true ;;
|
|
||||||
*) echo "Unknown parameter: $1"; exit 1 ;;
|
|
||||||
esac
|
|
||||||
shift
|
|
||||||
done
|
|
||||||
|
|
||||||
if [ "$FOREGROUND" = true ]; then
|
|
||||||
echo -e "${GREEN}Starting in foreground mode...${NC}"
|
|
||||||
|
|
||||||
if [ "$DEBUG" = true ]; then
|
|
||||||
echo -e "${YELLOW}Debug mode enabled${NC}"
|
|
||||||
DEBUG_ENABLED=true node server.js
|
|
||||||
else
|
|
||||||
node server.js
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
echo -e "${GREEN}Starting in background mode...${NC}"
|
|
||||||
|
|
||||||
if [ "$DEBUG" = true ]; then
|
|
||||||
echo -e "${YELLOW}Debug mode enabled${NC}"
|
|
||||||
DEBUG_ENABLED=true nohup node server.js > logs/output.log 2>&1 &
|
|
||||||
else
|
|
||||||
nohup node server.js > logs/output.log 2>&1 &
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo $! > "$APP_DIR/transmission-rss-manager.pid"
|
|
||||||
echo -e "${GREEN}Application started with PID: $!${NC}"
|
|
||||||
echo -e "${GREEN}Logs available at: $APP_DIR/logs/output.log${NC}"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# Main script
|
|
||||||
echo -e "${BOLD}==================================================${NC}"
|
|
||||||
echo -e "${BOLD} Transmission RSS Manager - Test & Start ${NC}"
|
|
||||||
echo -e "${BOLD}==================================================${NC}"
|
|
||||||
echo
|
|
||||||
|
|
||||||
# Run checks
|
|
||||||
check_node
|
|
||||||
check_transmission
|
|
||||||
check_dependencies
|
|
||||||
check_config
|
|
||||||
|
|
||||||
# Start the application
|
|
||||||
start_app "$@"
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
#!/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."
|
|
||||||
@@ -1,167 +0,0 @@
|
|||||||
// 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
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
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();
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+212
@@ -0,0 +1,212 @@
|
|||||||
|
#!/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
|
||||||
Binary file not shown.
@@ -0,0 +1,112 @@
|
|||||||
|
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" }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,350 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,309 @@
|
|||||||
|
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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
: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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,283 @@
|
|||||||
|
<!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>
|
||||||
@@ -0,0 +1,916 @@
|
|||||||
|
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');
|
||||||
|
}
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
// 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();
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
#!/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."
|
|
||||||
@@ -1,374 +0,0 @@
|
|||||||
#!/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
|
|
||||||
}
|
|
||||||
@@ -1,184 +0,0 @@
|
|||||||
#!/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."
|
|
||||||
}
|
|
||||||
@@ -1,241 +0,0 @@
|
|||||||
#!/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}"
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
#!/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."
|
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
// 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
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
<!-- 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>
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
#!/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."
|
|
||||||
Executable
+165
@@ -0,0 +1,165 @@
|
|||||||
|
#!/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"
|
||||||
Reference in New Issue
Block a user