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:
|
Install Transmission RSS Manager with a single command:
|
||||||
|
|
||||||
```bash
|
```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:
|
This command:
|
||||||
@ -40,7 +40,7 @@ If you prefer to examine the install script before running it, you can:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Download the install script
|
# 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
|
# Review it
|
||||||
less install.sh
|
less install.sh
|
||||||
|
@ -14,6 +14,11 @@
|
|||||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="7.0.7" />
|
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="7.0.7" />
|
||||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" />
|
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" />
|
||||||
<PackageReference Include="System.ServiceModel.Syndication" Version="7.0.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>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
157
install.sh
157
install.sh
@ -73,27 +73,13 @@ if [ $? -ne 0 ]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Install PostgreSQL
|
# PostgreSQL is not needed in simplified version
|
||||||
print_section "Installing PostgreSQL"
|
print_section "Checking dependencies"
|
||||||
if ! command -v psql &> /dev/null; then
|
echo "Using simplified version without database dependencies."
|
||||||
echo "Installing PostgreSQL..."
|
|
||||||
apt-get install -y postgresql postgresql-contrib
|
|
||||||
else
|
|
||||||
echo "PostgreSQL is already installed."
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Start PostgreSQL service
|
# No need for Entity Framework tools in the simplified version
|
||||||
systemctl start postgresql
|
print_section "Checking .NET tools"
|
||||||
systemctl enable postgresql
|
echo "Using simplified version without database dependencies."
|
||||||
|
|
||||||
# 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
|
|
||||||
|
|
||||||
# Create installation directory
|
# Create installation directory
|
||||||
print_section "Setting up application"
|
print_section "Setting up application"
|
||||||
@ -103,45 +89,50 @@ mkdir -p $INSTALL_DIR
|
|||||||
# Download or clone the application
|
# Download or clone the application
|
||||||
if [ ! -d "$INSTALL_DIR/.git" ]; then
|
if [ ! -d "$INSTALL_DIR/.git" ]; then
|
||||||
echo "Downloading application files..."
|
echo "Downloading application files..."
|
||||||
# Clone the repository
|
# Clone the repository (main branch)
|
||||||
git clone https://git.powerdata.dk/masterdraco/Torrent-Manager.git $INSTALL_DIR
|
git clone -b main https://git.powerdata.dk/masterdraco/Torrent-Manager.git $INSTALL_DIR
|
||||||
else
|
else
|
||||||
echo "Updating existing installation..."
|
echo "Updating existing installation..."
|
||||||
cd $INSTALL_DIR
|
cd $INSTALL_DIR
|
||||||
git pull
|
git pull
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Setup database
|
# Setup configuration directory
|
||||||
print_section "Setting up database"
|
print_section "Setting up configuration"
|
||||||
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
|
|
||||||
CONFIG_DIR="/etc/transmission-rss-manager"
|
CONFIG_DIR="/etc/transmission-rss-manager"
|
||||||
mkdir -p $CONFIG_DIR
|
mkdir -p $CONFIG_DIR
|
||||||
|
|
||||||
|
# Create config file with default settings
|
||||||
echo '{
|
echo '{
|
||||||
"ConnectionStrings": {
|
"Transmission": {
|
||||||
"DefaultConnection": "Host=localhost;Database='$DB_NAME';Username='$DB_USER';Password='$DB_PASSWORD'"
|
"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"
|
}' > "$CONFIG_DIR/appsettings.json"
|
||||||
|
|
||||||
# Set proper permissions
|
# Set proper permissions
|
||||||
chown -R postgres:postgres "$CONFIG_DIR"
|
chown -R root:root "$CONFIG_DIR"
|
||||||
chmod 750 "$CONFIG_DIR"
|
chmod 755 "$CONFIG_DIR"
|
||||||
chmod 640 "$CONFIG_DIR/appsettings.json"
|
chmod 644 "$CONFIG_DIR/appsettings.json"
|
||||||
|
|
||||||
# Build and deploy the application
|
# Build and deploy the application
|
||||||
print_section "Building application"
|
print_section "Building application"
|
||||||
@ -159,35 +150,86 @@ echo "Building project from: $PROJECT_DIR"
|
|||||||
cd "$PROJECT_DIR"
|
cd "$PROJECT_DIR"
|
||||||
dotnet restore
|
dotnet restore
|
||||||
dotnet build -c Release
|
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
|
# Copy configuration
|
||||||
cp "$CONFIG_DIR/appsettings.json" "$INSTALL_DIR/publish/appsettings.json"
|
cp "$CONFIG_DIR/appsettings.json" "$INSTALL_DIR/publish/appsettings.json"
|
||||||
|
|
||||||
# Run migrations
|
# No database migrations needed in simplified version
|
||||||
print_section "Running database migrations"
|
print_section "Configuration completed"
|
||||||
cd "$PROJECT_DIR"
|
echo "No database migrations needed in simplified version."
|
||||||
dotnet ef database update
|
|
||||||
|
|
||||||
# Create systemd service
|
# Create systemd service
|
||||||
print_section "Creating systemd service"
|
print_section "Creating systemd service"
|
||||||
# Find the main application DLL
|
# 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)
|
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]
|
echo "[Unit]
|
||||||
Description=Transmission RSS Manager
|
Description=Transmission RSS Manager
|
||||||
After=network.target postgresql.service
|
After=network.target
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
WorkingDirectory=$INSTALL_DIR/publish
|
WorkingDirectory=$INSTALL_DIR/publish
|
||||||
ExecStart=/usr/bin/dotnet $APP_DLL
|
ExecStart=/usr/bin/dotnet $INSTALL_DIR/publish/$APP_NAME.dll
|
||||||
Restart=always
|
Restart=always
|
||||||
RestartSec=10
|
RestartSec=10
|
||||||
|
KillSignal=SIGINT
|
||||||
SyslogIdentifier=transmission-rss-manager
|
SyslogIdentifier=transmission-rss-manager
|
||||||
User=postgres
|
User=root
|
||||||
Environment=ASPNETCORE_ENVIRONMENT=Production
|
Environment=ASPNETCORE_ENVIRONMENT=Production
|
||||||
Environment=DOTNET_PRINT_TELEMETRY_MESSAGE=false
|
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]
|
[Install]
|
||||||
WantedBy=multi-user.target" > /etc/systemd/system/transmission-rss-manager.service
|
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!"
|
print_section "Installation Complete!"
|
||||||
echo -e "${GREEN}Transmission RSS Manager has been successfully installed!${NC}"
|
echo -e "${GREEN}Transmission RSS Manager has been successfully installed!${NC}"
|
||||||
echo -e "Web interface: ${YELLOW}http://localhost:5000${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 "Configuration file: ${YELLOW}$CONFIG_DIR/appsettings.json${NC}"
|
||||||
echo -e "Application files: ${YELLOW}$INSTALL_DIR${NC}"
|
echo -e "Application files: ${YELLOW}$INSTALL_DIR${NC}"
|
||||||
echo -e "\nTo check service status: ${YELLOW}systemctl status transmission-rss-manager${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 System.Threading.Tasks;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using TransmissionRssManager.Core;
|
using TransmissionRssManager.Core;
|
||||||
|
using TransmissionRssManager.Services;
|
||||||
|
|
||||||
namespace TransmissionRssManager.Api.Controllers
|
namespace TransmissionRssManager.Api.Controllers
|
||||||
{
|
{
|
||||||
@ -33,17 +40,79 @@ namespace TransmissionRssManager.Api.Controllers
|
|||||||
host = config.Transmission.Host,
|
host = config.Transmission.Host,
|
||||||
port = config.Transmission.Port,
|
port = config.Transmission.Port,
|
||||||
useHttps = config.Transmission.UseHttps,
|
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,
|
autoDownloadEnabled = config.AutoDownloadEnabled,
|
||||||
checkIntervalMinutes = config.CheckIntervalMinutes,
|
checkIntervalMinutes = config.CheckIntervalMinutes,
|
||||||
downloadDirectory = config.DownloadDirectory,
|
downloadDirectory = config.DownloadDirectory,
|
||||||
mediaLibraryPath = config.MediaLibraryPath,
|
mediaLibraryPath = config.MediaLibraryPath,
|
||||||
postProcessing = config.PostProcessing
|
postProcessing = config.PostProcessing,
|
||||||
|
enableDetailedLogging = config.EnableDetailedLogging,
|
||||||
|
userPreferences = config.UserPreferences
|
||||||
};
|
};
|
||||||
|
|
||||||
return Ok(sanitizedConfig);
|
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]
|
[HttpPut]
|
||||||
public async Task<IActionResult> UpdateConfig([FromBody] AppConfig config)
|
public async Task<IActionResult> UpdateConfig([FromBody] AppConfig config)
|
||||||
@ -59,5 +128,93 @@ namespace TransmissionRssManager.Api.Controllers
|
|||||||
await _configService.SaveConfigurationAsync(config);
|
await _configService.SaveConfigurationAsync(config);
|
||||||
return Ok(new { success = true });
|
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 class AddTorrentRequest
|
||||||
{
|
{
|
||||||
public string Url { get; set; }
|
public string Url { get; set; } = string.Empty;
|
||||||
public string DownloadDir { get; set; }
|
public string DownloadDir { get; set; } = string.Empty;
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -1,21 +1,34 @@
|
|||||||
using Microsoft.AspNetCore.Builder;
|
using Microsoft.AspNetCore.Builder;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Hosting;
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
using TransmissionRssManager.Core;
|
using TransmissionRssManager.Core;
|
||||||
using TransmissionRssManager.Services;
|
using TransmissionRssManager.Services;
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
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
|
// Add services to the container
|
||||||
builder.Services.AddControllers();
|
builder.Services.AddControllers();
|
||||||
builder.Services.AddEndpointsApiExplorer();
|
builder.Services.AddEndpointsApiExplorer();
|
||||||
builder.Services.AddSwaggerGen();
|
builder.Services.AddSwaggerGen();
|
||||||
|
|
||||||
// Add custom services
|
// Add custom services as singletons for simplicity
|
||||||
builder.Services.AddSingleton<IConfigService, ConfigService>();
|
builder.Services.AddSingleton<IConfigService, ConfigService>();
|
||||||
builder.Services.AddSingleton<ITransmissionClient, TransmissionClient>();
|
builder.Services.AddSingleton<ITransmissionClient, TransmissionClient>();
|
||||||
builder.Services.AddSingleton<IRssFeedManager, RssFeedManager>();
|
builder.Services.AddSingleton<IRssFeedManager, RssFeedManager>();
|
||||||
builder.Services.AddSingleton<IPostProcessor, PostProcessor>();
|
builder.Services.AddSingleton<IPostProcessor, PostProcessor>();
|
||||||
|
builder.Services.AddSingleton<IMetricsService, MetricsService>();
|
||||||
|
builder.Services.AddSingleton<ILoggingService, LoggingService>();
|
||||||
|
|
||||||
// Add background services
|
// Add background services
|
||||||
builder.Services.AddHostedService<RssFeedBackgroundService>();
|
builder.Services.AddHostedService<RssFeedBackgroundService>();
|
||||||
@ -30,9 +43,19 @@ if (app.Environment.IsDevelopment())
|
|||||||
app.UseSwaggerUI();
|
app.UseSwaggerUI();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Configure static files to serve index.html as the default file
|
||||||
|
app.UseDefaultFiles();
|
||||||
app.UseStaticFiles();
|
app.UseStaticFiles();
|
||||||
app.UseRouting();
|
app.UseRouting();
|
||||||
app.UseAuthorization();
|
app.UseAuthorization();
|
||||||
app.MapControllers();
|
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;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
namespace TransmissionRssManager.Core
|
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 class RssFeedItem
|
||||||
{
|
{
|
||||||
public string Id { get; set; }
|
public string Id { get; set; } = string.Empty;
|
||||||
public string Title { get; set; }
|
public string Title { get; set; } = string.Empty;
|
||||||
public string Link { get; set; }
|
public string Link { get; set; } = string.Empty;
|
||||||
public string Description { get; set; }
|
public string Description { get; set; } = string.Empty;
|
||||||
public DateTime PublishDate { get; set; }
|
public DateTime PublishDate { get; set; }
|
||||||
public string TorrentUrl { get; set; }
|
public string TorrentUrl { get; set; } = string.Empty;
|
||||||
public bool IsDownloaded { get; set; }
|
public bool IsDownloaded { get; set; }
|
||||||
public bool IsMatched { 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 class TorrentInfo
|
||||||
{
|
{
|
||||||
public int Id { get; set; }
|
public int Id { get; set; }
|
||||||
public string Name { get; set; }
|
public string Name { get; set; } = string.Empty;
|
||||||
public string Status { get; set; }
|
public string Status { get; set; } = string.Empty;
|
||||||
public double PercentDone { get; set; }
|
public double PercentDone { get; set; }
|
||||||
public long TotalSize { 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 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 class RssFeed
|
||||||
{
|
{
|
||||||
public string Id { get; set; }
|
public string Id { get; set; } = string.Empty;
|
||||||
public string Url { get; set; }
|
public string Url { get; set; } = string.Empty;
|
||||||
public string Name { get; set; }
|
public string Name { get; set; } = string.Empty;
|
||||||
public List<string> Rules { get; set; } = new List<string>();
|
public List<string> Rules { get; set; } = new List<string>();
|
||||||
|
public List<RssFeedRule> AdvancedRules { get; set; } = new List<RssFeedRule>();
|
||||||
public bool AutoDownload { get; set; }
|
public bool AutoDownload { get; set; }
|
||||||
public DateTime LastChecked { 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 class AppConfig
|
||||||
{
|
{
|
||||||
public TransmissionConfig Transmission { get; set; } = new TransmissionConfig();
|
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 List<RssFeed> Feeds { get; set; } = new List<RssFeed>();
|
||||||
public bool AutoDownloadEnabled { get; set; }
|
public bool AutoDownloadEnabled { get; set; }
|
||||||
public int CheckIntervalMinutes { get; set; } = 30;
|
public int CheckIntervalMinutes { get; set; } = 30;
|
||||||
public string DownloadDirectory { get; set; }
|
public string DownloadDirectory { get; set; } = string.Empty;
|
||||||
public string MediaLibraryPath { get; set; }
|
public string MediaLibraryPath { get; set; } = string.Empty;
|
||||||
public PostProcessingConfig PostProcessing { get; set; } = new PostProcessingConfig();
|
public PostProcessingConfig PostProcessing { get; set; } = new PostProcessingConfig();
|
||||||
|
public bool EnableDetailedLogging { get; set; } = false;
|
||||||
|
public UserPreferences UserPreferences { get; set; } = new UserPreferences();
|
||||||
}
|
}
|
||||||
|
|
||||||
public class TransmissionConfig
|
public class TransmissionConfig
|
||||||
{
|
{
|
||||||
public string Host { get; set; } = "localhost";
|
public string Host { get; set; } = "localhost";
|
||||||
public int Port { get; set; } = 9091;
|
public int Port { get; set; } = 9091;
|
||||||
public string Username { get; set; }
|
public string Username { get; set; } = string.Empty;
|
||||||
public string Password { get; set; }
|
public string Password { get; set; } = string.Empty;
|
||||||
public bool UseHttps { get; set; } = false;
|
public bool UseHttps { get; set; } = false;
|
||||||
public string Url => $"{(UseHttps ? "https" : "http")}://{Host}:{Port}/transmission/rpc";
|
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 class PostProcessingConfig
|
||||||
{
|
{
|
||||||
public bool Enabled { get; set; } = false;
|
public bool Enabled { get; set; } = false;
|
||||||
@ -67,6 +132,30 @@ namespace TransmissionRssManager.Core
|
|||||||
public bool OrganizeMedia { get; set; } = true;
|
public bool OrganizeMedia { get; set; } = true;
|
||||||
public int MinimumSeedRatio { get; set; } = 1;
|
public int MinimumSeedRatio { get; set; } = 1;
|
||||||
public List<string> MediaExtensions { get; set; } = new List<string> { ".mp4", ".mkv", ".avi" };
|
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
|
public interface IConfigService
|
||||||
@ -93,6 +182,7 @@ namespace TransmissionRssManager.Core
|
|||||||
Task RemoveFeedAsync(string feedId);
|
Task RemoveFeedAsync(string feedId);
|
||||||
Task UpdateFeedAsync(RssFeed feed);
|
Task UpdateFeedAsync(RssFeed feed);
|
||||||
Task RefreshFeedsAsync(CancellationToken cancellationToken);
|
Task RefreshFeedsAsync(CancellationToken cancellationToken);
|
||||||
|
Task RefreshFeedAsync(string feedId, CancellationToken cancellationToken);
|
||||||
Task MarkItemAsDownloadedAsync(string itemId);
|
Task MarkItemAsDownloadedAsync(string itemId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,5 +1,7 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
@ -7,84 +9,385 @@ using TransmissionRssManager.Core;
|
|||||||
|
|
||||||
namespace TransmissionRssManager.Services
|
namespace TransmissionRssManager.Services
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Service for managing application configuration
|
||||||
|
/// File-based implementation that does not use a database
|
||||||
|
/// </summary>
|
||||||
public class ConfigService : IConfigService
|
public class ConfigService : IConfigService
|
||||||
{
|
{
|
||||||
private readonly ILogger<ConfigService> _logger;
|
private readonly ILogger<ConfigService> _logger;
|
||||||
private readonly string _configPath;
|
private readonly string _configFilePath;
|
||||||
private AppConfig _cachedConfig;
|
private AppConfig? _cachedConfig;
|
||||||
|
private readonly object _lockObject = new object();
|
||||||
|
|
||||||
public ConfigService(ILogger<ConfigService> logger)
|
public ConfigService(ILogger<ConfigService> logger)
|
||||||
{
|
{
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
|
||||||
// Get config directory
|
// Determine the appropriate config file path
|
||||||
string homeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
string baseDir = AppContext.BaseDirectory;
|
||||||
string configDir = Path.Combine(homeDir, ".config", "transmission-rss-manager");
|
string etcConfigPath = "/etc/transmission-rss-manager/appsettings.json";
|
||||||
|
string localConfigPath = Path.Combine(baseDir, "appsettings.json");
|
||||||
|
|
||||||
// Ensure directory exists
|
// Check if config exists in /etc (preferred) or in app directory
|
||||||
if (!Directory.Exists(configDir))
|
_configFilePath = File.Exists(etcConfigPath) ? etcConfigPath : localConfigPath;
|
||||||
{
|
|
||||||
Directory.CreateDirectory(configDir);
|
|
||||||
}
|
|
||||||
|
|
||||||
_configPath = Path.Combine(configDir, "config.json");
|
_logger.LogInformation($"Using configuration file: {_configFilePath}");
|
||||||
_cachedConfig = LoadConfiguration();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Implement the interface methods required by IConfigService
|
||||||
public AppConfig GetConfiguration()
|
public AppConfig GetConfiguration()
|
||||||
{
|
{
|
||||||
return _cachedConfig;
|
// Non-async method required by interface
|
||||||
}
|
if (_cachedConfig != null)
|
||||||
|
|
||||||
public async Task SaveConfigurationAsync(AppConfig config)
|
|
||||||
{
|
|
||||||
_cachedConfig = config;
|
|
||||||
|
|
||||||
var options = new JsonSerializerOptions
|
|
||||||
{
|
{
|
||||||
WriteIndented = true
|
return _cachedConfig;
|
||||||
};
|
|
||||||
|
|
||||||
string json = JsonSerializer.Serialize(config, options);
|
|
||||||
await File.WriteAllTextAsync(_configPath, json);
|
|
||||||
|
|
||||||
_logger.LogInformation("Configuration saved successfully");
|
|
||||||
}
|
|
||||||
|
|
||||||
private AppConfig LoadConfiguration()
|
|
||||||
{
|
|
||||||
if (!File.Exists(_configPath))
|
|
||||||
{
|
|
||||||
_logger.LogInformation("No configuration file found, creating default");
|
|
||||||
var defaultConfig = CreateDefaultConfig();
|
|
||||||
SaveConfigurationAsync(defaultConfig).Wait();
|
|
||||||
return defaultConfig;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
string json = File.ReadAllText(_configPath);
|
// Load synchronously since this is a sync method
|
||||||
var config = JsonSerializer.Deserialize<AppConfig>(json);
|
_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)
|
if (config == null)
|
||||||
{
|
{
|
||||||
_logger.LogWarning("Failed to deserialize config, creating default");
|
throw new InvalidOperationException("Failed to deserialize configuration");
|
||||||
return CreateDefaultConfig();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fill in any missing values with defaults
|
||||||
|
EnsureCompleteConfig(config);
|
||||||
|
|
||||||
return config;
|
return config;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "Error loading configuration");
|
_logger.LogError(ex, "Error loading configuration from file");
|
||||||
return CreateDefaultConfig();
|
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()
|
private AppConfig CreateDefaultConfig()
|
||||||
{
|
{
|
||||||
string homeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
|
||||||
|
|
||||||
return new AppConfig
|
return new AppConfig
|
||||||
{
|
{
|
||||||
Transmission = new TransmissionConfig
|
Transmission = new TransmissionConfig
|
||||||
@ -92,21 +395,64 @@ namespace TransmissionRssManager.Services
|
|||||||
Host = "localhost",
|
Host = "localhost",
|
||||||
Port = 9091,
|
Port = 9091,
|
||||||
Username = "",
|
Username = "",
|
||||||
Password = ""
|
Password = "",
|
||||||
|
UseHttps = false
|
||||||
},
|
},
|
||||||
AutoDownloadEnabled = false,
|
AutoDownloadEnabled = true,
|
||||||
CheckIntervalMinutes = 30,
|
CheckIntervalMinutes = 30,
|
||||||
DownloadDirectory = Path.Combine(homeDir, "Downloads"),
|
DownloadDirectory = "/var/lib/transmission-daemon/downloads",
|
||||||
MediaLibraryPath = Path.Combine(homeDir, "Media"),
|
MediaLibraryPath = "/media/library",
|
||||||
PostProcessing = new PostProcessingConfig
|
PostProcessing = new PostProcessingConfig
|
||||||
{
|
{
|
||||||
Enabled = false,
|
Enabled = false,
|
||||||
ExtractArchives = true,
|
ExtractArchives = true,
|
||||||
OrganizeMedia = true,
|
OrganizeMedia = true,
|
||||||
MinimumSeedRatio = 1,
|
MinimumSeedRatio = 1
|
||||||
MediaExtensions = new System.Collections.Generic.List<string> { ".mp4", ".mkv", ".avi" }
|
},
|
||||||
|
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;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
|
||||||
using Microsoft.Extensions.Hosting;
|
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using TransmissionRssManager.Core;
|
using TransmissionRssManager.Core;
|
||||||
using TransmissionRssManager.Data.Repositories;
|
|
||||||
|
|
||||||
namespace TransmissionRssManager.Services
|
namespace TransmissionRssManager.Services
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Interface for the metrics service that provides dashboard statistics and performance data
|
||||||
|
/// </summary>
|
||||||
public interface IMetricsService
|
public interface IMetricsService
|
||||||
{
|
{
|
||||||
Task<DashboardStats> GetDashboardStatsAsync();
|
Task<Dictionary<string, object>> GetDashboardStatsAsync();
|
||||||
Task<List<HistoricalDataPoint>> GetDownloadHistoryAsync(int days = 30);
|
|
||||||
Task<List<CategoryStats>> GetCategoryStatsAsync();
|
|
||||||
Task<SystemStatus> GetSystemStatusAsync();
|
|
||||||
Task<Dictionary<string, long>> EstimateDiskUsageAsync();
|
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
|
public class MetricsService : IMetricsService
|
||||||
{
|
{
|
||||||
private readonly ILogger<MetricsService> _logger;
|
private readonly ILogger<MetricsService> _logger;
|
||||||
private readonly ITransmissionClient _transmissionClient;
|
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;
|
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(
|
public MetricsService(
|
||||||
ILogger<MetricsService> logger,
|
ILogger<MetricsService> logger,
|
||||||
ITransmissionClient transmissionClient,
|
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)
|
IConfigService configService)
|
||||||
{
|
{
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_transmissionClient = transmissionClient;
|
_transmissionClient = transmissionClient;
|
||||||
_feedRepository = feedRepository;
|
|
||||||
_feedItemRepository = feedItemRepository;
|
|
||||||
_torrentRepository = torrentRepository;
|
|
||||||
_logRepository = logRepository;
|
|
||||||
_loggingService = loggingService;
|
|
||||||
_configService = configService;
|
_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
|
try
|
||||||
{
|
{
|
||||||
var now = DateTime.UtcNow;
|
var stats = new Dictionary<string, object>();
|
||||||
var today = new DateTime(now.Year, now.Month, now.Day, 0, 0, 0, DateTimeKind.Utc);
|
var torrents = await _transmissionClient.GetTorrentsAsync();
|
||||||
|
|
||||||
// Combine data from both Transmission and database
|
// Calculate basic stats
|
||||||
// Get active torrents from Transmission for real-time data
|
stats["TotalTorrents"] = torrents.Count;
|
||||||
var transmissionTorrents = await _transmissionClient.GetTorrentsAsync();
|
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
|
// Calculate total size
|
||||||
var dbTorrents = await _torrentRepository.Query().ToListAsync();
|
long totalSize = torrents.Sum(t => t.TotalSize);
|
||||||
|
stats["TotalSize"] = totalSize;
|
||||||
|
|
||||||
// Count active downloads (status is downloading)
|
return stats;
|
||||||
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
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "Error getting dashboard stats");
|
_logger.LogError(ex, "Error getting dashboard stats");
|
||||||
_loggingService.Log(
|
return new Dictionary<string, object>
|
||||||
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))
|
|
||||||
{
|
{
|
||||||
var day = new DateTime(date.Year, date.Month, date.Day, 0, 0, 0, DateTimeKind.Utc);
|
["Error"] = ex.Message,
|
||||||
|
["TotalTorrents"] = 0,
|
||||||
// Get data for this day
|
["ActiveDownloads"] = 0,
|
||||||
var addedData = groupedByAdded.FirstOrDefault(g => g.Date == day);
|
["SeedingTorrents"] = 0,
|
||||||
var completedData = groupedByCompleted.FirstOrDefault(g => g.Date == day);
|
["CompletedTorrents"] = 0
|
||||||
|
};
|
||||||
// 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>();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<List<CategoryStats>> GetCategoryStatsAsync()
|
/// <summary>
|
||||||
{
|
/// Estimates disk usage for torrents and available space
|
||||||
try
|
/// </summary>
|
||||||
{
|
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<Dictionary<string, long>> EstimateDiskUsageAsync()
|
public async Task<Dictionary<string, long>> EstimateDiskUsageAsync()
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Get disk usage from both Transmission and database
|
// Get disk usage from torrents
|
||||||
var transmissionTorrents = await _transmissionClient.GetTorrentsAsync();
|
var torrents = await _transmissionClient.GetTorrentsAsync();
|
||||||
var dbTorrents = await _torrentRepository.Query().ToListAsync();
|
long totalSize = torrents.Sum(t => t.TotalSize);
|
||||||
|
|
||||||
// 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
|
|
||||||
|
|
||||||
// Calculate available space in download directory
|
// Calculate available space in download directory
|
||||||
string downloadDir = _configService.GetConfiguration().DownloadDirectory;
|
string downloadDir = _configService.GetConfiguration().DownloadDirectory;
|
||||||
@ -532,8 +95,12 @@ namespace TransmissionRssManager.Services
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var driveInfo = new System.IO.DriveInfo(System.IO.Path.GetPathRoot(downloadDir));
|
var root = System.IO.Path.GetPathRoot(downloadDir);
|
||||||
availableSpace = driveInfo.AvailableFreeSpace;
|
if (!string.IsNullOrEmpty(root))
|
||||||
|
{
|
||||||
|
var driveInfo = new System.IO.DriveInfo(root);
|
||||||
|
availableSpace = driveInfo.AvailableFreeSpace;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@ -543,204 +110,56 @@ namespace TransmissionRssManager.Services
|
|||||||
|
|
||||||
return new Dictionary<string, long>
|
return new Dictionary<string, long>
|
||||||
{
|
{
|
||||||
["activeTorrentsSize"] = transmissionSize,
|
["activeTorrentsSize"] = totalSize,
|
||||||
["historicalTorrentsSize"] = historicalSize,
|
|
||||||
["totalTorrentsSize"] = transmissionSize + historicalSize,
|
|
||||||
["databaseSize"] = databaseSize,
|
|
||||||
["availableSpace"] = availableSpace
|
["availableSpace"] = availableSpace
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "Error estimating disk usage");
|
_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>
|
return new Dictionary<string, long>
|
||||||
{
|
{
|
||||||
["activeTorrentsSize"] = 0,
|
["activeTorrentsSize"] = 0,
|
||||||
["historicalTorrentsSize"] = 0,
|
|
||||||
["totalTorrentsSize"] = 0,
|
|
||||||
["databaseSize"] = 0,
|
|
||||||
["availableSpace"] = 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
|
try
|
||||||
{
|
{
|
||||||
// Calculate average time to complete downloads
|
// Try to connect to Transmission to check if it's available
|
||||||
var completedTorrents = await _torrentRepository.Query()
|
var torrents = await _transmissionClient.GetTorrentsAsync();
|
||||||
.Where(t => t.CompletedOn.HasValue)
|
|
||||||
.ToListAsync();
|
|
||||||
|
|
||||||
if (completedTorrents.Any())
|
status["TransmissionConnected"] = true;
|
||||||
{
|
status["TransmissionTorrentCount"] = torrents.Count;
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate feed refresh performance
|
// Count torrents by status
|
||||||
var feeds = await _feedRepository.Query().ToListAsync();
|
status["ActiveTorrentCount"] = torrents.Count(t => t.Status == "Downloading");
|
||||||
if (feeds.Any())
|
status["CompletedTorrentCount"] = torrents.Count(t => t.IsFinished);
|
||||||
{
|
|
||||||
var avgItemsPerFeed = await _feedItemRepository.Query().CountAsync() / (double)feeds.Count;
|
|
||||||
metrics["AvgItemsPerFeed"] = Math.Round(avgItemsPerFeed, 2);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
metrics["AvgItemsPerFeed"] = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
return metrics;
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "Error getting performance metrics");
|
_logger.LogError(ex, "Error getting system status");
|
||||||
return new Dictionary<string, double>
|
status["TransmissionConnected"] = false;
|
||||||
{
|
status["LastErrorMessage"] = ex.Message;
|
||||||
["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.LogInformation("Metrics background service stopped");
|
return status;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -3,10 +3,12 @@ using System.Collections.Generic;
|
|||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Hosting;
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
using TransmissionRssManager.Core;
|
using TransmissionRssManager.Core;
|
||||||
|
|
||||||
namespace TransmissionRssManager.Services
|
namespace TransmissionRssManager.Services
|
||||||
@ -16,6 +18,7 @@ namespace TransmissionRssManager.Services
|
|||||||
private readonly ILogger<PostProcessor> _logger;
|
private readonly ILogger<PostProcessor> _logger;
|
||||||
private readonly IConfigService _configService;
|
private readonly IConfigService _configService;
|
||||||
private readonly ITransmissionClient _transmissionClient;
|
private readonly ITransmissionClient _transmissionClient;
|
||||||
|
private readonly List<TorrentInfo> _completedTorrents = new List<TorrentInfo>();
|
||||||
|
|
||||||
public PostProcessor(
|
public PostProcessor(
|
||||||
ILogger<PostProcessor> logger,
|
ILogger<PostProcessor> logger,
|
||||||
@ -29,35 +32,41 @@ namespace TransmissionRssManager.Services
|
|||||||
|
|
||||||
public async Task ProcessCompletedDownloadsAsync(CancellationToken cancellationToken)
|
public async Task ProcessCompletedDownloadsAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var config = _configService.GetConfiguration();
|
_logger.LogInformation("Checking for completed downloads");
|
||||||
|
|
||||||
|
var config = _configService.GetConfiguration();
|
||||||
if (!config.PostProcessing.Enabled)
|
if (!config.PostProcessing.Enabled)
|
||||||
{
|
{
|
||||||
|
_logger.LogInformation("Post-processing is disabled");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogInformation("Processing completed downloads");
|
try
|
||||||
|
|
||||||
var torrents = await _transmissionClient.GetTorrentsAsync();
|
|
||||||
var completedTorrents = torrents.Where(t => t.IsFinished).ToList();
|
|
||||||
|
|
||||||
foreach (var torrent in completedTorrents)
|
|
||||||
{
|
{
|
||||||
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");
|
if (cancellationToken.IsCancellationRequested)
|
||||||
return;
|
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);
|
_completedTorrents.RemoveRange(0, _completedTorrents.Count - 1000);
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, $"Error processing torrent: {torrent.Name}");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error processing completed downloads");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task ProcessTorrentAsync(TorrentInfo torrent)
|
public async Task ProcessTorrentAsync(TorrentInfo torrent)
|
||||||
@ -65,190 +74,207 @@ namespace TransmissionRssManager.Services
|
|||||||
_logger.LogInformation($"Processing completed torrent: {torrent.Name}");
|
_logger.LogInformation($"Processing completed torrent: {torrent.Name}");
|
||||||
|
|
||||||
var config = _configService.GetConfiguration();
|
var config = _configService.GetConfiguration();
|
||||||
var downloadDir = torrent.DownloadDir;
|
var processingConfig = config.PostProcessing;
|
||||||
var torrentPath = Path.Combine(downloadDir, torrent.Name);
|
|
||||||
|
|
||||||
// Check if the file/directory exists
|
if (!Directory.Exists(torrent.DownloadDir))
|
||||||
if (!Directory.Exists(torrentPath) && !File.Exists(torrentPath))
|
|
||||||
{
|
{
|
||||||
_logger.LogWarning($"Downloaded path not found: {torrentPath}");
|
_logger.LogWarning($"Download directory does not exist: {torrent.DownloadDir}");
|
||||||
return;
|
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
|
try
|
||||||
{
|
{
|
||||||
var extension = Path.GetExtension(archivePath).ToLowerInvariant();
|
// Extract archives if enabled
|
||||||
var extractDir = Path.Combine(outputDir, Path.GetFileNameWithoutExtension(archivePath));
|
if (processingConfig.ExtractArchives)
|
||||||
|
|
||||||
// Create extraction directory if it doesn't exist
|
|
||||||
if (!Directory.Exists(extractDir))
|
|
||||||
{
|
{
|
||||||
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
|
await OrganizeMediaAsync(torrent.DownloadDir, config.MediaLibraryPath, processingConfig);
|
||||||
{
|
|
||||||
".rar" => "unrar",
|
|
||||||
".zip" => "unzip",
|
|
||||||
".7z" => "7z",
|
|
||||||
_ => throw new Exception($"Unsupported archive format: {extension}")
|
|
||||||
},
|
|
||||||
Arguments = extension switch
|
|
||||||
{
|
|
||||||
".rar" => $"x -o+ \"{archivePath}\" \"{extractDir}\"",
|
|
||||||
".zip" => $"-o \"{archivePath}\" -d \"{extractDir}\"",
|
|
||||||
".7z" => $"x \"{archivePath}\" -o\"{extractDir}\"",
|
|
||||||
_ => throw new Exception($"Unsupported archive format: {extension}")
|
|
||||||
},
|
|
||||||
UseShellExecute = false,
|
|
||||||
RedirectStandardOutput = true,
|
|
||||||
RedirectStandardError = true,
|
|
||||||
CreateNoWindow = true
|
|
||||||
};
|
|
||||||
|
|
||||||
using var process = new Process
|
|
||||||
{
|
|
||||||
StartInfo = processStartInfo
|
|
||||||
};
|
|
||||||
|
|
||||||
process.Start();
|
|
||||||
await process.WaitForExitAsync();
|
|
||||||
|
|
||||||
if (process.ExitCode != 0)
|
|
||||||
{
|
|
||||||
var error = await process.StandardError.ReadToEndAsync();
|
|
||||||
_logger.LogError($"Error extracting archive: {error}");
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogInformation($"Archive extracted to: {extractDir}");
|
_logger.LogInformation($"Completed processing torrent: {torrent.Name}");
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
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 archiveExtensions = new[] { ".rar", ".zip", ".7z", ".tar", ".gz" };
|
||||||
var mediaExtensions = config.PostProcessing.MediaExtensions;
|
var archiveFiles = Directory.GetFiles(directory, "*.*", SearchOption.AllDirectories)
|
||||||
|
.Where(f => archiveExtensions.Contains(Path.GetExtension(f).ToLowerInvariant()))
|
||||||
// Ensure media library path exists
|
.ToList();
|
||||||
if (!Directory.Exists(mediaLibraryPath))
|
|
||||||
|
foreach (var archiveFile in archiveFiles)
|
||||||
{
|
{
|
||||||
Directory.CreateDirectory(mediaLibraryPath);
|
try
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
if (File.Exists(path))
|
|
||||||
{
|
{
|
||||||
// Single file
|
_logger.LogInformation($"Extracting archive: {archiveFile}");
|
||||||
var extension = Path.GetExtension(path).ToLowerInvariant();
|
|
||||||
if (mediaExtensions.Contains(extension))
|
|
||||||
{
|
|
||||||
await CopyFileToMediaLibraryAsync(path, mediaLibraryPath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else if (Directory.Exists(path))
|
|
||||||
{
|
|
||||||
// Directory - find all media files recursively
|
|
||||||
var mediaFiles = Directory.GetFiles(path, "*.*", SearchOption.AllDirectories)
|
|
||||||
.Where(f => mediaExtensions.Contains(Path.GetExtension(f).ToLowerInvariant()))
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
foreach (var mediaFile in mediaFiles)
|
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);
|
// In a real implementation, this would analyze the file to determine its type
|
||||||
var destinationPath = Path.Combine(mediaLibraryPath, fileName);
|
// For now, just return a simple category based on extension
|
||||||
|
|
||||||
// If destination file already exists, add a unique identifier
|
string ext = Path.GetExtension(filePath).ToLowerInvariant();
|
||||||
if (File.Exists(destinationPath))
|
|
||||||
|
if (new[] { ".mp4", ".mkv", ".avi", ".mov" }.Contains(ext))
|
||||||
{
|
{
|
||||||
var fileNameWithoutExt = Path.GetFileNameWithoutExtension(fileName);
|
return "Videos";
|
||||||
var extension = Path.GetExtension(fileName);
|
}
|
||||||
var uniqueId = Guid.NewGuid().ToString().Substring(0, 8);
|
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}");
|
return cleanName;
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
using var sourceStream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read, 4096, true);
|
|
||||||
using var destinationStream = new FileStream(destinationPath, FileMode.Create, FileAccess.Write, FileShare.None, 4096, true);
|
|
||||||
|
|
||||||
await sourceStream.CopyToAsync(destinationStream);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, $"Error copying file to media library: {filePath}");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public class PostProcessingBackgroundService : BackgroundService
|
public class PostProcessingBackgroundService : BackgroundService
|
||||||
{
|
{
|
||||||
private readonly ILogger<PostProcessingBackgroundService> _logger;
|
private readonly ILogger<PostProcessingBackgroundService> _logger;
|
||||||
private readonly IPostProcessor _postProcessor;
|
private readonly IServiceProvider _serviceProvider;
|
||||||
private readonly IConfigService _configService;
|
|
||||||
|
|
||||||
public PostProcessingBackgroundService(
|
public PostProcessingBackgroundService(
|
||||||
ILogger<PostProcessingBackgroundService> logger,
|
ILogger<PostProcessingBackgroundService> logger,
|
||||||
IPostProcessor postProcessor,
|
IServiceProvider serviceProvider)
|
||||||
IConfigService configService)
|
|
||||||
{
|
{
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_postProcessor = postProcessor;
|
_serviceProvider = serviceProvider;
|
||||||
_configService = configService;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Post-processing background service started");
|
_logger.LogInformation("Post-processing background service started");
|
||||||
@ -257,16 +283,25 @@ namespace TransmissionRssManager.Services
|
|||||||
{
|
{
|
||||||
try
|
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)
|
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.Linq;
|
||||||
using System.Net.Http;
|
using System.Net.Http;
|
||||||
using System.ServiceModel.Syndication;
|
using System.ServiceModel.Syndication;
|
||||||
using System.Text.Json;
|
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using System.Xml;
|
using System.Xml;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Hosting;
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
using TransmissionRssManager.Core;
|
using TransmissionRssManager.Core;
|
||||||
|
|
||||||
namespace TransmissionRssManager.Services
|
namespace TransmissionRssManager.Services
|
||||||
@ -20,9 +20,9 @@ namespace TransmissionRssManager.Services
|
|||||||
private readonly ILogger<RssFeedManager> _logger;
|
private readonly ILogger<RssFeedManager> _logger;
|
||||||
private readonly IConfigService _configService;
|
private readonly IConfigService _configService;
|
||||||
private readonly ITransmissionClient _transmissionClient;
|
private readonly ITransmissionClient _transmissionClient;
|
||||||
|
private List<RssFeed> _feeds = new List<RssFeed>();
|
||||||
|
private List<RssFeedItem> _feedItems = new List<RssFeedItem>();
|
||||||
private readonly HttpClient _httpClient;
|
private readonly HttpClient _httpClient;
|
||||||
private readonly string _dataPath;
|
|
||||||
private List<RssFeedItem> _items = new List<RssFeedItem>();
|
|
||||||
|
|
||||||
public RssFeedManager(
|
public RssFeedManager(
|
||||||
ILogger<RssFeedManager> logger,
|
ILogger<RssFeedManager> logger,
|
||||||
@ -34,276 +34,294 @@ namespace TransmissionRssManager.Services
|
|||||||
_transmissionClient = transmissionClient;
|
_transmissionClient = transmissionClient;
|
||||||
_httpClient = new HttpClient();
|
_httpClient = new HttpClient();
|
||||||
|
|
||||||
// Create data directory
|
// Load feeds from config
|
||||||
string homeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
|
||||||
string dataDir = Path.Combine(homeDir, ".local", "share", "transmission-rss-manager");
|
|
||||||
|
|
||||||
if (!Directory.Exists(dataDir))
|
|
||||||
{
|
|
||||||
Directory.CreateDirectory(dataDir);
|
|
||||||
}
|
|
||||||
|
|
||||||
_dataPath = Path.Combine(dataDir, "rss-items.json");
|
|
||||||
LoadItems();
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task<List<RssFeedItem>> GetAllItemsAsync()
|
|
||||||
{
|
|
||||||
return Task.FromResult(_items.OrderByDescending(i => i.PublishDate).ToList());
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task<List<RssFeedItem>> GetMatchedItemsAsync()
|
|
||||||
{
|
|
||||||
return Task.FromResult(_items.Where(i => i.IsMatched).OrderByDescending(i => i.PublishDate).ToList());
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task<List<RssFeed>> GetFeedsAsync()
|
|
||||||
{
|
|
||||||
var config = _configService.GetConfiguration();
|
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)
|
public async Task AddFeedAsync(RssFeed feed)
|
||||||
{
|
{
|
||||||
feed.Id = Guid.NewGuid().ToString();
|
feed.Id = Guid.NewGuid().ToString();
|
||||||
feed.LastChecked = DateTime.MinValue;
|
_feeds.Add(feed);
|
||||||
|
await SaveFeedsToConfigAsync();
|
||||||
var config = _configService.GetConfiguration();
|
|
||||||
config.Feeds.Add(feed);
|
|
||||||
await _configService.SaveConfigurationAsync(config);
|
|
||||||
|
|
||||||
// Initial fetch of feed items
|
|
||||||
await FetchFeedAsync(feed);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task RemoveFeedAsync(string feedId)
|
public async Task RemoveFeedAsync(string feedId)
|
||||||
{
|
{
|
||||||
var config = _configService.GetConfiguration();
|
_feeds.RemoveAll(f => f.Id == feedId);
|
||||||
var feed = config.Feeds.FirstOrDefault(f => f.Id == feedId);
|
_feedItems.RemoveAll(i => i.FeedId == feedId);
|
||||||
|
await SaveFeedsToConfigAsync();
|
||||||
if (feed != null)
|
|
||||||
{
|
|
||||||
config.Feeds.Remove(feed);
|
|
||||||
await _configService.SaveConfigurationAsync(config);
|
|
||||||
|
|
||||||
// Remove items from this feed
|
|
||||||
_items.RemoveAll(i => i.Id.StartsWith(feedId));
|
|
||||||
await SaveItemsAsync();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task UpdateFeedAsync(RssFeed feed)
|
public async Task UpdateFeedAsync(RssFeed feed)
|
||||||
{
|
{
|
||||||
var config = _configService.GetConfiguration();
|
var existingFeed = _feeds.FirstOrDefault(f => f.Id == feed.Id);
|
||||||
var index = config.Feeds.FindIndex(f => f.Id == feed.Id);
|
if (existingFeed != null)
|
||||||
|
|
||||||
if (index != -1)
|
|
||||||
{
|
{
|
||||||
config.Feeds[index] = feed;
|
int index = _feeds.IndexOf(existingFeed);
|
||||||
await _configService.SaveConfigurationAsync(config);
|
_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)
|
public async Task RefreshFeedsAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Starting RSS feed refresh");
|
_logger.LogInformation("Refreshing RSS feeds");
|
||||||
var config = _configService.GetConfiguration();
|
|
||||||
|
|
||||||
foreach (var feed in config.Feeds)
|
foreach (var feed in _feeds.Where(f => f.Enabled))
|
||||||
{
|
{
|
||||||
if (cancellationToken.IsCancellationRequested)
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try
|
_logger.LogInformation($"Downloading matched item: {item.Title}");
|
||||||
{
|
|
||||||
await FetchFeedAsync(feed);
|
// Add torrent to Transmission
|
||||||
|
int torrentId = await _transmissionClient.AddTorrentAsync(item.TorrentUrl, downloadDir);
|
||||||
// Update last checked time
|
|
||||||
feed.LastChecked = DateTime.Now;
|
// Update feed item
|
||||||
await _configService.SaveConfigurationAsync(config);
|
item.IsDownloaded = true;
|
||||||
}
|
item.DownloadDate = DateTime.UtcNow;
|
||||||
catch (Exception ex)
|
item.TorrentId = torrentId;
|
||||||
{
|
|
||||||
_logger.LogError(ex, $"Error refreshing feed: {feed.Name}");
|
_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)
|
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)
|
if (item != null)
|
||||||
{
|
{
|
||||||
item.IsDownloaded = true;
|
item.IsDownloaded = true;
|
||||||
await SaveItemsAsync();
|
item.DownloadDate = DateTime.UtcNow;
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -311,17 +329,14 @@ namespace TransmissionRssManager.Services
|
|||||||
public class RssFeedBackgroundService : BackgroundService
|
public class RssFeedBackgroundService : BackgroundService
|
||||||
{
|
{
|
||||||
private readonly ILogger<RssFeedBackgroundService> _logger;
|
private readonly ILogger<RssFeedBackgroundService> _logger;
|
||||||
private readonly IRssFeedManager _rssFeedManager;
|
private readonly IServiceProvider _serviceProvider;
|
||||||
private readonly IConfigService _configService;
|
|
||||||
|
|
||||||
public RssFeedBackgroundService(
|
public RssFeedBackgroundService(
|
||||||
ILogger<RssFeedBackgroundService> logger,
|
ILogger<RssFeedBackgroundService> logger,
|
||||||
IRssFeedManager rssFeedManager,
|
IServiceProvider serviceProvider)
|
||||||
IConfigService configService)
|
|
||||||
{
|
{
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_rssFeedManager = rssFeedManager;
|
_serviceProvider = serviceProvider;
|
||||||
_configService = configService;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
@ -332,18 +347,25 @@ namespace TransmissionRssManager.Services
|
|||||||
{
|
{
|
||||||
try
|
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)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "Error refreshing RSS feeds");
|
_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
|
// Load initial dashboard data
|
||||||
loadDashboardData();
|
loadDashboardData();
|
||||||
|
|
||||||
|
// Set up dark mode based on user preference
|
||||||
|
initDarkMode();
|
||||||
|
|
||||||
|
// Set up auto refresh if enabled
|
||||||
|
initAutoRefresh();
|
||||||
|
|
||||||
// Initialize Bootstrap tooltips
|
// Initialize Bootstrap tooltips
|
||||||
const tooltips = document.querySelectorAll('[data-bs-toggle="tooltip"]');
|
const tooltips = document.querySelectorAll('[data-bs-toggle="tooltip"]');
|
||||||
tooltips.forEach(tooltip => new bootstrap.Tooltip(tooltip));
|
tooltips.forEach(tooltip => new bootstrap.Tooltip(tooltip));
|
||||||
@ -86,15 +92,65 @@ function initEventListeners() {
|
|||||||
document.getElementById('btn-refresh-torrents').addEventListener('click', loadTorrents);
|
document.getElementById('btn-refresh-torrents').addEventListener('click', loadTorrents);
|
||||||
document.getElementById('save-torrent-btn').addEventListener('click', saveTorrent);
|
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
|
// Settings page
|
||||||
document.getElementById('settings-form').addEventListener('submit', saveSettings);
|
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
|
// Dashboard
|
||||||
function loadDashboardData() {
|
function loadDashboardData() {
|
||||||
loadSystemStatus();
|
// Fetch dashboard statistics
|
||||||
loadRecentMatches();
|
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();
|
loadActiveTorrents();
|
||||||
|
loadRecentMatches();
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadSystemStatus() {
|
function loadSystemStatus() {
|
||||||
@ -913,4 +969,543 @@ function formatDate(date) {
|
|||||||
|
|
||||||
function padZero(num) {
|
function padZero(num) {
|
||||||
return num.toString().padStart(2, '0');
|
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
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Transmission RSS Manager Test Script
|
||||||
|
# This script checks if the Transmission RSS Manager is installed and running correctly
|
||||||
|
|
||||||
# Colors for output
|
# Colors for output
|
||||||
GREEN='\033[0;32m'
|
GREEN='\033[0;32m'
|
||||||
YELLOW='\033[1;33m'
|
YELLOW='\033[1;33m'
|
||||||
RED='\033[0;31m'
|
RED='\033[0;31m'
|
||||||
NC='\033[0m' # No Color
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
# Print section header
|
echo -e "${GREEN}Transmission RSS Manager Test Script${NC}"
|
||||||
print_section() {
|
echo -e "${YELLOW}This script will check if your installation is working correctly${NC}"
|
||||||
echo -e "\n${GREEN}====== $1 ======${NC}"
|
echo
|
||||||
}
|
|
||||||
|
|
||||||
# Error handling
|
# Check if service is installed
|
||||||
set -e
|
if [ ! -f "/etc/systemd/system/transmission-rss-manager.service" ]; then
|
||||||
trap 'echo -e "${RED}An error occurred. Test failed.${NC}"; exit 1' ERR
|
echo -e "${RED}ERROR: Service file not found. Installation seems incomplete.${NC}"
|
||||||
|
|
||||||
# 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}"
|
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Build the application
|
echo -e "${GREEN}✓${NC} Service file found"
|
||||||
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}"
|
|
||||||
|
|
||||||
# Create database configuration
|
# Check if service is running
|
||||||
print_section "Creating database configuration"
|
if systemctl is-active --quiet transmission-rss-manager; then
|
||||||
echo '{
|
echo -e "${GREEN}✓${NC} Service is running"
|
||||||
"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=$?
|
|
||||||
else
|
else
|
||||||
echo -e "${YELLOW}PostgreSQL client tools not found.${NC}"
|
echo -e "${YELLOW}⚠ Service is not running. Attempting to start...${NC}"
|
||||||
PG_READY=1
|
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
|
fi
|
||||||
|
|
||||||
if [ $PG_READY -ne 0 ]; then
|
# Check application files
|
||||||
echo -e "${YELLOW}PostgreSQL is not running or not installed.${NC}"
|
INSTALL_DIR="/opt/transmission-rss-manager"
|
||||||
echo -e "${YELLOW}This test expects PostgreSQL to be available with a 'postgres' user.${NC}"
|
PUBLISH_DIR="$INSTALL_DIR/publish"
|
||||||
echo -e "${YELLOW}Either install PostgreSQL or modify the connection string in appsettings.json.${NC}"
|
|
||||||
|
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
|
# Try to find the wwwroot directory in the source
|
||||||
echo -e "${YELLOW}Continuing test without database functionality.${NC}"
|
WWWROOT_SOURCE=$(find $INSTALL_DIR -path "*/Web/wwwroot" -type d 2>/dev/null | head -n 1)
|
||||||
|
|
||||||
# Update appsettings.json to use SQLite instead
|
if [ -n "$WWWROOT_SOURCE" ]; then
|
||||||
echo '{
|
# Found the static content, copy it
|
||||||
"ConnectionStrings": {
|
mkdir -p "$PUBLISH_DIR/wwwroot"
|
||||||
"DefaultConnection": "Data Source=torrentmanager_test.db"
|
cp -r "$WWWROOT_SOURCE/"* "$PUBLISH_DIR/wwwroot/"
|
||||||
}
|
echo -e "${GREEN}✓${NC} Static content copied from $WWWROOT_SOURCE"
|
||||||
}' > "$TEST_DIR/appsettings.json"
|
|
||||||
|
# Restart the service to apply changes
|
||||||
echo -e "${YELLOW}Using SQLite database instead.${NC}"
|
systemctl restart transmission-rss-manager
|
||||||
|
echo -e "${YELLOW}Service restarted. Please try accessing the web interface again.${NC}"
|
||||||
# Install Entity Framework Core SQLite provider
|
else
|
||||||
dotnet add package Microsoft.EntityFrameworkCore.Sqlite --version 7.0.17
|
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}"
|
||||||
# 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}"
|
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
# Create test database
|
# Check if index.html exists
|
||||||
echo -e "${GREEN}Creating test database...${NC}"
|
if [ ! -f "$PUBLISH_DIR/wwwroot/index.html" ]; then
|
||||||
psql -U postgres -c "DROP DATABASE IF EXISTS torrentmanager_test;" || true
|
echo -e "${RED}ERROR: index.html not found in wwwroot directory!${NC}"
|
||||||
psql -U postgres -c "CREATE DATABASE torrentmanager_test;"
|
ls -la "$PUBLISH_DIR/wwwroot"
|
||||||
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=$?
|
|
||||||
else
|
else
|
||||||
# Try using wget if curl is not available
|
echo -e "${GREEN}✓${NC} Static content found (wwwroot/index.html exists)"
|
||||||
if command -v wget >/dev/null 2>&1; then
|
fi
|
||||||
wget -q --spider http://localhost:5555/swagger/index.html
|
fi
|
||||||
API_STATUS=$?
|
|
||||||
else
|
# Check web service response
|
||||||
echo -e "${YELLOW}Neither curl nor wget found. Cannot test API.${NC}"
|
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
|
break
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ $API_STATUS -ne 0 ]; then
|
sleep 1
|
||||||
echo -n "."
|
|
||||||
sleep 1
|
|
||||||
ATTEMPT=$((ATTEMPT+1))
|
|
||||||
fi
|
|
||||||
done
|
done
|
||||||
|
|
||||||
echo "" # New line after progress dots
|
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}"
|
||||||
# Kill the app
|
echo -e "${YELLOW}Trying to check API/health endpoint instead...${NC}"
|
||||||
kill $APP_PID 2>/dev/null || true
|
|
||||||
|
HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:5000/api/config 2>/dev/null || echo "000")
|
||||||
if [ $API_STATUS -eq 0 ]; then
|
if [ "$HTTP_STATUS" = "200" ] || [ "$HTTP_STATUS" = "302" ] || [ "$HTTP_STATUS" = "401" ] || [ "$HTTP_STATUS" = "404" ]; then
|
||||||
echo -e "${GREEN}API successfully responded.${NC}"
|
echo -e "${GREEN}✓${NC} API endpoint is responsive (HTTP $HTTP_STATUS)"
|
||||||
else
|
else
|
||||||
echo -e "${YELLOW}API did not respond within the timeout period.${NC}"
|
echo -e "${RED}ERROR: Web service does not appear to be working (HTTP $HTTP_STATUS).${NC}"
|
||||||
echo -e "${YELLOW}This may be normal if database migrations are slow or there are other startup delays.${NC}"
|
echo -e "${YELLOW}Checking service logs:${NC}"
|
||||||
|
sudo journalctl -u transmission-rss-manager -n 20 --no-pager
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Check file permissions and structure
|
# Installation successful
|
||||||
print_section "Checking build outputs"
|
echo
|
||||||
if [ -f "$TEST_DIR/bin/Release/net7.0/TransmissionRssManager.dll" ]; then
|
echo -e "${GREEN}✅ Transmission RSS Manager appears to be installed and running correctly!${NC}"
|
||||||
echo -e "${GREEN}Application was built successfully.${NC}"
|
echo -e "${YELLOW}Web interface: http://localhost:5000${NC}"
|
||||||
else
|
echo -e "${YELLOW}Configuration: $CONFIG_DIR/appsettings.json${NC}"
|
||||||
echo -e "${RED}Missing application binaries.${NC}"
|
echo
|
||||||
fi
|
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}"
|
||||||
# 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}"
|
|
Loading…
x
Reference in New Issue
Block a user