Initial commit of Transmission RSS Manager with fixed remote connection and post-processing features
This commit is contained in:
@@ -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" }
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user