Compare commits
No commits in common. "feature/improved-transmission-connection" and "main" have entirely different histories.
feature/im
...
main
8
.env.install
Normal file
8
.env.install
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
export TRANSMISSION_REMOTE=true
|
||||||
|
export TRANSMISSION_HOST="192.168.5.19"
|
||||||
|
export TRANSMISSION_PORT="9091"
|
||||||
|
export TRANSMISSION_USER=""
|
||||||
|
export TRANSMISSION_PASS=""
|
||||||
|
export TRANSMISSION_RPC_PATH="/transmission/rpc"
|
||||||
|
export REMOTE_DOWNLOAD_DIR="/downloads"
|
||||||
|
export LOCAL_DOWNLOAD_DIR="/media"
|
30
.gitignore
vendored
Normal file
30
.gitignore
vendored
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
# Node.js dependencies
|
||||||
|
node_modules/
|
||||||
|
package-lock.json
|
||||||
|
|
||||||
|
# Environment and configuration
|
||||||
|
.env
|
||||||
|
config.json
|
||||||
|
|
||||||
|
# Log files
|
||||||
|
logs/
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
|
||||||
|
# Temporary and build files
|
||||||
|
temp/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
.DS_Store
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
# Data files
|
||||||
|
data/
|
||||||
|
rss-items.json
|
||||||
|
rss-feeds.json
|
||||||
|
|
||||||
|
# Authentication
|
||||||
|
*.pem
|
||||||
|
*.key
|
||||||
|
*.crt
|
51
CLAUDE.md
Normal file
51
CLAUDE.md
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
# Transmission RSS Manager - Development Guide
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
- Install dependencies: `npm install` (needed for rss-feed-manager.js)
|
||||||
|
- Setup: `./main-installer.sh` (main installation script)
|
||||||
|
- Run application: `node modules/rss-feed-manager.js`
|
||||||
|
|
||||||
|
## Code Style Guidelines
|
||||||
|
|
||||||
|
### JavaScript
|
||||||
|
- Indentation: 2 spaces
|
||||||
|
- Naming: camelCase for variables/functions, PascalCase for classes
|
||||||
|
- Semicolons: required
|
||||||
|
- Imports: group standard libraries first, then custom modules
|
||||||
|
- Error handling: use try/catch with descriptive error messages
|
||||||
|
- Functions: prefer arrow functions for callbacks
|
||||||
|
- String formatting: use template literals (`${variable}`)
|
||||||
|
|
||||||
|
### Bash Scripts
|
||||||
|
- Indentation: 2 spaces
|
||||||
|
- Function definition: use `function name() {}`
|
||||||
|
- Comments: add descriptive comments before functions
|
||||||
|
- Error handling: check return codes and provide meaningful feedback
|
||||||
|
- Organization: follow modular approach (each script handles specific tasks)
|
||||||
|
|
||||||
|
### HTML/CSS
|
||||||
|
- Indentation: 4 spaces
|
||||||
|
- CSS: use variables for consistent styling
|
||||||
|
- Layout: ensure mobile-responsive design
|
||||||
|
- HTML: use semantic elements when appropriate
|
||||||
|
|
||||||
|
## TODO List
|
||||||
|
|
||||||
|
### Next Steps
|
||||||
|
- [ ] Test system with actual RSS feeds and torrents
|
||||||
|
- [ ] Implement automated testing for key components
|
||||||
|
- [ ] Add advanced content detection features
|
||||||
|
- [ ] Enhance UI with visual download statistics
|
||||||
|
- [ ] Add more notification options (email, messaging platforms)
|
||||||
|
|
||||||
|
### Improvements
|
||||||
|
- [ ] Add user preference settings for automatic downloads
|
||||||
|
- [ ] Implement batch operations for torrent management
|
||||||
|
- [ ] Create detailed logging system with rotation
|
||||||
|
- [ ] Add support for multiple transmission instances
|
||||||
|
- [ ] Improve error recovery mechanisms
|
||||||
|
- [ ] Create a mobile-friendly responsive design
|
||||||
|
- [ ] Add dark mode support
|
||||||
|
- [ ] Implement content filtering based on regex patterns
|
||||||
|
- [ ] Add scheduling options for RSS checks
|
||||||
|
- [ ] Create dashboard with download metrics
|
581
README.md
581
README.md
@ -1,115 +1,580 @@
|
|||||||
# Transmission RSS Manager
|
# Transmission RSS Manager v2.0.12
|
||||||
|
|
||||||
A C# application for managing RSS feeds and automatically downloading torrents via Transmission BitTorrent client.
|
A comprehensive web-based tool to automate and manage your Transmission torrent downloads with RSS feed integration, intelligent media organization, and enhanced security features. Now with automatic updates and easy installation!
|
||||||
|
|
||||||
|
## Update System Requirements
|
||||||
|
|
||||||
|
To use the automatic update system, the following requirements must be met:
|
||||||
|
|
||||||
|
1. **Git must be installed:** The update system uses Git to fetch the latest version.
|
||||||
|
2. **Installation must be a Git repository:** Your installation directory must be a Git repository clone.
|
||||||
|
3. **Internet connectivity:** The server must be able to connect to the Git repository.
|
||||||
|
|
||||||
|
If you installed using the bootstrap installer, these requirements should be met automatically. If you experience issues with the update system, please ensure Git is properly installed and accessible to the application.
|
||||||
|
|
||||||
|
## Changelog
|
||||||
|
|
||||||
|
### v2.0.12 (2025-03-10)
|
||||||
|
- **Fixed**: Removed persistent floating update notification that wouldn't disappear
|
||||||
|
- **Fixed**: Major localStorage cleanup to prevent notification state persistence
|
||||||
|
- **Improved**: Complete rewrite of update notification system to use dashboard-only alerts
|
||||||
|
- **Improved**: Added DOM cleanup to remove any rogue notification elements
|
||||||
|
|
||||||
|
### v2.0.11 (2025-03-10)
|
||||||
|
- **Fixed**: Fixed update button persistence with advanced floating notification
|
||||||
|
- **Fixed**: Resolved version display issues with direct package.json reading
|
||||||
|
- **Fixed**: Improved update process for better version reporting
|
||||||
|
- **Fixed**: Resolved conflict between test mode and actual update status
|
||||||
|
- **Added**: Refresh button on update notification for easier version checking
|
||||||
|
- **Added**: Clear test mode indicators to prevent confusion
|
||||||
|
- **Improved**: Enhanced cache busting for more reliable version checking
|
||||||
|
- **Improved**: Better user feedback during update process
|
||||||
|
|
||||||
|
### v2.0.10 (2025-03-10)
|
||||||
|
- **Fixed**: Fixed "fs.existsSync is not a function" error in update check
|
||||||
|
- **Improved**: Better error handling for git repository checks
|
||||||
|
- **Improved**: More robust file system operations for update detection
|
||||||
|
|
||||||
|
### v2.0.9 (2025-03-07)
|
||||||
|
- **Fixed**: Update button now appears properly on dashboard
|
||||||
|
- **Fixed**: Remote Transmission connection issues resolved
|
||||||
|
- **Fixed**: Improved connection test with better error handling
|
||||||
|
- **Added**: System status and update endpoints for version checking
|
||||||
|
- **Improved**: Update detection and notification on dashboard
|
||||||
|
- **Improved**: Better error handling for git operations
|
||||||
|
|
||||||
|
### v2.0.8 (2025-03-07)
|
||||||
|
- **Fixed**: Module import issues on fresh installations with proper file extension handling
|
||||||
|
- **Fixed**: Adding compatibility symlinks for different module naming styles
|
||||||
|
- **Improved**: Server.js now uses consistent module import paths with .js extensions
|
||||||
|
- **Improved**: More robust module file handling in the installer
|
||||||
|
|
||||||
|
### v2.0.7 (2025-03-07)
|
||||||
|
- **Fixed**: Installation directory handling with prompt for choosing install path
|
||||||
|
- **Fixed**: Bootstrap-installer now defaults to /opt/trans-install with user configuration option
|
||||||
|
- **Improved**: Better detection and usage of existing installation directories during updates
|
||||||
|
- **Improved**: Updated documentation to clarify default installation paths
|
||||||
|
|
||||||
|
### v2.0.6 (2025-03-05)
|
||||||
|
- **Added**: Non-interactive mode support for scripted installations
|
||||||
|
- **Improved**: Remote Transmission configuration collection in install-script.sh
|
||||||
|
- **Improved**: Better handling of piped input for remote configuration details
|
||||||
|
- **Improved**: Added debug output to help troubleshoot configuration issues
|
||||||
|
- **Note**: When updating an existing installation, manual configuration of remote settings through the web interface may still be required
|
||||||
|
|
||||||
|
### v2.0.5 (2025-03-05)
|
||||||
|
- **Fixed**: Config file now properly stored in /etc/transmission-rss-manager directory
|
||||||
|
- **Fixed**: Remote Transmission detection in install-script.sh
|
||||||
|
- **Improved**: Enhanced symlink handling between installation dir and config dir
|
||||||
|
- **Improved**: Better environment variable passing between install scripts
|
||||||
|
|
||||||
|
### v2.0.4 (2025-03-05)
|
||||||
|
- **Fixed**: Remote transmission detection in installer
|
||||||
|
- **Fixed**: Configuration directory creation and permissions
|
||||||
|
- **Fixed**: Config file symlink now ensures application finds correct config location
|
||||||
|
|
||||||
|
### v2.0.3 (2025-03-05)
|
||||||
|
- **New**: Configuration file now stored in `/etc/transmission-rss-manager/`
|
||||||
|
- **Improved**: Installer creates config directory with proper permissions
|
||||||
|
- **Improved**: Application now stores path to installation directory for easier updates
|
||||||
|
|
||||||
|
### v2.0.2 (2025-03-05)
|
||||||
|
- **Fixed**: Bug in installer that would prompt to install Transmission for remote configurations
|
||||||
|
- **Improved**: Better detection of remote vs local Transmission installations
|
||||||
|
- **Improved**: Installation process now checks the TRANSMISSION_REMOTE flag correctly
|
||||||
|
|
||||||
|
### v2.0.1 (2025-03-05)
|
||||||
|
- **New**: Automatic detection of existing installations for seamless updates
|
||||||
|
- **Improved**: Enhanced update process that preserves existing configurations
|
||||||
|
- **Improved**: Installer now dynamically reads version from package.json
|
||||||
|
|
||||||
|
### v2.0.0 (2025-03-05)
|
||||||
|
- **Major**: Completely redesigned installation system using git
|
||||||
|
- **New**: One-click update feature with version checking
|
||||||
|
- **New**: Automatic Transmission detection and installation
|
||||||
|
- **New**: System status dashboard with update notifications
|
||||||
|
- **New**: About page with developer information
|
||||||
|
- **Improved**: Better error handling for Transmission connection
|
||||||
|
- **Fixed**: Various UI bugs and responsiveness issues
|
||||||
|
|
||||||
|
### v1.2.0 (2025-02-15)
|
||||||
|
- **New**: Enhanced media organization with better content detection
|
||||||
|
- **New**: Improved post-processor with configurable filtering
|
||||||
|
- **Improved**: Transmission client integration with better error handling
|
||||||
|
- **Improved**: UI enhancements for better usability
|
||||||
|
- **Fixed**: RSS feed parsing issues
|
||||||
|
- **Fixed**: Authentication problems in certain configurations
|
||||||
|
|
||||||
|
### v1.0.0 (2025-01-10)
|
||||||
|
- **Initial Release**: Basic functionality for RSS feed monitoring
|
||||||
|
- **New**: Integration with Transmission BitTorrent client
|
||||||
|
- **New**: Web interface for managing torrents and feeds
|
||||||
|
- **New**: Post-processing capability for downloaded content
|
||||||
|
- **New**: Basic content categorization by media type
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- Monitor multiple RSS feeds for new torrents
|
- 🔄 **RSS Feed Integration**: Automatically download torrents from RSS feeds with customizable filters
|
||||||
- Apply regex-based rules to automatically match and download content
|
- 📊 **Torrent Management**: Monitor and control your Transmission torrents from a clean web interface
|
||||||
- Manage Transmission torrents through a user-friendly web interface
|
- 📚 **Intelligent Media Organization**: Automatically categorize and organize downloads by media type
|
||||||
- Post-processing of completed downloads (extract archives, organize media files)
|
- 📖 **Book & Magazine Sorting**: Specialized processing for e-books and magazines with metadata extraction
|
||||||
- Responsive web UI for desktop and mobile use
|
- 📂 **Post-Processing**: Extract archives, rename files, and move content to appropriate directories
|
||||||
|
- 🔄 **Remote Support**: Connect to remote Transmission instances with local path mapping
|
||||||
## Requirements
|
- 🔒 **Enhanced Security**: Authentication, HTTPS support, and secure password storage
|
||||||
|
- 📱 **Mobile-Friendly UI**: Responsive design works on desktop and mobile devices
|
||||||
- .NET 7.0 or higher
|
- 🔧 **One-Click Updates**: Built-in version checking and automated update system
|
||||||
- Transmission BitTorrent client (with remote access enabled)
|
- 🚀 **Automatic Transmission Installation**: Installs and configures Transmission if needed
|
||||||
- Linux OS (tested on Ubuntu, Debian, Fedora, Arch)
|
|
||||||
- Dependencies: unzip, p7zip, unrar (for post-processing)
|
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
|
### Known Issues
|
||||||
|
|
||||||
|
- When updating an existing installation with Remote Transmission settings, you may need to manually configure the remote settings through the web interface after installation.
|
||||||
|
- The installer will properly detect and collect remote Transmission settings, but they may not be applied to the configuration file in update mode.
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Ubuntu/Debian-based system (may work on other Linux distributions)
|
||||||
|
- Git 2.25.0 or later (will be automatically installed by the bootstrap installer if needed)
|
||||||
|
- Required for the automatic update system
|
||||||
|
- Must be available in the PATH of the user running the application
|
||||||
|
- Must have proper permissions to access the installation directory
|
||||||
|
- Internet connection (for downloading and updates)
|
||||||
|
- Required for Git operations during updates (fetching latest code)
|
||||||
|
- Outbound access to git.powerdata.dk repository server
|
||||||
|
|
||||||
|
### System Requirements
|
||||||
|
|
||||||
|
- Memory: 512MB minimum, 1GB recommended
|
||||||
|
- CPU: Any modern processor (1GHz+)
|
||||||
|
- Disk: At least 200MB for the application, plus storage space for your media
|
||||||
|
- Network: Internet connection for RSS feed fetching and torrent downloading
|
||||||
|
|
||||||
|
### Installation Directory
|
||||||
|
|
||||||
|
**Note**: By default, the application will be installed to `/opt/trans-install`. During installation, you'll be prompted to choose a different directory if needed. If you're updating an existing installation, the installer will detect and use your current installation path.
|
||||||
|
|
||||||
### Automatic Installation
|
### Automatic Installation
|
||||||
|
|
||||||
Run the installer script:
|
The easiest way to install Transmission RSS Manager is with the bootstrap installer:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -sSL https://raw.githubusercontent.com/yourusername/transmission-rss-manager/main/install-script.sh | bash
|
# Download the bootstrap installer
|
||||||
|
wget https://git.powerdata.dk/masterdraco/transmission-rss-manager/raw/main/bootstrap-installer.sh
|
||||||
|
|
||||||
|
# Make it executable
|
||||||
|
chmod +x bootstrap-installer.sh
|
||||||
|
|
||||||
|
# Run it with sudo
|
||||||
|
sudo ./bootstrap-installer.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
Or if you've cloned the repository:
|
The bootstrap installer will:
|
||||||
|
1. Install git if needed
|
||||||
|
2. Clone the latest version from the repository
|
||||||
|
3. Run the main installer which guides you through configuration
|
||||||
|
4. Set up the service and web interface
|
||||||
|
|
||||||
```bash
|
**New in v2.0.0:**
|
||||||
./src/Infrastructure/install-script.sh
|
- The installer now detects if Transmission is installed and offers to install and configure it automatically if needed.
|
||||||
```
|
- The installer automatically detects existing installations and runs in update mode, preserving your existing configuration.
|
||||||
|
- When updating, only necessary files are modified while maintaining your custom settings and preferences.
|
||||||
|
|
||||||
### Manual Installation
|
### Manual Installation
|
||||||
|
|
||||||
1. Install .NET 7.0 SDK from [Microsoft's website](https://dotnet.microsoft.com/download)
|
If you prefer to install manually:
|
||||||
2. Clone the repository:
|
|
||||||
|
1. Clone the repository:
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/yourusername/transmission-rss-manager.git
|
git clone https://git.powerdata.dk/masterdraco/transmission-rss-manager.git
|
||||||
cd transmission-rss-manager
|
cd transmission-rss-manager
|
||||||
```
|
```
|
||||||
3. Build and run the application:
|
|
||||||
|
2. Install dependencies:
|
||||||
```bash
|
```bash
|
||||||
dotnet build -c Release
|
npm install
|
||||||
dotnet run
|
```
|
||||||
|
|
||||||
|
3. Configure settings:
|
||||||
|
```bash
|
||||||
|
cp config.example.json config.json
|
||||||
|
nano config.json
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Start the server:
|
||||||
|
```bash
|
||||||
|
# Using the convenience script (recommended)
|
||||||
|
./scripts/test-and-start.sh
|
||||||
|
|
||||||
|
# Or start with debug logging
|
||||||
|
./scripts/test-and-start.sh --debug
|
||||||
|
|
||||||
|
# Or run in foreground mode
|
||||||
|
./scripts/test-and-start.sh --foreground
|
||||||
|
|
||||||
|
# Or start directly
|
||||||
|
node server.js
|
||||||
```
|
```
|
||||||
4. Open a web browser and navigate to: `http://localhost:5000`
|
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
After starting the application for the first time, a configuration file will be created at `~/.config/transmission-rss-manager/config.json`.
|
### Main Configuration Options
|
||||||
|
|
||||||
You can configure the application through the web interface or by directly editing the configuration file.
|
The system can be configured through the web interface or by editing the configuration file, which is located at `/etc/transmission-rss-manager/config.json` (or at the application's installation directory as a fallback):
|
||||||
|
|
||||||
### Key configuration options
|
```json
|
||||||
|
{
|
||||||
|
"installPath": "/opt/transmission-rss-manager",
|
||||||
|
"transmissionConfig": {
|
||||||
|
"host": "localhost",
|
||||||
|
"port": 9091,
|
||||||
|
"username": "transmission",
|
||||||
|
"password": "password",
|
||||||
|
"path": "/transmission/rpc"
|
||||||
|
},
|
||||||
|
"destinationPaths": {
|
||||||
|
"movies": "/mnt/media/movies",
|
||||||
|
"tvShows": "/mnt/media/tvshows",
|
||||||
|
"music": "/mnt/media/music",
|
||||||
|
"books": "/mnt/media/books",
|
||||||
|
"magazines": "/mnt/media/magazines",
|
||||||
|
"software": "/mnt/media/software"
|
||||||
|
},
|
||||||
|
"processingOptions": {
|
||||||
|
"enableBookSorting": true,
|
||||||
|
"extractArchives": true,
|
||||||
|
"renameFiles": true,
|
||||||
|
"ignoreSample": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
- **Transmission settings**: Host, port, username, password
|
### Remote Transmission Setup
|
||||||
- **RSS feed checking interval**
|
|
||||||
- **Auto-download settings**
|
For remote Transmission instances, configure the directory mapping:
|
||||||
- **Post-processing options**
|
|
||||||
- **Download and media library directories**
|
```json
|
||||||
|
"remoteConfig": {
|
||||||
|
"isRemote": true,
|
||||||
|
"directoryMapping": {
|
||||||
|
"/var/lib/transmission-daemon/downloads": "/mnt/transmission-downloads"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This maps paths between your remote Transmission server and the local directories.
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
### Managing RSS Feeds
|
### Web Interface
|
||||||
|
|
||||||
1. Add RSS feeds through the web interface
|
The web interface provides access to all functionality and is available at:
|
||||||
2. Create regex rules for each feed to match desired content
|
```
|
||||||
3. Enable auto-download for feeds you want to process automatically
|
http://your-server-ip
|
||||||
|
```
|
||||||
|
|
||||||
|
### RSS Feed Management
|
||||||
|
|
||||||
|
1. Go to the "RSS Feeds" tab in the web interface
|
||||||
|
2. Click "Add Feed" and enter the RSS feed URL
|
||||||
|
3. Configure optional filters for automatic downloads
|
||||||
|
4. The system will periodically check feeds and download matching items
|
||||||
|
|
||||||
### Managing Torrents
|
### Managing Torrents
|
||||||
|
|
||||||
- Add torrents manually via URL or magnet link
|
From the "Torrents" tab, you can:
|
||||||
- View, start, stop, and remove torrents
|
- Add new torrents via URL or magnet link
|
||||||
- Process completed torrents to extract archives and organize media
|
- Start, stop, or delete existing torrents
|
||||||
|
- Monitor download progress and stats
|
||||||
|
|
||||||
## Development
|
### Media Organization
|
||||||
|
|
||||||
### Building from source
|
The post-processor automatically:
|
||||||
|
1. Waits for torrents to complete and meet seeding requirements
|
||||||
|
2. Identifies the media type based on content analysis
|
||||||
|
3. Extracts archives if needed
|
||||||
|
4. Moves files to the appropriate category directory
|
||||||
|
5. Renames files according to media type conventions
|
||||||
|
6. Updates the media library for browsing
|
||||||
|
|
||||||
|
### Book & Magazine Sorting
|
||||||
|
|
||||||
|
When enabled, the system can:
|
||||||
|
- Differentiate between books and magazines
|
||||||
|
- Extract author information from book filenames
|
||||||
|
- Organize magazines by title and issue number
|
||||||
|
- Create appropriate folder structures
|
||||||
|
|
||||||
|
## Detailed Features
|
||||||
|
|
||||||
|
### Automatic Media Detection and Processing
|
||||||
|
|
||||||
|
The system uses sophisticated detection to categorize downloads:
|
||||||
|
|
||||||
|
- **Movies**: Recognizes common patterns such as resolution (1080p, 720p) and release year
|
||||||
|
- **TV Shows**: Identifies season/episode patterns (S01E01) and TV-specific naming
|
||||||
|
- **Music**: Detects audio formats like MP3, FLAC, album folders
|
||||||
|
- **Books**: Identifies e-book formats (EPUB, MOBI, PDF) and author-title patterns
|
||||||
|
- **Magazines**: Recognizes magazine naming patterns, issues, volumes, and publication dates
|
||||||
|
- **Software**: Detects software installers, ISOs, and other program files
|
||||||
|
|
||||||
|
### Enhanced Post-Processing
|
||||||
|
|
||||||
|
The post-processor automatically processes completed torrents that have met seeding requirements:
|
||||||
|
|
||||||
|
- **Smart File Categorization**: Automatically detects media type based on content analysis
|
||||||
|
- **Intelligent Folder Organization**: Creates category-specific directories and file structures
|
||||||
|
- **Archive Extraction**: Automatically extracts compressed files (.zip, .rar, .7z, etc.)
|
||||||
|
- **File Renaming**: Cleans up filenames by removing dots, underscores, and other unwanted characters
|
||||||
|
- **Quality Management**: Optionally replace existing files with better quality versions
|
||||||
|
- **Seeding Requirements**: Configurable minimum ratio and seeding time before processing
|
||||||
|
|
||||||
|
### Robust Transmission Integration
|
||||||
|
|
||||||
|
The improved Transmission client integration provides:
|
||||||
|
|
||||||
|
- **Enhanced Error Handling**: Automatic retry on connection failures
|
||||||
|
- **Media Information**: Deep analysis of torrent content for better categorization
|
||||||
|
- **Remote Support**: Comprehensive path mapping between remote and local systems
|
||||||
|
- **Torrent Management**: Complete control over torrents (add, start, stop, remove)
|
||||||
|
- **Performance Monitoring**: Track download/upload speeds and other performance metrics
|
||||||
|
|
||||||
|
### RSS Feed Filtering
|
||||||
|
|
||||||
|
Powerful filtering options for RSS feeds:
|
||||||
|
|
||||||
|
- **Title matching**: Regular expression support for title patterns
|
||||||
|
- **Category filtering**: Filter by feed categories
|
||||||
|
- **Size limits**: Set minimum and maximum size requirements
|
||||||
|
- **Custom rules**: Combine multiple criteria for precise matching
|
||||||
|
|
||||||
|
### Remote Transmission Support
|
||||||
|
|
||||||
|
Full support for remote Transmission instances:
|
||||||
|
|
||||||
|
- **Secure authentication**: Username/password protection
|
||||||
|
- **Path mapping**: Configure how remote paths map to local directories
|
||||||
|
- **Full API support**: Complete control via the web interface
|
||||||
|
|
||||||
|
## Updating
|
||||||
|
|
||||||
|
### Using the Web Interface (New in v2.0.0)
|
||||||
|
|
||||||
|
The easiest way to update is through the web interface:
|
||||||
|
|
||||||
|
1. Navigate to the Dashboard
|
||||||
|
2. Look for the "System Status" section
|
||||||
|
3. If an update is available, an "Update Now" button will appear
|
||||||
|
4. Click the button to automatically update to the latest version
|
||||||
|
|
||||||
|
The system will:
|
||||||
|
- Back up your configuration
|
||||||
|
- Pull the latest code from the repository
|
||||||
|
- Install any new dependencies
|
||||||
|
- Restore your configuration
|
||||||
|
- Restart the service automatically
|
||||||
|
|
||||||
|
### Troubleshooting Update Issues
|
||||||
|
|
||||||
|
If you encounter "Failed to connect to server" or similar errors when checking for updates:
|
||||||
|
|
||||||
|
1. **Verify Git Installation**: Ensure Git is properly installed
|
||||||
|
```bash
|
||||||
|
which git
|
||||||
|
git --version # Should be 2.25.0 or higher
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Check Repository Status**: Verify the installation directory is a Git repository
|
||||||
|
```bash
|
||||||
|
cd /opt/transmission-rss-manager # Or your installation directory
|
||||||
|
git status
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Check Internet Connectivity**: Make sure the server can connect to git.powerdata.dk
|
||||||
|
```bash
|
||||||
|
ping git.powerdata.dk
|
||||||
|
curl -I https://git.powerdata.dk
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Check Permissions**: Ensure the application user has access to the Git repository
|
||||||
|
```bash
|
||||||
|
# Check ownership of the .git directory
|
||||||
|
ls -la /opt/transmission-rss-manager/.git
|
||||||
|
# If needed, fix permissions
|
||||||
|
sudo chown -R www-data:www-data /opt/transmission-rss-manager # Adjust user as needed
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Manual Update**: If the web update still fails, try the manual update method from the command line
|
||||||
|
|
||||||
|
### Using the Installer
|
||||||
|
|
||||||
|
You can also update by running the installer again:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
dotnet build
|
# Navigate to the installation directory
|
||||||
|
cd /opt/transmission-rss-manager
|
||||||
|
|
||||||
|
# Run the installer with sudo
|
||||||
|
sudo ./main-installer.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
### Running in development mode
|
The installer will:
|
||||||
|
1. Detect your existing installation automatically
|
||||||
|
2. Run in update mode
|
||||||
|
3. Preserve all your existing configurations
|
||||||
|
4. Update only the necessary files
|
||||||
|
5. Restart the service with your updated installation
|
||||||
|
|
||||||
|
### Using the Command Line
|
||||||
|
|
||||||
|
If you prefer to update via command line:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
dotnet run
|
cd /opt/transmission-rss-manager
|
||||||
|
sudo scripts/update.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
### Creating a release
|
Use the `--force` flag to force an update of dependencies even if no code changes are detected:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
dotnet publish -c Release
|
sudo scripts/update.sh --force
|
||||||
```
|
```
|
||||||
|
|
||||||
## Architecture
|
### Manual Update
|
||||||
|
|
||||||
The application is built using ASP.NET Core with the following components:
|
Alternatively, you can download and run the update script:
|
||||||
|
|
||||||
- **Web API**: REST endpoints for the web interface
|
```bash
|
||||||
- **Background Services**: RSS feed checking and post-processing
|
wget https://git.powerdata.dk/masterdraco/transmission-rss-manager/raw/main/scripts/update.sh
|
||||||
- **Core Services**: Configuration, Transmission communication, RSS parsing
|
chmod +x update.sh
|
||||||
|
sudo ./update.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
transmission-rss-manager/
|
||||||
|
├── server.js # Main application server
|
||||||
|
├── modules/ # Modular components
|
||||||
|
│ ├── post-processor.js # Media processing module
|
||||||
|
│ ├── rss-feed-manager.js # RSS feed management module
|
||||||
|
│ ├── transmission-client.js # Transmission API integration
|
||||||
|
│ ├── config-module.sh # Installation configuration
|
||||||
|
│ ├── dependencies-module.sh # Dependency installation
|
||||||
|
│ ├── file-creator-module.sh # File creation for installation
|
||||||
|
│ ├── service-setup-module.sh # Service setup
|
||||||
|
│ └── utils-module.sh # Utility functions
|
||||||
|
├── bootstrap-installer.sh # Minimal installer that clones the repository
|
||||||
|
├── main-installer.sh # Main installation script
|
||||||
|
├── config.json # Configuration file
|
||||||
|
├── scripts/ # Utility scripts
|
||||||
|
│ ├── update.sh # Update script
|
||||||
|
│ └── test-and-start.sh # Test and start script
|
||||||
|
├── public/ # Web interface files
|
||||||
|
│ ├── index.html # Main web interface
|
||||||
|
│ ├── js/ # JavaScript files
|
||||||
|
│ │ ├── app.js # Core application logic
|
||||||
|
│ │ └── utils.js # Utility functions
|
||||||
|
│ └── css/ # CSS stylesheets
|
||||||
|
│ └── styles.css # Main stylesheet
|
||||||
|
└── README.md # This file
|
||||||
|
```
|
||||||
|
|
||||||
|
## Modules
|
||||||
|
|
||||||
|
### Post-Processor
|
||||||
|
|
||||||
|
The Post-Processor module handles:
|
||||||
|
- Monitoring completed torrents
|
||||||
|
- Categorizing content by type
|
||||||
|
- Extracting archives
|
||||||
|
- Organizing files into the correct directories
|
||||||
|
- Renaming files according to conventions
|
||||||
|
|
||||||
|
### RSS Feed Manager
|
||||||
|
|
||||||
|
The RSS Feed Manager module provides:
|
||||||
|
- Regular checking of configured RSS feeds
|
||||||
|
- Filtering of feed items based on rules
|
||||||
|
- Automated downloading of matching content
|
||||||
|
- History tracking of downloaded items
|
||||||
|
|
||||||
|
## Advanced Configuration
|
||||||
|
|
||||||
|
### Seeding Requirements
|
||||||
|
|
||||||
|
Set minimum seeding requirements before processing:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"seedingRequirements": {
|
||||||
|
"minRatio": 1.0,
|
||||||
|
"minTimeMinutes": 60,
|
||||||
|
"checkIntervalSeconds": 300
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Security Settings
|
||||||
|
|
||||||
|
Enable authentication and HTTPS for secure access:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"securitySettings": {
|
||||||
|
"authEnabled": true,
|
||||||
|
"httpsEnabled": true,
|
||||||
|
"sslCertPath": "/path/to/ssl/cert.pem",
|
||||||
|
"sslKeyPath": "/path/to/ssl/key.pem",
|
||||||
|
"users": [
|
||||||
|
{
|
||||||
|
"username": "admin",
|
||||||
|
"password": "your-hashed-password",
|
||||||
|
"role": "admin"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"username": "user",
|
||||||
|
"password": "your-hashed-password",
|
||||||
|
"role": "user"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
*Note: Passwords are automatically hashed on first login if provided in plain text.*
|
||||||
|
|
||||||
|
### Processing Options
|
||||||
|
|
||||||
|
Customize how files are processed:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"processingOptions": {
|
||||||
|
"enableBookSorting": true,
|
||||||
|
"extractArchives": true,
|
||||||
|
"deleteArchives": true,
|
||||||
|
"createCategoryFolders": true,
|
||||||
|
"ignoreSample": true,
|
||||||
|
"ignoreExtras": true,
|
||||||
|
"renameFiles": true,
|
||||||
|
"autoReplaceUpgrades": true,
|
||||||
|
"removeDuplicates": true,
|
||||||
|
"keepOnlyBestVersion": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
Contributions are welcome! Here's how you can help:
|
||||||
|
|
||||||
|
1. Fork the repository
|
||||||
|
2. Create a feature branch (`git checkout -b feature/amazing-feature`)
|
||||||
|
3. Commit your changes (`git commit -m 'Add some amazing feature'`)
|
||||||
|
4. Push to the branch (`git push origin feature/amazing-feature`)
|
||||||
|
5. Open a Pull Request
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
This project is licensed under the MIT License - see the LICENSE file for details.
|
This project is licensed under the MIT License.
|
||||||
|
|
||||||
## Acknowledgments
|
## Acknowledgments
|
||||||
|
|
||||||
- [Transmission](https://transmissionbt.com/) - BitTorrent client
|
- [Transmission](https://transmissionbt.com/) for the excellent BitTorrent client
|
||||||
- [ASP.NET Core](https://docs.microsoft.com/en-us/aspnet/core/) - Web framework
|
- [Node.js](https://nodejs.org/) and the npm community for the foundation libraries
|
||||||
- [Bootstrap](https://getbootstrap.com/) - UI framework
|
- All contributors who have helped improve this project
|
||||||
|
@ -1,25 +0,0 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
|
||||||
|
|
||||||
<PropertyGroup>
|
|
||||||
<TargetFramework>net7.0</TargetFramework>
|
|
||||||
<RootNamespace>TransmissionRssManager</RootNamespace>
|
|
||||||
<ImplicitUsings>disable</ImplicitUsings>
|
|
||||||
<Nullable>enable</Nullable>
|
|
||||||
<Version>1.0.0</Version>
|
|
||||||
<Authors>TransmissionRssManager</Authors>
|
|
||||||
<Description>A C# application to manage RSS feeds and automatically download torrents via Transmission</Description>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="7.0.7" />
|
|
||||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" />
|
|
||||||
<PackageReference Include="System.ServiceModel.Syndication" Version="7.0.0" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<None Update="wwwroot\**\*">
|
|
||||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
|
||||||
</None>
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
</Project>
|
|
92
bootstrap-installer.sh
Executable file
92
bootstrap-installer.sh
Executable file
@ -0,0 +1,92 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Transmission RSS Manager - Bootstrap Installer
|
||||||
|
# This script downloads the latest version from git and runs the setup
|
||||||
|
|
||||||
|
# Color and formatting
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
RED='\033[0;31m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
BOLD='\033[1m'
|
||||||
|
|
||||||
|
# Installation directory
|
||||||
|
DEFAULT_INSTALL_DIR="/opt/trans-install"
|
||||||
|
REPO_URL="https://git.powerdata.dk/masterdraco/transmission-rss-manager.git"
|
||||||
|
|
||||||
|
# Ask for installation directory
|
||||||
|
echo -e "${YELLOW}Where would you like to install Transmission RSS Manager?${NC}"
|
||||||
|
read -p "Installation directory [$DEFAULT_INSTALL_DIR]: " input_install_dir
|
||||||
|
INSTALL_DIR=${input_install_dir:-$DEFAULT_INSTALL_DIR}
|
||||||
|
|
||||||
|
# Check if running as root
|
||||||
|
if [ "$EUID" -ne 0 ]; then
|
||||||
|
echo -e "${RED}This script must be run as root or with sudo privileges.${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Display welcome message
|
||||||
|
echo -e "${GREEN}${BOLD}Transmission RSS Manager - Bootstrap Installer${NC}"
|
||||||
|
echo -e "This script will install the latest version from the git repository."
|
||||||
|
echo -e "The default installation directory is ${BOLD}/opt/trans-install${NC}, but you can choose a different location."
|
||||||
|
echo
|
||||||
|
|
||||||
|
# Check for git installation
|
||||||
|
echo -e "${YELLOW}Checking dependencies...${NC}"
|
||||||
|
if ! command -v git &> /dev/null; then
|
||||||
|
echo -e "Git not found. Installing git..."
|
||||||
|
apt-get update
|
||||||
|
apt-get install -y git
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if installation directory exists
|
||||||
|
if [ -d "$INSTALL_DIR" ]; then
|
||||||
|
echo -e "${YELLOW}Installation directory already exists.${NC}"
|
||||||
|
read -p "Do you want to remove it and perform a fresh install? (y/n): " choice
|
||||||
|
if [[ "$choice" =~ ^[Yy]$ ]]; then
|
||||||
|
echo "Removing existing installation..."
|
||||||
|
rm -rf "$INSTALL_DIR"
|
||||||
|
else
|
||||||
|
echo -e "${RED}Installation aborted.${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create installation directory
|
||||||
|
echo -e "${YELLOW}Creating installation directory...${NC}"
|
||||||
|
mkdir -p "$INSTALL_DIR"
|
||||||
|
|
||||||
|
# Clone the repository
|
||||||
|
echo -e "${YELLOW}Cloning the latest version from git...${NC}"
|
||||||
|
git clone "$REPO_URL" "$INSTALL_DIR"
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo -e "${RED}Failed to clone the repository.${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Run the main installer
|
||||||
|
echo -e "${YELLOW}Running the main installer...${NC}"
|
||||||
|
cd "$INSTALL_DIR"
|
||||||
|
chmod +x main-installer.sh
|
||||||
|
|
||||||
|
# Let the user know what's about to happen
|
||||||
|
echo -e "${YELLOW}You will now be asked for configuration options including:${NC}"
|
||||||
|
echo -e "- Installation directory"
|
||||||
|
echo -e "- User to run the service as"
|
||||||
|
echo -e "- Whether Transmission is running on a remote server"
|
||||||
|
echo -e "- Transmission connection details"
|
||||||
|
echo -e "- Media organization preferences"
|
||||||
|
echo
|
||||||
|
|
||||||
|
# Add small delay to ensure user sees this message
|
||||||
|
sleep 2
|
||||||
|
|
||||||
|
# Force the installer to run with the right environment
|
||||||
|
env -i PATH="$PATH" TERM="$TERM" USER="$USER" HOME="$HOME" SUDO_USER="$SUDO_USER" ./main-installer.sh
|
||||||
|
|
||||||
|
# Installation complete
|
||||||
|
echo -e "${GREEN}${BOLD}Bootstrap installation complete!${NC}"
|
||||||
|
echo -e "Transmission RSS Manager has been installed in $INSTALL_DIR"
|
||||||
|
echo -e "You can access the web interface at http://localhost:3000"
|
||||||
|
echo
|
||||||
|
echo -e "To update in the future, use the update button in the System Status section of the web interface."
|
582
main-installer.sh
Executable file
582
main-installer.sh
Executable file
@ -0,0 +1,582 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Transmission RSS Manager Modular Installer
|
||||||
|
# Modified to work with the git-based approach
|
||||||
|
|
||||||
|
# Set script to exit on error
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Load installation environment variables if they exist
|
||||||
|
if [ -f "$(dirname "$0")/.env.install" ]; then
|
||||||
|
source "$(dirname "$0")/.env.install"
|
||||||
|
echo "Loaded TRANSMISSION_REMOTE=$TRANSMISSION_REMOTE from environment file"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Text formatting
|
||||||
|
BOLD='\033[1m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[0;33m'
|
||||||
|
RED='\033[0;31m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
# Get current directory
|
||||||
|
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )"
|
||||||
|
|
||||||
|
# Source the utils module first to make the log function available
|
||||||
|
source "${SCRIPT_DIR}/modules/utils-module.sh"
|
||||||
|
|
||||||
|
# Print header
|
||||||
|
echo -e "${BOLD}==================================================${NC}"
|
||||||
|
echo -e "${BOLD} Transmission RSS Manager Installer ${NC}"
|
||||||
|
VERSION=$(grep -oP '"version": "\K[^"]+' "${SCRIPT_DIR}/package.json" 2>/dev/null || echo "Unknown")
|
||||||
|
# Check if package.json exists, if not suggest creating it
|
||||||
|
if [ ! -f "${SCRIPT_DIR}/package.json" ]; then
|
||||||
|
echo -e "${YELLOW}Warning: package.json not found. You may need to run 'npm init' first.${NC}"
|
||||||
|
fi
|
||||||
|
echo -e "${BOLD} Version ${VERSION} - Git Edition ${NC}"
|
||||||
|
echo -e "${BOLD}==================================================${NC}"
|
||||||
|
echo
|
||||||
|
|
||||||
|
# Check if script is run with sudo
|
||||||
|
if [ "$EUID" -ne 0 ]; then
|
||||||
|
echo -e "${RED}Please run as root (use sudo)${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check for installation type
|
||||||
|
IS_UPDATE=false
|
||||||
|
INSTALLATION_DETECTED=false
|
||||||
|
|
||||||
|
# Check if we have existing config info from install-script.sh
|
||||||
|
if [ -n "$EXISTING_INSTALL_DIR" ] && [ -n "$EXISTING_CONFIG_PATH" ]; then
|
||||||
|
INSTALLATION_DETECTED=true
|
||||||
|
IS_UPDATE=true
|
||||||
|
# Use the existing installation directory as our target
|
||||||
|
INSTALL_DIR="$EXISTING_INSTALL_DIR"
|
||||||
|
CONFIG_FILE="$EXISTING_CONFIG_PATH"
|
||||||
|
log "INFO" "Using existing installation at $INSTALL_DIR detected by install-script.sh"
|
||||||
|
export INSTALL_DIR
|
||||||
|
else
|
||||||
|
# Check for config.json file (primary indicator)
|
||||||
|
POSSIBLE_CONFIG_LOCATIONS=(
|
||||||
|
"${SCRIPT_DIR}/config.json"
|
||||||
|
"/opt/transmission-rss-manager/config.json"
|
||||||
|
"/etc/transmission-rss-manager/config.json"
|
||||||
|
)
|
||||||
|
|
||||||
|
for CONFIG_PATH in "${POSSIBLE_CONFIG_LOCATIONS[@]}"; do
|
||||||
|
if [ -f "$CONFIG_PATH" ]; then
|
||||||
|
INSTALLATION_DETECTED=true
|
||||||
|
IS_UPDATE=true
|
||||||
|
INSTALL_DIR="$(dirname "$CONFIG_PATH")"
|
||||||
|
CONFIG_FILE="$CONFIG_PATH"
|
||||||
|
log "INFO" "Found existing installation at $INSTALL_DIR"
|
||||||
|
export INSTALL_DIR
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Check for service file (secondary indicator) if no config file found
|
||||||
|
if [ "$INSTALLATION_DETECTED" = "false" ] && [ -f "/etc/systemd/system/transmission-rss-manager.service" ]; then
|
||||||
|
INSTALLATION_DETECTED=true
|
||||||
|
IS_UPDATE=true
|
||||||
|
|
||||||
|
# Extract the installation directory from the service file
|
||||||
|
SERVICE_INSTALL_DIR=$(grep "WorkingDirectory=" "/etc/systemd/system/transmission-rss-manager.service" | cut -d'=' -f2)
|
||||||
|
if [ -n "$SERVICE_INSTALL_DIR" ]; then
|
||||||
|
INSTALL_DIR="$SERVICE_INSTALL_DIR"
|
||||||
|
log "INFO" "Found existing installation at $INSTALL_DIR from service file"
|
||||||
|
export INSTALL_DIR
|
||||||
|
|
||||||
|
# Check for config file in the detected installation directory
|
||||||
|
if [ -f "$INSTALL_DIR/config.json" ]; then
|
||||||
|
CONFIG_FILE="$INSTALL_DIR/config.json"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check for data directory (tertiary indicator)
|
||||||
|
if [ "$INSTALLATION_DETECTED" = "false" ] && [ -d "${SCRIPT_DIR}/data" ] && [ "$(ls -A "${SCRIPT_DIR}/data" 2>/dev/null)" ]; then
|
||||||
|
INSTALLATION_DETECTED=true
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Provide clear feedback about the installation type
|
||||||
|
if [ "$IS_UPDATE" = "true" ]; then
|
||||||
|
log "INFO" "Running in UPDATE mode - will preserve existing configuration"
|
||||||
|
log "INFO" "Target installation directory: $INSTALL_DIR"
|
||||||
|
if [ -n "$CONFIG_FILE" ]; then
|
||||||
|
log "INFO" "Using configuration file: $CONFIG_FILE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Make sure the variables are set correctly
|
||||||
|
echo -e "${YELLOW}Existing installation detected. Running in update mode.${NC}"
|
||||||
|
echo -e "${GREEN}Your existing configuration will be preserved.${NC}"
|
||||||
|
echo -e "${GREEN}Only application files will be updated.${NC}"
|
||||||
|
else
|
||||||
|
log "INFO" "Running in FRESH INSTALL mode"
|
||||||
|
echo -e "${GREEN}Fresh installation. Will create new configuration.${NC}"
|
||||||
|
fi
|
||||||
|
export IS_UPDATE
|
||||||
|
|
||||||
|
# Check if required module files exist
|
||||||
|
REQUIRED_MODULES=(
|
||||||
|
"${SCRIPT_DIR}/modules/config-module.sh"
|
||||||
|
"${SCRIPT_DIR}/modules/utils-module.sh"
|
||||||
|
"${SCRIPT_DIR}/modules/dependencies-module.sh"
|
||||||
|
"${SCRIPT_DIR}/modules/service-setup-module.sh"
|
||||||
|
"${SCRIPT_DIR}/modules/file-creator-module.sh"
|
||||||
|
)
|
||||||
|
|
||||||
|
for module in "${REQUIRED_MODULES[@]}"; do
|
||||||
|
if [ ! -f "$module" ]; then
|
||||||
|
echo -e "${RED}Error: Required module file not found: $module${NC}"
|
||||||
|
echo -e "${YELLOW}The module files should be included in the git repository.${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Source the remaining module files
|
||||||
|
source "${SCRIPT_DIR}/modules/config-module.sh"
|
||||||
|
source "${SCRIPT_DIR}/modules/dependencies-module.sh"
|
||||||
|
# Check if the updated service module exists, use it if available
|
||||||
|
if [ -f "${SCRIPT_DIR}/modules/service-setup-module-updated.sh" ]; then
|
||||||
|
log "INFO" "Using updated service setup module"
|
||||||
|
source "${SCRIPT_DIR}/modules/service-setup-module-updated.sh"
|
||||||
|
else
|
||||||
|
log "INFO" "Using standard service setup module"
|
||||||
|
source "${SCRIPT_DIR}/modules/service-setup-module.sh"
|
||||||
|
fi
|
||||||
|
source "${SCRIPT_DIR}/modules/file-creator-module.sh"
|
||||||
|
|
||||||
|
# Function to handle cleanup on error
|
||||||
|
function cleanup_on_error() {
|
||||||
|
log "ERROR" "Installation failed: $1"
|
||||||
|
log "INFO" "Cleaning up..."
|
||||||
|
|
||||||
|
# Add any cleanup steps here if needed
|
||||||
|
|
||||||
|
log "INFO" "You can try running the installer again after fixing the issues."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Set trap for error handling
|
||||||
|
trap 'cleanup_on_error "$BASH_COMMAND"' ERR
|
||||||
|
|
||||||
|
# Execute the installation steps in sequence
|
||||||
|
log "INFO" "Starting installation process..."
|
||||||
|
|
||||||
|
# Set defaults for key variables
|
||||||
|
export TRANSMISSION_REMOTE=false
|
||||||
|
export CONFIG_DIR=${CONFIG_DIR:-"/etc/transmission-rss-manager"}
|
||||||
|
export USER=${USER:-$(logname || echo $SUDO_USER)}
|
||||||
|
|
||||||
|
if [ "$IS_UPDATE" = true ]; then
|
||||||
|
log "INFO" "Running in update mode - preserving existing configuration..."
|
||||||
|
|
||||||
|
# First, let's check if we already have this value from the environment
|
||||||
|
# This allows for non-interactive usage in scripts
|
||||||
|
if [ -n "$TRANSMISSION_REMOTE" ]; then
|
||||||
|
is_remote=$([ "$TRANSMISSION_REMOTE" = true ] && echo "Remote" || echo "Local")
|
||||||
|
log "INFO" "Using Transmission mode from environment: $is_remote"
|
||||||
|
|
||||||
|
# Set the input_remote variable based on the environment variable
|
||||||
|
# This ensures consistent behavior with the rest of the script
|
||||||
|
if [ "$TRANSMISSION_REMOTE" = true ]; then
|
||||||
|
input_remote="y"
|
||||||
|
else
|
||||||
|
input_remote="n"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
# Directly ask about Transmission
|
||||||
|
# This is a direct approach that bypasses any potential sourcing issues
|
||||||
|
log "INFO" "Configuring Transmission connection..."
|
||||||
|
echo -e "${BOLD}Transmission Configuration:${NC}"
|
||||||
|
echo -e "Configure connection to your Transmission client:"
|
||||||
|
echo
|
||||||
|
|
||||||
|
# If stdin is not a terminal (pipe or redirect), assume default
|
||||||
|
if [ ! -t 0 ]; then
|
||||||
|
input_remote="n" # Default to no
|
||||||
|
log "INFO" "Non-interactive mode detected, using default: local Transmission"
|
||||||
|
else
|
||||||
|
read -p "Is Transmission running on a remote server? (y/n) [n]: " input_remote
|
||||||
|
fi
|
||||||
|
log "INFO" "DEBUG: Input received for remote: '$input_remote'"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# More explicit check for "y" or "Y" input
|
||||||
|
if [ "$input_remote" = "y" ] || [ "$input_remote" = "Y" ]; then
|
||||||
|
export TRANSMISSION_REMOTE=true
|
||||||
|
log "INFO" "Remote Transmission selected."
|
||||||
|
|
||||||
|
# Update the config file directly to set remote mode
|
||||||
|
if [ -f "$CONFIG_DIR/config.json" ]; then
|
||||||
|
log "INFO" "Updating configuration file for remote Transmission..."
|
||||||
|
|
||||||
|
# Log all environment variables we have for debugging
|
||||||
|
log "INFO" "DEBUG: Environment variables for remote configuration:"
|
||||||
|
log "INFO" "DEBUG: TRANSMISSION_HOST=${TRANSMISSION_HOST:-'not set'}"
|
||||||
|
log "INFO" "DEBUG: TRANSMISSION_PORT=${TRANSMISSION_PORT:-'not set'}"
|
||||||
|
log "INFO" "DEBUG: REMOTE_DOWNLOAD_DIR=${REMOTE_DOWNLOAD_DIR:-'not set'}"
|
||||||
|
log "INFO" "DEBUG: LOCAL_DOWNLOAD_DIR=${LOCAL_DOWNLOAD_DIR:-'not set'}"
|
||||||
|
|
||||||
|
# Check if we already have the remote configuration details from the environment
|
||||||
|
if [ -n "$TRANSMISSION_HOST" ] && [ -n "$TRANSMISSION_PORT" ] && [ -n "$REMOTE_DOWNLOAD_DIR" ] && [ -n "$LOCAL_DOWNLOAD_DIR" ]; then
|
||||||
|
log "INFO" "Using remote Transmission configuration from environment"
|
||||||
|
# Values are already set from the environment, no need to ask again
|
||||||
|
else
|
||||||
|
# Get and validate hostname
|
||||||
|
read -p "Remote Transmission host [localhost]: " input_trans_host
|
||||||
|
TRANSMISSION_HOST=${input_trans_host:-"localhost"}
|
||||||
|
|
||||||
|
# Get and validate port
|
||||||
|
read -p "Remote Transmission port [9091]: " input_trans_port
|
||||||
|
TRANSMISSION_PORT=${input_trans_port:-9091}
|
||||||
|
|
||||||
|
# Get credentials
|
||||||
|
read -p "Remote Transmission username []: " input_trans_user
|
||||||
|
TRANSMISSION_USER=${input_trans_user:-""}
|
||||||
|
|
||||||
|
# Use read -s for password to avoid showing it on screen
|
||||||
|
read -s -p "Remote Transmission password []: " input_trans_pass
|
||||||
|
echo # Add a newline after the password input
|
||||||
|
TRANSMISSION_PASS=${input_trans_pass:-""}
|
||||||
|
|
||||||
|
read -p "Remote Transmission RPC path [/transmission/rpc]: " input_trans_path
|
||||||
|
TRANSMISSION_RPC_PATH=${input_trans_path:-"/transmission/rpc"}
|
||||||
|
|
||||||
|
# Configure directory mapping for remote setup
|
||||||
|
echo
|
||||||
|
echo -e "${YELLOW}Directory Mapping Configuration${NC}"
|
||||||
|
echo -e "When using a remote Transmission server, you need to map paths between servers."
|
||||||
|
echo -e "For each directory on the remote server, specify the corresponding local directory."
|
||||||
|
echo
|
||||||
|
|
||||||
|
# Get remote download directory
|
||||||
|
read -p "Remote Transmission download directory [/var/lib/transmission-daemon/downloads]: " REMOTE_DOWNLOAD_DIR
|
||||||
|
REMOTE_DOWNLOAD_DIR=${REMOTE_DOWNLOAD_DIR:-"/var/lib/transmission-daemon/downloads"}
|
||||||
|
|
||||||
|
# Get local directory that corresponds to remote download directory
|
||||||
|
read -p "Local directory that corresponds to the remote download directory [/mnt/transmission-downloads]: " LOCAL_DOWNLOAD_DIR
|
||||||
|
LOCAL_DOWNLOAD_DIR=${LOCAL_DOWNLOAD_DIR:-"/mnt/transmission-downloads"}
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create mapping JSON
|
||||||
|
TRANSMISSION_DIR_MAPPING=$(cat <<EOF
|
||||||
|
{
|
||||||
|
"$REMOTE_DOWNLOAD_DIR": "$LOCAL_DOWNLOAD_DIR"
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create the local directory
|
||||||
|
mkdir -p "$LOCAL_DOWNLOAD_DIR"
|
||||||
|
chown -R $USER:$USER "$LOCAL_DOWNLOAD_DIR"
|
||||||
|
|
||||||
|
# Update the config file with the new remote settings
|
||||||
|
log "INFO" "Updating configuration file with remote Transmission settings..."
|
||||||
|
|
||||||
|
# Backup the original config file
|
||||||
|
cp "$CONFIG_DIR/config.json" "$CONFIG_DIR/config.json.bak.$(date +%Y%m%d%H%M%S)"
|
||||||
|
|
||||||
|
# Update the isRemote setting
|
||||||
|
sed -i 's/"isRemote": false/"isRemote": true/' "$CONFIG_DIR/config.json"
|
||||||
|
|
||||||
|
# Update the host setting
|
||||||
|
sed -i "s/\"host\": \"[^\"]*\"/\"host\": \"$TRANSMISSION_HOST\"/" "$CONFIG_DIR/config.json"
|
||||||
|
|
||||||
|
# Update the port setting
|
||||||
|
sed -i "s/\"port\": [0-9]*/\"port\": $TRANSMISSION_PORT/" "$CONFIG_DIR/config.json"
|
||||||
|
|
||||||
|
# Update the username setting
|
||||||
|
sed -i "s/\"username\": \"[^\"]*\"/\"username\": \"$TRANSMISSION_USER\"/" "$CONFIG_DIR/config.json"
|
||||||
|
|
||||||
|
# Update the password setting
|
||||||
|
sed -i "s/\"password\": \"[^\"]*\"/\"password\": \"$TRANSMISSION_PASS\"/" "$CONFIG_DIR/config.json"
|
||||||
|
|
||||||
|
# Update the RPC path setting
|
||||||
|
sed -i "s|\"path\": \"[^\"]*\"|\"path\": \"$TRANSMISSION_RPC_PATH\"|" "$CONFIG_DIR/config.json"
|
||||||
|
|
||||||
|
# Update the directory mapping
|
||||||
|
# Use a more complex approach since it's a JSON object
|
||||||
|
# This is a simplification and might need improvement for complex JSON handling
|
||||||
|
sed -i "/\"directoryMapping\":/c\\ \"directoryMapping\": $TRANSMISSION_DIR_MAPPING" "$CONFIG_DIR/config.json"
|
||||||
|
|
||||||
|
log "INFO" "Configuration updated for remote Transmission."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
export TRANSMISSION_REMOTE=false
|
||||||
|
log "INFO" "Local Transmission selected."
|
||||||
|
|
||||||
|
# Update the config file directly to set local mode
|
||||||
|
if [ -f "$CONFIG_DIR/config.json" ]; then
|
||||||
|
log "INFO" "Updating configuration file for local Transmission..."
|
||||||
|
# Backup the original config file
|
||||||
|
cp "$CONFIG_DIR/config.json" "$CONFIG_DIR/config.json.bak.$(date +%Y%m%d%H%M%S)"
|
||||||
|
|
||||||
|
# Update the isRemote setting
|
||||||
|
sed -i 's/"isRemote": true/"isRemote": false/' "$CONFIG_DIR/config.json"
|
||||||
|
|
||||||
|
# Update the host setting
|
||||||
|
sed -i 's/"host": "[^"]*"/"host": "localhost"/' "$CONFIG_DIR/config.json"
|
||||||
|
|
||||||
|
log "INFO" "Configuration updated for local Transmission."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Step 1: Check dependencies (but don't reconfigure)
|
||||||
|
log "INFO" "Checking dependencies..."
|
||||||
|
install_dependencies || {
|
||||||
|
log "ERROR" "Dependency check failed"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Step the service configuration (will preserve existing settings)
|
||||||
|
log "INFO" "Updating service configuration..."
|
||||||
|
setup_service || {
|
||||||
|
log "ERROR" "Service update failed"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Install npm dependencies using our common function
|
||||||
|
ensure_npm_packages "$INSTALL_DIR" || {
|
||||||
|
log "ERROR" "NPM installation failed"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Copy JavaScript module files during update as well
|
||||||
|
log "INFO" "Copying JavaScript module files..."
|
||||||
|
copy_module_files || {
|
||||||
|
log "ERROR" "Failed to copy JavaScript module files"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
else
|
||||||
|
# This is a fresh installation - run all steps
|
||||||
|
|
||||||
|
# Step 1: First, let's check if we already have this value from the environment
|
||||||
|
# This allows for non-interactive usage in scripts
|
||||||
|
if [ -n "$TRANSMISSION_REMOTE" ]; then
|
||||||
|
is_remote=$([ "$TRANSMISSION_REMOTE" = true ] && echo "Remote" || echo "Local")
|
||||||
|
log "INFO" "Using Transmission mode from environment: $is_remote"
|
||||||
|
|
||||||
|
# Set the input_remote variable based on the environment variable
|
||||||
|
# This ensures consistent behavior with the rest of the script
|
||||||
|
if [ "$TRANSMISSION_REMOTE" = true ]; then
|
||||||
|
input_remote="y"
|
||||||
|
else
|
||||||
|
input_remote="n"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
# Directly ask about Transmission
|
||||||
|
# This is a direct approach that bypasses any potential sourcing issues
|
||||||
|
log "INFO" "Configuring Transmission connection..."
|
||||||
|
echo -e "${BOLD}Transmission Configuration:${NC}"
|
||||||
|
echo -e "Configure connection to your Transmission client:"
|
||||||
|
echo
|
||||||
|
|
||||||
|
# If stdin is not a terminal (pipe or redirect), assume default
|
||||||
|
if [ ! -t 0 ]; then
|
||||||
|
input_remote="n" # Default to no
|
||||||
|
log "INFO" "Non-interactive mode detected, using default: local Transmission"
|
||||||
|
else
|
||||||
|
read -p "Is Transmission running on a remote server? (y/n) [n]: " input_remote
|
||||||
|
fi
|
||||||
|
log "INFO" "DEBUG: Input received for remote: '$input_remote'"
|
||||||
|
fi
|
||||||
|
# More explicit check for "y" or "Y" input
|
||||||
|
if [ "$input_remote" = "y" ] || [ "$input_remote" = "Y" ]; then
|
||||||
|
export TRANSMISSION_REMOTE=true
|
||||||
|
log "INFO" "Remote Transmission selected."
|
||||||
|
else
|
||||||
|
export TRANSMISSION_REMOTE=false
|
||||||
|
log "INFO" "Local Transmission selected."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Now gather the rest of the configuration
|
||||||
|
log "INFO" "Gathering remaining configuration..."
|
||||||
|
gather_configuration || {
|
||||||
|
log "ERROR" "Configuration gathering failed"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Debug: Verify TRANSMISSION_REMOTE is set
|
||||||
|
log "INFO" "After configuration gathering, TRANSMISSION_REMOTE=$TRANSMISSION_REMOTE"
|
||||||
|
|
||||||
|
# Step 2: Install dependencies
|
||||||
|
log "INFO" "Installing dependencies..."
|
||||||
|
install_dependencies || {
|
||||||
|
log "ERROR" "Dependency installation failed"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Step 3: Create installation directories
|
||||||
|
log "INFO" "Creating directories..."
|
||||||
|
# Make sure CONFIG_DIR is set and exported
|
||||||
|
export CONFIG_DIR=${CONFIG_DIR:-"/etc/transmission-rss-manager"}
|
||||||
|
# Call our new create_directories function
|
||||||
|
create_directories || {
|
||||||
|
log "ERROR" "Directory creation failed"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Step 4: Create configuration files only (no application files since they're from git)
|
||||||
|
log "INFO" "Creating configuration files..."
|
||||||
|
create_config_files || {
|
||||||
|
log "ERROR" "Configuration file creation failed"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Step 5: Create service files and install the service
|
||||||
|
log "INFO" "Setting up service..."
|
||||||
|
setup_service || {
|
||||||
|
log "ERROR" "Service setup failed"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Step 6: Install npm dependencies using our common function
|
||||||
|
ensure_npm_packages "$INSTALL_DIR" || {
|
||||||
|
log "ERROR" "NPM installation failed"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Step 7: Set up update script
|
||||||
|
log "INFO" "Setting up update script..."
|
||||||
|
mkdir -p "${SCRIPT_DIR}/scripts"
|
||||||
|
# Check if update script exists - don't copy it to itself
|
||||||
|
if [ ! -f "${SCRIPT_DIR}/scripts/update.sh" ]; then
|
||||||
|
# First, check if we have an update script to copy
|
||||||
|
if [ -f "${SCRIPT_DIR}/update.sh" ]; then
|
||||||
|
cp "${SCRIPT_DIR}/update.sh" "${SCRIPT_DIR}/scripts/update.sh"
|
||||||
|
log "INFO" "Copied update script from root to scripts directory"
|
||||||
|
else
|
||||||
|
# Create the update script since it doesn't exist
|
||||||
|
cat > "${SCRIPT_DIR}/scripts/update.sh" << 'EOL'
|
||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Transmission RSS Manager - Update Script
|
||||||
|
# This script pulls the latest version from git and runs necessary updates
|
||||||
|
|
||||||
|
# Color and formatting
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
RED='\033[0;31m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
BOLD='\033[1m'
|
||||||
|
|
||||||
|
# Installation directory (should be current directory)
|
||||||
|
INSTALL_DIR=$(pwd)
|
||||||
|
|
||||||
|
# Check if we're in the right directory
|
||||||
|
if [ ! -f "$INSTALL_DIR/package.json" ] || [ ! -d "$INSTALL_DIR/modules" ]; then
|
||||||
|
echo -e "${RED}Error: This script must be run from the installation directory.${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Get the current version
|
||||||
|
CURRENT_VERSION=$(grep -oP '"version": "\K[^"]+' package.json)
|
||||||
|
echo -e "${YELLOW}Current version: ${BOLD}$CURRENT_VERSION${NC}"
|
||||||
|
|
||||||
|
# Check for git repository
|
||||||
|
if [ ! -d ".git" ]; then
|
||||||
|
echo -e "${RED}Error: This installation was not set up using git.${NC}"
|
||||||
|
echo -e "Please use the bootstrap installer to perform a fresh installation."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Stash any local changes
|
||||||
|
echo -e "${YELLOW}Backing up any local configuration changes...${NC}"
|
||||||
|
git stash -q
|
||||||
|
|
||||||
|
# Pull the latest changes
|
||||||
|
echo -e "${YELLOW}Pulling latest updates from git...${NC}"
|
||||||
|
git pull
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo -e "${RED}Failed to pull updates. Restoring original state...${NC}"
|
||||||
|
git stash pop -q
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Get the new version
|
||||||
|
NEW_VERSION=$(grep -oP '"version": "\K[^"]+' package.json)
|
||||||
|
echo -e "${GREEN}New version: ${BOLD}$NEW_VERSION${NC}"
|
||||||
|
|
||||||
|
# Check if update is needed
|
||||||
|
if [ "$CURRENT_VERSION" == "$NEW_VERSION" ]; then
|
||||||
|
echo -e "${GREEN}You already have the latest version.${NC}"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Install any new npm dependencies
|
||||||
|
echo -e "${YELLOW}Installing dependencies...${NC}"
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Apply any local configuration changes
|
||||||
|
if git stash list | grep -q "stash@{0}"; then
|
||||||
|
echo -e "${YELLOW}Restoring local configuration changes...${NC}"
|
||||||
|
git stash pop -q
|
||||||
|
# Handle conflicts if any
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo -e "${RED}There were conflicts when restoring your configuration.${NC}"
|
||||||
|
echo -e "Please check the files and resolve conflicts manually."
|
||||||
|
echo -e "Your original configuration is saved in .git/refs/stash"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Restart the service
|
||||||
|
echo -e "${YELLOW}Restarting service...${NC}"
|
||||||
|
if command -v systemctl &> /dev/null; then
|
||||||
|
sudo systemctl restart transmission-rss-manager
|
||||||
|
else
|
||||||
|
echo -e "${RED}Could not restart service automatically.${NC}"
|
||||||
|
echo -e "Please restart the service manually."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Update complete
|
||||||
|
echo -e "${GREEN}${BOLD}Update complete!${NC}"
|
||||||
|
echo -e "Updated from version $CURRENT_VERSION to $NEW_VERSION"
|
||||||
|
echo -e "Changes will take effect immediately."
|
||||||
|
EOL
|
||||||
|
|
||||||
|
chmod +x "${SCRIPT_DIR}/scripts/update.sh"
|
||||||
|
log "INFO" "Created update script: ${SCRIPT_DIR}/scripts/update.sh"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Step 8: Final setup and permissions
|
||||||
|
log "INFO" "Finalizing setup..."
|
||||||
|
finalize_setup || {
|
||||||
|
log "ERROR" "Setup finalization failed"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Installation complete
|
||||||
|
echo
|
||||||
|
echo -e "${BOLD}${GREEN}==================================================${NC}"
|
||||||
|
|
||||||
|
if [ "$IS_UPDATE" = true ]; then
|
||||||
|
echo -e "${BOLD}${GREEN} Update Complete! ${NC}"
|
||||||
|
else
|
||||||
|
echo -e "${BOLD}${GREEN} Installation Complete! ${NC}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "${BOLD}${GREEN}==================================================${NC}"
|
||||||
|
echo -e "You can access the web interface at: ${BOLD}http://localhost:$PORT${NC} or ${BOLD}http://your-server-ip:$PORT${NC}"
|
||||||
|
echo -e "You may need to configure your firewall to allow access to port $PORT"
|
||||||
|
echo
|
||||||
|
echo -e "${BOLD}Useful Commands:${NC}"
|
||||||
|
echo -e " To check the service status: ${YELLOW}systemctl status $SERVICE_NAME${NC}"
|
||||||
|
echo -e " To view logs: ${YELLOW}journalctl -u $SERVICE_NAME${NC}"
|
||||||
|
echo -e " To restart the service: ${YELLOW}systemctl restart $SERVICE_NAME${NC}"
|
||||||
|
echo -e " To update the application: ${YELLOW}Use the Update button in the System Status section${NC}"
|
||||||
|
echo
|
||||||
|
|
||||||
|
if [ "$IS_UPDATE" = true ]; then
|
||||||
|
echo -e "Thank you for updating Transmission RSS Manager!"
|
||||||
|
echo -e "The service has been restarted with the new version."
|
||||||
|
else
|
||||||
|
echo -e "Thank you for installing Transmission RSS Manager!"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "${BOLD}==================================================${NC}"
|
382
modules/config-module.sh
Normal file
382
modules/config-module.sh
Normal file
@ -0,0 +1,382 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Configuration module for Transmission RSS Manager Installation
|
||||||
|
|
||||||
|
# Configuration variables with defaults
|
||||||
|
INSTALL_DIR=${INSTALL_DIR:-"/opt/trans-install"}
|
||||||
|
CONFIG_DIR="/etc/transmission-rss-manager"
|
||||||
|
SERVICE_NAME="transmission-rss-manager"
|
||||||
|
PORT=3000
|
||||||
|
|
||||||
|
# Get default user safely - avoid using root
|
||||||
|
get_default_user() {
|
||||||
|
local default_user
|
||||||
|
|
||||||
|
# Try logname first to get the user who invoked sudo
|
||||||
|
if command -v logname &> /dev/null; then
|
||||||
|
default_user=$(logname 2>/dev/null)
|
||||||
|
fi
|
||||||
|
|
||||||
|
# If logname failed, try SUDO_USER
|
||||||
|
if [ -z "$default_user" ] && [ -n "$SUDO_USER" ]; then
|
||||||
|
default_user="$SUDO_USER"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Fallback to current user if both methods failed
|
||||||
|
if [ -z "$default_user" ]; then
|
||||||
|
default_user="$(whoami)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Ensure the user is not root
|
||||||
|
if [ "$default_user" = "root" ]; then
|
||||||
|
echo "nobody"
|
||||||
|
else
|
||||||
|
echo "$default_user"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Initialize default user
|
||||||
|
USER=$(get_default_user)
|
||||||
|
|
||||||
|
# Transmission configuration variables
|
||||||
|
TRANSMISSION_REMOTE=false
|
||||||
|
TRANSMISSION_HOST="localhost"
|
||||||
|
TRANSMISSION_PORT=9091
|
||||||
|
TRANSMISSION_USER=""
|
||||||
|
TRANSMISSION_PASS=""
|
||||||
|
TRANSMISSION_RPC_PATH="/transmission/rpc"
|
||||||
|
TRANSMISSION_DOWNLOAD_DIR="/var/lib/transmission-daemon/downloads"
|
||||||
|
TRANSMISSION_DIR_MAPPING="{}"
|
||||||
|
|
||||||
|
# Media path defaults
|
||||||
|
MEDIA_DIR="/mnt/media"
|
||||||
|
ENABLE_BOOK_SORTING=true
|
||||||
|
|
||||||
|
# Helper function to validate port number
|
||||||
|
validate_port() {
|
||||||
|
local port="$1"
|
||||||
|
if [[ "$port" =~ ^[0-9]+$ ]] && [ "$port" -ge 1 ] && [ "$port" -le 65535 ]; then
|
||||||
|
return 0
|
||||||
|
else
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Helper function to validate URL hostname
|
||||||
|
validate_hostname() {
|
||||||
|
local hostname="$1"
|
||||||
|
if [[ "$hostname" =~ ^[a-zA-Z0-9]([a-zA-Z0-9\-\.]+[a-zA-Z0-9])?$ ]]; then
|
||||||
|
return 0
|
||||||
|
elif [[ "$hostname" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||||
|
return 0
|
||||||
|
else
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
function gather_configuration() {
|
||||||
|
log "INFO" "Starting configuration gathering"
|
||||||
|
|
||||||
|
# Initialize default values for Transmission mode
|
||||||
|
export TRANSMISSION_REMOTE=false
|
||||||
|
|
||||||
|
# Set flag to indicate that configuration was gathered
|
||||||
|
export CONFIG_GATHERED=true
|
||||||
|
echo -e "${BOLD}Installation Configuration:${NC}"
|
||||||
|
echo -e "Please provide the following configuration parameters:"
|
||||||
|
echo
|
||||||
|
|
||||||
|
read -p "Installation directory [$INSTALL_DIR]: " input_install_dir
|
||||||
|
if [ -n "$input_install_dir" ]; then
|
||||||
|
# Validate installation directory
|
||||||
|
if [[ ! "$input_install_dir" =~ ^/ ]]; then
|
||||||
|
log "WARN" "Installation directory must be an absolute path. Using default."
|
||||||
|
else
|
||||||
|
INSTALL_DIR="$input_install_dir"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Using fixed port 3000 to avoid permission issues with ports below 1024
|
||||||
|
log "INFO" "Using port 3000 for the web interface"
|
||||||
|
log "INFO" "This is to avoid permission issues with ports below 1024 (like port 80)"
|
||||||
|
PORT=3000
|
||||||
|
|
||||||
|
# Get user
|
||||||
|
read -p "Run as user [$USER]: " input_user
|
||||||
|
if [ -n "$input_user" ]; then
|
||||||
|
# Check if user exists
|
||||||
|
if id "$input_user" &>/dev/null; then
|
||||||
|
USER="$input_user"
|
||||||
|
else
|
||||||
|
log "WARN" "User $input_user does not exist. Using $USER instead."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo -e "${BOLD}Transmission Configuration:${NC}"
|
||||||
|
echo -e "Configure connection to your Transmission client:"
|
||||||
|
echo
|
||||||
|
|
||||||
|
# Don't ask about remote again - this is now handled in the main installer
|
||||||
|
# Just log the current setting for clarity
|
||||||
|
log "INFO" "Using previously selected Transmission mode: $([ "$TRANSMISSION_REMOTE" = true ] && echo "Remote" || echo "Local")"
|
||||||
|
|
||||||
|
if [ "$TRANSMISSION_REMOTE" = true ]; then
|
||||||
|
# Get and validate hostname
|
||||||
|
while true; do
|
||||||
|
read -p "Remote Transmission host [localhost]: " input_trans_host
|
||||||
|
if [ -z "$input_trans_host" ]; then
|
||||||
|
break
|
||||||
|
elif validate_hostname "$input_trans_host"; then
|
||||||
|
TRANSMISSION_HOST="$input_trans_host"
|
||||||
|
break
|
||||||
|
else
|
||||||
|
log "WARN" "Invalid hostname format."
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Get and validate port
|
||||||
|
while true; do
|
||||||
|
read -p "Remote Transmission port [9091]: " input_trans_port
|
||||||
|
if [ -z "$input_trans_port" ]; then
|
||||||
|
break
|
||||||
|
elif validate_port "$input_trans_port"; then
|
||||||
|
TRANSMISSION_PORT="$input_trans_port"
|
||||||
|
break
|
||||||
|
else
|
||||||
|
log "WARN" "Invalid port number. Port must be between 1 and 65535."
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Get credentials
|
||||||
|
read -p "Remote Transmission username []: " input_trans_user
|
||||||
|
TRANSMISSION_USER=${input_trans_user:-$TRANSMISSION_USER}
|
||||||
|
|
||||||
|
# Use read -s for password to avoid showing it on screen
|
||||||
|
read -s -p "Remote Transmission password []: " input_trans_pass
|
||||||
|
echo # Add a newline after the password input
|
||||||
|
if [ -n "$input_trans_pass" ]; then
|
||||||
|
# TODO: In a production environment, consider encrypting this password
|
||||||
|
TRANSMISSION_PASS="$input_trans_pass"
|
||||||
|
fi
|
||||||
|
|
||||||
|
read -p "Remote Transmission RPC path [/transmission/rpc]: " input_trans_path
|
||||||
|
if [ -n "$input_trans_path" ]; then
|
||||||
|
# Ensure path starts with / for consistency
|
||||||
|
if [[ ! "$input_trans_path" =~ ^/ ]]; then
|
||||||
|
input_trans_path="/$input_trans_path"
|
||||||
|
fi
|
||||||
|
TRANSMISSION_RPC_PATH="$input_trans_path"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Configure directory mapping for remote setup
|
||||||
|
echo
|
||||||
|
echo -e "${YELLOW}Directory Mapping Configuration${NC}"
|
||||||
|
echo -e "When using a remote Transmission server, you need to map paths between servers."
|
||||||
|
echo -e "For each directory on the remote server, specify the corresponding local directory."
|
||||||
|
echo
|
||||||
|
|
||||||
|
# Get remote download directory
|
||||||
|
read -p "Remote Transmission download directory: " REMOTE_DOWNLOAD_DIR
|
||||||
|
REMOTE_DOWNLOAD_DIR=${REMOTE_DOWNLOAD_DIR:-"/var/lib/transmission-daemon/downloads"}
|
||||||
|
|
||||||
|
# Get local directory that corresponds to remote download directory
|
||||||
|
read -p "Local directory that corresponds to the remote download directory: " LOCAL_DOWNLOAD_DIR
|
||||||
|
LOCAL_DOWNLOAD_DIR=${LOCAL_DOWNLOAD_DIR:-"/mnt/transmission-downloads"}
|
||||||
|
|
||||||
|
# Create mapping JSON - use proper JSON escaping for directory paths
|
||||||
|
REMOTE_DOWNLOAD_DIR_ESCAPED=$(echo "$REMOTE_DOWNLOAD_DIR" | sed 's/\\/\\\\/g' | sed 's/"/\\"/g')
|
||||||
|
LOCAL_DOWNLOAD_DIR_ESCAPED=$(echo "$LOCAL_DOWNLOAD_DIR" | sed 's/\\/\\\\/g' | sed 's/"/\\"/g')
|
||||||
|
|
||||||
|
TRANSMISSION_DIR_MAPPING="{\"$REMOTE_DOWNLOAD_DIR_ESCAPED\": \"$LOCAL_DOWNLOAD_DIR_ESCAPED\"}"
|
||||||
|
|
||||||
|
# Create the local directory
|
||||||
|
if ! mkdir -p "$LOCAL_DOWNLOAD_DIR"; then
|
||||||
|
log "ERROR" "Failed to create local download directory: $LOCAL_DOWNLOAD_DIR"
|
||||||
|
else
|
||||||
|
if ! chown -R "$USER:$USER" "$LOCAL_DOWNLOAD_DIR"; then
|
||||||
|
log "ERROR" "Failed to set permissions on local download directory: $LOCAL_DOWNLOAD_DIR"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Ask if want to add more mappings
|
||||||
|
while true; do
|
||||||
|
read -p "Add another directory mapping? (y/n) [n]: " add_another
|
||||||
|
if [[ ! $add_another =~ ^[Yy]$ ]]; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
|
||||||
|
read -p "Remote directory path: " remote_dir
|
||||||
|
read -p "Corresponding local directory path: " local_dir
|
||||||
|
|
||||||
|
if [ -n "$remote_dir" ] && [ -n "$local_dir" ]; then
|
||||||
|
# Escape directory paths for JSON
|
||||||
|
remote_dir_escaped=$(echo "$remote_dir" | sed 's/\\/\\\\/g' | sed 's/"/\\"/g')
|
||||||
|
local_dir_escaped=$(echo "$local_dir" | sed 's/\\/\\\\/g' | sed 's/"/\\"/g')
|
||||||
|
|
||||||
|
# Update mapping JSON (proper JSON manipulation)
|
||||||
|
# Remove the closing brace, add a comma and the new mapping, then close with brace
|
||||||
|
TRANSMISSION_DIR_MAPPING="${TRANSMISSION_DIR_MAPPING%\}}, \"$remote_dir_escaped\": \"$local_dir_escaped\"}"
|
||||||
|
|
||||||
|
# Create the local directory
|
||||||
|
if ! mkdir -p "$local_dir"; then
|
||||||
|
log "ERROR" "Failed to create directory: $local_dir"
|
||||||
|
else
|
||||||
|
if ! chown -R "$USER:$USER" "$local_dir"; then
|
||||||
|
log "WARN" "Failed to set permissions on directory: $local_dir"
|
||||||
|
fi
|
||||||
|
log "INFO" "Mapping added: $remote_dir → $local_dir"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Set Transmission download dir for configuration
|
||||||
|
TRANSMISSION_DOWNLOAD_DIR=$REMOTE_DOWNLOAD_DIR
|
||||||
|
else
|
||||||
|
# Local Transmission selected - this part was already set in main-installer.sh
|
||||||
|
|
||||||
|
echo -e "${YELLOW}You've selected to use a local Transmission installation.${NC}"
|
||||||
|
|
||||||
|
# Check if Transmission is already installed
|
||||||
|
if command -v transmission-daemon &> /dev/null; then
|
||||||
|
echo -e "${GREEN}Transmission is already installed on this system.${NC}"
|
||||||
|
else
|
||||||
|
echo -e "${YELLOW}NOTE: Transmission does not appear to be installed on this system.${NC}"
|
||||||
|
echo -e "${YELLOW}You will be prompted to install it during the dependency installation step.${NC}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Get and validate port
|
||||||
|
while true; do
|
||||||
|
read -p "Local Transmission port [9091]: " input_trans_port
|
||||||
|
if [ -z "$input_trans_port" ]; then
|
||||||
|
break
|
||||||
|
elif validate_port "$input_trans_port"; then
|
||||||
|
TRANSMISSION_PORT="$input_trans_port"
|
||||||
|
break
|
||||||
|
else
|
||||||
|
log "WARN" "Invalid port number. Port must be between 1 and 65535."
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Get credentials if any
|
||||||
|
read -p "Local Transmission username (leave empty if authentication is disabled) []: " input_trans_user
|
||||||
|
TRANSMISSION_USER=${input_trans_user:-$TRANSMISSION_USER}
|
||||||
|
|
||||||
|
if [ -n "$input_trans_user" ]; then
|
||||||
|
# Use read -s for password to hide it
|
||||||
|
read -s -p "Local Transmission password []: " input_trans_pass
|
||||||
|
echo # Add a newline after the password input
|
||||||
|
if [ -n "$input_trans_pass" ]; then
|
||||||
|
TRANSMISSION_PASS="$input_trans_pass"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
read -p "Transmission download directory [/var/lib/transmission-daemon/downloads]: " input_trans_dir
|
||||||
|
if [ -n "$input_trans_dir" ]; then
|
||||||
|
if [[ ! "$input_trans_dir" =~ ^/ ]]; then
|
||||||
|
log "WARN" "Download directory must be an absolute path. Using default."
|
||||||
|
else
|
||||||
|
TRANSMISSION_DOWNLOAD_DIR="$input_trans_dir"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo -e "${BOLD}Media Destination Configuration:${NC}"
|
||||||
|
|
||||||
|
read -p "Media destination base directory [/mnt/media]: " input_media_dir
|
||||||
|
if [ -n "$input_media_dir" ]; then
|
||||||
|
if [[ ! "$input_media_dir" =~ ^/ ]]; then
|
||||||
|
log "WARN" "Media directory must be an absolute path. Using default."
|
||||||
|
else
|
||||||
|
MEDIA_DIR="$input_media_dir"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Ask about enabling book/magazine sorting
|
||||||
|
echo
|
||||||
|
echo -e "${BOLD}Content Type Configuration:${NC}"
|
||||||
|
read -p "Enable book and magazine sorting? (y/n) [y]: " input_book_sorting
|
||||||
|
if [[ $input_book_sorting =~ ^[Nn]$ ]]; then
|
||||||
|
ENABLE_BOOK_SORTING=false
|
||||||
|
else
|
||||||
|
ENABLE_BOOK_SORTING=true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Security configuration
|
||||||
|
echo
|
||||||
|
echo -e "${BOLD}Security Configuration:${NC}"
|
||||||
|
|
||||||
|
# Ask about enabling authentication
|
||||||
|
read -p "Enable authentication? (y/n) [n]: " input_auth_enabled
|
||||||
|
AUTH_ENABLED=false
|
||||||
|
ADMIN_USERNAME=""
|
||||||
|
ADMIN_PASSWORD=""
|
||||||
|
|
||||||
|
if [[ $input_auth_enabled =~ ^[Yy]$ ]]; then
|
||||||
|
AUTH_ENABLED=true
|
||||||
|
|
||||||
|
# Get admin username and password
|
||||||
|
read -p "Admin username [admin]: " input_admin_username
|
||||||
|
ADMIN_USERNAME=${input_admin_username:-"admin"}
|
||||||
|
|
||||||
|
# Use read -s for password to avoid showing it on screen
|
||||||
|
read -s -p "Admin password: " input_admin_password
|
||||||
|
echo # Add a newline after the password input
|
||||||
|
|
||||||
|
if [ -z "$input_admin_password" ]; then
|
||||||
|
# Generate a random password if none provided
|
||||||
|
ADMIN_PASSWORD=$(openssl rand -base64 12)
|
||||||
|
echo -e "${YELLOW}Generated random admin password: $ADMIN_PASSWORD${NC}"
|
||||||
|
echo -e "${YELLOW}Please save this password somewhere safe!${NC}"
|
||||||
|
else
|
||||||
|
ADMIN_PASSWORD="$input_admin_password"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Ask about enabling HTTPS
|
||||||
|
read -p "Enable HTTPS? (requires SSL certificate) (y/n) [n]: " input_https_enabled
|
||||||
|
HTTPS_ENABLED=false
|
||||||
|
SSL_CERT_PATH=""
|
||||||
|
SSL_KEY_PATH=""
|
||||||
|
|
||||||
|
if [[ $input_https_enabled =~ ^[Yy]$ ]]; then
|
||||||
|
HTTPS_ENABLED=true
|
||||||
|
|
||||||
|
# Get SSL certificate paths
|
||||||
|
read -p "SSL certificate path: " input_ssl_cert_path
|
||||||
|
if [ -n "$input_ssl_cert_path" ]; then
|
||||||
|
# Check if file exists
|
||||||
|
if [ -f "$input_ssl_cert_path" ]; then
|
||||||
|
SSL_CERT_PATH="$input_ssl_cert_path"
|
||||||
|
else
|
||||||
|
log "WARN" "SSL certificate file not found. HTTPS will be disabled."
|
||||||
|
HTTPS_ENABLED=false
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
log "WARN" "SSL certificate path not provided. HTTPS will be disabled."
|
||||||
|
HTTPS_ENABLED=false
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Only ask for key if cert was found
|
||||||
|
if [ "$HTTPS_ENABLED" = true ]; then
|
||||||
|
read -p "SSL key path: " input_ssl_key_path
|
||||||
|
if [ -n "$input_ssl_key_path" ]; then
|
||||||
|
# Check if file exists
|
||||||
|
if [ -f "$input_ssl_key_path" ]; then
|
||||||
|
SSL_KEY_PATH="$input_ssl_key_path"
|
||||||
|
else
|
||||||
|
log "WARN" "SSL key file not found. HTTPS will be disabled."
|
||||||
|
HTTPS_ENABLED=false
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
log "WARN" "SSL key path not provided. HTTPS will be disabled."
|
||||||
|
HTTPS_ENABLED=false
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
log "INFO" "Configuration gathering complete"
|
||||||
|
echo -e "${GREEN}Configuration complete!${NC}"
|
||||||
|
echo
|
||||||
|
}
|
260
modules/dependencies-module.sh
Executable file
260
modules/dependencies-module.sh
Executable file
@ -0,0 +1,260 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Dependencies module for Transmission RSS Manager Installation
|
||||||
|
|
||||||
|
function install_dependencies() {
|
||||||
|
log "INFO" "Installing dependencies..."
|
||||||
|
|
||||||
|
# Make sure TRANSMISSION_REMOTE variable is set and honored
|
||||||
|
# First check for environment variable that might have been directly set
|
||||||
|
# Then check the .env.install file in various locations
|
||||||
|
|
||||||
|
# Try relative path first
|
||||||
|
ENV_FILE="$(dirname "$(dirname "$0")")/.env.install"
|
||||||
|
if [ -f "$ENV_FILE" ]; then
|
||||||
|
source "$ENV_FILE"
|
||||||
|
log "INFO" "Loaded transmission settings from environment file: TRANSMISSION_REMOTE=$TRANSMISSION_REMOTE"
|
||||||
|
# Try absolute path as fallback
|
||||||
|
elif [ -f "/opt/develop/transmission-rss-manager/.env.install" ]; then
|
||||||
|
source "/opt/develop/transmission-rss-manager/.env.install"
|
||||||
|
log "INFO" "Loaded transmission settings from absolute path: TRANSMISSION_REMOTE=$TRANSMISSION_REMOTE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# If we're in update mode, try to load the remote status from existing config
|
||||||
|
if [ "$IS_UPDATE" = "true" ] && [ -n "$EXISTING_CONFIG_PATH" ]; then
|
||||||
|
log "INFO" "Update mode detected with config at $EXISTING_CONFIG_PATH, checking Transmission remote setting"
|
||||||
|
if [ -f "$EXISTING_CONFIG_PATH" ]; then
|
||||||
|
# Try to extract the isRemote setting from the config file
|
||||||
|
if command -v grep &> /dev/null; then
|
||||||
|
IS_REMOTE=$(grep -o '"isRemote":[^,}]*' "$EXISTING_CONFIG_PATH" | grep -o 'true\|false')
|
||||||
|
if [ "$IS_REMOTE" = "true" ]; then
|
||||||
|
export TRANSMISSION_REMOTE=true
|
||||||
|
log "INFO" "Detected remote Transmission configuration from existing config"
|
||||||
|
elif [ "$IS_REMOTE" = "false" ]; then
|
||||||
|
export TRANSMISSION_REMOTE=false
|
||||||
|
log "INFO" "Detected local Transmission configuration from existing config"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Always prompt if we didn't get TRANSMISSION_REMOTE from environment or previous steps
|
||||||
|
if [ -z "$TRANSMISSION_REMOTE" ]; then
|
||||||
|
log "WARN" "TRANSMISSION_REMOTE variable was not set, asking now..."
|
||||||
|
|
||||||
|
# Directly ask about remote Transmission
|
||||||
|
echo -e "${BOLD}Transmission Configuration:${NC}"
|
||||||
|
echo -e "Configure connection to your Transmission client:"
|
||||||
|
echo
|
||||||
|
|
||||||
|
read -p "Is Transmission running on a remote server? (y/n) [n]: " input_remote
|
||||||
|
if [[ $input_remote =~ ^[Yy]$ ]]; then
|
||||||
|
export TRANSMISSION_REMOTE=true
|
||||||
|
log "INFO" "Remote Transmission selected."
|
||||||
|
else
|
||||||
|
export TRANSMISSION_REMOTE=false
|
||||||
|
log "INFO" "Local Transmission selected."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Save this choice to environment file for other scripts
|
||||||
|
echo "export TRANSMISSION_REMOTE=$TRANSMISSION_REMOTE" > "$(dirname "$(dirname "$0")")/.env.install"
|
||||||
|
chmod +x "$(dirname "$(dirname "$0")")/.env.install"
|
||||||
|
else
|
||||||
|
log "INFO" "Using previously set TRANSMISSION_REMOTE=$TRANSMISSION_REMOTE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "INFO" "Proceeding with Transmission mode: $([ "$TRANSMISSION_REMOTE" = true ] && echo "Remote" || echo "Local")"
|
||||||
|
|
||||||
|
# Check for package manager
|
||||||
|
if command -v apt-get &> /dev/null; then
|
||||||
|
# Update package index
|
||||||
|
apt-get update
|
||||||
|
|
||||||
|
# Install Node.js and npm if not already installed
|
||||||
|
if ! command_exists node; then
|
||||||
|
log "INFO" "Installing Node.js and npm..."
|
||||||
|
apt-get install -y ca-certificates curl gnupg
|
||||||
|
mkdir -p /etc/apt/keyrings
|
||||||
|
|
||||||
|
# Check if download succeeds
|
||||||
|
if ! curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg; then
|
||||||
|
log "ERROR" "Failed to download Node.js GPG key"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_18.x nodistro main" > /etc/apt/sources.list.d/nodesource.list
|
||||||
|
|
||||||
|
# Update again after adding repo
|
||||||
|
apt-get update
|
||||||
|
|
||||||
|
# Install nodejs
|
||||||
|
if ! apt-get install -y nodejs; then
|
||||||
|
log "ERROR" "Failed to install Node.js"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
log "INFO" "Node.js is already installed."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if we need to install Transmission (only if local transmission was selected and not in update mode)
|
||||||
|
if [ "$TRANSMISSION_REMOTE" = false ] && [ "$IS_UPDATE" != "true" ]; then
|
||||||
|
if ! command_exists transmission-daemon; then
|
||||||
|
log "INFO" "Local Transmission installation selected, but transmission-daemon is not installed."
|
||||||
|
log "INFO" "You selected to use a local Transmission installation during configuration."
|
||||||
|
read -p "Would you like to install Transmission now? (y/n): " install_transmission
|
||||||
|
|
||||||
|
if [[ "$install_transmission" =~ ^[Yy]$ ]]; then
|
||||||
|
log "INFO" "Installing Transmission..."
|
||||||
|
if ! apt-get install -y transmission-daemon; then
|
||||||
|
log "ERROR" "Failed to install Transmission"
|
||||||
|
log "WARN" "You will need to install Transmission manually before using this application."
|
||||||
|
else
|
||||||
|
# Stop transmission-daemon to allow configuration changes
|
||||||
|
systemctl stop transmission-daemon
|
||||||
|
|
||||||
|
# Set default settings
|
||||||
|
TRANSMISSION_SETTINGS_DIR="/var/lib/transmission-daemon/info"
|
||||||
|
if [ -f "$TRANSMISSION_SETTINGS_DIR/settings.json" ]; then
|
||||||
|
# Backup original settings
|
||||||
|
cp "$TRANSMISSION_SETTINGS_DIR/settings.json" "$TRANSMISSION_SETTINGS_DIR/settings.json.bak"
|
||||||
|
|
||||||
|
# Update RPC settings to allow our app to connect
|
||||||
|
sed -i 's/"rpc-authentication-required": true,/"rpc-authentication-required": false,/g' "$TRANSMISSION_SETTINGS_DIR/settings.json"
|
||||||
|
sed -i 's/"rpc-whitelist-enabled": true,/"rpc-whitelist-enabled": false,/g' "$TRANSMISSION_SETTINGS_DIR/settings.json"
|
||||||
|
|
||||||
|
log "INFO" "Transmission has been configured for local access."
|
||||||
|
else
|
||||||
|
log "WARN" "Could not find Transmission settings file. You may need to configure Transmission manually."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Start transmission-daemon
|
||||||
|
systemctl start transmission-daemon
|
||||||
|
log "INFO" "Transmission has been installed and started."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
log "WARN" "Transmission installation skipped. You will need to install it manually."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
log "INFO" "Transmission is already installed."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Install additional dependencies
|
||||||
|
log "INFO" "Installing additional dependencies..."
|
||||||
|
# Try to install unrar-free if unrar is not available
|
||||||
|
if ! apt-get install -y unrar 2>/dev/null; then
|
||||||
|
log "INFO" "unrar not available, trying unrar-free instead..."
|
||||||
|
apt-get install -y unrar-free
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Install other dependencies
|
||||||
|
apt-get install -y unzip p7zip-full
|
||||||
|
|
||||||
|
# Try to install nginx
|
||||||
|
apt-get install -y nginx || log "WARN" "Nginx installation failed, web interface may not be accessible"
|
||||||
|
else
|
||||||
|
log "ERROR" "This installer requires apt-get package manager"
|
||||||
|
log "INFO" "Please install the following dependencies manually:"
|
||||||
|
log "INFO" "- Node.js (v18.x)"
|
||||||
|
log "INFO" "- npm"
|
||||||
|
log "INFO" "- unrar"
|
||||||
|
log "INFO" "- unzip"
|
||||||
|
log "INFO" "- p7zip-full"
|
||||||
|
log "INFO" "- nginx"
|
||||||
|
if [ "$TRANSMISSION_HOST" = "localhost" ] || [ "$TRANSMISSION_HOST" = "127.0.0.1" ]; then
|
||||||
|
log "INFO" "- transmission-daemon"
|
||||||
|
fi
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if all dependencies were installed successfully
|
||||||
|
local dependencies=("node" "npm" "unzip" "nginx")
|
||||||
|
local missing_deps=()
|
||||||
|
|
||||||
|
# Add transmission to dependencies check if local installation was selected
|
||||||
|
if [ "$TRANSMISSION_REMOTE" = false ]; then
|
||||||
|
dependencies+=("transmission-daemon")
|
||||||
|
fi
|
||||||
|
|
||||||
|
for dep in "${dependencies[@]}"; do
|
||||||
|
if ! command_exists "$dep"; then
|
||||||
|
missing_deps+=("$dep")
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Check for either unrar or unrar-free
|
||||||
|
if ! command_exists "unrar" && ! command_exists "unrar-free"; then
|
||||||
|
missing_deps+=("unrar")
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check for either 7z or 7za (from p7zip-full)
|
||||||
|
if ! command_exists "7z" && ! command_exists "7za"; then
|
||||||
|
missing_deps+=("p7zip")
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ${#missing_deps[@]} -eq 0 ]; then
|
||||||
|
log "INFO" "All dependencies installed successfully."
|
||||||
|
else
|
||||||
|
log "ERROR" "Failed to install some dependencies: ${missing_deps[*]}"
|
||||||
|
log "WARN" "Please install them manually and rerun this script."
|
||||||
|
|
||||||
|
# More helpful information based on which deps are missing
|
||||||
|
if [[ " ${missing_deps[*]} " =~ " node " ]]; then
|
||||||
|
log "INFO" "To install Node.js manually, visit: https://nodejs.org/en/download/"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ " ${missing_deps[*]} " =~ " nginx " ]]; then
|
||||||
|
log "INFO" "To install nginx manually: sudo apt-get install nginx"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ " ${missing_deps[*]} " =~ " transmission-daemon " ]]; then
|
||||||
|
log "INFO" "To install Transmission manually: sudo apt-get install transmission-daemon"
|
||||||
|
log "INFO" "After installation, you may need to configure it by editing /var/lib/transmission-daemon/info/settings.json"
|
||||||
|
fi
|
||||||
|
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
function create_directories() {
|
||||||
|
log "INFO" "Creating installation directories..."
|
||||||
|
|
||||||
|
# Check if INSTALL_DIR is defined
|
||||||
|
if [ -z "$INSTALL_DIR" ]; then
|
||||||
|
log "ERROR" "INSTALL_DIR is not defined"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if CONFIG_DIR is defined
|
||||||
|
if [ -z "$CONFIG_DIR" ]; then
|
||||||
|
log "ERROR" "CONFIG_DIR is not defined"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create directories and check for errors
|
||||||
|
DIRECTORIES=(
|
||||||
|
"$INSTALL_DIR"
|
||||||
|
"$INSTALL_DIR/logs"
|
||||||
|
"$INSTALL_DIR/public/js"
|
||||||
|
"$INSTALL_DIR/public/css"
|
||||||
|
"$INSTALL_DIR/modules"
|
||||||
|
"$INSTALL_DIR/data"
|
||||||
|
"$CONFIG_DIR"
|
||||||
|
)
|
||||||
|
|
||||||
|
for dir in "${DIRECTORIES[@]}"; do
|
||||||
|
if ! mkdir -p "$dir"; then
|
||||||
|
log "ERROR" "Failed to create directory: $dir"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Set permissions for configuration directory
|
||||||
|
chown -R "$USER:$USER" "$CONFIG_DIR"
|
||||||
|
chmod 755 "$CONFIG_DIR"
|
||||||
|
|
||||||
|
# Create a symlink from the installation directory to the config directory
|
||||||
|
# This ensures the application can find the config regardless of where it looks
|
||||||
|
ln -sf "$CONFIG_DIR/config.json" "$INSTALL_DIR/config.json"
|
||||||
|
|
||||||
|
log "INFO" "Directories created successfully."
|
||||||
|
}
|
1942
modules/file-creator-module.sh
Executable file
1942
modules/file-creator-module.sh
Executable file
File diff suppressed because it is too large
Load Diff
1
modules/post-processor
Symbolic link
1
modules/post-processor
Symbolic link
@ -0,0 +1 @@
|
|||||||
|
post-processor.js
|
517
modules/post-processor.js
Normal file
517
modules/post-processor.js
Normal file
@ -0,0 +1,517 @@
|
|||||||
|
/**
|
||||||
|
* Post-Processor Module
|
||||||
|
* Handles the organization and processing of completed downloads
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fs = require('fs').promises;
|
||||||
|
const path = require('path');
|
||||||
|
const util = require('util');
|
||||||
|
const exec = util.promisify(require('child_process').exec);
|
||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
|
class PostProcessor {
|
||||||
|
constructor(config, transmissionClient) {
|
||||||
|
if (!config) {
|
||||||
|
throw new Error('Configuration is required for Post Processor');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!transmissionClient) {
|
||||||
|
throw new Error('Transmission client is required for Post Processor');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.config = config;
|
||||||
|
this.transmissionClient = transmissionClient;
|
||||||
|
this.isProcessing = false;
|
||||||
|
this.processingQueue = [];
|
||||||
|
this.processIntervalId = null;
|
||||||
|
this.checkIntervalSeconds = config.seedingRequirements?.checkIntervalSeconds || 300;
|
||||||
|
this.destinationPaths = config.destinationPaths || {};
|
||||||
|
this.processingOptions = config.processingOptions || {};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start the post-processor
|
||||||
|
* @returns {boolean} Whether the processor started successfully
|
||||||
|
*/
|
||||||
|
start() {
|
||||||
|
if (this.processIntervalId) {
|
||||||
|
console.log('Post-processor is already running');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Starting post-processor, check interval: ${this.checkIntervalSeconds} seconds`);
|
||||||
|
|
||||||
|
// Run immediately
|
||||||
|
this.checkCompletedDownloads();
|
||||||
|
|
||||||
|
// Then set up interval
|
||||||
|
this.processIntervalId = setInterval(() => {
|
||||||
|
this.checkCompletedDownloads();
|
||||||
|
}, this.checkIntervalSeconds * 1000);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop the post-processor
|
||||||
|
* @returns {boolean} Whether the processor stopped successfully
|
||||||
|
*/
|
||||||
|
stop() {
|
||||||
|
if (!this.processIntervalId) {
|
||||||
|
console.log('Post-processor is not running');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearInterval(this.processIntervalId);
|
||||||
|
this.processIntervalId = null;
|
||||||
|
console.log('Post-processor stopped');
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check for completed downloads that meet seeding requirements
|
||||||
|
*/
|
||||||
|
async checkCompletedDownloads() {
|
||||||
|
if (this.isProcessing) {
|
||||||
|
console.log('Post-processor is already running a processing cycle, skipping');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isProcessing = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('Checking for completed downloads...');
|
||||||
|
|
||||||
|
// Get all torrents
|
||||||
|
const torrentsResult = await this.transmissionClient.getTorrents();
|
||||||
|
|
||||||
|
if (!torrentsResult.success) {
|
||||||
|
console.error('Failed to get torrents from Transmission:', torrentsResult.error);
|
||||||
|
this.isProcessing = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const torrents = torrentsResult.torrents;
|
||||||
|
|
||||||
|
// Filter completed torrents
|
||||||
|
const completedTorrents = torrents.filter(torrent =>
|
||||||
|
torrent.percentDone === 1 && // Fully downloaded
|
||||||
|
torrent.status !== 0 && // Not stopped
|
||||||
|
torrent.doneDate > 0 // Has a completion date
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`Found ${completedTorrents.length} completed torrents`);
|
||||||
|
|
||||||
|
// Check each completed torrent against requirements
|
||||||
|
for (const torrent of completedTorrents) {
|
||||||
|
// Skip already processed torrents
|
||||||
|
if (this.processingQueue.includes(torrent.id)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it meets seeding requirements
|
||||||
|
const reqResult = await this.transmissionClient.verifyTorrentSeedingRequirements(
|
||||||
|
torrent.id,
|
||||||
|
this.config.seedingRequirements || {}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!reqResult.success) {
|
||||||
|
console.error(`Error checking requirements for ${torrent.name}:`, reqResult.error);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reqResult.requirementsMet) {
|
||||||
|
console.log(`Torrent ${torrent.name} has met seeding requirements, queuing for processing`);
|
||||||
|
|
||||||
|
// Add to processing queue
|
||||||
|
this.processingQueue.push(torrent.id);
|
||||||
|
|
||||||
|
// Process the torrent
|
||||||
|
await this.processTorrent(reqResult.torrent);
|
||||||
|
|
||||||
|
// Remove from queue after processing
|
||||||
|
this.processingQueue = this.processingQueue.filter(id => id !== torrent.id);
|
||||||
|
} else {
|
||||||
|
const { currentRatio, currentSeedingTimeMinutes } = reqResult;
|
||||||
|
const { minRatio, minTimeMinutes } = this.config.seedingRequirements || { minRatio: 1.0, minTimeMinutes: 60 };
|
||||||
|
|
||||||
|
console.log(`Torrent ${torrent.name} has not met seeding requirements yet:`);
|
||||||
|
console.log(`- Ratio: ${currentRatio.toFixed(2)} / ${minRatio} (${reqResult.ratioMet ? 'Met' : 'Not Met'})`);
|
||||||
|
console.log(`- Time: ${Math.floor(currentSeedingTimeMinutes)} / ${minTimeMinutes} minutes (${reqResult.timeMet ? 'Met' : 'Not Met'})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in post-processor cycle:', error);
|
||||||
|
} finally {
|
||||||
|
this.isProcessing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process a completed torrent
|
||||||
|
* @param {Object} torrent - Torrent object
|
||||||
|
*/
|
||||||
|
async processTorrent(torrent) {
|
||||||
|
console.log(`Processing torrent: ${torrent.name}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get detailed info with file analysis
|
||||||
|
const details = await this.transmissionClient.getTorrentDetails(torrent.id);
|
||||||
|
|
||||||
|
if (!details.success) {
|
||||||
|
console.error(`Failed to get details for torrent ${torrent.name}:`, details.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
torrent = details.torrent;
|
||||||
|
const mediaInfo = torrent.mediaInfo || { type: 'unknown' };
|
||||||
|
|
||||||
|
console.log(`Detected media type: ${mediaInfo.type}`);
|
||||||
|
|
||||||
|
// Determine destination path based on content type
|
||||||
|
let destinationDir = this.getDestinationPath(mediaInfo.type);
|
||||||
|
|
||||||
|
if (!destinationDir) {
|
||||||
|
console.error(`No destination directory configured for media type: ${mediaInfo.type}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the destination directory if it doesn't exist
|
||||||
|
await this.createDirectoryIfNotExists(destinationDir);
|
||||||
|
|
||||||
|
// If we're creating category folders, add category-specific subdirectory
|
||||||
|
if (this.processingOptions.createCategoryFolders) {
|
||||||
|
const categoryFolder = this.getCategoryFolder(torrent, mediaInfo);
|
||||||
|
if (categoryFolder) {
|
||||||
|
destinationDir = path.join(destinationDir, categoryFolder);
|
||||||
|
await this.createDirectoryIfNotExists(destinationDir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Processing to destination: ${destinationDir}`);
|
||||||
|
|
||||||
|
// Process files based on content type
|
||||||
|
if (mediaInfo.type === 'archive' && this.processingOptions.extractArchives) {
|
||||||
|
await this.processArchives(torrent, mediaInfo, destinationDir);
|
||||||
|
} else {
|
||||||
|
await this.processStandardFiles(torrent, mediaInfo, destinationDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Finished processing torrent: ${torrent.name}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error processing torrent ${torrent.name}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the appropriate destination path for a media type
|
||||||
|
* @param {string} mediaType - Type of media
|
||||||
|
* @returns {string} Destination path
|
||||||
|
*/
|
||||||
|
getDestinationPath(mediaType) {
|
||||||
|
switch (mediaType) {
|
||||||
|
case 'movie':
|
||||||
|
return this.destinationPaths.movies;
|
||||||
|
case 'tvshow':
|
||||||
|
return this.destinationPaths.tvShows;
|
||||||
|
case 'audio':
|
||||||
|
return this.destinationPaths.music;
|
||||||
|
case 'book':
|
||||||
|
return this.destinationPaths.books;
|
||||||
|
case 'magazine':
|
||||||
|
return this.destinationPaths.magazines;
|
||||||
|
default:
|
||||||
|
return this.destinationPaths.software;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a category folder name based on the content
|
||||||
|
* @param {Object} torrent - Torrent object
|
||||||
|
* @param {Object} mediaInfo - Media information
|
||||||
|
* @returns {string} Folder name
|
||||||
|
*/
|
||||||
|
getCategoryFolder(torrent, mediaInfo) {
|
||||||
|
const name = torrent.name;
|
||||||
|
|
||||||
|
switch (mediaInfo.type) {
|
||||||
|
case 'movie': {
|
||||||
|
// For movies, use the first letter of the title
|
||||||
|
const firstLetter = name.replace(/^[^a-zA-Z0-9]+/, '').charAt(0).toUpperCase();
|
||||||
|
return firstLetter || '#';
|
||||||
|
}
|
||||||
|
case 'tvshow': {
|
||||||
|
// For TV shows, extract the show name
|
||||||
|
const showName = name.replace(/[sS]\d{2}[eE]\d{2}.*$/, '').trim();
|
||||||
|
return showName;
|
||||||
|
}
|
||||||
|
case 'audio': {
|
||||||
|
// For music, try to extract artist name
|
||||||
|
const artistMatch = name.match(/^(.*?)\s*-\s*/);
|
||||||
|
return artistMatch ? artistMatch[1].trim() : 'Unsorted';
|
||||||
|
}
|
||||||
|
case 'book': {
|
||||||
|
// For books, use the first letter of title or author names
|
||||||
|
const firstLetter = name.replace(/^[^a-zA-Z0-9]+/, '').charAt(0).toUpperCase();
|
||||||
|
return firstLetter || '#';
|
||||||
|
}
|
||||||
|
case 'magazine': {
|
||||||
|
// For magazines, use the magazine name if possible
|
||||||
|
const magazineMatch = name.match(/^(.*?)\s*(?:Issue|Vol|Volume)/i);
|
||||||
|
return magazineMatch ? magazineMatch[1].trim() : 'Unsorted';
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process archive files (extract them)
|
||||||
|
* @param {Object} torrent - Torrent object
|
||||||
|
* @param {Object} mediaInfo - Media information
|
||||||
|
* @param {string} destinationDir - Destination directory
|
||||||
|
*/
|
||||||
|
async processArchives(torrent, mediaInfo, destinationDir) {
|
||||||
|
console.log(`Processing archives in ${torrent.name}`);
|
||||||
|
|
||||||
|
const archiveFiles = mediaInfo.archiveFiles;
|
||||||
|
const torrentDir = torrent.downloadDir;
|
||||||
|
|
||||||
|
for (const file of archiveFiles) {
|
||||||
|
const filePath = path.join(torrentDir, file.name);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Create a unique extraction directory
|
||||||
|
const extractionDirName = path.basename(file.name, path.extname(file.name));
|
||||||
|
const extractionDir = path.join(destinationDir, extractionDirName);
|
||||||
|
|
||||||
|
await this.createDirectoryIfNotExists(extractionDir);
|
||||||
|
|
||||||
|
console.log(`Extracting ${filePath} to ${extractionDir}`);
|
||||||
|
|
||||||
|
// Extract the archive based on type
|
||||||
|
if (/\.zip$/i.test(file.name)) {
|
||||||
|
await exec(`unzip -o "${filePath}" -d "${extractionDir}"`);
|
||||||
|
} else if (/\.rar$/i.test(file.name)) {
|
||||||
|
await exec(`unrar x -o+ "${filePath}" "${extractionDir}"`);
|
||||||
|
} else if (/\.7z$/i.test(file.name)) {
|
||||||
|
await exec(`7z x "${filePath}" -o"${extractionDir}"`);
|
||||||
|
} else if (/\.tar(\.(gz|bz2|xz))?$/i.test(file.name)) {
|
||||||
|
await exec(`tar -xf "${filePath}" -C "${extractionDir}"`);
|
||||||
|
} else {
|
||||||
|
console.log(`Unknown archive format for ${file.name}, skipping extraction`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Successfully extracted ${file.name}`);
|
||||||
|
|
||||||
|
// Delete archive if option is enabled
|
||||||
|
if (this.processingOptions.deleteArchives) {
|
||||||
|
try {
|
||||||
|
console.log(`Deleting archive after extraction: ${filePath}`);
|
||||||
|
await fs.unlink(filePath);
|
||||||
|
} catch (deleteError) {
|
||||||
|
console.error(`Failed to delete archive ${filePath}:`, deleteError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error extracting archive ${filePath}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process standard (non-archive) files
|
||||||
|
* @param {Object} torrent - Torrent object
|
||||||
|
* @param {Object} mediaInfo - Media information
|
||||||
|
* @param {string} destinationDir - Destination directory
|
||||||
|
*/
|
||||||
|
async processStandardFiles(torrent, mediaInfo, destinationDir) {
|
||||||
|
console.log(`Processing standard files in ${torrent.name}`);
|
||||||
|
|
||||||
|
const torrentDir = torrent.downloadDir;
|
||||||
|
const allFiles = [];
|
||||||
|
|
||||||
|
// Collect all files based on media type
|
||||||
|
switch (mediaInfo.type) {
|
||||||
|
case 'movie':
|
||||||
|
case 'tvshow':
|
||||||
|
allFiles.push(...mediaInfo.videoFiles);
|
||||||
|
break;
|
||||||
|
case 'audio':
|
||||||
|
allFiles.push(...mediaInfo.audioFiles);
|
||||||
|
break;
|
||||||
|
case 'book':
|
||||||
|
case 'magazine':
|
||||||
|
allFiles.push(...mediaInfo.documentFiles);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
// For unknown/software, add all files except samples if enabled
|
||||||
|
for (const type of Object.keys(mediaInfo)) {
|
||||||
|
if (Array.isArray(mediaInfo[type])) {
|
||||||
|
allFiles.push(...mediaInfo[type]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter out sample files if option is enabled
|
||||||
|
let filesToProcess = allFiles;
|
||||||
|
if (this.processingOptions.ignoreSample) {
|
||||||
|
filesToProcess = allFiles.filter(file => !file.isSample);
|
||||||
|
console.log(`Filtered out ${allFiles.length - filesToProcess.length} sample files`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process each file
|
||||||
|
for (const file of filesToProcess) {
|
||||||
|
const sourceFilePath = path.join(torrentDir, file.name);
|
||||||
|
let destFileName = file.name;
|
||||||
|
|
||||||
|
// Generate a better filename if rename option is enabled
|
||||||
|
if (this.processingOptions.renameFiles) {
|
||||||
|
destFileName = this.generateBetterFilename(file.name, mediaInfo.type);
|
||||||
|
}
|
||||||
|
|
||||||
|
const destFilePath = path.join(destinationDir, destFileName);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check if destination file already exists with the same name
|
||||||
|
const fileExists = await this.fileExists(destFilePath);
|
||||||
|
|
||||||
|
if (fileExists) {
|
||||||
|
if (this.processingOptions.autoReplaceUpgrades) {
|
||||||
|
// Compare file sizes to see if the new one is larger (potentially higher quality)
|
||||||
|
const existingStats = await fs.stat(destFilePath);
|
||||||
|
|
||||||
|
if (file.size > existingStats.size) {
|
||||||
|
console.log(`Replacing existing file with larger version: ${destFilePath}`);
|
||||||
|
await fs.copyFile(sourceFilePath, destFilePath);
|
||||||
|
} else {
|
||||||
|
console.log(`Skipping ${file.name}, existing file is same or better quality`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Generate a unique filename
|
||||||
|
const uniqueDestFilePath = this.makeFilenameUnique(destFilePath);
|
||||||
|
console.log(`Copying ${file.name} to ${uniqueDestFilePath}`);
|
||||||
|
await fs.copyFile(sourceFilePath, uniqueDestFilePath);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// File doesn't exist, simple copy
|
||||||
|
console.log(`Copying ${file.name} to ${destFilePath}`);
|
||||||
|
await fs.copyFile(sourceFilePath, destFilePath);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error processing file ${file.name}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a better filename based on content type
|
||||||
|
* @param {string} originalFilename - Original filename
|
||||||
|
* @param {string} mediaType - Media type
|
||||||
|
* @returns {string} Improved filename
|
||||||
|
*/
|
||||||
|
generateBetterFilename(originalFilename, mediaType) {
|
||||||
|
// Get the file extension
|
||||||
|
const ext = path.extname(originalFilename);
|
||||||
|
const basename = path.basename(originalFilename, ext);
|
||||||
|
|
||||||
|
// Clean up common issues in filenames
|
||||||
|
let cleanName = basename
|
||||||
|
.replace(/\[.*?\]|\(.*?\)/g, '') // Remove content in brackets/parentheses
|
||||||
|
.replace(/\._/g, '.') // Remove underscore after dots
|
||||||
|
.replace(/\./g, ' ') // Replace dots with spaces
|
||||||
|
.replace(/_/g, ' ') // Replace underscores with spaces
|
||||||
|
.replace(/\s{2,}/g, ' ') // Replace multiple spaces with a single one
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
// Media type specific formatting
|
||||||
|
switch (mediaType) {
|
||||||
|
case 'movie':
|
||||||
|
// Keep (year) format for movies if present
|
||||||
|
const yearMatch = basename.match(/\(*(19|20)\d{2}\)*$/);
|
||||||
|
if (yearMatch) {
|
||||||
|
const year = yearMatch[0].replace(/[()]/g, '');
|
||||||
|
// Remove any year that might have been part of the clean name already
|
||||||
|
cleanName = cleanName.replace(/(19|20)\d{2}/g, '').trim();
|
||||||
|
// Add the year in a consistent format
|
||||||
|
cleanName = `${cleanName} (${year})`;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'tvshow':
|
||||||
|
// Keep season and episode info for TV shows
|
||||||
|
const episodeMatch = basename.match(/[sS](\d{1,2})[eE](\d{1,2})/);
|
||||||
|
if (episodeMatch) {
|
||||||
|
const seasonNum = parseInt(episodeMatch[1], 10);
|
||||||
|
const episodeNum = parseInt(episodeMatch[2], 10);
|
||||||
|
|
||||||
|
// First, remove any existing season/episode info from clean name
|
||||||
|
cleanName = cleanName.replace(/[sS]\d{1,2}[eE]\d{1,2}/g, '').trim();
|
||||||
|
|
||||||
|
// Add back the season/episode in a consistent format
|
||||||
|
cleanName = `${cleanName} S${seasonNum.toString().padStart(2, '0')}E${episodeNum.toString().padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'audio':
|
||||||
|
// Try to organize as "Artist - Title" for music
|
||||||
|
const musicMatch = basename.match(/^(.*?)\s*-\s*(.*?)$/);
|
||||||
|
if (musicMatch && musicMatch[1] && musicMatch[2]) {
|
||||||
|
const artist = musicMatch[1].trim();
|
||||||
|
const title = musicMatch[2].trim();
|
||||||
|
cleanName = `${artist} - ${title}`;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return cleanName + ext;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make a filename unique by adding a suffix
|
||||||
|
* @param {string} filepath - Original filepath
|
||||||
|
* @returns {string} Unique filepath
|
||||||
|
*/
|
||||||
|
makeFilenameUnique(filepath) {
|
||||||
|
const ext = path.extname(filepath);
|
||||||
|
const basename = path.basename(filepath, ext);
|
||||||
|
const dirname = path.dirname(filepath);
|
||||||
|
|
||||||
|
// Add a timestamp to make it unique
|
||||||
|
const timestamp = new Date().toISOString().replace(/[-:]/g, '').replace('T', '_').substring(0, 15);
|
||||||
|
return path.join(dirname, `${basename}_${timestamp}${ext}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a directory if it doesn't exist
|
||||||
|
* @param {string} dirPath - Directory path
|
||||||
|
*/
|
||||||
|
async createDirectoryIfNotExists(dirPath) {
|
||||||
|
try {
|
||||||
|
await fs.mkdir(dirPath, { recursive: true });
|
||||||
|
} catch (error) {
|
||||||
|
// Ignore error if directory already exists
|
||||||
|
if (error.code !== 'EEXIST') {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a file exists
|
||||||
|
* @param {string} filePath - File path
|
||||||
|
* @returns {Promise<boolean>} Whether the file exists
|
||||||
|
*/
|
||||||
|
async fileExists(filePath) {
|
||||||
|
try {
|
||||||
|
await fs.access(filePath);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = PostProcessor;
|
1
modules/postProcessor
Symbolic link
1
modules/postProcessor
Symbolic link
@ -0,0 +1 @@
|
|||||||
|
post-processor.js
|
1
modules/postProcessor.js
Symbolic link
1
modules/postProcessor.js
Symbolic link
@ -0,0 +1 @@
|
|||||||
|
post-processor.js
|
1
modules/rss-feed-manager
Symbolic link
1
modules/rss-feed-manager
Symbolic link
@ -0,0 +1 @@
|
|||||||
|
rss-feed-manager.js
|
787
modules/rss-feed-manager.js
Normal file
787
modules/rss-feed-manager.js
Normal file
@ -0,0 +1,787 @@
|
|||||||
|
// rss-feed-manager.js - Handles RSS feed fetching, parsing, and torrent management
|
||||||
|
const fs = require('fs').promises;
|
||||||
|
const path = require('path');
|
||||||
|
const fetch = require('node-fetch');
|
||||||
|
const xml2js = require('xml2js');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
|
class RssFeedManager {
|
||||||
|
constructor(config) {
|
||||||
|
if (!config) {
|
||||||
|
throw new Error('Configuration is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.config = config;
|
||||||
|
this.feeds = config.feeds || [];
|
||||||
|
this.items = [];
|
||||||
|
this.updateIntervalId = null;
|
||||||
|
this.updateIntervalMinutes = config.updateIntervalMinutes || 60;
|
||||||
|
this.parser = new xml2js.Parser({ explicitArray: false });
|
||||||
|
|
||||||
|
// Set up the data path - first check if a data path was provided in the config
|
||||||
|
if (config.dataPath) {
|
||||||
|
this.dataPath = config.dataPath;
|
||||||
|
} else {
|
||||||
|
// Otherwise, use the default path relative to this module
|
||||||
|
this.dataPath = path.join(__dirname, '..', 'data');
|
||||||
|
|
||||||
|
// Log the data path for debugging
|
||||||
|
console.log(`Data directory path set to: ${this.dataPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// We'll always ensure the data directory exists regardless of where it's set
|
||||||
|
// Use synchronous operation to ensure directory exists immediately upon construction
|
||||||
|
try {
|
||||||
|
const fsSync = require('fs');
|
||||||
|
if (!fsSync.existsSync(this.dataPath)) {
|
||||||
|
fsSync.mkdirSync(this.dataPath, { recursive: true });
|
||||||
|
console.log(`Created data directory synchronously: ${this.dataPath}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`Warning: Could not create data directory synchronously: ${err.message}`);
|
||||||
|
// Will try again asynchronously in ensureDataDirectory when start() is called
|
||||||
|
}
|
||||||
|
|
||||||
|
// Maximum items to keep in memory to prevent memory leaks
|
||||||
|
this.maxItemsInMemory = config.maxItemsInMemory || 5000;
|
||||||
|
}
|
||||||
|
|
||||||
|
async start() {
|
||||||
|
if (this.updateIntervalId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Make sure the data directory exists first
|
||||||
|
await this.ensureDataDirectory();
|
||||||
|
console.log(`Using data directory: ${this.dataPath}`);
|
||||||
|
|
||||||
|
// Load existing feeds and items
|
||||||
|
await this.loadFeeds();
|
||||||
|
await this.loadItems();
|
||||||
|
|
||||||
|
// Run update immediately
|
||||||
|
await this.updateAllFeeds().catch(error => {
|
||||||
|
console.error('Error in initial feed update:', error);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Then set up interval
|
||||||
|
this.updateIntervalId = setInterval(async () => {
|
||||||
|
await this.updateAllFeeds().catch(error => {
|
||||||
|
console.error('Error in scheduled feed update:', error);
|
||||||
|
});
|
||||||
|
}, this.updateIntervalMinutes * 60 * 1000);
|
||||||
|
|
||||||
|
console.log(`RSS feed manager started, interval: ${this.updateIntervalMinutes} minutes`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to start RSS feed manager:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
if (this.updateIntervalId) {
|
||||||
|
clearInterval(this.updateIntervalId);
|
||||||
|
this.updateIntervalId = null;
|
||||||
|
console.log('RSS feed manager stopped');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateAllFeeds() {
|
||||||
|
console.log('Updating all RSS feeds...');
|
||||||
|
|
||||||
|
const results = [];
|
||||||
|
|
||||||
|
// Check if feeds array is valid
|
||||||
|
if (!Array.isArray(this.feeds)) {
|
||||||
|
console.error('Feeds is not an array:', this.feeds);
|
||||||
|
this.feeds = [];
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const feed of this.feeds) {
|
||||||
|
if (!feed || !feed.id || !feed.url) {
|
||||||
|
console.error('Invalid feed object:', feed);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await this.updateFeed(feed);
|
||||||
|
results.push({
|
||||||
|
feedId: feed.id,
|
||||||
|
success: true,
|
||||||
|
newItems: result.newItems
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error updating feed ${feed.id} (${feed.url}):`, error.message);
|
||||||
|
results.push({
|
||||||
|
feedId: feed.id,
|
||||||
|
success: false,
|
||||||
|
error: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Save updated items and truncate if necessary
|
||||||
|
this.trimItemsIfNeeded();
|
||||||
|
await this.saveItems();
|
||||||
|
await this.saveFeeds();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving data after feed update:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('RSS feed update completed');
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trim items to prevent memory bloat
|
||||||
|
trimItemsIfNeeded() {
|
||||||
|
if (this.items.length > this.maxItemsInMemory) {
|
||||||
|
console.log(`Trimming items from ${this.items.length} to ${this.maxItemsInMemory}`);
|
||||||
|
|
||||||
|
// Sort by date (newest first) and keep only the newest maxItemsInMemory items
|
||||||
|
this.items.sort((a, b) => new Date(b.added) - new Date(a.added));
|
||||||
|
this.items = this.items.slice(0, this.maxItemsInMemory);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateFeed(feed) {
|
||||||
|
if (!feed || !feed.url) {
|
||||||
|
throw new Error('Invalid feed configuration');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Updating feed: ${feed.name || 'Unnamed'} (${feed.url})`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get version from package.json if available, fallback to environment or hardcoded
|
||||||
|
const version = process.env.APP_VERSION || require('../package.json').version || '2.0.9';
|
||||||
|
|
||||||
|
const response = await fetch(feed.url, {
|
||||||
|
timeout: 30000, // 30 second timeout
|
||||||
|
headers: {
|
||||||
|
'User-Agent': `Transmission-RSS-Manager/${version}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error ${response.status}: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const xml = await response.text();
|
||||||
|
|
||||||
|
if (!xml || xml.trim() === '') {
|
||||||
|
throw new Error('Empty feed content');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await this.parseXml(xml);
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
throw new Error('Failed to parse XML feed');
|
||||||
|
}
|
||||||
|
|
||||||
|
const rssItems = this.extractItems(result, feed);
|
||||||
|
const newItems = this.processNewItems(rssItems, feed);
|
||||||
|
|
||||||
|
console.log(`Found ${rssItems.length} items, ${newItems.length} new items in feed: ${feed.name || 'Unnamed'}`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalItems: rssItems.length,
|
||||||
|
newItems: newItems.length
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error updating feed ${feed.url}:`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
parseXml(xml) {
|
||||||
|
if (!xml || typeof xml !== 'string') {
|
||||||
|
return Promise.reject(new Error('Invalid XML input'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this.parser.parseString(xml, (error, result) => {
|
||||||
|
if (error) {
|
||||||
|
reject(error);
|
||||||
|
} else {
|
||||||
|
resolve(result);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
extractItems(parsedXml, feed) {
|
||||||
|
if (!parsedXml || !feed) {
|
||||||
|
console.error('Invalid parsed XML or feed');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Handle standard RSS 2.0
|
||||||
|
if (parsedXml.rss && parsedXml.rss.channel) {
|
||||||
|
const channel = parsedXml.rss.channel;
|
||||||
|
|
||||||
|
if (!channel.item) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = Array.isArray(channel.item)
|
||||||
|
? channel.item.filter(Boolean)
|
||||||
|
: (channel.item ? [channel.item] : []);
|
||||||
|
|
||||||
|
return items.map(item => this.normalizeRssItem(item, feed));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle Atom
|
||||||
|
if (parsedXml.feed && parsedXml.feed.entry) {
|
||||||
|
const entries = Array.isArray(parsedXml.feed.entry)
|
||||||
|
? parsedXml.feed.entry.filter(Boolean)
|
||||||
|
: (parsedXml.feed.entry ? [parsedXml.feed.entry] : []);
|
||||||
|
|
||||||
|
return entries.map(entry => this.normalizeAtomItem(entry, feed));
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error extracting items from XML:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizeRssItem(item, feed) {
|
||||||
|
if (!item || !feed) {
|
||||||
|
console.error('Invalid RSS item or feed');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Create a unique ID for the item
|
||||||
|
const title = item.title || 'Untitled';
|
||||||
|
const pubDate = item.pubDate || '';
|
||||||
|
const link = item.link || '';
|
||||||
|
const idContent = `${feed.id}:${title}:${pubDate}:${link}`;
|
||||||
|
const id = crypto.createHash('md5').update(idContent).digest('hex');
|
||||||
|
|
||||||
|
// Extract enclosure (torrent link)
|
||||||
|
let torrentLink = link;
|
||||||
|
let fileSize = 0;
|
||||||
|
|
||||||
|
if (item.enclosure) {
|
||||||
|
if (item.enclosure.$) {
|
||||||
|
torrentLink = item.enclosure.$.url || torrentLink;
|
||||||
|
fileSize = parseInt(item.enclosure.$.length || 0, 10);
|
||||||
|
} else if (typeof item.enclosure === 'object') {
|
||||||
|
torrentLink = item.enclosure.url || torrentLink;
|
||||||
|
fileSize = parseInt(item.enclosure.length || 0, 10);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle custom namespaces (common in torrent feeds)
|
||||||
|
let category = '';
|
||||||
|
let size = fileSize;
|
||||||
|
|
||||||
|
if (item.category) {
|
||||||
|
category = Array.isArray(item.category) ? item.category[0] : item.category;
|
||||||
|
// Handle if category is an object with a value property
|
||||||
|
if (typeof category === 'object' && category._) {
|
||||||
|
category = category._;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Some feeds use torrent:contentLength
|
||||||
|
if (item['torrent:contentLength']) {
|
||||||
|
const contentLength = parseInt(item['torrent:contentLength'], 10);
|
||||||
|
if (!isNaN(contentLength)) {
|
||||||
|
size = contentLength;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
feedId: feed.id,
|
||||||
|
title,
|
||||||
|
link,
|
||||||
|
torrentLink,
|
||||||
|
pubDate: pubDate || new Date().toISOString(),
|
||||||
|
category: category || '',
|
||||||
|
description: item.description || '',
|
||||||
|
size: !isNaN(size) ? size : 0,
|
||||||
|
downloaded: false,
|
||||||
|
ignored: false,
|
||||||
|
added: new Date().toISOString()
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error normalizing RSS item:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizeAtomItem(entry, feed) {
|
||||||
|
if (!entry || !feed) {
|
||||||
|
console.error('Invalid Atom entry or feed');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Create a unique ID for the item
|
||||||
|
const title = entry.title || 'Untitled';
|
||||||
|
const updated = entry.updated || '';
|
||||||
|
const entryId = entry.id || '';
|
||||||
|
const idContent = `${feed.id}:${title}:${updated}:${entryId}`;
|
||||||
|
const id = crypto.createHash('md5').update(idContent).digest('hex');
|
||||||
|
|
||||||
|
// Extract link
|
||||||
|
let link = '';
|
||||||
|
let torrentLink = '';
|
||||||
|
|
||||||
|
if (entry.link) {
|
||||||
|
if (Array.isArray(entry.link)) {
|
||||||
|
const links = entry.link.filter(l => l && l.$);
|
||||||
|
const alternateLink = links.find(l => l.$ && l.$.rel === 'alternate');
|
||||||
|
const torrentTypeLink = links.find(l => l.$ && l.$.type && l.$.type.includes('torrent'));
|
||||||
|
|
||||||
|
link = alternateLink && alternateLink.$ && alternateLink.$.href ?
|
||||||
|
alternateLink.$.href :
|
||||||
|
(links[0] && links[0].$ && links[0].$.href ? links[0].$.href : '');
|
||||||
|
|
||||||
|
torrentLink = torrentTypeLink && torrentTypeLink.$ && torrentTypeLink.$.href ?
|
||||||
|
torrentTypeLink.$.href : link;
|
||||||
|
} else if (entry.link.$ && entry.link.$.href) {
|
||||||
|
link = entry.link.$.href;
|
||||||
|
torrentLink = link;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract category
|
||||||
|
let category = '';
|
||||||
|
if (entry.category && entry.category.$ && entry.category.$.term) {
|
||||||
|
category = entry.category.$.term;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract content
|
||||||
|
let description = '';
|
||||||
|
if (entry.summary) {
|
||||||
|
description = entry.summary;
|
||||||
|
} else if (entry.content) {
|
||||||
|
description = entry.content;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
feedId: feed.id,
|
||||||
|
title,
|
||||||
|
link,
|
||||||
|
torrentLink,
|
||||||
|
pubDate: entry.updated || entry.published || new Date().toISOString(),
|
||||||
|
category,
|
||||||
|
description,
|
||||||
|
size: 0, // Atom doesn't typically include file size
|
||||||
|
downloaded: false,
|
||||||
|
ignored: false,
|
||||||
|
added: new Date().toISOString()
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error normalizing Atom item:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
processNewItems(rssItems, feed) {
|
||||||
|
if (!Array.isArray(rssItems) || !feed) {
|
||||||
|
console.error('Invalid RSS items array or feed');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const newItems = [];
|
||||||
|
|
||||||
|
// Filter out null items
|
||||||
|
const validItems = rssItems.filter(item => item !== null);
|
||||||
|
|
||||||
|
for (const item of validItems) {
|
||||||
|
// Check if item already exists in our list
|
||||||
|
const existingItem = this.items.find(i => i.id === item.id);
|
||||||
|
|
||||||
|
if (!existingItem) {
|
||||||
|
// Add new item to our list
|
||||||
|
this.items.push(item);
|
||||||
|
newItems.push(item);
|
||||||
|
|
||||||
|
// Auto-download if enabled and matches filters
|
||||||
|
if (feed.autoDownload && this.matchesFilters(item, feed.filters)) {
|
||||||
|
this.queueItemForDownload(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return newItems;
|
||||||
|
}
|
||||||
|
|
||||||
|
matchesFilters(item, filters) {
|
||||||
|
if (!item) return false;
|
||||||
|
|
||||||
|
if (!filters || !Array.isArray(filters) || filters.length === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the item matches any of the filters
|
||||||
|
return filters.some(filter => {
|
||||||
|
if (!filter) return true;
|
||||||
|
|
||||||
|
// Title check
|
||||||
|
if (filter.title && typeof item.title === 'string' &&
|
||||||
|
!item.title.toLowerCase().includes(filter.title.toLowerCase())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Category check
|
||||||
|
if (filter.category && typeof item.category === 'string' &&
|
||||||
|
!item.category.toLowerCase().includes(filter.category.toLowerCase())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Size checks
|
||||||
|
if (filter.minSize && typeof item.size === 'number' && item.size < filter.minSize) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.maxSize && typeof item.size === 'number' && item.size > filter.maxSize) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// All checks passed
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
queueItemForDownload(item) {
|
||||||
|
if (!item) return;
|
||||||
|
|
||||||
|
// Mark the item as queued for download
|
||||||
|
console.log(`Auto-downloading item: ${item.title}`);
|
||||||
|
|
||||||
|
// This would be implemented to add to Transmission
|
||||||
|
// But we need a reference to the Transmission client
|
||||||
|
// In a real implementation, this might publish to a queue that's consumed elsewhere
|
||||||
|
item.downloadQueued = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveItems() {
|
||||||
|
try {
|
||||||
|
// Ensure data directory exists
|
||||||
|
await this.ensureDataDirectory();
|
||||||
|
|
||||||
|
// Save items to file
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(this.dataPath, 'rss-items.json'),
|
||||||
|
JSON.stringify(this.items, null, 2),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`Saved ${this.items.length} RSS items to disk`);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving RSS items:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveFeeds() {
|
||||||
|
try {
|
||||||
|
// Ensure data directory exists
|
||||||
|
await this.ensureDataDirectory();
|
||||||
|
|
||||||
|
// Save feeds to file
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(this.dataPath, 'rss-feeds.json'),
|
||||||
|
JSON.stringify(this.feeds, null, 2),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`Saved ${this.feeds.length} RSS feeds to disk`);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving RSS feeds:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensures the data directory exists, using a consistent approach across the application
|
||||||
|
* @returns {Promise<boolean>} true if directory exists or was created
|
||||||
|
*/
|
||||||
|
async ensureDataDirectory() {
|
||||||
|
try {
|
||||||
|
// Create data directory with recursive option (creates all parent directories if they don't exist)
|
||||||
|
await fs.mkdir(this.dataPath, { recursive: true });
|
||||||
|
console.log(`Ensured data directory exists at: ${this.dataPath}`);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
// Log the error details for debugging
|
||||||
|
console.error(`Error creating data directory ${this.dataPath}:`, error);
|
||||||
|
|
||||||
|
// Try an alternate approach if the first method fails
|
||||||
|
try {
|
||||||
|
const { execSync } = require('child_process');
|
||||||
|
execSync(`mkdir -p "${this.dataPath}"`);
|
||||||
|
console.log(`Created data directory using fallback method: ${this.dataPath}`);
|
||||||
|
return true;
|
||||||
|
} catch (fallbackError) {
|
||||||
|
console.error('All methods for creating data directory failed:', fallbackError);
|
||||||
|
throw new Error(`Failed to create data directory: ${this.dataPath}. Original error: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadItems() {
|
||||||
|
try {
|
||||||
|
const filePath = path.join(this.dataPath, 'rss-items.json');
|
||||||
|
|
||||||
|
// Check if file exists
|
||||||
|
try {
|
||||||
|
await fs.access(filePath);
|
||||||
|
} catch (error) {
|
||||||
|
console.log('No saved RSS items found');
|
||||||
|
this.items = [];
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load items from file
|
||||||
|
const data = await fs.readFile(filePath, 'utf8');
|
||||||
|
|
||||||
|
if (!data || data.trim() === '') {
|
||||||
|
console.log('Empty RSS items file');
|
||||||
|
this.items = [];
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const items = JSON.parse(data);
|
||||||
|
|
||||||
|
if (Array.isArray(items)) {
|
||||||
|
this.items = items;
|
||||||
|
console.log(`Loaded ${this.items.length} RSS items from disk`);
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
console.error('RSS items file does not contain an array');
|
||||||
|
this.items = [];
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (parseError) {
|
||||||
|
console.error('Error parsing RSS items JSON:', parseError);
|
||||||
|
this.items = [];
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading RSS items:', error);
|
||||||
|
this.items = [];
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadFeeds() {
|
||||||
|
try {
|
||||||
|
const filePath = path.join(this.dataPath, 'rss-feeds.json');
|
||||||
|
|
||||||
|
// Check if file exists
|
||||||
|
try {
|
||||||
|
await fs.access(filePath);
|
||||||
|
} catch (error) {
|
||||||
|
console.log('No saved RSS feeds found, using config feeds');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load feeds from file
|
||||||
|
const data = await fs.readFile(filePath, 'utf8');
|
||||||
|
|
||||||
|
if (!data || data.trim() === '') {
|
||||||
|
console.log('Empty RSS feeds file, using config feeds');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const feeds = JSON.parse(data);
|
||||||
|
|
||||||
|
if (Array.isArray(feeds)) {
|
||||||
|
this.feeds = feeds;
|
||||||
|
console.log(`Loaded ${this.feeds.length} RSS feeds from disk`);
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
console.error('RSS feeds file does not contain an array');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (parseError) {
|
||||||
|
console.error('Error parsing RSS feeds JSON:', parseError);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading RSS feeds:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Public API methods
|
||||||
|
|
||||||
|
getAllFeeds() {
|
||||||
|
return Array.isArray(this.feeds) ? this.feeds : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
addFeed(feedData) {
|
||||||
|
if (!feedData || !feedData.url) {
|
||||||
|
throw new Error('Feed URL is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate an ID for the feed
|
||||||
|
const id = crypto.randomBytes(8).toString('hex');
|
||||||
|
|
||||||
|
const newFeed = {
|
||||||
|
id,
|
||||||
|
name: feedData.name || 'Unnamed Feed',
|
||||||
|
url: feedData.url,
|
||||||
|
autoDownload: !!feedData.autoDownload,
|
||||||
|
filters: Array.isArray(feedData.filters) ? feedData.filters : [],
|
||||||
|
added: new Date().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!Array.isArray(this.feeds)) {
|
||||||
|
this.feeds = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
this.feeds.push(newFeed);
|
||||||
|
|
||||||
|
// Save the updated feeds
|
||||||
|
this.saveFeeds().catch(err => {
|
||||||
|
console.error('Error saving feeds after adding new feed:', err);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`Added new feed: ${newFeed.name} (${newFeed.url})`);
|
||||||
|
|
||||||
|
return newFeed;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateFeedConfig(feedId, updates) {
|
||||||
|
if (!feedId || !updates) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(this.feeds)) {
|
||||||
|
console.error('Feeds is not an array');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const feedIndex = this.feeds.findIndex(f => f && f.id === feedId);
|
||||||
|
|
||||||
|
if (feedIndex === -1) {
|
||||||
|
console.error(`Feed with ID ${feedId} not found`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the feed, preserving the id and added date
|
||||||
|
this.feeds[feedIndex] = {
|
||||||
|
...this.feeds[feedIndex],
|
||||||
|
...updates,
|
||||||
|
id: feedId,
|
||||||
|
added: this.feeds[feedIndex].added
|
||||||
|
};
|
||||||
|
|
||||||
|
// Save the updated feeds
|
||||||
|
this.saveFeeds().catch(err => {
|
||||||
|
console.error('Error saving feeds after updating feed:', err);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`Updated feed: ${this.feeds[feedIndex].name}`);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
removeFeed(feedId) {
|
||||||
|
if (!feedId || !Array.isArray(this.feeds)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialLength = this.feeds.length;
|
||||||
|
this.feeds = this.feeds.filter(f => f && f.id !== feedId);
|
||||||
|
|
||||||
|
if (this.feeds.length !== initialLength) {
|
||||||
|
// Save the updated feeds
|
||||||
|
this.saveFeeds().catch(err => {
|
||||||
|
console.error('Error saving feeds after removing feed:', err);
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
getAllItems() {
|
||||||
|
return Array.isArray(this.items) ? this.items : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
getUndownloadedItems() {
|
||||||
|
if (!Array.isArray(this.items)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return this.items.filter(item => item && !item.downloaded && !item.ignored);
|
||||||
|
}
|
||||||
|
|
||||||
|
filterItems(filters) {
|
||||||
|
if (!filters || !Array.isArray(this.items)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return this.items.filter(item => item && this.matchesFilters(item, [filters]));
|
||||||
|
}
|
||||||
|
|
||||||
|
async downloadItem(item, transmissionClient) {
|
||||||
|
if (!item || !item.torrentLink) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: 'Invalid item or missing torrent link'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!transmissionClient) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: 'Transmission client not available'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
transmissionClient.addUrl(item.torrentLink, async (err, result) => {
|
||||||
|
if (err) {
|
||||||
|
console.error(`Error adding torrent for ${item.title}:`, err);
|
||||||
|
resolve({
|
||||||
|
success: false,
|
||||||
|
message: `Error adding torrent: ${err.message}`,
|
||||||
|
result: null
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark the item as downloaded
|
||||||
|
item.downloaded = true;
|
||||||
|
item.downloadDate = new Date().toISOString();
|
||||||
|
|
||||||
|
// Save the updated items
|
||||||
|
try {
|
||||||
|
await this.saveItems();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error saving items after download:', err);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Successfully added torrent for item: ${item.title}`);
|
||||||
|
|
||||||
|
resolve({
|
||||||
|
success: true,
|
||||||
|
message: 'Torrent added successfully',
|
||||||
|
result
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = RssFeedManager;
|
1
modules/rssFeedManager
Symbolic link
1
modules/rssFeedManager
Symbolic link
@ -0,0 +1 @@
|
|||||||
|
rss-feed-manager.js
|
1
modules/rssFeedManager.js
Symbolic link
1
modules/rssFeedManager.js
Symbolic link
@ -0,0 +1 @@
|
|||||||
|
rss-feed-manager.js
|
324
modules/service-setup-module-updated.sh
Executable file
324
modules/service-setup-module-updated.sh
Executable file
@ -0,0 +1,324 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Service setup module for Transmission RSS Manager Installation
|
||||||
|
|
||||||
|
# Setup systemd service
|
||||||
|
function setup_service() {
|
||||||
|
log "INFO" "Setting up systemd service..."
|
||||||
|
|
||||||
|
# Ensure required variables are set
|
||||||
|
if [ -z "$SERVICE_NAME" ]; then
|
||||||
|
log "ERROR" "SERVICE_NAME variable is not set"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$USER" ]; then
|
||||||
|
log "ERROR" "USER variable is not set"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$INSTALL_DIR" ]; then
|
||||||
|
log "ERROR" "INSTALL_DIR variable is not set"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$CONFIG_DIR" ]; then
|
||||||
|
log "ERROR" "CONFIG_DIR variable is not set"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$PORT" ]; then
|
||||||
|
log "ERROR" "PORT variable is not set"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if systemd is available
|
||||||
|
if ! command -v systemctl &> /dev/null; then
|
||||||
|
log "ERROR" "systemd is not available on this system"
|
||||||
|
log "INFO" "Please set up the service manually using your system's service manager"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Ensure the test-and-start script exists and is executable
|
||||||
|
TEST_START_SCRIPT="$INSTALL_DIR/scripts/test-and-start.sh"
|
||||||
|
mkdir -p "$(dirname "$TEST_START_SCRIPT")"
|
||||||
|
cat > "$TEST_START_SCRIPT" << 'EOF'
|
||||||
|
#!/bin/bash
|
||||||
|
# Script to ensure data directory exists and start the application
|
||||||
|
|
||||||
|
# Define paths
|
||||||
|
APP_DIR="$(dirname "$(dirname "$(readlink -f "$0")")")"
|
||||||
|
DATA_DIR="$APP_DIR/data"
|
||||||
|
|
||||||
|
echo "Starting Transmission RSS Manager..."
|
||||||
|
echo "Application directory: $APP_DIR"
|
||||||
|
echo "Data directory: $DATA_DIR"
|
||||||
|
|
||||||
|
# Ensure the data directory exists
|
||||||
|
if [ ! -d "$DATA_DIR" ]; then
|
||||||
|
echo "Creating data directory: $DATA_DIR"
|
||||||
|
mkdir -p "$DATA_DIR"
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo "Failed to create data directory. Trying alternative method..."
|
||||||
|
# Try alternative method if standard mkdir fails
|
||||||
|
cd "$APP_DIR" && mkdir -p data
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo "ERROR: Both methods to create data directory failed. Please check permissions."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Set permissions
|
||||||
|
chmod -R 755 "$DATA_DIR"
|
||||||
|
|
||||||
|
# Check for RSS files
|
||||||
|
if [ ! -f "$DATA_DIR/rss-feeds.json" ]; then
|
||||||
|
echo "Creating initial empty rss-feeds.json file"
|
||||||
|
echo "[]" > "$DATA_DIR/rss-feeds.json"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -f "$DATA_DIR/rss-items.json" ]; then
|
||||||
|
echo "Creating initial empty rss-items.json file"
|
||||||
|
echo "[]" > "$DATA_DIR/rss-items.json"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Find the node executable path
|
||||||
|
NODE_PATH=$(which node 2>/dev/null)
|
||||||
|
if [ -z "$NODE_PATH" ]; then
|
||||||
|
# If node is not in PATH, try common locations
|
||||||
|
for path in /usr/bin/node /usr/local/bin/node /opt/node/bin/node /usr/lib/node; do
|
||||||
|
if [ -x "$path" ]; then
|
||||||
|
NODE_PATH="$path"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# If we still can't find node, use the default path
|
||||||
|
if [ -z "$NODE_PATH" ]; then
|
||||||
|
NODE_PATH="/usr/bin/node"
|
||||||
|
echo "Warning: Node.js not found in PATH, using default path: $NODE_PATH"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Start the application
|
||||||
|
cd "$APP_DIR" || { echo "Failed to change to application directory"; exit 1; }
|
||||||
|
echo "Starting node.js application with: $NODE_PATH $APP_DIR/server.js"
|
||||||
|
exec "$NODE_PATH" "$APP_DIR/server.js"
|
||||||
|
EOF
|
||||||
|
|
||||||
|
chmod +x "$TEST_START_SCRIPT"
|
||||||
|
log "INFO" "Created test-and-start script at $TEST_START_SCRIPT"
|
||||||
|
|
||||||
|
# Check if service file already exists
|
||||||
|
SERVICE_FILE="/etc/systemd/system/$SERVICE_NAME.service"
|
||||||
|
if [ -f "$SERVICE_FILE" ] && [ "$IS_UPDATE" = true ]; then
|
||||||
|
log "INFO" "Service file already exists. Preserving existing service configuration."
|
||||||
|
|
||||||
|
# Extract existing JWT_SECRET if present to maintain session consistency
|
||||||
|
EXISTING_JWT_SECRET=$(grep "Environment=JWT_SECRET=" "$SERVICE_FILE" | cut -d'=' -f3)
|
||||||
|
|
||||||
|
# Extract existing PORT if it differs from the configured one
|
||||||
|
EXISTING_PORT=$(grep "Environment=PORT=" "$SERVICE_FILE" | cut -d'=' -f3)
|
||||||
|
if [ -n "$EXISTING_PORT" ] && [ "$EXISTING_PORT" != "$PORT" ]; then
|
||||||
|
log "INFO" "Using existing port configuration: $EXISTING_PORT"
|
||||||
|
PORT=$EXISTING_PORT
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create backup of existing service file
|
||||||
|
backup_file "$SERVICE_FILE"
|
||||||
|
|
||||||
|
# Update the service file while preserving key settings
|
||||||
|
cat > "$SERVICE_FILE" << EOF
|
||||||
|
[Unit]
|
||||||
|
Description=Transmission RSS Manager
|
||||||
|
After=network.target transmission-daemon.service
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=$USER
|
||||||
|
WorkingDirectory=$INSTALL_DIR
|
||||||
|
ExecStart=$TEST_START_SCRIPT
|
||||||
|
Restart=always
|
||||||
|
RestartSec=10
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
Environment=PORT=$PORT
|
||||||
|
Environment=NODE_ENV=production
|
||||||
|
Environment=DEBUG_ENABLED=false
|
||||||
|
Environment=LOG_FILE=$INSTALL_DIR/logs/transmission-rss-manager.log
|
||||||
|
Environment=CONFIG_DIR=$CONFIG_DIR
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Preserve the existing JWT_SECRET if available
|
||||||
|
if [ -n "$EXISTING_JWT_SECRET" ]; then
|
||||||
|
echo "Environment=JWT_SECRET=$EXISTING_JWT_SECRET" >> "$SERVICE_FILE"
|
||||||
|
else
|
||||||
|
echo "# Generate a random JWT secret for security" >> "$SERVICE_FILE"
|
||||||
|
echo "Environment=JWT_SECRET=$(openssl rand -hex 32)" >> "$SERVICE_FILE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Close the service file definition
|
||||||
|
cat >> "$SERVICE_FILE" << EOF
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
EOF
|
||||||
|
|
||||||
|
else
|
||||||
|
# For fresh installations, create a new service file
|
||||||
|
log "INFO" "Creating new service file"
|
||||||
|
|
||||||
|
# Create backup of existing service file if it exists
|
||||||
|
if [ -f "$SERVICE_FILE" ]; then
|
||||||
|
backup_file "$SERVICE_FILE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create systemd service file
|
||||||
|
cat > "$SERVICE_FILE" << EOF
|
||||||
|
[Unit]
|
||||||
|
Description=Transmission RSS Manager
|
||||||
|
After=network.target transmission-daemon.service
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=$USER
|
||||||
|
WorkingDirectory=$INSTALL_DIR
|
||||||
|
ExecStart=$TEST_START_SCRIPT
|
||||||
|
Restart=always
|
||||||
|
RestartSec=10
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
Environment=PORT=$PORT
|
||||||
|
Environment=NODE_ENV=production
|
||||||
|
Environment=DEBUG_ENABLED=false
|
||||||
|
Environment=LOG_FILE=$INSTALL_DIR/logs/transmission-rss-manager.log
|
||||||
|
Environment=CONFIG_DIR=$CONFIG_DIR
|
||||||
|
# Generate a random JWT secret for security
|
||||||
|
Environment=JWT_SECRET=$(openssl rand -hex 32)
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
EOF
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create logs directory
|
||||||
|
mkdir -p "$INSTALL_DIR/logs"
|
||||||
|
chown -R $USER:$USER "$INSTALL_DIR/logs"
|
||||||
|
|
||||||
|
# Check if file was created successfully
|
||||||
|
if [ ! -f "$SERVICE_FILE" ]; then
|
||||||
|
log "ERROR" "Failed to create systemd service file"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "INFO" "Setting up Nginx reverse proxy..."
|
||||||
|
|
||||||
|
# Check if nginx is installed
|
||||||
|
if ! command -v nginx &> /dev/null; then
|
||||||
|
log "ERROR" "Nginx is not installed"
|
||||||
|
log "INFO" "Skipping Nginx configuration. Please configure your web server manually."
|
||||||
|
|
||||||
|
# Reload systemd and enable service
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable "$SERVICE_NAME"
|
||||||
|
|
||||||
|
log "INFO" "Systemd service has been created and enabled."
|
||||||
|
log "INFO" "The service will start automatically after installation."
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Detect nginx configuration directory
|
||||||
|
NGINX_AVAILABLE_DIR=""
|
||||||
|
NGINX_ENABLED_DIR=""
|
||||||
|
|
||||||
|
if [ -d "/etc/nginx/sites-available" ] && [ -d "/etc/nginx/sites-enabled" ]; then
|
||||||
|
# Debian/Ubuntu style
|
||||||
|
NGINX_AVAILABLE_DIR="/etc/nginx/sites-available"
|
||||||
|
NGINX_ENABLED_DIR="/etc/nginx/sites-enabled"
|
||||||
|
elif [ -d "/etc/nginx/conf.d" ]; then
|
||||||
|
# CentOS/RHEL style
|
||||||
|
NGINX_AVAILABLE_DIR="/etc/nginx/conf.d"
|
||||||
|
NGINX_ENABLED_DIR="/etc/nginx/conf.d"
|
||||||
|
else
|
||||||
|
log "WARN" "Unable to determine Nginx configuration directory"
|
||||||
|
log "INFO" "Please configure Nginx manually"
|
||||||
|
|
||||||
|
# Reload systemd and enable service
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable "$SERVICE_NAME"
|
||||||
|
|
||||||
|
log "INFO" "Systemd service has been created and enabled."
|
||||||
|
log "INFO" "The service will start automatically after installation."
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if default nginx file exists, back it up if it does
|
||||||
|
if [ -f "$NGINX_ENABLED_DIR/default" ]; then
|
||||||
|
backup_file "$NGINX_ENABLED_DIR/default"
|
||||||
|
if [ -f "$NGINX_ENABLED_DIR/default.bak" ]; then
|
||||||
|
log "INFO" "Backed up default nginx configuration."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create nginx configuration file
|
||||||
|
NGINX_CONFIG_FILE="$NGINX_AVAILABLE_DIR/$SERVICE_NAME.conf"
|
||||||
|
cat > "$NGINX_CONFIG_FILE" << EOF
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:$PORT;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade \$http_upgrade;
|
||||||
|
proxy_set_header Connection 'upgrade';
|
||||||
|
proxy_set_header Host \$host;
|
||||||
|
proxy_cache_bypass \$http_upgrade;
|
||||||
|
proxy_set_header X-Real-IP \$remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto \$scheme;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
log "INFO" "Nginx configured to proxy connections from port 80 to port $PORT"
|
||||||
|
log "INFO" "You can access Transmission RSS Manager at http://your-server-ip/ (port 80) via Nginx"
|
||||||
|
|
||||||
|
# Check if Debian/Ubuntu style (need symlink between available and enabled)
|
||||||
|
if [ "$NGINX_AVAILABLE_DIR" != "$NGINX_ENABLED_DIR" ]; then
|
||||||
|
# Create symbolic link to enable the site (if it doesn't already exist)
|
||||||
|
if [ ! -h "$NGINX_ENABLED_DIR/$SERVICE_NAME.conf" ]; then
|
||||||
|
ln -sf "$NGINX_CONFIG_FILE" "$NGINX_ENABLED_DIR/"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Test nginx configuration
|
||||||
|
if nginx -t; then
|
||||||
|
# Reload nginx
|
||||||
|
systemctl reload nginx
|
||||||
|
log "INFO" "Nginx configuration has been set up successfully."
|
||||||
|
else
|
||||||
|
log "ERROR" "Nginx configuration test failed. Please check the configuration manually."
|
||||||
|
log "WARN" "You may need to correct the configuration before the web interface will be accessible."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check for port conflicts
|
||||||
|
if ss -lnt | grep ":$PORT " &> /dev/null; then
|
||||||
|
log "WARN" "Port $PORT is already in use. This may cause conflicts with the service."
|
||||||
|
log "WARN" "The service will fail to start. Please stop any service using port $PORT and try again."
|
||||||
|
else
|
||||||
|
log "INFO" "You can access the web interface at: http://localhost:$PORT or http://your-server-ip:$PORT"
|
||||||
|
log "INFO" "You may need to configure your firewall to allow access to port $PORT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Reload systemd
|
||||||
|
systemctl daemon-reload
|
||||||
|
|
||||||
|
# Enable the service to start on boot
|
||||||
|
systemctl enable "$SERVICE_NAME"
|
||||||
|
|
||||||
|
log "INFO" "Systemd service has been created and enabled."
|
||||||
|
log "INFO" "The service will start automatically after installation."
|
||||||
|
}
|
253
modules/service-setup-module.sh
Normal file
253
modules/service-setup-module.sh
Normal file
@ -0,0 +1,253 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Service setup module for Transmission RSS Manager Installation
|
||||||
|
|
||||||
|
# Setup systemd service
|
||||||
|
function setup_service() {
|
||||||
|
log "INFO" "Setting up systemd service..."
|
||||||
|
|
||||||
|
# Ensure required variables are set
|
||||||
|
if [ -z "$SERVICE_NAME" ]; then
|
||||||
|
log "ERROR" "SERVICE_NAME variable is not set"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$USER" ]; then
|
||||||
|
log "ERROR" "USER variable is not set"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$INSTALL_DIR" ]; then
|
||||||
|
log "ERROR" "INSTALL_DIR variable is not set"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$CONFIG_DIR" ]; then
|
||||||
|
log "ERROR" "CONFIG_DIR variable is not set"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$PORT" ]; then
|
||||||
|
log "ERROR" "PORT variable is not set"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if systemd is available
|
||||||
|
if ! command -v systemctl &> /dev/null; then
|
||||||
|
log "ERROR" "systemd is not available on this system"
|
||||||
|
log "INFO" "Please set up the service manually using your system's service manager"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if service file already exists
|
||||||
|
SERVICE_FILE="/etc/systemd/system/$SERVICE_NAME.service"
|
||||||
|
if [ -f "$SERVICE_FILE" ] && [ "$IS_UPDATE" = true ]; then
|
||||||
|
log "INFO" "Service file already exists. Preserving existing service configuration."
|
||||||
|
|
||||||
|
# Extract existing JWT_SECRET if present to maintain session consistency
|
||||||
|
EXISTING_JWT_SECRET=$(grep "Environment=JWT_SECRET=" "$SERVICE_FILE" | cut -d'=' -f3)
|
||||||
|
|
||||||
|
# Extract existing PORT if it differs from the configured one
|
||||||
|
EXISTING_PORT=$(grep "Environment=PORT=" "$SERVICE_FILE" | cut -d'=' -f3)
|
||||||
|
if [ -n "$EXISTING_PORT" ] && [ "$EXISTING_PORT" != "$PORT" ]; then
|
||||||
|
log "INFO" "Using existing port configuration: $EXISTING_PORT"
|
||||||
|
PORT=$EXISTING_PORT
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create backup of existing service file
|
||||||
|
backup_file "$SERVICE_FILE"
|
||||||
|
|
||||||
|
# Update the service file while preserving key settings
|
||||||
|
cat > "$SERVICE_FILE" << EOF
|
||||||
|
[Unit]
|
||||||
|
Description=Transmission RSS Manager
|
||||||
|
After=network.target transmission-daemon.service
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=$USER
|
||||||
|
WorkingDirectory=$INSTALL_DIR
|
||||||
|
ExecStart=/usr/bin/node $INSTALL_DIR/server.js
|
||||||
|
Restart=always
|
||||||
|
RestartSec=10
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
Environment=PORT=$PORT
|
||||||
|
Environment=NODE_ENV=production
|
||||||
|
Environment=DEBUG_ENABLED=false
|
||||||
|
Environment=LOG_FILE=$INSTALL_DIR/logs/transmission-rss-manager.log
|
||||||
|
Environment=CONFIG_DIR=$CONFIG_DIR
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Preserve the existing JWT_SECRET if available
|
||||||
|
if [ -n "$EXISTING_JWT_SECRET" ]; then
|
||||||
|
echo "Environment=JWT_SECRET=$EXISTING_JWT_SECRET" >> "$SERVICE_FILE"
|
||||||
|
else
|
||||||
|
echo "# Generate a random JWT secret for security" >> "$SERVICE_FILE"
|
||||||
|
echo "Environment=JWT_SECRET=$(openssl rand -hex 32)" >> "$SERVICE_FILE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Close the service file definition
|
||||||
|
cat >> "$SERVICE_FILE" << EOF
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
EOF
|
||||||
|
|
||||||
|
else
|
||||||
|
# For fresh installations, create a new service file
|
||||||
|
log "INFO" "Creating new service file"
|
||||||
|
|
||||||
|
# Create backup of existing service file if it exists
|
||||||
|
if [ -f "$SERVICE_FILE" ]; then
|
||||||
|
backup_file "$SERVICE_FILE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create systemd service file
|
||||||
|
cat > "$SERVICE_FILE" << EOF
|
||||||
|
[Unit]
|
||||||
|
Description=Transmission RSS Manager
|
||||||
|
After=network.target transmission-daemon.service
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=$USER
|
||||||
|
WorkingDirectory=$INSTALL_DIR
|
||||||
|
ExecStart=/usr/bin/node $INSTALL_DIR/server.js
|
||||||
|
Restart=always
|
||||||
|
RestartSec=10
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
Environment=PORT=$PORT
|
||||||
|
Environment=NODE_ENV=production
|
||||||
|
Environment=DEBUG_ENABLED=false
|
||||||
|
Environment=LOG_FILE=$INSTALL_DIR/logs/transmission-rss-manager.log
|
||||||
|
Environment=CONFIG_DIR=$CONFIG_DIR
|
||||||
|
# Generate a random JWT secret for security
|
||||||
|
Environment=JWT_SECRET=$(openssl rand -hex 32)
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
EOF
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create logs directory
|
||||||
|
mkdir -p "$INSTALL_DIR/logs"
|
||||||
|
chown -R $USER:$USER "$INSTALL_DIR/logs"
|
||||||
|
|
||||||
|
# Check if file was created successfully
|
||||||
|
if [ ! -f "$SERVICE_FILE" ]; then
|
||||||
|
log "ERROR" "Failed to create systemd service file"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "INFO" "Setting up Nginx reverse proxy..."
|
||||||
|
|
||||||
|
# Check if nginx is installed
|
||||||
|
if ! command -v nginx &> /dev/null; then
|
||||||
|
log "ERROR" "Nginx is not installed"
|
||||||
|
log "INFO" "Skipping Nginx configuration. Please configure your web server manually."
|
||||||
|
|
||||||
|
# Reload systemd and enable service
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable "$SERVICE_NAME"
|
||||||
|
|
||||||
|
log "INFO" "Systemd service has been created and enabled."
|
||||||
|
log "INFO" "The service will start automatically after installation."
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Detect nginx configuration directory
|
||||||
|
NGINX_AVAILABLE_DIR=""
|
||||||
|
NGINX_ENABLED_DIR=""
|
||||||
|
|
||||||
|
if [ -d "/etc/nginx/sites-available" ] && [ -d "/etc/nginx/sites-enabled" ]; then
|
||||||
|
# Debian/Ubuntu style
|
||||||
|
NGINX_AVAILABLE_DIR="/etc/nginx/sites-available"
|
||||||
|
NGINX_ENABLED_DIR="/etc/nginx/sites-enabled"
|
||||||
|
elif [ -d "/etc/nginx/conf.d" ]; then
|
||||||
|
# CentOS/RHEL style
|
||||||
|
NGINX_AVAILABLE_DIR="/etc/nginx/conf.d"
|
||||||
|
NGINX_ENABLED_DIR="/etc/nginx/conf.d"
|
||||||
|
else
|
||||||
|
log "WARN" "Unable to determine Nginx configuration directory"
|
||||||
|
log "INFO" "Please configure Nginx manually"
|
||||||
|
|
||||||
|
# Reload systemd and enable service
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable "$SERVICE_NAME"
|
||||||
|
|
||||||
|
log "INFO" "Systemd service has been created and enabled."
|
||||||
|
log "INFO" "The service will start automatically after installation."
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if default nginx file exists, back it up if it does
|
||||||
|
if [ -f "$NGINX_ENABLED_DIR/default" ]; then
|
||||||
|
backup_file "$NGINX_ENABLED_DIR/default"
|
||||||
|
if [ -f "$NGINX_ENABLED_DIR/default.bak" ]; then
|
||||||
|
log "INFO" "Backed up default nginx configuration."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create nginx configuration file
|
||||||
|
NGINX_CONFIG_FILE="$NGINX_AVAILABLE_DIR/$SERVICE_NAME.conf"
|
||||||
|
cat > "$NGINX_CONFIG_FILE" << EOF
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:$PORT;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade \$http_upgrade;
|
||||||
|
proxy_set_header Connection 'upgrade';
|
||||||
|
proxy_set_header Host \$host;
|
||||||
|
proxy_cache_bypass \$http_upgrade;
|
||||||
|
proxy_set_header X-Real-IP \$remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto \$scheme;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
log "INFO" "Nginx configured to proxy connections from port 80 to port $PORT"
|
||||||
|
log "INFO" "You can access Transmission RSS Manager at http://your-server-ip/ (port 80) via Nginx"
|
||||||
|
|
||||||
|
# Check if Debian/Ubuntu style (need symlink between available and enabled)
|
||||||
|
if [ "$NGINX_AVAILABLE_DIR" != "$NGINX_ENABLED_DIR" ]; then
|
||||||
|
# Create symbolic link to enable the site (if it doesn't already exist)
|
||||||
|
if [ ! -h "$NGINX_ENABLED_DIR/$SERVICE_NAME.conf" ]; then
|
||||||
|
ln -sf "$NGINX_CONFIG_FILE" "$NGINX_ENABLED_DIR/"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Test nginx configuration
|
||||||
|
if nginx -t; then
|
||||||
|
# Reload nginx
|
||||||
|
systemctl reload nginx
|
||||||
|
log "INFO" "Nginx configuration has been set up successfully."
|
||||||
|
else
|
||||||
|
log "ERROR" "Nginx configuration test failed. Please check the configuration manually."
|
||||||
|
log "WARN" "You may need to correct the configuration before the web interface will be accessible."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check for port conflicts
|
||||||
|
if ss -lnt | grep ":$PORT " &> /dev/null; then
|
||||||
|
log "WARN" "Port $PORT is already in use. This may cause conflicts with the service."
|
||||||
|
log "WARN" "The service will fail to start. Please stop any service using port $PORT and try again."
|
||||||
|
else
|
||||||
|
log "INFO" "You can access the web interface at: http://localhost:$PORT or http://your-server-ip:$PORT"
|
||||||
|
log "INFO" "You may need to configure your firewall to allow access to port $PORT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Reload systemd
|
||||||
|
systemctl daemon-reload
|
||||||
|
|
||||||
|
# Enable the service to start on boot
|
||||||
|
systemctl enable "$SERVICE_NAME"
|
||||||
|
|
||||||
|
log "INFO" "Systemd service has been created and enabled."
|
||||||
|
log "INFO" "The service will start automatically after installation."
|
||||||
|
}
|
1
modules/transmission-client
Symbolic link
1
modules/transmission-client
Symbolic link
@ -0,0 +1 @@
|
|||||||
|
transmission-client.js
|
540
modules/transmission-client.js
Normal file
540
modules/transmission-client.js
Normal file
@ -0,0 +1,540 @@
|
|||||||
|
/**
|
||||||
|
* Transmission Client Module
|
||||||
|
* Enhanced integration with Transmission BitTorrent client
|
||||||
|
*/
|
||||||
|
|
||||||
|
const Transmission = require('transmission-promise');
|
||||||
|
const fs = require('fs').promises;
|
||||||
|
const path = require('path');
|
||||||
|
const util = require('util');
|
||||||
|
const exec = util.promisify(require('child_process').exec);
|
||||||
|
|
||||||
|
class TransmissionClient {
|
||||||
|
constructor(config) {
|
||||||
|
if (!config) {
|
||||||
|
throw new Error('Configuration is required for Transmission client');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.config = config;
|
||||||
|
this.client = null;
|
||||||
|
this.dirMappings = null;
|
||||||
|
this.lastSessionId = null;
|
||||||
|
this.connectRetries = 0;
|
||||||
|
this.maxRetries = 5;
|
||||||
|
this.retryDelay = 5000; // 5 seconds
|
||||||
|
|
||||||
|
// Initialize directory mappings if remote
|
||||||
|
if (config.remoteConfig && config.remoteConfig.isRemote && config.remoteConfig.directoryMapping) {
|
||||||
|
this.dirMappings = config.remoteConfig.directoryMapping;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize the connection - but don't throw if it fails initially
|
||||||
|
// This allows the object to be created even if the connection fails
|
||||||
|
try {
|
||||||
|
this.initializeConnection();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to initialize Transmission connection:", error.message);
|
||||||
|
// Don't throw - allow methods to handle connection retry logic
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the connection to Transmission
|
||||||
|
*/
|
||||||
|
initializeConnection() {
|
||||||
|
const { host, port, username, password, path: rpcPath } = this.config.transmissionConfig;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Only default to localhost if host is empty/null/undefined
|
||||||
|
const connectionHost = (host === undefined || host === null || host === '') ? 'localhost' : host;
|
||||||
|
|
||||||
|
this.client = new Transmission({
|
||||||
|
host: connectionHost,
|
||||||
|
port: port || 9091,
|
||||||
|
username: username || '',
|
||||||
|
password: password || '',
|
||||||
|
path: rpcPath || '/transmission/rpc',
|
||||||
|
timeout: 30000 // 30 seconds
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`Initialized Transmission client connection to ${connectionHost}:${port}${rpcPath}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to initialize Transmission client:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get client status and session information
|
||||||
|
* @returns {Promise<Object>} Status information
|
||||||
|
*/
|
||||||
|
async getStatus() {
|
||||||
|
try {
|
||||||
|
// Use the session-stats method for basic connectivity check
|
||||||
|
const sessionInfo = await this.client.sessionStats();
|
||||||
|
|
||||||
|
// Use the session-get method to get version info
|
||||||
|
// Note: In transmission-promise, this is 'session' not 'sessionGet'
|
||||||
|
const session = await this.client.session();
|
||||||
|
|
||||||
|
return {
|
||||||
|
connected: true,
|
||||||
|
version: session.version || "Unknown",
|
||||||
|
rpcVersion: session['rpc-version'] || "Unknown",
|
||||||
|
downloadSpeed: sessionInfo.downloadSpeed,
|
||||||
|
uploadSpeed: sessionInfo.uploadSpeed,
|
||||||
|
torrentCount: sessionInfo.torrentCount,
|
||||||
|
activeTorrentCount: sessionInfo.activeTorrentCount
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting Transmission status:', error);
|
||||||
|
|
||||||
|
if (error.message.includes('Connection refused') && this.connectRetries < this.maxRetries) {
|
||||||
|
this.connectRetries++;
|
||||||
|
console.log(`Retrying connection (${this.connectRetries}/${this.maxRetries})...`);
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
setTimeout(async () => {
|
||||||
|
this.initializeConnection();
|
||||||
|
try {
|
||||||
|
const status = await this.getStatus();
|
||||||
|
this.connectRetries = 0; // Reset retries on success
|
||||||
|
resolve(status);
|
||||||
|
} catch (retryError) {
|
||||||
|
resolve({
|
||||||
|
connected: false,
|
||||||
|
error: retryError.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, this.retryDelay);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
connected: false,
|
||||||
|
error: error.message
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a torrent from a URL or magnet link
|
||||||
|
* @param {string} url - Torrent URL or magnet link
|
||||||
|
* @param {Object} options - Additional options
|
||||||
|
* @returns {Promise<Object>} Result with torrent ID
|
||||||
|
*/
|
||||||
|
async addTorrent(url, options = {}) {
|
||||||
|
try {
|
||||||
|
// Verify client is initialized
|
||||||
|
if (!this.client) {
|
||||||
|
await this.initializeConnection();
|
||||||
|
|
||||||
|
if (!this.client) {
|
||||||
|
throw new Error("Failed to initialize Transmission client");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const downloadDir = options.downloadDir || null;
|
||||||
|
const result = await this.client.addUrl(url, {
|
||||||
|
"download-dir": downloadDir,
|
||||||
|
paused: options.paused || false
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`Added torrent from ${url}, ID: ${result.id}`);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
id: result.id,
|
||||||
|
name: result.name,
|
||||||
|
hashString: result.hashString
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error adding torrent from ${url}:`, error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.message
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all torrents with detailed information
|
||||||
|
* @param {Array} ids - Optional array of torrent IDs to filter
|
||||||
|
* @returns {Promise<Array>} Array of torrent objects
|
||||||
|
*/
|
||||||
|
async getTorrents(ids = null) {
|
||||||
|
try {
|
||||||
|
const torrents = await this.client.get(ids);
|
||||||
|
|
||||||
|
// Map remote paths to local paths if needed
|
||||||
|
if (this.dirMappings && Object.keys(this.dirMappings).length > 0) {
|
||||||
|
torrents.torrents = torrents.torrents.map(torrent => {
|
||||||
|
torrent.downloadDir = this.mapRemotePathToLocal(torrent.downloadDir);
|
||||||
|
return torrent;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
torrents: torrents.torrents
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting torrents:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.message,
|
||||||
|
torrents: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop torrents by IDs
|
||||||
|
* @param {Array|number} ids - Torrent ID(s) to stop
|
||||||
|
* @returns {Promise<Object>} Result
|
||||||
|
*/
|
||||||
|
async stopTorrents(ids) {
|
||||||
|
try {
|
||||||
|
await this.client.stop(ids);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: 'Torrents stopped successfully'
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error stopping torrents ${ids}:`, error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.message
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start torrents by IDs
|
||||||
|
* @param {Array|number} ids - Torrent ID(s) to start
|
||||||
|
* @returns {Promise<Object>} Result
|
||||||
|
*/
|
||||||
|
async startTorrents(ids) {
|
||||||
|
try {
|
||||||
|
await this.client.start(ids);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: 'Torrents started successfully'
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error starting torrents ${ids}:`, error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.message
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove torrents by IDs
|
||||||
|
* @param {Array|number} ids - Torrent ID(s) to remove
|
||||||
|
* @param {boolean} deleteLocalData - Whether to delete local data
|
||||||
|
* @returns {Promise<Object>} Result
|
||||||
|
*/
|
||||||
|
async removeTorrents(ids, deleteLocalData = false) {
|
||||||
|
try {
|
||||||
|
await this.client.remove(ids, deleteLocalData);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: `Torrents removed successfully${deleteLocalData ? ' with data' : ''}`
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error removing torrents ${ids}:`, error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.message
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get detailed information for a specific torrent
|
||||||
|
* @param {number} id - Torrent ID
|
||||||
|
* @returns {Promise<Object>} Torrent details
|
||||||
|
*/
|
||||||
|
async getTorrentDetails(id) {
|
||||||
|
try {
|
||||||
|
const fields = [
|
||||||
|
'id', 'name', 'status', 'hashString', 'downloadDir', 'totalSize',
|
||||||
|
'percentDone', 'addedDate', 'doneDate', 'uploadRatio', 'rateDownload',
|
||||||
|
'rateUpload', 'downloadedEver', 'uploadedEver', 'seedRatioLimit',
|
||||||
|
'error', 'errorString', 'files', 'fileStats', 'peers', 'peersFrom',
|
||||||
|
'pieces', 'trackers', 'trackerStats', 'labels'
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = await this.client.get(id, fields);
|
||||||
|
|
||||||
|
if (!result.torrents || result.torrents.length === 0) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'Torrent not found'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let torrent = result.torrents[0];
|
||||||
|
|
||||||
|
// Map download directory if needed
|
||||||
|
if (this.dirMappings) {
|
||||||
|
torrent.downloadDir = this.mapRemotePathToLocal(torrent.downloadDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process files for extra information if available
|
||||||
|
if (torrent.files && torrent.files.length > 0) {
|
||||||
|
torrent.mediaInfo = await this.analyzeMediaFiles(torrent.files, torrent.downloadDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
torrent
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error getting torrent details for ID ${id}:`, error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.message
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map a remote path to a local path
|
||||||
|
* @param {string} remotePath - Path on the remote server
|
||||||
|
* @returns {string} Local path
|
||||||
|
*/
|
||||||
|
mapRemotePathToLocal(remotePath) {
|
||||||
|
if (!this.dirMappings || !remotePath) {
|
||||||
|
return remotePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [remote, local] of Object.entries(this.dirMappings)) {
|
||||||
|
if (remotePath.startsWith(remote)) {
|
||||||
|
return remotePath.replace(remote, local);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return remotePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Analyze media files in a torrent
|
||||||
|
* @param {Array} files - Torrent files
|
||||||
|
* @param {string} baseDir - Base directory of the torrent
|
||||||
|
* @returns {Promise<Object>} Media info
|
||||||
|
*/
|
||||||
|
async analyzeMediaFiles(files, baseDir) {
|
||||||
|
try {
|
||||||
|
const mediaInfo = {
|
||||||
|
type: 'unknown',
|
||||||
|
videoFiles: [],
|
||||||
|
audioFiles: [],
|
||||||
|
imageFiles: [],
|
||||||
|
documentFiles: [],
|
||||||
|
archiveFiles: [],
|
||||||
|
otherFiles: [],
|
||||||
|
totalVideoSize: 0,
|
||||||
|
totalAudioSize: 0,
|
||||||
|
totalImageSize: 0,
|
||||||
|
totalDocumentSize: 0,
|
||||||
|
totalArchiveSize: 0,
|
||||||
|
totalOtherSize: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
// File type patterns
|
||||||
|
const videoPattern = /\.(mp4|mkv|avi|mov|wmv|flv|webm|m4v|mpg|mpeg|3gp|ts)$/i;
|
||||||
|
const audioPattern = /\.(mp3|flac|wav|aac|ogg|m4a|wma|opus)$/i;
|
||||||
|
const imagePattern = /\.(jpg|jpeg|png|gif|bmp|tiff|webp|svg)$/i;
|
||||||
|
const documentPattern = /\.(pdf|doc|docx|xls|xlsx|ppt|pptx|txt|rtf|odt|ods|odp|epub|mobi|azw3)$/i;
|
||||||
|
const archivePattern = /\.(zip|rar|7z|tar|gz|bz2|xz|iso)$/i;
|
||||||
|
const subtitlePattern = /\.(srt|sub|sbv|vtt|ass|ssa)$/i;
|
||||||
|
const samplePattern = /sample|trailer/i;
|
||||||
|
|
||||||
|
// Count files by category
|
||||||
|
for (const file of files) {
|
||||||
|
const fileName = path.basename(file.name).toLowerCase();
|
||||||
|
const fileSize = file.length;
|
||||||
|
|
||||||
|
const fileInfo = {
|
||||||
|
name: file.name,
|
||||||
|
size: fileSize,
|
||||||
|
extension: path.extname(file.name).substr(1).toLowerCase(),
|
||||||
|
isSample: samplePattern.test(fileName)
|
||||||
|
};
|
||||||
|
|
||||||
|
if (videoPattern.test(fileName)) {
|
||||||
|
mediaInfo.videoFiles.push(fileInfo);
|
||||||
|
mediaInfo.totalVideoSize += fileSize;
|
||||||
|
} else if (audioPattern.test(fileName)) {
|
||||||
|
mediaInfo.audioFiles.push(fileInfo);
|
||||||
|
mediaInfo.totalAudioSize += fileSize;
|
||||||
|
} else if (imagePattern.test(fileName)) {
|
||||||
|
mediaInfo.imageFiles.push(fileInfo);
|
||||||
|
mediaInfo.totalImageSize += fileSize;
|
||||||
|
} else if (documentPattern.test(fileName)) {
|
||||||
|
mediaInfo.documentFiles.push(fileInfo);
|
||||||
|
mediaInfo.totalDocumentSize += fileSize;
|
||||||
|
} else if (archivePattern.test(fileName)) {
|
||||||
|
mediaInfo.archiveFiles.push(fileInfo);
|
||||||
|
mediaInfo.totalArchiveSize += fileSize;
|
||||||
|
} else if (!subtitlePattern.test(fileName)) {
|
||||||
|
mediaInfo.otherFiles.push(fileInfo);
|
||||||
|
mediaInfo.totalOtherSize += fileSize;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine content type based on file distribution
|
||||||
|
if (mediaInfo.videoFiles.length > 0 &&
|
||||||
|
mediaInfo.totalVideoSize > (mediaInfo.totalAudioSize + mediaInfo.totalDocumentSize)) {
|
||||||
|
mediaInfo.type = 'video';
|
||||||
|
|
||||||
|
// Determine if it's a movie or TV show
|
||||||
|
const tvEpisodePattern = /(s\d{1,2}e\d{1,2}|\d{1,2}x\d{1,2})/i;
|
||||||
|
const movieYearPattern = /\(?(19|20)\d{2}\)?/;
|
||||||
|
|
||||||
|
let tvShowMatch = false;
|
||||||
|
|
||||||
|
for (const file of mediaInfo.videoFiles) {
|
||||||
|
if (tvEpisodePattern.test(file.name)) {
|
||||||
|
tvShowMatch = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tvShowMatch) {
|
||||||
|
mediaInfo.type = 'tvshow';
|
||||||
|
} else if (movieYearPattern.test(files[0].name)) {
|
||||||
|
mediaInfo.type = 'movie';
|
||||||
|
}
|
||||||
|
} else if (mediaInfo.audioFiles.length > 0 &&
|
||||||
|
mediaInfo.totalAudioSize > (mediaInfo.totalVideoSize + mediaInfo.totalDocumentSize)) {
|
||||||
|
mediaInfo.type = 'audio';
|
||||||
|
} else if (mediaInfo.documentFiles.length > 0 &&
|
||||||
|
mediaInfo.totalDocumentSize > (mediaInfo.totalVideoSize + mediaInfo.totalAudioSize)) {
|
||||||
|
// Check if it's a book or magazine
|
||||||
|
const magazinePattern = /(magazine|issue|volume|vol\.)\s*\d+/i;
|
||||||
|
|
||||||
|
let isMagazine = false;
|
||||||
|
for (const file of mediaInfo.documentFiles) {
|
||||||
|
if (magazinePattern.test(file.name)) {
|
||||||
|
isMagazine = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mediaInfo.type = isMagazine ? 'magazine' : 'book';
|
||||||
|
} else if (mediaInfo.archiveFiles.length > 0 &&
|
||||||
|
mediaInfo.totalArchiveSize > (mediaInfo.totalVideoSize + mediaInfo.totalAudioSize + mediaInfo.totalDocumentSize)) {
|
||||||
|
// If archives dominate, we need to check their content
|
||||||
|
mediaInfo.type = 'archive';
|
||||||
|
}
|
||||||
|
|
||||||
|
return mediaInfo;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error analyzing media files:', error);
|
||||||
|
return { type: 'unknown', error: error.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get session stats from Transmission
|
||||||
|
* @returns {Promise<Object>} Stats
|
||||||
|
*/
|
||||||
|
async getSessionStats() {
|
||||||
|
try {
|
||||||
|
const stats = await this.client.sessionStats();
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
stats
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting session stats:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.message
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set session parameters
|
||||||
|
* @param {Object} params - Session parameters
|
||||||
|
* @returns {Promise<Object>} Result
|
||||||
|
*/
|
||||||
|
async setSessionParams(params) {
|
||||||
|
try {
|
||||||
|
// In transmission-promise, the method is sessionUpdate not sessionSet
|
||||||
|
await this.client.sessionUpdate(params);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: 'Session parameters updated successfully'
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error setting session parameters:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.message
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify if a torrent has met seeding requirements
|
||||||
|
* @param {number} id - Torrent ID
|
||||||
|
* @param {Object} requirements - Seeding requirements
|
||||||
|
* @returns {Promise<Object>} Whether requirements are met
|
||||||
|
*/
|
||||||
|
async verifyTorrentSeedingRequirements(id, requirements) {
|
||||||
|
try {
|
||||||
|
const { minRatio = 1.0, minTimeMinutes = 60 } = requirements;
|
||||||
|
|
||||||
|
const details = await this.getTorrentDetails(id);
|
||||||
|
|
||||||
|
if (!details.success) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: details.error
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const torrent = details.torrent;
|
||||||
|
|
||||||
|
// Check if download is complete
|
||||||
|
if (torrent.percentDone < 1.0) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
requirementsMet: false,
|
||||||
|
reason: 'Download not complete',
|
||||||
|
torrent
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check ratio requirement
|
||||||
|
const ratioMet = torrent.uploadRatio >= minRatio;
|
||||||
|
|
||||||
|
// Check time requirement (doneDate is unix timestamp in seconds)
|
||||||
|
const seedingTimeMinutes = (Date.now() / 1000 - torrent.doneDate) / 60;
|
||||||
|
const timeMet = seedingTimeMinutes >= minTimeMinutes;
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
requirementsMet: ratioMet && timeMet,
|
||||||
|
ratioMet,
|
||||||
|
timeMet,
|
||||||
|
currentRatio: torrent.uploadRatio,
|
||||||
|
currentSeedingTimeMinutes: seedingTimeMinutes,
|
||||||
|
torrent
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error checking torrent seeding requirements for ID ${id}:`, error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.message
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = TransmissionClient;
|
1
modules/transmissionClient
Symbolic link
1
modules/transmissionClient
Symbolic link
@ -0,0 +1 @@
|
|||||||
|
transmission-client.js
|
1
modules/transmissionClient.js
Symbolic link
1
modules/transmissionClient.js
Symbolic link
@ -0,0 +1 @@
|
|||||||
|
transmission-client.js
|
312
modules/utils-module.sh
Normal file
312
modules/utils-module.sh
Normal file
@ -0,0 +1,312 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Utilities module for Transmission RSS Manager Installation
|
||||||
|
|
||||||
|
# Function to log a message with timestamp
|
||||||
|
function log() {
|
||||||
|
local level=$1
|
||||||
|
local message=$2
|
||||||
|
local timestamp=$(date '+%Y-%m-%d %H:%M:%S')
|
||||||
|
|
||||||
|
case $level in
|
||||||
|
"INFO")
|
||||||
|
echo -e "${timestamp} ${GREEN}[INFO]${NC} $message"
|
||||||
|
;;
|
||||||
|
"WARN")
|
||||||
|
echo -e "${timestamp} ${YELLOW}[WARN]${NC} $message"
|
||||||
|
;;
|
||||||
|
"ERROR")
|
||||||
|
echo -e "${timestamp} ${RED}[ERROR]${NC} $message"
|
||||||
|
;;
|
||||||
|
"DEBUG")
|
||||||
|
if [ "${DEBUG_ENABLED}" = "true" ]; then
|
||||||
|
echo -e "${timestamp} ${BOLD}[DEBUG]${NC} $message"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo -e "${timestamp} [LOG] $message"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# If log file is specified, also write to log file
|
||||||
|
if [ -n "${LOG_FILE}" ]; then
|
||||||
|
echo "${timestamp} [${level}] ${message}" >> "${LOG_FILE}"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Function to check if a command exists
|
||||||
|
function command_exists() {
|
||||||
|
command -v "$1" &> /dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
# Function to backup a file before modifying it
|
||||||
|
function backup_file() {
|
||||||
|
local file=$1
|
||||||
|
if [ -f "$file" ]; then
|
||||||
|
local backup="${file}.bak.$(date +%Y%m%d%H%M%S)"
|
||||||
|
cp "$file" "$backup"
|
||||||
|
log "INFO" "Created backup of $file at $backup"
|
||||||
|
echo "$backup"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Function to manage config file updates
|
||||||
|
function update_config_file() {
|
||||||
|
local config_file=$1
|
||||||
|
local is_update=$2
|
||||||
|
|
||||||
|
if [ "$is_update" = true ] && [ -f "$config_file" ]; then
|
||||||
|
# Backup the existing config file
|
||||||
|
local backup_file=$(backup_file "$config_file")
|
||||||
|
log "INFO" "Existing configuration backed up to $backup_file"
|
||||||
|
|
||||||
|
# We'll let the server.js handle merging the config
|
||||||
|
log "INFO" "Existing configuration will be preserved"
|
||||||
|
|
||||||
|
# Update the config version if needed
|
||||||
|
local current_version=$(grep -o '"version": "[^"]*"' "$config_file" | cut -d'"' -f4)
|
||||||
|
if [ -n "$current_version" ]; then
|
||||||
|
local new_version="2.0.0"
|
||||||
|
if [ "$current_version" != "$new_version" ]; then
|
||||||
|
log "INFO" "Updating config version from $current_version to $new_version"
|
||||||
|
sed -i "s/\"version\": \"$current_version\"/\"version\": \"$new_version\"/" "$config_file"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
return 0
|
||||||
|
else
|
||||||
|
# New installation, config file will be created by finalize_setup
|
||||||
|
log "INFO" "New configuration will be created"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Function to create a directory if it doesn't exist
|
||||||
|
function create_dir_if_not_exists() {
|
||||||
|
local dir=$1
|
||||||
|
local owner=$2
|
||||||
|
|
||||||
|
if [ ! -d "$dir" ]; then
|
||||||
|
mkdir -p "$dir"
|
||||||
|
log "INFO" "Created directory: $dir"
|
||||||
|
|
||||||
|
if [ -n "$owner" ]; then
|
||||||
|
chown -R "$owner" "$dir"
|
||||||
|
log "INFO" "Set ownership of $dir to $owner"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Function to finalize the setup (permissions, etc.)
|
||||||
|
# Function to ensure NPM packages are properly installed
|
||||||
|
function ensure_npm_packages() {
|
||||||
|
local install_dir=$1
|
||||||
|
|
||||||
|
# First ensure the installation directory exists
|
||||||
|
if [ ! -d "$install_dir" ]; then
|
||||||
|
log "INFO" "Creating installation directory: $install_dir"
|
||||||
|
mkdir -p "$install_dir"
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
log "ERROR" "Failed to create installation directory: $install_dir"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Ensure data directory exists
|
||||||
|
if [ ! -d "$install_dir/data" ]; then
|
||||||
|
log "INFO" "Creating data directory: $install_dir/data"
|
||||||
|
mkdir -p "$install_dir/data"
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
log "ERROR" "Failed to create data directory: $install_dir/data"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Initialize empty data files
|
||||||
|
echo "[]" > "$install_dir/data/rss-feeds.json"
|
||||||
|
echo "[]" > "$install_dir/data/rss-items.json"
|
||||||
|
log "INFO" "Initialized empty data files"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Ensure package.json exists in the installation directory
|
||||||
|
if [ ! -f "$install_dir/package.json" ]; then
|
||||||
|
log "INFO" "Copying package.json to installation directory..."
|
||||||
|
cp "$SCRIPT_DIR/package.json" "$install_dir/package.json"
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
log "ERROR" "Failed to copy package.json to installation directory"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Install NPM packages if not already installed or if it's an update
|
||||||
|
if [ ! -d "$install_dir/node_modules" ] || [ "$IS_UPDATE" = "true" ]; then
|
||||||
|
log "INFO" "Installing NPM packages in $install_dir..."
|
||||||
|
|
||||||
|
# Save current directory
|
||||||
|
local current_dir=$(pwd)
|
||||||
|
|
||||||
|
# Change to install directory and install packages
|
||||||
|
cd "$install_dir"
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
log "ERROR" "Failed to change to installation directory: $install_dir"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
npm install
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
log "ERROR" "NPM installation failed in $install_dir"
|
||||||
|
cd "$current_dir" # Return to original directory
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Return to original directory
|
||||||
|
cd "$current_dir"
|
||||||
|
log "INFO" "NPM packages successfully installed in $install_dir"
|
||||||
|
else
|
||||||
|
log "INFO" "NPM packages appear to be already installed in $install_dir, skipping"
|
||||||
|
fi
|
||||||
|
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function finalize_setup() {
|
||||||
|
log "INFO" "Setting up final permissions and configurations..."
|
||||||
|
|
||||||
|
# Ensure logs directory exists
|
||||||
|
mkdir -p "$INSTALL_DIR/logs"
|
||||||
|
log "INFO" "Created logs directory: $INSTALL_DIR/logs"
|
||||||
|
|
||||||
|
# Ensure CONFIG_DIR exists
|
||||||
|
if [ ! -d "$CONFIG_DIR" ]; then
|
||||||
|
mkdir -p "$CONFIG_DIR"
|
||||||
|
log "INFO" "Created configuration directory: $CONFIG_DIR"
|
||||||
|
chown -R "$USER:$USER" "$CONFIG_DIR"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if the config symlink exists, create it if not
|
||||||
|
if [ ! -L "$INSTALL_DIR/config.json" ] || [ ! -e "$INSTALL_DIR/config.json" ]; then
|
||||||
|
# If there's a real file at INSTALL_DIR/config.json (not a symlink), move it to CONFIG_DIR
|
||||||
|
if [ -f "$INSTALL_DIR/config.json" ] && [ ! -L "$INSTALL_DIR/config.json" ]; then
|
||||||
|
log "INFO" "Moving existing config.json to $CONFIG_DIR"
|
||||||
|
mv "$INSTALL_DIR/config.json" "$CONFIG_DIR/config.json"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create the symlink
|
||||||
|
ln -sf "$CONFIG_DIR/config.json" "$INSTALL_DIR/config.json"
|
||||||
|
log "INFO" "Created symlink from $INSTALL_DIR/config.json to $CONFIG_DIR/config.json"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Set proper ownership for the installation directory
|
||||||
|
chown -R $USER:$USER $INSTALL_DIR
|
||||||
|
|
||||||
|
# Create media directories with correct permissions
|
||||||
|
create_dir_if_not_exists "$MEDIA_DIR/movies" "$USER:$USER"
|
||||||
|
create_dir_if_not_exists "$MEDIA_DIR/tvshows" "$USER:$USER"
|
||||||
|
create_dir_if_not_exists "$MEDIA_DIR/music" "$USER:$USER"
|
||||||
|
create_dir_if_not_exists "$MEDIA_DIR/software" "$USER:$USER"
|
||||||
|
|
||||||
|
# Create book/magazine directories if enabled
|
||||||
|
if [ "$ENABLE_BOOK_SORTING" = true ]; then
|
||||||
|
create_dir_if_not_exists "$MEDIA_DIR/books" "$USER:$USER"
|
||||||
|
create_dir_if_not_exists "$MEDIA_DIR/magazines" "$USER:$USER"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Install npm packages
|
||||||
|
ensure_npm_packages "$INSTALL_DIR" || {
|
||||||
|
log "ERROR" "Failed to install NPM packages"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Handle configuration file
|
||||||
|
if ! update_config_file "$CONFIG_DIR/config.json" "$IS_UPDATE"; then
|
||||||
|
log "INFO" "Creating default configuration file..."
|
||||||
|
|
||||||
|
# Create the users array content for JSON
|
||||||
|
USER_JSON=""
|
||||||
|
if [ "${AUTH_ENABLED}" = "true" ] && [ -n "${ADMIN_USERNAME}" ]; then
|
||||||
|
USER_JSON="{ \"username\": \"${ADMIN_USERNAME}\", \"password\": \"${ADMIN_PASSWORD}\", \"role\": \"admin\" }"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Make sure CONFIG_DIR exists
|
||||||
|
mkdir -p "$CONFIG_DIR"
|
||||||
|
|
||||||
|
# Get version from package.json dynamically
|
||||||
|
VERSION=$(grep -oP '"version": "\K[^"]+' "${SCRIPT_DIR}/package.json" 2>/dev/null || echo "2.0.9")
|
||||||
|
|
||||||
|
cat > $CONFIG_DIR/config.json << EOF
|
||||||
|
{
|
||||||
|
"version": "$VERSION",
|
||||||
|
"transmissionConfig": {
|
||||||
|
"host": "${TRANSMISSION_HOST}",
|
||||||
|
"port": ${TRANSMISSION_PORT},
|
||||||
|
"username": "${TRANSMISSION_USER}",
|
||||||
|
"password": "${TRANSMISSION_PASS}",
|
||||||
|
"path": "${TRANSMISSION_RPC_PATH}"
|
||||||
|
},
|
||||||
|
"remoteConfig": {
|
||||||
|
"isRemote": ${TRANSMISSION_REMOTE},
|
||||||
|
"directoryMapping": ${TRANSMISSION_DIR_MAPPING}
|
||||||
|
},
|
||||||
|
"destinationPaths": {
|
||||||
|
"movies": "${MEDIA_DIR}/movies",
|
||||||
|
"tvShows": "${MEDIA_DIR}/tvshows",
|
||||||
|
"music": "${MEDIA_DIR}/music",
|
||||||
|
"books": "${MEDIA_DIR}/books",
|
||||||
|
"magazines": "${MEDIA_DIR}/magazines",
|
||||||
|
"software": "${MEDIA_DIR}/software"
|
||||||
|
},
|
||||||
|
"seedingRequirements": {
|
||||||
|
"minRatio": 1.0,
|
||||||
|
"minTimeMinutes": 60,
|
||||||
|
"checkIntervalSeconds": 300
|
||||||
|
},
|
||||||
|
"processingOptions": {
|
||||||
|
"enableBookSorting": ${ENABLE_BOOK_SORTING},
|
||||||
|
"extractArchives": true,
|
||||||
|
"deleteArchives": true,
|
||||||
|
"createCategoryFolders": true,
|
||||||
|
"ignoreSample": true,
|
||||||
|
"ignoreExtras": true,
|
||||||
|
"renameFiles": true,
|
||||||
|
"autoReplaceUpgrades": true,
|
||||||
|
"removeDuplicates": true,
|
||||||
|
"keepOnlyBestVersion": true
|
||||||
|
},
|
||||||
|
"securitySettings": {
|
||||||
|
"authEnabled": ${AUTH_ENABLED:-false},
|
||||||
|
"httpsEnabled": ${HTTPS_ENABLED:-false},
|
||||||
|
"sslCertPath": "${SSL_CERT_PATH:-""}",
|
||||||
|
"sslKeyPath": "${SSL_KEY_PATH:-""}",
|
||||||
|
"users": [
|
||||||
|
${USER_JSON}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"rssFeeds": [],
|
||||||
|
"rssUpdateIntervalMinutes": 60,
|
||||||
|
"autoProcessing": false,
|
||||||
|
"port": ${PORT},
|
||||||
|
"logLevel": "info"
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
# Set ownership for the config file
|
||||||
|
chown $USER:$USER $CONFIG_DIR/config.json
|
||||||
|
|
||||||
|
# Ensure symlink exists from INSTALL_DIR to CONFIG_DIR
|
||||||
|
ln -sf "$CONFIG_DIR/config.json" "$INSTALL_DIR/config.json"
|
||||||
|
log "INFO" "Created symlink from $INSTALL_DIR/config.json to $CONFIG_DIR/config.json"
|
||||||
|
log "INFO" "Default configuration created successfully"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Start the service
|
||||||
|
log "INFO" "Starting the service..."
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable $SERVICE_NAME
|
||||||
|
systemctl start $SERVICE_NAME
|
||||||
|
|
||||||
|
# Check if service started successfully
|
||||||
|
sleep 2
|
||||||
|
if systemctl is-active --quiet $SERVICE_NAME; then
|
||||||
|
log "INFO" "Service started successfully!"
|
||||||
|
else
|
||||||
|
log "ERROR" "Service failed to start. Check logs with: journalctl -u $SERVICE_NAME"
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "INFO" "Setup finalized!"
|
||||||
|
}
|
46
package.json
Normal file
46
package.json
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
{
|
||||||
|
"name": "transmission-rss-manager",
|
||||||
|
"version": "2.0.12",
|
||||||
|
"description": "A comprehensive web-based tool to automate and manage your Transmission torrent downloads with RSS feed integration and intelligent media organization",
|
||||||
|
"main": "server.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node server.js",
|
||||||
|
"dev": "nodemon server.js",
|
||||||
|
"lint": "eslint --fix --ext .js,.jsx .",
|
||||||
|
"test": "jest"
|
||||||
|
},
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "git+https://git.powerdata.dk/masterdraco/transmission-rss-manager.git"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"transmission",
|
||||||
|
"rss",
|
||||||
|
"torrent",
|
||||||
|
"automation",
|
||||||
|
"media",
|
||||||
|
"manager"
|
||||||
|
],
|
||||||
|
"author": "Your Name",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"bcrypt": "^5.1.0",
|
||||||
|
"body-parser": "^1.20.2",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"express": "^4.18.2",
|
||||||
|
"jsonwebtoken": "^9.0.0",
|
||||||
|
"morgan": "^1.10.0",
|
||||||
|
"node-fetch": "^2.6.11",
|
||||||
|
"semver": "^7.5.4",
|
||||||
|
"transmission-promise": "^1.1.5",
|
||||||
|
"xml2js": "^0.5.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"eslint": "^8.42.0",
|
||||||
|
"jest": "^29.5.0",
|
||||||
|
"nodemon": "^2.0.22"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.0.0"
|
||||||
|
}
|
||||||
|
}
|
731
public/css/styles.css
Normal file
731
public/css/styles.css
Normal file
@ -0,0 +1,731 @@
|
|||||||
|
/* Main Styles for Transmission RSS Manager */
|
||||||
|
:root {
|
||||||
|
--primary-color: #3498db;
|
||||||
|
--primary-dark: #2980b9;
|
||||||
|
--secondary-color: #2ecc71;
|
||||||
|
--secondary-dark: #27ae60;
|
||||||
|
--warning-color: #f39c12;
|
||||||
|
--danger-color: #e74c3c;
|
||||||
|
--background-color: #f8f9fa;
|
||||||
|
--dark-background: #1a1a1a;
|
||||||
|
--card-background: #ffffff;
|
||||||
|
--dark-card-background: #2a2a2a;
|
||||||
|
--text-color: #333333;
|
||||||
|
--dark-text-color: #f5f5f5;
|
||||||
|
--border-color: #dddddd;
|
||||||
|
--dark-border-color: #444444;
|
||||||
|
--success-background: #d4edda;
|
||||||
|
--success-text: #155724;
|
||||||
|
--error-background: #f8d7da;
|
||||||
|
--error-text: #721c24;
|
||||||
|
--input-background: #ffffff;
|
||||||
|
--dark-input-background: #333333;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode styles */
|
||||||
|
[data-theme="dark"] {
|
||||||
|
--background-color: var(--dark-background);
|
||||||
|
--card-background: var(--dark-card-background);
|
||||||
|
--text-color: var(--dark-text-color);
|
||||||
|
--border-color: var(--dark-border-color);
|
||||||
|
--input-background: var(--dark-input-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--text-color);
|
||||||
|
background-color: var(--background-color);
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
transition: background-color 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Typography */
|
||||||
|
h1, h2, h3, h4, h5, h6 {
|
||||||
|
font-weight: 600;
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--primary-color);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: color 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
color: var(--primary-dark);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
.btn {
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
font-size: 14px;
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
color: white;
|
||||||
|
transition: background-color 0.3s ease, transform 0.2s ease;
|
||||||
|
margin: 2px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn i, .btn svg {
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.btn-sm {
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.btn-lg {
|
||||||
|
padding: 12px 20px;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.btn-primary {
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.btn-success {
|
||||||
|
background-color: var(--secondary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.btn-warning {
|
||||||
|
background-color: var(--warning-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.btn-danger {
|
||||||
|
background-color: var(--danger-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.btn-outline {
|
||||||
|
background-color: transparent;
|
||||||
|
border: 1px solid var(--primary-color);
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.btn-outline:hover {
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Layout */
|
||||||
|
.container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin: -10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.col {
|
||||||
|
flex: 1;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.col-25 {
|
||||||
|
flex: 0 0 25%;
|
||||||
|
max-width: 25%;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.col-50 {
|
||||||
|
flex: 0 0 50%;
|
||||||
|
max-width: 50%;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.col-75 {
|
||||||
|
flex: 0 0 75%;
|
||||||
|
max-width: 75%;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header and Navigation */
|
||||||
|
header {
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
color: white;
|
||||||
|
padding: 1rem;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-brand {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: bold;
|
||||||
|
color: white;
|
||||||
|
text-decoration: none;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-brand i, .navbar-brand svg {
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-menu {
|
||||||
|
display: flex;
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-item {
|
||||||
|
margin: 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-link {
|
||||||
|
color: rgba(255, 255, 255, 0.85);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: color 0.3s ease;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-link i, .navbar-link svg {
|
||||||
|
margin-right: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-link:hover,
|
||||||
|
.navbar-link.active {
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-toggle {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
margin-left: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cards */
|
||||||
|
.card {
|
||||||
|
background-color: var(--card-background);
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
transition: box-shadow 0.3s ease, transform 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:hover {
|
||||||
|
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header h2, .card-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-body {
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-footer {
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Forms */
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-control {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
background-color: var(--input-background);
|
||||||
|
color: var(--text-color);
|
||||||
|
font-size: 1rem;
|
||||||
|
transition: border-color 0.3s ease, box-shadow 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-control:focus {
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
outline: none;
|
||||||
|
box-shadow: 0 0 0 3px rgba(52, 152, 219, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-check {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-check-input {
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tables */
|
||||||
|
.table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table th,
|
||||||
|
.table td {
|
||||||
|
padding: 0.75rem;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table th {
|
||||||
|
background-color: rgba(0, 0, 0, 0.05);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table tr:hover {
|
||||||
|
background-color: rgba(0, 0, 0, 0.025);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-responsive {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Progress Bar */
|
||||||
|
.progress {
|
||||||
|
height: 0.75rem;
|
||||||
|
background-color: var(--border-color);
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar {
|
||||||
|
height: 100%;
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar-success {
|
||||||
|
background-color: var(--secondary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar-warning {
|
||||||
|
background-color: var(--warning-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar-danger {
|
||||||
|
background-color: var(--danger-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Alerts */
|
||||||
|
.alert {
|
||||||
|
padding: 0.75rem 1.25rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-success {
|
||||||
|
background-color: var(--success-background);
|
||||||
|
border-color: #c3e6cb;
|
||||||
|
color: var(--success-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-danger {
|
||||||
|
background-color: var(--error-background);
|
||||||
|
border-color: #f5c6cb;
|
||||||
|
color: var(--error-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-warning {
|
||||||
|
background-color: #fff3cd;
|
||||||
|
border-color: #ffeeba;
|
||||||
|
color: #856404;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-info {
|
||||||
|
background-color: #d1ecf1;
|
||||||
|
border-color: #bee5eb;
|
||||||
|
color: #0c5460;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Badges */
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
text-align: center;
|
||||||
|
white-space: nowrap;
|
||||||
|
vertical-align: baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-primary {
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-success {
|
||||||
|
background-color: var(--secondary-color);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-warning {
|
||||||
|
background-color: var(--warning-color);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-danger {
|
||||||
|
background-color: var(--danger-color);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modals */
|
||||||
|
.modal-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background-color: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1050;
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
transition: opacity 0.3s ease, visibility 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-backdrop.show, #about-modal[style*="flex"] {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
background-color: var(--card-background);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3);
|
||||||
|
width: 100%;
|
||||||
|
max-width: 500px;
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-20px);
|
||||||
|
transition: opacity 0.3s ease, transform 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
#about-modal .modal {
|
||||||
|
max-width: 800px;
|
||||||
|
opacity: 1;
|
||||||
|
transform: none;
|
||||||
|
animation: modalFadeIn 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes modalFadeIn {
|
||||||
|
from { opacity: 0; transform: translateY(-30px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-backdrop.show .modal, #about-modal[style*="flex"] .modal {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
padding: 1rem;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close {
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#about-modal .modal-body {
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-footer {
|
||||||
|
padding: 1rem;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* About modal specific styles */
|
||||||
|
#about-modal .version-history {
|
||||||
|
border-left: 3px solid var(--primary-color);
|
||||||
|
padding-left: 20px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#about-modal .version {
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#about-modal .version h5 {
|
||||||
|
color: var(--primary-color);
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#about-modal .features-list {
|
||||||
|
list-style-type: none;
|
||||||
|
padding-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#about-modal .features-list li {
|
||||||
|
padding: 8px 0;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
#about-modal .features-list li:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#about-modal .lead {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tabs */
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab {
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
transition: border-color 0.3s ease, color 0.3s ease;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab:hover {
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab.active {
|
||||||
|
border-bottom-color: var(--primary-color);
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content.active {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dashboard Widgets */
|
||||||
|
.stats-container {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||||
|
gap: 1.5rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
background-color: var(--card-background);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
|
||||||
|
padding: 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-icon {
|
||||||
|
width: 3rem;
|
||||||
|
height: 3rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: rgba(52, 152, 219, 0.1);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-right: 1rem;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-info h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-info p {
|
||||||
|
margin: 0;
|
||||||
|
color: #777;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Testing Controls */
|
||||||
|
.testing-controls {
|
||||||
|
opacity: 0.5;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.testing-controls:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.testing-controls a {
|
||||||
|
text-decoration: underline;
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Utilities */
|
||||||
|
.text-center { text-align: center; }
|
||||||
|
.text-right { text-align: right; }
|
||||||
|
.text-left { text-align: left; }
|
||||||
|
.font-weight-bold { font-weight: bold; }
|
||||||
|
.text-muted { color: #6c757d; }
|
||||||
|
|
||||||
|
.mt-1 { margin-top: 0.5rem; }
|
||||||
|
.mt-2 { margin-top: 1rem; }
|
||||||
|
.mt-3 { margin-top: 1.5rem; }
|
||||||
|
|
||||||
|
.mb-1 { margin-bottom: 0.5rem; }
|
||||||
|
.mb-2 { margin-bottom: 1rem; }
|
||||||
|
.mb-3 { margin-bottom: 1.5rem; }
|
||||||
|
|
||||||
|
.ml-1 { margin-left: 0.5rem; }
|
||||||
|
.ml-2 { margin-left: 1rem; }
|
||||||
|
.ml-3 { margin-left: 1.5rem; }
|
||||||
|
|
||||||
|
.mr-1 { margin-right: 0.5rem; }
|
||||||
|
.mr-2 { margin-right: 1rem; }
|
||||||
|
.mr-3 { margin-right: 1.5rem; }
|
||||||
|
|
||||||
|
.pt-1 { padding-top: 0.5rem; }
|
||||||
|
.pt-2 { padding-top: 1rem; }
|
||||||
|
.pt-3 { padding-top: 1.5rem; }
|
||||||
|
|
||||||
|
.pb-1 { padding-bottom: 0.5rem; }
|
||||||
|
.pb-2 { padding-bottom: 1rem; }
|
||||||
|
.pb-3 { padding-bottom: 1.5rem; }
|
||||||
|
|
||||||
|
.pl-1 { padding-left: 0.5rem; }
|
||||||
|
.pl-2 { padding-left: 1rem; }
|
||||||
|
.pl-3 { padding-left: 1.5rem; }
|
||||||
|
|
||||||
|
.pr-1 { padding-right: 0.5rem; }
|
||||||
|
.pr-2 { padding-right: 1rem; }
|
||||||
|
.pr-3 { padding-right: 1.5rem; }
|
||||||
|
|
||||||
|
.d-none { display: none; }
|
||||||
|
.d-block { display: block; }
|
||||||
|
.d-flex { display: flex; }
|
||||||
|
.flex-wrap { flex-wrap: wrap; }
|
||||||
|
.align-items-center { align-items: center; }
|
||||||
|
.justify-content-center { justify-content: center; }
|
||||||
|
.justify-content-between { justify-content: space-between; }
|
||||||
|
.justify-content-end { justify-content: flex-end; }
|
||||||
|
|
||||||
|
/* Media Queries */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.navbar {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-menu {
|
||||||
|
flex-direction: column;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-item {
|
||||||
|
margin: 0.25rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-right {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-container {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.col-25, .col-50, .col-75 {
|
||||||
|
flex: 0 0 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 576px) {
|
||||||
|
.card-header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header > .btn {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
762
public/index.html
Normal file
762
public/index.html
Normal file
@ -0,0 +1,762 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" data-theme="light">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Transmission RSS Manager</title>
|
||||||
|
<meta name="description" content="Web interface for the Transmission RSS Manager, automating torrent downloads from RSS feeds">
|
||||||
|
<link rel="stylesheet" href="/css/styles.css">
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.2.1/css/all.min.css">
|
||||||
|
<!-- Favicon -->
|
||||||
|
<link rel="icon" href="/favicon.ico" type="image/x-icon">
|
||||||
|
<!-- Add Apple touch icon for iOS devices -->
|
||||||
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
||||||
|
<!-- Theme color for browser UI -->
|
||||||
|
<meta name="theme-color" content="#3498db">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- Loading Spinner -->
|
||||||
|
<div id="loading-spinner" class="loader-overlay">
|
||||||
|
<div class="loader"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Authentication Form (Hidden by default) -->
|
||||||
|
<div id="login-container" class="d-none">
|
||||||
|
<div class="auth-container">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h2><i class="fas fa-sign-in-alt"></i> Login</h2>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form id="login-form">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="username">Username</label>
|
||||||
|
<input type="text" id="username" class="form-control" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="password">Password</label>
|
||||||
|
<input type="password" id="password" class="form-control" required>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3 text-center">
|
||||||
|
<button type="submit" class="btn btn-primary btn-lg">
|
||||||
|
<i class="fas fa-unlock-alt"></i> Login
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main App Content -->
|
||||||
|
<div id="app-container">
|
||||||
|
<header>
|
||||||
|
<div class="navbar">
|
||||||
|
<a href="#" class="navbar-brand">
|
||||||
|
<i class="fas fa-rss"></i> Transmission RSS Manager
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<ul class="navbar-menu">
|
||||||
|
<li class="navbar-item">
|
||||||
|
<a href="#" class="navbar-link nav-link active" data-tab="home-tab">
|
||||||
|
<i class="fas fa-home"></i> Dashboard
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="navbar-item">
|
||||||
|
<a href="#" class="navbar-link nav-link" data-tab="rss-tab">
|
||||||
|
<i class="fas fa-rss"></i> RSS Feeds
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="navbar-item">
|
||||||
|
<a href="#" class="navbar-link nav-link" data-tab="torrents-tab">
|
||||||
|
<i class="fas fa-download"></i> Torrents
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="navbar-item">
|
||||||
|
<a href="#" class="navbar-link nav-link" data-tab="media-tab">
|
||||||
|
<i class="fas fa-photo-video"></i> Media Library
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="navbar-item">
|
||||||
|
<a href="#" class="navbar-link nav-link" data-tab="settings-tab">
|
||||||
|
<i class="fas fa-cog"></i> Settings
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="navbar-right">
|
||||||
|
<span id="user-info"></span>
|
||||||
|
<button id="theme-toggle" class="theme-toggle" aria-label="Toggle Theme">
|
||||||
|
<i id="theme-toggle-icon" class="fas fa-moon"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<!-- Tab Content -->
|
||||||
|
<div id="home-tab" class="tab-content active">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h2><i class="fas fa-tachometer-alt"></i> Dashboard</h2>
|
||||||
|
<button id="btn-refresh-status" class="btn btn-outline">
|
||||||
|
<i class="fas fa-sync-alt"></i> Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div id="status-container">
|
||||||
|
<!-- System Status Card -->
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-4 mb-3">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h4><i class="fas fa-info-circle"></i> System Status</h4>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="status-item">
|
||||||
|
<span class="status-label">Version:</span>
|
||||||
|
<span id="system-version" class="status-value">Loading...</span>
|
||||||
|
</div>
|
||||||
|
<div class="status-item">
|
||||||
|
<span class="status-label">Running since:</span>
|
||||||
|
<span id="uptime" class="status-value">Loading...</span>
|
||||||
|
</div>
|
||||||
|
<div class="status-item">
|
||||||
|
<span class="status-label">Transmission:</span>
|
||||||
|
<span id="transmission-status" class="status-value">
|
||||||
|
<i class="fas fa-circle-notch fa-spin"></i> Checking...
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="status-item">
|
||||||
|
<span class="status-label">Update:</span>
|
||||||
|
<span id="update-status" class="status-value">
|
||||||
|
<i class="fas fa-circle-notch fa-spin"></i> Checking...
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<!-- Testing Controls (hidden in production) -->
|
||||||
|
<div class="mt-2 testing-controls">
|
||||||
|
<small><a href="#" id="toggle-test-update-button">Toggle Test Update</a></small>
|
||||||
|
</div>
|
||||||
|
<div id="update-available" class="mt-3">
|
||||||
|
<div class="alert alert-info update-alert" style="display: none;">
|
||||||
|
<i class="fas fa-arrow-circle-up"></i>
|
||||||
|
<span>A new version is available!</span>
|
||||||
|
<button id="btn-update-now" class="btn btn-sm btn-primary ml-2">
|
||||||
|
<i class="fas fa-download"></i> Update Now
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Original Status Content -->
|
||||||
|
<p>Loading system status...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 class="mt-3">Quick Actions</h3>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-4 mb-3">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h4><i class="fas fa-rss"></i> RSS Manager</h4>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p>Manage your RSS feeds and automatic downloads</p>
|
||||||
|
</div>
|
||||||
|
<div class="card-footer">
|
||||||
|
<button id="btn-update-feeds" class="btn btn-success">
|
||||||
|
<i class="fas fa-sync"></i> Update Feeds Now
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-4 mb-3">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h4><i class="fas fa-cogs"></i> Post-Processing</h4>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p>Process completed downloads to your media library</p>
|
||||||
|
</div>
|
||||||
|
<div class="card-footer">
|
||||||
|
<button id="btn-start-processor" class="btn btn-success">
|
||||||
|
<i class="fas fa-play"></i> Start
|
||||||
|
</button>
|
||||||
|
<button id="btn-stop-processor" class="btn btn-warning">
|
||||||
|
<i class="fas fa-stop"></i> Stop
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-4 mb-3">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h4><i class="fas fa-magnet"></i> Add New Torrent</h4>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form id="add-torrent-form">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="torrent-url">Torrent URL or Magnet Link</label>
|
||||||
|
<input type="text" id="torrent-url" class="form-control" placeholder="Enter torrent URL or magnet link">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<button type="submit" class="btn btn-success">
|
||||||
|
<i class="fas fa-plus"></i> Add Torrent
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- RSS Tab -->
|
||||||
|
<div id="rss-tab" class="tab-content">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h2><i class="fas fa-rss"></i> RSS Feeds</h2>
|
||||||
|
<button id="btn-add-feed" class="btn btn-success">
|
||||||
|
<i class="fas fa-plus"></i> Add New Feed
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div id="feeds-container">
|
||||||
|
<p>Loading feeds...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card mt-3">
|
||||||
|
<div class="card-header">
|
||||||
|
<h2><i class="fas fa-list"></i> Available Items</h2>
|
||||||
|
<div>
|
||||||
|
<label class="form-check form-check-inline">
|
||||||
|
<input type="radio" name="item-filter" value="all" checked class="form-check-input">
|
||||||
|
<span class="form-check-label">All Items</span>
|
||||||
|
</label>
|
||||||
|
<label class="form-check form-check-inline">
|
||||||
|
<input type="radio" name="item-filter" value="undownloaded" class="form-check-input">
|
||||||
|
<span class="form-check-label">Undownloaded Only</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div id="items-container">
|
||||||
|
<p>Loading items...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Torrents Tab -->
|
||||||
|
<div id="torrents-tab" class="tab-content">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h2><i class="fas fa-download"></i> Active Torrents</h2>
|
||||||
|
<button id="refresh-torrents" class="btn btn-primary">
|
||||||
|
<i class="fas fa-sync"></i> Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div id="torrents-container">
|
||||||
|
<p>Loading torrents...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Media Library Tab -->
|
||||||
|
<div id="media-tab" class="tab-content">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h2><i class="fas fa-photo-video"></i> Media Library</h2>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-8">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="library-search" class="form-label">Search Library</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<span class="input-group-text"><i class="fas fa-search"></i></span>
|
||||||
|
<input type="text" id="library-search" class="form-control" placeholder="Search your library...">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label">Filter by Category</label>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-check">
|
||||||
|
<input type="radio" name="library-filter" value="all" checked class="form-check-input">
|
||||||
|
<span class="form-check-label">All</span>
|
||||||
|
</label>
|
||||||
|
<label class="form-check">
|
||||||
|
<input type="radio" name="library-filter" value="movies" class="form-check-input">
|
||||||
|
<span class="form-check-label">Movies</span>
|
||||||
|
</label>
|
||||||
|
<label class="form-check">
|
||||||
|
<input type="radio" name="library-filter" value="tvShows" class="form-check-input">
|
||||||
|
<span class="form-check-label">TV Shows</span>
|
||||||
|
</label>
|
||||||
|
<label class="form-check">
|
||||||
|
<input type="radio" name="library-filter" value="music" class="form-check-input">
|
||||||
|
<span class="form-check-label">Music</span>
|
||||||
|
</label>
|
||||||
|
<label class="form-check">
|
||||||
|
<input type="radio" name="library-filter" value="books" class="form-check-input">
|
||||||
|
<span class="form-check-label">Books</span>
|
||||||
|
</label>
|
||||||
|
<label class="form-check">
|
||||||
|
<input type="radio" name="library-filter" value="magazines" class="form-check-input">
|
||||||
|
<span class="form-check-label">Magazines</span>
|
||||||
|
</label>
|
||||||
|
<label class="form-check">
|
||||||
|
<input type="radio" name="library-filter" value="software" class="form-check-input">
|
||||||
|
<span class="form-check-label">Software</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="library-container">
|
||||||
|
<p>Loading media library...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Settings Tab -->
|
||||||
|
<div id="settings-tab" class="tab-content">
|
||||||
|
<form id="settings-form">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h2><i class="fas fa-exchange-alt"></i> Transmission Settings</h2>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="transmission-host">Host:</label>
|
||||||
|
<input type="text" id="transmission-host" class="form-control" placeholder="localhost">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="transmission-port">Port:</label>
|
||||||
|
<input type="number" id="transmission-port" class="form-control" placeholder="9091">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="transmission-user">Username:</label>
|
||||||
|
<input type="text" id="transmission-user" class="form-control">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="transmission-pass">Password:</label>
|
||||||
|
<input type="password" id="transmission-pass" class="form-control">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-footer">
|
||||||
|
<button type="button" id="test-connection" class="btn btn-outline" onclick="testTransmissionConnection()">
|
||||||
|
<i class="fas fa-plug"></i> Test Connection
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h2><i class="fas fa-shield-alt"></i> Security Settings</h2>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="form-check mb-3">
|
||||||
|
<input type="checkbox" id="https-enabled" class="form-check-input">
|
||||||
|
<label class="form-check-label" for="https-enabled">
|
||||||
|
Enable HTTPS
|
||||||
|
</label>
|
||||||
|
<small class="text-muted d-block mt-1">
|
||||||
|
Requires valid SSL certificate (not self-signed)
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-check mb-3">
|
||||||
|
<input type="checkbox" id="auth-enabled" class="form-check-input">
|
||||||
|
<label class="form-check-label" for="auth-enabled">
|
||||||
|
Enable Authentication
|
||||||
|
</label>
|
||||||
|
<small class="text-muted d-block mt-1">
|
||||||
|
Requires login to access the application
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="auth-settings" class="mt-3 d-none">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="admin-username">Admin Username:</label>
|
||||||
|
<input type="text" id="admin-username" class="form-control" placeholder="admin">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="admin-password">Admin Password:</label>
|
||||||
|
<input type="password" id="admin-password" class="form-control">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-header">
|
||||||
|
<h2><i class="fas fa-cogs"></i> Post-Processing Settings</h2>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<h3>Seeding Requirements</h3>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="seeding-ratio">Minimum Seeding Ratio:</label>
|
||||||
|
<input type="number" id="seeding-ratio" class="form-control" min="0" step="0.1" placeholder="1.0">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="seeding-time">Minimum Seeding Time (minutes):</label>
|
||||||
|
<input type="number" id="seeding-time" class="form-control" min="0" placeholder="60">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="check-interval">Check Interval (seconds):</label>
|
||||||
|
<input type="number" id="check-interval" class="form-control" min="30" placeholder="300">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-4">
|
||||||
|
<h3>Media Paths</h3>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="movies-path">Movies Path:</label>
|
||||||
|
<input type="text" id="movies-path" class="form-control" placeholder="/mnt/media/movies">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="tvshows-path">TV Shows Path:</label>
|
||||||
|
<input type="text" id="tvshows-path" class="form-control" placeholder="/mnt/media/tvshows">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="music-path">Music Path:</label>
|
||||||
|
<input type="text" id="music-path" class="form-control" placeholder="/mnt/media/music">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="books-path">Books Path:</label>
|
||||||
|
<input type="text" id="books-path" class="form-control" placeholder="/mnt/media/books">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="magazines-path">Magazines Path:</label>
|
||||||
|
<input type="text" id="magazines-path" class="form-control" placeholder="/mnt/media/magazines">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="software-path">Software Path:</label>
|
||||||
|
<input type="text" id="software-path" class="form-control" placeholder="/mnt/media/software">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-4">
|
||||||
|
<h3>Processing Options</h3>
|
||||||
|
<div class="form-check mb-2">
|
||||||
|
<input type="checkbox" id="extract-archives" class="form-check-input">
|
||||||
|
<label class="form-check-label" for="extract-archives">
|
||||||
|
Extract Archives
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check mb-2">
|
||||||
|
<input type="checkbox" id="delete-archives" class="form-check-input">
|
||||||
|
<label class="form-check-label" for="delete-archives">
|
||||||
|
Delete Archives After Extraction
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check mb-2">
|
||||||
|
<input type="checkbox" id="create-category-folders" class="form-check-input">
|
||||||
|
<label class="form-check-label" for="create-category-folders">
|
||||||
|
Create Category Folders
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check mb-2">
|
||||||
|
<input type="checkbox" id="rename-files" class="form-check-input">
|
||||||
|
<label class="form-check-label" for="rename-files">
|
||||||
|
Rename Files
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check mb-2">
|
||||||
|
<input type="checkbox" id="ignore-sample" class="form-check-input">
|
||||||
|
<label class="form-check-label" for="ignore-sample">
|
||||||
|
Ignore Sample Files
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check mb-2">
|
||||||
|
<input type="checkbox" id="ignore-extras" class="form-check-input">
|
||||||
|
<label class="form-check-label" for="ignore-extras">
|
||||||
|
Ignore Extras Files
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-header">
|
||||||
|
<h2><i class="fas fa-rss"></i> RSS Settings</h2>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="rss-interval">Update Interval (minutes):</label>
|
||||||
|
<input type="number" id="rss-interval" class="form-control" min="5" placeholder="60">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="notification-type">Notifications:</label>
|
||||||
|
<select id="notification-type" class="form-control">
|
||||||
|
<option value="none">None</option>
|
||||||
|
<option value="email">Email</option>
|
||||||
|
<option value="telegram">Telegram</option>
|
||||||
|
<option value="discord">Discord</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="notification-settings" class="d-none mt-3">
|
||||||
|
<!-- This will be populated dynamically based on notification type -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-footer text-right">
|
||||||
|
<button type="submit" class="btn btn-success btn-lg">
|
||||||
|
<i class="fas fa-save"></i> Save All Settings
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<footer class="footer mt-3">
|
||||||
|
<div class="container">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<p>Transmission RSS Manager <span id="footer-version">v2.0.10</span></p>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 text-right">
|
||||||
|
<p><a href="https://git.powerdata.dk/masterdraco/transmission-rss-manager" target="_blank" rel="noopener noreferrer">GitHub</a> | <a href="#" id="show-about-modal">About</a></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- About Modal -->
|
||||||
|
<div class="modal-backdrop" id="about-modal" style="display: none;">
|
||||||
|
<div class="modal">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2><i class="fas fa-info-circle"></i> About Transmission RSS Manager</h2>
|
||||||
|
<button class="modal-close" id="close-about-modal">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="about-logo text-center mb-4">
|
||||||
|
<img src="https://powerdata.dk/wp-content/uploads/2023/08/PowerData-logo-1.png" alt="PowerData Logo" style="max-width: 200px;">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>Developed by PowerData.dk</h3>
|
||||||
|
<p class="lead mb-4">Created by Michael Bay-Laursen, a passionate developer with a dedication to creating elegant, powerful automation solutions.</p>
|
||||||
|
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h4>About the Developer</h4>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p>Michael Bay-Laursen is the talented founder of PowerData.dk, bringing years of experience in system automation, network management, and web application development to create tools that simplify complex tasks.</p>
|
||||||
|
<p>With a focus on user experience and robust functionality, Michael has designed this application to make media management accessible to everyone while providing the power and flexibility that advanced users demand.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h4>Key Features</h4>
|
||||||
|
<ul class="features-list">
|
||||||
|
<li><strong>Intelligent RSS Integration</strong> - Automatically download content based on customizable filters</li>
|
||||||
|
<li><strong>Smart Media Organization</strong> - Categorize and organize your downloads automatically</li>
|
||||||
|
<li><strong>One-Click Updates</strong> - Stay current with the latest features and improvements</li>
|
||||||
|
<li><strong>Responsive Interface</strong> - Manage your downloads from any device</li>
|
||||||
|
<li><strong>Advanced Content Detection</strong> - Automatic categorization of various media types</li>
|
||||||
|
<li><strong>Transmission Integration</strong> - Seamless control of your Transmission client</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h4>Version History</h4>
|
||||||
|
<div class="version-history">
|
||||||
|
<div class="version">
|
||||||
|
<h5>v2.0.11 - March 2025</h5>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Fixed</strong>: Update button persistence with floating notification</li>
|
||||||
|
<li><strong>Fixed</strong>: Version display issues with direct package.json reading</li>
|
||||||
|
<li><strong>Fixed</strong>: Update process for better version reporting</li>
|
||||||
|
<li><strong>Fixed</strong>: Conflict between test mode and actual update status</li>
|
||||||
|
<li><strong>Added</strong>: Refresh button on update notification</li>
|
||||||
|
<li><strong>Added</strong>: Clear test mode indicators to prevent confusion</li>
|
||||||
|
<li><strong>Improved</strong>: Enhanced cache busting for version checking</li>
|
||||||
|
<li><strong>Improved</strong>: Better user feedback during update process</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="version">
|
||||||
|
<h5>v2.0.10 - March 2025</h5>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Fixed</strong>: fs.existsSync error in update check</li>
|
||||||
|
<li><strong>Fixed</strong>: Update button now stays visible when update is available</li>
|
||||||
|
<li><strong>Fixed</strong>: Footer version now shows correct running version</li>
|
||||||
|
<li><strong>Improved</strong>: Better error handling for git repository checks</li>
|
||||||
|
<li><strong>Improved</strong>: More robust file system operations for update detection</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="version">
|
||||||
|
<h5>v2.0.9 - March 2025</h5>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Fixed</strong>: Update button now appears properly on dashboard</li>
|
||||||
|
<li><strong>Fixed</strong>: Remote Transmission connection issues resolved</li>
|
||||||
|
<li><strong>Fixed</strong>: Improved connection test with better error handling</li>
|
||||||
|
<li><strong>Added</strong>: System status and update endpoints for version checking</li>
|
||||||
|
<li><strong>Improved</strong>: Update detection and notification on dashboard</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="version">
|
||||||
|
<h5>v2.0.6 - March 2025</h5>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Fixed</strong>: Remote Transmission configuration storage and application</li>
|
||||||
|
<li><strong>Fixed</strong>: Config directory issues in /etc/transmission-rss-manager</li>
|
||||||
|
<li><strong>Fixed</strong>: Input handling for automated/piped installations</li>
|
||||||
|
<li><strong>Improved</strong>: Non-interactive mode for scripted installations</li>
|
||||||
|
<li><strong>Added</strong>: Enhanced debug logging for installation process</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="version">
|
||||||
|
<h5>v2.0.0 - March 2025</h5>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Major</strong>: Completely redesigned installation system using git</li>
|
||||||
|
<li><strong>New</strong>: One-click update feature with version checking</li>
|
||||||
|
<li><strong>New</strong>: Automatic Transmission detection and installation</li>
|
||||||
|
<li><strong>New</strong>: System status dashboard with update notifications</li>
|
||||||
|
<li><strong>New</strong>: About page with developer information</li>
|
||||||
|
<li><strong>Improved</strong>: Better error handling for Transmission connection</li>
|
||||||
|
<li><strong>Fixed</strong>: Various UI bugs and responsiveness issues</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="version">
|
||||||
|
<h5>v1.2.0 - February 2025</h5>
|
||||||
|
<ul>
|
||||||
|
<li><strong>New</strong>: Enhanced media organization with better content detection</li>
|
||||||
|
<li><strong>New</strong>: Improved post-processor with configurable filtering</li>
|
||||||
|
<li><strong>Improved</strong>: Transmission client integration with better error handling</li>
|
||||||
|
<li><strong>Improved</strong>: UI enhancements for better usability</li>
|
||||||
|
<li><strong>Fixed</strong>: RSS feed parsing issues</li>
|
||||||
|
<li><strong>Fixed</strong>: Authentication problems in certain configurations</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="version">
|
||||||
|
<h5>v1.0.0 - January 2025</h5>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Initial Release</strong>: Basic functionality for RSS feed monitoring</li>
|
||||||
|
<li><strong>New</strong>: Integration with Transmission BitTorrent client</li>
|
||||||
|
<li><strong>New</strong>: Web interface for managing torrents and feeds</li>
|
||||||
|
<li><strong>New</strong>: Post-processing capability for downloaded content</li>
|
||||||
|
<li><strong>New</strong>: Basic content categorization by media type</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-center mt-4">
|
||||||
|
<p><strong id="about-version">Transmission RSS Manager v2.0.11</strong></p>
|
||||||
|
<p>© 2025 PowerData.dk - All Rights Reserved</p>
|
||||||
|
<p><a href="https://powerdata.dk" target="_blank">Visit PowerData.dk</a></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn-primary" id="close-about-btn">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Update Alert Custom Styles -->
|
||||||
|
<style>
|
||||||
|
/* Custom styles for update alert to ensure it's visible */
|
||||||
|
.update-alert {
|
||||||
|
display: none;
|
||||||
|
margin-top: 10px !important;
|
||||||
|
border: 2px solid #007bff !important;
|
||||||
|
background-color: #cce5ff !important;
|
||||||
|
color: #004085 !important;
|
||||||
|
font-weight: bold !important;
|
||||||
|
box-shadow: 0 2px 5px rgba(0,0,0,0.2) !important;
|
||||||
|
position: relative !important;
|
||||||
|
z-index: 100 !important;
|
||||||
|
}
|
||||||
|
.update-alert span {
|
||||||
|
color: #004085 !important;
|
||||||
|
font-weight: bold !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Floating update notification that's impossible to miss */
|
||||||
|
#floating-update-notification {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
top: 20px;
|
||||||
|
right: 20px;
|
||||||
|
width: 300px;
|
||||||
|
padding: 15px;
|
||||||
|
background-color: #ff5555;
|
||||||
|
color: white;
|
||||||
|
border: 3px solid #cc0000;
|
||||||
|
border-radius: 5px;
|
||||||
|
box-shadow: 0 0 20px rgba(0,0,0,0.5);
|
||||||
|
z-index: 10000;
|
||||||
|
font-weight: bold;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
#floating-update-notification button {
|
||||||
|
margin-top: 10px;
|
||||||
|
padding: 5px 10px;
|
||||||
|
background-color: white;
|
||||||
|
color: #cc0000;
|
||||||
|
border: none;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-weight: bold;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
#floating-update-notification button:hover {
|
||||||
|
background-color: #eeeeee;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<!-- Removed floating notification completely -->
|
||||||
|
<!-- The floating notification was removed to fix persistent display issues -->
|
||||||
|
|
||||||
|
<!-- JavaScript Files -->
|
||||||
|
<script src="/js/system-status.js"></script>
|
||||||
|
<script src="/js/app.js"></script>
|
||||||
|
|
||||||
|
<!-- About Modal Script -->
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
// Show about modal
|
||||||
|
document.getElementById('show-about-modal').addEventListener('click', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
document.getElementById('about-modal').style.display = 'flex';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close modal handlers
|
||||||
|
document.getElementById('close-about-modal').addEventListener('click', closeAboutModal);
|
||||||
|
document.getElementById('close-about-btn').addEventListener('click', closeAboutModal);
|
||||||
|
|
||||||
|
function closeAboutModal() {
|
||||||
|
document.getElementById('about-modal').style.display = 'none';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
1655
public/js/app.js
Normal file
1655
public/js/app.js
Normal file
File diff suppressed because it is too large
Load Diff
784
public/js/system-status.js
Normal file
784
public/js/system-status.js
Normal file
@ -0,0 +1,784 @@
|
|||||||
|
/**
|
||||||
|
* Transmission RSS Manager - System Status Module
|
||||||
|
* @description Functionality for system status display and updates
|
||||||
|
*/
|
||||||
|
|
||||||
|
// System status and updates functionality
|
||||||
|
function initSystemStatus() {
|
||||||
|
// Elements
|
||||||
|
const versionElement = document.getElementById('system-version');
|
||||||
|
const uptimeElement = document.getElementById('uptime');
|
||||||
|
const transmissionStatusElement = document.getElementById('transmission-status');
|
||||||
|
const updateStatusElement = document.getElementById('update-status');
|
||||||
|
const updateAvailableDiv = document.getElementById('update-available');
|
||||||
|
const updateButton = document.getElementById('btn-update-now');
|
||||||
|
const refreshButton = document.getElementById('btn-refresh-status');
|
||||||
|
|
||||||
|
// Load system status
|
||||||
|
function loadSystemStatus() {
|
||||||
|
// Add cache-busting parameter
|
||||||
|
const cacheBuster = `?_=${new Date().getTime()}`;
|
||||||
|
fetch('/api/system/status' + cacheBuster, {
|
||||||
|
headers: authHeaders()
|
||||||
|
})
|
||||||
|
.then(handleResponse)
|
||||||
|
.then(data => {
|
||||||
|
if (data.status === 'success') {
|
||||||
|
// Update version display
|
||||||
|
versionElement.textContent = data.data.version;
|
||||||
|
uptimeElement.textContent = data.data.uptime;
|
||||||
|
|
||||||
|
// Also update footer version
|
||||||
|
const footerVersion = document.getElementById('footer-version');
|
||||||
|
if (footerVersion) {
|
||||||
|
footerVersion.textContent = 'v' + data.data.version;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update version in about modal too if it exists
|
||||||
|
const aboutVersionElement = document.getElementById('about-version');
|
||||||
|
if (aboutVersionElement) {
|
||||||
|
aboutVersionElement.textContent = 'Transmission RSS Manager v' + data.data.version;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update transmission status with icon
|
||||||
|
if (data.data.transmissionStatus === 'Connected') {
|
||||||
|
transmissionStatusElement.innerHTML = '<i class="fas fa-check-circle text-success"></i> Connected';
|
||||||
|
} else {
|
||||||
|
transmissionStatusElement.innerHTML = '<i class="fas fa-times-circle text-danger"></i> Disconnected';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
showNotification('Failed to load system status', 'danger');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error fetching system status:', error);
|
||||||
|
showNotification('Failed to connect to server', 'danger');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// More robust update check status tracking
|
||||||
|
const UPDATE_KEY = 'trm_update_available';
|
||||||
|
const CURRENT_VERSION_KEY = 'trm_current_version';
|
||||||
|
const REMOTE_VERSION_KEY = 'trm_remote_version';
|
||||||
|
|
||||||
|
// Force clear any existing update notification state
|
||||||
|
localStorage.removeItem(UPDATE_KEY);
|
||||||
|
localStorage.removeItem(CURRENT_VERSION_KEY);
|
||||||
|
localStorage.removeItem(REMOTE_VERSION_KEY);
|
||||||
|
|
||||||
|
let updateCheckInProgress = false;
|
||||||
|
|
||||||
|
// Function to show update alert
|
||||||
|
function showUpdateAlert(currentVersion, remoteVersion) {
|
||||||
|
// Set status text in the system status panel
|
||||||
|
updateStatusElement.innerHTML = '<i class="fas fa-exclamation-circle text-warning"></i> Update available';
|
||||||
|
|
||||||
|
// Show only the original alert box in the dashboard
|
||||||
|
try {
|
||||||
|
const alertBox = updateAvailableDiv.querySelector('.alert');
|
||||||
|
if (alertBox) {
|
||||||
|
alertBox.style.display = 'block';
|
||||||
|
const spanElement = alertBox.querySelector('span');
|
||||||
|
if (spanElement) {
|
||||||
|
spanElement.textContent = `A new version is available: ${currentVersion} → ${remoteVersion}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error showing original alert box:', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// We've removed the floating notification entirely, so this part is skipped
|
||||||
|
console.log('Update alert shown in dashboard:', currentVersion, '->', remoteVersion);
|
||||||
|
|
||||||
|
// Store in localStorage
|
||||||
|
localStorage.setItem(UPDATE_KEY, 'true');
|
||||||
|
localStorage.setItem(CURRENT_VERSION_KEY, currentVersion);
|
||||||
|
localStorage.setItem(REMOTE_VERSION_KEY, remoteVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to hide update alert
|
||||||
|
function hideUpdateAlert() {
|
||||||
|
// Hide original alert
|
||||||
|
try {
|
||||||
|
const alertBox = updateAvailableDiv.querySelector('.alert');
|
||||||
|
if (alertBox) {
|
||||||
|
alertBox.style.display = 'none';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error hiding original alert:', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear localStorage
|
||||||
|
localStorage.removeItem(UPDATE_KEY);
|
||||||
|
localStorage.removeItem(CURRENT_VERSION_KEY);
|
||||||
|
localStorage.removeItem(REMOTE_VERSION_KEY);
|
||||||
|
|
||||||
|
console.log('Update alert hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check localStorage on init and set up MutationObserver to prevent hiding
|
||||||
|
(function checkStoredUpdateStatus() {
|
||||||
|
const isUpdateAvailable = localStorage.getItem(UPDATE_KEY) === 'true';
|
||||||
|
if (isUpdateAvailable) {
|
||||||
|
const currentVersion = localStorage.getItem(CURRENT_VERSION_KEY);
|
||||||
|
const remoteVersion = localStorage.getItem(REMOTE_VERSION_KEY);
|
||||||
|
if (currentVersion && remoteVersion) {
|
||||||
|
showUpdateAlert(currentVersion, remoteVersion);
|
||||||
|
|
||||||
|
// Set up mutation observer to detect and revert any attempts to hide the update alert
|
||||||
|
const alertBox = updateAvailableDiv.querySelector('.alert');
|
||||||
|
if (alertBox) {
|
||||||
|
const observer = new MutationObserver(function(mutations) {
|
||||||
|
mutations.forEach(function(mutation) {
|
||||||
|
if (mutation.type === 'attributes' &&
|
||||||
|
(mutation.attributeName === 'style' ||
|
||||||
|
mutation.attributeName === 'class')) {
|
||||||
|
|
||||||
|
// If display is being changed to hide the element, force it back to visible
|
||||||
|
if (alertBox.style.display !== 'block' ||
|
||||||
|
alertBox.classList.contains('d-none') ||
|
||||||
|
alertBox.style.visibility === 'hidden' ||
|
||||||
|
alertBox.style.opacity === '0') {
|
||||||
|
|
||||||
|
console.log('Detected attempt to hide update button, forcing display');
|
||||||
|
showUpdateAlert(currentVersion, remoteVersion);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Observe style and class attribute changes
|
||||||
|
observer.observe(alertBox, {
|
||||||
|
attributes: true,
|
||||||
|
attributeFilter: ['style', 'class']
|
||||||
|
});
|
||||||
|
|
||||||
|
// Store observer in window object to prevent garbage collection
|
||||||
|
window._updateButtonObserver = observer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Check for updates
|
||||||
|
function checkForUpdates() {
|
||||||
|
updateStatusElement.innerHTML = '<i class="fas fa-circle-notch fa-spin"></i> Checking...';
|
||||||
|
updateCheckInProgress = true;
|
||||||
|
|
||||||
|
// Add test=true parameter to force update availability for testing
|
||||||
|
const testMode = localStorage.getItem('showUpdateButton') === 'true';
|
||||||
|
const cacheBuster = `_=${new Date().getTime()}`;
|
||||||
|
const url = testMode
|
||||||
|
? `/api/system/check-updates?test=true&${cacheBuster}`
|
||||||
|
: `/api/system/check-updates?${cacheBuster}`;
|
||||||
|
|
||||||
|
// Set a timeout to detect network issues
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
updateStatusElement.innerHTML = '<i class="fas fa-times-circle text-danger"></i> Check timed out';
|
||||||
|
updateCheckInProgress = false;
|
||||||
|
showNotification('Update check timed out. Please try again later.', 'warning');
|
||||||
|
}, 10000); // 10 second timeout
|
||||||
|
|
||||||
|
// Create a timeout controller
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId2 = setTimeout(() => controller.abort(), 15000);
|
||||||
|
|
||||||
|
fetch(url, {
|
||||||
|
headers: authHeaders(),
|
||||||
|
// Add a fetch timeout using abort controller
|
||||||
|
signal: controller.signal // 15 second timeout
|
||||||
|
})
|
||||||
|
.then(response => {
|
||||||
|
clearTimeout(timeoutId2);
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
return response;
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
clearTimeout(timeoutId2);
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
throw error;
|
||||||
|
})
|
||||||
|
.then(response => {
|
||||||
|
// Better error checking
|
||||||
|
if (!response.ok) {
|
||||||
|
return response.json().then(data => {
|
||||||
|
throw new Error(data.message || `Server error: ${response.status}`);
|
||||||
|
}).catch(e => {
|
||||||
|
if (e instanceof SyntaxError) {
|
||||||
|
throw new Error(`Server error: ${response.status}`);
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
})
|
||||||
|
.then(data => {
|
||||||
|
updateCheckInProgress = false;
|
||||||
|
|
||||||
|
if (data.status === 'success') {
|
||||||
|
if (data.data && data.data.updateAvailable) {
|
||||||
|
// Show update alert with version info
|
||||||
|
showUpdateAlert(data.data.currentVersion, data.data.remoteVersion);
|
||||||
|
|
||||||
|
// Log to console for debugging
|
||||||
|
console.log('Update available detected:', data.data.currentVersion, '->', data.data.remoteVersion);
|
||||||
|
} else {
|
||||||
|
// No update available
|
||||||
|
updateStatusElement.innerHTML = '<i class="fas fa-check-circle text-success"></i> Up to date';
|
||||||
|
hideUpdateAlert();
|
||||||
|
|
||||||
|
// Force reload system status to ensure version is current
|
||||||
|
setTimeout(() => loadSystemStatus(), 1000);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Error status but with a response
|
||||||
|
updateStatusElement.innerHTML = '<i class="fas fa-times-circle text-danger"></i> Check failed';
|
||||||
|
showNotification(data.message || 'Failed to check for updates', 'danger');
|
||||||
|
// Don't clear update status on error - keep any previous update notification
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
updateCheckInProgress = false;
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
console.error('Error checking for updates:', error);
|
||||||
|
updateStatusElement.innerHTML = '<i class="fas fa-times-circle text-danger"></i> Check failed';
|
||||||
|
|
||||||
|
// More specific error message based on the error type
|
||||||
|
if (error.name === 'AbortError') {
|
||||||
|
showNotification('Update check timed out. Please try again later.', 'warning');
|
||||||
|
} else if (error.message.includes('Failed to fetch') || error.message.includes('NetworkError')) {
|
||||||
|
showNotification('Network error. Please check your connection and try again.', 'danger');
|
||||||
|
} else {
|
||||||
|
showNotification(error.message || 'Failed to connect to server', 'danger');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't clear update status on error - keep any previous update notification
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply update
|
||||||
|
function applyUpdate() {
|
||||||
|
// Show confirmation dialog
|
||||||
|
if (!confirm('Are you sure you want to update the application? The service will restart.')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disable test mode whenever we try to apply an update
|
||||||
|
localStorage.setItem('showUpdateButton', 'false');
|
||||||
|
|
||||||
|
// Update toggle button text if it exists
|
||||||
|
const testToggle = document.getElementById('toggle-test-update-button');
|
||||||
|
if (testToggle) {
|
||||||
|
testToggle.innerText = 'Enable Test Update';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show loading state on both update buttons
|
||||||
|
// Original button
|
||||||
|
if (updateButton) {
|
||||||
|
updateButton.disabled = true;
|
||||||
|
updateButton.innerHTML = '<i class="fas fa-circle-notch fa-spin"></i> Updating...';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Floating notification button
|
||||||
|
const floatingButton = document.getElementById('floating-update-button');
|
||||||
|
if (floatingButton) {
|
||||||
|
floatingButton.disabled = true;
|
||||||
|
floatingButton.textContent = 'Updating...';
|
||||||
|
}
|
||||||
|
|
||||||
|
showNotification('Applying update. Please wait...', 'info');
|
||||||
|
|
||||||
|
// Set a timeout for the update process
|
||||||
|
const updateTimeoutId = setTimeout(() => {
|
||||||
|
// Re-enable original button
|
||||||
|
if (updateButton) {
|
||||||
|
updateButton.disabled = false;
|
||||||
|
updateButton.innerHTML = '<i class="fas fa-download"></i> Update Now';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-enable floating button
|
||||||
|
if (floatingButton) {
|
||||||
|
floatingButton.disabled = false;
|
||||||
|
floatingButton.textContent = 'Update Now';
|
||||||
|
}
|
||||||
|
|
||||||
|
showNotification('Update process timed out. Please try again or check server logs.', 'warning');
|
||||||
|
}, 60000); // 60 second timeout for the entire update process
|
||||||
|
|
||||||
|
// Create a timeout controller
|
||||||
|
const updateController = new AbortController();
|
||||||
|
const updateTimeoutId2 = setTimeout(() => updateController.abort(), 45000);
|
||||||
|
|
||||||
|
fetch('/api/system/update', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...authHeaders()
|
||||||
|
},
|
||||||
|
signal: updateController.signal // 45 second timeout
|
||||||
|
})
|
||||||
|
.then(response => {
|
||||||
|
clearTimeout(updateTimeoutId2);
|
||||||
|
return response;
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
clearTimeout(updateTimeoutId2);
|
||||||
|
throw error;
|
||||||
|
})
|
||||||
|
.then(response => {
|
||||||
|
// Better error checking
|
||||||
|
if (!response.ok) {
|
||||||
|
return response.json().then(data => {
|
||||||
|
throw new Error(data.message || `Server error: ${response.status}`);
|
||||||
|
}).catch(e => {
|
||||||
|
if (e instanceof SyntaxError) {
|
||||||
|
throw new Error(`Server error: ${response.status}`);
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
})
|
||||||
|
.then(data => {
|
||||||
|
clearTimeout(updateTimeoutId);
|
||||||
|
|
||||||
|
if (data.status === 'success') {
|
||||||
|
// Check if there's an update message to determine if an update was actually applied
|
||||||
|
const updateApplied = data.message && data.message.includes('Update applied successfully');
|
||||||
|
const noNewUpdate = data.data && data.data.output && data.data.output.includes('already have the latest version');
|
||||||
|
|
||||||
|
// Hide update notification
|
||||||
|
hideUpdateAlert();
|
||||||
|
|
||||||
|
if (noNewUpdate) {
|
||||||
|
// If no update was needed, show a different message
|
||||||
|
showNotification('You already have the latest version. No update was needed.', 'info');
|
||||||
|
|
||||||
|
// Re-enable both buttons
|
||||||
|
if (updateButton) {
|
||||||
|
updateButton.disabled = false;
|
||||||
|
updateButton.innerHTML = '<i class="fas fa-download"></i> Check Again';
|
||||||
|
}
|
||||||
|
|
||||||
|
const floatingButton = document.getElementById('floating-update-button');
|
||||||
|
if (floatingButton) {
|
||||||
|
floatingButton.disabled = false;
|
||||||
|
floatingButton.textContent = 'Check Again';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update page to show current version without reloading
|
||||||
|
loadSystemStatus();
|
||||||
|
|
||||||
|
// Double-check system status again after a delay to ensure version is updated
|
||||||
|
setTimeout(() => {
|
||||||
|
loadSystemStatus();
|
||||||
|
checkForUpdates(); // Run check again to update status text
|
||||||
|
}, 2000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show success notification
|
||||||
|
showNotification('Update applied successfully. The page will reload in 30 seconds.', 'success');
|
||||||
|
|
||||||
|
// Update both buttons with countdown
|
||||||
|
let secondsLeft = 30;
|
||||||
|
|
||||||
|
// Function to update the countdown text
|
||||||
|
function updateCountdown() {
|
||||||
|
// Update original button if it exists
|
||||||
|
if (updateButton) {
|
||||||
|
updateButton.innerHTML = `<i class="fas fa-sync"></i> Reloading in ${secondsLeft}s...`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update floating button if it exists
|
||||||
|
const floatingButton = document.getElementById('floating-update-button');
|
||||||
|
if (floatingButton) {
|
||||||
|
floatingButton.textContent = `Reloading in ${secondsLeft}s...`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial text update
|
||||||
|
updateCountdown();
|
||||||
|
|
||||||
|
// Start countdown
|
||||||
|
const countdownInterval = setInterval(() => {
|
||||||
|
secondsLeft--;
|
||||||
|
updateCountdown();
|
||||||
|
|
||||||
|
if (secondsLeft <= 0) {
|
||||||
|
clearInterval(countdownInterval);
|
||||||
|
|
||||||
|
// Clear localStorage to ensure a clean reload
|
||||||
|
localStorage.removeItem(UPDATE_KEY);
|
||||||
|
localStorage.removeItem(CURRENT_VERSION_KEY);
|
||||||
|
localStorage.removeItem(REMOTE_VERSION_KEY);
|
||||||
|
|
||||||
|
// Also ensure floating notification is completely removed
|
||||||
|
const floatingNotification = document.getElementById('floating-update-notification');
|
||||||
|
if (floatingNotification) {
|
||||||
|
floatingNotification.style.display = 'none';
|
||||||
|
floatingNotification.removeAttribute('style');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Force a clean reload
|
||||||
|
window.location.href = window.location.href.split('#')[0] + '?t=' + new Date().getTime();
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
// Set a timer to reload the page after the service has time to restart
|
||||||
|
setTimeout(() => {
|
||||||
|
clearInterval(countdownInterval);
|
||||||
|
|
||||||
|
// Clear localStorage to ensure a clean reload
|
||||||
|
localStorage.removeItem(UPDATE_KEY);
|
||||||
|
localStorage.removeItem(CURRENT_VERSION_KEY);
|
||||||
|
localStorage.removeItem(REMOTE_VERSION_KEY);
|
||||||
|
|
||||||
|
// Also ensure floating notification is completely removed
|
||||||
|
const floatingNotification = document.getElementById('floating-update-notification');
|
||||||
|
if (floatingNotification) {
|
||||||
|
floatingNotification.style.display = 'none';
|
||||||
|
floatingNotification.removeAttribute('style');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Force a clean reload with cache-busting parameter
|
||||||
|
window.location.href = window.location.href.split('#')[0] + '?t=' + new Date().getTime();
|
||||||
|
}, 30000);
|
||||||
|
} else {
|
||||||
|
// Enable both buttons on failure
|
||||||
|
if (updateButton) {
|
||||||
|
updateButton.disabled = false;
|
||||||
|
updateButton.innerHTML = '<i class="fas fa-download"></i> Update Now';
|
||||||
|
}
|
||||||
|
|
||||||
|
const floatingButton = document.getElementById('floating-update-button');
|
||||||
|
if (floatingButton) {
|
||||||
|
floatingButton.disabled = false;
|
||||||
|
floatingButton.textContent = 'Update Now';
|
||||||
|
}
|
||||||
|
|
||||||
|
showNotification(data.message || 'Failed to apply update', 'danger');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
clearTimeout(updateTimeoutId);
|
||||||
|
console.error('Error applying update:', error);
|
||||||
|
|
||||||
|
// Re-enable both buttons on error
|
||||||
|
if (updateButton) {
|
||||||
|
updateButton.disabled = false;
|
||||||
|
updateButton.innerHTML = '<i class="fas fa-download"></i> Update Now';
|
||||||
|
}
|
||||||
|
|
||||||
|
const floatingButton = document.getElementById('floating-update-button');
|
||||||
|
if (floatingButton) {
|
||||||
|
floatingButton.disabled = false;
|
||||||
|
floatingButton.textContent = 'Update Now';
|
||||||
|
}
|
||||||
|
|
||||||
|
// More specific error message based on the error type
|
||||||
|
if (error.name === 'AbortError') {
|
||||||
|
showNotification('Update request timed out. The server might still be processing the update.', 'warning');
|
||||||
|
} else if (error.message.includes('Failed to fetch') || error.message.includes('NetworkError')) {
|
||||||
|
showNotification('Network error. Please check your connection and try again.', 'danger');
|
||||||
|
} else {
|
||||||
|
showNotification(error.message || 'Failed to connect to server', 'danger');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event listeners
|
||||||
|
if (refreshButton) {
|
||||||
|
refreshButton.addEventListener('click', () => {
|
||||||
|
loadSystemStatus();
|
||||||
|
checkForUpdates();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updateButton) {
|
||||||
|
updateButton.addEventListener('click', applyUpdate);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add handler for floating refresh button
|
||||||
|
const floatingRefreshButton = document.getElementById('floating-refresh-button');
|
||||||
|
if (floatingRefreshButton) {
|
||||||
|
floatingRefreshButton.addEventListener('click', () => {
|
||||||
|
// Force a hard refresh of everything
|
||||||
|
floatingRefreshButton.textContent = 'Refreshing...';
|
||||||
|
floatingRefreshButton.disabled = true;
|
||||||
|
|
||||||
|
// Force reload system status
|
||||||
|
loadSystemStatus();
|
||||||
|
|
||||||
|
// Force a check without the test parameter to get real status
|
||||||
|
const realCheckUrl = `/api/system/check-updates?_=${new Date().getTime()}`;
|
||||||
|
fetch(realCheckUrl, { headers: authHeaders() })
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
console.log('Manual refresh result:', data);
|
||||||
|
|
||||||
|
if (data.status === 'success') {
|
||||||
|
// Check if we're in test mode
|
||||||
|
const isTestMode = localStorage.getItem('showUpdateButton') === 'true';
|
||||||
|
|
||||||
|
// If test mode is enabled but update says no update available, disable test mode
|
||||||
|
if (isTestMode && data.data && !data.data.updateAvailable) {
|
||||||
|
localStorage.setItem('showUpdateButton', 'false');
|
||||||
|
testToggle.innerText = 'Enable Test Update';
|
||||||
|
showNotification('Test mode has been disabled - no real update is available', 'info');
|
||||||
|
hideUpdateAlert();
|
||||||
|
showNotification(`Current version: ${data.data.currentVersion}. You are up to date.`, 'success');
|
||||||
|
}
|
||||||
|
// Regular update handling
|
||||||
|
else if (data.data && data.data.updateAvailable) {
|
||||||
|
showUpdateAlert(data.data.currentVersion, data.data.remoteVersion);
|
||||||
|
showNotification(`Update is available: ${data.data.currentVersion} → ${data.data.remoteVersion}`, 'info');
|
||||||
|
} else {
|
||||||
|
hideUpdateAlert();
|
||||||
|
showNotification(`Current version: ${data.data.currentVersion}. You are up to date.`, 'success');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-enable button
|
||||||
|
floatingRefreshButton.textContent = 'Refresh Status';
|
||||||
|
floatingRefreshButton.disabled = false;
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error during manual refresh:', error);
|
||||||
|
floatingRefreshButton.textContent = 'Refresh Status';
|
||||||
|
floatingRefreshButton.disabled = false;
|
||||||
|
showNotification('Error checking update status', 'danger');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test mode toggle (for developers)
|
||||||
|
const testToggle = document.getElementById('toggle-test-update-button');
|
||||||
|
if (testToggle) {
|
||||||
|
// Initialize based on current localStorage setting
|
||||||
|
const isTestMode = localStorage.getItem('showUpdateButton') === 'true';
|
||||||
|
|
||||||
|
// If test mode is enabled but we have a version mismatch, update the stored version
|
||||||
|
if (isTestMode && versionElement && versionElement.textContent) {
|
||||||
|
const currentVersion = versionElement.textContent.trim();
|
||||||
|
if (localStorage.getItem(CURRENT_VERSION_KEY) !== currentVersion) {
|
||||||
|
localStorage.setItem(CURRENT_VERSION_KEY, currentVersion);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update toggle text
|
||||||
|
testToggle.innerText = isTestMode ? 'Disable Test Update' : 'Enable Test Update';
|
||||||
|
|
||||||
|
// Add click handler
|
||||||
|
testToggle.addEventListener('click', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const currentSetting = localStorage.getItem('showUpdateButton') === 'true';
|
||||||
|
const newSetting = !currentSetting;
|
||||||
|
|
||||||
|
localStorage.setItem('showUpdateButton', newSetting);
|
||||||
|
testToggle.innerText = newSetting ? 'Disable Test Update' : 'Enable Test Update';
|
||||||
|
|
||||||
|
if (newSetting) {
|
||||||
|
// Get the current version from the version element
|
||||||
|
let currentVersion = '2.0.11'; // Default fallback
|
||||||
|
if (versionElement && versionElement.textContent) {
|
||||||
|
currentVersion = versionElement.textContent.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// If enabling test mode, force show update button
|
||||||
|
showUpdateAlert(currentVersion, '2.1.0-test');
|
||||||
|
updateStatusElement.innerHTML = '<i class="fas fa-exclamation-circle text-warning"></i> Update available (TEST MODE)';
|
||||||
|
|
||||||
|
// Add test mode indicator to floating notification
|
||||||
|
const floatingNotification = document.getElementById('floating-update-notification');
|
||||||
|
if (floatingNotification) {
|
||||||
|
const testBadge = document.createElement('div');
|
||||||
|
testBadge.style.backgroundColor = 'orange';
|
||||||
|
testBadge.style.color = 'black';
|
||||||
|
testBadge.style.padding = '3px 8px';
|
||||||
|
testBadge.style.borderRadius = '4px';
|
||||||
|
testBadge.style.fontWeight = 'bold';
|
||||||
|
testBadge.style.marginBottom = '5px';
|
||||||
|
testBadge.style.fontSize = '12px';
|
||||||
|
testBadge.textContent = 'TEST MODE - NOT A REAL UPDATE';
|
||||||
|
|
||||||
|
// Insert at the top of the notification
|
||||||
|
floatingNotification.insertBefore(testBadge, floatingNotification.firstChild);
|
||||||
|
}
|
||||||
|
|
||||||
|
showNotification('TEST MODE ENABLED - This is not a real update', 'warning');
|
||||||
|
} else {
|
||||||
|
// If disabling test mode, check for real updates
|
||||||
|
hideUpdateAlert();
|
||||||
|
|
||||||
|
// Force a check without the test parameter to get real status
|
||||||
|
const realCheckUrl = '/api/system/check-updates';
|
||||||
|
fetch(realCheckUrl, { headers: authHeaders() })
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
console.log('Real update check result:', data);
|
||||||
|
if (data.status === 'success' && data.data && !data.data.updateAvailable) {
|
||||||
|
showNotification('No actual updates are available.', 'info');
|
||||||
|
} else if (data.status === 'success' && data.data && data.data.updateAvailable) {
|
||||||
|
showUpdateAlert(data.data.currentVersion, data.data.remoteVersion);
|
||||||
|
showNotification(`A real update is available: ${data.data.currentVersion} → ${data.data.remoteVersion}`, 'info');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => console.error('Error checking for real updates:', error));
|
||||||
|
|
||||||
|
showNotification('Test update button disabled', 'info');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persistent update button - force display every second if update is available
|
||||||
|
function forceShowUpdateButton() {
|
||||||
|
const isUpdateAvailable = localStorage.getItem(UPDATE_KEY) === 'true';
|
||||||
|
|
||||||
|
if (isUpdateAvailable) {
|
||||||
|
// Get the most current version
|
||||||
|
let currentVersion = localStorage.getItem(CURRENT_VERSION_KEY);
|
||||||
|
const remoteVersion = localStorage.getItem(REMOTE_VERSION_KEY);
|
||||||
|
|
||||||
|
// If we have the version element on screen, use that as the source of truth
|
||||||
|
if (versionElement && versionElement.textContent) {
|
||||||
|
const displayedVersion = versionElement.textContent.trim();
|
||||||
|
// Update stored version if different
|
||||||
|
if (displayedVersion !== currentVersion) {
|
||||||
|
localStorage.setItem(CURRENT_VERSION_KEY, displayedVersion);
|
||||||
|
currentVersion = displayedVersion;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentVersion && remoteVersion) {
|
||||||
|
// Check floating notification
|
||||||
|
const floatingNotification = document.getElementById('floating-update-notification');
|
||||||
|
if (floatingNotification && floatingNotification.style.display !== 'block') {
|
||||||
|
console.log('Forcing floating update notification display');
|
||||||
|
|
||||||
|
// Set the version text
|
||||||
|
const versionElement = document.getElementById('floating-update-version');
|
||||||
|
if (versionElement) {
|
||||||
|
versionElement.textContent = `Version ${currentVersion} → ${remoteVersion}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply strong styling - make sure to completely override any previous styles
|
||||||
|
floatingNotification.setAttribute('style', ''); // Clear any previous styles first
|
||||||
|
floatingNotification.setAttribute('style', ''); // Clear any previous styles first
|
||||||
|
floatingNotification.setAttribute('style',
|
||||||
|
'display: block !important; ' +
|
||||||
|
'visibility: visible !important; ' +
|
||||||
|
'opacity: 1 !important; ' +
|
||||||
|
'position: fixed !important; ' +
|
||||||
|
'top: 20px !important; ' +
|
||||||
|
'right: 20px !important; ' +
|
||||||
|
'width: 300px !important; ' +
|
||||||
|
'padding: 15px !important; ' +
|
||||||
|
'background-color: #ff5555 !important; ' +
|
||||||
|
'color: white !important; ' +
|
||||||
|
'border: 3px solid #cc0000 !important; ' +
|
||||||
|
'border-radius: 5px !important; ' +
|
||||||
|
'box-shadow: 0 0 20px rgba(0,0,0,0.5) !important; ' +
|
||||||
|
'z-index: 10000 !important; ' +
|
||||||
|
'font-weight: bold !important; ' +
|
||||||
|
'text-align: center !important;'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Ensure button has correct event handler
|
||||||
|
const updateButton = document.getElementById('floating-update-button');
|
||||||
|
if (updateButton) {
|
||||||
|
// Remove any existing listeners
|
||||||
|
updateButton.removeEventListener('click', applyUpdate);
|
||||||
|
// Add new listener
|
||||||
|
updateButton.addEventListener('click', applyUpdate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Still try the original alert as a fallback
|
||||||
|
try {
|
||||||
|
const alertBox = updateAvailableDiv.querySelector('.alert');
|
||||||
|
if (alertBox && alertBox.style.display !== 'block') {
|
||||||
|
alertBox.style.display = 'block';
|
||||||
|
updateAvailableDiv.style.display = 'block';
|
||||||
|
|
||||||
|
// Update message
|
||||||
|
const spanElement = alertBox.querySelector('span');
|
||||||
|
if (spanElement) {
|
||||||
|
spanElement.textContent = `A new version is available: ${currentVersion} → ${remoteVersion}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error forcing original update button:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize
|
||||||
|
loadSystemStatus();
|
||||||
|
|
||||||
|
// SUPER EMERGENCY FIX: Force hide and remove all update notifications
|
||||||
|
const emergencyFix = () => {
|
||||||
|
// Hard reset all update states
|
||||||
|
localStorage.clear(); // Clear ALL localStorage to be absolutely safe
|
||||||
|
|
||||||
|
// Find and destroy any floating notification elements
|
||||||
|
document.querySelectorAll('[id*="notification"], [id*="update"], [class*="notification"], [class*="update"]').forEach(el => {
|
||||||
|
try {
|
||||||
|
if (el.id !== 'update-status' && !el.id.includes('refresh')) {
|
||||||
|
el.style.cssText = 'display: none !important; visibility: hidden !important; opacity: 0 !important';
|
||||||
|
el.removeAttribute('style');
|
||||||
|
|
||||||
|
if (el.parentNode) {
|
||||||
|
el.parentNode.removeChild(el);
|
||||||
|
console.log('Element removed from DOM:', el.id || el.className || 'unnamed element');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error removing element:', e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add a MutationObserver to keep killing any notification elements that might reappear
|
||||||
|
if (!window._notificationKiller) {
|
||||||
|
const observer = new MutationObserver(mutations => {
|
||||||
|
mutations.forEach(mutation => {
|
||||||
|
mutation.addedNodes.forEach(node => {
|
||||||
|
if (node.nodeType === 1) { // Element node
|
||||||
|
if ((node.id && (node.id.includes('notification') || node.id.includes('update'))) ||
|
||||||
|
(node.className && (node.className.includes('notification') || node.className.includes('update')))) {
|
||||||
|
if (node.id !== 'update-status' && !node.id.includes('refresh')) {
|
||||||
|
node.style.cssText = 'display: none !important';
|
||||||
|
if (node.parentNode) {
|
||||||
|
node.parentNode.removeChild(node);
|
||||||
|
console.log('Dynamically added notification killed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
observer.observe(document.body, {
|
||||||
|
childList: true,
|
||||||
|
subtree: true
|
||||||
|
});
|
||||||
|
|
||||||
|
window._notificationKiller = observer;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Super emergency notification cleanup complete');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Run immediately
|
||||||
|
emergencyFix();
|
||||||
|
|
||||||
|
// Run again after delays to ensure it works
|
||||||
|
setTimeout(emergencyFix, 100);
|
||||||
|
setTimeout(emergencyFix, 500);
|
||||||
|
setTimeout(emergencyFix, 1000);
|
||||||
|
|
||||||
|
// Set interval to refresh uptime every minute
|
||||||
|
setInterval(loadSystemStatus, 60000);
|
||||||
|
}
|
637
public/js/utils.js
Normal file
637
public/js/utils.js
Normal file
@ -0,0 +1,637 @@
|
|||||||
|
/**
|
||||||
|
* Utility functions for Transmission RSS Manager
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a byte value to a human-readable string
|
||||||
|
* @param {number} bytes - Bytes to format
|
||||||
|
* @param {number} decimals - Number of decimal places to show
|
||||||
|
* @returns {string} - Formatted string (e.g., "1.5 MB")
|
||||||
|
*/
|
||||||
|
export function formatBytes(bytes, decimals = 2) {
|
||||||
|
if (bytes === 0) return '0 Bytes';
|
||||||
|
|
||||||
|
const k = 1024;
|
||||||
|
const dm = decimals < 0 ? 0 : decimals;
|
||||||
|
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
|
||||||
|
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a debounced version of a function
|
||||||
|
* @param {Function} func - Function to debounce
|
||||||
|
* @param {number} wait - Milliseconds to wait
|
||||||
|
* @returns {Function} - Debounced function
|
||||||
|
*/
|
||||||
|
export function debounce(func, wait) {
|
||||||
|
let timeout;
|
||||||
|
return function(...args) {
|
||||||
|
const context = this;
|
||||||
|
clearTimeout(timeout);
|
||||||
|
timeout = setTimeout(() => func.apply(context, args), wait);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a throttled version of a function
|
||||||
|
* @param {Function} func - Function to throttle
|
||||||
|
* @param {number} limit - Milliseconds to throttle
|
||||||
|
* @returns {Function} - Throttled function
|
||||||
|
*/
|
||||||
|
export function throttle(func, limit) {
|
||||||
|
let inThrottle;
|
||||||
|
return function(...args) {
|
||||||
|
const context = this;
|
||||||
|
if (!inThrottle) {
|
||||||
|
func.apply(context, args);
|
||||||
|
inThrottle = true;
|
||||||
|
setTimeout(() => { inThrottle = false; }, limit);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Safely parse JSON with error handling
|
||||||
|
* @param {string} json - JSON string to parse
|
||||||
|
* @param {*} fallback - Fallback value if parsing fails
|
||||||
|
* @returns {*} - Parsed object or fallback
|
||||||
|
*/
|
||||||
|
export function safeJsonParse(json, fallback = {}) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(json);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error parsing JSON:', e);
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escape HTML special characters
|
||||||
|
* @param {string} html - String potentially containing HTML
|
||||||
|
* @returns {string} - Escaped string
|
||||||
|
*/
|
||||||
|
export function escapeHtml(html) {
|
||||||
|
if (!html) return '';
|
||||||
|
const entities = {
|
||||||
|
'&': '&',
|
||||||
|
'<': '<',
|
||||||
|
'>': '>',
|
||||||
|
'"': '"',
|
||||||
|
"'": ''',
|
||||||
|
'/': '/'
|
||||||
|
};
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
};
|
@ -1,38 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# Reset and run the Transmission RSS Manager application with network access
|
|
||||||
|
|
||||||
GREEN='\033[0;32m'
|
|
||||||
YELLOW='\033[1;33m'
|
|
||||||
RED='\033[0;31m'
|
|
||||||
NC='\033[0m' # No Color
|
|
||||||
|
|
||||||
# Clean up existing test directory
|
|
||||||
echo -e "${YELLOW}Removing existing test directory...${NC}"
|
|
||||||
rm -rf "$HOME/transmission-rss-test"
|
|
||||||
|
|
||||||
# Create and prepare test directory
|
|
||||||
echo -e "${GREEN}Creating fresh test directory...${NC}"
|
|
||||||
TEST_DIR="$HOME/transmission-rss-test"
|
|
||||||
mkdir -p "$TEST_DIR"
|
|
||||||
|
|
||||||
# Create appsettings.json to listen on all interfaces
|
|
||||||
mkdir -p "$TEST_DIR/Properties"
|
|
||||||
cat > "$TEST_DIR/Properties/launchSettings.json" << 'EOL'
|
|
||||||
{
|
|
||||||
"profiles": {
|
|
||||||
"TransmissionRssManager": {
|
|
||||||
"commandName": "Project",
|
|
||||||
"dotnetRunMessages": true,
|
|
||||||
"launchBrowser": false,
|
|
||||||
"applicationUrl": "http://0.0.0.0:5000",
|
|
||||||
"environmentVariables": {
|
|
||||||
"ASPNETCORE_ENVIRONMENT": "Production"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
EOL
|
|
||||||
|
|
||||||
# Copy all other files from the original reset-and-run.sh
|
|
||||||
bash /opt/develop/transmission-rss-manager/reset-and-run.sh
|
|
1048
reset-and-run.sh
1048
reset-and-run.sh
File diff suppressed because it is too large
Load Diff
27
run-app.sh
27
run-app.sh
@ -1,27 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# Simple script to run the Transmission RSS Manager application
|
|
||||||
|
|
||||||
GREEN='\033[0;32m'
|
|
||||||
YELLOW='\033[1;33m'
|
|
||||||
RED='\033[0;31m'
|
|
||||||
NC='\033[0m' # No Color
|
|
||||||
|
|
||||||
# Check if the app directory exists
|
|
||||||
APP_DIR="$HOME/transmission-rss-test"
|
|
||||||
if [ ! -d "$APP_DIR" ]; then
|
|
||||||
echo -e "${YELLOW}Application directory not found. Did you run the test installer?${NC}"
|
|
||||||
echo -e "${YELLOW}Running test installer first...${NC}"
|
|
||||||
bash /opt/develop/transmission-rss-manager/test-installer.sh
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Navigate to the app directory
|
|
||||||
cd "$APP_DIR"
|
|
||||||
|
|
||||||
# Run the application
|
|
||||||
echo -e "${GREEN}Starting Transmission RSS Manager...${NC}"
|
|
||||||
echo -e "${GREEN}The web interface will be available at: http://localhost:5000${NC}"
|
|
||||||
echo -e "${YELLOW}Press Ctrl+C to stop the application${NC}"
|
|
||||||
|
|
||||||
dotnet run
|
|
212
scripts/create-module-links.sh
Executable file
212
scripts/create-module-links.sh
Executable file
@ -0,0 +1,212 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Script to create symlinks for all modules in different naming styles
|
||||||
|
# This ensures compatibility with different module import styles
|
||||||
|
|
||||||
|
APP_DIR="$(dirname "$(dirname "$(readlink -f "$0")")")"
|
||||||
|
MODULE_DIR="$APP_DIR/modules"
|
||||||
|
|
||||||
|
echo "Creating module symlinks for compatibility..."
|
||||||
|
echo "Module directory: $MODULE_DIR"
|
||||||
|
|
||||||
|
# Create a function to make bidirectional symlinks
|
||||||
|
create_module_symlinks() {
|
||||||
|
if [ ! -d "$MODULE_DIR" ]; then
|
||||||
|
echo "Error: Module directory not found at $MODULE_DIR"
|
||||||
|
mkdir -p "$MODULE_DIR"
|
||||||
|
echo "Created module directory: $MODULE_DIR"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if any .js files exist in the module directory
|
||||||
|
js_file_count=$(find "$MODULE_DIR" -maxdepth 1 -name "*.js" -type f | wc -l)
|
||||||
|
if [ "$js_file_count" -eq 0 ]; then
|
||||||
|
echo "Warning: No JavaScript module files found in $MODULE_DIR"
|
||||||
|
echo "Skipping symlink creation as there are no modules to link"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create symlinks for hyphenated modules
|
||||||
|
for module in "$MODULE_DIR"/*-*.js; do
|
||||||
|
if [ -f "$module" ]; then
|
||||||
|
# Convert hyphenated to camelCase
|
||||||
|
BASE_NAME=$(basename "$module")
|
||||||
|
CAMEL_NAME=$(echo "$BASE_NAME" | sed -E 's/-([a-z])/\U\1/g')
|
||||||
|
|
||||||
|
# Create camelCase symlink if needed
|
||||||
|
if [ ! -f "$MODULE_DIR/$CAMEL_NAME" ] && [ ! -L "$MODULE_DIR/$CAMEL_NAME" ]; then
|
||||||
|
if ln -sf "$BASE_NAME" "$MODULE_DIR/$CAMEL_NAME"; then
|
||||||
|
echo "Created symlink: $CAMEL_NAME -> $BASE_NAME"
|
||||||
|
else
|
||||||
|
echo "Error: Failed to create symlink $CAMEL_NAME"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create extension-less symlink for both versions
|
||||||
|
NO_EXT_BASE="${BASE_NAME%.js}"
|
||||||
|
if [ ! -f "$MODULE_DIR/$NO_EXT_BASE" ] && [ ! -L "$MODULE_DIR/$NO_EXT_BASE" ]; then
|
||||||
|
if ln -sf "$BASE_NAME" "$MODULE_DIR/$NO_EXT_BASE"; then
|
||||||
|
echo "Created symlink: $NO_EXT_BASE -> $BASE_NAME"
|
||||||
|
else
|
||||||
|
echo "Error: Failed to create symlink $NO_EXT_BASE"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
NO_EXT_CAMEL="${CAMEL_NAME%.js}"
|
||||||
|
if [ ! -f "$MODULE_DIR/$NO_EXT_CAMEL" ] && [ ! -L "$MODULE_DIR/$NO_EXT_CAMEL" ]; then
|
||||||
|
if ln -sf "$BASE_NAME" "$MODULE_DIR/$NO_EXT_CAMEL"; then
|
||||||
|
echo "Created symlink: $NO_EXT_CAMEL -> $BASE_NAME"
|
||||||
|
else
|
||||||
|
echo "Error: Failed to create symlink $NO_EXT_CAMEL"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Create symlinks for camelCase modules (only non-symlinked files)
|
||||||
|
for module in "$MODULE_DIR"/[a-z]*[A-Z]*.js; do
|
||||||
|
if [ -f "$module" ] && [ ! -L "$module" ]; then
|
||||||
|
# Convert camelCase to hyphenated
|
||||||
|
BASE_NAME=$(basename "$module")
|
||||||
|
HYPHEN_NAME=$(echo "$BASE_NAME" | sed -E 's/([a-z])([A-Z])/\1-\L\2/g')
|
||||||
|
|
||||||
|
# Create hyphenated symlink if needed
|
||||||
|
if [ ! -f "$MODULE_DIR/$HYPHEN_NAME" ] && [ ! -L "$MODULE_DIR/$HYPHEN_NAME" ]; then
|
||||||
|
if ln -sf "$BASE_NAME" "$MODULE_DIR/$HYPHEN_NAME"; then
|
||||||
|
echo "Created symlink: $HYPHEN_NAME -> $BASE_NAME"
|
||||||
|
else
|
||||||
|
echo "Error: Failed to create symlink $HYPHEN_NAME"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create extension-less symlink for both versions
|
||||||
|
NO_EXT_BASE="${BASE_NAME%.js}"
|
||||||
|
if [ ! -f "$MODULE_DIR/$NO_EXT_BASE" ] && [ ! -L "$MODULE_DIR/$NO_EXT_BASE" ]; then
|
||||||
|
if ln -sf "$BASE_NAME" "$MODULE_DIR/$NO_EXT_BASE"; then
|
||||||
|
echo "Created symlink: $NO_EXT_BASE -> $BASE_NAME"
|
||||||
|
else
|
||||||
|
echo "Error: Failed to create symlink $NO_EXT_BASE"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
NO_EXT_HYPHEN="${HYPHEN_NAME%.js}"
|
||||||
|
if [ ! -f "$MODULE_DIR/$NO_EXT_HYPHEN" ] && [ ! -L "$MODULE_DIR/$NO_EXT_HYPHEN" ]; then
|
||||||
|
if ln -sf "$BASE_NAME" "$MODULE_DIR/$NO_EXT_HYPHEN"; then
|
||||||
|
echo "Created symlink: $NO_EXT_HYPHEN -> $BASE_NAME"
|
||||||
|
else
|
||||||
|
echo "Error: Failed to create symlink $NO_EXT_HYPHEN"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Module symlinks created successfully"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Setup production directory if needed
|
||||||
|
setup_production_dir() {
|
||||||
|
# Check if this is running in development environment
|
||||||
|
DEV_DIR="/opt/develop/transmission-rss-manager"
|
||||||
|
|
||||||
|
# Check systemd service file to determine the correct production directory
|
||||||
|
PROD_DIR="/opt/transmission-rss-manager"
|
||||||
|
SERVICE_FILE="/etc/systemd/system/transmission-rss-manager.service"
|
||||||
|
|
||||||
|
if [ -f "$SERVICE_FILE" ]; then
|
||||||
|
# Extract the WorkingDirectory from the service file
|
||||||
|
WORKING_DIR=$(grep "WorkingDirectory=" "$SERVICE_FILE" | cut -d'=' -f2)
|
||||||
|
if [ -n "$WORKING_DIR" ]; then
|
||||||
|
PROD_DIR="$WORKING_DIR"
|
||||||
|
echo "Found production directory from service file: $PROD_DIR"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$APP_DIR" == "$DEV_DIR" ] && [ -d "$DEV_DIR" ]; then
|
||||||
|
echo "Setting up production directory symlinks at $PROD_DIR..."
|
||||||
|
|
||||||
|
# Create the production directory if it doesn't exist
|
||||||
|
if [ ! -d "$PROD_DIR" ]; then
|
||||||
|
if mkdir -p "$PROD_DIR"; then
|
||||||
|
echo "Created production directory: $PROD_DIR"
|
||||||
|
else
|
||||||
|
echo "Error: Failed to create production directory $PROD_DIR"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create the modules directory in production if it doesn't exist
|
||||||
|
if [ ! -d "$PROD_DIR/modules" ]; then
|
||||||
|
if mkdir -p "$PROD_DIR/modules"; then
|
||||||
|
echo "Created production modules directory: $PROD_DIR/modules"
|
||||||
|
else
|
||||||
|
echo "Error: Failed to create production modules directory"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check for JavaScript modules in dev directory
|
||||||
|
js_file_count=$(find "$MODULE_DIR" -maxdepth 1 -name "*.js" -type f | wc -l)
|
||||||
|
if [ "$js_file_count" -eq 0 ]; then
|
||||||
|
echo "Warning: No JavaScript module files found in $MODULE_DIR"
|
||||||
|
echo "Skipping production symlink creation"
|
||||||
|
else
|
||||||
|
# Create symlinks from development modules to production modules
|
||||||
|
for module in "$MODULE_DIR"/*.js; do
|
||||||
|
if [ -f "$module" ] && [ ! -L "$module" ]; then
|
||||||
|
MODULE_NAME=$(basename "$module")
|
||||||
|
# Create symlink in production directory
|
||||||
|
if ln -sf "$module" "$PROD_DIR/modules/$MODULE_NAME"; then
|
||||||
|
echo "Created production symlink: $PROD_DIR/modules/$MODULE_NAME -> $module"
|
||||||
|
else
|
||||||
|
echo "Error: Failed to create production symlink for $MODULE_NAME"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Copy server.js to production if it doesn't exist or needs updating
|
||||||
|
if [ -f "$DEV_DIR/server.js" ]; then
|
||||||
|
if [ ! -f "$PROD_DIR/server.js" ] || [ "$DEV_DIR/server.js" -nt "$PROD_DIR/server.js" ]; then
|
||||||
|
if cp "$DEV_DIR/server.js" "$PROD_DIR/server.js"; then
|
||||||
|
echo "Copied server.js to production directory"
|
||||||
|
else
|
||||||
|
echo "Error: Failed to copy server.js to production"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "Warning: server.js not found in development directory"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create data directory in production if it doesn't exist
|
||||||
|
if mkdir -p "$PROD_DIR/data"; then
|
||||||
|
echo "Ensured data directory exists in production"
|
||||||
|
else
|
||||||
|
echo "Error: Failed to create production data directory"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Make sure scripts directory exists in production
|
||||||
|
if mkdir -p "$PROD_DIR/scripts"; then
|
||||||
|
echo "Ensured scripts directory exists in production"
|
||||||
|
else
|
||||||
|
echo "Error: Failed to create production scripts directory"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Copy test-and-start.sh to production
|
||||||
|
if [ -f "$DEV_DIR/scripts/test-and-start.sh" ]; then
|
||||||
|
if cp "$DEV_DIR/scripts/test-and-start.sh" "$PROD_DIR/scripts/test-and-start.sh"; then
|
||||||
|
chmod +x "$PROD_DIR/scripts/test-and-start.sh"
|
||||||
|
echo "Copied test-and-start.sh script to production"
|
||||||
|
else
|
||||||
|
echo "Error: Failed to copy test-and-start.sh to production"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "Warning: test-and-start.sh not found in development scripts directory"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Production directory setup complete"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Execute the symlink creation function
|
||||||
|
create_module_symlinks
|
||||||
|
|
||||||
|
# Setup production directory if needed
|
||||||
|
setup_production_dir
|
140
scripts/test-and-start.sh
Executable file
140
scripts/test-and-start.sh
Executable file
@ -0,0 +1,140 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Script to ensure data directory exists and start the application
|
||||||
|
|
||||||
|
# Define paths
|
||||||
|
APP_DIR="$(dirname "$(dirname "$(readlink -f "$0")")")"
|
||||||
|
DATA_DIR="$APP_DIR/data"
|
||||||
|
|
||||||
|
echo "Starting Transmission RSS Manager..."
|
||||||
|
echo "Application directory: $APP_DIR"
|
||||||
|
echo "Data directory: $DATA_DIR"
|
||||||
|
|
||||||
|
# Ensure the data directory exists
|
||||||
|
if [ ! -d "$DATA_DIR" ]; then
|
||||||
|
echo "Creating data directory: $DATA_DIR"
|
||||||
|
mkdir -p "$DATA_DIR"
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo "Failed to create data directory. Trying alternative method..."
|
||||||
|
# Try alternative method if standard mkdir fails
|
||||||
|
cd "$APP_DIR" && mkdir -p data
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo "ERROR: Both methods to create data directory failed. Please check permissions."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Set permissions
|
||||||
|
chmod -R 755 "$DATA_DIR" || {
|
||||||
|
echo "Warning: Failed to set permissions on data directory"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check for RSS files
|
||||||
|
if [ ! -f "$DATA_DIR/rss-feeds.json" ]; then
|
||||||
|
echo "Creating initial empty rss-feeds.json file"
|
||||||
|
echo "[]" > "$DATA_DIR/rss-feeds.json" || {
|
||||||
|
echo "ERROR: Failed to create rss-feeds.json file"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -f "$DATA_DIR/rss-items.json" ]; then
|
||||||
|
echo "Creating initial empty rss-items.json file"
|
||||||
|
echo "[]" > "$DATA_DIR/rss-items.json" || {
|
||||||
|
echo "ERROR: Failed to create rss-items.json file"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Find the node executable path
|
||||||
|
NODE_PATH=$(which node 2>/dev/null)
|
||||||
|
if [ -z "$NODE_PATH" ]; then
|
||||||
|
# If node is not in PATH, try common locations
|
||||||
|
for path in /usr/bin/node /usr/local/bin/node /opt/node/bin/node /usr/lib/node; do
|
||||||
|
if [ -x "$path" ]; then
|
||||||
|
NODE_PATH="$path"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# If we still can't find node, use the default path
|
||||||
|
if [ -z "$NODE_PATH" ]; then
|
||||||
|
NODE_PATH="/usr/bin/node"
|
||||||
|
echo "Warning: Node.js not found in PATH, using default path: $NODE_PATH"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create module symlinks to ensure compatibility
|
||||||
|
echo "Creating module symlinks for compatibility..."
|
||||||
|
MODULE_DIR="$APP_DIR/modules"
|
||||||
|
|
||||||
|
# Create a function to make bidirectional symlinks
|
||||||
|
create_module_symlinks() {
|
||||||
|
if [ -d "$MODULE_DIR" ]; then
|
||||||
|
# Create symlinks for hyphenated modules
|
||||||
|
for module in "$MODULE_DIR"/*-*.js; do
|
||||||
|
if [ -f "$module" ]; then
|
||||||
|
# Convert hyphenated to camelCase
|
||||||
|
BASE_NAME=$(basename "$module")
|
||||||
|
CAMEL_NAME=$(echo "$BASE_NAME" | sed -E 's/-([a-z])/\U\1/g')
|
||||||
|
|
||||||
|
# Create camelCase symlink if needed
|
||||||
|
if [ ! -f "$MODULE_DIR/$CAMEL_NAME" ]; then
|
||||||
|
ln -sf "$BASE_NAME" "$MODULE_DIR/$CAMEL_NAME"
|
||||||
|
echo "Created symlink: $CAMEL_NAME -> $BASE_NAME"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create extension-less symlink for both versions
|
||||||
|
NO_EXT_BASE="${BASE_NAME%.js}"
|
||||||
|
if [ ! -f "$MODULE_DIR/$NO_EXT_BASE" ]; then
|
||||||
|
ln -sf "$BASE_NAME" "$MODULE_DIR/$NO_EXT_BASE"
|
||||||
|
echo "Created symlink: $NO_EXT_BASE -> $BASE_NAME"
|
||||||
|
fi
|
||||||
|
|
||||||
|
NO_EXT_CAMEL="${CAMEL_NAME%.js}"
|
||||||
|
if [ ! -f "$MODULE_DIR/$NO_EXT_CAMEL" ]; then
|
||||||
|
ln -sf "$BASE_NAME" "$MODULE_DIR/$NO_EXT_CAMEL"
|
||||||
|
echo "Created symlink: $NO_EXT_CAMEL -> $BASE_NAME"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Create symlinks for camelCase modules
|
||||||
|
for module in "$MODULE_DIR"/[a-z]*[A-Z]*.js; do
|
||||||
|
if [ -f "$module" ]; then
|
||||||
|
# Convert camelCase to hyphenated
|
||||||
|
BASE_NAME=$(basename "$module")
|
||||||
|
HYPHEN_NAME=$(echo "$BASE_NAME" | sed -E 's/([a-z])([A-Z])/\1-\L\2/g')
|
||||||
|
|
||||||
|
# Create hyphenated symlink if needed
|
||||||
|
if [ ! -f "$MODULE_DIR/$HYPHEN_NAME" ]; then
|
||||||
|
ln -sf "$BASE_NAME" "$MODULE_DIR/$HYPHEN_NAME"
|
||||||
|
echo "Created symlink: $HYPHEN_NAME -> $BASE_NAME"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create extension-less symlink for both versions
|
||||||
|
NO_EXT_BASE="${BASE_NAME%.js}"
|
||||||
|
if [ ! -f "$MODULE_DIR/$NO_EXT_BASE" ]; then
|
||||||
|
ln -sf "$BASE_NAME" "$MODULE_DIR/$NO_EXT_BASE"
|
||||||
|
echo "Created symlink: $NO_EXT_BASE -> $BASE_NAME"
|
||||||
|
fi
|
||||||
|
|
||||||
|
NO_EXT_HYPHEN="${HYPHEN_NAME%.js}"
|
||||||
|
if [ ! -f "$MODULE_DIR/$NO_EXT_HYPHEN" ]; then
|
||||||
|
ln -sf "$BASE_NAME" "$MODULE_DIR/$NO_EXT_HYPHEN"
|
||||||
|
echo "Created symlink: $NO_EXT_HYPHEN -> $BASE_NAME"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
else
|
||||||
|
echo "Warning: Module directory not found at $MODULE_DIR"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Execute the symlink creation function
|
||||||
|
create_module_symlinks
|
||||||
|
|
||||||
|
# Start the application
|
||||||
|
cd "$APP_DIR" || { echo "Failed to change to application directory"; exit 1; }
|
||||||
|
echo "Starting node.js application with: $NODE_PATH $APP_DIR/server.js"
|
||||||
|
exec "$NODE_PATH" "$APP_DIR/server.js"
|
89
scripts/update.sh
Executable file
89
scripts/update.sh
Executable file
@ -0,0 +1,89 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Transmission RSS Manager - Update Script
|
||||||
|
# This script pulls the latest version from git and runs necessary updates
|
||||||
|
|
||||||
|
# Color and formatting
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
RED='\033[0;31m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
BOLD='\033[1m'
|
||||||
|
|
||||||
|
# Installation directory (should be current directory)
|
||||||
|
INSTALL_DIR=$(pwd)
|
||||||
|
|
||||||
|
# Check if we're in the right directory
|
||||||
|
if [ ! -f "$INSTALL_DIR/package.json" ] || [ ! -d "$INSTALL_DIR/modules" ]; then
|
||||||
|
echo -e "${RED}Error: This script must be run from the installation directory.${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Get the current version
|
||||||
|
CURRENT_VERSION=$(grep -oP '"version": "\K[^"]+' package.json)
|
||||||
|
echo -e "${YELLOW}Current version: ${BOLD}$CURRENT_VERSION${NC}"
|
||||||
|
|
||||||
|
# Check for git repository
|
||||||
|
if [ ! -d ".git" ]; then
|
||||||
|
echo -e "${RED}Error: This installation was not set up using git.${NC}"
|
||||||
|
echo -e "Please use the bootstrap installer to perform a fresh installation."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Stash any local changes
|
||||||
|
echo -e "${YELLOW}Backing up any local configuration changes...${NC}"
|
||||||
|
git stash -q
|
||||||
|
|
||||||
|
# Pull the latest changes
|
||||||
|
echo -e "${YELLOW}Pulling latest updates from git...${NC}"
|
||||||
|
git pull
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo -e "${RED}Failed to pull updates. Restoring original state...${NC}"
|
||||||
|
git stash pop -q
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Get the new version
|
||||||
|
NEW_VERSION=$(grep -oP '"version": "\K[^"]+' package.json)
|
||||||
|
echo -e "${GREEN}New version: ${BOLD}$NEW_VERSION${NC}"
|
||||||
|
|
||||||
|
# Check if update is needed
|
||||||
|
if [ "$CURRENT_VERSION" == "$NEW_VERSION" ]; then
|
||||||
|
echo -e "${GREEN}You already have the latest version.${NC}"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Install any new npm dependencies
|
||||||
|
echo -e "${YELLOW}Installing dependencies...${NC}"
|
||||||
|
npm install
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo -e "${RED}Failed to install npm dependencies. Update aborted.${NC}"
|
||||||
|
echo -e "Please check the error messages above and try again."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Apply any local configuration changes
|
||||||
|
if git stash list | grep -q "stash@{0}"; then
|
||||||
|
echo -e "${YELLOW}Restoring local configuration changes...${NC}"
|
||||||
|
git stash pop -q
|
||||||
|
# Handle conflicts if any
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo -e "${RED}There were conflicts when restoring your configuration.${NC}"
|
||||||
|
echo -e "Please check the files and resolve conflicts manually."
|
||||||
|
echo -e "Your original configuration is saved in .git/refs/stash"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Restart the service
|
||||||
|
echo -e "${YELLOW}Restarting service...${NC}"
|
||||||
|
if command -v systemctl &> /dev/null; then
|
||||||
|
sudo systemctl restart transmission-rss-manager
|
||||||
|
else
|
||||||
|
echo -e "${RED}Could not restart service automatically.${NC}"
|
||||||
|
echo -e "Please restart the service manually."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Update complete
|
||||||
|
echo -e "${GREEN}${BOLD}Update complete!${NC}"
|
||||||
|
echo -e "Updated from version $CURRENT_VERSION to $NEW_VERSION"
|
||||||
|
echo -e "Changes will take effect immediately."
|
167
server-endpoints.js
Normal file
167
server-endpoints.js
Normal file
@ -0,0 +1,167 @@
|
|||||||
|
// Version and update endpoints for server.js
|
||||||
|
|
||||||
|
// Add these imports at the top of server.js
|
||||||
|
const { exec } = require('child_process');
|
||||||
|
const { promisify } = require('util');
|
||||||
|
const execAsync = promisify(exec);
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const semver = require('semver'); // Add this - for semantic version comparison
|
||||||
|
|
||||||
|
// Add these endpoints
|
||||||
|
|
||||||
|
// Get system status including version and uptime
|
||||||
|
app.get('/api/system/status', async (req, res) => {
|
||||||
|
try {
|
||||||
|
// Get package.json for version info
|
||||||
|
const packageJson = JSON.parse(fs.readFileSync(path.join(__dirname, 'package.json'), 'utf8'));
|
||||||
|
const version = packageJson.version;
|
||||||
|
|
||||||
|
// Get system uptime
|
||||||
|
const uptimeSeconds = Math.floor(process.uptime());
|
||||||
|
const hours = Math.floor(uptimeSeconds / 3600);
|
||||||
|
const minutes = Math.floor((uptimeSeconds % 3600) / 60);
|
||||||
|
const seconds = uptimeSeconds % 60;
|
||||||
|
const uptime = `${hours}h ${minutes}m ${seconds}s`;
|
||||||
|
|
||||||
|
// Check transmission connection
|
||||||
|
let transmissionStatus = 'Connected';
|
||||||
|
try {
|
||||||
|
await transmissionClient.sessionGet();
|
||||||
|
} catch (err) {
|
||||||
|
transmissionStatus = 'Disconnected';
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
status: 'success',
|
||||||
|
data: {
|
||||||
|
version,
|
||||||
|
uptime,
|
||||||
|
transmissionStatus
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting system status:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
status: 'error',
|
||||||
|
message: 'Failed to get system status'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check for updates
|
||||||
|
app.get('/api/system/check-updates', async (req, res) => {
|
||||||
|
try {
|
||||||
|
// Check if git is available and if this is a git repository
|
||||||
|
const isGitRepo = fs.existsSync(path.join(__dirname, '.git'));
|
||||||
|
if (!isGitRepo) {
|
||||||
|
return res.json({
|
||||||
|
status: 'error',
|
||||||
|
message: 'This installation is not set up for updates. Please use the bootstrap installer.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current version from the global APP_VERSION constant
|
||||||
|
const currentVersion = APP_VERSION;
|
||||||
|
|
||||||
|
// Check for test mode flag which forces update availability for testing
|
||||||
|
const testMode = req.query.test === 'true';
|
||||||
|
|
||||||
|
if (testMode) {
|
||||||
|
// In test mode, always return that an update is available
|
||||||
|
return res.json({
|
||||||
|
status: 'success',
|
||||||
|
data: {
|
||||||
|
updateAvailable: true,
|
||||||
|
currentVersion,
|
||||||
|
remoteVersion: '2.1.0-test',
|
||||||
|
commitsBehind: 1,
|
||||||
|
testMode: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normal mode - fetch latest updates without applying them
|
||||||
|
await execAsync('git fetch');
|
||||||
|
|
||||||
|
// Check if we're behind the remote repository
|
||||||
|
const { stdout } = await execAsync('git rev-list HEAD..origin/main --count');
|
||||||
|
const behindCount = parseInt(stdout.trim());
|
||||||
|
|
||||||
|
if (behindCount > 0) {
|
||||||
|
// Get the new version from the remote package.json
|
||||||
|
const { stdout: remotePackageJson } = await execAsync('git show origin/main:package.json');
|
||||||
|
const remotePackage = JSON.parse(remotePackageJson);
|
||||||
|
const remoteVersion = remotePackage.version;
|
||||||
|
|
||||||
|
// Compare versions semantically - only consider it an update if remote version is higher
|
||||||
|
const isNewerVersion = semver.gt(remoteVersion, currentVersion);
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
status: 'success',
|
||||||
|
data: {
|
||||||
|
updateAvailable: isNewerVersion,
|
||||||
|
currentVersion,
|
||||||
|
remoteVersion,
|
||||||
|
commitsBehind: behindCount,
|
||||||
|
newerVersion: isNewerVersion
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
return res.json({
|
||||||
|
status: 'success',
|
||||||
|
data: {
|
||||||
|
updateAvailable: false,
|
||||||
|
currentVersion
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error checking for updates:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
status: 'error',
|
||||||
|
message: 'Failed to check for updates'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Apply updates
|
||||||
|
app.post('/api/system/update', async (req, res) => {
|
||||||
|
try {
|
||||||
|
// Check if git is available and if this is a git repository
|
||||||
|
const isGitRepo = fs.existsSync(path.join(__dirname, '.git'));
|
||||||
|
if (!isGitRepo) {
|
||||||
|
return res.status(400).json({
|
||||||
|
status: 'error',
|
||||||
|
message: 'This installation is not set up for updates. Please use the bootstrap installer.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run the update script
|
||||||
|
const updateScriptPath = path.join(__dirname, 'scripts', 'update.sh');
|
||||||
|
|
||||||
|
// Make sure the update script is executable
|
||||||
|
await execAsync(`chmod +x ${updateScriptPath}`);
|
||||||
|
|
||||||
|
// Execute the update script
|
||||||
|
const { stdout, stderr } = await execAsync(updateScriptPath);
|
||||||
|
|
||||||
|
// If we get here, the update was successful
|
||||||
|
// The service will be restarted by the update script
|
||||||
|
res.json({
|
||||||
|
status: 'success',
|
||||||
|
message: 'Update applied successfully. The service will restart.',
|
||||||
|
data: {
|
||||||
|
output: stdout,
|
||||||
|
errors: stderr
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error applying update:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
status: 'error',
|
||||||
|
message: 'Failed to apply update',
|
||||||
|
error: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
@ -1,63 +0,0 @@
|
|||||||
using System.Threading.Tasks;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using TransmissionRssManager.Core;
|
|
||||||
|
|
||||||
namespace TransmissionRssManager.Api.Controllers
|
|
||||||
{
|
|
||||||
[ApiController]
|
|
||||||
[Route("api/[controller]")]
|
|
||||||
public class ConfigController : ControllerBase
|
|
||||||
{
|
|
||||||
private readonly ILogger<ConfigController> _logger;
|
|
||||||
private readonly IConfigService _configService;
|
|
||||||
|
|
||||||
public ConfigController(
|
|
||||||
ILogger<ConfigController> logger,
|
|
||||||
IConfigService configService)
|
|
||||||
{
|
|
||||||
_logger = logger;
|
|
||||||
_configService = configService;
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpGet]
|
|
||||||
public IActionResult GetConfig()
|
|
||||||
{
|
|
||||||
var config = _configService.GetConfiguration();
|
|
||||||
|
|
||||||
// Create a sanitized config without sensitive information
|
|
||||||
var sanitizedConfig = new
|
|
||||||
{
|
|
||||||
transmission = new
|
|
||||||
{
|
|
||||||
host = config.Transmission.Host,
|
|
||||||
port = config.Transmission.Port,
|
|
||||||
useHttps = config.Transmission.UseHttps,
|
|
||||||
hasCredentials = !string.IsNullOrEmpty(config.Transmission.Username)
|
|
||||||
},
|
|
||||||
autoDownloadEnabled = config.AutoDownloadEnabled,
|
|
||||||
checkIntervalMinutes = config.CheckIntervalMinutes,
|
|
||||||
downloadDirectory = config.DownloadDirectory,
|
|
||||||
mediaLibraryPath = config.MediaLibraryPath,
|
|
||||||
postProcessing = config.PostProcessing
|
|
||||||
};
|
|
||||||
|
|
||||||
return Ok(sanitizedConfig);
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpPut]
|
|
||||||
public async Task<IActionResult> UpdateConfig([FromBody] AppConfig config)
|
|
||||||
{
|
|
||||||
var currentConfig = _configService.GetConfiguration();
|
|
||||||
|
|
||||||
// If password is empty, keep the existing one
|
|
||||||
if (string.IsNullOrEmpty(config.Transmission.Password) && !string.IsNullOrEmpty(currentConfig.Transmission.Password))
|
|
||||||
{
|
|
||||||
config.Transmission.Password = currentConfig.Transmission.Password;
|
|
||||||
}
|
|
||||||
|
|
||||||
await _configService.SaveConfigurationAsync(config);
|
|
||||||
return Ok(new { success = true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,84 +0,0 @@
|
|||||||
using System.Threading.Tasks;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using TransmissionRssManager.Core;
|
|
||||||
|
|
||||||
namespace TransmissionRssManager.Api.Controllers
|
|
||||||
{
|
|
||||||
[ApiController]
|
|
||||||
[Route("api/[controller]")]
|
|
||||||
public class FeedsController : ControllerBase
|
|
||||||
{
|
|
||||||
private readonly ILogger<FeedsController> _logger;
|
|
||||||
private readonly IRssFeedManager _rssFeedManager;
|
|
||||||
|
|
||||||
public FeedsController(
|
|
||||||
ILogger<FeedsController> logger,
|
|
||||||
IRssFeedManager rssFeedManager)
|
|
||||||
{
|
|
||||||
_logger = logger;
|
|
||||||
_rssFeedManager = rssFeedManager;
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpGet]
|
|
||||||
public async Task<IActionResult> GetFeeds()
|
|
||||||
{
|
|
||||||
var feeds = await _rssFeedManager.GetFeedsAsync();
|
|
||||||
return Ok(feeds);
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpGet("items")]
|
|
||||||
public async Task<IActionResult> GetAllItems()
|
|
||||||
{
|
|
||||||
var items = await _rssFeedManager.GetAllItemsAsync();
|
|
||||||
return Ok(items);
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpGet("matched")]
|
|
||||||
public async Task<IActionResult> GetMatchedItems()
|
|
||||||
{
|
|
||||||
var items = await _rssFeedManager.GetMatchedItemsAsync();
|
|
||||||
return Ok(items);
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpPost]
|
|
||||||
public async Task<IActionResult> AddFeed([FromBody] RssFeed feed)
|
|
||||||
{
|
|
||||||
await _rssFeedManager.AddFeedAsync(feed);
|
|
||||||
return Ok(feed);
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpPut("{id}")]
|
|
||||||
public async Task<IActionResult> UpdateFeed(string id, [FromBody] RssFeed feed)
|
|
||||||
{
|
|
||||||
if (id != feed.Id)
|
|
||||||
{
|
|
||||||
return BadRequest("Feed ID mismatch");
|
|
||||||
}
|
|
||||||
|
|
||||||
await _rssFeedManager.UpdateFeedAsync(feed);
|
|
||||||
return Ok(feed);
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpDelete("{id}")]
|
|
||||||
public async Task<IActionResult> DeleteFeed(string id)
|
|
||||||
{
|
|
||||||
await _rssFeedManager.RemoveFeedAsync(id);
|
|
||||||
return Ok();
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpPost("refresh")]
|
|
||||||
public async Task<IActionResult> RefreshFeeds()
|
|
||||||
{
|
|
||||||
await _rssFeedManager.RefreshFeedsAsync(HttpContext.RequestAborted);
|
|
||||||
return Ok(new { success = true });
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpPost("download/{id}")]
|
|
||||||
public async Task<IActionResult> DownloadItem(string id)
|
|
||||||
{
|
|
||||||
await _rssFeedManager.MarkItemAsDownloadedAsync(id);
|
|
||||||
return Ok(new { success = true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,89 +0,0 @@
|
|||||||
using System.Threading.Tasks;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using TransmissionRssManager.Core;
|
|
||||||
using TransmissionRssManager.Services;
|
|
||||||
|
|
||||||
namespace TransmissionRssManager.Api.Controllers
|
|
||||||
{
|
|
||||||
[ApiController]
|
|
||||||
[Route("api/[controller]")]
|
|
||||||
public class TorrentsController : ControllerBase
|
|
||||||
{
|
|
||||||
private readonly ILogger<TorrentsController> _logger;
|
|
||||||
private readonly ITransmissionClient _transmissionClient;
|
|
||||||
private readonly IConfigService _configService;
|
|
||||||
private readonly IPostProcessor _postProcessor;
|
|
||||||
|
|
||||||
public TorrentsController(
|
|
||||||
ILogger<TorrentsController> logger,
|
|
||||||
ITransmissionClient transmissionClient,
|
|
||||||
IConfigService configService,
|
|
||||||
IPostProcessor postProcessor)
|
|
||||||
{
|
|
||||||
_logger = logger;
|
|
||||||
_transmissionClient = transmissionClient;
|
|
||||||
_configService = configService;
|
|
||||||
_postProcessor = postProcessor;
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpGet]
|
|
||||||
public async Task<IActionResult> GetTorrents()
|
|
||||||
{
|
|
||||||
var torrents = await _transmissionClient.GetTorrentsAsync();
|
|
||||||
return Ok(torrents);
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpPost]
|
|
||||||
public async Task<IActionResult> AddTorrent([FromBody] AddTorrentRequest request)
|
|
||||||
{
|
|
||||||
var config = _configService.GetConfiguration();
|
|
||||||
string downloadDir = request.DownloadDir ?? config.DownloadDirectory;
|
|
||||||
|
|
||||||
var torrentId = await _transmissionClient.AddTorrentAsync(request.Url, downloadDir);
|
|
||||||
return Ok(new { id = torrentId });
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpDelete("{id}")]
|
|
||||||
public async Task<IActionResult> RemoveTorrent(int id, [FromQuery] bool deleteLocalData = false)
|
|
||||||
{
|
|
||||||
await _transmissionClient.RemoveTorrentAsync(id, deleteLocalData);
|
|
||||||
return Ok();
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpPost("{id}/start")]
|
|
||||||
public async Task<IActionResult> StartTorrent(int id)
|
|
||||||
{
|
|
||||||
await _transmissionClient.StartTorrentAsync(id);
|
|
||||||
return Ok();
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpPost("{id}/stop")]
|
|
||||||
public async Task<IActionResult> StopTorrent(int id)
|
|
||||||
{
|
|
||||||
await _transmissionClient.StopTorrentAsync(id);
|
|
||||||
return Ok();
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpPost("{id}/process")]
|
|
||||||
public async Task<IActionResult> ProcessTorrent(int id)
|
|
||||||
{
|
|
||||||
var torrents = await _transmissionClient.GetTorrentsAsync();
|
|
||||||
var torrent = torrents.Find(t => t.Id == id);
|
|
||||||
|
|
||||||
if (torrent == null)
|
|
||||||
{
|
|
||||||
return NotFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
await _postProcessor.ProcessTorrentAsync(torrent);
|
|
||||||
return Ok();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public class AddTorrentRequest
|
|
||||||
{
|
|
||||||
public string Url { get; set; }
|
|
||||||
public string DownloadDir { get; set; }
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,38 +0,0 @@
|
|||||||
using Microsoft.AspNetCore.Builder;
|
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
|
||||||
using Microsoft.Extensions.Hosting;
|
|
||||||
using TransmissionRssManager.Core;
|
|
||||||
using TransmissionRssManager.Services;
|
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
|
||||||
|
|
||||||
// Add services to the container
|
|
||||||
builder.Services.AddControllers();
|
|
||||||
builder.Services.AddEndpointsApiExplorer();
|
|
||||||
builder.Services.AddSwaggerGen();
|
|
||||||
|
|
||||||
// Add custom services
|
|
||||||
builder.Services.AddSingleton<IConfigService, ConfigService>();
|
|
||||||
builder.Services.AddSingleton<ITransmissionClient, TransmissionClient>();
|
|
||||||
builder.Services.AddSingleton<IRssFeedManager, RssFeedManager>();
|
|
||||||
builder.Services.AddSingleton<IPostProcessor, PostProcessor>();
|
|
||||||
|
|
||||||
// Add background services
|
|
||||||
builder.Services.AddHostedService<RssFeedBackgroundService>();
|
|
||||||
builder.Services.AddHostedService<PostProcessingBackgroundService>();
|
|
||||||
|
|
||||||
var app = builder.Build();
|
|
||||||
|
|
||||||
// Configure middleware
|
|
||||||
if (app.Environment.IsDevelopment())
|
|
||||||
{
|
|
||||||
app.UseSwagger();
|
|
||||||
app.UseSwaggerUI();
|
|
||||||
}
|
|
||||||
|
|
||||||
app.UseStaticFiles();
|
|
||||||
app.UseRouting();
|
|
||||||
app.UseAuthorization();
|
|
||||||
app.MapControllers();
|
|
||||||
|
|
||||||
app.Run();
|
|
@ -1,104 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
|
|
||||||
namespace TransmissionRssManager.Core
|
|
||||||
{
|
|
||||||
public class RssFeedItem
|
|
||||||
{
|
|
||||||
public string Id { get; set; }
|
|
||||||
public string Title { get; set; }
|
|
||||||
public string Link { get; set; }
|
|
||||||
public string Description { get; set; }
|
|
||||||
public DateTime PublishDate { get; set; }
|
|
||||||
public string TorrentUrl { get; set; }
|
|
||||||
public bool IsDownloaded { get; set; }
|
|
||||||
public bool IsMatched { get; set; }
|
|
||||||
public string MatchedRule { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
public class TorrentInfo
|
|
||||||
{
|
|
||||||
public int Id { get; set; }
|
|
||||||
public string Name { get; set; }
|
|
||||||
public string Status { get; set; }
|
|
||||||
public double PercentDone { get; set; }
|
|
||||||
public long TotalSize { get; set; }
|
|
||||||
public string DownloadDir { get; set; }
|
|
||||||
public bool IsFinished => PercentDone >= 1.0;
|
|
||||||
}
|
|
||||||
|
|
||||||
public class RssFeed
|
|
||||||
{
|
|
||||||
public string Id { get; set; }
|
|
||||||
public string Url { get; set; }
|
|
||||||
public string Name { get; set; }
|
|
||||||
public List<string> Rules { get; set; } = new List<string>();
|
|
||||||
public bool AutoDownload { get; set; }
|
|
||||||
public DateTime LastChecked { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
public class AppConfig
|
|
||||||
{
|
|
||||||
public TransmissionConfig Transmission { get; set; } = new TransmissionConfig();
|
|
||||||
public List<RssFeed> Feeds { get; set; } = new List<RssFeed>();
|
|
||||||
public bool AutoDownloadEnabled { get; set; }
|
|
||||||
public int CheckIntervalMinutes { get; set; } = 30;
|
|
||||||
public string DownloadDirectory { get; set; }
|
|
||||||
public string MediaLibraryPath { get; set; }
|
|
||||||
public PostProcessingConfig PostProcessing { get; set; } = new PostProcessingConfig();
|
|
||||||
}
|
|
||||||
|
|
||||||
public class TransmissionConfig
|
|
||||||
{
|
|
||||||
public string Host { get; set; } = "localhost";
|
|
||||||
public int Port { get; set; } = 9091;
|
|
||||||
public string Username { get; set; }
|
|
||||||
public string Password { get; set; }
|
|
||||||
public bool UseHttps { get; set; } = false;
|
|
||||||
public string Url => $"{(UseHttps ? "https" : "http")}://{Host}:{Port}/transmission/rpc";
|
|
||||||
}
|
|
||||||
|
|
||||||
public class PostProcessingConfig
|
|
||||||
{
|
|
||||||
public bool Enabled { get; set; } = false;
|
|
||||||
public bool ExtractArchives { get; set; } = true;
|
|
||||||
public bool OrganizeMedia { get; set; } = true;
|
|
||||||
public int MinimumSeedRatio { get; set; } = 1;
|
|
||||||
public List<string> MediaExtensions { get; set; } = new List<string> { ".mp4", ".mkv", ".avi" };
|
|
||||||
}
|
|
||||||
|
|
||||||
public interface IConfigService
|
|
||||||
{
|
|
||||||
AppConfig GetConfiguration();
|
|
||||||
Task SaveConfigurationAsync(AppConfig config);
|
|
||||||
}
|
|
||||||
|
|
||||||
public interface ITransmissionClient
|
|
||||||
{
|
|
||||||
Task<List<TorrentInfo>> GetTorrentsAsync();
|
|
||||||
Task<int> AddTorrentAsync(string torrentUrl, string downloadDir);
|
|
||||||
Task RemoveTorrentAsync(int id, bool deleteLocalData);
|
|
||||||
Task StartTorrentAsync(int id);
|
|
||||||
Task StopTorrentAsync(int id);
|
|
||||||
}
|
|
||||||
|
|
||||||
public interface IRssFeedManager
|
|
||||||
{
|
|
||||||
Task<List<RssFeedItem>> GetAllItemsAsync();
|
|
||||||
Task<List<RssFeedItem>> GetMatchedItemsAsync();
|
|
||||||
Task<List<RssFeed>> GetFeedsAsync();
|
|
||||||
Task AddFeedAsync(RssFeed feed);
|
|
||||||
Task RemoveFeedAsync(string feedId);
|
|
||||||
Task UpdateFeedAsync(RssFeed feed);
|
|
||||||
Task RefreshFeedsAsync(CancellationToken cancellationToken);
|
|
||||||
Task MarkItemAsDownloadedAsync(string itemId);
|
|
||||||
}
|
|
||||||
|
|
||||||
public interface IPostProcessor
|
|
||||||
{
|
|
||||||
Task ProcessCompletedDownloadsAsync(CancellationToken cancellationToken);
|
|
||||||
Task ProcessTorrentAsync(TorrentInfo torrent);
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,212 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# TransmissionRssManager Installer Script for Linux
|
|
||||||
# This script installs the TransmissionRssManager application and its dependencies
|
|
||||||
|
|
||||||
# Colors for output
|
|
||||||
GREEN='\033[0;32m'
|
|
||||||
YELLOW='\033[1;33m'
|
|
||||||
RED='\033[0;31m'
|
|
||||||
NC='\033[0m' # No Color
|
|
||||||
|
|
||||||
# Error handling
|
|
||||||
set -e
|
|
||||||
trap 'echo -e "${RED}An error occurred. Installation failed.${NC}"; exit 1' ERR
|
|
||||||
|
|
||||||
# Check if script is run as root
|
|
||||||
if [ "$EUID" -eq 0 ]; then
|
|
||||||
echo -e "${YELLOW}Warning: It's recommended to run this script as a regular user with sudo privileges, not as root.${NC}"
|
|
||||||
read -p "Continue anyway? (y/n) " -n 1 -r
|
|
||||||
echo
|
|
||||||
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Detect Linux distribution
|
|
||||||
if [ -f /etc/os-release ]; then
|
|
||||||
. /etc/os-release
|
|
||||||
DISTRO=$ID
|
|
||||||
else
|
|
||||||
echo -e "${RED}Cannot detect Linux distribution. Exiting.${NC}"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo -e "${GREEN}Installing TransmissionRssManager on $PRETTY_NAME...${NC}"
|
|
||||||
|
|
||||||
# Install .NET SDK and runtime
|
|
||||||
install_dotnet() {
|
|
||||||
echo -e "${GREEN}Installing .NET SDK...${NC}"
|
|
||||||
|
|
||||||
case $DISTRO in
|
|
||||||
ubuntu|debian|linuxmint)
|
|
||||||
# Add Microsoft package repository
|
|
||||||
wget -O packages-microsoft-prod.deb https://packages.microsoft.com/config/$DISTRO/$VERSION_ID/packages-microsoft-prod.deb
|
|
||||||
sudo dpkg -i packages-microsoft-prod.deb
|
|
||||||
rm packages-microsoft-prod.deb
|
|
||||||
|
|
||||||
# Install .NET SDK
|
|
||||||
sudo apt-get update
|
|
||||||
sudo apt-get install -y apt-transport-https
|
|
||||||
sudo apt-get update
|
|
||||||
sudo apt-get install -y dotnet-sdk-7.0
|
|
||||||
;;
|
|
||||||
fedora|rhel|centos)
|
|
||||||
# Add Microsoft package repository
|
|
||||||
sudo rpm -Uvh https://packages.microsoft.com/config/centos/7/packages-microsoft-prod.rpm
|
|
||||||
|
|
||||||
# Install .NET SDK
|
|
||||||
sudo yum install -y dotnet-sdk-7.0
|
|
||||||
;;
|
|
||||||
opensuse*|sles)
|
|
||||||
# Install .NET SDK from zypper
|
|
||||||
sudo zypper install -y dotnet-sdk-7.0
|
|
||||||
;;
|
|
||||||
arch|manjaro)
|
|
||||||
# Install .NET SDK from pacman
|
|
||||||
sudo pacman -Sy dotnet-sdk aspnet-runtime --noconfirm
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
echo -e "${YELLOW}Unsupported distribution for automatic .NET installation.${NC}"
|
|
||||||
echo -e "${YELLOW}Please install .NET SDK 7.0 manually from https://dotnet.microsoft.com/download${NC}"
|
|
||||||
read -p "Press Enter to continue once .NET SDK is installed..."
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
# Verify .NET installation
|
|
||||||
dotnet --version
|
|
||||||
if [ $? -ne 0 ]; then
|
|
||||||
echo -e "${RED}.NET SDK installation failed. Please install .NET SDK 7.0 manually.${NC}"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# Install dependencies
|
|
||||||
install_dependencies() {
|
|
||||||
echo -e "${GREEN}Installing dependencies...${NC}"
|
|
||||||
|
|
||||||
case $DISTRO in
|
|
||||||
ubuntu|debian|linuxmint)
|
|
||||||
sudo apt-get update
|
|
||||||
sudo apt-get install -y unzip p7zip-full unrar-free libssl-dev zlib1g-dev libicu-dev build-essential
|
|
||||||
;;
|
|
||||||
fedora|rhel|centos)
|
|
||||||
sudo yum install -y unzip p7zip unrar openssl-devel zlib-devel libicu-devel gcc-c++ make
|
|
||||||
;;
|
|
||||||
opensuse*|sles)
|
|
||||||
sudo zypper install -y unzip p7zip unrar libopenssl-devel zlib-devel libicu-devel gcc-c++ make
|
|
||||||
;;
|
|
||||||
arch|manjaro)
|
|
||||||
sudo pacman -Sy unzip p7zip unrar openssl zlib icu gcc make --noconfirm
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
echo -e "${YELLOW}Unsupported distribution for automatic dependency installation.${NC}"
|
|
||||||
echo -e "${YELLOW}Please make sure the following are installed: unzip, p7zip, unrar, ssl, zlib, icu, gcc/g++ and make.${NC}"
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
# Install Entity Framework Core CLI tools if needed (version 7.x)
|
|
||||||
if ! command -v dotnet-ef &> /dev/null; then
|
|
||||||
echo -e "${GREEN}Installing Entity Framework Core tools compatible with .NET 7...${NC}"
|
|
||||||
dotnet tool install --global dotnet-ef --version 7.0.15
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# Check if .NET is already installed
|
|
||||||
if command -v dotnet >/dev/null 2>&1; then
|
|
||||||
dotnet_version=$(dotnet --version)
|
|
||||||
echo -e "${GREEN}.NET SDK version $dotnet_version is already installed.${NC}"
|
|
||||||
else
|
|
||||||
install_dotnet
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Install dependencies
|
|
||||||
install_dependencies
|
|
||||||
|
|
||||||
# Create installation directory
|
|
||||||
INSTALL_DIR="$HOME/.local/share/transmission-rss-manager"
|
|
||||||
mkdir -p "$INSTALL_DIR"
|
|
||||||
|
|
||||||
# Clone or download the application
|
|
||||||
echo -e "${GREEN}Downloading TransmissionRssManager...${NC}"
|
|
||||||
if [ -d "/opt/develop/transmission-rss-manager/TransmissionRssManager" ]; then
|
|
||||||
# We're running from the development directory
|
|
||||||
cp -r /opt/develop/transmission-rss-manager/TransmissionRssManager/* "$INSTALL_DIR/"
|
|
||||||
else
|
|
||||||
# Download and extract release
|
|
||||||
wget -O transmission-rss-manager.zip https://github.com/yourusername/transmission-rss-manager/releases/latest/download/transmission-rss-manager.zip
|
|
||||||
unzip transmission-rss-manager.zip -d "$INSTALL_DIR"
|
|
||||||
rm transmission-rss-manager.zip
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Install required NuGet packages (with versions compatible with .NET 7)
|
|
||||||
echo -e "${GREEN}Installing required NuGet packages...${NC}"
|
|
||||||
cd "$INSTALL_DIR"
|
|
||||||
dotnet add package Microsoft.AspNetCore.OpenApi --version 7.0.13
|
|
||||||
dotnet add package Swashbuckle.AspNetCore --version 6.5.0
|
|
||||||
dotnet add package System.ServiceModel.Syndication --version 7.0.0
|
|
||||||
|
|
||||||
# Build the application
|
|
||||||
echo -e "${GREEN}Building TransmissionRssManager...${NC}"
|
|
||||||
dotnet build -c Release
|
|
||||||
|
|
||||||
# Create configuration directory
|
|
||||||
CONFIG_DIR="$HOME/.config/transmission-rss-manager"
|
|
||||||
mkdir -p "$CONFIG_DIR"
|
|
||||||
|
|
||||||
# Create desktop entry
|
|
||||||
DESKTOP_FILE="$HOME/.local/share/applications/transmission-rss-manager.desktop"
|
|
||||||
echo "[Desktop Entry]
|
|
||||||
Name=Transmission RSS Manager
|
|
||||||
Comment=RSS Feed Manager for Transmission BitTorrent Client
|
|
||||||
Exec=dotnet $INSTALL_DIR/bin/Release/net7.0/TransmissionRssManager.dll
|
|
||||||
Icon=transmission
|
|
||||||
Terminal=false
|
|
||||||
Type=Application
|
|
||||||
Categories=Network;P2P;" > "$DESKTOP_FILE"
|
|
||||||
|
|
||||||
# Create systemd service for user
|
|
||||||
SERVICE_DIR="$HOME/.config/systemd/user"
|
|
||||||
mkdir -p "$SERVICE_DIR"
|
|
||||||
|
|
||||||
echo "[Unit]
|
|
||||||
Description=Transmission RSS Manager
|
|
||||||
After=network.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
ExecStart=dotnet $INSTALL_DIR/bin/Release/net7.0/TransmissionRssManager.dll
|
|
||||||
Restart=on-failure
|
|
||||||
RestartSec=10
|
|
||||||
SyslogIdentifier=transmission-rss-manager
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=default.target" > "$SERVICE_DIR/transmission-rss-manager.service"
|
|
||||||
|
|
||||||
# Reload systemd
|
|
||||||
systemctl --user daemon-reload
|
|
||||||
|
|
||||||
# Create launcher script
|
|
||||||
LAUNCHER="$HOME/.local/bin/transmission-rss-manager"
|
|
||||||
mkdir -p "$HOME/.local/bin"
|
|
||||||
|
|
||||||
echo "#!/bin/bash
|
|
||||||
dotnet $INSTALL_DIR/bin/Release/net7.0/TransmissionRssManager.dll" > "$LAUNCHER"
|
|
||||||
chmod +x "$LAUNCHER"
|
|
||||||
|
|
||||||
echo -e "${GREEN}Installation completed!${NC}"
|
|
||||||
echo -e "${GREEN}You can run TransmissionRssManager in these ways:${NC}"
|
|
||||||
echo -e " * Command: ${YELLOW}transmission-rss-manager${NC}"
|
|
||||||
echo -e " * Service: ${YELLOW}systemctl --user start transmission-rss-manager${NC}"
|
|
||||||
echo -e " * Enable service on startup: ${YELLOW}systemctl --user enable transmission-rss-manager${NC}"
|
|
||||||
echo -e " * Web interface will be available at: ${YELLOW}http://localhost:5000${NC}"
|
|
||||||
|
|
||||||
# Start the application
|
|
||||||
read -p "Do you want to start the application now? (y/n) " -n 1 -r
|
|
||||||
echo
|
|
||||||
if [[ $REPLY =~ ^[Yy]$ ]]; then
|
|
||||||
systemctl --user start transmission-rss-manager
|
|
||||||
echo -e "${GREEN}TransmissionRssManager service started.${NC}"
|
|
||||||
echo -e "${GREEN}Open http://localhost:5000 in your browser.${NC}"
|
|
||||||
else
|
|
||||||
echo -e "${YELLOW}You can start the application later using: systemctl --user start transmission-rss-manager${NC}"
|
|
||||||
fi
|
|
Binary file not shown.
@ -1,112 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.IO;
|
|
||||||
using System.Text.Json;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using TransmissionRssManager.Core;
|
|
||||||
|
|
||||||
namespace TransmissionRssManager.Services
|
|
||||||
{
|
|
||||||
public class ConfigService : IConfigService
|
|
||||||
{
|
|
||||||
private readonly ILogger<ConfigService> _logger;
|
|
||||||
private readonly string _configPath;
|
|
||||||
private AppConfig _cachedConfig;
|
|
||||||
|
|
||||||
public ConfigService(ILogger<ConfigService> logger)
|
|
||||||
{
|
|
||||||
_logger = logger;
|
|
||||||
|
|
||||||
// Get config directory
|
|
||||||
string homeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
|
||||||
string configDir = Path.Combine(homeDir, ".config", "transmission-rss-manager");
|
|
||||||
|
|
||||||
// Ensure directory exists
|
|
||||||
if (!Directory.Exists(configDir))
|
|
||||||
{
|
|
||||||
Directory.CreateDirectory(configDir);
|
|
||||||
}
|
|
||||||
|
|
||||||
_configPath = Path.Combine(configDir, "config.json");
|
|
||||||
_cachedConfig = LoadConfiguration();
|
|
||||||
}
|
|
||||||
|
|
||||||
public AppConfig GetConfiguration()
|
|
||||||
{
|
|
||||||
return _cachedConfig;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task SaveConfigurationAsync(AppConfig config)
|
|
||||||
{
|
|
||||||
_cachedConfig = config;
|
|
||||||
|
|
||||||
var options = new JsonSerializerOptions
|
|
||||||
{
|
|
||||||
WriteIndented = true
|
|
||||||
};
|
|
||||||
|
|
||||||
string json = JsonSerializer.Serialize(config, options);
|
|
||||||
await File.WriteAllTextAsync(_configPath, json);
|
|
||||||
|
|
||||||
_logger.LogInformation("Configuration saved successfully");
|
|
||||||
}
|
|
||||||
|
|
||||||
private AppConfig LoadConfiguration()
|
|
||||||
{
|
|
||||||
if (!File.Exists(_configPath))
|
|
||||||
{
|
|
||||||
_logger.LogInformation("No configuration file found, creating default");
|
|
||||||
var defaultConfig = CreateDefaultConfig();
|
|
||||||
SaveConfigurationAsync(defaultConfig).Wait();
|
|
||||||
return defaultConfig;
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
string json = File.ReadAllText(_configPath);
|
|
||||||
var config = JsonSerializer.Deserialize<AppConfig>(json);
|
|
||||||
|
|
||||||
if (config == null)
|
|
||||||
{
|
|
||||||
_logger.LogWarning("Failed to deserialize config, creating default");
|
|
||||||
return CreateDefaultConfig();
|
|
||||||
}
|
|
||||||
|
|
||||||
return config;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, "Error loading configuration");
|
|
||||||
return CreateDefaultConfig();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private AppConfig CreateDefaultConfig()
|
|
||||||
{
|
|
||||||
string homeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
|
||||||
|
|
||||||
return new AppConfig
|
|
||||||
{
|
|
||||||
Transmission = new TransmissionConfig
|
|
||||||
{
|
|
||||||
Host = "localhost",
|
|
||||||
Port = 9091,
|
|
||||||
Username = "",
|
|
||||||
Password = ""
|
|
||||||
},
|
|
||||||
AutoDownloadEnabled = false,
|
|
||||||
CheckIntervalMinutes = 30,
|
|
||||||
DownloadDirectory = Path.Combine(homeDir, "Downloads"),
|
|
||||||
MediaLibraryPath = Path.Combine(homeDir, "Media"),
|
|
||||||
PostProcessing = new PostProcessingConfig
|
|
||||||
{
|
|
||||||
Enabled = false,
|
|
||||||
ExtractArchives = true,
|
|
||||||
OrganizeMedia = true,
|
|
||||||
MinimumSeedRatio = 1,
|
|
||||||
MediaExtensions = new System.Collections.Generic.List<string> { ".mp4", ".mkv", ".avi" }
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,272 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Diagnostics;
|
|
||||||
using System.IO;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using Microsoft.Extensions.Hosting;
|
|
||||||
using TransmissionRssManager.Core;
|
|
||||||
|
|
||||||
namespace TransmissionRssManager.Services
|
|
||||||
{
|
|
||||||
public class PostProcessor : IPostProcessor
|
|
||||||
{
|
|
||||||
private readonly ILogger<PostProcessor> _logger;
|
|
||||||
private readonly IConfigService _configService;
|
|
||||||
private readonly ITransmissionClient _transmissionClient;
|
|
||||||
|
|
||||||
public PostProcessor(
|
|
||||||
ILogger<PostProcessor> logger,
|
|
||||||
IConfigService configService,
|
|
||||||
ITransmissionClient transmissionClient)
|
|
||||||
{
|
|
||||||
_logger = logger;
|
|
||||||
_configService = configService;
|
|
||||||
_transmissionClient = transmissionClient;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task ProcessCompletedDownloadsAsync(CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var config = _configService.GetConfiguration();
|
|
||||||
|
|
||||||
if (!config.PostProcessing.Enabled)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_logger.LogInformation("Processing completed downloads");
|
|
||||||
|
|
||||||
var torrents = await _transmissionClient.GetTorrentsAsync();
|
|
||||||
var completedTorrents = torrents.Where(t => t.IsFinished).ToList();
|
|
||||||
|
|
||||||
foreach (var torrent in completedTorrents)
|
|
||||||
{
|
|
||||||
if (cancellationToken.IsCancellationRequested)
|
|
||||||
{
|
|
||||||
_logger.LogInformation("Post-processing cancelled");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await ProcessTorrentAsync(torrent);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, $"Error processing torrent: {torrent.Name}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task ProcessTorrentAsync(TorrentInfo torrent)
|
|
||||||
{
|
|
||||||
_logger.LogInformation($"Processing completed torrent: {torrent.Name}");
|
|
||||||
|
|
||||||
var config = _configService.GetConfiguration();
|
|
||||||
var downloadDir = torrent.DownloadDir;
|
|
||||||
var torrentPath = Path.Combine(downloadDir, torrent.Name);
|
|
||||||
|
|
||||||
// Check if the file/directory exists
|
|
||||||
if (!Directory.Exists(torrentPath) && !File.Exists(torrentPath))
|
|
||||||
{
|
|
||||||
_logger.LogWarning($"Downloaded path not found: {torrentPath}");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle archives if enabled
|
|
||||||
if (config.PostProcessing.ExtractArchives && IsArchive(torrentPath))
|
|
||||||
{
|
|
||||||
await ExtractArchiveAsync(torrentPath, downloadDir);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Organize media files if enabled
|
|
||||||
if (config.PostProcessing.OrganizeMedia)
|
|
||||||
{
|
|
||||||
await OrganizeMediaAsync(torrentPath, config.MediaLibraryPath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private bool IsArchive(string path)
|
|
||||||
{
|
|
||||||
if (!File.Exists(path))
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
var extension = Path.GetExtension(path).ToLowerInvariant();
|
|
||||||
return extension == ".rar" || extension == ".zip" || extension == ".7z";
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task ExtractArchiveAsync(string archivePath, string outputDir)
|
|
||||||
{
|
|
||||||
_logger.LogInformation($"Extracting archive: {archivePath}");
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var extension = Path.GetExtension(archivePath).ToLowerInvariant();
|
|
||||||
var extractDir = Path.Combine(outputDir, Path.GetFileNameWithoutExtension(archivePath));
|
|
||||||
|
|
||||||
// Create extraction directory if it doesn't exist
|
|
||||||
if (!Directory.Exists(extractDir))
|
|
||||||
{
|
|
||||||
Directory.CreateDirectory(extractDir);
|
|
||||||
}
|
|
||||||
|
|
||||||
var processStartInfo = new ProcessStartInfo
|
|
||||||
{
|
|
||||||
FileName = extension switch
|
|
||||||
{
|
|
||||||
".rar" => "unrar",
|
|
||||||
".zip" => "unzip",
|
|
||||||
".7z" => "7z",
|
|
||||||
_ => throw new Exception($"Unsupported archive format: {extension}")
|
|
||||||
},
|
|
||||||
Arguments = extension switch
|
|
||||||
{
|
|
||||||
".rar" => $"x -o+ \"{archivePath}\" \"{extractDir}\"",
|
|
||||||
".zip" => $"-o \"{archivePath}\" -d \"{extractDir}\"",
|
|
||||||
".7z" => $"x \"{archivePath}\" -o\"{extractDir}\"",
|
|
||||||
_ => throw new Exception($"Unsupported archive format: {extension}")
|
|
||||||
},
|
|
||||||
UseShellExecute = false,
|
|
||||||
RedirectStandardOutput = true,
|
|
||||||
RedirectStandardError = true,
|
|
||||||
CreateNoWindow = true
|
|
||||||
};
|
|
||||||
|
|
||||||
using var process = new Process
|
|
||||||
{
|
|
||||||
StartInfo = processStartInfo
|
|
||||||
};
|
|
||||||
|
|
||||||
process.Start();
|
|
||||||
await process.WaitForExitAsync();
|
|
||||||
|
|
||||||
if (process.ExitCode != 0)
|
|
||||||
{
|
|
||||||
var error = await process.StandardError.ReadToEndAsync();
|
|
||||||
_logger.LogError($"Error extracting archive: {error}");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_logger.LogInformation($"Archive extracted to: {extractDir}");
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, $"Error extracting archive: {archivePath}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task OrganizeMediaAsync(string path, string mediaLibraryPath)
|
|
||||||
{
|
|
||||||
_logger.LogInformation($"Organizing media: {path}");
|
|
||||||
|
|
||||||
var config = _configService.GetConfiguration();
|
|
||||||
var mediaExtensions = config.PostProcessing.MediaExtensions;
|
|
||||||
|
|
||||||
// Ensure media library path exists
|
|
||||||
if (!Directory.Exists(mediaLibraryPath))
|
|
||||||
{
|
|
||||||
Directory.CreateDirectory(mediaLibraryPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
if (File.Exists(path))
|
|
||||||
{
|
|
||||||
// Single file
|
|
||||||
var extension = Path.GetExtension(path).ToLowerInvariant();
|
|
||||||
if (mediaExtensions.Contains(extension))
|
|
||||||
{
|
|
||||||
await CopyFileToMediaLibraryAsync(path, mediaLibraryPath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else if (Directory.Exists(path))
|
|
||||||
{
|
|
||||||
// Directory - find all media files recursively
|
|
||||||
var mediaFiles = Directory.GetFiles(path, "*.*", SearchOption.AllDirectories)
|
|
||||||
.Where(f => mediaExtensions.Contains(Path.GetExtension(f).ToLowerInvariant()))
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
foreach (var mediaFile in mediaFiles)
|
|
||||||
{
|
|
||||||
await CopyFileToMediaLibraryAsync(mediaFile, mediaLibraryPath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, $"Error organizing media: {path}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task CopyFileToMediaLibraryAsync(string filePath, string mediaLibraryPath)
|
|
||||||
{
|
|
||||||
var fileName = Path.GetFileName(filePath);
|
|
||||||
var destinationPath = Path.Combine(mediaLibraryPath, fileName);
|
|
||||||
|
|
||||||
// If destination file already exists, add a unique identifier
|
|
||||||
if (File.Exists(destinationPath))
|
|
||||||
{
|
|
||||||
var fileNameWithoutExt = Path.GetFileNameWithoutExtension(fileName);
|
|
||||||
var extension = Path.GetExtension(fileName);
|
|
||||||
var uniqueId = Guid.NewGuid().ToString().Substring(0, 8);
|
|
||||||
|
|
||||||
destinationPath = Path.Combine(mediaLibraryPath, $"{fileNameWithoutExt}_{uniqueId}{extension}");
|
|
||||||
}
|
|
||||||
|
|
||||||
_logger.LogInformation($"Copying media file to library: {destinationPath}");
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
using var sourceStream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read, 4096, true);
|
|
||||||
using var destinationStream = new FileStream(destinationPath, FileMode.Create, FileAccess.Write, FileShare.None, 4096, true);
|
|
||||||
|
|
||||||
await sourceStream.CopyToAsync(destinationStream);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, $"Error copying file to media library: {filePath}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public class PostProcessingBackgroundService : BackgroundService
|
|
||||||
{
|
|
||||||
private readonly ILogger<PostProcessingBackgroundService> _logger;
|
|
||||||
private readonly IPostProcessor _postProcessor;
|
|
||||||
private readonly IConfigService _configService;
|
|
||||||
|
|
||||||
public PostProcessingBackgroundService(
|
|
||||||
ILogger<PostProcessingBackgroundService> logger,
|
|
||||||
IPostProcessor postProcessor,
|
|
||||||
IConfigService configService)
|
|
||||||
{
|
|
||||||
_logger = logger;
|
|
||||||
_postProcessor = postProcessor;
|
|
||||||
_configService = configService;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
|
||||||
{
|
|
||||||
_logger.LogInformation("Post-processing background service started");
|
|
||||||
|
|
||||||
while (!stoppingToken.IsCancellationRequested)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await _postProcessor.ProcessCompletedDownloadsAsync(stoppingToken);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, "Error processing completed downloads");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check every 5 minutes
|
|
||||||
await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,350 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.IO;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Net.Http;
|
|
||||||
using System.ServiceModel.Syndication;
|
|
||||||
using System.Text.Json;
|
|
||||||
using System.Text.RegularExpressions;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using System.Xml;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using Microsoft.Extensions.Hosting;
|
|
||||||
using TransmissionRssManager.Core;
|
|
||||||
|
|
||||||
namespace TransmissionRssManager.Services
|
|
||||||
{
|
|
||||||
public class RssFeedManager : IRssFeedManager
|
|
||||||
{
|
|
||||||
private readonly ILogger<RssFeedManager> _logger;
|
|
||||||
private readonly IConfigService _configService;
|
|
||||||
private readonly ITransmissionClient _transmissionClient;
|
|
||||||
private readonly HttpClient _httpClient;
|
|
||||||
private readonly string _dataPath;
|
|
||||||
private List<RssFeedItem> _items = new List<RssFeedItem>();
|
|
||||||
|
|
||||||
public RssFeedManager(
|
|
||||||
ILogger<RssFeedManager> logger,
|
|
||||||
IConfigService configService,
|
|
||||||
ITransmissionClient transmissionClient)
|
|
||||||
{
|
|
||||||
_logger = logger;
|
|
||||||
_configService = configService;
|
|
||||||
_transmissionClient = transmissionClient;
|
|
||||||
_httpClient = new HttpClient();
|
|
||||||
|
|
||||||
// Create data directory
|
|
||||||
string homeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
|
||||||
string dataDir = Path.Combine(homeDir, ".local", "share", "transmission-rss-manager");
|
|
||||||
|
|
||||||
if (!Directory.Exists(dataDir))
|
|
||||||
{
|
|
||||||
Directory.CreateDirectory(dataDir);
|
|
||||||
}
|
|
||||||
|
|
||||||
_dataPath = Path.Combine(dataDir, "rss-items.json");
|
|
||||||
LoadItems();
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task<List<RssFeedItem>> GetAllItemsAsync()
|
|
||||||
{
|
|
||||||
return Task.FromResult(_items.OrderByDescending(i => i.PublishDate).ToList());
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task<List<RssFeedItem>> GetMatchedItemsAsync()
|
|
||||||
{
|
|
||||||
return Task.FromResult(_items.Where(i => i.IsMatched).OrderByDescending(i => i.PublishDate).ToList());
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task<List<RssFeed>> GetFeedsAsync()
|
|
||||||
{
|
|
||||||
var config = _configService.GetConfiguration();
|
|
||||||
return Task.FromResult(config.Feeds);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task AddFeedAsync(RssFeed feed)
|
|
||||||
{
|
|
||||||
feed.Id = Guid.NewGuid().ToString();
|
|
||||||
feed.LastChecked = DateTime.MinValue;
|
|
||||||
|
|
||||||
var config = _configService.GetConfiguration();
|
|
||||||
config.Feeds.Add(feed);
|
|
||||||
await _configService.SaveConfigurationAsync(config);
|
|
||||||
|
|
||||||
// Initial fetch of feed items
|
|
||||||
await FetchFeedAsync(feed);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task RemoveFeedAsync(string feedId)
|
|
||||||
{
|
|
||||||
var config = _configService.GetConfiguration();
|
|
||||||
var feed = config.Feeds.FirstOrDefault(f => f.Id == feedId);
|
|
||||||
|
|
||||||
if (feed != null)
|
|
||||||
{
|
|
||||||
config.Feeds.Remove(feed);
|
|
||||||
await _configService.SaveConfigurationAsync(config);
|
|
||||||
|
|
||||||
// Remove items from this feed
|
|
||||||
_items.RemoveAll(i => i.Id.StartsWith(feedId));
|
|
||||||
await SaveItemsAsync();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task UpdateFeedAsync(RssFeed feed)
|
|
||||||
{
|
|
||||||
var config = _configService.GetConfiguration();
|
|
||||||
var index = config.Feeds.FindIndex(f => f.Id == feed.Id);
|
|
||||||
|
|
||||||
if (index != -1)
|
|
||||||
{
|
|
||||||
config.Feeds[index] = feed;
|
|
||||||
await _configService.SaveConfigurationAsync(config);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task RefreshFeedsAsync(CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
_logger.LogInformation("Starting RSS feed refresh");
|
|
||||||
var config = _configService.GetConfiguration();
|
|
||||||
|
|
||||||
foreach (var feed in config.Feeds)
|
|
||||||
{
|
|
||||||
if (cancellationToken.IsCancellationRequested)
|
|
||||||
{
|
|
||||||
_logger.LogInformation("RSS refresh cancelled");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await FetchFeedAsync(feed);
|
|
||||||
|
|
||||||
// Update last checked time
|
|
||||||
feed.LastChecked = DateTime.Now;
|
|
||||||
await _configService.SaveConfigurationAsync(config);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, $"Error refreshing feed: {feed.Name}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for matches and auto-download if enabled
|
|
||||||
await ProcessMatchesAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task MarkItemAsDownloadedAsync(string itemId)
|
|
||||||
{
|
|
||||||
var item = _items.FirstOrDefault(i => i.Id == itemId);
|
|
||||||
|
|
||||||
if (item != null)
|
|
||||||
{
|
|
||||||
item.IsDownloaded = true;
|
|
||||||
await SaveItemsAsync();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task FetchFeedAsync(RssFeed feed)
|
|
||||||
{
|
|
||||||
_logger.LogInformation($"Fetching feed: {feed.Name}");
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var response = await _httpClient.GetStringAsync(feed.Url);
|
|
||||||
using var stringReader = new StringReader(response);
|
|
||||||
using var xmlReader = XmlReader.Create(stringReader);
|
|
||||||
|
|
||||||
var syndicationFeed = SyndicationFeed.Load(xmlReader);
|
|
||||||
|
|
||||||
foreach (var item in syndicationFeed.Items)
|
|
||||||
{
|
|
||||||
var link = item.Links.FirstOrDefault()?.Uri.ToString() ?? "";
|
|
||||||
var torrentUrl = ExtractTorrentUrl(link, item.Title.Text);
|
|
||||||
|
|
||||||
// Create a unique ID for this item
|
|
||||||
var itemId = $"{feed.Id}:{item.Id ?? Guid.NewGuid().ToString()}";
|
|
||||||
|
|
||||||
// Check if we already have this item
|
|
||||||
if (_items.Any(i => i.Id == itemId))
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
var feedItem = new RssFeedItem
|
|
||||||
{
|
|
||||||
Id = itemId,
|
|
||||||
Title = item.Title.Text,
|
|
||||||
Link = link,
|
|
||||||
Description = item.Summary?.Text ?? "",
|
|
||||||
PublishDate = item.PublishDate.DateTime,
|
|
||||||
TorrentUrl = torrentUrl,
|
|
||||||
IsDownloaded = false
|
|
||||||
};
|
|
||||||
|
|
||||||
// Check if this item matches any rules
|
|
||||||
CheckForMatches(feedItem, feed.Rules);
|
|
||||||
|
|
||||||
_items.Add(feedItem);
|
|
||||||
}
|
|
||||||
|
|
||||||
await SaveItemsAsync();
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, $"Error fetching feed: {feed.Name}");
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private string ExtractTorrentUrl(string link, string title)
|
|
||||||
{
|
|
||||||
// Try to find a .torrent link
|
|
||||||
if (link.EndsWith(".torrent", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
return link;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If it's a magnet link, return it
|
|
||||||
if (link.StartsWith("magnet:", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
return link;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return the link as is, we'll try to find the torrent on the page
|
|
||||||
return link;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void CheckForMatches(RssFeedItem item, List<string> rules)
|
|
||||||
{
|
|
||||||
foreach (var rule in rules)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
if (Regex.IsMatch(item.Title, rule, RegexOptions.IgnoreCase))
|
|
||||||
{
|
|
||||||
item.IsMatched = true;
|
|
||||||
item.MatchedRule = rule;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, $"Invalid regex rule: {rule}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task ProcessMatchesAsync()
|
|
||||||
{
|
|
||||||
var config = _configService.GetConfiguration();
|
|
||||||
|
|
||||||
if (!config.AutoDownloadEnabled)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var matchedItems = _items.Where(i => i.IsMatched && !i.IsDownloaded).ToList();
|
|
||||||
|
|
||||||
foreach (var item in matchedItems)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
_logger.LogInformation($"Auto-downloading: {item.Title}");
|
|
||||||
|
|
||||||
var torrentId = await _transmissionClient.AddTorrentAsync(
|
|
||||||
item.TorrentUrl,
|
|
||||||
config.DownloadDirectory);
|
|
||||||
|
|
||||||
item.IsDownloaded = true;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, $"Error downloading torrent: {item.Title}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await SaveItemsAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void LoadItems()
|
|
||||||
{
|
|
||||||
if (!File.Exists(_dataPath))
|
|
||||||
{
|
|
||||||
_items = new List<RssFeedItem>();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var json = File.ReadAllText(_dataPath);
|
|
||||||
var items = JsonSerializer.Deserialize<List<RssFeedItem>>(json);
|
|
||||||
_items = items ?? new List<RssFeedItem>();
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, "Error loading RSS items");
|
|
||||||
_items = new List<RssFeedItem>();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task SaveItemsAsync()
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var options = new JsonSerializerOptions
|
|
||||||
{
|
|
||||||
WriteIndented = true
|
|
||||||
};
|
|
||||||
|
|
||||||
var json = JsonSerializer.Serialize(_items, options);
|
|
||||||
await File.WriteAllTextAsync(_dataPath, json);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, "Error saving RSS items");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public class RssFeedBackgroundService : BackgroundService
|
|
||||||
{
|
|
||||||
private readonly ILogger<RssFeedBackgroundService> _logger;
|
|
||||||
private readonly IRssFeedManager _rssFeedManager;
|
|
||||||
private readonly IConfigService _configService;
|
|
||||||
|
|
||||||
public RssFeedBackgroundService(
|
|
||||||
ILogger<RssFeedBackgroundService> logger,
|
|
||||||
IRssFeedManager rssFeedManager,
|
|
||||||
IConfigService configService)
|
|
||||||
{
|
|
||||||
_logger = logger;
|
|
||||||
_rssFeedManager = rssFeedManager;
|
|
||||||
_configService = configService;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
|
||||||
{
|
|
||||||
_logger.LogInformation("RSS feed background service started");
|
|
||||||
|
|
||||||
while (!stoppingToken.IsCancellationRequested)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await _rssFeedManager.RefreshFeedsAsync(stoppingToken);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, "Error refreshing RSS feeds");
|
|
||||||
}
|
|
||||||
|
|
||||||
var config = _configService.GetConfiguration();
|
|
||||||
var interval = TimeSpan.FromMinutes(config.CheckIntervalMinutes);
|
|
||||||
|
|
||||||
_logger.LogInformation($"Next refresh in {interval.TotalMinutes} minutes");
|
|
||||||
await Task.Delay(interval, stoppingToken);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,309 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Net.Http;
|
|
||||||
using System.Net.Http.Headers;
|
|
||||||
using System.Text;
|
|
||||||
using System.Text.Json;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using System.Linq;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using TransmissionRssManager.Core;
|
|
||||||
|
|
||||||
namespace TransmissionRssManager.Services
|
|
||||||
{
|
|
||||||
public class TransmissionClient : ITransmissionClient
|
|
||||||
{
|
|
||||||
private readonly ILogger<TransmissionClient> _logger;
|
|
||||||
private readonly IConfigService _configService;
|
|
||||||
private readonly HttpClient _httpClient;
|
|
||||||
private string _sessionId = string.Empty;
|
|
||||||
|
|
||||||
public TransmissionClient(ILogger<TransmissionClient> logger, IConfigService configService)
|
|
||||||
{
|
|
||||||
_logger = logger;
|
|
||||||
_configService = configService;
|
|
||||||
|
|
||||||
// Configure the main HttpClient with handler that ignores certificate errors
|
|
||||||
var handler = new HttpClientHandler
|
|
||||||
{
|
|
||||||
ServerCertificateCustomValidationCallback = (sender, cert, chain, sslPolicyErrors) => true
|
|
||||||
};
|
|
||||||
_httpClient = new HttpClient(handler);
|
|
||||||
_httpClient.Timeout = TimeSpan.FromSeconds(10);
|
|
||||||
|
|
||||||
_logger.LogInformation("TransmissionClient initialized with certificate validation disabled");
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<List<TorrentInfo>> GetTorrentsAsync()
|
|
||||||
{
|
|
||||||
var config = _configService.GetConfiguration();
|
|
||||||
var request = new
|
|
||||||
{
|
|
||||||
method = "torrent-get",
|
|
||||||
arguments = new
|
|
||||||
{
|
|
||||||
fields = new[] { "id", "name", "status", "percentDone", "totalSize", "downloadDir" }
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
var response = await SendRequestAsync<TorrentGetResponse>(config.Transmission.Url, request);
|
|
||||||
|
|
||||||
_logger.LogInformation($"Transmission torrent response: {response != null}, Arguments: {response?.Arguments != null}, Result: {response?.Result}");
|
|
||||||
|
|
||||||
if (response?.Arguments?.Torrents == null)
|
|
||||||
{
|
|
||||||
_logger.LogWarning("No torrents found in response");
|
|
||||||
return new List<TorrentInfo>();
|
|
||||||
}
|
|
||||||
|
|
||||||
_logger.LogInformation($"Found {response.Arguments.Torrents.Count} torrents in response");
|
|
||||||
|
|
||||||
var torrents = new List<TorrentInfo>();
|
|
||||||
foreach (var torrent in response.Arguments.Torrents)
|
|
||||||
{
|
|
||||||
_logger.LogInformation($"Processing torrent: {torrent.Id} - {torrent.Name}");
|
|
||||||
torrents.Add(new TorrentInfo
|
|
||||||
{
|
|
||||||
Id = torrent.Id,
|
|
||||||
Name = torrent.Name,
|
|
||||||
Status = GetStatusText(torrent.Status),
|
|
||||||
PercentDone = torrent.PercentDone,
|
|
||||||
TotalSize = torrent.TotalSize,
|
|
||||||
DownloadDir = torrent.DownloadDir
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return torrents;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<int> AddTorrentAsync(string torrentUrl, string downloadDir)
|
|
||||||
{
|
|
||||||
var config = _configService.GetConfiguration();
|
|
||||||
var request = new
|
|
||||||
{
|
|
||||||
method = "torrent-add",
|
|
||||||
arguments = new
|
|
||||||
{
|
|
||||||
filename = torrentUrl,
|
|
||||||
downloadDir = downloadDir
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
var response = await SendRequestAsync<TorrentAddResponse>(config.Transmission.Url, request);
|
|
||||||
|
|
||||||
if (response?.Arguments?.TorrentAdded != null)
|
|
||||||
{
|
|
||||||
return response.Arguments.TorrentAdded.Id;
|
|
||||||
}
|
|
||||||
else if (response?.Arguments?.TorrentDuplicate != null)
|
|
||||||
{
|
|
||||||
return response.Arguments.TorrentDuplicate.Id;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Exception("Failed to add torrent");
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task RemoveTorrentAsync(int id, bool deleteLocalData)
|
|
||||||
{
|
|
||||||
var config = _configService.GetConfiguration();
|
|
||||||
var request = new
|
|
||||||
{
|
|
||||||
method = "torrent-remove",
|
|
||||||
arguments = new
|
|
||||||
{
|
|
||||||
ids = new[] { id },
|
|
||||||
deleteLocalData = deleteLocalData
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
await SendRequestAsync<object>(config.Transmission.Url, request);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task StartTorrentAsync(int id)
|
|
||||||
{
|
|
||||||
var config = _configService.GetConfiguration();
|
|
||||||
var request = new
|
|
||||||
{
|
|
||||||
method = "torrent-start",
|
|
||||||
arguments = new
|
|
||||||
{
|
|
||||||
ids = new[] { id }
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
await SendRequestAsync<object>(config.Transmission.Url, request);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task StopTorrentAsync(int id)
|
|
||||||
{
|
|
||||||
var config = _configService.GetConfiguration();
|
|
||||||
var request = new
|
|
||||||
{
|
|
||||||
method = "torrent-stop",
|
|
||||||
arguments = new
|
|
||||||
{
|
|
||||||
ids = new[] { id }
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
await SendRequestAsync<object>(config.Transmission.Url, request);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<T> SendRequestAsync<T>(string url, object requestData)
|
|
||||||
{
|
|
||||||
var config = _configService.GetConfiguration();
|
|
||||||
var jsonContent = JsonSerializer.Serialize(requestData);
|
|
||||||
var content = new StringContent(jsonContent, Encoding.UTF8, "application/json");
|
|
||||||
|
|
||||||
// Always create a fresh HttpClient to avoid connection issues
|
|
||||||
using var httpClient = new HttpClient(new HttpClientHandler
|
|
||||||
{
|
|
||||||
ServerCertificateCustomValidationCallback = (sender, cert, chain, sslPolicyErrors) => true
|
|
||||||
});
|
|
||||||
|
|
||||||
// Ensure we have a valid URL by reconstructing it explicitly
|
|
||||||
var protocol = config.Transmission.UseHttps ? "https" : "http";
|
|
||||||
var serverUrl = $"{protocol}://{config.Transmission.Host}:{config.Transmission.Port}/transmission/rpc";
|
|
||||||
|
|
||||||
var request = new HttpRequestMessage(HttpMethod.Post, serverUrl)
|
|
||||||
{
|
|
||||||
Content = content
|
|
||||||
};
|
|
||||||
|
|
||||||
// Add session ID if we have one
|
|
||||||
if (!string.IsNullOrEmpty(_sessionId))
|
|
||||||
{
|
|
||||||
request.Headers.Add("X-Transmission-Session-Id", _sessionId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add authentication if provided
|
|
||||||
if (!string.IsNullOrEmpty(config.Transmission.Username) && !string.IsNullOrEmpty(config.Transmission.Password))
|
|
||||||
{
|
|
||||||
var credentials = Convert.ToBase64String(Encoding.ASCII.GetBytes($"{config.Transmission.Username}:{config.Transmission.Password}"));
|
|
||||||
request.Headers.Authorization = new AuthenticationHeaderValue("Basic", credentials);
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// Set timeout to avoid hanging indefinitely on connection issues
|
|
||||||
httpClient.Timeout = TimeSpan.FromSeconds(10);
|
|
||||||
|
|
||||||
_logger.LogInformation($"Connecting to Transmission at {serverUrl} with auth: {!string.IsNullOrEmpty(config.Transmission.Username)}");
|
|
||||||
var response = await httpClient.SendAsync(request);
|
|
||||||
|
|
||||||
// Check if we need a new session ID
|
|
||||||
if (response.StatusCode == System.Net.HttpStatusCode.Conflict)
|
|
||||||
{
|
|
||||||
if (response.Headers.TryGetValues("X-Transmission-Session-Id", out var sessionIds))
|
|
||||||
{
|
|
||||||
_sessionId = sessionIds.FirstOrDefault() ?? string.Empty;
|
|
||||||
_logger.LogInformation($"Got new Transmission session ID: {_sessionId}");
|
|
||||||
|
|
||||||
// Retry request with new session ID
|
|
||||||
return await SendRequestAsync<T>(url, requestData);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
response.EnsureSuccessStatusCode();
|
|
||||||
|
|
||||||
var resultContent = await response.Content.ReadAsStringAsync();
|
|
||||||
_logger.LogInformation($"Received successful response from Transmission: {resultContent.Substring(0, Math.Min(resultContent.Length, 500))}");
|
|
||||||
|
|
||||||
// Configure JSON deserializer to be case insensitive
|
|
||||||
var options = new JsonSerializerOptions
|
|
||||||
{
|
|
||||||
PropertyNameCaseInsensitive = true
|
|
||||||
};
|
|
||||||
|
|
||||||
return JsonSerializer.Deserialize<T>(resultContent, options);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, $"Error communicating with Transmission at {serverUrl}: {ex.Message}");
|
|
||||||
throw new Exception($"Failed to connect to Transmission at {config.Transmission.Host}:{config.Transmission.Port}. Error: {ex.Message}", ex);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private string GetStatusText(int status)
|
|
||||||
{
|
|
||||||
return status switch
|
|
||||||
{
|
|
||||||
0 => "Stopped",
|
|
||||||
1 => "Queued",
|
|
||||||
2 => "Verifying",
|
|
||||||
3 => "Downloading",
|
|
||||||
4 => "Seeding",
|
|
||||||
5 => "Queued",
|
|
||||||
6 => "Checking",
|
|
||||||
_ => "Unknown"
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Transmission response classes with proper JSON attribute names
|
|
||||||
private class TorrentGetResponse
|
|
||||||
{
|
|
||||||
[System.Text.Json.Serialization.JsonPropertyName("arguments")]
|
|
||||||
public TorrentGetArguments Arguments { get; set; }
|
|
||||||
|
|
||||||
[System.Text.Json.Serialization.JsonPropertyName("result")]
|
|
||||||
public string Result { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
private class TorrentGetArguments
|
|
||||||
{
|
|
||||||
[System.Text.Json.Serialization.JsonPropertyName("torrents")]
|
|
||||||
public List<TransmissionTorrent> Torrents { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
private class TransmissionTorrent
|
|
||||||
{
|
|
||||||
[System.Text.Json.Serialization.JsonPropertyName("id")]
|
|
||||||
public int Id { get; set; }
|
|
||||||
|
|
||||||
[System.Text.Json.Serialization.JsonPropertyName("name")]
|
|
||||||
public string Name { get; set; }
|
|
||||||
|
|
||||||
[System.Text.Json.Serialization.JsonPropertyName("status")]
|
|
||||||
public int Status { get; set; }
|
|
||||||
|
|
||||||
[System.Text.Json.Serialization.JsonPropertyName("percentDone")]
|
|
||||||
public double PercentDone { get; set; }
|
|
||||||
|
|
||||||
[System.Text.Json.Serialization.JsonPropertyName("totalSize")]
|
|
||||||
public long TotalSize { get; set; }
|
|
||||||
|
|
||||||
[System.Text.Json.Serialization.JsonPropertyName("downloadDir")]
|
|
||||||
public string DownloadDir { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
private class TorrentAddResponse
|
|
||||||
{
|
|
||||||
[System.Text.Json.Serialization.JsonPropertyName("arguments")]
|
|
||||||
public TorrentAddArguments Arguments { get; set; }
|
|
||||||
|
|
||||||
[System.Text.Json.Serialization.JsonPropertyName("result")]
|
|
||||||
public string Result { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
private class TorrentAddArguments
|
|
||||||
{
|
|
||||||
[System.Text.Json.Serialization.JsonPropertyName("torrent-added")]
|
|
||||||
public TorrentAddInfo TorrentAdded { get; set; }
|
|
||||||
|
|
||||||
[System.Text.Json.Serialization.JsonPropertyName("torrent-duplicate")]
|
|
||||||
public TorrentAddInfo TorrentDuplicate { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
private class TorrentAddInfo
|
|
||||||
{
|
|
||||||
[System.Text.Json.Serialization.JsonPropertyName("id")]
|
|
||||||
public int Id { get; set; }
|
|
||||||
|
|
||||||
[System.Text.Json.Serialization.JsonPropertyName("name")]
|
|
||||||
public string Name { get; set; }
|
|
||||||
|
|
||||||
[System.Text.Json.Serialization.JsonPropertyName("hashString")]
|
|
||||||
public string HashString { get; set; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,171 +0,0 @@
|
|||||||
:root {
|
|
||||||
--primary-color: #0d6efd;
|
|
||||||
--secondary-color: #6c757d;
|
|
||||||
--dark-color: #212529;
|
|
||||||
--light-color: #f8f9fa;
|
|
||||||
--success-color: #198754;
|
|
||||||
--danger-color: #dc3545;
|
|
||||||
--warning-color: #ffc107;
|
|
||||||
--info-color: #0dcaf0;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
padding-bottom: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.navbar {
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-content {
|
|
||||||
animation: fadeIn 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes fadeIn {
|
|
||||||
from { opacity: 0; }
|
|
||||||
to { opacity: 1; }
|
|
||||||
}
|
|
||||||
|
|
||||||
.card {
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-header {
|
|
||||||
background-color: var(--light-color);
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress {
|
|
||||||
height: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge {
|
|
||||||
padding: 0.35em 0.65em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-downloading {
|
|
||||||
background-color: var(--info-color);
|
|
||||||
color: var(--dark-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-seeding {
|
|
||||||
background-color: var(--success-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-stopped {
|
|
||||||
background-color: var(--secondary-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-checking {
|
|
||||||
background-color: var(--warning-color);
|
|
||||||
color: var(--dark-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-queued {
|
|
||||||
background-color: var(--secondary-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-icon {
|
|
||||||
padding: 0.25rem 0.5rem;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.feed-item {
|
|
||||||
border-left: 3px solid transparent;
|
|
||||||
padding: 10px;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
background-color: #f8f9fa;
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.feed-item:hover {
|
|
||||||
background-color: #e9ecef;
|
|
||||||
}
|
|
||||||
|
|
||||||
.feed-item.matched {
|
|
||||||
border-left-color: var(--success-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.feed-item.downloaded {
|
|
||||||
opacity: 0.7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.feed-item-title {
|
|
||||||
font-weight: 500;
|
|
||||||
margin-bottom: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.feed-item-date {
|
|
||||||
font-size: 0.85rem;
|
|
||||||
color: var(--secondary-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.feed-item-buttons {
|
|
||||||
margin-top: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.torrent-item {
|
|
||||||
margin-bottom: 15px;
|
|
||||||
padding: 15px;
|
|
||||||
border-radius: 4px;
|
|
||||||
background-color: #f8f9fa;
|
|
||||||
}
|
|
||||||
|
|
||||||
.torrent-item-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.torrent-item-title {
|
|
||||||
font-weight: 500;
|
|
||||||
margin-right: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.torrent-item-progress {
|
|
||||||
margin: 10px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.torrent-item-details {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
color: var(--secondary-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.torrent-item-buttons {
|
|
||||||
margin-top: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Responsive tweaks */
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.container {
|
|
||||||
padding-left: 10px;
|
|
||||||
padding-right: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-body {
|
|
||||||
padding: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.torrent-item-header {
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.torrent-item-buttons {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.torrent-item-buttons .btn {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,283 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Transmission RSS Manager</title>
|
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css">
|
|
||||||
<link rel="stylesheet" href="css/styles.css">
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
|
|
||||||
<div class="container">
|
|
||||||
<a class="navbar-brand" href="#">Transmission RSS Manager</a>
|
|
||||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
|
||||||
<span class="navbar-toggler-icon"></span>
|
|
||||||
</button>
|
|
||||||
<div class="collapse navbar-collapse" id="navbarNav">
|
|
||||||
<ul class="navbar-nav">
|
|
||||||
<li class="nav-item"><a class="nav-link" href="#" data-page="dashboard">Dashboard</a></li>
|
|
||||||
<li class="nav-item"><a class="nav-link" href="#" data-page="feeds">RSS Feeds</a></li>
|
|
||||||
<li class="nav-item"><a class="nav-link" href="#" data-page="torrents">Torrents</a></li>
|
|
||||||
<li class="nav-item"><a class="nav-link" href="#" data-page="settings">Settings</a></li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<div class="container mt-4">
|
|
||||||
<div id="page-dashboard" class="page-content">
|
|
||||||
<h2>Dashboard</h2>
|
|
||||||
<div class="row mt-4">
|
|
||||||
<div class="col-md-6">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header">System Status</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div id="system-status">Loading...</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header">Recent Matches</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div id="recent-matches">Loading...</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="row mt-4">
|
|
||||||
<div class="col-md-12">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header">Active Torrents</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div id="active-torrents">Loading...</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="page-feeds" class="page-content d-none">
|
|
||||||
<h2>RSS Feeds</h2>
|
|
||||||
<div class="mb-3">
|
|
||||||
<button id="btn-add-feed" class="btn btn-primary">Add Feed</button>
|
|
||||||
<button id="btn-refresh-feeds" class="btn btn-secondary">Refresh Feeds</button>
|
|
||||||
</div>
|
|
||||||
<div id="feeds-list">Loading...</div>
|
|
||||||
|
|
||||||
<div class="mt-4">
|
|
||||||
<h3>Feed Items</h3>
|
|
||||||
<ul class="nav nav-tabs" id="feedTabs">
|
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link active" data-bs-toggle="tab" href="#all-items">All Items</a>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link" data-bs-toggle="tab" href="#matched-items">Matched Items</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
<div class="tab-content mt-2">
|
|
||||||
<div class="tab-pane fade show active" id="all-items">
|
|
||||||
<div id="all-items-list">Loading...</div>
|
|
||||||
</div>
|
|
||||||
<div class="tab-pane fade" id="matched-items">
|
|
||||||
<div id="matched-items-list">Loading...</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="page-torrents" class="page-content d-none">
|
|
||||||
<h2>Torrents</h2>
|
|
||||||
<div class="mb-3">
|
|
||||||
<button id="btn-add-torrent" class="btn btn-primary">Add Torrent</button>
|
|
||||||
<button id="btn-refresh-torrents" class="btn btn-secondary">Refresh Torrents</button>
|
|
||||||
</div>
|
|
||||||
<div id="torrents-list">Loading...</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="page-settings" class="page-content d-none">
|
|
||||||
<h2>Settings</h2>
|
|
||||||
<form id="settings-form">
|
|
||||||
<div class="card mb-4">
|
|
||||||
<div class="card-header">Transmission Settings</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-6">
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="transmission-host" class="form-label">Host</label>
|
|
||||||
<input type="text" class="form-control" id="transmission-host" name="transmission.host">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6">
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="transmission-port" class="form-label">Port</label>
|
|
||||||
<input type="number" class="form-control" id="transmission-port" name="transmission.port">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-12">
|
|
||||||
<div class="mb-3">
|
|
||||||
<div class="form-check form-switch">
|
|
||||||
<input class="form-check-input" type="checkbox" id="transmission-use-https" name="transmission.useHttps">
|
|
||||||
<label class="form-check-label" for="transmission-use-https">Use HTTPS</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-6">
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="transmission-username" class="form-label">Username</label>
|
|
||||||
<input type="text" class="form-control" id="transmission-username" name="transmission.username">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6">
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="transmission-password" class="form-label">Password</label>
|
|
||||||
<input type="password" class="form-control" id="transmission-password" name="transmission.password">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card mb-4">
|
|
||||||
<div class="card-header">RSS Settings</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="mb-3">
|
|
||||||
<div class="form-check form-switch">
|
|
||||||
<input class="form-check-input" type="checkbox" id="auto-download-enabled" name="autoDownloadEnabled">
|
|
||||||
<label class="form-check-label" for="auto-download-enabled">Enable Auto Download</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="check-interval" class="form-label">Check Interval (minutes)</label>
|
|
||||||
<input type="number" class="form-control" id="check-interval" name="checkIntervalMinutes">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card mb-4">
|
|
||||||
<div class="card-header">Directories</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="download-directory" class="form-label">Download Directory</label>
|
|
||||||
<input type="text" class="form-control" id="download-directory" name="downloadDirectory">
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="media-library" class="form-label">Media Library Path</label>
|
|
||||||
<input type="text" class="form-control" id="media-library" name="mediaLibraryPath">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card mb-4">
|
|
||||||
<div class="card-header">Post Processing</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="mb-3">
|
|
||||||
<div class="form-check form-switch">
|
|
||||||
<input class="form-check-input" type="checkbox" id="post-processing-enabled" name="postProcessing.enabled">
|
|
||||||
<label class="form-check-label" for="post-processing-enabled">Enable Post Processing</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<div class="form-check form-switch">
|
|
||||||
<input class="form-check-input" type="checkbox" id="extract-archives" name="postProcessing.extractArchives">
|
|
||||||
<label class="form-check-label" for="extract-archives">Extract Archives</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<div class="form-check form-switch">
|
|
||||||
<input class="form-check-input" type="checkbox" id="organize-media" name="postProcessing.organizeMedia">
|
|
||||||
<label class="form-check-label" for="organize-media">Organize Media</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="minimum-seed-ratio" class="form-label">Minimum Seed Ratio</label>
|
|
||||||
<input type="number" class="form-control" id="minimum-seed-ratio" name="postProcessing.minimumSeedRatio">
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="media-extensions" class="form-label">Media Extensions (comma separated)</label>
|
|
||||||
<input type="text" class="form-control" id="media-extensions" name="mediaExtensions">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button type="submit" class="btn btn-primary">Save Settings</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Modals -->
|
|
||||||
<div class="modal fade" id="add-feed-modal" tabindex="-1">
|
|
||||||
<div class="modal-dialog">
|
|
||||||
<div class="modal-content">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h5 class="modal-title">Add RSS Feed</h5>
|
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body">
|
|
||||||
<form id="add-feed-form">
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="feed-name" class="form-label">Name</label>
|
|
||||||
<input type="text" class="form-control" id="feed-name" required>
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="feed-url" class="form-label">URL</label>
|
|
||||||
<input type="url" class="form-control" id="feed-url" required>
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="feed-rules" class="form-label">Match Rules (one per line)</label>
|
|
||||||
<textarea class="form-control" id="feed-rules" rows="5"></textarea>
|
|
||||||
<div class="form-text">Use regular expressions to match feed items.</div>
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<div class="form-check form-switch">
|
|
||||||
<input class="form-check-input" type="checkbox" id="feed-auto-download">
|
|
||||||
<label class="form-check-label" for="feed-auto-download">Auto Download</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer">
|
|
||||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
|
||||||
<button type="button" class="btn btn-primary" id="save-feed-btn">Add Feed</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="modal fade" id="add-torrent-modal" tabindex="-1">
|
|
||||||
<div class="modal-dialog">
|
|
||||||
<div class="modal-content">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h5 class="modal-title">Add Torrent</h5>
|
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body">
|
|
||||||
<form id="add-torrent-form">
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="torrent-url" class="form-label">Torrent URL or Magnet Link</label>
|
|
||||||
<input type="text" class="form-control" id="torrent-url" required>
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="torrent-download-dir" class="form-label">Download Directory (optional)</label>
|
|
||||||
<input type="text" class="form-control" id="torrent-download-dir">
|
|
||||||
<div class="form-text">Leave empty to use default download directory.</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer">
|
|
||||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
|
||||||
<button type="button" class="btn btn-primary" id="save-torrent-btn">Add Torrent</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
|
||||||
<script src="js/app.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
@ -1,916 +0,0 @@
|
|||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
// Initialize navigation
|
|
||||||
initNavigation();
|
|
||||||
|
|
||||||
// Initialize event listeners
|
|
||||||
initEventListeners();
|
|
||||||
|
|
||||||
// Load initial dashboard data
|
|
||||||
loadDashboardData();
|
|
||||||
|
|
||||||
// Initialize Bootstrap tooltips
|
|
||||||
const tooltips = document.querySelectorAll('[data-bs-toggle="tooltip"]');
|
|
||||||
tooltips.forEach(tooltip => new bootstrap.Tooltip(tooltip));
|
|
||||||
});
|
|
||||||
|
|
||||||
function initNavigation() {
|
|
||||||
const navLinks = document.querySelectorAll('.navbar-nav .nav-link');
|
|
||||||
navLinks.forEach(link => {
|
|
||||||
link.addEventListener('click', function(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
const page = this.getAttribute('data-page');
|
|
||||||
showPage(page);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Set active page from URL hash or default to dashboard
|
|
||||||
const hash = window.location.hash.substring(1);
|
|
||||||
showPage(hash || 'dashboard');
|
|
||||||
}
|
|
||||||
|
|
||||||
function showPage(page) {
|
|
||||||
// Hide all pages
|
|
||||||
const pages = document.querySelectorAll('.page-content');
|
|
||||||
pages.forEach(p => p.classList.add('d-none'));
|
|
||||||
|
|
||||||
// Remove active class from all nav links
|
|
||||||
const navLinks = document.querySelectorAll('.navbar-nav .nav-link');
|
|
||||||
navLinks.forEach(link => link.classList.remove('active'));
|
|
||||||
|
|
||||||
// Show selected page
|
|
||||||
const selectedPage = document.getElementById(`page-${page}`);
|
|
||||||
if (selectedPage) {
|
|
||||||
selectedPage.classList.remove('d-none');
|
|
||||||
|
|
||||||
// Set active class on nav link
|
|
||||||
const activeNav = document.querySelector(`.nav-link[data-page="${page}"]`);
|
|
||||||
if (activeNav) {
|
|
||||||
activeNav.classList.add('active');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update URL hash
|
|
||||||
window.location.hash = page;
|
|
||||||
|
|
||||||
// Load page-specific data
|
|
||||||
loadPageData(page);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadPageData(page) {
|
|
||||||
switch (page) {
|
|
||||||
case 'dashboard':
|
|
||||||
loadDashboardData();
|
|
||||||
break;
|
|
||||||
case 'feeds':
|
|
||||||
loadFeeds();
|
|
||||||
loadAllItems();
|
|
||||||
loadMatchedItems();
|
|
||||||
break;
|
|
||||||
case 'torrents':
|
|
||||||
loadTorrents();
|
|
||||||
break;
|
|
||||||
case 'settings':
|
|
||||||
loadSettings();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function initEventListeners() {
|
|
||||||
// RSS Feeds page
|
|
||||||
document.getElementById('btn-add-feed').addEventListener('click', showAddFeedModal);
|
|
||||||
document.getElementById('btn-refresh-feeds').addEventListener('click', refreshFeeds);
|
|
||||||
document.getElementById('save-feed-btn').addEventListener('click', saveFeed);
|
|
||||||
|
|
||||||
// Torrents page
|
|
||||||
document.getElementById('btn-add-torrent').addEventListener('click', showAddTorrentModal);
|
|
||||||
document.getElementById('btn-refresh-torrents').addEventListener('click', loadTorrents);
|
|
||||||
document.getElementById('save-torrent-btn').addEventListener('click', saveTorrent);
|
|
||||||
|
|
||||||
// Settings page
|
|
||||||
document.getElementById('settings-form').addEventListener('submit', saveSettings);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Dashboard
|
|
||||||
function loadDashboardData() {
|
|
||||||
loadSystemStatus();
|
|
||||||
loadRecentMatches();
|
|
||||||
loadActiveTorrents();
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadSystemStatus() {
|
|
||||||
const statusElement = document.getElementById('system-status');
|
|
||||||
statusElement.innerHTML = '<div class="d-flex justify-content-center"><div class="spinner-border spinner-border-sm text-primary" role="status"></div></div>';
|
|
||||||
|
|
||||||
fetch('/api/config')
|
|
||||||
.then(response => response.json())
|
|
||||||
.then(config => {
|
|
||||||
// Create system status HTML
|
|
||||||
let html = '<ul class="list-group">';
|
|
||||||
html += `<li class="list-group-item d-flex justify-content-between align-items-center">Auto Download <span class="badge ${config.autoDownloadEnabled ? 'bg-success' : 'bg-danger'}">${config.autoDownloadEnabled ? 'Enabled' : 'Disabled'}</span></li>`;
|
|
||||||
html += `<li class="list-group-item d-flex justify-content-between align-items-center">Check Interval <span class="badge bg-primary">${config.checkIntervalMinutes} minutes</span></li>`;
|
|
||||||
html += `<li class="list-group-item d-flex justify-content-between align-items-center">Transmission Connection <span class="badge ${config.transmission.host ? 'bg-success' : 'bg-warning'}">${config.transmission.host ? config.transmission.host + ':' + config.transmission.port : 'Not configured'}</span></li>`;
|
|
||||||
html += `<li class="list-group-item d-flex justify-content-between align-items-center">Post Processing <span class="badge ${config.postProcessing.enabled ? 'bg-success' : 'bg-danger'}">${config.postProcessing.enabled ? 'Enabled' : 'Disabled'}</span></li>`;
|
|
||||||
html += '</ul>';
|
|
||||||
|
|
||||||
statusElement.innerHTML = html;
|
|
||||||
})
|
|
||||||
.catch(error => {
|
|
||||||
console.error('Error loading system status:', error);
|
|
||||||
statusElement.innerHTML = '<div class="alert alert-danger">Error loading system status</div>';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadRecentMatches() {
|
|
||||||
const matchesElement = document.getElementById('recent-matches');
|
|
||||||
matchesElement.innerHTML = '<div class="d-flex justify-content-center"><div class="spinner-border spinner-border-sm text-primary" role="status"></div></div>';
|
|
||||||
|
|
||||||
fetch('/api/feeds/matched')
|
|
||||||
.then(response => response.json())
|
|
||||||
.then(items => {
|
|
||||||
// Sort by publish date descending and take the first 5
|
|
||||||
const recentItems = items.sort((a, b) => new Date(b.publishDate) - new Date(a.publishDate)).slice(0, 5);
|
|
||||||
|
|
||||||
if (recentItems.length === 0) {
|
|
||||||
matchesElement.innerHTML = '<div class="alert alert-info">No matched items yet</div>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let html = '<div class="list-group">';
|
|
||||||
recentItems.forEach(item => {
|
|
||||||
const date = new Date(item.publishDate);
|
|
||||||
html += `<a href="${item.link}" target="_blank" class="list-group-item list-group-item-action">
|
|
||||||
<div class="d-flex w-100 justify-content-between">
|
|
||||||
<h6 class="mb-1">${item.title}</h6>
|
|
||||||
<small>${formatDate(date)}</small>
|
|
||||||
</div>
|
|
||||||
<small class="d-flex justify-content-between">
|
|
||||||
<span>Matched rule: ${item.matchedRule}</span>
|
|
||||||
<span class="badge ${item.isDownloaded ? 'bg-success' : 'bg-warning'}">${item.isDownloaded ? 'Downloaded' : 'Not Downloaded'}</span>
|
|
||||||
</small>
|
|
||||||
</a>`;
|
|
||||||
});
|
|
||||||
html += '</div>';
|
|
||||||
|
|
||||||
matchesElement.innerHTML = html;
|
|
||||||
})
|
|
||||||
.catch(error => {
|
|
||||||
console.error('Error loading recent matches:', error);
|
|
||||||
matchesElement.innerHTML = '<div class="alert alert-danger">Error loading recent matches</div>';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadActiveTorrents() {
|
|
||||||
const torrentsElement = document.getElementById('active-torrents');
|
|
||||||
torrentsElement.innerHTML = '<div class="d-flex justify-content-center"><div class="spinner-border spinner-border-sm text-primary" role="status"></div></div>';
|
|
||||||
|
|
||||||
fetch('/api/torrents')
|
|
||||||
.then(response => response.json())
|
|
||||||
.then(torrents => {
|
|
||||||
console.log('Dashboard torrents:', torrents);
|
|
||||||
|
|
||||||
// Sort by progress ascending and filter for active torrents
|
|
||||||
const activeTorrents = torrents
|
|
||||||
.filter(t => t && t.status && (t.status === 'Downloading' || t.status === 'Seeding'))
|
|
||||||
.sort((a, b) => (a.percentDone || 0) - (b.percentDone || 0));
|
|
||||||
|
|
||||||
if (activeTorrents.length === 0) {
|
|
||||||
torrentsElement.innerHTML = '<div class="alert alert-info">No active torrents</div>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let html = '<div class="list-group">';
|
|
||||||
activeTorrents.forEach(torrent => {
|
|
||||||
// Handle potential null or undefined values
|
|
||||||
if (!torrent || !torrent.name) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Safely calculate percentages and sizes with error handling
|
|
||||||
let progressPercent = 0;
|
|
||||||
try {
|
|
||||||
progressPercent = Math.round((torrent.percentDone || 0) * 100);
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('Error calculating progress percent:', e);
|
|
||||||
}
|
|
||||||
|
|
||||||
let sizeInGB = '0.00';
|
|
||||||
try {
|
|
||||||
if (torrent.totalSize && torrent.totalSize > 0) {
|
|
||||||
sizeInGB = (torrent.totalSize / 1073741824).toFixed(2);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('Error calculating size in GB:', e);
|
|
||||||
}
|
|
||||||
|
|
||||||
const torrentStatus = torrent.status || 'Unknown';
|
|
||||||
const statusClass = torrentStatus.toLowerCase().replace(/\s+/g, '-');
|
|
||||||
|
|
||||||
html += `<div class="list-group-item">
|
|
||||||
<div class="d-flex w-100 justify-content-between">
|
|
||||||
<h6 class="mb-1">${torrent.name}</h6>
|
|
||||||
<span class="badge bg-${statusClass === 'seeding' ? 'success' : 'primary'}">${torrentStatus}</span>
|
|
||||||
</div>
|
|
||||||
<div class="progress mt-2">
|
|
||||||
<div class="progress-bar ${torrentStatus === 'Seeding' ? 'bg-success' : 'bg-primary'}" role="progressbar" style="width: ${progressPercent}%">${progressPercent}%</div>
|
|
||||||
</div>
|
|
||||||
<small class="d-block mt-1 text-muted">Size: ${sizeInGB} GB</small>
|
|
||||||
</div>`;
|
|
||||||
});
|
|
||||||
html += '</div>';
|
|
||||||
|
|
||||||
torrentsElement.innerHTML = html;
|
|
||||||
})
|
|
||||||
.catch(error => {
|
|
||||||
console.error('Error loading active torrents:', error);
|
|
||||||
torrentsElement.innerHTML = '<div class="alert alert-danger">Error loading active torrents</div>';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// RSS Feeds
|
|
||||||
function loadFeeds() {
|
|
||||||
const feedsElement = document.getElementById('feeds-list');
|
|
||||||
feedsElement.innerHTML = '<div class="d-flex justify-content-center"><div class="spinner-border spinner-border-sm text-primary" role="status"></div></div>';
|
|
||||||
|
|
||||||
fetch('/api/feeds')
|
|
||||||
.then(response => response.json())
|
|
||||||
.then(feeds => {
|
|
||||||
if (feeds.length === 0) {
|
|
||||||
feedsElement.innerHTML = '<div class="alert alert-info">No feeds added yet</div>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let html = '<div class="list-group">';
|
|
||||||
feeds.forEach(feed => {
|
|
||||||
const lastChecked = feed.lastChecked ? new Date(feed.lastChecked) : null;
|
|
||||||
|
|
||||||
html += `<div class="list-group-item list-group-item-action" data-feed-id="${feed.id}">
|
|
||||||
<div class="d-flex w-100 justify-content-between">
|
|
||||||
<h5 class="mb-1">${feed.name}</h5>
|
|
||||||
<small>${lastChecked ? 'Last checked: ' + formatDate(lastChecked) : 'Never checked'}</small>
|
|
||||||
</div>
|
|
||||||
<p class="mb-1"><a href="${feed.url}" target="_blank">${feed.url}</a></p>
|
|
||||||
<div class="d-flex justify-content-between align-items-center">
|
|
||||||
<small>${feed.rules.length} rules</small>
|
|
||||||
<div>
|
|
||||||
<button class="btn btn-sm btn-outline-primary me-2 btn-edit-feed" data-feed-id="${feed.id}">Edit</button>
|
|
||||||
<button class="btn btn-sm btn-outline-danger btn-delete-feed" data-feed-id="${feed.id}">Delete</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
});
|
|
||||||
html += '</div>';
|
|
||||||
|
|
||||||
feedsElement.innerHTML = html;
|
|
||||||
|
|
||||||
// Add event listeners
|
|
||||||
document.querySelectorAll('.btn-edit-feed').forEach(btn => {
|
|
||||||
btn.addEventListener('click', function() {
|
|
||||||
const feedId = this.getAttribute('data-feed-id');
|
|
||||||
editFeed(feedId);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
document.querySelectorAll('.btn-delete-feed').forEach(btn => {
|
|
||||||
btn.addEventListener('click', function() {
|
|
||||||
const feedId = this.getAttribute('data-feed-id');
|
|
||||||
deleteFeed(feedId);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.catch(error => {
|
|
||||||
console.error('Error loading feeds:', error);
|
|
||||||
feedsElement.innerHTML = '<div class="alert alert-danger">Error loading feeds</div>';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadAllItems() {
|
|
||||||
const itemsElement = document.getElementById('all-items-list');
|
|
||||||
itemsElement.innerHTML = '<div class="d-flex justify-content-center"><div class="spinner-border spinner-border-sm text-primary" role="status"></div></div>';
|
|
||||||
|
|
||||||
fetch('/api/feeds/items')
|
|
||||||
.then(response => response.json())
|
|
||||||
.then(items => {
|
|
||||||
if (items.length === 0) {
|
|
||||||
itemsElement.innerHTML = '<div class="alert alert-info">No feed items yet</div>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let html = '<div class="feed-items-container">';
|
|
||||||
items.forEach(item => {
|
|
||||||
const date = new Date(item.publishDate);
|
|
||||||
const classes = `feed-item ${item.isMatched ? 'matched' : ''} ${item.isDownloaded ? 'downloaded' : ''}`;
|
|
||||||
|
|
||||||
html += `<div class="${classes}" data-item-id="${item.id}">
|
|
||||||
<div class="feed-item-title"><a href="${item.link}" target="_blank">${item.title}</a></div>
|
|
||||||
<div class="feed-item-date">${formatDate(date)}</div>
|
|
||||||
${item.isMatched ? `<div class="text-success small">Matched rule: ${item.matchedRule}</div>` : ''}
|
|
||||||
${item.isDownloaded ? '<div class="text-muted small">Downloaded</div>' : ''}
|
|
||||||
${!item.isDownloaded && item.isMatched ?
|
|
||||||
`<div class="feed-item-buttons">
|
|
||||||
<button class="btn btn-sm btn-primary btn-download-item" data-item-id="${item.id}">Download</button>
|
|
||||||
</div>` : ''
|
|
||||||
}
|
|
||||||
</div>`;
|
|
||||||
});
|
|
||||||
html += '</div>';
|
|
||||||
|
|
||||||
itemsElement.innerHTML = html;
|
|
||||||
|
|
||||||
// Add event listeners
|
|
||||||
document.querySelectorAll('.btn-download-item').forEach(btn => {
|
|
||||||
btn.addEventListener('click', function() {
|
|
||||||
const itemId = this.getAttribute('data-item-id');
|
|
||||||
downloadItem(itemId);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.catch(error => {
|
|
||||||
console.error('Error loading feed items:', error);
|
|
||||||
itemsElement.innerHTML = '<div class="alert alert-danger">Error loading feed items</div>';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadMatchedItems() {
|
|
||||||
const matchedElement = document.getElementById('matched-items-list');
|
|
||||||
matchedElement.innerHTML = '<div class="d-flex justify-content-center"><div class="spinner-border spinner-border-sm text-primary" role="status"></div></div>';
|
|
||||||
|
|
||||||
fetch('/api/feeds/matched')
|
|
||||||
.then(response => response.json())
|
|
||||||
.then(items => {
|
|
||||||
if (items.length === 0) {
|
|
||||||
matchedElement.innerHTML = '<div class="alert alert-info">No matched items yet</div>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let html = '<div class="feed-items-container">';
|
|
||||||
items.forEach(item => {
|
|
||||||
const date = new Date(item.publishDate);
|
|
||||||
const classes = `feed-item matched ${item.isDownloaded ? 'downloaded' : ''}`;
|
|
||||||
|
|
||||||
html += `<div class="${classes}" data-item-id="${item.id}">
|
|
||||||
<div class="feed-item-title"><a href="${item.link}" target="_blank">${item.title}</a></div>
|
|
||||||
<div class="feed-item-date">${formatDate(date)}</div>
|
|
||||||
<div class="text-success small">Matched rule: ${item.matchedRule}</div>
|
|
||||||
${item.isDownloaded ? '<div class="text-muted small">Downloaded</div>' : ''}
|
|
||||||
${!item.isDownloaded ?
|
|
||||||
`<div class="feed-item-buttons">
|
|
||||||
<button class="btn btn-sm btn-primary btn-download-matched-item" data-item-id="${item.id}">Download</button>
|
|
||||||
</div>` : ''
|
|
||||||
}
|
|
||||||
</div>`;
|
|
||||||
});
|
|
||||||
html += '</div>';
|
|
||||||
|
|
||||||
matchedElement.innerHTML = html;
|
|
||||||
|
|
||||||
// Add event listeners
|
|
||||||
document.querySelectorAll('.btn-download-matched-item').forEach(btn => {
|
|
||||||
btn.addEventListener('click', function() {
|
|
||||||
const itemId = this.getAttribute('data-item-id');
|
|
||||||
downloadItem(itemId);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.catch(error => {
|
|
||||||
console.error('Error loading matched items:', error);
|
|
||||||
matchedElement.innerHTML = '<div class="alert alert-danger">Error loading matched items</div>';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function showAddFeedModal() {
|
|
||||||
// Clear form
|
|
||||||
document.getElementById('feed-name').value = '';
|
|
||||||
document.getElementById('feed-url').value = '';
|
|
||||||
document.getElementById('feed-rules').value = '';
|
|
||||||
document.getElementById('feed-auto-download').checked = false;
|
|
||||||
|
|
||||||
// Update modal title and button text
|
|
||||||
document.querySelector('#add-feed-modal .modal-title').textContent = 'Add RSS Feed';
|
|
||||||
document.getElementById('save-feed-btn').textContent = 'Add Feed';
|
|
||||||
|
|
||||||
// Remove feed ID data attribute
|
|
||||||
document.getElementById('save-feed-btn').removeAttribute('data-feed-id');
|
|
||||||
|
|
||||||
// Show modal
|
|
||||||
const modal = new bootstrap.Modal(document.getElementById('add-feed-modal'));
|
|
||||||
modal.show();
|
|
||||||
}
|
|
||||||
|
|
||||||
function editFeed(feedId) {
|
|
||||||
// Fetch feed data
|
|
||||||
fetch(`/api/feeds`)
|
|
||||||
.then(response => response.json())
|
|
||||||
.then(feeds => {
|
|
||||||
const feed = feeds.find(f => f.id === feedId);
|
|
||||||
if (!feed) {
|
|
||||||
alert('Feed not found');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Populate form
|
|
||||||
document.getElementById('feed-name').value = feed.name;
|
|
||||||
document.getElementById('feed-url').value = feed.url;
|
|
||||||
document.getElementById('feed-rules').value = feed.rules.join('\n');
|
|
||||||
document.getElementById('feed-auto-download').checked = feed.autoDownload;
|
|
||||||
|
|
||||||
// Update modal title and button text
|
|
||||||
document.querySelector('#add-feed-modal .modal-title').textContent = 'Edit RSS Feed';
|
|
||||||
document.getElementById('save-feed-btn').textContent = 'Save Changes';
|
|
||||||
|
|
||||||
// Add feed ID data attribute
|
|
||||||
document.getElementById('save-feed-btn').setAttribute('data-feed-id', feedId);
|
|
||||||
|
|
||||||
// Show modal
|
|
||||||
const modal = new bootstrap.Modal(document.getElementById('add-feed-modal'));
|
|
||||||
modal.show();
|
|
||||||
})
|
|
||||||
.catch(error => {
|
|
||||||
console.error('Error fetching feed:', error);
|
|
||||||
alert('Error fetching feed');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveFeed() {
|
|
||||||
const name = document.getElementById('feed-name').value.trim();
|
|
||||||
const url = document.getElementById('feed-url').value.trim();
|
|
||||||
const rulesText = document.getElementById('feed-rules').value.trim();
|
|
||||||
const autoDownload = document.getElementById('feed-auto-download').checked;
|
|
||||||
|
|
||||||
if (!name || !url) {
|
|
||||||
alert('Please enter a name and URL');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse rules (split by new line and remove empty lines)
|
|
||||||
const rules = rulesText.split('\n').filter(rule => rule.trim() !== '');
|
|
||||||
|
|
||||||
const feedId = document.getElementById('save-feed-btn').getAttribute('data-feed-id');
|
|
||||||
const isEditing = !!feedId;
|
|
||||||
|
|
||||||
const feedData = {
|
|
||||||
name: name,
|
|
||||||
url: url,
|
|
||||||
rules: rules,
|
|
||||||
autoDownload: autoDownload
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isEditing) {
|
|
||||||
feedData.id = feedId;
|
|
||||||
|
|
||||||
// Update existing feed
|
|
||||||
fetch(`/api/feeds/${feedId}`, {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify(feedData)
|
|
||||||
})
|
|
||||||
.then(response => {
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Failed to update feed');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close modal
|
|
||||||
const modal = bootstrap.Modal.getInstance(document.getElementById('add-feed-modal'));
|
|
||||||
modal.hide();
|
|
||||||
|
|
||||||
// Refresh feeds
|
|
||||||
loadFeeds();
|
|
||||||
})
|
|
||||||
.catch(error => {
|
|
||||||
console.error('Error updating feed:', error);
|
|
||||||
alert('Error updating feed');
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// Add new feed
|
|
||||||
fetch('/api/feeds', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify(feedData)
|
|
||||||
})
|
|
||||||
.then(response => {
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Failed to add feed');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close modal
|
|
||||||
const modal = bootstrap.Modal.getInstance(document.getElementById('add-feed-modal'));
|
|
||||||
modal.hide();
|
|
||||||
|
|
||||||
// Refresh feeds
|
|
||||||
loadFeeds();
|
|
||||||
// Also refresh items since a new feed might have new items
|
|
||||||
loadAllItems();
|
|
||||||
loadMatchedItems();
|
|
||||||
})
|
|
||||||
.catch(error => {
|
|
||||||
console.error('Error adding feed:', error);
|
|
||||||
alert('Error adding feed');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function deleteFeed(feedId) {
|
|
||||||
if (!confirm('Are you sure you want to delete this feed?')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
fetch(`/api/feeds/${feedId}`, {
|
|
||||||
method: 'DELETE'
|
|
||||||
})
|
|
||||||
.then(response => {
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Failed to delete feed');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Refresh feeds
|
|
||||||
loadFeeds();
|
|
||||||
// Also refresh items since items from this feed should be removed
|
|
||||||
loadAllItems();
|
|
||||||
loadMatchedItems();
|
|
||||||
})
|
|
||||||
.catch(error => {
|
|
||||||
console.error('Error deleting feed:', error);
|
|
||||||
alert('Error deleting feed');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function refreshFeeds() {
|
|
||||||
const btn = document.getElementById('btn-refresh-feeds');
|
|
||||||
btn.disabled = true;
|
|
||||||
btn.innerHTML = '<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> Refreshing...';
|
|
||||||
|
|
||||||
fetch('/api/feeds/refresh', {
|
|
||||||
method: 'POST'
|
|
||||||
})
|
|
||||||
.then(response => {
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Failed to refresh feeds');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Re-enable button
|
|
||||||
btn.disabled = false;
|
|
||||||
btn.textContent = 'Refresh Feeds';
|
|
||||||
|
|
||||||
// Refresh feed items
|
|
||||||
loadFeeds();
|
|
||||||
loadAllItems();
|
|
||||||
loadMatchedItems();
|
|
||||||
})
|
|
||||||
.catch(error => {
|
|
||||||
console.error('Error refreshing feeds:', error);
|
|
||||||
alert('Error refreshing feeds');
|
|
||||||
|
|
||||||
// Re-enable button
|
|
||||||
btn.disabled = false;
|
|
||||||
btn.textContent = 'Refresh Feeds';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function downloadItem(itemId) {
|
|
||||||
fetch(`/api/feeds/download/${itemId}`, {
|
|
||||||
method: 'POST'
|
|
||||||
})
|
|
||||||
.then(response => {
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Failed to download item');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Refresh items
|
|
||||||
loadAllItems();
|
|
||||||
loadMatchedItems();
|
|
||||||
// Also refresh torrents since a new torrent should be added
|
|
||||||
loadTorrents();
|
|
||||||
})
|
|
||||||
.catch(error => {
|
|
||||||
console.error('Error downloading item:', error);
|
|
||||||
alert('Error downloading item');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Torrents
|
|
||||||
function loadTorrents() {
|
|
||||||
const torrentsElement = document.getElementById('torrents-list');
|
|
||||||
torrentsElement.innerHTML = '<div class="d-flex justify-content-center"><div class="spinner-border spinner-border-sm text-primary" role="status"></div></div>';
|
|
||||||
|
|
||||||
fetch('/api/torrents')
|
|
||||||
.then(response => response.json())
|
|
||||||
.then(torrents => {
|
|
||||||
console.log('Loaded torrents:', torrents);
|
|
||||||
if (torrents.length === 0) {
|
|
||||||
torrentsElement.innerHTML = '<div class="alert alert-info">No torrents</div>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let html = '<div class="torrents-container">';
|
|
||||||
torrents.forEach(torrent => {
|
|
||||||
// Handle potential null or undefined values
|
|
||||||
if (!torrent || !torrent.name) {
|
|
||||||
console.warn('Invalid torrent data:', torrent);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Safely calculate percentages and sizes with error handling
|
|
||||||
let progressPercent = 0;
|
|
||||||
try {
|
|
||||||
progressPercent = Math.round((torrent.percentDone || 0) * 100);
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('Error calculating progress percent:', e);
|
|
||||||
}
|
|
||||||
|
|
||||||
let sizeInGB = '0.00';
|
|
||||||
try {
|
|
||||||
if (torrent.totalSize && torrent.totalSize > 0) {
|
|
||||||
sizeInGB = (torrent.totalSize / 1073741824).toFixed(2);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('Error calculating size in GB:', e);
|
|
||||||
}
|
|
||||||
|
|
||||||
const torrentStatus = torrent.status || 'Unknown';
|
|
||||||
const statusClass = torrentStatus.toLowerCase().replace(/\s+/g, '-');
|
|
||||||
|
|
||||||
html += `<div class="torrent-item" data-torrent-id="${torrent.id || 0}">
|
|
||||||
<div class="torrent-item-header">
|
|
||||||
<div class="torrent-item-title">${torrent.name}</div>
|
|
||||||
<span class="badge bg-${statusClass === 'seeding' ? 'success' : statusClass === 'downloading' ? 'primary' : statusClass === 'stopped' ? 'secondary' : 'info'}">${torrentStatus}</span>
|
|
||||||
</div>
|
|
||||||
<div class="torrent-item-progress">
|
|
||||||
<div class="progress">
|
|
||||||
<div class="progress-bar ${torrentStatus === 'Seeding' ? 'bg-success' : 'bg-primary'}" role="progressbar" style="width: ${progressPercent}%">${progressPercent}%</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="torrent-item-details">
|
|
||||||
<span>Size: ${sizeInGB} GB</span>
|
|
||||||
<span>Location: ${torrent.downloadDir || 'Unknown'}</span>
|
|
||||||
</div>
|
|
||||||
<div class="torrent-item-buttons">
|
|
||||||
${torrentStatus === 'Stopped' ?
|
|
||||||
`<button class="btn btn-sm btn-success me-2 btn-start-torrent" data-torrent-id="${torrent.id || 0}">Start</button>` :
|
|
||||||
`<button class="btn btn-sm btn-warning me-2 btn-stop-torrent" data-torrent-id="${torrent.id || 0}">Stop</button>`
|
|
||||||
}
|
|
||||||
<button class="btn btn-sm btn-danger me-2 btn-remove-torrent" data-torrent-id="${torrent.id || 0}">Remove</button>
|
|
||||||
${progressPercent >= 100 ?
|
|
||||||
`<button class="btn btn-sm btn-info btn-process-torrent" data-torrent-id="${torrent.id || 0}">Process</button>` : ''
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
});
|
|
||||||
html += '</div>';
|
|
||||||
|
|
||||||
torrentsElement.innerHTML = html;
|
|
||||||
|
|
||||||
// Add event listeners
|
|
||||||
document.querySelectorAll('.btn-start-torrent').forEach(btn => {
|
|
||||||
btn.addEventListener('click', function() {
|
|
||||||
const torrentId = parseInt(this.getAttribute('data-torrent-id'));
|
|
||||||
startTorrent(torrentId);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
document.querySelectorAll('.btn-stop-torrent').forEach(btn => {
|
|
||||||
btn.addEventListener('click', function() {
|
|
||||||
const torrentId = parseInt(this.getAttribute('data-torrent-id'));
|
|
||||||
stopTorrent(torrentId);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
document.querySelectorAll('.btn-remove-torrent').forEach(btn => {
|
|
||||||
btn.addEventListener('click', function() {
|
|
||||||
const torrentId = parseInt(this.getAttribute('data-torrent-id'));
|
|
||||||
removeTorrent(torrentId);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
document.querySelectorAll('.btn-process-torrent').forEach(btn => {
|
|
||||||
btn.addEventListener('click', function() {
|
|
||||||
const torrentId = parseInt(this.getAttribute('data-torrent-id'));
|
|
||||||
processTorrent(torrentId);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.catch(error => {
|
|
||||||
console.error('Error loading torrents:', error);
|
|
||||||
torrentsElement.innerHTML = '<div class="alert alert-danger">Error loading torrents</div>';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function showAddTorrentModal() {
|
|
||||||
// Clear form
|
|
||||||
document.getElementById('torrent-url').value = '';
|
|
||||||
document.getElementById('torrent-download-dir').value = '';
|
|
||||||
|
|
||||||
// Show modal
|
|
||||||
const modal = new bootstrap.Modal(document.getElementById('add-torrent-modal'));
|
|
||||||
modal.show();
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveTorrent() {
|
|
||||||
const url = document.getElementById('torrent-url').value.trim();
|
|
||||||
const downloadDir = document.getElementById('torrent-download-dir').value.trim();
|
|
||||||
|
|
||||||
if (!url) {
|
|
||||||
alert('Please enter a torrent URL or magnet link');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const torrentData = {
|
|
||||||
url: url
|
|
||||||
};
|
|
||||||
|
|
||||||
if (downloadDir) {
|
|
||||||
torrentData.downloadDir = downloadDir;
|
|
||||||
}
|
|
||||||
|
|
||||||
fetch('/api/torrents', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify(torrentData)
|
|
||||||
})
|
|
||||||
.then(response => {
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Failed to add torrent');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close modal
|
|
||||||
const modal = bootstrap.Modal.getInstance(document.getElementById('add-torrent-modal'));
|
|
||||||
modal.hide();
|
|
||||||
|
|
||||||
// Refresh torrents
|
|
||||||
loadTorrents();
|
|
||||||
})
|
|
||||||
.catch(error => {
|
|
||||||
console.error('Error adding torrent:', error);
|
|
||||||
alert('Error adding torrent');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function startTorrent(torrentId) {
|
|
||||||
fetch(`/api/torrents/${torrentId}/start`, {
|
|
||||||
method: 'POST'
|
|
||||||
})
|
|
||||||
.then(response => {
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Failed to start torrent');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Refresh torrents
|
|
||||||
loadTorrents();
|
|
||||||
})
|
|
||||||
.catch(error => {
|
|
||||||
console.error('Error starting torrent:', error);
|
|
||||||
alert('Error starting torrent');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function stopTorrent(torrentId) {
|
|
||||||
fetch(`/api/torrents/${torrentId}/stop`, {
|
|
||||||
method: 'POST'
|
|
||||||
})
|
|
||||||
.then(response => {
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Failed to stop torrent');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Refresh torrents
|
|
||||||
loadTorrents();
|
|
||||||
})
|
|
||||||
.catch(error => {
|
|
||||||
console.error('Error stopping torrent:', error);
|
|
||||||
alert('Error stopping torrent');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeTorrent(torrentId) {
|
|
||||||
if (!confirm('Are you sure you want to remove this torrent? The downloaded files will be kept.')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
fetch(`/api/torrents/${torrentId}`, {
|
|
||||||
method: 'DELETE'
|
|
||||||
})
|
|
||||||
.then(response => {
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Failed to remove torrent');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Refresh torrents
|
|
||||||
loadTorrents();
|
|
||||||
})
|
|
||||||
.catch(error => {
|
|
||||||
console.error('Error removing torrent:', error);
|
|
||||||
alert('Error removing torrent');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function processTorrent(torrentId) {
|
|
||||||
fetch(`/api/torrents/${torrentId}/process`, {
|
|
||||||
method: 'POST'
|
|
||||||
})
|
|
||||||
.then(response => {
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Failed to process torrent');
|
|
||||||
}
|
|
||||||
|
|
||||||
alert('Torrent processing started');
|
|
||||||
})
|
|
||||||
.catch(error => {
|
|
||||||
console.error('Error processing torrent:', error);
|
|
||||||
alert('Error processing torrent');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Settings
|
|
||||||
function loadSettings() {
|
|
||||||
const form = document.getElementById('settings-form');
|
|
||||||
|
|
||||||
fetch('/api/config')
|
|
||||||
.then(response => response.json())
|
|
||||||
.then(config => {
|
|
||||||
// Transmission settings
|
|
||||||
document.getElementById('transmission-host').value = config.transmission.host;
|
|
||||||
document.getElementById('transmission-port').value = config.transmission.port;
|
|
||||||
document.getElementById('transmission-use-https').checked = config.transmission.useHttps;
|
|
||||||
document.getElementById('transmission-username').value = '';
|
|
||||||
document.getElementById('transmission-password').value = '';
|
|
||||||
|
|
||||||
// RSS settings
|
|
||||||
document.getElementById('auto-download-enabled').checked = config.autoDownloadEnabled;
|
|
||||||
document.getElementById('check-interval').value = config.checkIntervalMinutes;
|
|
||||||
|
|
||||||
// Directory settings
|
|
||||||
document.getElementById('download-directory').value = config.downloadDirectory;
|
|
||||||
document.getElementById('media-library').value = config.mediaLibraryPath;
|
|
||||||
|
|
||||||
// Post processing settings
|
|
||||||
document.getElementById('post-processing-enabled').checked = config.postProcessing.enabled;
|
|
||||||
document.getElementById('extract-archives').checked = config.postProcessing.extractArchives;
|
|
||||||
document.getElementById('organize-media').checked = config.postProcessing.organizeMedia;
|
|
||||||
document.getElementById('minimum-seed-ratio').value = config.postProcessing.minimumSeedRatio;
|
|
||||||
document.getElementById('media-extensions').value = config.postProcessing.mediaExtensions.join(', ');
|
|
||||||
})
|
|
||||||
.catch(error => {
|
|
||||||
console.error('Error loading settings:', error);
|
|
||||||
alert('Error loading settings');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveSettings(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
const config = {
|
|
||||||
transmission: {
|
|
||||||
host: document.getElementById('transmission-host').value.trim(),
|
|
||||||
port: parseInt(document.getElementById('transmission-port').value),
|
|
||||||
useHttps: document.getElementById('transmission-use-https').checked,
|
|
||||||
username: document.getElementById('transmission-username').value.trim(),
|
|
||||||
password: document.getElementById('transmission-password').value.trim()
|
|
||||||
},
|
|
||||||
autoDownloadEnabled: document.getElementById('auto-download-enabled').checked,
|
|
||||||
checkIntervalMinutes: parseInt(document.getElementById('check-interval').value),
|
|
||||||
downloadDirectory: document.getElementById('download-directory').value.trim(),
|
|
||||||
mediaLibraryPath: document.getElementById('media-library').value.trim(),
|
|
||||||
postProcessing: {
|
|
||||||
enabled: document.getElementById('post-processing-enabled').checked,
|
|
||||||
extractArchives: document.getElementById('extract-archives').checked,
|
|
||||||
organizeMedia: document.getElementById('organize-media').checked,
|
|
||||||
minimumSeedRatio: parseInt(document.getElementById('minimum-seed-ratio').value),
|
|
||||||
mediaExtensions: document.getElementById('media-extensions').value.split(',').map(ext => ext.trim())
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
fetch('/api/config', {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify(config)
|
|
||||||
})
|
|
||||||
.then(response => {
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Failed to save settings');
|
|
||||||
}
|
|
||||||
|
|
||||||
alert('Settings saved successfully');
|
|
||||||
})
|
|
||||||
.catch(error => {
|
|
||||||
console.error('Error saving settings:', error);
|
|
||||||
alert('Error saving settings');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper functions
|
|
||||||
function formatDate(date) {
|
|
||||||
if (!date) return 'N/A';
|
|
||||||
|
|
||||||
// Format as "YYYY-MM-DD HH:MM"
|
|
||||||
return `${date.getFullYear()}-${padZero(date.getMonth() + 1)}-${padZero(date.getDate())} ${padZero(date.getHours())}:${padZero(date.getMinutes())}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function padZero(num) {
|
|
||||||
return num.toString().padStart(2, '0');
|
|
||||||
}
|
|
48
temp_work/SUMMARY.md
Normal file
48
temp_work/SUMMARY.md
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
# Changes for Moving Config File to /etc/transmission-rss-manager
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
This update moves the configuration file from the application directory to `/etc/transmission-rss-manager/config.json` for better system organization, while maintaining backward compatibility by checking the original location as a fallback.
|
||||||
|
|
||||||
|
## Changes Made
|
||||||
|
|
||||||
|
### 1. Server.js Updates
|
||||||
|
- Changed default config location to `/etc/transmission-rss-manager/config.json`
|
||||||
|
- Added fallback location (`path.join(__dirname, 'config.json')`) for backward compatibility
|
||||||
|
- Enhanced `loadConfig()` function to try primary location first, then fallback location
|
||||||
|
- Updated `saveConfig()` function to try saving to primary location first, then fallback
|
||||||
|
- Added `installPath` property to configuration to store the application installation path for easier updates
|
||||||
|
|
||||||
|
### 2. Installer Updates
|
||||||
|
- Added `CONFIG_DIR="/etc/transmission-rss-manager"` variable to config-module.sh
|
||||||
|
- Updated `create_directories()` in dependencies-module.sh to create the config directory
|
||||||
|
- Updated permission settings to ensure proper access to config directory
|
||||||
|
- Added checks for the CONFIG_DIR variable to ensure it's set
|
||||||
|
- Modified service-setup-module.sh to include CONFIG_DIR in the environment variables for the systemd service
|
||||||
|
- Updated file-creator-module.sh to write the initial config.json to the new location
|
||||||
|
|
||||||
|
### 3. Documentation Updates
|
||||||
|
- Updated README.md to reflect new config file location
|
||||||
|
- Added entry to the changelog for version 2.0.3
|
||||||
|
- Updated code examples in README
|
||||||
|
- Bumped version from 2.0.2 to 2.0.3 in both README.md and package.json
|
||||||
|
|
||||||
|
## Technical Details
|
||||||
|
|
||||||
|
### Config File Loading Process
|
||||||
|
1. Try to load config from primary location (`/etc/transmission-rss-manager/config.json`)
|
||||||
|
2. If not found, try fallback location (`<install_dir>/config.json`)
|
||||||
|
3. If neither exists, create a new config file at primary location
|
||||||
|
4. If primary location is not writable, fall back to application directory
|
||||||
|
|
||||||
|
### Configuration Storage Logic
|
||||||
|
- Primary storage location: `/etc/transmission-rss-manager/config.json`
|
||||||
|
- Fallback location: `<install_dir>/config.json`
|
||||||
|
- Automatic migration from fallback to primary when possible
|
||||||
|
- Always try to write to primary first, then fallback if needed
|
||||||
|
|
||||||
|
## Benefits
|
||||||
|
- More standard Linux configuration location in /etc
|
||||||
|
- Easier to find and edit configuration
|
||||||
|
- Clear separation between code and configuration
|
||||||
|
- Maintains backward compatibility with existing installations
|
||||||
|
- Simplified update process by storing installation path
|
131
temp_work/app-update.js
Normal file
131
temp_work/app-update.js
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
// Client-side JavaScript for system status and updates
|
||||||
|
// Add this to the public/js/app.js file
|
||||||
|
|
||||||
|
// System status and updates functionality
|
||||||
|
function initSystemStatus() {
|
||||||
|
// Elements
|
||||||
|
const versionElement = document.getElementById('system-version');
|
||||||
|
const uptimeElement = document.getElementById('uptime');
|
||||||
|
const transmissionStatusElement = document.getElementById('transmission-status');
|
||||||
|
const updateStatusElement = document.getElementById('update-status');
|
||||||
|
const updateAvailableDiv = document.getElementById('update-available');
|
||||||
|
const updateButton = document.getElementById('btn-update-now');
|
||||||
|
const refreshButton = document.getElementById('btn-refresh-status');
|
||||||
|
|
||||||
|
// Load system status
|
||||||
|
function loadSystemStatus() {
|
||||||
|
fetch('/api/system/status')
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.status === 'success') {
|
||||||
|
versionElement.textContent = data.data.version;
|
||||||
|
uptimeElement.textContent = data.data.uptime;
|
||||||
|
|
||||||
|
// Update transmission status with icon
|
||||||
|
if (data.data.transmissionStatus === 'Connected') {
|
||||||
|
transmissionStatusElement.innerHTML = '<i class="fas fa-check-circle text-success"></i> Connected';
|
||||||
|
} else {
|
||||||
|
transmissionStatusElement.innerHTML = '<i class="fas fa-times-circle text-danger"></i> Disconnected';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
showToast('error', 'Failed to load system status');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error fetching system status:', error);
|
||||||
|
showToast('error', 'Failed to connect to server');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for updates
|
||||||
|
function checkForUpdates() {
|
||||||
|
updateStatusElement.innerHTML = '<i class="fas fa-circle-notch fa-spin"></i> Checking...';
|
||||||
|
updateAvailableDiv.classList.add('d-none');
|
||||||
|
|
||||||
|
fetch('/api/system/check-updates')
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.status === 'success') {
|
||||||
|
if (data.data.updateAvailable) {
|
||||||
|
updateStatusElement.innerHTML = '<i class="fas fa-exclamation-circle text-warning"></i> Update available';
|
||||||
|
updateAvailableDiv.classList.remove('d-none');
|
||||||
|
updateAvailableDiv.querySelector('span').textContent =
|
||||||
|
`A new version is available: ${data.data.currentVersion} → ${data.data.remoteVersion}`;
|
||||||
|
} else {
|
||||||
|
updateStatusElement.innerHTML = '<i class="fas fa-check-circle text-success"></i> Up to date';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
updateStatusElement.innerHTML = '<i class="fas fa-times-circle text-danger"></i> Check failed';
|
||||||
|
showToast('error', data.message || 'Failed to check for updates');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error checking for updates:', error);
|
||||||
|
updateStatusElement.innerHTML = '<i class="fas fa-times-circle text-danger"></i> Check failed';
|
||||||
|
showToast('error', 'Failed to connect to server');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply update
|
||||||
|
function applyUpdate() {
|
||||||
|
// Show confirmation dialog
|
||||||
|
if (!confirm('Are you sure you want to update the application? The service will restart.')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show loading state
|
||||||
|
updateButton.disabled = true;
|
||||||
|
updateButton.innerHTML = '<i class="fas fa-circle-notch fa-spin"></i> Updating...';
|
||||||
|
showToast('info', 'Applying update. Please wait...');
|
||||||
|
|
||||||
|
fetch('/api/system/update', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.status === 'success') {
|
||||||
|
showToast('success', 'Update applied successfully. The page will reload in 30 seconds.');
|
||||||
|
// Set a timer to reload the page after the service has time to restart
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.reload();
|
||||||
|
}, 30000);
|
||||||
|
} else {
|
||||||
|
updateButton.disabled = false;
|
||||||
|
updateButton.innerHTML = '<i class="fas fa-download"></i> Update Now';
|
||||||
|
showToast('error', data.message || 'Failed to apply update');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error applying update:', error);
|
||||||
|
updateButton.disabled = false;
|
||||||
|
updateButton.innerHTML = '<i class="fas fa-download"></i> Update Now';
|
||||||
|
showToast('error', 'Failed to connect to server');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event listeners
|
||||||
|
if (refreshButton) {
|
||||||
|
refreshButton.addEventListener('click', () => {
|
||||||
|
loadSystemStatus();
|
||||||
|
checkForUpdates();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updateButton) {
|
||||||
|
updateButton.addEventListener('click', applyUpdate);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize
|
||||||
|
loadSystemStatus();
|
||||||
|
checkForUpdates();
|
||||||
|
|
||||||
|
// Set interval to refresh uptime every minute
|
||||||
|
setInterval(loadSystemStatus, 60000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call this function from the main init function
|
||||||
|
// Add this line to your document ready or DOMContentLoaded handler:
|
||||||
|
// initSystemStatus();
|
72
temp_work/bootstrap-installer.sh
Normal file
72
temp_work/bootstrap-installer.sh
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Transmission RSS Manager - Bootstrap Installer
|
||||||
|
# This script downloads the latest version from git and runs the setup
|
||||||
|
|
||||||
|
# Color and formatting
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
RED='\033[0;31m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
BOLD='\033[1m'
|
||||||
|
|
||||||
|
# Installation directory
|
||||||
|
INSTALL_DIR="/opt/trans-install"
|
||||||
|
REPO_URL="https://git.powerdata.dk/masterdraco/transmission-rss-manager.git"
|
||||||
|
|
||||||
|
# Check if running as root
|
||||||
|
if [ "$EUID" -ne 0 ]; then
|
||||||
|
echo -e "${RED}This script must be run as root or with sudo privileges.${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Display welcome message
|
||||||
|
echo -e "${GREEN}${BOLD}Transmission RSS Manager - Bootstrap Installer${NC}"
|
||||||
|
echo -e "This script will install the latest version from the git repository."
|
||||||
|
echo
|
||||||
|
|
||||||
|
# Check for git installation
|
||||||
|
echo -e "${YELLOW}Checking dependencies...${NC}"
|
||||||
|
if ! command -v git &> /dev/null; then
|
||||||
|
echo -e "Git not found. Installing git..."
|
||||||
|
apt-get update
|
||||||
|
apt-get install -y git
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if installation directory exists
|
||||||
|
if [ -d "$INSTALL_DIR" ]; then
|
||||||
|
echo -e "${YELLOW}Installation directory already exists.${NC}"
|
||||||
|
read -p "Do you want to remove it and perform a fresh install? (y/n): " choice
|
||||||
|
if [[ "$choice" =~ ^[Yy]$ ]]; then
|
||||||
|
echo "Removing existing installation..."
|
||||||
|
rm -rf "$INSTALL_DIR"
|
||||||
|
else
|
||||||
|
echo -e "${RED}Installation aborted.${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create installation directory
|
||||||
|
echo -e "${YELLOW}Creating installation directory...${NC}"
|
||||||
|
mkdir -p "$INSTALL_DIR"
|
||||||
|
|
||||||
|
# Clone the repository
|
||||||
|
echo -e "${YELLOW}Cloning the latest version from git...${NC}"
|
||||||
|
git clone "$REPO_URL" "$INSTALL_DIR"
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo -e "${RED}Failed to clone the repository.${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Run the main installer
|
||||||
|
echo -e "${YELLOW}Running the main installer...${NC}"
|
||||||
|
cd "$INSTALL_DIR"
|
||||||
|
chmod +x main-installer.sh
|
||||||
|
./main-installer.sh
|
||||||
|
|
||||||
|
# Installation complete
|
||||||
|
echo -e "${GREEN}${BOLD}Bootstrap installation complete!${NC}"
|
||||||
|
echo -e "Transmission RSS Manager has been installed in $INSTALL_DIR"
|
||||||
|
echo -e "You can access the web interface at http://localhost:3000"
|
||||||
|
echo
|
||||||
|
echo -e "To update in the future, use the update button in the System Status section of the web interface."
|
374
temp_work/config-module-updated.sh
Normal file
374
temp_work/config-module-updated.sh
Normal file
@ -0,0 +1,374 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Configuration module for Transmission RSS Manager Installation
|
||||||
|
|
||||||
|
# Configuration variables with defaults
|
||||||
|
INSTALL_DIR="/opt/transmission-rss-manager"
|
||||||
|
SERVICE_NAME="transmission-rss-manager"
|
||||||
|
PORT=3000
|
||||||
|
|
||||||
|
# Get default user safely - avoid using root
|
||||||
|
get_default_user() {
|
||||||
|
local default_user
|
||||||
|
|
||||||
|
# Try logname first to get the user who invoked sudo
|
||||||
|
if command -v logname &> /dev/null; then
|
||||||
|
default_user=$(logname 2>/dev/null)
|
||||||
|
fi
|
||||||
|
|
||||||
|
# If logname failed, try SUDO_USER
|
||||||
|
if [ -z "$default_user" ] && [ -n "$SUDO_USER" ]; then
|
||||||
|
default_user="$SUDO_USER"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Fallback to current user if both methods failed
|
||||||
|
if [ -z "$default_user" ]; then
|
||||||
|
default_user="$(whoami)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Ensure the user is not root
|
||||||
|
if [ "$default_user" = "root" ]; then
|
||||||
|
echo "nobody"
|
||||||
|
else
|
||||||
|
echo "$default_user"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Initialize default user
|
||||||
|
USER=$(get_default_user)
|
||||||
|
|
||||||
|
# Transmission configuration variables
|
||||||
|
TRANSMISSION_REMOTE=false
|
||||||
|
TRANSMISSION_HOST="localhost"
|
||||||
|
TRANSMISSION_PORT=9091
|
||||||
|
TRANSMISSION_USER=""
|
||||||
|
TRANSMISSION_PASS=""
|
||||||
|
TRANSMISSION_RPC_PATH="/transmission/rpc"
|
||||||
|
TRANSMISSION_DOWNLOAD_DIR="/var/lib/transmission-daemon/downloads"
|
||||||
|
TRANSMISSION_DIR_MAPPING="{}"
|
||||||
|
|
||||||
|
# Media path defaults
|
||||||
|
MEDIA_DIR="/mnt/media"
|
||||||
|
ENABLE_BOOK_SORTING=true
|
||||||
|
|
||||||
|
# Helper function to validate port number
|
||||||
|
validate_port() {
|
||||||
|
local port="$1"
|
||||||
|
if [[ "$port" =~ ^[0-9]+$ ]] && [ "$port" -ge 1 ] && [ "$port" -le 65535 ]; then
|
||||||
|
return 0
|
||||||
|
else
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Helper function to validate URL hostname
|
||||||
|
validate_hostname() {
|
||||||
|
local hostname="$1"
|
||||||
|
if [[ "$hostname" =~ ^[a-zA-Z0-9]([a-zA-Z0-9\-\.]+[a-zA-Z0-9])?$ ]]; then
|
||||||
|
return 0
|
||||||
|
elif [[ "$hostname" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||||
|
return 0
|
||||||
|
else
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
function gather_configuration() {
|
||||||
|
log "INFO" "Starting configuration gathering"
|
||||||
|
echo -e "${BOLD}Installation Configuration:${NC}"
|
||||||
|
echo -e "Please provide the following configuration parameters:"
|
||||||
|
echo
|
||||||
|
|
||||||
|
read -p "Installation directory [$INSTALL_DIR]: " input_install_dir
|
||||||
|
if [ -n "$input_install_dir" ]; then
|
||||||
|
# Validate installation directory
|
||||||
|
if [[ ! "$input_install_dir" =~ ^/ ]]; then
|
||||||
|
log "WARN" "Installation directory must be an absolute path. Using default."
|
||||||
|
else
|
||||||
|
INSTALL_DIR="$input_install_dir"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Using fixed port 3000 to avoid permission issues with ports below 1024
|
||||||
|
log "INFO" "Using port 3000 for the web interface"
|
||||||
|
log "INFO" "This is to avoid permission issues with ports below 1024 (like port 80)"
|
||||||
|
PORT=3000
|
||||||
|
|
||||||
|
# Get user
|
||||||
|
read -p "Run as user [$USER]: " input_user
|
||||||
|
if [ -n "$input_user" ]; then
|
||||||
|
# Check if user exists
|
||||||
|
if id "$input_user" &>/dev/null; then
|
||||||
|
USER="$input_user"
|
||||||
|
else
|
||||||
|
log "WARN" "User $input_user does not exist. Using $USER instead."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo -e "${BOLD}Transmission Configuration:${NC}"
|
||||||
|
echo -e "Configure connection to your Transmission client:"
|
||||||
|
echo
|
||||||
|
|
||||||
|
# Ask if Transmission is remote
|
||||||
|
read -p "Is Transmission running on a remote server? (y/n) [n]: " input_remote
|
||||||
|
if [[ $input_remote =~ ^[Yy]$ ]]; then
|
||||||
|
TRANSMISSION_REMOTE=true
|
||||||
|
|
||||||
|
# Get and validate hostname
|
||||||
|
while true; do
|
||||||
|
read -p "Remote Transmission host [localhost]: " input_trans_host
|
||||||
|
if [ -z "$input_trans_host" ]; then
|
||||||
|
break
|
||||||
|
elif validate_hostname "$input_trans_host"; then
|
||||||
|
TRANSMISSION_HOST="$input_trans_host"
|
||||||
|
break
|
||||||
|
else
|
||||||
|
log "WARN" "Invalid hostname format."
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Get and validate port
|
||||||
|
while true; do
|
||||||
|
read -p "Remote Transmission port [9091]: " input_trans_port
|
||||||
|
if [ -z "$input_trans_port" ]; then
|
||||||
|
break
|
||||||
|
elif validate_port "$input_trans_port"; then
|
||||||
|
TRANSMISSION_PORT="$input_trans_port"
|
||||||
|
break
|
||||||
|
else
|
||||||
|
log "WARN" "Invalid port number. Port must be between 1 and 65535."
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Get credentials
|
||||||
|
read -p "Remote Transmission username []: " input_trans_user
|
||||||
|
TRANSMISSION_USER=${input_trans_user:-$TRANSMISSION_USER}
|
||||||
|
|
||||||
|
# Use read -s for password to avoid showing it on screen
|
||||||
|
read -s -p "Remote Transmission password []: " input_trans_pass
|
||||||
|
echo # Add a newline after the password input
|
||||||
|
if [ -n "$input_trans_pass" ]; then
|
||||||
|
# TODO: In a production environment, consider encrypting this password
|
||||||
|
TRANSMISSION_PASS="$input_trans_pass"
|
||||||
|
fi
|
||||||
|
|
||||||
|
read -p "Remote Transmission RPC path [/transmission/rpc]: " input_trans_path
|
||||||
|
if [ -n "$input_trans_path" ]; then
|
||||||
|
# Ensure path starts with / for consistency
|
||||||
|
if [[ ! "$input_trans_path" =~ ^/ ]]; then
|
||||||
|
input_trans_path="/$input_trans_path"
|
||||||
|
fi
|
||||||
|
TRANSMISSION_RPC_PATH="$input_trans_path"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Configure directory mapping for remote setup
|
||||||
|
echo
|
||||||
|
echo -e "${YELLOW}Directory Mapping Configuration${NC}"
|
||||||
|
echo -e "When using a remote Transmission server, you need to map paths between servers."
|
||||||
|
echo -e "For each directory on the remote server, specify the corresponding local directory."
|
||||||
|
echo
|
||||||
|
|
||||||
|
# Get remote download directory
|
||||||
|
read -p "Remote Transmission download directory: " REMOTE_DOWNLOAD_DIR
|
||||||
|
REMOTE_DOWNLOAD_DIR=${REMOTE_DOWNLOAD_DIR:-"/var/lib/transmission-daemon/downloads"}
|
||||||
|
|
||||||
|
# Get local directory that corresponds to remote download directory
|
||||||
|
read -p "Local directory that corresponds to the remote download directory: " LOCAL_DOWNLOAD_DIR
|
||||||
|
LOCAL_DOWNLOAD_DIR=${LOCAL_DOWNLOAD_DIR:-"/mnt/transmission-downloads"}
|
||||||
|
|
||||||
|
# Create mapping JSON - use proper JSON escaping for directory paths
|
||||||
|
REMOTE_DOWNLOAD_DIR_ESCAPED=$(echo "$REMOTE_DOWNLOAD_DIR" | sed 's/\\/\\\\/g' | sed 's/"/\\"/g')
|
||||||
|
LOCAL_DOWNLOAD_DIR_ESCAPED=$(echo "$LOCAL_DOWNLOAD_DIR" | sed 's/\\/\\\\/g' | sed 's/"/\\"/g')
|
||||||
|
|
||||||
|
TRANSMISSION_DIR_MAPPING="{\"$REMOTE_DOWNLOAD_DIR_ESCAPED\": \"$LOCAL_DOWNLOAD_DIR_ESCAPED\"}"
|
||||||
|
|
||||||
|
# Create the local directory
|
||||||
|
if ! mkdir -p "$LOCAL_DOWNLOAD_DIR"; then
|
||||||
|
log "ERROR" "Failed to create local download directory: $LOCAL_DOWNLOAD_DIR"
|
||||||
|
else
|
||||||
|
if ! chown -R "$USER:$USER" "$LOCAL_DOWNLOAD_DIR"; then
|
||||||
|
log "ERROR" "Failed to set permissions on local download directory: $LOCAL_DOWNLOAD_DIR"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Ask if want to add more mappings
|
||||||
|
while true; do
|
||||||
|
read -p "Add another directory mapping? (y/n) [n]: " add_another
|
||||||
|
if [[ ! $add_another =~ ^[Yy]$ ]]; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
|
||||||
|
read -p "Remote directory path: " remote_dir
|
||||||
|
read -p "Corresponding local directory path: " local_dir
|
||||||
|
|
||||||
|
if [ -n "$remote_dir" ] && [ -n "$local_dir" ]; then
|
||||||
|
# Escape directory paths for JSON
|
||||||
|
remote_dir_escaped=$(echo "$remote_dir" | sed 's/\\/\\\\/g' | sed 's/"/\\"/g')
|
||||||
|
local_dir_escaped=$(echo "$local_dir" | sed 's/\\/\\\\/g' | sed 's/"/\\"/g')
|
||||||
|
|
||||||
|
# Update mapping JSON (proper JSON manipulation)
|
||||||
|
# Remove the closing brace, add a comma and the new mapping, then close with brace
|
||||||
|
TRANSMISSION_DIR_MAPPING="${TRANSMISSION_DIR_MAPPING%\}}, \"$remote_dir_escaped\": \"$local_dir_escaped\"}"
|
||||||
|
|
||||||
|
# Create the local directory
|
||||||
|
if ! mkdir -p "$local_dir"; then
|
||||||
|
log "ERROR" "Failed to create directory: $local_dir"
|
||||||
|
else
|
||||||
|
if ! chown -R "$USER:$USER" "$local_dir"; then
|
||||||
|
log "WARN" "Failed to set permissions on directory: $local_dir"
|
||||||
|
fi
|
||||||
|
log "INFO" "Mapping added: $remote_dir → $local_dir"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Set Transmission download dir for configuration
|
||||||
|
TRANSMISSION_DOWNLOAD_DIR=$REMOTE_DOWNLOAD_DIR
|
||||||
|
else
|
||||||
|
# Local Transmission selected
|
||||||
|
echo -e "${YELLOW}You've selected to use a local Transmission installation.${NC}"
|
||||||
|
|
||||||
|
# Check if Transmission is already installed
|
||||||
|
if command -v transmission-daemon &> /dev/null; then
|
||||||
|
echo -e "${GREEN}Transmission is already installed on this system.${NC}"
|
||||||
|
else
|
||||||
|
echo -e "${YELLOW}NOTE: Transmission does not appear to be installed on this system.${NC}"
|
||||||
|
echo -e "${YELLOW}You will be prompted to install it during the dependency installation step.${NC}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Get and validate port
|
||||||
|
while true; do
|
||||||
|
read -p "Local Transmission port [9091]: " input_trans_port
|
||||||
|
if [ -z "$input_trans_port" ]; then
|
||||||
|
break
|
||||||
|
elif validate_port "$input_trans_port"; then
|
||||||
|
TRANSMISSION_PORT="$input_trans_port"
|
||||||
|
break
|
||||||
|
else
|
||||||
|
log "WARN" "Invalid port number. Port must be between 1 and 65535."
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Get credentials if any
|
||||||
|
read -p "Local Transmission username (leave empty if authentication is disabled) []: " input_trans_user
|
||||||
|
TRANSMISSION_USER=${input_trans_user:-$TRANSMISSION_USER}
|
||||||
|
|
||||||
|
if [ -n "$input_trans_user" ]; then
|
||||||
|
# Use read -s for password to hide it
|
||||||
|
read -s -p "Local Transmission password []: " input_trans_pass
|
||||||
|
echo # Add a newline after the password input
|
||||||
|
if [ -n "$input_trans_pass" ]; then
|
||||||
|
TRANSMISSION_PASS="$input_trans_pass"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
read -p "Transmission download directory [/var/lib/transmission-daemon/downloads]: " input_trans_dir
|
||||||
|
if [ -n "$input_trans_dir" ]; then
|
||||||
|
if [[ ! "$input_trans_dir" =~ ^/ ]]; then
|
||||||
|
log "WARN" "Download directory must be an absolute path. Using default."
|
||||||
|
else
|
||||||
|
TRANSMISSION_DOWNLOAD_DIR="$input_trans_dir"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo -e "${BOLD}Media Destination Configuration:${NC}"
|
||||||
|
|
||||||
|
read -p "Media destination base directory [/mnt/media]: " input_media_dir
|
||||||
|
if [ -n "$input_media_dir" ]; then
|
||||||
|
if [[ ! "$input_media_dir" =~ ^/ ]]; then
|
||||||
|
log "WARN" "Media directory must be an absolute path. Using default."
|
||||||
|
else
|
||||||
|
MEDIA_DIR="$input_media_dir"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Ask about enabling book/magazine sorting
|
||||||
|
echo
|
||||||
|
echo -e "${BOLD}Content Type Configuration:${NC}"
|
||||||
|
read -p "Enable book and magazine sorting? (y/n) [y]: " input_book_sorting
|
||||||
|
if [[ $input_book_sorting =~ ^[Nn]$ ]]; then
|
||||||
|
ENABLE_BOOK_SORTING=false
|
||||||
|
else
|
||||||
|
ENABLE_BOOK_SORTING=true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Security configuration
|
||||||
|
echo
|
||||||
|
echo -e "${BOLD}Security Configuration:${NC}"
|
||||||
|
|
||||||
|
# Ask about enabling authentication
|
||||||
|
read -p "Enable authentication? (y/n) [n]: " input_auth_enabled
|
||||||
|
AUTH_ENABLED=false
|
||||||
|
ADMIN_USERNAME=""
|
||||||
|
ADMIN_PASSWORD=""
|
||||||
|
|
||||||
|
if [[ $input_auth_enabled =~ ^[Yy]$ ]]; then
|
||||||
|
AUTH_ENABLED=true
|
||||||
|
|
||||||
|
# Get admin username and password
|
||||||
|
read -p "Admin username [admin]: " input_admin_username
|
||||||
|
ADMIN_USERNAME=${input_admin_username:-"admin"}
|
||||||
|
|
||||||
|
# Use read -s for password to avoid showing it on screen
|
||||||
|
read -s -p "Admin password: " input_admin_password
|
||||||
|
echo # Add a newline after the password input
|
||||||
|
|
||||||
|
if [ -z "$input_admin_password" ]; then
|
||||||
|
# Generate a random password if none provided
|
||||||
|
ADMIN_PASSWORD=$(openssl rand -base64 12)
|
||||||
|
echo -e "${YELLOW}Generated random admin password: $ADMIN_PASSWORD${NC}"
|
||||||
|
echo -e "${YELLOW}Please save this password somewhere safe!${NC}"
|
||||||
|
else
|
||||||
|
ADMIN_PASSWORD="$input_admin_password"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Ask about enabling HTTPS
|
||||||
|
read -p "Enable HTTPS? (requires SSL certificate) (y/n) [n]: " input_https_enabled
|
||||||
|
HTTPS_ENABLED=false
|
||||||
|
SSL_CERT_PATH=""
|
||||||
|
SSL_KEY_PATH=""
|
||||||
|
|
||||||
|
if [[ $input_https_enabled =~ ^[Yy]$ ]]; then
|
||||||
|
HTTPS_ENABLED=true
|
||||||
|
|
||||||
|
# Get SSL certificate paths
|
||||||
|
read -p "SSL certificate path: " input_ssl_cert_path
|
||||||
|
if [ -n "$input_ssl_cert_path" ]; then
|
||||||
|
# Check if file exists
|
||||||
|
if [ -f "$input_ssl_cert_path" ]; then
|
||||||
|
SSL_CERT_PATH="$input_ssl_cert_path"
|
||||||
|
else
|
||||||
|
log "WARN" "SSL certificate file not found. HTTPS will be disabled."
|
||||||
|
HTTPS_ENABLED=false
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
log "WARN" "SSL certificate path not provided. HTTPS will be disabled."
|
||||||
|
HTTPS_ENABLED=false
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Only ask for key if cert was found
|
||||||
|
if [ "$HTTPS_ENABLED" = true ]; then
|
||||||
|
read -p "SSL key path: " input_ssl_key_path
|
||||||
|
if [ -n "$input_ssl_key_path" ]; then
|
||||||
|
# Check if file exists
|
||||||
|
if [ -f "$input_ssl_key_path" ]; then
|
||||||
|
SSL_KEY_PATH="$input_ssl_key_path"
|
||||||
|
else
|
||||||
|
log "WARN" "SSL key file not found. HTTPS will be disabled."
|
||||||
|
HTTPS_ENABLED=false
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
log "WARN" "SSL key path not provided. HTTPS will be disabled."
|
||||||
|
HTTPS_ENABLED=false
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
log "INFO" "Configuration gathering complete"
|
||||||
|
echo -e "${GREEN}Configuration complete!${NC}"
|
||||||
|
echo
|
||||||
|
}
|
184
temp_work/dependencies-module-updated.sh
Normal file
184
temp_work/dependencies-module-updated.sh
Normal file
@ -0,0 +1,184 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Dependencies module for Transmission RSS Manager Installation
|
||||||
|
|
||||||
|
function install_dependencies() {
|
||||||
|
log "INFO" "Installing dependencies..."
|
||||||
|
|
||||||
|
# Check for package manager
|
||||||
|
if command -v apt-get &> /dev/null; then
|
||||||
|
# Update package index
|
||||||
|
apt-get update
|
||||||
|
|
||||||
|
# Install Node.js and npm if not already installed
|
||||||
|
if ! command_exists node; then
|
||||||
|
log "INFO" "Installing Node.js and npm..."
|
||||||
|
apt-get install -y ca-certificates curl gnupg
|
||||||
|
mkdir -p /etc/apt/keyrings
|
||||||
|
|
||||||
|
# Check if download succeeds
|
||||||
|
if ! curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg; then
|
||||||
|
log "ERROR" "Failed to download Node.js GPG key"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_18.x nodistro main" > /etc/apt/sources.list.d/nodesource.list
|
||||||
|
|
||||||
|
# Update again after adding repo
|
||||||
|
apt-get update
|
||||||
|
|
||||||
|
# Install nodejs
|
||||||
|
if ! apt-get install -y nodejs; then
|
||||||
|
log "ERROR" "Failed to install Node.js"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
log "INFO" "Node.js is already installed."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if we need to install Transmission (only if local transmission was selected and not remote)
|
||||||
|
if [ "$TRANSMISSION_REMOTE" = false ] && ([ "$TRANSMISSION_HOST" = "localhost" ] || [ "$TRANSMISSION_HOST" = "127.0.0.1" ]); then
|
||||||
|
if ! command_exists transmission-daemon; then
|
||||||
|
log "INFO" "Local Transmission installation selected, but transmission-daemon is not installed."
|
||||||
|
read -p "Would you like to install Transmission now? (y/n): " install_transmission
|
||||||
|
|
||||||
|
if [[ "$install_transmission" =~ ^[Yy]$ ]]; then
|
||||||
|
log "INFO" "Installing Transmission..."
|
||||||
|
if ! apt-get install -y transmission-daemon; then
|
||||||
|
log "ERROR" "Failed to install Transmission"
|
||||||
|
log "WARN" "You will need to install Transmission manually before using this application."
|
||||||
|
else
|
||||||
|
# Stop transmission-daemon to allow configuration changes
|
||||||
|
systemctl stop transmission-daemon
|
||||||
|
|
||||||
|
# Set default settings
|
||||||
|
TRANSMISSION_SETTINGS_DIR="/var/lib/transmission-daemon/info"
|
||||||
|
if [ -f "$TRANSMISSION_SETTINGS_DIR/settings.json" ]; then
|
||||||
|
# Backup original settings
|
||||||
|
cp "$TRANSMISSION_SETTINGS_DIR/settings.json" "$TRANSMISSION_SETTINGS_DIR/settings.json.bak"
|
||||||
|
|
||||||
|
# Update RPC settings to allow our app to connect
|
||||||
|
sed -i 's/"rpc-authentication-required": true,/"rpc-authentication-required": false,/g' "$TRANSMISSION_SETTINGS_DIR/settings.json"
|
||||||
|
sed -i 's/"rpc-whitelist-enabled": true,/"rpc-whitelist-enabled": false,/g' "$TRANSMISSION_SETTINGS_DIR/settings.json"
|
||||||
|
|
||||||
|
log "INFO" "Transmission has been configured for local access."
|
||||||
|
else
|
||||||
|
log "WARN" "Could not find Transmission settings file. You may need to configure Transmission manually."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Start transmission-daemon
|
||||||
|
systemctl start transmission-daemon
|
||||||
|
log "INFO" "Transmission has been installed and started."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
log "WARN" "Transmission installation skipped. You will need to install it manually."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
log "INFO" "Transmission is already installed."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Install additional dependencies
|
||||||
|
log "INFO" "Installing additional dependencies..."
|
||||||
|
# Try to install unrar-free if unrar is not available
|
||||||
|
if ! apt-get install -y unrar 2>/dev/null; then
|
||||||
|
log "INFO" "unrar not available, trying unrar-free instead..."
|
||||||
|
apt-get install -y unrar-free
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Install other dependencies
|
||||||
|
apt-get install -y unzip p7zip-full
|
||||||
|
|
||||||
|
# Try to install nginx
|
||||||
|
apt-get install -y nginx || log "WARN" "Nginx installation failed, web interface may not be accessible"
|
||||||
|
else
|
||||||
|
log "ERROR" "This installer requires apt-get package manager"
|
||||||
|
log "INFO" "Please install the following dependencies manually:"
|
||||||
|
log "INFO" "- Node.js (v18.x)"
|
||||||
|
log "INFO" "- npm"
|
||||||
|
log "INFO" "- unrar"
|
||||||
|
log "INFO" "- unzip"
|
||||||
|
log "INFO" "- p7zip-full"
|
||||||
|
log "INFO" "- nginx"
|
||||||
|
if [ "$TRANSMISSION_REMOTE" = false ] && ([ "$TRANSMISSION_HOST" = "localhost" ] || [ "$TRANSMISSION_HOST" = "127.0.0.1" ]); then
|
||||||
|
log "INFO" "- transmission-daemon"
|
||||||
|
fi
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if all dependencies were installed successfully
|
||||||
|
local dependencies=("node" "npm" "unzip" "nginx")
|
||||||
|
local missing_deps=()
|
||||||
|
|
||||||
|
# Add transmission to dependencies check if local installation was selected and not remote
|
||||||
|
if [ "$TRANSMISSION_REMOTE" = false ] && ([ "$TRANSMISSION_HOST" = "localhost" ] || [ "$TRANSMISSION_HOST" = "127.0.0.1" ]); then
|
||||||
|
dependencies+=("transmission-daemon")
|
||||||
|
fi
|
||||||
|
|
||||||
|
for dep in "${dependencies[@]}"; do
|
||||||
|
if ! command_exists "$dep"; then
|
||||||
|
missing_deps+=("$dep")
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Check for either unrar or unrar-free
|
||||||
|
if ! command_exists "unrar" && ! command_exists "unrar-free"; then
|
||||||
|
missing_deps+=("unrar")
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check for either 7z or 7za (from p7zip-full)
|
||||||
|
if ! command_exists "7z" && ! command_exists "7za"; then
|
||||||
|
missing_deps+=("p7zip")
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ${#missing_deps[@]} -eq 0 ]; then
|
||||||
|
log "INFO" "All dependencies installed successfully."
|
||||||
|
else
|
||||||
|
log "ERROR" "Failed to install some dependencies: ${missing_deps[*]}"
|
||||||
|
log "WARN" "Please install them manually and rerun this script."
|
||||||
|
|
||||||
|
# More helpful information based on which deps are missing
|
||||||
|
if [[ " ${missing_deps[*]} " =~ " node " ]]; then
|
||||||
|
log "INFO" "To install Node.js manually, visit: https://nodejs.org/en/download/"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ " ${missing_deps[*]} " =~ " nginx " ]]; then
|
||||||
|
log "INFO" "To install nginx manually: sudo apt-get install nginx"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ " ${missing_deps[*]} " =~ " transmission-daemon " ]]; then
|
||||||
|
log "INFO" "To install Transmission manually: sudo apt-get install transmission-daemon"
|
||||||
|
log "INFO" "After installation, you may need to configure it by editing /var/lib/transmission-daemon/info/settings.json"
|
||||||
|
fi
|
||||||
|
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
function create_directories() {
|
||||||
|
log "INFO" "Creating installation directories..."
|
||||||
|
|
||||||
|
# Check if INSTALL_DIR is defined
|
||||||
|
if [ -z "$INSTALL_DIR" ]; then
|
||||||
|
log "ERROR" "INSTALL_DIR is not defined"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create directories and check for errors
|
||||||
|
DIRECTORIES=(
|
||||||
|
"$INSTALL_DIR"
|
||||||
|
"$INSTALL_DIR/logs"
|
||||||
|
"$INSTALL_DIR/public/js"
|
||||||
|
"$INSTALL_DIR/public/css"
|
||||||
|
"$INSTALL_DIR/modules"
|
||||||
|
"$INSTALL_DIR/data"
|
||||||
|
)
|
||||||
|
|
||||||
|
for dir in "${DIRECTORIES[@]}"; do
|
||||||
|
if ! mkdir -p "$dir"; then
|
||||||
|
log "ERROR" "Failed to create directory: $dir"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
log "INFO" "Directories created successfully."
|
||||||
|
}
|
241
temp_work/main-installer-modified.sh
Normal file
241
temp_work/main-installer-modified.sh
Normal file
@ -0,0 +1,241 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Transmission RSS Manager Modular Installer
|
||||||
|
# Modified to work with the git-based approach
|
||||||
|
|
||||||
|
# Set script to exit on error
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Text formatting
|
||||||
|
BOLD='\033[1m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[0;33m'
|
||||||
|
RED='\033[0;31m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
# Print header
|
||||||
|
echo -e "${BOLD}==================================================${NC}"
|
||||||
|
echo -e "${BOLD} Transmission RSS Manager Installer ${NC}"
|
||||||
|
echo -e "${BOLD} Version 1.2.0 - Git Edition ${NC}"
|
||||||
|
echo -e "${BOLD}==================================================${NC}"
|
||||||
|
echo
|
||||||
|
|
||||||
|
# Check if script is run with sudo
|
||||||
|
if [ "$EUID" -ne 0 ]; then
|
||||||
|
echo -e "${RED}Please run as root (use sudo)${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Get current directory
|
||||||
|
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )"
|
||||||
|
|
||||||
|
# Check for installation type
|
||||||
|
IS_UPDATE=false
|
||||||
|
if [ -f "${SCRIPT_DIR}/config.json" ]; then
|
||||||
|
IS_UPDATE=true
|
||||||
|
echo -e "${YELLOW}Existing installation detected. Running in update mode.${NC}"
|
||||||
|
echo -e "${GREEN}Your existing configuration will be preserved.${NC}"
|
||||||
|
else
|
||||||
|
echo -e "${GREEN}Fresh installation. Will create new configuration.${NC}"
|
||||||
|
fi
|
||||||
|
export IS_UPDATE
|
||||||
|
|
||||||
|
# Check if required module files exist
|
||||||
|
REQUIRED_MODULES=(
|
||||||
|
"${SCRIPT_DIR}/modules/config-module.sh"
|
||||||
|
"${SCRIPT_DIR}/modules/utils-module.sh"
|
||||||
|
"${SCRIPT_DIR}/modules/dependencies-module.sh"
|
||||||
|
"${SCRIPT_DIR}/modules/service-setup-module.sh"
|
||||||
|
)
|
||||||
|
|
||||||
|
for module in "${REQUIRED_MODULES[@]}"; do
|
||||||
|
if [ ! -f "$module" ]; then
|
||||||
|
echo -e "${RED}Error: Required module file not found: $module${NC}"
|
||||||
|
echo -e "${YELLOW}The module files should be included in the git repository.${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Source the module files
|
||||||
|
source "${SCRIPT_DIR}/modules/utils-module.sh" # Load utilities first for logging
|
||||||
|
source "${SCRIPT_DIR}/modules/config-module.sh"
|
||||||
|
source "${SCRIPT_DIR}/modules/dependencies-module.sh"
|
||||||
|
source "${SCRIPT_DIR}/modules/service-setup-module.sh"
|
||||||
|
|
||||||
|
# Function to handle cleanup on error
|
||||||
|
function cleanup_on_error() {
|
||||||
|
log "ERROR" "Installation failed: $1"
|
||||||
|
log "INFO" "Cleaning up..."
|
||||||
|
|
||||||
|
# Add any cleanup steps here if needed
|
||||||
|
|
||||||
|
log "INFO" "You can try running the installer again after fixing the issues."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Set trap for error handling
|
||||||
|
trap 'cleanup_on_error "$BASH_COMMAND"' ERR
|
||||||
|
|
||||||
|
# Execute the installation steps in sequence
|
||||||
|
log "INFO" "Starting installation process..."
|
||||||
|
|
||||||
|
# Step 1: Gather configuration from user
|
||||||
|
log "INFO" "Gathering configuration..."
|
||||||
|
gather_configuration || {
|
||||||
|
log "ERROR" "Configuration gathering failed"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Step 2: Install dependencies
|
||||||
|
log "INFO" "Installing dependencies..."
|
||||||
|
install_dependencies || {
|
||||||
|
log "ERROR" "Dependency installation failed"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Step 3: Create installation directories
|
||||||
|
log "INFO" "Creating directories..."
|
||||||
|
create_directories || {
|
||||||
|
log "ERROR" "Directory creation failed"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Step 4: Create configuration files only (no application files since they're from git)
|
||||||
|
log "INFO" "Creating configuration files..."
|
||||||
|
create_config_files || {
|
||||||
|
log "ERROR" "Configuration file creation failed"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Step 5: Create service files and install the service
|
||||||
|
log "INFO" "Setting up service..."
|
||||||
|
setup_service || {
|
||||||
|
log "ERROR" "Service setup failed"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Step 6: Install npm dependencies
|
||||||
|
log "INFO" "Installing npm dependencies..."
|
||||||
|
cd "$SCRIPT_DIR"
|
||||||
|
npm install || {
|
||||||
|
log "ERROR" "NPM installation failed"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Step 7: Set up update script
|
||||||
|
log "INFO" "Setting up update script..."
|
||||||
|
mkdir -p "${SCRIPT_DIR}/scripts"
|
||||||
|
cp "${SCRIPT_DIR}/scripts/update.sh" "${SCRIPT_DIR}/scripts/update.sh" 2>/dev/null || {
|
||||||
|
# If copy fails, it probably doesn't exist, so we'll create it
|
||||||
|
cat > "${SCRIPT_DIR}/scripts/update.sh" << 'EOL'
|
||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Transmission RSS Manager - Update Script
|
||||||
|
# This script pulls the latest version from git and runs necessary updates
|
||||||
|
|
||||||
|
# Color and formatting
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
RED='\033[0;31m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
BOLD='\033[1m'
|
||||||
|
|
||||||
|
# Installation directory (should be current directory)
|
||||||
|
INSTALL_DIR=$(pwd)
|
||||||
|
|
||||||
|
# Check if we're in the right directory
|
||||||
|
if [ ! -f "$INSTALL_DIR/package.json" ] || [ ! -d "$INSTALL_DIR/modules" ]; then
|
||||||
|
echo -e "${RED}Error: This script must be run from the installation directory.${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Get the current version
|
||||||
|
CURRENT_VERSION=$(grep -oP '"version": "\K[^"]+' package.json)
|
||||||
|
echo -e "${YELLOW}Current version: ${BOLD}$CURRENT_VERSION${NC}"
|
||||||
|
|
||||||
|
# Check for git repository
|
||||||
|
if [ ! -d ".git" ]; then
|
||||||
|
echo -e "${RED}Error: This installation was not set up using git.${NC}"
|
||||||
|
echo -e "Please use the bootstrap installer to perform a fresh installation."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Stash any local changes
|
||||||
|
echo -e "${YELLOW}Backing up any local configuration changes...${NC}"
|
||||||
|
git stash -q
|
||||||
|
|
||||||
|
# Pull the latest changes
|
||||||
|
echo -e "${YELLOW}Pulling latest updates from git...${NC}"
|
||||||
|
git pull
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo -e "${RED}Failed to pull updates. Restoring original state...${NC}"
|
||||||
|
git stash pop -q
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Get the new version
|
||||||
|
NEW_VERSION=$(grep -oP '"version": "\K[^"]+' package.json)
|
||||||
|
echo -e "${GREEN}New version: ${BOLD}$NEW_VERSION${NC}"
|
||||||
|
|
||||||
|
# Check if update is needed
|
||||||
|
if [ "$CURRENT_VERSION" == "$NEW_VERSION" ]; then
|
||||||
|
echo -e "${GREEN}You already have the latest version.${NC}"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Install any new npm dependencies
|
||||||
|
echo -e "${YELLOW}Installing dependencies...${NC}"
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Apply any local configuration changes
|
||||||
|
if git stash list | grep -q "stash@{0}"; then
|
||||||
|
echo -e "${YELLOW}Restoring local configuration changes...${NC}"
|
||||||
|
git stash pop -q
|
||||||
|
# Handle conflicts if any
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo -e "${RED}There were conflicts when restoring your configuration.${NC}"
|
||||||
|
echo -e "Please check the files and resolve conflicts manually."
|
||||||
|
echo -e "Your original configuration is saved in .git/refs/stash"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Restart the service
|
||||||
|
echo -e "${YELLOW}Restarting service...${NC}"
|
||||||
|
if command -v systemctl &> /dev/null; then
|
||||||
|
sudo systemctl restart transmission-rss-manager
|
||||||
|
else
|
||||||
|
echo -e "${RED}Could not restart service automatically.${NC}"
|
||||||
|
echo -e "Please restart the service manually."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Update complete
|
||||||
|
echo -e "${GREEN}${BOLD}Update complete!${NC}"
|
||||||
|
echo -e "Updated from version $CURRENT_VERSION to $NEW_VERSION"
|
||||||
|
echo -e "Changes will take effect immediately."
|
||||||
|
EOL
|
||||||
|
|
||||||
|
chmod +x "${SCRIPT_DIR}/scripts/update.sh"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Step 8: Final setup and permissions
|
||||||
|
log "INFO" "Finalizing setup..."
|
||||||
|
finalize_setup || {
|
||||||
|
log "ERROR" "Setup finalization failed"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Installation complete
|
||||||
|
echo
|
||||||
|
echo -e "${BOLD}${GREEN}==================================================${NC}"
|
||||||
|
echo -e "${BOLD}${GREEN} Installation Complete! ${NC}"
|
||||||
|
echo -e "${BOLD}${GREEN}==================================================${NC}"
|
||||||
|
echo -e "You can access the web interface at: ${BOLD}http://localhost:$PORT${NC} or ${BOLD}http://your-server-ip:$PORT${NC}"
|
||||||
|
echo -e "You may need to configure your firewall to allow access to port $PORT"
|
||||||
|
echo
|
||||||
|
echo -e "${BOLD}Useful Commands:${NC}"
|
||||||
|
echo -e " To check the service status: ${YELLOW}systemctl status $SERVICE_NAME${NC}"
|
||||||
|
echo -e " To view logs: ${YELLOW}journalctl -u $SERVICE_NAME${NC}"
|
||||||
|
echo -e " To restart the service: ${YELLOW}systemctl restart $SERVICE_NAME${NC}"
|
||||||
|
echo -e " To update the application: ${YELLOW}Use the Update button in the System Status section${NC}"
|
||||||
|
echo
|
||||||
|
echo -e "Thank you for installing Transmission RSS Manager!"
|
||||||
|
echo -e "${BOLD}==================================================${NC}"
|
84
temp_work/scripts/update.sh
Normal file
84
temp_work/scripts/update.sh
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Transmission RSS Manager - Update Script
|
||||||
|
# This script pulls the latest version from git and runs necessary updates
|
||||||
|
|
||||||
|
# Color and formatting
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
RED='\033[0;31m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
BOLD='\033[1m'
|
||||||
|
|
||||||
|
# Installation directory (should be current directory)
|
||||||
|
INSTALL_DIR=$(pwd)
|
||||||
|
|
||||||
|
# Check if we're in the right directory
|
||||||
|
if [ ! -f "$INSTALL_DIR/package.json" ] || [ ! -d "$INSTALL_DIR/modules" ]; then
|
||||||
|
echo -e "${RED}Error: This script must be run from the installation directory.${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Get the current version
|
||||||
|
CURRENT_VERSION=$(grep -oP '"version": "\K[^"]+' package.json)
|
||||||
|
echo -e "${YELLOW}Current version: ${BOLD}$CURRENT_VERSION${NC}"
|
||||||
|
|
||||||
|
# Check for git repository
|
||||||
|
if [ ! -d ".git" ]; then
|
||||||
|
echo -e "${RED}Error: This installation was not set up using git.${NC}"
|
||||||
|
echo -e "Please use the bootstrap installer to perform a fresh installation."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Stash any local changes
|
||||||
|
echo -e "${YELLOW}Backing up any local configuration changes...${NC}"
|
||||||
|
git stash -q
|
||||||
|
|
||||||
|
# Pull the latest changes
|
||||||
|
echo -e "${YELLOW}Pulling latest updates from git...${NC}"
|
||||||
|
git pull
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo -e "${RED}Failed to pull updates. Restoring original state...${NC}"
|
||||||
|
git stash pop -q
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Get the new version
|
||||||
|
NEW_VERSION=$(grep -oP '"version": "\K[^"]+' package.json)
|
||||||
|
echo -e "${GREEN}New version: ${BOLD}$NEW_VERSION${NC}"
|
||||||
|
|
||||||
|
# Check if update is needed
|
||||||
|
if [ "$CURRENT_VERSION" == "$NEW_VERSION" ]; then
|
||||||
|
echo -e "${GREEN}You already have the latest version.${NC}"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Install any new npm dependencies
|
||||||
|
echo -e "${YELLOW}Installing dependencies...${NC}"
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Apply any local configuration changes
|
||||||
|
if git stash list | grep -q "stash@{0}"; then
|
||||||
|
echo -e "${YELLOW}Restoring local configuration changes...${NC}"
|
||||||
|
git stash pop -q
|
||||||
|
# Handle conflicts if any
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo -e "${RED}There were conflicts when restoring your configuration.${NC}"
|
||||||
|
echo -e "Please check the files and resolve conflicts manually."
|
||||||
|
echo -e "Your original configuration is saved in .git/refs/stash"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Restart the service
|
||||||
|
echo -e "${YELLOW}Restarting service...${NC}"
|
||||||
|
if command -v systemctl &> /dev/null; then
|
||||||
|
sudo systemctl restart transmission-rss-manager
|
||||||
|
else
|
||||||
|
echo -e "${RED}Could not restart service automatically.${NC}"
|
||||||
|
echo -e "Please restart the service manually."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Update complete
|
||||||
|
echo -e "${GREEN}${BOLD}Update complete!${NC}"
|
||||||
|
echo -e "Updated from version $CURRENT_VERSION to $NEW_VERSION"
|
||||||
|
echo -e "Changes will take effect immediately."
|
146
temp_work/server-endpoints.js
Normal file
146
temp_work/server-endpoints.js
Normal file
@ -0,0 +1,146 @@
|
|||||||
|
// Version and update endpoints to be added to server.js
|
||||||
|
|
||||||
|
// Add these imports at the top of server.js
|
||||||
|
const { exec } = require('child_process');
|
||||||
|
const { promisify } = require('util');
|
||||||
|
const execAsync = promisify(exec);
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
// Add these endpoints
|
||||||
|
|
||||||
|
// Get system status including version and uptime
|
||||||
|
app.get('/api/system/status', async (req, res) => {
|
||||||
|
try {
|
||||||
|
// Get package.json for version info
|
||||||
|
const packageJson = JSON.parse(fs.readFileSync(path.join(__dirname, 'package.json'), 'utf8'));
|
||||||
|
const version = packageJson.version;
|
||||||
|
|
||||||
|
// Get system uptime
|
||||||
|
const uptimeSeconds = Math.floor(process.uptime());
|
||||||
|
const hours = Math.floor(uptimeSeconds / 3600);
|
||||||
|
const minutes = Math.floor((uptimeSeconds % 3600) / 60);
|
||||||
|
const seconds = uptimeSeconds % 60;
|
||||||
|
const uptime = `${hours}h ${minutes}m ${seconds}s`;
|
||||||
|
|
||||||
|
// Check transmission connection
|
||||||
|
let transmissionStatus = 'Connected';
|
||||||
|
try {
|
||||||
|
await transmissionClient.sessionGet();
|
||||||
|
} catch (err) {
|
||||||
|
transmissionStatus = 'Disconnected';
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
status: 'success',
|
||||||
|
data: {
|
||||||
|
version,
|
||||||
|
uptime,
|
||||||
|
transmissionStatus
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting system status:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
status: 'error',
|
||||||
|
message: 'Failed to get system status'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check for updates
|
||||||
|
app.get('/api/system/check-updates', async (req, res) => {
|
||||||
|
try {
|
||||||
|
// Check if git is available and if this is a git repository
|
||||||
|
const isGitRepo = fs.existsSync(path.join(__dirname, '.git'));
|
||||||
|
if (!isGitRepo) {
|
||||||
|
return res.json({
|
||||||
|
status: 'error',
|
||||||
|
message: 'This installation is not set up for updates. Please use the bootstrap installer.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current version
|
||||||
|
const packageJson = JSON.parse(fs.readFileSync(path.join(__dirname, 'package.json'), 'utf8'));
|
||||||
|
const currentVersion = packageJson.version;
|
||||||
|
|
||||||
|
// Fetch latest updates without applying them
|
||||||
|
await execAsync('git fetch');
|
||||||
|
|
||||||
|
// Check if we're behind the remote repository
|
||||||
|
const { stdout } = await execAsync('git rev-list HEAD..origin/main --count');
|
||||||
|
const behindCount = parseInt(stdout.trim());
|
||||||
|
|
||||||
|
if (behindCount > 0) {
|
||||||
|
// Get the new version from the remote package.json
|
||||||
|
const { stdout: remotePackageJson } = await execAsync('git show origin/main:package.json');
|
||||||
|
const remotePackage = JSON.parse(remotePackageJson);
|
||||||
|
const remoteVersion = remotePackage.version;
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
status: 'success',
|
||||||
|
data: {
|
||||||
|
updateAvailable: true,
|
||||||
|
currentVersion,
|
||||||
|
remoteVersion,
|
||||||
|
commitsBehind: behindCount
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
return res.json({
|
||||||
|
status: 'success',
|
||||||
|
data: {
|
||||||
|
updateAvailable: false,
|
||||||
|
currentVersion
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error checking for updates:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
status: 'error',
|
||||||
|
message: 'Failed to check for updates'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Apply updates
|
||||||
|
app.post('/api/system/update', async (req, res) => {
|
||||||
|
try {
|
||||||
|
// Check if git is available and if this is a git repository
|
||||||
|
const isGitRepo = fs.existsSync(path.join(__dirname, '.git'));
|
||||||
|
if (!isGitRepo) {
|
||||||
|
return res.status(400).json({
|
||||||
|
status: 'error',
|
||||||
|
message: 'This installation is not set up for updates. Please use the bootstrap installer.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run the update script
|
||||||
|
const updateScriptPath = path.join(__dirname, 'scripts', 'update.sh');
|
||||||
|
|
||||||
|
// Make sure the update script is executable
|
||||||
|
await execAsync(`chmod +x ${updateScriptPath}`);
|
||||||
|
|
||||||
|
// Execute the update script
|
||||||
|
const { stdout, stderr } = await execAsync(updateScriptPath);
|
||||||
|
|
||||||
|
// If we get here, the update was successful
|
||||||
|
// The service will be restarted by the update script
|
||||||
|
res.json({
|
||||||
|
status: 'success',
|
||||||
|
message: 'Update applied successfully. The service will restart.',
|
||||||
|
data: {
|
||||||
|
output: stdout,
|
||||||
|
errors: stderr
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error applying update:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
status: 'error',
|
||||||
|
message: 'Failed to apply update',
|
||||||
|
error: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
41
temp_work/system-status.html
Normal file
41
temp_work/system-status.html
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
<!-- This HTML block will be inserted into the index.html dashboard -->
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-4 mb-3">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h4><i class="fas fa-info-circle"></i> System Status</h4>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="status-item">
|
||||||
|
<span class="status-label">Version:</span>
|
||||||
|
<span id="system-version" class="status-value">Loading...</span>
|
||||||
|
</div>
|
||||||
|
<div class="status-item">
|
||||||
|
<span class="status-label">Running since:</span>
|
||||||
|
<span id="uptime" class="status-value">Loading...</span>
|
||||||
|
</div>
|
||||||
|
<div class="status-item">
|
||||||
|
<span class="status-label">Transmission:</span>
|
||||||
|
<span id="transmission-status" class="status-value">
|
||||||
|
<i class="fas fa-circle-notch fa-spin"></i> Checking...
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="status-item">
|
||||||
|
<span class="status-label">Update:</span>
|
||||||
|
<span id="update-status" class="status-value">
|
||||||
|
<i class="fas fa-circle-notch fa-spin"></i> Checking...
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div id="update-available" class="mt-3 d-none">
|
||||||
|
<div class="alert alert-info">
|
||||||
|
<i class="fas fa-arrow-circle-up"></i>
|
||||||
|
<span>A new version is available!</span>
|
||||||
|
<button id="btn-update-now" class="btn btn-sm btn-primary ml-2">
|
||||||
|
<i class="fas fa-download"></i> Update Now
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
84
temp_work/update.sh
Normal file
84
temp_work/update.sh
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Transmission RSS Manager - Update Script
|
||||||
|
# This script pulls the latest version from git and runs necessary updates
|
||||||
|
|
||||||
|
# Color and formatting
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
RED='\033[0;31m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
BOLD='\033[1m'
|
||||||
|
|
||||||
|
# Installation directory (should be current directory)
|
||||||
|
INSTALL_DIR=$(pwd)
|
||||||
|
|
||||||
|
# Check if we're in the right directory
|
||||||
|
if [ ! -f "$INSTALL_DIR/package.json" ] || [ ! -d "$INSTALL_DIR/modules" ]; then
|
||||||
|
echo -e "${RED}Error: This script must be run from the installation directory.${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Get the current version
|
||||||
|
CURRENT_VERSION=$(grep -oP '"version": "\K[^"]+' package.json)
|
||||||
|
echo -e "${YELLOW}Current version: ${BOLD}$CURRENT_VERSION${NC}"
|
||||||
|
|
||||||
|
# Check for git repository
|
||||||
|
if [ ! -d ".git" ]; then
|
||||||
|
echo -e "${RED}Error: This installation was not set up using git.${NC}"
|
||||||
|
echo -e "Please use the bootstrap installer to perform a fresh installation."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Stash any local changes
|
||||||
|
echo -e "${YELLOW}Backing up any local configuration changes...${NC}"
|
||||||
|
git stash -q
|
||||||
|
|
||||||
|
# Pull the latest changes
|
||||||
|
echo -e "${YELLOW}Pulling latest updates from git...${NC}"
|
||||||
|
git pull
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo -e "${RED}Failed to pull updates. Restoring original state...${NC}"
|
||||||
|
git stash pop -q
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Get the new version
|
||||||
|
NEW_VERSION=$(grep -oP '"version": "\K[^"]+' package.json)
|
||||||
|
echo -e "${GREEN}New version: ${BOLD}$NEW_VERSION${NC}"
|
||||||
|
|
||||||
|
# Check if update is needed
|
||||||
|
if [ "$CURRENT_VERSION" == "$NEW_VERSION" ]; then
|
||||||
|
echo -e "${GREEN}You already have the latest version.${NC}"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Install any new npm dependencies
|
||||||
|
echo -e "${YELLOW}Installing dependencies...${NC}"
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Apply any local configuration changes
|
||||||
|
if git stash list | grep -q "stash@{0}"; then
|
||||||
|
echo -e "${YELLOW}Restoring local configuration changes...${NC}"
|
||||||
|
git stash pop -q
|
||||||
|
# Handle conflicts if any
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo -e "${RED}There were conflicts when restoring your configuration.${NC}"
|
||||||
|
echo -e "Please check the files and resolve conflicts manually."
|
||||||
|
echo -e "Your original configuration is saved in .git/refs/stash"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Restart the service
|
||||||
|
echo -e "${YELLOW}Restarting service...${NC}"
|
||||||
|
if command -v systemctl &> /dev/null; then
|
||||||
|
sudo systemctl restart transmission-rss-manager
|
||||||
|
else
|
||||||
|
echo -e "${RED}Could not restart service automatically.${NC}"
|
||||||
|
echo -e "Please restart the service manually."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Update complete
|
||||||
|
echo -e "${GREEN}${BOLD}Update complete!${NC}"
|
||||||
|
echo -e "Updated from version $CURRENT_VERSION to $NEW_VERSION"
|
||||||
|
echo -e "Changes will take effect immediately."
|
@ -1,165 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# Working network version with proper static file configuration
|
|
||||||
|
|
||||||
GREEN='\033[0;32m'
|
|
||||||
YELLOW='\033[1;33m'
|
|
||||||
RED='\033[0;31m'
|
|
||||||
NC='\033[0m' # No Color
|
|
||||||
|
|
||||||
# Clean up existing test directory
|
|
||||||
echo -e "${YELLOW}Removing existing test directory...${NC}"
|
|
||||||
rm -rf "$HOME/transmission-rss-test"
|
|
||||||
|
|
||||||
# Create and prepare test directory
|
|
||||||
echo -e "${GREEN}Creating fresh test directory...${NC}"
|
|
||||||
TEST_DIR="$HOME/transmission-rss-test"
|
|
||||||
mkdir -p "$TEST_DIR"
|
|
||||||
mkdir -p "$TEST_DIR/wwwroot/css"
|
|
||||||
mkdir -p "$TEST_DIR/wwwroot/js"
|
|
||||||
|
|
||||||
# Copy web static files directly to wwwroot
|
|
||||||
cp -rv /opt/develop/transmission-rss-manager/TransmissionRssManager/src/Web/wwwroot/* "$TEST_DIR/wwwroot/"
|
|
||||||
|
|
||||||
# Create Program.cs with fixed static file configuration
|
|
||||||
cat > "$TEST_DIR/Program.cs" << 'EOL'
|
|
||||||
using Microsoft.AspNetCore.Builder;
|
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
|
||||||
using Microsoft.Extensions.Hosting;
|
|
||||||
using Microsoft.Extensions.FileProviders;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using System;
|
|
||||||
using System.IO;
|
|
||||||
using TransmissionRssManager.Core;
|
|
||||||
using TransmissionRssManager.Services;
|
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
|
||||||
|
|
||||||
// Add services to the container
|
|
||||||
builder.Services.AddControllers();
|
|
||||||
builder.Services.AddEndpointsApiExplorer();
|
|
||||||
builder.Services.AddSwaggerGen();
|
|
||||||
|
|
||||||
// Add custom services
|
|
||||||
builder.Services.AddSingleton<IConfigService, ConfigService>();
|
|
||||||
builder.Services.AddSingleton<ITransmissionClient, TransmissionClient>();
|
|
||||||
builder.Services.AddSingleton<IRssFeedManager, RssFeedManager>();
|
|
||||||
builder.Services.AddSingleton<IPostProcessor, PostProcessor>();
|
|
||||||
|
|
||||||
// Add background services
|
|
||||||
builder.Services.AddHostedService<RssFeedBackgroundService>();
|
|
||||||
builder.Services.AddHostedService<PostProcessingBackgroundService>();
|
|
||||||
|
|
||||||
var app = builder.Build();
|
|
||||||
|
|
||||||
// Configure middleware
|
|
||||||
if (app.Environment.IsDevelopment())
|
|
||||||
{
|
|
||||||
app.UseSwagger();
|
|
||||||
app.UseSwaggerUI();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Configure static files
|
|
||||||
var wwwrootPath = Path.Combine(Directory.GetCurrentDirectory(), "wwwroot");
|
|
||||||
app.UseStaticFiles(new StaticFileOptions
|
|
||||||
{
|
|
||||||
FileProvider = new PhysicalFileProvider(wwwrootPath),
|
|
||||||
RequestPath = ""
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create default route to serve index.html
|
|
||||||
app.MapGet("/", context =>
|
|
||||||
{
|
|
||||||
context.Response.ContentType = "text/html";
|
|
||||||
context.Response.Redirect("/index.html");
|
|
||||||
return System.Threading.Tasks.Task.CompletedTask;
|
|
||||||
});
|
|
||||||
|
|
||||||
app.UseRouting();
|
|
||||||
app.UseAuthorization();
|
|
||||||
app.MapControllers();
|
|
||||||
|
|
||||||
// Log where static files are being served from
|
|
||||||
app.Logger.LogInformation($"Static files are served from: {wwwrootPath}");
|
|
||||||
|
|
||||||
app.Run();
|
|
||||||
EOL
|
|
||||||
|
|
||||||
# Create project file with System.IO
|
|
||||||
cat > "$TEST_DIR/TransmissionRssManager.csproj" << 'EOL'
|
|
||||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
|
||||||
|
|
||||||
<PropertyGroup>
|
|
||||||
<TargetFramework>net7.0</TargetFramework>
|
|
||||||
<RootNamespace>TransmissionRssManager</RootNamespace>
|
|
||||||
<ImplicitUsings>disable</ImplicitUsings>
|
|
||||||
<Nullable>enable</Nullable>
|
|
||||||
<Version>1.0.0</Version>
|
|
||||||
<Authors>TransmissionRssManager</Authors>
|
|
||||||
<Description>A C# application to manage RSS feeds and automatically download torrents via Transmission</Description>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="7.0.13" />
|
|
||||||
<PackageReference Include="Microsoft.Extensions.FileProviders.Physical" Version="7.0.0" />
|
|
||||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" />
|
|
||||||
<PackageReference Include="System.ServiceModel.Syndication" Version="7.0.0" />
|
|
||||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="7.0.0" />
|
|
||||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="7.0.0" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
</Project>
|
|
||||||
EOL
|
|
||||||
|
|
||||||
# Create source directories
|
|
||||||
mkdir -p "$TEST_DIR/src/Core"
|
|
||||||
mkdir -p "$TEST_DIR/src/Services"
|
|
||||||
mkdir -p "$TEST_DIR/src/Api/Controllers"
|
|
||||||
|
|
||||||
# Copy core interfaces
|
|
||||||
cp -v /opt/develop/transmission-rss-manager/TransmissionRssManager/src/Core/Interfaces.cs "$TEST_DIR/src/Core/"
|
|
||||||
|
|
||||||
# Copy service implementations
|
|
||||||
cp -v /opt/develop/transmission-rss-manager/TransmissionRssManager/src/Services/ConfigService.cs "$TEST_DIR/src/Services/"
|
|
||||||
cp -v /opt/develop/transmission-rss-manager/TransmissionRssManager/src/Services/TransmissionClient.cs "$TEST_DIR/src/Services/"
|
|
||||||
cp -v /opt/develop/transmission-rss-manager/TransmissionRssManager/src/Services/RssFeedManager.cs "$TEST_DIR/src/Services/"
|
|
||||||
cp -v /opt/develop/transmission-rss-manager/TransmissionRssManager/src/Services/PostProcessor.cs "$TEST_DIR/src/Services/"
|
|
||||||
|
|
||||||
# Copy API controllers
|
|
||||||
cp -v /opt/develop/transmission-rss-manager/TransmissionRssManager/src/Api/Controllers/ConfigController.cs "$TEST_DIR/src/Api/Controllers/"
|
|
||||||
cp -v /opt/develop/transmission-rss-manager/TransmissionRssManager/src/Api/Controllers/FeedsController.cs "$TEST_DIR/src/Api/Controllers/"
|
|
||||||
cp -v /opt/develop/transmission-rss-manager/TransmissionRssManager/src/Api/Controllers/TorrentsController.cs "$TEST_DIR/src/Api/Controllers/"
|
|
||||||
|
|
||||||
# Fix namespaces for Services
|
|
||||||
sed -i 's/using Microsoft.Extensions.Hosting;/using Microsoft.Extensions.Hosting;\nusing System.Linq;/g' "$TEST_DIR/src/Services/RssFeedManager.cs"
|
|
||||||
sed -i 's/using Microsoft.Extensions.Hosting;/using Microsoft.Extensions.Hosting;\nusing System.Linq;/g' "$TEST_DIR/src/Services/PostProcessor.cs"
|
|
||||||
|
|
||||||
# Get server IP
|
|
||||||
SERVER_IP=$(hostname -I | awk '{print $1}')
|
|
||||||
echo -e "${GREEN}Server IP: $SERVER_IP${NC}"
|
|
||||||
|
|
||||||
# Build the application
|
|
||||||
cd "$TEST_DIR"
|
|
||||||
echo -e "${GREEN}Setting up NuGet packages...${NC}"
|
|
||||||
dotnet restore
|
|
||||||
if [ $? -ne 0 ]; then
|
|
||||||
echo -e "${RED}Failed to restore NuGet packages.${NC}"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo -e "${GREEN}Building application...${NC}"
|
|
||||||
dotnet build
|
|
||||||
if [ $? -ne 0 ]; then
|
|
||||||
echo -e "${RED}Build failed.${NC}"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Run with explicit host binding
|
|
||||||
echo -e "${GREEN}Starting application on all interfaces with explicit binding...${NC}"
|
|
||||||
echo -e "${GREEN}The web interface will be available at:${NC}"
|
|
||||||
echo -e "${GREEN}- Local: http://localhost:5000${NC}"
|
|
||||||
echo -e "${GREEN}- Network: http://${SERVER_IP}:5000${NC}"
|
|
||||||
echo -e "${YELLOW}Press Ctrl+C to stop the application${NC}"
|
|
||||||
|
|
||||||
cd "$TEST_DIR"
|
|
||||||
dotnet run --urls="http://0.0.0.0:5000"
|
|
Loading…
x
Reference in New Issue
Block a user