Initial commit of Transmission RSS Manager with fixed remote connection and post-processing features
This commit is contained in:
commit
f804ca51d3
115
README.md
Normal file
115
README.md
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
# Transmission RSS Manager
|
||||||
|
|
||||||
|
A C# application for managing RSS feeds and automatically downloading torrents via Transmission BitTorrent client.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Monitor multiple RSS feeds for new torrents
|
||||||
|
- Apply regex-based rules to automatically match and download content
|
||||||
|
- Manage Transmission torrents through a user-friendly web interface
|
||||||
|
- Post-processing of completed downloads (extract archives, organize media files)
|
||||||
|
- Responsive web UI for desktop and mobile use
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- .NET 7.0 or higher
|
||||||
|
- Transmission BitTorrent client (with remote access enabled)
|
||||||
|
- Linux OS (tested on Ubuntu, Debian, Fedora, Arch)
|
||||||
|
- Dependencies: unzip, p7zip, unrar (for post-processing)
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### Automatic Installation
|
||||||
|
|
||||||
|
Run the installer script:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sSL https://raw.githubusercontent.com/yourusername/transmission-rss-manager/main/install-script.sh | bash
|
||||||
|
```
|
||||||
|
|
||||||
|
Or if you've cloned the repository:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./src/Infrastructure/install-script.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### Manual Installation
|
||||||
|
|
||||||
|
1. Install .NET 7.0 SDK from [Microsoft's website](https://dotnet.microsoft.com/download)
|
||||||
|
2. Clone the repository:
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/yourusername/transmission-rss-manager.git
|
||||||
|
cd transmission-rss-manager
|
||||||
|
```
|
||||||
|
3. Build and run the application:
|
||||||
|
```bash
|
||||||
|
dotnet build -c Release
|
||||||
|
dotnet run
|
||||||
|
```
|
||||||
|
4. Open a web browser and navigate to: `http://localhost:5000`
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
After starting the application for the first time, a configuration file will be created at `~/.config/transmission-rss-manager/config.json`.
|
||||||
|
|
||||||
|
You can configure the application through the web interface or by directly editing the configuration file.
|
||||||
|
|
||||||
|
### Key configuration options
|
||||||
|
|
||||||
|
- **Transmission settings**: Host, port, username, password
|
||||||
|
- **RSS feed checking interval**
|
||||||
|
- **Auto-download settings**
|
||||||
|
- **Post-processing options**
|
||||||
|
- **Download and media library directories**
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Managing RSS Feeds
|
||||||
|
|
||||||
|
1. Add RSS feeds through the web interface
|
||||||
|
2. Create regex rules for each feed to match desired content
|
||||||
|
3. Enable auto-download for feeds you want to process automatically
|
||||||
|
|
||||||
|
### Managing Torrents
|
||||||
|
|
||||||
|
- Add torrents manually via URL or magnet link
|
||||||
|
- View, start, stop, and remove torrents
|
||||||
|
- Process completed torrents to extract archives and organize media
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
### Building from source
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet build
|
||||||
|
```
|
||||||
|
|
||||||
|
### Running in development mode
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet run
|
||||||
|
```
|
||||||
|
|
||||||
|
### Creating a release
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet publish -c Release
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
The application is built using ASP.NET Core with the following components:
|
||||||
|
|
||||||
|
- **Web API**: REST endpoints for the web interface
|
||||||
|
- **Background Services**: RSS feed checking and post-processing
|
||||||
|
- **Core Services**: Configuration, Transmission communication, RSS parsing
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
This project is licensed under the MIT License - see the LICENSE file for details.
|
||||||
|
|
||||||
|
## Acknowledgments
|
||||||
|
|
||||||
|
- [Transmission](https://transmissionbt.com/) - BitTorrent client
|
||||||
|
- [ASP.NET Core](https://docs.microsoft.com/en-us/aspnet/core/) - Web framework
|
||||||
|
- [Bootstrap](https://getbootstrap.com/) - UI framework
|
25
TransmissionRssManager.csproj
Normal file
25
TransmissionRssManager.csproj
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net7.0</TargetFramework>
|
||||||
|
<RootNamespace>TransmissionRssManager</RootNamespace>
|
||||||
|
<ImplicitUsings>disable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<Version>1.0.0</Version>
|
||||||
|
<Authors>TransmissionRssManager</Authors>
|
||||||
|
<Description>A C# application to manage RSS feeds and automatically download torrents via Transmission</Description>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="7.0.7" />
|
||||||
|
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" />
|
||||||
|
<PackageReference Include="System.ServiceModel.Syndication" Version="7.0.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<None Update="wwwroot\**\*">
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
</None>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
1359
install-script.sh
Executable file
1359
install-script.sh
Executable file
File diff suppressed because it is too large
Load Diff
38
reset-and-run-network.sh
Executable file
38
reset-and-run-network.sh
Executable file
@ -0,0 +1,38 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Reset and run the Transmission RSS Manager application with network access
|
||||||
|
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
RED='\033[0;31m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
# Clean up existing test directory
|
||||||
|
echo -e "${YELLOW}Removing existing test directory...${NC}"
|
||||||
|
rm -rf "$HOME/transmission-rss-test"
|
||||||
|
|
||||||
|
# Create and prepare test directory
|
||||||
|
echo -e "${GREEN}Creating fresh test directory...${NC}"
|
||||||
|
TEST_DIR="$HOME/transmission-rss-test"
|
||||||
|
mkdir -p "$TEST_DIR"
|
||||||
|
|
||||||
|
# Create appsettings.json to listen on all interfaces
|
||||||
|
mkdir -p "$TEST_DIR/Properties"
|
||||||
|
cat > "$TEST_DIR/Properties/launchSettings.json" << 'EOL'
|
||||||
|
{
|
||||||
|
"profiles": {
|
||||||
|
"TransmissionRssManager": {
|
||||||
|
"commandName": "Project",
|
||||||
|
"dotnetRunMessages": true,
|
||||||
|
"launchBrowser": false,
|
||||||
|
"applicationUrl": "http://0.0.0.0:5000",
|
||||||
|
"environmentVariables": {
|
||||||
|
"ASPNETCORE_ENVIRONMENT": "Production"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EOL
|
||||||
|
|
||||||
|
# Copy all other files from the original reset-and-run.sh
|
||||||
|
bash /opt/develop/transmission-rss-manager/reset-and-run.sh
|
1048
reset-and-run.sh
Executable file
1048
reset-and-run.sh
Executable file
File diff suppressed because it is too large
Load Diff
27
run-app.sh
Executable file
27
run-app.sh
Executable file
@ -0,0 +1,27 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Simple script to run the Transmission RSS Manager application
|
||||||
|
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
RED='\033[0;31m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
# Check if the app directory exists
|
||||||
|
APP_DIR="$HOME/transmission-rss-test"
|
||||||
|
if [ ! -d "$APP_DIR" ]; then
|
||||||
|
echo -e "${YELLOW}Application directory not found. Did you run the test installer?${NC}"
|
||||||
|
echo -e "${YELLOW}Running test installer first...${NC}"
|
||||||
|
bash /opt/develop/transmission-rss-manager/test-installer.sh
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Navigate to the app directory
|
||||||
|
cd "$APP_DIR"
|
||||||
|
|
||||||
|
# Run the application
|
||||||
|
echo -e "${GREEN}Starting Transmission RSS Manager...${NC}"
|
||||||
|
echo -e "${GREEN}The web interface will be available at: http://localhost:5000${NC}"
|
||||||
|
echo -e "${YELLOW}Press Ctrl+C to stop the application${NC}"
|
||||||
|
|
||||||
|
dotnet run
|
63
src/Api/Controllers/ConfigController.cs
Normal file
63
src/Api/Controllers/ConfigController.cs
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
using System.Threading.Tasks;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using TransmissionRssManager.Core;
|
||||||
|
|
||||||
|
namespace TransmissionRssManager.Api.Controllers
|
||||||
|
{
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/[controller]")]
|
||||||
|
public class ConfigController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly ILogger<ConfigController> _logger;
|
||||||
|
private readonly IConfigService _configService;
|
||||||
|
|
||||||
|
public ConfigController(
|
||||||
|
ILogger<ConfigController> logger,
|
||||||
|
IConfigService configService)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
_configService = configService;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
public IActionResult GetConfig()
|
||||||
|
{
|
||||||
|
var config = _configService.GetConfiguration();
|
||||||
|
|
||||||
|
// Create a sanitized config without sensitive information
|
||||||
|
var sanitizedConfig = new
|
||||||
|
{
|
||||||
|
transmission = new
|
||||||
|
{
|
||||||
|
host = config.Transmission.Host,
|
||||||
|
port = config.Transmission.Port,
|
||||||
|
useHttps = config.Transmission.UseHttps,
|
||||||
|
hasCredentials = !string.IsNullOrEmpty(config.Transmission.Username)
|
||||||
|
},
|
||||||
|
autoDownloadEnabled = config.AutoDownloadEnabled,
|
||||||
|
checkIntervalMinutes = config.CheckIntervalMinutes,
|
||||||
|
downloadDirectory = config.DownloadDirectory,
|
||||||
|
mediaLibraryPath = config.MediaLibraryPath,
|
||||||
|
postProcessing = config.PostProcessing
|
||||||
|
};
|
||||||
|
|
||||||
|
return Ok(sanitizedConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut]
|
||||||
|
public async Task<IActionResult> UpdateConfig([FromBody] AppConfig config)
|
||||||
|
{
|
||||||
|
var currentConfig = _configService.GetConfiguration();
|
||||||
|
|
||||||
|
// If password is empty, keep the existing one
|
||||||
|
if (string.IsNullOrEmpty(config.Transmission.Password) && !string.IsNullOrEmpty(currentConfig.Transmission.Password))
|
||||||
|
{
|
||||||
|
config.Transmission.Password = currentConfig.Transmission.Password;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _configService.SaveConfigurationAsync(config);
|
||||||
|
return Ok(new { success = true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
84
src/Api/Controllers/FeedsController.cs
Normal file
84
src/Api/Controllers/FeedsController.cs
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
using System.Threading.Tasks;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using TransmissionRssManager.Core;
|
||||||
|
|
||||||
|
namespace TransmissionRssManager.Api.Controllers
|
||||||
|
{
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/[controller]")]
|
||||||
|
public class FeedsController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly ILogger<FeedsController> _logger;
|
||||||
|
private readonly IRssFeedManager _rssFeedManager;
|
||||||
|
|
||||||
|
public FeedsController(
|
||||||
|
ILogger<FeedsController> logger,
|
||||||
|
IRssFeedManager rssFeedManager)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
_rssFeedManager = rssFeedManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<IActionResult> GetFeeds()
|
||||||
|
{
|
||||||
|
var feeds = await _rssFeedManager.GetFeedsAsync();
|
||||||
|
return Ok(feeds);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("items")]
|
||||||
|
public async Task<IActionResult> GetAllItems()
|
||||||
|
{
|
||||||
|
var items = await _rssFeedManager.GetAllItemsAsync();
|
||||||
|
return Ok(items);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("matched")]
|
||||||
|
public async Task<IActionResult> GetMatchedItems()
|
||||||
|
{
|
||||||
|
var items = await _rssFeedManager.GetMatchedItemsAsync();
|
||||||
|
return Ok(items);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
public async Task<IActionResult> AddFeed([FromBody] RssFeed feed)
|
||||||
|
{
|
||||||
|
await _rssFeedManager.AddFeedAsync(feed);
|
||||||
|
return Ok(feed);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("{id}")]
|
||||||
|
public async Task<IActionResult> UpdateFeed(string id, [FromBody] RssFeed feed)
|
||||||
|
{
|
||||||
|
if (id != feed.Id)
|
||||||
|
{
|
||||||
|
return BadRequest("Feed ID mismatch");
|
||||||
|
}
|
||||||
|
|
||||||
|
await _rssFeedManager.UpdateFeedAsync(feed);
|
||||||
|
return Ok(feed);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete("{id}")]
|
||||||
|
public async Task<IActionResult> DeleteFeed(string id)
|
||||||
|
{
|
||||||
|
await _rssFeedManager.RemoveFeedAsync(id);
|
||||||
|
return Ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("refresh")]
|
||||||
|
public async Task<IActionResult> RefreshFeeds()
|
||||||
|
{
|
||||||
|
await _rssFeedManager.RefreshFeedsAsync(HttpContext.RequestAborted);
|
||||||
|
return Ok(new { success = true });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("download/{id}")]
|
||||||
|
public async Task<IActionResult> DownloadItem(string id)
|
||||||
|
{
|
||||||
|
await _rssFeedManager.MarkItemAsDownloadedAsync(id);
|
||||||
|
return Ok(new { success = true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
89
src/Api/Controllers/TorrentsController.cs
Normal file
89
src/Api/Controllers/TorrentsController.cs
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
using System.Threading.Tasks;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using TransmissionRssManager.Core;
|
||||||
|
using TransmissionRssManager.Services;
|
||||||
|
|
||||||
|
namespace TransmissionRssManager.Api.Controllers
|
||||||
|
{
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/[controller]")]
|
||||||
|
public class TorrentsController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly ILogger<TorrentsController> _logger;
|
||||||
|
private readonly ITransmissionClient _transmissionClient;
|
||||||
|
private readonly IConfigService _configService;
|
||||||
|
private readonly IPostProcessor _postProcessor;
|
||||||
|
|
||||||
|
public TorrentsController(
|
||||||
|
ILogger<TorrentsController> logger,
|
||||||
|
ITransmissionClient transmissionClient,
|
||||||
|
IConfigService configService,
|
||||||
|
IPostProcessor postProcessor)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
_transmissionClient = transmissionClient;
|
||||||
|
_configService = configService;
|
||||||
|
_postProcessor = postProcessor;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<IActionResult> GetTorrents()
|
||||||
|
{
|
||||||
|
var torrents = await _transmissionClient.GetTorrentsAsync();
|
||||||
|
return Ok(torrents);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
public async Task<IActionResult> AddTorrent([FromBody] AddTorrentRequest request)
|
||||||
|
{
|
||||||
|
var config = _configService.GetConfiguration();
|
||||||
|
string downloadDir = request.DownloadDir ?? config.DownloadDirectory;
|
||||||
|
|
||||||
|
var torrentId = await _transmissionClient.AddTorrentAsync(request.Url, downloadDir);
|
||||||
|
return Ok(new { id = torrentId });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete("{id}")]
|
||||||
|
public async Task<IActionResult> RemoveTorrent(int id, [FromQuery] bool deleteLocalData = false)
|
||||||
|
{
|
||||||
|
await _transmissionClient.RemoveTorrentAsync(id, deleteLocalData);
|
||||||
|
return Ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{id}/start")]
|
||||||
|
public async Task<IActionResult> StartTorrent(int id)
|
||||||
|
{
|
||||||
|
await _transmissionClient.StartTorrentAsync(id);
|
||||||
|
return Ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{id}/stop")]
|
||||||
|
public async Task<IActionResult> StopTorrent(int id)
|
||||||
|
{
|
||||||
|
await _transmissionClient.StopTorrentAsync(id);
|
||||||
|
return Ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{id}/process")]
|
||||||
|
public async Task<IActionResult> ProcessTorrent(int id)
|
||||||
|
{
|
||||||
|
var torrents = await _transmissionClient.GetTorrentsAsync();
|
||||||
|
var torrent = torrents.Find(t => t.Id == id);
|
||||||
|
|
||||||
|
if (torrent == null)
|
||||||
|
{
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
await _postProcessor.ProcessTorrentAsync(torrent);
|
||||||
|
return Ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class AddTorrentRequest
|
||||||
|
{
|
||||||
|
public string Url { get; set; }
|
||||||
|
public string DownloadDir { get; set; }
|
||||||
|
}
|
||||||
|
}
|
38
src/Api/Program.cs
Normal file
38
src/Api/Program.cs
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
using Microsoft.AspNetCore.Builder;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using TransmissionRssManager.Core;
|
||||||
|
using TransmissionRssManager.Services;
|
||||||
|
|
||||||
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
|
// Add services to the container
|
||||||
|
builder.Services.AddControllers();
|
||||||
|
builder.Services.AddEndpointsApiExplorer();
|
||||||
|
builder.Services.AddSwaggerGen();
|
||||||
|
|
||||||
|
// Add custom services
|
||||||
|
builder.Services.AddSingleton<IConfigService, ConfigService>();
|
||||||
|
builder.Services.AddSingleton<ITransmissionClient, TransmissionClient>();
|
||||||
|
builder.Services.AddSingleton<IRssFeedManager, RssFeedManager>();
|
||||||
|
builder.Services.AddSingleton<IPostProcessor, PostProcessor>();
|
||||||
|
|
||||||
|
// Add background services
|
||||||
|
builder.Services.AddHostedService<RssFeedBackgroundService>();
|
||||||
|
builder.Services.AddHostedService<PostProcessingBackgroundService>();
|
||||||
|
|
||||||
|
var app = builder.Build();
|
||||||
|
|
||||||
|
// Configure middleware
|
||||||
|
if (app.Environment.IsDevelopment())
|
||||||
|
{
|
||||||
|
app.UseSwagger();
|
||||||
|
app.UseSwaggerUI();
|
||||||
|
}
|
||||||
|
|
||||||
|
app.UseStaticFiles();
|
||||||
|
app.UseRouting();
|
||||||
|
app.UseAuthorization();
|
||||||
|
app.MapControllers();
|
||||||
|
|
||||||
|
app.Run();
|
104
src/Core/Interfaces.cs
Normal file
104
src/Core/Interfaces.cs
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace TransmissionRssManager.Core
|
||||||
|
{
|
||||||
|
public class RssFeedItem
|
||||||
|
{
|
||||||
|
public string Id { get; set; }
|
||||||
|
public string Title { get; set; }
|
||||||
|
public string Link { get; set; }
|
||||||
|
public string Description { get; set; }
|
||||||
|
public DateTime PublishDate { get; set; }
|
||||||
|
public string TorrentUrl { get; set; }
|
||||||
|
public bool IsDownloaded { get; set; }
|
||||||
|
public bool IsMatched { get; set; }
|
||||||
|
public string MatchedRule { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class TorrentInfo
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string Name { get; set; }
|
||||||
|
public string Status { get; set; }
|
||||||
|
public double PercentDone { get; set; }
|
||||||
|
public long TotalSize { get; set; }
|
||||||
|
public string DownloadDir { get; set; }
|
||||||
|
public bool IsFinished => PercentDone >= 1.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class RssFeed
|
||||||
|
{
|
||||||
|
public string Id { get; set; }
|
||||||
|
public string Url { get; set; }
|
||||||
|
public string Name { get; set; }
|
||||||
|
public List<string> Rules { get; set; } = new List<string>();
|
||||||
|
public bool AutoDownload { get; set; }
|
||||||
|
public DateTime LastChecked { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class AppConfig
|
||||||
|
{
|
||||||
|
public TransmissionConfig Transmission { get; set; } = new TransmissionConfig();
|
||||||
|
public List<RssFeed> Feeds { get; set; } = new List<RssFeed>();
|
||||||
|
public bool AutoDownloadEnabled { get; set; }
|
||||||
|
public int CheckIntervalMinutes { get; set; } = 30;
|
||||||
|
public string DownloadDirectory { get; set; }
|
||||||
|
public string MediaLibraryPath { get; set; }
|
||||||
|
public PostProcessingConfig PostProcessing { get; set; } = new PostProcessingConfig();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class TransmissionConfig
|
||||||
|
{
|
||||||
|
public string Host { get; set; } = "localhost";
|
||||||
|
public int Port { get; set; } = 9091;
|
||||||
|
public string Username { get; set; }
|
||||||
|
public string Password { get; set; }
|
||||||
|
public bool UseHttps { get; set; } = false;
|
||||||
|
public string Url => $"{(UseHttps ? "https" : "http")}://{Host}:{Port}/transmission/rpc";
|
||||||
|
}
|
||||||
|
|
||||||
|
public class PostProcessingConfig
|
||||||
|
{
|
||||||
|
public bool Enabled { get; set; } = false;
|
||||||
|
public bool ExtractArchives { get; set; } = true;
|
||||||
|
public bool OrganizeMedia { get; set; } = true;
|
||||||
|
public int MinimumSeedRatio { get; set; } = 1;
|
||||||
|
public List<string> MediaExtensions { get; set; } = new List<string> { ".mp4", ".mkv", ".avi" };
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface IConfigService
|
||||||
|
{
|
||||||
|
AppConfig GetConfiguration();
|
||||||
|
Task SaveConfigurationAsync(AppConfig config);
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface ITransmissionClient
|
||||||
|
{
|
||||||
|
Task<List<TorrentInfo>> GetTorrentsAsync();
|
||||||
|
Task<int> AddTorrentAsync(string torrentUrl, string downloadDir);
|
||||||
|
Task RemoveTorrentAsync(int id, bool deleteLocalData);
|
||||||
|
Task StartTorrentAsync(int id);
|
||||||
|
Task StopTorrentAsync(int id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface IRssFeedManager
|
||||||
|
{
|
||||||
|
Task<List<RssFeedItem>> GetAllItemsAsync();
|
||||||
|
Task<List<RssFeedItem>> GetMatchedItemsAsync();
|
||||||
|
Task<List<RssFeed>> GetFeedsAsync();
|
||||||
|
Task AddFeedAsync(RssFeed feed);
|
||||||
|
Task RemoveFeedAsync(string feedId);
|
||||||
|
Task UpdateFeedAsync(RssFeed feed);
|
||||||
|
Task RefreshFeedsAsync(CancellationToken cancellationToken);
|
||||||
|
Task MarkItemAsDownloadedAsync(string itemId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface IPostProcessor
|
||||||
|
{
|
||||||
|
Task ProcessCompletedDownloadsAsync(CancellationToken cancellationToken);
|
||||||
|
Task ProcessTorrentAsync(TorrentInfo torrent);
|
||||||
|
}
|
||||||
|
}
|
212
src/Infrastructure/install-script.sh
Executable file
212
src/Infrastructure/install-script.sh
Executable file
@ -0,0 +1,212 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# TransmissionRssManager Installer Script for Linux
|
||||||
|
# This script installs the TransmissionRssManager application and its dependencies
|
||||||
|
|
||||||
|
# Colors for output
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
RED='\033[0;31m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
# Error handling
|
||||||
|
set -e
|
||||||
|
trap 'echo -e "${RED}An error occurred. Installation failed.${NC}"; exit 1' ERR
|
||||||
|
|
||||||
|
# Check if script is run as root
|
||||||
|
if [ "$EUID" -eq 0 ]; then
|
||||||
|
echo -e "${YELLOW}Warning: It's recommended to run this script as a regular user with sudo privileges, not as root.${NC}"
|
||||||
|
read -p "Continue anyway? (y/n) " -n 1 -r
|
||||||
|
echo
|
||||||
|
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Detect Linux distribution
|
||||||
|
if [ -f /etc/os-release ]; then
|
||||||
|
. /etc/os-release
|
||||||
|
DISTRO=$ID
|
||||||
|
else
|
||||||
|
echo -e "${RED}Cannot detect Linux distribution. Exiting.${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "${GREEN}Installing TransmissionRssManager on $PRETTY_NAME...${NC}"
|
||||||
|
|
||||||
|
# Install .NET SDK and runtime
|
||||||
|
install_dotnet() {
|
||||||
|
echo -e "${GREEN}Installing .NET SDK...${NC}"
|
||||||
|
|
||||||
|
case $DISTRO in
|
||||||
|
ubuntu|debian|linuxmint)
|
||||||
|
# Add Microsoft package repository
|
||||||
|
wget -O packages-microsoft-prod.deb https://packages.microsoft.com/config/$DISTRO/$VERSION_ID/packages-microsoft-prod.deb
|
||||||
|
sudo dpkg -i packages-microsoft-prod.deb
|
||||||
|
rm packages-microsoft-prod.deb
|
||||||
|
|
||||||
|
# Install .NET SDK
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y apt-transport-https
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y dotnet-sdk-7.0
|
||||||
|
;;
|
||||||
|
fedora|rhel|centos)
|
||||||
|
# Add Microsoft package repository
|
||||||
|
sudo rpm -Uvh https://packages.microsoft.com/config/centos/7/packages-microsoft-prod.rpm
|
||||||
|
|
||||||
|
# Install .NET SDK
|
||||||
|
sudo yum install -y dotnet-sdk-7.0
|
||||||
|
;;
|
||||||
|
opensuse*|sles)
|
||||||
|
# Install .NET SDK from zypper
|
||||||
|
sudo zypper install -y dotnet-sdk-7.0
|
||||||
|
;;
|
||||||
|
arch|manjaro)
|
||||||
|
# Install .NET SDK from pacman
|
||||||
|
sudo pacman -Sy dotnet-sdk aspnet-runtime --noconfirm
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo -e "${YELLOW}Unsupported distribution for automatic .NET installation.${NC}"
|
||||||
|
echo -e "${YELLOW}Please install .NET SDK 7.0 manually from https://dotnet.microsoft.com/download${NC}"
|
||||||
|
read -p "Press Enter to continue once .NET SDK is installed..."
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# Verify .NET installation
|
||||||
|
dotnet --version
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo -e "${RED}.NET SDK installation failed. Please install .NET SDK 7.0 manually.${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
install_dependencies() {
|
||||||
|
echo -e "${GREEN}Installing dependencies...${NC}"
|
||||||
|
|
||||||
|
case $DISTRO in
|
||||||
|
ubuntu|debian|linuxmint)
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y unzip p7zip-full unrar-free libssl-dev zlib1g-dev libicu-dev build-essential
|
||||||
|
;;
|
||||||
|
fedora|rhel|centos)
|
||||||
|
sudo yum install -y unzip p7zip unrar openssl-devel zlib-devel libicu-devel gcc-c++ make
|
||||||
|
;;
|
||||||
|
opensuse*|sles)
|
||||||
|
sudo zypper install -y unzip p7zip unrar libopenssl-devel zlib-devel libicu-devel gcc-c++ make
|
||||||
|
;;
|
||||||
|
arch|manjaro)
|
||||||
|
sudo pacman -Sy unzip p7zip unrar openssl zlib icu gcc make --noconfirm
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo -e "${YELLOW}Unsupported distribution for automatic dependency installation.${NC}"
|
||||||
|
echo -e "${YELLOW}Please make sure the following are installed: unzip, p7zip, unrar, ssl, zlib, icu, gcc/g++ and make.${NC}"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# Install Entity Framework Core CLI tools if needed (version 7.x)
|
||||||
|
if ! command -v dotnet-ef &> /dev/null; then
|
||||||
|
echo -e "${GREEN}Installing Entity Framework Core tools compatible with .NET 7...${NC}"
|
||||||
|
dotnet tool install --global dotnet-ef --version 7.0.15
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check if .NET is already installed
|
||||||
|
if command -v dotnet >/dev/null 2>&1; then
|
||||||
|
dotnet_version=$(dotnet --version)
|
||||||
|
echo -e "${GREEN}.NET SDK version $dotnet_version is already installed.${NC}"
|
||||||
|
else
|
||||||
|
install_dotnet
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
install_dependencies
|
||||||
|
|
||||||
|
# Create installation directory
|
||||||
|
INSTALL_DIR="$HOME/.local/share/transmission-rss-manager"
|
||||||
|
mkdir -p "$INSTALL_DIR"
|
||||||
|
|
||||||
|
# Clone or download the application
|
||||||
|
echo -e "${GREEN}Downloading TransmissionRssManager...${NC}"
|
||||||
|
if [ -d "/opt/develop/transmission-rss-manager/TransmissionRssManager" ]; then
|
||||||
|
# We're running from the development directory
|
||||||
|
cp -r /opt/develop/transmission-rss-manager/TransmissionRssManager/* "$INSTALL_DIR/"
|
||||||
|
else
|
||||||
|
# Download and extract release
|
||||||
|
wget -O transmission-rss-manager.zip https://github.com/yourusername/transmission-rss-manager/releases/latest/download/transmission-rss-manager.zip
|
||||||
|
unzip transmission-rss-manager.zip -d "$INSTALL_DIR"
|
||||||
|
rm transmission-rss-manager.zip
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Install required NuGet packages (with versions compatible with .NET 7)
|
||||||
|
echo -e "${GREEN}Installing required NuGet packages...${NC}"
|
||||||
|
cd "$INSTALL_DIR"
|
||||||
|
dotnet add package Microsoft.AspNetCore.OpenApi --version 7.0.13
|
||||||
|
dotnet add package Swashbuckle.AspNetCore --version 6.5.0
|
||||||
|
dotnet add package System.ServiceModel.Syndication --version 7.0.0
|
||||||
|
|
||||||
|
# Build the application
|
||||||
|
echo -e "${GREEN}Building TransmissionRssManager...${NC}"
|
||||||
|
dotnet build -c Release
|
||||||
|
|
||||||
|
# Create configuration directory
|
||||||
|
CONFIG_DIR="$HOME/.config/transmission-rss-manager"
|
||||||
|
mkdir -p "$CONFIG_DIR"
|
||||||
|
|
||||||
|
# Create desktop entry
|
||||||
|
DESKTOP_FILE="$HOME/.local/share/applications/transmission-rss-manager.desktop"
|
||||||
|
echo "[Desktop Entry]
|
||||||
|
Name=Transmission RSS Manager
|
||||||
|
Comment=RSS Feed Manager for Transmission BitTorrent Client
|
||||||
|
Exec=dotnet $INSTALL_DIR/bin/Release/net7.0/TransmissionRssManager.dll
|
||||||
|
Icon=transmission
|
||||||
|
Terminal=false
|
||||||
|
Type=Application
|
||||||
|
Categories=Network;P2P;" > "$DESKTOP_FILE"
|
||||||
|
|
||||||
|
# Create systemd service for user
|
||||||
|
SERVICE_DIR="$HOME/.config/systemd/user"
|
||||||
|
mkdir -p "$SERVICE_DIR"
|
||||||
|
|
||||||
|
echo "[Unit]
|
||||||
|
Description=Transmission RSS Manager
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
ExecStart=dotnet $INSTALL_DIR/bin/Release/net7.0/TransmissionRssManager.dll
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=10
|
||||||
|
SyslogIdentifier=transmission-rss-manager
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=default.target" > "$SERVICE_DIR/transmission-rss-manager.service"
|
||||||
|
|
||||||
|
# Reload systemd
|
||||||
|
systemctl --user daemon-reload
|
||||||
|
|
||||||
|
# Create launcher script
|
||||||
|
LAUNCHER="$HOME/.local/bin/transmission-rss-manager"
|
||||||
|
mkdir -p "$HOME/.local/bin"
|
||||||
|
|
||||||
|
echo "#!/bin/bash
|
||||||
|
dotnet $INSTALL_DIR/bin/Release/net7.0/TransmissionRssManager.dll" > "$LAUNCHER"
|
||||||
|
chmod +x "$LAUNCHER"
|
||||||
|
|
||||||
|
echo -e "${GREEN}Installation completed!${NC}"
|
||||||
|
echo -e "${GREEN}You can run TransmissionRssManager in these ways:${NC}"
|
||||||
|
echo -e " * Command: ${YELLOW}transmission-rss-manager${NC}"
|
||||||
|
echo -e " * Service: ${YELLOW}systemctl --user start transmission-rss-manager${NC}"
|
||||||
|
echo -e " * Enable service on startup: ${YELLOW}systemctl --user enable transmission-rss-manager${NC}"
|
||||||
|
echo -e " * Web interface will be available at: ${YELLOW}http://localhost:5000${NC}"
|
||||||
|
|
||||||
|
# Start the application
|
||||||
|
read -p "Do you want to start the application now? (y/n) " -n 1 -r
|
||||||
|
echo
|
||||||
|
if [[ $REPLY =~ ^[Yy]$ ]]; then
|
||||||
|
systemctl --user start transmission-rss-manager
|
||||||
|
echo -e "${GREEN}TransmissionRssManager service started.${NC}"
|
||||||
|
echo -e "${GREEN}Open http://localhost:5000 in your browser.${NC}"
|
||||||
|
else
|
||||||
|
echo -e "${YELLOW}You can start the application later using: systemctl --user start transmission-rss-manager${NC}"
|
||||||
|
fi
|
BIN
src/Infrastructure/packages-microsoft-prod.deb
Normal file
BIN
src/Infrastructure/packages-microsoft-prod.deb
Normal file
Binary file not shown.
112
src/Services/ConfigService.cs
Normal file
112
src/Services/ConfigService.cs
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using TransmissionRssManager.Core;
|
||||||
|
|
||||||
|
namespace TransmissionRssManager.Services
|
||||||
|
{
|
||||||
|
public class ConfigService : IConfigService
|
||||||
|
{
|
||||||
|
private readonly ILogger<ConfigService> _logger;
|
||||||
|
private readonly string _configPath;
|
||||||
|
private AppConfig _cachedConfig;
|
||||||
|
|
||||||
|
public ConfigService(ILogger<ConfigService> logger)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
|
||||||
|
// Get config directory
|
||||||
|
string homeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
||||||
|
string configDir = Path.Combine(homeDir, ".config", "transmission-rss-manager");
|
||||||
|
|
||||||
|
// Ensure directory exists
|
||||||
|
if (!Directory.Exists(configDir))
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(configDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
_configPath = Path.Combine(configDir, "config.json");
|
||||||
|
_cachedConfig = LoadConfiguration();
|
||||||
|
}
|
||||||
|
|
||||||
|
public AppConfig GetConfiguration()
|
||||||
|
{
|
||||||
|
return _cachedConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SaveConfigurationAsync(AppConfig config)
|
||||||
|
{
|
||||||
|
_cachedConfig = config;
|
||||||
|
|
||||||
|
var options = new JsonSerializerOptions
|
||||||
|
{
|
||||||
|
WriteIndented = true
|
||||||
|
};
|
||||||
|
|
||||||
|
string json = JsonSerializer.Serialize(config, options);
|
||||||
|
await File.WriteAllTextAsync(_configPath, json);
|
||||||
|
|
||||||
|
_logger.LogInformation("Configuration saved successfully");
|
||||||
|
}
|
||||||
|
|
||||||
|
private AppConfig LoadConfiguration()
|
||||||
|
{
|
||||||
|
if (!File.Exists(_configPath))
|
||||||
|
{
|
||||||
|
_logger.LogInformation("No configuration file found, creating default");
|
||||||
|
var defaultConfig = CreateDefaultConfig();
|
||||||
|
SaveConfigurationAsync(defaultConfig).Wait();
|
||||||
|
return defaultConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
string json = File.ReadAllText(_configPath);
|
||||||
|
var config = JsonSerializer.Deserialize<AppConfig>(json);
|
||||||
|
|
||||||
|
if (config == null)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Failed to deserialize config, creating default");
|
||||||
|
return CreateDefaultConfig();
|
||||||
|
}
|
||||||
|
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error loading configuration");
|
||||||
|
return CreateDefaultConfig();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private AppConfig CreateDefaultConfig()
|
||||||
|
{
|
||||||
|
string homeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
||||||
|
|
||||||
|
return new AppConfig
|
||||||
|
{
|
||||||
|
Transmission = new TransmissionConfig
|
||||||
|
{
|
||||||
|
Host = "localhost",
|
||||||
|
Port = 9091,
|
||||||
|
Username = "",
|
||||||
|
Password = ""
|
||||||
|
},
|
||||||
|
AutoDownloadEnabled = false,
|
||||||
|
CheckIntervalMinutes = 30,
|
||||||
|
DownloadDirectory = Path.Combine(homeDir, "Downloads"),
|
||||||
|
MediaLibraryPath = Path.Combine(homeDir, "Media"),
|
||||||
|
PostProcessing = new PostProcessingConfig
|
||||||
|
{
|
||||||
|
Enabled = false,
|
||||||
|
ExtractArchives = true,
|
||||||
|
OrganizeMedia = true,
|
||||||
|
MinimumSeedRatio = 1,
|
||||||
|
MediaExtensions = new System.Collections.Generic.List<string> { ".mp4", ".mkv", ".avi" }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
272
src/Services/PostProcessor.cs
Normal file
272
src/Services/PostProcessor.cs
Normal file
@ -0,0 +1,272 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using TransmissionRssManager.Core;
|
||||||
|
|
||||||
|
namespace TransmissionRssManager.Services
|
||||||
|
{
|
||||||
|
public class PostProcessor : IPostProcessor
|
||||||
|
{
|
||||||
|
private readonly ILogger<PostProcessor> _logger;
|
||||||
|
private readonly IConfigService _configService;
|
||||||
|
private readonly ITransmissionClient _transmissionClient;
|
||||||
|
|
||||||
|
public PostProcessor(
|
||||||
|
ILogger<PostProcessor> logger,
|
||||||
|
IConfigService configService,
|
||||||
|
ITransmissionClient transmissionClient)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
_configService = configService;
|
||||||
|
_transmissionClient = transmissionClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task ProcessCompletedDownloadsAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var config = _configService.GetConfiguration();
|
||||||
|
|
||||||
|
if (!config.PostProcessing.Enabled)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("Processing completed downloads");
|
||||||
|
|
||||||
|
var torrents = await _transmissionClient.GetTorrentsAsync();
|
||||||
|
var completedTorrents = torrents.Where(t => t.IsFinished).ToList();
|
||||||
|
|
||||||
|
foreach (var torrent in completedTorrents)
|
||||||
|
{
|
||||||
|
if (cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Post-processing cancelled");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await ProcessTorrentAsync(torrent);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, $"Error processing torrent: {torrent.Name}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task ProcessTorrentAsync(TorrentInfo torrent)
|
||||||
|
{
|
||||||
|
_logger.LogInformation($"Processing completed torrent: {torrent.Name}");
|
||||||
|
|
||||||
|
var config = _configService.GetConfiguration();
|
||||||
|
var downloadDir = torrent.DownloadDir;
|
||||||
|
var torrentPath = Path.Combine(downloadDir, torrent.Name);
|
||||||
|
|
||||||
|
// Check if the file/directory exists
|
||||||
|
if (!Directory.Exists(torrentPath) && !File.Exists(torrentPath))
|
||||||
|
{
|
||||||
|
_logger.LogWarning($"Downloaded path not found: {torrentPath}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle archives if enabled
|
||||||
|
if (config.PostProcessing.ExtractArchives && IsArchive(torrentPath))
|
||||||
|
{
|
||||||
|
await ExtractArchiveAsync(torrentPath, downloadDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Organize media files if enabled
|
||||||
|
if (config.PostProcessing.OrganizeMedia)
|
||||||
|
{
|
||||||
|
await OrganizeMediaAsync(torrentPath, config.MediaLibraryPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool IsArchive(string path)
|
||||||
|
{
|
||||||
|
if (!File.Exists(path))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var extension = Path.GetExtension(path).ToLowerInvariant();
|
||||||
|
return extension == ".rar" || extension == ".zip" || extension == ".7z";
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ExtractArchiveAsync(string archivePath, string outputDir)
|
||||||
|
{
|
||||||
|
_logger.LogInformation($"Extracting archive: {archivePath}");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var extension = Path.GetExtension(archivePath).ToLowerInvariant();
|
||||||
|
var extractDir = Path.Combine(outputDir, Path.GetFileNameWithoutExtension(archivePath));
|
||||||
|
|
||||||
|
// Create extraction directory if it doesn't exist
|
||||||
|
if (!Directory.Exists(extractDir))
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(extractDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
var processStartInfo = new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = extension switch
|
||||||
|
{
|
||||||
|
".rar" => "unrar",
|
||||||
|
".zip" => "unzip",
|
||||||
|
".7z" => "7z",
|
||||||
|
_ => throw new Exception($"Unsupported archive format: {extension}")
|
||||||
|
},
|
||||||
|
Arguments = extension switch
|
||||||
|
{
|
||||||
|
".rar" => $"x -o+ \"{archivePath}\" \"{extractDir}\"",
|
||||||
|
".zip" => $"-o \"{archivePath}\" -d \"{extractDir}\"",
|
||||||
|
".7z" => $"x \"{archivePath}\" -o\"{extractDir}\"",
|
||||||
|
_ => throw new Exception($"Unsupported archive format: {extension}")
|
||||||
|
},
|
||||||
|
UseShellExecute = false,
|
||||||
|
RedirectStandardOutput = true,
|
||||||
|
RedirectStandardError = true,
|
||||||
|
CreateNoWindow = true
|
||||||
|
};
|
||||||
|
|
||||||
|
using var process = new Process
|
||||||
|
{
|
||||||
|
StartInfo = processStartInfo
|
||||||
|
};
|
||||||
|
|
||||||
|
process.Start();
|
||||||
|
await process.WaitForExitAsync();
|
||||||
|
|
||||||
|
if (process.ExitCode != 0)
|
||||||
|
{
|
||||||
|
var error = await process.StandardError.ReadToEndAsync();
|
||||||
|
_logger.LogError($"Error extracting archive: {error}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation($"Archive extracted to: {extractDir}");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, $"Error extracting archive: {archivePath}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task OrganizeMediaAsync(string path, string mediaLibraryPath)
|
||||||
|
{
|
||||||
|
_logger.LogInformation($"Organizing media: {path}");
|
||||||
|
|
||||||
|
var config = _configService.GetConfiguration();
|
||||||
|
var mediaExtensions = config.PostProcessing.MediaExtensions;
|
||||||
|
|
||||||
|
// Ensure media library path exists
|
||||||
|
if (!Directory.Exists(mediaLibraryPath))
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(mediaLibraryPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (File.Exists(path))
|
||||||
|
{
|
||||||
|
// Single file
|
||||||
|
var extension = Path.GetExtension(path).ToLowerInvariant();
|
||||||
|
if (mediaExtensions.Contains(extension))
|
||||||
|
{
|
||||||
|
await CopyFileToMediaLibraryAsync(path, mediaLibraryPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (Directory.Exists(path))
|
||||||
|
{
|
||||||
|
// Directory - find all media files recursively
|
||||||
|
var mediaFiles = Directory.GetFiles(path, "*.*", SearchOption.AllDirectories)
|
||||||
|
.Where(f => mediaExtensions.Contains(Path.GetExtension(f).ToLowerInvariant()))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
foreach (var mediaFile in mediaFiles)
|
||||||
|
{
|
||||||
|
await CopyFileToMediaLibraryAsync(mediaFile, mediaLibraryPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, $"Error organizing media: {path}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task CopyFileToMediaLibraryAsync(string filePath, string mediaLibraryPath)
|
||||||
|
{
|
||||||
|
var fileName = Path.GetFileName(filePath);
|
||||||
|
var destinationPath = Path.Combine(mediaLibraryPath, fileName);
|
||||||
|
|
||||||
|
// If destination file already exists, add a unique identifier
|
||||||
|
if (File.Exists(destinationPath))
|
||||||
|
{
|
||||||
|
var fileNameWithoutExt = Path.GetFileNameWithoutExtension(fileName);
|
||||||
|
var extension = Path.GetExtension(fileName);
|
||||||
|
var uniqueId = Guid.NewGuid().ToString().Substring(0, 8);
|
||||||
|
|
||||||
|
destinationPath = Path.Combine(mediaLibraryPath, $"{fileNameWithoutExt}_{uniqueId}{extension}");
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation($"Copying media file to library: {destinationPath}");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var sourceStream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read, 4096, true);
|
||||||
|
using var destinationStream = new FileStream(destinationPath, FileMode.Create, FileAccess.Write, FileShare.None, 4096, true);
|
||||||
|
|
||||||
|
await sourceStream.CopyToAsync(destinationStream);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, $"Error copying file to media library: {filePath}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class PostProcessingBackgroundService : BackgroundService
|
||||||
|
{
|
||||||
|
private readonly ILogger<PostProcessingBackgroundService> _logger;
|
||||||
|
private readonly IPostProcessor _postProcessor;
|
||||||
|
private readonly IConfigService _configService;
|
||||||
|
|
||||||
|
public PostProcessingBackgroundService(
|
||||||
|
ILogger<PostProcessingBackgroundService> logger,
|
||||||
|
IPostProcessor postProcessor,
|
||||||
|
IConfigService configService)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
_postProcessor = postProcessor;
|
||||||
|
_configService = configService;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Post-processing background service started");
|
||||||
|
|
||||||
|
while (!stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _postProcessor.ProcessCompletedDownloadsAsync(stoppingToken);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error processing completed downloads");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check every 5 minutes
|
||||||
|
await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
350
src/Services/RssFeedManager.cs
Normal file
350
src/Services/RssFeedManager.cs
Normal file
@ -0,0 +1,350 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Net.Http;
|
||||||
|
using System.ServiceModel.Syndication;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using System.Xml;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using TransmissionRssManager.Core;
|
||||||
|
|
||||||
|
namespace TransmissionRssManager.Services
|
||||||
|
{
|
||||||
|
public class RssFeedManager : IRssFeedManager
|
||||||
|
{
|
||||||
|
private readonly ILogger<RssFeedManager> _logger;
|
||||||
|
private readonly IConfigService _configService;
|
||||||
|
private readonly ITransmissionClient _transmissionClient;
|
||||||
|
private readonly HttpClient _httpClient;
|
||||||
|
private readonly string _dataPath;
|
||||||
|
private List<RssFeedItem> _items = new List<RssFeedItem>();
|
||||||
|
|
||||||
|
public RssFeedManager(
|
||||||
|
ILogger<RssFeedManager> logger,
|
||||||
|
IConfigService configService,
|
||||||
|
ITransmissionClient transmissionClient)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
_configService = configService;
|
||||||
|
_transmissionClient = transmissionClient;
|
||||||
|
_httpClient = new HttpClient();
|
||||||
|
|
||||||
|
// Create data directory
|
||||||
|
string homeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
||||||
|
string dataDir = Path.Combine(homeDir, ".local", "share", "transmission-rss-manager");
|
||||||
|
|
||||||
|
if (!Directory.Exists(dataDir))
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(dataDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
_dataPath = Path.Combine(dataDir, "rss-items.json");
|
||||||
|
LoadItems();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<List<RssFeedItem>> GetAllItemsAsync()
|
||||||
|
{
|
||||||
|
return Task.FromResult(_items.OrderByDescending(i => i.PublishDate).ToList());
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<List<RssFeedItem>> GetMatchedItemsAsync()
|
||||||
|
{
|
||||||
|
return Task.FromResult(_items.Where(i => i.IsMatched).OrderByDescending(i => i.PublishDate).ToList());
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<List<RssFeed>> GetFeedsAsync()
|
||||||
|
{
|
||||||
|
var config = _configService.GetConfiguration();
|
||||||
|
return Task.FromResult(config.Feeds);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task AddFeedAsync(RssFeed feed)
|
||||||
|
{
|
||||||
|
feed.Id = Guid.NewGuid().ToString();
|
||||||
|
feed.LastChecked = DateTime.MinValue;
|
||||||
|
|
||||||
|
var config = _configService.GetConfiguration();
|
||||||
|
config.Feeds.Add(feed);
|
||||||
|
await _configService.SaveConfigurationAsync(config);
|
||||||
|
|
||||||
|
// Initial fetch of feed items
|
||||||
|
await FetchFeedAsync(feed);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task RemoveFeedAsync(string feedId)
|
||||||
|
{
|
||||||
|
var config = _configService.GetConfiguration();
|
||||||
|
var feed = config.Feeds.FirstOrDefault(f => f.Id == feedId);
|
||||||
|
|
||||||
|
if (feed != null)
|
||||||
|
{
|
||||||
|
config.Feeds.Remove(feed);
|
||||||
|
await _configService.SaveConfigurationAsync(config);
|
||||||
|
|
||||||
|
// Remove items from this feed
|
||||||
|
_items.RemoveAll(i => i.Id.StartsWith(feedId));
|
||||||
|
await SaveItemsAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task UpdateFeedAsync(RssFeed feed)
|
||||||
|
{
|
||||||
|
var config = _configService.GetConfiguration();
|
||||||
|
var index = config.Feeds.FindIndex(f => f.Id == feed.Id);
|
||||||
|
|
||||||
|
if (index != -1)
|
||||||
|
{
|
||||||
|
config.Feeds[index] = feed;
|
||||||
|
await _configService.SaveConfigurationAsync(config);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task RefreshFeedsAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Starting RSS feed refresh");
|
||||||
|
var config = _configService.GetConfiguration();
|
||||||
|
|
||||||
|
foreach (var feed in config.Feeds)
|
||||||
|
{
|
||||||
|
if (cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("RSS refresh cancelled");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await FetchFeedAsync(feed);
|
||||||
|
|
||||||
|
// Update last checked time
|
||||||
|
feed.LastChecked = DateTime.Now;
|
||||||
|
await _configService.SaveConfigurationAsync(config);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, $"Error refreshing feed: {feed.Name}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for matches and auto-download if enabled
|
||||||
|
await ProcessMatchesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task MarkItemAsDownloadedAsync(string itemId)
|
||||||
|
{
|
||||||
|
var item = _items.FirstOrDefault(i => i.Id == itemId);
|
||||||
|
|
||||||
|
if (item != null)
|
||||||
|
{
|
||||||
|
item.IsDownloaded = true;
|
||||||
|
await SaveItemsAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task FetchFeedAsync(RssFeed feed)
|
||||||
|
{
|
||||||
|
_logger.LogInformation($"Fetching feed: {feed.Name}");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var response = await _httpClient.GetStringAsync(feed.Url);
|
||||||
|
using var stringReader = new StringReader(response);
|
||||||
|
using var xmlReader = XmlReader.Create(stringReader);
|
||||||
|
|
||||||
|
var syndicationFeed = SyndicationFeed.Load(xmlReader);
|
||||||
|
|
||||||
|
foreach (var item in syndicationFeed.Items)
|
||||||
|
{
|
||||||
|
var link = item.Links.FirstOrDefault()?.Uri.ToString() ?? "";
|
||||||
|
var torrentUrl = ExtractTorrentUrl(link, item.Title.Text);
|
||||||
|
|
||||||
|
// Create a unique ID for this item
|
||||||
|
var itemId = $"{feed.Id}:{item.Id ?? Guid.NewGuid().ToString()}";
|
||||||
|
|
||||||
|
// Check if we already have this item
|
||||||
|
if (_items.Any(i => i.Id == itemId))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var feedItem = new RssFeedItem
|
||||||
|
{
|
||||||
|
Id = itemId,
|
||||||
|
Title = item.Title.Text,
|
||||||
|
Link = link,
|
||||||
|
Description = item.Summary?.Text ?? "",
|
||||||
|
PublishDate = item.PublishDate.DateTime,
|
||||||
|
TorrentUrl = torrentUrl,
|
||||||
|
IsDownloaded = false
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if this item matches any rules
|
||||||
|
CheckForMatches(feedItem, feed.Rules);
|
||||||
|
|
||||||
|
_items.Add(feedItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
await SaveItemsAsync();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, $"Error fetching feed: {feed.Name}");
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private string ExtractTorrentUrl(string link, string title)
|
||||||
|
{
|
||||||
|
// Try to find a .torrent link
|
||||||
|
if (link.EndsWith(".torrent", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return link;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If it's a magnet link, return it
|
||||||
|
if (link.StartsWith("magnet:", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return link;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return the link as is, we'll try to find the torrent on the page
|
||||||
|
return link;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CheckForMatches(RssFeedItem item, List<string> rules)
|
||||||
|
{
|
||||||
|
foreach (var rule in rules)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (Regex.IsMatch(item.Title, rule, RegexOptions.IgnoreCase))
|
||||||
|
{
|
||||||
|
item.IsMatched = true;
|
||||||
|
item.MatchedRule = rule;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, $"Invalid regex rule: {rule}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ProcessMatchesAsync()
|
||||||
|
{
|
||||||
|
var config = _configService.GetConfiguration();
|
||||||
|
|
||||||
|
if (!config.AutoDownloadEnabled)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var matchedItems = _items.Where(i => i.IsMatched && !i.IsDownloaded).ToList();
|
||||||
|
|
||||||
|
foreach (var item in matchedItems)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_logger.LogInformation($"Auto-downloading: {item.Title}");
|
||||||
|
|
||||||
|
var torrentId = await _transmissionClient.AddTorrentAsync(
|
||||||
|
item.TorrentUrl,
|
||||||
|
config.DownloadDirectory);
|
||||||
|
|
||||||
|
item.IsDownloaded = true;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, $"Error downloading torrent: {item.Title}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await SaveItemsAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void LoadItems()
|
||||||
|
{
|
||||||
|
if (!File.Exists(_dataPath))
|
||||||
|
{
|
||||||
|
_items = new List<RssFeedItem>();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var json = File.ReadAllText(_dataPath);
|
||||||
|
var items = JsonSerializer.Deserialize<List<RssFeedItem>>(json);
|
||||||
|
_items = items ?? new List<RssFeedItem>();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error loading RSS items");
|
||||||
|
_items = new List<RssFeedItem>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SaveItemsAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var options = new JsonSerializerOptions
|
||||||
|
{
|
||||||
|
WriteIndented = true
|
||||||
|
};
|
||||||
|
|
||||||
|
var json = JsonSerializer.Serialize(_items, options);
|
||||||
|
await File.WriteAllTextAsync(_dataPath, json);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error saving RSS items");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class RssFeedBackgroundService : BackgroundService
|
||||||
|
{
|
||||||
|
private readonly ILogger<RssFeedBackgroundService> _logger;
|
||||||
|
private readonly IRssFeedManager _rssFeedManager;
|
||||||
|
private readonly IConfigService _configService;
|
||||||
|
|
||||||
|
public RssFeedBackgroundService(
|
||||||
|
ILogger<RssFeedBackgroundService> logger,
|
||||||
|
IRssFeedManager rssFeedManager,
|
||||||
|
IConfigService configService)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
_rssFeedManager = rssFeedManager;
|
||||||
|
_configService = configService;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("RSS feed background service started");
|
||||||
|
|
||||||
|
while (!stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _rssFeedManager.RefreshFeedsAsync(stoppingToken);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error refreshing RSS feeds");
|
||||||
|
}
|
||||||
|
|
||||||
|
var config = _configService.GetConfiguration();
|
||||||
|
var interval = TimeSpan.FromMinutes(config.CheckIntervalMinutes);
|
||||||
|
|
||||||
|
_logger.LogInformation($"Next refresh in {interval.TotalMinutes} minutes");
|
||||||
|
await Task.Delay(interval, stoppingToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
309
src/Services/TransmissionClient.cs
Normal file
309
src/Services/TransmissionClient.cs
Normal file
@ -0,0 +1,309 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Net.Http;
|
||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using System.Linq;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using TransmissionRssManager.Core;
|
||||||
|
|
||||||
|
namespace TransmissionRssManager.Services
|
||||||
|
{
|
||||||
|
public class TransmissionClient : ITransmissionClient
|
||||||
|
{
|
||||||
|
private readonly ILogger<TransmissionClient> _logger;
|
||||||
|
private readonly IConfigService _configService;
|
||||||
|
private readonly HttpClient _httpClient;
|
||||||
|
private string _sessionId = string.Empty;
|
||||||
|
|
||||||
|
public TransmissionClient(ILogger<TransmissionClient> logger, IConfigService configService)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
_configService = configService;
|
||||||
|
|
||||||
|
// Configure the main HttpClient with handler that ignores certificate errors
|
||||||
|
var handler = new HttpClientHandler
|
||||||
|
{
|
||||||
|
ServerCertificateCustomValidationCallback = (sender, cert, chain, sslPolicyErrors) => true
|
||||||
|
};
|
||||||
|
_httpClient = new HttpClient(handler);
|
||||||
|
_httpClient.Timeout = TimeSpan.FromSeconds(10);
|
||||||
|
|
||||||
|
_logger.LogInformation("TransmissionClient initialized with certificate validation disabled");
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<TorrentInfo>> GetTorrentsAsync()
|
||||||
|
{
|
||||||
|
var config = _configService.GetConfiguration();
|
||||||
|
var request = new
|
||||||
|
{
|
||||||
|
method = "torrent-get",
|
||||||
|
arguments = new
|
||||||
|
{
|
||||||
|
fields = new[] { "id", "name", "status", "percentDone", "totalSize", "downloadDir" }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var response = await SendRequestAsync<TorrentGetResponse>(config.Transmission.Url, request);
|
||||||
|
|
||||||
|
_logger.LogInformation($"Transmission torrent response: {response != null}, Arguments: {response?.Arguments != null}, Result: {response?.Result}");
|
||||||
|
|
||||||
|
if (response?.Arguments?.Torrents == null)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("No torrents found in response");
|
||||||
|
return new List<TorrentInfo>();
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation($"Found {response.Arguments.Torrents.Count} torrents in response");
|
||||||
|
|
||||||
|
var torrents = new List<TorrentInfo>();
|
||||||
|
foreach (var torrent in response.Arguments.Torrents)
|
||||||
|
{
|
||||||
|
_logger.LogInformation($"Processing torrent: {torrent.Id} - {torrent.Name}");
|
||||||
|
torrents.Add(new TorrentInfo
|
||||||
|
{
|
||||||
|
Id = torrent.Id,
|
||||||
|
Name = torrent.Name,
|
||||||
|
Status = GetStatusText(torrent.Status),
|
||||||
|
PercentDone = torrent.PercentDone,
|
||||||
|
TotalSize = torrent.TotalSize,
|
||||||
|
DownloadDir = torrent.DownloadDir
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return torrents;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<int> AddTorrentAsync(string torrentUrl, string downloadDir)
|
||||||
|
{
|
||||||
|
var config = _configService.GetConfiguration();
|
||||||
|
var request = new
|
||||||
|
{
|
||||||
|
method = "torrent-add",
|
||||||
|
arguments = new
|
||||||
|
{
|
||||||
|
filename = torrentUrl,
|
||||||
|
downloadDir = downloadDir
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var response = await SendRequestAsync<TorrentAddResponse>(config.Transmission.Url, request);
|
||||||
|
|
||||||
|
if (response?.Arguments?.TorrentAdded != null)
|
||||||
|
{
|
||||||
|
return response.Arguments.TorrentAdded.Id;
|
||||||
|
}
|
||||||
|
else if (response?.Arguments?.TorrentDuplicate != null)
|
||||||
|
{
|
||||||
|
return response.Arguments.TorrentDuplicate.Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Exception("Failed to add torrent");
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task RemoveTorrentAsync(int id, bool deleteLocalData)
|
||||||
|
{
|
||||||
|
var config = _configService.GetConfiguration();
|
||||||
|
var request = new
|
||||||
|
{
|
||||||
|
method = "torrent-remove",
|
||||||
|
arguments = new
|
||||||
|
{
|
||||||
|
ids = new[] { id },
|
||||||
|
deleteLocalData = deleteLocalData
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await SendRequestAsync<object>(config.Transmission.Url, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task StartTorrentAsync(int id)
|
||||||
|
{
|
||||||
|
var config = _configService.GetConfiguration();
|
||||||
|
var request = new
|
||||||
|
{
|
||||||
|
method = "torrent-start",
|
||||||
|
arguments = new
|
||||||
|
{
|
||||||
|
ids = new[] { id }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await SendRequestAsync<object>(config.Transmission.Url, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task StopTorrentAsync(int id)
|
||||||
|
{
|
||||||
|
var config = _configService.GetConfiguration();
|
||||||
|
var request = new
|
||||||
|
{
|
||||||
|
method = "torrent-stop",
|
||||||
|
arguments = new
|
||||||
|
{
|
||||||
|
ids = new[] { id }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await SendRequestAsync<object>(config.Transmission.Url, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<T> SendRequestAsync<T>(string url, object requestData)
|
||||||
|
{
|
||||||
|
var config = _configService.GetConfiguration();
|
||||||
|
var jsonContent = JsonSerializer.Serialize(requestData);
|
||||||
|
var content = new StringContent(jsonContent, Encoding.UTF8, "application/json");
|
||||||
|
|
||||||
|
// Always create a fresh HttpClient to avoid connection issues
|
||||||
|
using var httpClient = new HttpClient(new HttpClientHandler
|
||||||
|
{
|
||||||
|
ServerCertificateCustomValidationCallback = (sender, cert, chain, sslPolicyErrors) => true
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ensure we have a valid URL by reconstructing it explicitly
|
||||||
|
var protocol = config.Transmission.UseHttps ? "https" : "http";
|
||||||
|
var serverUrl = $"{protocol}://{config.Transmission.Host}:{config.Transmission.Port}/transmission/rpc";
|
||||||
|
|
||||||
|
var request = new HttpRequestMessage(HttpMethod.Post, serverUrl)
|
||||||
|
{
|
||||||
|
Content = content
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add session ID if we have one
|
||||||
|
if (!string.IsNullOrEmpty(_sessionId))
|
||||||
|
{
|
||||||
|
request.Headers.Add("X-Transmission-Session-Id", _sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add authentication if provided
|
||||||
|
if (!string.IsNullOrEmpty(config.Transmission.Username) && !string.IsNullOrEmpty(config.Transmission.Password))
|
||||||
|
{
|
||||||
|
var credentials = Convert.ToBase64String(Encoding.ASCII.GetBytes($"{config.Transmission.Username}:{config.Transmission.Password}"));
|
||||||
|
request.Headers.Authorization = new AuthenticationHeaderValue("Basic", credentials);
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Set timeout to avoid hanging indefinitely on connection issues
|
||||||
|
httpClient.Timeout = TimeSpan.FromSeconds(10);
|
||||||
|
|
||||||
|
_logger.LogInformation($"Connecting to Transmission at {serverUrl} with auth: {!string.IsNullOrEmpty(config.Transmission.Username)}");
|
||||||
|
var response = await httpClient.SendAsync(request);
|
||||||
|
|
||||||
|
// Check if we need a new session ID
|
||||||
|
if (response.StatusCode == System.Net.HttpStatusCode.Conflict)
|
||||||
|
{
|
||||||
|
if (response.Headers.TryGetValues("X-Transmission-Session-Id", out var sessionIds))
|
||||||
|
{
|
||||||
|
_sessionId = sessionIds.FirstOrDefault() ?? string.Empty;
|
||||||
|
_logger.LogInformation($"Got new Transmission session ID: {_sessionId}");
|
||||||
|
|
||||||
|
// Retry request with new session ID
|
||||||
|
return await SendRequestAsync<T>(url, requestData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
var resultContent = await response.Content.ReadAsStringAsync();
|
||||||
|
_logger.LogInformation($"Received successful response from Transmission: {resultContent.Substring(0, Math.Min(resultContent.Length, 500))}");
|
||||||
|
|
||||||
|
// Configure JSON deserializer to be case insensitive
|
||||||
|
var options = new JsonSerializerOptions
|
||||||
|
{
|
||||||
|
PropertyNameCaseInsensitive = true
|
||||||
|
};
|
||||||
|
|
||||||
|
return JsonSerializer.Deserialize<T>(resultContent, options);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, $"Error communicating with Transmission at {serverUrl}: {ex.Message}");
|
||||||
|
throw new Exception($"Failed to connect to Transmission at {config.Transmission.Host}:{config.Transmission.Port}. Error: {ex.Message}", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private string GetStatusText(int status)
|
||||||
|
{
|
||||||
|
return status switch
|
||||||
|
{
|
||||||
|
0 => "Stopped",
|
||||||
|
1 => "Queued",
|
||||||
|
2 => "Verifying",
|
||||||
|
3 => "Downloading",
|
||||||
|
4 => "Seeding",
|
||||||
|
5 => "Queued",
|
||||||
|
6 => "Checking",
|
||||||
|
_ => "Unknown"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transmission response classes with proper JSON attribute names
|
||||||
|
private class TorrentGetResponse
|
||||||
|
{
|
||||||
|
[System.Text.Json.Serialization.JsonPropertyName("arguments")]
|
||||||
|
public TorrentGetArguments Arguments { get; set; }
|
||||||
|
|
||||||
|
[System.Text.Json.Serialization.JsonPropertyName("result")]
|
||||||
|
public string Result { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private class TorrentGetArguments
|
||||||
|
{
|
||||||
|
[System.Text.Json.Serialization.JsonPropertyName("torrents")]
|
||||||
|
public List<TransmissionTorrent> Torrents { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private class TransmissionTorrent
|
||||||
|
{
|
||||||
|
[System.Text.Json.Serialization.JsonPropertyName("id")]
|
||||||
|
public int Id { get; set; }
|
||||||
|
|
||||||
|
[System.Text.Json.Serialization.JsonPropertyName("name")]
|
||||||
|
public string Name { get; set; }
|
||||||
|
|
||||||
|
[System.Text.Json.Serialization.JsonPropertyName("status")]
|
||||||
|
public int Status { get; set; }
|
||||||
|
|
||||||
|
[System.Text.Json.Serialization.JsonPropertyName("percentDone")]
|
||||||
|
public double PercentDone { get; set; }
|
||||||
|
|
||||||
|
[System.Text.Json.Serialization.JsonPropertyName("totalSize")]
|
||||||
|
public long TotalSize { get; set; }
|
||||||
|
|
||||||
|
[System.Text.Json.Serialization.JsonPropertyName("downloadDir")]
|
||||||
|
public string DownloadDir { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private class TorrentAddResponse
|
||||||
|
{
|
||||||
|
[System.Text.Json.Serialization.JsonPropertyName("arguments")]
|
||||||
|
public TorrentAddArguments Arguments { get; set; }
|
||||||
|
|
||||||
|
[System.Text.Json.Serialization.JsonPropertyName("result")]
|
||||||
|
public string Result { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private class TorrentAddArguments
|
||||||
|
{
|
||||||
|
[System.Text.Json.Serialization.JsonPropertyName("torrent-added")]
|
||||||
|
public TorrentAddInfo TorrentAdded { get; set; }
|
||||||
|
|
||||||
|
[System.Text.Json.Serialization.JsonPropertyName("torrent-duplicate")]
|
||||||
|
public TorrentAddInfo TorrentDuplicate { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private class TorrentAddInfo
|
||||||
|
{
|
||||||
|
[System.Text.Json.Serialization.JsonPropertyName("id")]
|
||||||
|
public int Id { get; set; }
|
||||||
|
|
||||||
|
[System.Text.Json.Serialization.JsonPropertyName("name")]
|
||||||
|
public string Name { get; set; }
|
||||||
|
|
||||||
|
[System.Text.Json.Serialization.JsonPropertyName("hashString")]
|
||||||
|
public string HashString { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
171
src/Web/wwwroot/css/styles.css
Normal file
171
src/Web/wwwroot/css/styles.css
Normal file
@ -0,0 +1,171 @@
|
|||||||
|
:root {
|
||||||
|
--primary-color: #0d6efd;
|
||||||
|
--secondary-color: #6c757d;
|
||||||
|
--dark-color: #212529;
|
||||||
|
--light-color: #f8f9fa;
|
||||||
|
--success-color: #198754;
|
||||||
|
--danger-color: #dc3545;
|
||||||
|
--warning-color: #ffc107;
|
||||||
|
--info-color: #0dcaf0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
padding-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-content {
|
||||||
|
animation: fadeIn 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
background-color: var(--light-color);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress {
|
||||||
|
height: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
padding: 0.35em 0.65em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-downloading {
|
||||||
|
background-color: var(--info-color);
|
||||||
|
color: var(--dark-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-seeding {
|
||||||
|
background-color: var(--success-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-stopped {
|
||||||
|
background-color: var(--secondary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-checking {
|
||||||
|
background-color: var(--warning-color);
|
||||||
|
color: var(--dark-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-queued {
|
||||||
|
background-color: var(--secondary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon {
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-item {
|
||||||
|
border-left: 3px solid transparent;
|
||||||
|
padding: 10px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-item:hover {
|
||||||
|
background-color: #e9ecef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-item.matched {
|
||||||
|
border-left-color: var(--success-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-item.downloaded {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-item-title {
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-item-date {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--secondary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-item-buttons {
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.torrent-item {
|
||||||
|
margin-bottom: 15px;
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.torrent-item-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.torrent-item-title {
|
||||||
|
font-weight: 500;
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.torrent-item-progress {
|
||||||
|
margin: 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.torrent-item-details {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--secondary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.torrent-item-buttons {
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive tweaks */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.container {
|
||||||
|
padding-left: 10px;
|
||||||
|
padding-right: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-body {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.torrent-item-header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.torrent-item-buttons {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.torrent-item-buttons .btn {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
}
|
283
src/Web/wwwroot/index.html
Normal file
283
src/Web/wwwroot/index.html
Normal file
@ -0,0 +1,283 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Transmission RSS Manager</title>
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css">
|
||||||
|
<link rel="stylesheet" href="css/styles.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
|
||||||
|
<div class="container">
|
||||||
|
<a class="navbar-brand" href="#">Transmission RSS Manager</a>
|
||||||
|
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
||||||
|
<span class="navbar-toggler-icon"></span>
|
||||||
|
</button>
|
||||||
|
<div class="collapse navbar-collapse" id="navbarNav">
|
||||||
|
<ul class="navbar-nav">
|
||||||
|
<li class="nav-item"><a class="nav-link" href="#" data-page="dashboard">Dashboard</a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link" href="#" data-page="feeds">RSS Feeds</a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link" href="#" data-page="torrents">Torrents</a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link" href="#" data-page="settings">Settings</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="container mt-4">
|
||||||
|
<div id="page-dashboard" class="page-content">
|
||||||
|
<h2>Dashboard</h2>
|
||||||
|
<div class="row mt-4">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">System Status</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div id="system-status">Loading...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">Recent Matches</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div id="recent-matches">Loading...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row mt-4">
|
||||||
|
<div class="col-md-12">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">Active Torrents</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div id="active-torrents">Loading...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="page-feeds" class="page-content d-none">
|
||||||
|
<h2>RSS Feeds</h2>
|
||||||
|
<div class="mb-3">
|
||||||
|
<button id="btn-add-feed" class="btn btn-primary">Add Feed</button>
|
||||||
|
<button id="btn-refresh-feeds" class="btn btn-secondary">Refresh Feeds</button>
|
||||||
|
</div>
|
||||||
|
<div id="feeds-list">Loading...</div>
|
||||||
|
|
||||||
|
<div class="mt-4">
|
||||||
|
<h3>Feed Items</h3>
|
||||||
|
<ul class="nav nav-tabs" id="feedTabs">
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link active" data-bs-toggle="tab" href="#all-items">All Items</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" data-bs-toggle="tab" href="#matched-items">Matched Items</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<div class="tab-content mt-2">
|
||||||
|
<div class="tab-pane fade show active" id="all-items">
|
||||||
|
<div id="all-items-list">Loading...</div>
|
||||||
|
</div>
|
||||||
|
<div class="tab-pane fade" id="matched-items">
|
||||||
|
<div id="matched-items-list">Loading...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="page-torrents" class="page-content d-none">
|
||||||
|
<h2>Torrents</h2>
|
||||||
|
<div class="mb-3">
|
||||||
|
<button id="btn-add-torrent" class="btn btn-primary">Add Torrent</button>
|
||||||
|
<button id="btn-refresh-torrents" class="btn btn-secondary">Refresh Torrents</button>
|
||||||
|
</div>
|
||||||
|
<div id="torrents-list">Loading...</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="page-settings" class="page-content d-none">
|
||||||
|
<h2>Settings</h2>
|
||||||
|
<form id="settings-form">
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">Transmission Settings</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="transmission-host" class="form-label">Host</label>
|
||||||
|
<input type="text" class="form-control" id="transmission-host" name="transmission.host">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="transmission-port" class="form-label">Port</label>
|
||||||
|
<input type="number" class="form-control" id="transmission-port" name="transmission.port">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-12">
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="form-check form-switch">
|
||||||
|
<input class="form-check-input" type="checkbox" id="transmission-use-https" name="transmission.useHttps">
|
||||||
|
<label class="form-check-label" for="transmission-use-https">Use HTTPS</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="transmission-username" class="form-label">Username</label>
|
||||||
|
<input type="text" class="form-control" id="transmission-username" name="transmission.username">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="transmission-password" class="form-label">Password</label>
|
||||||
|
<input type="password" class="form-control" id="transmission-password" name="transmission.password">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">RSS Settings</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="form-check form-switch">
|
||||||
|
<input class="form-check-input" type="checkbox" id="auto-download-enabled" name="autoDownloadEnabled">
|
||||||
|
<label class="form-check-label" for="auto-download-enabled">Enable Auto Download</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="check-interval" class="form-label">Check Interval (minutes)</label>
|
||||||
|
<input type="number" class="form-control" id="check-interval" name="checkIntervalMinutes">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">Directories</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="download-directory" class="form-label">Download Directory</label>
|
||||||
|
<input type="text" class="form-control" id="download-directory" name="downloadDirectory">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="media-library" class="form-label">Media Library Path</label>
|
||||||
|
<input type="text" class="form-control" id="media-library" name="mediaLibraryPath">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">Post Processing</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="form-check form-switch">
|
||||||
|
<input class="form-check-input" type="checkbox" id="post-processing-enabled" name="postProcessing.enabled">
|
||||||
|
<label class="form-check-label" for="post-processing-enabled">Enable Post Processing</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="form-check form-switch">
|
||||||
|
<input class="form-check-input" type="checkbox" id="extract-archives" name="postProcessing.extractArchives">
|
||||||
|
<label class="form-check-label" for="extract-archives">Extract Archives</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="form-check form-switch">
|
||||||
|
<input class="form-check-input" type="checkbox" id="organize-media" name="postProcessing.organizeMedia">
|
||||||
|
<label class="form-check-label" for="organize-media">Organize Media</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="minimum-seed-ratio" class="form-label">Minimum Seed Ratio</label>
|
||||||
|
<input type="number" class="form-control" id="minimum-seed-ratio" name="postProcessing.minimumSeedRatio">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="media-extensions" class="form-label">Media Extensions (comma separated)</label>
|
||||||
|
<input type="text" class="form-control" id="media-extensions" name="mediaExtensions">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-primary">Save Settings</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modals -->
|
||||||
|
<div class="modal fade" id="add-feed-modal" tabindex="-1">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">Add RSS Feed</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<form id="add-feed-form">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="feed-name" class="form-label">Name</label>
|
||||||
|
<input type="text" class="form-control" id="feed-name" required>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="feed-url" class="form-label">URL</label>
|
||||||
|
<input type="url" class="form-control" id="feed-url" required>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="feed-rules" class="form-label">Match Rules (one per line)</label>
|
||||||
|
<textarea class="form-control" id="feed-rules" rows="5"></textarea>
|
||||||
|
<div class="form-text">Use regular expressions to match feed items.</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="form-check form-switch">
|
||||||
|
<input class="form-check-input" type="checkbox" id="feed-auto-download">
|
||||||
|
<label class="form-check-label" for="feed-auto-download">Auto Download</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||||
|
<button type="button" class="btn btn-primary" id="save-feed-btn">Add Feed</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal fade" id="add-torrent-modal" tabindex="-1">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">Add Torrent</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<form id="add-torrent-form">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="torrent-url" class="form-label">Torrent URL or Magnet Link</label>
|
||||||
|
<input type="text" class="form-control" id="torrent-url" required>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="torrent-download-dir" class="form-label">Download Directory (optional)</label>
|
||||||
|
<input type="text" class="form-control" id="torrent-download-dir">
|
||||||
|
<div class="form-text">Leave empty to use default download directory.</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||||
|
<button type="button" class="btn btn-primary" id="save-torrent-btn">Add Torrent</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
<script src="js/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
916
src/Web/wwwroot/js/app.js
Normal file
916
src/Web/wwwroot/js/app.js
Normal file
@ -0,0 +1,916 @@
|
|||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
// Initialize navigation
|
||||||
|
initNavigation();
|
||||||
|
|
||||||
|
// Initialize event listeners
|
||||||
|
initEventListeners();
|
||||||
|
|
||||||
|
// Load initial dashboard data
|
||||||
|
loadDashboardData();
|
||||||
|
|
||||||
|
// Initialize Bootstrap tooltips
|
||||||
|
const tooltips = document.querySelectorAll('[data-bs-toggle="tooltip"]');
|
||||||
|
tooltips.forEach(tooltip => new bootstrap.Tooltip(tooltip));
|
||||||
|
});
|
||||||
|
|
||||||
|
function initNavigation() {
|
||||||
|
const navLinks = document.querySelectorAll('.navbar-nav .nav-link');
|
||||||
|
navLinks.forEach(link => {
|
||||||
|
link.addEventListener('click', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const page = this.getAttribute('data-page');
|
||||||
|
showPage(page);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set active page from URL hash or default to dashboard
|
||||||
|
const hash = window.location.hash.substring(1);
|
||||||
|
showPage(hash || 'dashboard');
|
||||||
|
}
|
||||||
|
|
||||||
|
function showPage(page) {
|
||||||
|
// Hide all pages
|
||||||
|
const pages = document.querySelectorAll('.page-content');
|
||||||
|
pages.forEach(p => p.classList.add('d-none'));
|
||||||
|
|
||||||
|
// Remove active class from all nav links
|
||||||
|
const navLinks = document.querySelectorAll('.navbar-nav .nav-link');
|
||||||
|
navLinks.forEach(link => link.classList.remove('active'));
|
||||||
|
|
||||||
|
// Show selected page
|
||||||
|
const selectedPage = document.getElementById(`page-${page}`);
|
||||||
|
if (selectedPage) {
|
||||||
|
selectedPage.classList.remove('d-none');
|
||||||
|
|
||||||
|
// Set active class on nav link
|
||||||
|
const activeNav = document.querySelector(`.nav-link[data-page="${page}"]`);
|
||||||
|
if (activeNav) {
|
||||||
|
activeNav.classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update URL hash
|
||||||
|
window.location.hash = page;
|
||||||
|
|
||||||
|
// Load page-specific data
|
||||||
|
loadPageData(page);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadPageData(page) {
|
||||||
|
switch (page) {
|
||||||
|
case 'dashboard':
|
||||||
|
loadDashboardData();
|
||||||
|
break;
|
||||||
|
case 'feeds':
|
||||||
|
loadFeeds();
|
||||||
|
loadAllItems();
|
||||||
|
loadMatchedItems();
|
||||||
|
break;
|
||||||
|
case 'torrents':
|
||||||
|
loadTorrents();
|
||||||
|
break;
|
||||||
|
case 'settings':
|
||||||
|
loadSettings();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function initEventListeners() {
|
||||||
|
// RSS Feeds page
|
||||||
|
document.getElementById('btn-add-feed').addEventListener('click', showAddFeedModal);
|
||||||
|
document.getElementById('btn-refresh-feeds').addEventListener('click', refreshFeeds);
|
||||||
|
document.getElementById('save-feed-btn').addEventListener('click', saveFeed);
|
||||||
|
|
||||||
|
// Torrents page
|
||||||
|
document.getElementById('btn-add-torrent').addEventListener('click', showAddTorrentModal);
|
||||||
|
document.getElementById('btn-refresh-torrents').addEventListener('click', loadTorrents);
|
||||||
|
document.getElementById('save-torrent-btn').addEventListener('click', saveTorrent);
|
||||||
|
|
||||||
|
// Settings page
|
||||||
|
document.getElementById('settings-form').addEventListener('submit', saveSettings);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dashboard
|
||||||
|
function loadDashboardData() {
|
||||||
|
loadSystemStatus();
|
||||||
|
loadRecentMatches();
|
||||||
|
loadActiveTorrents();
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadSystemStatus() {
|
||||||
|
const statusElement = document.getElementById('system-status');
|
||||||
|
statusElement.innerHTML = '<div class="d-flex justify-content-center"><div class="spinner-border spinner-border-sm text-primary" role="status"></div></div>';
|
||||||
|
|
||||||
|
fetch('/api/config')
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(config => {
|
||||||
|
// Create system status HTML
|
||||||
|
let html = '<ul class="list-group">';
|
||||||
|
html += `<li class="list-group-item d-flex justify-content-between align-items-center">Auto Download <span class="badge ${config.autoDownloadEnabled ? 'bg-success' : 'bg-danger'}">${config.autoDownloadEnabled ? 'Enabled' : 'Disabled'}</span></li>`;
|
||||||
|
html += `<li class="list-group-item d-flex justify-content-between align-items-center">Check Interval <span class="badge bg-primary">${config.checkIntervalMinutes} minutes</span></li>`;
|
||||||
|
html += `<li class="list-group-item d-flex justify-content-between align-items-center">Transmission Connection <span class="badge ${config.transmission.host ? 'bg-success' : 'bg-warning'}">${config.transmission.host ? config.transmission.host + ':' + config.transmission.port : 'Not configured'}</span></li>`;
|
||||||
|
html += `<li class="list-group-item d-flex justify-content-between align-items-center">Post Processing <span class="badge ${config.postProcessing.enabled ? 'bg-success' : 'bg-danger'}">${config.postProcessing.enabled ? 'Enabled' : 'Disabled'}</span></li>`;
|
||||||
|
html += '</ul>';
|
||||||
|
|
||||||
|
statusElement.innerHTML = html;
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error loading system status:', error);
|
||||||
|
statusElement.innerHTML = '<div class="alert alert-danger">Error loading system status</div>';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadRecentMatches() {
|
||||||
|
const matchesElement = document.getElementById('recent-matches');
|
||||||
|
matchesElement.innerHTML = '<div class="d-flex justify-content-center"><div class="spinner-border spinner-border-sm text-primary" role="status"></div></div>';
|
||||||
|
|
||||||
|
fetch('/api/feeds/matched')
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(items => {
|
||||||
|
// Sort by publish date descending and take the first 5
|
||||||
|
const recentItems = items.sort((a, b) => new Date(b.publishDate) - new Date(a.publishDate)).slice(0, 5);
|
||||||
|
|
||||||
|
if (recentItems.length === 0) {
|
||||||
|
matchesElement.innerHTML = '<div class="alert alert-info">No matched items yet</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let html = '<div class="list-group">';
|
||||||
|
recentItems.forEach(item => {
|
||||||
|
const date = new Date(item.publishDate);
|
||||||
|
html += `<a href="${item.link}" target="_blank" class="list-group-item list-group-item-action">
|
||||||
|
<div class="d-flex w-100 justify-content-between">
|
||||||
|
<h6 class="mb-1">${item.title}</h6>
|
||||||
|
<small>${formatDate(date)}</small>
|
||||||
|
</div>
|
||||||
|
<small class="d-flex justify-content-between">
|
||||||
|
<span>Matched rule: ${item.matchedRule}</span>
|
||||||
|
<span class="badge ${item.isDownloaded ? 'bg-success' : 'bg-warning'}">${item.isDownloaded ? 'Downloaded' : 'Not Downloaded'}</span>
|
||||||
|
</small>
|
||||||
|
</a>`;
|
||||||
|
});
|
||||||
|
html += '</div>';
|
||||||
|
|
||||||
|
matchesElement.innerHTML = html;
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error loading recent matches:', error);
|
||||||
|
matchesElement.innerHTML = '<div class="alert alert-danger">Error loading recent matches</div>';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadActiveTorrents() {
|
||||||
|
const torrentsElement = document.getElementById('active-torrents');
|
||||||
|
torrentsElement.innerHTML = '<div class="d-flex justify-content-center"><div class="spinner-border spinner-border-sm text-primary" role="status"></div></div>';
|
||||||
|
|
||||||
|
fetch('/api/torrents')
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(torrents => {
|
||||||
|
console.log('Dashboard torrents:', torrents);
|
||||||
|
|
||||||
|
// Sort by progress ascending and filter for active torrents
|
||||||
|
const activeTorrents = torrents
|
||||||
|
.filter(t => t && t.status && (t.status === 'Downloading' || t.status === 'Seeding'))
|
||||||
|
.sort((a, b) => (a.percentDone || 0) - (b.percentDone || 0));
|
||||||
|
|
||||||
|
if (activeTorrents.length === 0) {
|
||||||
|
torrentsElement.innerHTML = '<div class="alert alert-info">No active torrents</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let html = '<div class="list-group">';
|
||||||
|
activeTorrents.forEach(torrent => {
|
||||||
|
// Handle potential null or undefined values
|
||||||
|
if (!torrent || !torrent.name) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Safely calculate percentages and sizes with error handling
|
||||||
|
let progressPercent = 0;
|
||||||
|
try {
|
||||||
|
progressPercent = Math.round((torrent.percentDone || 0) * 100);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Error calculating progress percent:', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
let sizeInGB = '0.00';
|
||||||
|
try {
|
||||||
|
if (torrent.totalSize && torrent.totalSize > 0) {
|
||||||
|
sizeInGB = (torrent.totalSize / 1073741824).toFixed(2);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Error calculating size in GB:', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
const torrentStatus = torrent.status || 'Unknown';
|
||||||
|
const statusClass = torrentStatus.toLowerCase().replace(/\s+/g, '-');
|
||||||
|
|
||||||
|
html += `<div class="list-group-item">
|
||||||
|
<div class="d-flex w-100 justify-content-between">
|
||||||
|
<h6 class="mb-1">${torrent.name}</h6>
|
||||||
|
<span class="badge bg-${statusClass === 'seeding' ? 'success' : 'primary'}">${torrentStatus}</span>
|
||||||
|
</div>
|
||||||
|
<div class="progress mt-2">
|
||||||
|
<div class="progress-bar ${torrentStatus === 'Seeding' ? 'bg-success' : 'bg-primary'}" role="progressbar" style="width: ${progressPercent}%">${progressPercent}%</div>
|
||||||
|
</div>
|
||||||
|
<small class="d-block mt-1 text-muted">Size: ${sizeInGB} GB</small>
|
||||||
|
</div>`;
|
||||||
|
});
|
||||||
|
html += '</div>';
|
||||||
|
|
||||||
|
torrentsElement.innerHTML = html;
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error loading active torrents:', error);
|
||||||
|
torrentsElement.innerHTML = '<div class="alert alert-danger">Error loading active torrents</div>';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// RSS Feeds
|
||||||
|
function loadFeeds() {
|
||||||
|
const feedsElement = document.getElementById('feeds-list');
|
||||||
|
feedsElement.innerHTML = '<div class="d-flex justify-content-center"><div class="spinner-border spinner-border-sm text-primary" role="status"></div></div>';
|
||||||
|
|
||||||
|
fetch('/api/feeds')
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(feeds => {
|
||||||
|
if (feeds.length === 0) {
|
||||||
|
feedsElement.innerHTML = '<div class="alert alert-info">No feeds added yet</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let html = '<div class="list-group">';
|
||||||
|
feeds.forEach(feed => {
|
||||||
|
const lastChecked = feed.lastChecked ? new Date(feed.lastChecked) : null;
|
||||||
|
|
||||||
|
html += `<div class="list-group-item list-group-item-action" data-feed-id="${feed.id}">
|
||||||
|
<div class="d-flex w-100 justify-content-between">
|
||||||
|
<h5 class="mb-1">${feed.name}</h5>
|
||||||
|
<small>${lastChecked ? 'Last checked: ' + formatDate(lastChecked) : 'Never checked'}</small>
|
||||||
|
</div>
|
||||||
|
<p class="mb-1"><a href="${feed.url}" target="_blank">${feed.url}</a></p>
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<small>${feed.rules.length} rules</small>
|
||||||
|
<div>
|
||||||
|
<button class="btn btn-sm btn-outline-primary me-2 btn-edit-feed" data-feed-id="${feed.id}">Edit</button>
|
||||||
|
<button class="btn btn-sm btn-outline-danger btn-delete-feed" data-feed-id="${feed.id}">Delete</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
});
|
||||||
|
html += '</div>';
|
||||||
|
|
||||||
|
feedsElement.innerHTML = html;
|
||||||
|
|
||||||
|
// Add event listeners
|
||||||
|
document.querySelectorAll('.btn-edit-feed').forEach(btn => {
|
||||||
|
btn.addEventListener('click', function() {
|
||||||
|
const feedId = this.getAttribute('data-feed-id');
|
||||||
|
editFeed(feedId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('.btn-delete-feed').forEach(btn => {
|
||||||
|
btn.addEventListener('click', function() {
|
||||||
|
const feedId = this.getAttribute('data-feed-id');
|
||||||
|
deleteFeed(feedId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error loading feeds:', error);
|
||||||
|
feedsElement.innerHTML = '<div class="alert alert-danger">Error loading feeds</div>';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadAllItems() {
|
||||||
|
const itemsElement = document.getElementById('all-items-list');
|
||||||
|
itemsElement.innerHTML = '<div class="d-flex justify-content-center"><div class="spinner-border spinner-border-sm text-primary" role="status"></div></div>';
|
||||||
|
|
||||||
|
fetch('/api/feeds/items')
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(items => {
|
||||||
|
if (items.length === 0) {
|
||||||
|
itemsElement.innerHTML = '<div class="alert alert-info">No feed items yet</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let html = '<div class="feed-items-container">';
|
||||||
|
items.forEach(item => {
|
||||||
|
const date = new Date(item.publishDate);
|
||||||
|
const classes = `feed-item ${item.isMatched ? 'matched' : ''} ${item.isDownloaded ? 'downloaded' : ''}`;
|
||||||
|
|
||||||
|
html += `<div class="${classes}" data-item-id="${item.id}">
|
||||||
|
<div class="feed-item-title"><a href="${item.link}" target="_blank">${item.title}</a></div>
|
||||||
|
<div class="feed-item-date">${formatDate(date)}</div>
|
||||||
|
${item.isMatched ? `<div class="text-success small">Matched rule: ${item.matchedRule}</div>` : ''}
|
||||||
|
${item.isDownloaded ? '<div class="text-muted small">Downloaded</div>' : ''}
|
||||||
|
${!item.isDownloaded && item.isMatched ?
|
||||||
|
`<div class="feed-item-buttons">
|
||||||
|
<button class="btn btn-sm btn-primary btn-download-item" data-item-id="${item.id}">Download</button>
|
||||||
|
</div>` : ''
|
||||||
|
}
|
||||||
|
</div>`;
|
||||||
|
});
|
||||||
|
html += '</div>';
|
||||||
|
|
||||||
|
itemsElement.innerHTML = html;
|
||||||
|
|
||||||
|
// Add event listeners
|
||||||
|
document.querySelectorAll('.btn-download-item').forEach(btn => {
|
||||||
|
btn.addEventListener('click', function() {
|
||||||
|
const itemId = this.getAttribute('data-item-id');
|
||||||
|
downloadItem(itemId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error loading feed items:', error);
|
||||||
|
itemsElement.innerHTML = '<div class="alert alert-danger">Error loading feed items</div>';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadMatchedItems() {
|
||||||
|
const matchedElement = document.getElementById('matched-items-list');
|
||||||
|
matchedElement.innerHTML = '<div class="d-flex justify-content-center"><div class="spinner-border spinner-border-sm text-primary" role="status"></div></div>';
|
||||||
|
|
||||||
|
fetch('/api/feeds/matched')
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(items => {
|
||||||
|
if (items.length === 0) {
|
||||||
|
matchedElement.innerHTML = '<div class="alert alert-info">No matched items yet</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let html = '<div class="feed-items-container">';
|
||||||
|
items.forEach(item => {
|
||||||
|
const date = new Date(item.publishDate);
|
||||||
|
const classes = `feed-item matched ${item.isDownloaded ? 'downloaded' : ''}`;
|
||||||
|
|
||||||
|
html += `<div class="${classes}" data-item-id="${item.id}">
|
||||||
|
<div class="feed-item-title"><a href="${item.link}" target="_blank">${item.title}</a></div>
|
||||||
|
<div class="feed-item-date">${formatDate(date)}</div>
|
||||||
|
<div class="text-success small">Matched rule: ${item.matchedRule}</div>
|
||||||
|
${item.isDownloaded ? '<div class="text-muted small">Downloaded</div>' : ''}
|
||||||
|
${!item.isDownloaded ?
|
||||||
|
`<div class="feed-item-buttons">
|
||||||
|
<button class="btn btn-sm btn-primary btn-download-matched-item" data-item-id="${item.id}">Download</button>
|
||||||
|
</div>` : ''
|
||||||
|
}
|
||||||
|
</div>`;
|
||||||
|
});
|
||||||
|
html += '</div>';
|
||||||
|
|
||||||
|
matchedElement.innerHTML = html;
|
||||||
|
|
||||||
|
// Add event listeners
|
||||||
|
document.querySelectorAll('.btn-download-matched-item').forEach(btn => {
|
||||||
|
btn.addEventListener('click', function() {
|
||||||
|
const itemId = this.getAttribute('data-item-id');
|
||||||
|
downloadItem(itemId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error loading matched items:', error);
|
||||||
|
matchedElement.innerHTML = '<div class="alert alert-danger">Error loading matched items</div>';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function showAddFeedModal() {
|
||||||
|
// Clear form
|
||||||
|
document.getElementById('feed-name').value = '';
|
||||||
|
document.getElementById('feed-url').value = '';
|
||||||
|
document.getElementById('feed-rules').value = '';
|
||||||
|
document.getElementById('feed-auto-download').checked = false;
|
||||||
|
|
||||||
|
// Update modal title and button text
|
||||||
|
document.querySelector('#add-feed-modal .modal-title').textContent = 'Add RSS Feed';
|
||||||
|
document.getElementById('save-feed-btn').textContent = 'Add Feed';
|
||||||
|
|
||||||
|
// Remove feed ID data attribute
|
||||||
|
document.getElementById('save-feed-btn').removeAttribute('data-feed-id');
|
||||||
|
|
||||||
|
// Show modal
|
||||||
|
const modal = new bootstrap.Modal(document.getElementById('add-feed-modal'));
|
||||||
|
modal.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
function editFeed(feedId) {
|
||||||
|
// Fetch feed data
|
||||||
|
fetch(`/api/feeds`)
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(feeds => {
|
||||||
|
const feed = feeds.find(f => f.id === feedId);
|
||||||
|
if (!feed) {
|
||||||
|
alert('Feed not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Populate form
|
||||||
|
document.getElementById('feed-name').value = feed.name;
|
||||||
|
document.getElementById('feed-url').value = feed.url;
|
||||||
|
document.getElementById('feed-rules').value = feed.rules.join('\n');
|
||||||
|
document.getElementById('feed-auto-download').checked = feed.autoDownload;
|
||||||
|
|
||||||
|
// Update modal title and button text
|
||||||
|
document.querySelector('#add-feed-modal .modal-title').textContent = 'Edit RSS Feed';
|
||||||
|
document.getElementById('save-feed-btn').textContent = 'Save Changes';
|
||||||
|
|
||||||
|
// Add feed ID data attribute
|
||||||
|
document.getElementById('save-feed-btn').setAttribute('data-feed-id', feedId);
|
||||||
|
|
||||||
|
// Show modal
|
||||||
|
const modal = new bootstrap.Modal(document.getElementById('add-feed-modal'));
|
||||||
|
modal.show();
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error fetching feed:', error);
|
||||||
|
alert('Error fetching feed');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveFeed() {
|
||||||
|
const name = document.getElementById('feed-name').value.trim();
|
||||||
|
const url = document.getElementById('feed-url').value.trim();
|
||||||
|
const rulesText = document.getElementById('feed-rules').value.trim();
|
||||||
|
const autoDownload = document.getElementById('feed-auto-download').checked;
|
||||||
|
|
||||||
|
if (!name || !url) {
|
||||||
|
alert('Please enter a name and URL');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse rules (split by new line and remove empty lines)
|
||||||
|
const rules = rulesText.split('\n').filter(rule => rule.trim() !== '');
|
||||||
|
|
||||||
|
const feedId = document.getElementById('save-feed-btn').getAttribute('data-feed-id');
|
||||||
|
const isEditing = !!feedId;
|
||||||
|
|
||||||
|
const feedData = {
|
||||||
|
name: name,
|
||||||
|
url: url,
|
||||||
|
rules: rules,
|
||||||
|
autoDownload: autoDownload
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isEditing) {
|
||||||
|
feedData.id = feedId;
|
||||||
|
|
||||||
|
// Update existing feed
|
||||||
|
fetch(`/api/feeds/${feedId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(feedData)
|
||||||
|
})
|
||||||
|
.then(response => {
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to update feed');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close modal
|
||||||
|
const modal = bootstrap.Modal.getInstance(document.getElementById('add-feed-modal'));
|
||||||
|
modal.hide();
|
||||||
|
|
||||||
|
// Refresh feeds
|
||||||
|
loadFeeds();
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error updating feed:', error);
|
||||||
|
alert('Error updating feed');
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Add new feed
|
||||||
|
fetch('/api/feeds', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(feedData)
|
||||||
|
})
|
||||||
|
.then(response => {
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to add feed');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close modal
|
||||||
|
const modal = bootstrap.Modal.getInstance(document.getElementById('add-feed-modal'));
|
||||||
|
modal.hide();
|
||||||
|
|
||||||
|
// Refresh feeds
|
||||||
|
loadFeeds();
|
||||||
|
// Also refresh items since a new feed might have new items
|
||||||
|
loadAllItems();
|
||||||
|
loadMatchedItems();
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error adding feed:', error);
|
||||||
|
alert('Error adding feed');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteFeed(feedId) {
|
||||||
|
if (!confirm('Are you sure you want to delete this feed?')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch(`/api/feeds/${feedId}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
})
|
||||||
|
.then(response => {
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to delete feed');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh feeds
|
||||||
|
loadFeeds();
|
||||||
|
// Also refresh items since items from this feed should be removed
|
||||||
|
loadAllItems();
|
||||||
|
loadMatchedItems();
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error deleting feed:', error);
|
||||||
|
alert('Error deleting feed');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshFeeds() {
|
||||||
|
const btn = document.getElementById('btn-refresh-feeds');
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.innerHTML = '<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> Refreshing...';
|
||||||
|
|
||||||
|
fetch('/api/feeds/refresh', {
|
||||||
|
method: 'POST'
|
||||||
|
})
|
||||||
|
.then(response => {
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to refresh feeds');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-enable button
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = 'Refresh Feeds';
|
||||||
|
|
||||||
|
// Refresh feed items
|
||||||
|
loadFeeds();
|
||||||
|
loadAllItems();
|
||||||
|
loadMatchedItems();
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error refreshing feeds:', error);
|
||||||
|
alert('Error refreshing feeds');
|
||||||
|
|
||||||
|
// Re-enable button
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = 'Refresh Feeds';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadItem(itemId) {
|
||||||
|
fetch(`/api/feeds/download/${itemId}`, {
|
||||||
|
method: 'POST'
|
||||||
|
})
|
||||||
|
.then(response => {
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to download item');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh items
|
||||||
|
loadAllItems();
|
||||||
|
loadMatchedItems();
|
||||||
|
// Also refresh torrents since a new torrent should be added
|
||||||
|
loadTorrents();
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error downloading item:', error);
|
||||||
|
alert('Error downloading item');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Torrents
|
||||||
|
function loadTorrents() {
|
||||||
|
const torrentsElement = document.getElementById('torrents-list');
|
||||||
|
torrentsElement.innerHTML = '<div class="d-flex justify-content-center"><div class="spinner-border spinner-border-sm text-primary" role="status"></div></div>';
|
||||||
|
|
||||||
|
fetch('/api/torrents')
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(torrents => {
|
||||||
|
console.log('Loaded torrents:', torrents);
|
||||||
|
if (torrents.length === 0) {
|
||||||
|
torrentsElement.innerHTML = '<div class="alert alert-info">No torrents</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let html = '<div class="torrents-container">';
|
||||||
|
torrents.forEach(torrent => {
|
||||||
|
// Handle potential null or undefined values
|
||||||
|
if (!torrent || !torrent.name) {
|
||||||
|
console.warn('Invalid torrent data:', torrent);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Safely calculate percentages and sizes with error handling
|
||||||
|
let progressPercent = 0;
|
||||||
|
try {
|
||||||
|
progressPercent = Math.round((torrent.percentDone || 0) * 100);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Error calculating progress percent:', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
let sizeInGB = '0.00';
|
||||||
|
try {
|
||||||
|
if (torrent.totalSize && torrent.totalSize > 0) {
|
||||||
|
sizeInGB = (torrent.totalSize / 1073741824).toFixed(2);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Error calculating size in GB:', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
const torrentStatus = torrent.status || 'Unknown';
|
||||||
|
const statusClass = torrentStatus.toLowerCase().replace(/\s+/g, '-');
|
||||||
|
|
||||||
|
html += `<div class="torrent-item" data-torrent-id="${torrent.id || 0}">
|
||||||
|
<div class="torrent-item-header">
|
||||||
|
<div class="torrent-item-title">${torrent.name}</div>
|
||||||
|
<span class="badge bg-${statusClass === 'seeding' ? 'success' : statusClass === 'downloading' ? 'primary' : statusClass === 'stopped' ? 'secondary' : 'info'}">${torrentStatus}</span>
|
||||||
|
</div>
|
||||||
|
<div class="torrent-item-progress">
|
||||||
|
<div class="progress">
|
||||||
|
<div class="progress-bar ${torrentStatus === 'Seeding' ? 'bg-success' : 'bg-primary'}" role="progressbar" style="width: ${progressPercent}%">${progressPercent}%</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="torrent-item-details">
|
||||||
|
<span>Size: ${sizeInGB} GB</span>
|
||||||
|
<span>Location: ${torrent.downloadDir || 'Unknown'}</span>
|
||||||
|
</div>
|
||||||
|
<div class="torrent-item-buttons">
|
||||||
|
${torrentStatus === 'Stopped' ?
|
||||||
|
`<button class="btn btn-sm btn-success me-2 btn-start-torrent" data-torrent-id="${torrent.id || 0}">Start</button>` :
|
||||||
|
`<button class="btn btn-sm btn-warning me-2 btn-stop-torrent" data-torrent-id="${torrent.id || 0}">Stop</button>`
|
||||||
|
}
|
||||||
|
<button class="btn btn-sm btn-danger me-2 btn-remove-torrent" data-torrent-id="${torrent.id || 0}">Remove</button>
|
||||||
|
${progressPercent >= 100 ?
|
||||||
|
`<button class="btn btn-sm btn-info btn-process-torrent" data-torrent-id="${torrent.id || 0}">Process</button>` : ''
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
});
|
||||||
|
html += '</div>';
|
||||||
|
|
||||||
|
torrentsElement.innerHTML = html;
|
||||||
|
|
||||||
|
// Add event listeners
|
||||||
|
document.querySelectorAll('.btn-start-torrent').forEach(btn => {
|
||||||
|
btn.addEventListener('click', function() {
|
||||||
|
const torrentId = parseInt(this.getAttribute('data-torrent-id'));
|
||||||
|
startTorrent(torrentId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('.btn-stop-torrent').forEach(btn => {
|
||||||
|
btn.addEventListener('click', function() {
|
||||||
|
const torrentId = parseInt(this.getAttribute('data-torrent-id'));
|
||||||
|
stopTorrent(torrentId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('.btn-remove-torrent').forEach(btn => {
|
||||||
|
btn.addEventListener('click', function() {
|
||||||
|
const torrentId = parseInt(this.getAttribute('data-torrent-id'));
|
||||||
|
removeTorrent(torrentId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('.btn-process-torrent').forEach(btn => {
|
||||||
|
btn.addEventListener('click', function() {
|
||||||
|
const torrentId = parseInt(this.getAttribute('data-torrent-id'));
|
||||||
|
processTorrent(torrentId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error loading torrents:', error);
|
||||||
|
torrentsElement.innerHTML = '<div class="alert alert-danger">Error loading torrents</div>';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function showAddTorrentModal() {
|
||||||
|
// Clear form
|
||||||
|
document.getElementById('torrent-url').value = '';
|
||||||
|
document.getElementById('torrent-download-dir').value = '';
|
||||||
|
|
||||||
|
// Show modal
|
||||||
|
const modal = new bootstrap.Modal(document.getElementById('add-torrent-modal'));
|
||||||
|
modal.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveTorrent() {
|
||||||
|
const url = document.getElementById('torrent-url').value.trim();
|
||||||
|
const downloadDir = document.getElementById('torrent-download-dir').value.trim();
|
||||||
|
|
||||||
|
if (!url) {
|
||||||
|
alert('Please enter a torrent URL or magnet link');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const torrentData = {
|
||||||
|
url: url
|
||||||
|
};
|
||||||
|
|
||||||
|
if (downloadDir) {
|
||||||
|
torrentData.downloadDir = downloadDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch('/api/torrents', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(torrentData)
|
||||||
|
})
|
||||||
|
.then(response => {
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to add torrent');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close modal
|
||||||
|
const modal = bootstrap.Modal.getInstance(document.getElementById('add-torrent-modal'));
|
||||||
|
modal.hide();
|
||||||
|
|
||||||
|
// Refresh torrents
|
||||||
|
loadTorrents();
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error adding torrent:', error);
|
||||||
|
alert('Error adding torrent');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function startTorrent(torrentId) {
|
||||||
|
fetch(`/api/torrents/${torrentId}/start`, {
|
||||||
|
method: 'POST'
|
||||||
|
})
|
||||||
|
.then(response => {
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to start torrent');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh torrents
|
||||||
|
loadTorrents();
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error starting torrent:', error);
|
||||||
|
alert('Error starting torrent');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopTorrent(torrentId) {
|
||||||
|
fetch(`/api/torrents/${torrentId}/stop`, {
|
||||||
|
method: 'POST'
|
||||||
|
})
|
||||||
|
.then(response => {
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to stop torrent');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh torrents
|
||||||
|
loadTorrents();
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error stopping torrent:', error);
|
||||||
|
alert('Error stopping torrent');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeTorrent(torrentId) {
|
||||||
|
if (!confirm('Are you sure you want to remove this torrent? The downloaded files will be kept.')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch(`/api/torrents/${torrentId}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
})
|
||||||
|
.then(response => {
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to remove torrent');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh torrents
|
||||||
|
loadTorrents();
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error removing torrent:', error);
|
||||||
|
alert('Error removing torrent');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function processTorrent(torrentId) {
|
||||||
|
fetch(`/api/torrents/${torrentId}/process`, {
|
||||||
|
method: 'POST'
|
||||||
|
})
|
||||||
|
.then(response => {
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to process torrent');
|
||||||
|
}
|
||||||
|
|
||||||
|
alert('Torrent processing started');
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error processing torrent:', error);
|
||||||
|
alert('Error processing torrent');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Settings
|
||||||
|
function loadSettings() {
|
||||||
|
const form = document.getElementById('settings-form');
|
||||||
|
|
||||||
|
fetch('/api/config')
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(config => {
|
||||||
|
// Transmission settings
|
||||||
|
document.getElementById('transmission-host').value = config.transmission.host;
|
||||||
|
document.getElementById('transmission-port').value = config.transmission.port;
|
||||||
|
document.getElementById('transmission-use-https').checked = config.transmission.useHttps;
|
||||||
|
document.getElementById('transmission-username').value = '';
|
||||||
|
document.getElementById('transmission-password').value = '';
|
||||||
|
|
||||||
|
// RSS settings
|
||||||
|
document.getElementById('auto-download-enabled').checked = config.autoDownloadEnabled;
|
||||||
|
document.getElementById('check-interval').value = config.checkIntervalMinutes;
|
||||||
|
|
||||||
|
// Directory settings
|
||||||
|
document.getElementById('download-directory').value = config.downloadDirectory;
|
||||||
|
document.getElementById('media-library').value = config.mediaLibraryPath;
|
||||||
|
|
||||||
|
// Post processing settings
|
||||||
|
document.getElementById('post-processing-enabled').checked = config.postProcessing.enabled;
|
||||||
|
document.getElementById('extract-archives').checked = config.postProcessing.extractArchives;
|
||||||
|
document.getElementById('organize-media').checked = config.postProcessing.organizeMedia;
|
||||||
|
document.getElementById('minimum-seed-ratio').value = config.postProcessing.minimumSeedRatio;
|
||||||
|
document.getElementById('media-extensions').value = config.postProcessing.mediaExtensions.join(', ');
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error loading settings:', error);
|
||||||
|
alert('Error loading settings');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveSettings(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
transmission: {
|
||||||
|
host: document.getElementById('transmission-host').value.trim(),
|
||||||
|
port: parseInt(document.getElementById('transmission-port').value),
|
||||||
|
useHttps: document.getElementById('transmission-use-https').checked,
|
||||||
|
username: document.getElementById('transmission-username').value.trim(),
|
||||||
|
password: document.getElementById('transmission-password').value.trim()
|
||||||
|
},
|
||||||
|
autoDownloadEnabled: document.getElementById('auto-download-enabled').checked,
|
||||||
|
checkIntervalMinutes: parseInt(document.getElementById('check-interval').value),
|
||||||
|
downloadDirectory: document.getElementById('download-directory').value.trim(),
|
||||||
|
mediaLibraryPath: document.getElementById('media-library').value.trim(),
|
||||||
|
postProcessing: {
|
||||||
|
enabled: document.getElementById('post-processing-enabled').checked,
|
||||||
|
extractArchives: document.getElementById('extract-archives').checked,
|
||||||
|
organizeMedia: document.getElementById('organize-media').checked,
|
||||||
|
minimumSeedRatio: parseInt(document.getElementById('minimum-seed-ratio').value),
|
||||||
|
mediaExtensions: document.getElementById('media-extensions').value.split(',').map(ext => ext.trim())
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetch('/api/config', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(config)
|
||||||
|
})
|
||||||
|
.then(response => {
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to save settings');
|
||||||
|
}
|
||||||
|
|
||||||
|
alert('Settings saved successfully');
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error saving settings:', error);
|
||||||
|
alert('Error saving settings');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper functions
|
||||||
|
function formatDate(date) {
|
||||||
|
if (!date) return 'N/A';
|
||||||
|
|
||||||
|
// Format as "YYYY-MM-DD HH:MM"
|
||||||
|
return `${date.getFullYear()}-${padZero(date.getMonth() + 1)}-${padZero(date.getDate())} ${padZero(date.getHours())}:${padZero(date.getMinutes())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function padZero(num) {
|
||||||
|
return num.toString().padStart(2, '0');
|
||||||
|
}
|
165
working-network-version.sh
Executable file
165
working-network-version.sh
Executable file
@ -0,0 +1,165 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Working network version with proper static file configuration
|
||||||
|
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
RED='\033[0;31m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
# Clean up existing test directory
|
||||||
|
echo -e "${YELLOW}Removing existing test directory...${NC}"
|
||||||
|
rm -rf "$HOME/transmission-rss-test"
|
||||||
|
|
||||||
|
# Create and prepare test directory
|
||||||
|
echo -e "${GREEN}Creating fresh test directory...${NC}"
|
||||||
|
TEST_DIR="$HOME/transmission-rss-test"
|
||||||
|
mkdir -p "$TEST_DIR"
|
||||||
|
mkdir -p "$TEST_DIR/wwwroot/css"
|
||||||
|
mkdir -p "$TEST_DIR/wwwroot/js"
|
||||||
|
|
||||||
|
# Copy web static files directly to wwwroot
|
||||||
|
cp -rv /opt/develop/transmission-rss-manager/TransmissionRssManager/src/Web/wwwroot/* "$TEST_DIR/wwwroot/"
|
||||||
|
|
||||||
|
# Create Program.cs with fixed static file configuration
|
||||||
|
cat > "$TEST_DIR/Program.cs" << 'EOL'
|
||||||
|
using Microsoft.AspNetCore.Builder;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using Microsoft.Extensions.FileProviders;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using TransmissionRssManager.Core;
|
||||||
|
using TransmissionRssManager.Services;
|
||||||
|
|
||||||
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
|
// Add services to the container
|
||||||
|
builder.Services.AddControllers();
|
||||||
|
builder.Services.AddEndpointsApiExplorer();
|
||||||
|
builder.Services.AddSwaggerGen();
|
||||||
|
|
||||||
|
// Add custom services
|
||||||
|
builder.Services.AddSingleton<IConfigService, ConfigService>();
|
||||||
|
builder.Services.AddSingleton<ITransmissionClient, TransmissionClient>();
|
||||||
|
builder.Services.AddSingleton<IRssFeedManager, RssFeedManager>();
|
||||||
|
builder.Services.AddSingleton<IPostProcessor, PostProcessor>();
|
||||||
|
|
||||||
|
// Add background services
|
||||||
|
builder.Services.AddHostedService<RssFeedBackgroundService>();
|
||||||
|
builder.Services.AddHostedService<PostProcessingBackgroundService>();
|
||||||
|
|
||||||
|
var app = builder.Build();
|
||||||
|
|
||||||
|
// Configure middleware
|
||||||
|
if (app.Environment.IsDevelopment())
|
||||||
|
{
|
||||||
|
app.UseSwagger();
|
||||||
|
app.UseSwaggerUI();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configure static files
|
||||||
|
var wwwrootPath = Path.Combine(Directory.GetCurrentDirectory(), "wwwroot");
|
||||||
|
app.UseStaticFiles(new StaticFileOptions
|
||||||
|
{
|
||||||
|
FileProvider = new PhysicalFileProvider(wwwrootPath),
|
||||||
|
RequestPath = ""
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create default route to serve index.html
|
||||||
|
app.MapGet("/", context =>
|
||||||
|
{
|
||||||
|
context.Response.ContentType = "text/html";
|
||||||
|
context.Response.Redirect("/index.html");
|
||||||
|
return System.Threading.Tasks.Task.CompletedTask;
|
||||||
|
});
|
||||||
|
|
||||||
|
app.UseRouting();
|
||||||
|
app.UseAuthorization();
|
||||||
|
app.MapControllers();
|
||||||
|
|
||||||
|
// Log where static files are being served from
|
||||||
|
app.Logger.LogInformation($"Static files are served from: {wwwrootPath}");
|
||||||
|
|
||||||
|
app.Run();
|
||||||
|
EOL
|
||||||
|
|
||||||
|
# Create project file with System.IO
|
||||||
|
cat > "$TEST_DIR/TransmissionRssManager.csproj" << 'EOL'
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net7.0</TargetFramework>
|
||||||
|
<RootNamespace>TransmissionRssManager</RootNamespace>
|
||||||
|
<ImplicitUsings>disable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<Version>1.0.0</Version>
|
||||||
|
<Authors>TransmissionRssManager</Authors>
|
||||||
|
<Description>A C# application to manage RSS feeds and automatically download torrents via Transmission</Description>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="7.0.13" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.FileProviders.Physical" Version="7.0.0" />
|
||||||
|
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" />
|
||||||
|
<PackageReference Include="System.ServiceModel.Syndication" Version="7.0.0" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Hosting" Version="7.0.0" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="7.0.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
|
EOL
|
||||||
|
|
||||||
|
# Create source directories
|
||||||
|
mkdir -p "$TEST_DIR/src/Core"
|
||||||
|
mkdir -p "$TEST_DIR/src/Services"
|
||||||
|
mkdir -p "$TEST_DIR/src/Api/Controllers"
|
||||||
|
|
||||||
|
# Copy core interfaces
|
||||||
|
cp -v /opt/develop/transmission-rss-manager/TransmissionRssManager/src/Core/Interfaces.cs "$TEST_DIR/src/Core/"
|
||||||
|
|
||||||
|
# Copy service implementations
|
||||||
|
cp -v /opt/develop/transmission-rss-manager/TransmissionRssManager/src/Services/ConfigService.cs "$TEST_DIR/src/Services/"
|
||||||
|
cp -v /opt/develop/transmission-rss-manager/TransmissionRssManager/src/Services/TransmissionClient.cs "$TEST_DIR/src/Services/"
|
||||||
|
cp -v /opt/develop/transmission-rss-manager/TransmissionRssManager/src/Services/RssFeedManager.cs "$TEST_DIR/src/Services/"
|
||||||
|
cp -v /opt/develop/transmission-rss-manager/TransmissionRssManager/src/Services/PostProcessor.cs "$TEST_DIR/src/Services/"
|
||||||
|
|
||||||
|
# Copy API controllers
|
||||||
|
cp -v /opt/develop/transmission-rss-manager/TransmissionRssManager/src/Api/Controllers/ConfigController.cs "$TEST_DIR/src/Api/Controllers/"
|
||||||
|
cp -v /opt/develop/transmission-rss-manager/TransmissionRssManager/src/Api/Controllers/FeedsController.cs "$TEST_DIR/src/Api/Controllers/"
|
||||||
|
cp -v /opt/develop/transmission-rss-manager/TransmissionRssManager/src/Api/Controllers/TorrentsController.cs "$TEST_DIR/src/Api/Controllers/"
|
||||||
|
|
||||||
|
# Fix namespaces for Services
|
||||||
|
sed -i 's/using Microsoft.Extensions.Hosting;/using Microsoft.Extensions.Hosting;\nusing System.Linq;/g' "$TEST_DIR/src/Services/RssFeedManager.cs"
|
||||||
|
sed -i 's/using Microsoft.Extensions.Hosting;/using Microsoft.Extensions.Hosting;\nusing System.Linq;/g' "$TEST_DIR/src/Services/PostProcessor.cs"
|
||||||
|
|
||||||
|
# Get server IP
|
||||||
|
SERVER_IP=$(hostname -I | awk '{print $1}')
|
||||||
|
echo -e "${GREEN}Server IP: $SERVER_IP${NC}"
|
||||||
|
|
||||||
|
# Build the application
|
||||||
|
cd "$TEST_DIR"
|
||||||
|
echo -e "${GREEN}Setting up NuGet packages...${NC}"
|
||||||
|
dotnet restore
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo -e "${RED}Failed to restore NuGet packages.${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "${GREEN}Building application...${NC}"
|
||||||
|
dotnet build
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo -e "${RED}Build failed.${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Run with explicit host binding
|
||||||
|
echo -e "${GREEN}Starting application on all interfaces with explicit binding...${NC}"
|
||||||
|
echo -e "${GREEN}The web interface will be available at:${NC}"
|
||||||
|
echo -e "${GREEN}- Local: http://localhost:5000${NC}"
|
||||||
|
echo -e "${GREEN}- Network: http://${SERVER_IP}:5000${NC}"
|
||||||
|
echo -e "${YELLOW}Press Ctrl+C to stop the application${NC}"
|
||||||
|
|
||||||
|
cd "$TEST_DIR"
|
||||||
|
dotnet run --urls="http://0.0.0.0:5000"
|
Loading…
x
Reference in New Issue
Block a user