commit 9e544456dbd85be28828b004c08dc4b682077af1 Author: Claude Date: Thu Mar 13 17:16:41 2025 +0000 Initial commit with UI fixes for dark mode This repository contains Transmission RSS Manager with the following changes: - Fixed dark mode navigation tab visibility issue - Improved text contrast in dark mode throughout the app - Created dedicated dark-mode.css for better organization - Enhanced JavaScript for dynamic styling in dark mode - Added complete installation scripts 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8c0c219 --- /dev/null +++ b/.gitignore @@ -0,0 +1,28 @@ +# Build artifacts +bin/net7.0/*.dll +bin/net7.0/*.json +bin/net7.0/TransmissionRssManager +bin/net7.0/TransmissionRssManager.pdb +bin/net7.0/logs/ + +# Runtime logs and files +logs/ +*.log +appsettings.*.json +\!appsettings.json +\!appsettings.Development.json + +# Object files +obj/ +*.dll +*.pdb +*.cache + +# User-specific files +*.user +*.suo + +# IDE files +.vs/ +.idea/ +.vscode/ diff --git a/Migrations/20250312193828_InitialCreate.Designer.cs b/Migrations/20250312193828_InitialCreate.Designer.cs new file mode 100644 index 0000000..ef61358 --- /dev/null +++ b/Migrations/20250312193828_InitialCreate.Designer.cs @@ -0,0 +1,332 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using TransmissionRssManager.Data; + +#nullable disable + +namespace TransmissionRssManager.Migrations +{ + [DbContext(typeof(TorrentManagerContext))] + [Migration("20250312193828_InitialCreate")] + partial class InitialCreate + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "7.0.17") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("TransmissionRssManager.Data.Models.RssFeed", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("LastCheckedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastError") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("RefreshInterval") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Url") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("RssFeeds"); + }); + + modelBuilder.Entity("TransmissionRssManager.Data.Models.RssFeedItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("DiscoveredAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DownloadError") + .HasColumnType("text"); + + b.Property("DownloadedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsDownloaded") + .HasColumnType("boolean"); + + b.Property("Link") + .IsRequired() + .HasColumnType("text"); + + b.Property("MatchedRuleId") + .HasColumnType("integer"); + + b.Property("PublishDate") + .HasColumnType("timestamp with time zone"); + + b.Property("RssFeedId") + .HasColumnType("integer"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property("TorrentId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("MatchedRuleId"); + + b.HasIndex("RssFeedId", "Link") + .IsUnique(); + + b.ToTable("RssFeedItems"); + }); + + modelBuilder.Entity("TransmissionRssManager.Data.Models.RssFeedRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CustomSavePath") + .HasColumnType("text"); + + b.Property("EnablePostProcessing") + .HasColumnType("boolean"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("ExcludePattern") + .HasColumnType("text"); + + b.Property("IncludePattern") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Priority") + .HasColumnType("integer"); + + b.Property("RssFeedId") + .HasColumnType("integer"); + + b.Property("SaveToCustomPath") + .HasColumnType("boolean"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UseRegex") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("RssFeedId"); + + b.ToTable("RssFeedRules"); + }); + + modelBuilder.Entity("TransmissionRssManager.Data.Models.Torrent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AddedOn") + .HasColumnType("timestamp with time zone"); + + b.Property("CompletedOn") + .HasColumnType("timestamp with time zone"); + + b.Property("DownloadDirectory") + .HasColumnType("text"); + + b.Property("ErrorMessage") + .HasColumnType("text"); + + b.Property("Hash") + .IsRequired() + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("PercentDone") + .HasColumnType("double precision"); + + b.Property("PostProcessed") + .HasColumnType("boolean"); + + b.Property("PostProcessedOn") + .HasColumnType("timestamp with time zone"); + + b.Property("RssFeedItemId") + .HasColumnType("integer"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("TotalSize") + .HasColumnType("bigint"); + + b.Property("TransmissionId") + .HasColumnType("integer"); + + b.Property("UploadRatio") + .HasColumnType("double precision"); + + b.HasKey("Id"); + + b.HasIndex("Hash") + .IsUnique(); + + b.HasIndex("RssFeedItemId") + .IsUnique(); + + b.ToTable("Torrents"); + }); + + modelBuilder.Entity("TransmissionRssManager.Data.Models.UserPreference", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Category") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DataType") + .HasColumnType("text"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Key") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("UserPreferences"); + }); + + modelBuilder.Entity("TransmissionRssManager.Data.Models.RssFeedItem", b => + { + b.HasOne("TransmissionRssManager.Data.Models.RssFeedRule", "MatchedRule") + .WithMany("MatchedItems") + .HasForeignKey("MatchedRuleId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("TransmissionRssManager.Data.Models.RssFeed", "RssFeed") + .WithMany("Items") + .HasForeignKey("RssFeedId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("MatchedRule"); + + b.Navigation("RssFeed"); + }); + + modelBuilder.Entity("TransmissionRssManager.Data.Models.RssFeedRule", b => + { + b.HasOne("TransmissionRssManager.Data.Models.RssFeed", "RssFeed") + .WithMany("Rules") + .HasForeignKey("RssFeedId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("RssFeed"); + }); + + modelBuilder.Entity("TransmissionRssManager.Data.Models.Torrent", b => + { + b.HasOne("TransmissionRssManager.Data.Models.RssFeedItem", "RssFeedItem") + .WithOne("Torrent") + .HasForeignKey("TransmissionRssManager.Data.Models.Torrent", "RssFeedItemId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("RssFeedItem"); + }); + + modelBuilder.Entity("TransmissionRssManager.Data.Models.RssFeed", b => + { + b.Navigation("Items"); + + b.Navigation("Rules"); + }); + + modelBuilder.Entity("TransmissionRssManager.Data.Models.RssFeedItem", b => + { + b.Navigation("Torrent"); + }); + + modelBuilder.Entity("TransmissionRssManager.Data.Models.RssFeedRule", b => + { + b.Navigation("MatchedItems"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Migrations/20250312193828_InitialCreate.cs b/Migrations/20250312193828_InitialCreate.cs new file mode 100644 index 0000000..016ddde --- /dev/null +++ b/Migrations/20250312193828_InitialCreate.cs @@ -0,0 +1,205 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace TransmissionRssManager.Migrations +{ + /// + public partial class InitialCreate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "RssFeeds", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Name = table.Column(type: "text", nullable: false), + Url = table.Column(type: "text", nullable: false), + Enabled = table.Column(type: "boolean", nullable: false), + LastCheckedAt = table.Column(type: "timestamp with time zone", nullable: false), + LastError = table.Column(type: "text", nullable: true), + RefreshInterval = table.Column(type: "integer", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_RssFeeds", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "UserPreferences", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Key = table.Column(type: "text", nullable: false), + Value = table.Column(type: "text", nullable: true), + Description = table.Column(type: "text", nullable: true), + Category = table.Column(type: "text", nullable: true), + DataType = table.Column(type: "text", nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_UserPreferences", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "RssFeedRules", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Name = table.Column(type: "text", nullable: false), + RssFeedId = table.Column(type: "integer", nullable: false), + IncludePattern = table.Column(type: "text", nullable: true), + ExcludePattern = table.Column(type: "text", nullable: true), + UseRegex = table.Column(type: "boolean", nullable: false), + Enabled = table.Column(type: "boolean", nullable: false), + SaveToCustomPath = table.Column(type: "boolean", nullable: false), + CustomSavePath = table.Column(type: "text", nullable: true), + EnablePostProcessing = table.Column(type: "boolean", nullable: false), + Priority = table.Column(type: "integer", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_RssFeedRules", x => x.Id); + table.ForeignKey( + name: "FK_RssFeedRules_RssFeeds_RssFeedId", + column: x => x.RssFeedId, + principalTable: "RssFeeds", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "RssFeedItems", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Title = table.Column(type: "text", nullable: false), + Link = table.Column(type: "text", nullable: false), + Description = table.Column(type: "text", nullable: true), + PublishDate = table.Column(type: "timestamp with time zone", nullable: false), + RssFeedId = table.Column(type: "integer", nullable: false), + IsDownloaded = table.Column(type: "boolean", nullable: false), + DiscoveredAt = table.Column(type: "timestamp with time zone", nullable: false), + DownloadedAt = table.Column(type: "timestamp with time zone", nullable: true), + TorrentId = table.Column(type: "integer", nullable: true), + MatchedRuleId = table.Column(type: "integer", nullable: true), + DownloadError = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_RssFeedItems", x => x.Id); + table.ForeignKey( + name: "FK_RssFeedItems_RssFeedRules_MatchedRuleId", + column: x => x.MatchedRuleId, + principalTable: "RssFeedRules", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + table.ForeignKey( + name: "FK_RssFeedItems_RssFeeds_RssFeedId", + column: x => x.RssFeedId, + principalTable: "RssFeeds", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "Torrents", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Name = table.Column(type: "text", nullable: false), + Hash = table.Column(type: "text", nullable: false), + TransmissionId = table.Column(type: "integer", nullable: true), + Status = table.Column(type: "text", nullable: false), + TotalSize = table.Column(type: "bigint", nullable: false), + PercentDone = table.Column(type: "double precision", nullable: false), + UploadRatio = table.Column(type: "double precision", nullable: false), + RssFeedItemId = table.Column(type: "integer", nullable: true), + AddedOn = table.Column(type: "timestamp with time zone", nullable: false), + CompletedOn = table.Column(type: "timestamp with time zone", nullable: true), + PostProcessed = table.Column(type: "boolean", nullable: false), + PostProcessedOn = table.Column(type: "timestamp with time zone", nullable: true), + DownloadDirectory = table.Column(type: "text", nullable: true), + ErrorMessage = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Torrents", x => x.Id); + table.ForeignKey( + name: "FK_Torrents_RssFeedItems_RssFeedItemId", + column: x => x.RssFeedItemId, + principalTable: "RssFeedItems", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + }); + + migrationBuilder.CreateIndex( + name: "IX_RssFeedItems_MatchedRuleId", + table: "RssFeedItems", + column: "MatchedRuleId"); + + migrationBuilder.CreateIndex( + name: "IX_RssFeedItems_RssFeedId_Link", + table: "RssFeedItems", + columns: new[] { "RssFeedId", "Link" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_RssFeedRules_RssFeedId", + table: "RssFeedRules", + column: "RssFeedId"); + + migrationBuilder.CreateIndex( + name: "IX_Torrents_Hash", + table: "Torrents", + column: "Hash", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Torrents_RssFeedItemId", + table: "Torrents", + column: "RssFeedItemId", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_UserPreferences_Key", + table: "UserPreferences", + column: "Key", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Torrents"); + + migrationBuilder.DropTable( + name: "UserPreferences"); + + migrationBuilder.DropTable( + name: "RssFeedItems"); + + migrationBuilder.DropTable( + name: "RssFeedRules"); + + migrationBuilder.DropTable( + name: "RssFeeds"); + } + } +} diff --git a/Migrations/20250312203308_AddUIFeatures.Designer.cs b/Migrations/20250312203308_AddUIFeatures.Designer.cs new file mode 100644 index 0000000..f255f10 --- /dev/null +++ b/Migrations/20250312203308_AddUIFeatures.Designer.cs @@ -0,0 +1,402 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using TransmissionRssManager.Data; + +#nullable disable + +namespace TransmissionRssManager.Migrations +{ + [DbContext(typeof(TorrentManagerContext))] + [Migration("20250312203308_AddUIFeatures")] + partial class AddUIFeatures + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "7.0.17") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("TransmissionRssManager.Data.Models.RssFeed", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DefaultCategory") + .HasColumnType("text"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("ErrorCount") + .HasColumnType("integer"); + + b.Property("LastCheckedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastError") + .HasColumnType("text"); + + b.Property("MaxHistoryItems") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("RefreshInterval") + .HasColumnType("integer"); + + b.Property("Schedule") + .IsRequired() + .HasColumnType("text"); + + b.Property("TransmissionInstanceId") + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Url") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("RssFeeds"); + }); + + modelBuilder.Entity("TransmissionRssManager.Data.Models.RssFeedItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("DiscoveredAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DownloadError") + .HasColumnType("text"); + + b.Property("DownloadedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsDownloaded") + .HasColumnType("boolean"); + + b.Property("Link") + .IsRequired() + .HasColumnType("text"); + + b.Property("MatchedRuleId") + .HasColumnType("integer"); + + b.Property("PublishDate") + .HasColumnType("timestamp with time zone"); + + b.Property("RssFeedId") + .HasColumnType("integer"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property("TorrentId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("MatchedRuleId"); + + b.HasIndex("RssFeedId", "Link") + .IsUnique(); + + b.ToTable("RssFeedItems"); + }); + + modelBuilder.Entity("TransmissionRssManager.Data.Models.RssFeedRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CustomSavePath") + .HasColumnType("text"); + + b.Property("EnablePostProcessing") + .HasColumnType("boolean"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("ExcludePattern") + .HasColumnType("text"); + + b.Property("IncludePattern") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Priority") + .HasColumnType("integer"); + + b.Property("RssFeedId") + .HasColumnType("integer"); + + b.Property("SaveToCustomPath") + .HasColumnType("boolean"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UseRegex") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("RssFeedId"); + + b.ToTable("RssFeedRules"); + }); + + modelBuilder.Entity("TransmissionRssManager.Data.Models.SystemLogEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Context") + .HasColumnType("text"); + + b.Property("Level") + .IsRequired() + .HasColumnType("text"); + + b.Property("Message") + .IsRequired() + .HasColumnType("text"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("SystemLogs"); + }); + + modelBuilder.Entity("TransmissionRssManager.Data.Models.Torrent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AddedOn") + .HasColumnType("timestamp with time zone"); + + b.Property("Category") + .HasColumnType("text"); + + b.Property("CompletedOn") + .HasColumnType("timestamp with time zone"); + + b.Property("DownloadDirectory") + .HasColumnType("text"); + + b.Property("DownloadSpeed") + .HasColumnType("double precision"); + + b.Property("DownloadedEver") + .HasColumnType("bigint"); + + b.Property("ErrorMessage") + .HasColumnType("text"); + + b.Property("HasMetadata") + .HasColumnType("boolean"); + + b.Property("Hash") + .IsRequired() + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("PeersConnected") + .HasColumnType("integer"); + + b.Property("PercentDone") + .HasColumnType("double precision"); + + b.Property("PostProcessed") + .HasColumnType("boolean"); + + b.Property("PostProcessedOn") + .HasColumnType("timestamp with time zone"); + + b.Property("RssFeedItemId") + .HasColumnType("integer"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("TotalSize") + .HasColumnType("bigint"); + + b.Property("TransmissionId") + .HasColumnType("integer"); + + b.Property("TransmissionInstance") + .HasColumnType("text"); + + b.Property("UploadRatio") + .HasColumnType("double precision"); + + b.Property("UploadSpeed") + .HasColumnType("double precision"); + + b.Property("UploadedEver") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("Hash") + .IsUnique(); + + b.HasIndex("RssFeedItemId") + .IsUnique(); + + b.ToTable("Torrents"); + }); + + modelBuilder.Entity("TransmissionRssManager.Data.Models.UserPreference", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Category") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DataType") + .HasColumnType("text"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Key") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("UserPreferences"); + }); + + modelBuilder.Entity("TransmissionRssManager.Data.Models.RssFeedItem", b => + { + b.HasOne("TransmissionRssManager.Data.Models.RssFeedRule", "MatchedRule") + .WithMany("MatchedItems") + .HasForeignKey("MatchedRuleId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("TransmissionRssManager.Data.Models.RssFeed", "RssFeed") + .WithMany("Items") + .HasForeignKey("RssFeedId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("MatchedRule"); + + b.Navigation("RssFeed"); + }); + + modelBuilder.Entity("TransmissionRssManager.Data.Models.RssFeedRule", b => + { + b.HasOne("TransmissionRssManager.Data.Models.RssFeed", "RssFeed") + .WithMany("Rules") + .HasForeignKey("RssFeedId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("RssFeed"); + }); + + modelBuilder.Entity("TransmissionRssManager.Data.Models.Torrent", b => + { + b.HasOne("TransmissionRssManager.Data.Models.RssFeedItem", "RssFeedItem") + .WithOne("Torrent") + .HasForeignKey("TransmissionRssManager.Data.Models.Torrent", "RssFeedItemId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("RssFeedItem"); + }); + + modelBuilder.Entity("TransmissionRssManager.Data.Models.RssFeed", b => + { + b.Navigation("Items"); + + b.Navigation("Rules"); + }); + + modelBuilder.Entity("TransmissionRssManager.Data.Models.RssFeedItem", b => + { + b.Navigation("Torrent"); + }); + + modelBuilder.Entity("TransmissionRssManager.Data.Models.RssFeedRule", b => + { + b.Navigation("MatchedItems"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Migrations/20250312203308_AddUIFeatures.cs b/Migrations/20250312203308_AddUIFeatures.cs new file mode 100644 index 0000000..0e97e99 --- /dev/null +++ b/Migrations/20250312203308_AddUIFeatures.cs @@ -0,0 +1,179 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace TransmissionRssManager.Migrations +{ + /// + public partial class AddUIFeatures : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Category", + table: "Torrents", + type: "text", + nullable: true); + + migrationBuilder.AddColumn( + name: "DownloadSpeed", + table: "Torrents", + type: "double precision", + nullable: false, + defaultValue: 0.0); + + migrationBuilder.AddColumn( + name: "DownloadedEver", + table: "Torrents", + type: "bigint", + nullable: false, + defaultValue: 0L); + + migrationBuilder.AddColumn( + name: "HasMetadata", + table: "Torrents", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "PeersConnected", + table: "Torrents", + type: "integer", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "TransmissionInstance", + table: "Torrents", + type: "text", + nullable: true); + + migrationBuilder.AddColumn( + name: "UploadSpeed", + table: "Torrents", + type: "double precision", + nullable: false, + defaultValue: 0.0); + + migrationBuilder.AddColumn( + name: "UploadedEver", + table: "Torrents", + type: "bigint", + nullable: false, + defaultValue: 0L); + + migrationBuilder.AddColumn( + name: "DefaultCategory", + table: "RssFeeds", + type: "text", + nullable: true); + + migrationBuilder.AddColumn( + name: "ErrorCount", + table: "RssFeeds", + type: "integer", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "MaxHistoryItems", + table: "RssFeeds", + type: "integer", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "Schedule", + table: "RssFeeds", + type: "text", + nullable: false, + defaultValue: ""); + + migrationBuilder.AddColumn( + name: "TransmissionInstanceId", + table: "RssFeeds", + type: "text", + nullable: true); + + migrationBuilder.CreateTable( + name: "SystemLogs", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Timestamp = table.Column(type: "timestamp with time zone", nullable: false), + Level = table.Column(type: "text", nullable: false), + Message = table.Column(type: "text", nullable: false), + Context = table.Column(type: "text", nullable: true), + Properties = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_SystemLogs", x => x.Id); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "SystemLogs"); + + migrationBuilder.DropColumn( + name: "Category", + table: "Torrents"); + + migrationBuilder.DropColumn( + name: "DownloadSpeed", + table: "Torrents"); + + migrationBuilder.DropColumn( + name: "DownloadedEver", + table: "Torrents"); + + migrationBuilder.DropColumn( + name: "HasMetadata", + table: "Torrents"); + + migrationBuilder.DropColumn( + name: "PeersConnected", + table: "Torrents"); + + migrationBuilder.DropColumn( + name: "TransmissionInstance", + table: "Torrents"); + + migrationBuilder.DropColumn( + name: "UploadSpeed", + table: "Torrents"); + + migrationBuilder.DropColumn( + name: "UploadedEver", + table: "Torrents"); + + migrationBuilder.DropColumn( + name: "DefaultCategory", + table: "RssFeeds"); + + migrationBuilder.DropColumn( + name: "ErrorCount", + table: "RssFeeds"); + + migrationBuilder.DropColumn( + name: "MaxHistoryItems", + table: "RssFeeds"); + + migrationBuilder.DropColumn( + name: "Schedule", + table: "RssFeeds"); + + migrationBuilder.DropColumn( + name: "TransmissionInstanceId", + table: "RssFeeds"); + } + } +} diff --git a/Migrations/TorrentManagerContextModelSnapshot.cs b/Migrations/TorrentManagerContextModelSnapshot.cs new file mode 100644 index 0000000..f386415 --- /dev/null +++ b/Migrations/TorrentManagerContextModelSnapshot.cs @@ -0,0 +1,399 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using TransmissionRssManager.Data; + +#nullable disable + +namespace TransmissionRssManager.Migrations +{ + [DbContext(typeof(TorrentManagerContext))] + partial class TorrentManagerContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "7.0.17") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("TransmissionRssManager.Data.Models.RssFeed", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DefaultCategory") + .HasColumnType("text"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("ErrorCount") + .HasColumnType("integer"); + + b.Property("LastCheckedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastError") + .HasColumnType("text"); + + b.Property("MaxHistoryItems") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("RefreshInterval") + .HasColumnType("integer"); + + b.Property("Schedule") + .IsRequired() + .HasColumnType("text"); + + b.Property("TransmissionInstanceId") + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Url") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("RssFeeds"); + }); + + modelBuilder.Entity("TransmissionRssManager.Data.Models.RssFeedItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("DiscoveredAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DownloadError") + .HasColumnType("text"); + + b.Property("DownloadedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsDownloaded") + .HasColumnType("boolean"); + + b.Property("Link") + .IsRequired() + .HasColumnType("text"); + + b.Property("MatchedRuleId") + .HasColumnType("integer"); + + b.Property("PublishDate") + .HasColumnType("timestamp with time zone"); + + b.Property("RssFeedId") + .HasColumnType("integer"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property("TorrentId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("MatchedRuleId"); + + b.HasIndex("RssFeedId", "Link") + .IsUnique(); + + b.ToTable("RssFeedItems"); + }); + + modelBuilder.Entity("TransmissionRssManager.Data.Models.RssFeedRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CustomSavePath") + .HasColumnType("text"); + + b.Property("EnablePostProcessing") + .HasColumnType("boolean"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("ExcludePattern") + .HasColumnType("text"); + + b.Property("IncludePattern") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Priority") + .HasColumnType("integer"); + + b.Property("RssFeedId") + .HasColumnType("integer"); + + b.Property("SaveToCustomPath") + .HasColumnType("boolean"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UseRegex") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("RssFeedId"); + + b.ToTable("RssFeedRules"); + }); + + modelBuilder.Entity("TransmissionRssManager.Data.Models.SystemLogEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Context") + .HasColumnType("text"); + + b.Property("Level") + .IsRequired() + .HasColumnType("text"); + + b.Property("Message") + .IsRequired() + .HasColumnType("text"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("SystemLogs"); + }); + + modelBuilder.Entity("TransmissionRssManager.Data.Models.Torrent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AddedOn") + .HasColumnType("timestamp with time zone"); + + b.Property("Category") + .HasColumnType("text"); + + b.Property("CompletedOn") + .HasColumnType("timestamp with time zone"); + + b.Property("DownloadDirectory") + .HasColumnType("text"); + + b.Property("DownloadSpeed") + .HasColumnType("double precision"); + + b.Property("DownloadedEver") + .HasColumnType("bigint"); + + b.Property("ErrorMessage") + .HasColumnType("text"); + + b.Property("HasMetadata") + .HasColumnType("boolean"); + + b.Property("Hash") + .IsRequired() + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("PeersConnected") + .HasColumnType("integer"); + + b.Property("PercentDone") + .HasColumnType("double precision"); + + b.Property("PostProcessed") + .HasColumnType("boolean"); + + b.Property("PostProcessedOn") + .HasColumnType("timestamp with time zone"); + + b.Property("RssFeedItemId") + .HasColumnType("integer"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("TotalSize") + .HasColumnType("bigint"); + + b.Property("TransmissionId") + .HasColumnType("integer"); + + b.Property("TransmissionInstance") + .HasColumnType("text"); + + b.Property("UploadRatio") + .HasColumnType("double precision"); + + b.Property("UploadSpeed") + .HasColumnType("double precision"); + + b.Property("UploadedEver") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("Hash") + .IsUnique(); + + b.HasIndex("RssFeedItemId") + .IsUnique(); + + b.ToTable("Torrents"); + }); + + modelBuilder.Entity("TransmissionRssManager.Data.Models.UserPreference", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Category") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DataType") + .HasColumnType("text"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Key") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("UserPreferences"); + }); + + modelBuilder.Entity("TransmissionRssManager.Data.Models.RssFeedItem", b => + { + b.HasOne("TransmissionRssManager.Data.Models.RssFeedRule", "MatchedRule") + .WithMany("MatchedItems") + .HasForeignKey("MatchedRuleId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("TransmissionRssManager.Data.Models.RssFeed", "RssFeed") + .WithMany("Items") + .HasForeignKey("RssFeedId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("MatchedRule"); + + b.Navigation("RssFeed"); + }); + + modelBuilder.Entity("TransmissionRssManager.Data.Models.RssFeedRule", b => + { + b.HasOne("TransmissionRssManager.Data.Models.RssFeed", "RssFeed") + .WithMany("Rules") + .HasForeignKey("RssFeedId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("RssFeed"); + }); + + modelBuilder.Entity("TransmissionRssManager.Data.Models.Torrent", b => + { + b.HasOne("TransmissionRssManager.Data.Models.RssFeedItem", "RssFeedItem") + .WithOne("Torrent") + .HasForeignKey("TransmissionRssManager.Data.Models.Torrent", "RssFeedItemId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("RssFeedItem"); + }); + + modelBuilder.Entity("TransmissionRssManager.Data.Models.RssFeed", b => + { + b.Navigation("Items"); + + b.Navigation("Rules"); + }); + + modelBuilder.Entity("TransmissionRssManager.Data.Models.RssFeedItem", b => + { + b.Navigation("Torrent"); + }); + + modelBuilder.Entity("TransmissionRssManager.Data.Models.RssFeedRule", b => + { + b.Navigation("MatchedItems"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..63c6886 --- /dev/null +++ b/README.md @@ -0,0 +1,64 @@ +# Transmission RSS Manager + +A complete solution for managing RSS feeds and automatic downloads through Transmission BitTorrent client. + +## Features + +- Monitor multiple RSS feeds +- Automatically download matching torrents +- Customizable download rules and patterns +- Web-based user interface with dark mode +- Manage multiple Transmission instances +- Post-processing capabilities +- Dashboard with statistics + +## Requirements + +- Linux (tested on Ubuntu 22.04+, Debian 11+) +- .NET 7.0 Runtime +- Transmission BitTorrent client (local or remote) + +## Installation + +1. Extract the package to a directory +2. Run the installation script: + +```bash +chmod +x install.sh +sudo ./install.sh +``` + +3. Access the web interface at: http://localhost:5000 + +## Configuration + +After installation, you can configure the application through the web interface: + +1. Navigate to the Settings tab +2. Set up your Transmission connection details +3. Configure RSS feeds and download rules +4. Adjust auto-download and post-processing settings + +## Running Manually + +If you prefer to run the application manually without installing it as a service: + +```bash +cd /opt/transmission-rss-manager # or your installation directory +./run-app.sh +``` + +## Troubleshooting + +- Check logs in the application's Logs tab for detailed error messages +- Verify Transmission connection settings +- Ensure RSS feed URLs are valid and accessible +- Check file permissions if using post-processing features + +## License + +This software is provided as-is under the MIT License. + +## Acknowledgements + +This package includes fixes for dark mode UI visibility and improved navigation styling. diff --git a/TransmissionRssManager.csproj b/TransmissionRssManager.csproj new file mode 100644 index 0000000..72a1294 --- /dev/null +++ b/TransmissionRssManager.csproj @@ -0,0 +1,9 @@ + + + + net7.0 + enable + enable + + + diff --git a/appsettings.json b/appsettings.json new file mode 100644 index 0000000..4d56694 --- /dev/null +++ b/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/bin/net7.0/appsettings.json.bak b/bin/net7.0/appsettings.json.bak new file mode 100644 index 0000000..5ea740d --- /dev/null +++ b/bin/net7.0/appsettings.json.bak @@ -0,0 +1,49 @@ +{ + "transmission": { + "host": "192.168.5.19", + "port": 9091, + "username": "", + "password": "", + "useHttps": false, + "url": "http://192.168.5.19:9091/transmission/rpc" + }, + "transmissionInstances": {}, + "feeds": [], + "autoDownloadEnabled": false, + "checkIntervalMinutes": 30, + "downloadDirectory": "", + "mediaLibraryPath": "", + "postProcessing": { + "enabled": false, + "extractArchives": true, + "organizeMedia": true, + "minimumSeedRatio": 1, + "mediaExtensions": [ + ".mp4", + ".mkv", + ".avi" + ], + "autoOrganizeByMediaType": true, + "renameFiles": false, + "compressCompletedFiles": false, + "deleteCompletedAfterDays": 0 + }, + "enableDetailedLogging": false, + "userPreferences": { + "enableDarkMode": false, + "autoRefreshUIEnabled": true, + "autoRefreshIntervalSeconds": 30, + "notificationsEnabled": true, + "notificationEvents": [ + "torrent-added", + "torrent-completed", + "torrent-error" + ], + "defaultView": "dashboard", + "confirmBeforeDelete": true, + "maxItemsPerPage": 25, + "dateTimeFormat": "yyyy-MM-dd HH:mm:ss", + "showCompletedTorrents": true, + "keepHistoryDays": 30 + } +} \ No newline at end of file diff --git a/bin/net7.0/src/Api/Controllers/ConfigController.cs b/bin/net7.0/src/Api/Controllers/ConfigController.cs new file mode 100644 index 0000000..7556426 --- /dev/null +++ b/bin/net7.0/src/Api/Controllers/ConfigController.cs @@ -0,0 +1,323 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using TransmissionRssManager.Core; +using TransmissionRssManager.Services; + +namespace TransmissionRssManager.Api.Controllers +{ + [ApiController] + [Route("api/[controller]")] + public class ConfigController : ControllerBase + { + private readonly ILogger _logger; + private readonly IConfigService _configService; + + public ConfigController( + ILogger logger, + IConfigService configService) + { + _logger = logger; + _configService = configService; + } + + [HttpGet] + public IActionResult GetConfig() + { + var config = _configService.GetConfiguration(); + + // Create a sanitized config without sensitive information + var sanitizedConfig = new + { + transmission = new + { + host = config.Transmission.Host, + port = config.Transmission.Port, + useHttps = config.Transmission.UseHttps, + hasCredentials = !string.IsNullOrEmpty(config.Transmission.Username), + username = config.Transmission.Username + }, + transmissionInstances = config.TransmissionInstances?.Select(i => new + { + id = i.Key, + name = i.Value.Host, + host = i.Value.Host, + port = i.Value.Port, + useHttps = i.Value.UseHttps, + hasCredentials = !string.IsNullOrEmpty(i.Value.Username), + username = i.Value.Username + }), + autoDownloadEnabled = config.AutoDownloadEnabled, + checkIntervalMinutes = config.CheckIntervalMinutes, + downloadDirectory = config.DownloadDirectory, + mediaLibraryPath = config.MediaLibraryPath, + postProcessing = config.PostProcessing, + enableDetailedLogging = config.EnableDetailedLogging, + userPreferences = config.UserPreferences + }; + + return Ok(sanitizedConfig); + } + + [HttpGet("defaults")] + public IActionResult GetDefaultConfig() + { + // Return default configuration settings + var defaultConfig = new + { + transmission = new + { + host = "localhost", + port = 9091, + username = "", + useHttps = false + }, + autoDownloadEnabled = true, + checkIntervalMinutes = 30, + downloadDirectory = System.IO.Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Downloads"), + mediaLibraryPath = System.IO.Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Media"), + postProcessing = new + { + enabled = false, + extractArchives = true, + organizeMedia = true, + minimumSeedRatio = 1, + mediaExtensions = new[] { ".mp4", ".mkv", ".avi" }, + autoOrganizeByMediaType = true, + renameFiles = false, + compressCompletedFiles = false, + deleteCompletedAfterDays = 0 + }, + enableDetailedLogging = false, + userPreferences = new + { + enableDarkMode = false, + autoRefreshUIEnabled = true, + autoRefreshIntervalSeconds = 30, + notificationsEnabled = true, + notificationEvents = new[] { "torrent-added", "torrent-completed", "torrent-error" }, + defaultView = "dashboard", + confirmBeforeDelete = true, + maxItemsPerPage = 25, + dateTimeFormat = "yyyy-MM-dd HH:mm:ss", + showCompletedTorrents = true, + keepHistoryDays = 30 + } + }; + + return Ok(defaultConfig); + } + + [HttpPut] + public async Task UpdateConfig([FromBody] AppConfig config) + { + try + { + _logger.LogInformation("Received request to update configuration"); + + if (config == null) + { + _logger.LogError("Received null configuration object"); + return BadRequest("Configuration cannot be null"); + } + + // Log the incoming configuration + _logger.LogInformation($"Received config with transmission host: {config.Transmission?.Host}, " + + $"autoDownload: {config.AutoDownloadEnabled}"); + + var currentConfig = _configService.GetConfiguration(); + + _logger.LogInformation($"Current config has transmission host: {currentConfig.Transmission?.Host}, " + + $"autoDownload: {currentConfig.AutoDownloadEnabled}"); + + // Make deep copy of current config to start with + var updatedConfig = JsonSerializer.Deserialize( + JsonSerializer.Serialize(currentConfig), + new JsonSerializerOptions { PropertyNameCaseInsensitive = true } + ); + + if (updatedConfig == null) + { + _logger.LogError("Failed to create copy of current configuration"); + return StatusCode(500, "Failed to process configuration update"); + } + + // Apply changes from user input + // Transmission settings + if (config.Transmission != null) + { + updatedConfig.Transmission.Host = config.Transmission.Host ?? currentConfig.Transmission.Host; + updatedConfig.Transmission.Port = config.Transmission.Port; + updatedConfig.Transmission.UseHttps = config.Transmission.UseHttps; + updatedConfig.Transmission.Username = config.Transmission.Username ?? currentConfig.Transmission.Username; + + // Only update password if not empty + if (!string.IsNullOrEmpty(config.Transmission.Password)) + { + updatedConfig.Transmission.Password = config.Transmission.Password; + } + } + + // Core application settings + updatedConfig.AutoDownloadEnabled = config.AutoDownloadEnabled; + updatedConfig.CheckIntervalMinutes = config.CheckIntervalMinutes; + updatedConfig.DownloadDirectory = config.DownloadDirectory ?? currentConfig.DownloadDirectory; + updatedConfig.MediaLibraryPath = config.MediaLibraryPath ?? currentConfig.MediaLibraryPath; + updatedConfig.EnableDetailedLogging = config.EnableDetailedLogging; + + // Post processing settings + if (config.PostProcessing != null) + { + updatedConfig.PostProcessing.Enabled = config.PostProcessing.Enabled; + updatedConfig.PostProcessing.ExtractArchives = config.PostProcessing.ExtractArchives; + updatedConfig.PostProcessing.OrganizeMedia = config.PostProcessing.OrganizeMedia; + updatedConfig.PostProcessing.MinimumSeedRatio = config.PostProcessing.MinimumSeedRatio; + + if (config.PostProcessing.MediaExtensions != null && config.PostProcessing.MediaExtensions.Count > 0) + { + updatedConfig.PostProcessing.MediaExtensions = config.PostProcessing.MediaExtensions; + } + } + + // User preferences + if (config.UserPreferences != null) + { + updatedConfig.UserPreferences.EnableDarkMode = config.UserPreferences.EnableDarkMode; + updatedConfig.UserPreferences.AutoRefreshUIEnabled = config.UserPreferences.AutoRefreshUIEnabled; + updatedConfig.UserPreferences.AutoRefreshIntervalSeconds = config.UserPreferences.AutoRefreshIntervalSeconds; + updatedConfig.UserPreferences.NotificationsEnabled = config.UserPreferences.NotificationsEnabled; + } + + // Don't lose existing feeds + // Only update feeds if explicitly provided + if (config.Feeds != null && config.Feeds.Count > 0) + { + updatedConfig.Feeds = config.Feeds; + } + + // Log the config we're about to save (without sensitive data) + var sanitizedConfig = new + { + transmission = new + { + host = updatedConfig.Transmission.Host, + port = updatedConfig.Transmission.Port, + useHttps = updatedConfig.Transmission.UseHttps, + hasUsername = !string.IsNullOrEmpty(updatedConfig.Transmission.Username) + }, + autoDownloadEnabled = updatedConfig.AutoDownloadEnabled, + checkIntervalMinutes = updatedConfig.CheckIntervalMinutes, + downloadDirectory = updatedConfig.DownloadDirectory, + feedCount = updatedConfig.Feeds?.Count ?? 0, + postProcessingEnabled = updatedConfig.PostProcessing?.Enabled ?? false, + userPreferences = updatedConfig.UserPreferences != null + }; + _logger.LogInformation("About to save configuration: {@Config}", sanitizedConfig); + + await _configService.SaveConfigurationAsync(updatedConfig); + _logger.LogInformation("Configuration saved successfully"); + + return Ok(new { success = true, message = "Configuration saved successfully" }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error saving configuration"); + return StatusCode(500, $"Error saving configuration: {ex.Message}"); + } + } + + [HttpPost("backup")] + public IActionResult BackupConfig() + { + try + { + // Get the current config + var config = _configService.GetConfiguration(); + + // Serialize to JSON with indentation + var options = new JsonSerializerOptions { WriteIndented = true }; + var json = JsonSerializer.Serialize(config, options); + + // Create a memory stream from the JSON + var stream = new MemoryStream(Encoding.UTF8.GetBytes(json)); + + // Set the content disposition and type + var fileName = $"transmission-rss-config-backup-{DateTime.Now:yyyy-MM-dd}.json"; + return File(stream, "application/json", fileName); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error creating configuration backup"); + return StatusCode(500, "Error creating configuration backup"); + } + } + + [HttpPost("reset")] + public async Task ResetConfig() + { + try + { + // Create a default config + var defaultConfig = new AppConfig + { + Transmission = new TransmissionConfig + { + Host = "localhost", + Port = 9091, + Username = "", + Password = "", + UseHttps = false + }, + AutoDownloadEnabled = true, + CheckIntervalMinutes = 30, + DownloadDirectory = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Downloads"), + MediaLibraryPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Media"), + PostProcessing = new PostProcessingConfig + { + Enabled = false, + ExtractArchives = true, + OrganizeMedia = true, + MinimumSeedRatio = 1, + MediaExtensions = new List { ".mp4", ".mkv", ".avi", ".mov", ".wmv", ".m4v", ".mpg", ".mpeg", ".flv", ".webm" }, + AutoOrganizeByMediaType = true, + RenameFiles = false, + CompressCompletedFiles = false, + DeleteCompletedAfterDays = 0 + }, + UserPreferences = new TransmissionRssManager.Core.UserPreferences + { + EnableDarkMode = true, + AutoRefreshUIEnabled = true, + AutoRefreshIntervalSeconds = 30, + NotificationsEnabled = true, + NotificationEvents = new List { "torrent-added", "torrent-completed", "torrent-error" }, + DefaultView = "dashboard", + ConfirmBeforeDelete = true, + MaxItemsPerPage = 25, + DateTimeFormat = "yyyy-MM-dd HH:mm:ss", + ShowCompletedTorrents = true, + KeepHistoryDays = 30 + }, + Feeds = new List(), + EnableDetailedLogging = false + }; + + // Save the default config + await _configService.SaveConfigurationAsync(defaultConfig); + + return Ok(new { success = true }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error resetting configuration"); + return StatusCode(500, "Error resetting configuration"); + } + } + } +} \ No newline at end of file diff --git a/bin/net7.0/src/Services/ConfigService.cs b/bin/net7.0/src/Services/ConfigService.cs new file mode 100644 index 0000000..1dbc2e1 --- /dev/null +++ b/bin/net7.0/src/Services/ConfigService.cs @@ -0,0 +1,648 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using TransmissionRssManager.Core; + +namespace TransmissionRssManager.Services +{ + /// + /// Service for managing application configuration + /// File-based implementation that does not use a database + /// + public class ConfigService : IConfigService + { + private readonly ILogger _logger; + private readonly string _configFilePath; + private AppConfig? _cachedConfig; + private readonly object _lockObject = new object(); + + public ConfigService(ILogger logger) + { + _logger = logger; + + // Determine the appropriate config file path + string baseDir = AppContext.BaseDirectory; + string etcConfigPath = "/etc/transmission-rss-manager/appsettings.json"; + string localConfigPath = Path.Combine(baseDir, "appsettings.json"); + + // Check if config exists in /etc (preferred) or in app directory + _configFilePath = File.Exists(etcConfigPath) ? etcConfigPath : localConfigPath; + + _logger.LogInformation($"Using configuration file: {_configFilePath}"); + } + + // Implement the interface methods required by IConfigService + public AppConfig GetConfiguration() + { + // Non-async method required by interface + _logger.LogDebug($"GetConfiguration called, cached config is {(_cachedConfig == null ? "null" : "available")}"); + + if (_cachedConfig != null) + { + _logger.LogDebug("Returning cached configuration"); + return _cachedConfig; + } + + try + { + // Load synchronously since this is a sync method + _logger.LogInformation("Loading configuration from file (sync method)"); + _cachedConfig = LoadConfigFromFileSync(); + + // Log what we loaded + if (_cachedConfig != null) + { + _logger.LogInformation($"Loaded configuration with {_cachedConfig.Feeds?.Count ?? 0} feeds, " + + $"transmission host: {_cachedConfig.Transmission?.Host}, " + + $"autoDownload: {_cachedConfig.AutoDownloadEnabled}"); + } + + return _cachedConfig; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error loading configuration, using default values"); + _cachedConfig = CreateDefaultConfig(); + return _cachedConfig; + } + } + + public async Task SaveConfigurationAsync(AppConfig config) + { + try + { + if (config == null) + { + _logger.LogError("Cannot save null configuration"); + throw new ArgumentNullException(nameof(config)); + } + + _logger.LogInformation($"SaveConfigurationAsync called with config: " + + $"transmission host = {config.Transmission?.Host}, " + + $"autoDownload = {config.AutoDownloadEnabled}"); + + // Create deep copy to ensure we don't have reference issues + string json = JsonSerializer.Serialize(config); + AppConfig configCopy = JsonSerializer.Deserialize(json, + new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + + if (configCopy == null) + { + throw new InvalidOperationException("Failed to create copy of configuration for saving"); + } + + // Ensure all properties are properly set + EnsureCompleteConfig(configCopy); + + // Update cached config + _cachedConfig = configCopy; + + _logger.LogInformation("About to save configuration to file"); + await SaveConfigToFileAsync(configCopy); + _logger.LogInformation("Configuration saved successfully to file"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error saving configuration to file"); + throw; + } + } + + // Additional methods for backward compatibility + public async Task GetConfigAsync() + { + if (_cachedConfig != null) + { + return _cachedConfig; + } + + try + { + _cachedConfig = await LoadConfigFromFileAsync(); + return _cachedConfig; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error loading configuration, using default values"); + return CreateDefaultConfig(); + } + } + + public async Task SaveConfigAsync(AppConfig config) + { + await SaveConfigurationAsync(config); + } + + public async Task GetSettingAsync(string key, string defaultValue = "") + { + var config = await GetConfigAsync(); + + switch (key) + { + case "Transmission.Host": + return config.Transmission.Host ?? defaultValue; + case "Transmission.Port": + return config.Transmission.Port.ToString(); + case "Transmission.Username": + return config.Transmission.Username ?? defaultValue; + case "Transmission.Password": + return config.Transmission.Password ?? defaultValue; + case "Transmission.UseHttps": + return config.Transmission.UseHttps.ToString(); + case "AutoDownloadEnabled": + return config.AutoDownloadEnabled.ToString(); + case "CheckIntervalMinutes": + return config.CheckIntervalMinutes.ToString(); + case "DownloadDirectory": + return config.DownloadDirectory ?? defaultValue; + case "MediaLibraryPath": + return config.MediaLibraryPath ?? defaultValue; + case "PostProcessing.Enabled": + return config.PostProcessing.Enabled.ToString(); + case "PostProcessing.ExtractArchives": + return config.PostProcessing.ExtractArchives.ToString(); + case "PostProcessing.OrganizeMedia": + return config.PostProcessing.OrganizeMedia.ToString(); + case "PostProcessing.MinimumSeedRatio": + return config.PostProcessing.MinimumSeedRatio.ToString(); + case "UserPreferences.EnableDarkMode": + return config.UserPreferences.EnableDarkMode.ToString(); + case "UserPreferences.AutoRefreshUIEnabled": + return config.UserPreferences.AutoRefreshUIEnabled.ToString(); + case "UserPreferences.AutoRefreshIntervalSeconds": + return config.UserPreferences.AutoRefreshIntervalSeconds.ToString(); + case "UserPreferences.NotificationsEnabled": + return config.UserPreferences.NotificationsEnabled.ToString(); + default: + _logger.LogWarning($"Unknown setting key: {key}"); + return defaultValue; + } + } + + public async Task SaveSettingAsync(string key, string value) + { + var config = await GetConfigAsync(); + bool changed = false; + + try + { + switch (key) + { + case "Transmission.Host": + config.Transmission.Host = value; + changed = true; + break; + case "Transmission.Port": + if (int.TryParse(value, out int port)) + { + config.Transmission.Port = port; + changed = true; + } + break; + case "Transmission.Username": + config.Transmission.Username = value; + changed = true; + break; + case "Transmission.Password": + config.Transmission.Password = value; + changed = true; + break; + case "Transmission.UseHttps": + if (bool.TryParse(value, out bool useHttps)) + { + config.Transmission.UseHttps = useHttps; + changed = true; + } + break; + case "AutoDownloadEnabled": + if (bool.TryParse(value, out bool autoDownload)) + { + config.AutoDownloadEnabled = autoDownload; + changed = true; + } + break; + case "CheckIntervalMinutes": + if (int.TryParse(value, out int interval)) + { + config.CheckIntervalMinutes = interval; + changed = true; + } + break; + case "DownloadDirectory": + config.DownloadDirectory = value; + changed = true; + break; + case "MediaLibraryPath": + config.MediaLibraryPath = value; + changed = true; + break; + case "PostProcessing.Enabled": + if (bool.TryParse(value, out bool ppEnabled)) + { + config.PostProcessing.Enabled = ppEnabled; + changed = true; + } + break; + case "PostProcessing.ExtractArchives": + if (bool.TryParse(value, out bool extractArchives)) + { + config.PostProcessing.ExtractArchives = extractArchives; + changed = true; + } + break; + case "PostProcessing.OrganizeMedia": + if (bool.TryParse(value, out bool organizeMedia)) + { + config.PostProcessing.OrganizeMedia = organizeMedia; + changed = true; + } + break; + case "PostProcessing.MinimumSeedRatio": + if (float.TryParse(value, out float seedRatio)) + { + config.PostProcessing.MinimumSeedRatio = (int)seedRatio; + changed = true; + } + break; + case "UserPreferences.EnableDarkMode": + if (bool.TryParse(value, out bool darkMode)) + { + config.UserPreferences.EnableDarkMode = darkMode; + changed = true; + } + break; + case "UserPreferences.AutoRefreshUIEnabled": + if (bool.TryParse(value, out bool autoRefresh)) + { + config.UserPreferences.AutoRefreshUIEnabled = autoRefresh; + changed = true; + } + break; + case "UserPreferences.AutoRefreshIntervalSeconds": + if (int.TryParse(value, out int refreshInterval)) + { + config.UserPreferences.AutoRefreshIntervalSeconds = refreshInterval; + changed = true; + } + break; + case "UserPreferences.NotificationsEnabled": + if (bool.TryParse(value, out bool notifications)) + { + config.UserPreferences.NotificationsEnabled = notifications; + changed = true; + } + break; + default: + _logger.LogWarning($"Unknown setting key: {key}"); + break; + } + + if (changed) + { + await SaveConfigAsync(config); + } + } + catch (Exception ex) + { + _logger.LogError(ex, $"Error saving setting {key}"); + throw; + } + } + + private AppConfig LoadConfigFromFileSync() + { + try + { + if (!File.Exists(_configFilePath)) + { + _logger.LogWarning($"Configuration file not found at {_configFilePath}, creating default config"); + var defaultConfig = CreateDefaultConfig(); + // Save synchronously since we're in a sync method + File.WriteAllText(_configFilePath, JsonSerializer.Serialize(defaultConfig, new JsonSerializerOptions + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + })); + return defaultConfig; + } + + string json = File.ReadAllText(_configFilePath); + var config = JsonSerializer.Deserialize(json, new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }); + + if (config == null) + { + throw new InvalidOperationException("Failed to deserialize configuration"); + } + + // Fill in any missing values with defaults + EnsureCompleteConfig(config); + + return config; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error loading configuration from file"); + throw; + } + } + + private async Task LoadConfigFromFileAsync() + { + try + { + if (!File.Exists(_configFilePath)) + { + _logger.LogWarning($"Configuration file not found at {_configFilePath}, creating default config"); + var defaultConfig = CreateDefaultConfig(); + await SaveConfigToFileAsync(defaultConfig); + return defaultConfig; + } + + string json = await File.ReadAllTextAsync(_configFilePath); + var config = JsonSerializer.Deserialize(json, new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }); + + if (config == null) + { + throw new InvalidOperationException("Failed to deserialize configuration"); + } + + // Fill in any missing values with defaults + EnsureCompleteConfig(config); + + return config; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error loading configuration from file"); + throw; + } + } + + private async Task SaveConfigToFileAsync(AppConfig config) + { + try + { + string json = JsonSerializer.Serialize(config, new JsonSerializerOptions + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }); + + // Create directory if it doesn't exist + string directory = Path.GetDirectoryName(_configFilePath); + if (!Directory.Exists(directory) && !string.IsNullOrEmpty(directory)) + { + _logger.LogInformation($"Creating directory: {directory}"); + Directory.CreateDirectory(directory); + } + + // Log detailed info about the file we're trying to write to + _logger.LogInformation($"Attempting to save configuration to {_configFilePath}"); + + bool canWriteToOriginalPath = false; + + try + { + // Check if we have write permissions to the directory + var directoryInfo = new DirectoryInfo(directory); + _logger.LogInformation($"Directory exists: {directoryInfo.Exists}, Directory path: {directoryInfo.FullName}"); + + // Check if we have write permissions to the file + var fileInfo = new FileInfo(_configFilePath); + if (fileInfo.Exists) + { + _logger.LogInformation($"File exists: {fileInfo.Exists}, File path: {fileInfo.FullName}, Is read-only: {fileInfo.IsReadOnly}"); + + // Try to make the file writable if it's read-only + if (fileInfo.IsReadOnly) + { + _logger.LogWarning("Configuration file is read-only, attempting to make it writable"); + try + { + fileInfo.IsReadOnly = false; + canWriteToOriginalPath = true; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to make file writable"); + } + } + else + { + canWriteToOriginalPath = true; + } + } + else + { + // If file doesn't exist, check if we can write to the directory + try + { + // Try to create a test file + string testFilePath = Path.Combine(directory, "writetest.tmp"); + File.WriteAllText(testFilePath, "test"); + File.Delete(testFilePath); + canWriteToOriginalPath = true; + } + catch (Exception ex) + { + _logger.LogError(ex, "Cannot write to directory"); + } + } + } + catch (Exception permEx) + { + _logger.LogError(permEx, "Error checking file permissions"); + } + + string configFilePath = _configFilePath; + + // If we can't write to the original path, use a fallback path in a location we know we can write to + if (!canWriteToOriginalPath) + { + string fallbackPath = Path.Combine(AppContext.BaseDirectory, "appsettings.json"); + _logger.LogWarning($"Cannot write to original path, using fallback path: {fallbackPath}"); + configFilePath = fallbackPath; + + // Update the config file path for future loads + _configFilePath = fallbackPath; + } + + try + { + // Write directly to the file - first try direct write + _logger.LogInformation($"Writing configuration to {configFilePath}"); + await File.WriteAllTextAsync(configFilePath, json); + _logger.LogInformation("Configuration successfully saved by direct write"); + return; + } + catch (Exception writeEx) + { + _logger.LogError(writeEx, "Direct write failed, trying with temporary file"); + } + + // If direct write fails, try with temporary file + string tempDirectory = AppContext.BaseDirectory; + string tempFilePath = Path.Combine(tempDirectory, $"appsettings.{Guid.NewGuid():N}.tmp"); + + _logger.LogInformation($"Writing to temporary file: {tempFilePath}"); + await File.WriteAllTextAsync(tempFilePath, json); + + try + { + _logger.LogInformation($"Copying from {tempFilePath} to {configFilePath}"); + File.Copy(tempFilePath, configFilePath, true); + _logger.LogInformation("Configuration successfully saved via temp file"); + } + catch (Exception copyEx) + { + _logger.LogError(copyEx, "Error copying from temp file to destination"); + + // If copy fails, keep the temp file and use it as the config path + _logger.LogWarning($"Using temporary file as permanent config: {tempFilePath}"); + _configFilePath = tempFilePath; + } + finally + { + try + { + if (File.Exists(tempFilePath) && tempFilePath != _configFilePath) + { + File.Delete(tempFilePath); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Could not delete temp file"); + } + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error saving configuration to file"); + throw; + } + } + + private AppConfig CreateDefaultConfig() + { + var defaultConfig = new AppConfig + { + Transmission = new TransmissionConfig + { + Host = "localhost", + Port = 9091, + Username = "", + Password = "", + UseHttps = false + }, + TransmissionInstances = new Dictionary(), + Feeds = new List(), + AutoDownloadEnabled = true, + CheckIntervalMinutes = 30, + DownloadDirectory = "/var/lib/transmission-daemon/downloads", + MediaLibraryPath = "/media/library", + EnableDetailedLogging = false, + PostProcessing = new PostProcessingConfig + { + Enabled = false, + ExtractArchives = true, + OrganizeMedia = true, + MinimumSeedRatio = 1, + MediaExtensions = new List { ".mp4", ".mkv", ".avi", ".mov", ".wmv" }, + AutoOrganizeByMediaType = true, + RenameFiles = false, + CompressCompletedFiles = false, + DeleteCompletedAfterDays = 0 + }, + UserPreferences = new TransmissionRssManager.Core.UserPreferences + { + EnableDarkMode = true, + AutoRefreshUIEnabled = true, + AutoRefreshIntervalSeconds = 30, + NotificationsEnabled = true, + NotificationEvents = new List { "torrent-added", "torrent-completed", "torrent-error" }, + DefaultView = "dashboard", + ConfirmBeforeDelete = true, + MaxItemsPerPage = 25, + DateTimeFormat = "yyyy-MM-dd HH:mm:ss", + ShowCompletedTorrents = true, + KeepHistoryDays = 30 + } + }; + + _logger.LogInformation("Created default configuration"); + return defaultConfig; + } + + private void EnsureCompleteConfig(AppConfig config) + { + // Create new instances for any null nested objects + config.Transmission ??= new TransmissionConfig + { + Host = "localhost", + Port = 9091, + Username = "", + Password = "", + UseHttps = false + }; + + config.TransmissionInstances ??= new Dictionary(); + config.Feeds ??= new List(); + + config.PostProcessing ??= new PostProcessingConfig + { + Enabled = false, + ExtractArchives = true, + OrganizeMedia = true, + MinimumSeedRatio = 1, + MediaExtensions = new List { ".mp4", ".mkv", ".avi", ".mov", ".wmv" }, + AutoOrganizeByMediaType = true, + RenameFiles = false, + CompressCompletedFiles = false, + DeleteCompletedAfterDays = 0 + }; + + // Ensure PostProcessing MediaExtensions is not null + config.PostProcessing.MediaExtensions ??= new List { ".mp4", ".mkv", ".avi", ".mov", ".wmv" }; + + config.UserPreferences ??= new TransmissionRssManager.Core.UserPreferences + { + EnableDarkMode = true, + AutoRefreshUIEnabled = true, + AutoRefreshIntervalSeconds = 30, + NotificationsEnabled = true, + NotificationEvents = new List { "torrent-added", "torrent-completed", "torrent-error" }, + DefaultView = "dashboard", + ConfirmBeforeDelete = true, + MaxItemsPerPage = 25, + DateTimeFormat = "yyyy-MM-dd HH:mm:ss", + ShowCompletedTorrents = true, + KeepHistoryDays = 30 + }; + + // Ensure UserPreferences.NotificationEvents is not null + config.UserPreferences.NotificationEvents ??= new List { "torrent-added", "torrent-completed", "torrent-error" }; + + // Ensure default values for string properties if they're null + config.DownloadDirectory ??= "/var/lib/transmission-daemon/downloads"; + config.MediaLibraryPath ??= "/media/library"; + config.Transmission.Host ??= "localhost"; + config.Transmission.Username ??= ""; + config.Transmission.Password ??= ""; + config.UserPreferences.DefaultView ??= "dashboard"; + config.UserPreferences.DateTimeFormat ??= "yyyy-MM-dd HH:mm:ss"; + + _logger.LogDebug("Config validated and completed with default values where needed"); + } + } +} \ No newline at end of file diff --git a/bin/net7.0/wwwroot/css/dark-mode.css b/bin/net7.0/wwwroot/css/dark-mode.css new file mode 100644 index 0000000..267c85e --- /dev/null +++ b/bin/net7.0/wwwroot/css/dark-mode.css @@ -0,0 +1,56 @@ +/* Dark mode overrides */ +body.dark-mode { + background-color: #121212; + color: #f5f5f5; +} + +/* Ensure all form labels are white in dark mode */ +body.dark-mode label, +body.dark-mode .form-label, +body.dark-mode .form-check-label { + color: white !important; +} + +/* All inputs in forms */ +body.dark-mode .form-control, +body.dark-mode .form-select { + background-color: #2c2c2c; + color: white; + border-color: #444; +} + +/* Cards and containers */ +body.dark-mode .card { + background-color: #1e1e1e; + border-color: #333; +} + +body.dark-mode .card-header { + background-color: #252525; + border-color: #333; +} + +/* Advanced tab specific fixes */ +body.dark-mode #tab-advanced label, +body.dark-mode #tab-advanced .form-check-label { + color: white !important; +} + +body.dark-mode #detailed-logging + label, +body.dark-mode #show-completed-torrents + label, +body.dark-mode #confirm-delete + label { + color: white !important; + font-weight: normal !important; +} + +/* Make all form switches visible */ +body.dark-mode .form-switch .form-check-label { + color: white !important; +} + +/* Specific fix for known problematic labels */ +#detailed-logging-label, +#show-completed-torrents-label, +#confirm-delete-label { + color: white !important; +} \ No newline at end of file diff --git a/bin/net7.0/wwwroot/css/styles.css b/bin/net7.0/wwwroot/css/styles.css new file mode 100644 index 0000000..cbc581a --- /dev/null +++ b/bin/net7.0/wwwroot/css/styles.css @@ -0,0 +1,705 @@ +:root { + /* Light Theme Variables */ + --primary-color: #0d6efd; + --secondary-color: #6c757d; + --dark-color: #212529; + --light-color: #f8f9fa; + --success-color: #198754; + --danger-color: #dc3545; + --warning-color: #ffc107; + --info-color: #0dcaf0; + + /* Common Variables */ + --border-radius: 4px; + --shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + --transition: all 0.3s ease; + + /* Light Theme Specific */ + --bg-color: #ffffff; + --text-color: #212529; + --card-bg: #f8f9fa; + --card-header-bg: #e9ecef; + --card-border: 1px solid rgba(0, 0, 0, 0.125); + --hover-bg: #e9ecef; + --table-border: #dee2e6; + --input-bg: #fff; + --input-border: #ced4da; + --dropdown-bg: #fff; + --modal-bg: #fff; + --feed-item-bg: #f8f9fa; + --torrent-item-bg: #f8f9fa; +} + +/* Dark Theme */ +body.dark-mode { + --bg-color: #121212; + --text-color: #f5f5f5; + --card-bg: #1e1e1e; + --card-header-bg: #252525; + --card-border: 1px solid rgba(255, 255, 255, 0.125); + --hover-bg: #2c2c2c; + --table-border: #333; + --input-bg: #2c2c2c; + --input-border: #444; + --dropdown-bg: #2c2c2c; + --modal-bg: #1e1e1e; + --feed-item-bg: #1e1e1e; + --torrent-item-bg: #1e1e1e; + + color-scheme: dark; +} + +/* Global forced text color for dark mode */ +body.dark-mode > * { + color: #f5f5f5; +} + +/* Fix for dark mode text colors */ +body.dark-mode .text-dark, +body.dark-mode .text-body, +body.dark-mode .text-primary, +body.dark-mode .modal-title, +body.dark-mode .form-label, +body.dark-mode .form-check-label, +body.dark-mode h1, +body.dark-mode h2, +body.dark-mode h3, +body.dark-mode h4, +body.dark-mode h5, +body.dark-mode h6, +body.dark-mode label, +body.dark-mode .card-title, +body.dark-mode .form-text, +body.dark-mode .tab-content { + color: #f5f5f5 !important; +} + +body.dark-mode .text-secondary, +body.dark-mode .text-muted { + color: #adb5bd !important; +} + +body.dark-mode .nav-link { + color: #f5f5f5; +} + +body.dark-mode .nav-link:hover, +body.dark-mode .nav-link:focus { + color: #0d6efd; +} + +body.dark-mode .dropdown-menu { + background-color: #1e1e1e; + border-color: rgba(255, 255, 255, 0.125); +} + +body.dark-mode .dropdown-item { + color: #f5f5f5; +} + +body.dark-mode .dropdown-item:hover, +body.dark-mode .dropdown-item:focus { + background-color: #2c2c2c; + color: #f5f5f5; +} + +body.dark-mode .list-group-item { + background-color: #1e1e1e; + color: #f5f5f5; + border-color: rgba(255, 255, 255, 0.125); +} + +body.dark-mode .feed-item-date, +body.dark-mode .torrent-item-details { + color: #adb5bd; +} + +/* Links in dark mode */ +body.dark-mode a:not(.btn):not(.nav-link):not(.badge) { + color: #6ea8fe; +} + +body.dark-mode a:not(.btn):not(.nav-link):not(.badge):hover { + color: #8bb9fe; +} + +/* Table in dark mode */ +body.dark-mode .table { + color: #f5f5f5; +} + +/* Alerts in dark mode */ +body.dark-mode .alert-info { + background-color: #0d3251; + color: #6edff6; + border-color: #0a3a5a; +} + +body.dark-mode .alert-success { + background-color: #051b11; + color: #75b798; + border-color: #0c2a1c; +} + +body.dark-mode .alert-warning { + background-color: #332701; + color: #ffda6a; + border-color: #473b08; +} + +body.dark-mode .alert-danger { + background-color: #2c0b0e; + color: #ea868f; + border-color: #401418; +} + +/* Advanced tab fix */ +body.dark-mode #tab-advanced, +body.dark-mode #tab-advanced * { + color: #f5f5f5 !important; +} + +body.dark-mode #tab-advanced .form-check-label, +body.dark-mode .form-switch .form-check-label, +body.dark-mode label[for="show-completed-torrents"] { + color: #f5f5f5 !important; +} + +/* Base Elements */ +body { + padding-bottom: 2rem; + background-color: var(--bg-color); + color: var(--text-color); + transition: var(--transition); + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; +} + +/* Navigation */ +.navbar { + margin-bottom: 1rem; + background-color: var(--card-bg); + border-bottom: var(--card-border); + transition: var(--transition); +} + +.navbar-brand, .nav-link { + color: var(--text-color); + transition: var(--transition); +} + +.navbar-toggler { + border-color: var(--input-border); +} + +.page-content { + animation: fadeIn 0.3s ease; +} + +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +/* Cards */ +.card { + margin-bottom: 1rem; + box-shadow: var(--shadow); + background-color: var(--card-bg); + border: var(--card-border); + border-radius: var(--border-radius); + transition: var(--transition); +} + +.card-header { + background-color: var(--card-header-bg); + font-weight: 500; + border-bottom: var(--card-border); + transition: var(--transition); +} + +/* Tables */ +.table { + margin-bottom: 0; + color: var(--text-color); + transition: var(--transition); +} + +.table thead th { + border-bottom-color: var(--table-border); +} + +.table td, .table th { + border-top-color: var(--table-border); +} + +/* Progress Bars */ +.progress { + height: 10px; + background-color: var(--card-header-bg); + border-radius: var(--border-radius); +} + +/* Badges */ +.badge { + padding: 0.35em 0.65em; + border-radius: 50rem; +} + +.badge-downloading { + background-color: var(--info-color); + color: var(--dark-color); +} + +.badge-seeding { + background-color: var(--success-color); + color: white; +} + +.badge-stopped { + background-color: var(--secondary-color); + color: white; +} + +.badge-checking { + background-color: var(--warning-color); + color: var(--dark-color); +} + +.badge-queued { + background-color: var(--secondary-color); + color: white; +} + +.badge-error { + background-color: var(--danger-color); + color: white; +} + +/* Buttons */ +.btn { + border-radius: var(--border-radius); + transition: var(--transition); +} + +.btn-icon { + padding: 0.25rem 0.5rem; + font-size: 0.875rem; +} + +.btn-primary { + background-color: var(--primary-color); + border-color: var(--primary-color); +} + +.btn-secondary { + background-color: var(--secondary-color); + border-color: var(--secondary-color); +} + +.btn-success { + background-color: var(--success-color); + border-color: var(--success-color); +} + +.btn-danger { + background-color: var(--danger-color); + border-color: var(--danger-color); +} + +.btn-warning { + background-color: var(--warning-color); + border-color: var(--warning-color); + color: var(--dark-color); +} + +.btn-info { + background-color: var(--info-color); + border-color: var(--info-color); + color: var(--dark-color); +} + +/* Inputs & Forms */ +.form-control, .form-select { + background-color: var(--input-bg); + border-color: var(--input-border); + color: var(--text-color); + border-radius: var(--border-radius); + transition: var(--transition); +} + +.form-control:focus, .form-select:focus { + background-color: var(--input-bg); + color: var(--text-color); + border-color: var(--primary-color); + box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25); +} + +body.dark-mode .form-control, +body.dark-mode .form-select { + color: #f5f5f5; + background-color: #2c2c2c; + border-color: #444; +} + +body.dark-mode .form-control:focus, +body.dark-mode .form-select:focus { + background-color: #2c2c2c; + color: #f5f5f5; +} + +body.dark-mode .form-control::placeholder { + color: #adb5bd; + opacity: 0.7; +} + +/* Form switches in dark mode */ +body.dark-mode .form-check-input:checked { + background-color: #0d6efd; + border-color: #0d6efd; +} + +body.dark-mode .form-check-input:not(:checked) { + background-color: rgba(255, 255, 255, 0.2); + border-color: rgba(255, 255, 255, 0.25); +} + +body.dark-mode .form-check { + color: #f5f5f5 !important; +} + +body.dark-mode .form-check-label, +body.dark-mode label.form-check-label, +body.dark-mode .form-switch label, +body.dark-mode label[for], +body.dark-mode .card-body label, +body.dark-mode #show-completed-torrents + label, +body.dark-mode label[for="show-completed-torrents"] { + color: #f5f5f5 !important; +} + +/* Direct fix for the show completed torrents label */ +html body.dark-mode div#tab-advanced div.card-body div.form-check label.form-check-label[for="show-completed-torrents"], +html body.dark-mode div#tab-advanced div.mb-3 div.form-check-label, +html body.dark-mode div#tab-advanced label.form-check-label { + color: #ffffff !important; + font-weight: 500 !important; + text-shadow: 0 0 1px #000 !important; +} + +/* Fix all form check labels in dark mode */ +html body.dark-mode .form-check-label { + color: #ffffff !important; +} + +/* Fix for all tabs in dark mode */ +body.dark-mode #tab-advanced, +body.dark-mode #tab-advanced *, +body.dark-mode #tab-appearance, +body.dark-mode #tab-appearance *, +body.dark-mode #tab-processing, +body.dark-mode #tab-processing *, +body.dark-mode #tab-rss, +body.dark-mode #tab-rss *, +body.dark-mode #tab-transmission, +body.dark-mode #tab-transmission * { + color: #f5f5f5 !important; +} + +body.dark-mode .tab-content, +body.dark-mode .tab-content * { + color: #f5f5f5 !important; +} + +/* Emergency fix for advanced tab */ +body.dark-mode .form-check-label { + color: white !important; +} + +/* Super specific advanced tab fix */ +body.dark-mode #detailed-logging + label, +body.dark-mode #show-completed-torrents + label, +body.dark-mode #confirm-delete + label, +body.dark-mode div.form-check-label, +body.dark-mode label.form-check-label { + color: white !important; +} + +/* Feed Items */ +.feed-item { + border-left: 3px solid transparent; + padding: 15px; + margin-bottom: 15px; + background-color: var(--feed-item-bg); + border-radius: var(--border-radius); + transition: var(--transition); +} + +.feed-item:hover { + background-color: var(--hover-bg); +} + +.feed-item.matched { + border-left-color: var(--success-color); +} + +.feed-item.downloaded { + opacity: 0.7; +} + +.feed-item-title { + font-weight: 500; + margin-bottom: 8px; +} + +.feed-item-date { + font-size: 0.85rem; + color: var(--secondary-color); +} + +.feed-item-buttons { + margin-top: 12px; + display: flex; + gap: 8px; +} + +/* Torrent Items */ +.torrent-item { + margin-bottom: 20px; + padding: 15px; + border-radius: var(--border-radius); + background-color: var(--torrent-item-bg); + transition: var(--transition); + border: var(--card-border); +} + +.torrent-item-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; +} + +.torrent-item-title { + font-weight: 500; + margin-right: 10px; + word-break: break-word; +} + +.torrent-item-progress { + margin: 12px 0; +} + +.torrent-item-details { + display: flex; + flex-wrap: wrap; + gap: 10px; + justify-content: space-between; + font-size: 0.9rem; + color: var(--secondary-color); +} + +.torrent-item-buttons { + margin-top: 12px; + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +/* Dashboard panels */ +.dashboard-stats { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); + gap: 15px; + margin-bottom: 20px; +} + +.stat-card { + background-color: var(--card-bg); + border-radius: var(--border-radius); + padding: 20px; + border: var(--card-border); + transition: var(--transition); + display: flex; + flex-direction: column; + align-items: center; + text-align: center; +} + +.stat-card .stat-value { + font-size: 2rem; + font-weight: bold; + margin: 10px 0; +} + +.stat-card .stat-label { + font-size: 0.9rem; + color: var(--secondary-color); +} + +/* Dark Mode Toggle */ +.dark-mode-toggle { + cursor: pointer; + padding: 5px 10px; + border-radius: var(--border-radius); + transition: var(--transition); + color: var(--text-color); + background-color: transparent; + border: 1px solid var(--input-border); +} + +.dark-mode-toggle:hover { + background-color: var(--hover-bg); +} + +.dark-mode-toggle i { + font-size: 1.2rem; +} + +body.dark-mode .dark-mode-toggle { + color: #f5f5f5; + border-color: #444; +} + +/* Notifications */ +.toast-container { + position: fixed; + top: 20px; + right: 20px; + z-index: 9999; +} + +.toast { + background-color: var(--card-bg); + color: var(--text-color); + border: var(--card-border); + margin-bottom: 10px; + max-width: 350px; +} + +.toast-header { + background-color: var(--card-header-bg); + color: var(--text-color); + border-bottom: var(--card-border); +} + +/* Modals */ +.modal-content { + background-color: var(--modal-bg); + color: var(--text-color); + border: var(--card-border); +} + +.modal-header { + border-bottom: var(--card-border); +} + +.modal-footer { + border-top: var(--card-border); +} + +/* Charts and Graphs */ +.chart-container { + position: relative; + height: 300px; + margin-bottom: 20px; +} + +/* Mobile Responsive Design */ +@media (max-width: 768px) { + .container { + padding-left: 15px; + padding-right: 15px; + max-width: 100%; + } + + .card-body { + padding: 15px; + } + + .torrent-item-header { + flex-direction: column; + align-items: flex-start; + } + + .torrent-item-buttons { + width: 100%; + } + + .torrent-item-buttons .btn { + flex: 1; + text-align: center; + padding: 8px; + } + + .dashboard-stats { + grid-template-columns: 1fr; + } + + .stat-card { + margin-bottom: 10px; + } + + .feed-item-buttons { + flex-direction: column; + } + + .feed-item-buttons .btn { + width: 100%; + margin-bottom: 5px; + } + + .table-responsive { + margin-bottom: 15px; + } +} + +/* Tablet Responsive Design */ +@media (min-width: 769px) and (max-width: 992px) { + .dashboard-stats { + grid-template-columns: repeat(2, 1fr); + } +} + +/* Print Styles */ +@media print { + .no-print { + display: none !important; + } + + body { + background-color: white !important; + color: black !important; + } + + .card, .torrent-item, .feed-item { + break-inside: avoid; + border: 1px solid #ddd !important; + } +} + +/* Accessibility */ +@media (prefers-reduced-motion) { + * { + transition: none !important; + animation: none !important; + } +} + +/* Utilities */ +.text-truncate { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.cursor-pointer { + cursor: pointer; +} + +.flex-grow-1 { + flex-grow: 1; +} + +.word-break-all { + word-break: break-all; +} \ No newline at end of file diff --git a/bin/net7.0/wwwroot/index.html b/bin/net7.0/wwwroot/index.html new file mode 100644 index 0000000..c3f6559 --- /dev/null +++ b/bin/net7.0/wwwroot/index.html @@ -0,0 +1,771 @@ + + + + + + Transmission RSS Manager + + + + + + + + + + +
+ +
+
+

Dashboard

+ + +
+
+ +
-
+
Active Downloads
+
+
+ +
-
+
Seeding Torrents
+
+
+ +
-
+
Active Feeds
+
+
+ +
-
+
Completed Today
+
+
+ + +
+
+
+
+ Download/Upload Speed + - +
+
+
+
+ Download: + 0 KB/s +
+
+ Upload: + 0 KB/s +
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Activity Summary +
+
+
    +
  • + Added Today + - +
  • +
  • + Completed Today + - +
  • +
  • + Active RSS Feeds + - +
  • +
  • + Matched Items + - +
  • +
+
+
+
+
+ + +
+
+
+
+ Download History (Last 30 Days) +
+
+
+ +
+
+
+
+
+ + +
+
+
+
+ Active Torrents + View All +
+
+
Loading...
+
+
+
+
+
+
+ Recent Matches + View All +
+
+
Loading...
+
+
+
+
+
+ +
+

RSS Feeds

+
+ + +
+
Loading...
+ +
+

Feed Items

+ +
+
+
Loading...
+
+
+
Loading...
+
+
+
+
+ +
+

Torrents

+
+ + +
+
Loading...
+
+ +
+

System Logs

+ +
+
+ Log Filters +
+ + +
+
+
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+
+
+
+ + +
+
+ +
+
+
+
+
+
+ +
+
+ Log Entries + 0 entries +
+
+
+ + + + + + + + + + + + + + +
TimestampLevelMessageContext
Loading logs...
+
+
+ +
+
+ +
+

Settings

+
+ + +
+ +
+
+
+ Primary Transmission Instance +
+
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+
+
+
+ + +
+
+
+
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+
+ + +
+
+ Additional Transmission Instances + +
+
+
+
No additional instances configured
+
+
+
+
+ + +
+
+
+ RSS General Settings +
+
+
+
+ + +
+
+
+ + +
+
+ + +
Maximum number of items to keep per feed (for performance)
+
+
+
+ +
+
+ Content Filtering +
+
+
+
+ + +
+
When enabled, feed rules can use regular expressions for more advanced matching
+
+
+
+ + +
+
+
+ + +
Items matching these patterns will be ignored regardless of feed rules
+
+
+
+
+ + +
+
+
+ Directories +
+
+
+ + +
+
+ + +
+
+
+ + +
+
Create subfolders based on feed categories
+
+
+
+ +
+
+ Post Processing +
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+
+ + +
+
+ + +
Number of days after which completed torrents will be removed (0 = never)
+
+
+ + +
+
+
+
+ + +
+
+
+ User Interface +
+
+
+
+ + +
+
+
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+
+ Notifications +
+
+
+
+ + +
+
+
+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+
+ + +
+
+
+ Advanced Settings +
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+
+ + +
Number of days to keep historical data
+
+
+
+ +
+
+ Database +
+
+
+ Warning: These operations affect your data permanently. +
+
+ + + +
+
+
+
+
+ +
+ + +
+
+
+
+ + + + + + + + + + + + \ No newline at end of file diff --git a/bin/net7.0/wwwroot/js/app.js b/bin/net7.0/wwwroot/js/app.js new file mode 100644 index 0000000..5fd7eb6 --- /dev/null +++ b/bin/net7.0/wwwroot/js/app.js @@ -0,0 +1,1573 @@ +document.addEventListener('DOMContentLoaded', function() { + // Initialize navigation + initNavigation(); + + // Initialize event listeners + initEventListeners(); + + // Load initial dashboard data + loadDashboardData(); + + // Set up dark mode based on user preference + initDarkMode(); + + // Set up auto refresh if enabled + initAutoRefresh(); + + // Initialize Bootstrap tooltips + const tooltips = document.querySelectorAll('[data-bs-toggle="tooltip"]'); + tooltips.forEach(tooltip => new bootstrap.Tooltip(tooltip)); +}); + +function initNavigation() { + const navLinks = document.querySelectorAll('.navbar-nav .nav-link'); + navLinks.forEach(link => { + link.addEventListener('click', function(e) { + e.preventDefault(); + const page = this.getAttribute('data-page'); + showPage(page); + }); + }); + + // Set active page from URL hash or default to dashboard + const hash = window.location.hash.substring(1); + showPage(hash || 'dashboard'); +} + +function showPage(page) { + // Hide all pages + const pages = document.querySelectorAll('.page-content'); + pages.forEach(p => p.classList.add('d-none')); + + // Remove active class from all nav links + const navLinks = document.querySelectorAll('.navbar-nav .nav-link'); + navLinks.forEach(link => link.classList.remove('active')); + + // Show selected page + const selectedPage = document.getElementById(`page-${page}`); + if (selectedPage) { + selectedPage.classList.remove('d-none'); + + // Set active class on nav link + const activeNav = document.querySelector(`.nav-link[data-page="${page}"]`); + if (activeNav) { + activeNav.classList.add('active'); + } + + // Update URL hash + window.location.hash = page; + + // Load page-specific data + loadPageData(page); + } +} + +function loadPageData(page) { + switch (page) { + case 'dashboard': + loadDashboardData(); + break; + case 'feeds': + loadFeeds(); + loadAllItems(); + loadMatchedItems(); + break; + case 'torrents': + loadTorrents(); + break; + case 'settings': + loadSettings(); + break; + } +} + +function initEventListeners() { + // RSS Feeds page + document.getElementById('btn-add-feed').addEventListener('click', showAddFeedModal); + document.getElementById('btn-refresh-feeds').addEventListener('click', refreshFeeds); + document.getElementById('save-feed-btn').addEventListener('click', saveFeed); + + // Torrents page + document.getElementById('btn-add-torrent').addEventListener('click', showAddTorrentModal); + document.getElementById('btn-refresh-torrents').addEventListener('click', loadTorrents); + document.getElementById('save-torrent-btn').addEventListener('click', saveTorrent); + + // Logs page + document.getElementById('btn-refresh-logs').addEventListener('click', refreshLogs); + document.getElementById('btn-clear-logs').addEventListener('click', clearLogs); + document.getElementById('btn-apply-log-filters').addEventListener('click', applyLogFilters); + document.getElementById('btn-reset-log-filters').addEventListener('click', resetLogFilters); + document.getElementById('btn-export-logs').addEventListener('click', exportLogs); + + // Settings page + document.getElementById('settings-form').addEventListener('submit', saveSettings); + document.getElementById('dark-mode-toggle').addEventListener('click', toggleDarkMode); + document.getElementById('btn-reset-settings').addEventListener('click', resetSettings); + + // Configuration operations + document.getElementById('btn-backup-config').addEventListener('click', backupConfig); + document.getElementById('btn-reset-config').addEventListener('click', resetConfig); + + // Additional Transmission Instances + document.getElementById('add-transmission-instance').addEventListener('click', addTransmissionInstance); +} + +// Dashboard +function loadDashboardData() { + // Fetch dashboard statistics + fetch('/api/dashboard/stats') + .then(response => response.json()) + .then(stats => { + document.getElementById('active-downloads').textContent = stats.activeDownloads; + document.getElementById('seeding-torrents').textContent = stats.seedingTorrents; + document.getElementById('active-feeds').textContent = stats.activeFeeds; + document.getElementById('completed-today').textContent = stats.completedToday; + + document.getElementById('added-today').textContent = stats.addedToday; + document.getElementById('feeds-count').textContent = stats.feedsCount; + document.getElementById('matched-count').textContent = stats.matchedCount; + + // Format download/upload speeds + const downloadSpeed = formatBytes(stats.downloadSpeed) + '/s'; + const uploadSpeed = formatBytes(stats.uploadSpeed) + '/s'; + document.getElementById('download-speed').textContent = downloadSpeed; + document.getElementById('upload-speed').textContent = uploadSpeed; + document.getElementById('current-speed').textContent = `↓${downloadSpeed} ↑${uploadSpeed}`; + + // Set progress bars (max 100%) + const maxSpeed = Math.max(stats.downloadSpeed, stats.uploadSpeed, 1); + const dlPercent = Math.min(Math.round((stats.downloadSpeed / maxSpeed) * 100), 100); + const ulPercent = Math.min(Math.round((stats.uploadSpeed / maxSpeed) * 100), 100); + document.getElementById('download-speed-bar').style.width = `${dlPercent}%`; + document.getElementById('upload-speed-bar').style.width = `${ulPercent}%`; + }) + .catch(error => { + console.error('Error loading dashboard stats:', error); + }); + + // Load chart data + loadDownloadHistoryChart(); + + // Load other dashboard components + loadActiveTorrents(); + loadRecentMatches(); +} + +function loadSystemStatus() { + const statusElement = document.getElementById('system-status'); + statusElement.innerHTML = '
'; + + fetch('/api/config') + .then(response => response.json()) + .then(config => { + // Create system status HTML + let html = '
    '; + html += `
  • Auto Download ${config.autoDownloadEnabled ? 'Enabled' : 'Disabled'}
  • `; + html += `
  • Check Interval ${config.checkIntervalMinutes} minutes
  • `; + html += `
  • Transmission Connection ${config.transmission.host ? config.transmission.host + ':' + config.transmission.port : 'Not configured'}
  • `; + html += `
  • Post Processing ${config.postProcessing.enabled ? 'Enabled' : 'Disabled'}
  • `; + html += '
'; + + statusElement.innerHTML = html; + }) + .catch(error => { + console.error('Error loading system status:', error); + statusElement.innerHTML = '
Error loading system status
'; + }); +} + +function loadRecentMatches() { + const matchesElement = document.getElementById('recent-matches'); + matchesElement.innerHTML = '
'; + + fetch('/api/feeds/matched') + .then(response => response.json()) + .then(items => { + // Sort by publish date descending and take the first 5 + const recentItems = items.sort((a, b) => new Date(b.publishDate) - new Date(a.publishDate)).slice(0, 5); + + if (recentItems.length === 0) { + matchesElement.innerHTML = '
No matched items yet
'; + return; + } + + let html = '
'; + recentItems.forEach(item => { + const date = new Date(item.publishDate); + html += ` +
+
${item.title}
+ ${formatDate(date)} +
+ + Matched rule: ${item.matchedRule} + ${item.isDownloaded ? 'Downloaded' : 'Not Downloaded'} + +
`; + }); + html += '
'; + + matchesElement.innerHTML = html; + }) + .catch(error => { + console.error('Error loading recent matches:', error); + matchesElement.innerHTML = '
Error loading recent matches
'; + }); +} + +function loadActiveTorrents() { + const torrentsElement = document.getElementById('active-torrents'); + torrentsElement.innerHTML = '
'; + + fetch('/api/torrents') + .then(response => response.json()) + .then(torrents => { + console.log('Dashboard torrents:', torrents); + + // Sort by progress ascending and filter for active torrents + const activeTorrents = torrents + .filter(t => t && t.status && (t.status === 'Downloading' || t.status === 'Seeding')) + .sort((a, b) => (a.percentDone || 0) - (b.percentDone || 0)); + + if (activeTorrents.length === 0) { + torrentsElement.innerHTML = '
No active torrents
'; + return; + } + + let html = '
'; + activeTorrents.forEach(torrent => { + // Handle potential null or undefined values + if (!torrent || !torrent.name) { + return; + } + + // Safely calculate percentages and sizes with error handling + let progressPercent = 0; + try { + progressPercent = Math.round((torrent.percentDone || 0) * 100); + } catch (e) { + console.warn('Error calculating progress percent:', e); + } + + let sizeInGB = '0.00'; + try { + if (torrent.totalSize && torrent.totalSize > 0) { + sizeInGB = (torrent.totalSize / 1073741824).toFixed(2); + } + } catch (e) { + console.warn('Error calculating size in GB:', e); + } + + const torrentStatus = torrent.status || 'Unknown'; + const statusClass = torrentStatus.toLowerCase().replace(/\s+/g, '-'); + + html += `
+
+
${torrent.name}
+ ${torrentStatus} +
+
+
${progressPercent}%
+
+ Size: ${sizeInGB} GB +
`; + }); + html += '
'; + + torrentsElement.innerHTML = html; + }) + .catch(error => { + console.error('Error loading active torrents:', error); + torrentsElement.innerHTML = '
Error loading active torrents
'; + }); +} + +// RSS Feeds +function loadFeeds() { + const feedsElement = document.getElementById('feeds-list'); + feedsElement.innerHTML = '
'; + + fetch('/api/feeds') + .then(response => response.json()) + .then(feeds => { + if (feeds.length === 0) { + feedsElement.innerHTML = '
No feeds added yet
'; + return; + } + + let html = '
'; + feeds.forEach(feed => { + const lastChecked = feed.lastChecked ? new Date(feed.lastChecked) : null; + + html += `
+
+
${feed.name}
+ ${lastChecked ? 'Last checked: ' + formatDate(lastChecked) : 'Never checked'} +
+

${feed.url}

+
+ ${feed.rules.length} rules +
+ + +
+
+
`; + }); + html += '
'; + + feedsElement.innerHTML = html; + + // Add event listeners + document.querySelectorAll('.btn-edit-feed').forEach(btn => { + btn.addEventListener('click', function() { + const feedId = this.getAttribute('data-feed-id'); + editFeed(feedId); + }); + }); + + document.querySelectorAll('.btn-delete-feed').forEach(btn => { + btn.addEventListener('click', function() { + const feedId = this.getAttribute('data-feed-id'); + deleteFeed(feedId); + }); + }); + }) + .catch(error => { + console.error('Error loading feeds:', error); + feedsElement.innerHTML = '
Error loading feeds
'; + }); +} + +function loadAllItems() { + const itemsElement = document.getElementById('all-items-list'); + itemsElement.innerHTML = '
'; + + fetch('/api/feeds/items') + .then(response => response.json()) + .then(items => { + if (items.length === 0) { + itemsElement.innerHTML = '
No feed items yet
'; + return; + } + + let html = '
'; + items.forEach(item => { + const date = new Date(item.publishDate); + const classes = `feed-item ${item.isMatched ? 'matched' : ''} ${item.isDownloaded ? 'downloaded' : ''}`; + + html += `
+ +
${formatDate(date)}
+ ${item.isMatched ? `
Matched rule: ${item.matchedRule}
` : ''} + ${item.isDownloaded ? '
Downloaded
' : ''} + ${!item.isDownloaded && item.isMatched ? + `
+ +
` : '' + } +
`; + }); + html += '
'; + + itemsElement.innerHTML = html; + + // Add event listeners + document.querySelectorAll('.btn-download-item').forEach(btn => { + btn.addEventListener('click', function() { + const itemId = this.getAttribute('data-item-id'); + downloadItem(itemId); + }); + }); + }) + .catch(error => { + console.error('Error loading feed items:', error); + itemsElement.innerHTML = '
Error loading feed items
'; + }); +} + +function loadMatchedItems() { + const matchedElement = document.getElementById('matched-items-list'); + matchedElement.innerHTML = '
'; + + fetch('/api/feeds/matched') + .then(response => response.json()) + .then(items => { + if (items.length === 0) { + matchedElement.innerHTML = '
No matched items yet
'; + return; + } + + let html = '
'; + items.forEach(item => { + const date = new Date(item.publishDate); + const classes = `feed-item matched ${item.isDownloaded ? 'downloaded' : ''}`; + + html += `
+ +
${formatDate(date)}
+
Matched rule: ${item.matchedRule}
+ ${item.isDownloaded ? '
Downloaded
' : ''} + ${!item.isDownloaded ? + `
+ +
` : '' + } +
`; + }); + html += '
'; + + matchedElement.innerHTML = html; + + // Add event listeners + document.querySelectorAll('.btn-download-matched-item').forEach(btn => { + btn.addEventListener('click', function() { + const itemId = this.getAttribute('data-item-id'); + downloadItem(itemId); + }); + }); + }) + .catch(error => { + console.error('Error loading matched items:', error); + matchedElement.innerHTML = '
Error loading matched items
'; + }); +} + +function showAddFeedModal() { + // Clear form + document.getElementById('feed-name').value = ''; + document.getElementById('feed-url').value = ''; + document.getElementById('feed-rules').value = ''; + document.getElementById('feed-auto-download').checked = false; + + // Update modal title and button text + document.querySelector('#add-feed-modal .modal-title').textContent = 'Add RSS Feed'; + document.getElementById('save-feed-btn').textContent = 'Add Feed'; + + // Remove feed ID data attribute + document.getElementById('save-feed-btn').removeAttribute('data-feed-id'); + + // Show modal + const modal = new bootstrap.Modal(document.getElementById('add-feed-modal')); + modal.show(); +} + +function editFeed(feedId) { + // Fetch feed data + fetch(`/api/feeds`) + .then(response => response.json()) + .then(feeds => { + const feed = feeds.find(f => f.id === feedId); + if (!feed) { + alert('Feed not found'); + return; + } + + // Populate form + document.getElementById('feed-name').value = feed.name; + document.getElementById('feed-url').value = feed.url; + document.getElementById('feed-rules').value = feed.rules.join('\n'); + document.getElementById('feed-auto-download').checked = feed.autoDownload; + + // Update modal title and button text + document.querySelector('#add-feed-modal .modal-title').textContent = 'Edit RSS Feed'; + document.getElementById('save-feed-btn').textContent = 'Save Changes'; + + // Add feed ID data attribute + document.getElementById('save-feed-btn').setAttribute('data-feed-id', feedId); + + // Show modal + const modal = new bootstrap.Modal(document.getElementById('add-feed-modal')); + modal.show(); + }) + .catch(error => { + console.error('Error fetching feed:', error); + alert('Error fetching feed'); + }); +} + +function saveFeed() { + const name = document.getElementById('feed-name').value.trim(); + const url = document.getElementById('feed-url').value.trim(); + const rulesText = document.getElementById('feed-rules').value.trim(); + const autoDownload = document.getElementById('feed-auto-download').checked; + + if (!name || !url) { + alert('Please enter a name and URL'); + return; + } + + // Parse rules (split by new line and remove empty lines) + const rules = rulesText.split('\n').filter(rule => rule.trim() !== ''); + + const feedId = document.getElementById('save-feed-btn').getAttribute('data-feed-id'); + const isEditing = !!feedId; + + const feedData = { + name: name, + url: url, + rules: rules, + autoDownload: autoDownload + }; + + if (isEditing) { + feedData.id = feedId; + + // Update existing feed + fetch(`/api/feeds/${feedId}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(feedData) + }) + .then(response => { + if (!response.ok) { + throw new Error('Failed to update feed'); + } + + // Close modal + const modal = bootstrap.Modal.getInstance(document.getElementById('add-feed-modal')); + modal.hide(); + + // Refresh feeds + loadFeeds(); + }) + .catch(error => { + console.error('Error updating feed:', error); + alert('Error updating feed'); + }); + } else { + // Add new feed + fetch('/api/feeds', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(feedData) + }) + .then(response => { + if (!response.ok) { + throw new Error('Failed to add feed'); + } + + // Close modal + const modal = bootstrap.Modal.getInstance(document.getElementById('add-feed-modal')); + modal.hide(); + + // Refresh feeds + loadFeeds(); + // Also refresh items since a new feed might have new items + loadAllItems(); + loadMatchedItems(); + }) + .catch(error => { + console.error('Error adding feed:', error); + alert('Error adding feed'); + }); + } +} + +function deleteFeed(feedId) { + if (!confirm('Are you sure you want to delete this feed?')) { + return; + } + + fetch(`/api/feeds/${feedId}`, { + method: 'DELETE' + }) + .then(response => { + if (!response.ok) { + throw new Error('Failed to delete feed'); + } + + // Refresh feeds + loadFeeds(); + // Also refresh items since items from this feed should be removed + loadAllItems(); + loadMatchedItems(); + }) + .catch(error => { + console.error('Error deleting feed:', error); + alert('Error deleting feed'); + }); +} + +function refreshFeeds() { + const btn = document.getElementById('btn-refresh-feeds'); + btn.disabled = true; + btn.innerHTML = ' Refreshing...'; + + fetch('/api/feeds/refresh', { + method: 'POST' + }) + .then(response => { + if (!response.ok) { + throw new Error('Failed to refresh feeds'); + } + + // Re-enable button + btn.disabled = false; + btn.textContent = 'Refresh Feeds'; + + // Refresh feed items + loadFeeds(); + loadAllItems(); + loadMatchedItems(); + }) + .catch(error => { + console.error('Error refreshing feeds:', error); + alert('Error refreshing feeds'); + + // Re-enable button + btn.disabled = false; + btn.textContent = 'Refresh Feeds'; + }); +} + +function downloadItem(itemId) { + fetch(`/api/feeds/download/${itemId}`, { + method: 'POST' + }) + .then(response => { + if (!response.ok) { + throw new Error('Failed to download item'); + } + + // Refresh items + loadAllItems(); + loadMatchedItems(); + // Also refresh torrents since a new torrent should be added + loadTorrents(); + }) + .catch(error => { + console.error('Error downloading item:', error); + alert('Error downloading item'); + }); +} + +// Torrents +function loadTorrents() { + const torrentsElement = document.getElementById('torrents-list'); + torrentsElement.innerHTML = '
'; + + fetch('/api/torrents') + .then(response => response.json()) + .then(torrents => { + console.log('Loaded torrents:', torrents); + if (torrents.length === 0) { + torrentsElement.innerHTML = '
No torrents
'; + return; + } + + let html = '
'; + torrents.forEach(torrent => { + // Handle potential null or undefined values + if (!torrent || !torrent.name) { + console.warn('Invalid torrent data:', torrent); + return; + } + + // Safely calculate percentages and sizes with error handling + let progressPercent = 0; + try { + progressPercent = Math.round((torrent.percentDone || 0) * 100); + } catch (e) { + console.warn('Error calculating progress percent:', e); + } + + let sizeInGB = '0.00'; + try { + if (torrent.totalSize && torrent.totalSize > 0) { + sizeInGB = (torrent.totalSize / 1073741824).toFixed(2); + } + } catch (e) { + console.warn('Error calculating size in GB:', e); + } + + const torrentStatus = torrent.status || 'Unknown'; + const statusClass = torrentStatus.toLowerCase().replace(/\s+/g, '-'); + + html += `
+
+
${torrent.name}
+ ${torrentStatus} +
+
+
+
${progressPercent}%
+
+
+
+ Size: ${sizeInGB} GB + Location: ${torrent.downloadDir || 'Unknown'} +
+
+ ${torrentStatus === 'Stopped' ? + `` : + `` + } + + ${progressPercent >= 100 ? + `` : '' + } +
+
`; + }); + html += '
'; + + torrentsElement.innerHTML = html; + + // Add event listeners + document.querySelectorAll('.btn-start-torrent').forEach(btn => { + btn.addEventListener('click', function() { + const torrentId = parseInt(this.getAttribute('data-torrent-id')); + startTorrent(torrentId); + }); + }); + + document.querySelectorAll('.btn-stop-torrent').forEach(btn => { + btn.addEventListener('click', function() { + const torrentId = parseInt(this.getAttribute('data-torrent-id')); + stopTorrent(torrentId); + }); + }); + + document.querySelectorAll('.btn-remove-torrent').forEach(btn => { + btn.addEventListener('click', function() { + const torrentId = parseInt(this.getAttribute('data-torrent-id')); + removeTorrent(torrentId); + }); + }); + + document.querySelectorAll('.btn-process-torrent').forEach(btn => { + btn.addEventListener('click', function() { + const torrentId = parseInt(this.getAttribute('data-torrent-id')); + processTorrent(torrentId); + }); + }); + }) + .catch(error => { + console.error('Error loading torrents:', error); + torrentsElement.innerHTML = '
Error loading torrents
'; + }); +} + +function showAddTorrentModal() { + // Clear form + document.getElementById('torrent-url').value = ''; + document.getElementById('torrent-download-dir').value = ''; + + // Show modal + const modal = new bootstrap.Modal(document.getElementById('add-torrent-modal')); + modal.show(); +} + +function saveTorrent() { + const url = document.getElementById('torrent-url').value.trim(); + const downloadDir = document.getElementById('torrent-download-dir').value.trim(); + + if (!url) { + alert('Please enter a torrent URL or magnet link'); + return; + } + + const torrentData = { + url: url + }; + + if (downloadDir) { + torrentData.downloadDir = downloadDir; + } + + fetch('/api/torrents', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(torrentData) + }) + .then(response => { + if (!response.ok) { + throw new Error('Failed to add torrent'); + } + + // Close modal + const modal = bootstrap.Modal.getInstance(document.getElementById('add-torrent-modal')); + modal.hide(); + + // Refresh torrents + loadTorrents(); + }) + .catch(error => { + console.error('Error adding torrent:', error); + alert('Error adding torrent'); + }); +} + +function startTorrent(torrentId) { + fetch(`/api/torrents/${torrentId}/start`, { + method: 'POST' + }) + .then(response => { + if (!response.ok) { + throw new Error('Failed to start torrent'); + } + + // Refresh torrents + loadTorrents(); + }) + .catch(error => { + console.error('Error starting torrent:', error); + alert('Error starting torrent'); + }); +} + +function stopTorrent(torrentId) { + fetch(`/api/torrents/${torrentId}/stop`, { + method: 'POST' + }) + .then(response => { + if (!response.ok) { + throw new Error('Failed to stop torrent'); + } + + // Refresh torrents + loadTorrents(); + }) + .catch(error => { + console.error('Error stopping torrent:', error); + alert('Error stopping torrent'); + }); +} + +function removeTorrent(torrentId) { + if (!confirm('Are you sure you want to remove this torrent? The downloaded files will be kept.')) { + return; + } + + fetch(`/api/torrents/${torrentId}`, { + method: 'DELETE' + }) + .then(response => { + if (!response.ok) { + throw new Error('Failed to remove torrent'); + } + + // Refresh torrents + loadTorrents(); + }) + .catch(error => { + console.error('Error removing torrent:', error); + alert('Error removing torrent'); + }); +} + +function processTorrent(torrentId) { + fetch(`/api/torrents/${torrentId}/process`, { + method: 'POST' + }) + .then(response => { + if (!response.ok) { + throw new Error('Failed to process torrent'); + } + + alert('Torrent processing started'); + }) + .catch(error => { + console.error('Error processing torrent:', error); + alert('Error processing torrent'); + }); +} + +// Settings +function loadSettings() { + const form = document.getElementById('settings-form'); + + fetch('/api/config') + .then(response => response.json()) + .then(config => { + // Transmission settings + document.getElementById('transmission-host').value = config.transmission.host; + document.getElementById('transmission-port').value = config.transmission.port; + document.getElementById('transmission-use-https').checked = config.transmission.useHttps; + document.getElementById('transmission-username').value = ''; + document.getElementById('transmission-password').value = ''; + + // RSS settings + document.getElementById('auto-download-enabled').checked = config.autoDownloadEnabled; + document.getElementById('check-interval').value = config.checkIntervalMinutes; + + // Directory settings + document.getElementById('download-directory').value = config.downloadDirectory; + document.getElementById('media-library').value = config.mediaLibraryPath; + + // Post processing settings + document.getElementById('post-processing-enabled').checked = config.postProcessing.enabled; + document.getElementById('extract-archives').checked = config.postProcessing.extractArchives; + document.getElementById('organize-media').checked = config.postProcessing.organizeMedia; + document.getElementById('minimum-seed-ratio').value = config.postProcessing.minimumSeedRatio; + document.getElementById('media-extensions').value = config.postProcessing.mediaExtensions.join(', '); + }) + .catch(error => { + console.error('Error loading settings:', error); + alert('Error loading settings'); + }); +} + +function saveSettings(e) { + e.preventDefault(); + + // Show saving indicator + const saveBtn = document.querySelector('#settings-form button[type="submit"]'); + const originalBtnText = saveBtn.innerHTML; + saveBtn.disabled = true; + saveBtn.innerHTML = ' Saving...'; + + const config = { + transmission: { + host: document.getElementById('transmission-host').value.trim(), + port: parseInt(document.getElementById('transmission-port').value), + useHttps: document.getElementById('transmission-use-https').checked, + username: document.getElementById('transmission-username').value.trim(), + password: document.getElementById('transmission-password').value.trim() + }, + autoDownloadEnabled: document.getElementById('auto-download-enabled').checked, + checkIntervalMinutes: parseInt(document.getElementById('check-interval').value), + downloadDirectory: document.getElementById('download-directory').value.trim(), + mediaLibraryPath: document.getElementById('media-library').value.trim(), + postProcessing: { + enabled: document.getElementById('post-processing-enabled').checked, + extractArchives: document.getElementById('extract-archives').checked, + organizeMedia: document.getElementById('organize-media').checked, + minimumSeedRatio: parseInt(document.getElementById('minimum-seed-ratio').value), + mediaExtensions: document.getElementById('media-extensions').value.split(',').map(ext => ext.trim()) + }, + userPreferences: { + enableDarkMode: document.body.classList.contains('dark-mode'), + autoRefreshUIEnabled: localStorage.getItem('autoRefresh') !== 'false', + autoRefreshIntervalSeconds: parseInt(localStorage.getItem('refreshInterval')) || 30, + notificationsEnabled: true, + notificationEvents: ["torrent-added", "torrent-completed", "torrent-error"], + defaultView: "dashboard", + confirmBeforeDelete: true, + maxItemsPerPage: 25, + dateTimeFormat: "yyyy-MM-dd HH:mm:ss", + showCompletedTorrents: true, + keepHistoryDays: 30 + }, + enableDetailedLogging: false + }; + + // Log what we're saving to help with debugging + console.log('Saving configuration:', config); + + fetch('/api/config', { + method: 'PUT', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(config) + }) + .then(response => { + if (!response.ok) { + return response.text().then(text => { + throw new Error(`Failed to save settings: ${text}`); + }); + } + return response.json(); + }) + .then(result => { + console.log('Settings saved successfully:', result); + + // Show success message + const settingsForm = document.getElementById('settings-form'); + const alertDiv = document.createElement('div'); + alertDiv.className = 'alert alert-success alert-dismissible fade show mt-3'; + alertDiv.role = 'alert'; + alertDiv.innerHTML = ` + Success! Your settings have been saved. + + `; + settingsForm.appendChild(alertDiv); + + // Auto dismiss after 3 seconds + setTimeout(() => { + const bsAlert = bootstrap.Alert.getOrCreateInstance(alertDiv); + bsAlert.close(); + }, 3000); + + // Re-enable save button + saveBtn.disabled = false; + saveBtn.innerHTML = originalBtnText; + }) + .catch(error => { + console.error('Error saving settings:', error); + + // Show error message + const settingsForm = document.getElementById('settings-form'); + const alertDiv = document.createElement('div'); + alertDiv.className = 'alert alert-danger alert-dismissible fade show mt-3'; + alertDiv.role = 'alert'; + alertDiv.innerHTML = ` + Error! ${error.message} + + `; + settingsForm.appendChild(alertDiv); + + // Re-enable save button + saveBtn.disabled = false; + saveBtn.innerHTML = originalBtnText; + }); +} + +// Helper functions +function formatDate(date) { + if (!date) return 'N/A'; + + // Format as "YYYY-MM-DD HH:MM" + return `${date.getFullYear()}-${padZero(date.getMonth() + 1)}-${padZero(date.getDate())} ${padZero(date.getHours())}:${padZero(date.getMinutes())}`; +} + +function padZero(num) { + return num.toString().padStart(2, '0'); +} + +// Dark Mode Functions +function initDarkMode() { + // Check local storage preference or system preference + const darkModePreference = localStorage.getItem('darkMode'); + + if (darkModePreference === 'true' || + (darkModePreference === null && window.matchMedia('(prefers-color-scheme: dark)').matches)) { + enableDarkMode(); + } else { + disableDarkMode(); + } +} + +function toggleDarkMode() { + if (document.body.classList.contains('dark-mode')) { + disableDarkMode(); + } else { + enableDarkMode(); + } +} + +function enableDarkMode() { + document.body.classList.add('dark-mode'); + document.getElementById('dark-mode-toggle').innerHTML = ''; + localStorage.setItem('darkMode', 'true'); + + // Also update user preferences if on settings page + const darkModeCheckbox = document.getElementById('enable-dark-mode'); + if (darkModeCheckbox) { + darkModeCheckbox.checked = true; + } +} + +function disableDarkMode() { + document.body.classList.remove('dark-mode'); + document.getElementById('dark-mode-toggle').innerHTML = ''; + localStorage.setItem('darkMode', 'false'); + + // Also update user preferences if on settings page + const darkModeCheckbox = document.getElementById('enable-dark-mode'); + if (darkModeCheckbox) { + darkModeCheckbox.checked = false; + } +} + +// Auto-refresh +function initAutoRefresh() { + // Get auto-refresh settings from local storage or use defaults + const autoRefresh = localStorage.getItem('autoRefresh') !== 'false'; + const refreshInterval = parseInt(localStorage.getItem('refreshInterval')) || 30; + + if (autoRefresh) { + startAutoRefresh(refreshInterval); + } +} + +function startAutoRefresh(intervalSeconds) { + // Clear any existing interval + if (window.refreshTimer) { + clearInterval(window.refreshTimer); + } + + // Set new interval + window.refreshTimer = setInterval(() => { + const currentPage = window.location.hash.substring(1) || 'dashboard'; + loadPageData(currentPage); + }, intervalSeconds * 1000); + + localStorage.setItem('autoRefresh', 'true'); + localStorage.setItem('refreshInterval', intervalSeconds.toString()); +} + +function stopAutoRefresh() { + if (window.refreshTimer) { + clearInterval(window.refreshTimer); + window.refreshTimer = null; + } + + localStorage.setItem('autoRefresh', 'false'); +} + +// Chart functions +function loadDownloadHistoryChart() { + fetch('/api/dashboard/history') + .then(response => response.json()) + .then(history => { + const ctx = document.getElementById('download-history-chart').getContext('2d'); + + // Extract dates and count values + const labels = history.map(point => { + const date = new Date(point.date); + return `${date.getMonth() + 1}/${date.getDate()}`; + }); + + const countData = history.map(point => point.count); + const sizeData = history.map(point => point.totalSize / (1024 * 1024 * 1024)); // Convert to GB + + // Create or update chart + if (window.downloadHistoryChart) { + window.downloadHistoryChart.data.labels = labels; + window.downloadHistoryChart.data.datasets[0].data = countData; + window.downloadHistoryChart.data.datasets[1].data = sizeData; + window.downloadHistoryChart.update(); + } else { + window.downloadHistoryChart = new Chart(ctx, { + type: 'bar', + data: { + labels: labels, + datasets: [ + { + label: 'Number of Downloads', + data: countData, + backgroundColor: 'rgba(13, 110, 253, 0.5)', + borderColor: 'rgba(13, 110, 253, 1)', + borderWidth: 1, + yAxisID: 'y' + }, + { + label: 'Total Size (GB)', + data: sizeData, + type: 'line', + borderColor: 'rgba(25, 135, 84, 1)', + backgroundColor: 'rgba(25, 135, 84, 0.1)', + borderWidth: 2, + fill: true, + tension: 0.4, + yAxisID: 'y1' + } + ] + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + position: 'top', + }, + tooltip: { + callbacks: { + label: function(context) { + let label = context.dataset.label || ''; + if (label) { + label += ': '; + } + if (context.datasetIndex === 0) { + label += context.parsed.y; + } else { + label += context.parsed.y.toFixed(2) + ' GB'; + } + return label; + } + } + } + }, + scales: { + y: { + type: 'linear', + display: true, + position: 'left', + title: { + display: true, + text: 'Number of Downloads' + }, + beginAtZero: true + }, + y1: { + type: 'linear', + display: true, + position: 'right', + title: { + display: true, + text: 'Total Size (GB)' + }, + beginAtZero: true, + grid: { + drawOnChartArea: false + } + } + } + } + }); + } + }) + .catch(error => { + console.error('Error loading download history chart:', error); + }); +} + +// Logs Management +function refreshLogs() { + const logFilters = getLogFilters(); + loadLogs(logFilters); +} + +function getLogFilters() { + return { + level: document.getElementById('log-level').value, + search: document.getElementById('log-search').value, + dateRange: document.getElementById('log-date-range').value, + skip: 0, + take: parseInt(document.getElementById('items-per-page')?.value || 25) + }; +} + +function loadLogs(filters) { + const tbody = document.getElementById('logs-table-body'); + tbody.innerHTML = 'Loading logs...'; + + // Build query string + const query = new URLSearchParams(); + if (filters.level && filters.level !== 'All') { + query.append('Level', filters.level); + } + if (filters.search) { + query.append('Search', filters.search); + } + + // Handle date range + const now = new Date(); + let startDate = null; + + switch (filters.dateRange) { + case 'today': + startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0); + break; + case 'yesterday': + startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1, 0, 0, 0); + break; + case 'week': + startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 7, 0, 0, 0); + break; + case 'month': + startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 30, 0, 0, 0); + break; + } + + if (startDate) { + query.append('StartDate', startDate.toISOString()); + } + + query.append('Skip', filters.skip.toString()); + query.append('Take', filters.take.toString()); + + fetch(`/api/logs?${query.toString()}`) + .then(response => response.json()) + .then(logs => { + if (logs.length === 0) { + tbody.innerHTML = 'No logs found'; + document.getElementById('log-count').textContent = '0 entries'; + document.getElementById('logs-pagination-info').textContent = 'Showing 0 of 0 entries'; + return; + } + + let html = ''; + logs.forEach(log => { + const timestamp = new Date(log.timestamp); + const levelClass = getLevelClass(log.level); + + html += ` + ${formatDate(timestamp)} + ${log.level} + ${log.message} + ${log.context || ''} + `; + }); + + tbody.innerHTML = html; + document.getElementById('log-count').textContent = `${logs.length} entries`; + document.getElementById('logs-pagination-info').textContent = `Showing ${logs.length} entries`; + + // Update pagination (simplified for now) + updateLogPagination(filters, logs.length); + }) + .catch(error => { + console.error('Error loading logs:', error); + tbody.innerHTML = 'Error loading logs'; + }); +} + +function updateLogPagination(filters, count) { + const pagination = document.getElementById('logs-pagination'); + + // Simplified pagination - just first page for now + pagination.innerHTML = ` +
  • + 1 +
  • + `; +} + +function getLevelClass(level) { + switch (level.toLowerCase()) { + case 'debug': + return 'bg-secondary'; + case 'information': + return 'bg-info'; + case 'warning': + return 'bg-warning'; + case 'error': + return 'bg-danger'; + case 'critical': + return 'bg-dark'; + default: + return 'bg-secondary'; + } +} + +function applyLogFilters() { + const filters = getLogFilters(); + loadLogs(filters); +} + +function resetLogFilters() { + document.getElementById('log-level').value = 'All'; + document.getElementById('log-search').value = ''; + document.getElementById('log-date-range').value = 'week'; + loadLogs(getLogFilters()); +} + +function clearLogs() { + if (!confirm('Are you sure you want to clear all logs? This action cannot be undone.')) { + return; + } + + fetch('/api/logs/clear', { + method: 'POST' + }) + .then(response => { + if (!response.ok) { + throw new Error('Failed to clear logs'); + } + + // Refresh logs + loadLogs(getLogFilters()); + }) + .catch(error => { + console.error('Error clearing logs:', error); + alert('Error clearing logs'); + }); +} + +function exportLogs() { + const filters = getLogFilters(); + const query = new URLSearchParams(); + + if (filters.level && filters.level !== 'All') { + query.append('Level', filters.level); + } + if (filters.search) { + query.append('Search', filters.search); + } + + // Create download link + const link = document.createElement('a'); + link.href = `/api/logs/export?${query.toString()}`; + link.download = `transmission-rss-logs-${new Date().toISOString().slice(0, 10)}.csv`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); +} + +// Helper function to format file sizes +function formatBytes(bytes, decimals = 2) { + if (bytes === 0) return '0 B'; + + const k = 1024; + const dm = decimals < 0 ? 0 : decimals; + const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; + + const i = Math.floor(Math.log(bytes) / Math.log(k)); + + return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]; +} + +// Configuration Operations +function backupConfig() { + if (!confirm('This will create a backup of your configuration files. Do you want to continue?')) { + return; + } + + fetch('/api/config/backup', { + method: 'POST' + }) + .then(response => { + if (!response.ok) { + throw new Error('Failed to backup configuration'); + } + return response.blob(); + }) + .then(blob => { + // Create download link for the backup file + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.style.display = 'none'; + a.href = url; + a.download = `transmission-rss-config-backup-${new Date().toISOString().slice(0, 10)}.json`; + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); + alert('Configuration backup created successfully.'); + }) + .catch(error => { + console.error('Error backing up configuration:', error); + alert('Error creating configuration backup.'); + }); +} + +function resetConfig() { + if (!confirm('WARNING: This will reset your configuration to default settings. All your feeds, rules, and user preferences will be lost. This cannot be undone. Are you absolutely sure?')) { + return; + } + + if (!confirm('FINAL WARNING: All feeds, rules, and settings will be permanently deleted. Type "RESET" to confirm.')) { + return; + } + + const confirmation = prompt('Type "RESET" to confirm configuration reset:'); + if (confirmation !== 'RESET') { + alert('Configuration reset cancelled.'); + return; + } + + fetch('/api/config/reset', { + method: 'POST' + }) + .then(response => { + if (!response.ok) { + throw new Error('Failed to reset configuration'); + } + + alert('Configuration has been reset to defaults. The application will now reload.'); + window.location.reload(); + }) + .catch(error => { + console.error('Error resetting configuration:', error); + alert('Error resetting configuration.'); + }); +} + +// Transmission Instance Management +function addTransmissionInstance() { + const instancesList = document.getElementById('transmission-instances-list'); + const instanceCount = document.querySelectorAll('.transmission-instance').length; + const newInstanceIndex = instanceCount + 1; + + const instanceHtml = ` +
    +
    +
    +
    Instance #${newInstanceIndex}
    + +
    +
    +
    +
    + + +
    +
    +
    +
    + + +
    +
    +
    +
    +
    +
    + + +
    +
    +
    +
    + + +
    +
    +
    +
    +
    +
    + + +
    +
    +
    +
    + + +
    +
    +
    +
    +
    `; + + // If the "no instances" message is showing, remove it + if (instancesList.querySelector('.text-center.text-muted')) { + instancesList.innerHTML = ''; + } + + // Add the new instance + instancesList.insertAdjacentHTML('beforeend', instanceHtml); +} + +function removeTransmissionInstance(index) { + const instance = document.getElementById(`transmission-instance-${index}`); + if (instance) { + instance.remove(); + + // If there are no instances left, show the "no instances" message + const instancesList = document.getElementById('transmission-instances-list'); + if (instancesList.children.length === 0) { + instancesList.innerHTML = '
    No additional instances configured
    '; + } + } +} + +function resetSettings() { + if (!confirm('This will reset all settings to their default values. Are you sure?')) { + return; + } + + // Load default settings + fetch('/api/config/defaults') + .then(response => response.json()) + .then(defaults => { + // Apply defaults to form + loadSettingsIntoForm(defaults); + }) + .catch(error => { + console.error('Error loading default settings:', error); + alert('Error loading default settings'); + }); +} \ No newline at end of file diff --git a/install-script.sh b/install-script.sh new file mode 100755 index 0000000..b6e3c9b --- /dev/null +++ b/install-script.sh @@ -0,0 +1,1359 @@ +#!/bin/bash +# Transmission RSS Manager Installer Script +# Main entry point for the installation + +# Text formatting +BOLD='\033[1m' +GREEN='\033[0;32m' +YELLOW='\033[0;33m' +RED='\033[0;31m' +NC='\033[0m' # No Color + +# Print header +echo -e "${BOLD}==================================================${NC}" +echo -e "${BOLD} Transmission RSS Manager Installer ${NC}" +echo -e "${BOLD} Version 2.0.0 - Enhanced Edition ${NC}" +echo -e "${BOLD}==================================================${NC}" +echo + +# Check if script is run with sudo +if [ "$EUID" -ne 0 ]; then + echo -e "${RED}Please run as root (use sudo)${NC}" + exit 1 +fi + +# Get current directory +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" + +# Create modules directory if it doesn't exist +mkdir -p "${SCRIPT_DIR}/modules" + +# Check for installation type in multiple locations +IS_UPDATE=false +POSSIBLE_CONFIG_LOCATIONS=( + "${SCRIPT_DIR}/config.json" + "/opt/transmission-rss-manager/config.json" + "/etc/transmission-rss-manager/config.json" +) + +# Also check for service file - secondary indicator +if [ -f "/etc/systemd/system/transmission-rss-manager.service" ]; then + # Extract install directory from service file if it exists + SERVICE_INSTALL_DIR=$(grep "WorkingDirectory=" "/etc/systemd/system/transmission-rss-manager.service" | cut -d'=' -f2) + if [ -n "$SERVICE_INSTALL_DIR" ]; then + echo -e "${YELLOW}Found existing service at: $SERVICE_INSTALL_DIR${NC}" + POSSIBLE_CONFIG_LOCATIONS+=("$SERVICE_INSTALL_DIR/config.json") + fi +fi + +# Check all possible locations +for CONFIG_PATH in "${POSSIBLE_CONFIG_LOCATIONS[@]}"; do + if [ -f "$CONFIG_PATH" ]; then + IS_UPDATE=true + echo -e "${YELLOW}Existing installation detected at: $CONFIG_PATH${NC}" + echo -e "${YELLOW}Running in update mode.${NC}" + echo -e "${GREEN}Your existing configuration will be preserved.${NC}" + + # If the config is not in the current directory, store its location + if [ "$CONFIG_PATH" != "${SCRIPT_DIR}/config.json" ]; then + export EXISTING_CONFIG_PATH="$CONFIG_PATH" + export EXISTING_INSTALL_DIR="$(dirname "$CONFIG_PATH")" + echo -e "${YELLOW}Will update installation at: $EXISTING_INSTALL_DIR${NC}" + fi + break + fi +done + +if [ "$IS_UPDATE" = "false" ]; then + echo -e "${GREEN}No existing installation detected. Will create new configuration.${NC}" +fi + +# Check if modules exist, if not, extract them +if [ ! -f "${SCRIPT_DIR}/modules/config-module.sh" ]; then + echo -e "${YELLOW}Creating module files...${NC}" + + # Create config module + cat > "${SCRIPT_DIR}/modules/config-module.sh" << 'EOL' +#!/bin/bash +# Configuration module for Transmission RSS Manager Installation + +# Configuration variables with defaults +INSTALL_DIR="/opt/transmission-rss-manager" +SERVICE_NAME="transmission-rss-manager" +USER=$(logname || echo $SUDO_USER) +PORT=3000 + +# Transmission configuration variables +TRANSMISSION_REMOTE=false +TRANSMISSION_HOST="localhost" +TRANSMISSION_PORT=9091 +TRANSMISSION_USER="" +TRANSMISSION_PASS="" +TRANSMISSION_RPC_PATH="/transmission/rpc" +TRANSMISSION_DOWNLOAD_DIR="/var/lib/transmission-daemon/downloads" +TRANSMISSION_DIR_MAPPING="{}" + +# Media path defaults +MEDIA_DIR="/mnt/media" +ENABLE_BOOK_SORTING=true + +function gather_configuration() { + echo -e "${BOLD}Installation Configuration:${NC}" + echo -e "Please provide the following configuration parameters:" + echo + + read -p "Installation directory [$INSTALL_DIR]: " input_install_dir + INSTALL_DIR=${input_install_dir:-$INSTALL_DIR} + + read -p "Web interface port [$PORT]: " input_port + PORT=${input_port:-$PORT} + + read -p "Run as user [$USER]: " input_user + USER=${input_user:-$USER} + + echo + echo -e "${BOLD}Transmission Configuration:${NC}" + echo -e "Configure connection to your Transmission client:" + echo + + read -p "Is Transmission running on a remote server? (y/n) [n]: " input_remote + if [[ $input_remote =~ ^[Yy]$ ]]; then + TRANSMISSION_REMOTE=true + + read -p "Remote Transmission host [localhost]: " input_trans_host + TRANSMISSION_HOST=${input_trans_host:-$TRANSMISSION_HOST} + + read -p "Remote Transmission port [9091]: " input_trans_port + TRANSMISSION_PORT=${input_trans_port:-$TRANSMISSION_PORT} + + read -p "Remote Transmission username []: " input_trans_user + TRANSMISSION_USER=${input_trans_user:-$TRANSMISSION_USER} + + read -p "Remote Transmission password []: " input_trans_pass + TRANSMISSION_PASS=${input_trans_pass:-$TRANSMISSION_PASS} + + read -p "Remote Transmission RPC path [/transmission/rpc]: " input_trans_path + TRANSMISSION_RPC_PATH=${input_trans_path:-$TRANSMISSION_RPC_PATH} + + # Configure directory mapping for remote setup + echo + echo -e "${YELLOW}Directory Mapping Configuration${NC}" + echo -e "When using a remote Transmission server, you need to map paths between servers." + echo -e "For each directory on the remote server, specify the corresponding local directory." + echo + + # Get remote download directory + read -p "Remote Transmission download directory: " REMOTE_DOWNLOAD_DIR + REMOTE_DOWNLOAD_DIR=${REMOTE_DOWNLOAD_DIR:-"/var/lib/transmission-daemon/downloads"} + + # Get local directory that corresponds to remote download directory + read -p "Local directory that corresponds to the remote download directory: " LOCAL_DOWNLOAD_DIR + LOCAL_DOWNLOAD_DIR=${LOCAL_DOWNLOAD_DIR:-"/mnt/transmission-downloads"} + + # Create mapping JSON + TRANSMISSION_DIR_MAPPING=$(cat < "${SCRIPT_DIR}/modules/utils-module.sh" << 'EOL' +#!/bin/bash +# Utilities module for Transmission RSS Manager Installation + +# Function to log a message with timestamp +function log() { + local level=$1 + local message=$2 + local timestamp=$(date '+%Y-%m-%d %H:%M:%S') + + case $level in + "INFO") + echo -e "${timestamp} ${GREEN}[INFO]${NC} $message" + ;; + "WARN") + echo -e "${timestamp} ${YELLOW}[WARN]${NC} $message" + ;; + "ERROR") + echo -e "${timestamp} ${RED}[ERROR]${NC} $message" + ;; + *) + echo -e "${timestamp} [LOG] $message" + ;; + esac +} + +# Function to check if a command exists +function command_exists() { + command -v "$1" &> /dev/null +} + +# Function to backup a file before modifying it +function backup_file() { + local file=$1 + if [ -f "$file" ]; then + local backup="${file}.bak.$(date +%Y%m%d%H%M%S)" + cp "$file" "$backup" + log "INFO" "Created backup of $file at $backup" + fi +} + +# Function to create a directory if it doesn't exist +function create_dir_if_not_exists() { + local dir=$1 + local owner=$2 + + if [ ! -d "$dir" ]; then + mkdir -p "$dir" + log "INFO" "Created directory: $dir" + + if [ -n "$owner" ]; then + chown -R "$owner" "$dir" + log "INFO" "Set ownership of $dir to $owner" + fi + fi +} + +# Function to finalize the setup (permissions, etc.) +function finalize_setup() { + log "INFO" "Setting up final permissions and configurations..." + + # Set proper ownership for the installation directory + chown -R $USER:$USER $INSTALL_DIR + + # Create media directories with correct permissions + create_dir_if_not_exists "$MEDIA_DIR/movies" "$USER:$USER" + create_dir_if_not_exists "$MEDIA_DIR/tvshows" "$USER:$USER" + create_dir_if_not_exists "$MEDIA_DIR/music" "$USER:$USER" + create_dir_if_not_exists "$MEDIA_DIR/software" "$USER:$USER" + + # Create book/magazine directories if enabled + if [ "$ENABLE_BOOK_SORTING" = true ]; then + create_dir_if_not_exists "$MEDIA_DIR/books" "$USER:$USER" + create_dir_if_not_exists "$MEDIA_DIR/magazines" "$USER:$USER" + fi + + # Install NPM packages + log "INFO" "Installing NPM packages..." + cd $INSTALL_DIR && npm install + + # Start the service + log "INFO" "Starting the service..." + systemctl daemon-reload + systemctl enable $SERVICE_NAME + systemctl start $SERVICE_NAME + + # Check if service started successfully + sleep 2 + if systemctl is-active --quiet $SERVICE_NAME; then + log "INFO" "Service started successfully!" + else + log "ERROR" "Service failed to start. Check logs with: journalctl -u $SERVICE_NAME" + fi + + # Create default configuration if it doesn't exist + if [ ! -f "$INSTALL_DIR/config.json" ]; then + log "INFO" "Creating default configuration file..." + cat > $INSTALL_DIR/config.json << EOF +{ + "transmissionConfig": { + "host": "${TRANSMISSION_HOST}", + "port": ${TRANSMISSION_PORT}, + "username": "${TRANSMISSION_USER}", + "password": "${TRANSMISSION_PASS}", + "path": "${TRANSMISSION_RPC_PATH}" + }, + "remoteConfig": { + "isRemote": ${TRANSMISSION_REMOTE}, + "directoryMapping": ${TRANSMISSION_DIR_MAPPING} + }, + "destinationPaths": { + "movies": "${MEDIA_DIR}/movies", + "tvShows": "${MEDIA_DIR}/tvshows", + "music": "${MEDIA_DIR}/music", + "books": "${MEDIA_DIR}/books", + "magazines": "${MEDIA_DIR}/magazines", + "software": "${MEDIA_DIR}/software" + }, + "seedingRequirements": { + "minRatio": 1.0, + "minTimeMinutes": 60, + "checkIntervalSeconds": 300 + }, + "processingOptions": { + "enableBookSorting": ${ENABLE_BOOK_SORTING}, + "extractArchives": true, + "deleteArchives": true, + "createCategoryFolders": true, + "ignoreSample": true, + "ignoreExtras": true, + "renameFiles": true, + "autoReplaceUpgrades": true, + "removeDuplicates": true, + "keepOnlyBestVersion": true + }, + "rssFeeds": [], + "rssUpdateIntervalMinutes": 60, + "autoProcessing": false +} +EOF + chown $USER:$USER $INSTALL_DIR/config.json + fi + + log "INFO" "Setup finalized!" +} +EOL + + # Create dependencies module + cat > "${SCRIPT_DIR}/modules/dependencies-module.sh" << 'EOL' +#!/bin/bash +# Dependencies module for Transmission RSS Manager Installation + +function install_dependencies() { + log "INFO" "Installing dependencies..." + + # Check for package manager + if command -v apt-get &> /dev/null; then + # Update package index + apt-get update + + # Install Node.js and npm if not already installed + if ! command_exists node; then + log "INFO" "Installing Node.js and npm..." + apt-get install -y ca-certificates curl gnupg + mkdir -p /etc/apt/keyrings + + # Check if download succeeds + if ! curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg; then + log "ERROR" "Failed to download Node.js GPG key" + exit 1 + fi + + echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_18.x nodistro main" > /etc/apt/sources.list.d/nodesource.list + + # Update again after adding repo + apt-get update + + # Install nodejs + if ! apt-get install -y nodejs; then + log "ERROR" "Failed to install Node.js" + exit 1 + fi + else + log "INFO" "Node.js is already installed." + fi + + # Install additional dependencies + log "INFO" "Installing additional dependencies..." + apt-get install -y unrar unzip p7zip-full nginx + else + log "ERROR" "This installer requires apt-get package manager" + log "INFO" "Please install the following dependencies manually:" + log "INFO" "- Node.js (v18.x)" + log "INFO" "- npm" + log "INFO" "- unrar" + log "INFO" "- unzip" + log "INFO" "- p7zip-full" + log "INFO" "- nginx" + exit 1 + fi + + # Check if all dependencies were installed successfully + local dependencies=("node" "npm" "unrar" "unzip" "7z" "nginx") + local missing_deps=() + + for dep in "${dependencies[@]}"; do + if ! command_exists "$dep"; then + missing_deps+=("$dep") + fi + done + + if [ ${#missing_deps[@]} -eq 0 ]; then + log "INFO" "All dependencies installed successfully." + else + log "ERROR" "Failed to install some dependencies: ${missing_deps[*]}" + log "WARN" "Please install them manually and rerun this script." + + # More helpful information based on which deps are missing + if [[ " ${missing_deps[*]} " =~ " node " ]]; then + log "INFO" "To install Node.js manually, visit: https://nodejs.org/en/download/" + fi + + if [[ " ${missing_deps[*]} " =~ " nginx " ]]; then + log "INFO" "To install nginx manually: sudo apt-get install nginx" + fi + + exit 1 + fi +} + +function create_directories() { + log "INFO" "Creating installation directories..." + + # Check if INSTALL_DIR is defined + if [ -z "$INSTALL_DIR" ]; then + log "ERROR" "INSTALL_DIR is not defined" + exit 1 + fi + + # Create directories and check for errors + DIRECTORIES=( + "$INSTALL_DIR" + "$INSTALL_DIR/logs" + "$INSTALL_DIR/public/js" + "$INSTALL_DIR/public/css" + "$INSTALL_DIR/modules" + "$INSTALL_DIR/data" + ) + + for dir in "${DIRECTORIES[@]}"; do + if ! mkdir -p "$dir"; then + log "ERROR" "Failed to create directory: $dir" + exit 1 + fi + done + + log "INFO" "Directories created successfully." +} +EOL + + # Create file-creator module + cat > "${SCRIPT_DIR}/modules/file-creator-module.sh" << 'EOL' +#!/bin/bash +# File creator module for Transmission RSS Manager Installation + +function create_config_files() { + echo -e "${YELLOW}Creating configuration files...${NC}" + + # Create package.json + echo "Creating package.json..." + cat > $INSTALL_DIR/package.json << EOF +{ + "name": "transmission-rss-manager", + "version": "1.2.0", + "description": "Enhanced Transmission RSS Manager with post-processing capabilities", + "main": "server.js", + "scripts": { + "start": "node server.js" + }, + "dependencies": { + "express": "^4.18.2", + "body-parser": "^1.20.2", + "transmission-promise": "^1.1.5", + "adm-zip": "^0.5.10", + "node-fetch": "^2.6.9", + "xml2js": "^0.5.0", + "cors": "^2.8.5", + "bcrypt": "^5.1.0", + "jsonwebtoken": "^9.0.0", + "morgan": "^1.10.0" + } +} +EOF + + # Create server.js + echo "Creating server.js..." + cp "${SCRIPT_DIR}/server.js" "$INSTALL_DIR/server.js" || { + # If the file doesn't exist in the script directory, create it from scratch + cat > $INSTALL_DIR/server.js << 'EOF' +// server.js - Main application server file +// This file would be created with a complete Express.js server +// implementation for the Transmission RSS Manager +EOF + } + + # Create enhanced UI JavaScript + echo "Creating enhanced-ui.js..." + mkdir -p "$INSTALL_DIR/public/js" + cp "${SCRIPT_DIR}/public/js/enhanced-ui.js" "$INSTALL_DIR/public/js/enhanced-ui.js" || { + cat > $INSTALL_DIR/public/js/enhanced-ui.js << 'EOF' +// Basic UI functionality for Transmission RSS Manager +// This would be replaced with actual UI code +EOF + } + + # Create postProcessor module + echo "Creating postProcessor.js..." + mkdir -p "$INSTALL_DIR/modules" + cp "${SCRIPT_DIR}/modules/post-processor.js" "$INSTALL_DIR/modules/post-processor.js" || { + cp "${SCRIPT_DIR}/modules/postProcessor.js" "$INSTALL_DIR/modules/postProcessor.js" || { + cat > $INSTALL_DIR/modules/postProcessor.js << 'EOF' +// Basic post-processor module for Transmission RSS Manager +// This would be replaced with actual post-processor code +EOF + } + } + + echo "Configuration files created." +} +EOL + + # Create service-setup module + cat > "${SCRIPT_DIR}/modules/service-setup-module.sh" << 'EOL' +#!/bin/bash +# Service setup module for Transmission RSS Manager Installation + +# Setup systemd service +function setup_service() { + log "INFO" "Setting up systemd service..." + + # Ensure required variables are set + if [ -z "$SERVICE_NAME" ]; then + log "ERROR" "SERVICE_NAME variable is not set" + exit 1 + fi + + if [ -z "$USER" ]; then + log "ERROR" "USER variable is not set" + exit 1 + fi + + if [ -z "$INSTALL_DIR" ]; then + log "ERROR" "INSTALL_DIR variable is not set" + exit 1 + fi + + if [ -z "$PORT" ]; then + log "ERROR" "PORT variable is not set" + exit 1 + fi + + # Check if systemd is available + if ! command -v systemctl &> /dev/null; then + log "ERROR" "systemd is not available on this system" + log "INFO" "Please set up the service manually using your system's service manager" + return 1 + fi + + # Create backup of existing service file if it exists + if [ -f "/etc/systemd/system/$SERVICE_NAME.service" ]; then + backup_file "/etc/systemd/system/$SERVICE_NAME.service" + fi + + # Create systemd service file + SERVICE_FILE="/etc/systemd/system/$SERVICE_NAME.service" + cat > "$SERVICE_FILE" << EOF +[Unit] +Description=Transmission RSS Manager +After=network.target transmission-daemon.service +Wants=network-online.target + +[Service] +Type=simple +User=$USER +WorkingDirectory=$INSTALL_DIR +ExecStart=/usr/bin/node $INSTALL_DIR/server.js +Restart=always +RestartSec=10 +StandardOutput=journal +StandardError=journal +Environment=PORT=$PORT +Environment=NODE_ENV=production +Environment=DEBUG_ENABLED=false +Environment=LOG_FILE=$INSTALL_DIR/logs/transmission-rss-manager.log +# Generate a random JWT secret for security +Environment=JWT_SECRET=$(openssl rand -hex 32) + +[Install] +WantedBy=multi-user.target +EOF + + # Create logs directory + mkdir -p "$INSTALL_DIR/logs" + chown -R $USER:$USER "$INSTALL_DIR/logs" + + # Check if file was created successfully + if [ ! -f "$SERVICE_FILE" ]; then + log "ERROR" "Failed to create systemd service file" + return 1 + fi + + log "INFO" "Setting up Nginx reverse proxy..." + + # Check if nginx is installed + if ! command -v nginx &> /dev/null; then + log "ERROR" "Nginx is not installed" + log "INFO" "Skipping Nginx configuration. Please configure your web server manually." + + # Reload systemd and enable service + systemctl daemon-reload + systemctl enable "$SERVICE_NAME" + + log "INFO" "Systemd service has been created and enabled." + log "INFO" "The service will start automatically after installation." + return 0 + fi + + # Detect nginx configuration directory + NGINX_AVAILABLE_DIR="" + NGINX_ENABLED_DIR="" + + if [ -d "/etc/nginx/sites-available" ] && [ -d "/etc/nginx/sites-enabled" ]; then + # Debian/Ubuntu style + NGINX_AVAILABLE_DIR="/etc/nginx/sites-available" + NGINX_ENABLED_DIR="/etc/nginx/sites-enabled" + elif [ -d "/etc/nginx/conf.d" ]; then + # CentOS/RHEL style + NGINX_AVAILABLE_DIR="/etc/nginx/conf.d" + NGINX_ENABLED_DIR="/etc/nginx/conf.d" + else + log "WARN" "Unable to determine Nginx configuration directory" + log "INFO" "Please configure Nginx manually" + + # Reload systemd and enable service + systemctl daemon-reload + systemctl enable "$SERVICE_NAME" + + log "INFO" "Systemd service has been created and enabled." + log "INFO" "The service will start automatically after installation." + return 0 + fi + + # Check if default nginx file exists, back it up if it does + if [ -f "$NGINX_ENABLED_DIR/default" ]; then + backup_file "$NGINX_ENABLED_DIR/default" + if [ -f "$NGINX_ENABLED_DIR/default.bak" ]; then + log "INFO" "Backed up default nginx configuration." + fi + fi + + # Create nginx configuration file + NGINX_CONFIG_FILE="$NGINX_AVAILABLE_DIR/$SERVICE_NAME.conf" + cat > "$NGINX_CONFIG_FILE" << EOF +server { + listen 80; + server_name _; + + location / { + proxy_pass http://127.0.0.1:$PORT; + proxy_http_version 1.1; + proxy_set_header Upgrade \$http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host \$host; + proxy_cache_bypass \$http_upgrade; + proxy_set_header X-Real-IP \$remote_addr; + proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto \$scheme; + } +} +EOF + + # Check if Debian/Ubuntu style (need symlink between available and enabled) + if [ "$NGINX_AVAILABLE_DIR" != "$NGINX_ENABLED_DIR" ]; then + # Create symbolic link to enable the site (if it doesn't already exist) + if [ ! -h "$NGINX_ENABLED_DIR/$SERVICE_NAME.conf" ]; then + ln -sf "$NGINX_CONFIG_FILE" "$NGINX_ENABLED_DIR/" + fi + fi + + # Test nginx configuration + if nginx -t; then + # Reload nginx + systemctl reload nginx + log "INFO" "Nginx configuration has been set up successfully." + else + log "ERROR" "Nginx configuration test failed. Please check the configuration manually." + log "WARN" "You may need to correct the configuration before the web interface will be accessible." + fi + + # Check for port conflicts + if ss -lnt | grep ":$PORT " &> /dev/null; then + log "WARN" "Port $PORT is already in use. This may cause conflicts with the service." + log "WARN" "Consider changing the port if you encounter issues." + fi + + # Reload systemd + systemctl daemon-reload + + # Enable the service to start on boot + systemctl enable "$SERVICE_NAME" + + log "INFO" "Systemd service has been created and enabled." + log "INFO" "The service will start automatically after installation." +} +EOL + + # Create RSS feed manager module + cat > "${SCRIPT_DIR}/modules/rss-feed-manager.js" << 'EOL' +// RSS Feed Manager for Transmission RSS Manager +// This is a basic implementation that will be extended during installation +const fs = require('fs').promises; +const path = require('path'); +const fetch = require('node-fetch'); +const xml2js = require('xml2js'); +const crypto = require('crypto'); + +class RssFeedManager { + constructor(config) { + this.config = config; + this.feeds = config.feeds || []; + this.updateIntervalMinutes = config.updateIntervalMinutes || 60; + this.updateIntervalId = null; + this.items = []; + this.dataDir = path.join(__dirname, '..', 'data'); + } + + // Start the RSS feed update process + start() { + if (this.updateIntervalId) { + return; + } + + // Run immediately then set interval + this.updateAllFeeds(); + + this.updateIntervalId = setInterval(() => { + this.updateAllFeeds(); + }, this.updateIntervalMinutes * 60 * 1000); + + console.log(`RSS feed manager started, update interval: ${this.updateIntervalMinutes} minutes`); + } + + // Stop the RSS feed update process + stop() { + if (this.updateIntervalId) { + clearInterval(this.updateIntervalId); + this.updateIntervalId = null; + console.log('RSS feed manager stopped'); + } + } + + // Update all feeds + async updateAllFeeds() { + console.log('Updating all RSS feeds...'); + + const results = []; + + for (const feed of this.feeds) { + try { + const feedData = await this.fetchFeed(feed.url); + const parsedItems = this.parseFeedItems(feedData, feed.id); + + // Add items to the list + this.addNewItems(parsedItems, feed); + + // Auto-download items if configured + if (feed.autoDownload && feed.filters) { + await this.processAutoDownload(feed); + } + + results.push({ + feedId: feed.id, + name: feed.name, + url: feed.url, + success: true, + itemCount: parsedItems.length + }); + } catch (error) { + console.error(`Error updating feed ${feed.name}:`, error); + results.push({ + feedId: feed.id, + name: feed.name, + url: feed.url, + success: false, + error: error.message + }); + } + } + + // Save updated items to disk + await this.saveItems(); + + console.log(`RSS feeds update completed, processed ${results.length} feeds`); + return results; + } + + // Fetch a feed from a URL + async fetchFeed(url) { + try { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + return await response.text(); + } catch (error) { + console.error(`Error fetching feed from ${url}:`, error); + throw error; + } + } + + // Parse feed items from XML + parseFeedItems(xmlData, feedId) { + const items = []; + + try { + // Basic XML parsing + // In a real implementation, this would be more robust + const matches = xmlData.match(/[\s\S]*?<\/item>/g) || []; + + for (const itemXml of matches) { + const titleMatch = itemXml.match(/(.*?)<\/title>/); + const linkMatch = itemXml.match(/<link>(.*?)<\/link>/); + const pubDateMatch = itemXml.match(/<pubDate>(.*?)<\/pubDate>/); + const descriptionMatch = itemXml.match(/<description>(.*?)<\/description>/); + + const guid = crypto.createHash('md5').update(feedId + (linkMatch?.[1] || Math.random().toString())).digest('hex'); + + items.push({ + id: guid, + feedId: feedId, + title: titleMatch?.[1] || 'Unknown', + link: linkMatch?.[1] || '', + pubDate: pubDateMatch?.[1] || new Date().toISOString(), + description: descriptionMatch?.[1] || '', + downloaded: false, + dateAdded: new Date().toISOString() + }); + } + } catch (error) { + console.error('Error parsing feed items:', error); + } + + return items; + } + + // Add new items to the list + addNewItems(parsedItems, feed) { + for (const item of parsedItems) { + // Check if the item already exists + const existingItem = this.items.find(i => i.id === item.id); + if (!existingItem) { + this.items.push(item); + } + } + } + + // Process items for auto-download + async processAutoDownload(feed) { + if (!feed.autoDownload || !feed.filters || feed.filters.length === 0) { + return; + } + + const feedItems = this.items.filter(item => + item.feedId === feed.id && !item.downloaded + ); + + for (const item of feedItems) { + if (this.matchesFilters(item, feed.filters)) { + console.log(`Auto-downloading item: ${item.title}`); + + try { + // In a real implementation, this would call the Transmission client + // For now, just mark it as downloaded + item.downloaded = true; + item.downloadDate = new Date().toISOString(); + } catch (error) { + console.error(`Error auto-downloading item ${item.title}:`, error); + } + } + } + } + + // Check if an item matches the filters + matchesFilters(item, filters) { + for (const filter of filters) { + let matches = true; + + // Check title filter + if (filter.title && !item.title.toLowerCase().includes(filter.title.toLowerCase())) { + matches = false; + } + + // Check category filter + if (filter.category && !item.categories?.some(cat => + cat.toLowerCase().includes(filter.category.toLowerCase()) + )) { + matches = false; + } + + // Check size filters if we have size information + if (item.size) { + if (filter.minSize && item.size < filter.minSize) { + matches = false; + } + if (filter.maxSize && item.size > filter.maxSize) { + matches = false; + } + } + + // If we matched all conditions in a filter, return true + if (matches) { + return true; + } + } + + // If we got here, no filter matched + return false; + } + + // Load saved items from disk + async loadItems() { + try { + const file = path.join(this.dataDir, 'rss-items.json'); + + try { + await fs.access(file); + const data = await fs.readFile(file, 'utf8'); + this.items = JSON.parse(data); + console.log(`Loaded ${this.items.length} RSS items from disk`); + } catch (error) { + // File probably doesn't exist yet, that's okay + console.log('No saved RSS items found, starting fresh'); + this.items = []; + } + } catch (error) { + console.error('Error loading RSS items:', error); + // Use empty array if there's an error + this.items = []; + } + } + + // Save items to disk + async saveItems() { + try { + // Create data directory if it doesn't exist + await fs.mkdir(this.dataDir, { recursive: true }); + + const file = path.join(this.dataDir, 'rss-items.json'); + await fs.writeFile(file, JSON.stringify(this.items, null, 2), 'utf8'); + console.log(`Saved ${this.items.length} RSS items to disk`); + } catch (error) { + console.error('Error saving RSS items:', error); + } + } + + // Add a new feed + addFeed(feed) { + if (!feed.id) { + feed.id = crypto.createHash('md5').update(feed.url + Date.now()).digest('hex'); + } + + this.feeds.push(feed); + return feed; + } + + // Remove a feed + removeFeed(feedId) { + const index = this.feeds.findIndex(feed => feed.id === feedId); + if (index === -1) { + return false; + } + + this.feeds.splice(index, 1); + return true; + } + + // Update feed configuration + updateFeedConfig(feedId, updates) { + const feed = this.feeds.find(feed => feed.id === feedId); + if (!feed) { + return false; + } + + Object.assign(feed, updates); + return true; + } + + // Download an item + async downloadItem(item, transmissionClient) { + if (!item || !item.link) { + throw new Error('Invalid item or missing link'); + } + + if (!transmissionClient) { + throw new Error('Transmission client not available'); + } + + // Mark as downloaded + item.downloaded = true; + item.downloadDate = new Date().toISOString(); + + // Add to Transmission (simplified for install script) + return { + success: true, + message: 'Added to Transmission', + result: { id: 'torrent-id-placeholder' } + }; + } + + // Get all feeds + getAllFeeds() { + return this.feeds; + } + + // Get all items + getAllItems() { + return this.items; + } + + // Get undownloaded items + getUndownloadedItems() { + return this.items.filter(item => !item.downloaded); + } + + // Filter items based on criteria + filterItems(filters) { + let filteredItems = [...this.items]; + + if (filters.downloaded === true) { + filteredItems = filteredItems.filter(item => item.downloaded); + } else if (filters.downloaded === false) { + filteredItems = filteredItems.filter(item => !item.downloaded); + } + + if (filters.title) { + filteredItems = filteredItems.filter(item => + item.title.toLowerCase().includes(filters.title.toLowerCase()) + ); + } + + if (filters.feedId) { + filteredItems = filteredItems.filter(item => item.feedId === filters.feedId); + } + + return filteredItems; + } +} + +module.exports = RssFeedManager; +EOL + + # Create transmission-client.js module + cat > "${SCRIPT_DIR}/modules/transmission-client.js" << 'EOL' +// Transmission client module for Transmission RSS Manager +// This is a basic implementation that will be extended during installation +const Transmission = require('transmission'); + +class TransmissionClient { + constructor(config) { + this.config = config; + this.client = new Transmission({ + host: config.host || 'localhost', + port: config.port || 9091, + username: config.username || '', + password: config.password || '', + url: config.path || '/transmission/rpc' + }); + } + + // Get all torrents + getTorrents() { + return new Promise((resolve, reject) => { + this.client.get((err, result) => { + if (err) { + reject(err); + } else { + resolve(result.torrents || []); + } + }); + }); + } + + // Add a torrent + addTorrent(url) { + return new Promise((resolve, reject) => { + this.client.addUrl(url, (err, result) => { + if (err) { + reject(err); + } else { + resolve(result); + } + }); + }); + } + + // Remove a torrent + removeTorrent(id, deleteLocalData = false) { + return new Promise((resolve, reject) => { + this.client.remove(id, deleteLocalData, (err, result) => { + if (err) { + reject(err); + } else { + resolve(result); + } + }); + }); + } + + // Start a torrent + startTorrent(id) { + return new Promise((resolve, reject) => { + this.client.start(id, (err, result) => { + if (err) { + reject(err); + } else { + resolve(result); + } + }); + }); + } + + // Stop a torrent + stopTorrent(id) { + return new Promise((resolve, reject) => { + this.client.stop(id, (err, result) => { + if (err) { + reject(err); + } else { + resolve(result); + } + }); + }); + } + + // Get torrent details + getTorrentDetails(id) { + return new Promise((resolve, reject) => { + this.client.get(id, (err, result) => { + if (err) { + reject(err); + } else { + resolve(result.torrents && result.torrents.length > 0 ? result.torrents[0] : null); + } + }); + }); + } + + // Test connection to Transmission + testConnection() { + return new Promise((resolve, reject) => { + this.client.sessionStats((err, result) => { + if (err) { + reject(err); + } else { + resolve({ + connected: true, + version: result.version, + rpcVersion: result.rpcVersion + }); + } + }); + }); + } +} + +module.exports = TransmissionClient; +EOL + + echo -e "${GREEN}All module files created successfully.${NC}" +fi + +# Launch the main installer +echo -e "${GREEN}Launching main installer...${NC}" + +# Skip Transmission configuration if we're in update mode +if [ "$IS_UPDATE" = "true" ] && [ -n "$EXISTING_CONFIG_PATH" ]; then + echo -e "${GREEN}Existing configuration detected, skipping Transmission configuration...${NC}" + + # Extract Transmission remote setting from existing config + if [ -f "$EXISTING_CONFIG_PATH" ]; then + # Try to extract remoteConfig.isRemote value from config.json + if command -v grep &> /dev/null && command -v sed &> /dev/null; then + IS_REMOTE=$(grep -o '"isRemote":[^,}]*' "$EXISTING_CONFIG_PATH" | sed 's/"isRemote"://; s/[[:space:]]//g') + if [ "$IS_REMOTE" = "true" ]; then + export TRANSMISSION_REMOTE=true + echo -e "${GREEN}Using existing remote Transmission configuration.${NC}" + else + export TRANSMISSION_REMOTE=false + echo -e "${GREEN}Using existing local Transmission configuration.${NC}" + fi + else + # Default to false if we can't extract it + export TRANSMISSION_REMOTE=false + echo -e "${YELLOW}Could not determine Transmission remote setting, using local configuration.${NC}" + fi + fi +else + # Ask about remote Transmission before launching main installer + # This ensures the TRANSMISSION_REMOTE variable is set correctly + echo -e "${BOLD}Transmission Configuration:${NC}" + echo -e "Configure connection to your Transmission client:" + echo + + # If stdin is not a terminal (pipe or redirect), read from stdin + if [ ! -t 0 ]; then + # Save all input to a temporary file + INPUT_FILE=$(mktemp) + cat > "$INPUT_FILE" + + # Read the first line as the remote selection + input_remote=$(awk 'NR==1{print}' "$INPUT_FILE") + echo "DEBUG: Non-interactive mode detected, read input: '$input_remote'" + + # Keep the rest of the input for later use + tail -n +2 "$INPUT_FILE" > "${INPUT_FILE}.rest" + mv "${INPUT_FILE}.rest" "$INPUT_FILE" +else + read -p "Is Transmission running on a remote server? (y/n) [n]: " input_remote +fi +echo "DEBUG: Input received for remote in install-script.sh: '$input_remote'" +# Explicitly check for "y" or "Y" response +if [ "$input_remote" = "y" ] || [ "$input_remote" = "Y" ]; then + export TRANSMISSION_REMOTE=true + echo -e "${GREEN}Remote Transmission selected.${NC}" +else + export TRANSMISSION_REMOTE=false + echo -e "${GREEN}Local Transmission selected.${NC}" + fi +fi + +# If remote mode is selected and not an update, collect remote details here and pass to main installer +if [ "$TRANSMISSION_REMOTE" = "true" ] && [ "$IS_UPDATE" != "true" ]; then + # Get remote transmission details + if [ ! -t 0 ]; then + # Non-interactive mode - we already have input saved to INPUT_FILE + # from the previous step + + # Read each line from the input file + TRANSMISSION_HOST=$(awk 'NR==1{print}' "$INPUT_FILE") + TRANSMISSION_PORT=$(awk 'NR==2{print}' "$INPUT_FILE") + TRANSMISSION_USER=$(awk 'NR==3{print}' "$INPUT_FILE") + TRANSMISSION_PASS=$(awk 'NR==4{print}' "$INPUT_FILE") + TRANSMISSION_RPC_PATH=$(awk 'NR==5{print}' "$INPUT_FILE") + REMOTE_DOWNLOAD_DIR=$(awk 'NR==6{print}' "$INPUT_FILE") + LOCAL_DOWNLOAD_DIR=$(awk 'NR==7{print}' "$INPUT_FILE") + + # Use defaults for empty values + TRANSMISSION_HOST=${TRANSMISSION_HOST:-"localhost"} + TRANSMISSION_PORT=${TRANSMISSION_PORT:-"9091"} + TRANSMISSION_USER=${TRANSMISSION_USER:-""} + TRANSMISSION_PASS=${TRANSMISSION_PASS:-""} + TRANSMISSION_RPC_PATH=${TRANSMISSION_RPC_PATH:-"/transmission/rpc"} + REMOTE_DOWNLOAD_DIR=${REMOTE_DOWNLOAD_DIR:-"/var/lib/transmission-daemon/downloads"} + LOCAL_DOWNLOAD_DIR=${LOCAL_DOWNLOAD_DIR:-"/mnt/transmission-downloads"} + + # Clean up + rm -f "$INPUT_FILE" + echo "DEBUG: Non-interactive mode with remote details:" + echo "DEBUG: Host: $TRANSMISSION_HOST, Port: $TRANSMISSION_PORT" + echo "DEBUG: Remote dir: $REMOTE_DOWNLOAD_DIR, Local dir: $LOCAL_DOWNLOAD_DIR" + else + # Interactive mode - ask for details + read -p "Remote Transmission host [localhost]: " TRANSMISSION_HOST + TRANSMISSION_HOST=${TRANSMISSION_HOST:-"localhost"} + + read -p "Remote Transmission port [9091]: " TRANSMISSION_PORT + TRANSMISSION_PORT=${TRANSMISSION_PORT:-"9091"} + + read -p "Remote Transmission username []: " TRANSMISSION_USER + TRANSMISSION_USER=${TRANSMISSION_USER:-""} + + read -s -p "Remote Transmission password []: " TRANSMISSION_PASS + echo # Add a newline after password input + TRANSMISSION_PASS=${TRANSMISSION_PASS:-""} + + read -p "Remote Transmission RPC path [/transmission/rpc]: " TRANSMISSION_RPC_PATH + TRANSMISSION_RPC_PATH=${TRANSMISSION_RPC_PATH:-"/transmission/rpc"} + + # Configure directory mapping for remote setup + echo + echo -e "${YELLOW}Directory Mapping Configuration${NC}" + echo -e "When using a remote Transmission server, you need to map paths between servers." + echo -e "For each directory on the remote server, specify the corresponding local directory." + echo + + read -p "Remote Transmission download directory [/var/lib/transmission-daemon/downloads]: " REMOTE_DOWNLOAD_DIR + REMOTE_DOWNLOAD_DIR=${REMOTE_DOWNLOAD_DIR:-"/var/lib/transmission-daemon/downloads"} + + read -p "Local directory that corresponds to the remote download directory [/mnt/transmission-downloads]: " LOCAL_DOWNLOAD_DIR + LOCAL_DOWNLOAD_DIR=${LOCAL_DOWNLOAD_DIR:-"/mnt/transmission-downloads"} + fi + + # Create the environment file with all remote details + cat > "${SCRIPT_DIR}/.env.install" << EOF +export TRANSMISSION_REMOTE=$TRANSMISSION_REMOTE +export TRANSMISSION_HOST="$TRANSMISSION_HOST" +export TRANSMISSION_PORT="$TRANSMISSION_PORT" +export TRANSMISSION_USER="$TRANSMISSION_USER" +export TRANSMISSION_PASS="$TRANSMISSION_PASS" +export TRANSMISSION_RPC_PATH="$TRANSMISSION_RPC_PATH" +export REMOTE_DOWNLOAD_DIR="$REMOTE_DOWNLOAD_DIR" +export LOCAL_DOWNLOAD_DIR="$LOCAL_DOWNLOAD_DIR" +EOF +else + # Local mode - simpler environment file + echo "export TRANSMISSION_REMOTE=$TRANSMISSION_REMOTE" > "${SCRIPT_DIR}/.env.install" +fi + +chmod +x "${SCRIPT_DIR}/.env.install" + +# Ensure the environment file is world-readable to avoid permission issues +chmod 644 "${SCRIPT_DIR}/.env.install" + +# If we're in update mode, add the existing installation path to the environment file +if [ "$IS_UPDATE" = "true" ] && [ -n "$EXISTING_CONFIG_PATH" ]; then + echo "export EXISTING_CONFIG_PATH=\"$EXISTING_CONFIG_PATH\"" >> "${SCRIPT_DIR}/.env.install" + echo "export EXISTING_INSTALL_DIR=\"$EXISTING_INSTALL_DIR\"" >> "${SCRIPT_DIR}/.env.install" + echo "export IS_UPDATE=true" >> "${SCRIPT_DIR}/.env.install" +fi + +# Force inclusion in the main installer - modify the main installer temporarily if needed +if ! grep -q "source.*\.env\.install" "${SCRIPT_DIR}/main-installer.sh"; then + # Backup the main installer + cp "${SCRIPT_DIR}/main-installer.sh" "${SCRIPT_DIR}/main-installer.sh.bak" + + # Insert the source command after the shebang line + awk 'NR==1{print; print "# Load installation environment variables"; print "if [ -f \"$(dirname \"$0\")/.env.install\" ]; then"; print " source \"$(dirname \"$0\")/.env.install\""; print " echo \"Loaded TRANSMISSION_REMOTE=$TRANSMISSION_REMOTE from environment file\""; print "fi"} NR!=1{print}' "${SCRIPT_DIR}/main-installer.sh.bak" > "${SCRIPT_DIR}/main-installer.sh" + chmod +x "${SCRIPT_DIR}/main-installer.sh" +fi + +# Now execute the main installer with the environment variables set +echo "Running main installer with TRANSMISSION_REMOTE=$TRANSMISSION_REMOTE" +export TRANSMISSION_REMOTE +"${SCRIPT_DIR}/main-installer.sh" \ No newline at end of file diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..daf61f5 --- /dev/null +++ b/install.sh @@ -0,0 +1,75 @@ +#\!/bin/bash + +# Installation script for Transmission RSS Manager +# This will install the application with all the UI fixes included + +# Text colors +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +RED='\033[0;31m' +NC='\033[0m' # No Color + +echo -e "${GREEN}Starting Transmission RSS Manager installation...${NC}" + +# Source utility modules +if [ -d "./modules" ]; then + source ./modules/utils-module.sh + source ./modules/dependencies-module.sh + source ./modules/config-module.sh + source ./modules/service-setup-module.sh + source ./modules/file-creator-module.sh +else + echo -e "${RED}Error: Required modules not found\!${NC}" + exit 1 +fi + +# Installation directory +INSTALL_DIR="/opt/transmission-rss-manager" + +# Create installation directory +echo -e "${GREEN}Creating installation directory...${NC}" +sudo mkdir -p $INSTALL_DIR +sudo chown $(whoami):$(whoami) $INSTALL_DIR + +# Install dependencies +echo -e "${GREEN}Installing dependencies...${NC}" +install_dependencies + +# Copy files to installation directory +echo -e "${GREEN}Copying application files...${NC}" +cp -r ./bin/net7.0/* $INSTALL_DIR/ +cp -r ./wwwroot $INSTALL_DIR/ +cp -r ./src $INSTALL_DIR/ # For reference only +cp ./appsettings*.json $INSTALL_DIR/ +cp ./run-app.sh $INSTALL_DIR/ + +# Make scripts executable +chmod +x $INSTALL_DIR/run-app.sh +chmod +x $INSTALL_DIR/TransmissionRssManager + +# Create default configuration +echo -e "${GREEN}Creating configuration...${NC}" +create_default_config $INSTALL_DIR/appsettings.json + +# Set up as a service +echo -e "${GREEN}Setting up service...${NC}" +setup_systemd_service $INSTALL_DIR + +# Final steps +echo -e "${GREEN}Installation complete\!${NC}" +echo -e "${YELLOW}The server can be accessed at: http://localhost:5000${NC}" +echo -e "${YELLOW}To start the service: sudo systemctl start transmission-rss-manager${NC}" +echo -e "${YELLOW}To enable at boot: sudo systemctl enable transmission-rss-manager${NC}" +echo -e "${YELLOW}To check status: sudo systemctl status transmission-rss-manager${NC}" +echo -e "${YELLOW}To run manually: cd $INSTALL_DIR && ./run-app.sh${NC}" + +# Ask if user wants to start the service now +read -p "Do you want to start the service now? (y/n) " -n 1 -r +echo +if [[ $REPLY =~ ^[Yy]$ ]]; then + sudo systemctl start transmission-rss-manager + echo -e "${GREEN}Service started\!${NC}" + echo -e "${YELLOW}You can access the web interface at: http://localhost:5000${NC}" +fi + +exit 0 diff --git a/main-installer.sh b/main-installer.sh new file mode 100755 index 0000000..c48bf0f --- /dev/null +++ b/main-installer.sh @@ -0,0 +1,582 @@ +#!/bin/bash +# Transmission RSS Manager Modular Installer +# Modified to work with the git-based approach + +# Set script to exit on error +set -e + +# Load installation environment variables if they exist +if [ -f "$(dirname "$0")/.env.install" ]; then + source "$(dirname "$0")/.env.install" + echo "Loaded TRANSMISSION_REMOTE=$TRANSMISSION_REMOTE from environment file" +fi + +# Text formatting +BOLD='\033[1m' +GREEN='\033[0;32m' +YELLOW='\033[0;33m' +RED='\033[0;31m' +NC='\033[0m' # No Color + +# Get current directory +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" + +# Source the utils module first to make the log function available +source "${SCRIPT_DIR}/modules/utils-module.sh" + +# Print header +echo -e "${BOLD}==================================================${NC}" +echo -e "${BOLD} Transmission RSS Manager Installer ${NC}" +VERSION=$(grep -oP '"version": "\K[^"]+' "${SCRIPT_DIR}/package.json" 2>/dev/null || echo "Unknown") +# Check if package.json exists, if not suggest creating it +if [ ! -f "${SCRIPT_DIR}/package.json" ]; then + echo -e "${YELLOW}Warning: package.json not found. You may need to run 'npm init' first.${NC}" +fi +echo -e "${BOLD} Version ${VERSION} - Git Edition ${NC}" +echo -e "${BOLD}==================================================${NC}" +echo + +# Check if script is run with sudo +if [ "$EUID" -ne 0 ]; then + echo -e "${RED}Please run as root (use sudo)${NC}" + exit 1 +fi + +# Check for installation type +IS_UPDATE=false +INSTALLATION_DETECTED=false + +# Check if we have existing config info from install-script.sh +if [ -n "$EXISTING_INSTALL_DIR" ] && [ -n "$EXISTING_CONFIG_PATH" ]; then + INSTALLATION_DETECTED=true + IS_UPDATE=true + # Use the existing installation directory as our target + INSTALL_DIR="$EXISTING_INSTALL_DIR" + CONFIG_FILE="$EXISTING_CONFIG_PATH" + log "INFO" "Using existing installation at $INSTALL_DIR detected by install-script.sh" + export INSTALL_DIR +else + # Check for config.json file (primary indicator) + POSSIBLE_CONFIG_LOCATIONS=( + "${SCRIPT_DIR}/config.json" + "/opt/transmission-rss-manager/config.json" + "/etc/transmission-rss-manager/config.json" + ) + + for CONFIG_PATH in "${POSSIBLE_CONFIG_LOCATIONS[@]}"; do + if [ -f "$CONFIG_PATH" ]; then + INSTALLATION_DETECTED=true + IS_UPDATE=true + INSTALL_DIR="$(dirname "$CONFIG_PATH")" + CONFIG_FILE="$CONFIG_PATH" + log "INFO" "Found existing installation at $INSTALL_DIR" + export INSTALL_DIR + break + fi + done + + # Check for service file (secondary indicator) if no config file found + if [ "$INSTALLATION_DETECTED" = "false" ] && [ -f "/etc/systemd/system/transmission-rss-manager.service" ]; then + INSTALLATION_DETECTED=true + IS_UPDATE=true + + # Extract the installation directory from the service file + SERVICE_INSTALL_DIR=$(grep "WorkingDirectory=" "/etc/systemd/system/transmission-rss-manager.service" | cut -d'=' -f2) + if [ -n "$SERVICE_INSTALL_DIR" ]; then + INSTALL_DIR="$SERVICE_INSTALL_DIR" + log "INFO" "Found existing installation at $INSTALL_DIR from service file" + export INSTALL_DIR + + # Check for config file in the detected installation directory + if [ -f "$INSTALL_DIR/config.json" ]; then + CONFIG_FILE="$INSTALL_DIR/config.json" + fi + fi + fi + + # Check for data directory (tertiary indicator) + if [ "$INSTALLATION_DETECTED" = "false" ] && [ -d "${SCRIPT_DIR}/data" ] && [ "$(ls -A "${SCRIPT_DIR}/data" 2>/dev/null)" ]; then + INSTALLATION_DETECTED=true + fi +fi + +# Provide clear feedback about the installation type +if [ "$IS_UPDATE" = "true" ]; then + log "INFO" "Running in UPDATE mode - will preserve existing configuration" + log "INFO" "Target installation directory: $INSTALL_DIR" + if [ -n "$CONFIG_FILE" ]; then + log "INFO" "Using configuration file: $CONFIG_FILE" + fi + + # Make sure the variables are set correctly + echo -e "${YELLOW}Existing installation detected. Running in update mode.${NC}" + echo -e "${GREEN}Your existing configuration will be preserved.${NC}" + echo -e "${GREEN}Only application files will be updated.${NC}" +else + log "INFO" "Running in FRESH INSTALL mode" + echo -e "${GREEN}Fresh installation. Will create new configuration.${NC}" +fi +export IS_UPDATE + +# Check if required module files exist +REQUIRED_MODULES=( + "${SCRIPT_DIR}/modules/config-module.sh" + "${SCRIPT_DIR}/modules/utils-module.sh" + "${SCRIPT_DIR}/modules/dependencies-module.sh" + "${SCRIPT_DIR}/modules/service-setup-module.sh" + "${SCRIPT_DIR}/modules/file-creator-module.sh" +) + +for module in "${REQUIRED_MODULES[@]}"; do + if [ ! -f "$module" ]; then + echo -e "${RED}Error: Required module file not found: $module${NC}" + echo -e "${YELLOW}The module files should be included in the git repository.${NC}" + exit 1 + fi +done + +# Source the remaining module files +source "${SCRIPT_DIR}/modules/config-module.sh" +source "${SCRIPT_DIR}/modules/dependencies-module.sh" +# Check if the updated service module exists, use it if available +if [ -f "${SCRIPT_DIR}/modules/service-setup-module-updated.sh" ]; then + log "INFO" "Using updated service setup module" + source "${SCRIPT_DIR}/modules/service-setup-module-updated.sh" +else + log "INFO" "Using standard service setup module" + source "${SCRIPT_DIR}/modules/service-setup-module.sh" +fi +source "${SCRIPT_DIR}/modules/file-creator-module.sh" + +# Function to handle cleanup on error +function cleanup_on_error() { + log "ERROR" "Installation failed: $1" + log "INFO" "Cleaning up..." + + # Add any cleanup steps here if needed + + log "INFO" "You can try running the installer again after fixing the issues." + exit 1 +} + +# Set trap for error handling +trap 'cleanup_on_error "$BASH_COMMAND"' ERR + +# Execute the installation steps in sequence +log "INFO" "Starting installation process..." + +# Set defaults for key variables +export TRANSMISSION_REMOTE=false +export CONFIG_DIR=${CONFIG_DIR:-"/etc/transmission-rss-manager"} +export USER=${USER:-$(logname || echo $SUDO_USER)} + +if [ "$IS_UPDATE" = true ]; then + log "INFO" "Running in update mode - preserving existing configuration..." + + # First, let's check if we already have this value from the environment + # This allows for non-interactive usage in scripts + if [ -n "$TRANSMISSION_REMOTE" ]; then + is_remote=$([ "$TRANSMISSION_REMOTE" = true ] && echo "Remote" || echo "Local") + log "INFO" "Using Transmission mode from environment: $is_remote" + + # Set the input_remote variable based on the environment variable + # This ensures consistent behavior with the rest of the script + if [ "$TRANSMISSION_REMOTE" = true ]; then + input_remote="y" + else + input_remote="n" + fi + else + # Directly ask about Transmission + # This is a direct approach that bypasses any potential sourcing issues + log "INFO" "Configuring Transmission connection..." + echo -e "${BOLD}Transmission Configuration:${NC}" + echo -e "Configure connection to your Transmission client:" + echo + + # If stdin is not a terminal (pipe or redirect), assume default + if [ ! -t 0 ]; then + input_remote="n" # Default to no + log "INFO" "Non-interactive mode detected, using default: local Transmission" + else + read -p "Is Transmission running on a remote server? (y/n) [n]: " input_remote + fi + log "INFO" "DEBUG: Input received for remote: '$input_remote'" + fi + + # More explicit check for "y" or "Y" input + if [ "$input_remote" = "y" ] || [ "$input_remote" = "Y" ]; then + export TRANSMISSION_REMOTE=true + log "INFO" "Remote Transmission selected." + + # Update the config file directly to set remote mode + if [ -f "$CONFIG_DIR/config.json" ]; then + log "INFO" "Updating configuration file for remote Transmission..." + + # Log all environment variables we have for debugging + log "INFO" "DEBUG: Environment variables for remote configuration:" + log "INFO" "DEBUG: TRANSMISSION_HOST=${TRANSMISSION_HOST:-'not set'}" + log "INFO" "DEBUG: TRANSMISSION_PORT=${TRANSMISSION_PORT:-'not set'}" + log "INFO" "DEBUG: REMOTE_DOWNLOAD_DIR=${REMOTE_DOWNLOAD_DIR:-'not set'}" + log "INFO" "DEBUG: LOCAL_DOWNLOAD_DIR=${LOCAL_DOWNLOAD_DIR:-'not set'}" + + # Check if we already have the remote configuration details from the environment + if [ -n "$TRANSMISSION_HOST" ] && [ -n "$TRANSMISSION_PORT" ] && [ -n "$REMOTE_DOWNLOAD_DIR" ] && [ -n "$LOCAL_DOWNLOAD_DIR" ]; then + log "INFO" "Using remote Transmission configuration from environment" + # Values are already set from the environment, no need to ask again + else + # Get and validate hostname + read -p "Remote Transmission host [localhost]: " input_trans_host + TRANSMISSION_HOST=${input_trans_host:-"localhost"} + + # Get and validate port + read -p "Remote Transmission port [9091]: " input_trans_port + TRANSMISSION_PORT=${input_trans_port:-9091} + + # Get credentials + read -p "Remote Transmission username []: " input_trans_user + TRANSMISSION_USER=${input_trans_user:-""} + + # Use read -s for password to avoid showing it on screen + read -s -p "Remote Transmission password []: " input_trans_pass + echo # Add a newline after the password input + TRANSMISSION_PASS=${input_trans_pass:-""} + + read -p "Remote Transmission RPC path [/transmission/rpc]: " input_trans_path + TRANSMISSION_RPC_PATH=${input_trans_path:-"/transmission/rpc"} + + # Configure directory mapping for remote setup + echo + echo -e "${YELLOW}Directory Mapping Configuration${NC}" + echo -e "When using a remote Transmission server, you need to map paths between servers." + echo -e "For each directory on the remote server, specify the corresponding local directory." + echo + + # Get remote download directory + read -p "Remote Transmission download directory [/var/lib/transmission-daemon/downloads]: " REMOTE_DOWNLOAD_DIR + REMOTE_DOWNLOAD_DIR=${REMOTE_DOWNLOAD_DIR:-"/var/lib/transmission-daemon/downloads"} + + # Get local directory that corresponds to remote download directory + read -p "Local directory that corresponds to the remote download directory [/mnt/transmission-downloads]: " LOCAL_DOWNLOAD_DIR + LOCAL_DOWNLOAD_DIR=${LOCAL_DOWNLOAD_DIR:-"/mnt/transmission-downloads"} + fi + + # Create mapping JSON + TRANSMISSION_DIR_MAPPING=$(cat <<EOF +{ + "$REMOTE_DOWNLOAD_DIR": "$LOCAL_DOWNLOAD_DIR" +} +EOF +) + + # Create the local directory + mkdir -p "$LOCAL_DOWNLOAD_DIR" + chown -R $USER:$USER "$LOCAL_DOWNLOAD_DIR" + + # Update the config file with the new remote settings + log "INFO" "Updating configuration file with remote Transmission settings..." + + # Backup the original config file + cp "$CONFIG_DIR/config.json" "$CONFIG_DIR/config.json.bak.$(date +%Y%m%d%H%M%S)" + + # Update the isRemote setting + sed -i 's/"isRemote": false/"isRemote": true/' "$CONFIG_DIR/config.json" + + # Update the host setting + sed -i "s/\"host\": \"[^\"]*\"/\"host\": \"$TRANSMISSION_HOST\"/" "$CONFIG_DIR/config.json" + + # Update the port setting + sed -i "s/\"port\": [0-9]*/\"port\": $TRANSMISSION_PORT/" "$CONFIG_DIR/config.json" + + # Update the username setting + sed -i "s/\"username\": \"[^\"]*\"/\"username\": \"$TRANSMISSION_USER\"/" "$CONFIG_DIR/config.json" + + # Update the password setting + sed -i "s/\"password\": \"[^\"]*\"/\"password\": \"$TRANSMISSION_PASS\"/" "$CONFIG_DIR/config.json" + + # Update the RPC path setting + sed -i "s|\"path\": \"[^\"]*\"|\"path\": \"$TRANSMISSION_RPC_PATH\"|" "$CONFIG_DIR/config.json" + + # Update the directory mapping + # Use a more complex approach since it's a JSON object + # This is a simplification and might need improvement for complex JSON handling + sed -i "/\"directoryMapping\":/c\\ \"directoryMapping\": $TRANSMISSION_DIR_MAPPING" "$CONFIG_DIR/config.json" + + log "INFO" "Configuration updated for remote Transmission." + fi + else + export TRANSMISSION_REMOTE=false + log "INFO" "Local Transmission selected." + + # Update the config file directly to set local mode + if [ -f "$CONFIG_DIR/config.json" ]; then + log "INFO" "Updating configuration file for local Transmission..." + # Backup the original config file + cp "$CONFIG_DIR/config.json" "$CONFIG_DIR/config.json.bak.$(date +%Y%m%d%H%M%S)" + + # Update the isRemote setting + sed -i 's/"isRemote": true/"isRemote": false/' "$CONFIG_DIR/config.json" + + # Update the host setting + sed -i 's/"host": "[^"]*"/"host": "localhost"/' "$CONFIG_DIR/config.json" + + log "INFO" "Configuration updated for local Transmission." + fi + fi + + # Step 1: Check dependencies (but don't reconfigure) + log "INFO" "Checking dependencies..." + install_dependencies || { + log "ERROR" "Dependency check failed" + exit 1 + } + + # Step the service configuration (will preserve existing settings) + log "INFO" "Updating service configuration..." + setup_service || { + log "ERROR" "Service update failed" + exit 1 + } + + # Install npm dependencies using our common function + ensure_npm_packages "$INSTALL_DIR" || { + log "ERROR" "NPM installation failed" + exit 1 + } + + # Copy JavaScript module files during update as well + log "INFO" "Copying JavaScript module files..." + copy_module_files || { + log "ERROR" "Failed to copy JavaScript module files" + exit 1 + } + +else + # This is a fresh installation - run all steps + + # Step 1: First, let's check if we already have this value from the environment + # This allows for non-interactive usage in scripts + if [ -n "$TRANSMISSION_REMOTE" ]; then + is_remote=$([ "$TRANSMISSION_REMOTE" = true ] && echo "Remote" || echo "Local") + log "INFO" "Using Transmission mode from environment: $is_remote" + + # Set the input_remote variable based on the environment variable + # This ensures consistent behavior with the rest of the script + if [ "$TRANSMISSION_REMOTE" = true ]; then + input_remote="y" + else + input_remote="n" + fi + else + # Directly ask about Transmission + # This is a direct approach that bypasses any potential sourcing issues + log "INFO" "Configuring Transmission connection..." + echo -e "${BOLD}Transmission Configuration:${NC}" + echo -e "Configure connection to your Transmission client:" + echo + + # If stdin is not a terminal (pipe or redirect), assume default + if [ ! -t 0 ]; then + input_remote="n" # Default to no + log "INFO" "Non-interactive mode detected, using default: local Transmission" + else + read -p "Is Transmission running on a remote server? (y/n) [n]: " input_remote + fi + log "INFO" "DEBUG: Input received for remote: '$input_remote'" + fi + # More explicit check for "y" or "Y" input + if [ "$input_remote" = "y" ] || [ "$input_remote" = "Y" ]; then + export TRANSMISSION_REMOTE=true + log "INFO" "Remote Transmission selected." + else + export TRANSMISSION_REMOTE=false + log "INFO" "Local Transmission selected." + fi + + # Now gather the rest of the configuration + log "INFO" "Gathering remaining configuration..." + gather_configuration || { + log "ERROR" "Configuration gathering failed" + exit 1 + } + + # Debug: Verify TRANSMISSION_REMOTE is set + log "INFO" "After configuration gathering, TRANSMISSION_REMOTE=$TRANSMISSION_REMOTE" + + # Step 2: Install dependencies + log "INFO" "Installing dependencies..." + install_dependencies || { + log "ERROR" "Dependency installation failed" + exit 1 + } + + # Step 3: Create installation directories + log "INFO" "Creating directories..." + # Make sure CONFIG_DIR is set and exported + export CONFIG_DIR=${CONFIG_DIR:-"/etc/transmission-rss-manager"} + # Call our new create_directories function + create_directories || { + log "ERROR" "Directory creation failed" + exit 1 + } + + # Step 4: Create configuration files only (no application files since they're from git) + log "INFO" "Creating configuration files..." + create_config_files || { + log "ERROR" "Configuration file creation failed" + exit 1 + } + + # Step 5: Create service files and install the service + log "INFO" "Setting up service..." + setup_service || { + log "ERROR" "Service setup failed" + exit 1 + } + + # Step 6: Install npm dependencies using our common function + ensure_npm_packages "$INSTALL_DIR" || { + log "ERROR" "NPM installation failed" + exit 1 + } +fi + +# Step 7: Set up update script +log "INFO" "Setting up update script..." +mkdir -p "${SCRIPT_DIR}/scripts" +# Check if update script exists - don't copy it to itself +if [ ! -f "${SCRIPT_DIR}/scripts/update.sh" ]; then + # First, check if we have an update script to copy + if [ -f "${SCRIPT_DIR}/update.sh" ]; then + cp "${SCRIPT_DIR}/update.sh" "${SCRIPT_DIR}/scripts/update.sh" + log "INFO" "Copied update script from root to scripts directory" + else + # Create the update script since it doesn't exist + cat > "${SCRIPT_DIR}/scripts/update.sh" << 'EOL' +#!/bin/bash + +# Transmission RSS Manager - Update Script +# This script pulls the latest version from git and runs necessary updates + +# Color and formatting +GREEN='\033[0;32m' +RED='\033[0;31m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color +BOLD='\033[1m' + +# Installation directory (should be current directory) +INSTALL_DIR=$(pwd) + +# Check if we're in the right directory +if [ ! -f "$INSTALL_DIR/package.json" ] || [ ! -d "$INSTALL_DIR/modules" ]; then + echo -e "${RED}Error: This script must be run from the installation directory.${NC}" + exit 1 +fi + +# Get the current version +CURRENT_VERSION=$(grep -oP '"version": "\K[^"]+' package.json) +echo -e "${YELLOW}Current version: ${BOLD}$CURRENT_VERSION${NC}" + +# Check for git repository +if [ ! -d ".git" ]; then + echo -e "${RED}Error: This installation was not set up using git.${NC}" + echo -e "Please use the bootstrap installer to perform a fresh installation." + exit 1 +fi + +# Stash any local changes +echo -e "${YELLOW}Backing up any local configuration changes...${NC}" +git stash -q + +# Pull the latest changes +echo -e "${YELLOW}Pulling latest updates from git...${NC}" +git pull +if [ $? -ne 0 ]; then + echo -e "${RED}Failed to pull updates. Restoring original state...${NC}" + git stash pop -q + exit 1 +fi + +# Get the new version +NEW_VERSION=$(grep -oP '"version": "\K[^"]+' package.json) +echo -e "${GREEN}New version: ${BOLD}$NEW_VERSION${NC}" + +# Check if update is needed +if [ "$CURRENT_VERSION" == "$NEW_VERSION" ]; then + echo -e "${GREEN}You already have the latest version.${NC}" + exit 0 +fi + +# Install any new npm dependencies +echo -e "${YELLOW}Installing dependencies...${NC}" +npm install + +# Apply any local configuration changes +if git stash list | grep -q "stash@{0}"; then + echo -e "${YELLOW}Restoring local configuration changes...${NC}" + git stash pop -q + # Handle conflicts if any + if [ $? -ne 0 ]; then + echo -e "${RED}There were conflicts when restoring your configuration.${NC}" + echo -e "Please check the files and resolve conflicts manually." + echo -e "Your original configuration is saved in .git/refs/stash" + fi +fi + +# Restart the service +echo -e "${YELLOW}Restarting service...${NC}" +if command -v systemctl &> /dev/null; then + sudo systemctl restart transmission-rss-manager +else + echo -e "${RED}Could not restart service automatically.${NC}" + echo -e "Please restart the service manually." +fi + +# Update complete +echo -e "${GREEN}${BOLD}Update complete!${NC}" +echo -e "Updated from version $CURRENT_VERSION to $NEW_VERSION" +echo -e "Changes will take effect immediately." +EOL + + chmod +x "${SCRIPT_DIR}/scripts/update.sh" + log "INFO" "Created update script: ${SCRIPT_DIR}/scripts/update.sh" + fi +fi + +# Step 8: Final setup and permissions +log "INFO" "Finalizing setup..." +finalize_setup || { + log "ERROR" "Setup finalization failed" + exit 1 +} + +# Installation complete +echo +echo -e "${BOLD}${GREEN}==================================================${NC}" + +if [ "$IS_UPDATE" = true ]; then + echo -e "${BOLD}${GREEN} Update Complete! ${NC}" +else + echo -e "${BOLD}${GREEN} Installation Complete! ${NC}" +fi + +echo -e "${BOLD}${GREEN}==================================================${NC}" +echo -e "You can access the web interface at: ${BOLD}http://localhost:$PORT${NC} or ${BOLD}http://your-server-ip:$PORT${NC}" +echo -e "You may need to configure your firewall to allow access to port $PORT" +echo +echo -e "${BOLD}Useful Commands:${NC}" +echo -e " To check the service status: ${YELLOW}systemctl status $SERVICE_NAME${NC}" +echo -e " To view logs: ${YELLOW}journalctl -u $SERVICE_NAME${NC}" +echo -e " To restart the service: ${YELLOW}systemctl restart $SERVICE_NAME${NC}" +echo -e " To update the application: ${YELLOW}Use the Update button in the System Status section${NC}" +echo + +if [ "$IS_UPDATE" = true ]; then + echo -e "Thank you for updating Transmission RSS Manager!" + echo -e "The service has been restarted with the new version." +else + echo -e "Thank you for installing Transmission RSS Manager!" +fi + +echo -e "${BOLD}==================================================${NC}" \ No newline at end of file diff --git a/modules/config-module.sh b/modules/config-module.sh new file mode 100755 index 0000000..65639a6 --- /dev/null +++ b/modules/config-module.sh @@ -0,0 +1,121 @@ +#\!/bin/bash + +# This module handles configuration related tasks + +create_default_config() { + local config_file=$1 + + # Check if config file already exists + if [ -f "$config_file" ]; then + echo "Configuration file already exists at $config_file" + return 0 + fi + + echo "Creating default configuration..." + + # Create default appsettings.json + cat > "$config_file" << EOL +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "DatabaseSettings": { + "ConnectionString": "Data Source=torrentmanager.db", + "UseInMemoryDatabase": true + }, + "Serilog": { + "MinimumLevel": { + "Default": "Information", + "Override": { + "Microsoft": "Warning", + "System": "Warning" + } + }, + "WriteTo": [ + { + "Name": "Console", + "Args": { + "outputTemplate": "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}" + } + }, + { + "Name": "File", + "Args": { + "path": "logs/log-.txt", + "rollingInterval": "Day", + "outputTemplate": "[{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} {Level:u3}] {Message:lj}{NewLine}{Exception}" + } + } + ] + }, + "AppConfig": { + "Transmission": { + "Host": "localhost", + "Port": 9091, + "UseHttps": false, + "Username": "", + "Password": "" + }, + "AutoDownloadEnabled": false, + "CheckIntervalMinutes": 30, + "DownloadDirectory": "", + "MediaLibraryPath": "", + "PostProcessing": { + "Enabled": false, + "ExtractArchives": true, + "OrganizeMedia": true, + "MinimumSeedRatio": 1, + "MediaExtensions": [ + ".mp4", + ".mkv", + ".avi" + ], + "AutoOrganizeByMediaType": true, + "RenameFiles": false, + "CompressCompletedFiles": false, + "DeleteCompletedAfterDays": 0 + }, + "EnableDetailedLogging": false, + "UserPreferences": { + "EnableDarkMode": false, + "AutoRefreshUIEnabled": true, + "AutoRefreshIntervalSeconds": 30, + "NotificationsEnabled": true, + "NotificationEvents": [ + "torrent-added", + "torrent-completed", + "torrent-error" + ], + "DefaultView": "dashboard", + "ConfirmBeforeDelete": true, + "MaxItemsPerPage": 25, + "DateTimeFormat": "yyyy-MM-dd HH:mm:ss", + "ShowCompletedTorrents": true, + "KeepHistoryDays": 30 + } + } +} +EOL + + echo "Default configuration created" +} + +update_config_value() { + local config_file=$1 + local key=$2 + local value=$3 + + # This is a simplified version that assumes jq is installed + # For a production script, you might want to add error checking + if command -v jq &> /dev/null; then + tmp=$(mktemp) + jq ".$key = \"$value\"" "$config_file" > "$tmp" && mv "$tmp" "$config_file" + echo "Updated $key to $value" + else + echo "jq is not installed, skipping config update" + fi +} diff --git a/modules/dependencies-module.sh b/modules/dependencies-module.sh new file mode 100755 index 0000000..2a13827 --- /dev/null +++ b/modules/dependencies-module.sh @@ -0,0 +1,85 @@ +#\!/bin/bash + +# This module handles installing dependencies + +install_dependencies() { + echo "Checking and installing dependencies..." + + # Check if we're on a Debian/Ubuntu system + if command -v apt-get &> /dev/null; then + install_debian_dependencies + # Check if we're on a RHEL/CentOS/Fedora system + elif command -v yum &> /dev/null || command -v dnf &> /dev/null; then + install_rhel_dependencies + else + echo "Unsupported package manager. Please install dependencies manually." + return 1 + fi + + # Install .NET runtime if needed + install_dotnet + + return 0 +} + +install_debian_dependencies() { + echo "Installing dependencies for Debian/Ubuntu..." + + sudo apt-get update + sudo apt-get install -y wget curl jq unzip + + return 0 +} + +install_rhel_dependencies() { + echo "Installing dependencies for RHEL/CentOS/Fedora..." + + if command -v dnf &> /dev/null; then + sudo dnf install -y wget curl jq unzip + else + sudo yum install -y wget curl jq unzip + fi + + return 0 +} + +install_dotnet() { + echo "Checking .NET runtime..." + + # Check if .NET 7.0 is already installed + if command -v dotnet &> /dev/null; then + dotnet_version=$(dotnet --version) + if [[ $dotnet_version == 7.* ]]; then + echo ".NET 7.0 is already installed (version $dotnet_version)" + return 0 + fi + fi + + echo "Installing .NET 7.0 runtime..." + + # For Debian/Ubuntu + if command -v apt-get &> /dev/null; then + wget https://packages.microsoft.com/config/ubuntu/22.04/packages-microsoft-prod.deb -O packages-microsoft-prod.deb + sudo dpkg -i packages-microsoft-prod.deb + rm packages-microsoft-prod.deb + + sudo apt-get update + sudo apt-get install -y dotnet-runtime-7.0 + + # For RHEL/CentOS/Fedora + elif command -v yum &> /dev/null || command -v dnf &> /dev/null; then + sudo rpm -Uvh https://packages.microsoft.com/config/centos/7/packages-microsoft-prod.rpm + + if command -v dnf &> /dev/null; then + sudo dnf install -y dotnet-runtime-7.0 + else + sudo yum install -y dotnet-runtime-7.0 + fi + else + echo "Unsupported system for automatic .NET installation. Please install .NET 7.0 runtime manually." + return 1 + fi + + echo ".NET 7.0 runtime installed successfully" + return 0 +} diff --git a/modules/file-creator-module.sh b/modules/file-creator-module.sh new file mode 100755 index 0000000..c6a6204 --- /dev/null +++ b/modules/file-creator-module.sh @@ -0,0 +1,74 @@ +#\!/bin/bash + +# This module handles creating necessary files + +# Function to create systemd service file +create_systemd_service_file() { + local install_dir=$1 + local service_name=$2 + local description=$3 + local exec_command=$4 + + local service_file="/tmp/$service_name.service" + + echo "Creating systemd service file for $service_name..." + + cat > "$service_file" << EOL +[Unit] +Description=$description +After=network.target + +[Service] +Type=simple +User=$(whoami) +WorkingDirectory=$install_dir +ExecStart=$exec_command +Restart=on-failure +RestartSec=10 +SyslogIdentifier=$service_name +Environment=ASPNETCORE_ENVIRONMENT=Production + +[Install] +WantedBy=multi-user.target +EOL + + sudo mv "$service_file" "/etc/systemd/system/$service_name.service" + sudo systemctl daemon-reload + + echo "Service file created for $service_name" +} + +# Function to create a simple start script +create_start_script() { + local install_dir=$1 + local script_path="$install_dir/start.sh" + + echo "Creating start script..." + + cat > "$script_path" << 'EOL' +#\!/bin/bash + +# Colors +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +echo -e "${GREEN}Starting Transmission RSS Manager...${NC}" +echo -e "${GREEN}The web interface will be available at: http://localhost:5000${NC}" +echo -e "${YELLOW}Press Ctrl+C to stop the application${NC}" + +./TransmissionRssManager --urls=http://0.0.0.0:5000 +EOL + + chmod +x "$script_path" + echo "Start script created at $script_path" +} + +# Function to create logs directory +create_logs_directory() { + local install_dir=$1 + local logs_dir="$install_dir/logs" + + mkdir -p "$logs_dir" + echo "Logs directory created at $logs_dir" +} diff --git a/modules/rss-feed-manager.js b/modules/rss-feed-manager.js new file mode 100644 index 0000000..fb3f21e --- /dev/null +++ b/modules/rss-feed-manager.js @@ -0,0 +1,341 @@ +// RSS Feed Manager for Transmission RSS Manager +// This is a basic implementation that will be extended during installation +const fs = require('fs').promises; +const path = require('path'); +const fetch = require('node-fetch'); +const xml2js = require('xml2js'); +const crypto = require('crypto'); + +class RssFeedManager { + constructor(config) { + this.config = config; + this.feeds = config.feeds || []; + this.updateIntervalMinutes = config.updateIntervalMinutes || 60; + this.updateIntervalId = null; + this.items = []; + this.dataDir = path.join(__dirname, '..', 'data'); + } + + // Start the RSS feed update process + start() { + if (this.updateIntervalId) { + return; + } + + // Run immediately then set interval + this.updateAllFeeds(); + + this.updateIntervalId = setInterval(() => { + this.updateAllFeeds(); + }, this.updateIntervalMinutes * 60 * 1000); + + console.log(`RSS feed manager started, update interval: ${this.updateIntervalMinutes} minutes`); + } + + // Stop the RSS feed update process + stop() { + if (this.updateIntervalId) { + clearInterval(this.updateIntervalId); + this.updateIntervalId = null; + console.log('RSS feed manager stopped'); + } + } + + // Update all feeds + async updateAllFeeds() { + console.log('Updating all RSS feeds...'); + + const results = []; + + for (const feed of this.feeds) { + try { + const feedData = await this.fetchFeed(feed.url); + const parsedItems = this.parseFeedItems(feedData, feed.id); + + // Add items to the list + this.addNewItems(parsedItems, feed); + + // Auto-download items if configured + if (feed.autoDownload && feed.filters) { + await this.processAutoDownload(feed); + } + + results.push({ + feedId: feed.id, + name: feed.name, + url: feed.url, + success: true, + itemCount: parsedItems.length + }); + } catch (error) { + console.error(`Error updating feed ${feed.name}:`, error); + results.push({ + feedId: feed.id, + name: feed.name, + url: feed.url, + success: false, + error: error.message + }); + } + } + + // Save updated items to disk + await this.saveItems(); + + console.log(`RSS feeds update completed, processed ${results.length} feeds`); + return results; + } + + // Fetch a feed from a URL + async fetchFeed(url) { + try { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + return await response.text(); + } catch (error) { + console.error(`Error fetching feed from ${url}:`, error); + throw error; + } + } + + // Parse feed items from XML + parseFeedItems(xmlData, feedId) { + const items = []; + + try { + // Basic XML parsing + // In a real implementation, this would be more robust + const matches = xmlData.match(/<item>[\s\S]*?<\/item>/g) || []; + + for (const itemXml of matches) { + const titleMatch = itemXml.match(/<title>(.*?)<\/title>/); + const linkMatch = itemXml.match(/<link>(.*?)<\/link>/); + const pubDateMatch = itemXml.match(/<pubDate>(.*?)<\/pubDate>/); + const descriptionMatch = itemXml.match(/<description>(.*?)<\/description>/); + + const guid = crypto.createHash('md5').update(feedId + (linkMatch?.[1] || Math.random().toString())).digest('hex'); + + items.push({ + id: guid, + feedId: feedId, + title: titleMatch?.[1] || 'Unknown', + link: linkMatch?.[1] || '', + pubDate: pubDateMatch?.[1] || new Date().toISOString(), + description: descriptionMatch?.[1] || '', + downloaded: false, + dateAdded: new Date().toISOString() + }); + } + } catch (error) { + console.error('Error parsing feed items:', error); + } + + return items; + } + + // Add new items to the list + addNewItems(parsedItems, feed) { + for (const item of parsedItems) { + // Check if the item already exists + const existingItem = this.items.find(i => i.id === item.id); + if (!existingItem) { + this.items.push(item); + } + } + } + + // Process items for auto-download + async processAutoDownload(feed) { + if (!feed.autoDownload || !feed.filters || feed.filters.length === 0) { + return; + } + + const feedItems = this.items.filter(item => + item.feedId === feed.id && !item.downloaded + ); + + for (const item of feedItems) { + if (this.matchesFilters(item, feed.filters)) { + console.log(`Auto-downloading item: ${item.title}`); + + try { + // In a real implementation, this would call the Transmission client + // For now, just mark it as downloaded + item.downloaded = true; + item.downloadDate = new Date().toISOString(); + } catch (error) { + console.error(`Error auto-downloading item ${item.title}:`, error); + } + } + } + } + + // Check if an item matches the filters + matchesFilters(item, filters) { + for (const filter of filters) { + let matches = true; + + // Check title filter + if (filter.title && !item.title.toLowerCase().includes(filter.title.toLowerCase())) { + matches = false; + } + + // Check category filter + if (filter.category && !item.categories?.some(cat => + cat.toLowerCase().includes(filter.category.toLowerCase()) + )) { + matches = false; + } + + // Check size filters if we have size information + if (item.size) { + if (filter.minSize && item.size < filter.minSize) { + matches = false; + } + if (filter.maxSize && item.size > filter.maxSize) { + matches = false; + } + } + + // If we matched all conditions in a filter, return true + if (matches) { + return true; + } + } + + // If we got here, no filter matched + return false; + } + + // Load saved items from disk + async loadItems() { + try { + const file = path.join(this.dataDir, 'rss-items.json'); + + try { + await fs.access(file); + const data = await fs.readFile(file, 'utf8'); + this.items = JSON.parse(data); + console.log(`Loaded ${this.items.length} RSS items from disk`); + } catch (error) { + // File probably doesn't exist yet, that's okay + console.log('No saved RSS items found, starting fresh'); + this.items = []; + } + } catch (error) { + console.error('Error loading RSS items:', error); + // Use empty array if there's an error + this.items = []; + } + } + + // Save items to disk + async saveItems() { + try { + // Create data directory if it doesn't exist + await fs.mkdir(this.dataDir, { recursive: true }); + + const file = path.join(this.dataDir, 'rss-items.json'); + await fs.writeFile(file, JSON.stringify(this.items, null, 2), 'utf8'); + console.log(`Saved ${this.items.length} RSS items to disk`); + } catch (error) { + console.error('Error saving RSS items:', error); + } + } + + // Add a new feed + addFeed(feed) { + if (!feed.id) { + feed.id = crypto.createHash('md5').update(feed.url + Date.now()).digest('hex'); + } + + this.feeds.push(feed); + return feed; + } + + // Remove a feed + removeFeed(feedId) { + const index = this.feeds.findIndex(feed => feed.id === feedId); + if (index === -1) { + return false; + } + + this.feeds.splice(index, 1); + return true; + } + + // Update feed configuration + updateFeedConfig(feedId, updates) { + const feed = this.feeds.find(feed => feed.id === feedId); + if (!feed) { + return false; + } + + Object.assign(feed, updates); + return true; + } + + // Download an item + async downloadItem(item, transmissionClient) { + if (!item || !item.link) { + throw new Error('Invalid item or missing link'); + } + + if (!transmissionClient) { + throw new Error('Transmission client not available'); + } + + // Mark as downloaded + item.downloaded = true; + item.downloadDate = new Date().toISOString(); + + // Add to Transmission (simplified for install script) + return { + success: true, + message: 'Added to Transmission', + result: { id: 'torrent-id-placeholder' } + }; + } + + // Get all feeds + getAllFeeds() { + return this.feeds; + } + + // Get all items + getAllItems() { + return this.items; + } + + // Get undownloaded items + getUndownloadedItems() { + return this.items.filter(item => !item.downloaded); + } + + // Filter items based on criteria + filterItems(filters) { + let filteredItems = [...this.items]; + + if (filters.downloaded === true) { + filteredItems = filteredItems.filter(item => item.downloaded); + } else if (filters.downloaded === false) { + filteredItems = filteredItems.filter(item => !item.downloaded); + } + + if (filters.title) { + filteredItems = filteredItems.filter(item => + item.title.toLowerCase().includes(filters.title.toLowerCase()) + ); + } + + if (filters.feedId) { + filteredItems = filteredItems.filter(item => item.feedId === filters.feedId); + } + + return filteredItems; + } +} + +module.exports = RssFeedManager; diff --git a/modules/service-setup-module.sh b/modules/service-setup-module.sh new file mode 100755 index 0000000..cf725b8 --- /dev/null +++ b/modules/service-setup-module.sh @@ -0,0 +1,73 @@ +#\!/bin/bash + +# This module handles setting up the application as a system service + +setup_systemd_service() { + local install_dir=$1 + echo "Setting up Transmission RSS Manager as a systemd service..." + + # Create service file + cat > /tmp/transmission-rss-manager.service << EOL +[Unit] +Description=Transmission RSS Manager Service +After=network.target + +[Service] +Type=simple +User=$(whoami) +WorkingDirectory=${install_dir} +ExecStart=${install_dir}/TransmissionRssManager --urls=http://0.0.0.0:5000 +Restart=on-failure +RestartSec=10 +SyslogIdentifier=transmission-rss-manager +Environment=ASPNETCORE_ENVIRONMENT=Production + +[Install] +WantedBy=multi-user.target +EOL + + # Move service file to systemd directory + sudo mv /tmp/transmission-rss-manager.service /etc/systemd/system/ + + # Reload systemd + sudo systemctl daemon-reload + + echo "Service has been set up" + echo "To start the service: sudo systemctl start transmission-rss-manager" + echo "To enable at boot: sudo systemctl enable transmission-rss-manager" +} + +# Function to check if the service is running +check_service_status() { + if systemctl is-active --quiet transmission-rss-manager; then + echo "Service is running" + return 0 + else + echo "Service is not running" + return 1 + fi +} + +# Function to start the service +start_service() { + echo "Starting Transmission RSS Manager service..." + sudo systemctl start transmission-rss-manager + + if check_service_status; then + echo "Service started successfully" + else + echo "Failed to start service" + fi +} + +# Function to stop the service +stop_service() { + echo "Stopping Transmission RSS Manager service..." + sudo systemctl stop transmission-rss-manager + + if \! check_service_status; then + echo "Service stopped successfully" + else + echo "Failed to stop service" + fi +} diff --git a/modules/transmission-client.js b/modules/transmission-client.js new file mode 100644 index 0000000..83f6fed --- /dev/null +++ b/modules/transmission-client.js @@ -0,0 +1,113 @@ +// Transmission client module for Transmission RSS Manager +// This is a basic implementation that will be extended during installation +const Transmission = require('transmission'); + +class TransmissionClient { + constructor(config) { + this.config = config; + this.client = new Transmission({ + host: config.host || 'localhost', + port: config.port || 9091, + username: config.username || '', + password: config.password || '', + url: config.path || '/transmission/rpc' + }); + } + + // Get all torrents + getTorrents() { + return new Promise((resolve, reject) => { + this.client.get((err, result) => { + if (err) { + reject(err); + } else { + resolve(result.torrents || []); + } + }); + }); + } + + // Add a torrent + addTorrent(url) { + return new Promise((resolve, reject) => { + this.client.addUrl(url, (err, result) => { + if (err) { + reject(err); + } else { + resolve(result); + } + }); + }); + } + + // Remove a torrent + removeTorrent(id, deleteLocalData = false) { + return new Promise((resolve, reject) => { + this.client.remove(id, deleteLocalData, (err, result) => { + if (err) { + reject(err); + } else { + resolve(result); + } + }); + }); + } + + // Start a torrent + startTorrent(id) { + return new Promise((resolve, reject) => { + this.client.start(id, (err, result) => { + if (err) { + reject(err); + } else { + resolve(result); + } + }); + }); + } + + // Stop a torrent + stopTorrent(id) { + return new Promise((resolve, reject) => { + this.client.stop(id, (err, result) => { + if (err) { + reject(err); + } else { + resolve(result); + } + }); + }); + } + + // Get torrent details + getTorrentDetails(id) { + return new Promise((resolve, reject) => { + this.client.get(id, (err, result) => { + if (err) { + reject(err); + } else { + resolve(result.torrents && result.torrents.length > 0 ? result.torrents[0] : null); + } + }); + }); + } + + // Test connection to Transmission + testConnection() { + return new Promise((resolve, reject) => { + this.client.sessionStats((err, result) => { + if (err) { + reject(err); + } else { + resolve({ + connected: true, + version: result.version, + rpcVersion: result.rpcVersion + }); + } + }); + }); + } +} + +module.exports = TransmissionClient; diff --git a/modules/utils-module.sh b/modules/utils-module.sh new file mode 100755 index 0000000..31fb6e8 --- /dev/null +++ b/modules/utils-module.sh @@ -0,0 +1,91 @@ +#\!/bin/bash + +# This module contains utility functions + +# Text colors +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +RED='\033[0;31m' +NC='\033[0m' # No Color + +# Function to check if a command exists +command_exists() { + command -v "$1" &> /dev/null +} + +# Function to check if running as root +check_root() { + if [ "$(id -u)" -ne 0 ]; then + echo -e "${RED}This script must be run as root or with sudo${NC}" + exit 1 + fi +} + +# Function to get the system's IP address +get_ip_address() { + local ip="" + + if command_exists ip; then + ip=$(ip -4 addr show scope global | grep -oP '(?<=inet\s)\d+(\.\d+){3}' | head -n 1) + elif command_exists hostname; then + ip=$(hostname -I | awk '{print $1}') + elif command_exists ifconfig; then + ip=$(ifconfig | grep -Eo 'inet (addr:)?([0-9]*\.){3}[0-9]*' | grep -Eo '([0-9]*\.){3}[0-9]*' | grep -v '127.0.0.1' | head -n 1) + fi + + echo "$ip" +} + +# Function to check if a service is installed +service_exists() { + local service_name=$1 + if [ -f "/etc/systemd/system/$service_name.service" ]; then + return 0 # Service exists + else + return 1 # Service does not exist + fi +} + +# Function to create directory if it doesn't exist +ensure_dir_exists() { + local dir_path=$1 + + if [ \! -d "$dir_path" ]; then + mkdir -p "$dir_path" + echo "Created directory: $dir_path" + fi +} + +# Function to verify file permissions +verify_permissions() { + local file_path=$1 + local user=$2 + + # If user is not specified, use current user + if [ -z "$user" ]; then + user=$(whoami) + fi + + # Check if file exists + if [ \! -f "$file_path" ]; then + echo "File does not exist: $file_path" + return 1 + fi + + # Check ownership + local owner=$(stat -c '%U' "$file_path") + if [ "$owner" \!= "$user" ]; then + echo "Changing ownership of $file_path to $user" + sudo chown "$user" "$file_path" + fi + + # Ensure file is executable if it's a script + if [[ "$file_path" == *.sh ]]; then + if [ \! -x "$file_path" ]; then + echo "Making $file_path executable" + chmod +x "$file_path" + fi + fi + + return 0 +} diff --git a/run-app.sh b/run-app.sh new file mode 100755 index 0000000..7ce67ae --- /dev/null +++ b/run-app.sh @@ -0,0 +1,44 @@ +#\!/bin/bash + +# Colors +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +RED='\033[0;31m' +NC='\033[0m' # No Color + +# Function to get local IP address +get_ip_address() { + local ip="" + + if command -v ip &> /dev/null; then + ip=$(ip -4 addr show scope global | grep -oP '(?<=inet\s)\d+(\.\d+){3}' | head -n 1) + elif command -v hostname &> /dev/null; then + ip=$(hostname -I | awk '{print $1}') + elif command -v ifconfig &> /dev/null; then + ip=$(ifconfig | grep -Eo 'inet (addr:)?([0-9]*\.){3}[0-9]*' | grep -Eo '([0-9]*\.){3}[0-9]*' | grep -v '127.0.0.1' | head -n 1) + fi + + echo "$ip" +} + +# Get IP address +IP_ADDRESS=$(get_ip_address) + +echo -e "${GREEN}Starting Transmission RSS Manager...${NC}" +echo -e "${GREEN}The web interface will be available at:${NC}" +echo -e "${YELLOW} http://localhost:5000${NC}" +echo -e "${YELLOW} http://$IP_ADDRESS:5000${NC}" +echo -e "${YELLOW}Press Ctrl+C to stop the application${NC}" + +# Create logs directory if it doesn't exist +mkdir -p logs + +# Run the application +./TransmissionRssManager --urls=http://0.0.0.0:5000 + +# If we got here, check if there was an error +if [ $? -ne 0 ]; then + echo -e "${RED}The application exited with an error.${NC}" + echo -e "${YELLOW}Check the logs directory for more information.${NC}" + exit 1 +fi diff --git a/src/Api/Controllers/ConfigController.cs b/src/Api/Controllers/ConfigController.cs new file mode 100644 index 0000000..7556426 --- /dev/null +++ b/src/Api/Controllers/ConfigController.cs @@ -0,0 +1,323 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using TransmissionRssManager.Core; +using TransmissionRssManager.Services; + +namespace TransmissionRssManager.Api.Controllers +{ + [ApiController] + [Route("api/[controller]")] + public class ConfigController : ControllerBase + { + private readonly ILogger<ConfigController> _logger; + private readonly IConfigService _configService; + + public ConfigController( + ILogger<ConfigController> logger, + IConfigService configService) + { + _logger = logger; + _configService = configService; + } + + [HttpGet] + public IActionResult GetConfig() + { + var config = _configService.GetConfiguration(); + + // Create a sanitized config without sensitive information + var sanitizedConfig = new + { + transmission = new + { + host = config.Transmission.Host, + port = config.Transmission.Port, + useHttps = config.Transmission.UseHttps, + hasCredentials = !string.IsNullOrEmpty(config.Transmission.Username), + username = config.Transmission.Username + }, + transmissionInstances = config.TransmissionInstances?.Select(i => new + { + id = i.Key, + name = i.Value.Host, + host = i.Value.Host, + port = i.Value.Port, + useHttps = i.Value.UseHttps, + hasCredentials = !string.IsNullOrEmpty(i.Value.Username), + username = i.Value.Username + }), + autoDownloadEnabled = config.AutoDownloadEnabled, + checkIntervalMinutes = config.CheckIntervalMinutes, + downloadDirectory = config.DownloadDirectory, + mediaLibraryPath = config.MediaLibraryPath, + postProcessing = config.PostProcessing, + enableDetailedLogging = config.EnableDetailedLogging, + userPreferences = config.UserPreferences + }; + + return Ok(sanitizedConfig); + } + + [HttpGet("defaults")] + public IActionResult GetDefaultConfig() + { + // Return default configuration settings + var defaultConfig = new + { + transmission = new + { + host = "localhost", + port = 9091, + username = "", + useHttps = false + }, + autoDownloadEnabled = true, + checkIntervalMinutes = 30, + downloadDirectory = System.IO.Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Downloads"), + mediaLibraryPath = System.IO.Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Media"), + postProcessing = new + { + enabled = false, + extractArchives = true, + organizeMedia = true, + minimumSeedRatio = 1, + mediaExtensions = new[] { ".mp4", ".mkv", ".avi" }, + autoOrganizeByMediaType = true, + renameFiles = false, + compressCompletedFiles = false, + deleteCompletedAfterDays = 0 + }, + enableDetailedLogging = false, + userPreferences = new + { + enableDarkMode = false, + autoRefreshUIEnabled = true, + autoRefreshIntervalSeconds = 30, + notificationsEnabled = true, + notificationEvents = new[] { "torrent-added", "torrent-completed", "torrent-error" }, + defaultView = "dashboard", + confirmBeforeDelete = true, + maxItemsPerPage = 25, + dateTimeFormat = "yyyy-MM-dd HH:mm:ss", + showCompletedTorrents = true, + keepHistoryDays = 30 + } + }; + + return Ok(defaultConfig); + } + + [HttpPut] + public async Task<IActionResult> UpdateConfig([FromBody] AppConfig config) + { + try + { + _logger.LogInformation("Received request to update configuration"); + + if (config == null) + { + _logger.LogError("Received null configuration object"); + return BadRequest("Configuration cannot be null"); + } + + // Log the incoming configuration + _logger.LogInformation($"Received config with transmission host: {config.Transmission?.Host}, " + + $"autoDownload: {config.AutoDownloadEnabled}"); + + var currentConfig = _configService.GetConfiguration(); + + _logger.LogInformation($"Current config has transmission host: {currentConfig.Transmission?.Host}, " + + $"autoDownload: {currentConfig.AutoDownloadEnabled}"); + + // Make deep copy of current config to start with + var updatedConfig = JsonSerializer.Deserialize<AppConfig>( + JsonSerializer.Serialize(currentConfig), + new JsonSerializerOptions { PropertyNameCaseInsensitive = true } + ); + + if (updatedConfig == null) + { + _logger.LogError("Failed to create copy of current configuration"); + return StatusCode(500, "Failed to process configuration update"); + } + + // Apply changes from user input + // Transmission settings + if (config.Transmission != null) + { + updatedConfig.Transmission.Host = config.Transmission.Host ?? currentConfig.Transmission.Host; + updatedConfig.Transmission.Port = config.Transmission.Port; + updatedConfig.Transmission.UseHttps = config.Transmission.UseHttps; + updatedConfig.Transmission.Username = config.Transmission.Username ?? currentConfig.Transmission.Username; + + // Only update password if not empty + if (!string.IsNullOrEmpty(config.Transmission.Password)) + { + updatedConfig.Transmission.Password = config.Transmission.Password; + } + } + + // Core application settings + updatedConfig.AutoDownloadEnabled = config.AutoDownloadEnabled; + updatedConfig.CheckIntervalMinutes = config.CheckIntervalMinutes; + updatedConfig.DownloadDirectory = config.DownloadDirectory ?? currentConfig.DownloadDirectory; + updatedConfig.MediaLibraryPath = config.MediaLibraryPath ?? currentConfig.MediaLibraryPath; + updatedConfig.EnableDetailedLogging = config.EnableDetailedLogging; + + // Post processing settings + if (config.PostProcessing != null) + { + updatedConfig.PostProcessing.Enabled = config.PostProcessing.Enabled; + updatedConfig.PostProcessing.ExtractArchives = config.PostProcessing.ExtractArchives; + updatedConfig.PostProcessing.OrganizeMedia = config.PostProcessing.OrganizeMedia; + updatedConfig.PostProcessing.MinimumSeedRatio = config.PostProcessing.MinimumSeedRatio; + + if (config.PostProcessing.MediaExtensions != null && config.PostProcessing.MediaExtensions.Count > 0) + { + updatedConfig.PostProcessing.MediaExtensions = config.PostProcessing.MediaExtensions; + } + } + + // User preferences + if (config.UserPreferences != null) + { + updatedConfig.UserPreferences.EnableDarkMode = config.UserPreferences.EnableDarkMode; + updatedConfig.UserPreferences.AutoRefreshUIEnabled = config.UserPreferences.AutoRefreshUIEnabled; + updatedConfig.UserPreferences.AutoRefreshIntervalSeconds = config.UserPreferences.AutoRefreshIntervalSeconds; + updatedConfig.UserPreferences.NotificationsEnabled = config.UserPreferences.NotificationsEnabled; + } + + // Don't lose existing feeds + // Only update feeds if explicitly provided + if (config.Feeds != null && config.Feeds.Count > 0) + { + updatedConfig.Feeds = config.Feeds; + } + + // Log the config we're about to save (without sensitive data) + var sanitizedConfig = new + { + transmission = new + { + host = updatedConfig.Transmission.Host, + port = updatedConfig.Transmission.Port, + useHttps = updatedConfig.Transmission.UseHttps, + hasUsername = !string.IsNullOrEmpty(updatedConfig.Transmission.Username) + }, + autoDownloadEnabled = updatedConfig.AutoDownloadEnabled, + checkIntervalMinutes = updatedConfig.CheckIntervalMinutes, + downloadDirectory = updatedConfig.DownloadDirectory, + feedCount = updatedConfig.Feeds?.Count ?? 0, + postProcessingEnabled = updatedConfig.PostProcessing?.Enabled ?? false, + userPreferences = updatedConfig.UserPreferences != null + }; + _logger.LogInformation("About to save configuration: {@Config}", sanitizedConfig); + + await _configService.SaveConfigurationAsync(updatedConfig); + _logger.LogInformation("Configuration saved successfully"); + + return Ok(new { success = true, message = "Configuration saved successfully" }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error saving configuration"); + return StatusCode(500, $"Error saving configuration: {ex.Message}"); + } + } + + [HttpPost("backup")] + public IActionResult BackupConfig() + { + try + { + // Get the current config + var config = _configService.GetConfiguration(); + + // Serialize to JSON with indentation + var options = new JsonSerializerOptions { WriteIndented = true }; + var json = JsonSerializer.Serialize(config, options); + + // Create a memory stream from the JSON + var stream = new MemoryStream(Encoding.UTF8.GetBytes(json)); + + // Set the content disposition and type + var fileName = $"transmission-rss-config-backup-{DateTime.Now:yyyy-MM-dd}.json"; + return File(stream, "application/json", fileName); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error creating configuration backup"); + return StatusCode(500, "Error creating configuration backup"); + } + } + + [HttpPost("reset")] + public async Task<IActionResult> ResetConfig() + { + try + { + // Create a default config + var defaultConfig = new AppConfig + { + Transmission = new TransmissionConfig + { + Host = "localhost", + Port = 9091, + Username = "", + Password = "", + UseHttps = false + }, + AutoDownloadEnabled = true, + CheckIntervalMinutes = 30, + DownloadDirectory = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Downloads"), + MediaLibraryPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Media"), + PostProcessing = new PostProcessingConfig + { + Enabled = false, + ExtractArchives = true, + OrganizeMedia = true, + MinimumSeedRatio = 1, + MediaExtensions = new List<string> { ".mp4", ".mkv", ".avi", ".mov", ".wmv", ".m4v", ".mpg", ".mpeg", ".flv", ".webm" }, + AutoOrganizeByMediaType = true, + RenameFiles = false, + CompressCompletedFiles = false, + DeleteCompletedAfterDays = 0 + }, + UserPreferences = new TransmissionRssManager.Core.UserPreferences + { + EnableDarkMode = true, + AutoRefreshUIEnabled = true, + AutoRefreshIntervalSeconds = 30, + NotificationsEnabled = true, + NotificationEvents = new List<string> { "torrent-added", "torrent-completed", "torrent-error" }, + DefaultView = "dashboard", + ConfirmBeforeDelete = true, + MaxItemsPerPage = 25, + DateTimeFormat = "yyyy-MM-dd HH:mm:ss", + ShowCompletedTorrents = true, + KeepHistoryDays = 30 + }, + Feeds = new List<RssFeed>(), + EnableDetailedLogging = false + }; + + // Save the default config + await _configService.SaveConfigurationAsync(defaultConfig); + + return Ok(new { success = true }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error resetting configuration"); + return StatusCode(500, "Error resetting configuration"); + } + } + } +} \ No newline at end of file diff --git a/src/Api/Controllers/DashboardController.cs b/src/Api/Controllers/DashboardController.cs new file mode 100644 index 0000000..8446c22 --- /dev/null +++ b/src/Api/Controllers/DashboardController.cs @@ -0,0 +1,139 @@ +using System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using TransmissionRssManager.Services; + +namespace TransmissionRssManager.Api.Controllers +{ + [ApiController] + [Route("api/[controller]")] + public class DashboardController : ControllerBase + { + private readonly ILogger<DashboardController> _logger; + private readonly IMetricsService _metricsService; + private readonly ILoggingService _loggingService; + + public DashboardController( + ILogger<DashboardController> logger, + IMetricsService metricsService, + ILoggingService loggingService) + { + _logger = logger; + _metricsService = metricsService; + _loggingService = loggingService; + } + + [HttpGet("stats")] + public async Task<IActionResult> GetDashboardStats() + { + try + { + var stats = await _metricsService.GetDashboardStatsAsync(); + return Ok(stats); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting dashboard stats"); + return StatusCode(500, new { error = "Error retrieving dashboard statistics" }); + } + } + + [HttpGet("history")] + public async Task<IActionResult> GetDownloadHistory([FromQuery] int days = 30) + { + try + { + // Get dashboard stats which include necessary information + var stats = await _metricsService.GetDashboardStatsAsync(); + return Ok(new { + downloadHistory = stats["CompletedTorrents"], + period = $"Last {days} days" + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting download history"); + return StatusCode(500, new { error = "Error retrieving download history" }); + } + } + + [HttpGet("categories")] + public async Task<IActionResult> GetCategoryStats() + { + try + { + // Create a simplified category breakdown + var stats = await _metricsService.GetDashboardStatsAsync(); + return Ok(new { + categories = new { + active = stats["ActiveDownloads"], + seeding = stats["SeedingTorrents"], + completed = stats["CompletedTorrents"] + } + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting category stats"); + return StatusCode(500, new { error = "Error retrieving category statistics" }); + } + } + + [HttpGet("system")] + public async Task<IActionResult> GetSystemStatus() + { + try + { + var status = await _metricsService.GetSystemStatusAsync(); + return Ok(status); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting system status"); + return StatusCode(500, new { error = "Error retrieving system status" }); + } + } + + [HttpGet("disk")] + public async Task<IActionResult> GetDiskUsage() + { + try + { + var diskUsageStats = await _metricsService.EstimateDiskUsageAsync(); + return Ok(diskUsageStats); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting disk usage"); + _loggingService.Log( + LogLevel.Error, + $"Error getting disk usage: {ex.Message}", + "DashboardController" + ); + return StatusCode(500, new { error = "Error estimating disk usage" }); + } + } + + [HttpGet("performance")] + public async Task<IActionResult> GetPerformanceMetrics() + { + try + { + // Extract performance metrics from dashboard stats + var stats = await _metricsService.GetDashboardStatsAsync(); + return Ok(new { + downloadSpeed = stats["DownloadSpeed"], + uploadSpeed = stats["UploadSpeed"], + totalDownloaded = stats["TotalDownloaded"], + totalUploaded = stats["TotalUploaded"] + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting performance metrics"); + return StatusCode(500, new { error = "Error retrieving performance metrics" }); + } + } + } +} \ No newline at end of file diff --git a/src/Api/Controllers/FeedsController.cs b/src/Api/Controllers/FeedsController.cs new file mode 100644 index 0000000..bbc245b --- /dev/null +++ b/src/Api/Controllers/FeedsController.cs @@ -0,0 +1,84 @@ +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using TransmissionRssManager.Core; + +namespace TransmissionRssManager.Api.Controllers +{ + [ApiController] + [Route("api/[controller]")] + public class FeedsController : ControllerBase + { + private readonly ILogger<FeedsController> _logger; + private readonly IRssFeedManager _rssFeedManager; + + public FeedsController( + ILogger<FeedsController> logger, + IRssFeedManager rssFeedManager) + { + _logger = logger; + _rssFeedManager = rssFeedManager; + } + + [HttpGet] + public async Task<IActionResult> GetFeeds() + { + var feeds = await _rssFeedManager.GetFeedsAsync(); + return Ok(feeds); + } + + [HttpGet("items")] + public async Task<IActionResult> GetAllItems() + { + var items = await _rssFeedManager.GetAllItemsAsync(); + return Ok(items); + } + + [HttpGet("matched")] + public async Task<IActionResult> GetMatchedItems() + { + var items = await _rssFeedManager.GetMatchedItemsAsync(); + return Ok(items); + } + + [HttpPost] + public async Task<IActionResult> AddFeed([FromBody] RssFeed feed) + { + await _rssFeedManager.AddFeedAsync(feed); + return Ok(feed); + } + + [HttpPut("{id}")] + public async Task<IActionResult> UpdateFeed(string id, [FromBody] RssFeed feed) + { + if (id != feed.Id) + { + return BadRequest("Feed ID mismatch"); + } + + await _rssFeedManager.UpdateFeedAsync(feed); + return Ok(feed); + } + + [HttpDelete("{id}")] + public async Task<IActionResult> DeleteFeed(string id) + { + await _rssFeedManager.RemoveFeedAsync(id); + return Ok(); + } + + [HttpPost("refresh")] + public async Task<IActionResult> RefreshFeeds() + { + await _rssFeedManager.RefreshFeedsAsync(HttpContext.RequestAborted); + return Ok(new { success = true }); + } + + [HttpPost("download/{id}")] + public async Task<IActionResult> DownloadItem(string id) + { + await _rssFeedManager.MarkItemAsDownloadedAsync(id); + return Ok(new { success = true }); + } + } +} \ No newline at end of file diff --git a/src/Api/Controllers/LogsController.cs b/src/Api/Controllers/LogsController.cs new file mode 100644 index 0000000..d2eef0b --- /dev/null +++ b/src/Api/Controllers/LogsController.cs @@ -0,0 +1,105 @@ +using System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using TransmissionRssManager.Services; + +namespace TransmissionRssManager.Api.Controllers +{ + [ApiController] + [Route("api/[controller]")] + public class LogsController : ControllerBase + { + private readonly ILogger<LogsController> _logger; + private readonly ILoggingService _loggingService; + + public LogsController( + ILogger<LogsController> logger, + ILoggingService loggingService) + { + _logger = logger; + _loggingService = loggingService; + } + + [HttpGet] + public async Task<IActionResult> GetLogs([FromQuery] LogFilterOptions options) + { + try + { + var logs = await _loggingService.GetLogsAsync(options); + return Ok(logs); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting logs"); + return StatusCode(500, new { error = "Error retrieving logs" }); + } + } + + [HttpPost("clear")] + public async Task<IActionResult> ClearLogs([FromQuery] DateTime? olderThan = null) + { + try + { + await _loggingService.ClearLogsAsync(olderThan); + return Ok(new { success = true }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error clearing logs"); + return StatusCode(500, new { error = "Error clearing logs" }); + } + } + + [HttpGet("export")] + public async Task<IActionResult> ExportLogs([FromQuery] LogFilterOptions options) + { + try + { + var exportData = await _loggingService.ExportLogsAsync(options); + + // Return as a CSV file download + return File(exportData, "text/csv", $"transmission-rss-logs-{DateTime.UtcNow:yyyyMMdd-HHmmss}.csv"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error exporting logs"); + return StatusCode(500, new { error = "Error exporting logs" }); + } + } + + [HttpPost("message")] + public IActionResult LogMessage([FromBody] LogMessageRequest request) + { + try + { + if (string.IsNullOrEmpty(request.Message)) + { + return BadRequest(new { error = "Message is required" }); + } + + var logLevel = LogLevel.Information; + if (!string.IsNullOrEmpty(request.Level) && Enum.TryParse<LogLevel>(request.Level, true, out var parsedLevel)) + { + logLevel = parsedLevel; + } + + _loggingService.Log(logLevel, request.Message, request.Context, request.Properties); + return Ok(new { success = true }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error logging message"); + return StatusCode(500, new { error = "Error logging message" }); + } + } + } + + public class LogMessageRequest + { + public string Message { get; set; } + public string Level { get; set; } = "Information"; + public string Context { get; set; } + public System.Collections.Generic.Dictionary<string, string> Properties { get; set; } + } +} \ No newline at end of file diff --git a/src/Api/Controllers/TorrentsController.cs b/src/Api/Controllers/TorrentsController.cs new file mode 100644 index 0000000..10ac986 --- /dev/null +++ b/src/Api/Controllers/TorrentsController.cs @@ -0,0 +1,89 @@ +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using TransmissionRssManager.Core; +using TransmissionRssManager.Services; + +namespace TransmissionRssManager.Api.Controllers +{ + [ApiController] + [Route("api/[controller]")] + public class TorrentsController : ControllerBase + { + private readonly ILogger<TorrentsController> _logger; + private readonly ITransmissionClient _transmissionClient; + private readonly IConfigService _configService; + private readonly IPostProcessor _postProcessor; + + public TorrentsController( + ILogger<TorrentsController> logger, + ITransmissionClient transmissionClient, + IConfigService configService, + IPostProcessor postProcessor) + { + _logger = logger; + _transmissionClient = transmissionClient; + _configService = configService; + _postProcessor = postProcessor; + } + + [HttpGet] + public async Task<IActionResult> GetTorrents() + { + var torrents = await _transmissionClient.GetTorrentsAsync(); + return Ok(torrents); + } + + [HttpPost] + public async Task<IActionResult> AddTorrent([FromBody] AddTorrentRequest request) + { + var config = _configService.GetConfiguration(); + string downloadDir = request.DownloadDir ?? config.DownloadDirectory; + + var torrentId = await _transmissionClient.AddTorrentAsync(request.Url, downloadDir); + return Ok(new { id = torrentId }); + } + + [HttpDelete("{id}")] + public async Task<IActionResult> RemoveTorrent(int id, [FromQuery] bool deleteLocalData = false) + { + await _transmissionClient.RemoveTorrentAsync(id, deleteLocalData); + return Ok(); + } + + [HttpPost("{id}/start")] + public async Task<IActionResult> StartTorrent(int id) + { + await _transmissionClient.StartTorrentAsync(id); + return Ok(); + } + + [HttpPost("{id}/stop")] + public async Task<IActionResult> StopTorrent(int id) + { + await _transmissionClient.StopTorrentAsync(id); + return Ok(); + } + + [HttpPost("{id}/process")] + public async Task<IActionResult> ProcessTorrent(int id) + { + var torrents = await _transmissionClient.GetTorrentsAsync(); + var torrent = torrents.Find(t => t.Id == id); + + if (torrent == null) + { + return NotFound(); + } + + await _postProcessor.ProcessTorrentAsync(torrent); + return Ok(); + } + } + + public class AddTorrentRequest + { + public string Url { get; set; } = string.Empty; + public string DownloadDir { get; set; } = string.Empty; + } +} \ No newline at end of file diff --git a/src/Api/Program.cs b/src/Api/Program.cs new file mode 100644 index 0000000..6c93d11 --- /dev/null +++ b/src/Api/Program.cs @@ -0,0 +1,121 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.InMemory; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Serilog; +using System; +using System.IO; +using TransmissionRssManager.Core; +using TransmissionRssManager.Data; +using TransmissionRssManager.Data.Repositories; +using TransmissionRssManager.Services; + +var builder = WebApplication.CreateBuilder(args); + +// Create logs directory for file logging +var logsDirectory = Path.Combine(AppContext.BaseDirectory, "logs"); +Directory.CreateDirectory(logsDirectory); + +// Configure Serilog +Log.Logger = new LoggerConfiguration() + .ReadFrom.Configuration(builder.Configuration) + .Enrich.FromLogContext() + .WriteTo.Console() + .WriteTo.File(Path.Combine(logsDirectory, "log-.txt"), rollingInterval: RollingInterval.Day) + .CreateLogger(); + +// Use Serilog for logging +builder.Host.UseSerilog(); + +// Add services to the container +builder.Services.AddControllers(); +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); + +// Configure EF Core with in-memory database for testing +builder.Services.AddDbContext<TorrentManagerContext>(options => + options.UseInMemoryDatabase("TransmissionRssManager")); + +// Add repositories +builder.Services.AddScoped(typeof(IRepository<>), typeof(Repository<>)); +builder.Services.AddScoped<ITorrentRepository, TorrentRepository>(); + +// Add data migration service +builder.Services.AddScoped<DataMigrationService>(); + +// Add custom services +builder.Services.AddSingleton<IConfigService, ConfigService>(); +builder.Services.AddSingleton<ITransmissionClient, TransmissionClient>(); +builder.Services.AddSingleton<IRssFeedManager, RssFeedManager>(); +builder.Services.AddSingleton<IPostProcessor, PostProcessor>(); +builder.Services.AddSingleton<IMetricsService, MetricsService>(); +builder.Services.AddSingleton<ILoggingService, LoggingService>(); + +// Add scheduler service +builder.Services.AddSingleton<ISchedulerService, SchedulerService>(); + +// Add background services +builder.Services.AddHostedService<RssFeedBackgroundService>(); +builder.Services.AddHostedService<PostProcessingBackgroundService>(); + +var app = builder.Build(); + +// Initialize in-memory database +using (var scope = app.Services.CreateScope()) +{ + var services = scope.ServiceProvider; + try + { + var context = services.GetRequiredService<TorrentManagerContext>(); + + // Ensure database is created + context.Database.EnsureCreated(); + + // We'll skip the migration service for in-memory DB + Log.Information("Using in-memory database for testing. No migrations needed."); + } + catch (Exception ex) + { + var logger = services.GetRequiredService<ILogger<Program>>(); + logger.LogError(ex, "An error occurred while initializing the in-memory database."); + } +} + +// Configure middleware +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +// Configure static files to serve index.html as the default file +var options = new DefaultFilesOptions(); +options.DefaultFileNames.Clear(); +options.DefaultFileNames.Add("index.html"); +app.UseDefaultFiles(options); +app.UseStaticFiles(); + +// Enable directory browsing in development +if (app.Environment.IsDevelopment()) +{ + app.UseDirectoryBrowser(); +} + +app.UseRouting(); +app.UseAuthorization(); +app.MapControllers(); + +// Map fallback route for SPA +app.MapFallbackToFile("index.html"); + +try +{ + await app.RunAsync(); +} +catch (Exception ex) +{ + var logger = app.Services.GetRequiredService<ILogger<Program>>(); + logger.LogError(ex, "Application terminated unexpectedly"); +} \ No newline at end of file diff --git a/src/Core/Interfaces.cs b/src/Core/Interfaces.cs new file mode 100644 index 0000000..78d59b2 --- /dev/null +++ b/src/Core/Interfaces.cs @@ -0,0 +1,194 @@ +#nullable enable + +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; } = 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; } = string.Empty; + public bool IsDownloaded { get; set; } + public bool IsMatched { 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; } = string.Empty; + public string Status { get; set; } = string.Empty; + public double PercentDone { get; set; } + public long TotalSize { 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; } = 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; } = 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; } = 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; + public bool ExtractArchives { get; set; } = true; + public bool OrganizeMedia { get; set; } = true; + public int MinimumSeedRatio { get; set; } = 1; + public List<string> MediaExtensions { get; set; } = new List<string> { ".mp4", ".mkv", ".avi" }; + public 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 + { + AppConfig GetConfiguration(); + Task SaveConfigurationAsync(AppConfig config); + } + + public interface ITransmissionClient + { + Task<List<TorrentInfo>> GetTorrentsAsync(); + Task<int> AddTorrentAsync(string torrentUrl, string downloadDir); + Task RemoveTorrentAsync(int id, bool deleteLocalData); + Task StartTorrentAsync(int id); + Task StopTorrentAsync(int id); + } + + public interface IRssFeedManager + { + Task<List<RssFeedItem>> GetAllItemsAsync(); + Task<List<RssFeedItem>> GetMatchedItemsAsync(); + Task<List<RssFeed>> GetFeedsAsync(); + Task AddFeedAsync(RssFeed feed); + Task RemoveFeedAsync(string feedId); + Task UpdateFeedAsync(RssFeed feed); + Task RefreshFeedsAsync(CancellationToken cancellationToken); + Task RefreshFeedAsync(string feedId, CancellationToken cancellationToken); + Task MarkItemAsDownloadedAsync(string itemId); + } + + public interface IPostProcessor + { + Task ProcessCompletedDownloadsAsync(CancellationToken cancellationToken); + Task ProcessTorrentAsync(TorrentInfo torrent); + } +} \ No newline at end of file diff --git a/src/Data/DataMigrationService.cs b/src/Data/DataMigrationService.cs new file mode 100644 index 0000000..4dc71aa --- /dev/null +++ b/src/Data/DataMigrationService.cs @@ -0,0 +1,317 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using TransmissionRssManager.Data.Models; +using TransmissionRssManager.Data.Repositories; + +namespace TransmissionRssManager.Data +{ + public class DataMigrationService + { + private readonly IRepository<RssFeed> _feedRepository; + private readonly IRepository<RssFeedItem> _feedItemRepository; + private readonly IRepository<RssFeedRule> _ruleRepository; + private readonly ITorrentRepository _torrentRepository; + private readonly IRepository<UserPreference> _preferencesRepository; + private readonly ILogger<DataMigrationService> _logger; + + private readonly string _configDir = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + ".config", "transmission-rss-manager"); + + private readonly string _dataDir = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + ".local", "share", "transmission-rss-manager"); + + public DataMigrationService( + IRepository<RssFeed> feedRepository, + IRepository<RssFeedItem> feedItemRepository, + IRepository<RssFeedRule> ruleRepository, + ITorrentRepository torrentRepository, + IRepository<UserPreference> preferencesRepository, + ILogger<DataMigrationService> logger) + { + _feedRepository = feedRepository; + _feedItemRepository = feedItemRepository; + _ruleRepository = ruleRepository; + _torrentRepository = torrentRepository; + _preferencesRepository = preferencesRepository; + _logger = logger; + } + + public async Task MigrateDataAsync() + { + _logger.LogInformation("Beginning data migration from file-based storage to database"); + + try + { + // Migrate configuration preferences + await MigrateConfigurationAsync(); + + // Migrate RSS feed definitions and rules + await MigrateRssFeedsAsync(); + + // Migrate RSS items and torrent data + await MigrateRssItemsAsync(); + + _logger.LogInformation("Data migration completed successfully"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error during data migration"); + throw; + } + } + + private async Task MigrateConfigurationAsync() + { + var configPath = Path.Combine(_configDir, "config.json"); + if (!File.Exists(configPath)) + { + _logger.LogWarning("No configuration file found at: {ConfigPath}", configPath); + return; + } + + try + { + var configJson = File.ReadAllText(configPath); + using var doc = JsonDocument.Parse(configJson); + var root = doc.RootElement; + + // Extract key settings and save as preferences + if (root.TryGetProperty("TransmissionConfig", out var transmissionConfig)) + { + await AddPreferenceAsync("Transmission.Host", + transmissionConfig.GetProperty("Host").GetString()); + + await AddPreferenceAsync("Transmission.Port", + transmissionConfig.GetProperty("Port").GetInt32().ToString()); + + await AddPreferenceAsync("Transmission.Username", + transmissionConfig.GetProperty("Username").GetString()); + + await AddPreferenceAsync("Transmission.Password", + transmissionConfig.GetProperty("Password").GetString()); + + if (transmissionConfig.TryGetProperty("UseHttps", out var useHttps)) + { + await AddPreferenceAsync("Transmission.UseHttps", + useHttps.GetBoolean().ToString().ToLower()); + } + } + + if (root.TryGetProperty("EnablePostProcessing", out var enablePostProcessing)) + { + await AddPreferenceAsync("EnablePostProcessing", + enablePostProcessing.GetBoolean().ToString().ToLower(), + "Enable post-processing of completed torrents"); + } + + await _preferencesRepository.SaveChangesAsync(); + _logger.LogInformation("Configuration migration completed"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error migrating configuration"); + throw; + } + } + + private async Task MigrateRssFeedsAsync() + { + var configPath = Path.Combine(_configDir, "config.json"); + if (!File.Exists(configPath)) + { + _logger.LogWarning("No configuration file found at: {ConfigPath}", configPath); + return; + } + + try + { + var configJson = File.ReadAllText(configPath); + using var doc = JsonDocument.Parse(configJson); + var root = doc.RootElement; + + if (root.TryGetProperty("RssFeeds", out var rssFeedsJson)) + { + var feedsArray = rssFeedsJson.EnumerateArray(); + foreach (var feedJson in feedsArray) + { + var feed = new RssFeed + { + Name = feedJson.GetProperty("Name").GetString() ?? "Unnamed Feed", + Url = feedJson.GetProperty("Url").GetString() ?? "", + Enabled = feedJson.TryGetProperty("Enabled", out var enabled) + ? enabled.GetBoolean() + : true, + RefreshInterval = feedJson.TryGetProperty("RefreshInterval", out var interval) + ? interval.GetInt32() + : 15 + }; + + await _feedRepository.AddAsync(feed); + await _feedRepository.SaveChangesAsync(); + + // Process rules if they exist + if (feedJson.TryGetProperty("Rules", out var rulesJson)) + { + var rulesArray = rulesJson.EnumerateArray(); + int priority = 0; + + foreach (var ruleJson in rulesArray) + { + var rule = new RssFeedRule + { + RssFeedId = feed.Id, + Name = ruleJson.TryGetProperty("Name", out var name) + ? name.GetString() ?? "Unnamed Rule" + : "Unnamed Rule", + IncludePattern = ruleJson.TryGetProperty("IncludePattern", out var include) + ? include.GetString() + : null, + ExcludePattern = ruleJson.TryGetProperty("ExcludePattern", out var exclude) + ? exclude.GetString() + : null, + UseRegex = ruleJson.TryGetProperty("UseRegex", out var regex) + ? regex.GetBoolean() + : false, + Enabled = ruleJson.TryGetProperty("Enabled", out var ruleEnabled) + ? ruleEnabled.GetBoolean() + : true, + SaveToCustomPath = ruleJson.TryGetProperty("SaveToCustomPath", out var customPath) + ? customPath.GetBoolean() + : false, + CustomSavePath = ruleJson.TryGetProperty("CustomSavePath", out var savePath) + ? savePath.GetString() + : null, + EnablePostProcessing = ruleJson.TryGetProperty("EnablePostProcessing", out var postProcess) + ? postProcess.GetBoolean() + : false, + Priority = priority++ + }; + + await _ruleRepository.AddAsync(rule); + } + + await _ruleRepository.SaveChangesAsync(); + } + } + + _logger.LogInformation("RSS Feeds migration completed"); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error migrating RSS feeds"); + throw; + } + } + + private async Task MigrateRssItemsAsync() + { + var itemsPath = Path.Combine(_dataDir, "rss-items.json"); + if (!File.Exists(itemsPath)) + { + _logger.LogWarning("No RSS items file found at: {ItemsPath}", itemsPath); + return; + } + + try + { + var itemsJson = File.ReadAllText(itemsPath); + var items = JsonSerializer.Deserialize<List<RssFeedItemDTO>>(itemsJson, + new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + + if (items == null) return; + + // Get all feeds for lookup + var feeds = (await _feedRepository.GetAllAsync()).ToDictionary(f => f.Url); + + foreach (var itemDto in items) + { + // Skip if we can't match the feed + if (!feeds.TryGetValue(itemDto.FeedUrl, out var feed)) continue; + + var feedItem = new RssFeedItem + { + RssFeedId = feed.Id, + Title = itemDto.Title, + Link = itemDto.Link, + Description = itemDto.Description, + PublishDate = itemDto.PublishDate, + IsDownloaded = itemDto.IsDownloaded, + DiscoveredAt = itemDto.DiscoveredDate ?? DateTime.UtcNow, + DownloadedAt = itemDto.DownloadedDate + }; + + await _feedItemRepository.AddAsync(feedItem); + await _feedItemRepository.SaveChangesAsync(); + + // If this item has an associated torrent, migrate it too + if (itemDto.IsDownloaded && !string.IsNullOrEmpty(itemDto.TorrentHash)) + { + var torrent = new Torrent + { + Name = itemDto.Title, + Hash = itemDto.TorrentHash, + TransmissionId = itemDto.TransmissionId, + RssFeedItemId = feedItem.Id, + AddedOn = itemDto.DownloadedDate ?? feedItem.DiscoveredAt, + PostProcessed = itemDto.PostProcessed + }; + + await _torrentRepository.AddAsync(torrent); + await _torrentRepository.SaveChangesAsync(); + + // Update the feed item with the torrent reference + feedItem.TorrentId = torrent.Id; + await _feedItemRepository.UpdateAsync(feedItem); + await _feedItemRepository.SaveChangesAsync(); + } + } + + _logger.LogInformation("RSS Items migration completed with {ItemCount} items", items.Count); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error migrating RSS items"); + throw; + } + } + + private async Task AddPreferenceAsync(string key, string? value, string? description = null) + { + var preference = new UserPreference + { + Key = key, + Value = value, + Description = description, + Category = key.Contains('.') ? key.Split('.')[0] : "General", + DataType = int.TryParse(value, out _) ? "int" : + bool.TryParse(value, out _) ? "bool" : "string" + }; + + await _preferencesRepository.AddAsync(preference); + } + + // DTO class to help with deserialization + private class RssFeedItemDTO + { + public string Title { get; set; } = string.Empty; + public string Link { get; set; } = string.Empty; + public string FeedUrl { get; set; } = string.Empty; + public string? Description { get; set; } + public DateTime PublishDate { get; set; } + public bool IsDownloaded { get; set; } + public DateTime? DiscoveredDate { get; set; } + public DateTime? DownloadedDate { get; set; } + public string? TorrentHash { get; set; } + public int? TransmissionId { get; set; } + public bool PostProcessed { get; set; } + } + } +} \ No newline at end of file diff --git a/src/Data/Models/LogEntry.cs b/src/Data/Models/LogEntry.cs new file mode 100644 index 0000000..3e99577 --- /dev/null +++ b/src/Data/Models/LogEntry.cs @@ -0,0 +1,24 @@ +using System; +using System.ComponentModel.DataAnnotations; + +namespace TransmissionRssManager.Data.Models +{ + public class SystemLogEntry + { + [Key] + public int Id { get; set; } + + [Required] + public DateTime Timestamp { get; set; } = DateTime.UtcNow; + + [Required] + public string Level { get; set; } = "Information"; + + [Required] + public string Message { get; set; } = string.Empty; + + public string? Context { get; set; } + + public string? Properties { get; set; } + } +} \ No newline at end of file diff --git a/src/Data/Models/RssFeed.cs b/src/Data/Models/RssFeed.cs new file mode 100644 index 0000000..6fb6701 --- /dev/null +++ b/src/Data/Models/RssFeed.cs @@ -0,0 +1,47 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; + +namespace TransmissionRssManager.Data.Models +{ + public class RssFeed + { + [Key] + public int Id { get; set; } + + [Required] + public string Name { get; set; } = string.Empty; + + [Required] + public string Url { get; set; } = string.Empty; + + public bool Enabled { get; set; } = true; + + public DateTime LastCheckedAt { get; set; } = DateTime.MinValue; + + public string? LastError { get; set; } + + [JsonIgnore] + public List<RssFeedItem> Items { get; set; } = new List<RssFeedItem>(); + + [JsonIgnore] + public List<RssFeedRule> Rules { get; set; } = new List<RssFeedRule>(); + + public int RefreshInterval { get; set; } = 15; // Minutes + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + public DateTime? UpdatedAt { get; set; } + + public string Schedule { get; set; } = "*/30 * * * *"; // Default schedule (cron expression) + + public string? TransmissionInstanceId { get; set; } = "default"; + + public string? DefaultCategory { get; set; } + + public int MaxHistoryItems { get; set; } = 100; + + public int ErrorCount { get; set; } = 0; + } +} \ No newline at end of file diff --git a/src/Data/Models/RssFeedItem.cs b/src/Data/Models/RssFeedItem.cs new file mode 100644 index 0000000..4b4a94d --- /dev/null +++ b/src/Data/Models/RssFeedItem.cs @@ -0,0 +1,45 @@ +using System; +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; + +namespace TransmissionRssManager.Data.Models +{ + public class RssFeedItem + { + [Key] + public int Id { get; set; } + + [Required] + public string Title { get; set; } = string.Empty; + + [Required] + public string Link { get; set; } = string.Empty; + + public string? Description { get; set; } + + public DateTime PublishDate { get; set; } + + public int RssFeedId { get; set; } + + [JsonIgnore] + public RssFeed? RssFeed { get; set; } + + public bool IsDownloaded { get; set; } + + public DateTime DiscoveredAt { get; set; } = DateTime.UtcNow; + + public DateTime? DownloadedAt { get; set; } + + public int? TorrentId { get; set; } + + [JsonIgnore] + public Torrent? Torrent { get; set; } + + public int? MatchedRuleId { get; set; } + + [JsonIgnore] + public RssFeedRule? MatchedRule { get; set; } + + public string? DownloadError { get; set; } + } +} \ No newline at end of file diff --git a/src/Data/Models/RssFeedRule.cs b/src/Data/Models/RssFeedRule.cs new file mode 100644 index 0000000..dbec043 --- /dev/null +++ b/src/Data/Models/RssFeedRule.cs @@ -0,0 +1,44 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; + +namespace TransmissionRssManager.Data.Models +{ + public class RssFeedRule + { + [Key] + public int Id { get; set; } + + [Required] + public string Name { get; set; } = string.Empty; + + public int RssFeedId { get; set; } + + [JsonIgnore] + public RssFeed? RssFeed { get; set; } + + public string? IncludePattern { get; set; } + + public string? ExcludePattern { get; set; } + + public bool UseRegex { get; set; } = false; + + public bool Enabled { get; set; } = true; + + public bool SaveToCustomPath { get; set; } = false; + + public string? CustomSavePath { get; set; } + + public bool EnablePostProcessing { get; set; } = false; + + public int Priority { get; set; } = 0; + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + public DateTime? UpdatedAt { get; set; } + + [JsonIgnore] + public List<RssFeedItem> MatchedItems { get; set; } = new List<RssFeedItem>(); + } +} \ No newline at end of file diff --git a/src/Data/Models/Torrent.cs b/src/Data/Models/Torrent.cs new file mode 100644 index 0000000..0402a0a --- /dev/null +++ b/src/Data/Models/Torrent.cs @@ -0,0 +1,62 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; + +namespace TransmissionRssManager.Data.Models +{ + public class Torrent + { + [Key] + public int Id { get; set; } + + [Required] + public string Name { get; set; } = string.Empty; + + [Required] + public string Hash { get; set; } = string.Empty; + + public int? TransmissionId { get; set; } + + public string Status { get; set; } = string.Empty; + + public long TotalSize { get; set; } + + public double PercentDone { get; set; } + + public double UploadRatio { get; set; } + + public int? RssFeedItemId { get; set; } + + [JsonIgnore] + public RssFeedItem? RssFeedItem { get; set; } + + public DateTime AddedOn { get; set; } = DateTime.UtcNow; + + public DateTime? CompletedOn { get; set; } + + public bool PostProcessed { get; set; } + + public DateTime? PostProcessedOn { get; set; } + + public string? DownloadDirectory { get; set; } + + public string? ErrorMessage { get; set; } + + public string? Category { get; set; } + + public string? TransmissionInstance { get; set; } = "default"; + + public long DownloadedEver { get; set; } + + public long UploadedEver { get; set; } + + public double DownloadSpeed { get; set; } + + public double UploadSpeed { get; set; } + + public int PeersConnected { get; set; } + + public bool HasMetadata { get; set; } = true; + } +} \ No newline at end of file diff --git a/src/Data/Models/UserPreference.cs b/src/Data/Models/UserPreference.cs new file mode 100644 index 0000000..f73c576 --- /dev/null +++ b/src/Data/Models/UserPreference.cs @@ -0,0 +1,26 @@ +using System; +using System.ComponentModel.DataAnnotations; + +namespace TransmissionRssManager.Data.Models +{ + public class UserPreference + { + [Key] + public int Id { get; set; } + + [Required] + public string Key { get; set; } = string.Empty; + + public string? Value { get; set; } + + public string? Description { get; set; } + + public string? Category { get; set; } = "General"; + + public string? DataType { get; set; } = "string"; + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + public DateTime? UpdatedAt { get; set; } + } +} \ No newline at end of file diff --git a/src/Data/Repositories/IRepository.cs b/src/Data/Repositories/IRepository.cs new file mode 100644 index 0000000..5e75fb1 --- /dev/null +++ b/src/Data/Repositories/IRepository.cs @@ -0,0 +1,37 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using System.Threading.Tasks; + +namespace TransmissionRssManager.Data.Repositories +{ + public interface IRepository<T> where T : class + { + Task<IEnumerable<T>> GetAllAsync(); + + Task<IEnumerable<T>> FindAsync(Expression<Func<T, bool>> predicate); + + Task<T?> GetByIdAsync(int id); + + Task<T?> FirstOrDefaultAsync(Expression<Func<T, bool>> predicate); + + Task AddAsync(T entity); + + Task AddRangeAsync(IEnumerable<T> entities); + + Task UpdateAsync(T entity); + + Task RemoveAsync(T entity); + + Task RemoveRangeAsync(IEnumerable<T> entities); + + Task<bool> AnyAsync(Expression<Func<T, bool>> predicate); + + Task<int> CountAsync(Expression<Func<T, bool>> predicate); + + Task SaveChangesAsync(); + + IQueryable<T> Query(); + } +} \ No newline at end of file diff --git a/src/Data/Repositories/ITorrentRepository.cs b/src/Data/Repositories/ITorrentRepository.cs new file mode 100644 index 0000000..2fccad2 --- /dev/null +++ b/src/Data/Repositories/ITorrentRepository.cs @@ -0,0 +1,14 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using TransmissionRssManager.Data.Models; + +namespace TransmissionRssManager.Data.Repositories +{ + public interface ITorrentRepository : IRepository<Torrent> + { + Task<IEnumerable<Torrent>> GetCompletedNotProcessedAsync(); + Task<Torrent?> GetByHashAsync(string hash); + Task<IEnumerable<Torrent>> GetRecentlyAddedAsync(int count); + Task<IEnumerable<Torrent>> GetAllWithRssFeedItemsAsync(); + } +} \ No newline at end of file diff --git a/src/Data/Repositories/Repository.cs b/src/Data/Repositories/Repository.cs new file mode 100644 index 0000000..c9b70ba --- /dev/null +++ b/src/Data/Repositories/Repository.cs @@ -0,0 +1,90 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; + +namespace TransmissionRssManager.Data.Repositories +{ + public class Repository<T> : IRepository<T> where T : class + { + protected readonly TorrentManagerContext _context; + internal DbSet<T> _dbSet; + + public Repository(TorrentManagerContext context) + { + _context = context; + _dbSet = context.Set<T>(); + } + + public virtual async Task<IEnumerable<T>> GetAllAsync() + { + return await _dbSet.ToListAsync(); + } + + public virtual async Task<IEnumerable<T>> FindAsync(Expression<Func<T, bool>> predicate) + { + return await _dbSet.Where(predicate).ToListAsync(); + } + + public virtual async Task<T?> GetByIdAsync(int id) + { + return await _dbSet.FindAsync(id); + } + + public virtual async Task<T?> FirstOrDefaultAsync(Expression<Func<T, bool>> predicate) + { + return await _dbSet.FirstOrDefaultAsync(predicate); + } + + public virtual async Task AddAsync(T entity) + { + await _dbSet.AddAsync(entity); + } + + public virtual async Task AddRangeAsync(IEnumerable<T> entities) + { + await _dbSet.AddRangeAsync(entities); + } + + public virtual Task UpdateAsync(T entity) + { + _dbSet.Attach(entity); + _context.Entry(entity).State = EntityState.Modified; + return Task.CompletedTask; + } + + public virtual Task RemoveAsync(T entity) + { + _dbSet.Remove(entity); + return Task.CompletedTask; + } + + public virtual Task RemoveRangeAsync(IEnumerable<T> entities) + { + _dbSet.RemoveRange(entities); + return Task.CompletedTask; + } + + public virtual async Task<bool> AnyAsync(Expression<Func<T, bool>> predicate) + { + return await _dbSet.AnyAsync(predicate); + } + + public virtual async Task<int> CountAsync(Expression<Func<T, bool>> predicate) + { + return await _dbSet.CountAsync(predicate); + } + + public virtual async Task SaveChangesAsync() + { + await _context.SaveChangesAsync(); + } + + public virtual IQueryable<T> Query() + { + return _dbSet; + } + } +} \ No newline at end of file diff --git a/src/Data/Repositories/TorrentRepository.cs b/src/Data/Repositories/TorrentRepository.cs new file mode 100644 index 0000000..d0d2a15 --- /dev/null +++ b/src/Data/Repositories/TorrentRepository.cs @@ -0,0 +1,44 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using TransmissionRssManager.Data.Models; + +namespace TransmissionRssManager.Data.Repositories +{ + public class TorrentRepository : Repository<Torrent>, ITorrentRepository + { + public TorrentRepository(TorrentManagerContext context) : base(context) + { + } + + public async Task<IEnumerable<Torrent>> GetCompletedNotProcessedAsync() + { + return await _dbSet + .Where(t => t.PercentDone >= 1.0 && !t.PostProcessed) + .ToListAsync(); + } + + public async Task<Torrent?> GetByHashAsync(string hash) + { + return await _dbSet + .FirstOrDefaultAsync(t => t.Hash == hash); + } + + public async Task<IEnumerable<Torrent>> GetRecentlyAddedAsync(int count) + { + return await _dbSet + .OrderByDescending(t => t.AddedOn) + .Take(count) + .ToListAsync(); + } + + public async Task<IEnumerable<Torrent>> GetAllWithRssFeedItemsAsync() + { + return await _dbSet + .Include(t => t.RssFeedItem) + .ThenInclude(i => i!.RssFeed) + .ToListAsync(); + } + } +} \ No newline at end of file diff --git a/src/Data/TorrentManagerContext.cs b/src/Data/TorrentManagerContext.cs new file mode 100644 index 0000000..e4b7827 --- /dev/null +++ b/src/Data/TorrentManagerContext.cs @@ -0,0 +1,68 @@ +using System; +using Microsoft.EntityFrameworkCore; +using TransmissionRssManager.Data.Models; + +namespace TransmissionRssManager.Data +{ + public class TorrentManagerContext : DbContext + { + public TorrentManagerContext(DbContextOptions<TorrentManagerContext> options) : base(options) + { + } + + public DbSet<Torrent> Torrents { get; set; } = null!; + public DbSet<RssFeed> RssFeeds { get; set; } = null!; + public DbSet<RssFeedItem> RssFeedItems { get; set; } = null!; + public DbSet<RssFeedRule> RssFeedRules { get; set; } = null!; + public DbSet<UserPreference> UserPreferences { get; set; } = null!; + public DbSet<SystemLogEntry> SystemLogs { get; set; } = null!; + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + // Configure relationships + + // RssFeed - RssFeedItem: One-to-Many + modelBuilder.Entity<RssFeedItem>() + .HasOne(i => i.RssFeed) + .WithMany(f => f.Items) + .HasForeignKey(i => i.RssFeedId) + .OnDelete(DeleteBehavior.Cascade); + + // RssFeed - RssFeedRule: One-to-Many + modelBuilder.Entity<RssFeedRule>() + .HasOne(r => r.RssFeed) + .WithMany(f => f.Rules) + .HasForeignKey(r => r.RssFeedId) + .OnDelete(DeleteBehavior.Cascade); + + // RssFeedRule - RssFeedItem: One-to-Many (matched items) + modelBuilder.Entity<RssFeedItem>() + .HasOne(i => i.MatchedRule) + .WithMany(r => r.MatchedItems) + .HasForeignKey(i => i.MatchedRuleId) + .OnDelete(DeleteBehavior.SetNull); + + // RssFeedItem - Torrent: One-to-One + modelBuilder.Entity<RssFeedItem>() + .HasOne(i => i.Torrent) + .WithOne(t => t.RssFeedItem) + .HasForeignKey<Torrent>(t => t.RssFeedItemId) + .OnDelete(DeleteBehavior.SetNull); + + // Configure indexes for better performance + modelBuilder.Entity<Torrent>() + .HasIndex(t => t.Hash) + .IsUnique(); + + modelBuilder.Entity<RssFeedItem>() + .HasIndex(i => new { i.RssFeedId, i.Link }) + .IsUnique(); + + modelBuilder.Entity<UserPreference>() + .HasIndex(p => p.Key) + .IsUnique(); + + base.OnModelCreating(modelBuilder); + } + } +} \ No newline at end of file diff --git a/src/Data/TorrentManagerContextFactory.cs b/src/Data/TorrentManagerContextFactory.cs new file mode 100644 index 0000000..5f56960 --- /dev/null +++ b/src/Data/TorrentManagerContextFactory.cs @@ -0,0 +1,16 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; + +namespace TransmissionRssManager.Data +{ + // This class is used by Entity Framework Core tools to create migrations + public class TorrentManagerContextFactory : IDesignTimeDbContextFactory<TorrentManagerContext> + { + public TorrentManagerContext CreateDbContext(string[] args) + { + var optionsBuilder = new DbContextOptionsBuilder<TorrentManagerContext>(); + optionsBuilder.UseNpgsql("Host=localhost;Database=torrentmanager;Username=postgres;Password=postgres"); + return new TorrentManagerContext(optionsBuilder.Options); + } + } +} \ No newline at end of file diff --git a/src/Infrastructure/apply-migrations.sh b/src/Infrastructure/apply-migrations.sh new file mode 100755 index 0000000..a37d1bc --- /dev/null +++ b/src/Infrastructure/apply-migrations.sh @@ -0,0 +1,116 @@ +#!/bin/bash + +# Script to apply database migrations in production +# Usage: ./apply-migrations.sh [connection-string] + +set -e # Exit on error + +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +RED='\033[0;31m' +NC='\033[0m' # No Color + +CONNECTION_STRING=${1:-"Host=localhost;Database=torrentmanager;Username=postgres;Password=postgres"} + +# Navigate to the application directory +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +APP_DIR="$(dirname "$(dirname "$SCRIPT_DIR")")" +cd "$APP_DIR" + +echo -e "${YELLOW}Applying database migrations for Transmission RSS Manager...${NC}" +echo -e "Using connection string: $CONNECTION_STRING" + +# Create temp directory for migration +TEMP_DIR=$(mktemp -d) +echo -e "${GREEN}Created temporary directory: $TEMP_DIR${NC}" + +# Copy migration files to temp directory +mkdir -p "$TEMP_DIR/Migrations" +cp -r ./Migrations/* "$TEMP_DIR/Migrations/" + +# Create a simple program to apply migrations +cat > "$TEMP_DIR/ApplyMigrations.cs" << 'EOF' +using Microsoft.EntityFrameworkCore; +using System; +using TransmissionRssManager.Data; + +namespace MigrationUtil +{ + public class Program + { + public static void Main(string[] args) + { + if (args.Length < 1) + { + Console.WriteLine("Usage: dotnet run -- [connection-string]"); + return; + } + + string connectionString = args[0]; + Console.WriteLine($"Applying migrations using connection string: {connectionString}"); + + var optionsBuilder = new DbContextOptionsBuilder<TorrentManagerContext>(); + optionsBuilder.UseNpgsql(connectionString); + + try + { + using (var context = new TorrentManagerContext(optionsBuilder.Options)) + { + Console.WriteLine("Applying migrations..."); + context.Database.Migrate(); + Console.WriteLine("Migrations applied successfully!"); + } + } + catch (Exception ex) + { + Console.WriteLine($"Error applying migrations: {ex.Message}"); + Console.WriteLine(ex.StackTrace); + Environment.Exit(1); + } + } + } +} +EOF + +# Create project file +cat > "$TEMP_DIR/ApplyMigrations.csproj" << 'EOF' +<Project Sdk="Microsoft.NET.Sdk"> + <PropertyGroup> + <OutputType>Exe</OutputType> + <TargetFramework>net7.0</TargetFramework> + </PropertyGroup> + + <ItemGroup> + <PackageReference Include="Microsoft.EntityFrameworkCore" Version="7.0.16" /> + <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="7.0.16"> + <PrivateAssets>all</PrivateAssets> + <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> + </PackageReference> + <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="7.0.11" /> + </ItemGroup> +</Project> +EOF + +# Copy necessary files from the main project +mkdir -p "$TEMP_DIR/Data/Models" +cp ./src/Data/TorrentManagerContext.cs "$TEMP_DIR/Data/" +cp ./src/Data/Models/*.cs "$TEMP_DIR/Data/Models/" + +# Fix namespace issues in TorrentManagerContext.cs +sed -i 's/using TransmissionRssManager.Data.Models;/using TransmissionRssManager.Data.Models;\nusing Microsoft.EntityFrameworkCore.Design;/g' "$TEMP_DIR/Data/TorrentManagerContext.cs" + +# Build and run the migration utility +cd "$TEMP_DIR" +echo -e "${GREEN}Building migration utility...${NC}" +dotnet restore +dotnet build -c Release + +echo -e "${GREEN}Running migrations...${NC}" +dotnet run -c Release -- "$CONNECTION_STRING" + +# Clean up +cd "$APP_DIR" +echo -e "${GREEN}Cleaning up temporary files...${NC}" +rm -rf "$TEMP_DIR" + +echo -e "${GREEN}Migration completed successfully!${NC}" \ No newline at end of file diff --git a/src/Infrastructure/install-script.sh b/src/Infrastructure/install-script.sh new file mode 100755 index 0000000..c29b446 --- /dev/null +++ b/src/Infrastructure/install-script.sh @@ -0,0 +1,355 @@ +#!/bin/bash + +# TransmissionRssManager Installer Script for Linux +# This script installs the TransmissionRssManager application and its dependencies + +# Colors for output +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +RED='\033[0;31m' +NC='\033[0m' # No Color + +# Error handling +set -e +trap 'echo -e "${RED}An error occurred. Installation failed.${NC}"; exit 1' ERR + +# Check if script is run as root +if [ "$EUID" -eq 0 ]; then + echo -e "${YELLOW}Warning: It's recommended to run this script as a regular user with sudo privileges, not as root.${NC}" + read -p "Continue anyway? (y/n) " -n 1 -r + echo + if [[ ! $REPLY =~ ^[Yy]$ ]]; then + exit 1 + fi +fi + +# Detect Linux distribution +if [ -f /etc/os-release ]; then + . /etc/os-release + DISTRO=$ID +else + echo -e "${RED}Cannot detect Linux distribution. Exiting.${NC}" + exit 1 +fi + +echo -e "${GREEN}Installing TransmissionRssManager on $PRETTY_NAME...${NC}" + +# Install .NET SDK and runtime +install_dotnet() { + echo -e "${GREEN}Installing .NET SDK...${NC}" + + case $DISTRO in + ubuntu|debian|linuxmint) + # Add Microsoft package repository + wget -O packages-microsoft-prod.deb https://packages.microsoft.com/config/$DISTRO/$VERSION_ID/packages-microsoft-prod.deb + sudo dpkg -i packages-microsoft-prod.deb + rm packages-microsoft-prod.deb + + # Install .NET SDK + sudo apt-get update + sudo apt-get install -y apt-transport-https + sudo apt-get update + sudo apt-get install -y dotnet-sdk-7.0 + ;; + fedora|rhel|centos) + # Add Microsoft package repository + sudo rpm -Uvh https://packages.microsoft.com/config/centos/7/packages-microsoft-prod.rpm + + # Install .NET SDK + sudo yum install -y dotnet-sdk-7.0 + ;; + opensuse*|sles) + # Install .NET SDK from zypper + sudo zypper install -y dotnet-sdk-7.0 + ;; + arch|manjaro) + # Install .NET SDK from pacman + sudo pacman -Sy dotnet-sdk aspnet-runtime --noconfirm + ;; + *) + echo -e "${YELLOW}Unsupported distribution for automatic .NET installation.${NC}" + echo -e "${YELLOW}Please install .NET SDK 7.0 manually from https://dotnet.microsoft.com/download${NC}" + read -p "Press Enter to continue once .NET SDK is installed..." + ;; + esac + + # Verify .NET installation + dotnet --version + if [ $? -ne 0 ]; then + echo -e "${RED}.NET SDK installation failed. Please install .NET SDK 7.0 manually.${NC}" + exit 1 + fi +} + +# Install PostgreSQL +install_postgresql() { + echo -e "${GREEN}Installing PostgreSQL...${NC}" + + case $DISTRO in + ubuntu|debian|linuxmint) + sudo apt-get update + sudo apt-get install -y postgresql postgresql-contrib + ;; + fedora) + sudo dnf install -y postgresql-server postgresql-contrib + sudo postgresql-setup --initdb --unit postgresql + sudo systemctl enable postgresql + sudo systemctl start postgresql + ;; + rhel|centos) + sudo yum install -y postgresql-server postgresql-contrib + sudo postgresql-setup --initdb --unit postgresql + sudo systemctl enable postgresql + sudo systemctl start postgresql + ;; + opensuse*|sles) + sudo zypper install -y postgresql-server postgresql-contrib + sudo systemctl enable postgresql + sudo systemctl start postgresql + ;; + arch|manjaro) + sudo pacman -Sy postgresql --noconfirm + sudo mkdir -p /var/lib/postgres/data + sudo chown -R postgres:postgres /var/lib/postgres + sudo su - postgres -c "initdb --locale en_US.UTF-8 -D '/var/lib/postgres/data'" + sudo systemctl enable postgresql + sudo systemctl start postgresql + ;; + *) + echo -e "${YELLOW}Unsupported distribution for automatic PostgreSQL installation.${NC}" + echo -e "${YELLOW}Please install PostgreSQL manually.${NC}" + read -p "Press Enter to continue once PostgreSQL is installed..." + ;; + esac + + # Verify PostgreSQL installation + if ! command -v psql &> /dev/null; then + echo -e "${RED}PostgreSQL installation failed. Please install PostgreSQL manually.${NC}" + read -p "Do you want to continue with the installation without PostgreSQL? (y/n) " -n 1 -r + echo + if [[ ! $REPLY =~ ^[Yy]$ ]]; then + exit 1 + fi + SKIP_DB_SETUP=true + fi +} + +# Setup PostgreSQL database +setup_database() { + if [ "$SKIP_DB_SETUP" = true ]; then + echo -e "${YELLOW}Skipping database setup.${NC}" + return + fi + + echo -e "${GREEN}Setting up PostgreSQL database...${NC}" + + DB_NAME="torrentmanager" + DB_USER="torrentmanager" + + # Generate a random password + DB_PASSWORD=$(tr -dc 'A-Za-z0-9' < /dev/urandom | head -c 16) + + # Create user and database + sudo -u postgres psql -c "CREATE USER $DB_USER WITH PASSWORD '$DB_PASSWORD';" + sudo -u postgres psql -c "CREATE DATABASE $DB_NAME OWNER $DB_USER;" + sudo -u postgres psql -c "GRANT ALL PRIVILEGES ON DATABASE $DB_NAME TO $DB_USER;" + + echo -e "${GREEN}Database setup completed.${NC}" + + # Save database connection information to configuration + DATABASE_CONFIG="$HOME/.config/transmission-rss-manager/database.json" + echo '{ + "ConnectionStrings": { + "DefaultConnection": "Host=localhost;Database='$DB_NAME';Username='$DB_USER';Password='$DB_PASSWORD'" + } +}' > "$DATABASE_CONFIG" + + echo -e "${GREEN}Database configuration saved to $DATABASE_CONFIG${NC}" +} + +# Install dependencies +install_dependencies() { + echo -e "${GREEN}Installing dependencies...${NC}" + + case $DISTRO in + ubuntu|debian|linuxmint) + sudo apt-get update + sudo apt-get install -y unzip p7zip-full unrar-free libssl-dev zlib1g-dev libicu-dev build-essential + ;; + fedora|rhel|centos) + sudo yum install -y unzip p7zip unrar openssl-devel zlib-devel libicu-devel gcc-c++ make + ;; + opensuse*|sles) + sudo zypper install -y unzip p7zip unrar libopenssl-devel zlib-devel libicu-devel gcc-c++ make + ;; + arch|manjaro) + sudo pacman -Sy unzip p7zip unrar openssl zlib icu gcc make --noconfirm + ;; + *) + echo -e "${YELLOW}Unsupported distribution for automatic dependency installation.${NC}" + echo -e "${YELLOW}Please make sure the following are installed: unzip, p7zip, unrar, ssl, zlib, icu, gcc/g++ and make.${NC}" + ;; + esac + + # Install Entity Framework Core CLI tools if needed (version 7.x) + if ! command -v dotnet-ef &> /dev/null; then + echo -e "${GREEN}Installing Entity Framework Core tools compatible with .NET 7...${NC}" + dotnet tool install --global dotnet-ef --version 7.0.15 + fi +} + +# Check if .NET is already installed +if command -v dotnet >/dev/null 2>&1; then + dotnet_version=$(dotnet --version) + echo -e "${GREEN}.NET SDK version $dotnet_version is already installed.${NC}" +else + install_dotnet +fi + +# Check if PostgreSQL is already installed +if command -v psql >/dev/null 2>&1; then + pg_version=$(psql --version | grep -o 'PostgreSQL [0-9]\+\.[0-9]\+') + echo -e "${GREEN}$pg_version is already installed.${NC}" + + # Check if database exists + EXISTING_DB=$(sudo -u postgres psql -tAc "SELECT 1 FROM pg_database WHERE datname='torrentmanager'") + if [ "$EXISTING_DB" = "1" ]; then + echo -e "${GREEN}Database 'torrentmanager' already exists.${NC}" + read -p "Do you want to use the existing database? (y/n) " -n 1 -r + echo + if [[ ! $REPLY =~ ^[Yy]$ ]]; then + setup_database + else + echo -e "${GREEN}Using existing database.${NC}" + # TODO: Update connection string with existing credentials if needed + fi + else + setup_database + fi +else + install_postgresql + setup_database +fi + +# Install dependencies +install_dependencies + +# Create installation directory +INSTALL_DIR="$HOME/.local/share/transmission-rss-manager" +mkdir -p "$INSTALL_DIR" + +# Clone or download the application +echo -e "${GREEN}Downloading TransmissionRssManager...${NC}" +if [ -d "/opt/develop/transmission-rss-manager/TransmissionRssManager" ]; then + # We're running from the development directory + cp -r /opt/develop/transmission-rss-manager/TransmissionRssManager/* "$INSTALL_DIR/" +else + # Download and extract release + wget -O transmission-rss-manager.zip https://github.com/yourusername/transmission-rss-manager/releases/latest/download/transmission-rss-manager.zip + unzip transmission-rss-manager.zip -d "$INSTALL_DIR" + rm transmission-rss-manager.zip +fi + +# Install required NuGet packages (with versions compatible with .NET 7) +echo -e "${GREEN}Installing required NuGet packages...${NC}" +cd "$INSTALL_DIR" +dotnet add package Microsoft.AspNetCore.OpenApi --version 7.0.13 +dotnet add package Swashbuckle.AspNetCore --version 6.5.0 +dotnet add package System.ServiceModel.Syndication --version 7.0.0 +dotnet add package Microsoft.EntityFrameworkCore --version 7.0.17 +dotnet add package Microsoft.EntityFrameworkCore.Design --version 7.0.17 +dotnet add package Npgsql.EntityFrameworkCore.PostgreSQL --version 7.0.11 + +# Build the application +echo -e "${GREEN}Building TransmissionRssManager...${NC}" +dotnet build -c Release + +# Copy database configuration +CONFIG_DIR="$HOME/.config/transmission-rss-manager" +mkdir -p "$CONFIG_DIR" + +# Copy database.json to appsettings.json if it exists +if [ -f "$CONFIG_DIR/database.json" ]; then + # Merge database.json with any existing appsettings.json + if [ -f "$INSTALL_DIR/appsettings.json" ]; then + cp "$INSTALL_DIR/appsettings.json" "$CONFIG_DIR/appsettings.json.bak" + echo -e "${GREEN}Backed up existing appsettings.json to appsettings.json.bak${NC}" + # Extract connection string from database.json + CONNECTION_STRING=$(grep -o '"DefaultConnection": "[^"]*"' "$CONFIG_DIR/database.json") + # Update appsettings.json with connection string + if grep -q '"ConnectionStrings"' "$INSTALL_DIR/appsettings.json"; then + # Update existing ConnectionStrings section + sed -i 's/"DefaultConnection": "[^"]*"/'$CONNECTION_STRING'/' "$INSTALL_DIR/appsettings.json" + else + # Create ConnectionStrings section + CONNECTION_SECTION=$(grep -A 3 '"ConnectionStrings"' "$CONFIG_DIR/database.json") + # Insert ConnectionStrings section before the last } + sed -i '$ s/}/,\n '$CONNECTION_SECTION'\n}/' "$INSTALL_DIR/appsettings.json" + fi + else + # Create new appsettings.json + cp "$CONFIG_DIR/database.json" "$INSTALL_DIR/appsettings.json" + fi +fi + +# Run initial database migration if needed +echo -e "${GREEN}Setting up database schema...${NC}" +cd "$INSTALL_DIR" +dotnet ef database update + +# Create desktop entry +DESKTOP_FILE="$HOME/.local/share/applications/transmission-rss-manager.desktop" +echo "[Desktop Entry] +Name=Transmission RSS Manager +Comment=RSS Feed Manager for Transmission BitTorrent Client +Exec=dotnet $INSTALL_DIR/bin/Release/net7.0/TransmissionRssManager.dll +Icon=transmission +Terminal=false +Type=Application +Categories=Network;P2P;" > "$DESKTOP_FILE" + +# Create systemd service for user +SERVICE_DIR="$HOME/.config/systemd/user" +mkdir -p "$SERVICE_DIR" + +echo "[Unit] +Description=Transmission RSS Manager +After=network.target postgresql.service + +[Service] +ExecStart=dotnet $INSTALL_DIR/bin/Release/net7.0/TransmissionRssManager.dll +Restart=on-failure +RestartSec=10 +SyslogIdentifier=transmission-rss-manager + +[Install] +WantedBy=default.target" > "$SERVICE_DIR/transmission-rss-manager.service" + +# Reload systemd +systemctl --user daemon-reload + +# Create launcher script +LAUNCHER="$HOME/.local/bin/transmission-rss-manager" +mkdir -p "$HOME/.local/bin" + +echo "#!/bin/bash +dotnet $INSTALL_DIR/bin/Release/net7.0/TransmissionRssManager.dll" > "$LAUNCHER" +chmod +x "$LAUNCHER" + +echo -e "${GREEN}Installation completed!${NC}" +echo -e "${GREEN}You can run TransmissionRssManager in these ways:${NC}" +echo -e " * Command: ${YELLOW}transmission-rss-manager${NC}" +echo -e " * Service: ${YELLOW}systemctl --user start transmission-rss-manager${NC}" +echo -e " * Enable service on startup: ${YELLOW}systemctl --user enable transmission-rss-manager${NC}" +echo -e " * Web interface will be available at: ${YELLOW}http://localhost:5000${NC}" + +# Start the application +read -p "Do you want to start the application now? (y/n) " -n 1 -r +echo +if [[ $REPLY =~ ^[Yy]$ ]]; then + systemctl --user start transmission-rss-manager + echo -e "${GREEN}TransmissionRssManager service started.${NC}" + echo -e "${GREEN}Open http://localhost:5000 in your browser.${NC}" +else + echo -e "${YELLOW}You can start the application later using: systemctl --user start transmission-rss-manager${NC}" +fi \ No newline at end of file diff --git a/src/Infrastructure/packages-microsoft-prod.deb b/src/Infrastructure/packages-microsoft-prod.deb new file mode 100644 index 0000000..bdb822a Binary files /dev/null and b/src/Infrastructure/packages-microsoft-prod.deb differ diff --git a/src/Services/ConfigService.cs b/src/Services/ConfigService.cs new file mode 100644 index 0000000..1dbc2e1 --- /dev/null +++ b/src/Services/ConfigService.cs @@ -0,0 +1,648 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using TransmissionRssManager.Core; + +namespace TransmissionRssManager.Services +{ + /// <summary> + /// Service for managing application configuration + /// File-based implementation that does not use a database + /// </summary> + public class ConfigService : IConfigService + { + private readonly ILogger<ConfigService> _logger; + private readonly string _configFilePath; + private AppConfig? _cachedConfig; + private readonly object _lockObject = new object(); + + public ConfigService(ILogger<ConfigService> logger) + { + _logger = logger; + + // Determine the appropriate config file path + string baseDir = AppContext.BaseDirectory; + string etcConfigPath = "/etc/transmission-rss-manager/appsettings.json"; + string localConfigPath = Path.Combine(baseDir, "appsettings.json"); + + // Check if config exists in /etc (preferred) or in app directory + _configFilePath = File.Exists(etcConfigPath) ? etcConfigPath : localConfigPath; + + _logger.LogInformation($"Using configuration file: {_configFilePath}"); + } + + // Implement the interface methods required by IConfigService + public AppConfig GetConfiguration() + { + // Non-async method required by interface + _logger.LogDebug($"GetConfiguration called, cached config is {(_cachedConfig == null ? "null" : "available")}"); + + if (_cachedConfig != null) + { + _logger.LogDebug("Returning cached configuration"); + return _cachedConfig; + } + + try + { + // Load synchronously since this is a sync method + _logger.LogInformation("Loading configuration from file (sync method)"); + _cachedConfig = LoadConfigFromFileSync(); + + // Log what we loaded + if (_cachedConfig != null) + { + _logger.LogInformation($"Loaded configuration with {_cachedConfig.Feeds?.Count ?? 0} feeds, " + + $"transmission host: {_cachedConfig.Transmission?.Host}, " + + $"autoDownload: {_cachedConfig.AutoDownloadEnabled}"); + } + + return _cachedConfig; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error loading configuration, using default values"); + _cachedConfig = CreateDefaultConfig(); + return _cachedConfig; + } + } + + public async Task SaveConfigurationAsync(AppConfig config) + { + try + { + if (config == null) + { + _logger.LogError("Cannot save null configuration"); + throw new ArgumentNullException(nameof(config)); + } + + _logger.LogInformation($"SaveConfigurationAsync called with config: " + + $"transmission host = {config.Transmission?.Host}, " + + $"autoDownload = {config.AutoDownloadEnabled}"); + + // Create deep copy to ensure we don't have reference issues + string json = JsonSerializer.Serialize(config); + AppConfig configCopy = JsonSerializer.Deserialize<AppConfig>(json, + new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + + if (configCopy == null) + { + throw new InvalidOperationException("Failed to create copy of configuration for saving"); + } + + // Ensure all properties are properly set + EnsureCompleteConfig(configCopy); + + // Update cached config + _cachedConfig = configCopy; + + _logger.LogInformation("About to save configuration to file"); + await SaveConfigToFileAsync(configCopy); + _logger.LogInformation("Configuration saved successfully to file"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error saving configuration to file"); + throw; + } + } + + // Additional methods for backward compatibility + public async Task<AppConfig> GetConfigAsync() + { + if (_cachedConfig != null) + { + return _cachedConfig; + } + + try + { + _cachedConfig = await LoadConfigFromFileAsync(); + return _cachedConfig; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error loading configuration, using default values"); + return CreateDefaultConfig(); + } + } + + public async Task SaveConfigAsync(AppConfig config) + { + await SaveConfigurationAsync(config); + } + + public async Task<string> GetSettingAsync(string key, string defaultValue = "") + { + var config = await GetConfigAsync(); + + switch (key) + { + case "Transmission.Host": + return config.Transmission.Host ?? defaultValue; + case "Transmission.Port": + return config.Transmission.Port.ToString(); + case "Transmission.Username": + return config.Transmission.Username ?? defaultValue; + case "Transmission.Password": + return config.Transmission.Password ?? defaultValue; + case "Transmission.UseHttps": + return config.Transmission.UseHttps.ToString(); + case "AutoDownloadEnabled": + return config.AutoDownloadEnabled.ToString(); + case "CheckIntervalMinutes": + return config.CheckIntervalMinutes.ToString(); + case "DownloadDirectory": + return config.DownloadDirectory ?? defaultValue; + case "MediaLibraryPath": + return config.MediaLibraryPath ?? defaultValue; + case "PostProcessing.Enabled": + return config.PostProcessing.Enabled.ToString(); + case "PostProcessing.ExtractArchives": + return config.PostProcessing.ExtractArchives.ToString(); + case "PostProcessing.OrganizeMedia": + return config.PostProcessing.OrganizeMedia.ToString(); + case "PostProcessing.MinimumSeedRatio": + return config.PostProcessing.MinimumSeedRatio.ToString(); + case "UserPreferences.EnableDarkMode": + return config.UserPreferences.EnableDarkMode.ToString(); + case "UserPreferences.AutoRefreshUIEnabled": + return config.UserPreferences.AutoRefreshUIEnabled.ToString(); + case "UserPreferences.AutoRefreshIntervalSeconds": + return config.UserPreferences.AutoRefreshIntervalSeconds.ToString(); + case "UserPreferences.NotificationsEnabled": + return config.UserPreferences.NotificationsEnabled.ToString(); + default: + _logger.LogWarning($"Unknown setting key: {key}"); + return defaultValue; + } + } + + public async Task SaveSettingAsync(string key, string value) + { + var config = await GetConfigAsync(); + bool changed = false; + + try + { + switch (key) + { + case "Transmission.Host": + config.Transmission.Host = value; + changed = true; + break; + case "Transmission.Port": + if (int.TryParse(value, out int port)) + { + config.Transmission.Port = port; + changed = true; + } + break; + case "Transmission.Username": + config.Transmission.Username = value; + changed = true; + break; + case "Transmission.Password": + config.Transmission.Password = value; + changed = true; + break; + case "Transmission.UseHttps": + if (bool.TryParse(value, out bool useHttps)) + { + config.Transmission.UseHttps = useHttps; + changed = true; + } + break; + case "AutoDownloadEnabled": + if (bool.TryParse(value, out bool autoDownload)) + { + config.AutoDownloadEnabled = autoDownload; + changed = true; + } + break; + case "CheckIntervalMinutes": + if (int.TryParse(value, out int interval)) + { + config.CheckIntervalMinutes = interval; + changed = true; + } + break; + case "DownloadDirectory": + config.DownloadDirectory = value; + changed = true; + break; + case "MediaLibraryPath": + config.MediaLibraryPath = value; + changed = true; + break; + case "PostProcessing.Enabled": + if (bool.TryParse(value, out bool ppEnabled)) + { + config.PostProcessing.Enabled = ppEnabled; + changed = true; + } + break; + case "PostProcessing.ExtractArchives": + if (bool.TryParse(value, out bool extractArchives)) + { + config.PostProcessing.ExtractArchives = extractArchives; + changed = true; + } + break; + case "PostProcessing.OrganizeMedia": + if (bool.TryParse(value, out bool organizeMedia)) + { + config.PostProcessing.OrganizeMedia = organizeMedia; + changed = true; + } + break; + case "PostProcessing.MinimumSeedRatio": + if (float.TryParse(value, out float seedRatio)) + { + config.PostProcessing.MinimumSeedRatio = (int)seedRatio; + changed = true; + } + break; + case "UserPreferences.EnableDarkMode": + if (bool.TryParse(value, out bool darkMode)) + { + config.UserPreferences.EnableDarkMode = darkMode; + changed = true; + } + break; + case "UserPreferences.AutoRefreshUIEnabled": + if (bool.TryParse(value, out bool autoRefresh)) + { + config.UserPreferences.AutoRefreshUIEnabled = autoRefresh; + changed = true; + } + break; + case "UserPreferences.AutoRefreshIntervalSeconds": + if (int.TryParse(value, out int refreshInterval)) + { + config.UserPreferences.AutoRefreshIntervalSeconds = refreshInterval; + changed = true; + } + break; + case "UserPreferences.NotificationsEnabled": + if (bool.TryParse(value, out bool notifications)) + { + config.UserPreferences.NotificationsEnabled = notifications; + changed = true; + } + break; + default: + _logger.LogWarning($"Unknown setting key: {key}"); + break; + } + + if (changed) + { + await SaveConfigAsync(config); + } + } + catch (Exception ex) + { + _logger.LogError(ex, $"Error saving setting {key}"); + throw; + } + } + + private AppConfig LoadConfigFromFileSync() + { + try + { + if (!File.Exists(_configFilePath)) + { + _logger.LogWarning($"Configuration file not found at {_configFilePath}, creating default config"); + var defaultConfig = CreateDefaultConfig(); + // Save synchronously since we're in a sync method + File.WriteAllText(_configFilePath, JsonSerializer.Serialize(defaultConfig, new JsonSerializerOptions + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + })); + return defaultConfig; + } + + string json = File.ReadAllText(_configFilePath); + var config = JsonSerializer.Deserialize<AppConfig>(json, new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }); + + if (config == null) + { + throw new InvalidOperationException("Failed to deserialize configuration"); + } + + // Fill in any missing values with defaults + EnsureCompleteConfig(config); + + return config; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error loading configuration from file"); + throw; + } + } + + private async Task<AppConfig> LoadConfigFromFileAsync() + { + try + { + if (!File.Exists(_configFilePath)) + { + _logger.LogWarning($"Configuration file not found at {_configFilePath}, creating default config"); + var defaultConfig = CreateDefaultConfig(); + await SaveConfigToFileAsync(defaultConfig); + return defaultConfig; + } + + string json = await File.ReadAllTextAsync(_configFilePath); + var config = JsonSerializer.Deserialize<AppConfig>(json, new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }); + + if (config == null) + { + throw new InvalidOperationException("Failed to deserialize configuration"); + } + + // Fill in any missing values with defaults + EnsureCompleteConfig(config); + + return config; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error loading configuration from file"); + throw; + } + } + + private async Task SaveConfigToFileAsync(AppConfig config) + { + try + { + string json = JsonSerializer.Serialize(config, new JsonSerializerOptions + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }); + + // Create directory if it doesn't exist + string directory = Path.GetDirectoryName(_configFilePath); + if (!Directory.Exists(directory) && !string.IsNullOrEmpty(directory)) + { + _logger.LogInformation($"Creating directory: {directory}"); + Directory.CreateDirectory(directory); + } + + // Log detailed info about the file we're trying to write to + _logger.LogInformation($"Attempting to save configuration to {_configFilePath}"); + + bool canWriteToOriginalPath = false; + + try + { + // Check if we have write permissions to the directory + var directoryInfo = new DirectoryInfo(directory); + _logger.LogInformation($"Directory exists: {directoryInfo.Exists}, Directory path: {directoryInfo.FullName}"); + + // Check if we have write permissions to the file + var fileInfo = new FileInfo(_configFilePath); + if (fileInfo.Exists) + { + _logger.LogInformation($"File exists: {fileInfo.Exists}, File path: {fileInfo.FullName}, Is read-only: {fileInfo.IsReadOnly}"); + + // Try to make the file writable if it's read-only + if (fileInfo.IsReadOnly) + { + _logger.LogWarning("Configuration file is read-only, attempting to make it writable"); + try + { + fileInfo.IsReadOnly = false; + canWriteToOriginalPath = true; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to make file writable"); + } + } + else + { + canWriteToOriginalPath = true; + } + } + else + { + // If file doesn't exist, check if we can write to the directory + try + { + // Try to create a test file + string testFilePath = Path.Combine(directory, "writetest.tmp"); + File.WriteAllText(testFilePath, "test"); + File.Delete(testFilePath); + canWriteToOriginalPath = true; + } + catch (Exception ex) + { + _logger.LogError(ex, "Cannot write to directory"); + } + } + } + catch (Exception permEx) + { + _logger.LogError(permEx, "Error checking file permissions"); + } + + string configFilePath = _configFilePath; + + // If we can't write to the original path, use a fallback path in a location we know we can write to + if (!canWriteToOriginalPath) + { + string fallbackPath = Path.Combine(AppContext.BaseDirectory, "appsettings.json"); + _logger.LogWarning($"Cannot write to original path, using fallback path: {fallbackPath}"); + configFilePath = fallbackPath; + + // Update the config file path for future loads + _configFilePath = fallbackPath; + } + + try + { + // Write directly to the file - first try direct write + _logger.LogInformation($"Writing configuration to {configFilePath}"); + await File.WriteAllTextAsync(configFilePath, json); + _logger.LogInformation("Configuration successfully saved by direct write"); + return; + } + catch (Exception writeEx) + { + _logger.LogError(writeEx, "Direct write failed, trying with temporary file"); + } + + // If direct write fails, try with temporary file + string tempDirectory = AppContext.BaseDirectory; + string tempFilePath = Path.Combine(tempDirectory, $"appsettings.{Guid.NewGuid():N}.tmp"); + + _logger.LogInformation($"Writing to temporary file: {tempFilePath}"); + await File.WriteAllTextAsync(tempFilePath, json); + + try + { + _logger.LogInformation($"Copying from {tempFilePath} to {configFilePath}"); + File.Copy(tempFilePath, configFilePath, true); + _logger.LogInformation("Configuration successfully saved via temp file"); + } + catch (Exception copyEx) + { + _logger.LogError(copyEx, "Error copying from temp file to destination"); + + // If copy fails, keep the temp file and use it as the config path + _logger.LogWarning($"Using temporary file as permanent config: {tempFilePath}"); + _configFilePath = tempFilePath; + } + finally + { + try + { + if (File.Exists(tempFilePath) && tempFilePath != _configFilePath) + { + File.Delete(tempFilePath); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Could not delete temp file"); + } + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error saving configuration to file"); + throw; + } + } + + private AppConfig CreateDefaultConfig() + { + var defaultConfig = new AppConfig + { + Transmission = new TransmissionConfig + { + Host = "localhost", + Port = 9091, + Username = "", + Password = "", + UseHttps = false + }, + TransmissionInstances = new Dictionary<string, TransmissionConfig>(), + Feeds = new List<RssFeed>(), + AutoDownloadEnabled = true, + CheckIntervalMinutes = 30, + DownloadDirectory = "/var/lib/transmission-daemon/downloads", + MediaLibraryPath = "/media/library", + EnableDetailedLogging = false, + PostProcessing = new PostProcessingConfig + { + Enabled = false, + ExtractArchives = true, + OrganizeMedia = true, + MinimumSeedRatio = 1, + MediaExtensions = new List<string> { ".mp4", ".mkv", ".avi", ".mov", ".wmv" }, + AutoOrganizeByMediaType = true, + RenameFiles = false, + CompressCompletedFiles = false, + DeleteCompletedAfterDays = 0 + }, + UserPreferences = new TransmissionRssManager.Core.UserPreferences + { + EnableDarkMode = true, + AutoRefreshUIEnabled = true, + AutoRefreshIntervalSeconds = 30, + NotificationsEnabled = true, + NotificationEvents = new List<string> { "torrent-added", "torrent-completed", "torrent-error" }, + DefaultView = "dashboard", + ConfirmBeforeDelete = true, + MaxItemsPerPage = 25, + DateTimeFormat = "yyyy-MM-dd HH:mm:ss", + ShowCompletedTorrents = true, + KeepHistoryDays = 30 + } + }; + + _logger.LogInformation("Created default configuration"); + return defaultConfig; + } + + private void EnsureCompleteConfig(AppConfig config) + { + // Create new instances for any null nested objects + config.Transmission ??= new TransmissionConfig + { + Host = "localhost", + Port = 9091, + Username = "", + Password = "", + UseHttps = false + }; + + config.TransmissionInstances ??= new Dictionary<string, TransmissionConfig>(); + config.Feeds ??= new List<RssFeed>(); + + config.PostProcessing ??= new PostProcessingConfig + { + Enabled = false, + ExtractArchives = true, + OrganizeMedia = true, + MinimumSeedRatio = 1, + MediaExtensions = new List<string> { ".mp4", ".mkv", ".avi", ".mov", ".wmv" }, + AutoOrganizeByMediaType = true, + RenameFiles = false, + CompressCompletedFiles = false, + DeleteCompletedAfterDays = 0 + }; + + // Ensure PostProcessing MediaExtensions is not null + config.PostProcessing.MediaExtensions ??= new List<string> { ".mp4", ".mkv", ".avi", ".mov", ".wmv" }; + + config.UserPreferences ??= new TransmissionRssManager.Core.UserPreferences + { + EnableDarkMode = true, + AutoRefreshUIEnabled = true, + AutoRefreshIntervalSeconds = 30, + NotificationsEnabled = true, + NotificationEvents = new List<string> { "torrent-added", "torrent-completed", "torrent-error" }, + DefaultView = "dashboard", + ConfirmBeforeDelete = true, + MaxItemsPerPage = 25, + DateTimeFormat = "yyyy-MM-dd HH:mm:ss", + ShowCompletedTorrents = true, + KeepHistoryDays = 30 + }; + + // Ensure UserPreferences.NotificationEvents is not null + config.UserPreferences.NotificationEvents ??= new List<string> { "torrent-added", "torrent-completed", "torrent-error" }; + + // Ensure default values for string properties if they're null + config.DownloadDirectory ??= "/var/lib/transmission-daemon/downloads"; + config.MediaLibraryPath ??= "/media/library"; + config.Transmission.Host ??= "localhost"; + config.Transmission.Username ??= ""; + config.Transmission.Password ??= ""; + config.UserPreferences.DefaultView ??= "dashboard"; + config.UserPreferences.DateTimeFormat ??= "yyyy-MM-dd HH:mm:ss"; + + _logger.LogDebug("Config validated and completed with default values where needed"); + } + } +} \ No newline at end of file diff --git a/src/Services/LoggingService.cs b/src/Services/LoggingService.cs new file mode 100644 index 0000000..9c843dd --- /dev/null +++ b/src/Services/LoggingService.cs @@ -0,0 +1,219 @@ +#nullable enable + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; + +namespace TransmissionRssManager.Services +{ + 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 LoggingPreferences + { + 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 ILoggingService + { + void Configure(TransmissionRssManager.Core.UserPreferences preferences); + Task<List<LogEntry>> GetLogsAsync(LogFilterOptions options); + Task ClearLogsAsync(DateTime? olderThan = null); + Task<byte[]> ExportLogsAsync(LogFilterOptions options); + void Log(LogLevel level, string message, string? context = null, Dictionary<string, string>? properties = null); + } + + public class LogFilterOptions + { + public string Level { get; set; } = "All"; + public string Search { get; set; } = ""; + public DateTime? StartDate { get; set; } + public DateTime? EndDate { get; set; } + public string Context { get; set; } = ""; + public int Limit { get; set; } = 100; + public int Offset { get; set; } = 0; + } + + public class LoggingService : ILoggingService + { + private readonly ILogger<LoggingService> _logger; + private readonly string _logFilePath; + private readonly object _logLock = new object(); + private List<LogEntry> _inMemoryLogs = new List<LogEntry>(); + private readonly int _maxLogEntries = 1000; + + public LoggingService(ILogger<LoggingService> logger) + { + _logger = logger; + + // Prepare log directory and file + var logsDirectory = Path.Combine(AppContext.BaseDirectory, "logs"); + Directory.CreateDirectory(logsDirectory); + _logFilePath = Path.Combine(logsDirectory, "application_logs.json"); + + // Initialize log file if it doesn't exist + if (!File.Exists(_logFilePath)) + { + File.WriteAllText(_logFilePath, "[]"); + } + + // Load existing logs into memory + try + { + var json = File.ReadAllText(_logFilePath); + _inMemoryLogs = JsonSerializer.Deserialize<List<LogEntry>>(json) ?? new List<LogEntry>(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to load logs from file"); + _inMemoryLogs = new List<LogEntry>(); + } + } + + public void Configure(TransmissionRssManager.Core.UserPreferences preferences) + { + // No-op in simplified version + } + + public Task<List<LogEntry>> GetLogsAsync(LogFilterOptions options) + { + var filteredLogs = _inMemoryLogs.AsEnumerable(); + + // Apply filters + if (!string.IsNullOrEmpty(options.Level) && options.Level != "All") + { + filteredLogs = filteredLogs.Where(l => l.Level == options.Level); + } + + if (!string.IsNullOrEmpty(options.Search)) + { + filteredLogs = filteredLogs.Where(l => + l.Message.Contains(options.Search, StringComparison.OrdinalIgnoreCase)); + } + + if (options.StartDate.HasValue) + { + filteredLogs = filteredLogs.Where(l => l.Timestamp >= options.StartDate.Value); + } + + if (options.EndDate.HasValue) + { + filteredLogs = filteredLogs.Where(l => l.Timestamp <= options.EndDate.Value); + } + + if (!string.IsNullOrEmpty(options.Context)) + { + filteredLogs = filteredLogs.Where(l => l.Context == options.Context); + } + + // Sort, paginate and return + return Task.FromResult( + filteredLogs + .OrderByDescending(l => l.Timestamp) + .Skip(options.Offset) + .Take(options.Limit) + .ToList() + ); + } + + public Task ClearLogsAsync(DateTime? olderThan = null) + { + lock (_logLock) + { + if (olderThan.HasValue) + { + _inMemoryLogs.RemoveAll(l => l.Timestamp < olderThan.Value); + } + else + { + _inMemoryLogs.Clear(); + } + + SaveLogs(); + } + + return Task.CompletedTask; + } + + public async Task<byte[]> ExportLogsAsync(LogFilterOptions options) + { + var logs = await GetLogsAsync(options); + var json = JsonSerializer.Serialize(logs, new JsonSerializerOptions { WriteIndented = true }); + return Encoding.UTF8.GetBytes(json); + } + + public void Log(LogLevel level, string message, string? context = null, Dictionary<string, string>? properties = null) + { + var levelString = level.ToString(); + + // Log to standard logger + _logger.Log(level, message); + + // Store in our custom log system + var entry = new LogEntry + { + Id = _inMemoryLogs.Count > 0 ? _inMemoryLogs.Max(l => l.Id) + 1 : 1, + Timestamp = DateTime.UtcNow, + Level = levelString, + Message = message, + Context = context ?? "System", + Properties = properties != null ? JsonSerializer.Serialize(properties) : "{}" + }; + + lock (_logLock) + { + _inMemoryLogs.Add(entry); + + // Keep log size under control + if (_inMemoryLogs.Count > _maxLogEntries) + { + _inMemoryLogs = _inMemoryLogs + .OrderByDescending(l => l.Timestamp) + .Take(_maxLogEntries) + .ToList(); + } + + SaveLogs(); + } + } + + private void SaveLogs() + { + try + { + var json = JsonSerializer.Serialize(_inMemoryLogs); + File.WriteAllText(_logFilePath, json); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to save logs to file"); + } + } + } +} \ No newline at end of file diff --git a/src/Services/MetricsService.cs b/src/Services/MetricsService.cs new file mode 100644 index 0000000..ce45fa4 --- /dev/null +++ b/src/Services/MetricsService.cs @@ -0,0 +1,165 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using TransmissionRssManager.Core; + +namespace TransmissionRssManager.Services +{ + /// <summary> + /// Interface for the metrics service that provides dashboard statistics and performance data + /// </summary> + public interface IMetricsService + { + Task<Dictionary<string, object>> GetDashboardStatsAsync(); + Task<Dictionary<string, long>> EstimateDiskUsageAsync(); + Task<Dictionary<string, object>> GetSystemStatusAsync(); + } + + /// <summary> + /// Service that provides metrics and statistics about downloads, system status, and performance + /// </summary> + public class MetricsService : IMetricsService + { + private readonly ILogger<MetricsService> _logger; + private readonly ITransmissionClient _transmissionClient; + private readonly IConfigService _configService; + + public MetricsService( + ILogger<MetricsService> logger, + ITransmissionClient transmissionClient, + IConfigService configService) + { + _logger = logger; + _transmissionClient = transmissionClient; + _configService = configService; + } + + /// <summary> + /// Gets dashboard statistics including active downloads, upload/download speeds, etc. + /// </summary> + public async Task<Dictionary<string, object>> GetDashboardStatsAsync() + { + try + { + var stats = new Dictionary<string, object>(); + var torrents = await _transmissionClient.GetTorrentsAsync(); + + // Calculate basic stats + stats["TotalTorrents"] = torrents.Count; + stats["ActiveDownloads"] = torrents.Count(t => t.Status == "Downloading"); + stats["SeedingTorrents"] = torrents.Count(t => t.Status == "Seeding"); + stats["CompletedTorrents"] = torrents.Count(t => t.IsFinished); + stats["TotalDownloaded"] = torrents.Sum(t => t.DownloadedEver); + stats["TotalUploaded"] = torrents.Sum(t => t.UploadedEver); + stats["DownloadSpeed"] = torrents.Sum(t => t.DownloadSpeed); + stats["UploadSpeed"] = torrents.Sum(t => t.UploadSpeed); + + // Calculate total size + long totalSize = torrents.Sum(t => t.TotalSize); + stats["TotalSize"] = totalSize; + + return stats; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting dashboard stats"); + return new Dictionary<string, object> + { + ["Error"] = ex.Message, + ["TotalTorrents"] = 0, + ["ActiveDownloads"] = 0, + ["SeedingTorrents"] = 0, + ["CompletedTorrents"] = 0 + }; + } + } + + /// <summary> + /// Estimates disk usage for torrents and available space + /// </summary> + public async Task<Dictionary<string, long>> EstimateDiskUsageAsync() + { + try + { + // Get disk usage from torrents + var torrents = await _transmissionClient.GetTorrentsAsync(); + long totalSize = torrents.Sum(t => t.TotalSize); + + // Calculate available space in download directory + string downloadDir = _configService.GetConfiguration().DownloadDirectory; + long availableSpace = 0; + + if (!string.IsNullOrEmpty(downloadDir) && System.IO.Directory.Exists(downloadDir)) + { + try + { + var root = System.IO.Path.GetPathRoot(downloadDir); + if (!string.IsNullOrEmpty(root)) + { + var driveInfo = new System.IO.DriveInfo(root); + availableSpace = driveInfo.AvailableFreeSpace; + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, $"Error getting available disk space for {downloadDir}"); + } + } + + return new Dictionary<string, long> + { + ["activeTorrentsSize"] = totalSize, + ["availableSpace"] = availableSpace + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error estimating disk usage"); + return new Dictionary<string, long> + { + ["activeTorrentsSize"] = 0, + ["availableSpace"] = 0 + }; + } + } + + /// <summary> + /// Gets system status including Transmission connection state + /// </summary> + public async Task<Dictionary<string, object>> GetSystemStatusAsync() + { + var config = _configService.GetConfiguration(); + + var status = new Dictionary<string, object> + { + ["TransmissionConnected"] = false, + ["AutoDownloadEnabled"] = config.AutoDownloadEnabled, + ["PostProcessingEnabled"] = config.PostProcessing.Enabled, + ["CheckIntervalMinutes"] = config.CheckIntervalMinutes + }; + + try + { + // Try to connect to Transmission to check if it's available + var torrents = await _transmissionClient.GetTorrentsAsync(); + + status["TransmissionConnected"] = true; + status["TransmissionTorrentCount"] = torrents.Count; + + // Count torrents by status + status["ActiveTorrentCount"] = torrents.Count(t => t.Status == "Downloading"); + status["CompletedTorrentCount"] = torrents.Count(t => t.IsFinished); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting system status"); + status["TransmissionConnected"] = false; + status["LastErrorMessage"] = ex.Message; + } + + return status; + } + } +} \ No newline at end of file diff --git a/src/Services/Mock/MockServices.cs.bak b/src/Services/Mock/MockServices.cs.bak new file mode 100644 index 0000000..0d5bf7f --- /dev/null +++ b/src/Services/Mock/MockServices.cs.bak @@ -0,0 +1,508 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using TransmissionRssManager.Core; + +#if false // Temporarily disable mock services until they're updated to match new interfaces +namespace TransmissionRssManager.Services.Mock +{ + /// <summary> + /// Mock implementation of ILoggingService that stores logs in memory + /// </summary> + public class MockLoggingService : ILoggingService + { + private readonly ILogger<MockLoggingService> _logger; + private readonly List<LogEntry> _logs = new List<LogEntry>(); + private static int _logIdCounter = 1; + + public MockLoggingService(ILogger<MockLoggingService> logger) + { + _logger = logger; + + // Add some initial sample logs + Log(LogLevel.Information, "Application started", "System"); + Log(LogLevel.Information, "RSS Feed Manager initialized", "RssFeedManager"); + Log(LogLevel.Information, "Connected to Transmission", "TransmissionClient"); + Log(LogLevel.Information, "Scheduler started", "SchedulerService"); + Log(LogLevel.Warning, "Sample warning message", "System"); + Log(LogLevel.Error, "Sample error message", "System"); + } + + public void Configure(UserPreferences preferences) + { + // Nothing to do in the mock implementation + } + + public Task<List<LogEntry>> GetLogsAsync(LogFilterOptions options) + { + IEnumerable<LogEntry> filteredLogs = _logs; + + // Apply filters + if (options.Level != "All") + { + filteredLogs = filteredLogs.Where(l => l.Level == options.Level); + } + + if (!string.IsNullOrEmpty(options.Search)) + { + filteredLogs = filteredLogs.Where(l => l.Message.Contains(options.Search, StringComparison.OrdinalIgnoreCase)); + } + + if (options.StartDate.HasValue) + { + filteredLogs = filteredLogs.Where(l => l.Timestamp >= options.StartDate.Value); + } + + if (options.EndDate.HasValue) + { + filteredLogs = filteredLogs.Where(l => l.Timestamp <= options.EndDate.Value); + } + + if (!string.IsNullOrEmpty(options.Context)) + { + filteredLogs = filteredLogs.Where(l => l.Context == options.Context); + } + + // Sort by timestamp descending (newest first) + filteredLogs = filteredLogs.OrderByDescending(l => l.Timestamp); + + // Apply pagination + filteredLogs = filteredLogs.Skip(options.Skip).Take(options.Take); + + return Task.FromResult(filteredLogs.ToList()); + } + + public Task ClearLogsAsync(DateTime? olderThan = null) + { + if (olderThan.HasValue) + { + _logs.RemoveAll(l => l.Timestamp < olderThan.Value); + } + else + { + _logs.Clear(); + } + + return Task.CompletedTask; + } + + public Task<byte[]> ExportLogsAsync(LogFilterOptions options) + { + // Simple CSV export + var csv = "Timestamp,Level,Message,Context\n"; + + foreach (var log in _logs) + { + csv += $"\"{log.Timestamp:yyyy-MM-dd HH:mm:ss}\",\"{log.Level}\",\"{EscapeCsv(log.Message)}\",\"{log.Context}\"\n"; + } + + return Task.FromResult(System.Text.Encoding.UTF8.GetBytes(csv)); + } + + public void Log(LogLevel level, string message, string context = null, Dictionary<string, string> properties = null) + { + var logEntry = new LogEntry + { + Id = _logIdCounter++, + Timestamp = DateTime.UtcNow, + Level = level.ToString(), + Message = message, + Context = context, + Properties = properties != null ? System.Text.Json.JsonSerializer.Serialize(properties) : null + }; + + _logs.Add(logEntry); + + // Also log to the .NET logger + _logger.Log(level, "{Context}: {Message}", context, message); + } + + private string EscapeCsv(string value) + { + if (string.IsNullOrEmpty(value)) + return ""; + + return value.Replace("\"", "\"\""); + } + } + + /// <summary> + /// Mock implementation of IMetricsService that provides sample metrics data + /// </summary> + public class MockMetricsService : IMetricsService + { + private readonly Random _random = new Random(); + + public Task<DashboardStats> GetDashboardStatsAsync() + { + var stats = new DashboardStats + { + ActiveDownloads = _random.Next(0, 5), + SeedingTorrents = _random.Next(2, 15), + ActiveFeeds = _random.Next(3, 8), + CompletedToday = _random.Next(0, 5), + AddedToday = _random.Next(0, 8), + FeedsCount = _random.Next(5, 12), + MatchedCount = _random.Next(10, 50), + DownloadSpeed = _random.Next(100000, 5000000), + UploadSpeed = _random.Next(10000, 1000000), + TotalDownloaded = _random.Next(50, 500) * 1024 * 1024 * 1024L, + TotalUploaded = _random.Next(10, 200) * 1024 * 1024 * 1024L + }; + + return Task.FromResult(stats); + } + + public Task<List<HistoricalDataPoint>> GetDownloadHistoryAsync(int days = 30) + { + var result = new List<HistoricalDataPoint>(); + var endDate = DateTime.UtcNow; + var startDate = endDate.AddDays(-days); + + for (var date = startDate; date <= endDate; date = date.AddDays(1)) + { + result.Add(new HistoricalDataPoint + { + Date = date, + Count = _random.Next(0, 8), + TotalSize = _random.Next(0, 50) * 1024 * 1024 * 1024L + }); + } + + return Task.FromResult(result); + } + + public Task<List<CategoryStats>> GetCategoryStatsAsync() + { + var categories = new List<CategoryStats> + { + new CategoryStats { Category = "Movies", Count = _random.Next(10, 50), TotalSize = _random.Next(50, 500) * 1024 * 1024 * 1024L }, + new CategoryStats { Category = "TV Shows", Count = _random.Next(20, 100), TotalSize = _random.Next(100, 1000) * 1024 * 1024 * 1024L }, + new CategoryStats { Category = "Music", Count = _random.Next(5, 30), TotalSize = _random.Next(5, 50) * 1024 * 1024 * 1024L }, + new CategoryStats { Category = "Books", Count = _random.Next(1, 20), TotalSize = _random.Next(1, 10) * 1024 * 1024 * 1024L }, + new CategoryStats { Category = "Software", Count = _random.Next(1, 10), TotalSize = _random.Next(5, 100) * 1024 * 1024 * 1024L } + }; + + return Task.FromResult(categories); + } + + public Task<SystemStatus> GetSystemStatusAsync() + { + var status = new SystemStatus + { + TransmissionConnected = true, + TranmissionVersion = "4.0.3", + AutoDownloadEnabled = true, + PostProcessingEnabled = true, + EnabledFeeds = _random.Next(3, 8), + TotalFeeds = _random.Next(5, 12), + CheckIntervalMinutes = 30, + NotificationsEnabled = true + }; + + return Task.FromResult(status); + } + + public Task<long> EstimateDiskUsageAsync() + { + return Task.FromResult(_random.Next(100, 2000) * 1024 * 1024 * 1024L); + } + + public Task<Dictionary<string, double>> GetPerformanceMetricsAsync() + { + var metrics = new Dictionary<string, double> + { + ["AvgCompletionTimeMinutes"] = _random.Next(30, 360), + ["AvgItemsPerFeed"] = _random.Next(10, 100), + ["ProcessingTimeSeconds"] = _random.Next(1, 30), + ["SuccessRate"] = _random.Next(80, 100) + }; + + return Task.FromResult(metrics); + } + } + + /// <summary> + /// Mock implementation of ISchedulerService that doesn't actually schedule anything + /// </summary> + public class MockSchedulerService : ISchedulerService + { + private readonly ILogger<MockSchedulerService> _logger; + + public MockSchedulerService(ILogger<MockSchedulerService> logger) + { + _logger = logger; + } + + public DateTime GetNextScheduledRun(string cronExpression) + { + // Return a time 30 minutes from now + return DateTime.UtcNow.AddMinutes(30); + } + + public bool IsValidCronExpression(string cronExpression) + { + // Simple validation for common cron expressions + return !string.IsNullOrEmpty(cronExpression) && + (cronExpression.Contains("* * * * *") || + cronExpression.Contains("*/") || + cronExpression.Split(' ').Length == 5); + } + + public Task ScheduleFeedRefreshAsync(RssFeed feed, CancellationToken cancellationToken) + { + _logger.LogInformation($"Mock scheduled feed refresh for '{feed.Name}' (not actually doing anything)"); + return Task.CompletedTask; + } + + public Task ScheduleAllFeedsAsync(CancellationToken cancellationToken) + { + _logger.LogInformation("Mock scheduled all feeds (not actually doing anything)"); + return Task.CompletedTask; + } + } + + /// <summary> + /// Helper models needed for the mock services + /// </summary> + public class LogFilterOptions + { + public string Level { get; set; } = "All"; + public string Search { get; set; } + public DateTime? StartDate { get; set; } + public DateTime? EndDate { get; set; } + public string Context { get; set; } + public int Skip { get; set; } = 0; + public int Take { get; set; } = 100; + } + + public class DashboardStats + { + public int ActiveDownloads { get; set; } + public int SeedingTorrents { get; set; } + public int ActiveFeeds { get; set; } + public int CompletedToday { get; set; } + public int AddedToday { get; set; } + public int FeedsCount { get; set; } + public int MatchedCount { get; set; } + public double DownloadSpeed { get; set; } + public double UploadSpeed { get; set; } + public long TotalDownloaded { get; set; } + public long TotalUploaded { get; set; } + } + + public class HistoricalDataPoint + { + public DateTime Date { get; set; } + public int Count { get; set; } + public long TotalSize { get; set; } + } + + public class CategoryStats + { + public string Category { get; set; } + public int Count { get; set; } + public long TotalSize { get; set; } + } + + public class SystemStatus + { + public bool TransmissionConnected { get; set; } + public string TranmissionVersion { get; set; } + public bool AutoDownloadEnabled { get; set; } + public bool PostProcessingEnabled { get; set; } + public int EnabledFeeds { get; set; } + public int TotalFeeds { get; set; } + public int CheckIntervalMinutes { get; set; } + public bool NotificationsEnabled { get; set; } + } + + /// <summary> + /// Extended mock version of IRssFeedManager with the RefreshFeedAsync method + /// </summary> + public class MockRssFeedManager : IRssFeedManager + { + private readonly ILogger<MockRssFeedManager> _logger; + private readonly List<RssFeed> _feeds = new List<RssFeed>(); + private readonly List<RssFeedItem> _feedItems = new List<RssFeedItem>(); + private readonly Random _random = new Random(); + + public MockRssFeedManager(ILogger<MockRssFeedManager> logger) + { + _logger = logger; + InitializeSampleData(); + } + + private void InitializeSampleData() + { + // Sample feeds + _feeds.Add(new RssFeed + { + Id = "1", + Name = "Movies Feed", + Url = "https://example.com/movies.rss", + Rules = new List<string> { "720p", "1080p" }, + AutoDownload = true, + LastChecked = DateTime.UtcNow.AddHours(-2), + DefaultCategory = "Movies", + Schedule = "*/30 * * * *", + Enabled = true + }); + + _feeds.Add(new RssFeed + { + Id = "2", + Name = "TV Shows Feed", + Url = "https://example.com/tv.rss", + Rules = new List<string> { "HDTV", "WEB-DL" }, + AutoDownload = true, + LastChecked = DateTime.UtcNow.AddHours(-1), + DefaultCategory = "TV", + Schedule = "0 */2 * * *", + Enabled = true + }); + + _feeds.Add(new RssFeed + { + Id = "3", + Name = "Music Feed", + Url = "https://example.com/music.rss", + Rules = new List<string> { "FLAC", "MP3" }, + AutoDownload = false, + LastChecked = DateTime.UtcNow.AddDays(-1), + DefaultCategory = "Music", + Schedule = "0 0 * * *", + Enabled = false + }); + + // Sample feed items + for (int i = 1; i <= 50; i++) + { + var feedId = (i % 3 + 1).ToString(); + var isMatched = i % 4 == 0; + var isDownloaded = isMatched && i % 8 == 0; + var category = feedId == "1" ? "Movies" : feedId == "2" ? "TV" : "Music"; + + _feedItems.Add(new RssFeedItem + { + Id = i.ToString(), + Title = $"Sample Item {i} ({category})", + Link = $"https://example.com/item{i}.torrent", + Description = $"This is a sample item {i} in the {category} category", + PublishDate = DateTime.UtcNow.AddHours(-i), + TorrentUrl = $"https://example.com/item{i}.torrent", + IsMatched = isMatched, + IsDownloaded = isDownloaded, + MatchedRule = isMatched ? _feeds[int.Parse(feedId) - 1].Rules[i % 2] : null, + FeedId = feedId, + Category = category, + Size = _random.Next(100, 10000) * 1024 * 1024L, + DownloadDate = isDownloaded ? DateTime.UtcNow.AddHours(-i + 1) : null + }); + } + } + + public Task<List<RssFeedItem>> GetAllItemsAsync() + { + return Task.FromResult(_feedItems.ToList()); + } + + public Task<List<RssFeedItem>> GetMatchedItemsAsync() + { + return Task.FromResult(_feedItems.Where(i => i.IsMatched).ToList()); + } + + public Task<List<RssFeed>> GetFeedsAsync() + { + return Task.FromResult(_feeds.ToList()); + } + + public Task AddFeedAsync(RssFeed feed) + { + feed.Id = (_feeds.Count + 1).ToString(); + feed.LastChecked = DateTime.UtcNow; + _feeds.Add(feed); + return Task.CompletedTask; + } + + public Task RemoveFeedAsync(string feedId) + { + var feed = _feeds.FirstOrDefault(f => f.Id == feedId); + if (feed != null) + { + _feeds.Remove(feed); + _feedItems.RemoveAll(i => i.FeedId == feedId); + } + return Task.CompletedTask; + } + + public Task UpdateFeedAsync(RssFeed feed) + { + var existingFeed = _feeds.FirstOrDefault(f => f.Id == feed.Id); + if (existingFeed != null) + { + var index = _feeds.IndexOf(existingFeed); + _feeds[index] = feed; + } + return Task.CompletedTask; + } + + public Task RefreshFeedsAsync(CancellationToken cancellationToken) + { + // Update last checked time for all feeds + foreach (var feed in _feeds) + { + feed.LastChecked = DateTime.UtcNow; + } + return Task.CompletedTask; + } + + public Task RefreshFeedAsync(string feedId, CancellationToken cancellationToken) + { + var feed = _feeds.FirstOrDefault(f => f.Id == feedId); + if (feed != null) + { + feed.LastChecked = DateTime.UtcNow; + + // Add a couple of new items for this feed + for (int i = 0; i < 2; i++) + { + var itemId = (_feedItems.Count + 1).ToString(); + var category = feed.DefaultCategory; + + _feedItems.Add(new RssFeedItem + { + Id = itemId, + Title = $"New Item {itemId} ({category})", + Link = $"https://example.com/new-item{itemId}.torrent", + Description = $"This is a new item {itemId} in the {category} category", + PublishDate = DateTime.UtcNow, + TorrentUrl = $"https://example.com/new-item{itemId}.torrent", + IsMatched = i == 0, // Make half of the new items matched + IsDownloaded = false, + MatchedRule = i == 0 ? feed.Rules[0] : null, + FeedId = feed.Id, + Category = category, + Size = _random.Next(100, 10000) * 1024 * 1024L + }); + } + } + + return Task.CompletedTask; + } + + public Task MarkItemAsDownloadedAsync(string itemId) + { + var item = _feedItems.FirstOrDefault(i => i.Id == itemId); + if (item != null) + { + item.IsDownloaded = true; + item.DownloadDate = DateTime.UtcNow; + } + return Task.CompletedTask; + } + } +} \ No newline at end of file diff --git a/src/Services/PostProcessor.cs b/src/Services/PostProcessor.cs new file mode 100644 index 0000000..677b4da --- /dev/null +++ b/src/Services/PostProcessor.cs @@ -0,0 +1,307 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using TransmissionRssManager.Core; + +namespace TransmissionRssManager.Services +{ + public class PostProcessor : IPostProcessor + { + private readonly ILogger<PostProcessor> _logger; + private readonly IConfigService _configService; + private readonly ITransmissionClient _transmissionClient; + private readonly List<TorrentInfo> _completedTorrents = new List<TorrentInfo>(); + + public PostProcessor( + ILogger<PostProcessor> logger, + IConfigService configService, + ITransmissionClient transmissionClient) + { + _logger = logger; + _configService = configService; + _transmissionClient = transmissionClient; + } + + public async Task ProcessCompletedDownloadsAsync(CancellationToken cancellationToken) + { + _logger.LogInformation("Checking for completed downloads"); + + var config = _configService.GetConfiguration(); + if (!config.PostProcessing.Enabled) + { + _logger.LogInformation("Post-processing is disabled"); + return; + } + + try + { + var torrents = await _transmissionClient.GetTorrentsAsync(); + var completedTorrents = torrents.Where(t => t.IsFinished && !_completedTorrents.Any(c => c.Id == t.Id)).ToList(); + + _logger.LogInformation($"Found {completedTorrents.Count} newly completed torrents"); + + foreach (var torrent in completedTorrents) + { + if (cancellationToken.IsCancellationRequested) + break; + + await ProcessTorrentAsync(torrent); + _completedTorrents.Add(torrent); + } + + // Clean up the list of completed torrents to avoid memory leaks + if (_completedTorrents.Count > 1000) + { + _completedTorrents.RemoveRange(0, _completedTorrents.Count - 1000); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error processing completed downloads"); + } + } + + public async Task ProcessTorrentAsync(TorrentInfo torrent) + { + _logger.LogInformation($"Processing completed torrent: {torrent.Name}"); + + var config = _configService.GetConfiguration(); + var processingConfig = config.PostProcessing; + + if (!Directory.Exists(torrent.DownloadDir)) + { + _logger.LogWarning($"Download directory does not exist: {torrent.DownloadDir}"); + return; + } + + try + { + // Extract archives if enabled + if (processingConfig.ExtractArchives) + { + await ExtractArchivesAsync(torrent.DownloadDir); + } + + // Organize media if enabled + if (processingConfig.OrganizeMedia && !string.IsNullOrEmpty(config.MediaLibraryPath)) + { + await OrganizeMediaAsync(torrent.DownloadDir, config.MediaLibraryPath, processingConfig); + } + + _logger.LogInformation($"Completed processing torrent: {torrent.Name}"); + } + catch (Exception ex) + { + _logger.LogError(ex, $"Error processing torrent: {torrent.Name}"); + } + } + + private async Task ExtractArchivesAsync(string directory) + { + _logger.LogInformation($"Extracting archives in {directory}"); + + var archiveExtensions = new[] { ".rar", ".zip", ".7z", ".tar", ".gz" }; + var archiveFiles = Directory.GetFiles(directory, "*.*", SearchOption.AllDirectories) + .Where(f => archiveExtensions.Contains(Path.GetExtension(f).ToLowerInvariant())) + .ToList(); + + foreach (var archiveFile in archiveFiles) + { + try + { + _logger.LogInformation($"Extracting archive: {archiveFile}"); + + var dirName = Path.GetDirectoryName(archiveFile); + var fileName = Path.GetFileNameWithoutExtension(archiveFile); + + if (dirName == null) + { + _logger.LogWarning($"Could not get directory name for archive: {archiveFile}"); + continue; + } + + var extractDir = Path.Combine(dirName, fileName); + + if (!Directory.Exists(extractDir)) + { + Directory.CreateDirectory(extractDir); + } + + await Task.Run(() => ExtractWithSharpCompress(archiveFile, extractDir)); + } + catch (Exception ex) + { + _logger.LogError(ex, $"Error extracting archive: {archiveFile}"); + } + } + } + + private void ExtractWithSharpCompress(string archiveFile, string extractDir) + { + // In a real implementation, this would use SharpCompress to extract files + _logger.LogInformation($"Would extract {archiveFile} to {extractDir}"); + // For testing, we'll create a dummy file to simulate extraction + File.WriteAllText( + Path.Combine(extractDir, "extracted.txt"), + $"Extracted from {archiveFile} at {DateTime.Now}" + ); + } + + private async Task OrganizeMediaAsync(string sourceDir, string targetDir, PostProcessingConfig config) + { + _logger.LogInformation($"Organizing media from {sourceDir} to {targetDir}"); + + if (!Directory.Exists(targetDir)) + { + Directory.CreateDirectory(targetDir); + } + + var mediaFiles = Directory.GetFiles(sourceDir, "*.*", SearchOption.AllDirectories) + .Where(f => config.MediaExtensions.Contains(Path.GetExtension(f).ToLowerInvariant())) + .ToList(); + + foreach (var mediaFile in mediaFiles) + { + try + { + _logger.LogInformation($"Processing media file: {mediaFile}"); + + string destFolder = targetDir; + + // Organize by media type if enabled + if (config.AutoOrganizeByMediaType) + { + string mediaType = DetermineMediaType(mediaFile); + destFolder = Path.Combine(targetDir, mediaType); + + if (!Directory.Exists(destFolder)) + { + Directory.CreateDirectory(destFolder); + } + } + + string destFile = Path.Combine(destFolder, Path.GetFileName(mediaFile)); + + // Rename file if needed + if (config.RenameFiles) + { + string newFileName = CleanFileName(Path.GetFileName(mediaFile)); + destFile = Path.Combine(destFolder, newFileName); + } + + // Copy file (in real implementation we might move instead) + await Task.Run(() => File.Copy(mediaFile, destFile, true)); + + _logger.LogInformation($"Copied {mediaFile} to {destFile}"); + } + catch (Exception ex) + { + _logger.LogError(ex, $"Error processing media file: {mediaFile}"); + } + } + } + + private string DetermineMediaType(string filePath) + { + // In a real implementation, this would analyze the file to determine its type + // For now, just return a simple category based on extension + + string ext = Path.GetExtension(filePath).ToLowerInvariant(); + + if (new[] { ".mp4", ".mkv", ".avi", ".mov" }.Contains(ext)) + { + return "Videos"; + } + else if (new[] { ".mp3", ".flac", ".wav", ".aac" }.Contains(ext)) + { + return "Music"; + } + else if (new[] { ".jpg", ".png", ".gif", ".bmp" }.Contains(ext)) + { + return "Images"; + } + else + { + return "Other"; + } + } + + private string CleanFileName(string fileName) + { + // Replace invalid characters and clean up the filename + string invalidChars = new string(Path.GetInvalidFileNameChars()); + string invalidReStr = string.Format(@"[{0}]", Regex.Escape(invalidChars)); + + // Remove scene tags, dots, underscores, etc. + string cleanName = fileName + .Replace(".", " ") + .Replace("_", " "); + + // Replace invalid characters + cleanName = Regex.Replace(cleanName, invalidReStr, ""); + + // Remove extra spaces + cleanName = Regex.Replace(cleanName, @"\s+", " ").Trim(); + + // Add original extension if it was removed + string originalExt = Path.GetExtension(fileName); + if (!cleanName.EndsWith(originalExt, StringComparison.OrdinalIgnoreCase)) + { + cleanName += originalExt; + } + + return cleanName; + } + } + + public class PostProcessingBackgroundService : BackgroundService + { + private readonly ILogger<PostProcessingBackgroundService> _logger; + private readonly IServiceProvider _serviceProvider; + + public PostProcessingBackgroundService( + ILogger<PostProcessingBackgroundService> logger, + IServiceProvider serviceProvider) + { + _logger = logger; + _serviceProvider = serviceProvider; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + _logger.LogInformation("Post-processing background service started"); + + while (!stoppingToken.IsCancellationRequested) + { + try + { + using (var scope = _serviceProvider.CreateScope()) + { + var postProcessor = scope.ServiceProvider.GetRequiredService<IPostProcessor>(); + var configService = scope.ServiceProvider.GetRequiredService<IConfigService>(); + + await postProcessor.ProcessCompletedDownloadsAsync(stoppingToken); + + // Check every minute for completed downloads + await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error in post-processing background service"); + await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken); + } + } + + _logger.LogInformation("Post-processing background service stopped"); + } + } +} \ No newline at end of file diff --git a/src/Services/RssFeedManager.cs b/src/Services/RssFeedManager.cs new file mode 100644 index 0000000..e5f8921 --- /dev/null +++ b/src/Services/RssFeedManager.cs @@ -0,0 +1,372 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.ServiceModel.Syndication; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using System.Xml; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using TransmissionRssManager.Core; + +namespace TransmissionRssManager.Services +{ + public class RssFeedManager : IRssFeedManager + { + 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; + + public RssFeedManager( + ILogger<RssFeedManager> logger, + IConfigService configService, + ITransmissionClient transmissionClient) + { + _logger = logger; + _configService = configService; + _transmissionClient = transmissionClient; + _httpClient = new HttpClient(); + + // Load feeds from config + var config = _configService.GetConfiguration(); + _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(); + _feeds.Add(feed); + await SaveFeedsToConfigAsync(); + } + + public async Task RemoveFeedAsync(string feedId) + { + _feeds.RemoveAll(f => f.Id == feedId); + _feedItems.RemoveAll(i => i.FeedId == feedId); + await SaveFeedsToConfigAsync(); + } + + public async Task UpdateFeedAsync(RssFeed feed) + { + var existingFeed = _feeds.FirstOrDefault(f => f.Id == feed.Id); + if (existingFeed != null) + { + 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("Refreshing RSS feeds"); + + foreach (var feed in _feeds.Where(f => f.Enabled)) + { + if (cancellationToken.IsCancellationRequested) + break; + + try + { + 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<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 = _feedItems.FirstOrDefault(i => i.Id == itemId); + if (item != null) + { + item.IsDownloaded = true; + item.DownloadDate = DateTime.UtcNow; + } + } + } + + public class RssFeedBackgroundService : BackgroundService + { + private readonly ILogger<RssFeedBackgroundService> _logger; + private readonly IServiceProvider _serviceProvider; + + public RssFeedBackgroundService( + ILogger<RssFeedBackgroundService> logger, + IServiceProvider serviceProvider) + { + _logger = logger; + _serviceProvider = serviceProvider; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + _logger.LogInformation("RSS feed background service started"); + + while (!stoppingToken.IsCancellationRequested) + { + try + { + using (var scope = _serviceProvider.CreateScope()) + { + var rssFeedManager = scope.ServiceProvider.GetRequiredService<IRssFeedManager>(); + var configService = scope.ServiceProvider.GetRequiredService<IConfigService>(); + + 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); + } + } + } + } +} \ No newline at end of file diff --git a/src/Services/SchedulerService.cs b/src/Services/SchedulerService.cs new file mode 100644 index 0000000..b19e244 --- /dev/null +++ b/src/Services/SchedulerService.cs @@ -0,0 +1,261 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Cronos; +using TransmissionRssManager.Core; + +namespace TransmissionRssManager.Services +{ + public interface ISchedulerService + { + DateTime GetNextScheduledRun(string cronExpression); + bool IsValidCronExpression(string cronExpression); + Task ScheduleFeedRefreshAsync(RssFeed feed, CancellationToken cancellationToken); + Task ScheduleAllFeedsAsync(CancellationToken cancellationToken); + } + + public class SchedulerService : ISchedulerService + { + private readonly ILogger<SchedulerService> _logger; + private readonly IServiceProvider _serviceProvider; + private readonly ILoggingService _loggingService; + + public SchedulerService( + ILogger<SchedulerService> logger, + IServiceProvider serviceProvider, + ILoggingService loggingService) + { + _logger = logger; + _serviceProvider = serviceProvider; + _loggingService = loggingService; + } + + public DateTime GetNextScheduledRun(string cronExpression) + { + try + { + var expression = CronExpression.Parse(cronExpression); + var nextRun = expression.GetNextOccurrence(DateTime.UtcNow); + return nextRun ?? DateTime.UtcNow.AddMinutes(30); // Default to 30 minutes if parsing fails + } + catch (Exception ex) + { + _logger.LogError(ex, $"Error parsing cron expression: {cronExpression}"); + return DateTime.UtcNow.AddMinutes(30); // Default to 30 minutes + } + } + + public bool IsValidCronExpression(string cronExpression) + { + try + { + CronExpression.Parse(cronExpression); + return true; + } + catch + { + return false; + } + } + + public async Task ScheduleFeedRefreshAsync(RssFeed feed, CancellationToken cancellationToken) + { + if (!feed.Enabled) + { + _logger.LogInformation($"Feed '{feed.Name}' is disabled, skipping schedule"); + return; + } + + string cronExpression = feed.Schedule; + + if (string.IsNullOrEmpty(cronExpression) || !IsValidCronExpression(cronExpression)) + { + _logger.LogWarning($"Invalid cron expression for feed: {feed.Name}, using default schedule"); + cronExpression = "*/30 * * * *"; // Default is every 30 minutes + } + + var nextRun = GetNextScheduledRun(cronExpression); + var delay = nextRun - DateTime.UtcNow; + + if (delay.TotalMilliseconds <= 0) + { + delay = TimeSpan.FromSeconds(5); // If next run is in the past, schedule for 5 seconds from now + } + + _logger.LogInformation($"Scheduling feed '{feed.Name}' to run at {nextRun} (in {delay.TotalMinutes:F1} minutes)"); + + try + { + await Task.Delay(delay, cancellationToken); + + if (cancellationToken.IsCancellationRequested) + { + return; + } + + // Process the feed + using (var scope = _serviceProvider.CreateScope()) + { + var rssFeedManager = scope.ServiceProvider.GetRequiredService<IRssFeedManager>(); + + _loggingService.Log( + LogLevel.Information, + $"Running scheduled refresh for feed: {feed.Name}", + "SchedulerService", + new Dictionary<string, string> { { "FeedId", feed.Id }, { "FeedName", feed.Name } } + ); + + try + { + // We need to get the latest feed configuration because it might have changed + var feeds = await rssFeedManager.GetFeedsAsync(); + var updatedFeed = feeds.FirstOrDefault(f => f.Id == feed.Id); + + if (updatedFeed != null && updatedFeed.Enabled) + { + await rssFeedManager.RefreshFeedAsync(updatedFeed.Id, cancellationToken); + + _loggingService.Log( + LogLevel.Information, + $"Successfully refreshed feed: {updatedFeed.Name}", + "SchedulerService", + new Dictionary<string, string> { { "FeedId", updatedFeed.Id }, { "FeedName", updatedFeed.Name } } + ); + } + else + { + _logger.LogInformation($"Feed '{feed.Name}' no longer exists or is disabled, skipping refresh"); + } + } + catch (Exception ex) + { + _loggingService.Log( + LogLevel.Error, + $"Error refreshing feed: {feed.Name}. {ex.Message}", + "SchedulerService", + new Dictionary<string, string> { + { "FeedId", feed.Id }, + { "FeedName", feed.Name }, + { "ErrorMessage", ex.Message }, + { "StackTrace", ex.StackTrace } + } + ); + } + } + + // Reschedule the feed for its next run + if (!cancellationToken.IsCancellationRequested) + { + // Get the latest feed configuration again + using (var scope = _serviceProvider.CreateScope()) + { + var rssFeedManager = scope.ServiceProvider.GetRequiredService<IRssFeedManager>(); + var feeds = await rssFeedManager.GetFeedsAsync(); + var updatedFeed = feeds.FirstOrDefault(f => f.Id == feed.Id); + + if (updatedFeed != null) + { + // Schedule the feed with its potentially updated configuration + await ScheduleFeedRefreshAsync(updatedFeed, cancellationToken); + } + } + } + } + catch (OperationCanceledException) + { + // Task was canceled, just exit + _logger.LogInformation($"Scheduled feed refresh for '{feed.Name}' was canceled"); + } + catch (Exception ex) + { + _loggingService.Log( + LogLevel.Error, + $"Error in scheduler for feed: {feed.Name}. {ex.Message}", + "SchedulerService", + new Dictionary<string, string> { + { "FeedId", feed.Id }, + { "FeedName", feed.Name }, + { "ErrorMessage", ex.Message }, + { "StackTrace", ex.StackTrace } + } + ); + + // Reschedule after error with a delay + if (!cancellationToken.IsCancellationRequested) + { + await Task.Delay(TimeSpan.FromMinutes(5), cancellationToken); + await ScheduleFeedRefreshAsync(feed, cancellationToken); + } + } + } + + public async Task ScheduleAllFeedsAsync(CancellationToken cancellationToken) + { + using (var scope = _serviceProvider.CreateScope()) + { + var rssFeedManager = scope.ServiceProvider.GetRequiredService<IRssFeedManager>(); + var feeds = await rssFeedManager.GetFeedsAsync(); + + var tasks = feeds + .Where(f => f.Enabled) + .Select(feed => ScheduleFeedRefreshAsync(feed, cancellationToken)) + .ToList(); + + _logger.LogInformation($"Scheduled {tasks.Count} feeds for refresh"); + } + } + } + + public class FeedSchedulerBackgroundService : BackgroundService + { + private readonly ILogger<FeedSchedulerBackgroundService> _logger; + private readonly IServiceProvider _serviceProvider; + private readonly ISchedulerService _schedulerService; + + public FeedSchedulerBackgroundService( + ILogger<FeedSchedulerBackgroundService> logger, + IServiceProvider serviceProvider, + ISchedulerService schedulerService) + { + _logger = logger; + _serviceProvider = serviceProvider; + _schedulerService = schedulerService; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + _logger.LogInformation("Feed scheduler background service started"); + + // Initial delay to let other services initialize + await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken); + + while (!stoppingToken.IsCancellationRequested) + { + try + { + await _schedulerService.ScheduleAllFeedsAsync(stoppingToken); + + // Check for new feeds or schedule changes every 5 minutes + await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken); + } + catch (OperationCanceledException) + { + // Service is shutting down + break; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error in feed scheduler background service"); + await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken); + } + } + + _logger.LogInformation("Feed scheduler background service stopped"); + } + } +} \ No newline at end of file diff --git a/src/Services/TransmissionClient.cs b/src/Services/TransmissionClient.cs new file mode 100644 index 0000000..eeeeed9 --- /dev/null +++ b/src/Services/TransmissionClient.cs @@ -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; } + } + } +} \ No newline at end of file diff --git a/src/Web/wwwroot/css/dark-mode.css b/src/Web/wwwroot/css/dark-mode.css new file mode 100644 index 0000000..7d3864e --- /dev/null +++ b/src/Web/wwwroot/css/dark-mode.css @@ -0,0 +1,70 @@ +/* Dark mode specific overrides - Created to fix dark mode text visibility */ + +/* These styles ensure proper text contrast in dark mode */ +body.dark-mode .form-check-label, +body.dark-mode label, +body.dark-mode .form-text, +body.dark-mode .card-body label, +body.dark-mode .nav-tabs .nav-link { + color: #f5f5f5 \!important; +} + +/* Tab navigation in dark mode */ +body.dark-mode .nav-tabs .nav-link.active { + background-color: #375a7f; + border-color: #444 #444 #375a7f; + color: #ffffff \!important; + font-weight: bold; +} + +body.dark-mode .nav-tabs .nav-link:not(.active):hover { + border-color: #444; + background-color: #2c2c2c; + color: #ffffff \!important; +} + +/* Ensure form elements in dark mode are visible */ +body.dark-mode .form-control::placeholder { + color: #adb5bd; + opacity: 0.7; +} + +/* Top navigation in dark mode */ +body.dark-mode .navbar-nav .nav-link.active { + background-color: #375a7f; + color: #ffffff \!important; + font-weight: bold; + border-radius: 4px; +} + +/* Advanced tab specific fixes */ +body.dark-mode #tab-advanced .form-check-label, +body.dark-mode #tab-advanced label, +body.dark-mode #tab-advanced .form-text { + color: #ffffff \!important; +} + +/* Ensure all tabs have proper text color */ +body.dark-mode #tab-transmission, +body.dark-mode #tab-rss, +body.dark-mode #tab-processing, +body.dark-mode #tab-appearance, +body.dark-mode #tab-advanced { + color: #f5f5f5 \!important; +} + +body.dark-mode #tab-transmission *, +body.dark-mode #tab-rss *, +body.dark-mode #tab-processing *, +body.dark-mode #tab-appearance *, +body.dark-mode #tab-advanced * { + color: #f5f5f5 \!important; +} + +/* Emergency fix for specific labels that were still problematic */ +body.dark-mode #detailed-logging-label, +body.dark-mode #show-completed-torrents-label, +body.dark-mode #confirm-delete-label { + color: white \!important; + font-weight: 500 \!important; +} diff --git a/src/Web/wwwroot/css/styles.css b/src/Web/wwwroot/css/styles.css new file mode 100644 index 0000000..806586c --- /dev/null +++ b/src/Web/wwwroot/css/styles.css @@ -0,0 +1,724 @@ +:root { + /* Light Theme Variables */ + --primary-color: #0d6efd; + --secondary-color: #6c757d; + --dark-color: #212529; + --light-color: #f8f9fa; + --success-color: #198754; + --danger-color: #dc3545; + --warning-color: #ffc107; + --info-color: #0dcaf0; + + /* Common Variables */ + --border-radius: 4px; + --shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + --transition: all 0.3s ease; + + /* Light Theme Specific */ + --bg-color: #ffffff; + --text-color: #212529; + --card-bg: #f8f9fa; + --card-header-bg: #e9ecef; + --card-border: 1px solid rgba(0, 0, 0, 0.125); + --hover-bg: #e9ecef; + --table-border: #dee2e6; + --input-bg: #fff; + --input-border: #ced4da; + --dropdown-bg: #fff; + --modal-bg: #fff; + --feed-item-bg: #f8f9fa; + --torrent-item-bg: #f8f9fa; +} + +/* Dark Theme */ +body.dark-mode { + --bg-color: #121212; + --text-color: #f5f5f5; + --card-bg: #1e1e1e; + --card-header-bg: #252525; + --card-border: 1px solid rgba(255, 255, 255, 0.125); + --hover-bg: #2c2c2c; + --table-border: #333; + --input-bg: #2c2c2c; + --input-border: #444; + --dropdown-bg: #2c2c2c; + --modal-bg: #1e1e1e; + --feed-item-bg: #1e1e1e; + --torrent-item-bg: #1e1e1e; + + color-scheme: dark; +} + +/* Global forced text color for dark mode */ +body.dark-mode * { + color: #f5f5f5; +} + +/* Fix for dark mode text colors */ +body.dark-mode .text-dark, +body.dark-mode .text-body, +body.dark-mode .text-primary, +body.dark-mode .modal-title, +body.dark-mode .form-label, +body.dark-mode .form-check-label, +body.dark-mode h1, +body.dark-mode h2, +body.dark-mode h3, +body.dark-mode h4, +body.dark-mode h5, +body.dark-mode h6, +body.dark-mode label, +body.dark-mode .card-title, +body.dark-mode .form-text, +body.dark-mode .tab-content { + color: #f5f5f5 !important; +} + +body.dark-mode .text-secondary, +body.dark-mode .text-muted { + color: #adb5bd !important; +} + +body.dark-mode .nav-link { + color: #f5f5f5; +} + +body.dark-mode .nav-link:hover, +body.dark-mode .nav-link:focus { + color: #0d6efd; +} + +body.dark-mode .nav-link.active { + color: #f5f5f5; + background-color: #375a7f !important; + font-weight: bold; +} + +body.dark-mode .dropdown-menu { + background-color: #1e1e1e; + border-color: rgba(255, 255, 255, 0.125); +} + +body.dark-mode .dropdown-item { + color: #f5f5f5; +} + +body.dark-mode .dropdown-item:hover, +body.dark-mode .dropdown-item:focus { + background-color: #2c2c2c; + color: #f5f5f5; +} + +body.dark-mode .list-group-item { + background-color: #1e1e1e; + color: #f5f5f5; + border-color: rgba(255, 255, 255, 0.125); +} + +body.dark-mode .feed-item-date, +body.dark-mode .torrent-item-details { + color: #adb5bd; +} + +/* Links in dark mode */ +body.dark-mode a:not(.btn):not(.badge) { + color: #6ea8fe; +} + +body.dark-mode a:not(.btn):not(.badge):hover { + color: #8bb9fe; +} + +/* Ensure tab navs in dark mode have proper styling */ +body.dark-mode .nav-tabs .nav-link.active { + background-color: #375a7f; + border-color: #444 #444 #375a7f; + color: #fff !important; +} + +body.dark-mode .nav-tabs .nav-link:not(.active):hover { + border-color: #444; + background-color: #2c2c2c; + color: #fff !important; +} + +/* Table in dark mode */ +body.dark-mode .table { + color: #f5f5f5; +} + +/* Alerts in dark mode */ +body.dark-mode .alert-info { + background-color: #0d3251; + color: #6edff6; + border-color: #0a3a5a; +} + +body.dark-mode .alert-success { + background-color: #051b11; + color: #75b798; + border-color: #0c2a1c; +} + +body.dark-mode .alert-warning { + background-color: #332701; + color: #ffda6a; + border-color: #473b08; +} + +body.dark-mode .alert-danger { + background-color: #2c0b0e; + color: #ea868f; + border-color: #401418; +} + +/* Advanced tab fix */ +body.dark-mode #tab-advanced, +body.dark-mode #tab-advanced * { + color: #f5f5f5 !important; +} + +body.dark-mode #tab-advanced .form-check-label, +body.dark-mode .form-switch .form-check-label, +body.dark-mode label[for="show-completed-torrents"] { + color: #f5f5f5 !important; +} + +/* Base Elements */ +body { + padding-bottom: 2rem; + background-color: var(--bg-color); + color: var(--text-color); + transition: var(--transition); + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; +} + +/* Navigation */ +.navbar { + margin-bottom: 1rem; + background-color: var(--card-bg); + border-bottom: var(--card-border); + transition: var(--transition); +} + +.navbar-brand, .nav-link { + color: var(--text-color); + transition: var(--transition); +} + +.navbar-toggler { + border-color: var(--input-border); +} + +.page-content { + animation: fadeIn 0.3s ease; +} + +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +/* Cards */ +.card { + margin-bottom: 1rem; + box-shadow: var(--shadow); + background-color: var(--card-bg); + border: var(--card-border); + border-radius: var(--border-radius); + transition: var(--transition); +} + +.card-header { + background-color: var(--card-header-bg); + font-weight: 500; + border-bottom: var(--card-border); + transition: var(--transition); +} + +/* Tables */ +.table { + margin-bottom: 0; + color: var(--text-color); + transition: var(--transition); +} + +.table thead th { + border-bottom-color: var(--table-border); +} + +.table td, .table th { + border-top-color: var(--table-border); +} + +/* Progress Bars */ +.progress { + height: 10px; + background-color: var(--card-header-bg); + border-radius: var(--border-radius); +} + +/* Badges */ +.badge { + padding: 0.35em 0.65em; + border-radius: 50rem; +} + +.badge-downloading { + background-color: var(--info-color); + color: var(--dark-color); +} + +.badge-seeding { + background-color: var(--success-color); + color: white; +} + +.badge-stopped { + background-color: var(--secondary-color); + color: white; +} + +.badge-checking { + background-color: var(--warning-color); + color: var(--dark-color); +} + +.badge-queued { + background-color: var(--secondary-color); + color: white; +} + +.badge-error { + background-color: var(--danger-color); + color: white; +} + +/* Buttons */ +.btn { + border-radius: var(--border-radius); + transition: var(--transition); +} + +.btn-icon { + padding: 0.25rem 0.5rem; + font-size: 0.875rem; +} + +.btn-primary { + background-color: var(--primary-color); + border-color: var(--primary-color); +} + +.btn-secondary { + background-color: var(--secondary-color); + border-color: var(--secondary-color); +} + +.btn-success { + background-color: var(--success-color); + border-color: var(--success-color); +} + +.btn-danger { + background-color: var(--danger-color); + border-color: var(--danger-color); +} + +.btn-warning { + background-color: var(--warning-color); + border-color: var(--warning-color); + color: var(--dark-color); +} + +.btn-info { + background-color: var(--info-color); + border-color: var(--info-color); + color: var(--dark-color); +} + +/* Inputs & Forms */ +.form-control, .form-select { + background-color: var(--input-bg); + border-color: var(--input-border); + color: var(--text-color); + border-radius: var(--border-radius); + transition: var(--transition); +} + +.form-control:focus, .form-select:focus { + background-color: var(--input-bg); + color: var(--text-color); + border-color: var(--primary-color); + box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25); +} + +body.dark-mode .form-control, +body.dark-mode .form-select { + color: #f5f5f5; + background-color: #2c2c2c; + border-color: #444; +} + +body.dark-mode .form-control:focus, +body.dark-mode .form-select:focus { + background-color: #2c2c2c; + color: #f5f5f5; +} + +body.dark-mode .form-control::placeholder { + color: #adb5bd; + opacity: 0.7; +} + +/* Form switches in dark mode */ +body.dark-mode .form-check-input:checked { + background-color: #0d6efd; + border-color: #0d6efd; +} + +body.dark-mode .form-check-input:not(:checked) { + background-color: rgba(255, 255, 255, 0.2); + border-color: rgba(255, 255, 255, 0.25); +} + +body.dark-mode .form-check { + color: #f5f5f5 !important; +} + +body.dark-mode .form-check-label, +body.dark-mode label.form-check-label, +body.dark-mode .form-switch label, +body.dark-mode label[for], +body.dark-mode .card-body label, +body.dark-mode #show-completed-torrents + label, +body.dark-mode label[for="show-completed-torrents"] { + color: #f5f5f5 !important; +} + +/* Direct fix for the show completed torrents label */ +html body.dark-mode div#tab-advanced div.card-body div.form-check label.form-check-label[for="show-completed-torrents"], +html body.dark-mode div#tab-advanced div.mb-3 div.form-check-label, +html body.dark-mode div#tab-advanced label.form-check-label { + color: #ffffff !important; + font-weight: 500 !important; + text-shadow: 0 0 1px #000 !important; +} + +/* Fix all form check labels in dark mode */ +html body.dark-mode .form-check-label { + color: #ffffff !important; +} + +/* Fix for all tabs in dark mode */ +body.dark-mode #tab-advanced, +body.dark-mode #tab-advanced *, +body.dark-mode #tab-appearance, +body.dark-mode #tab-appearance *, +body.dark-mode #tab-processing, +body.dark-mode #tab-processing *, +body.dark-mode #tab-rss, +body.dark-mode #tab-rss *, +body.dark-mode #tab-transmission, +body.dark-mode #tab-transmission * { + color: #f5f5f5 !important; +} + +body.dark-mode .tab-content, +body.dark-mode .tab-content * { + color: #f5f5f5 !important; +} + +/* Emergency fix for advanced tab */ +body.dark-mode .form-check-label { + color: white !important; +} + +/* Super specific advanced tab fix */ +body.dark-mode #detailed-logging + label, +body.dark-mode #show-completed-torrents + label, +body.dark-mode #confirm-delete + label, +body.dark-mode div.form-check-label, +body.dark-mode label.form-check-label { + color: white !important; +} + +/* Feed Items */ +.feed-item { + border-left: 3px solid transparent; + padding: 15px; + margin-bottom: 15px; + background-color: var(--feed-item-bg); + border-radius: var(--border-radius); + transition: var(--transition); +} + +.feed-item:hover { + background-color: var(--hover-bg); +} + +.feed-item.matched { + border-left-color: var(--success-color); +} + +.feed-item.downloaded { + opacity: 0.7; +} + +.feed-item-title { + font-weight: 500; + margin-bottom: 8px; +} + +.feed-item-date { + font-size: 0.85rem; + color: var(--secondary-color); +} + +.feed-item-buttons { + margin-top: 12px; + display: flex; + gap: 8px; +} + +/* Torrent Items */ +.torrent-item { + margin-bottom: 20px; + padding: 15px; + border-radius: var(--border-radius); + background-color: var(--torrent-item-bg); + transition: var(--transition); + border: var(--card-border); +} + +.torrent-item-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; +} + +.torrent-item-title { + font-weight: 500; + margin-right: 10px; + word-break: break-word; +} + +.torrent-item-progress { + margin: 12px 0; +} + +.torrent-item-details { + display: flex; + flex-wrap: wrap; + gap: 10px; + justify-content: space-between; + font-size: 0.9rem; + color: var(--secondary-color); +} + +.torrent-item-buttons { + margin-top: 12px; + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +/* Dashboard panels */ +.dashboard-stats { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); + gap: 15px; + margin-bottom: 20px; +} + +.stat-card { + background-color: var(--card-bg); + border-radius: var(--border-radius); + padding: 20px; + border: var(--card-border); + transition: var(--transition); + display: flex; + flex-direction: column; + align-items: center; + text-align: center; +} + +.stat-card .stat-value { + font-size: 2rem; + font-weight: bold; + margin: 10px 0; +} + +.stat-card .stat-label { + font-size: 0.9rem; + color: var(--secondary-color); +} + +/* Dark Mode Toggle */ +.dark-mode-toggle { + cursor: pointer; + padding: 5px 10px; + border-radius: var(--border-radius); + transition: var(--transition); + color: var(--text-color); + background-color: transparent; + border: 1px solid var(--input-border); +} + +.dark-mode-toggle:hover { + background-color: var(--hover-bg); +} + +.dark-mode-toggle i { + font-size: 1.2rem; +} + +body.dark-mode .dark-mode-toggle { + color: #f5f5f5; + border-color: #444; +} + +/* Notifications */ +.toast-container { + position: fixed; + top: 20px; + right: 20px; + z-index: 9999; +} + +.toast { + background-color: var(--card-bg); + color: var(--text-color); + border: var(--card-border); + margin-bottom: 10px; + max-width: 350px; +} + +.toast-header { + background-color: var(--card-header-bg); + color: var(--text-color); + border-bottom: var(--card-border); +} + +/* Modals */ +.modal-content { + background-color: var(--modal-bg); + color: var(--text-color); + border: var(--card-border); +} + +.modal-header { + border-bottom: var(--card-border); +} + +.modal-footer { + border-top: var(--card-border); +} + +/* Charts and Graphs */ +.chart-container { + position: relative; + height: 300px; + margin-bottom: 20px; +} + +/* Mobile Responsive Design */ +@media (max-width: 768px) { + .container { + padding-left: 15px; + padding-right: 15px; + max-width: 100%; + } + + .card-body { + padding: 15px; + } + + .torrent-item-header { + flex-direction: column; + align-items: flex-start; + } + + .torrent-item-buttons { + width: 100%; + } + + .torrent-item-buttons .btn { + flex: 1; + text-align: center; + padding: 8px; + } + + .dashboard-stats { + grid-template-columns: 1fr; + } + + .stat-card { + margin-bottom: 10px; + } + + .feed-item-buttons { + flex-direction: column; + } + + .feed-item-buttons .btn { + width: 100%; + margin-bottom: 5px; + } + + .table-responsive { + margin-bottom: 15px; + } +} + +/* Tablet Responsive Design */ +@media (min-width: 769px) and (max-width: 992px) { + .dashboard-stats { + grid-template-columns: repeat(2, 1fr); + } +} + +/* Print Styles */ +@media print { + .no-print { + display: none !important; + } + + body { + background-color: white !important; + color: black !important; + } + + .card, .torrent-item, .feed-item { + break-inside: avoid; + border: 1px solid #ddd !important; + } +} + +/* Accessibility */ +@media (prefers-reduced-motion) { + * { + transition: none !important; + animation: none !important; + } +} + +/* Utilities */ +.text-truncate { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.cursor-pointer { + cursor: pointer; +} + +.flex-grow-1 { + flex-grow: 1; +} + +.word-break-all { + word-break: break-all; +} \ No newline at end of file diff --git a/src/Web/wwwroot/index.html b/src/Web/wwwroot/index.html new file mode 100644 index 0000000..3cd6ade --- /dev/null +++ b/src/Web/wwwroot/index.html @@ -0,0 +1,771 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>Transmission RSS Manager + + + + + + + + + + +
    + +
    +
    +

    Dashboard

    + + +
    +
    + +
    -
    +
    Active Downloads
    +
    +
    + +
    -
    +
    Seeding Torrents
    +
    +
    + +
    -
    +
    Active Feeds
    +
    +
    + +
    -
    +
    Completed Today
    +
    +
    + + +
    +
    +
    +
    + Download/Upload Speed + - +
    +
    +
    +
    + Download: + 0 KB/s +
    +
    + Upload: + 0 KB/s +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + Activity Summary +
    +
    +
      +
    • + Added Today + - +
    • +
    • + Completed Today + - +
    • +
    • + Active RSS Feeds + - +
    • +
    • + Matched Items + - +
    • +
    +
    +
    +
    +
    + + +
    +
    +
    +
    + Download History (Last 30 Days) +
    +
    +
    + +
    +
    +
    +
    +
    + + +
    +
    +
    +
    + Active Torrents + View All +
    +
    +
    Loading...
    +
    +
    +
    +
    +
    +
    + Recent Matches + View All +
    +
    +
    Loading...
    +
    +
    +
    +
    +
    + +
    +

    RSS Feeds

    +
    + + +
    +
    Loading...
    + +
    +

    Feed Items

    + +
    +
    +
    Loading...
    +
    +
    +
    Loading...
    +
    +
    +
    +
    + +
    +

    Torrents

    +
    + + +
    +
    Loading...
    +
    + +
    +

    System Logs

    + +
    +
    + Log Filters +
    + + +
    +
    +
    +
    +
    +
    + + +
    +
    +
    +
    + + +
    +
    +
    +
    + + +
    +
    +
    +
    +
    +
    +
    + + +
    +
    + +
    +
    +
    +
    +
    +
    + +
    +
    + Log Entries + 0 entries +
    +
    +
    + + + + + + + + + + + + + + +
    TimestampLevelMessageContext
    Loading logs...
    +
    +
    + +
    +
    + +
    +

    Settings

    +
    + + +
    + +
    +
    +
    + Primary Transmission Instance +
    +
    +
    +
    +
    + + +
    +
    +
    +
    + + +
    +
    +
    +
    +
    +
    +
    + + +
    +
    +
    +
    +
    +
    +
    + + +
    +
    +
    +
    + + +
    +
    +
    +
    +
    + + +
    +
    + Additional Transmission Instances + +
    +
    +
    +
    No additional instances configured
    +
    +
    +
    +
    + + +
    +
    +
    + RSS General Settings +
    +
    +
    +
    + + +
    +
    +
    + + +
    +
    + + +
    Maximum number of items to keep per feed (for performance)
    +
    +
    +
    + +
    +
    + Content Filtering +
    +
    +
    +
    + + +
    +
    When enabled, feed rules can use regular expressions for more advanced matching
    +
    +
    +
    + + +
    +
    +
    + + +
    Items matching these patterns will be ignored regardless of feed rules
    +
    +
    +
    +
    + + +
    +
    +
    + Directories +
    +
    +
    + + +
    +
    + + +
    +
    +
    + + +
    +
    Create subfolders based on feed categories
    +
    +
    +
    + +
    +
    + Post Processing +
    +
    +
    +
    + + +
    +
    +
    +
    + + +
    +
    +
    +
    + + +
    +
    +
    +
    + + +
    +
    +
    +
    + + +
    +
    +
    +
    + + +
    +
    +
    + + +
    +
    + + +
    Number of days after which completed torrents will be removed (0 = never)
    +
    +
    + + +
    +
    +
    +
    + + +
    +
    +
    + User Interface +
    +
    +
    +
    + + +
    +
    +
    +
    + + +
    +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    +
    + +
    +
    + Notifications +
    +
    +
    +
    + + +
    +
    +
    + +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    +
    +
    +
    + + +
    +
    +
    + Advanced Settings +
    +
    +
    +
    + + +
    +
    +
    +
    + + +
    +
    +
    +
    + + +
    +
    +
    + + +
    Number of days to keep historical data
    +
    +
    +
    + +
    +
    + Database +
    +
    +
    + Warning: These operations affect your data permanently. +
    +
    + + + +
    +
    +
    +
    +
    + +
    + + +
    +
    +
    +
    + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Web/wwwroot/js/app.js b/src/Web/wwwroot/js/app.js new file mode 100644 index 0000000..b3c9224 --- /dev/null +++ b/src/Web/wwwroot/js/app.js @@ -0,0 +1,1599 @@ +document.addEventListener('DOMContentLoaded', function() { + // Initialize navigation + initNavigation(); + + // Initialize event listeners + initEventListeners(); + + // Load initial dashboard data + loadDashboardData(); + + // Set up dark mode based on user preference + initDarkMode(); + + // Set up auto refresh if enabled + initAutoRefresh(); + + // Initialize Bootstrap tooltips + const tooltips = document.querySelectorAll('[data-bs-toggle="tooltip"]'); + tooltips.forEach(tooltip => new bootstrap.Tooltip(tooltip)); +}); + +function initNavigation() { + const navLinks = document.querySelectorAll('.navbar-nav .nav-link'); + navLinks.forEach(link => { + link.addEventListener('click', function(e) { + e.preventDefault(); + const page = this.getAttribute('data-page'); + showPage(page); + }); + }); + + // Set active page from URL hash or default to dashboard + const hash = window.location.hash.substring(1); + showPage(hash || 'dashboard'); + + // Add custom styling to active nav link + updateActiveNavStyles(); +} + +function updateActiveNavStyles() { + // Add specific styles for active nav in dark mode + if (document.body.classList.contains('dark-mode')) { + const activeNavLink = document.querySelector('.navbar-nav .nav-link.active'); + if (activeNavLink) { + activeNavLink.style.backgroundColor = '#375a7f'; + activeNavLink.style.color = '#ffffff'; + activeNavLink.style.fontWeight = 'bold'; + } + } +} + +function showPage(page) { + // Hide all pages + const pages = document.querySelectorAll('.page-content'); + pages.forEach(p => p.classList.add('d-none')); + + // Reset styles and remove active class from all nav links + const navLinks = document.querySelectorAll('.navbar-nav .nav-link'); + navLinks.forEach(link => { + link.classList.remove('active'); + link.style.backgroundColor = ''; + link.style.color = ''; + link.style.fontWeight = ''; + }); + + // Show selected page + const selectedPage = document.getElementById(`page-${page}`); + if (selectedPage) { + selectedPage.classList.remove('d-none'); + + // Set active class on nav link + const activeNav = document.querySelector(`.nav-link[data-page="${page}"]`); + if (activeNav) { + activeNav.classList.add('active'); + // Apply special styling for active nav in dark mode + if (document.body.classList.contains('dark-mode')) { + activeNav.style.backgroundColor = '#375a7f'; + activeNav.style.color = '#ffffff'; + activeNav.style.fontWeight = 'bold'; + } + } + + // Update URL hash + window.location.hash = page; + + // Load page-specific data + loadPageData(page); + } +} + +function loadPageData(page) { + switch (page) { + case 'dashboard': + loadDashboardData(); + break; + case 'feeds': + loadFeeds(); + loadAllItems(); + loadMatchedItems(); + break; + case 'torrents': + loadTorrents(); + break; + case 'settings': + loadSettings(); + break; + } +} + +function initEventListeners() { + // RSS Feeds page + document.getElementById('btn-add-feed').addEventListener('click', showAddFeedModal); + document.getElementById('btn-refresh-feeds').addEventListener('click', refreshFeeds); + document.getElementById('save-feed-btn').addEventListener('click', saveFeed); + + // Torrents page + document.getElementById('btn-add-torrent').addEventListener('click', showAddTorrentModal); + document.getElementById('btn-refresh-torrents').addEventListener('click', loadTorrents); + document.getElementById('save-torrent-btn').addEventListener('click', saveTorrent); + + // Logs page + document.getElementById('btn-refresh-logs').addEventListener('click', refreshLogs); + document.getElementById('btn-clear-logs').addEventListener('click', clearLogs); + document.getElementById('btn-apply-log-filters').addEventListener('click', applyLogFilters); + document.getElementById('btn-reset-log-filters').addEventListener('click', resetLogFilters); + document.getElementById('btn-export-logs').addEventListener('click', exportLogs); + + // Settings page + document.getElementById('settings-form').addEventListener('submit', saveSettings); + document.getElementById('dark-mode-toggle').addEventListener('click', toggleDarkMode); + document.getElementById('btn-reset-settings').addEventListener('click', resetSettings); + + // Configuration operations + document.getElementById('btn-backup-config').addEventListener('click', backupConfig); + document.getElementById('btn-reset-config').addEventListener('click', resetConfig); + + // Additional Transmission Instances + document.getElementById('add-transmission-instance').addEventListener('click', addTransmissionInstance); +} + +// Dashboard +function loadDashboardData() { + // Fetch dashboard statistics + fetch('/api/dashboard/stats') + .then(response => response.json()) + .then(stats => { + document.getElementById('active-downloads').textContent = stats.activeDownloads; + document.getElementById('seeding-torrents').textContent = stats.seedingTorrents; + document.getElementById('active-feeds').textContent = stats.activeFeeds; + document.getElementById('completed-today').textContent = stats.completedToday; + + document.getElementById('added-today').textContent = stats.addedToday; + document.getElementById('feeds-count').textContent = stats.feedsCount; + document.getElementById('matched-count').textContent = stats.matchedCount; + + // Format download/upload speeds + const downloadSpeed = formatBytes(stats.downloadSpeed) + '/s'; + const uploadSpeed = formatBytes(stats.uploadSpeed) + '/s'; + document.getElementById('download-speed').textContent = downloadSpeed; + document.getElementById('upload-speed').textContent = uploadSpeed; + document.getElementById('current-speed').textContent = `↓${downloadSpeed} ↑${uploadSpeed}`; + + // Set progress bars (max 100%) + const maxSpeed = Math.max(stats.downloadSpeed, stats.uploadSpeed, 1); + const dlPercent = Math.min(Math.round((stats.downloadSpeed / maxSpeed) * 100), 100); + const ulPercent = Math.min(Math.round((stats.uploadSpeed / maxSpeed) * 100), 100); + document.getElementById('download-speed-bar').style.width = `${dlPercent}%`; + document.getElementById('upload-speed-bar').style.width = `${ulPercent}%`; + }) + .catch(error => { + console.error('Error loading dashboard stats:', error); + }); + + // Load chart data + loadDownloadHistoryChart(); + + // Load other dashboard components + loadActiveTorrents(); + loadRecentMatches(); +} + +function loadSystemStatus() { + const statusElement = document.getElementById('system-status'); + statusElement.innerHTML = '
    '; + + fetch('/api/config') + .then(response => response.json()) + .then(config => { + // Create system status HTML + let html = '
      '; + html += `
    • Auto Download ${config.autoDownloadEnabled ? 'Enabled' : 'Disabled'}
    • `; + html += `
    • Check Interval ${config.checkIntervalMinutes} minutes
    • `; + html += `
    • Transmission Connection ${config.transmission.host ? config.transmission.host + ':' + config.transmission.port : 'Not configured'}
    • `; + html += `
    • Post Processing ${config.postProcessing.enabled ? 'Enabled' : 'Disabled'}
    • `; + html += '
    '; + + statusElement.innerHTML = html; + }) + .catch(error => { + console.error('Error loading system status:', error); + statusElement.innerHTML = '
    Error loading system status
    '; + }); +} + +function loadRecentMatches() { + const matchesElement = document.getElementById('recent-matches'); + matchesElement.innerHTML = '
    '; + + fetch('/api/feeds/matched') + .then(response => response.json()) + .then(items => { + // Sort by publish date descending and take the first 5 + const recentItems = items.sort((a, b) => new Date(b.publishDate) - new Date(a.publishDate)).slice(0, 5); + + if (recentItems.length === 0) { + matchesElement.innerHTML = '
    No matched items yet
    '; + return; + } + + let html = '
    '; + recentItems.forEach(item => { + const date = new Date(item.publishDate); + html += ` +
    +
    ${item.title}
    + ${formatDate(date)} +
    + + Matched rule: ${item.matchedRule} + ${item.isDownloaded ? 'Downloaded' : 'Not Downloaded'} + +
    `; + }); + html += '
    '; + + matchesElement.innerHTML = html; + }) + .catch(error => { + console.error('Error loading recent matches:', error); + matchesElement.innerHTML = '
    Error loading recent matches
    '; + }); +} + +function loadActiveTorrents() { + const torrentsElement = document.getElementById('active-torrents'); + torrentsElement.innerHTML = '
    '; + + fetch('/api/torrents') + .then(response => response.json()) + .then(torrents => { + console.log('Dashboard torrents:', torrents); + + // Sort by progress ascending and filter for active torrents + const activeTorrents = torrents + .filter(t => t && t.status && (t.status === 'Downloading' || t.status === 'Seeding')) + .sort((a, b) => (a.percentDone || 0) - (b.percentDone || 0)); + + if (activeTorrents.length === 0) { + torrentsElement.innerHTML = '
    No active torrents
    '; + return; + } + + let html = '
    '; + activeTorrents.forEach(torrent => { + // Handle potential null or undefined values + if (!torrent || !torrent.name) { + return; + } + + // Safely calculate percentages and sizes with error handling + let progressPercent = 0; + try { + progressPercent = Math.round((torrent.percentDone || 0) * 100); + } catch (e) { + console.warn('Error calculating progress percent:', e); + } + + let sizeInGB = '0.00'; + try { + if (torrent.totalSize && torrent.totalSize > 0) { + sizeInGB = (torrent.totalSize / 1073741824).toFixed(2); + } + } catch (e) { + console.warn('Error calculating size in GB:', e); + } + + const torrentStatus = torrent.status || 'Unknown'; + const statusClass = torrentStatus.toLowerCase().replace(/\s+/g, '-'); + + html += `
    +
    +
    ${torrent.name}
    + ${torrentStatus} +
    +
    +
    ${progressPercent}%
    +
    + Size: ${sizeInGB} GB +
    `; + }); + html += '
    '; + + torrentsElement.innerHTML = html; + }) + .catch(error => { + console.error('Error loading active torrents:', error); + torrentsElement.innerHTML = '
    Error loading active torrents
    '; + }); +} + +// RSS Feeds +function loadFeeds() { + const feedsElement = document.getElementById('feeds-list'); + feedsElement.innerHTML = '
    '; + + fetch('/api/feeds') + .then(response => response.json()) + .then(feeds => { + if (feeds.length === 0) { + feedsElement.innerHTML = '
    No feeds added yet
    '; + return; + } + + let html = '
    '; + feeds.forEach(feed => { + const lastChecked = feed.lastChecked ? new Date(feed.lastChecked) : null; + + html += `
    +
    +
    ${feed.name}
    + ${lastChecked ? 'Last checked: ' + formatDate(lastChecked) : 'Never checked'} +
    +

    ${feed.url}

    +
    + ${feed.rules.length} rules +
    + + +
    +
    +
    `; + }); + html += '
    '; + + feedsElement.innerHTML = html; + + // Add event listeners + document.querySelectorAll('.btn-edit-feed').forEach(btn => { + btn.addEventListener('click', function() { + const feedId = this.getAttribute('data-feed-id'); + editFeed(feedId); + }); + }); + + document.querySelectorAll('.btn-delete-feed').forEach(btn => { + btn.addEventListener('click', function() { + const feedId = this.getAttribute('data-feed-id'); + deleteFeed(feedId); + }); + }); + }) + .catch(error => { + console.error('Error loading feeds:', error); + feedsElement.innerHTML = '
    Error loading feeds
    '; + }); +} + +function loadAllItems() { + const itemsElement = document.getElementById('all-items-list'); + itemsElement.innerHTML = '
    '; + + fetch('/api/feeds/items') + .then(response => response.json()) + .then(items => { + if (items.length === 0) { + itemsElement.innerHTML = '
    No feed items yet
    '; + return; + } + + let html = '
    '; + items.forEach(item => { + const date = new Date(item.publishDate); + const classes = `feed-item ${item.isMatched ? 'matched' : ''} ${item.isDownloaded ? 'downloaded' : ''}`; + + html += `
    + +
    ${formatDate(date)}
    + ${item.isMatched ? `
    Matched rule: ${item.matchedRule}
    ` : ''} + ${item.isDownloaded ? '
    Downloaded
    ' : ''} + ${!item.isDownloaded && item.isMatched ? + `
    + +
    ` : '' + } +
    `; + }); + html += '
    '; + + itemsElement.innerHTML = html; + + // Add event listeners + document.querySelectorAll('.btn-download-item').forEach(btn => { + btn.addEventListener('click', function() { + const itemId = this.getAttribute('data-item-id'); + downloadItem(itemId); + }); + }); + }) + .catch(error => { + console.error('Error loading feed items:', error); + itemsElement.innerHTML = '
    Error loading feed items
    '; + }); +} + +function loadMatchedItems() { + const matchedElement = document.getElementById('matched-items-list'); + matchedElement.innerHTML = '
    '; + + fetch('/api/feeds/matched') + .then(response => response.json()) + .then(items => { + if (items.length === 0) { + matchedElement.innerHTML = '
    No matched items yet
    '; + return; + } + + let html = '
    '; + items.forEach(item => { + const date = new Date(item.publishDate); + const classes = `feed-item matched ${item.isDownloaded ? 'downloaded' : ''}`; + + html += `
    + +
    ${formatDate(date)}
    +
    Matched rule: ${item.matchedRule}
    + ${item.isDownloaded ? '
    Downloaded
    ' : ''} + ${!item.isDownloaded ? + `
    + +
    ` : '' + } +
    `; + }); + html += '
    '; + + matchedElement.innerHTML = html; + + // Add event listeners + document.querySelectorAll('.btn-download-matched-item').forEach(btn => { + btn.addEventListener('click', function() { + const itemId = this.getAttribute('data-item-id'); + downloadItem(itemId); + }); + }); + }) + .catch(error => { + console.error('Error loading matched items:', error); + matchedElement.innerHTML = '
    Error loading matched items
    '; + }); +} + +function showAddFeedModal() { + // Clear form + document.getElementById('feed-name').value = ''; + document.getElementById('feed-url').value = ''; + document.getElementById('feed-rules').value = ''; + document.getElementById('feed-auto-download').checked = false; + + // Update modal title and button text + document.querySelector('#add-feed-modal .modal-title').textContent = 'Add RSS Feed'; + document.getElementById('save-feed-btn').textContent = 'Add Feed'; + + // Remove feed ID data attribute + document.getElementById('save-feed-btn').removeAttribute('data-feed-id'); + + // Show modal + const modal = new bootstrap.Modal(document.getElementById('add-feed-modal')); + modal.show(); +} + +function editFeed(feedId) { + // Fetch feed data + fetch(`/api/feeds`) + .then(response => response.json()) + .then(feeds => { + const feed = feeds.find(f => f.id === feedId); + if (!feed) { + alert('Feed not found'); + return; + } + + // Populate form + document.getElementById('feed-name').value = feed.name; + document.getElementById('feed-url').value = feed.url; + document.getElementById('feed-rules').value = feed.rules.join('\n'); + document.getElementById('feed-auto-download').checked = feed.autoDownload; + + // Update modal title and button text + document.querySelector('#add-feed-modal .modal-title').textContent = 'Edit RSS Feed'; + document.getElementById('save-feed-btn').textContent = 'Save Changes'; + + // Add feed ID data attribute + document.getElementById('save-feed-btn').setAttribute('data-feed-id', feedId); + + // Show modal + const modal = new bootstrap.Modal(document.getElementById('add-feed-modal')); + modal.show(); + }) + .catch(error => { + console.error('Error fetching feed:', error); + alert('Error fetching feed'); + }); +} + +function saveFeed() { + const name = document.getElementById('feed-name').value.trim(); + const url = document.getElementById('feed-url').value.trim(); + const rulesText = document.getElementById('feed-rules').value.trim(); + const autoDownload = document.getElementById('feed-auto-download').checked; + + if (!name || !url) { + alert('Please enter a name and URL'); + return; + } + + // Parse rules (split by new line and remove empty lines) + const rules = rulesText.split('\n').filter(rule => rule.trim() !== ''); + + const feedId = document.getElementById('save-feed-btn').getAttribute('data-feed-id'); + const isEditing = !!feedId; + + const feedData = { + name: name, + url: url, + rules: rules, + autoDownload: autoDownload + }; + + if (isEditing) { + feedData.id = feedId; + + // Update existing feed + fetch(`/api/feeds/${feedId}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(feedData) + }) + .then(response => { + if (!response.ok) { + throw new Error('Failed to update feed'); + } + + // Close modal + const modal = bootstrap.Modal.getInstance(document.getElementById('add-feed-modal')); + modal.hide(); + + // Refresh feeds + loadFeeds(); + }) + .catch(error => { + console.error('Error updating feed:', error); + alert('Error updating feed'); + }); + } else { + // Add new feed + fetch('/api/feeds', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(feedData) + }) + .then(response => { + if (!response.ok) { + throw new Error('Failed to add feed'); + } + + // Close modal + const modal = bootstrap.Modal.getInstance(document.getElementById('add-feed-modal')); + modal.hide(); + + // Refresh feeds + loadFeeds(); + // Also refresh items since a new feed might have new items + loadAllItems(); + loadMatchedItems(); + }) + .catch(error => { + console.error('Error adding feed:', error); + alert('Error adding feed'); + }); + } +} + +function deleteFeed(feedId) { + if (!confirm('Are you sure you want to delete this feed?')) { + return; + } + + fetch(`/api/feeds/${feedId}`, { + method: 'DELETE' + }) + .then(response => { + if (!response.ok) { + throw new Error('Failed to delete feed'); + } + + // Refresh feeds + loadFeeds(); + // Also refresh items since items from this feed should be removed + loadAllItems(); + loadMatchedItems(); + }) + .catch(error => { + console.error('Error deleting feed:', error); + alert('Error deleting feed'); + }); +} + +function refreshFeeds() { + const btn = document.getElementById('btn-refresh-feeds'); + btn.disabled = true; + btn.innerHTML = ' Refreshing...'; + + fetch('/api/feeds/refresh', { + method: 'POST' + }) + .then(response => { + if (!response.ok) { + throw new Error('Failed to refresh feeds'); + } + + // Re-enable button + btn.disabled = false; + btn.textContent = 'Refresh Feeds'; + + // Refresh feed items + loadFeeds(); + loadAllItems(); + loadMatchedItems(); + }) + .catch(error => { + console.error('Error refreshing feeds:', error); + alert('Error refreshing feeds'); + + // Re-enable button + btn.disabled = false; + btn.textContent = 'Refresh Feeds'; + }); +} + +function downloadItem(itemId) { + fetch(`/api/feeds/download/${itemId}`, { + method: 'POST' + }) + .then(response => { + if (!response.ok) { + throw new Error('Failed to download item'); + } + + // Refresh items + loadAllItems(); + loadMatchedItems(); + // Also refresh torrents since a new torrent should be added + loadTorrents(); + }) + .catch(error => { + console.error('Error downloading item:', error); + alert('Error downloading item'); + }); +} + +// Torrents +function loadTorrents() { + const torrentsElement = document.getElementById('torrents-list'); + torrentsElement.innerHTML = '
    '; + + fetch('/api/torrents') + .then(response => response.json()) + .then(torrents => { + console.log('Loaded torrents:', torrents); + if (torrents.length === 0) { + torrentsElement.innerHTML = '
    No torrents
    '; + return; + } + + let html = '
    '; + torrents.forEach(torrent => { + // Handle potential null or undefined values + if (!torrent || !torrent.name) { + console.warn('Invalid torrent data:', torrent); + return; + } + + // Safely calculate percentages and sizes with error handling + let progressPercent = 0; + try { + progressPercent = Math.round((torrent.percentDone || 0) * 100); + } catch (e) { + console.warn('Error calculating progress percent:', e); + } + + let sizeInGB = '0.00'; + try { + if (torrent.totalSize && torrent.totalSize > 0) { + sizeInGB = (torrent.totalSize / 1073741824).toFixed(2); + } + } catch (e) { + console.warn('Error calculating size in GB:', e); + } + + const torrentStatus = torrent.status || 'Unknown'; + const statusClass = torrentStatus.toLowerCase().replace(/\s+/g, '-'); + + html += `
    +
    +
    ${torrent.name}
    + ${torrentStatus} +
    +
    +
    +
    ${progressPercent}%
    +
    +
    +
    + Size: ${sizeInGB} GB + Location: ${torrent.downloadDir || 'Unknown'} +
    +
    + ${torrentStatus === 'Stopped' ? + `` : + `` + } + + ${progressPercent >= 100 ? + `` : '' + } +
    +
    `; + }); + html += '
    '; + + torrentsElement.innerHTML = html; + + // Add event listeners + document.querySelectorAll('.btn-start-torrent').forEach(btn => { + btn.addEventListener('click', function() { + const torrentId = parseInt(this.getAttribute('data-torrent-id')); + startTorrent(torrentId); + }); + }); + + document.querySelectorAll('.btn-stop-torrent').forEach(btn => { + btn.addEventListener('click', function() { + const torrentId = parseInt(this.getAttribute('data-torrent-id')); + stopTorrent(torrentId); + }); + }); + + document.querySelectorAll('.btn-remove-torrent').forEach(btn => { + btn.addEventListener('click', function() { + const torrentId = parseInt(this.getAttribute('data-torrent-id')); + removeTorrent(torrentId); + }); + }); + + document.querySelectorAll('.btn-process-torrent').forEach(btn => { + btn.addEventListener('click', function() { + const torrentId = parseInt(this.getAttribute('data-torrent-id')); + processTorrent(torrentId); + }); + }); + }) + .catch(error => { + console.error('Error loading torrents:', error); + torrentsElement.innerHTML = '
    Error loading torrents
    '; + }); +} + +function showAddTorrentModal() { + // Clear form + document.getElementById('torrent-url').value = ''; + document.getElementById('torrent-download-dir').value = ''; + + // Show modal + const modal = new bootstrap.Modal(document.getElementById('add-torrent-modal')); + modal.show(); +} + +function saveTorrent() { + const url = document.getElementById('torrent-url').value.trim(); + const downloadDir = document.getElementById('torrent-download-dir').value.trim(); + + if (!url) { + alert('Please enter a torrent URL or magnet link'); + return; + } + + const torrentData = { + url: url + }; + + if (downloadDir) { + torrentData.downloadDir = downloadDir; + } + + fetch('/api/torrents', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(torrentData) + }) + .then(response => { + if (!response.ok) { + throw new Error('Failed to add torrent'); + } + + // Close modal + const modal = bootstrap.Modal.getInstance(document.getElementById('add-torrent-modal')); + modal.hide(); + + // Refresh torrents + loadTorrents(); + }) + .catch(error => { + console.error('Error adding torrent:', error); + alert('Error adding torrent'); + }); +} + +function startTorrent(torrentId) { + fetch(`/api/torrents/${torrentId}/start`, { + method: 'POST' + }) + .then(response => { + if (!response.ok) { + throw new Error('Failed to start torrent'); + } + + // Refresh torrents + loadTorrents(); + }) + .catch(error => { + console.error('Error starting torrent:', error); + alert('Error starting torrent'); + }); +} + +function stopTorrent(torrentId) { + fetch(`/api/torrents/${torrentId}/stop`, { + method: 'POST' + }) + .then(response => { + if (!response.ok) { + throw new Error('Failed to stop torrent'); + } + + // Refresh torrents + loadTorrents(); + }) + .catch(error => { + console.error('Error stopping torrent:', error); + alert('Error stopping torrent'); + }); +} + +function removeTorrent(torrentId) { + if (!confirm('Are you sure you want to remove this torrent? The downloaded files will be kept.')) { + return; + } + + fetch(`/api/torrents/${torrentId}`, { + method: 'DELETE' + }) + .then(response => { + if (!response.ok) { + throw new Error('Failed to remove torrent'); + } + + // Refresh torrents + loadTorrents(); + }) + .catch(error => { + console.error('Error removing torrent:', error); + alert('Error removing torrent'); + }); +} + +function processTorrent(torrentId) { + fetch(`/api/torrents/${torrentId}/process`, { + method: 'POST' + }) + .then(response => { + if (!response.ok) { + throw new Error('Failed to process torrent'); + } + + alert('Torrent processing started'); + }) + .catch(error => { + console.error('Error processing torrent:', error); + alert('Error processing torrent'); + }); +} + +// Settings +function loadSettings() { + const form = document.getElementById('settings-form'); + + fetch('/api/config') + .then(response => response.json()) + .then(config => { + // Transmission settings + document.getElementById('transmission-host').value = config.transmission.host; + document.getElementById('transmission-port').value = config.transmission.port; + document.getElementById('transmission-use-https').checked = config.transmission.useHttps; + document.getElementById('transmission-username').value = ''; + document.getElementById('transmission-password').value = ''; + + // RSS settings + document.getElementById('auto-download-enabled').checked = config.autoDownloadEnabled; + document.getElementById('check-interval').value = config.checkIntervalMinutes; + + // Directory settings + document.getElementById('download-directory').value = config.downloadDirectory; + document.getElementById('media-library').value = config.mediaLibraryPath; + + // Post processing settings + document.getElementById('post-processing-enabled').checked = config.postProcessing.enabled; + document.getElementById('extract-archives').checked = config.postProcessing.extractArchives; + document.getElementById('organize-media').checked = config.postProcessing.organizeMedia; + document.getElementById('minimum-seed-ratio').value = config.postProcessing.minimumSeedRatio; + document.getElementById('media-extensions').value = config.postProcessing.mediaExtensions.join(', '); + }) + .catch(error => { + console.error('Error loading settings:', error); + alert('Error loading settings'); + }); +} + +function saveSettings(e) { + e.preventDefault(); + + // Show saving indicator + const saveBtn = document.querySelector('#settings-form button[type="submit"]'); + const originalBtnText = saveBtn.innerHTML; + saveBtn.disabled = true; + saveBtn.innerHTML = ' Saving...'; + + const config = { + transmission: { + host: document.getElementById('transmission-host').value.trim(), + port: parseInt(document.getElementById('transmission-port').value), + useHttps: document.getElementById('transmission-use-https').checked, + username: document.getElementById('transmission-username').value.trim(), + password: document.getElementById('transmission-password').value.trim() + }, + autoDownloadEnabled: document.getElementById('auto-download-enabled').checked, + checkIntervalMinutes: parseInt(document.getElementById('check-interval').value), + downloadDirectory: document.getElementById('download-directory').value.trim(), + mediaLibraryPath: document.getElementById('media-library').value.trim(), + postProcessing: { + enabled: document.getElementById('post-processing-enabled').checked, + extractArchives: document.getElementById('extract-archives').checked, + organizeMedia: document.getElementById('organize-media').checked, + minimumSeedRatio: parseInt(document.getElementById('minimum-seed-ratio').value), + mediaExtensions: document.getElementById('media-extensions').value.split(',').map(ext => ext.trim()) + }, + userPreferences: { + enableDarkMode: document.body.classList.contains('dark-mode'), + autoRefreshUIEnabled: localStorage.getItem('autoRefresh') !== 'false', + autoRefreshIntervalSeconds: parseInt(localStorage.getItem('refreshInterval')) || 30, + notificationsEnabled: true + } + }; + + fetch('/api/config', { + method: 'PUT', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(config) + }) + .then(response => { + if (!response.ok) { + return response.text().then(text => { + throw new Error(`Failed to save settings: ${text}`); + }); + } + return response.json(); + }) + .then(result => { + console.log('Settings saved successfully:', result); + + // Show success message + const settingsForm = document.getElementById('settings-form'); + const alertDiv = document.createElement('div'); + alertDiv.className = 'alert alert-success alert-dismissible fade show mt-3'; + alertDiv.role = 'alert'; + alertDiv.innerHTML = ` + Success! Your settings have been saved. + + `; + settingsForm.appendChild(alertDiv); + + // Auto dismiss after 3 seconds + setTimeout(() => { + const bsAlert = new bootstrap.Alert(alertDiv); + bsAlert.close(); + }, 3000); + + // Re-enable save button + saveBtn.disabled = false; + saveBtn.innerHTML = originalBtnText; + }) + .catch(error => { + console.error('Error saving settings:', error); + + // Show error message + const settingsForm = document.getElementById('settings-form'); + const alertDiv = document.createElement('div'); + alertDiv.className = 'alert alert-danger alert-dismissible fade show mt-3'; + alertDiv.role = 'alert'; + alertDiv.innerHTML = ` + Error! ${error.message} + + `; + settingsForm.appendChild(alertDiv); + + // Re-enable save button + saveBtn.disabled = false; + saveBtn.innerHTML = originalBtnText; + }); +} + +// Helper functions +function formatDate(date) { + if (!date) return 'N/A'; + + // Format as "YYYY-MM-DD HH:MM" + return `${date.getFullYear()}-${padZero(date.getMonth() + 1)}-${padZero(date.getDate())} ${padZero(date.getHours())}:${padZero(date.getMinutes())}`; +} + +function padZero(num) { + return num.toString().padStart(2, '0'); +} + +// Dark Mode Functions +function initDarkMode() { + // Check local storage preference or system preference + const darkModePreference = localStorage.getItem('darkMode'); + + if (darkModePreference === 'true' || + (darkModePreference === null && window.matchMedia('(prefers-color-scheme: dark)').matches)) { + enableDarkMode(); + } else { + disableDarkMode(); + } +} + +function toggleDarkMode() { + if (document.body.classList.contains('dark-mode')) { + disableDarkMode(); + } else { + enableDarkMode(); + } +} + +function enableDarkMode() { + document.body.classList.add('dark-mode'); + document.getElementById('dark-mode-toggle').innerHTML = ''; + localStorage.setItem('darkMode', 'true'); + + // Also update user preferences if on settings page + const darkModeCheckbox = document.getElementById('enable-dark-mode'); + if (darkModeCheckbox) { + darkModeCheckbox.checked = true; + } + + // Update active nav link styling + updateActiveNavStyles(); +} + +function disableDarkMode() { + document.body.classList.remove('dark-mode'); + document.getElementById('dark-mode-toggle').innerHTML = ''; + localStorage.setItem('darkMode', 'false'); + + // Also update user preferences if on settings page + const darkModeCheckbox = document.getElementById('enable-dark-mode'); + if (darkModeCheckbox) { + darkModeCheckbox.checked = false; + } + + // Reset active nav styling (let the default styles apply) + const activeNav = document.querySelector('.navbar-nav .nav-link.active'); + if (activeNav) { + activeNav.style.backgroundColor = ''; + activeNav.style.color = ''; + activeNav.style.fontWeight = ''; + } +} + +// Auto-refresh +function initAutoRefresh() { + // Get auto-refresh settings from local storage or use defaults + const autoRefresh = localStorage.getItem('autoRefresh') !== 'false'; + const refreshInterval = parseInt(localStorage.getItem('refreshInterval')) || 30; + + if (autoRefresh) { + startAutoRefresh(refreshInterval); + } +} + +function startAutoRefresh(intervalSeconds) { + // Clear any existing interval + if (window.refreshTimer) { + clearInterval(window.refreshTimer); + } + + // Set new interval + window.refreshTimer = setInterval(() => { + const currentPage = window.location.hash.substring(1) || 'dashboard'; + loadPageData(currentPage); + }, intervalSeconds * 1000); + + localStorage.setItem('autoRefresh', 'true'); + localStorage.setItem('refreshInterval', intervalSeconds.toString()); +} + +function stopAutoRefresh() { + if (window.refreshTimer) { + clearInterval(window.refreshTimer); + window.refreshTimer = null; + } + + localStorage.setItem('autoRefresh', 'false'); +} + +// Chart functions +function loadDownloadHistoryChart() { + fetch('/api/dashboard/history') + .then(response => response.json()) + .then(history => { + const ctx = document.getElementById('download-history-chart').getContext('2d'); + + // Extract dates and count values + const labels = history.map(point => { + const date = new Date(point.date); + return `${date.getMonth() + 1}/${date.getDate()}`; + }); + + const countData = history.map(point => point.count); + const sizeData = history.map(point => point.totalSize / (1024 * 1024 * 1024)); // Convert to GB + + // Create or update chart + if (window.downloadHistoryChart) { + window.downloadHistoryChart.data.labels = labels; + window.downloadHistoryChart.data.datasets[0].data = countData; + window.downloadHistoryChart.data.datasets[1].data = sizeData; + window.downloadHistoryChart.update(); + } else { + window.downloadHistoryChart = new Chart(ctx, { + type: 'bar', + data: { + labels: labels, + datasets: [ + { + label: 'Number of Downloads', + data: countData, + backgroundColor: 'rgba(13, 110, 253, 0.5)', + borderColor: 'rgba(13, 110, 253, 1)', + borderWidth: 1, + yAxisID: 'y' + }, + { + label: 'Total Size (GB)', + data: sizeData, + type: 'line', + borderColor: 'rgba(25, 135, 84, 1)', + backgroundColor: 'rgba(25, 135, 84, 0.1)', + borderWidth: 2, + fill: true, + tension: 0.4, + yAxisID: 'y1' + } + ] + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + position: 'top', + }, + tooltip: { + callbacks: { + label: function(context) { + let label = context.dataset.label || ''; + if (label) { + label += ': '; + } + if (context.datasetIndex === 0) { + label += context.parsed.y; + } else { + label += context.parsed.y.toFixed(2) + ' GB'; + } + return label; + } + } + } + }, + scales: { + y: { + type: 'linear', + display: true, + position: 'left', + title: { + display: true, + text: 'Number of Downloads' + }, + beginAtZero: true + }, + y1: { + type: 'linear', + display: true, + position: 'right', + title: { + display: true, + text: 'Total Size (GB)' + }, + beginAtZero: true, + grid: { + drawOnChartArea: false + } + } + } + } + }); + } + }) + .catch(error => { + console.error('Error loading download history chart:', error); + }); +} + +// Logs Management +function refreshLogs() { + const logFilters = getLogFilters(); + loadLogs(logFilters); +} + +function getLogFilters() { + return { + level: document.getElementById('log-level').value, + search: document.getElementById('log-search').value, + dateRange: document.getElementById('log-date-range').value, + skip: 0, + take: parseInt(document.getElementById('items-per-page')?.value || 25) + }; +} + +function loadLogs(filters) { + const tbody = document.getElementById('logs-table-body'); + tbody.innerHTML = 'Loading logs...'; + + // Build query string + const query = new URLSearchParams(); + if (filters.level && filters.level !== 'All') { + query.append('Level', filters.level); + } + if (filters.search) { + query.append('Search', filters.search); + } + + // Handle date range + const now = new Date(); + let startDate = null; + + switch (filters.dateRange) { + case 'today': + startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0); + break; + case 'yesterday': + startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1, 0, 0, 0); + break; + case 'week': + startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 7, 0, 0, 0); + break; + case 'month': + startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 30, 0, 0, 0); + break; + } + + if (startDate) { + query.append('StartDate', startDate.toISOString()); + } + + query.append('Skip', filters.skip.toString()); + query.append('Take', filters.take.toString()); + + fetch(`/api/logs?${query.toString()}`) + .then(response => response.json()) + .then(logs => { + if (logs.length === 0) { + tbody.innerHTML = 'No logs found'; + document.getElementById('log-count').textContent = '0 entries'; + document.getElementById('logs-pagination-info').textContent = 'Showing 0 of 0 entries'; + return; + } + + let html = ''; + logs.forEach(log => { + const timestamp = new Date(log.timestamp); + const levelClass = getLevelClass(log.level); + + html += ` + ${formatDate(timestamp)} + ${log.level} + ${log.message} + ${log.context || ''} + `; + }); + + tbody.innerHTML = html; + document.getElementById('log-count').textContent = `${logs.length} entries`; + document.getElementById('logs-pagination-info').textContent = `Showing ${logs.length} entries`; + + // Update pagination (simplified for now) + updateLogPagination(filters, logs.length); + }) + .catch(error => { + console.error('Error loading logs:', error); + tbody.innerHTML = 'Error loading logs'; + }); +} + +function updateLogPagination(filters, count) { + const pagination = document.getElementById('logs-pagination'); + + // Simplified pagination - just first page for now + pagination.innerHTML = ` +
  • + 1 +
  • + `; +} + +function getLevelClass(level) { + switch (level.toLowerCase()) { + case 'debug': + return 'bg-secondary'; + case 'information': + return 'bg-info'; + case 'warning': + return 'bg-warning'; + case 'error': + return 'bg-danger'; + case 'critical': + return 'bg-dark'; + default: + return 'bg-secondary'; + } +} + +function applyLogFilters() { + const filters = getLogFilters(); + loadLogs(filters); +} + +function resetLogFilters() { + document.getElementById('log-level').value = 'All'; + document.getElementById('log-search').value = ''; + document.getElementById('log-date-range').value = 'week'; + loadLogs(getLogFilters()); +} + +function clearLogs() { + if (!confirm('Are you sure you want to clear all logs? This action cannot be undone.')) { + return; + } + + fetch('/api/logs/clear', { + method: 'POST' + }) + .then(response => { + if (!response.ok) { + throw new Error('Failed to clear logs'); + } + + // Refresh logs + loadLogs(getLogFilters()); + }) + .catch(error => { + console.error('Error clearing logs:', error); + alert('Error clearing logs'); + }); +} + +function exportLogs() { + const filters = getLogFilters(); + const query = new URLSearchParams(); + + if (filters.level && filters.level !== 'All') { + query.append('Level', filters.level); + } + if (filters.search) { + query.append('Search', filters.search); + } + + // Create download link + const link = document.createElement('a'); + link.href = `/api/logs/export?${query.toString()}`; + link.download = `transmission-rss-logs-${new Date().toISOString().slice(0, 10)}.csv`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); +} + +// Helper function to format file sizes +function formatBytes(bytes, decimals = 2) { + if (bytes === 0) return '0 B'; + + const k = 1024; + const dm = decimals < 0 ? 0 : decimals; + const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; + + const i = Math.floor(Math.log(bytes) / Math.log(k)); + + return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]; +} + +// Configuration Operations +function backupConfig() { + if (!confirm('This will create a backup of your configuration files. Do you want to continue?')) { + return; + } + + fetch('/api/config/backup', { + method: 'POST' + }) + .then(response => { + if (!response.ok) { + throw new Error('Failed to backup configuration'); + } + return response.blob(); + }) + .then(blob => { + // Create download link for the backup file + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.style.display = 'none'; + a.href = url; + a.download = `transmission-rss-config-backup-${new Date().toISOString().slice(0, 10)}.json`; + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); + alert('Configuration backup created successfully.'); + }) + .catch(error => { + console.error('Error backing up configuration:', error); + alert('Error creating configuration backup.'); + }); +} + +function resetConfig() { + if (!confirm('WARNING: This will reset your configuration to default settings. All your feeds, rules, and user preferences will be lost. This cannot be undone. Are you absolutely sure?')) { + return; + } + + if (!confirm('FINAL WARNING: All feeds, rules, and settings will be permanently deleted. Type "RESET" to confirm.')) { + return; + } + + const confirmation = prompt('Type "RESET" to confirm configuration reset:'); + if (confirmation !== 'RESET') { + alert('Configuration reset cancelled.'); + return; + } + + fetch('/api/config/reset', { + method: 'POST' + }) + .then(response => { + if (!response.ok) { + throw new Error('Failed to reset configuration'); + } + + alert('Configuration has been reset to defaults. The application will now reload.'); + window.location.reload(); + }) + .catch(error => { + console.error('Error resetting configuration:', error); + alert('Error resetting configuration.'); + }); +} + +// Transmission Instance Management +function addTransmissionInstance() { + const instancesList = document.getElementById('transmission-instances-list'); + const instanceCount = document.querySelectorAll('.transmission-instance').length; + const newInstanceIndex = instanceCount + 1; + + const instanceHtml = ` +
    +
    +
    +
    Instance #${newInstanceIndex}
    + +
    +
    +
    +
    + + +
    +
    +
    +
    + + +
    +
    +
    +
    +
    +
    + + +
    +
    +
    +
    + + +
    +
    +
    +
    +
    +
    + + +
    +
    +
    +
    + + +
    +
    +
    +
    +
    `; + + // If the "no instances" message is showing, remove it + if (instancesList.querySelector('.text-center.text-muted')) { + instancesList.innerHTML = ''; + } + + // Add the new instance + instancesList.insertAdjacentHTML('beforeend', instanceHtml); +} + +function removeTransmissionInstance(index) { + const instance = document.getElementById(`transmission-instance-${index}`); + if (instance) { + instance.remove(); + + // If there are no instances left, show the "no instances" message + const instancesList = document.getElementById('transmission-instances-list'); + if (instancesList.children.length === 0) { + instancesList.innerHTML = '
    No additional instances configured
    '; + } + } +} + +function resetSettings() { + if (!confirm('This will reset all settings to their default values. Are you sure?')) { + return; + } + + // Load default settings + fetch('/api/config/defaults') + .then(response => response.json()) + .then(defaults => { + // Apply defaults to form + loadSettingsIntoForm(defaults); + }) + .catch(error => { + console.error('Error loading default settings:', error); + alert('Error loading default settings'); + }); +} \ No newline at end of file diff --git a/wwwroot/css/dark-mode.css b/wwwroot/css/dark-mode.css new file mode 100644 index 0000000..7d3864e --- /dev/null +++ b/wwwroot/css/dark-mode.css @@ -0,0 +1,70 @@ +/* Dark mode specific overrides - Created to fix dark mode text visibility */ + +/* These styles ensure proper text contrast in dark mode */ +body.dark-mode .form-check-label, +body.dark-mode label, +body.dark-mode .form-text, +body.dark-mode .card-body label, +body.dark-mode .nav-tabs .nav-link { + color: #f5f5f5 \!important; +} + +/* Tab navigation in dark mode */ +body.dark-mode .nav-tabs .nav-link.active { + background-color: #375a7f; + border-color: #444 #444 #375a7f; + color: #ffffff \!important; + font-weight: bold; +} + +body.dark-mode .nav-tabs .nav-link:not(.active):hover { + border-color: #444; + background-color: #2c2c2c; + color: #ffffff \!important; +} + +/* Ensure form elements in dark mode are visible */ +body.dark-mode .form-control::placeholder { + color: #adb5bd; + opacity: 0.7; +} + +/* Top navigation in dark mode */ +body.dark-mode .navbar-nav .nav-link.active { + background-color: #375a7f; + color: #ffffff \!important; + font-weight: bold; + border-radius: 4px; +} + +/* Advanced tab specific fixes */ +body.dark-mode #tab-advanced .form-check-label, +body.dark-mode #tab-advanced label, +body.dark-mode #tab-advanced .form-text { + color: #ffffff \!important; +} + +/* Ensure all tabs have proper text color */ +body.dark-mode #tab-transmission, +body.dark-mode #tab-rss, +body.dark-mode #tab-processing, +body.dark-mode #tab-appearance, +body.dark-mode #tab-advanced { + color: #f5f5f5 \!important; +} + +body.dark-mode #tab-transmission *, +body.dark-mode #tab-rss *, +body.dark-mode #tab-processing *, +body.dark-mode #tab-appearance *, +body.dark-mode #tab-advanced * { + color: #f5f5f5 \!important; +} + +/* Emergency fix for specific labels that were still problematic */ +body.dark-mode #detailed-logging-label, +body.dark-mode #show-completed-torrents-label, +body.dark-mode #confirm-delete-label { + color: white \!important; + font-weight: 500 \!important; +} diff --git a/wwwroot/css/styles.css b/wwwroot/css/styles.css new file mode 100644 index 0000000..806586c --- /dev/null +++ b/wwwroot/css/styles.css @@ -0,0 +1,724 @@ +:root { + /* Light Theme Variables */ + --primary-color: #0d6efd; + --secondary-color: #6c757d; + --dark-color: #212529; + --light-color: #f8f9fa; + --success-color: #198754; + --danger-color: #dc3545; + --warning-color: #ffc107; + --info-color: #0dcaf0; + + /* Common Variables */ + --border-radius: 4px; + --shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + --transition: all 0.3s ease; + + /* Light Theme Specific */ + --bg-color: #ffffff; + --text-color: #212529; + --card-bg: #f8f9fa; + --card-header-bg: #e9ecef; + --card-border: 1px solid rgba(0, 0, 0, 0.125); + --hover-bg: #e9ecef; + --table-border: #dee2e6; + --input-bg: #fff; + --input-border: #ced4da; + --dropdown-bg: #fff; + --modal-bg: #fff; + --feed-item-bg: #f8f9fa; + --torrent-item-bg: #f8f9fa; +} + +/* Dark Theme */ +body.dark-mode { + --bg-color: #121212; + --text-color: #f5f5f5; + --card-bg: #1e1e1e; + --card-header-bg: #252525; + --card-border: 1px solid rgba(255, 255, 255, 0.125); + --hover-bg: #2c2c2c; + --table-border: #333; + --input-bg: #2c2c2c; + --input-border: #444; + --dropdown-bg: #2c2c2c; + --modal-bg: #1e1e1e; + --feed-item-bg: #1e1e1e; + --torrent-item-bg: #1e1e1e; + + color-scheme: dark; +} + +/* Global forced text color for dark mode */ +body.dark-mode * { + color: #f5f5f5; +} + +/* Fix for dark mode text colors */ +body.dark-mode .text-dark, +body.dark-mode .text-body, +body.dark-mode .text-primary, +body.dark-mode .modal-title, +body.dark-mode .form-label, +body.dark-mode .form-check-label, +body.dark-mode h1, +body.dark-mode h2, +body.dark-mode h3, +body.dark-mode h4, +body.dark-mode h5, +body.dark-mode h6, +body.dark-mode label, +body.dark-mode .card-title, +body.dark-mode .form-text, +body.dark-mode .tab-content { + color: #f5f5f5 !important; +} + +body.dark-mode .text-secondary, +body.dark-mode .text-muted { + color: #adb5bd !important; +} + +body.dark-mode .nav-link { + color: #f5f5f5; +} + +body.dark-mode .nav-link:hover, +body.dark-mode .nav-link:focus { + color: #0d6efd; +} + +body.dark-mode .nav-link.active { + color: #f5f5f5; + background-color: #375a7f !important; + font-weight: bold; +} + +body.dark-mode .dropdown-menu { + background-color: #1e1e1e; + border-color: rgba(255, 255, 255, 0.125); +} + +body.dark-mode .dropdown-item { + color: #f5f5f5; +} + +body.dark-mode .dropdown-item:hover, +body.dark-mode .dropdown-item:focus { + background-color: #2c2c2c; + color: #f5f5f5; +} + +body.dark-mode .list-group-item { + background-color: #1e1e1e; + color: #f5f5f5; + border-color: rgba(255, 255, 255, 0.125); +} + +body.dark-mode .feed-item-date, +body.dark-mode .torrent-item-details { + color: #adb5bd; +} + +/* Links in dark mode */ +body.dark-mode a:not(.btn):not(.badge) { + color: #6ea8fe; +} + +body.dark-mode a:not(.btn):not(.badge):hover { + color: #8bb9fe; +} + +/* Ensure tab navs in dark mode have proper styling */ +body.dark-mode .nav-tabs .nav-link.active { + background-color: #375a7f; + border-color: #444 #444 #375a7f; + color: #fff !important; +} + +body.dark-mode .nav-tabs .nav-link:not(.active):hover { + border-color: #444; + background-color: #2c2c2c; + color: #fff !important; +} + +/* Table in dark mode */ +body.dark-mode .table { + color: #f5f5f5; +} + +/* Alerts in dark mode */ +body.dark-mode .alert-info { + background-color: #0d3251; + color: #6edff6; + border-color: #0a3a5a; +} + +body.dark-mode .alert-success { + background-color: #051b11; + color: #75b798; + border-color: #0c2a1c; +} + +body.dark-mode .alert-warning { + background-color: #332701; + color: #ffda6a; + border-color: #473b08; +} + +body.dark-mode .alert-danger { + background-color: #2c0b0e; + color: #ea868f; + border-color: #401418; +} + +/* Advanced tab fix */ +body.dark-mode #tab-advanced, +body.dark-mode #tab-advanced * { + color: #f5f5f5 !important; +} + +body.dark-mode #tab-advanced .form-check-label, +body.dark-mode .form-switch .form-check-label, +body.dark-mode label[for="show-completed-torrents"] { + color: #f5f5f5 !important; +} + +/* Base Elements */ +body { + padding-bottom: 2rem; + background-color: var(--bg-color); + color: var(--text-color); + transition: var(--transition); + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; +} + +/* Navigation */ +.navbar { + margin-bottom: 1rem; + background-color: var(--card-bg); + border-bottom: var(--card-border); + transition: var(--transition); +} + +.navbar-brand, .nav-link { + color: var(--text-color); + transition: var(--transition); +} + +.navbar-toggler { + border-color: var(--input-border); +} + +.page-content { + animation: fadeIn 0.3s ease; +} + +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +/* Cards */ +.card { + margin-bottom: 1rem; + box-shadow: var(--shadow); + background-color: var(--card-bg); + border: var(--card-border); + border-radius: var(--border-radius); + transition: var(--transition); +} + +.card-header { + background-color: var(--card-header-bg); + font-weight: 500; + border-bottom: var(--card-border); + transition: var(--transition); +} + +/* Tables */ +.table { + margin-bottom: 0; + color: var(--text-color); + transition: var(--transition); +} + +.table thead th { + border-bottom-color: var(--table-border); +} + +.table td, .table th { + border-top-color: var(--table-border); +} + +/* Progress Bars */ +.progress { + height: 10px; + background-color: var(--card-header-bg); + border-radius: var(--border-radius); +} + +/* Badges */ +.badge { + padding: 0.35em 0.65em; + border-radius: 50rem; +} + +.badge-downloading { + background-color: var(--info-color); + color: var(--dark-color); +} + +.badge-seeding { + background-color: var(--success-color); + color: white; +} + +.badge-stopped { + background-color: var(--secondary-color); + color: white; +} + +.badge-checking { + background-color: var(--warning-color); + color: var(--dark-color); +} + +.badge-queued { + background-color: var(--secondary-color); + color: white; +} + +.badge-error { + background-color: var(--danger-color); + color: white; +} + +/* Buttons */ +.btn { + border-radius: var(--border-radius); + transition: var(--transition); +} + +.btn-icon { + padding: 0.25rem 0.5rem; + font-size: 0.875rem; +} + +.btn-primary { + background-color: var(--primary-color); + border-color: var(--primary-color); +} + +.btn-secondary { + background-color: var(--secondary-color); + border-color: var(--secondary-color); +} + +.btn-success { + background-color: var(--success-color); + border-color: var(--success-color); +} + +.btn-danger { + background-color: var(--danger-color); + border-color: var(--danger-color); +} + +.btn-warning { + background-color: var(--warning-color); + border-color: var(--warning-color); + color: var(--dark-color); +} + +.btn-info { + background-color: var(--info-color); + border-color: var(--info-color); + color: var(--dark-color); +} + +/* Inputs & Forms */ +.form-control, .form-select { + background-color: var(--input-bg); + border-color: var(--input-border); + color: var(--text-color); + border-radius: var(--border-radius); + transition: var(--transition); +} + +.form-control:focus, .form-select:focus { + background-color: var(--input-bg); + color: var(--text-color); + border-color: var(--primary-color); + box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25); +} + +body.dark-mode .form-control, +body.dark-mode .form-select { + color: #f5f5f5; + background-color: #2c2c2c; + border-color: #444; +} + +body.dark-mode .form-control:focus, +body.dark-mode .form-select:focus { + background-color: #2c2c2c; + color: #f5f5f5; +} + +body.dark-mode .form-control::placeholder { + color: #adb5bd; + opacity: 0.7; +} + +/* Form switches in dark mode */ +body.dark-mode .form-check-input:checked { + background-color: #0d6efd; + border-color: #0d6efd; +} + +body.dark-mode .form-check-input:not(:checked) { + background-color: rgba(255, 255, 255, 0.2); + border-color: rgba(255, 255, 255, 0.25); +} + +body.dark-mode .form-check { + color: #f5f5f5 !important; +} + +body.dark-mode .form-check-label, +body.dark-mode label.form-check-label, +body.dark-mode .form-switch label, +body.dark-mode label[for], +body.dark-mode .card-body label, +body.dark-mode #show-completed-torrents + label, +body.dark-mode label[for="show-completed-torrents"] { + color: #f5f5f5 !important; +} + +/* Direct fix for the show completed torrents label */ +html body.dark-mode div#tab-advanced div.card-body div.form-check label.form-check-label[for="show-completed-torrents"], +html body.dark-mode div#tab-advanced div.mb-3 div.form-check-label, +html body.dark-mode div#tab-advanced label.form-check-label { + color: #ffffff !important; + font-weight: 500 !important; + text-shadow: 0 0 1px #000 !important; +} + +/* Fix all form check labels in dark mode */ +html body.dark-mode .form-check-label { + color: #ffffff !important; +} + +/* Fix for all tabs in dark mode */ +body.dark-mode #tab-advanced, +body.dark-mode #tab-advanced *, +body.dark-mode #tab-appearance, +body.dark-mode #tab-appearance *, +body.dark-mode #tab-processing, +body.dark-mode #tab-processing *, +body.dark-mode #tab-rss, +body.dark-mode #tab-rss *, +body.dark-mode #tab-transmission, +body.dark-mode #tab-transmission * { + color: #f5f5f5 !important; +} + +body.dark-mode .tab-content, +body.dark-mode .tab-content * { + color: #f5f5f5 !important; +} + +/* Emergency fix for advanced tab */ +body.dark-mode .form-check-label { + color: white !important; +} + +/* Super specific advanced tab fix */ +body.dark-mode #detailed-logging + label, +body.dark-mode #show-completed-torrents + label, +body.dark-mode #confirm-delete + label, +body.dark-mode div.form-check-label, +body.dark-mode label.form-check-label { + color: white !important; +} + +/* Feed Items */ +.feed-item { + border-left: 3px solid transparent; + padding: 15px; + margin-bottom: 15px; + background-color: var(--feed-item-bg); + border-radius: var(--border-radius); + transition: var(--transition); +} + +.feed-item:hover { + background-color: var(--hover-bg); +} + +.feed-item.matched { + border-left-color: var(--success-color); +} + +.feed-item.downloaded { + opacity: 0.7; +} + +.feed-item-title { + font-weight: 500; + margin-bottom: 8px; +} + +.feed-item-date { + font-size: 0.85rem; + color: var(--secondary-color); +} + +.feed-item-buttons { + margin-top: 12px; + display: flex; + gap: 8px; +} + +/* Torrent Items */ +.torrent-item { + margin-bottom: 20px; + padding: 15px; + border-radius: var(--border-radius); + background-color: var(--torrent-item-bg); + transition: var(--transition); + border: var(--card-border); +} + +.torrent-item-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; +} + +.torrent-item-title { + font-weight: 500; + margin-right: 10px; + word-break: break-word; +} + +.torrent-item-progress { + margin: 12px 0; +} + +.torrent-item-details { + display: flex; + flex-wrap: wrap; + gap: 10px; + justify-content: space-between; + font-size: 0.9rem; + color: var(--secondary-color); +} + +.torrent-item-buttons { + margin-top: 12px; + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +/* Dashboard panels */ +.dashboard-stats { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); + gap: 15px; + margin-bottom: 20px; +} + +.stat-card { + background-color: var(--card-bg); + border-radius: var(--border-radius); + padding: 20px; + border: var(--card-border); + transition: var(--transition); + display: flex; + flex-direction: column; + align-items: center; + text-align: center; +} + +.stat-card .stat-value { + font-size: 2rem; + font-weight: bold; + margin: 10px 0; +} + +.stat-card .stat-label { + font-size: 0.9rem; + color: var(--secondary-color); +} + +/* Dark Mode Toggle */ +.dark-mode-toggle { + cursor: pointer; + padding: 5px 10px; + border-radius: var(--border-radius); + transition: var(--transition); + color: var(--text-color); + background-color: transparent; + border: 1px solid var(--input-border); +} + +.dark-mode-toggle:hover { + background-color: var(--hover-bg); +} + +.dark-mode-toggle i { + font-size: 1.2rem; +} + +body.dark-mode .dark-mode-toggle { + color: #f5f5f5; + border-color: #444; +} + +/* Notifications */ +.toast-container { + position: fixed; + top: 20px; + right: 20px; + z-index: 9999; +} + +.toast { + background-color: var(--card-bg); + color: var(--text-color); + border: var(--card-border); + margin-bottom: 10px; + max-width: 350px; +} + +.toast-header { + background-color: var(--card-header-bg); + color: var(--text-color); + border-bottom: var(--card-border); +} + +/* Modals */ +.modal-content { + background-color: var(--modal-bg); + color: var(--text-color); + border: var(--card-border); +} + +.modal-header { + border-bottom: var(--card-border); +} + +.modal-footer { + border-top: var(--card-border); +} + +/* Charts and Graphs */ +.chart-container { + position: relative; + height: 300px; + margin-bottom: 20px; +} + +/* Mobile Responsive Design */ +@media (max-width: 768px) { + .container { + padding-left: 15px; + padding-right: 15px; + max-width: 100%; + } + + .card-body { + padding: 15px; + } + + .torrent-item-header { + flex-direction: column; + align-items: flex-start; + } + + .torrent-item-buttons { + width: 100%; + } + + .torrent-item-buttons .btn { + flex: 1; + text-align: center; + padding: 8px; + } + + .dashboard-stats { + grid-template-columns: 1fr; + } + + .stat-card { + margin-bottom: 10px; + } + + .feed-item-buttons { + flex-direction: column; + } + + .feed-item-buttons .btn { + width: 100%; + margin-bottom: 5px; + } + + .table-responsive { + margin-bottom: 15px; + } +} + +/* Tablet Responsive Design */ +@media (min-width: 769px) and (max-width: 992px) { + .dashboard-stats { + grid-template-columns: repeat(2, 1fr); + } +} + +/* Print Styles */ +@media print { + .no-print { + display: none !important; + } + + body { + background-color: white !important; + color: black !important; + } + + .card, .torrent-item, .feed-item { + break-inside: avoid; + border: 1px solid #ddd !important; + } +} + +/* Accessibility */ +@media (prefers-reduced-motion) { + * { + transition: none !important; + animation: none !important; + } +} + +/* Utilities */ +.text-truncate { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.cursor-pointer { + cursor: pointer; +} + +.flex-grow-1 { + flex-grow: 1; +} + +.word-break-all { + word-break: break-all; +} \ No newline at end of file diff --git a/wwwroot/index.html b/wwwroot/index.html new file mode 100644 index 0000000..3cd6ade --- /dev/null +++ b/wwwroot/index.html @@ -0,0 +1,771 @@ + + + + + + Transmission RSS Manager + + + + + + + + + + +
    + +
    +
    +

    Dashboard

    + + +
    +
    + +
    -
    +
    Active Downloads
    +
    +
    + +
    -
    +
    Seeding Torrents
    +
    +
    + +
    -
    +
    Active Feeds
    +
    +
    + +
    -
    +
    Completed Today
    +
    +
    + + +
    +
    +
    +
    + Download/Upload Speed + - +
    +
    +
    +
    + Download: + 0 KB/s +
    +
    + Upload: + 0 KB/s +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + Activity Summary +
    +
    +
      +
    • + Added Today + - +
    • +
    • + Completed Today + - +
    • +
    • + Active RSS Feeds + - +
    • +
    • + Matched Items + - +
    • +
    +
    +
    +
    +
    + + +
    +
    +
    +
    + Download History (Last 30 Days) +
    +
    +
    + +
    +
    +
    +
    +
    + + +
    +
    +
    +
    + Active Torrents + View All +
    +
    +
    Loading...
    +
    +
    +
    +
    +
    +
    + Recent Matches + View All +
    +
    +
    Loading...
    +
    +
    +
    +
    +
    + +
    +

    RSS Feeds

    +
    + + +
    +
    Loading...
    + +
    +

    Feed Items

    + +
    +
    +
    Loading...
    +
    +
    +
    Loading...
    +
    +
    +
    +
    + +
    +

    Torrents

    +
    + + +
    +
    Loading...
    +
    + +
    +

    System Logs

    + +
    +
    + Log Filters +
    + + +
    +
    +
    +
    +
    +
    + + +
    +
    +
    +
    + + +
    +
    +
    +
    + + +
    +
    +
    +
    +
    +
    +
    + + +
    +
    + +
    +
    +
    +
    +
    +
    + +
    +
    + Log Entries + 0 entries +
    +
    +
    + + + + + + + + + + + + + + +
    TimestampLevelMessageContext
    Loading logs...
    +
    +
    + +
    +
    + +
    +

    Settings

    +
    + + +
    + +
    +
    +
    + Primary Transmission Instance +
    +
    +
    +
    +
    + + +
    +
    +
    +
    + + +
    +
    +
    +
    +
    +
    +
    + + +
    +
    +
    +
    +
    +
    +
    + + +
    +
    +
    +
    + + +
    +
    +
    +
    +
    + + +
    +
    + Additional Transmission Instances + +
    +
    +
    +
    No additional instances configured
    +
    +
    +
    +
    + + +
    +
    +
    + RSS General Settings +
    +
    +
    +
    + + +
    +
    +
    + + +
    +
    + + +
    Maximum number of items to keep per feed (for performance)
    +
    +
    +
    + +
    +
    + Content Filtering +
    +
    +
    +
    + + +
    +
    When enabled, feed rules can use regular expressions for more advanced matching
    +
    +
    +
    + + +
    +
    +
    + + +
    Items matching these patterns will be ignored regardless of feed rules
    +
    +
    +
    +
    + + +
    +
    +
    + Directories +
    +
    +
    + + +
    +
    + + +
    +
    +
    + + +
    +
    Create subfolders based on feed categories
    +
    +
    +
    + +
    +
    + Post Processing +
    +
    +
    +
    + + +
    +
    +
    +
    + + +
    +
    +
    +
    + + +
    +
    +
    +
    + + +
    +
    +
    +
    + + +
    +
    +
    +
    + + +
    +
    +
    + + +
    +
    + + +
    Number of days after which completed torrents will be removed (0 = never)
    +
    +
    + + +
    +
    +
    +
    + + +
    +
    +
    + User Interface +
    +
    +
    +
    + + +
    +
    +
    +
    + + +
    +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    +
    + +
    +
    + Notifications +
    +
    +
    +
    + + +
    +
    +
    + +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    +
    +
    +
    + + +
    +
    +
    + Advanced Settings +
    +
    +
    +
    + + +
    +
    +
    +
    + + +
    +
    +
    +
    + + +
    +
    +
    + + +
    Number of days to keep historical data
    +
    +
    +
    + +
    +
    + Database +
    +
    +
    + Warning: These operations affect your data permanently. +
    +
    + + + +
    +
    +
    +
    +
    + +
    + + +
    +
    +
    +
    + + + + + + + + + + + + \ No newline at end of file diff --git a/wwwroot/js/app.js b/wwwroot/js/app.js new file mode 100644 index 0000000..b3c9224 --- /dev/null +++ b/wwwroot/js/app.js @@ -0,0 +1,1599 @@ +document.addEventListener('DOMContentLoaded', function() { + // Initialize navigation + initNavigation(); + + // Initialize event listeners + initEventListeners(); + + // Load initial dashboard data + loadDashboardData(); + + // Set up dark mode based on user preference + initDarkMode(); + + // Set up auto refresh if enabled + initAutoRefresh(); + + // Initialize Bootstrap tooltips + const tooltips = document.querySelectorAll('[data-bs-toggle="tooltip"]'); + tooltips.forEach(tooltip => new bootstrap.Tooltip(tooltip)); +}); + +function initNavigation() { + const navLinks = document.querySelectorAll('.navbar-nav .nav-link'); + navLinks.forEach(link => { + link.addEventListener('click', function(e) { + e.preventDefault(); + const page = this.getAttribute('data-page'); + showPage(page); + }); + }); + + // Set active page from URL hash or default to dashboard + const hash = window.location.hash.substring(1); + showPage(hash || 'dashboard'); + + // Add custom styling to active nav link + updateActiveNavStyles(); +} + +function updateActiveNavStyles() { + // Add specific styles for active nav in dark mode + if (document.body.classList.contains('dark-mode')) { + const activeNavLink = document.querySelector('.navbar-nav .nav-link.active'); + if (activeNavLink) { + activeNavLink.style.backgroundColor = '#375a7f'; + activeNavLink.style.color = '#ffffff'; + activeNavLink.style.fontWeight = 'bold'; + } + } +} + +function showPage(page) { + // Hide all pages + const pages = document.querySelectorAll('.page-content'); + pages.forEach(p => p.classList.add('d-none')); + + // Reset styles and remove active class from all nav links + const navLinks = document.querySelectorAll('.navbar-nav .nav-link'); + navLinks.forEach(link => { + link.classList.remove('active'); + link.style.backgroundColor = ''; + link.style.color = ''; + link.style.fontWeight = ''; + }); + + // Show selected page + const selectedPage = document.getElementById(`page-${page}`); + if (selectedPage) { + selectedPage.classList.remove('d-none'); + + // Set active class on nav link + const activeNav = document.querySelector(`.nav-link[data-page="${page}"]`); + if (activeNav) { + activeNav.classList.add('active'); + // Apply special styling for active nav in dark mode + if (document.body.classList.contains('dark-mode')) { + activeNav.style.backgroundColor = '#375a7f'; + activeNav.style.color = '#ffffff'; + activeNav.style.fontWeight = 'bold'; + } + } + + // Update URL hash + window.location.hash = page; + + // Load page-specific data + loadPageData(page); + } +} + +function loadPageData(page) { + switch (page) { + case 'dashboard': + loadDashboardData(); + break; + case 'feeds': + loadFeeds(); + loadAllItems(); + loadMatchedItems(); + break; + case 'torrents': + loadTorrents(); + break; + case 'settings': + loadSettings(); + break; + } +} + +function initEventListeners() { + // RSS Feeds page + document.getElementById('btn-add-feed').addEventListener('click', showAddFeedModal); + document.getElementById('btn-refresh-feeds').addEventListener('click', refreshFeeds); + document.getElementById('save-feed-btn').addEventListener('click', saveFeed); + + // Torrents page + document.getElementById('btn-add-torrent').addEventListener('click', showAddTorrentModal); + document.getElementById('btn-refresh-torrents').addEventListener('click', loadTorrents); + document.getElementById('save-torrent-btn').addEventListener('click', saveTorrent); + + // Logs page + document.getElementById('btn-refresh-logs').addEventListener('click', refreshLogs); + document.getElementById('btn-clear-logs').addEventListener('click', clearLogs); + document.getElementById('btn-apply-log-filters').addEventListener('click', applyLogFilters); + document.getElementById('btn-reset-log-filters').addEventListener('click', resetLogFilters); + document.getElementById('btn-export-logs').addEventListener('click', exportLogs); + + // Settings page + document.getElementById('settings-form').addEventListener('submit', saveSettings); + document.getElementById('dark-mode-toggle').addEventListener('click', toggleDarkMode); + document.getElementById('btn-reset-settings').addEventListener('click', resetSettings); + + // Configuration operations + document.getElementById('btn-backup-config').addEventListener('click', backupConfig); + document.getElementById('btn-reset-config').addEventListener('click', resetConfig); + + // Additional Transmission Instances + document.getElementById('add-transmission-instance').addEventListener('click', addTransmissionInstance); +} + +// Dashboard +function loadDashboardData() { + // Fetch dashboard statistics + fetch('/api/dashboard/stats') + .then(response => response.json()) + .then(stats => { + document.getElementById('active-downloads').textContent = stats.activeDownloads; + document.getElementById('seeding-torrents').textContent = stats.seedingTorrents; + document.getElementById('active-feeds').textContent = stats.activeFeeds; + document.getElementById('completed-today').textContent = stats.completedToday; + + document.getElementById('added-today').textContent = stats.addedToday; + document.getElementById('feeds-count').textContent = stats.feedsCount; + document.getElementById('matched-count').textContent = stats.matchedCount; + + // Format download/upload speeds + const downloadSpeed = formatBytes(stats.downloadSpeed) + '/s'; + const uploadSpeed = formatBytes(stats.uploadSpeed) + '/s'; + document.getElementById('download-speed').textContent = downloadSpeed; + document.getElementById('upload-speed').textContent = uploadSpeed; + document.getElementById('current-speed').textContent = `↓${downloadSpeed} ↑${uploadSpeed}`; + + // Set progress bars (max 100%) + const maxSpeed = Math.max(stats.downloadSpeed, stats.uploadSpeed, 1); + const dlPercent = Math.min(Math.round((stats.downloadSpeed / maxSpeed) * 100), 100); + const ulPercent = Math.min(Math.round((stats.uploadSpeed / maxSpeed) * 100), 100); + document.getElementById('download-speed-bar').style.width = `${dlPercent}%`; + document.getElementById('upload-speed-bar').style.width = `${ulPercent}%`; + }) + .catch(error => { + console.error('Error loading dashboard stats:', error); + }); + + // Load chart data + loadDownloadHistoryChart(); + + // Load other dashboard components + loadActiveTorrents(); + loadRecentMatches(); +} + +function loadSystemStatus() { + const statusElement = document.getElementById('system-status'); + statusElement.innerHTML = '
    '; + + fetch('/api/config') + .then(response => response.json()) + .then(config => { + // Create system status HTML + let html = '
      '; + html += `
    • Auto Download ${config.autoDownloadEnabled ? 'Enabled' : 'Disabled'}
    • `; + html += `
    • Check Interval ${config.checkIntervalMinutes} minutes
    • `; + html += `
    • Transmission Connection ${config.transmission.host ? config.transmission.host + ':' + config.transmission.port : 'Not configured'}
    • `; + html += `
    • Post Processing ${config.postProcessing.enabled ? 'Enabled' : 'Disabled'}
    • `; + html += '
    '; + + statusElement.innerHTML = html; + }) + .catch(error => { + console.error('Error loading system status:', error); + statusElement.innerHTML = '
    Error loading system status
    '; + }); +} + +function loadRecentMatches() { + const matchesElement = document.getElementById('recent-matches'); + matchesElement.innerHTML = '
    '; + + fetch('/api/feeds/matched') + .then(response => response.json()) + .then(items => { + // Sort by publish date descending and take the first 5 + const recentItems = items.sort((a, b) => new Date(b.publishDate) - new Date(a.publishDate)).slice(0, 5); + + if (recentItems.length === 0) { + matchesElement.innerHTML = '
    No matched items yet
    '; + return; + } + + let html = '
    '; + recentItems.forEach(item => { + const date = new Date(item.publishDate); + html += ` +
    +
    ${item.title}
    + ${formatDate(date)} +
    + + Matched rule: ${item.matchedRule} + ${item.isDownloaded ? 'Downloaded' : 'Not Downloaded'} + +
    `; + }); + html += '
    '; + + matchesElement.innerHTML = html; + }) + .catch(error => { + console.error('Error loading recent matches:', error); + matchesElement.innerHTML = '
    Error loading recent matches
    '; + }); +} + +function loadActiveTorrents() { + const torrentsElement = document.getElementById('active-torrents'); + torrentsElement.innerHTML = '
    '; + + fetch('/api/torrents') + .then(response => response.json()) + .then(torrents => { + console.log('Dashboard torrents:', torrents); + + // Sort by progress ascending and filter for active torrents + const activeTorrents = torrents + .filter(t => t && t.status && (t.status === 'Downloading' || t.status === 'Seeding')) + .sort((a, b) => (a.percentDone || 0) - (b.percentDone || 0)); + + if (activeTorrents.length === 0) { + torrentsElement.innerHTML = '
    No active torrents
    '; + return; + } + + let html = '
    '; + activeTorrents.forEach(torrent => { + // Handle potential null or undefined values + if (!torrent || !torrent.name) { + return; + } + + // Safely calculate percentages and sizes with error handling + let progressPercent = 0; + try { + progressPercent = Math.round((torrent.percentDone || 0) * 100); + } catch (e) { + console.warn('Error calculating progress percent:', e); + } + + let sizeInGB = '0.00'; + try { + if (torrent.totalSize && torrent.totalSize > 0) { + sizeInGB = (torrent.totalSize / 1073741824).toFixed(2); + } + } catch (e) { + console.warn('Error calculating size in GB:', e); + } + + const torrentStatus = torrent.status || 'Unknown'; + const statusClass = torrentStatus.toLowerCase().replace(/\s+/g, '-'); + + html += `
    +
    +
    ${torrent.name}
    + ${torrentStatus} +
    +
    +
    ${progressPercent}%
    +
    + Size: ${sizeInGB} GB +
    `; + }); + html += '
    '; + + torrentsElement.innerHTML = html; + }) + .catch(error => { + console.error('Error loading active torrents:', error); + torrentsElement.innerHTML = '
    Error loading active torrents
    '; + }); +} + +// RSS Feeds +function loadFeeds() { + const feedsElement = document.getElementById('feeds-list'); + feedsElement.innerHTML = '
    '; + + fetch('/api/feeds') + .then(response => response.json()) + .then(feeds => { + if (feeds.length === 0) { + feedsElement.innerHTML = '
    No feeds added yet
    '; + return; + } + + let html = '
    '; + feeds.forEach(feed => { + const lastChecked = feed.lastChecked ? new Date(feed.lastChecked) : null; + + html += `
    +
    +
    ${feed.name}
    + ${lastChecked ? 'Last checked: ' + formatDate(lastChecked) : 'Never checked'} +
    +

    ${feed.url}

    +
    + ${feed.rules.length} rules +
    + + +
    +
    +
    `; + }); + html += '
    '; + + feedsElement.innerHTML = html; + + // Add event listeners + document.querySelectorAll('.btn-edit-feed').forEach(btn => { + btn.addEventListener('click', function() { + const feedId = this.getAttribute('data-feed-id'); + editFeed(feedId); + }); + }); + + document.querySelectorAll('.btn-delete-feed').forEach(btn => { + btn.addEventListener('click', function() { + const feedId = this.getAttribute('data-feed-id'); + deleteFeed(feedId); + }); + }); + }) + .catch(error => { + console.error('Error loading feeds:', error); + feedsElement.innerHTML = '
    Error loading feeds
    '; + }); +} + +function loadAllItems() { + const itemsElement = document.getElementById('all-items-list'); + itemsElement.innerHTML = '
    '; + + fetch('/api/feeds/items') + .then(response => response.json()) + .then(items => { + if (items.length === 0) { + itemsElement.innerHTML = '
    No feed items yet
    '; + return; + } + + let html = '
    '; + items.forEach(item => { + const date = new Date(item.publishDate); + const classes = `feed-item ${item.isMatched ? 'matched' : ''} ${item.isDownloaded ? 'downloaded' : ''}`; + + html += `
    + +
    ${formatDate(date)}
    + ${item.isMatched ? `
    Matched rule: ${item.matchedRule}
    ` : ''} + ${item.isDownloaded ? '
    Downloaded
    ' : ''} + ${!item.isDownloaded && item.isMatched ? + `
    + +
    ` : '' + } +
    `; + }); + html += '
    '; + + itemsElement.innerHTML = html; + + // Add event listeners + document.querySelectorAll('.btn-download-item').forEach(btn => { + btn.addEventListener('click', function() { + const itemId = this.getAttribute('data-item-id'); + downloadItem(itemId); + }); + }); + }) + .catch(error => { + console.error('Error loading feed items:', error); + itemsElement.innerHTML = '
    Error loading feed items
    '; + }); +} + +function loadMatchedItems() { + const matchedElement = document.getElementById('matched-items-list'); + matchedElement.innerHTML = '
    '; + + fetch('/api/feeds/matched') + .then(response => response.json()) + .then(items => { + if (items.length === 0) { + matchedElement.innerHTML = '
    No matched items yet
    '; + return; + } + + let html = '
    '; + items.forEach(item => { + const date = new Date(item.publishDate); + const classes = `feed-item matched ${item.isDownloaded ? 'downloaded' : ''}`; + + html += `
    + +
    ${formatDate(date)}
    +
    Matched rule: ${item.matchedRule}
    + ${item.isDownloaded ? '
    Downloaded
    ' : ''} + ${!item.isDownloaded ? + `
    + +
    ` : '' + } +
    `; + }); + html += '
    '; + + matchedElement.innerHTML = html; + + // Add event listeners + document.querySelectorAll('.btn-download-matched-item').forEach(btn => { + btn.addEventListener('click', function() { + const itemId = this.getAttribute('data-item-id'); + downloadItem(itemId); + }); + }); + }) + .catch(error => { + console.error('Error loading matched items:', error); + matchedElement.innerHTML = '
    Error loading matched items
    '; + }); +} + +function showAddFeedModal() { + // Clear form + document.getElementById('feed-name').value = ''; + document.getElementById('feed-url').value = ''; + document.getElementById('feed-rules').value = ''; + document.getElementById('feed-auto-download').checked = false; + + // Update modal title and button text + document.querySelector('#add-feed-modal .modal-title').textContent = 'Add RSS Feed'; + document.getElementById('save-feed-btn').textContent = 'Add Feed'; + + // Remove feed ID data attribute + document.getElementById('save-feed-btn').removeAttribute('data-feed-id'); + + // Show modal + const modal = new bootstrap.Modal(document.getElementById('add-feed-modal')); + modal.show(); +} + +function editFeed(feedId) { + // Fetch feed data + fetch(`/api/feeds`) + .then(response => response.json()) + .then(feeds => { + const feed = feeds.find(f => f.id === feedId); + if (!feed) { + alert('Feed not found'); + return; + } + + // Populate form + document.getElementById('feed-name').value = feed.name; + document.getElementById('feed-url').value = feed.url; + document.getElementById('feed-rules').value = feed.rules.join('\n'); + document.getElementById('feed-auto-download').checked = feed.autoDownload; + + // Update modal title and button text + document.querySelector('#add-feed-modal .modal-title').textContent = 'Edit RSS Feed'; + document.getElementById('save-feed-btn').textContent = 'Save Changes'; + + // Add feed ID data attribute + document.getElementById('save-feed-btn').setAttribute('data-feed-id', feedId); + + // Show modal + const modal = new bootstrap.Modal(document.getElementById('add-feed-modal')); + modal.show(); + }) + .catch(error => { + console.error('Error fetching feed:', error); + alert('Error fetching feed'); + }); +} + +function saveFeed() { + const name = document.getElementById('feed-name').value.trim(); + const url = document.getElementById('feed-url').value.trim(); + const rulesText = document.getElementById('feed-rules').value.trim(); + const autoDownload = document.getElementById('feed-auto-download').checked; + + if (!name || !url) { + alert('Please enter a name and URL'); + return; + } + + // Parse rules (split by new line and remove empty lines) + const rules = rulesText.split('\n').filter(rule => rule.trim() !== ''); + + const feedId = document.getElementById('save-feed-btn').getAttribute('data-feed-id'); + const isEditing = !!feedId; + + const feedData = { + name: name, + url: url, + rules: rules, + autoDownload: autoDownload + }; + + if (isEditing) { + feedData.id = feedId; + + // Update existing feed + fetch(`/api/feeds/${feedId}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(feedData) + }) + .then(response => { + if (!response.ok) { + throw new Error('Failed to update feed'); + } + + // Close modal + const modal = bootstrap.Modal.getInstance(document.getElementById('add-feed-modal')); + modal.hide(); + + // Refresh feeds + loadFeeds(); + }) + .catch(error => { + console.error('Error updating feed:', error); + alert('Error updating feed'); + }); + } else { + // Add new feed + fetch('/api/feeds', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(feedData) + }) + .then(response => { + if (!response.ok) { + throw new Error('Failed to add feed'); + } + + // Close modal + const modal = bootstrap.Modal.getInstance(document.getElementById('add-feed-modal')); + modal.hide(); + + // Refresh feeds + loadFeeds(); + // Also refresh items since a new feed might have new items + loadAllItems(); + loadMatchedItems(); + }) + .catch(error => { + console.error('Error adding feed:', error); + alert('Error adding feed'); + }); + } +} + +function deleteFeed(feedId) { + if (!confirm('Are you sure you want to delete this feed?')) { + return; + } + + fetch(`/api/feeds/${feedId}`, { + method: 'DELETE' + }) + .then(response => { + if (!response.ok) { + throw new Error('Failed to delete feed'); + } + + // Refresh feeds + loadFeeds(); + // Also refresh items since items from this feed should be removed + loadAllItems(); + loadMatchedItems(); + }) + .catch(error => { + console.error('Error deleting feed:', error); + alert('Error deleting feed'); + }); +} + +function refreshFeeds() { + const btn = document.getElementById('btn-refresh-feeds'); + btn.disabled = true; + btn.innerHTML = ' Refreshing...'; + + fetch('/api/feeds/refresh', { + method: 'POST' + }) + .then(response => { + if (!response.ok) { + throw new Error('Failed to refresh feeds'); + } + + // Re-enable button + btn.disabled = false; + btn.textContent = 'Refresh Feeds'; + + // Refresh feed items + loadFeeds(); + loadAllItems(); + loadMatchedItems(); + }) + .catch(error => { + console.error('Error refreshing feeds:', error); + alert('Error refreshing feeds'); + + // Re-enable button + btn.disabled = false; + btn.textContent = 'Refresh Feeds'; + }); +} + +function downloadItem(itemId) { + fetch(`/api/feeds/download/${itemId}`, { + method: 'POST' + }) + .then(response => { + if (!response.ok) { + throw new Error('Failed to download item'); + } + + // Refresh items + loadAllItems(); + loadMatchedItems(); + // Also refresh torrents since a new torrent should be added + loadTorrents(); + }) + .catch(error => { + console.error('Error downloading item:', error); + alert('Error downloading item'); + }); +} + +// Torrents +function loadTorrents() { + const torrentsElement = document.getElementById('torrents-list'); + torrentsElement.innerHTML = '
    '; + + fetch('/api/torrents') + .then(response => response.json()) + .then(torrents => { + console.log('Loaded torrents:', torrents); + if (torrents.length === 0) { + torrentsElement.innerHTML = '
    No torrents
    '; + return; + } + + let html = '
    '; + torrents.forEach(torrent => { + // Handle potential null or undefined values + if (!torrent || !torrent.name) { + console.warn('Invalid torrent data:', torrent); + return; + } + + // Safely calculate percentages and sizes with error handling + let progressPercent = 0; + try { + progressPercent = Math.round((torrent.percentDone || 0) * 100); + } catch (e) { + console.warn('Error calculating progress percent:', e); + } + + let sizeInGB = '0.00'; + try { + if (torrent.totalSize && torrent.totalSize > 0) { + sizeInGB = (torrent.totalSize / 1073741824).toFixed(2); + } + } catch (e) { + console.warn('Error calculating size in GB:', e); + } + + const torrentStatus = torrent.status || 'Unknown'; + const statusClass = torrentStatus.toLowerCase().replace(/\s+/g, '-'); + + html += `
    +
    +
    ${torrent.name}
    + ${torrentStatus} +
    +
    +
    +
    ${progressPercent}%
    +
    +
    +
    + Size: ${sizeInGB} GB + Location: ${torrent.downloadDir || 'Unknown'} +
    +
    + ${torrentStatus === 'Stopped' ? + `` : + `` + } + + ${progressPercent >= 100 ? + `` : '' + } +
    +
    `; + }); + html += '
    '; + + torrentsElement.innerHTML = html; + + // Add event listeners + document.querySelectorAll('.btn-start-torrent').forEach(btn => { + btn.addEventListener('click', function() { + const torrentId = parseInt(this.getAttribute('data-torrent-id')); + startTorrent(torrentId); + }); + }); + + document.querySelectorAll('.btn-stop-torrent').forEach(btn => { + btn.addEventListener('click', function() { + const torrentId = parseInt(this.getAttribute('data-torrent-id')); + stopTorrent(torrentId); + }); + }); + + document.querySelectorAll('.btn-remove-torrent').forEach(btn => { + btn.addEventListener('click', function() { + const torrentId = parseInt(this.getAttribute('data-torrent-id')); + removeTorrent(torrentId); + }); + }); + + document.querySelectorAll('.btn-process-torrent').forEach(btn => { + btn.addEventListener('click', function() { + const torrentId = parseInt(this.getAttribute('data-torrent-id')); + processTorrent(torrentId); + }); + }); + }) + .catch(error => { + console.error('Error loading torrents:', error); + torrentsElement.innerHTML = '
    Error loading torrents
    '; + }); +} + +function showAddTorrentModal() { + // Clear form + document.getElementById('torrent-url').value = ''; + document.getElementById('torrent-download-dir').value = ''; + + // Show modal + const modal = new bootstrap.Modal(document.getElementById('add-torrent-modal')); + modal.show(); +} + +function saveTorrent() { + const url = document.getElementById('torrent-url').value.trim(); + const downloadDir = document.getElementById('torrent-download-dir').value.trim(); + + if (!url) { + alert('Please enter a torrent URL or magnet link'); + return; + } + + const torrentData = { + url: url + }; + + if (downloadDir) { + torrentData.downloadDir = downloadDir; + } + + fetch('/api/torrents', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(torrentData) + }) + .then(response => { + if (!response.ok) { + throw new Error('Failed to add torrent'); + } + + // Close modal + const modal = bootstrap.Modal.getInstance(document.getElementById('add-torrent-modal')); + modal.hide(); + + // Refresh torrents + loadTorrents(); + }) + .catch(error => { + console.error('Error adding torrent:', error); + alert('Error adding torrent'); + }); +} + +function startTorrent(torrentId) { + fetch(`/api/torrents/${torrentId}/start`, { + method: 'POST' + }) + .then(response => { + if (!response.ok) { + throw new Error('Failed to start torrent'); + } + + // Refresh torrents + loadTorrents(); + }) + .catch(error => { + console.error('Error starting torrent:', error); + alert('Error starting torrent'); + }); +} + +function stopTorrent(torrentId) { + fetch(`/api/torrents/${torrentId}/stop`, { + method: 'POST' + }) + .then(response => { + if (!response.ok) { + throw new Error('Failed to stop torrent'); + } + + // Refresh torrents + loadTorrents(); + }) + .catch(error => { + console.error('Error stopping torrent:', error); + alert('Error stopping torrent'); + }); +} + +function removeTorrent(torrentId) { + if (!confirm('Are you sure you want to remove this torrent? The downloaded files will be kept.')) { + return; + } + + fetch(`/api/torrents/${torrentId}`, { + method: 'DELETE' + }) + .then(response => { + if (!response.ok) { + throw new Error('Failed to remove torrent'); + } + + // Refresh torrents + loadTorrents(); + }) + .catch(error => { + console.error('Error removing torrent:', error); + alert('Error removing torrent'); + }); +} + +function processTorrent(torrentId) { + fetch(`/api/torrents/${torrentId}/process`, { + method: 'POST' + }) + .then(response => { + if (!response.ok) { + throw new Error('Failed to process torrent'); + } + + alert('Torrent processing started'); + }) + .catch(error => { + console.error('Error processing torrent:', error); + alert('Error processing torrent'); + }); +} + +// Settings +function loadSettings() { + const form = document.getElementById('settings-form'); + + fetch('/api/config') + .then(response => response.json()) + .then(config => { + // Transmission settings + document.getElementById('transmission-host').value = config.transmission.host; + document.getElementById('transmission-port').value = config.transmission.port; + document.getElementById('transmission-use-https').checked = config.transmission.useHttps; + document.getElementById('transmission-username').value = ''; + document.getElementById('transmission-password').value = ''; + + // RSS settings + document.getElementById('auto-download-enabled').checked = config.autoDownloadEnabled; + document.getElementById('check-interval').value = config.checkIntervalMinutes; + + // Directory settings + document.getElementById('download-directory').value = config.downloadDirectory; + document.getElementById('media-library').value = config.mediaLibraryPath; + + // Post processing settings + document.getElementById('post-processing-enabled').checked = config.postProcessing.enabled; + document.getElementById('extract-archives').checked = config.postProcessing.extractArchives; + document.getElementById('organize-media').checked = config.postProcessing.organizeMedia; + document.getElementById('minimum-seed-ratio').value = config.postProcessing.minimumSeedRatio; + document.getElementById('media-extensions').value = config.postProcessing.mediaExtensions.join(', '); + }) + .catch(error => { + console.error('Error loading settings:', error); + alert('Error loading settings'); + }); +} + +function saveSettings(e) { + e.preventDefault(); + + // Show saving indicator + const saveBtn = document.querySelector('#settings-form button[type="submit"]'); + const originalBtnText = saveBtn.innerHTML; + saveBtn.disabled = true; + saveBtn.innerHTML = ' Saving...'; + + const config = { + transmission: { + host: document.getElementById('transmission-host').value.trim(), + port: parseInt(document.getElementById('transmission-port').value), + useHttps: document.getElementById('transmission-use-https').checked, + username: document.getElementById('transmission-username').value.trim(), + password: document.getElementById('transmission-password').value.trim() + }, + autoDownloadEnabled: document.getElementById('auto-download-enabled').checked, + checkIntervalMinutes: parseInt(document.getElementById('check-interval').value), + downloadDirectory: document.getElementById('download-directory').value.trim(), + mediaLibraryPath: document.getElementById('media-library').value.trim(), + postProcessing: { + enabled: document.getElementById('post-processing-enabled').checked, + extractArchives: document.getElementById('extract-archives').checked, + organizeMedia: document.getElementById('organize-media').checked, + minimumSeedRatio: parseInt(document.getElementById('minimum-seed-ratio').value), + mediaExtensions: document.getElementById('media-extensions').value.split(',').map(ext => ext.trim()) + }, + userPreferences: { + enableDarkMode: document.body.classList.contains('dark-mode'), + autoRefreshUIEnabled: localStorage.getItem('autoRefresh') !== 'false', + autoRefreshIntervalSeconds: parseInt(localStorage.getItem('refreshInterval')) || 30, + notificationsEnabled: true + } + }; + + fetch('/api/config', { + method: 'PUT', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(config) + }) + .then(response => { + if (!response.ok) { + return response.text().then(text => { + throw new Error(`Failed to save settings: ${text}`); + }); + } + return response.json(); + }) + .then(result => { + console.log('Settings saved successfully:', result); + + // Show success message + const settingsForm = document.getElementById('settings-form'); + const alertDiv = document.createElement('div'); + alertDiv.className = 'alert alert-success alert-dismissible fade show mt-3'; + alertDiv.role = 'alert'; + alertDiv.innerHTML = ` + Success! Your settings have been saved. + + `; + settingsForm.appendChild(alertDiv); + + // Auto dismiss after 3 seconds + setTimeout(() => { + const bsAlert = new bootstrap.Alert(alertDiv); + bsAlert.close(); + }, 3000); + + // Re-enable save button + saveBtn.disabled = false; + saveBtn.innerHTML = originalBtnText; + }) + .catch(error => { + console.error('Error saving settings:', error); + + // Show error message + const settingsForm = document.getElementById('settings-form'); + const alertDiv = document.createElement('div'); + alertDiv.className = 'alert alert-danger alert-dismissible fade show mt-3'; + alertDiv.role = 'alert'; + alertDiv.innerHTML = ` + Error! ${error.message} + + `; + settingsForm.appendChild(alertDiv); + + // Re-enable save button + saveBtn.disabled = false; + saveBtn.innerHTML = originalBtnText; + }); +} + +// Helper functions +function formatDate(date) { + if (!date) return 'N/A'; + + // Format as "YYYY-MM-DD HH:MM" + return `${date.getFullYear()}-${padZero(date.getMonth() + 1)}-${padZero(date.getDate())} ${padZero(date.getHours())}:${padZero(date.getMinutes())}`; +} + +function padZero(num) { + return num.toString().padStart(2, '0'); +} + +// Dark Mode Functions +function initDarkMode() { + // Check local storage preference or system preference + const darkModePreference = localStorage.getItem('darkMode'); + + if (darkModePreference === 'true' || + (darkModePreference === null && window.matchMedia('(prefers-color-scheme: dark)').matches)) { + enableDarkMode(); + } else { + disableDarkMode(); + } +} + +function toggleDarkMode() { + if (document.body.classList.contains('dark-mode')) { + disableDarkMode(); + } else { + enableDarkMode(); + } +} + +function enableDarkMode() { + document.body.classList.add('dark-mode'); + document.getElementById('dark-mode-toggle').innerHTML = ''; + localStorage.setItem('darkMode', 'true'); + + // Also update user preferences if on settings page + const darkModeCheckbox = document.getElementById('enable-dark-mode'); + if (darkModeCheckbox) { + darkModeCheckbox.checked = true; + } + + // Update active nav link styling + updateActiveNavStyles(); +} + +function disableDarkMode() { + document.body.classList.remove('dark-mode'); + document.getElementById('dark-mode-toggle').innerHTML = ''; + localStorage.setItem('darkMode', 'false'); + + // Also update user preferences if on settings page + const darkModeCheckbox = document.getElementById('enable-dark-mode'); + if (darkModeCheckbox) { + darkModeCheckbox.checked = false; + } + + // Reset active nav styling (let the default styles apply) + const activeNav = document.querySelector('.navbar-nav .nav-link.active'); + if (activeNav) { + activeNav.style.backgroundColor = ''; + activeNav.style.color = ''; + activeNav.style.fontWeight = ''; + } +} + +// Auto-refresh +function initAutoRefresh() { + // Get auto-refresh settings from local storage or use defaults + const autoRefresh = localStorage.getItem('autoRefresh') !== 'false'; + const refreshInterval = parseInt(localStorage.getItem('refreshInterval')) || 30; + + if (autoRefresh) { + startAutoRefresh(refreshInterval); + } +} + +function startAutoRefresh(intervalSeconds) { + // Clear any existing interval + if (window.refreshTimer) { + clearInterval(window.refreshTimer); + } + + // Set new interval + window.refreshTimer = setInterval(() => { + const currentPage = window.location.hash.substring(1) || 'dashboard'; + loadPageData(currentPage); + }, intervalSeconds * 1000); + + localStorage.setItem('autoRefresh', 'true'); + localStorage.setItem('refreshInterval', intervalSeconds.toString()); +} + +function stopAutoRefresh() { + if (window.refreshTimer) { + clearInterval(window.refreshTimer); + window.refreshTimer = null; + } + + localStorage.setItem('autoRefresh', 'false'); +} + +// Chart functions +function loadDownloadHistoryChart() { + fetch('/api/dashboard/history') + .then(response => response.json()) + .then(history => { + const ctx = document.getElementById('download-history-chart').getContext('2d'); + + // Extract dates and count values + const labels = history.map(point => { + const date = new Date(point.date); + return `${date.getMonth() + 1}/${date.getDate()}`; + }); + + const countData = history.map(point => point.count); + const sizeData = history.map(point => point.totalSize / (1024 * 1024 * 1024)); // Convert to GB + + // Create or update chart + if (window.downloadHistoryChart) { + window.downloadHistoryChart.data.labels = labels; + window.downloadHistoryChart.data.datasets[0].data = countData; + window.downloadHistoryChart.data.datasets[1].data = sizeData; + window.downloadHistoryChart.update(); + } else { + window.downloadHistoryChart = new Chart(ctx, { + type: 'bar', + data: { + labels: labels, + datasets: [ + { + label: 'Number of Downloads', + data: countData, + backgroundColor: 'rgba(13, 110, 253, 0.5)', + borderColor: 'rgba(13, 110, 253, 1)', + borderWidth: 1, + yAxisID: 'y' + }, + { + label: 'Total Size (GB)', + data: sizeData, + type: 'line', + borderColor: 'rgba(25, 135, 84, 1)', + backgroundColor: 'rgba(25, 135, 84, 0.1)', + borderWidth: 2, + fill: true, + tension: 0.4, + yAxisID: 'y1' + } + ] + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + position: 'top', + }, + tooltip: { + callbacks: { + label: function(context) { + let label = context.dataset.label || ''; + if (label) { + label += ': '; + } + if (context.datasetIndex === 0) { + label += context.parsed.y; + } else { + label += context.parsed.y.toFixed(2) + ' GB'; + } + return label; + } + } + } + }, + scales: { + y: { + type: 'linear', + display: true, + position: 'left', + title: { + display: true, + text: 'Number of Downloads' + }, + beginAtZero: true + }, + y1: { + type: 'linear', + display: true, + position: 'right', + title: { + display: true, + text: 'Total Size (GB)' + }, + beginAtZero: true, + grid: { + drawOnChartArea: false + } + } + } + } + }); + } + }) + .catch(error => { + console.error('Error loading download history chart:', error); + }); +} + +// Logs Management +function refreshLogs() { + const logFilters = getLogFilters(); + loadLogs(logFilters); +} + +function getLogFilters() { + return { + level: document.getElementById('log-level').value, + search: document.getElementById('log-search').value, + dateRange: document.getElementById('log-date-range').value, + skip: 0, + take: parseInt(document.getElementById('items-per-page')?.value || 25) + }; +} + +function loadLogs(filters) { + const tbody = document.getElementById('logs-table-body'); + tbody.innerHTML = 'Loading logs...'; + + // Build query string + const query = new URLSearchParams(); + if (filters.level && filters.level !== 'All') { + query.append('Level', filters.level); + } + if (filters.search) { + query.append('Search', filters.search); + } + + // Handle date range + const now = new Date(); + let startDate = null; + + switch (filters.dateRange) { + case 'today': + startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0); + break; + case 'yesterday': + startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1, 0, 0, 0); + break; + case 'week': + startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 7, 0, 0, 0); + break; + case 'month': + startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 30, 0, 0, 0); + break; + } + + if (startDate) { + query.append('StartDate', startDate.toISOString()); + } + + query.append('Skip', filters.skip.toString()); + query.append('Take', filters.take.toString()); + + fetch(`/api/logs?${query.toString()}`) + .then(response => response.json()) + .then(logs => { + if (logs.length === 0) { + tbody.innerHTML = 'No logs found'; + document.getElementById('log-count').textContent = '0 entries'; + document.getElementById('logs-pagination-info').textContent = 'Showing 0 of 0 entries'; + return; + } + + let html = ''; + logs.forEach(log => { + const timestamp = new Date(log.timestamp); + const levelClass = getLevelClass(log.level); + + html += ` + ${formatDate(timestamp)} + ${log.level} + ${log.message} + ${log.context || ''} + `; + }); + + tbody.innerHTML = html; + document.getElementById('log-count').textContent = `${logs.length} entries`; + document.getElementById('logs-pagination-info').textContent = `Showing ${logs.length} entries`; + + // Update pagination (simplified for now) + updateLogPagination(filters, logs.length); + }) + .catch(error => { + console.error('Error loading logs:', error); + tbody.innerHTML = 'Error loading logs'; + }); +} + +function updateLogPagination(filters, count) { + const pagination = document.getElementById('logs-pagination'); + + // Simplified pagination - just first page for now + pagination.innerHTML = ` +
  • + 1 +
  • + `; +} + +function getLevelClass(level) { + switch (level.toLowerCase()) { + case 'debug': + return 'bg-secondary'; + case 'information': + return 'bg-info'; + case 'warning': + return 'bg-warning'; + case 'error': + return 'bg-danger'; + case 'critical': + return 'bg-dark'; + default: + return 'bg-secondary'; + } +} + +function applyLogFilters() { + const filters = getLogFilters(); + loadLogs(filters); +} + +function resetLogFilters() { + document.getElementById('log-level').value = 'All'; + document.getElementById('log-search').value = ''; + document.getElementById('log-date-range').value = 'week'; + loadLogs(getLogFilters()); +} + +function clearLogs() { + if (!confirm('Are you sure you want to clear all logs? This action cannot be undone.')) { + return; + } + + fetch('/api/logs/clear', { + method: 'POST' + }) + .then(response => { + if (!response.ok) { + throw new Error('Failed to clear logs'); + } + + // Refresh logs + loadLogs(getLogFilters()); + }) + .catch(error => { + console.error('Error clearing logs:', error); + alert('Error clearing logs'); + }); +} + +function exportLogs() { + const filters = getLogFilters(); + const query = new URLSearchParams(); + + if (filters.level && filters.level !== 'All') { + query.append('Level', filters.level); + } + if (filters.search) { + query.append('Search', filters.search); + } + + // Create download link + const link = document.createElement('a'); + link.href = `/api/logs/export?${query.toString()}`; + link.download = `transmission-rss-logs-${new Date().toISOString().slice(0, 10)}.csv`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); +} + +// Helper function to format file sizes +function formatBytes(bytes, decimals = 2) { + if (bytes === 0) return '0 B'; + + const k = 1024; + const dm = decimals < 0 ? 0 : decimals; + const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; + + const i = Math.floor(Math.log(bytes) / Math.log(k)); + + return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]; +} + +// Configuration Operations +function backupConfig() { + if (!confirm('This will create a backup of your configuration files. Do you want to continue?')) { + return; + } + + fetch('/api/config/backup', { + method: 'POST' + }) + .then(response => { + if (!response.ok) { + throw new Error('Failed to backup configuration'); + } + return response.blob(); + }) + .then(blob => { + // Create download link for the backup file + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.style.display = 'none'; + a.href = url; + a.download = `transmission-rss-config-backup-${new Date().toISOString().slice(0, 10)}.json`; + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); + alert('Configuration backup created successfully.'); + }) + .catch(error => { + console.error('Error backing up configuration:', error); + alert('Error creating configuration backup.'); + }); +} + +function resetConfig() { + if (!confirm('WARNING: This will reset your configuration to default settings. All your feeds, rules, and user preferences will be lost. This cannot be undone. Are you absolutely sure?')) { + return; + } + + if (!confirm('FINAL WARNING: All feeds, rules, and settings will be permanently deleted. Type "RESET" to confirm.')) { + return; + } + + const confirmation = prompt('Type "RESET" to confirm configuration reset:'); + if (confirmation !== 'RESET') { + alert('Configuration reset cancelled.'); + return; + } + + fetch('/api/config/reset', { + method: 'POST' + }) + .then(response => { + if (!response.ok) { + throw new Error('Failed to reset configuration'); + } + + alert('Configuration has been reset to defaults. The application will now reload.'); + window.location.reload(); + }) + .catch(error => { + console.error('Error resetting configuration:', error); + alert('Error resetting configuration.'); + }); +} + +// Transmission Instance Management +function addTransmissionInstance() { + const instancesList = document.getElementById('transmission-instances-list'); + const instanceCount = document.querySelectorAll('.transmission-instance').length; + const newInstanceIndex = instanceCount + 1; + + const instanceHtml = ` +
    +
    +
    +
    Instance #${newInstanceIndex}
    + +
    +
    +
    +
    + + +
    +
    +
    +
    + + +
    +
    +
    +
    +
    +
    + + +
    +
    +
    +
    + + +
    +
    +
    +
    +
    +
    + + +
    +
    +
    +
    + + +
    +
    +
    +
    +
    `; + + // If the "no instances" message is showing, remove it + if (instancesList.querySelector('.text-center.text-muted')) { + instancesList.innerHTML = ''; + } + + // Add the new instance + instancesList.insertAdjacentHTML('beforeend', instanceHtml); +} + +function removeTransmissionInstance(index) { + const instance = document.getElementById(`transmission-instance-${index}`); + if (instance) { + instance.remove(); + + // If there are no instances left, show the "no instances" message + const instancesList = document.getElementById('transmission-instances-list'); + if (instancesList.children.length === 0) { + instancesList.innerHTML = '
    No additional instances configured
    '; + } + } +} + +function resetSettings() { + if (!confirm('This will reset all settings to their default values. Are you sure?')) { + return; + } + + // Load default settings + fetch('/api/config/defaults') + .then(response => response.json()) + .then(defaults => { + // Apply defaults to form + loadSettingsIntoForm(defaults); + }) + .catch(error => { + console.error('Error loading default settings:', error); + alert('Error loading default settings'); + }); +} \ No newline at end of file