Compare commits

...

17 Commits

Author SHA1 Message Date
164268abd1 fix: Resolve ambiguous UserPreferences class conflict
- Renamed UserPreferences in LoggingService to LoggingPreferences
- Used fully qualified names for TransmissionRssManager.Core.UserPreferences
- Fixed float to int conversion issues with explicit casts
- Fixed interface implementation issues

🤖 Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude <noreply@anthropic.com>
2025-03-12 22:40:44 +00:00
2e2e38d979 fix: Fix type mismatch errors and nullable warnings
- Updated type conversions for MinimumSeedRatio from float to int
- Fixed UserPreferencesConfig references to use UserPreferences class
- Added null checks for Path operations in PostProcessor
- Changed arrays to Lists for MediaExtensions and NotificationEvents
- Added null checks in MetricsService for DriveInfo paths
- Added default values for nullable string properties
- Fixed missing using statements

🤖 Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude <noreply@anthropic.com>
2025-03-12 22:38:56 +00:00
f21639455d fix: Implement required interface methods and address nullable warnings
- Added GetConfiguration() and SaveConfigurationAsync() methods to ConfigService
- Added synchronous file reading for GetConfiguration()
- Fixed nullable reference type warnings in LoggingService
- Added nullable annotations to interface definitions
- Enabled nullable reference types for affected files

🤖 Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude <noreply@anthropic.com>
2025-03-12 22:36:39 +00:00
63b33c5fc0 feat: Replace database operations with file-based configuration actions
- Removed database-related code from the UI JavaScript
- Added configuration backup functionality to export config as JSON
- Added configuration reset functionality to restore defaults
- Updated ConfigController with backup and reset endpoints
- Enhanced file-based configuration persistence

🤖 Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude <noreply@anthropic.com>
2025-03-12 22:33:33 +00:00
c61f308de7 feat: Implement file-based configuration storage
- Replaced database-dependent ConfigService with pure file-based implementation
- Added support for both system-wide (/etc) and local app configuration
- Implemented safe atomic file writes with backups
- Added fallback to default values for missing configuration fields
- Improved error handling for configuration loading/saving

🤖 Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude <noreply@anthropic.com>
2025-03-12 22:30:53 +00:00
5d6faef880 fix: Ensure static web content is properly deployed and served
- Added UseDefaultFiles middleware to serve index.html by default
- Explicitly copy wwwroot contents during installation
- Added checks in test script to verify static content
- Added fallback to /index.html endpoint if root path doesn't respond
- Enhanced test script to diagnose and fix common deployment issues

🤖 Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude <noreply@anthropic.com>
2025-03-12 22:27:19 +00:00
573031fcc9 fix: Ensure app runs as framework-dependent with proper runtimeconfig.json
- Added correct runtimeconfig.json for Microsoft.AspNetCore.OpenApi reference
- Fixed systemd service to specify correct working directory and environment
- Set explicit --no-self-contained and framework target for publishing
- Added test-installation.sh script for easy verification
- Fixed libhostpolicy.so error when running as a service

🤖 Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude <noreply@anthropic.com>
2025-03-12 22:20:42 +00:00
0f27b1a939 Simplify PostProcessor to remove database dependencies 2025-03-12 22:13:41 +00:00
619a861546 Fix RssFeedManager to remove database dependencies and add string initializers to Core models 2025-03-12 22:12:42 +00:00
6dff6103d9 Remove SimpleInterfaces.cs to avoid duplicate class definitions 2025-03-12 22:08:13 +00:00
b11193795b Add simplified interfaces to Core namespace 2025-03-12 22:07:18 +00:00
3f2567803c Add LogEntry and UserPreferences classes to LoggingService 2025-03-12 22:04:48 +00:00
b6d7183094 Simplify installation script and project dependencies 2025-03-12 22:02:37 +00:00
3f9875cb1a Simplify Program.cs and LoggingService to remove database dependencies 2025-03-12 22:01:02 +00:00
d919516f2d Remove MetricsBackgroundService registration 2025-03-12 21:57:00 +00:00
681e1aa3e9 Simplify MetricsService implementation to remove database dependencies 2025-03-12 21:56:43 +00:00
71fc571f38 Update installation script URLs in README 2025-03-12 21:44:30 +00:00
14 changed files with 2286 additions and 1368 deletions

View File

