diff --git a/src/Core/Interfaces.cs b/src/Core/Interfaces.cs index fe1cb70..4d1651e 100644 --- a/src/Core/Interfaces.cs +++ b/src/Core/Interfaces.cs @@ -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 Categories { get; set; } = new List(); + public Dictionary AdditionalMetadata { get; set; } = new Dictionary(); + 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 Rules { get; set; } = new List(); + public List AdvancedRules { get; set; } = new List(); 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 TransmissionInstances { get; set; } = new Dictionary(); public List Feeds { get; set; } = new List(); 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 MediaExtensions { get; set; } = new List { ".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 NotificationEvents { get; set; } = new List + { + "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); } diff --git a/src/Services/RssFeedManager.cs b/src/Services/RssFeedManager.cs index 4d0113a..e5f8921 100644 --- a/src/Services/RssFeedManager.cs +++ b/src/Services/RssFeedManager.cs @@ -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 _logger; private readonly IConfigService _configService; private readonly ITransmissionClient _transmissionClient; + private List _feeds = new List(); + private List _feedItems = new List(); private readonly HttpClient _httpClient; - private readonly string _dataPath; - private List _items = new List(); public RssFeedManager( ILogger 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> GetAllItemsAsync() - { - return Task.FromResult(_items.OrderByDescending(i => i.PublishDate).ToList()); - } - - public Task> GetMatchedItemsAsync() - { - return Task.FromResult(_items.Where(i => i.IsMatched).OrderByDescending(i => i.PublishDate).ToList()); - } - - public Task> GetFeedsAsync() - { + // Load feeds from config var config = _configService.GetConfiguration(); - return Task.FromResult(config.Feeds); + _feeds = config.Feeds; } - + + public async Task> GetAllItemsAsync() + { + return _feedItems; + } + + public async Task> GetMatchedItemsAsync() + { + return _feedItems.Where(item => item.IsMatched).ToList(); + } + + public async Task> GetFeedsAsync() + { + return _feeds; + } + public async Task AddFeedAsync(RssFeed feed) { feed.Id = Guid.NewGuid().ToString(); - feed.LastChecked = DateTime.MinValue; - - var config = _configService.GetConfiguration(); - config.Feeds.Add(feed); - await _configService.SaveConfigurationAsync(config); - - // Initial fetch of feed items - await FetchFeedAsync(feed); + _feeds.Add(feed); + await SaveFeedsToConfigAsync(); } - + public async Task RemoveFeedAsync(string feedId) { - var config = _configService.GetConfiguration(); - var feed = config.Feeds.FirstOrDefault(f => f.Id == feedId); - - if (feed != null) - { - config.Feeds.Remove(feed); - await _configService.SaveConfigurationAsync(config); - - // Remove items from this feed - _items.RemoveAll(i => i.Id.StartsWith(feedId)); - await SaveItemsAsync(); - } + _feeds.RemoveAll(f => f.Id == feedId); + _feedItems.RemoveAll(i => i.FeedId == feedId); + await SaveFeedsToConfigAsync(); } - + public async Task UpdateFeedAsync(RssFeed feed) { - var config = _configService.GetConfiguration(); - var index = config.Feeds.FindIndex(f => f.Id == feed.Id); - - if (index != -1) + var existingFeed = _feeds.FirstOrDefault(f => f.Id == feed.Id); + if (existingFeed != null) { - config.Feeds[index] = feed; - await _configService.SaveConfigurationAsync(config); + int index = _feeds.IndexOf(existingFeed); + _feeds[index] = feed; + await SaveFeedsToConfigAsync(); } } - + + private async Task SaveFeedsToConfigAsync() + { + var config = _configService.GetConfiguration(); + config.Feeds = _feeds; + await _configService.SaveConfigurationAsync(config); + } + public async Task RefreshFeedsAsync(CancellationToken cancellationToken) { - _logger.LogInformation("Starting RSS feed refresh"); - var config = _configService.GetConfiguration(); + _logger.LogInformation("Refreshing RSS feeds"); - foreach (var feed in config.Feeds) + foreach (var feed in _feeds.Where(f => f.Enabled)) { if (cancellationToken.IsCancellationRequested) + break; + + try { - _logger.LogInformation("RSS refresh cancelled"); + await RefreshFeedAsync(feed.Id, cancellationToken); + } + catch (Exception ex) + { + _logger.LogError(ex, $"Error refreshing feed {feed.Name}"); + } + } + } + + public async Task RefreshFeedAsync(string feedId, CancellationToken cancellationToken) + { + var feed = _feeds.FirstOrDefault(f => f.Id == feedId); + if (feed == null) + return; + + try + { + _logger.LogInformation($"Refreshing feed: {feed.Name}"); + + var feedItems = await FetchFeedItemsAsync(feed.Url); + foreach (var item in feedItems) + { + // Add only if we don't already have this item + if (!_feedItems.Any(i => i.Link == item.Link && i.FeedId == feed.Id)) + { + item.FeedId = feed.Id; + _feedItems.Add(item); + + // Apply rules + ApplyRulesToItem(feed, item); + + // Download if matched and auto-download is enabled + if (item.IsMatched && feed.AutoDownload) + { + await DownloadMatchedItemAsync(item); + } + } + } + + // Update last checked time + feed.LastChecked = DateTime.UtcNow; + feed.ErrorCount = 0; + feed.LastErrorMessage = string.Empty; + + // Cleanup old items + CleanupOldItems(feed); + } + catch (Exception ex) + { + _logger.LogError(ex, $"Error refreshing feed {feed.Name}: {ex.Message}"); + feed.ErrorCount++; + feed.LastError = DateTime.UtcNow; + feed.LastErrorMessage = ex.Message; + } + } + + private async Task> FetchFeedItemsAsync(string url) + { + var feedItems = new List(); + + try + { + var response = await _httpClient.GetStringAsync(url); + using (var reader = XmlReader.Create(new StringReader(response))) + { + var feed = SyndicationFeed.Load(reader); + + foreach (var item in feed.Items) + { + var feedItem = new RssFeedItem + { + Id = Guid.NewGuid().ToString(), + Title = item.Title?.Text ?? "", + Description = item.Summary?.Text ?? "", + Link = item.Links.FirstOrDefault()?.Uri.ToString() ?? "", + PublishDate = item.PublishDate.UtcDateTime, + Author = item.Authors.FirstOrDefault()?.Name ?? "" + }; + + // Find torrent link + foreach (var link in item.Links) + { + if (link.MediaType?.Contains("torrent") == true || + link.Uri.ToString().EndsWith(".torrent") || + link.Uri.ToString().StartsWith("magnet:")) + { + feedItem.TorrentUrl = link.Uri.ToString(); + break; + } + } + + // If no torrent link found, use the main link + if (string.IsNullOrEmpty(feedItem.TorrentUrl)) + { + feedItem.TorrentUrl = feedItem.Link; + } + + feedItems.Add(feedItem); + } + } + } + catch (Exception ex) + { + _logger.LogError(ex, $"Error fetching feed: {url}"); + throw; + } + + return feedItems; + } + + private void ApplyRulesToItem(RssFeed feed, RssFeedItem item) + { + item.IsMatched = false; + item.MatchedRule = string.Empty; + + // Apply simple string rules + foreach (var rulePattern in feed.Rules) + { + if (item.Title.Contains(rulePattern, StringComparison.OrdinalIgnoreCase)) + { + item.IsMatched = true; + item.MatchedRule = rulePattern; + item.Category = feed.DefaultCategory; + break; + } + } + + // Apply advanced rules + foreach (var rule in feed.AdvancedRules.Where(r => r.IsEnabled).OrderByDescending(r => r.Priority)) + { + bool isMatch = false; + + if (rule.IsRegex) + { + try + { + var regex = new Regex(rule.Pattern, + rule.IsCaseSensitive ? RegexOptions.None : RegexOptions.IgnoreCase); + isMatch = regex.IsMatch(item.Title); + } + catch (Exception ex) + { + _logger.LogError(ex, $"Invalid regex pattern: {rule.Pattern}"); + } + } + else + { + var comparison = rule.IsCaseSensitive ? + StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase; + isMatch = item.Title.Contains(rule.Pattern, comparison); + } + + if (isMatch) + { + item.IsMatched = true; + item.MatchedRule = rule.Name; + item.Category = rule.Category; + break; + } + } + } + + private async Task DownloadMatchedItemAsync(RssFeedItem item) + { + try + { + var config = _configService.GetConfiguration(); + var downloadDir = config.DownloadDirectory; + + if (string.IsNullOrEmpty(downloadDir)) + { + _logger.LogWarning("Download directory not configured"); return; } - try - { - await FetchFeedAsync(feed); - - // Update last checked time - feed.LastChecked = DateTime.Now; - await _configService.SaveConfigurationAsync(config); - } - catch (Exception ex) - { - _logger.LogError(ex, $"Error refreshing feed: {feed.Name}"); - } + _logger.LogInformation($"Downloading matched item: {item.Title}"); + + // Add torrent to Transmission + int torrentId = await _transmissionClient.AddTorrentAsync(item.TorrentUrl, downloadDir); + + // Update feed item + item.IsDownloaded = true; + item.DownloadDate = DateTime.UtcNow; + item.TorrentId = torrentId; + + _logger.LogInformation($"Added torrent: {item.Title} (ID: {torrentId})"); + } + catch (Exception ex) + { + _logger.LogError(ex, $"Error downloading item: {item.Title}"); + item.RejectionReason = ex.Message; } - - // Check for matches and auto-download if enabled - await ProcessMatchesAsync(); } - + + private void CleanupOldItems(RssFeed feed) + { + if (feed.MaxHistoryItems <= 0) + return; + + var feedItems = _feedItems.Where(i => i.FeedId == feed.Id).ToList(); + + if (feedItems.Count > feed.MaxHistoryItems) + { + // Keep all downloaded items + var downloadedItems = feedItems.Where(i => i.IsDownloaded).ToList(); + + // Keep most recent non-downloaded items up to the limit + var nonDownloadedItems = feedItems.Where(i => !i.IsDownloaded) + .OrderByDescending(i => i.PublishDate) + .Take(feed.MaxHistoryItems - downloadedItems.Count) + .ToList(); + + // Set new list + var itemsToKeep = downloadedItems.Union(nonDownloadedItems).ToList(); + _feedItems.RemoveAll(i => i.FeedId == feed.Id && !itemsToKeep.Contains(i)); + } + } + public async Task MarkItemAsDownloadedAsync(string itemId) { - var item = _items.FirstOrDefault(i => i.Id == itemId); - + var item = _feedItems.FirstOrDefault(i => i.Id == itemId); if (item != null) { item.IsDownloaded = true; - await SaveItemsAsync(); - } - } - - private async Task FetchFeedAsync(RssFeed feed) - { - _logger.LogInformation($"Fetching feed: {feed.Name}"); - - try - { - var response = await _httpClient.GetStringAsync(feed.Url); - using var stringReader = new StringReader(response); - using var xmlReader = XmlReader.Create(stringReader); - - var syndicationFeed = SyndicationFeed.Load(xmlReader); - - foreach (var item in syndicationFeed.Items) - { - var link = item.Links.FirstOrDefault()?.Uri.ToString() ?? ""; - var torrentUrl = ExtractTorrentUrl(link, item.Title.Text); - - // Create a unique ID for this item - var itemId = $"{feed.Id}:{item.Id ?? Guid.NewGuid().ToString()}"; - - // Check if we already have this item - if (_items.Any(i => i.Id == itemId)) - { - continue; - } - - var feedItem = new RssFeedItem - { - Id = itemId, - Title = item.Title.Text, - Link = link, - Description = item.Summary?.Text ?? "", - PublishDate = item.PublishDate.DateTime, - TorrentUrl = torrentUrl, - IsDownloaded = false - }; - - // Check if this item matches any rules - CheckForMatches(feedItem, feed.Rules); - - _items.Add(feedItem); - } - - await SaveItemsAsync(); - } - catch (Exception ex) - { - _logger.LogError(ex, $"Error fetching feed: {feed.Name}"); - throw; - } - } - - private string ExtractTorrentUrl(string link, string title) - { - // Try to find a .torrent link - if (link.EndsWith(".torrent", StringComparison.OrdinalIgnoreCase)) - { - return link; - } - - // If it's a magnet link, return it - if (link.StartsWith("magnet:", StringComparison.OrdinalIgnoreCase)) - { - return link; - } - - // Return the link as is, we'll try to find the torrent on the page - return link; - } - - private void CheckForMatches(RssFeedItem item, List 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(); - return; - } - - try - { - var json = File.ReadAllText(_dataPath); - var items = JsonSerializer.Deserialize>(json); - _items = items ?? new List(); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error loading RSS items"); - _items = new List(); - } - } - - 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 _logger; - private readonly IRssFeedManager _rssFeedManager; - private readonly IConfigService _configService; + private readonly IServiceProvider _serviceProvider; public RssFeedBackgroundService( ILogger logger, - IRssFeedManager rssFeedManager, - IConfigService configService) + IServiceProvider serviceProvider) { _logger = logger; - _rssFeedManager = rssFeedManager; - _configService = configService; + _serviceProvider = serviceProvider; } protected override async Task ExecuteAsync(CancellationToken stoppingToken) @@ -332,18 +347,25 @@ namespace TransmissionRssManager.Services { try { - await _rssFeedManager.RefreshFeedsAsync(stoppingToken); + using (var scope = _serviceProvider.CreateScope()) + { + var rssFeedManager = scope.ServiceProvider.GetRequiredService(); + var configService = scope.ServiceProvider.GetRequiredService(); + + await rssFeedManager.RefreshFeedsAsync(stoppingToken); + + var config = configService.GetConfiguration(); + var interval = TimeSpan.FromMinutes(config.CheckIntervalMinutes); + + _logger.LogInformation($"Next refresh in {interval.TotalMinutes} minutes"); + await Task.Delay(interval, stoppingToken); + } } catch (Exception ex) { _logger.LogError(ex, "Error refreshing RSS feeds"); + await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken); } - - var config = _configService.GetConfiguration(); - var interval = TimeSpan.FromMinutes(config.CheckIntervalMinutes); - - _logger.LogInformation($"Next refresh in {interval.TotalMinutes} minutes"); - await Task.Delay(interval, stoppingToken); } } }