Move source payloads out of torrent rows

This commit is contained in:
omgbeez 2026-05-29 23:12:03 -04:00
parent f369e97d6e
commit 81318957df
No known key found for this signature in database
15 changed files with 1103 additions and 40 deletions

View file

@ -11,6 +11,7 @@ public class DataContext(DbContextOptions options) : IdentityDbContext(options)
public DbSet<Download> Downloads { get; set; }
public DbSet<Setting> Settings { get; set; }
public DbSet<Torrent> Torrents { get; set; }
public DbSet<TorrentPayload> TorrentPayloads { get; set; }
protected override void OnModelCreating(ModelBuilder builder)
{
@ -24,6 +25,12 @@ public class DataContext(DbContextOptions options) : IdentityDbContext(options)
})
.IsUnique();
builder.Entity<Torrent>()
.HasOne(m => m.Payload)
.WithOne(m => m.Torrent)
.HasForeignKey<TorrentPayload>(m => m.TorrentId)
.IsRequired(false);
var cascadeFKs = builder.Model.GetEntityTypes()
.SelectMany(t => t.GetForeignKeys())
.Where(fk => !fk.IsOwnership && fk.DeleteBehavior == DeleteBehavior.Cascade);

View file

@ -8,6 +8,7 @@ public interface ITorrentData
Task<IList<Torrent>> Get();
Task<Torrent?> GetById(Guid torrentId);
Task<Torrent?> GetByHash(String hash);
Task<String?> GetPayloadContent(Guid torrentId);
Task<Torrent> Add(String? rdId,
String hash,

View file

@ -9,12 +9,9 @@ public class TorrentData(DataContext dataContext, ILogger<TorrentData>? logger =
{
public async Task<IList<Torrent>> 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 class TorrentData(DataContext dataContext, ILogger<TorrentData>? logger =
public async Task<Torrent?> 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 class TorrentData(DataContext dataContext, ILogger<TorrentData>? logger =
{
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 class TorrentData(DataContext dataContext, ILogger<TorrentData>? logger =
return dbTorrent;
}
public async Task<String?> GetPayloadContent(Guid torrentId)
{
return await dataContext.TorrentPayloads
.AsNoTracking()
.Where(m => m.TorrentId == torrentId)
.Select(m => m.Content)
.FirstOrDefaultAsync();
}
public async Task<Torrent> Add(String? rdId,
String hash,
String? fileOrMagnetContents,
@ -88,7 +90,6 @@ public class TorrentData(DataContext dataContext, ILogger<TorrentData>? logger =
DownloadManualFiles = torrent.DownloadManualFiles,
DownloadClient = downloadClient,
Type = downloadType,
FileOrMagnet = fileOrMagnetContents,
IsFile = isFile,
Priority = torrent.Priority,
TorrentRetryAttempts = torrent.TorrentRetryAttempts,
@ -96,7 +97,13 @@ public class TorrentData(DataContext dataContext, ILogger<TorrentData>? logger =
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 @@ public class TorrentData(DataContext dataContext, ILogger<TorrentData>? logger =
.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 @@ public class TorrentData(DataContext dataContext, ILogger<TorrentData>? logger =
return;
}
}
private IQueryable<Torrent> BuildTorrentQuery(Boolean includePayload)
{
var query = dataContext.Torrents
.AsNoTracking()
.AsSplitQuery()
.Include(m => m.Downloads)
.AsQueryable();
if (includePayload)
{
query = query.Include(m => m.Payload);
}
return query;
}
}

View file

@ -0,0 +1,508 @@
// <auto-generated />
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<string>("Id")
.HasColumnType("TEXT");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("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<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ClaimType")
.HasColumnType("TEXT");
b.Property<string>("ClaimValue")
.HasColumnType("TEXT");
b.Property<string>("RoleId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("AspNetRoleClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUser", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<int>("AccessFailedCount")
.HasColumnType("INTEGER");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("TEXT");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<bool>("EmailConfirmed")
.HasColumnType("INTEGER");
b.Property<bool>("LockoutEnabled")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("TEXT");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.HasColumnType("TEXT");
b.Property<string>("PhoneNumber")
.HasColumnType("TEXT");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("INTEGER");
b.Property<string>("SecurityStamp")
.HasColumnType("TEXT");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("INTEGER");
b.Property<string>("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<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ClaimType")
.HasColumnType("TEXT");
b.Property<string>("ClaimValue")
.HasColumnType("TEXT");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AspNetUserClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("TEXT");
b.Property<string>("ProviderKey")
.HasColumnType("TEXT");
b.Property<string>("ProviderDisplayName")
.HasColumnType("TEXT");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("AspNetUserLogins", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("TEXT");
b.Property<string>("RoleId")
.HasColumnType("TEXT");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("AspNetUserRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("TEXT");
b.Property<string>("LoginProvider")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<string>("Value")
.HasColumnType("TEXT");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("AspNetUserTokens", (string)null);
});
modelBuilder.Entity("RdtClient.Data.Models.Data.Download", b =>
{
b.Property<Guid>("DownloadId")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("Added")
.HasColumnType("TEXT");
b.Property<DateTimeOffset?>("Completed")
.HasColumnType("TEXT");
b.Property<DateTimeOffset?>("DownloadFinished")
.HasColumnType("TEXT");
b.Property<DateTimeOffset?>("DownloadQueued")
.HasColumnType("TEXT");
b.Property<DateTimeOffset?>("DownloadStarted")
.HasColumnType("TEXT");
b.Property<string>("Error")
.HasColumnType("TEXT");
b.Property<string>("FileName")
.HasColumnType("TEXT");
b.Property<string>("Link")
.HasColumnType("TEXT");
b.Property<string>("Path")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("RemoteId")
.HasColumnType("TEXT");
b.Property<int>("RetryCount")
.HasColumnType("INTEGER");
b.Property<Guid>("TorrentId")
.HasColumnType("TEXT");
b.Property<DateTimeOffset?>("UnpackingFinished")
.HasColumnType("TEXT");
b.Property<DateTimeOffset?>("UnpackingQueued")
.HasColumnType("TEXT");
b.Property<DateTimeOffset?>("UnpackingStarted")
.HasColumnType("TEXT");
b.HasKey("DownloadId");
b.HasIndex("TorrentId", "Path")
.IsUnique();
b.ToTable("Downloads");
});
modelBuilder.Entity("RdtClient.Data.Models.Data.Setting", b =>
{
b.Property<string>("SettingId")
.HasColumnType("TEXT");
b.Property<string>("Value")
.HasColumnType("TEXT");
b.HasKey("SettingId");
b.ToTable("Settings");
});
modelBuilder.Entity("RdtClient.Data.Models.Data.Torrent", b =>
{
b.Property<Guid>("TorrentId")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("Added")
.HasColumnType("TEXT");
b.Property<string>("Category")
.HasColumnType("TEXT");
b.Property<int?>("ClientKind")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset?>("Completed")
.HasColumnType("TEXT");
b.Property<int>("DeleteOnError")
.HasColumnType("INTEGER");
b.Property<int>("DownloadAction")
.HasColumnType("INTEGER");
b.Property<int>("DownloadClient")
.HasColumnType("INTEGER");
b.Property<string>("DownloadManualFiles")
.HasColumnType("TEXT");
b.Property<int>("DownloadMinSize")
.HasColumnType("INTEGER");
b.Property<int>("DownloadRetryAttempts")
.HasColumnType("INTEGER");
b.Property<string>("Error")
.HasColumnType("TEXT");
b.Property<string>("ExcludeRegex")
.HasColumnType("TEXT");
b.Property<DateTimeOffset?>("FilesSelected")
.HasColumnType("TEXT");
b.Property<int>("FinishedAction")
.HasColumnType("INTEGER");
b.Property<int>("FinishedActionDelay")
.HasColumnType("INTEGER");
b.Property<string>("Hash")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("HostDownloadAction")
.HasColumnType("INTEGER");
b.Property<string>("IncludeRegex")
.HasColumnType("TEXT");
b.Property<bool>("IsFile")
.HasColumnType("INTEGER");
b.Property<int>("Lifetime")
.HasColumnType("INTEGER");
b.Property<int?>("Priority")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset?>("RdAdded")
.HasColumnType("TEXT");
b.Property<DateTimeOffset?>("RdEnded")
.HasColumnType("TEXT");
b.Property<string>("RdFiles")
.HasColumnType("TEXT");
b.Property<string>("RdHost")
.HasColumnType("TEXT");
b.Property<string>("RdId")
.HasColumnType("TEXT");
b.Property<string>("RdName")
.HasColumnType("TEXT");
b.Property<long?>("RdProgress")
.HasColumnType("INTEGER");
b.Property<long?>("RdSeeders")
.HasColumnType("INTEGER");
b.Property<long?>("RdSize")
.HasColumnType("INTEGER");
b.Property<long?>("RdSpeed")
.HasColumnType("INTEGER");
b.Property<long?>("RdSplit")
.HasColumnType("INTEGER");
b.Property<int?>("RdStatus")
.HasColumnType("INTEGER");
b.Property<string>("RdStatusRaw")
.HasColumnType("TEXT");
b.Property<DateTimeOffset?>("Retry")
.HasColumnType("TEXT");
b.Property<int>("RetryCount")
.HasColumnType("INTEGER");
b.Property<int>("TorrentRetryAttempts")
.HasColumnType("INTEGER");
b.Property<int>("Type")
.HasColumnType("INTEGER");
b.HasKey("TorrentId");
b.ToTable("Torrents");
});
modelBuilder.Entity("RdtClient.Data.Models.Data.TorrentPayload", b =>
{
b.Property<Guid>("TorrentId")
.HasColumnType("TEXT");
b.Property<string>("Content")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("TorrentId");
b.ToTable("TorrentPayloads");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", 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<string>", 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
}
}
}

View file

@ -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<Guid>(type: "TEXT", nullable: false),
Content = table.Column<string>(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<string>(
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");
}
}

View file

@ -324,9 +324,6 @@ namespace RdtClient.Data.Migrations
b.Property<string>("ExcludeRegex")
.HasColumnType("TEXT");
b.Property<string>("FileOrMagnet")
.HasColumnType("TEXT");
b.Property<DateTimeOffset?>("FilesSelected")
.HasColumnType("TEXT");
@ -411,6 +408,20 @@ namespace RdtClient.Data.Migrations
b.ToTable("Torrents");
});
modelBuilder.Entity("RdtClient.Data.Models.Data.TorrentPayload", b =>
{
b.Property<Guid>("TorrentId")
.HasColumnType("TEXT");
b.Property<string>("Content")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("TorrentId");
b.ToTable("TorrentPayloads");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
@ -473,9 +484,21 @@ namespace RdtClient.Data.Migrations
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
}

View file

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

View file

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

View file

@ -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<DataContext>()
.UseSqlite(connectionString)
.Options;
return new(options);
}
}

View file

@ -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<IMigrator>();
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<IMigrationsAssembly>();
var modelDiffer = migrationContext.GetService<IMigrationsModelDiffer>();
var designTimeModel = migrationContext.GetService<IDesignTimeModel>();
var modelRuntimeInitializer = migrationContext.GetService<IModelRuntimeInitializer>();
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<String>();
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<DataContext>()
.UseSqlite(connectionString)
.Options;
return new(options);
}
}

View file

@ -26,7 +26,10 @@ public class TorrentRunnerTest
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,

View file

@ -439,4 +439,82 @@ public class TorrentsTest
It.IsAny<Torrent>()),
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<Guid>(),
It.IsAny<String?>(),
It.IsAny<DateTimeOffset?>(),
It.IsAny<Boolean>()))
.Returns(Task.CompletedTask);
mocks.TorrentDataMock.Setup(t => t.UpdateRetry(It.IsAny<Guid>(),
It.IsAny<DateTimeOffset?>(),
It.IsAny<Int32>()))
.Returns(Task.CompletedTask);
mocks.TorrentDataMock.Setup(t => t.Delete(originalTorrent.TorrentId))
.Returns(Task.CompletedTask);
mocks.TorrentDataMock.Setup(t => t.GetByHash(It.IsAny<String>()))
.ReturnsAsync((Torrent?)null);
mocks.TorrentDataMock.Setup(t => t.Add(null,
It.IsAny<String>(),
magnetLink,
false,
DownloadType.Torrent,
originalTorrent.DownloadClient,
It.IsAny<Torrent>()))
.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<String>(),
magnetLink,
false,
DownloadType.Torrent,
originalTorrent.DownloadClient,
It.IsAny<Torrent>()),
Times.Once);
mocks.TorrentDataMock.Verify(t => t.UpdateRetry(requeuedTorrent.TorrentId, null, 3), Times.Once);
}
}

