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:
commit
9e544456db
28
.gitignore
vendored
Normal file
28
.gitignore
vendored
Normal 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/
|
332
Migrations/20250312193828_InitialCreate.Designer.cs
generated
Normal file
332
Migrations/20250312193828_InitialCreate.Designer.cs
generated
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
205
Migrations/20250312193828_InitialCreate.cs
Normal file
205
Migrations/20250312193828_InitialCreate.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
402
Migrations/20250312203308_AddUIFeatures.Designer.cs
generated
Normal file
402
Migrations/20250312203308_AddUIFeatures.Designer.cs
generated
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
179
Migrations/20250312203308_AddUIFeatures.cs
Normal file
179
Migrations/20250312203308_AddUIFeatures.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
399
Migrations/TorrentManagerContextModelSnapshot.cs
Normal file
399
Migrations/TorrentManagerContextModelSnapshot.cs
Normal 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
64
README.md
Normal 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.
|
9
TransmissionRssManager.csproj
Normal file
9
TransmissionRssManager.csproj
Normal 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
9
appsettings.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
49
bin/net7.0/appsettings.json.bak
Normal file
49
bin/net7.0/appsettings.json.bak
Normal 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
|
||||
}
|
||||
}
|
323
bin/net7.0/src/Api/Controllers/ConfigController.cs
Normal file
323
bin/net7.0/src/Api/Controllers/ConfigController.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
648
bin/net7.0/src/Services/ConfigService.cs
Normal file
648
bin/net7.0/src/Services/ConfigService.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
56
bin/net7.0/wwwroot/css/dark-mode.css
Normal file
56
bin/net7.0/wwwroot/css/dark-mode.css
Normal 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;
|
||||
}
|
705
bin/net7.0/wwwroot/css/styles.css
Normal file
705
bin/net7.0/wwwroot/css/styles.css
Normal 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;
|
||||
}
|
771
bin/net7.0/wwwroot/index.html
Normal file
771
bin/net7.0/wwwroot/index.html
Normal 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">«</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">»</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
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
1359
install-script.sh
Executable file
File diff suppressed because it is too large
Load Diff
75
install.sh
Executable file
75
install.sh
Executable 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
582
main-installer.sh
Executable 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
121
modules/config-module.sh
Executable 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
85
modules/dependencies-module.sh
Executable 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
74
modules/file-creator-module.sh
Executable 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
341
modules/rss-feed-manager.js
Normal 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
73
modules/service-setup-module.sh
Executable 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
|
||||
}
|
113
modules/transmission-client.js
Normal file
113
modules/transmission-client.js
Normal 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
91
modules/utils-module.sh
Executable 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
44
run-app.sh
Executable 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
|
323
src/Api/Controllers/ConfigController.cs
Normal file
323
src/Api/Controllers/ConfigController.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
139
src/Api/Controllers/DashboardController.cs
Normal file
139
src/Api/Controllers/DashboardController.cs
Normal 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" });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
84
src/Api/Controllers/FeedsController.cs
Normal file
84
src/Api/Controllers/FeedsController.cs
Normal 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 });
|
||||
}
|
||||
}
|
||||
}
|
105
src/Api/Controllers/LogsController.cs
Normal file
105
src/Api/Controllers/LogsController.cs
Normal 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; }
|
||||
}
|
||||
}
|
89
src/Api/Controllers/TorrentsController.cs
Normal file
89
src/Api/Controllers/TorrentsController.cs
Normal 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
121
src/Api/Program.cs
Normal 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
194
src/Core/Interfaces.cs
Normal 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);
|
||||
}
|
||||
}
|
317
src/Data/DataMigrationService.cs
Normal file
317
src/Data/DataMigrationService.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
}
|
24
src/Data/Models/LogEntry.cs
Normal file
24
src/Data/Models/LogEntry.cs
Normal 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; }
|
||||
}
|
||||
}
|
47
src/Data/Models/RssFeed.cs
Normal file
47
src/Data/Models/RssFeed.cs
Normal 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;
|
||||
}
|
||||
}
|
45
src/Data/Models/RssFeedItem.cs
Normal file
45
src/Data/Models/RssFeedItem.cs
Normal 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; }
|
||||
}
|
||||
}
|
44
src/Data/Models/RssFeedRule.cs
Normal file
44
src/Data/Models/RssFeedRule.cs
Normal 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>();
|
||||
}
|
||||
}
|
62
src/Data/Models/Torrent.cs
Normal file
62
src/Data/Models/Torrent.cs
Normal 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;
|
||||
}
|
||||
}
|
26
src/Data/Models/UserPreference.cs
Normal file
26
src/Data/Models/UserPreference.cs
Normal 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; }
|
||||
}
|
||||
}
|
37
src/Data/Repositories/IRepository.cs
Normal file
37
src/Data/Repositories/IRepository.cs
Normal 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();
|
||||
}
|
||||
}
|
14
src/Data/Repositories/ITorrentRepository.cs
Normal file
14
src/Data/Repositories/ITorrentRepository.cs
Normal 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();
|
||||
}
|
||||
}
|
90
src/Data/Repositories/Repository.cs
Normal file
90
src/Data/Repositories/Repository.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
44
src/Data/Repositories/TorrentRepository.cs
Normal file
44
src/Data/Repositories/TorrentRepository.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
68
src/Data/TorrentManagerContext.cs
Normal file
68
src/Data/TorrentManagerContext.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
16
src/Data/TorrentManagerContextFactory.cs
Normal file
16
src/Data/TorrentManagerContextFactory.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
116
src/Infrastructure/apply-migrations.sh
Executable file
116
src/Infrastructure/apply-migrations.sh
Executable 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}"
|
355
src/Infrastructure/install-script.sh
Executable file
355
src/Infrastructure/install-script.sh
Executable 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
|
BIN
src/Infrastructure/packages-microsoft-prod.deb
Normal file
BIN
src/Infrastructure/packages-microsoft-prod.deb
Normal file
Binary file not shown.
648
src/Services/ConfigService.cs
Normal file
648
src/Services/ConfigService.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
219
src/Services/LoggingService.cs
Normal file
219
src/Services/LoggingService.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
165
src/Services/MetricsService.cs
Normal file
165
src/Services/MetricsService.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
508
src/Services/Mock/MockServices.cs.bak
Normal file
508
src/Services/Mock/MockServices.cs.bak
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
307
src/Services/PostProcessor.cs
Normal file
307
src/Services/PostProcessor.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
372
src/Services/RssFeedManager.cs
Normal file
372
src/Services/RssFeedManager.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
261
src/Services/SchedulerService.cs
Normal file
261
src/Services/SchedulerService.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
309
src/Services/TransmissionClient.cs
Normal file
309
src/Services/TransmissionClient.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
}
|
70
src/Web/wwwroot/css/dark-mode.css
Normal file
70
src/Web/wwwroot/css/dark-mode.css
Normal 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
src/Web/wwwroot/css/styles.css
Normal file
724
src/Web/wwwroot/css/styles.css
Normal 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
771
src/Web/wwwroot/index.html
Normal 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">«</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">»</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
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
70
wwwroot/css/dark-mode.css
Normal 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
724
wwwroot/css/styles.css
Normal 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
771
wwwroot/index.html
Normal 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">«</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">»</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
1599
wwwroot/js/app.js
Normal file
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user