#!/bin/bash # Reset and run the Transmission RSS Manager application 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" # Copy files with fixed namespaces echo -e "${GREEN}Copying application files with fixed namespaces...${NC}" mkdir -p "$TEST_DIR/src/Api" mkdir -p "$TEST_DIR/src/Core" mkdir -p "$TEST_DIR/src/Services" mkdir -p "$TEST_DIR/src/Web/wwwroot/css" mkdir -p "$TEST_DIR/src/Web/wwwroot/js" # Copy Program.cs with fixed namespaces cat > "$TEST_DIR/src/Api/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(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); // Add background services builder.Services.AddHostedService(); builder.Services.AddHostedService(); var app = builder.Build(); // Configure middleware if (app.Environment.IsDevelopment()) { app.UseSwagger(); app.UseSwaggerUI(); } // Configure static files var webRootPath = Path.Combine(AppContext.BaseDirectory, "wwwroot"); app.UseStaticFiles(new StaticFileOptions { FileProvider = new PhysicalFileProvider(webRootPath), 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: {webRootPath}"); app.Run(); EOL # Copy project file cat > "$TEST_DIR/TransmissionRssManager.csproj" << 'EOL' net7.0 TransmissionRssManager disable enable 1.0.0 TransmissionRssManager A C# application to manage RSS feeds and automatically download torrents via Transmission PreserveNewest EOL # Copy Core/Interfaces.cs cp -v /opt/develop/transmission-rss-manager/TransmissionRssManager/src/Core/Interfaces.cs "$TEST_DIR/src/Core/" # Copy RssFeedManager with fixed namespaces cat > "$TEST_DIR/src/Services/RssFeedManager.cs" << 'EOL' 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 _logger; private readonly IConfigService _configService; private readonly ITransmissionClient _transmissionClient; private readonly HttpClient _httpClient; private readonly string _dataPath; private List _items = new List(); public RssFeedManager( ILogger 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> 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() { 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 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"); } } } public class RssFeedBackgroundService : BackgroundService { private readonly ILogger _logger; private readonly IRssFeedManager _rssFeedManager; private readonly IConfigService _configService; public RssFeedBackgroundService( ILogger 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); } } } } EOL # Copy PostProcessor with fixed namespaces cat > "$TEST_DIR/src/Services/PostProcessor.cs" << 'EOL' 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 _logger; private readonly IConfigService _configService; private readonly ITransmissionClient _transmissionClient; public PostProcessor( ILogger 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 _logger; private readonly IPostProcessor _postProcessor; private readonly IConfigService _configService; public PostProcessingBackgroundService( ILogger 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); } } } } EOL # Copy TransmissionClient with fixed namespaces cat > "$TEST_DIR/src/Services/TransmissionClient.cs" << 'EOL' 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 _logger; private readonly IConfigService _configService; private readonly HttpClient _httpClient; private string _sessionId = string.Empty; public TransmissionClient(ILogger logger, IConfigService configService) { _logger = logger; _configService = configService; _httpClient = new HttpClient(); } public async Task> 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(config.Transmission.Url, request); if (response?.Arguments?.Torrents == null) { return new List(); } var torrents = new List(); foreach (var torrent in response.Arguments.Torrents) { 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 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(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(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(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(config.Transmission.Url, request); } private async Task SendRequestAsync(string url, object requestData) { var config = _configService.GetConfiguration(); var jsonContent = JsonSerializer.Serialize(requestData); var content = new StringContent(jsonContent, Encoding.UTF8, "application/json"); var request = new HttpRequestMessage(HttpMethod.Post, url) { 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 { 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"); // Retry request with new session ID return await SendRequestAsync(url, requestData); } } response.EnsureSuccessStatusCode(); var resultContent = await response.Content.ReadAsStringAsync(); return JsonSerializer.Deserialize(resultContent); } catch (Exception ex) { _logger.LogError(ex, "Error communicating with Transmission"); throw; } } 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 private class TorrentGetResponse { public TorrentGetArguments Arguments { get; set; } public string Result { get; set; } } private class TorrentGetArguments { public List Torrents { get; set; } } private class TransmissionTorrent { public int Id { get; set; } public string Name { get; set; } public int Status { get; set; } public double PercentDone { get; set; } public long TotalSize { get; set; } public string DownloadDir { get; set; } } private class TorrentAddResponse { public TorrentAddArguments Arguments { get; set; } public string Result { get; set; } } private class TorrentAddArguments { public TorrentAddInfo TorrentAdded { get; set; } public TorrentAddInfo TorrentDuplicate { get; set; } } private class TorrentAddInfo { public int Id { get; set; } public string Name { get; set; } public string HashString { get; set; } } } } EOL # Copy ConfigService.cs cp -v /opt/develop/transmission-rss-manager/TransmissionRssManager/src/Services/ConfigService.cs "$TEST_DIR/src/Services/" # Copy API Controllers cp -vr /opt/develop/transmission-rss-manager/TransmissionRssManager/src/Api/Controllers "$TEST_DIR/src/Api/" # Copy web content cp -vr /opt/develop/transmission-rss-manager/TransmissionRssManager/src/Web/wwwroot/* "$TEST_DIR/src/Web/wwwroot/" # 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 # Find server's IP address SERVER_IP=$(hostname -I | awk '{print $1}') # Skip running if parameter is passed if [[ "$1" == "no_run" ]]; then return 0 fi # Run the application echo -e "${GREEN}Starting application...${NC}" echo -e "${GREEN}The web interface will be available at:${NC}" echo -e "${GREEN}- Local: http://localhost:5000${NC}" if [[ -f "./Properties/launchSettings.json" && -n "$SERVER_IP" ]]; then echo -e "${GREEN}- Network: http://${SERVER_IP}:5000${NC}" fi echo -e "${YELLOW}Press Ctrl+C to stop the application${NC}" dotnet run