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 <noreply@anthropic.com>
This commit is contained in:
Claude 2025-03-13 17:16:41 +00:00
commit 9e544456db
66 changed files with 20187 additions and 0 deletions

28
.gitignore vendored Normal file
View File

@ -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/

View File

@ -0,0 +1,332 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<bool>("Enabled")
.HasColumnType("boolean");
b.Property<DateTime>("LastCheckedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("LastError")
.HasColumnType("text");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<int>("RefreshInterval")
.HasColumnType("integer");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Url")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("RssFeeds");
});
modelBuilder.Entity("TransmissionRssManager.Data.Models.RssFeedItem", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Description")
.HasColumnType("text");
b.Property<DateTime>("DiscoveredAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("DownloadError")
.HasColumnType("text");
b.Property<DateTime?>("DownloadedAt")
.HasColumnType("timestamp with time zone");
b.Property<bool>("IsDownloaded")
.HasColumnType("boolean");
b.Property<string>("Link")
.IsRequired()
.HasColumnType("text");
b.Property<int?>("MatchedRuleId")
.HasColumnType("integer");
b.Property<DateTime>("PublishDate")
.HasColumnType("timestamp with time zone");
b.Property<int>("RssFeedId")
.HasColumnType("integer");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("text");
b.Property<int?>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("CustomSavePath")
.HasColumnType("text");
b.Property<bool>("EnablePostProcessing")
.HasColumnType("boolean");
b.Property<bool>("Enabled")
.HasColumnType("boolean");
b.Property<string>("ExcludePattern")
.HasColumnType("text");
b.Property<string>("IncludePattern")
.HasColumnType("text");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<int>("Priority")
.HasColumnType("integer");
b.Property<int>("RssFeedId")
.HasColumnType("integer");
b.Property<bool>("SaveToCustomPath")
.HasColumnType("boolean");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<bool>("UseRegex")
.HasColumnType("boolean");
b.HasKey("Id");
b.HasIndex("RssFeedId");
b.ToTable("RssFeedRules");
});
modelBuilder.Entity("TransmissionRssManager.Data.Models.Torrent", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime>("AddedOn")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("CompletedOn")
.HasColumnType("timestamp with time zone");
b.Property<string>("DownloadDirectory")
.HasColumnType("text");
b.Property<string>("ErrorMessage")
.HasColumnType("text");
b.Property<string>("Hash")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<double>("PercentDone")
.HasColumnType("double precision");
b.Property<bool>("PostProcessed")
.HasColumnType("boolean");
b.Property<DateTime?>("PostProcessedOn")
.HasColumnType("timestamp with time zone");
b.Property<int?>("RssFeedItemId")
.HasColumnType("integer");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("text");
b.Property<long>("TotalSize")
.HasColumnType("bigint");
b.Property<int?>("TransmissionId")
.HasColumnType("integer");
b.Property<double>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Category")
.HasColumnType("text");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("DataType")
.HasColumnType("text");
b.Property<string>("Description")
.HasColumnType("text");
b.Property<string>("Key")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("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
}
}
}

View File

@ -0,0 +1,205 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace TransmissionRssManager.Migrations
{
/// <inheritdoc />
public partial class InitialCreate : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "RssFeeds",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
Name = table.Column<string>(type: "text", nullable: false),
Url = table.Column<string>(type: "text", nullable: false),
Enabled = table.Column<bool>(type: "boolean", nullable: false),
LastCheckedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
LastError = table.Column<string>(type: "text", nullable: true),
RefreshInterval = table.Column<int>(type: "integer", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTime>(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<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
Key = table.Column<string>(type: "text", nullable: false),
Value = table.Column<string>(type: "text", nullable: true),
Description = table.Column<string>(type: "text", nullable: true),
Category = table.Column<string>(type: "text", nullable: true),
DataType = table.Column<string>(type: "text", nullable: true),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTime>(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<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
Name = table.Column<string>(type: "text", nullable: false),
RssFeedId = table.Column<int>(type: "integer", nullable: false),
IncludePattern = table.Column<string>(type: "text", nullable: true),
ExcludePattern = table.Column<string>(type: "text", nullable: true),
UseRegex = table.Column<bool>(type: "boolean", nullable: false),
Enabled = table.Column<bool>(type: "boolean", nullable: false),
SaveToCustomPath = table.Column<bool>(type: "boolean", nullable: false),
CustomSavePath = table.Column<string>(type: "text", nullable: true),
EnablePostProcessing = table.Column<bool>(type: "boolean", nullable: false),
Priority = table.Column<int>(type: "integer", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTime>(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<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
Title = table.Column<string>(type: "text", nullable: false),
Link = table.Column<string>(type: "text", nullable: false),
Description = table.Column<string>(type: "text", nullable: true),
PublishDate = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
RssFeedId = table.Column<int>(type: "integer", nullable: false),
IsDownloaded = table.Column<bool>(type: "boolean", nullable: false),
DiscoveredAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
DownloadedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
TorrentId = table.Column<int>(type: "integer", nullable: true),
MatchedRuleId = table.Column<int>(type: "integer", nullable: true),
DownloadError = table.Column<string>(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<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
Name = table.Column<string>(type: "text", nullable: false),
Hash = table.Column<string>(type: "text", nullable: false),
TransmissionId = table.Column<int>(type: "integer", nullable: true),
Status = table.Column<string>(type: "text", nullable: false),
TotalSize = table.Column<long>(type: "bigint", nullable: false),
PercentDone = table.Column<double>(type: "double precision", nullable: false),
UploadRatio = table.Column<double>(type: "double precision", nullable: false),
RssFeedItemId = table.Column<int>(type: "integer", nullable: true),
AddedOn = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
CompletedOn = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
PostProcessed = table.Column<bool>(type: "boolean", nullable: false),
PostProcessedOn = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
DownloadDirectory = table.Column<string>(type: "text", nullable: true),
ErrorMessage = table.Column<string>(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);
}
/// <inheritdoc />
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");
}
}
}

View File

@ -0,0 +1,402 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("DefaultCategory")
.HasColumnType("text");
b.Property<bool>("Enabled")
.HasColumnType("boolean");
b.Property<int>("ErrorCount")
.HasColumnType("integer");
b.Property<DateTime>("LastCheckedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("LastError")
.HasColumnType("text");
b.Property<int>("MaxHistoryItems")
.HasColumnType("integer");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<int>("RefreshInterval")
.HasColumnType("integer");
b.Property<string>("Schedule")
.IsRequired()
.HasColumnType("text");
b.Property<string>("TransmissionInstanceId")
.HasColumnType("text");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Url")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("RssFeeds");
});
modelBuilder.Entity("TransmissionRssManager.Data.Models.RssFeedItem", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Description")
.HasColumnType("text");
b.Property<DateTime>("DiscoveredAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("DownloadError")
.HasColumnType("text");
b.Property<DateTime?>("DownloadedAt")
.HasColumnType("timestamp with time zone");
b.Property<bool>("IsDownloaded")
.HasColumnType("boolean");
b.Property<string>("Link")
.IsRequired()
.HasColumnType("text");
b.Property<int?>("MatchedRuleId")
.HasColumnType("integer");
b.Property<DateTime>("PublishDate")
.HasColumnType("timestamp with time zone");
b.Property<int>("RssFeedId")
.HasColumnType("integer");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("text");
b.Property<int?>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("CustomSavePath")
.HasColumnType("text");
b.Property<bool>("EnablePostProcessing")
.HasColumnType("boolean");
b.Property<bool>("Enabled")
.HasColumnType("boolean");
b.Property<string>("ExcludePattern")
.HasColumnType("text");
b.Property<string>("IncludePattern")
.HasColumnType("text");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<int>("Priority")
.HasColumnType("integer");
b.Property<int>("RssFeedId")
.HasColumnType("integer");
b.Property<bool>("SaveToCustomPath")
.HasColumnType("boolean");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<bool>("UseRegex")
.HasColumnType("boolean");
b.HasKey("Id");
b.HasIndex("RssFeedId");
b.ToTable("RssFeedRules");
});
modelBuilder.Entity("TransmissionRssManager.Data.Models.SystemLogEntry", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Context")
.HasColumnType("text");
b.Property<string>("Level")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Message")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Properties")
.HasColumnType("text");
b.Property<DateTime>("Timestamp")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.ToTable("SystemLogs");
});
modelBuilder.Entity("TransmissionRssManager.Data.Models.Torrent", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime>("AddedOn")
.HasColumnType("timestamp with time zone");
b.Property<string>("Category")
.HasColumnType("text");
b.Property<DateTime?>("CompletedOn")
.HasColumnType("timestamp with time zone");
b.Property<string>("DownloadDirectory")
.HasColumnType("text");
b.Property<double>("DownloadSpeed")
.HasColumnType("double precision");
b.Property<long>("DownloadedEver")
.HasColumnType("bigint");
b.Property<string>("ErrorMessage")
.HasColumnType("text");
b.Property<bool>("HasMetadata")
.HasColumnType("boolean");
b.Property<string>("Hash")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<int>("PeersConnected")
.HasColumnType("integer");
b.Property<double>("PercentDone")
.HasColumnType("double precision");
b.Property<bool>("PostProcessed")
.HasColumnType("boolean");
b.Property<DateTime?>("PostProcessedOn")
.HasColumnType("timestamp with time zone");
b.Property<int?>("RssFeedItemId")
.HasColumnType("integer");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("text");
b.Property<long>("TotalSize")
.HasColumnType("bigint");
b.Property<int?>("TransmissionId")
.HasColumnType("integer");
b.Property<string>("TransmissionInstance")
.HasColumnType("text");
b.Property<double>("UploadRatio")
.HasColumnType("double precision");
b.Property<double>("UploadSpeed")
.HasColumnType("double precision");
b.Property<long>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Category")
.HasColumnType("text");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("DataType")
.HasColumnType("text");
b.Property<string>("Description")
.HasColumnType("text");
b.Property<string>("Key")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("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
}
}
}

View File

@ -0,0 +1,179 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace TransmissionRssManager.Migrations
{
/// <inheritdoc />
public partial class AddUIFeatures : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Category",
table: "Torrents",
type: "text",
nullable: true);
migrationBuilder.AddColumn<double>(
name: "DownloadSpeed",
table: "Torrents",
type: "double precision",
nullable: false,
defaultValue: 0.0);
migrationBuilder.AddColumn<long>(
name: "DownloadedEver",
table: "Torrents",
type: "bigint",
nullable: false,
defaultValue: 0L);
migrationBuilder.AddColumn<bool>(
name: "HasMetadata",
table: "Torrents",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<int>(
name: "PeersConnected",
table: "Torrents",
type: "integer",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<string>(
name: "TransmissionInstance",
table: "Torrents",
type: "text",
nullable: true);
migrationBuilder.AddColumn<double>(
name: "UploadSpeed",
table: "Torrents",
type: "double precision",
nullable: false,
defaultValue: 0.0);
migrationBuilder.AddColumn<long>(
name: "UploadedEver",
table: "Torrents",
type: "bigint",
nullable: false,
defaultValue: 0L);
migrationBuilder.AddColumn<string>(
name: "DefaultCategory",
table: "RssFeeds",
type: "text",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "ErrorCount",
table: "RssFeeds",
type: "integer",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<int>(
name: "MaxHistoryItems",
table: "RssFeeds",
type: "integer",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<string>(
name: "Schedule",
table: "RssFeeds",
type: "text",
nullable: false,
defaultValue: "");
migrationBuilder.AddColumn<string>(
name: "TransmissionInstanceId",
table: "RssFeeds",
type: "text",
nullable: true);
migrationBuilder.CreateTable(
name: "SystemLogs",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
Timestamp = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
Level = table.Column<string>(type: "text", nullable: false),
Message = table.Column<string>(type: "text", nullable: false),
Context = table.Column<string>(type: "text", nullable: true),
Properties = table.Column<string>(type: "text", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_SystemLogs", x => x.Id);
});
}
/// <inheritdoc />
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");
}
}
}

