diff --git a/server/RdtClient.Data/Data/DataContext.cs b/server/RdtClient.Data/Data/DataContext.cs index 3f2ac5f0..741766f6 100644 --- a/server/RdtClient.Data/Data/DataContext.cs +++ b/server/RdtClient.Data/Data/DataContext.cs @@ -11,6 +11,7 @@ public class DataContext(DbContextOptions options) : IdentityDbContext(options) public DbSet Downloads { get; set; } public DbSet Settings { get; set; } public DbSet Torrents { get; set; } + public DbSet TorrentPayloads { get; set; } protected override void OnModelCreating(ModelBuilder builder) { @@ -24,6 +25,12 @@ protected override void OnModelCreating(ModelBuilder builder) }) .IsUnique(); + builder.Entity() + .HasOne(m => m.Payload) + .WithOne(m => m.Torrent) + .HasForeignKey(m => m.TorrentId) + .IsRequired(false); + var cascadeFKs = builder.Model.GetEntityTypes() .SelectMany(t => t.GetForeignKeys()) .Where(fk => !fk.IsOwnership && fk.DeleteBehavior == DeleteBehavior.Cascade); diff --git a/server/RdtClient.Data/Data/ITorrentData.cs b/server/RdtClient.Data/Data/ITorrentData.cs index 3ff60715..281d28b6 100644 --- a/server/RdtClient.Data/Data/ITorrentData.cs +++ b/server/RdtClient.Data/Data/ITorrentData.cs @@ -8,6 +8,7 @@ public interface ITorrentData Task> Get(); Task GetById(Guid torrentId); Task GetByHash(String hash); + Task GetPayloadContent(Guid torrentId); Task Add(String? rdId, String hash, diff --git a/server/RdtClient.Data/Data/TorrentData.cs b/server/RdtClient.Data/Data/TorrentData.cs index b34a4b08..c5c9e823 100644 --- a/server/RdtClient.Data/Data/TorrentData.cs +++ b/server/RdtClient.Data/Data/TorrentData.cs @@ -9,12 +9,9 @@ public class TorrentData(DataContext dataContext, ILogger? logger = { public async Task> Get() { - var torrents = await dataContext.Torrents - .AsNoTracking() - .AsSplitQuery() - .Include(m => m.Downloads) - .OrderBy(m => m.Priority ?? 9999) - .ToListAsync(); + var torrents = await BuildTorrentQuery(includePayload: false) + .OrderBy(m => m.Priority ?? 9999) + .ToListAsync(); return torrents.OrderBy(m => m.Priority ?? 9999) .ThenBy(m => m.Added) @@ -23,10 +20,8 @@ public async Task> Get() public async Task GetById(Guid torrentId) { - var dbTorrent = await dataContext.Torrents - .AsNoTracking() - .Include(m => m.Downloads) - .FirstOrDefaultAsync(m => m.TorrentId == torrentId); + var dbTorrent = await BuildTorrentQuery(includePayload: true) + .FirstOrDefaultAsync(m => m.TorrentId == torrentId); if (dbTorrent == null) { @@ -45,10 +40,8 @@ public async Task> Get() { hash = hash.ToLower(); - var dbTorrent = await dataContext.Torrents - .AsNoTracking() - .Include(m => m.Downloads) - .FirstOrDefaultAsync(m => m.Hash == hash); + var dbTorrent = await BuildTorrentQuery(includePayload: false) + .FirstOrDefaultAsync(m => m.Hash == hash); if (dbTorrent == null) { @@ -63,6 +56,15 @@ public async Task> Get() return dbTorrent; } + public async Task GetPayloadContent(Guid torrentId) + { + return await dataContext.TorrentPayloads + .AsNoTracking() + .Where(m => m.TorrentId == torrentId) + .Select(m => m.Content) + .FirstOrDefaultAsync(); + } + public async Task Add(String? rdId, String hash, String? fileOrMagnetContents, @@ -88,7 +90,6 @@ public async Task Add(String? rdId, DownloadManualFiles = torrent.DownloadManualFiles, DownloadClient = downloadClient, Type = downloadType, - FileOrMagnet = fileOrMagnetContents, IsFile = isFile, Priority = torrent.Priority, TorrentRetryAttempts = torrent.TorrentRetryAttempts, @@ -96,7 +97,13 @@ public async Task Add(String? rdId, DeleteOnError = torrent.DeleteOnError, Lifetime = torrent.Lifetime, RdStatus = torrent.RdStatus, - RdName = torrent.RdName + RdName = torrent.RdName, + Payload = String.IsNullOrWhiteSpace(fileOrMagnetContents) + ? null + : new() + { + Content = fileOrMagnetContents + } }; await dataContext.Torrents.AddAsync(newTorrent); @@ -294,6 +301,10 @@ await dataContext.Downloads .Where(m => m.TorrentId == torrentId) .ExecuteDeleteAsync(); + await dataContext.TorrentPayloads + .Where(m => m.TorrentId == torrentId) + .ExecuteDeleteAsync(); + var deletedTorrents = await dataContext.Torrents .Where(m => m.TorrentId == torrentId) .ExecuteDeleteAsync(); @@ -307,4 +318,20 @@ await dataContext.Downloads return; } } + + private IQueryable BuildTorrentQuery(Boolean includePayload) + { + var query = dataContext.Torrents + .AsNoTracking() + .AsSplitQuery() + .Include(m => m.Downloads) + .AsQueryable(); + + if (includePayload) + { + query = query.Include(m => m.Payload); + } + + return query; + } } diff --git a/server/RdtClient.Data/Migrations/20260528163000_MoveSourcePayloadToPayloadTable.Designer.cs b/server/RdtClient.Data/Migrations/20260528163000_MoveSourcePayloadToPayloadTable.Designer.cs new file mode 100644 index 00000000..77da9eef --- /dev/null +++ b/server/RdtClient.Data/Migrations/20260528163000_MoveSourcePayloadToPayloadTable.Designer.cs @@ -0,0 +1,508 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using RdtClient.Data.Data; + +#nullable disable + +namespace RdtClient.Data.Migrations +{ + [DbContext(typeof(DataContext))] + [Migration("20260528163000_MoveSourcePayloadToPayloadTable")] + partial class MoveSourcePayloadToPayloadTable + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.5"); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUser", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("RdtClient.Data.Models.Data.Download", b => + { + b.Property("DownloadId") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Added") + .HasColumnType("TEXT"); + + b.Property("Completed") + .HasColumnType("TEXT"); + + b.Property("DownloadFinished") + .HasColumnType("TEXT"); + + b.Property("DownloadQueued") + .HasColumnType("TEXT"); + + b.Property("DownloadStarted") + .HasColumnType("TEXT"); + + b.Property("Error") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("Link") + .HasColumnType("TEXT"); + + b.Property("Path") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RemoteId") + .HasColumnType("TEXT"); + + b.Property("RetryCount") + .HasColumnType("INTEGER"); + + b.Property("TorrentId") + .HasColumnType("TEXT"); + + b.Property("UnpackingFinished") + .HasColumnType("TEXT"); + + b.Property("UnpackingQueued") + .HasColumnType("TEXT"); + + b.Property("UnpackingStarted") + .HasColumnType("TEXT"); + + b.HasKey("DownloadId"); + + b.HasIndex("TorrentId", "Path") + .IsUnique(); + + b.ToTable("Downloads"); + }); + + modelBuilder.Entity("RdtClient.Data.Models.Data.Setting", b => + { + b.Property("SettingId") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("SettingId"); + + b.ToTable("Settings"); + }); + + modelBuilder.Entity("RdtClient.Data.Models.Data.Torrent", b => + { + b.Property("TorrentId") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Added") + .HasColumnType("TEXT"); + + b.Property("Category") + .HasColumnType("TEXT"); + + b.Property("ClientKind") + .HasColumnType("INTEGER"); + + b.Property("Completed") + .HasColumnType("TEXT"); + + b.Property("DeleteOnError") + .HasColumnType("INTEGER"); + + b.Property("DownloadAction") + .HasColumnType("INTEGER"); + + b.Property("DownloadClient") + .HasColumnType("INTEGER"); + + b.Property("DownloadManualFiles") + .HasColumnType("TEXT"); + + b.Property("DownloadMinSize") + .HasColumnType("INTEGER"); + + b.Property("DownloadRetryAttempts") + .HasColumnType("INTEGER"); + + b.Property("Error") + .HasColumnType("TEXT"); + + b.Property("ExcludeRegex") + .HasColumnType("TEXT"); + + b.Property("FilesSelected") + .HasColumnType("TEXT"); + + b.Property("FinishedAction") + .HasColumnType("INTEGER"); + + b.Property("FinishedActionDelay") + .HasColumnType("INTEGER"); + + b.Property("Hash") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("HostDownloadAction") + .HasColumnType("INTEGER"); + + b.Property("IncludeRegex") + .HasColumnType("TEXT"); + + b.Property("IsFile") + .HasColumnType("INTEGER"); + + b.Property("Lifetime") + .HasColumnType("INTEGER"); + + b.Property("Priority") + .HasColumnType("INTEGER"); + + b.Property("RdAdded") + .HasColumnType("TEXT"); + + b.Property("RdEnded") + .HasColumnType("TEXT"); + + b.Property("RdFiles") + .HasColumnType("TEXT"); + + b.Property("RdHost") + .HasColumnType("TEXT"); + + b.Property("RdId") + .HasColumnType("TEXT"); + + b.Property("RdName") + .HasColumnType("TEXT"); + + b.Property("RdProgress") + .HasColumnType("INTEGER"); + + b.Property("RdSeeders") + .HasColumnType("INTEGER"); + + b.Property("RdSize") + .HasColumnType("INTEGER"); + + b.Property("RdSpeed") + .HasColumnType("INTEGER"); + + b.Property("RdSplit") + .HasColumnType("INTEGER"); + + b.Property("RdStatus") + .HasColumnType("INTEGER"); + + b.Property("RdStatusRaw") + .HasColumnType("TEXT"); + + b.Property("Retry") + .HasColumnType("TEXT"); + + b.Property("RetryCount") + .HasColumnType("INTEGER"); + + b.Property("TorrentRetryAttempts") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("TorrentId"); + + b.ToTable("Torrents"); + }); + + modelBuilder.Entity("RdtClient.Data.Models.Data.TorrentPayload", b => + { + b.Property("TorrentId") + .HasColumnType("TEXT"); + + b.Property("Content") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("TorrentId"); + + b.ToTable("TorrentPayloads"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("RdtClient.Data.Models.Data.Download", b => + { + b.HasOne("RdtClient.Data.Models.Data.Torrent", "Torrent") + .WithMany("Downloads") + .HasForeignKey("TorrentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Torrent"); + }); + + modelBuilder.Entity("RdtClient.Data.Models.Data.TorrentPayload", b => + { + b.HasOne("RdtClient.Data.Models.Data.Torrent", "Torrent") + .WithOne("Payload") + .HasForeignKey("RdtClient.Data.Models.Data.TorrentPayload", "TorrentId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.Navigation("Torrent"); + }); + + modelBuilder.Entity("RdtClient.Data.Models.Data.Torrent", b => + { + b.Navigation("Downloads"); + b.Navigation("Payload"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/server/RdtClient.Data/Migrations/20260528163000_MoveSourcePayloadToPayloadTable.cs b/server/RdtClient.Data/Migrations/20260528163000_MoveSourcePayloadToPayloadTable.cs new file mode 100644 index 00000000..d23b94c9 --- /dev/null +++ b/server/RdtClient.Data/Migrations/20260528163000_MoveSourcePayloadToPayloadTable.cs @@ -0,0 +1,65 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace RdtClient.Data.Migrations; + +public partial class MoveSourcePayloadToPayloadTable : Migration +{ + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "TorrentPayloads", + columns: table => new + { + TorrentId = table.Column(type: "TEXT", nullable: false), + Content = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_TorrentPayloads", x => x.TorrentId); + table.ForeignKey( + name: "FK_TorrentPayloads_Torrents_TorrentId", + column: x => x.TorrentId, + principalTable: "Torrents", + principalColumn: "TorrentId", + onDelete: ReferentialAction.NoAction); + }); + + migrationBuilder.Sql(""" + INSERT INTO TorrentPayloads (TorrentId, Content) + SELECT TorrentId, FileOrMagnet + FROM Torrents + WHERE FileOrMagnet IS NOT NULL; + """); + + migrationBuilder.DropColumn( + name: "FileOrMagnet", + table: "Torrents"); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "FileOrMagnet", + table: "Torrents", + type: "TEXT", + nullable: true); + + migrationBuilder.Sql(""" + UPDATE Torrents + SET FileOrMagnet = ( + SELECT Content + FROM TorrentPayloads + WHERE TorrentPayloads.TorrentId = Torrents.TorrentId + ) + WHERE TorrentId IN ( + SELECT TorrentId + FROM TorrentPayloads + ); + """); + + migrationBuilder.DropTable( + name: "TorrentPayloads"); + } +} diff --git a/server/RdtClient.Data/Migrations/DataContextModelSnapshot.cs b/server/RdtClient.Data/Migrations/DataContextModelSnapshot.cs index d28b3a39..4c5d8324 100644 --- a/server/RdtClient.Data/Migrations/DataContextModelSnapshot.cs +++ b/server/RdtClient.Data/Migrations/DataContextModelSnapshot.cs @@ -324,9 +324,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("ExcludeRegex") .HasColumnType("TEXT"); - b.Property("FileOrMagnet") - .HasColumnType("TEXT"); - b.Property("FilesSelected") .HasColumnType("TEXT"); @@ -411,6 +408,20 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("Torrents"); }); + modelBuilder.Entity("RdtClient.Data.Models.Data.TorrentPayload", b => + { + b.Property("TorrentId") + .HasColumnType("TEXT"); + + b.Property("Content") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("TorrentId"); + + b.ToTable("TorrentPayloads"); + }); + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => { b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) @@ -473,9 +484,21 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("Torrent"); }); + modelBuilder.Entity("RdtClient.Data.Models.Data.TorrentPayload", b => + { + b.HasOne("RdtClient.Data.Models.Data.Torrent", "Torrent") + .WithOne("Payload") + .HasForeignKey("RdtClient.Data.Models.Data.TorrentPayload", "TorrentId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.Navigation("Torrent"); + }); + modelBuilder.Entity("RdtClient.Data.Models.Data.Torrent", b => { b.Navigation("Downloads"); + b.Navigation("Payload"); }); #pragma warning restore 612, 618 } diff --git a/server/RdtClient.Data/Models/Data/Torrent.cs b/server/RdtClient.Data/Models/Data/Torrent.cs index 3780f385..5953155a 100644 --- a/server/RdtClient.Data/Models/Data/Torrent.cs +++ b/server/RdtClient.Data/Models/Data/Torrent.cs @@ -35,7 +35,6 @@ public class Torrent public DateTimeOffset? Retry { get; set; } public DownloadType Type { get; set; } - public String? FileOrMagnet { get; set; } public Boolean IsFile { get; set; } public Int32? Priority { get; set; } @@ -50,6 +49,9 @@ public class Torrent [InverseProperty("Torrent")] public IList Downloads { get; set; } = []; + [InverseProperty(nameof(TorrentPayload.Torrent))] + public TorrentPayload? Payload { get; set; } + public Provider? ClientKind { get; set; } public String? RdId { get; set; } public String? RdName { get; set; } diff --git a/server/RdtClient.Data/Models/Data/TorrentPayload.cs b/server/RdtClient.Data/Models/Data/TorrentPayload.cs new file mode 100644 index 00000000..3fd49178 --- /dev/null +++ b/server/RdtClient.Data/Models/Data/TorrentPayload.cs @@ -0,0 +1,15 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace RdtClient.Data.Models.Data; + +public class TorrentPayload +{ + [Key] + [ForeignKey(nameof(Torrent))] + public Guid TorrentId { get; set; } + + public String Content { get; set; } = null!; + + public Torrent Torrent { get; set; } = null!; +} diff --git a/server/RdtClient.Service.Test/Regression/TorrentPayloadDataTests.cs b/server/RdtClient.Service.Test/Regression/TorrentPayloadDataTests.cs new file mode 100644 index 00000000..457cb52b --- /dev/null +++ b/server/RdtClient.Service.Test/Regression/TorrentPayloadDataTests.cs @@ -0,0 +1,138 @@ +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using RdtClient.Data.Data; +using RdtClient.Data.Enums; +using RdtClient.Data.Models.Data; + +namespace RdtClient.Service.Test.Regression; + +public class TorrentPayloadDataTests : IAsyncLifetime +{ + private readonly String _databasePath = Path.Combine(Path.GetTempPath(), $"rdt-client-payload-data-{Guid.NewGuid():N}.sqlite"); + + [Fact] + public async Task Add_ShouldCreatePayloadRow_AndDetailReadShouldIncludePayload() + { + await using var context = CreateContext(); + await context.Database.EnsureCreatedAsync(); + + var torrentData = new TorrentData(context); + var template = new Torrent + { + DownloadClient = DownloadClient.Bezzad, + HostDownloadAction = TorrentHostDownloadAction.DownloadAll, + DownloadAction = TorrentDownloadAction.DownloadAll, + FinishedAction = TorrentFinishedAction.None + }; + + var added = await torrentData.Add(null, + "hash-1", + "magnet:?xt=urn:btih:hash-1", + false, + DownloadType.Torrent, + DownloadClient.Bezzad, + template); + + await using var verifyContext = CreateContext(); + var payloadRow = await verifyContext.TorrentPayloads.SingleAsync(m => m.TorrentId == added.TorrentId); + Assert.Equal("magnet:?xt=urn:btih:hash-1", payloadRow.Content); + + var detailTorrent = await new TorrentData(verifyContext).GetById(added.TorrentId); + Assert.NotNull(detailTorrent); + Assert.NotNull(detailTorrent!.Payload); + Assert.Equal("magnet:?xt=urn:btih:hash-1", detailTorrent.Payload!.Content); + } + + [Fact] + public async Task Get_ShouldNotMaterializePayloadNavigation() + { + await using var context = CreateContext(); + await context.Database.EnsureCreatedAsync(); + + context.Torrents.Add(new Torrent + { + TorrentId = Guid.NewGuid(), + Hash = "hash-2", + Added = DateTimeOffset.UtcNow, + Type = DownloadType.Nzb, + IsFile = true, + Payload = new() + { + Content = Convert.ToBase64String(new Byte[1024]) + } + }); + + await context.SaveChangesAsync(); + + await using var readContext = CreateContext(); + var results = await new TorrentData(readContext).Get(); + + Assert.Single(results); + Assert.Null(results[0].Payload); + } + + [Fact] + public async Task Delete_ShouldRemovePayloadRow() + { + var torrentId = Guid.NewGuid(); + + await using (var context = CreateContext()) + { + await context.Database.EnsureCreatedAsync(); + context.Torrents.Add(new Torrent + { + TorrentId = torrentId, + Hash = "hash-3", + Added = DateTimeOffset.UtcNow, + Type = DownloadType.Torrent, + Payload = new() + { + TorrentId = torrentId, + Content = "magnet:?xt=urn:btih:hash-3" + } + }); + await context.SaveChangesAsync(); + } + + await using (var deleteContext = CreateContext()) + { + await new TorrentData(deleteContext).Delete(torrentId); + } + + await using var verifyContext = CreateContext(); + Assert.False(await verifyContext.Torrents.AnyAsync(m => m.TorrentId == torrentId)); + Assert.False(await verifyContext.TorrentPayloads.AnyAsync(m => m.TorrentId == torrentId)); + } + + public Task InitializeAsync() + { + return Task.CompletedTask; + } + + public Task DisposeAsync() + { + SqliteConnection.ClearAllPools(); + + if (File.Exists(_databasePath)) + { + File.Delete(_databasePath); + } + + return Task.CompletedTask; + } + + private DataContext CreateContext() + { + var connectionString = new SqliteConnectionStringBuilder + { + DataSource = _databasePath, + ForeignKeys = true + }.ToString(); + + var options = new DbContextOptionsBuilder() + .UseSqlite(connectionString) + .Options; + + return new(options); + } +} diff --git a/server/RdtClient.Service.Test/Regression/TorrentPayloadMigrationTests.cs b/server/RdtClient.Service.Test/Regression/TorrentPayloadMigrationTests.cs new file mode 100644 index 00000000..0b6442e7 --- /dev/null +++ b/server/RdtClient.Service.Test/Regression/TorrentPayloadMigrationTests.cs @@ -0,0 +1,177 @@ +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Migrations.Operations; +using RdtClient.Data.Data; +using RdtClient.Data.Enums; +using RdtClient.Data.Models.Data; + +namespace RdtClient.Service.Test.Regression; + +public class TorrentPayloadMigrationTests : IAsyncLifetime +{ + private readonly String _databasePath = Path.Combine(Path.GetTempPath(), $"rdt-client-payload-migration-{Guid.NewGuid():N}.sqlite"); + + [Fact] + public async Task Migrate_ShouldBackfillPayloadTable_AndDropLegacyColumn() + { + await using var migrationContext = CreateContext(); + var migrations = migrationContext.Database.GetMigrations().ToList(); + + Assert.True(migrations.Count >= 2); + + var previousMigration = migrations[^2]; + var migrator = migrationContext.GetService(); + + await migrator.MigrateAsync(previousMigration); + + var torrentId = Guid.NewGuid(); + const String payload = "magnet:?xt=urn:btih:legacy-hash"; + + await migrationContext.Database.ExecuteSqlRawAsync( + """ + INSERT INTO Torrents ( + TorrentId, + Hash, + DownloadAction, + FinishedAction, + FinishedActionDelay, + HostDownloadAction, + DownloadMinSize, + DownloadClient, + Added, + Type, + FileOrMagnet, + IsFile, + RetryCount, + DownloadRetryAttempts, + TorrentRetryAttempts, + DeleteOnError, + Lifetime, + RdName + ) + VALUES ( + {0}, + {1}, + {2}, + {3}, + {4}, + {5}, + {6}, + {7}, + {8}, + {9}, + {10}, + {11}, + {12}, + {13}, + {14}, + {15}, + {16}, + {17} + ); + """, + torrentId, + "legacy-hash", + (Int32)TorrentDownloadAction.DownloadAll, + (Int32)TorrentFinishedAction.None, + 0, + (Int32)TorrentHostDownloadAction.DownloadAll, + 0, + (Int32)DownloadClient.Bezzad, + DateTimeOffset.UtcNow, + (Int32)DownloadType.Torrent, + payload, + false, + 0, + 0, + 0, + 0, + 0, + "Legacy Torrent"); + + var migrationsAssembly = migrationContext.GetService(); + var modelDiffer = migrationContext.GetService(); + var designTimeModel = migrationContext.GetService(); + var modelRuntimeInitializer = migrationContext.GetService(); + var snapshotModel = migrationsAssembly.ModelSnapshot?.Model; + + Assert.NotNull(snapshotModel); + + var initializedSnapshotModel = modelRuntimeInitializer.Initialize(snapshotModel!); + + var pendingOperations = modelDiffer.GetDifferences(initializedSnapshotModel.GetRelationalModel(), designTimeModel.Model.GetRelationalModel()) + .Select(m => m switch + { + DropForeignKeyOperation drop => $"{drop.GetType().Name}:{drop.Table}.{drop.Name}", + AddForeignKeyOperation add => $"{add.GetType().Name}:{add.Table}.{add.Name}:delete={add.OnDelete}", + _ => m.GetType().Name + }) + .ToList(); + + Assert.True(pendingOperations.Count == 0, $"Pending operations: {String.Join(", ", pendingOperations)}"); + + await migrator.MigrateAsync(); + + await using var verificationContext = CreateContext(); + var torrentData = new TorrentData(verificationContext); + var torrent = await torrentData.GetById(torrentId); + + Assert.NotNull(torrent); + Assert.NotNull(torrent!.Payload); + Assert.Equal(payload, torrent.Payload!.Content); + + await using var command = verificationContext.Database.GetDbConnection().CreateCommand(); + command.CommandText = "PRAGMA table_info('Torrents');"; + + if (command.Connection!.State != System.Data.ConnectionState.Open) + { + await command.Connection.OpenAsync(); + } + + var columns = new List(); + + await using var reader = await command.ExecuteReaderAsync(); + + while (await reader.ReadAsync()) + { + columns.Add(reader.GetString(1)); + } + + Assert.DoesNotContain("FileOrMagnet", columns); + } + + public Task InitializeAsync() + { + return Task.CompletedTask; + } + + public Task DisposeAsync() + { + SqliteConnection.ClearAllPools(); + + if (File.Exists(_databasePath)) + { + File.Delete(_databasePath); + } + + return Task.CompletedTask; + } + + private DataContext CreateContext() + { + var connectionString = new SqliteConnectionStringBuilder + { + DataSource = _databasePath, + ForeignKeys = true + }.ToString(); + + var options = new DbContextOptionsBuilder() + .UseSqlite(connectionString) + .Options; + + return new(options); + } +} diff --git a/server/RdtClient.Service.Test/Services/TorrentRunnerTest.cs b/server/RdtClient.Service.Test/Services/TorrentRunnerTest.cs index 573dba18..4422edde 100644 --- a/server/RdtClient.Service.Test/Services/TorrentRunnerTest.cs +++ b/server/RdtClient.Service.Test/Services/TorrentRunnerTest.cs @@ -26,7 +26,10 @@ public async Task Tick_ShouldNotRequeueCompletedErrorTorrent() TorrentId = Guid.NewGuid(), Hash = "hash-1", RdName = "Torrent 1", - FileOrMagnet = "magnet:?xt=urn:btih:hash-1", + Payload = new() + { + Content = "magnet:?xt=urn:btih:hash-1" + }, Type = DownloadType.Torrent, RdStatus = TorrentStatus.Queued, DeleteOnError = 10, diff --git a/server/RdtClient.Service.Test/Services/TorrentsTest.cs b/server/RdtClient.Service.Test/Services/TorrentsTest.cs index a3d3c8a7..95b31251 100644 --- a/server/RdtClient.Service.Test/Services/TorrentsTest.cs +++ b/server/RdtClient.Service.Test/Services/TorrentsTest.cs @@ -439,4 +439,82 @@ public async Task AddNzbLinkToDebridQueue_ShouldSetDownloadTypeNzb() It.IsAny()), Times.Once); } + + [Fact] + public async Task RetryTorrent_ShouldRequeueUsingStoredPayload() + { + var mocks = new Mocks(); + var magnetLink = "magnet:?xt=urn:btih:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa&dn=RetryTorrent"; + var originalTorrent = new Torrent + { + TorrentId = Guid.NewGuid(), + Hash = "legacy-hash", + Type = DownloadType.Torrent, + IsFile = false, + Retry = DateTimeOffset.UtcNow, + Payload = new() + { + Content = magnetLink + }, + Downloads = [], + DownloadClient = DownloadClient.Bezzad + }; + + var requeuedTorrent = new Torrent + { + TorrentId = Guid.NewGuid() + }; + + mocks.TorrentDataMock.Setup(t => t.GetById(originalTorrent.TorrentId)) + .ReturnsAsync(originalTorrent); + mocks.TorrentDataMock.Setup(t => t.UpdateComplete(It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns(Task.CompletedTask); + mocks.TorrentDataMock.Setup(t => t.UpdateRetry(It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns(Task.CompletedTask); + mocks.TorrentDataMock.Setup(t => t.Delete(originalTorrent.TorrentId)) + .Returns(Task.CompletedTask); + mocks.TorrentDataMock.Setup(t => t.GetByHash(It.IsAny())) + .ReturnsAsync((Torrent?)null); + mocks.TorrentDataMock.Setup(t => t.Add(null, + It.IsAny(), + magnetLink, + false, + DownloadType.Torrent, + originalTorrent.DownloadClient, + It.IsAny())) + .ReturnsAsync(requeuedTorrent); + mocks.EnricherMock.Setup(e => e.EnrichMagnetLink(magnetLink)) + .ReturnsAsync(magnetLink); + + var torrents = new TorrentsService(mocks.TorrentsLoggerMock.Object, + mocks.TorrentDataMock.Object, + mocks.DownloadsMock.Object, + mocks.ProcessFactoryMock.Object, + new MockFileSystem(), + mocks.EnricherMock.Object, + null!, + null!, + null!, + null!, + null!, + new TestSettings(), + new TorrentRunnerState()); + + await torrents.RetryTorrent(originalTorrent.TorrentId, 3); + + mocks.TorrentDataMock.Verify(t => t.Add(null, + It.IsAny(), + magnetLink, + false, + DownloadType.Torrent, + originalTorrent.DownloadClient, + It.IsAny()), + Times.Once); + mocks.TorrentDataMock.Verify(t => t.UpdateRetry(requeuedTorrent.TorrentId, null, 3), Times.Once); + } } diff --git a/server/RdtClient.Service/Helpers/TorrentDtoMapper.cs b/server/RdtClient.Service/Helpers/TorrentDtoMapper.cs index fea77fa2..0c8a1127 100644 --- a/server/RdtClient.Service/Helpers/TorrentDtoMapper.cs +++ b/server/RdtClient.Service/Helpers/TorrentDtoMapper.cs @@ -47,7 +47,7 @@ private static TorrentDto ToDto(Torrent torrent, FilesSelected = torrent.FilesSelected, Completed = torrent.Completed, Type = torrent.Type, - FileOrMagnet = includeFileOrMagnet ? torrent.FileOrMagnet : null, + FileOrMagnet = includeFileOrMagnet ? torrent.Payload?.Content : null, IsFile = torrent.IsFile, Priority = torrent.Priority, RetryCount = torrent.RetryCount, diff --git a/server/RdtClient.Service/Services/TorrentRunner.cs b/server/RdtClient.Service/Services/TorrentRunner.cs index 0ee1cbd5..25c2430c 100644 --- a/server/RdtClient.Service/Services/TorrentRunner.cs +++ b/server/RdtClient.Service/Services/TorrentRunner.cs @@ -367,10 +367,8 @@ await remoteService.UpdateRateLimitStatus(new() } // Process torrents in DebridQueue - var torrentsToAddToProvider = allTorrents - .Where(m => m.Completed == null && m.Error == null && m.RdId == null && m.RdAdded == null && m.FileOrMagnet != null && - m.RdStatus == TorrentStatus.Queued) - .ToList(); + var torrentsToAddToProvider = allTorrents.Where(m => m.Completed == null && m.Error == null && m.RdId == null && m.RdAdded == null && m.RdStatus == TorrentStatus.Queued) + .ToList(); if (torrentsToAddToProvider.Count != 0) { diff --git a/server/RdtClient.Service/Services/Torrents.cs b/server/RdtClient.Service/Services/Torrents.cs index 30c600b9..e250de2a 100644 --- a/server/RdtClient.Service/Services/Torrents.cs +++ b/server/RdtClient.Service/Services/Torrents.cs @@ -314,7 +314,7 @@ public virtual async Task AddFileToDebridQueue(Byte[] bytes, Torrent to private async Task CopyAddedTorrent(Torrent torrent) { - if (String.IsNullOrWhiteSpace(settings.Current.General.CopyAddedTorrents) || String.IsNullOrWhiteSpace(torrent.FileOrMagnet) || String.IsNullOrWhiteSpace(torrent.RdName)) + if (String.IsNullOrWhiteSpace(settings.Current.General.CopyAddedTorrents) || String.IsNullOrWhiteSpace(torrent.RdName)) { return; } @@ -345,14 +345,21 @@ private async Task CopyAddedTorrent(Torrent torrent) fileSystem.File.Delete(copyFileName); } + var payloadContent = await GetPayloadContent(torrent); + + if (String.IsNullOrWhiteSpace(payloadContent)) + { + return; + } + if (torrent.IsFile) { - var bytes = Convert.FromBase64String(torrent.FileOrMagnet); + var bytes = Convert.FromBase64String(payloadContent); await fileSystem.File.WriteAllBytesAsync(copyFileName, bytes); } else { - await fileSystem.File.WriteAllTextAsync(copyFileName, torrent.FileOrMagnet); + await fileSystem.File.WriteAllTextAsync(copyFileName, payloadContent); } } catch (Exception ex) @@ -366,7 +373,7 @@ private async Task CopyAddedTorrent(Torrent torrent) /// /// The torrent from the database to upload to the debrid provider /// Updated torrent - /// When RdId is not null or FileOrMagnet is null. + /// When RdId is not null or the stored source payload is missing. public async Task DequeueFromDebridQueue(Torrent torrent) { if (torrent.RdId != null) @@ -374,7 +381,9 @@ public async Task DequeueFromDebridQueue(Torrent torrent) throw new("Torrent already added to debrid provider, cannot dequeue"); } - if (torrent.FileOrMagnet == null) + var payloadContent = await GetPayloadContent(torrent); + + if (payloadContent == null) { throw new("Torrent has no torrent file or magnet link"); } @@ -390,14 +399,14 @@ public async Task DequeueFromDebridQueue(Torrent torrent) if (torrent.Type == DownloadType.Nzb) { id = torrent.IsFile - ? await DebridClient.AddNzbFile(Convert.FromBase64String(torrent.FileOrMagnet), torrent.RdName) - : await DebridClient.AddNzbLink(torrent.FileOrMagnet); + ? await DebridClient.AddNzbFile(Convert.FromBase64String(payloadContent), torrent.RdName) + : await DebridClient.AddNzbLink(payloadContent); } else { id = torrent.IsFile - ? await DebridClient.AddTorrentFile(Convert.FromBase64String(torrent.FileOrMagnet)) - : await DebridClient.AddTorrentMagnet(torrent.FileOrMagnet); + ? await DebridClient.AddTorrentFile(Convert.FromBase64String(payloadContent)) + : await DebridClient.AddTorrentMagnet(payloadContent); } await torrentData.UpdateRdId(torrent, id); @@ -795,7 +804,9 @@ public async Task RetryTorrent(Guid torrentId, Int32 retryCount) await Delete(torrentId, true, true, true); - if (String.IsNullOrWhiteSpace(torrent.FileOrMagnet)) + var payloadContent = await GetPayloadContent(torrent); + + if (String.IsNullOrWhiteSpace(payloadContent)) { throw new($"Cannot re-add this torrent, original magnet or file not found"); } @@ -806,26 +817,26 @@ public async Task RetryTorrent(Guid torrentId, Int32 retryCount) { if (torrent.IsFile) { - var bytes = Convert.FromBase64String(torrent.FileOrMagnet!); + var bytes = Convert.FromBase64String(payloadContent); newTorrent = await AddNzbFileToDebridQueue(bytes, torrent.RdName, torrent); } else { - newTorrent = await AddNzbLinkToDebridQueue(torrent.FileOrMagnet!, torrent); + newTorrent = await AddNzbLinkToDebridQueue(payloadContent, torrent); } } else { if (torrent.IsFile) { - var bytes = Convert.FromBase64String(torrent.FileOrMagnet!); + var bytes = Convert.FromBase64String(payloadContent); newTorrent = await AddFileToDebridQueue(bytes, torrent); } else { - newTorrent = await AddMagnetToDebridQueue(torrent.FileOrMagnet!, torrent); + newTorrent = await AddMagnetToDebridQueue(payloadContent, torrent); } } @@ -975,6 +986,16 @@ private static String DownloadPath(Torrent torrent, DbSettings settings) return settingDownloadPath; } + private async Task GetPayloadContent(Torrent torrent) + { + if (!String.IsNullOrWhiteSpace(torrent.Payload?.Content)) + { + return torrent.Payload.Content; + } + + return await torrentData.GetPayloadContent(torrent.TorrentId); + } + private async Task AddQueued(String infoHash, String fileOrMagnetContents, Boolean isFile,