Fix RssFeedManager to remove database dependencies and add string initializers to Core models

This commit is contained in:
MasterDraco 2025-03-12 22:12:42 +00:00
parent 6dff6103d9
commit 619a861546
2 changed files with 383 additions and 273 deletions

View File

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

View File

@ -4,13 +4,13 @@ using System.IO;
using System.Linq;
using System.Net.Http;
using System.ServiceModel.Syndication;
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using System.Xml;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using TransmissionRssManager.Core;
namespace TransmissionRssManager.Services
@ -20,9 +20,9 @@ namespace TransmissionRssManager.Services
private readonly ILogger<RssFeedManager> _logger;
private readonly IConfigService _configService;
private readonly ITransmissionClient _transmissionClient;
private List<RssFeed> _feeds = new List<RssFeed>();
private List<RssFeedItem> _feedItems = new List<RssFeedItem>();
private readonly HttpClient _httpClient;
private readonly string _dataPath;
private List<RssFeedItem> _items = new List<RssFeedItem>();
public RssFeedManager(
ILogger<RssFeedManager> logger,
@ -34,276 +34,294 @@ namespace TransmissionRssManager.Services
_transmissionClient = transmissionClient;
_httpClient = new HttpClient();
// Create data directory
string homeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
string dataDir = Path.Combine(homeDir, ".local", "share", "transmission-rss-manager");
if (!Directory.Exists(dataDir))
{
Directory.CreateDirectory(dataDir);
}
_dataPath = Path.Combine(dataDir, "rss-items.json");
LoadItems();
}
public Task<List<RssFeedItem>> GetAllItemsAsync()
{
return Task.FromResult(_items.OrderByDescending(i => i.PublishDate).ToList());
}
public Task<List<RssFeedItem>> GetMatchedItemsAsync()
{
return Task.FromResult(_items.Where(i => i.IsMatched).OrderByDescending(i => i.PublishDate).ToList());
}
public Task<List<RssFeed>> GetFeedsAsync()
{
// Load feeds from config
var config = _configService.GetConfiguration();
return Task.FromResult(config.Feeds);
_feeds = config.Feeds;
}
public async Task<List<RssFeedItem>> GetAllItemsAsync()
{
return _feedItems;
}
public async Task<List<RssFeedItem>> GetMatchedItemsAsync()
{
return _feedItems.Where(item => item.IsMatched).ToList();
}
public async Task<List<RssFeed>> GetFeedsAsync()
{
return _feeds;
}
public async Task AddFeedAsync(RssFeed feed)
{
feed.Id = Guid.NewGuid().ToString();
feed.LastChecked = DateTime.MinValue;
var config = _configService.GetConfiguration();
config.Feeds.Add(feed);
await _configService.SaveConfigurationAsync(config);
// Initial fetch of feed items
await FetchFeedAsync(feed);
_feeds.Add(feed);
await SaveFeedsToConfigAsync();
}
public async Task RemoveFeedAsync(string feedId)
{
var config = _configService.GetConfiguration();
var feed = config.Feeds.FirstOrDefault(f => f.Id == feedId);
if (feed != null)
{
config.Feeds.Remove(feed);
await _configService.SaveConfigurationAsync(config);
// Remove items from this feed
_items.RemoveAll(i => i.Id.StartsWith(feedId));
await SaveItemsAsync();
}
_feeds.RemoveAll(f => f.Id == feedId);
_feedItems.RemoveAll(i => i.FeedId == feedId);
await SaveFeedsToConfigAsync();
}
public async Task UpdateFeedAsync(RssFeed feed)
{
var config = _configService.GetConfiguration();
var index = config.Feeds.FindIndex(f => f.Id == feed.Id);
if (index != -1)
var existingFeed = _feeds.FirstOrDefault(f => f.Id == feed.Id);
if (existingFeed != null)
{
config.Feeds[index] = feed;
await _configService.SaveConfigurationAsync(config);
int index = _feeds.IndexOf(existingFeed);
_feeds[index] = feed;
await SaveFeedsToConfigAsync();
}
}
private async Task SaveFeedsToConfigAsync()
{
var config = _configService.GetConfiguration();
config.Feeds = _feeds;
await _configService.SaveConfigurationAsync(config);
}
public async Task RefreshFeedsAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("Starting RSS feed refresh");
var config = _configService.GetConfiguration();
_logger.LogInformation("Refreshing RSS feeds");
foreach (var feed in config.Feeds)
foreach (var feed in _feeds.Where(f => f.Enabled))
{
if (cancellationToken.IsCancellationRequested)
{
_logger.LogInformation("RSS refresh cancelled");
return;
}
break;
try
{
await FetchFeedAsync(feed);
// Update last checked time
feed.LastChecked = DateTime.Now;
await _configService.SaveConfigurationAsync(config);
await RefreshFeedAsync(feed.Id, cancellationToken);
}
catch (Exception ex)
{
_logger.LogError(ex, $"Error refreshing feed: {feed.Name}");
_logger.LogError(ex, $"Error refreshing feed {feed.Name}");
}
}
}
// Check for matches and auto-download if enabled
await ProcessMatchesAsync();
public async Task RefreshFeedAsync(string feedId, CancellationToken cancellationToken)
{
var feed = _feeds.FirstOrDefault(f => f.Id == feedId);
if (feed == null)
return;
try
{
_logger.LogInformation($"Refreshing feed: {feed.Name}");
var feedItems = await FetchFeedItemsAsync(feed.Url);
foreach (var item in feedItems)
{
// Add only if we don't already have this item
if (!_feedItems.Any(i => i.Link == item.Link && i.FeedId == feed.Id))
{
item.FeedId = feed.Id;
_feedItems.Add(item);
// Apply rules
ApplyRulesToItem(feed, item);
// Download if matched and auto-download is enabled
if (item.IsMatched && feed.AutoDownload)
{
await DownloadMatchedItemAsync(item);
}
}
}
// Update last checked time
feed.LastChecked = DateTime.UtcNow;
feed.ErrorCount = 0;
feed.LastErrorMessage = string.Empty;
// Cleanup old items
CleanupOldItems(feed);
}
catch (Exception ex)
{
_logger.LogError(ex, $"Error refreshing feed {feed.Name}: {ex.Message}");
feed.ErrorCount++;
feed.LastError = DateTime.UtcNow;
feed.LastErrorMessage = ex.Message;
}
}
private async Task<List<RssFeedItem>> FetchFeedItemsAsync(string url)
{
var feedItems = new List<RssFeedItem>();
try
{
var response = await _httpClient.GetStringAsync(url);
using (var reader = XmlReader.Create(new StringReader(response)))
{
var feed = SyndicationFeed.Load(reader);
foreach (var item in feed.Items)
{
var feedItem = new RssFeedItem
{
Id = Guid.NewGuid().ToString(),
Title = item.Title?.Text ?? "",
Description = item.Summary?.Text ?? "",
Link = item.Links.FirstOrDefault()?.Uri.ToString() ?? "",
PublishDate = item.PublishDate.UtcDateTime,
Author = item.Authors.FirstOrDefault()?.Name ?? ""
};
// Find torrent link
foreach (var link in item.Links)
{
if (link.MediaType?.Contains("torrent") == true ||
link.Uri.ToString().EndsWith(".torrent") ||
link.Uri.ToString().StartsWith("magnet:"))
{
feedItem.TorrentUrl = link.Uri.ToString();
break;
}
}
// If no torrent link found, use the main link
if (string.IsNullOrEmpty(feedItem.TorrentUrl))
{
feedItem.TorrentUrl = feedItem.Link;
}
feedItems.Add(feedItem);
}
}
}
catch (Exception ex)
{
_logger.LogError(ex, $"Error fetching feed: {url}");
throw;
}
return feedItems;
}
private void ApplyRulesToItem(RssFeed feed, RssFeedItem item)
{
item.IsMatched = false;
item.MatchedRule = string.Empty;
// Apply simple string rules
foreach (var rulePattern in feed.Rules)
{
if (item.Title.Contains(rulePattern, StringComparison.OrdinalIgnoreCase))
{
item.IsMatched = true;
item.MatchedRule = rulePattern;
item.Category = feed.DefaultCategory;
break;
}
}
// Apply advanced rules
foreach (var rule in feed.AdvancedRules.Where(r => r.IsEnabled).OrderByDescending(r => r.Priority))
{
bool isMatch = false;
if (rule.IsRegex)
{
try
{
var regex = new Regex(rule.Pattern,
rule.IsCaseSensitive ? RegexOptions.None : RegexOptions.IgnoreCase);
isMatch = regex.IsMatch(item.Title);
}
catch (Exception ex)
{
_logger.LogError(ex, $"Invalid regex pattern: {rule.Pattern}");
}
}
else
{
var comparison = rule.IsCaseSensitive ?
StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase;
isMatch = item.Title.Contains(rule.Pattern, comparison);
}
if (isMatch)
{
item.IsMatched = true;
item.MatchedRule = rule.Name;
item.Category = rule.Category;
break;
}
}
}
private async Task DownloadMatchedItemAsync(RssFeedItem item)
{
try
{
var config = _configService.GetConfiguration();
var downloadDir = config.DownloadDirectory;
if (string.IsNullOrEmpty(downloadDir))
{
_logger.LogWarning("Download directory not configured");
return;
}
_logger.LogInformation($"Downloading matched item: {item.Title}");
// Add torrent to Transmission
int torrentId = await _transmissionClient.AddTorrentAsync(item.TorrentUrl, downloadDir);
// Update feed item
item.IsDownloaded = true;
item.DownloadDate = DateTime.UtcNow;
item.TorrentId = torrentId;
_logger.LogInformation($"Added torrent: {item.Title} (ID: {torrentId})");
}
catch (Exception ex)
{
_logger.LogError(ex, $"Error downloading item: {item.Title}");
item.RejectionReason = ex.Message;
}
}
private void CleanupOldItems(RssFeed feed)
{
if (feed.MaxHistoryItems <= 0)
return;
var feedItems = _feedItems.Where(i => i.FeedId == feed.Id).ToList();
if (feedItems.Count > feed.MaxHistoryItems)
{
// Keep all downloaded items
var downloadedItems = feedItems.Where(i => i.IsDownloaded).ToList();
// Keep most recent non-downloaded items up to the limit
var nonDownloadedItems = feedItems.Where(i => !i.IsDownloaded)
.OrderByDescending(i => i.PublishDate)
.Take(feed.MaxHistoryItems - downloadedItems.Count)
.ToList();
// Set new list
var itemsToKeep = downloadedItems.Union(nonDownloadedItems).ToList();
_feedItems.RemoveAll(i => i.FeedId == feed.Id && !itemsToKeep.Contains(i));
}
}
public async Task MarkItemAsDownloadedAsync(string itemId)
{
var item = _items.FirstOrDefault(i => i.Id == itemId);
var item = _feedItems.FirstOrDefault(i => i.Id == itemId);
if (item != null)
{
item.IsDownloaded = true;
await SaveItemsAsync();
}
}
private async Task FetchFeedAsync(RssFeed feed)
{
_logger.LogInformation($"Fetching feed: {feed.Name}");
try
{
var response = await _httpClient.GetStringAsync(feed.Url);
using var stringReader = new StringReader(response);
using var xmlReader = XmlReader.Create(stringReader);
var syndicationFeed = SyndicationFeed.Load(xmlReader);
foreach (var item in syndicationFeed.Items)
{
var link = item.Links.FirstOrDefault()?.Uri.ToString() ?? "";
var torrentUrl = ExtractTorrentUrl(link, item.Title.Text);
// Create a unique ID for this item
var itemId = $"{feed.Id}:{item.Id ?? Guid.NewGuid().ToString()}";
// Check if we already have this item
if (_items.Any(i => i.Id == itemId))
{
continue;
}
var feedItem = new RssFeedItem
{
Id = itemId,
Title = item.Title.Text,
Link = link,
Description = item.Summary?.Text ?? "",
PublishDate = item.PublishDate.DateTime,
TorrentUrl = torrentUrl,
IsDownloaded = false
};
// Check if this item matches any rules
CheckForMatches(feedItem, feed.Rules);
_items.Add(feedItem);
}
await SaveItemsAsync();
}
catch (Exception ex)
{
_logger.LogError(ex, $"Error fetching feed: {feed.Name}");
throw;
}
}
private string ExtractTorrentUrl(string link, string title)
{
// Try to find a .torrent link
if (link.EndsWith(".torrent", StringComparison.OrdinalIgnoreCase))
{
return link;
}
// If it's a magnet link, return it
if (link.StartsWith("magnet:", StringComparison.OrdinalIgnoreCase))
{
return link;
}
// Return the link as is, we'll try to find the torrent on the page
return link;
}
private void CheckForMatches(RssFeedItem item, List<string> rules)
{
foreach (var rule in rules)
{
try
{
if (Regex.IsMatch(item.Title, rule, RegexOptions.IgnoreCase))
{
item.IsMatched = true;
item.MatchedRule = rule;
break;
}
}
catch (Exception ex)
{
_logger.LogError(ex, $"Invalid regex rule: {rule}");
}
}
}
private async Task ProcessMatchesAsync()
{
var config = _configService.GetConfiguration();
if (!config.AutoDownloadEnabled)
{
return;
}
var matchedItems = _items.Where(i => i.IsMatched && !i.IsDownloaded).ToList();
foreach (var item in matchedItems)
{
try
{
_logger.LogInformation($"Auto-downloading: {item.Title}");
var torrentId = await _transmissionClient.AddTorrentAsync(
item.TorrentUrl,
config.DownloadDirectory);
item.IsDownloaded = true;
}
catch (Exception ex)
{
_logger.LogError(ex, $"Error downloading torrent: {item.Title}");
}
}
await SaveItemsAsync();
}
private void LoadItems()
{
if (!File.Exists(_dataPath))
{
_items = new List<RssFeedItem>();
return;
}
try
{
var json = File.ReadAllText(_dataPath);
var items = JsonSerializer.Deserialize<List<RssFeedItem>>(json);
_items = items ?? new List<RssFeedItem>();
}
catch (Exception ex)
{
_logger.LogError(ex, "Error loading RSS items");
_items = new List<RssFeedItem>();
}
}
private async Task SaveItemsAsync()
{
try
{
var options = new JsonSerializerOptions
{
WriteIndented = true
};
var json = JsonSerializer.Serialize(_items, options);
await File.WriteAllTextAsync(_dataPath, json);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error saving RSS items");
item.DownloadDate = DateTime.UtcNow;
}
}
}
@ -311,17 +329,14 @@ namespace TransmissionRssManager.Services
public class RssFeedBackgroundService : BackgroundService
{
private readonly ILogger<RssFeedBackgroundService> _logger;
private readonly IRssFeedManager _rssFeedManager;
private readonly IConfigService _configService;
private readonly IServiceProvider _serviceProvider;
public RssFeedBackgroundService(
ILogger<RssFeedBackgroundService> logger,
IRssFeedManager rssFeedManager,
IConfigService configService)
IServiceProvider serviceProvider)
{
_logger = logger;
_rssFeedManager = rssFeedManager;
_configService = configService;
_serviceProvider = serviceProvider;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
@ -332,19 +347,26 @@ namespace TransmissionRssManager.Services
{
try
{
await _rssFeedManager.RefreshFeedsAsync(stoppingToken);
}
catch (Exception ex)
using (var scope = _serviceProvider.CreateScope())
{
_logger.LogError(ex, "Error refreshing RSS feeds");
}
var rssFeedManager = scope.ServiceProvider.GetRequiredService<IRssFeedManager>();
var configService = scope.ServiceProvider.GetRequiredService<IConfigService>();
var config = _configService.GetConfiguration();
await rssFeedManager.RefreshFeedsAsync(stoppingToken);
var config = configService.GetConfiguration();
var interval = TimeSpan.FromMinutes(config.CheckIntervalMinutes);
_logger.LogInformation($"Next refresh in {interval.TotalMinutes} minutes");
await Task.Delay(interval, stoppingToken);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error refreshing RSS feeds");
await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken);
}
}
}
}
}