Compare commits
17 Commits
feature/im
...
main
Author | SHA1 | Date | |
---|---|---|---|
164268abd1 | |||
2e2e38d979 | |||
f21639455d | |||
63b33c5fc0 | |||
c61f308de7 | |||
5d6faef880 | |||
573031fcc9 | |||
0f27b1a939 | |||
619a861546 | |||
6dff6103d9 | |||
b11193795b | |||
3f2567803c | |||
b6d7183094 | |||
3f9875cb1a | |||
d919516f2d | |||
681e1aa3e9 | |||
71fc571f38 |
@ -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
|
||||
|
@ -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>
|
||||
|
157
install.sh
157
install.sh
@ -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}"
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -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");
|
||||
}
|
@ -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);
|
||||
}
|
||||
|
||||
|
@ -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 ??= "";
|
||||
}
|
||||
}
|
||||
}
|
219
src/Services/LoggingService.cs
Normal file
219
src/Services/LoggingService.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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');
|
||||
});
|
||||
}
|
@ -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}"
|
Loading…
x
Reference in New Issue
Block a user