View File

@ -0,0 +1,399 @@
// <auto-generated />
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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("DefaultCategory")
.HasColumnType("text");
b.Property<bool>("Enabled")
.HasColumnType("boolean");
b.Property<int>("ErrorCount")
.HasColumnType("integer");
b.Property<DateTime>("LastCheckedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("LastError")
.HasColumnType("text");
b.Property<int>("MaxHistoryItems")
.HasColumnType("integer");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<int>("RefreshInterval")
.HasColumnType("integer");
b.Property<string>("Schedule")
.IsRequired()
.HasColumnType("text");
b.Property<string>("TransmissionInstanceId")
.HasColumnType("text");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Url")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("RssFeeds");
});
modelBuilder.Entity("TransmissionRssManager.Data.Models.RssFeedItem", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Description")
.HasColumnType("text");
b.Property<DateTime>("DiscoveredAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("DownloadError")
.HasColumnType("text");
b.Property<DateTime?>("DownloadedAt")
.HasColumnType("timestamp with time zone");
b.Property<bool>("IsDownloaded")
.HasColumnType("boolean");
b.Property<string>("Link")
.IsRequired()
.HasColumnType("text");
b.Property<int?>("MatchedRuleId")
.HasColumnType("integer");
b.Property<DateTime>("PublishDate")
.HasColumnType("timestamp with time zone");
b.Property<int>("RssFeedId")
.HasColumnType("integer");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("text");
b.Property<int?>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("CustomSavePath")
.HasColumnType("text");
b.Property<bool>("EnablePostProcessing")
.HasColumnType("boolean");
b.Property<bool>("Enabled")
.HasColumnType("boolean");
b.Property<string>("ExcludePattern")
.HasColumnType("text");
b.Property<string>("IncludePattern")
.HasColumnType("text");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<int>("Priority")
.HasColumnType("integer");
b.Property<int>("RssFeedId")
.HasColumnType("integer");
b.Property<bool>("SaveToCustomPath")
.HasColumnType("boolean");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<bool>("UseRegex")
.HasColumnType("boolean");
b.HasKey("Id");
b.HasIndex("RssFeedId");
b.ToTable("RssFeedRules");
});
modelBuilder.Entity("TransmissionRssManager.Data.Models.SystemLogEntry", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Context")
.HasColumnType("text");
b.Property<string>("Level")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Message")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Properties")
.HasColumnType("text");
b.Property<DateTime>("Timestamp")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.ToTable("SystemLogs");
});
modelBuilder.Entity("TransmissionRssManager.Data.Models.Torrent", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime>("AddedOn")
.HasColumnType("timestamp with time zone");
b.Property<string>("Category")
.HasColumnType("text");
b.Property<DateTime?>("CompletedOn")
.HasColumnType("timestamp with time zone");
b.Property<string>("DownloadDirectory")
.HasColumnType("text");
b.Property<double>("DownloadSpeed")
.HasColumnType("double precision");
b.Property<long>("DownloadedEver")
.HasColumnType("bigint");
b.Property<string>("ErrorMessage")
.HasColumnType("text");
b.Property<bool>("HasMetadata")
.HasColumnType("boolean");
b.Property<string>("Hash")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<int>("PeersConnected")
.HasColumnType("integer");
b.Property<double>("PercentDone")
.HasColumnType("double precision");
b.Property<bool>("PostProcessed")
.HasColumnType("boolean");
b.Property<DateTime?>("PostProcessedOn")
.HasColumnType("timestamp with time zone");
b.Property<int?>("RssFeedItemId")
.HasColumnType("integer");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("text");
b.Property<long>("TotalSize")
.HasColumnType("bigint");
b.Property<int?>("TransmissionId")
.HasColumnType("integer");
b.Property<string>("TransmissionInstance")
.HasColumnType("text");
b.Property<double>("UploadRatio")
.HasColumnType("double precision");
b.Property<double>("UploadSpeed")
.HasColumnType("double precision");
b.Property<long>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Category")
.HasColumnType("text");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("DataType")
.HasColumnType("text");
b.Property<string>("Description")
.HasColumnType("text");
b.Property<string>("Key")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("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
}
}
}

64
README.md Normal file
View File

@ -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.

View File

@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net7.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>

9
appsettings.json Normal file
View File

@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}

View File

@ -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
}
}

View File

@ -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");
}
}
}
}

View File

