Initial commit of Transmission RSS Manager with fixed remote connection and post-processing features

This commit is contained in:
MasterDraco 2025-03-12 19:13:59 +00:00
commit f804ca51d3
21 changed files with 5780 additions and 0 deletions

115
README.md Normal file
View File

@ -0,0 +1,115 @@
# Transmission RSS Manager
A C# application for managing RSS feeds and automatically downloading torrents via Transmission BitTorrent client.
## Features
- Monitor multiple RSS feeds for new torrents
- Apply regex-based rules to automatically match and download content
- Manage Transmission torrents through a user-friendly web interface
- Post-processing of completed downloads (extract archives, organize media files)
- Responsive web UI for desktop and mobile use
## Requirements
- .NET 7.0 or higher
- Transmission BitTorrent client (with remote access enabled)
- Linux OS (tested on Ubuntu, Debian, Fedora, Arch)
- Dependencies: unzip, p7zip, unrar (for post-processing)
## Installation
### Automatic Installation
Run the installer script:
```bash
curl -sSL https://raw.githubusercontent.com/yourusername/transmission-rss-manager/main/install-script.sh | bash
```
Or if you've cloned the repository:
```bash
./src/Infrastructure/install-script.sh
```
### Manual Installation
1. Install .NET 7.0 SDK from [Microsoft's website](https://dotnet.microsoft.com/download)
2. Clone the repository:
```bash
git clone https://github.com/yourusername/transmission-rss-manager.git
cd transmission-rss-manager
```
3. Build and run the application:
```bash
dotnet build -c Release
dotnet run
```
4. Open a web browser and navigate to: `http://localhost:5000`
## Configuration
After starting the application for the first time, a configuration file will be created at `~/.config/transmission-rss-manager/config.json`.
You can configure the application through the web interface or by directly editing the configuration file.
### Key configuration options
- **Transmission settings**: Host, port, username, password
- **RSS feed checking interval**
- **Auto-download settings**
- **Post-processing options**
- **Download and media library directories**
## Usage
### Managing RSS Feeds
1. Add RSS feeds through the web interface
2. Create regex rules for each feed to match desired content
3. Enable auto-download for feeds you want to process automatically
### Managing Torrents
- Add torrents manually via URL or magnet link
- View, start, stop, and remove torrents
- Process completed torrents to extract archives and organize media
## Development
### Building from source
```bash
dotnet build
```
### Running in development mode
```bash
dotnet run
```
### Creating a release
```bash
dotnet publish -c Release
```
## Architecture
The application is built using ASP.NET Core with the following components:
- **Web API**: REST endpoints for the web interface
- **Background Services**: RSS feed checking and post-processing
- **Core Services**: Configuration, Transmission communication, RSS parsing
## License
This project is licensed under the MIT License - see the LICENSE file for details.
## Acknowledgments
- [Transmission](https://transmissionbt.com/) - BitTorrent client
- [ASP.NET Core](https://docs.microsoft.com/en-us/aspnet/core/) - Web framework
- [Bootstrap](https://getbootstrap.com/) - UI framework

View File

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

1359
install-script.sh Executable file

File diff suppressed because it is too large Load Diff

38
reset-and-run-network.sh Executable file
View File

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

1048
reset-and-run.sh Executable file

File diff suppressed because it is too large Load Diff

27
run-app.sh Executable file
View File

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

View File

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

View File

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

View File

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

38
src/Api/Program.cs Normal file
View File

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

104
src/Core/Interfaces.cs Normal file
View File

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

View File

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

Binary file not shown.

View File

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

View File

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

View File

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

View File

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

View File

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

283
src/Web/wwwroot/index.html Normal file
View File

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

916
src/Web/wwwroot/js/app.js Normal file
View File

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

165
working-network-version.sh Executable file
View File

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