@ -22,7 +22,7 @@ A C# application for managing RSS feeds and automatically downloading torrents v
Install Transmission RSS Manager with a single command:
```bash
wget -O - https://git.powerdata.dk/masterdraco/Torrent-Manager/raw/branch/main/TransmissionRssManager/install.sh | sudo bash
wget -O - https://git.powerdata.dk/masterdraco/Torrent-Manager/raw/branch/main/install.sh | sudo bash
```
This command:
@ -40,7 +40,7 @@ If you prefer to examine the install script before running it, you can:
```bash
# Download the install script
wget https://git.powerdata.dk/masterdraco/Torrent-Manager/raw/branch/main/TransmissionRssManager/install.sh
wget https://git.powerdata.dk/masterdraco/Torrent-Manager/raw/branch/main/install.sh
# Review it
less install.sh

View File

@ -14,6 +14,11 @@
<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" />
<PackageReference Include="Cronos" Version="0.7.1" />
<PackageReference Include="SharpCompress" Version="0.35.0" />
<PackageReference Include="System.Text.RegularExpressions" Version="4.3.1" />
<PackageReference Include="System.Text.Json" Version="7.0.3" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
</ItemGroup>
<ItemGroup>

View File

@ -73,27 +73,13 @@ if [ $? -ne 0 ]; then
exit 1
fi
# Install PostgreSQL
print_section "Installing PostgreSQL"
if ! command -v psql &> /dev/null; then
echo "Installing PostgreSQL..."
apt-get install -y postgresql postgresql-contrib
else
echo "PostgreSQL is already installed."
fi
# PostgreSQL is not needed in simplified version
print_section "Checking dependencies"
echo "Using simplified version without database dependencies."
# Start PostgreSQL service
systemctl start postgresql
systemctl enable postgresql
# Install Entity Framework Core tools
print_section "Installing EF Core tools"
if ! su - postgres -c "dotnet tool list -g" | grep "dotnet-ef" > /dev/null; then
echo "Installing Entity Framework Core tools..."
su - postgres -c "dotnet tool install --global dotnet-ef --version 7.0.15"
else
echo "Entity Framework Core tools are already installed."
fi
# No need for Entity Framework tools in the simplified version
print_section "Checking .NET tools"
echo "Using simplified version without database dependencies."
# Create installation directory
print_section "Setting up application"
@ -103,45 +89,50 @@ mkdir -p $INSTALL_DIR
# Download or clone the application
if [ ! -d "$INSTALL_DIR/.git" ]; then
echo "Downloading application files..."
# Clone the repository
git clone https://git.powerdata.dk/masterdraco/Torrent-Manager.git $INSTALL_DIR
# Clone the repository (main branch)
git clone -b main https://git.powerdata.dk/masterdraco/Torrent-Manager.git $INSTALL_DIR
else
echo "Updating existing installation..."
cd $INSTALL_DIR
git pull
fi
# Setup database
print_section "Setting up database"
DB_NAME="torrentmanager"
DB_USER="torrentmanager"
DB_PASSWORD=$(tr -dc 'A-Za-z0-9' < /dev/urandom | head -c 16)
# Check if database exists
DB_EXISTS=$(su - postgres -c "psql -tAc \"SELECT 1 FROM pg_database WHERE datname='$DB_NAME'\"")
if [ "$DB_EXISTS" != "1" ]; then
echo "Creating database and user..."
# Create database and user
su - postgres -c "psql -c \"CREATE USER $DB_USER WITH PASSWORD '$DB_PASSWORD';\""
su - postgres -c "psql -c \"CREATE DATABASE $DB_NAME OWNER $DB_USER;\""
su - postgres -c "psql -c \"GRANT ALL PRIVILEGES ON DATABASE $DB_NAME TO $DB_USER;\""
else
echo "Database already exists."
fi
# Save connection string
# Setup configuration directory
print_section "Setting up configuration"
CONFIG_DIR="/etc/transmission-rss-manager"
mkdir -p $CONFIG_DIR
# Create config file with default settings
echo '{
"ConnectionStrings": {
"DefaultConnection": "Host=localhost;Database='$DB_NAME';Username='$DB_USER';Password='$DB_PASSWORD'"
"Transmission": {
"Host": "localhost",
"Port": 9091,
"Username": "",
"Password": "",
"UseHttps": false
},
"AutoDownloadEnabled": true,
"CheckIntervalMinutes": 30,
"DownloadDirectory": "/var/lib/transmission-daemon/downloads",
"MediaLibraryPath": "/media/library",
"PostProcessing": {
"Enabled": false,
"ExtractArchives": true,
"OrganizeMedia": true,
"MinimumSeedRatio": 1
},
"UserPreferences": {
"EnableDarkMode": true,
"AutoRefreshUIEnabled": true,
"AutoRefreshIntervalSeconds": 30,
"NotificationsEnabled": true
}
}' > "$CONFIG_DIR/appsettings.json"
# Set proper permissions
chown -R postgres:postgres "$CONFIG_DIR"
chmod 750 "$CONFIG_DIR"
chmod 640 "$CONFIG_DIR/appsettings.json"
chown -R root:root "$CONFIG_DIR"
chmod 755 "$CONFIG_DIR"
chmod 644 "$CONFIG_DIR/appsettings.json"
# Build and deploy the application
print_section "Building application"
@ -159,35 +150,86 @@ echo "Building project from: $PROJECT_DIR"
cd "$PROJECT_DIR"
dotnet restore
dotnet build -c Release
dotnet publish -c Release -o $INSTALL_DIR/publish
# Publish as framework-dependent, not self-contained, ensuring static content is included
dotnet publish -c Release -o $INSTALL_DIR/publish --no-self-contained -f net7.0 -p:PublishSingleFile=false
# Ensure the wwwroot folder is properly copied
echo "Copying static web content..."
if [ -d "$PROJECT_DIR/src/Web/wwwroot" ]; then
mkdir -p "$INSTALL_DIR/publish/wwwroot"
cp -r "$PROJECT_DIR/src/Web/wwwroot/"* "$INSTALL_DIR/publish/wwwroot/"
fi
# Copy configuration
cp "$CONFIG_DIR/appsettings.json" "$INSTALL_DIR/publish/appsettings.json"
# Run migrations
print_section "Running database migrations"
cd "$PROJECT_DIR"
dotnet ef database update
# No database migrations needed in simplified version
print_section "Configuration completed"
echo "No database migrations needed in simplified version."
# Create systemd service
print_section "Creating systemd service"
# Find the main application DLL
APP_DLL=$(find $INSTALL_DIR/publish -name "*.dll" | head -n 1)
APP_DLL=$(find $INSTALL_DIR/publish -name "TransmissionRssManager.dll")
if [ -z "$APP_DLL" ]; then
APP_DLL=$(find $INSTALL_DIR/publish -name "*.dll" | head -n 1)
fi
APP_NAME=$(basename "$APP_DLL" .dll)
# Make sure we're using a framework-dependent deployment
# Create the main application runtimeconfig.json file
echo '{
"runtimeOptions": {
"tfm": "net7.0",
"framework": {
"name": "Microsoft.AspNetCore.App",
"version": "7.0.0"
},
"configProperties": {
"System.GC.Server": true,
"System.Runtime.TieredCompilation": true
}
}
}' > "$INSTALL_DIR/publish/$APP_NAME.runtimeconfig.json"
# Create Microsoft.AspNetCore.OpenApi.runtimeconfig.json file (referenced in error message)
echo '{
"runtimeOptions": {
"tfm": "net7.0",
"framework": {
"name": "Microsoft.NETCore.App",
"version": "7.0.0"
}
}
}' > "$INSTALL_DIR/publish/Microsoft.AspNetCore.OpenApi.runtimeconfig.json"
# Create runtimeconfig.dev.json files that are sometimes needed
echo '{
"runtimeOptions": {
"additionalProbingPaths": [
"/root/.dotnet/store/|arch|/|tfm|",
"/root/.nuget/packages"
]
}
}' > "$INSTALL_DIR/publish/$APP_NAME.runtimeconfig.dev.json"
echo "[Unit]
Description=Transmission RSS Manager
After=network.target postgresql.service
After=network.target
[Service]
WorkingDirectory=$INSTALL_DIR/publish
ExecStart=/usr/bin/dotnet $APP_DLL
ExecStart=/usr/bin/dotnet $INSTALL_DIR/publish/$APP_NAME.dll
Restart=always
RestartSec=10
KillSignal=SIGINT
SyslogIdentifier=transmission-rss-manager
User=postgres
User=root
Environment=ASPNETCORE_ENVIRONMENT=Production
Environment=DOTNET_PRINT_TELEMETRY_MESSAGE=false
Environment=DOTNET_ROLL_FORWARD=LatestMinor
Environment=DOTNET_ROOT=/usr/share/dotnet
Environment=ASPNETCORE_URLS=http://0.0.0.0:5000
[Install]
WantedBy=multi-user.target" > /etc/systemd/system/transmission-rss-manager.service
@ -212,9 +254,8 @@ Categories=Network;P2P;" > /usr/share/applications/transmission-rss-manager.desk
print_section "Installation Complete!"
echo -e "${GREEN}Transmission RSS Manager has been successfully installed!${NC}"
echo -e "Web interface: ${YELLOW}http://localhost:5000${NC}"
echo -e "Database username: ${YELLOW}$DB_USER${NC}"
echo -e "Database password: ${YELLOW}$DB_PASSWORD${NC}"
echo -e "Configuration file: ${YELLOW}$CONFIG_DIR/appsettings.json${NC}"
echo -e "Application files: ${YELLOW}$INSTALL_DIR${NC}"
echo -e "\nTo check service status: ${YELLOW}systemctl status transmission-rss-manager${NC}"
echo -e "View logs: ${YELLOW}journalctl -u transmission-rss-manager${NC}"
echo -e "View logs: ${YELLOW}journalctl -u transmission-rss-manager${NC}"
echo -e "Restart service: ${YELLOW}systemctl restart transmission-rss-manager${NC}"

View File

@ -1,7 +1,14 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using TransmissionRssManager.Core;
using TransmissionRssManager.Services;
namespace TransmissionRssManager.Api.Controllers
{
@ -33,17 +40,79 @@ namespace TransmissionRssManager.Api.Controllers
host = config.Transmission.Host,
port = config.Transmission.Port,
useHttps = config.Transmission.UseHttps,
hasCredentials = !string.IsNullOrEmpty(config.Transmission.Username)
hasCredentials = !string.IsNullOrEmpty(config.Transmission.Username),
username = config.Transmission.Username
},
transmissionInstances = config.TransmissionInstances?.Select(i => new
{
id = i.Key,
name = i.Value.Host,
host = i.Value.Host,
port = i.Value.Port,
useHttps = i.Value.UseHttps,
hasCredentials = !string.IsNullOrEmpty(i.Value.Username),
username = i.Value.Username
}),
autoDownloadEnabled = config.AutoDownloadEnabled,
checkIntervalMinutes = config.CheckIntervalMinutes,
downloadDirectory = config.DownloadDirectory,
mediaLibraryPath = config.MediaLibraryPath,
postProcessing = config.PostProcessing
postProcessing = config.PostProcessing,
enableDetailedLogging = config.EnableDetailedLogging,
userPreferences = config.UserPreferences
};
return Ok(sanitizedConfig);
}
[HttpGet("defaults")]
public IActionResult GetDefaultConfig()
{
// Return default configuration settings
var defaultConfig = new
{
transmission = new
{
host = "localhost",
port = 9091,
username = "",
useHttps = false
},
autoDownloadEnabled = true,
checkIntervalMinutes = 30,
downloadDirectory = System.IO.Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Downloads"),
mediaLibraryPath = System.IO.Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Media"),
postProcessing = new
{
enabled = false,
extractArchives = true,
organizeMedia = true,
minimumSeedRatio = 1,
mediaExtensions = new[] { ".mp4", ".mkv", ".avi" },
autoOrganizeByMediaType = true,
renameFiles = false,
compressCompletedFiles = false,
deleteCompletedAfterDays = 0
},
enableDetailedLogging = false,
userPreferences = new
{
enableDarkMode = false,
autoRefreshUIEnabled = true,
autoRefreshIntervalSeconds = 30,
notificationsEnabled = true,
notificationEvents = new[] { "torrent-added", "torrent-completed", "torrent-error" },
defaultView = "dashboard",
confirmBeforeDelete = true,
maxItemsPerPage = 25,
dateTimeFormat = "yyyy-MM-dd HH:mm:ss",
showCompletedTorrents = true,
keepHistoryDays = 30
}
};
return Ok(defaultConfig);
}
[HttpPut]
public async Task<IActionResult> UpdateConfig([FromBody] AppConfig config)
@ -59,5 +128,93 @@ namespace TransmissionRssManager.Api.Controllers
await _configService.SaveConfigurationAsync(config);
return Ok(new { success = true });
}
[HttpPost("backup")]
public IActionResult BackupConfig()
{
try
{
// Get the current config
var config = _configService.GetConfiguration();
// Serialize to JSON with indentation
var options = new JsonSerializerOptions { WriteIndented = true };
var json = JsonSerializer.Serialize(config, options);
// Create a memory stream from the JSON
var stream = new MemoryStream(Encoding.UTF8.GetBytes(json));
// Set the content disposition and type
var fileName = $"transmission-rss-config-backup-{DateTime.Now:yyyy-MM-dd}.json";
return File(stream, "application/json", fileName);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error creating configuration backup");
return StatusCode(500, "Error creating configuration backup");
}
}
[HttpPost("reset")]
public async Task<IActionResult> ResetConfig()
{
try
{
// Create a default config
var defaultConfig = new AppConfig
{
Transmission = new TransmissionConfig
{
Host = "localhost",
Port = 9091,
Username = "",
Password = "",
UseHttps = false
},
AutoDownloadEnabled = true,
CheckIntervalMinutes = 30,
DownloadDirectory = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Downloads"),
MediaLibraryPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Media"),
PostProcessing = new PostProcessingConfig
{
Enabled = false,
ExtractArchives = true,
OrganizeMedia = true,
MinimumSeedRatio = 1,
MediaExtensions = new List<string> { ".mp4", ".mkv", ".avi", ".mov", ".wmv", ".m4v", ".mpg", ".mpeg", ".flv", ".webm" },
AutoOrganizeByMediaType = true,
RenameFiles = false,
CompressCompletedFiles = false,
DeleteCompletedAfterDays = 0
},
UserPreferences = new TransmissionRssManager.Core.UserPreferences
{
EnableDarkMode = true,
AutoRefreshUIEnabled = true,
AutoRefreshIntervalSeconds = 30,
NotificationsEnabled = true,
NotificationEvents = new List<string> { "torrent-added", "torrent-completed", "torrent-error" },
DefaultView = "dashboard",
ConfirmBeforeDelete = true,
MaxItemsPerPage = 25,
DateTimeFormat = "yyyy-MM-dd HH:mm:ss",
ShowCompletedTorrents = true,
KeepHistoryDays = 30
},
Feeds = new List<RssFeed>(),
EnableDetailedLogging = false
};
// Save the default config
await _configService.SaveConfigurationAsync(defaultConfig);
return Ok(new { success = true });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error resetting configuration");
return StatusCode(500, "Error resetting configuration");
}
}
}
}

View File

@ -83,7 +83,7 @@ namespace TransmissionRssManager.Api.Controllers
public class AddTorrentRequest
{
public string Url { get; set; }
public string DownloadDir { get; set; }
public string Url { get; set; } = string.Empty;
public string DownloadDir { get; set; } = string.Empty;
}
}

View File

@ -1,21 +1,34 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using System;
using System.IO;
using TransmissionRssManager.Core;
using TransmissionRssManager.Services;
var builder = WebApplication.CreateBuilder(args);
// Add logging
builder.Logging.AddConsole();
builder.Logging.AddDebug();
// Create logs directory for file logging
var logsDirectory = Path.Combine(AppContext.BaseDirectory, "logs");
Directory.CreateDirectory(logsDirectory);
// Add services to the container
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
// Add custom services
// Add custom services as singletons for simplicity
builder.Services.AddSingleton<IConfigService, ConfigService>();
builder.Services.AddSingleton<ITransmissionClient, TransmissionClient>();
builder.Services.AddSingleton<IRssFeedManager, RssFeedManager>();
builder.Services.AddSingleton<IPostProcessor, PostProcessor>();
builder.Services.AddSingleton<IMetricsService, MetricsService>();
builder.Services.AddSingleton<ILoggingService, LoggingService>();
// Add background services
builder.Services.AddHostedService<RssFeedBackgroundService>();
@ -30,9 +43,19 @@ if (app.Environment.IsDevelopment())
app.UseSwaggerUI();
}
// Configure static files to serve index.html as the default file
app.UseDefaultFiles();
app.UseStaticFiles();
app.UseRouting();
app.UseAuthorization();
app.MapControllers();
app.Run();
try
{
await app.RunAsync();
}
catch (Exception ex)
{
var logger = app.Services.GetRequiredService<ILogger<Program>>();
logger.LogError(ex, "Application terminated unexpectedly");
}

View File

@ -1,65 +1,130 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
namespace TransmissionRssManager.Core
{
public class LogEntry
{
public int Id { get; set; }
public DateTime Timestamp { get; set; }
public string Level { get; set; } = string.Empty;
public string Message { get; set; } = string.Empty;
public string Context { get; set; } = string.Empty;
public string Properties { get; set; } = string.Empty;
}
public class RssFeedItem
{
public string Id { get; set; }
public string Title { get; set; }
public string Link { get; set; }
public string Description { get; set; }
public string Id { get; set; } = string.Empty;
public string Title { get; set; } = string.Empty;
public string Link { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public DateTime PublishDate { get; set; }
public string TorrentUrl { get; set; }
public string TorrentUrl { get; set; } = string.Empty;
public bool IsDownloaded { get; set; }
public bool IsMatched { get; set; }
public string MatchedRule { get; set; }
public string MatchedRule { get; set; } = string.Empty;
public string FeedId { get; set; } = string.Empty;
public string Category { get; set; } = string.Empty;
public long Size { get; set; }
public string Author { get; set; } = string.Empty;
public List<string> Categories { get; set; } = new List<string>();
public Dictionary<string, string> AdditionalMetadata { get; set; } = new Dictionary<string, string>();
public DateTime? DownloadDate { get; set; }
public int? TorrentId { get; set; }
public string RejectionReason { get; set; } = string.Empty;
public bool IsRejected => !string.IsNullOrEmpty(RejectionReason);
}
public class TorrentInfo
{
public int Id { get; set; }
public string Name { get; set; }
public string Status { get; set; }
public string Name { get; set; } = string.Empty;
public string Status { get; set; } = string.Empty;
public double PercentDone { get; set; }
public long TotalSize { get; set; }
public string DownloadDir { get; set; }
public string DownloadDir { get; set; } = string.Empty;
public bool IsFinished => PercentDone >= 1.0;
public DateTime? AddedDate { get; set; }
public DateTime? CompletedDate { get; set; }
public long DownloadedEver { get; set; }
public long UploadedEver { get; set; }
public int UploadRatio { get; set; }
public string ErrorString { get; set; } = string.Empty;
public bool IsError => !string.IsNullOrEmpty(ErrorString);
public int Priority { get; set; }
public string HashString { get; set; } = string.Empty;
public int PeersConnected { get; set; }
public double DownloadSpeed { get; set; }
public double UploadSpeed { get; set; }
public string Category { get; set; } = string.Empty;
public bool HasMetadata { get; set; }
public string TransmissionInstance { get; set; } = "default";
public string SourceFeedId { get; set; } = string.Empty;
public bool IsPostProcessed { get; set; }
}
public class RssFeed
{
public string Id { get; set; }
public string Url { get; set; }
public string Name { get; set; }
public string Id { get; set; } = string.Empty;
public string Url { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public List<string> Rules { get; set; } = new List<string>();
public List<RssFeedRule> AdvancedRules { get; set; } = new List<RssFeedRule>();
public bool AutoDownload { get; set; }
public DateTime LastChecked { get; set; }
public string TransmissionInstanceId { get; set; } = "default";
public string Schedule { get; set; } = "*/30 * * * *"; // Default is every 30 minutes (cron expression)
public bool Enabled { get; set; } = true;
public int MaxHistoryItems { get; set; } = 100;
public string DefaultCategory { get; set; } = string.Empty;
public int ErrorCount { get; set; } = 0;
public DateTime? LastError { get; set; }
public string LastErrorMessage { get; set; } = string.Empty;
}
public class AppConfig
{
public TransmissionConfig Transmission { get; set; } = new TransmissionConfig();
public Dictionary<string, TransmissionConfig> TransmissionInstances { get; set; } = new Dictionary<string, 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 string DownloadDirectory { get; set; } = string.Empty;
public string MediaLibraryPath { get; set; } = string.Empty;
public PostProcessingConfig PostProcessing { get; set; } = new PostProcessingConfig();
public bool EnableDetailedLogging { get; set; } = false;
public UserPreferences UserPreferences { get; set; } = new UserPreferences();
}
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 string Username { get; set; } = string.Empty;
public string Password { get; set; } = string.Empty;
public bool UseHttps { get; set; } = false;
public string Url => $"{(UseHttps ? "https" : "http")}://{Host}:{Port}/transmission/rpc";
}
public class RssFeedRule
{
public string Id { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public string Pattern { get; set; } = string.Empty;
public bool IsRegex { get; set; } = false;
public bool IsEnabled { get; set; } = true;
public bool IsCaseSensitive { get; set; } = false;
public string Category { get; set; } = string.Empty;
public int Priority { get; set; } = 0;
public string Action { get; set; } = "download"; // download, notify, ignore
public string DestinationFolder { get; set; } = string.Empty;
}
public class PostProcessingConfig
{
public bool Enabled { get; set; } = false;
@ -67,6 +132,30 @@ namespace TransmissionRssManager.Core
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 bool AutoOrganizeByMediaType { get; set; } = true;
public bool RenameFiles { get; set; } = false;
public bool CompressCompletedFiles { get; set; } = false;
public int DeleteCompletedAfterDays { get; set; } = 0; // 0 = never delete
}
public class UserPreferences
{
public bool EnableDarkMode { get; set; } = false;
public bool AutoRefreshUIEnabled { get; set; } = true;
public int AutoRefreshIntervalSeconds { get; set; } = 30;
public bool NotificationsEnabled { get; set; } = true;
public List<string> NotificationEvents { get; set; } = new List<string>
{
"torrent-added",
"torrent-completed",
"torrent-error"
};
public string DefaultView { get; set; } = "dashboard";
public bool ConfirmBeforeDelete { get; set; } = true;
public int MaxItemsPerPage { get; set; } = 25;
public string DateTimeFormat { get; set; } = "yyyy-MM-dd HH:mm:ss";
public bool ShowCompletedTorrents { get; set; } = true;
public int KeepHistoryDays { get; set; } = 30;
}
public interface IConfigService
@ -93,6 +182,7 @@ namespace TransmissionRssManager.Core
Task RemoveFeedAsync(string feedId);
Task UpdateFeedAsync(RssFeed feed);
Task RefreshFeedsAsync(CancellationToken cancellationToken);
Task RefreshFeedAsync(string feedId, CancellationToken cancellationToken);
Task MarkItemAsDownloadedAsync(string itemId);
}

View File

@ -1,5 +1,7 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
@ -7,84 +9,385 @@ using TransmissionRssManager.Core;
namespace TransmissionRssManager.Services
{
/// <summary>
/// Service for managing application configuration
/// File-based implementation that does not use a database
/// </summary>
public class ConfigService : IConfigService
{
private readonly ILogger<ConfigService> _logger;
private readonly string _configPath;
private AppConfig _cachedConfig;
private readonly string _configFilePath;
private AppConfig? _cachedConfig;
private readonly object _lockObject = new object();
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");
// Determine the appropriate config file path
string baseDir = AppContext.BaseDirectory;
string etcConfigPath = "/etc/transmission-rss-manager/appsettings.json";
string localConfigPath = Path.Combine(baseDir, "appsettings.json");
// Ensure directory exists
if (!Directory.Exists(configDir))
{
Directory.CreateDirectory(configDir);
}
// Check if config exists in /etc (preferred) or in app directory
_configFilePath = File.Exists(etcConfigPath) ? etcConfigPath : localConfigPath;
_configPath = Path.Combine(configDir, "config.json");
_cachedConfig = LoadConfiguration();
_logger.LogInformation($"Using configuration file: {_configFilePath}");
}
// Implement the interface methods required by IConfigService
public AppConfig GetConfiguration()
{
return _cachedConfig;
}
public async Task SaveConfigurationAsync(AppConfig config)
{
_cachedConfig = config;
var options = new JsonSerializerOptions
// Non-async method required by interface
if (_cachedConfig != null)
{
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;
return _cachedConfig;
}
try
{
string json = File.ReadAllText(_configPath);
var config = JsonSerializer.Deserialize<AppConfig>(json);
// Load synchronously since this is a sync method
_cachedConfig = LoadConfigFromFileSync();
return _cachedConfig;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error loading configuration, using default values");
return CreateDefaultConfig();
}
}
public async Task SaveConfigurationAsync(AppConfig config)
{
try
{
_cachedConfig = config;
await SaveConfigToFileAsync(config);
_logger.LogInformation("Configuration saved successfully to file");
}
catch (Exception ex)
{
_logger.LogError(ex, "Error saving configuration to file");
throw;
}
}
// Additional methods for backward compatibility
public async Task<AppConfig> GetConfigAsync()
{
if (_cachedConfig != null)
{
return _cachedConfig;
}
try
{
_cachedConfig = await LoadConfigFromFileAsync();
return _cachedConfig;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error loading configuration, using default values");
return CreateDefaultConfig();
}
}
public async Task SaveConfigAsync(AppConfig config)
{
await SaveConfigurationAsync(config);
}
public async Task<string> GetSettingAsync(string key, string defaultValue = "")
{
var config = await GetConfigAsync();
switch (key)
{
case "Transmission.Host":
return config.Transmission.Host ?? defaultValue;
case "Transmission.Port":
return config.Transmission.Port.ToString();
case "Transmission.Username":
return config.Transmission.Username ?? defaultValue;
case "Transmission.Password":
return config.Transmission.Password ?? defaultValue;
case "Transmission.UseHttps":
return config.Transmission.UseHttps.ToString();
case "AutoDownloadEnabled":
return config.AutoDownloadEnabled.ToString();
case "CheckIntervalMinutes":
return config.CheckIntervalMinutes.ToString();
case "DownloadDirectory":
return config.DownloadDirectory ?? defaultValue;
case "MediaLibraryPath":
return config.MediaLibraryPath ?? defaultValue;
case "PostProcessing.Enabled":
return config.PostProcessing.Enabled.ToString();
case "PostProcessing.ExtractArchives":
return config.PostProcessing.ExtractArchives.ToString();
case "PostProcessing.OrganizeMedia":
return config.PostProcessing.OrganizeMedia.ToString();
case "PostProcessing.MinimumSeedRatio":
return config.PostProcessing.MinimumSeedRatio.ToString();
case "UserPreferences.EnableDarkMode":
return config.UserPreferences.EnableDarkMode.ToString();
case "UserPreferences.AutoRefreshUIEnabled":
return config.UserPreferences.AutoRefreshUIEnabled.ToString();
case "UserPreferences.AutoRefreshIntervalSeconds":
return config.UserPreferences.AutoRefreshIntervalSeconds.ToString();
case "UserPreferences.NotificationsEnabled":
return config.UserPreferences.NotificationsEnabled.ToString();
default:
_logger.LogWarning($"Unknown setting key: {key}");
return defaultValue;
}
}
public async Task SaveSettingAsync(string key, string value)
{
var config = await GetConfigAsync();
bool changed = false;
try
{
switch (key)
{
case "Transmission.Host":
config.Transmission.Host = value;
changed = true;
break;
case "Transmission.Port":
if (int.TryParse(value, out int port))
{
config.Transmission.Port = port;
changed = true;
}
break;
case "Transmission.Username":
config.Transmission.Username = value;
changed = true;
break;
case "Transmission.Password":
config.Transmission.Password = value;
changed = true;
break;
case "Transmission.UseHttps":
if (bool.TryParse(value, out bool useHttps))
{
config.Transmission.UseHttps = useHttps;
changed = true;
}
break;
case "AutoDownloadEnabled":
if (bool.TryParse(value, out bool autoDownload))
{
config.AutoDownloadEnabled = autoDownload;
changed = true;
}
break;
case "CheckIntervalMinutes":
if (int.TryParse(value, out int interval))
{
config.CheckIntervalMinutes = interval;
changed = true;
}
break;
case "DownloadDirectory":
config.DownloadDirectory = value;
changed = true;
break;
case "MediaLibraryPath":
config.MediaLibraryPath = value;
changed = true;
break;
case "PostProcessing.Enabled":
if (bool.TryParse(value, out bool ppEnabled))
{
config.PostProcessing.Enabled = ppEnabled;
changed = true;
}
break;
case "PostProcessing.ExtractArchives":
if (bool.TryParse(value, out bool extractArchives))
{
config.PostProcessing.ExtractArchives = extractArchives;
changed = true;
}
break;
case "PostProcessing.OrganizeMedia":
if (bool.TryParse(value, out bool organizeMedia))
{
config.PostProcessing.OrganizeMedia = organizeMedia;
changed = true;
}
break;
case "PostProcessing.MinimumSeedRatio":
if (float.TryParse(value, out float seedRatio))
{
config.PostProcessing.MinimumSeedRatio = (int)seedRatio;
changed = true;
}
break;
case "UserPreferences.EnableDarkMode":
if (bool.TryParse(value, out bool darkMode))
{
config.UserPreferences.EnableDarkMode = darkMode;
changed = true;
}
break;
case "UserPreferences.AutoRefreshUIEnabled":
if (bool.TryParse(value, out bool autoRefresh))
{
config.UserPreferences.AutoRefreshUIEnabled = autoRefresh;
changed = true;
}
break;
case "UserPreferences.AutoRefreshIntervalSeconds":
if (int.TryParse(value, out int refreshInterval))
{
config.UserPreferences.AutoRefreshIntervalSeconds = refreshInterval;
changed = true;
}
break;
case "UserPreferences.NotificationsEnabled":
if (bool.TryParse(value, out bool notifications))
{
config.UserPreferences.NotificationsEnabled = notifications;
changed = true;
}
break;
default:
_logger.LogWarning($"Unknown setting key: {key}");
break;
}
if (changed)
{
await SaveConfigAsync(config);
}
}
catch (Exception ex)
{
_logger.LogError(ex, $"Error saving setting {key}");
throw;
}
}
private AppConfig LoadConfigFromFileSync()
{
try
{
if (!File.Exists(_configFilePath))
{
_logger.LogWarning($"Configuration file not found at {_configFilePath}, creating default config");
var defaultConfig = CreateDefaultConfig();
// Save synchronously since we're in a sync method
File.WriteAllText(_configFilePath, JsonSerializer.Serialize(defaultConfig, new JsonSerializerOptions
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
}));
return defaultConfig;
}
string json = File.ReadAllText(_configFilePath);
var config = JsonSerializer.Deserialize<AppConfig>(json, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
});
if (config == null)
{
_logger.LogWarning("Failed to deserialize config, creating default");
return CreateDefaultConfig();
throw new InvalidOperationException("Failed to deserialize configuration");
}
// Fill in any missing values with defaults
EnsureCompleteConfig(config);
return config;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error loading configuration");
return CreateDefaultConfig();
_logger.LogError(ex, "Error loading configuration from file");
throw;
}
}
private async Task<AppConfig> LoadConfigFromFileAsync()
{
try
{
if (!File.Exists(_configFilePath))
{
_logger.LogWarning($"Configuration file not found at {_configFilePath}, creating default config");
var defaultConfig = CreateDefaultConfig();
await SaveConfigToFileAsync(defaultConfig);
return defaultConfig;
}
string json = await File.ReadAllTextAsync(_configFilePath);
var config = JsonSerializer.Deserialize<AppConfig>(json, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
});
if (config == null)
{
throw new InvalidOperationException("Failed to deserialize configuration");
}
// Fill in any missing values with defaults
EnsureCompleteConfig(config);
return config;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error loading configuration from file");
throw;
}
}
private async Task SaveConfigToFileAsync(AppConfig config)
{
try
{
string json = JsonSerializer.Serialize(config, new JsonSerializerOptions
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
});
// Create directory if it doesn't exist
string directory = Path.GetDirectoryName(_configFilePath);
if (!Directory.Exists(directory) && !string.IsNullOrEmpty(directory))
{
Directory.CreateDirectory(directory);
}
// Use a temporary file to avoid corruption if the process crashes during write
string tempFilePath = _configFilePath + ".tmp";
await File.WriteAllTextAsync(tempFilePath, json);
// Atomic file replacement (as much as the filesystem allows)
if (File.Exists(_configFilePath))
{
File.Replace(tempFilePath, _configFilePath, _configFilePath + ".bak");
}
else
{
File.Move(tempFilePath, _configFilePath);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error saving configuration to file");
throw;
}
}
private AppConfig CreateDefaultConfig()
{
string homeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
return new AppConfig
{
Transmission = new TransmissionConfig
@ -92,21 +395,64 @@ namespace TransmissionRssManager.Services
Host = "localhost",
Port = 9091,
Username = "",
Password = ""
Password = "",
UseHttps = false
},
AutoDownloadEnabled = false,
AutoDownloadEnabled = true,
CheckIntervalMinutes = 30,
DownloadDirectory = Path.Combine(homeDir, "Downloads"),
MediaLibraryPath = Path.Combine(homeDir, "Media"),
DownloadDirectory = "/var/lib/transmission-daemon/downloads",
MediaLibraryPath = "/media/library",
PostProcessing = new PostProcessingConfig
{
Enabled = false,
ExtractArchives = true,
OrganizeMedia = true,
MinimumSeedRatio = 1,
MediaExtensions = new System.Collections.Generic.List<string> { ".mp4", ".mkv", ".avi" }
MinimumSeedRatio = 1
},
UserPreferences = new TransmissionRssManager.Core.UserPreferences
{
EnableDarkMode = true,
AutoRefreshUIEnabled = true,
AutoRefreshIntervalSeconds = 30,
NotificationsEnabled = true
}
};
}
private void EnsureCompleteConfig(AppConfig config)
{
// Create new instances for any null nested objects
config.Transmission ??= new TransmissionConfig
{
Host = "localhost",
Port = 9091,
Username = "",
Password = "",
UseHttps = false
};
config.PostProcessing ??= new PostProcessingConfig
{
Enabled = false,
ExtractArchives = true,
OrganizeMedia = true,
MinimumSeedRatio = 1
};
config.UserPreferences ??= new TransmissionRssManager.Core.UserPreferences
{
EnableDarkMode = true,
AutoRefreshUIEnabled = true,
AutoRefreshIntervalSeconds = 30,
NotificationsEnabled = true
};
// Ensure default values for string properties if they're null
config.DownloadDirectory ??= "/var/lib/transmission-daemon/downloads";
config.MediaLibraryPath ??= "/media/library";
config.Transmission.Host ??= "localhost";
config.Transmission.Username ??= "";
config.Transmission.Password ??= "";
}
}
}

View File

@ -0,0 +1,219 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
namespace TransmissionRssManager.Services
{
public class LogEntry
{
public int Id { get; set; }
public DateTime Timestamp { get; set; }
public string Level { get; set; } = string.Empty;
public string Message { get; set; } = string.Empty;
public string Context { get; set; } = string.Empty;
public string Properties { get; set; } = string.Empty;
}
public class LoggingPreferences
{
public bool EnableDarkMode { get; set; } = false;
public bool AutoRefreshUIEnabled { get; set; } = true;
public int AutoRefreshIntervalSeconds { get; set; } = 30;
public bool NotificationsEnabled { get; set; } = true;
public List<string> NotificationEvents { get; set; } = new List<string>
{
"torrent-added",
"torrent-completed",
"torrent-error"
};
public string DefaultView { get; set; } = "dashboard";
public bool ConfirmBeforeDelete { get; set; } = true;
public int MaxItemsPerPage { get; set; } = 25;
public string DateTimeFormat { get; set; } = "yyyy-MM-dd HH:mm:ss";
public bool ShowCompletedTorrents { get; set; } = true;
public int KeepHistoryDays { get; set; } = 30;
}
public interface ILoggingService
{
void Configure(TransmissionRssManager.Core.UserPreferences preferences);
Task<List<LogEntry>> GetLogsAsync(LogFilterOptions options);
Task ClearLogsAsync(DateTime? olderThan = null);
Task<byte[]> ExportLogsAsync(LogFilterOptions options);
void Log(LogLevel level, string message, string? context = null, Dictionary<string, string>? properties = null);
}
public class LogFilterOptions
{
public string Level { get; set; } = "All";
public string Search { get; set; } = "";
public DateTime? StartDate { get; set; }
public DateTime? EndDate { get; set; }
public string Context { get; set; } = "";
public int Limit { get; set; } = 100;
public int Offset { get; set; } = 0;
}
public class LoggingService : ILoggingService
{
private readonly ILogger<LoggingService> _logger;
private readonly string _logFilePath;
private readonly object _logLock = new object();
private List<LogEntry> _inMemoryLogs = new List<LogEntry>();
private readonly int _maxLogEntries = 1000;
public LoggingService(ILogger<LoggingService> logger)
{
_logger = logger;
// Prepare log directory and file
var logsDirectory = Path.Combine(AppContext.BaseDirectory, "logs");
Directory.CreateDirectory(logsDirectory);
_logFilePath = Path.Combine(logsDirectory, "application_logs.json");
// Initialize log file if it doesn't exist
if (!File.Exists(_logFilePath))
{
File.WriteAllText(_logFilePath, "[]");
}
// Load existing logs into memory
try
{
var json = File.ReadAllText(_logFilePath);
_inMemoryLogs = JsonSerializer.Deserialize<List<LogEntry>>(json) ?? new List<LogEntry>();
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to load logs from file");
_inMemoryLogs = new List<LogEntry>();
}
}
public void Configure(TransmissionRssManager.Core.UserPreferences preferences)
{
// No-op in simplified version
}
public Task<List<LogEntry>> GetLogsAsync(LogFilterOptions options)
{
var filteredLogs = _inMemoryLogs.AsEnumerable();
// Apply filters
if (!string.IsNullOrEmpty(options.Level) && options.Level != "All")
{
filteredLogs = filteredLogs.Where(l => l.Level == options.Level);
}
if (!string.IsNullOrEmpty(options.Search))
{
filteredLogs = filteredLogs.Where(l =>
l.Message.Contains(options.Search, StringComparison.OrdinalIgnoreCase));
}
if (options.StartDate.HasValue)
{
filteredLogs = filteredLogs.Where(l => l.Timestamp >= options.StartDate.Value);
}
if (options.EndDate.HasValue)
{
filteredLogs = filteredLogs.Where(l => l.Timestamp <= options.EndDate.Value);
}
if (!string.IsNullOrEmpty(options.Context))
{
filteredLogs = filteredLogs.Where(l => l.Context == options.Context);
}
// Sort, paginate and return
return Task.FromResult(
filteredLogs
.OrderByDescending(l => l.Timestamp)
.Skip(options.Offset)
.Take(options.Limit)
.ToList()
);
}
public Task ClearLogsAsync(DateTime? olderThan = null)
{
lock (_logLock)
{
if (olderThan.HasValue)
{
_inMemoryLogs.RemoveAll(l => l.Timestamp < olderThan.Value);
}
else
{
_inMemoryLogs.Clear();
}
SaveLogs();
}
return Task.CompletedTask;
}
public async Task<byte[]> ExportLogsAsync(LogFilterOptions options)
{
var logs = await GetLogsAsync(options);
var json = JsonSerializer.Serialize(logs, new JsonSerializerOptions { WriteIndented = true });
return Encoding.UTF8.GetBytes(json);
}
public void Log(LogLevel level, string message, string? context = null, Dictionary<string, string>? properties = null)
{
var levelString = level.ToString();
// Log to standard logger
_logger.Log(level, message);
// Store in our custom log system
var entry = new LogEntry
{
Id = _inMemoryLogs.Count > 0 ? _inMemoryLogs.Max(l => l.Id) + 1 : 1,
Timestamp = DateTime.UtcNow,
Level = levelString,
Message = message,
Context = context ?? "System",
Properties = properties != null ? JsonSerializer.Serialize(properties) : "{}"
};
lock (_logLock)
{
_inMemoryLogs.Add(entry);
// Keep log size under control
if (_inMemoryLogs.Count > _maxLogEntries)
{
_inMemoryLogs = _inMemoryLogs
.OrderByDescending(l => l.Timestamp)
.Take(_maxLogEntries)
.ToList();
}
SaveLogs();
}
}
private void SaveLogs()
{
try
{
var json = JsonSerializer.Serialize(_inMemoryLogs);
File.WriteAllText(_logFilePath, json);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to save logs to file");
}
}
}
}

View File

@ -1,528 +1,91 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using TransmissionRssManager.Core;
using TransmissionRssManager.Data.Repositories;
namespace TransmissionRssManager.Services
{
/// <summary>
/// Interface for the metrics service that provides dashboard statistics and performance data
/// </summary>
public interface IMetricsService
{
Task<DashboardStats> GetDashboardStatsAsync();
Task<List<HistoricalDataPoint>> GetDownloadHistoryAsync(int days = 30);
Task<List<CategoryStats>> GetCategoryStatsAsync();
Task<SystemStatus> GetSystemStatusAsync();
Task<Dictionary<string, object>> GetDashboardStatsAsync();
Task<Dictionary<string, long>> EstimateDiskUsageAsync();
Task<Dictionary<string, double>> GetPerformanceMetricsAsync();
Task<Dictionary<string, object>> GetSystemStatusAsync();
}
/// <summary>
/// Service that provides metrics and statistics about downloads, system status, and performance
/// </summary>
public class MetricsService : IMetricsService
{
private readonly ILogger<MetricsService> _logger;
private readonly ITransmissionClient _transmissionClient;
private readonly IRepository<TransmissionRssManager.Data.Models.RssFeed> _feedRepository;
private readonly IRepository<TransmissionRssManager.Data.Models.RssFeedItem> _feedItemRepository;
private readonly IRepository<TransmissionRssManager.Data.Models.Torrent> _torrentRepository;
private readonly IRepository<TransmissionRssManager.Data.Models.SystemLogEntry> _logRepository;
private readonly ILoggingService _loggingService;
private readonly IConfigService _configService;
// Helper method to map database Torrent model to Core.TorrentInfo
private Core.TorrentInfo MapToTorrentInfo(TransmissionRssManager.Data.Models.Torrent torrent)
{
return new Core.TorrentInfo
{
Id = torrent.Id,
Name = torrent.Name,
Status = torrent.Status,
PercentDone = torrent.PercentDone,
TotalSize = torrent.TotalSize,
DownloadDir = torrent.DownloadDirectory ?? "",
AddedDate = torrent.AddedOn,
CompletedDate = torrent.CompletedOn,
DownloadedEver = torrent.DownloadedEver,
UploadedEver = torrent.UploadedEver,
UploadRatio = (int)torrent.UploadRatio,
ErrorString = torrent.ErrorMessage ?? "",
HashString = torrent.Hash,
PeersConnected = torrent.PeersConnected,
DownloadSpeed = torrent.DownloadSpeed,
UploadSpeed = torrent.UploadSpeed,
Category = torrent.Category ?? "",
HasMetadata = torrent.HasMetadata,
TransmissionInstance = torrent.TransmissionInstance ?? "default",
SourceFeedId = torrent.RssFeedItemId?.ToString() ?? "",
IsPostProcessed = torrent.PostProcessed
};
}
// Helper method to map database RssFeed model to Core.RssFeed
private Core.RssFeed MapToRssFeed(TransmissionRssManager.Data.Models.RssFeed feed)
{
var result = new Core.RssFeed
{
Id = feed.Id.ToString(),
Name = feed.Name,
Url = feed.Url,
AutoDownload = feed.Enabled,
LastChecked = feed.LastCheckedAt,
TransmissionInstanceId = feed.TransmissionInstanceId ?? "default",
Schedule = feed.Schedule,
Enabled = feed.Enabled,
MaxHistoryItems = feed.MaxHistoryItems,
DefaultCategory = feed.DefaultCategory ?? "",
ErrorCount = feed.ErrorCount,
LastError = feed.LastError != null ? feed.LastCheckedAt : null,
LastErrorMessage = feed.LastError
};
// Add rules to the feed
if (feed.Rules != null)
{
foreach (var rule in feed.Rules)
{
result.AdvancedRules.Add(new Core.RssFeedRule
{
Id = rule.Id.ToString(),
Name = rule.Name,
Pattern = rule.IncludePattern ?? "",
IsRegex = rule.UseRegex,
IsEnabled = rule.Enabled,
IsCaseSensitive = false, // Default value as this field doesn't exist in the DB model
Category = rule.CustomSavePath ?? "",
Priority = rule.Priority,
Action = rule.EnablePostProcessing ? "process" : "download",
DestinationFolder = rule.CustomSavePath ?? ""
});
}
}
return result;
}
public MetricsService(
ILogger<MetricsService> logger,
ITransmissionClient transmissionClient,
IRepository<TransmissionRssManager.Data.Models.RssFeed> feedRepository,
IRepository<TransmissionRssManager.Data.Models.RssFeedItem> feedItemRepository,
IRepository<TransmissionRssManager.Data.Models.Torrent> torrentRepository,
IRepository<TransmissionRssManager.Data.Models.SystemLogEntry> logRepository,
ILoggingService loggingService,
IConfigService configService)
{
_logger = logger;
_transmissionClient = transmissionClient;
_feedRepository = feedRepository;
_feedItemRepository = feedItemRepository;
_torrentRepository = torrentRepository;
_logRepository = logRepository;
_loggingService = loggingService;
_configService = configService;
}
public async Task<DashboardStats> GetDashboardStatsAsync()
/// <summary>
/// Gets dashboard statistics including active downloads, upload/download speeds, etc.
/// </summary>
public async Task<Dictionary<string, object>> GetDashboardStatsAsync()
{
try
{
var now = DateTime.UtcNow;
var today = new DateTime(now.Year, now.Month, now.Day, 0, 0, 0, DateTimeKind.Utc);
var stats = new Dictionary<string, object>();
var torrents = await _transmissionClient.GetTorrentsAsync();
// Combine data from both Transmission and database
// Get active torrents from Transmission for real-time data
var transmissionTorrents = await _transmissionClient.GetTorrentsAsync();
// Calculate basic stats
stats["TotalTorrents"] = torrents.Count;
stats["ActiveDownloads"] = torrents.Count(t => t.Status == "Downloading");
stats["SeedingTorrents"] = torrents.Count(t => t.Status == "Seeding");
stats["CompletedTorrents"] = torrents.Count(t => t.IsFinished);
stats["TotalDownloaded"] = torrents.Sum(t => t.DownloadedEver);
stats["TotalUploaded"] = torrents.Sum(t => t.UploadedEver);
stats["DownloadSpeed"] = torrents.Sum(t => t.DownloadSpeed);
stats["UploadSpeed"] = torrents.Sum(t => t.UploadSpeed);
// Get database torrent data for historical information
var dbTorrents = await _torrentRepository.Query().ToListAsync();
// Calculate total size
long totalSize = torrents.Sum(t => t.TotalSize);
stats["TotalSize"] = totalSize;
// Count active downloads (status is downloading)
var activeDownloads = transmissionTorrents.Count(t => t.Status == "Downloading");
// Count seeding torrents (status is seeding)
var seedingTorrents = transmissionTorrents.Count(t => t.Status == "Seeding");
// Get active feeds count
var activeFeeds = await _feedRepository.Query()
.Where(f => f.Enabled)
.CountAsync();
// Get completed downloads today
var completedToday = dbTorrents
.Count(t => t.CompletedOn.HasValue && t.CompletedOn.Value >= today);
// Get added today
var addedToday = dbTorrents
.Count(t => t.AddedOn >= today);
// Get matched count (all time)
var matchedCount = await _feedItemRepository.Query()
.Where(i => i.MatchedRuleId != null)
.CountAsync();
// Calculate download/upload speeds from Transmission (real-time data)
double downloadSpeed = transmissionTorrents.Sum(t => t.DownloadSpeed);
double uploadSpeed = transmissionTorrents.Sum(t => t.UploadSpeed);
// Update database objects with transmission data
// This helps keep database in sync with transmission for metrics
foreach (var transmissionTorrent in transmissionTorrents)
{
var dbTorrent = dbTorrents.FirstOrDefault(t => t.TransmissionId == transmissionTorrent.Id);
if (dbTorrent != null)
{
// Update database with current speeds and status for the background service to store
dbTorrent.DownloadSpeed = transmissionTorrent.DownloadSpeed;
dbTorrent.UploadSpeed = transmissionTorrent.UploadSpeed;
dbTorrent.Status = transmissionTorrent.Status;
dbTorrent.PercentDone = transmissionTorrent.PercentDone;
dbTorrent.DownloadedEver = transmissionTorrent.DownloadedEver;
dbTorrent.UploadedEver = transmissionTorrent.UploadedEver;
dbTorrent.PeersConnected = transmissionTorrent.PeersConnected;
// Update the database object
await _torrentRepository.UpdateAsync(dbTorrent);
}
}
// Save the changes
await _torrentRepository.SaveChangesAsync();
// Calculate total downloaded and uploaded
// Use Transmission for active torrents (more accurate) and database for historical torrents
var totalDownloaded = transmissionTorrents.Sum(t => t.DownloadedEver);
var totalUploaded = transmissionTorrents.Sum(t => t.UploadedEver);
// Add historical data from database for torrents that are no longer in Transmission
var transmissionIds = transmissionTorrents.Select(t => t.Id).ToHashSet();
var historicalTorrents = dbTorrents.Where(t => t.TransmissionId.HasValue && !transmissionIds.Contains(t.TransmissionId.Value));
totalDownloaded += historicalTorrents.Sum(t => t.DownloadedEver);
totalUploaded += historicalTorrents.Sum(t => t.UploadedEver);
return new DashboardStats
{
ActiveDownloads = activeDownloads,
SeedingTorrents = seedingTorrents,
ActiveFeeds = activeFeeds,
CompletedToday = completedToday,
AddedToday = addedToday,
FeedsCount = await _feedRepository.Query().CountAsync(),
MatchedCount = matchedCount,
DownloadSpeed = downloadSpeed,
UploadSpeed = uploadSpeed,
TotalDownloaded = totalDownloaded,
TotalUploaded = totalUploaded
};
return stats;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting dashboard stats");
_loggingService.Log(
LogLevel.Error,
$"Error getting dashboard stats: {ex.Message}",
"MetricsService",
new Dictionary<string, string> {
{ "ErrorMessage", ex.Message },
{ "StackTrace", ex.StackTrace }
}
);
return new DashboardStats();
}
}
public async Task<List<HistoricalDataPoint>> GetDownloadHistoryAsync(int days = 30)
{
try
{
var result = new List<HistoricalDataPoint>();
var endDate = DateTime.UtcNow;
var startDate = endDate.AddDays(-days);
// Get download history from the database for added torrents
var addedTorrents = await _torrentRepository.Query()
.Where(t => t.AddedOn >= startDate && t.AddedOn <= endDate)
.ToListAsync();
// Get completed torrents history
var completedTorrents = await _torrentRepository.Query()
.Where(t => t.CompletedOn.HasValue && t.CompletedOn.Value >= startDate && t.CompletedOn.Value <= endDate)
.ToListAsync();
// Group by date added
var groupedByAdded = addedTorrents
.GroupBy(t => new DateTime(t.AddedOn.Year, t.AddedOn.Month, t.AddedOn.Day, 0, 0, 0, DateTimeKind.Utc))
.Select(g => new {
Date = g.Key,
Count = g.Count(),
TotalSize = g.Sum(t => t.TotalSize)
})
.OrderBy(g => g.Date)
.ToList();
// Group by date completed
var groupedByCompleted = completedTorrents
.GroupBy(t => new DateTime(t.CompletedOn.Value.Year, t.CompletedOn.Value.Month, t.CompletedOn.Value.Day, 0, 0, 0, DateTimeKind.Utc))
.Select(g => new {
Date = g.Key,
Count = g.Count(),
TotalSize = g.Sum(t => t.TotalSize)
})
.OrderBy(g => g.Date)
.ToList();
// Fill in missing dates
for (var date = startDate; date <= endDate; date = date.AddDays(1))
return new Dictionary<string, object>
{
var day = new DateTime(date.Year, date.Month, date.Day, 0, 0, 0, DateTimeKind.Utc);
// Get data for this day
var addedData = groupedByAdded.FirstOrDefault(g => g.Date == day);
var completedData = groupedByCompleted.FirstOrDefault(g => g.Date == day);
// Create data point with added count and size
result.Add(new HistoricalDataPoint
{
Date = day,
Count = addedData?.Count ?? 0, // Use added count by default
TotalSize = addedData?.TotalSize ?? 0,
CompletedCount = completedData?.Count ?? 0,
CompletedSize = completedData?.TotalSize ?? 0
});
}
return result;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting download history");
_loggingService.Log(
LogLevel.Error,
$"Error getting download history: {ex.Message}",
"MetricsService",
new Dictionary<string, string> {
{ "ErrorMessage", ex.Message },
{ "StackTrace", ex.StackTrace },
{ "Days", days.ToString() }
}
);
return new List<HistoricalDataPoint>();
["Error"] = ex.Message,
["TotalTorrents"] = 0,
["ActiveDownloads"] = 0,
["SeedingTorrents"] = 0,
["CompletedTorrents"] = 0
};
}
}
public async Task<List<CategoryStats>> GetCategoryStatsAsync()
{
try
{
// Get torrents with categories from database
var dbTorrents = await _torrentRepository.Query()
.Where(t => t.Category != null)
.ToListAsync();
// Get torrents from Transmission for real-time data
var transmissionTorrents = await _transmissionClient.GetTorrentsAsync();
// Map Transmission torrents to dictionary by hash for quick lookup
var transmissionTorrentsByHash = transmissionTorrents
.Where(t => !string.IsNullOrEmpty(t.HashString))
.ToDictionary(t => t.HashString, t => t);
// Create a list to store category stats with combined data
var categoryStats = new Dictionary<string, CategoryStats>();
// Process database torrents first
foreach (var torrent in dbTorrents)
{
var category = torrent.Category ?? "Uncategorized";
if (!categoryStats.TryGetValue(category, out var stats))
{
stats = new CategoryStats
{
Category = category,
Count = 0,
TotalSize = 0,
ActiveCount = 0,
CompletedCount = 0,
DownloadSpeed = 0,
UploadSpeed = 0
};
categoryStats[category] = stats;
}
stats.Count++;
stats.TotalSize += torrent.TotalSize;
// Check if this torrent is completed
if (torrent.CompletedOn.HasValue)
{
stats.CompletedCount++;
}
// Check if this torrent is active in Transmission
if (transmissionTorrentsByHash.TryGetValue(torrent.Hash, out var transmissionTorrent))
{
stats.ActiveCount++;
stats.DownloadSpeed += transmissionTorrent.DownloadSpeed;
stats.UploadSpeed += transmissionTorrent.UploadSpeed;
}
}
// Process any Transmission torrents that might not be in the database
foreach (var torrent in transmissionTorrents)
{
// Skip if no hash or already processed
if (string.IsNullOrEmpty(torrent.HashString) ||
dbTorrents.Any(t => t.Hash == torrent.HashString))
{
continue;
}
var category = torrent.Category ?? "Uncategorized";
if (!categoryStats.TryGetValue(category, out var stats))
{
stats = new CategoryStats
{
Category = category,
Count = 0,
TotalSize = 0,
ActiveCount = 0,
CompletedCount = 0,
DownloadSpeed = 0,
UploadSpeed = 0
};
categoryStats[category] = stats;
}
stats.Count++;
stats.TotalSize += torrent.TotalSize;
stats.ActiveCount++;
stats.DownloadSpeed += torrent.DownloadSpeed;
stats.UploadSpeed += torrent.UploadSpeed;
// Check if this torrent is completed
if (torrent.IsFinished)
{
stats.CompletedCount++;
}
}
// Return the category stats ordered by count
return categoryStats.Values
.OrderByDescending(c => c.Count)
.ToList();
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting category stats");
_loggingService.Log(
LogLevel.Error,
$"Error getting category stats: {ex.Message}",
"MetricsService",
new Dictionary<string, string> {
{ "ErrorMessage", ex.Message },
{ "StackTrace", ex.StackTrace }
}
);
return new List<CategoryStats>();
}
}
public async Task<SystemStatus> GetSystemStatusAsync()
{
var config = _configService.GetConfiguration();
var status = new SystemStatus
{
TransmissionConnected = false,
AutoDownloadEnabled = config.AutoDownloadEnabled,
PostProcessingEnabled = config.PostProcessing.Enabled,
EnabledFeeds = await _feedRepository.Query().Where(f => f.Enabled).CountAsync(),
TotalFeeds = await _feedRepository.Query().CountAsync(),
CheckIntervalMinutes = config.CheckIntervalMinutes,
NotificationsEnabled = config.UserPreferences.NotificationsEnabled,
DatabaseStatus = "Connected"
};
try
{
// Check database health by counting torrents
var torrentCount = await _torrentRepository.Query().CountAsync();
status.TorrentCount = torrentCount;
// Count torrents by status
status.ActiveTorrentCount = await _torrentRepository.Query()
.Where(t => t.Status == "downloading" || t.Status == "Downloading")
.CountAsync();
status.CompletedTorrentCount = await _torrentRepository.Query()
.Where(t => t.CompletedOn.HasValue)
.CountAsync();
// Check feed items count
status.FeedItemCount = await _feedItemRepository.Query().CountAsync();
// Check log entries count
var logCount = await _logRepository.Query().CountAsync();
status.LogEntryCount = logCount;
// Get database size estimate (rows * avg row size)
long estimatedDbSize = (torrentCount * 1024) + (status.FeedItemCount * 512) + (logCount * 256);
status.EstimatedDatabaseSizeBytes = estimatedDbSize;
// Try to connect to Transmission to check if it's available
var torrents = await _transmissionClient.GetTorrentsAsync();
status.TransmissionConnected = true;
status.TransmissionVersion = "Unknown"; // We don't have a way to get this info directly
status.TransmissionTorrentCount = torrents.Count;
// Enhancement: Map any transmission torrents not in our database
var dbTorrents = await _torrentRepository.Query().ToListAsync();
var transmissionHashes = torrents.Select(t => t.HashString).ToHashSet();
var dbHashes = dbTorrents.Select(t => t.Hash).ToHashSet();
status.OrphanedTorrentCount = transmissionHashes.Count(h => !dbHashes.Contains(h));
status.StaleDbTorrentCount = dbHashes.Count(h => !transmissionHashes.Contains(h));
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting system status");
status.TransmissionConnected = false;
status.LastErrorMessage = ex.Message;
}
return status;
}
/// <summary>
/// Estimates disk usage for torrents and available space
/// </summary>
public async Task<Dictionary<string, long>> EstimateDiskUsageAsync()
{
try
{
// Get disk usage from both Transmission and database
var transmissionTorrents = await _transmissionClient.GetTorrentsAsync();
var dbTorrents = await _torrentRepository.Query().ToListAsync();
// Calculate total size from Transmission (most accurate for active torrents)
long transmissionSize = transmissionTorrents.Sum(t => t.TotalSize);
// Add sizes from database for torrents not in Transmission (historical data)
var transmissionHashes = transmissionTorrents.Select(t => t.HashString).ToHashSet();
var historicalTorrents = dbTorrents.Where(t => !transmissionHashes.Contains(t.Hash));
long historicalSize = historicalTorrents.Sum(t => t.TotalSize);
// Also get estimated database size
long databaseSize = await _torrentRepository.Query().CountAsync() * 1024 + // ~1KB per torrent
await _feedItemRepository.Query().CountAsync() * 512 + // ~512B per feed item
await _logRepository.Query().CountAsync() * 256; // ~256B per log entry
// Get disk usage from torrents
var torrents = await _transmissionClient.GetTorrentsAsync();
long totalSize = torrents.Sum(t => t.TotalSize);
// Calculate available space in download directory
string downloadDir = _configService.GetConfiguration().DownloadDirectory;
@ -532,8 +95,12 @@ namespace TransmissionRssManager.Services
{
try
{
var driveInfo = new System.IO.DriveInfo(System.IO.Path.GetPathRoot(downloadDir));
availableSpace = driveInfo.AvailableFreeSpace;
var root = System.IO.Path.GetPathRoot(downloadDir);
if (!string.IsNullOrEmpty(root))
{
var driveInfo = new System.IO.DriveInfo(root);
availableSpace = driveInfo.AvailableFreeSpace;
}
}
catch (Exception ex)
{
@ -543,204 +110,56 @@ namespace TransmissionRssManager.Services
return new Dictionary<string, long>
{
["activeTorrentsSize"] = transmissionSize,
["historicalTorrentsSize"] = historicalSize,
["totalTorrentsSize"] = transmissionSize + historicalSize,
["databaseSize"] = databaseSize,
["activeTorrentsSize"] = totalSize,
["availableSpace"] = availableSpace
};
}
catch (Exception ex)
{
_logger.LogError(ex, "Error estimating disk usage");
_loggingService.Log(
LogLevel.Error,
$"Error estimating disk usage: {ex.Message}",
"MetricsService",
new Dictionary<string, string> {
{ "ErrorMessage", ex.Message },
{ "StackTrace", ex.StackTrace }
}
);
return new Dictionary<string, long>
{
["activeTorrentsSize"] = 0,
["historicalTorrentsSize"] = 0,
["totalTorrentsSize"] = 0,
["databaseSize"] = 0,
["availableSpace"] = 0
};
}
}
public async Task<Dictionary<string, double>> GetPerformanceMetricsAsync()
/// <summary>
/// Gets system status including Transmission connection state
/// </summary>
public async Task<Dictionary<string, object>> GetSystemStatusAsync()
{
var metrics = new Dictionary<string, double>();
var config = _configService.GetConfiguration();
var status = new Dictionary<string, object>
{
["TransmissionConnected"] = false,
["AutoDownloadEnabled"] = config.AutoDownloadEnabled,
["PostProcessingEnabled"] = config.PostProcessing.Enabled,
["CheckIntervalMinutes"] = config.CheckIntervalMinutes
};
try
{
// Calculate average time to complete downloads
var completedTorrents = await _torrentRepository.Query()
.Where(t => t.CompletedOn.HasValue)
.ToListAsync();
// Try to connect to Transmission to check if it's available
var torrents = await _transmissionClient.GetTorrentsAsync();
if (completedTorrents.Any())
{
var avgCompletionTimeMinutes = completedTorrents
.Where(t => t.AddedOn < t.CompletedOn)
.Average(t => (t.CompletedOn.Value - t.AddedOn).TotalMinutes);
metrics["AvgCompletionTimeMinutes"] = Math.Round(avgCompletionTimeMinutes, 2);
}
else
{
metrics["AvgCompletionTimeMinutes"] = 0;
}
status["TransmissionConnected"] = true;
status["TransmissionTorrentCount"] = torrents.Count;
// Calculate feed refresh performance
var feeds = await _feedRepository.Query().ToListAsync();
if (feeds.Any())
{
var avgItemsPerFeed = await _feedItemRepository.Query().CountAsync() / (double)feeds.Count;
metrics["AvgItemsPerFeed"] = Math.Round(avgItemsPerFeed, 2);
}
else
{
metrics["AvgItemsPerFeed"] = 0;
}
return metrics;
// Count torrents by status
status["ActiveTorrentCount"] = torrents.Count(t => t.Status == "Downloading");
status["CompletedTorrentCount"] = torrents.Count(t => t.IsFinished);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting performance metrics");
return new Dictionary<string, double>
{
["AvgCompletionTimeMinutes"] = 0,
["AvgItemsPerFeed"] = 0
};
}
}
}
public class DashboardStats
{
public int ActiveDownloads { get; set; }
public int SeedingTorrents { get; set; }
public int ActiveFeeds { get; set; }
public int CompletedToday { get; set; }
public int AddedToday { get; set; }
public int FeedsCount { get; set; }
public int MatchedCount { get; set; }
public double DownloadSpeed { get; set; }
public double UploadSpeed { get; set; }
public long TotalDownloaded { get; set; }
public long TotalUploaded { get; set; }
}
public class HistoricalDataPoint
{
public DateTime Date { get; set; }
public int Count { get; set; }
public long TotalSize { get; set; }
public int CompletedCount { get; set; }
public long CompletedSize { get; set; }
}
public class CategoryStats
{
public string Category { get; set; }
public int Count { get; set; }
public long TotalSize { get; set; }
public int ActiveCount { get; set; }
public int CompletedCount { get; set; }
public double DownloadSpeed { get; set; }
public double UploadSpeed { get; set; }
}
public class SystemStatus
{
public bool TransmissionConnected { get; set; }
public string TransmissionVersion { get; set; }
public bool AutoDownloadEnabled { get; set; }
public bool PostProcessingEnabled { get; set; }
public int EnabledFeeds { get; set; }
public int TotalFeeds { get; set; }
public int CheckIntervalMinutes { get; set; }
public bool NotificationsEnabled { get; set; }
// Database status
public string DatabaseStatus { get; set; }
public int TorrentCount { get; set; }
public int ActiveTorrentCount { get; set; }
public int CompletedTorrentCount { get; set; }
public int FeedItemCount { get; set; }
public int LogEntryCount { get; set; }
public long EstimatedDatabaseSizeBytes { get; set; }
// Transmission status
public int TransmissionTorrentCount { get; set; }
// Sync status
public int OrphanedTorrentCount { get; set; } // Torrents in Transmission but not in database
public int StaleDbTorrentCount { get; set; } // Torrents in database but not in Transmission
// For compatibility
public string TranmissionVersion
{
get => TransmissionVersion;
set => TransmissionVersion = value;
}
// Error info
public string LastErrorMessage { get; set; }
}
public class MetricsBackgroundService : BackgroundService
{
private readonly ILogger<MetricsBackgroundService> _logger;
private readonly IServiceProvider _serviceProvider;
public MetricsBackgroundService(
ILogger<MetricsBackgroundService> logger,
IServiceProvider serviceProvider)
{
_logger = logger;
_serviceProvider = serviceProvider;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("Metrics background service started");
// Update metrics every minute
var timer = new PeriodicTimer(TimeSpan.FromMinutes(1));
while (!stoppingToken.IsCancellationRequested && await timer.WaitForNextTickAsync(stoppingToken))
{
try
{
using var scope = _serviceProvider.CreateScope();
var metricsService = scope.ServiceProvider.GetRequiredService<IMetricsService>();
// This just ensures the metrics are calculated and cached if needed
await metricsService.GetDashboardStatsAsync();
_logger.LogDebug("Metrics updated");
}
catch (OperationCanceledException)
{
// Service is shutting down
break;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error updating metrics");
}
_logger.LogError(ex, "Error getting system status");
status["TransmissionConnected"] = false;
status["LastErrorMessage"] = ex.Message;
}
_logger.LogInformation("Metrics background service stopped");
return status;
}
}
}

View File

@ -3,10 +3,12 @@ using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using TransmissionRssManager.Core;
namespace TransmissionRssManager.Services
@ -16,6 +18,7 @@ namespace TransmissionRssManager.Services
private readonly ILogger<PostProcessor> _logger;
private readonly IConfigService _configService;
private readonly ITransmissionClient _transmissionClient;
private readonly List<TorrentInfo> _completedTorrents = new List<TorrentInfo>();
public PostProcessor(
ILogger<PostProcessor> logger,
@ -29,35 +32,41 @@ namespace TransmissionRssManager.Services
public async Task ProcessCompletedDownloadsAsync(CancellationToken cancellationToken)
{
var config = _configService.GetConfiguration();
_logger.LogInformation("Checking for completed downloads");
var config = _configService.GetConfiguration();
if (!config.PostProcessing.Enabled)
{
_logger.LogInformation("Post-processing is disabled");
return;
}
_logger.LogInformation("Processing completed downloads");
var torrents = await _transmissionClient.GetTorrentsAsync();
var completedTorrents = torrents.Where(t => t.IsFinished).ToList();
foreach (var torrent in completedTorrents)
try
{
if (cancellationToken.IsCancellationRequested)
var torrents = await _transmissionClient.GetTorrentsAsync();
var completedTorrents = torrents.Where(t => t.IsFinished && !_completedTorrents.Any(c => c.Id == t.Id)).ToList();
_logger.LogInformation($"Found {completedTorrents.Count} newly completed torrents");
foreach (var torrent in completedTorrents)
{
_logger.LogInformation("Post-processing cancelled");
return;
if (cancellationToken.IsCancellationRequested)
break;
await ProcessTorrentAsync(torrent);
_completedTorrents.Add(torrent);
}
try
// Clean up the list of completed torrents to avoid memory leaks
if (_completedTorrents.Count > 1000)
{
await ProcessTorrentAsync(torrent);
}
catch (Exception ex)
{
_logger.LogError(ex, $"Error processing torrent: {torrent.Name}");
_completedTorrents.RemoveRange(0, _completedTorrents.Count - 1000);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error processing completed downloads");
}
}
public async Task ProcessTorrentAsync(TorrentInfo torrent)
@ -65,190 +74,207 @@ namespace TransmissionRssManager.Services
_logger.LogInformation($"Processing completed torrent: {torrent.Name}");
var config = _configService.GetConfiguration();
var downloadDir = torrent.DownloadDir;
var torrentPath = Path.Combine(downloadDir, torrent.Name);
var processingConfig = config.PostProcessing;
// Check if the file/directory exists
if (!Directory.Exists(torrentPath) && !File.Exists(torrentPath))
if (!Directory.Exists(torrent.DownloadDir))
{
_logger.LogWarning($"Downloaded path not found: {torrentPath}");
_logger.LogWarning($"Download directory does not exist: {torrent.DownloadDir}");
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))
// Extract archives if enabled
if (processingConfig.ExtractArchives)
{
Directory.CreateDirectory(extractDir);
await ExtractArchivesAsync(torrent.DownloadDir);
}
var processStartInfo = new ProcessStartInfo
// Organize media if enabled
if (processingConfig.OrganizeMedia && !string.IsNullOrEmpty(config.MediaLibraryPath))
{
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;
await OrganizeMediaAsync(torrent.DownloadDir, config.MediaLibraryPath, processingConfig);
}
_logger.LogInformation($"Archive extracted to: {extractDir}");
_logger.LogInformation($"Completed processing torrent: {torrent.Name}");
}
catch (Exception ex)
{
_logger.LogError(ex, $"Error extracting archive: {archivePath}");
_logger.LogError(ex, $"Error processing torrent: {torrent.Name}");
}
}
private async Task OrganizeMediaAsync(string path, string mediaLibraryPath)
private async Task ExtractArchivesAsync(string directory)
{
_logger.LogInformation($"Organizing media: {path}");
_logger.LogInformation($"Extracting archives in {directory}");
var config = _configService.GetConfiguration();
var mediaExtensions = config.PostProcessing.MediaExtensions;
// Ensure media library path exists
if (!Directory.Exists(mediaLibraryPath))
var archiveExtensions = new[] { ".rar", ".zip", ".7z", ".tar", ".gz" };
var archiveFiles = Directory.GetFiles(directory, "*.*", SearchOption.AllDirectories)
.Where(f => archiveExtensions.Contains(Path.GetExtension(f).ToLowerInvariant()))
.ToList();
foreach (var archiveFile in archiveFiles)
{
Directory.CreateDirectory(mediaLibraryPath);
}
try
{
if (File.Exists(path))
try
{
// 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();
_logger.LogInformation($"Extracting archive: {archiveFile}");
foreach (var mediaFile in mediaFiles)
var dirName = Path.GetDirectoryName(archiveFile);
var fileName = Path.GetFileNameWithoutExtension(archiveFile);
if (dirName == null)
{
await CopyFileToMediaLibraryAsync(mediaFile, mediaLibraryPath);
_logger.LogWarning($"Could not get directory name for archive: {archiveFile}");
continue;
}
var extractDir = Path.Combine(dirName, fileName);
if (!Directory.Exists(extractDir))
{
Directory.CreateDirectory(extractDir);
}
await Task.Run(() => ExtractWithSharpCompress(archiveFile, extractDir));
}
catch (Exception ex)
{
_logger.LogError(ex, $"Error extracting archive: {archiveFile}");
}
}
catch (Exception ex)
}
private void ExtractWithSharpCompress(string archiveFile, string extractDir)
{
// In a real implementation, this would use SharpCompress to extract files
_logger.LogInformation($"Would extract {archiveFile} to {extractDir}");
// For testing, we'll create a dummy file to simulate extraction
File.WriteAllText(
Path.Combine(extractDir, "extracted.txt"),
$"Extracted from {archiveFile} at {DateTime.Now}"
);
}
private async Task OrganizeMediaAsync(string sourceDir, string targetDir, PostProcessingConfig config)
{
_logger.LogInformation($"Organizing media from {sourceDir} to {targetDir}");
if (!Directory.Exists(targetDir))
{
_logger.LogError(ex, $"Error organizing media: {path}");
Directory.CreateDirectory(targetDir);
}
var mediaFiles = Directory.GetFiles(sourceDir, "*.*", SearchOption.AllDirectories)
.Where(f => config.MediaExtensions.Contains(Path.GetExtension(f).ToLowerInvariant()))
.ToList();
foreach (var mediaFile in mediaFiles)
{
try
{
_logger.LogInformation($"Processing media file: {mediaFile}");
string destFolder = targetDir;
// Organize by media type if enabled
if (config.AutoOrganizeByMediaType)
{
string mediaType = DetermineMediaType(mediaFile);
destFolder = Path.Combine(targetDir, mediaType);
if (!Directory.Exists(destFolder))
{
Directory.CreateDirectory(destFolder);
}
}
string destFile = Path.Combine(destFolder, Path.GetFileName(mediaFile));
// Rename file if needed
if (config.RenameFiles)
{
string newFileName = CleanFileName(Path.GetFileName(mediaFile));
destFile = Path.Combine(destFolder, newFileName);
}
// Copy file (in real implementation we might move instead)
await Task.Run(() => File.Copy(mediaFile, destFile, true));
_logger.LogInformation($"Copied {mediaFile} to {destFile}");
}
catch (Exception ex)
{
_logger.LogError(ex, $"Error processing media file: {mediaFile}");
}
}
}
private async Task CopyFileToMediaLibraryAsync(string filePath, string mediaLibraryPath)
private string DetermineMediaType(string filePath)
{
var fileName = Path.GetFileName(filePath);
var destinationPath = Path.Combine(mediaLibraryPath, fileName);
// In a real implementation, this would analyze the file to determine its type
// For now, just return a simple category based on extension
// If destination file already exists, add a unique identifier
if (File.Exists(destinationPath))
string ext = Path.GetExtension(filePath).ToLowerInvariant();
if (new[] { ".mp4", ".mkv", ".avi", ".mov" }.Contains(ext))
{
var fileNameWithoutExt = Path.GetFileNameWithoutExtension(fileName);
var extension = Path.GetExtension(fileName);
var uniqueId = Guid.NewGuid().ToString().Substring(0, 8);
return "Videos";
}
else if (new[] { ".mp3", ".flac", ".wav", ".aac" }.Contains(ext))
{
return "Music";
}
else if (new[] { ".jpg", ".png", ".gif", ".bmp" }.Contains(ext))
{
return "Images";
}
else
{
return "Other";
}
}
private string CleanFileName(string fileName)
{
// Replace invalid characters and clean up the filename
string invalidChars = new string(Path.GetInvalidFileNameChars());
string invalidReStr = string.Format(@"[{0}]", Regex.Escape(invalidChars));
// Remove scene tags, dots, underscores, etc.
string cleanName = fileName
.Replace(".", " ")
.Replace("_", " ");
destinationPath = Path.Combine(mediaLibraryPath, $"{fileNameWithoutExt}_{uniqueId}{extension}");
// Replace invalid characters
cleanName = Regex.Replace(cleanName, invalidReStr, "");
// Remove extra spaces
cleanName = Regex.Replace(cleanName, @"\s+", " ").Trim();
// Add original extension if it was removed
string originalExt = Path.GetExtension(fileName);
if (!cleanName.EndsWith(originalExt, StringComparison.OrdinalIgnoreCase))
{
cleanName += originalExt;
}
_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}");
}
return cleanName;
}
}
public class PostProcessingBackgroundService : BackgroundService
{
private readonly ILogger<PostProcessingBackgroundService> _logger;
private readonly IPostProcessor _postProcessor;
private readonly IConfigService _configService;
private readonly IServiceProvider _serviceProvider;
public PostProcessingBackgroundService(
ILogger<PostProcessingBackgroundService> logger,
IPostProcessor postProcessor,
IConfigService configService)
IServiceProvider serviceProvider)
{
_logger = logger;
_postProcessor = postProcessor;
_configService = configService;
_serviceProvider = serviceProvider;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("Post-processing background service started");
@ -257,16 +283,25 @@ namespace TransmissionRssManager.Services
{
try
{
await _postProcessor.ProcessCompletedDownloadsAsync(stoppingToken);
using (var scope = _serviceProvider.CreateScope())
{
var postProcessor = scope.ServiceProvider.GetRequiredService<IPostProcessor>();
var configService = scope.ServiceProvider.GetRequiredService<IConfigService>();
await postProcessor.ProcessCompletedDownloadsAsync(stoppingToken);
// Check every minute for completed downloads
await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error processing completed downloads");
_logger.LogError(ex, "Error in post-processing background service");
await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken);
}
// Check every 5 minutes
await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken);
}
_logger.LogInformation("Post-processing background service stopped");
}
}
}

View File

@ -4,13 +4,13 @@ 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.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using TransmissionRssManager.Core;
namespace TransmissionRssManager.Services
@ -20,9 +20,9 @@ namespace TransmissionRssManager.Services
private readonly ILogger<RssFeedManager> _logger;
private readonly IConfigService _configService;
private readonly ITransmissionClient _transmissionClient;
private List<RssFeed> _feeds = new List<RssFeed>();
private List<RssFeedItem> _feedItems = new List<RssFeedItem>();
private readonly HttpClient _httpClient;
private readonly string _dataPath;
private List<RssFeedItem> _items = new List<RssFeedItem>();
public RssFeedManager(
ILogger<RssFeedManager> logger,
@ -34,276 +34,294 @@ namespace TransmissionRssManager.Services
_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()
{
// Load feeds from config
var config = _configService.GetConfiguration();
return Task.FromResult(config.Feeds);
_feeds = config.Feeds;
}
public async Task<List<RssFeedItem>> GetAllItemsAsync()
{
return _feedItems;
}
public async Task<List<RssFeedItem>> GetMatchedItemsAsync()
{
return _feedItems.Where(item => item.IsMatched).ToList();
}
public async Task<List<RssFeed>> GetFeedsAsync()
{
return _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);
_feeds.Add(feed);
await SaveFeedsToConfigAsync();
}
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();
}
_feeds.RemoveAll(f => f.Id == feedId);
_feedItems.RemoveAll(i => i.FeedId == feedId);
await SaveFeedsToConfigAsync();
}
public async Task UpdateFeedAsync(RssFeed feed)
{
var config = _configService.GetConfiguration();
var index = config.Feeds.FindIndex(f => f.Id == feed.Id);
if (index != -1)
var existingFeed = _feeds.FirstOrDefault(f => f.Id == feed.Id);
if (existingFeed != null)
{
config.Feeds[index] = feed;
await _configService.SaveConfigurationAsync(config);
int index = _feeds.IndexOf(existingFeed);
_feeds[index] = feed;
await SaveFeedsToConfigAsync();
}
}
private async Task SaveFeedsToConfigAsync()
{
var config = _configService.GetConfiguration();
config.Feeds = _feeds;
await _configService.SaveConfigurationAsync(config);
}
public async Task RefreshFeedsAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("Starting RSS feed refresh");
var config = _configService.GetConfiguration();
_logger.LogInformation("Refreshing RSS feeds");
foreach (var feed in config.Feeds)
foreach (var feed in _feeds.Where(f => f.Enabled))
{
if (cancellationToken.IsCancellationRequested)
break;
try
{
_logger.LogInformation("RSS refresh cancelled");
await RefreshFeedAsync(feed.Id, cancellationToken);
}
catch (Exception ex)
{
_logger.LogError(ex, $"Error refreshing feed {feed.Name}");
}
}
}
public async Task RefreshFeedAsync(string feedId, CancellationToken cancellationToken)
{
var feed = _feeds.FirstOrDefault(f => f.Id == feedId);
if (feed == null)
return;
try
{
_logger.LogInformation($"Refreshing feed: {feed.Name}");
var feedItems = await FetchFeedItemsAsync(feed.Url);
foreach (var item in feedItems)
{
// Add only if we don't already have this item
if (!_feedItems.Any(i => i.Link == item.Link && i.FeedId == feed.Id))
{
item.FeedId = feed.Id;
_feedItems.Add(item);
// Apply rules
ApplyRulesToItem(feed, item);
// Download if matched and auto-download is enabled
if (item.IsMatched && feed.AutoDownload)
{
await DownloadMatchedItemAsync(item);
}
}
}
// Update last checked time
feed.LastChecked = DateTime.UtcNow;
feed.ErrorCount = 0;
feed.LastErrorMessage = string.Empty;
// Cleanup old items
CleanupOldItems(feed);
}
catch (Exception ex)
{
_logger.LogError(ex, $"Error refreshing feed {feed.Name}: {ex.Message}");
feed.ErrorCount++;
feed.LastError = DateTime.UtcNow;
feed.LastErrorMessage = ex.Message;
}
}
private async Task<List<RssFeedItem>> FetchFeedItemsAsync(string url)
{
var feedItems = new List<RssFeedItem>();
try
{
var response = await _httpClient.GetStringAsync(url);
using (var reader = XmlReader.Create(new StringReader(response)))
{
var feed = SyndicationFeed.Load(reader);
foreach (var item in feed.Items)
{
var feedItem = new RssFeedItem
{
Id = Guid.NewGuid().ToString(),
Title = item.Title?.Text ?? "",
Description = item.Summary?.Text ?? "",
Link = item.Links.FirstOrDefault()?.Uri.ToString() ?? "",
PublishDate = item.PublishDate.UtcDateTime,
Author = item.Authors.FirstOrDefault()?.Name ?? ""
};
// Find torrent link
foreach (var link in item.Links)
{
if (link.MediaType?.Contains("torrent") == true ||
link.Uri.ToString().EndsWith(".torrent") ||
link.Uri.ToString().StartsWith("magnet:"))
{
feedItem.TorrentUrl = link.Uri.ToString();
break;
}
}
// If no torrent link found, use the main link
if (string.IsNullOrEmpty(feedItem.TorrentUrl))
{
feedItem.TorrentUrl = feedItem.Link;
}
feedItems.Add(feedItem);
}
}
}
catch (Exception ex)
{
_logger.LogError(ex, $"Error fetching feed: {url}");
throw;
}
return feedItems;
}
private void ApplyRulesToItem(RssFeed feed, RssFeedItem item)
{
item.IsMatched = false;
item.MatchedRule = string.Empty;
// Apply simple string rules
foreach (var rulePattern in feed.Rules)
{
if (item.Title.Contains(rulePattern, StringComparison.OrdinalIgnoreCase))
{
item.IsMatched = true;
item.MatchedRule = rulePattern;
item.Category = feed.DefaultCategory;
break;
}
}
// Apply advanced rules
foreach (var rule in feed.AdvancedRules.Where(r => r.IsEnabled).OrderByDescending(r => r.Priority))
{
bool isMatch = false;
if (rule.IsRegex)
{
try
{
var regex = new Regex(rule.Pattern,
rule.IsCaseSensitive ? RegexOptions.None : RegexOptions.IgnoreCase);
isMatch = regex.IsMatch(item.Title);
}
catch (Exception ex)
{
_logger.LogError(ex, $"Invalid regex pattern: {rule.Pattern}");
}
}
else
{
var comparison = rule.IsCaseSensitive ?
StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase;
isMatch = item.Title.Contains(rule.Pattern, comparison);
}
if (isMatch)
{
item.IsMatched = true;
item.MatchedRule = rule.Name;
item.Category = rule.Category;
break;
}
}
}
private async Task DownloadMatchedItemAsync(RssFeedItem item)
{
try
{
var config = _configService.GetConfiguration();
var downloadDir = config.DownloadDirectory;
if (string.IsNullOrEmpty(downloadDir))
{
_logger.LogWarning("Download directory not configured");
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}");
}
_logger.LogInformation($"Downloading matched item: {item.Title}");
// Add torrent to Transmission
int torrentId = await _transmissionClient.AddTorrentAsync(item.TorrentUrl, downloadDir);
// Update feed item
item.IsDownloaded = true;
item.DownloadDate = DateTime.UtcNow;
item.TorrentId = torrentId;
_logger.LogInformation($"Added torrent: {item.Title} (ID: {torrentId})");
}
catch (Exception ex)
{
_logger.LogError(ex, $"Error downloading item: {item.Title}");
item.RejectionReason = ex.Message;
}
// Check for matches and auto-download if enabled
await ProcessMatchesAsync();
}
private void CleanupOldItems(RssFeed feed)
{
if (feed.MaxHistoryItems <= 0)
return;
var feedItems = _feedItems.Where(i => i.FeedId == feed.Id).ToList();
if (feedItems.Count > feed.MaxHistoryItems)
{
// Keep all downloaded items
var downloadedItems = feedItems.Where(i => i.IsDownloaded).ToList();
// Keep most recent non-downloaded items up to the limit
var nonDownloadedItems = feedItems.Where(i => !i.IsDownloaded)
.OrderByDescending(i => i.PublishDate)
.Take(feed.MaxHistoryItems - downloadedItems.Count)
.ToList();
// Set new list
var itemsToKeep = downloadedItems.Union(nonDownloadedItems).ToList();
_feedItems.RemoveAll(i => i.FeedId == feed.Id && !itemsToKeep.Contains(i));
}
}
public async Task MarkItemAsDownloadedAsync(string itemId)
{
var item = _items.FirstOrDefault(i => i.Id == itemId);
var item = _feedItems.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");
item.DownloadDate = DateTime.UtcNow;
}
}
}
@ -311,17 +329,14 @@ namespace TransmissionRssManager.Services
public class RssFeedBackgroundService : BackgroundService
{
private readonly ILogger<RssFeedBackgroundService> _logger;
private readonly IRssFeedManager _rssFeedManager;
private readonly IConfigService _configService;
private readonly IServiceProvider _serviceProvider;
public RssFeedBackgroundService(
ILogger<RssFeedBackgroundService> logger,
IRssFeedManager rssFeedManager,
IConfigService configService)
IServiceProvider serviceProvider)
{
_logger = logger;
_rssFeedManager = rssFeedManager;
_configService = configService;
_serviceProvider = serviceProvider;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
@ -332,18 +347,25 @@ namespace TransmissionRssManager.Services
{
try
{
await _rssFeedManager.RefreshFeedsAsync(stoppingToken);
using (var scope = _serviceProvider.CreateScope())
{
var rssFeedManager = scope.ServiceProvider.GetRequiredService<IRssFeedManager>();
var configService = scope.ServiceProvider.GetRequiredService<IConfigService>();
await rssFeedManager.RefreshFeedsAsync(stoppingToken);
var config = configService.GetConfiguration();
var interval = TimeSpan.FromMinutes(config.CheckIntervalMinutes);
_logger.LogInformation($"Next refresh in {interval.TotalMinutes} minutes");
await Task.Delay(interval, stoppingToken);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error refreshing RSS feeds");
await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken);
}
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

@ -8,6 +8,12 @@ document.addEventListener('DOMContentLoaded', function() {
// Load initial dashboard data
loadDashboardData();
// Set up dark mode based on user preference
initDarkMode();
// Set up auto refresh if enabled
initAutoRefresh();
// Initialize Bootstrap tooltips
const tooltips = document.querySelectorAll('[data-bs-toggle="tooltip"]');
tooltips.forEach(tooltip => new bootstrap.Tooltip(tooltip));
@ -86,15 +92,65 @@ function initEventListeners() {
document.getElementById('btn-refresh-torrents').addEventListener('click', loadTorrents);
document.getElementById('save-torrent-btn').addEventListener('click', saveTorrent);
// Logs page
document.getElementById('btn-refresh-logs').addEventListener('click', refreshLogs);
document.getElementById('btn-clear-logs').addEventListener('click', clearLogs);
document.getElementById('btn-apply-log-filters').addEventListener('click', applyLogFilters);
document.getElementById('btn-reset-log-filters').addEventListener('click', resetLogFilters);
document.getElementById('btn-export-logs').addEventListener('click', exportLogs);
// Settings page
document.getElementById('settings-form').addEventListener('submit', saveSettings);
document.getElementById('dark-mode-toggle').addEventListener('click', toggleDarkMode);
document.getElementById('btn-reset-settings').addEventListener('click', resetSettings);
// Configuration operations
document.getElementById('btn-backup-config').addEventListener('click', backupConfig);
document.getElementById('btn-reset-config').addEventListener('click', resetConfig);
// Additional Transmission Instances
document.getElementById('add-transmission-instance').addEventListener('click', addTransmissionInstance);
}
// Dashboard
function loadDashboardData() {
loadSystemStatus();
loadRecentMatches();
// Fetch dashboard statistics
fetch('/api/dashboard/stats')
.then(response => response.json())
.then(stats => {
document.getElementById('active-downloads').textContent = stats.activeDownloads;
document.getElementById('seeding-torrents').textContent = stats.seedingTorrents;
document.getElementById('active-feeds').textContent = stats.activeFeeds;
document.getElementById('completed-today').textContent = stats.completedToday;
document.getElementById('added-today').textContent = stats.addedToday;
document.getElementById('feeds-count').textContent = stats.feedsCount;
document.getElementById('matched-count').textContent = stats.matchedCount;
// Format download/upload speeds
const downloadSpeed = formatBytes(stats.downloadSpeed) + '/s';
const uploadSpeed = formatBytes(stats.uploadSpeed) + '/s';
document.getElementById('download-speed').textContent = downloadSpeed;
document.getElementById('upload-speed').textContent = uploadSpeed;
document.getElementById('current-speed').textContent = `${downloadSpeed}${uploadSpeed}`;
// Set progress bars (max 100%)
const maxSpeed = Math.max(stats.downloadSpeed, stats.uploadSpeed, 1);
const dlPercent = Math.min(Math.round((stats.downloadSpeed / maxSpeed) * 100), 100);
const ulPercent = Math.min(Math.round((stats.uploadSpeed / maxSpeed) * 100), 100);
document.getElementById('download-speed-bar').style.width = `${dlPercent}%`;
document.getElementById('upload-speed-bar').style.width = `${ulPercent}%`;
})
.catch(error => {
console.error('Error loading dashboard stats:', error);
});
// Load chart data
loadDownloadHistoryChart();
// Load other dashboard components
loadActiveTorrents();
loadRecentMatches();
}
function loadSystemStatus() {
@ -913,4 +969,543 @@ function formatDate(date) {
function padZero(num) {
return num.toString().padStart(2, '0');
}
// Dark Mode Functions
function initDarkMode() {
// Check local storage preference or system preference
const darkModePreference = localStorage.getItem('darkMode');
if (darkModePreference === 'true' ||
(darkModePreference === null && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
enableDarkMode();
} else {
disableDarkMode();
}
}
function toggleDarkMode() {
if (document.body.classList.contains('dark-mode')) {
disableDarkMode();
} else {
enableDarkMode();
}
}
function enableDarkMode() {
document.body.classList.add('dark-mode');
document.getElementById('dark-mode-toggle').innerHTML = '<i class="bi bi-sun-fill"></i>';
localStorage.setItem('darkMode', 'true');
// Also update user preferences if on settings page
const darkModeCheckbox = document.getElementById('enable-dark-mode');
if (darkModeCheckbox) {
darkModeCheckbox.checked = true;
}
}
function disableDarkMode() {
document.body.classList.remove('dark-mode');
document.getElementById('dark-mode-toggle').innerHTML = '<i class="bi bi-moon-fill"></i>';
localStorage.setItem('darkMode', 'false');
// Also update user preferences if on settings page
const darkModeCheckbox = document.getElementById('enable-dark-mode');
if (darkModeCheckbox) {
darkModeCheckbox.checked = false;
}
}
// Auto-refresh
function initAutoRefresh() {
// Get auto-refresh settings from local storage or use defaults
const autoRefresh = localStorage.getItem('autoRefresh') !== 'false';
const refreshInterval = parseInt(localStorage.getItem('refreshInterval')) || 30;
if (autoRefresh) {
startAutoRefresh(refreshInterval);
}
}
function startAutoRefresh(intervalSeconds) {
// Clear any existing interval
if (window.refreshTimer) {
clearInterval(window.refreshTimer);
}
// Set new interval
window.refreshTimer = setInterval(() => {
const currentPage = window.location.hash.substring(1) || 'dashboard';
loadPageData(currentPage);
}, intervalSeconds * 1000);
localStorage.setItem('autoRefresh', 'true');
localStorage.setItem('refreshInterval', intervalSeconds.toString());
}
function stopAutoRefresh() {
if (window.refreshTimer) {
clearInterval(window.refreshTimer);
window.refreshTimer = null;
}
localStorage.setItem('autoRefresh', 'false');
}
// Chart functions
function loadDownloadHistoryChart() {
fetch('/api/dashboard/history')
.then(response => response.json())
.then(history => {
const ctx = document.getElementById('download-history-chart').getContext('2d');
// Extract dates and count values
const labels = history.map(point => {
const date = new Date(point.date);
return `${date.getMonth() + 1}/${date.getDate()}`;
});
const countData = history.map(point => point.count);
const sizeData = history.map(point => point.totalSize / (1024 * 1024 * 1024)); // Convert to GB
// Create or update chart
if (window.downloadHistoryChart) {
window.downloadHistoryChart.data.labels = labels;
window.downloadHistoryChart.data.datasets[0].data = countData;
window.downloadHistoryChart.data.datasets[1].data = sizeData;
window.downloadHistoryChart.update();
} else {
window.downloadHistoryChart = new Chart(ctx, {
type: 'bar',
data: {
labels: labels,
datasets: [
{
label: 'Number of Downloads',
data: countData,
backgroundColor: 'rgba(13, 110, 253, 0.5)',
borderColor: 'rgba(13, 110, 253, 1)',
borderWidth: 1,
yAxisID: 'y'
},
{
label: 'Total Size (GB)',
data: sizeData,
type: 'line',
borderColor: 'rgba(25, 135, 84, 1)',
backgroundColor: 'rgba(25, 135, 84, 0.1)',
borderWidth: 2,
fill: true,
tension: 0.4,
yAxisID: 'y1'
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'top',
},
tooltip: {
callbacks: {
label: function(context) {
let label = context.dataset.label || '';
if (label) {
label += ': ';
}
if (context.datasetIndex === 0) {
label += context.parsed.y;
} else {
label += context.parsed.y.toFixed(2) + ' GB';
}
return label;
}
}
}
},
scales: {
y: {
type: 'linear',
display: true,
position: 'left',
title: {
display: true,
text: 'Number of Downloads'
},
beginAtZero: true
},
y1: {
type: 'linear',
display: true,
position: 'right',
title: {
display: true,
text: 'Total Size (GB)'
},
beginAtZero: true,
grid: {
drawOnChartArea: false
}
}
}
}
});
}
})
.catch(error => {
console.error('Error loading download history chart:', error);
});
}
// Logs Management
function refreshLogs() {
const logFilters = getLogFilters();
loadLogs(logFilters);
}
function getLogFilters() {
return {
level: document.getElementById('log-level').value,
search: document.getElementById('log-search').value,
dateRange: document.getElementById('log-date-range').value,
skip: 0,
take: parseInt(document.getElementById('items-per-page')?.value || 25)
};
}
function loadLogs(filters) {
const tbody = document.getElementById('logs-table-body');
tbody.innerHTML = '<tr><td colspan="4" class="text-center py-4">Loading logs...</td></tr>';
// Build query string
const query = new URLSearchParams();
if (filters.level && filters.level !== 'All') {
query.append('Level', filters.level);
}
if (filters.search) {
query.append('Search', filters.search);
}
// Handle date range
const now = new Date();
let startDate = null;
switch (filters.dateRange) {
case 'today':
startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0);
break;
case 'yesterday':
startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1, 0, 0, 0);
break;
case 'week':
startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 7, 0, 0, 0);
break;
case 'month':
startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 30, 0, 0, 0);
break;
}
if (startDate) {
query.append('StartDate', startDate.toISOString());
}
query.append('Skip', filters.skip.toString());
query.append('Take', filters.take.toString());
fetch(`/api/logs?${query.toString()}`)
.then(response => response.json())
.then(logs => {
if (logs.length === 0) {
tbody.innerHTML = '<tr><td colspan="4" class="text-center py-4">No logs found</td></tr>';
document.getElementById('log-count').textContent = '0 entries';
document.getElementById('logs-pagination-info').textContent = 'Showing 0 of 0 entries';
return;
}
let html = '';
logs.forEach(log => {
const timestamp = new Date(log.timestamp);
const levelClass = getLevelClass(log.level);
html += `<tr>
<td class="text-nowrap">${formatDate(timestamp)}</td>
<td><span class="badge ${levelClass}">${log.level}</span></td>
<td>${log.message}</td>
<td>${log.context || ''}</td>
</tr>`;
});
tbody.innerHTML = html;
document.getElementById('log-count').textContent = `${logs.length} entries`;
document.getElementById('logs-pagination-info').textContent = `Showing ${logs.length} entries`;
// Update pagination (simplified for now)
updateLogPagination(filters, logs.length);
})
.catch(error => {
console.error('Error loading logs:', error);
tbody.innerHTML = '<tr><td colspan="4" class="text-center py-4">Error loading logs</td></tr>';
});
}
function updateLogPagination(filters, count) {
const pagination = document.getElementById('logs-pagination');
// Simplified pagination - just first page for now
pagination.innerHTML = `
<li class="page-item active">
<a class="page-link" href="#">1</a>
</li>
`;
}
function getLevelClass(level) {
switch (level.toLowerCase()) {
case 'debug':
return 'bg-secondary';
case 'information':
return 'bg-info';
case 'warning':
return 'bg-warning';
case 'error':
return 'bg-danger';
case 'critical':
return 'bg-dark';
default:
return 'bg-secondary';
}
}
function applyLogFilters() {
const filters = getLogFilters();
loadLogs(filters);
}
function resetLogFilters() {
document.getElementById('log-level').value = 'All';
document.getElementById('log-search').value = '';
document.getElementById('log-date-range').value = 'week';
loadLogs(getLogFilters());
}
function clearLogs() {
if (!confirm('Are you sure you want to clear all logs? This action cannot be undone.')) {
return;
}
fetch('/api/logs/clear', {
method: 'POST'
})
.then(response => {
if (!response.ok) {
throw new Error('Failed to clear logs');
}
// Refresh logs
loadLogs(getLogFilters());
})
.catch(error => {
console.error('Error clearing logs:', error);
alert('Error clearing logs');
});
}
function exportLogs() {
const filters = getLogFilters();
const query = new URLSearchParams();
if (filters.level && filters.level !== 'All') {
query.append('Level', filters.level);
}
if (filters.search) {
query.append('Search', filters.search);
}
// Create download link
const link = document.createElement('a');
link.href = `/api/logs/export?${query.toString()}`;
link.download = `transmission-rss-logs-${new Date().toISOString().slice(0, 10)}.csv`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
// Helper function to format file sizes
function formatBytes(bytes, decimals = 2) {
if (bytes === 0) return '0 B';
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
}
// Configuration Operations
function backupConfig() {
if (!confirm('This will create a backup of your configuration files. Do you want to continue?')) {
return;
}
fetch('/api/config/backup', {
method: 'POST'
})
.then(response => {
if (!response.ok) {
throw new Error('Failed to backup configuration');
}
return response.blob();
})
.then(blob => {
// Create download link for the backup file
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.style.display = 'none';
a.href = url;
a.download = `transmission-rss-config-backup-${new Date().toISOString().slice(0, 10)}.json`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
alert('Configuration backup created successfully.');
})
.catch(error => {
console.error('Error backing up configuration:', error);
alert('Error creating configuration backup.');
});
}
function resetConfig() {
if (!confirm('WARNING: This will reset your configuration to default settings. All your feeds, rules, and user preferences will be lost. This cannot be undone. Are you absolutely sure?')) {
return;
}
if (!confirm('FINAL WARNING: All feeds, rules, and settings will be permanently deleted. Type "RESET" to confirm.')) {
return;
}
const confirmation = prompt('Type "RESET" to confirm configuration reset:');
if (confirmation !== 'RESET') {
alert('Configuration reset cancelled.');
return;
}
fetch('/api/config/reset', {
method: 'POST'
})
.then(response => {
if (!response.ok) {
throw new Error('Failed to reset configuration');
}
alert('Configuration has been reset to defaults. The application will now reload.');
window.location.reload();
})
.catch(error => {
console.error('Error resetting configuration:', error);
alert('Error resetting configuration.');
});
}
// Transmission Instance Management
function addTransmissionInstance() {
const instancesList = document.getElementById('transmission-instances-list');
const instanceCount = document.querySelectorAll('.transmission-instance').length;
const newInstanceIndex = instanceCount + 1;
const instanceHtml = `
<div class="transmission-instance card mb-3" id="transmission-instance-${newInstanceIndex}">
<div class="card-body">
<div class="d-flex justify-content-between mb-3">
<h5 class="card-title">Instance #${newInstanceIndex}</h5>
<button type="button" class="btn btn-sm btn-danger" onclick="removeTransmissionInstance(${newInstanceIndex})">
<i class="bi bi-trash me-1"></i>Remove
</button>
</div>
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label class="form-label">Name</label>
<input type="text" class="form-control" name="transmissionInstances[${newInstanceIndex}].name" placeholder="Secondary Server">
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label class="form-label">Host</label>
<input type="text" class="form-control" name="transmissionInstances[${newInstanceIndex}].host">
</div>
</div>
</div>
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label class="form-label">Port</label>
<input type="number" class="form-control" name="transmissionInstances[${newInstanceIndex}].port" value="9091">
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label class="form-label">Username</label>
<input type="text" class="form-control" name="transmissionInstances[${newInstanceIndex}].username">
</div>
</div>
</div>
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label class="form-label">Password</label>
<input type="password" class="form-control" name="transmissionInstances[${newInstanceIndex}].password">
</div>
</div>
<div class="col-md-6">
<div class="form-check form-switch mt-4">
<input class="form-check-input" type="checkbox" name="transmissionInstances[${newInstanceIndex}].useHttps">
<label class="form-check-label">Use HTTPS</label>
</div>
</div>
</div>
</div>
</div>`;
// If the "no instances" message is showing, remove it
if (instancesList.querySelector('.text-center.text-muted')) {
instancesList.innerHTML = '';
}
// Add the new instance
instancesList.insertAdjacentHTML('beforeend', instanceHtml);
}
function removeTransmissionInstance(index) {
const instance = document.getElementById(`transmission-instance-${index}`);
if (instance) {
instance.remove();
// If there are no instances left, show the "no instances" message
const instancesList = document.getElementById('transmission-instances-list');
if (instancesList.children.length === 0) {
instancesList.innerHTML = '<div class="text-center text-muted py-3">No additional instances configured</div>';
}
}
}
function resetSettings() {
if (!confirm('This will reset all settings to their default values. Are you sure?')) {
return;
}
// Load default settings
fetch('/api/config/defaults')
.then(response => response.json())
.then(defaults => {
// Apply defaults to form
loadSettingsIntoForm(defaults);
})
.catch(error => {
console.error('Error loading default settings:', error);
alert('Error loading default settings');
});
}

View File

@ -1,192 +1,158 @@
#!/bin/bash
# Transmission RSS Manager Test Script
# This script checks if the Transmission RSS Manager is installed and running correctly
# Colors for output
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
NC='\033[0m' # No Color
# Print section header
print_section() {
echo -e "\n${GREEN}====== $1 ======${NC}"
}
echo -e "${GREEN}Transmission RSS Manager Test Script${NC}"
echo -e "${YELLOW}This script will check if your installation is working correctly${NC}"
echo
# Error handling
set -e
trap 'echo -e "${RED}An error occurred. Test failed.${NC}"; exit 1' ERR
# Setup test environment
print_section "Setting up test environment"
CURRENT_DIR=$(pwd)
TEST_DIR="$CURRENT_DIR/test-install"
mkdir -p "$TEST_DIR"
CONFIG_DIR="$TEST_DIR/config"
mkdir -p "$CONFIG_DIR"
# Copy necessary files
print_section "Copying files"
mkdir -p "$TEST_DIR"
# Copy only essential files (not the test directory itself)
find "$CURRENT_DIR" -maxdepth 1 -not -path "*test-install*" -not -path "$CURRENT_DIR" -exec cp -r {} "$TEST_DIR/" \;
cd "$TEST_DIR"
# Check .NET SDK installation
print_section "Checking .NET SDK"
dotnet --version
if [ $? -ne 0 ]; then
echo -e "${RED}.NET SDK is not installed or not in PATH. Please install .NET SDK 7.0.${NC}"
# Check if service is installed
if [ ! -f "/etc/systemd/system/transmission-rss-manager.service" ]; then
echo -e "${RED}ERROR: Service file not found. Installation seems incomplete.${NC}"
exit 1
fi
# Build the application
print_section "Building application"
dotnet build -c Release
if [ $? -ne 0 ]; then
echo -e "${RED}Build failed. See errors above.${NC}"
exit 1
fi
echo -e "${GREEN}Build successful.${NC}"
echo -e "${GREEN}${NC} Service file found"
# Create database configuration
print_section "Creating database configuration"
echo '{
"ConnectionStrings": {
"DefaultConnection": "Host=localhost;Database=torrentmanager_test;Username=postgres;Password=postgres"
}
}' > "$CONFIG_DIR/appsettings.json"
cp "$CONFIG_DIR/appsettings.json" "$TEST_DIR/appsettings.json"
echo -e "${GREEN}Database configuration created at $CONFIG_DIR/appsettings.json${NC}"
# Check if PostgreSQL is running
print_section "Checking PostgreSQL"
if command -v pg_isready >/dev/null 2>&1; then
pg_isready
PG_READY=$?
# Check if service is running
if systemctl is-active --quiet transmission-rss-manager; then
echo -e "${GREEN}${NC} Service is running"
else
echo -e "${YELLOW}PostgreSQL client tools not found.${NC}"
PG_READY=1
echo -e "${YELLOW}⚠ Service is not running. Attempting to start...${NC}"
sudo systemctl start transmission-rss-manager
sleep 2
if systemctl is-active --quiet transmission-rss-manager; then
echo -e "${GREEN}${NC} Service successfully started"
else
echo -e "${RED}ERROR: Failed to start service. Checking logs...${NC}"
echo
echo -e "${YELLOW}Service Logs:${NC}"
sudo journalctl -u transmission-rss-manager -n 20 --no-pager
exit 1
fi
fi
if [ $PG_READY -ne 0 ]; then
echo -e "${YELLOW}PostgreSQL is not running or not installed.${NC}"
echo -e "${YELLOW}This test expects PostgreSQL to be available with a 'postgres' user.${NC}"
echo -e "${YELLOW}Either install PostgreSQL or modify the connection string in appsettings.json.${NC}"
# Check application files
INSTALL_DIR="/opt/transmission-rss-manager"
PUBLISH_DIR="$INSTALL_DIR/publish"
if [ ! -d "$PUBLISH_DIR" ]; then
echo -e "${RED}ERROR: Application directory not found at $PUBLISH_DIR${NC}"
exit 1
fi
echo -e "${GREEN}${NC} Application directory found"
# Check for main DLL
APP_DLL=$(find $INSTALL_DIR/publish -name "TransmissionRssManager.dll" 2>/dev/null)
if [ -z "$APP_DLL" ]; then
echo -e "${RED}ERROR: Main application DLL not found${NC}"
exit 1
fi
echo -e "${GREEN}${NC} Application files found"
# Check for config file
CONFIG_DIR="/etc/transmission-rss-manager"
if [ ! -f "$CONFIG_DIR/appsettings.json" ]; then
echo -e "${RED}ERROR: Configuration file not found at $CONFIG_DIR/appsettings.json${NC}"
exit 1
fi
echo -e "${GREEN}${NC} Configuration file found"
# Check for runtime config file
APP_NAME=$(basename "$APP_DLL" .dll)
if [ ! -f "$PUBLISH_DIR/$APP_NAME.runtimeconfig.json" ]; then
echo -e "${RED}ERROR: Runtime configuration file not found${NC}"
exit 1
fi
echo -e "${GREEN}${NC} Runtime configuration file found"
# Check for static web content
if [ ! -d "$PUBLISH_DIR/wwwroot" ]; then
echo -e "${RED}ERROR: wwwroot directory not found. Static content is missing!${NC}"
echo -e "${YELLOW}Creating wwwroot directory and copying static content...${NC}"
# Continue anyway for testing other aspects
echo -e "${YELLOW}Continuing test without database functionality.${NC}"
# Try to find the wwwroot directory in the source
WWWROOT_SOURCE=$(find $INSTALL_DIR -path "*/Web/wwwroot" -type d 2>/dev/null | head -n 1)
# Update appsettings.json to use SQLite instead
echo '{
"ConnectionStrings": {
"DefaultConnection": "Data Source=torrentmanager_test.db"
}
}' > "$TEST_DIR/appsettings.json"
echo -e "${YELLOW}Using SQLite database instead.${NC}"
# Install Entity Framework Core SQLite provider
dotnet add package Microsoft.EntityFrameworkCore.Sqlite --version 7.0.17
# Update DatabaseProvider to use SQLite
if [ -f "$TEST_DIR/src/Data/TorrentManagerContextFactory.cs" ]; then
# Replace Npgsql with SQLite in DbContext factory
sed -i 's/UseNpgsql/UseSqlite/g' "$TEST_DIR/src/Data/TorrentManagerContextFactory.cs"
echo -e "${YELLOW}Modified database provider to use SQLite.${NC}"
if [ -n "$WWWROOT_SOURCE" ]; then
# Found the static content, copy it
mkdir -p "$PUBLISH_DIR/wwwroot"
cp -r "$WWWROOT_SOURCE/"* "$PUBLISH_DIR/wwwroot/"
echo -e "${GREEN}${NC} Static content copied from $WWWROOT_SOURCE"
# Restart the service to apply changes
systemctl restart transmission-rss-manager
echo -e "${YELLOW}Service restarted. Please try accessing the web interface again.${NC}"
else
echo -e "${RED}Could not find source wwwroot directory to copy static content from.${NC}"
echo -e "${YELLOW}Please copy static content manually to $PUBLISH_DIR/wwwroot${NC}"
fi
else
# Create test database
echo -e "${GREEN}Creating test database...${NC}"
psql -U postgres -c "DROP DATABASE IF EXISTS torrentmanager_test;" || true
psql -U postgres -c "CREATE DATABASE torrentmanager_test;"
fi
# Install Entity Framework CLI if needed
if ! dotnet tool list -g | grep "dotnet-ef" > /dev/null; then
echo -e "${GREEN}Installing Entity Framework Core tools...${NC}"
dotnet tool install --global dotnet-ef --version 7.0.15
fi
# Apply migrations
print_section "Applying database migrations"
dotnet ef database update || true
if [ $? -eq 0 ]; then
echo -e "${GREEN}Migrations applied successfully.${NC}"
else
echo -e "${YELLOW}Failed to apply migrations, but continuing test.${NC}"
fi
# Test run the application (with timeout)
print_section "Testing application startup"
timeout 30s dotnet run --urls=http://localhost:5555 &
APP_PID=$!
# Wait for the app to start
echo -e "${GREEN}Waiting for application to start...${NC}"
sleep 5
# Try to access the API
print_section "Testing API endpoints"
MAX_ATTEMPTS=30
ATTEMPT=0
API_STATUS=1
echo -e "${GREEN}Waiting for API to become available...${NC}"
while [ $ATTEMPT -lt $MAX_ATTEMPTS ] && [ $API_STATUS -ne 0 ]; do
if command -v curl >/dev/null 2>&1; then
curl -s http://localhost:5555/swagger/index.html > /dev/null
API_STATUS=$?
# Check if index.html exists
if [ ! -f "$PUBLISH_DIR/wwwroot/index.html" ]; then
echo -e "${RED}ERROR: index.html not found in wwwroot directory!${NC}"
ls -la "$PUBLISH_DIR/wwwroot"
else
# Try using wget if curl is not available
if command -v wget >/dev/null 2>&1; then
wget -q --spider http://localhost:5555/swagger/index.html
API_STATUS=$?
else
echo -e "${YELLOW}Neither curl nor wget found. Cannot test API.${NC}"
echo -e "${GREEN}${NC} Static content found (wwwroot/index.html exists)"
fi
fi
# Check web service response
echo -e "${YELLOW}Checking web service (this may take a few seconds)...${NC}"
for i in {1..15}; do
HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:5000 2>/dev/null || echo "000")
if [ "$HTTP_STATUS" = "200" ] || [ "$HTTP_STATUS" = "302" ]; then
echo -e "${GREEN}${NC} Web service is responsive (HTTP $HTTP_STATUS)"
WEB_OK=true
break
fi
# If root URL doesn't work, try direct access to index.html
if [ "$i" -eq 5 ]; then
echo -e "${YELLOW}Root path not responding, trying explicit index.html...${NC}"
HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:5000/index.html 2>/dev/null || echo "000")
if [ "$HTTP_STATUS" = "200" ] || [ "$HTTP_STATUS" = "302" ]; then
echo -e "${GREEN}${NC} Web service is responsive at /index.html (HTTP $HTTP_STATUS)"
echo -e "${YELLOW}Access the app at http://localhost:5000/index.html${NC}"
WEB_OK=true
break
fi
fi
if [ $API_STATUS -ne 0 ]; then
echo -n "."
sleep 1
ATTEMPT=$((ATTEMPT+1))
fi
sleep 1
done
echo "" # New line after progress dots
# Kill the app
kill $APP_PID 2>/dev/null || true
if [ $API_STATUS -eq 0 ]; then
echo -e "${GREEN}API successfully responded.${NC}"
else
echo -e "${YELLOW}API did not respond within the timeout period.${NC}"
echo -e "${YELLOW}This may be normal if database migrations are slow or there are other startup delays.${NC}"
if [ -z "$WEB_OK" ]; then
echo -e "${RED}⚠ Web service is not responding. This might be normal if your app doesn't serve content on the root path.${NC}"
echo -e "${YELLOW}Trying to check API/health endpoint instead...${NC}"
HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:5000/api/config 2>/dev/null || echo "000")
if [ "$HTTP_STATUS" = "200" ] || [ "$HTTP_STATUS" = "302" ] || [ "$HTTP_STATUS" = "401" ] || [ "$HTTP_STATUS" = "404" ]; then
echo -e "${GREEN}${NC} API endpoint is responsive (HTTP $HTTP_STATUS)"
else
echo -e "${RED}ERROR: Web service does not appear to be working (HTTP $HTTP_STATUS).${NC}"
echo -e "${YELLOW}Checking service logs:${NC}"
sudo journalctl -u transmission-rss-manager -n 20 --no-pager
exit 1
fi
fi
# Check file permissions and structure
print_section "Checking build outputs"
if [ -f "$TEST_DIR/bin/Release/net7.0/TransmissionRssManager.dll" ]; then
echo -e "${GREEN}Application was built successfully.${NC}"
else
echo -e "${RED}Missing application binaries.${NC}"
fi
# Clean up test resources
print_section "Test completed"
if [ "$1" != "--keep" ]; then
echo -e "${GREEN}Cleaning up test resources...${NC}"
# Drop test database if PostgreSQL is available
pg_isready > /dev/null && psql -U postgres -c "DROP DATABASE IF EXISTS torrentmanager_test;" || true
# Remove test directory
rm -rf "$TEST_DIR"
echo -e "${GREEN}Test directory removed.${NC}"
else
echo -e "${GREEN}Test resources kept as requested.${NC}"
echo -e "${GREEN}Test directory: $TEST_DIR${NC}"
fi
echo -e "${GREEN}Installation test completed.${NC}"
# Installation successful
echo
echo -e "${GREEN}✅ Transmission RSS Manager appears to be installed and running correctly!${NC}"
echo -e "${YELLOW}Web interface: http://localhost:5000${NC}"
echo -e "${YELLOW}Configuration: $CONFIG_DIR/appsettings.json${NC}"
echo
echo -e "To view logs: ${YELLOW}sudo journalctl -u transmission-rss-manager -f${NC}"
echo -e "To restart: ${YELLOW}sudo systemctl restart transmission-rss-manager${NC}"