@ -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");
}
}
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js">
<link rel="stylesheet" href="css/styles.css">
<link rel="stylesheet" href="css/dark-mode.css">
</head>
<body>
<nav class="navbar navbar-expand-lg">
<div class="container">
<a class="navbar-brand" href="#"><i class="bi bi-rss-fill me-2"></i>Transmission RSS Manager</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav me-auto">
<li class="nav-item"><a class="nav-link" href="#" data-page="dashboard"><i class="bi bi-speedometer2 me-1"></i>Dashboard</a></li>
<li class="nav-item"><a class="nav-link" href="#" data-page="feeds"><i class="bi bi-rss me-1"></i>RSS Feeds</a></li>
<li class="nav-item"><a class="nav-link" href="#" data-page="torrents"><i class="bi bi-cloud-download me-1"></i>Torrents</a></li>
<li class="nav-item"><a class="nav-link" href="#" data-page="logs"><i class="bi bi-journal-text me-1"></i>Logs</a></li>
<li class="nav-item"><a class="nav-link" href="#" data-page="settings"><i class="bi bi-gear me-1"></i>Settings</a></li>
</ul>
<div class="d-flex align-items-center">
<button id="dark-mode-toggle" class="btn dark-mode-toggle" title="Toggle Dark Mode">
<i class="bi bi-moon-fill"></i>
</button>
<span class="ms-2 me-2 app-version">v1.0.0</span>
</div>
</div>
</div>
</nav>
<!-- Toast Container for Notifications -->
<div class="toast-container"></div>
<div class="container mt-4">
<div id="page-dashboard" class="page-content">
<h2 class="mb-4"><i class="bi bi-speedometer2 me-2"></i>Dashboard</h2>
<!-- Dashboard Stats Cards -->
<div class="dashboard-stats mb-4">
<div class="stat-card">
<i class="bi bi-cloud-download text-primary mb-2" style="font-size: 2rem;"></i>
<div class="stat-value" id="active-downloads">-</div>
<div class="stat-label">Active Downloads</div>
</div>
<div class="stat-card">
<i class="bi bi-cloud-upload text-success mb-2" style="font-size: 2rem;"></i>
<div class="stat-value" id="seeding-torrents">-</div>
<div class="stat-label">Seeding Torrents</div>
</div>
<div class="stat-card">
<i class="bi bi-rss text-info mb-2" style="font-size: 2rem;"></i>
<div class="stat-value" id="active-feeds">-</div>
<div class="stat-label">Active Feeds</div>
</div>
<div class="stat-card">
<i class="bi bi-check2-circle text-success mb-2" style="font-size: 2rem;"></i>
<div class="stat-value" id="completed-today">-</div>
<div class="stat-label">Completed Today</div>
</div>
</div>
<!-- Download and Upload Speed -->
<div class="row mb-4">
<div class="col-md-6">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<span><i class="bi bi-arrow-down-up me-2"></i>Download/Upload Speed</span>
<span class="badge bg-primary" id="current-speed">-</span>
</div>
<div class="card-body">
<div class="d-flex justify-content-between mb-2">
<div>
<span class="text-primary"><i class="bi bi-arrow-down me-1"></i>Download:</span>
<span id="download-speed">0 KB/s</span>
</div>
<div>
<span class="text-success"><i class="bi bi-arrow-up me-1"></i>Upload:</span>
<span id="upload-speed">0 KB/s</span>
</div>
</div>
<div class="progress mb-3">
<div id="download-speed-bar" class="progress-bar bg-primary" style="width: 0%"></div>
</div>
<div class="progress">
<div id="upload-speed-bar" class="progress-bar bg-success" style="width: 0%"></div>
</div>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card">
<div class="card-header">
<i class="bi bi-clock-history me-2"></i>Activity Summary
</div>
<div class="card-body">
<ul class="list-group list-group-flush">
<li class="list-group-item d-flex justify-content-between align-items-center">
Added Today
<span class="badge bg-primary" id="added-today">-</span>
</li>
<li class="list-group-item d-flex justify-content-between align-items-center">
Completed Today
<span class="badge bg-success" id="finished-today">-</span>
</li>
<li class="list-group-item d-flex justify-content-between align-items-center">
Active RSS Feeds
<span class="badge bg-info" id="feeds-count">-</span>
</li>
<li class="list-group-item d-flex justify-content-between align-items-center">
Matched Items
<span class="badge bg-warning" id="matched-count">-</span>
</li>
</ul>
</div>
</div>
</div>
</div>
<!-- Download History Chart -->
<div class="row mb-4">
<div class="col-md-12">
<div class="card">
<div class="card-header">
<i class="bi bi-graph-up me-2"></i>Download History (Last 30 Days)
</div>
<div class="card-body">
<div class="chart-container">
<canvas id="download-history-chart"></canvas>
</div>
</div>
</div>
</div>
</div>
<!-- Active Torrents and Recent Matches -->
<div class="row">
<div class="col-lg-7">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<span><i class="bi bi-cloud-download me-2"></i>Active Torrents</span>
<a href="#" data-page="torrents" class="btn btn-sm btn-outline-primary">View All</a>
</div>
<div class="card-body">
<div id="active-torrents-list">Loading...</div>
</div>
</div>
</div>
<div class="col-lg-5">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<span><i class="bi bi-lightning-charge me-2"></i>Recent Matches</span>
<a href="#" data-page="feeds" class="btn btn-sm btn-outline-primary">View All</a>
</div>
<div class="card-body">
<div id="recent-matches-list">Loading...</div>
</div>
</div>
</div>
</div>
</div>
<div id="page-feeds" class="page-content d-none">
<h2>RSS Feeds</h2>
<div class="mb-3">
<button id="btn-add-feed" class="btn btn-primary">Add Feed</button>
<button id="btn-refresh-feeds" class="btn btn-secondary">Refresh Feeds</button>
</div>
<div id="feeds-list">Loading...</div>
<div class="mt-4">
<h3>Feed Items</h3>
<ul class="nav nav-tabs" id="feedTabs">
<li class="nav-item">
<a class="nav-link active" data-bs-toggle="tab" href="#all-items">All Items</a>
</li>
<li class="nav-item">
<a class="nav-link" data-bs-toggle="tab" href="#matched-items">Matched Items</a>
</li>
</ul>
<div class="tab-content mt-2">
<div class="tab-pane fade show active" id="all-items">
<div id="all-items-list">Loading...</div>
</div>
<div class="tab-pane fade" id="matched-items">
<div id="matched-items-list">Loading...</div>
</div>
</div>
</div>
</div>
<div id="page-torrents" class="page-content d-none">
<h2>Torrents</h2>
<div class="mb-3">
<button id="btn-add-torrent" class="btn btn-primary">Add Torrent</button>
<button id="btn-refresh-torrents" class="btn btn-secondary">Refresh Torrents</button>
</div>
<div id="torrents-list">Loading...</div>
</div>
<div id="page-logs" class="page-content d-none">
<h2 class="mb-4"><i class="bi bi-journal-text me-2"></i>System Logs</h2>
<div class="card mb-4">
<div class="card-header d-flex justify-content-between align-items-center">
<span><i class="bi bi-funnel me-2"></i>Log Filters</span>
<div>
<button class="btn btn-sm btn-outline-secondary" id="btn-refresh-logs">
<i class="bi bi-arrow-clockwise me-1"></i>Refresh
</button>
<button class="btn btn-sm btn-outline-danger ms-2" id="btn-clear-logs">
<i class="bi bi-trash me-1"></i>Clear Logs
</button>
</div>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-4">
<div class="mb-3">
<label for="log-level" class="form-label">Log Level</label>
<select class="form-select" id="log-level">
<option value="All">All Levels</option>
<option value="Debug">Debug</option>
<option value="Information">Information</option>
<option value="Warning">Warning</option>
<option value="Error">Error</option>
</select>
</div>
</div>
<div class="col-md-4">
<div class="mb-3">
<label for="log-search" class="form-label">Search</label>
<input type="text" class="form-control" id="log-search" placeholder="Search logs...">
</div>
</div>
<div class="col-md-4">
<div class="mb-3">
<label for="log-date-range" class="form-label">Date Range</label>
<select class="form-select" id="log-date-range">
<option value="today">Today</option>
<option value="yesterday">Yesterday</option>
<option value="week" selected>Last 7 days</option>
<option value="month">Last 30 days</option>
<option value="all">All time</option>
</select>
</div>
</div>
</div>
<div class="row">
<div class="col-12">
<div class="d-flex justify-content-between mb-2">
<div>
<button class="btn btn-sm btn-outline-primary" id="btn-apply-log-filters">
<i class="bi bi-funnel-fill me-1"></i>Apply Filters
</button>
<button class="btn btn-sm btn-outline-secondary ms-2" id="btn-reset-log-filters">
<i class="bi bi-x-circle me-1"></i>Reset Filters
</button>
</div>
<div>
<button class="btn btn-sm btn-outline-primary" id="btn-export-logs">
<i class="bi bi-download me-1"></i>Export Logs
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<span><i class="bi bi-list-ul me-2"></i>Log Entries</span>
<span class="badge bg-secondary" id="log-count">0 entries</span>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead>
<tr>
<th style="width: 180px;">Timestamp</th>
<th style="width: 100px;">Level</th>
<th>Message</th>
<th style="width: 120px;">Context</th>
</tr>
</thead>
<tbody id="logs-table-body">
<tr>
<td colspan="4" class="text-center py-4">Loading logs...</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="card-footer d-flex justify-content-between align-items-center">
<div>
<span id="logs-pagination-info">Showing 0 of 0 entries</span>
</div>
<div>
<nav aria-label="Logs pagination">
<ul class="pagination pagination-sm mb-0" id="logs-pagination">
<li class="page-item disabled">
<a class="page-link" href="#" aria-label="Previous">
<span aria-hidden="true">&laquo;</span>
</a>
</li>
<li class="page-item active"><a class="page-link" href="#">1</a></li>
<li class="page-item disabled">
<a class="page-link" href="#" aria-label="Next">
<span aria-hidden="true">&raquo;</span>
</a>
</li>
</ul>
</nav>
</div>
</div>
</div>
</div>
<div id="page-settings" class="page-content d-none">
<h2 class="mb-4"><i class="bi bi-gear me-2"></i>Settings</h2>
<form id="settings-form">
<ul class="nav nav-tabs mb-4" id="settings-tabs">
<li class="nav-item">
<a class="nav-link active" data-bs-toggle="tab" href="#tab-transmission">
<i class="bi bi-cloud me-1"></i>Transmission
</a>
</li>
<li class="nav-item">
<a class="nav-link" data-bs-toggle="tab" href="#tab-rss">
<i class="bi bi-rss me-1"></i>RSS
</a>
</li>
<li class="nav-item">
<a class="nav-link" data-bs-toggle="tab" href="#tab-processing">
<i class="bi bi-tools me-1"></i>Processing
</a>
</li>
<li class="nav-item">
<a class="nav-link" data-bs-toggle="tab" href="#tab-appearance">
<i class="bi bi-palette me-1"></i>Appearance
</a>
</li>
<li class="nav-item">
<a class="nav-link" data-bs-toggle="tab" href="#tab-advanced">
<i class="bi bi-sliders me-1"></i>Advanced
</a>
</li>
</ul>
<div class="tab-content">
<!-- Transmission Settings Tab -->
<div class="tab-pane fade show active" id="tab-transmission">
<div class="card mb-4">
<div class="card-header">
<i class="bi bi-cloud me-2"></i>Primary Transmission Instance
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label for="transmission-host" class="form-label">Host</label>
<input type="text" class="form-control" id="transmission-host" name="transmission.host">
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label for="transmission-port" class="form-label">Port</label>
<input type="number" class="form-control" id="transmission-port" name="transmission.port">
</div>
</div>
</div>
<div class="row">
<div class="col-md-12">
<div class="mb-3">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="transmission-use-https" name="transmission.useHttps">
<label class="form-check-label" for="transmission-use-https">Use HTTPS</label>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label for="transmission-username" class="form-label">Username</label>
<input type="text" class="form-control" id="transmission-username" name="transmission.username">
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label for="transmission-password" class="form-label">Password</label>
<input type="password" class="form-control" id="transmission-password" name="transmission.password">
</div>
</div>
</div>
</div>
</div>
<!-- Additional Transmission Instances -->
<div class="card mb-4">
<div class="card-header d-flex justify-content-between align-items-center">
<span><i class="bi bi-hdd-stack me-2"></i>Additional Transmission Instances</span>
<button type="button" class="btn btn-sm btn-primary" id="add-transmission-instance">
<i class="bi bi-plus-circle me-1"></i>Add Instance
</button>
</div>
<div class="card-body">
<div id="transmission-instances-list">
<div class="text-center text-muted py-3">No additional instances configured</div>
</div>
</div>
</div>
</div>
<!-- RSS Settings Tab -->
<div class="tab-pane fade" id="tab-rss">
<div class="card mb-4">
<div class="card-header">
<i class="bi bi-rss me-2"></i>RSS General Settings
</div>
<div class="card-body">
<div class="mb-3">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="auto-download-enabled" name="autoDownloadEnabled">
<label class="form-check-label" for="auto-download-enabled">Enable Auto Download</label>
</div>
</div>
<div class="mb-3">
<label for="check-interval" class="form-label">Default Check Interval (minutes)</label>
<input type="number" class="form-control" id="check-interval" name="checkIntervalMinutes">
</div>
<div class="mb-3">
<label for="max-feed-items" class="form-label">Maximum Items per Feed</label>
<input type="number" class="form-control" id="max-feed-items" name="maxFeedItems" value="100">
<div class="form-text">Maximum number of items to keep per feed (for performance)</div>
</div>
</div>
</div>
<div class="card mb-4">
<div class="card-header">
<i class="bi bi-filter-circle me-2"></i>Content Filtering
</div>
<div class="card-body">
<div class="mb-3">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="enable-regex-matching" name="enableRegexMatching">
<label class="form-check-label" for="enable-regex-matching">Enable Regular Expression Matching</label>
</div>
<div class="form-text">When enabled, feed rules can use regular expressions for more advanced matching</div>
</div>
<div class="mb-3">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="case-sensitive-matching" name="caseSensitiveMatching">
<label class="form-check-label" for="case-sensitive-matching">Case Sensitive Matching</label>
</div>
</div>
<div class="mb-3">
<label for="global-exclude-patterns" class="form-label">Global Exclude Patterns (one per line)</label>
<textarea class="form-control" id="global-exclude-patterns" name="globalExcludePatterns" rows="3"></textarea>
<div class="form-text">Items matching these patterns will be ignored regardless of feed rules</div>
</div>
</div>
</div>
</div>
<!-- Processing Tab -->
<div class="tab-pane fade" id="tab-processing">
<div class="card mb-4">
<div class="card-header">
<i class="bi bi-folder me-2"></i>Directories
</div>
<div class="card-body">
<div class="mb-3">
<label for="download-directory" class="form-label">Default Download Directory</label>
<input type="text" class="form-control" id="download-directory" name="downloadDirectory">
</div>
<div class="mb-3">
<label for="media-library" class="form-label">Media Library Path</label>
<input type="text" class="form-control" id="media-library" name="mediaLibraryPath">
</div>
<div class="mb-3">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="create-category-folders" name="createCategoryFolders">
<label class="form-check-label" for="create-category-folders">Create Category Folders</label>
</div>
<div class="form-text">Create subfolders based on feed categories</div>
</div>
</div>
</div>
<div class="card mb-4">
<div class="card-header">
<i class="bi bi-tools me-2"></i>Post Processing
</div>
<div class="card-body">
<div class="mb-3">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="post-processing-enabled" name="postProcessing.enabled">
<label class="form-check-label" for="post-processing-enabled">Enable Post Processing</label>
</div>
</div>
<div class="mb-3">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="extract-archives" name="postProcessing.extractArchives">
<label class="form-check-label" for="extract-archives">Extract Archives</label>
</div>
</div>
<div class="mb-3">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="organize-media" name="postProcessing.organizeMedia">
<label class="form-check-label" for="organize-media">Organize Media</label>
</div>
</div>
<div class="mb-3">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="auto-organize-media-type" name="postProcessing.autoOrganizeByMediaType">
<label class="form-check-label" for="auto-organize-media-type">Auto-organize by Media Type</label>
</div>
</div>
<div class="mb-3">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="rename-files" name="postProcessing.renameFiles">
<label class="form-check-label" for="rename-files">Rename Files</label>
</div>
</div>
<div class="mb-3">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="compress-completed" name="postProcessing.compressCompletedFiles">
<label class="form-check-label" for="compress-completed">Compress Completed Files</label>
</div>
</div>
<div class="mb-3">
<label for="minimum-seed-ratio" class="form-label">Minimum Seed Ratio</label>
<input type="number" class="form-control" id="minimum-seed-ratio" name="postProcessing.minimumSeedRatio">
</div>
<div class="mb-3">
<label for="delete-completed-after" class="form-label">Delete Completed After (days)</label>
<input type="number" class="form-control" id="delete-completed-after" name="postProcessing.deleteCompletedAfterDays" value="0">
<div class="form-text">Number of days after which completed torrents will be removed (0 = never)</div>
</div>
<div class="mb-3">
<label for="media-extensions" class="form-label">Media Extensions (comma separated)</label>
<input type="text" class="form-control" id="media-extensions" name="mediaExtensions">
</div>
</div>
</div>
</div>
<!-- Appearance Tab -->
<div class="tab-pane fade" id="tab-appearance">
<div class="card mb-4">
<div class="card-header">
<i class="bi bi-palette me-2"></i>User Interface
</div>
<div class="card-body">
<div class="mb-3">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="enable-dark-mode" name="userPreferences.enableDarkMode">
<label class="form-check-label" for="enable-dark-mode">Enable Dark Mode</label>
</div>
</div>
<div class="mb-3">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="auto-refresh-ui" name="userPreferences.autoRefreshUIEnabled">
<label class="form-check-label" for="auto-refresh-ui">Auto Refresh UI</label>
</div>
</div>
<div class="mb-3">
<label for="auto-refresh-interval" class="form-label">Auto Refresh Interval (seconds)</label>
<input type="number" class="form-control" id="auto-refresh-interval" name="userPreferences.autoRefreshIntervalSeconds" value="30">
</div>
<div class="mb-3">
<label for="default-view" class="form-label">Default View</label>
<select class="form-select" id="default-view" name="userPreferences.defaultView">
<option value="dashboard">Dashboard</option>
<option value="feeds">RSS Feeds</option>
<option value="torrents">Torrents</option>
<option value="settings">Settings</option>
</select>
</div>
<div class="mb-3">
<label for="items-per-page" class="form-label">Items Per Page</label>
<input type="number" class="form-control" id="items-per-page" name="userPreferences.maxItemsPerPage" value="25">
</div>
<div class="mb-3">
<label for="date-format" class="form-label">Date Format</label>
<input type="text" class="form-control" id="date-format" name="userPreferences.dateTimeFormat" value="yyyy-MM-dd HH:mm:ss">
</div>
</div>
</div>
<div class="card mb-4">
<div class="card-header">
<i class="bi bi-bell me-2"></i>Notifications
</div>
<div class="card-body">
<div class="mb-3">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="enable-notifications" name="userPreferences.notificationsEnabled">
<label class="form-check-label" for="enable-notifications">Enable Notifications</label>
</div>
</div>
<div class="mb-3">
<label class="form-label">Notification Events</label>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="notify-torrent-added" name="notificationEvents" value="torrent-added">
<label class="form-check-label" for="notify-torrent-added">Torrent Added</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="notify-torrent-completed" name="notificationEvents" value="torrent-completed">
<label class="form-check-label" for="notify-torrent-completed">Torrent Completed</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="notify-torrent-error" name="notificationEvents" value="torrent-error">
<label class="form-check-label" for="notify-torrent-error">Torrent Error</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="notify-feed-error" name="notificationEvents" value="feed-error">
<label class="form-check-label" for="notify-feed-error">Feed Error</label>
</div>
</div>
</div>
</div>
</div>
<!-- Advanced Tab -->
<div class="tab-pane fade" id="tab-advanced">
<div class="card mb-4">
<div class="card-header">
<i class="bi bi-sliders me-2"></i>Advanced Settings
</div>
<div class="card-body">
<div class="mb-3">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="detailed-logging" name="enableDetailedLogging">
<label class="form-check-label" for="detailed-logging" id="detailed-logging-label" style="color: inherit !important;">Enable Detailed Logging</label>
</div>
</div>
<div class="mb-3">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="show-completed-torrents" name="userPreferences.showCompletedTorrents">
<label class="form-check-label" for="show-completed-torrents" id="show-completed-torrents-label" style="color: white !important;">Show Completed Torrents</label>
</div>
</div>
<div class="mb-3">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="confirm-delete" name="userPreferences.confirmBeforeDelete">
<label class="form-check-label" for="confirm-delete" id="confirm-delete-label" style="color: white !important;">Confirm Before Delete</label>
</div>
</div>
<div class="mb-3">
<label for="history-days" class="form-label">Keep History (days)</label>
<input type="number" class="form-control" id="history-days" name="userPreferences.keepHistoryDays" value="30">
<div class="form-text">Number of days to keep historical data</div>
</div>
</div>
</div>
<div class="card mb-4">
<div class="card-header">
<i class="bi bi-database me-2"></i>Database
</div>
<div class="card-body">
<div class="alert alert-warning">
<i class="bi bi-exclamation-triangle-fill me-2"></i>Warning: These operations affect your data permanently.
</div>
<div class="d-flex gap-2 mt-3">
<button type="button" class="btn btn-outline-primary" id="btn-backup-db">
<i class="bi bi-download me-1"></i>Backup Database
</button>
<button type="button" class="btn btn-outline-secondary" id="btn-clean-db">
<i class="bi bi-trash me-1"></i>Clean Old Data
</button>
<button type="button" class="btn btn-outline-danger" id="btn-reset-db">
<i class="bi bi-arrow-repeat me-1"></i>Reset Database
</button>
</div>
</div>
</div>
</div>
</div>
<div class="d-flex justify-content-between mt-4">
<button type="button" class="btn btn-outline-secondary" id="btn-reset-settings">Reset to Defaults</button>
<button type="submit" class="btn btn-primary"><i class="bi bi-save me-1"></i>Save Settings</button>
</div>
</form>
</div>
</div>
<!-- Modals -->
<div class="modal fade" id="add-feed-modal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Add RSS Feed</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="add-feed-form">
<div class="mb-3">
<label for="feed-name" class="form-label">Name</label>
<input type="text" class="form-control" id="feed-name" required>
</div>
<div class="mb-3">
<label for="feed-url" class="form-label">URL</label>
<input type="url" class="form-control" id="feed-url" required>
</div>
<div class="mb-3">
<label for="feed-rules" class="form-label">Match Rules (one per line)</label>
<textarea class="form-control" id="feed-rules" rows="5"></textarea>
<div class="form-text">Use regular expressions to match feed items.</div>
</div>
<div class="mb-3">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="feed-auto-download">
<label class="form-check-label" for="feed-auto-download">Auto Download</label>
</div>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="save-feed-btn">Add Feed</button>
</div>
</div>
</div>
</div>
<div class="modal fade" id="add-torrent-modal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Add Torrent</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="add-torrent-form">
<div class="mb-3">
<label for="torrent-url" class="form-label">Torrent URL or Magnet Link</label>
<input type="text" class="form-control" id="torrent-url" required>
</div>
<div class="mb-3">
<label for="torrent-download-dir" class="form-label">Download Directory (optional)</label>
<input type="text" class="form-control" id="torrent-download-dir">
<div class="form-text">Leave empty to use default download directory.</div>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="save-torrent-btn">Add Torrent</button>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/luxon@3.4.4/build/global/luxon.min.js"></script>
<script src="js/app.js"></script>
</body>
</html>