View file

@ -47,7 +47,7 @@ public static class TorrentDtoMapper
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,

View file

@ -367,10 +367,8 @@ public class TorrentRunner(
}
// 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)
{

View file

@ -314,7 +314,7 @@ public class Torrents(
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 @@ public class Torrents(
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 @@ public class Torrents(
/// </summary>
/// <param name="torrent">The torrent from the database to upload to the debrid provider</param>
/// <returns>Updated torrent</returns>
/// <exception cref="Exception">When RdId is not null or FileOrMagnet is null.</exception>
/// <exception cref="Exception">When RdId is not null or the stored source payload is missing.</exception>
public async Task DequeueFromDebridQueue(Torrent torrent)
{
if (torrent.RdId != null)
@ -374,7 +381,9 @@ public class Torrents(
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 class Torrents(
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 class Torrents(
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 class Torrents(
{
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 @@ public class Torrents(
return settingDownloadPath;
}
private async Task<String?> GetPayloadContent(Torrent torrent)
{
if (!String.IsNullOrWhiteSpace(torrent.Payload?.Content))
{
return torrent.Payload.Content;
}
return await torrentData.GetPayloadContent(torrent.TorrentId);
}
private async Task<Torrent> AddQueued(String infoHash,
String fileOrMagnetContents,
Boolean isFile,