1573
bin/net7.0/wwwroot/js/app.js Normal file

File diff suppressed because it is too large Load Diff

1359
install-script.sh Executable file

File diff suppressed because it is too large Load Diff

75
install.sh Executable file
View File

@ -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

582
main-installer.sh Executable file
View File

@ -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}"

121
modules/config-module.sh Executable file
View File

@ -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
}

85
modules/dependencies-module.sh Executable file
View File

@ -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
}

74
modules/file-creator-module.sh Executable file
View File

@ -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"
}

341
modules/rss-feed-manager.js Normal file
View File

@ -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;

73
modules/service-setup-module.sh Executable file
View File

@ -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
}

View File

@ -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;

91
modules/utils-module.sh Executable file
View File

@ -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
}

44
run-app.sh Executable file
View File

@ -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

View File

@ -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");
}
}
}
}

View File

@ -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" });
}
}
}
}

View File

@ -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 });
}
}
}

View File

@ -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; }
}
}

View File

@ -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;
}
}

121
src/Api/Program.cs Normal file
View File

@ -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");
}

194
src/Core/Interfaces.cs Normal file
View File

@ -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);
}
}

View File

@ -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; }
}
}
}

View File

@ -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; }
}
}

View File

@ -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;
}
}

View File

@ -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; }
}
}

View File

@ -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>();
}
}

View File

@ -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;
}
}

View File

@ -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; }
}
}

View File

@ -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();
}
}

View File

@ -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();
}
}

View File

@ -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;
}
}
}

View File

@ -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();
}
}
}

View File

@ -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);
}
}
}

View File

@ -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);
}
}
}

View File

@ -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}"

View File

@ -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

Binary file not shown.

View File

@ -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");
}
}
}

View File

@ -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");
}
}
}
}

View File

@ -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;
}
}
}

View File

@ -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;
}
}
}

View File

@ -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");
}
}
}

View File

@ -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);
}
}
}
}
}

View File

@ -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");
}
}
}

View File

@ -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; }
}
}
}

View File

@ -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;
}

View File

@ -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;
}

771
src/Web/wwwroot/index.html Normal file
View File

@ -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</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js">
<link rel="stylesheet" href="css/styles.css">
<link rel="stylesheet" href="css/dark-mode.css">
</head>
<body>
<nav class="navbar navbar-expand-lg">
<div class="container">
<a class="navbar-brand" href="#"><i class="bi bi-rss-fill me-2"></i>Transmission RSS Manager</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav me-auto">
<li class="nav-item"><a class="nav-link" href="#" data-page="dashboard"><i class="bi bi-speedometer2 me-1"></i>Dashboard</a></li>
<li class="nav-item"><a class="nav-link" href="#" data-page="feeds"><i class="bi bi-rss me-1"></i>RSS Feeds</a></li>
<li class="nav-item"><a class="nav-link" href="#" data-page="torrents"><i class="bi bi-cloud-download me-1"></i>Torrents</a></li>
<li class="nav-item"><a class="nav-link" href="#" data-page="logs"><i class="bi bi-journal-text me-1"></i>Logs</a></li>
<li class="nav-item"><a class="nav-link" href="#" data-page="settings"><i class="bi bi-gear me-1"></i>Settings</a></li>
</ul>
<div class="d-flex align-items-center">
<button id="dark-mode-toggle" class="btn dark-mode-toggle" title="Toggle Dark Mode">
<i class="bi bi-moon-fill"></i>
</button>
<span class="ms-2 me-2 app-version">v1.0.0</span>
</div>
</div>
</div>
</nav>
<!-- Toast Container for Notifications -->
<div class="toast-container"></div>
<div class="container mt-4">
<div id="page-dashboard" class="page-content">
<h2 class="mb-4"><i class="bi bi-speedometer2 me-2"></i>Dashboard</h2>
<!-- Dashboard Stats Cards -->
<div class="dashboard-stats mb-4">
<div class="stat-card">
<i class="bi bi-cloud-download text-primary mb-2" style="font-size: 2rem;"></i>
<div class="stat-value" id="active-downloads">-</div>
<div class="stat-label">Active Downloads</div>
</div>
<div class="stat-card">
<i class="bi bi-cloud-upload text-success mb-2" style="font-size: 2rem;"></i>
<div class="stat-value" id="seeding-torrents">-</div>
<div class="stat-label">Seeding Torrents</div>
</div>
<div class="stat-card">
<i class="bi bi-rss text-info mb-2" style="font-size: 2rem;"></i>
<div class="stat-value" id="active-feeds">-</div>
<div class="stat-label">Active Feeds</div>
</div>
<div class="stat-card">
<i class="bi bi-check2-circle text-success mb-2" style="font-size: 2rem;"></i>
<div class="stat-value" id="completed-today">-</div>
<div class="stat-label">Completed Today</div>
</div>
</div>
<!-- Download and Upload Speed -->
<div class="row mb-4">
<div class="col-md-6">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<span><i class="bi bi-arrow-down-up me-2"></i>Download/Upload Speed</span>
<span class="badge bg-primary" id="current-speed">-</span>
</div>
<div class="card-body">
<div class="d-flex justify-content-between mb-2">
<div>
<span class="text-primary"><i class="bi bi-arrow-down me-1"></i>Download:</span>
<span id="download-speed">0 KB/s</span>
</div>
<div>
<span class="text-success"><i class="bi bi-arrow-up me-1"></i>Upload:</span>
<span id="upload-speed">0 KB/s</span>
</div>
</div>
<div class="progress mb-3">
<div id="download-speed-bar" class="progress-bar bg-primary" style="width: 0%"></div>
</div>
<div class="progress">
<div id="upload-speed-bar" class="progress-bar bg-success" style="width: 0%"></div>
</div>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card">
<div class="card-header">
<i class="bi bi-clock-history me-2"></i>Activity Summary
</div>
<div class="card-body">
<ul class="list-group list-group-flush">
<li class="list-group-item d-flex justify-content-between align-items-center">
Added Today
<span class="badge bg-primary" id="added-today">-</span>
</li>
<li class="list-group-item d-flex justify-content-between align-items-center">
Completed Today
<span class="badge bg-success" id="finished-today">-</span>
</li>
<li class="list-group-item d-flex justify-content-between align-items-center">
Active RSS Feeds
<span class="badge bg-info" id="feeds-count">-</span>
</li>
<li class="list-group-item d-flex justify-content-between align-items-center">
Matched Items
<span class="badge bg-warning" id="matched-count">-</span>
</li>
</ul>
</div>
</div>
</div>
</div>
<!-- Download History Chart -->
<div class="row mb-4">
<div class="col-md-12">
<div class="card">
<div class="card-header">
<i class="bi bi-graph-up me-2"></i>Download History (Last 30 Days)
</div>
<div class="card-body">
<div class="chart-container">
<canvas id="download-history-chart"></canvas>
</div>
</div>
</div>
</div>
</div>
<!-- Active Torrents and Recent Matches -->
<div class="row">
<div class="col-lg-7">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<span><i class="bi bi-cloud-download me-2"></i>Active Torrents</span>
<a href="#" data-page="torrents" class="btn btn-sm btn-outline-primary">View All</a>
</div>
<div class="card-body">
<div id="active-torrents-list">Loading...</div>
</div>
</div>
</div>
<div class="col-lg-5">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<span><i class="bi bi-lightning-charge me-2"></i>Recent Matches</span>
<a href="#" data-page="feeds" class="btn btn-sm btn-outline-primary">View All</a>
</div>
<div class="card-body">
<div id="recent-matches-list">Loading...</div>
</div>
</div>
</div>
</div>
</div>
<div id="page-feeds" class="page-content d-none">
<h2>RSS Feeds</h2>
<div class="mb-3">
<button id="btn-add-feed" class="btn btn-primary">Add Feed</button>
<button id="btn-refresh-feeds" class="btn btn-secondary">Refresh Feeds</button>
</div>
<div id="feeds-list">Loading...</div>
<div class="mt-4">
<h3>Feed Items</h3>
<ul class="nav nav-tabs" id="feedTabs">
<li class="nav-item">
<a class="nav-link active" data-bs-toggle="tab" href="#all-items" style="color: inherit;">All Items</a>
</li>
<li class="nav-item">
<a class="nav-link" data-bs-toggle="tab" href="#matched-items" style="color: inherit;">Matched Items</a>
</li>
</ul>
<div class="tab-content mt-2">
<div class="tab-pane fade show active" id="all-items">
<div id="all-items-list">Loading...</div>
</div>
<div class="tab-pane fade" id="matched-items">
<div id="matched-items-list">Loading...</div>
</div>
</div>
</div>
</div>
<div id="page-torrents" class="page-content d-none">
<h2>Torrents</h2>
<div class="mb-3">
<button id="btn-add-torrent" class="btn btn-primary">Add Torrent</button>
<button id="btn-refresh-torrents" class="btn btn-secondary">Refresh Torrents</button>
</div>
<div id="torrents-list">Loading...</div>
</div>
<div id="page-logs" class="page-content d-none">
<h2 class="mb-4"><i class="bi bi-journal-text me-2"></i>System Logs</h2>
<div class="card mb-4">
<div class="card-header d-flex justify-content-between align-items-center">
<span><i class="bi bi-funnel me-2"></i>Log Filters</span>
<div>
<button class="btn btn-sm btn-outline-secondary" id="btn-refresh-logs">
<i class="bi bi-arrow-clockwise me-1"></i>Refresh
</button>
<button class="btn btn-sm btn-outline-danger ms-2" id="btn-clear-logs">
<i class="bi bi-trash me-1"></i>Clear Logs
</button>
</div>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-4">
<div class="mb-3">
<label for="log-level" class="form-label">Log Level</label>
<select class="form-select" id="log-level">
<option value="All">All Levels</option>
<option value="Debug">Debug</option>
<option value="Information">Information</option>
<option value="Warning">Warning</option>
<option value="Error">Error</option>
</select>
</div>
</div>
<div class="col-md-4">
<div class="mb-3">
<label for="log-search" class="form-label">Search</label>
<input type="text" class="form-control" id="log-search" placeholder="Search logs...">
</div>
</div>
<div class="col-md-4">
<div class="mb-3">
<label for="log-date-range" class="form-label">Date Range</label>
<select class="form-select" id="log-date-range">
<option value="today">Today</option>
<option value="yesterday">Yesterday</option>
<option value="week" selected>Last 7 days</option>
<option value="month">Last 30 days</option>
<option value="all">All time</option>
</select>
</div>
</div>
</div>
<div class="row">
<div class="col-12">
<div class="d-flex justify-content-between mb-2">
<div>
<button class="btn btn-sm btn-outline-primary" id="btn-apply-log-filters">
<i class="bi bi-funnel-fill me-1"></i>Apply Filters
</button>
<button class="btn btn-sm btn-outline-secondary ms-2" id="btn-reset-log-filters">
<i class="bi bi-x-circle me-1"></i>Reset Filters
</button>
</div>
<div>
<button class="btn btn-sm btn-outline-primary" id="btn-export-logs">
<i class="bi bi-download me-1"></i>Export Logs
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<span><i class="bi bi-list-ul me-2"></i>Log Entries</span>
<span class="badge bg-secondary" id="log-count">0 entries</span>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead>
<tr>
<th style="width: 180px;">Timestamp</th>
<th style="width: 100px;">Level</th>
<th>Message</th>
<th style="width: 120px;">Context</th>
</tr>
</thead>
<tbody id="logs-table-body">
<tr>
<td colspan="4" class="text-center py-4">Loading logs...</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="card-footer d-flex justify-content-between align-items-center">
<div>
<span id="logs-pagination-info">Showing 0 of 0 entries</span>
</div>
<div>
<nav aria-label="Logs pagination">
<ul class="pagination pagination-sm mb-0" id="logs-pagination">
<li class="page-item disabled">
<a class="page-link" href="#" aria-label="Previous">
<span aria-hidden="true">&laquo;</span>
</a>
</li>
<li class="page-item active"><a class="page-link" href="#">1</a></li>
<li class="page-item disabled">
<a class="page-link" href="#" aria-label="Next">
<span aria-hidden="true">&raquo;</span>
</a>
</li>
</ul>
</nav>
</div>
</div>
</div>
</div>
<div id="page-settings" class="page-content d-none">
<h2 class="mb-4"><i class="bi bi-gear me-2"></i>Settings</h2>
<form id="settings-form">
<ul class="nav nav-tabs mb-4" id="settings-tabs">
<li class="nav-item">
<a class="nav-link active" data-bs-toggle="tab" href="#tab-transmission" style="color: inherit;">
<i class="bi bi-cloud me-1"></i>Transmission
</a>
</li>
<li class="nav-item">
<a class="nav-link" data-bs-toggle="tab" href="#tab-rss" style="color: inherit;">
<i class="bi bi-rss me-1"></i>RSS
</a>
</li>
<li class="nav-item">
<a class="nav-link" data-bs-toggle="tab" href="#tab-processing" style="color: inherit;">
<i class="bi bi-tools me-1"></i>Processing
</a>
</li>
<li class="nav-item">
<a class="nav-link" data-bs-toggle="tab" href="#tab-appearance" style="color: inherit;">
<i class="bi bi-palette me-1"></i>Appearance
</a>
</li>
<li class="nav-item">
<a class="nav-link" data-bs-toggle="tab" href="#tab-advanced" style="color: inherit;">
<i class="bi bi-sliders me-1"></i>Advanced
</a>
</li>
</ul>
<div class="tab-content">
<!-- Transmission Settings Tab -->
<div class="tab-pane fade show active" id="tab-transmission">
<div class="card mb-4">
<div class="card-header">
<i class="bi bi-cloud me-2"></i>Primary Transmission Instance
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label for="transmission-host" class="form-label">Host</label>
<input type="text" class="form-control" id="transmission-host" name="transmission.host">
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label for="transmission-port" class="form-label">Port</label>
<input type="number" class="form-control" id="transmission-port" name="transmission.port">
</div>
</div>
</div>
<div class="row">
<div class="col-md-12">
<div class="mb-3">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="transmission-use-https" name="transmission.useHttps">
<label class="form-check-label" for="transmission-use-https">Use HTTPS</label>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label for="transmission-username" class="form-label">Username</label>
<input type="text" class="form-control" id="transmission-username" name="transmission.username">
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label for="transmission-password" class="form-label">Password</label>
<input type="password" class="form-control" id="transmission-password" name="transmission.password">
</div>
</div>
</div>
</div>
</div>
<!-- Additional Transmission Instances -->
<div class="card mb-4">
<div class="card-header d-flex justify-content-between align-items-center">
<span><i class="bi bi-hdd-stack me-2"></i>Additional Transmission Instances</span>
<button type="button" class="btn btn-sm btn-primary" id="add-transmission-instance">
<i class="bi bi-plus-circle me-1"></i>Add Instance
</button>
</div>
<div class="card-body">
<div id="transmission-instances-list">
<div class="text-center text-muted py-3">No additional instances configured</div>
</div>
</div>
</div>
</div>
<!-- RSS Settings Tab -->
<div class="tab-pane fade" id="tab-rss">
<div class="card mb-4">
<div class="card-header">
<i class="bi bi-rss me-2"></i>RSS General Settings
</div>
<div class="card-body">
<div class="mb-3">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="auto-download-enabled" name="autoDownloadEnabled">
<label class="form-check-label" for="auto-download-enabled">Enable Auto Download</label>
</div>
</div>
<div class="mb-3">
<label for="check-interval" class="form-label">Default Check Interval (minutes)</label>
<input type="number" class="form-control" id="check-interval" name="checkIntervalMinutes">
</div>
<div class="mb-3">
<label for="max-feed-items" class="form-label">Maximum Items per Feed</label>
<input type="number" class="form-control" id="max-feed-items" name="maxFeedItems" value="100">
<div class="form-text">Maximum number of items to keep per feed (for performance)</div>
</div>
</div>
</div>
<div class="card mb-4">
<div class="card-header">
<i class="bi bi-filter-circle me-2"></i>Content Filtering
</div>
<div class="card-body">
<div class="mb-3">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="enable-regex-matching" name="enableRegexMatching">
<label class="form-check-label" for="enable-regex-matching">Enable Regular Expression Matching</label>
</div>
<div class="form-text">When enabled, feed rules can use regular expressions for more advanced matching</div>
</div>
<div class="mb-3">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="case-sensitive-matching" name="caseSensitiveMatching">
<label class="form-check-label" for="case-sensitive-matching">Case Sensitive Matching</label>
</div>
</div>
<div class="mb-3">
<label for="global-exclude-patterns" class="form-label">Global Exclude Patterns (one per line)</label>
<textarea class="form-control" id="global-exclude-patterns" name="globalExcludePatterns" rows="3"></textarea>
<div class="form-text">Items matching these patterns will be ignored regardless of feed rules</div>
</div>
</div>
</div>
</div>
<!-- Processing Tab -->
<div class="tab-pane fade" id="tab-processing">
<div class="card mb-4">
<div class="card-header">
<i class="bi bi-folder me-2"></i>Directories
</div>
<div class="card-body">
<div class="mb-3">
<label for="download-directory" class="form-label">Default Download Directory</label>
<input type="text" class="form-control" id="download-directory" name="downloadDirectory">
</div>
<div class="mb-3">
<label for="media-library" class="form-label">Media Library Path</label>
<input type="text" class="form-control" id="media-library" name="mediaLibraryPath">
</div>
<div class="mb-3">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="create-category-folders" name="createCategoryFolders">
<label class="form-check-label" for="create-category-folders">Create Category Folders</label>
</div>
<div class="form-text">Create subfolders based on feed categories</div>
</div>
</div>
</div>
<div class="card mb-4">
<div class="card-header">
<i class="bi bi-tools me-2"></i>Post Processing
</div>
<div class="card-body">
<div class="mb-3">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="post-processing-enabled" name="postProcessing.enabled">
<label class="form-check-label" for="post-processing-enabled">Enable Post Processing</label>
</div>
</div>
<div class="mb-3">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="extract-archives" name="postProcessing.extractArchives">
<label class="form-check-label" for="extract-archives">Extract Archives</label>
</div>
</div>
<div class="mb-3">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="organize-media" name="postProcessing.organizeMedia">
<label class="form-check-label" for="organize-media">Organize Media</label>
</div>
</div>
<div class="mb-3">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="auto-organize-media-type" name="postProcessing.autoOrganizeByMediaType">
<label class="form-check-label" for="auto-organize-media-type">Auto-organize by Media Type</label>
</div>
</div>
<div class="mb-3">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="rename-files" name="postProcessing.renameFiles">
<label class="form-check-label" for="rename-files">Rename Files</label>
</div>
</div>
<div class="mb-3">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="compress-completed" name="postProcessing.compressCompletedFiles">
<label class="form-check-label" for="compress-completed">Compress Completed Files</label>
</div>
</div>
<div class="mb-3">
<label for="minimum-seed-ratio" class="form-label">Minimum Seed Ratio</label>
<input type="number" class="form-control" id="minimum-seed-ratio" name="postProcessing.minimumSeedRatio">
</div>
<div class="mb-3">
<label for="delete-completed-after" class="form-label">Delete Completed After (days)</label>
<input type="number" class="form-control" id="delete-completed-after" name="postProcessing.deleteCompletedAfterDays" value="0">
<div class="form-text">Number of days after which completed torrents will be removed (0 = never)</div>
</div>
<div class="mb-3">
<label for="media-extensions" class="form-label">Media Extensions (comma separated)</label>
<input type="text" class="form-control" id="media-extensions" name="mediaExtensions">
</div>
</div>
</div>
</div>
<!-- Appearance Tab -->
<div class="tab-pane fade" id="tab-appearance">
<div class="card mb-4">
<div class="card-header">
<i class="bi bi-palette me-2"></i>User Interface
</div>
<div class="card-body">
<div class="mb-3">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="enable-dark-mode" name="userPreferences.enableDarkMode">
<label class="form-check-label" for="enable-dark-mode">Enable Dark Mode</label>
</div>
</div>
<div class="mb-3">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="auto-refresh-ui" name="userPreferences.autoRefreshUIEnabled">
<label class="form-check-label" for="auto-refresh-ui">Auto Refresh UI</label>
</div>
</div>
<div class="mb-3">
<label for="auto-refresh-interval" class="form-label">Auto Refresh Interval (seconds)</label>
<input type="number" class="form-control" id="auto-refresh-interval" name="userPreferences.autoRefreshIntervalSeconds" value="30">
</div>
<div class="mb-3">
<label for="default-view" class="form-label">Default View</label>
<select class="form-select" id="default-view" name="userPreferences.defaultView">
<option value="dashboard">Dashboard</option>
<option value="feeds">RSS Feeds</option>
<option value="torrents">Torrents</option>
<option value="settings">Settings</option>
</select>
</div>
<div class="mb-3">
<label for="items-per-page" class="form-label">Items Per Page</label>
<input type="number" class="form-control" id="items-per-page" name="userPreferences.maxItemsPerPage" value="25">
</div>
<div class="mb-3">
<label for="date-format" class="form-label">Date Format</label>
<input type="text" class="form-control" id="date-format" name="userPreferences.dateTimeFormat" value="yyyy-MM-dd HH:mm:ss">
</div>
</div>
</div>
<div class="card mb-4">
<div class="card-header">
<i class="bi bi-bell me-2"></i>Notifications
</div>
<div class="card-body">
<div class="mb-3">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="enable-notifications" name="userPreferences.notificationsEnabled">
<label class="form-check-label" for="enable-notifications">Enable Notifications</label>
</div>
</div>
<div class="mb-3">
<label class="form-label">Notification Events</label>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="notify-torrent-added" name="notificationEvents" value="torrent-added">
<label class="form-check-label" for="notify-torrent-added">Torrent Added</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="notify-torrent-completed" name="notificationEvents" value="torrent-completed">
<label class="form-check-label" for="notify-torrent-completed">Torrent Completed</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="notify-torrent-error" name="notificationEvents" value="torrent-error">
<label class="form-check-label" for="notify-torrent-error">Torrent Error</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="notify-feed-error" name="notificationEvents" value="feed-error">
<label class="form-check-label" for="notify-feed-error">Feed Error</label>
</div>
</div>
</div>
</div>
</div>
<!-- Advanced Tab -->
<div class="tab-pane fade" id="tab-advanced">
<div class="card mb-4">
<div class="card-header">
<i class="bi bi-sliders me-2"></i>Advanced Settings
</div>
<div class="card-body">
<div class="mb-3">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="detailed-logging" name="enableDetailedLogging">
<label class="form-check-label" for="detailed-logging" id="detailed-logging-label" style="color: inherit !important;">Enable Detailed Logging</label>
</div>
</div>
<div class="mb-3">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="show-completed-torrents" name="userPreferences.showCompletedTorrents">
<label class="form-check-label" for="show-completed-torrents" id="show-completed-torrents-label" style="color: white !important;">Show Completed Torrents</label>
</div>
</div>
<div class="mb-3">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="confirm-delete" name="userPreferences.confirmBeforeDelete">
<label class="form-check-label" for="confirm-delete" id="confirm-delete-label" style="color: white !important;">Confirm Before Delete</label>
</div>
</div>
<div class="mb-3">
<label for="history-days" class="form-label">Keep History (days)</label>
<input type="number" class="form-control" id="history-days" name="userPreferences.keepHistoryDays" value="30">
<div class="form-text">Number of days to keep historical data</div>
</div>
</div>
</div>
<div class="card mb-4">
<div class="card-header">
<i class="bi bi-database me-2"></i>Database
</div>
<div class="card-body">
<div class="alert alert-warning">
<i class="bi bi-exclamation-triangle-fill me-2"></i>Warning: These operations affect your data permanently.
</div>
<div class="d-flex gap-2 mt-3">
<button type="button" class="btn btn-outline-primary" id="btn-backup-db">
<i class="bi bi-download me-1"></i>Backup Database
</button>
<button type="button" class="btn btn-outline-secondary" id="btn-clean-db">
<i class="bi bi-trash me-1"></i>Clean Old Data
</button>
<button type="button" class="btn btn-outline-danger" id="btn-reset-db">
<i class="bi bi-arrow-repeat me-1"></i>Reset Database
</button>
</div>
</div>
</div>
</div>
</div>
<div class="d-flex justify-content-between mt-4">
<button type="button" class="btn btn-outline-secondary" id="btn-reset-settings">Reset to Defaults</button>
<button type="submit" class="btn btn-primary"><i class="bi bi-save me-1"></i>Save Settings</button>
</div>
</form>
</div>
</div>
<!-- Modals -->
<div class="modal fade" id="add-feed-modal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Add RSS Feed</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="add-feed-form">
<div class="mb-3">
<label for="feed-name" class="form-label">Name</label>
<input type="text" class="form-control" id="feed-name" required>
</div>
<div class="mb-3">
<label for="feed-url" class="form-label">URL</label>
<input type="url" class="form-control" id="feed-url" required>
</div>
<div class="mb-3">
<label for="feed-rules" class="form-label">Match Rules (one per line)</label>
<textarea class="form-control" id="feed-rules" rows="5"></textarea>
<div class="form-text">Use regular expressions to match feed items.</div>
</div>
<div class="mb-3">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="feed-auto-download">
<label class="form-check-label" for="feed-auto-download">Auto Download</label>
</div>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="save-feed-btn">Add Feed</button>
</div>
</div>
</div>
</div>
<div class="modal fade" id="add-torrent-modal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Add Torrent</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="add-torrent-form">
<div class="mb-3">
<label for="torrent-url" class="form-label">Torrent URL or Magnet Link</label>
<input type="text" class="form-control" id="torrent-url" required>
</div>
<div class="mb-3">
<label for="torrent-download-dir" class="form-label">Download Directory (optional)</label>
<input type="text" class="form-control" id="torrent-download-dir">
<div class="form-text">Leave empty to use default download directory.</div>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="save-torrent-btn">Add Torrent</button>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/luxon@3.4.4/build/global/luxon.min.js"></script>
<script src="js/app.js"></script>
</body>
</html>

1599
src/Web/wwwroot/js/app.js Normal file

File diff suppressed because it is too large Load Diff

70
wwwroot/css/dark-mode.css Normal file
View File

@ -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;
}

724
wwwroot/css/styles.css Normal file
View File

@ -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;
}

771
wwwroot/index.html Normal file
View File

@ -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</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js">
<link rel="stylesheet" href="css/styles.css">
<link rel="stylesheet" href="css/dark-mode.css">
</head>
<body>
<nav class="navbar navbar-expand-lg">
<div class="container">
<a class="navbar-brand" href="#"><i class="bi bi-rss-fill me-2"></i>Transmission RSS Manager</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav me-auto">
<li class="nav-item"><a class="nav-link" href="#" data-page="dashboard"><i class="bi bi-speedometer2 me-1"></i>Dashboard</a></li>
<li class="nav-item"><a class="nav-link" href="#" data-page="feeds"><i class="bi bi-rss me-1"></i>RSS Feeds</a></li>
<li class="nav-item"><a class="nav-link" href="#" data-page="torrents"><i class="bi bi-cloud-download me-1"></i>Torrents</a></li>
<li class="nav-item"><a class="nav-link" href="#" data-page="logs"><i class="bi bi-journal-text me-1"></i>Logs</a></li>
<li class="nav-item"><a class="nav-link" href="#" data-page="settings"><i class="bi bi-gear me-1"></i>Settings</a></li>
</ul>
<div class="d-flex align-items-center">
<button id="dark-mode-toggle" class="btn dark-mode-toggle" title="Toggle Dark Mode">
<i class="bi bi-moon-fill"></i>
</button>
<span class="ms-2 me-2 app-version">v1.0.0</span>
</div>
</div>
</div>
</nav>
<!-- Toast Container for Notifications -->
<div class="toast-container"></div>
<div class="container mt-4">
<div id="page-dashboard" class="page-content">
<h2 class="mb-4"><i class="bi bi-speedometer2 me-2"></i>Dashboard</h2>
<!-- Dashboard Stats Cards -->
<div class="dashboard-stats mb-4">
<div class="stat-card">
<i class="bi bi-cloud-download text-primary mb-2" style="font-size: 2rem;"></i>
<div class="stat-value" id="active-downloads">-</div>
<div class="stat-label">Active Downloads</div>
</div>
<div class="stat-card">
<i class="bi bi-cloud-upload text-success mb-2" style="font-size: 2rem;"></i>
<div class="stat-value" id="seeding-torrents">-</div>
<div class="stat-label">Seeding Torrents</div>
</div>
<div class="stat-card">
<i class="bi bi-rss text-info mb-2" style="font-size: 2rem;"></i>
<div class="stat-value" id="active-feeds">-</div>
<div class="stat-label">Active Feeds</div>
</div>
<div class="stat-card">
<i class="bi bi-check2-circle text-success mb-2" style="font-size: 2rem;"></i>
<div class="stat-value" id="completed-today">-</div>
<div class="stat-label">Completed Today</div>
</div>
</div>
<!-- Download and Upload Speed -->
<div class="row mb-4">
<div class="col-md-6">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<span><i class="bi bi-arrow-down-up me-2"></i>Download/Upload Speed</span>
<span class="badge bg-primary" id="current-speed">-</span>
</div>
<div class="card-body">
<div class="d-flex justify-content-between mb-2">
<div>
<span class="text-primary"><i class="bi bi-arrow-down me-1"></i>Download:</span>
<span id="download-speed">0 KB/s</span>
</div>
<div>
<span class="text-success"><i class="bi bi-arrow-up me-1"></i>Upload:</span>
<span id="upload-speed">0 KB/s</span>
</div>
</div>
<div class="progress mb-3">
<div id="download-speed-bar" class="progress-bar bg-primary" style="width: 0%"></div>
</div>
<div class="progress">
<div id="upload-speed-bar" class="progress-bar bg-success" style="width: 0%"></div>
</div>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card">
<div class="card-header">
<i class="bi bi-clock-history me-2"></i>Activity Summary
</div>
<div class="card-body">
<ul class="list-group list-group-flush">
<li class="list-group-item d-flex justify-content-between align-items-center">
Added Today
<span class="badge bg-primary" id="added-today">-</span>
</li>
<li class="list-group-item d-flex justify-content-between align-items-center">
Completed Today
<span class="badge bg-success" id="finished-today">-</span>
</li>
<li class="list-group-item d-flex justify-content-between align-items-center">
Active RSS Feeds
<span class="badge bg-info" id="feeds-count">-</span>
</li>
<li class="list-group-item d-flex justify-content-between align-items-center">
Matched Items
<span class="badge bg-warning" id="matched-count">-</span>
</li>
</ul>
</div>
</div>
</div>
</div>
<!-- Download History Chart -->
<div class="row mb-4">
<div class="col-md-12">
<div class="card">
<div class="card-header">
<i class="bi bi-graph-up me-2"></i>Download History (Last 30 Days)
</div>
<div class="card-body">
<div class="chart-container">
<canvas id="download-history-chart"></canvas>
</div>
</div>
</div>
</div>
</div>
<!-- Active Torrents and Recent Matches -->
<div class="row">
<div class="col-lg-7">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<span><i class="bi bi-cloud-download me-2"></i>Active Torrents</span>
<a href="#" data-page="torrents" class="btn btn-sm btn-outline-primary">View All</a>
</div>
<div class="card-body">
<div id="active-torrents-list">Loading...</div>
</div>
</div>
</div>
<div class="col-lg-5">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<span><i class="bi bi-lightning-charge me-2"></i>Recent Matches</span>
<a href="#" data-page="feeds" class="btn btn-sm btn-outline-primary">View All</a>
</div>
<div class="card-body">
<div id="recent-matches-list">Loading...</div>
</div>
</div>
</div>
</div>
</div>
<div id="page-feeds" class="page-content d-none">
<h2>RSS Feeds</h2>
<div class="mb-3">
<button id="btn-add-feed" class="btn btn-primary">Add Feed</button>
<button id="btn-refresh-feeds" class="btn btn-secondary">Refresh Feeds</button>
</div>
<div id="feeds-list">Loading...</div>
<div class="mt-4">
<h3>Feed Items</h3>
<ul class="nav nav-tabs" id="feedTabs">
<li class="nav-item">
<a class="nav-link active" data-bs-toggle="tab" href="#all-items" style="color: inherit;">All Items</a>
</li>
<li class="nav-item">
<a class="nav-link" data-bs-toggle="tab" href="#matched-items" style="color: inherit;">Matched Items</a>
</li>
</ul>
<div class="tab-content mt-2">
<div class="tab-pane fade show active" id="all-items">
<div id="all-items-list">Loading...</div>
</div>
<div class="tab-pane fade" id="matched-items">
<div id="matched-items-list">Loading...</div>
</div>
</div>
</div>
</div>
<div id="page-torrents" class="page-content d-none">
<h2>Torrents</h2>
<div class="mb-3">
<button id="btn-add-torrent" class="btn btn-primary">Add Torrent</button>
<button id="btn-refresh-torrents" class="btn btn-secondary">Refresh Torrents</button>
</div>
<div id="torrents-list">Loading...</div>
</div>
<div id="page-logs" class="page-content d-none">
<h2 class="mb-4"><i class="bi bi-journal-text me-2"></i>System Logs</h2>
<div class="card mb-4">
<div class="card-header d-flex justify-content-between align-items-center">
<span><i class="bi bi-funnel me-2"></i>Log Filters</span>
<div>
<button class="btn btn-sm btn-outline-secondary" id="btn-refresh-logs">
<i class="bi bi-arrow-clockwise me-1"></i>Refresh
</button>
<button class="btn btn-sm btn-outline-danger ms-2" id="btn-clear-logs">
<i class="bi bi-trash me-1"></i>Clear Logs
</button>
</div>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-4">
<div class="mb-3">
<label for="log-level" class="form-label">Log Level</label>
<select class="form-select" id="log-level">
<option value="All">All Levels</option>
<option value="Debug">Debug</option>
<option value="Information">Information</option>
<option value="Warning">Warning</option>
<option value="Error">Error</option>
</select>
</div>
</div>
<div class="col-md-4">
<div class="mb-3">
<label for="log-search" class="form-label">Search</label>
<input type="text" class="form-control" id="log-search" placeholder="Search logs...">
</div>
</div>
<div class="col-md-4">
<div class="mb-3">
<label for="log-date-range" class="form-label">Date Range</label>
<select class="form-select" id="log-date-range">
<option value="today">Today</option>
<option value="yesterday">Yesterday</option>
<option value="week" selected>Last 7 days</option>
<option value="month">Last 30 days</option>
<option value="all">All time</option>
</select>
</div>
</div>
</div>
<div class="row">
<div class="col-12">
<div class="d-flex justify-content-between mb-2">
<div>
<button class="btn btn-sm btn-outline-primary" id="btn-apply-log-filters">
<i class="bi bi-funnel-fill me-1"></i>Apply Filters
</button>
<button class="btn btn-sm btn-outline-secondary ms-2" id="btn-reset-log-filters">
<i class="bi bi-x-circle me-1"></i>Reset Filters
</button>
</div>
<div>
<button class="btn btn-sm btn-outline-primary" id="btn-export-logs">
<i class="bi bi-download me-1"></i>Export Logs
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<span><i class="bi bi-list-ul me-2"></i>Log Entries</span>
<span class="badge bg-secondary" id="log-count">0 entries</span>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead>
<tr>
<th style="width: 180px;">Timestamp</th>
<th style="width: 100px;">Level</th>
<th>Message</th>
<th style="width: 120px;">Context</th>
</tr>
</thead>
<tbody id="logs-table-body">
<tr>
<td colspan="4" class="text-center py-4">Loading logs...</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="card-footer d-flex justify-content-between align-items-center">
<div>
<span id="logs-pagination-info">Showing 0 of 0 entries</span>
</div>
<div>
<nav aria-label="Logs pagination">
<ul class="pagination pagination-sm mb-0" id="logs-pagination">
<li class="page-item disabled">
<a class="page-link" href="#" aria-label="Previous">
<span aria-hidden="true">&laquo;</span>
</a>
</li>
<li class="page-item active"><a class="page-link" href="#">1</a></li>
<li class="page-item disabled">
<a class="page-link" href="#" aria-label="Next">
<span aria-hidden="true">&raquo;</span>
</a>
</li>
</ul>
</nav>
</div>
</div>
</div>
</div>
<div id="page-settings" class="page-content d-none">
<h2 class="mb-4"><i class="bi bi-gear me-2"></i>Settings</h2>
<form id="settings-form">
<ul class="nav nav-tabs mb-4" id="settings-tabs">
<li class="nav-item">
<a class="nav-link active" data-bs-toggle="tab" href="#tab-transmission" style="color: inherit;">
<i class="bi bi-cloud me-1"></i>Transmission
</a>
</li>
<li class="nav-item">
<a class="nav-link" data-bs-toggle="tab" href="#tab-rss" style="color: inherit;">
<i class="bi bi-rss me-1"></i>RSS
</a>
</li>
<li class="nav-item">
<a class="nav-link" data-bs-toggle="tab" href="#tab-processing" style="color: inherit;">
<i class="bi bi-tools me-1"></i>Processing
</a>
</li>
<li class="nav-item">
<a class="nav-link" data-bs-toggle="tab" href="#tab-appearance" style="color: inherit;">
<i class="bi bi-palette me-1"></i>Appearance
</a>
</li>
<li class="nav-item">
<a class="nav-link" data-bs-toggle="tab" href="#tab-advanced" style="color: inherit;">
<i class="bi bi-sliders me-1"></i>Advanced
</a>
</li>
</ul>
<div class="tab-content">
<!-- Transmission Settings Tab -->
<div class="tab-pane fade show active" id="tab-transmission">
<div class="card mb-4">
<div class="card-header">
<i class="bi bi-cloud me-2"></i>Primary Transmission Instance
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label for="transmission-host" class="form-label">Host</label>
<input type="text" class="form-control" id="transmission-host" name="transmission.host">
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label for="transmission-port" class="form-label">Port</label>
<input type="number" class="form-control" id="transmission-port" name="transmission.port">
</div>
</div>
</div>
<div class="row">
<div class="col-md-12">
<div class="mb-3">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="transmission-use-https" name="transmission.useHttps">
<label class="form-check-label" for="transmission-use-https">Use HTTPS</label>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label for="transmission-username" class="form-label">Username</label>
<input type="text" class="form-control" id="transmission-username" name="transmission.username">
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label for="transmission-password" class="form-label">Password</label>
<input type="password" class="form-control" id="transmission-password" name="transmission.password">
</div>
</div>
</div>
</div>
</div>
<!-- Additional Transmission Instances -->
<div class="card mb-4">
<div class="card-header d-flex justify-content-between align-items-center">
<span><i class="bi bi-hdd-stack me-2"></i>Additional Transmission Instances</span>
<button type="button" class="btn btn-sm btn-primary" id="add-transmission-instance">
<i class="bi bi-plus-circle me-1"></i>Add Instance
</button>
</div>
<div class="card-body">
<div id="transmission-instances-list">
<div class="text-center text-muted py-3">No additional instances configured</div>
</div>
</div>
</div>
</div>
<!-- RSS Settings Tab -->
<div class="tab-pane fade" id="tab-rss">
<div class="card mb-4">
<div class="card-header">
<i class="bi bi-rss me-2"></i>RSS General Settings
</div>
<div class="card-body">
<div class="mb-3">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="auto-download-enabled" name="autoDownloadEnabled">
<label class="form-check-label" for="auto-download-enabled">Enable Auto Download</label>
</div>
</div>
<div class="mb-3">
<label for="check-interval" class="form-label">Default Check Interval (minutes)</label>
<input type="number" class="form-control" id="check-interval" name="checkIntervalMinutes">
</div>
<div class="mb-3">
<label for="max-feed-items" class="form-label">Maximum Items per Feed</label>
<input type="number" class="form-control" id="max-feed-items" name="maxFeedItems" value="100">
<div class="form-text">Maximum number of items to keep per feed (for performance)</div>
</div>
</div>
</div>
<div class="card mb-4">
<div class="card-header">
<i class="bi bi-filter-circle me-2"></i>Content Filtering
</div>
<div class="card-body">
<div class="mb-3">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="enable-regex-matching" name="enableRegexMatching">
<label class="form-check-label" for="enable-regex-matching">Enable Regular Expression Matching</label>
</div>
<div class="form-text">When enabled, feed rules can use regular expressions for more advanced matching</div>
</div>
<div class="mb-3">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="case-sensitive-matching" name="caseSensitiveMatching">
<label class="form-check-label" for="case-sensitive-matching">Case Sensitive Matching</label>
</div>
</div>
<div class="mb-3">
<label for="global-exclude-patterns" class="form-label">Global Exclude Patterns (one per line)</label>
<textarea class="form-control" id="global-exclude-patterns" name="globalExcludePatterns" rows="3"></textarea>
<div class="form-text">Items matching these patterns will be ignored regardless of feed rules</div>
</div>
</div>
</div>
</div>
<!-- Processing Tab -->
<div class="tab-pane fade" id="tab-processing">
<div class="card mb-4">
<div class="card-header">
<i class="bi bi-folder me-2"></i>Directories
</div>
<div class="card-body">
<div class="mb-3">
<label for="download-directory" class="form-label">Default Download Directory</label>
<input type="text" class="form-control" id="download-directory" name="downloadDirectory">
</div>
<div class="mb-3">
<label for="media-library" class="form-label">Media Library Path</label>
<input type="text" class="form-control" id="media-library" name="mediaLibraryPath">
</div>
<div class="mb-3">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="create-category-folders" name="createCategoryFolders">
<label class="form-check-label" for="create-category-folders">Create Category Folders</label>
</div>
<div class="form-text">Create subfolders based on feed categories</div>
</div>
</div>
</div>
<div class="card mb-4">
<div class="card-header">
<i class="bi bi-tools me-2"></i>Post Processing
</div>
<div class="card-body">
<div class="mb-3">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="post-processing-enabled" name="postProcessing.enabled">
<label class="form-check-label" for="post-processing-enabled">Enable Post Processing</label>
</div>
</div>
<div class="mb-3">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="extract-archives" name="postProcessing.extractArchives">
<label class="form-check-label" for="extract-archives">Extract Archives</label>
</div>
</div>
<div class="mb-3">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="organize-media" name="postProcessing.organizeMedia">
<label class="form-check-label" for="organize-media">Organize Media</label>
</div>
</div>
<div class="mb-3">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="auto-organize-media-type" name="postProcessing.autoOrganizeByMediaType">
<label class="form-check-label" for="auto-organize-media-type">Auto-organize by Media Type</label>
</div>
</div>
<div class="mb-3">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="rename-files" name="postProcessing.renameFiles">
<label class="form-check-label" for="rename-files">Rename Files</label>
</div>
</div>
<div class="mb-3">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="compress-completed" name="postProcessing.compressCompletedFiles">
<label class="form-check-label" for="compress-completed">Compress Completed Files</label>
</div>
</div>
<div class="mb-3">
<label for="minimum-seed-ratio" class="form-label">Minimum Seed Ratio</label>
<input type="number" class="form-control" id="minimum-seed-ratio" name="postProcessing.minimumSeedRatio">
</div>
<div class="mb-3">
<label for="delete-completed-after" class="form-label">Delete Completed After (days)</label>
<input type="number" class="form-control" id="delete-completed-after" name="postProcessing.deleteCompletedAfterDays" value="0">
<div class="form-text">Number of days after which completed torrents will be removed (0 = never)</div>
</div>
<div class="mb-3">
<label for="media-extensions" class="form-label">Media Extensions (comma separated)</label>
<input type="text" class="form-control" id="media-extensions" name="mediaExtensions">
</div>
</div>
</div>
</div>
<!-- Appearance Tab -->
<div class="tab-pane fade" id="tab-appearance">
<div class="card mb-4">
<div class="card-header">
<i class="bi bi-palette me-2"></i>User Interface
</div>
<div class="card-body">
<div class="mb-3">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="enable-dark-mode" name="userPreferences.enableDarkMode">
<label class="form-check-label" for="enable-dark-mode">Enable Dark Mode</label>
</div>
</div>
<div class="mb-3">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="auto-refresh-ui" name="userPreferences.autoRefreshUIEnabled">
<label class="form-check-label" for="auto-refresh-ui">Auto Refresh UI</label>
</div>
</div>
<div class="mb-3">
<label for="auto-refresh-interval" class="form-label">Auto Refresh Interval (seconds)</label>
<input type="number" class="form-control" id="auto-refresh-interval" name="userPreferences.autoRefreshIntervalSeconds" value="30">
</div>
<div class="mb-3">
<label for="default-view" class="form-label">Default View</label>
<select class="form-select" id="default-view" name="userPreferences.defaultView">
<option value="dashboard">Dashboard</option>
<option value="feeds">RSS Feeds</option>
<option value="torrents">Torrents</option>
<option value="settings">Settings</option>
</select>
</div>
<div class="mb-3">
<label for="items-per-page" class="form-label">Items Per Page</label>
<input type="number" class="form-control" id="items-per-page" name="userPreferences.maxItemsPerPage" value="25">
</div>
<div class="mb-3">
<label for="date-format" class="form-label">Date Format</label>
<input type="text" class="form-control" id="date-format" name="userPreferences.dateTimeFormat" value="yyyy-MM-dd HH:mm:ss">
</div>
</div>
</div>
<div class="card mb-4">
<div class="card-header">
<i class="bi bi-bell me-2"></i>Notifications
</div>
<div class="card-body">
<div class="mb-3">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="enable-notifications" name="userPreferences.notificationsEnabled">
<label class="form-check-label" for="enable-notifications">Enable Notifications</label>
</div>
</div>
<div class="mb-3">
<label class="form-label">Notification Events</label>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="notify-torrent-added" name="notificationEvents" value="torrent-added">
<label class="form-check-label" for="notify-torrent-added">Torrent Added</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="notify-torrent-completed" name="notificationEvents" value="torrent-completed">
<label class="form-check-label" for="notify-torrent-completed">Torrent Completed</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="notify-torrent-error" name="notificationEvents" value="torrent-error">
<label class="form-check-label" for="notify-torrent-error">Torrent Error</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="notify-feed-error" name="notificationEvents" value="feed-error">
<label class="form-check-label" for="notify-feed-error">Feed Error</label>
</div>
</div>
</div>
</div>
</div>
<!-- Advanced Tab -->
<div class="tab-pane fade" id="tab-advanced">
<div class="card mb-4">
<div class="card-header">
<i class="bi bi-sliders me-2"></i>Advanced Settings
</div>
<div class="card-body">
<div class="mb-3">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="detailed-logging" name="enableDetailedLogging">
<label class="form-check-label" for="detailed-logging" id="detailed-logging-label" style="color: inherit !important;">Enable Detailed Logging</label>
</div>
</div>
<div class="mb-3">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="show-completed-torrents" name="userPreferences.showCompletedTorrents">
<label class="form-check-label" for="show-completed-torrents" id="show-completed-torrents-label" style="color: white !important;">Show Completed Torrents</label>
</div>
</div>
<div class="mb-3">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="confirm-delete" name="userPreferences.confirmBeforeDelete">
<label class="form-check-label" for="confirm-delete" id="confirm-delete-label" style="color: white !important;">Confirm Before Delete</label>
</div>
</div>
<div class="mb-3">
<label for="history-days" class="form-label">Keep History (days)</label>
<input type="number" class="form-control" id="history-days" name="userPreferences.keepHistoryDays" value="30">
<div class="form-text">Number of days to keep historical data</div>
</div>
</div>
</div>
<div class="card mb-4">
<div class="card-header">
<i class="bi bi-database me-2"></i>Database
</div>
<div class="card-body">
<div class="alert alert-warning">
<i class="bi bi-exclamation-triangle-fill me-2"></i>Warning: These operations affect your data permanently.
</div>
<div class="d-flex gap-2 mt-3">
<button type="button" class="btn btn-outline-primary" id="btn-backup-db">
<i class="bi bi-download me-1"></i>Backup Database
</button>
<button type="button" class="btn btn-outline-secondary" id="btn-clean-db">
<i class="bi bi-trash me-1"></i>Clean Old Data
</button>
<button type="button" class="btn btn-outline-danger" id="btn-reset-db">
<i class="bi bi-arrow-repeat me-1"></i>Reset Database
</button>
</div>
</div>
</div>
</div>
</div>
<div class="d-flex justify-content-between mt-4">
<button type="button" class="btn btn-outline-secondary" id="btn-reset-settings">Reset to Defaults</button>
<button type="submit" class="btn btn-primary"><i class="bi bi-save me-1"></i>Save Settings</button>
</div>
</form>
</div>
</div>
<!-- Modals -->
<div class="modal fade" id="add-feed-modal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Add RSS Feed</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="add-feed-form">
<div class="mb-3">
<label for="feed-name" class="form-label">Name</label>
<input type="text" class="form-control" id="feed-name" required>
</div>
<div class="mb-3">
<label for="feed-url" class="form-label">URL</label>
<input type="url" class="form-control" id="feed-url" required>
</div>
<div class="mb-3">
<label for="feed-rules" class="form-label">Match Rules (one per line)</label>
<textarea class="form-control" id="feed-rules" rows="5"></textarea>
<div class="form-text">Use regular expressions to match feed items.</div>
</div>
<div class="mb-3">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="feed-auto-download">
<label class="form-check-label" for="feed-auto-download">Auto Download</label>
</div>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="save-feed-btn">Add Feed</button>
</div>
</div>
</div>
</div>
<div class="modal fade" id="add-torrent-modal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Add Torrent</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="add-torrent-form">
<div class="mb-3">
<label for="torrent-url" class="form-label">Torrent URL or Magnet Link</label>
<input type="text" class="form-control" id="torrent-url" required>
</div>
<div class="mb-3">
<label for="torrent-download-dir" class="form-label">Download Directory (optional)</label>
<input type="text" class="form-control" id="torrent-download-dir">
<div class="form-text">Leave empty to use default download directory.</div>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="save-torrent-btn">Add Torrent</button>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/luxon@3.4.4/build/global/luxon.min.js"></script>
<script src="js/app.js"></script>
</body>
</html>

1599
wwwroot/js/app.js Normal file

File diff suppressed because it is too large Load Diff