4 Commits

60 changed files with 2936 additions and 263 deletions

View File

@@ -8,6 +8,10 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.11">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2"/>
</ItemGroup>

View File

@@ -1,107 +1,37 @@
using FictionArchive.Service.NovelService.Models.Enums;
using FictionArchive.Service.NovelService.Models.IntegrationEvents;
using FictionArchive.Service.NovelService.Models.Localization;
using FictionArchive.Service.NovelService.Models.Novels;
using FictionArchive.Service.NovelService.Models.SourceAdapters;
using FictionArchive.Service.NovelService.Services;
using FictionArchive.Service.NovelService.Services.SourceAdapters;
using FictionArchive.Service.Shared.Services.EventBus;
using Microsoft.EntityFrameworkCore;
namespace FictionArchive.Service.NovelService.GraphQL;
public class Mutation
{
// TODO Make this kick off a job in the background somehow. Probably want to think of how jobs will work across services
// Also of course need to make it a proper 'upsert'
public async Task<Novel> ImportNovel(string novelUrl, NovelServiceDbContext dbContext,
IEnumerable<ISourceAdapter> adapters)
public async Task<NovelUpdateRequestedEvent> ImportNovel(string novelUrl, IEventBus eventBus)
{
NovelMetadata? metadata = null;
foreach (ISourceAdapter sourceAdapter in adapters)
var importNovelRequestEvent = new NovelUpdateRequestedEvent()
{
if (await sourceAdapter.CanProcessNovel(novelUrl))
{
metadata = await sourceAdapter.GetMetadata(novelUrl);
}
}
if (metadata == null)
{
throw new NotSupportedException("The provided novel url is currently unsupported.");
}
var systemTags = metadata.SystemTags.Select(tag => new NovelTag()
{
Key = tag,
DisplayName = LocalizationKey.CreateFromText(tag, metadata.RawLanguage),
TagType = TagType.System
});
var sourceTags = metadata.SourceTags.Select(tag => new NovelTag()
{
Key = tag,
DisplayName = LocalizationKey.CreateFromText(tag, metadata.RawLanguage),
TagType = TagType.External
});
var addedNovel = dbContext.Novels.Add(new Novel()
{
Author = new Person()
{
Name = LocalizationKey.CreateFromText(metadata.AuthorName, metadata.RawLanguage),
ExternalUrl = metadata.AuthorUrl,
},
RawLanguage = metadata.RawLanguage,
Url = metadata.Url,
ExternalId = metadata.ExternalId,
Chapters = metadata.Chapters.Select(chapter =>
{
return new Chapter()
{
Order = chapter.Order,
Url = chapter.Url,
Revision = chapter.Revision,
Name = LocalizationKey.CreateFromText(chapter.Name, metadata.RawLanguage),
Body = new LocalizationKey()
{
Texts = new List<LocalizationText>()
}
};
}).ToList(),
Description = LocalizationKey.CreateFromText(metadata.Description, metadata.RawLanguage),
Name = LocalizationKey.CreateFromText(metadata.Name, metadata.RawLanguage),
RawStatus = metadata.RawStatus,
Tags = sourceTags.Concat(systemTags).ToList(),
Source = new Source()
{
Name = metadata.SourceDescriptor.Name,
Url = metadata.SourceDescriptor.Url,
Key = metadata.SourceDescriptor.Key,
}
});
await dbContext.SaveChangesAsync();
return addedNovel.Entity;
NovelUrl = novelUrl
};
await eventBus.Publish(importNovelRequestEvent);
return importNovelRequestEvent;
}
public async Task<Chapter> FetchChapterContents(uint novelId,
public async Task<ChapterPullRequestedEvent> FetchChapterContents(uint novelId,
uint chapterNumber,
NovelServiceDbContext dbContext,
IEnumerable<ISourceAdapter> sourceAdapters)
IEventBus eventBus)
{
var novel = await dbContext.Novels.Where(novel => novel.Id == novelId)
.Include(novel => novel.Chapters)
.ThenInclude(chapter => chapter.Body)
.ThenInclude(body => body.Texts)
.Include(novel => novel.Source)
.FirstOrDefaultAsync();
var chapter = novel.Chapters.Where(chapter => chapter.Order == chapterNumber).FirstOrDefault();
var adapter = sourceAdapters.FirstOrDefault(adapter => adapter.SourceDescriptor.Key == novel.Source.Key);
var rawChapter = await adapter.GetRawChapter(chapter.Url);
chapter.Body.Texts.Add(new LocalizationText()
var chapterPullEvent = new ChapterPullRequestedEvent()
{
Text = rawChapter,
Language = novel.RawLanguage
});
await dbContext.SaveChangesAsync();
return chapter;
NovelId = novelId,
ChapterNumber = chapterNumber
};
await eventBus.Publish(chapterPullEvent);
return chapterPullEvent;
}
}

View File

@@ -1,4 +1,5 @@
// <auto-generated />
using System;
using FictionArchive.Service.NovelService.Services;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -12,7 +13,7 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
namespace FictionArchive.Service.NovelService.Migrations
{
[DbContext(typeof(NovelServiceDbContext))]
[Migration("20251118045235_Initial")]
[Migration("20251120012317_Initial")]
partial class Initial
{
/// <inheritdoc />
@@ -27,11 +28,9 @@ namespace FictionArchive.Service.NovelService.Migrations
modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Localization.LocalizationKey", b =>
{
b.Property<long>("Id")
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
.HasColumnType("uuid");
b.Property<Instant>("CreatedTime")
.HasColumnType("timestamp with time zone");
@@ -41,16 +40,44 @@ namespace FictionArchive.Service.NovelService.Migrations
b.HasKey("Id");
b.ToTable("LocalizationKey");
b.ToTable("LocalizationKeys");
});
modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Localization.LocalizationRequest", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Instant>("CreatedTime")
.HasColumnType("timestamp with time zone");
b.Property<long>("EngineId")
.HasColumnType("bigint");
b.Property<Guid>("KeyRequestedForTranslationId")
.HasColumnType("uuid");
b.Property<Instant>("LastUpdatedTime")
.HasColumnType("timestamp with time zone");
b.Property<int>("TranslateTo")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("EngineId");
b.HasIndex("KeyRequestedForTranslationId");
b.ToTable("LocalizationRequests");
});
modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Localization.LocalizationText", b =>
{
b.Property<long>("Id")
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
.HasColumnType("uuid");
b.Property<Instant>("CreatedTime")
.HasColumnType("timestamp with time zone");
@@ -61,8 +88,8 @@ namespace FictionArchive.Service.NovelService.Migrations
b.Property<Instant>("LastUpdatedTime")
.HasColumnType("timestamp with time zone");
b.Property<long?>("LocalizationKeyId")
.HasColumnType("bigint");
b.Property<Guid?>("LocalizationKeyId")
.HasColumnType("uuid");
b.Property<string>("Text")
.IsRequired()
@@ -88,8 +115,8 @@ namespace FictionArchive.Service.NovelService.Migrations
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<long>("BodyId")
.HasColumnType("bigint");
b.Property<Guid>("BodyId")
.HasColumnType("uuid");
b.Property<Instant>("CreatedTime")
.HasColumnType("timestamp with time zone");
@@ -97,8 +124,8 @@ namespace FictionArchive.Service.NovelService.Migrations
b.Property<Instant>("LastUpdatedTime")
.HasColumnType("timestamp with time zone");
b.Property<long>("NameId")
.HasColumnType("bigint");
b.Property<Guid>("NameId")
.HasColumnType("uuid");
b.Property<long?>("NovelId")
.HasColumnType("bigint");
@@ -137,8 +164,8 @@ namespace FictionArchive.Service.NovelService.Migrations
b.Property<Instant>("CreatedTime")
.HasColumnType("timestamp with time zone");
b.Property<long>("DescriptionId")
.HasColumnType("bigint");
b.Property<Guid>("DescriptionId")
.HasColumnType("uuid");
b.Property<string>("ExternalId")
.IsRequired()
@@ -147,8 +174,8 @@ namespace FictionArchive.Service.NovelService.Migrations
b.Property<Instant>("LastUpdatedTime")
.HasColumnType("timestamp with time zone");
b.Property<long>("NameId")
.HasColumnType("bigint");
b.Property<Guid>("NameId")
.HasColumnType("uuid");
b.Property<int>("RawLanguage")
.HasColumnType("integer");
@@ -190,8 +217,8 @@ namespace FictionArchive.Service.NovelService.Migrations
b.Property<Instant>("CreatedTime")
.HasColumnType("timestamp with time zone");
b.Property<long>("DisplayNameId")
.HasColumnType("bigint");
b.Property<Guid>("DisplayNameId")
.HasColumnType("uuid");
b.Property<string>("Key")
.IsRequired()
@@ -232,12 +259,13 @@ namespace FictionArchive.Service.NovelService.Migrations
b.Property<Instant>("LastUpdatedTime")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<Guid>("NameId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("NameId");
b.ToTable("Person");
});
@@ -283,10 +311,6 @@ namespace FictionArchive.Service.NovelService.Migrations
b.Property<Instant>("CreatedTime")
.HasColumnType("timestamp with time zone");
b.Property<string>("DisplayName")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Key")
.IsRequired()
.HasColumnType("text");
@@ -314,6 +338,25 @@ namespace FictionArchive.Service.NovelService.Migrations
b.ToTable("NovelNovelTag");
});
modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Localization.LocalizationRequest", b =>
{
b.HasOne("FictionArchive.Service.NovelService.Models.Novels.TranslationEngine", "Engine")
.WithMany()
.HasForeignKey("EngineId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("FictionArchive.Service.NovelService.Models.Localization.LocalizationKey", "KeyRequestedForTranslation")
.WithMany()
.HasForeignKey("KeyRequestedForTranslationId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Engine");
b.Navigation("KeyRequestedForTranslation");
});
modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Localization.LocalizationText", b =>
{
b.HasOne("FictionArchive.Service.NovelService.Models.Localization.LocalizationKey", null)
@@ -402,6 +445,17 @@ namespace FictionArchive.Service.NovelService.Migrations
b.Navigation("Source");
});
modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Novels.Person", b =>
{
b.HasOne("FictionArchive.Service.NovelService.Models.Localization.LocalizationKey", "Name")
.WithMany()
.HasForeignKey("NameId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Name");
});
modelBuilder.Entity("NovelNovelTag", b =>
{
b.HasOne("FictionArchive.Service.NovelService.Models.Novels.Novel", null)

View File

@@ -1,4 +1,5 @@
using Microsoft.EntityFrameworkCore.Migrations;
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using NodaTime;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
@@ -13,33 +14,16 @@ namespace FictionArchive.Service.NovelService.Migrations
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "LocalizationKey",
name: "LocalizationKeys",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
Id = table.Column<Guid>(type: "uuid", nullable: false),
CreatedTime = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
LastUpdatedTime = table.Column<Instant>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_LocalizationKey", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Person",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
Name = table.Column<string>(type: "text", nullable: false),
ExternalUrl = table.Column<string>(type: "text", nullable: true),
CreatedTime = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
LastUpdatedTime = table.Column<Instant>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Person", x => x.Id);
table.PrimaryKey("PK_LocalizationKeys", x => x.Id);
});
migrationBuilder.CreateTable(
@@ -66,7 +50,6 @@ namespace FictionArchive.Service.NovelService.Migrations
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
Key = table.Column<string>(type: "text", nullable: false),
DisplayName = table.Column<string>(type: "text", nullable: false),
CreatedTime = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
LastUpdatedTime = table.Column<Instant>(type: "timestamp with time zone", nullable: false)
},
@@ -75,6 +58,112 @@ namespace FictionArchive.Service.NovelService.Migrations
table.PrimaryKey("PK_TranslationEngines", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Person",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
NameId = table.Column<Guid>(type: "uuid", nullable: false),
ExternalUrl = table.Column<string>(type: "text", nullable: true),
CreatedTime = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
LastUpdatedTime = table.Column<Instant>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Person", x => x.Id);
table.ForeignKey(
name: "FK_Person_LocalizationKeys_NameId",
column: x => x.NameId,
principalTable: "LocalizationKeys",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "Tags",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
Key = table.Column<string>(type: "text", nullable: false),
DisplayNameId = table.Column<Guid>(type: "uuid", nullable: false),
TagType = table.Column<int>(type: "integer", nullable: false),
SourceId = table.Column<long>(type: "bigint", nullable: true),
CreatedTime = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
LastUpdatedTime = table.Column<Instant>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Tags", x => x.Id);
table.ForeignKey(
name: "FK_Tags_LocalizationKeys_DisplayNameId",
column: x => x.DisplayNameId,
principalTable: "LocalizationKeys",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_Tags_Sources_SourceId",
column: x => x.SourceId,
principalTable: "Sources",
principalColumn: "Id");
});
migrationBuilder.CreateTable(
name: "LocalizationRequests",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
KeyRequestedForTranslationId = table.Column<Guid>(type: "uuid", nullable: false),
TranslateTo = table.Column<int>(type: "integer", nullable: false),
EngineId = table.Column<long>(type: "bigint", nullable: false),
CreatedTime = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
LastUpdatedTime = table.Column<Instant>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_LocalizationRequests", x => x.Id);
table.ForeignKey(
name: "FK_LocalizationRequests_LocalizationKeys_KeyRequestedForTransl~",
column: x => x.KeyRequestedForTranslationId,
principalTable: "LocalizationKeys",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_LocalizationRequests_TranslationEngines_EngineId",
column: x => x.EngineId,
principalTable: "TranslationEngines",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "LocalizationText",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Language = table.Column<int>(type: "integer", nullable: false),
Text = table.Column<string>(type: "text", nullable: false),
TranslationEngineId = table.Column<long>(type: "bigint", nullable: true),
LocalizationKeyId = table.Column<Guid>(type: "uuid", nullable: true),
CreatedTime = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
LastUpdatedTime = table.Column<Instant>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_LocalizationText", x => x.Id);
table.ForeignKey(
name: "FK_LocalizationText_LocalizationKeys_LocalizationKeyId",
column: x => x.LocalizationKeyId,
principalTable: "LocalizationKeys",
principalColumn: "Id");
table.ForeignKey(
name: "FK_LocalizationText_TranslationEngines_TranslationEngineId",
column: x => x.TranslationEngineId,
principalTable: "TranslationEngines",
principalColumn: "Id");
});
migrationBuilder.CreateTable(
name: "Novels",
columns: table => new
@@ -88,8 +177,8 @@ namespace FictionArchive.Service.NovelService.Migrations
StatusOverride = table.Column<int>(type: "integer", nullable: true),
SourceId = table.Column<long>(type: "bigint", nullable: false),
ExternalId = table.Column<string>(type: "text", nullable: false),
NameId = table.Column<long>(type: "bigint", nullable: false),
DescriptionId = table.Column<long>(type: "bigint", nullable: false),
NameId = table.Column<Guid>(type: "uuid", nullable: false),
DescriptionId = table.Column<Guid>(type: "uuid", nullable: false),
CreatedTime = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
LastUpdatedTime = table.Column<Instant>(type: "timestamp with time zone", nullable: false)
},
@@ -97,15 +186,15 @@ namespace FictionArchive.Service.NovelService.Migrations
{
table.PrimaryKey("PK_Novels", x => x.Id);
table.ForeignKey(
name: "FK_Novels_LocalizationKey_DescriptionId",
name: "FK_Novels_LocalizationKeys_DescriptionId",
column: x => x.DescriptionId,
principalTable: "LocalizationKey",
principalTable: "LocalizationKeys",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_Novels_LocalizationKey_NameId",
name: "FK_Novels_LocalizationKeys_NameId",
column: x => x.NameId,
principalTable: "LocalizationKey",
principalTable: "LocalizationKeys",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
@@ -122,63 +211,6 @@ namespace FictionArchive.Service.NovelService.Migrations
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "Tags",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
Key = table.Column<string>(type: "text", nullable: false),
DisplayNameId = table.Column<long>(type: "bigint", nullable: false),
TagType = table.Column<int>(type: "integer", nullable: false),
SourceId = table.Column<long>(type: "bigint", nullable: true),
CreatedTime = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
LastUpdatedTime = table.Column<Instant>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Tags", x => x.Id);
table.ForeignKey(
name: "FK_Tags_LocalizationKey_DisplayNameId",
column: x => x.DisplayNameId,
principalTable: "LocalizationKey",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_Tags_Sources_SourceId",
column: x => x.SourceId,
principalTable: "Sources",
principalColumn: "Id");
});
migrationBuilder.CreateTable(
name: "LocalizationText",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
Language = table.Column<int>(type: "integer", nullable: false),
Text = table.Column<string>(type: "text", nullable: false),
TranslationEngineId = table.Column<long>(type: "bigint", nullable: true),
LocalizationKeyId = table.Column<long>(type: "bigint", nullable: true),
CreatedTime = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
LastUpdatedTime = table.Column<Instant>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_LocalizationText", x => x.Id);
table.ForeignKey(
name: "FK_LocalizationText_LocalizationKey_LocalizationKeyId",
column: x => x.LocalizationKeyId,
principalTable: "LocalizationKey",
principalColumn: "Id");
table.ForeignKey(
name: "FK_LocalizationText_TranslationEngines_TranslationEngineId",
column: x => x.TranslationEngineId,
principalTable: "TranslationEngines",
principalColumn: "Id");
});
migrationBuilder.CreateTable(
name: "Chapter",
columns: table => new
@@ -188,8 +220,8 @@ namespace FictionArchive.Service.NovelService.Migrations
Revision = table.Column<long>(type: "bigint", nullable: false),
Order = table.Column<long>(type: "bigint", nullable: false),
Url = table.Column<string>(type: "text", nullable: true),
NameId = table.Column<long>(type: "bigint", nullable: false),
BodyId = table.Column<long>(type: "bigint", nullable: false),
NameId = table.Column<Guid>(type: "uuid", nullable: false),
BodyId = table.Column<Guid>(type: "uuid", nullable: false),
NovelId = table.Column<long>(type: "bigint", nullable: true),
CreatedTime = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
LastUpdatedTime = table.Column<Instant>(type: "timestamp with time zone", nullable: false)
@@ -198,15 +230,15 @@ namespace FictionArchive.Service.NovelService.Migrations
{
table.PrimaryKey("PK_Chapter", x => x.Id);
table.ForeignKey(
name: "FK_Chapter_LocalizationKey_BodyId",
name: "FK_Chapter_LocalizationKeys_BodyId",
column: x => x.BodyId,
principalTable: "LocalizationKey",
principalTable: "LocalizationKeys",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_Chapter_LocalizationKey_NameId",
name: "FK_Chapter_LocalizationKeys_NameId",
column: x => x.NameId,
principalTable: "LocalizationKey",
principalTable: "LocalizationKeys",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
@@ -255,6 +287,16 @@ namespace FictionArchive.Service.NovelService.Migrations
table: "Chapter",
column: "NovelId");
migrationBuilder.CreateIndex(
name: "IX_LocalizationRequests_EngineId",
table: "LocalizationRequests",
column: "EngineId");
migrationBuilder.CreateIndex(
name: "IX_LocalizationRequests_KeyRequestedForTranslationId",
table: "LocalizationRequests",
column: "KeyRequestedForTranslationId");
migrationBuilder.CreateIndex(
name: "IX_LocalizationText_LocalizationKeyId",
table: "LocalizationText",
@@ -290,6 +332,11 @@ namespace FictionArchive.Service.NovelService.Migrations
table: "Novels",
column: "SourceId");
migrationBuilder.CreateIndex(
name: "IX_Person_NameId",
table: "Person",
column: "NameId");
migrationBuilder.CreateIndex(
name: "IX_Tags_DisplayNameId",
table: "Tags",
@@ -307,6 +354,9 @@ namespace FictionArchive.Service.NovelService.Migrations
migrationBuilder.DropTable(
name: "Chapter");
migrationBuilder.DropTable(
name: "LocalizationRequests");
migrationBuilder.DropTable(
name: "LocalizationText");
@@ -326,10 +376,10 @@ namespace FictionArchive.Service.NovelService.Migrations
name: "Person");
migrationBuilder.DropTable(
name: "LocalizationKey");
name: "Sources");
migrationBuilder.DropTable(
name: "Sources");
name: "LocalizationKeys");
}
}
}

View File

@@ -1,4 +1,5 @@
// <auto-generated />
using System;
using FictionArchive.Service.NovelService.Services;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -24,11 +25,9 @@ namespace FictionArchive.Service.NovelService.Migrations
modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Localization.LocalizationKey", b =>
{
b.Property<long>("Id")
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
.HasColumnType("uuid");
b.Property<Instant>("CreatedTime")
.HasColumnType("timestamp with time zone");
@@ -38,16 +37,44 @@ namespace FictionArchive.Service.NovelService.Migrations
b.HasKey("Id");
b.ToTable("LocalizationKey");
b.ToTable("LocalizationKeys");
});
modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Localization.LocalizationRequest", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Instant>("CreatedTime")
.HasColumnType("timestamp with time zone");
b.Property<long>("EngineId")
.HasColumnType("bigint");
b.Property<Guid>("KeyRequestedForTranslationId")
.HasColumnType("uuid");
b.Property<Instant>("LastUpdatedTime")
.HasColumnType("timestamp with time zone");
b.Property<int>("TranslateTo")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("EngineId");
b.HasIndex("KeyRequestedForTranslationId");
b.ToTable("LocalizationRequests");
});
modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Localization.LocalizationText", b =>
{
b.Property<long>("Id")
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
.HasColumnType("uuid");
b.Property<Instant>("CreatedTime")
.HasColumnType("timestamp with time zone");
@@ -58,8 +85,8 @@ namespace FictionArchive.Service.NovelService.Migrations
b.Property<Instant>("LastUpdatedTime")
.HasColumnType("timestamp with time zone");
b.Property<long?>("LocalizationKeyId")
.HasColumnType("bigint");
b.Property<Guid?>("LocalizationKeyId")
.HasColumnType("uuid");
b.Property<string>("Text")
.IsRequired()
@@ -85,8 +112,8 @@ namespace FictionArchive.Service.NovelService.Migrations
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<long>("BodyId")
.HasColumnType("bigint");
b.Property<Guid>("BodyId")
.HasColumnType("uuid");
b.Property<Instant>("CreatedTime")
.HasColumnType("timestamp with time zone");
@@ -94,8 +121,8 @@ namespace FictionArchive.Service.NovelService.Migrations
b.Property<Instant>("LastUpdatedTime")
.HasColumnType("timestamp with time zone");
b.Property<long>("NameId")
.HasColumnType("bigint");
b.Property<Guid>("NameId")
.HasColumnType("uuid");
b.Property<long?>("NovelId")
.HasColumnType("bigint");
@@ -134,8 +161,8 @@ namespace FictionArchive.Service.NovelService.Migrations
b.Property<Instant>("CreatedTime")
.HasColumnType("timestamp with time zone");
b.Property<long>("DescriptionId")
.HasColumnType("bigint");
b.Property<Guid>("DescriptionId")
.HasColumnType("uuid");
b.Property<string>("ExternalId")
.IsRequired()
@@ -144,8 +171,8 @@ namespace FictionArchive.Service.NovelService.Migrations
b.Property<Instant>("LastUpdatedTime")
.HasColumnType("timestamp with time zone");
b.Property<long>("NameId")
.HasColumnType("bigint");
b.Property<Guid>("NameId")
.HasColumnType("uuid");
b.Property<int>("RawLanguage")
.HasColumnType("integer");
@@ -187,8 +214,8 @@ namespace FictionArchive.Service.NovelService.Migrations
b.Property<Instant>("CreatedTime")
.HasColumnType("timestamp with time zone");
b.Property<long>("DisplayNameId")
.HasColumnType("bigint");
b.Property<Guid>("DisplayNameId")
.HasColumnType("uuid");
b.Property<string>("Key")
.IsRequired()
@@ -229,12 +256,13 @@ namespace FictionArchive.Service.NovelService.Migrations
b.Property<Instant>("LastUpdatedTime")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<Guid>("NameId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("NameId");
b.ToTable("Person");
});
@@ -280,10 +308,6 @@ namespace FictionArchive.Service.NovelService.Migrations
b.Property<Instant>("CreatedTime")
.HasColumnType("timestamp with time zone");
b.Property<string>("DisplayName")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Key")
.IsRequired()
.HasColumnType("text");
@@ -311,6 +335,25 @@ namespace FictionArchive.Service.NovelService.Migrations
b.ToTable("NovelNovelTag");
});
modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Localization.LocalizationRequest", b =>
{
b.HasOne("FictionArchive.Service.NovelService.Models.Novels.TranslationEngine", "Engine")
.WithMany()
.HasForeignKey("EngineId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("FictionArchive.Service.NovelService.Models.Localization.LocalizationKey", "KeyRequestedForTranslation")
.WithMany()
.HasForeignKey("KeyRequestedForTranslationId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Engine");
b.Navigation("KeyRequestedForTranslation");
});
modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Localization.LocalizationText", b =>
{
b.HasOne("FictionArchive.Service.NovelService.Models.Localization.LocalizationKey", null)
@@ -399,6 +442,17 @@ namespace FictionArchive.Service.NovelService.Migrations
b.Navigation("Source");
});
modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Novels.Person", b =>
{
b.HasOne("FictionArchive.Service.NovelService.Models.Localization.LocalizationKey", "Name")
.WithMany()
.HasForeignKey("NameId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Name");
});
modelBuilder.Entity("NovelNovelTag", b =>
{
b.HasOne("FictionArchive.Service.NovelService.Models.Novels.Novel", null)

View File

@@ -0,0 +1,9 @@
using FictionArchive.Service.Shared.Services.EventBus;
namespace FictionArchive.Service.NovelService.Models.IntegrationEvents;
public class ChapterPullRequestedEvent : IntegrationEvent
{
public uint NovelId { get; set; }
public uint ChapterNumber { get; set; }
}

View File

@@ -0,0 +1,8 @@
using FictionArchive.Service.Shared.Services.EventBus;
namespace FictionArchive.Service.NovelService.Models.IntegrationEvents;
public class NovelUpdateRequestedEvent : IntegrationEvent
{
public string NovelUrl { get; set; }
}

View File

@@ -0,0 +1,17 @@
using FictionArchive.Common.Enums;
using FictionArchive.Service.Shared.Services.EventBus;
namespace FictionArchive.Service.NovelService.Models.IntegrationEvents;
public class TranslationRequestCompletedEvent : IntegrationEvent
{
/// <summary>
/// Maps this event back to a triggering request.
/// </summary>
public Guid? TranslationRequestId { get; set; }
/// <summary>
/// The resulting text.
/// </summary>
public string? TranslatedText { get; set; }
}

View File

@@ -0,0 +1,13 @@
using FictionArchive.Common.Enums;
using FictionArchive.Service.Shared.Services.EventBus;
namespace FictionArchive.Service.NovelService.Models.IntegrationEvents;
public class TranslationRequestCreatedEvent : IntegrationEvent
{
public Guid TranslationRequestId { get; set; }
public Language From { get; set; }
public Language To { get; set; }
public string Body { get; set; }
public string TranslationEngineKey { get; set; }
}

View File

@@ -5,7 +5,7 @@ using Microsoft.EntityFrameworkCore;
namespace FictionArchive.Service.NovelService.Models.Localization;
public class LocalizationKey : BaseEntity<uint>
public class LocalizationKey : BaseEntity<Guid>
{
public List<LocalizationText> Texts { get; set; }

View File

@@ -0,0 +1,12 @@
using FictionArchive.Common.Enums;
using FictionArchive.Service.NovelService.Models.Novels;
using FictionArchive.Service.Shared.Models;
namespace FictionArchive.Service.NovelService.Models.Localization;
public class LocalizationRequest : BaseEntity<Guid>
{
public LocalizationKey KeyRequestedForTranslation { get; set; }
public Language TranslateTo { get; set; }
public TranslationEngine Engine { get; set; }
}

View File

@@ -4,7 +4,7 @@ using FictionArchive.Service.Shared.Models;
namespace FictionArchive.Service.NovelService.Models.Localization;
public class LocalizationText : BaseEntity<uint>
public class LocalizationText : BaseEntity<Guid>
{
public Language Language { get; set; }
public string Text { get; set; }

View File

@@ -5,5 +5,4 @@ namespace FictionArchive.Service.NovelService.Models.Novels;
public class TranslationEngine : BaseEntity<uint>
{
public string Key { get; set; }
public string DisplayName { get; set; }
}

View File

@@ -1,8 +1,11 @@
using FictionArchive.Service.NovelService.GraphQL;
using FictionArchive.Service.NovelService.Models.IntegrationEvents;
using FictionArchive.Service.NovelService.Services;
using FictionArchive.Service.NovelService.Services.EventHandlers;
using FictionArchive.Service.NovelService.Services.SourceAdapters;
using FictionArchive.Service.NovelService.Services.SourceAdapters.Novelpia;
using FictionArchive.Service.Shared.Extensions;
using FictionArchive.Service.Shared.Services.EventBus.Implementations;
using FictionArchive.Service.Shared.Services.GraphQL;
using Microsoft.EntityFrameworkCore;
@@ -16,6 +19,18 @@ public class Program
builder.Services.AddMemoryCache();
#region Event Bus
builder.Services.AddRabbitMQ(opt =>
{
builder.Configuration.GetSection("RabbitMQ").Bind(opt);
})
.Subscribe<TranslationRequestCompletedEvent, TranslationRequestCompletedEventHandler>()
.Subscribe<NovelUpdateRequestedEvent, NovelUpdateRequestedEventHandler>()
.Subscribe<ChapterPullRequestedEvent, ChapterPullRequestedEventHandler>();
#endregion
#region GraphQL
builder.Services.AddDefaultGraphQl<Query, Mutation>();
@@ -40,6 +55,8 @@ public class Program
client.BaseAddress = new Uri("https://novelpia.com");
})
.AddHttpMessageHandler<NovelpiaAuthMessageHandler>();
builder.Services.AddTransient<NovelUpdateService>();
#endregion

View File

@@ -0,0 +1,19 @@
using FictionArchive.Service.NovelService.Models.IntegrationEvents;
using FictionArchive.Service.Shared.Services.EventBus;
namespace FictionArchive.Service.NovelService.Services.EventHandlers;
public class ChapterPullRequestedEventHandler : IIntegrationEventHandler<ChapterPullRequestedEvent>
{
private readonly NovelUpdateService _novelUpdateService;
public ChapterPullRequestedEventHandler(NovelUpdateService novelUpdateService)
{
_novelUpdateService = novelUpdateService;
}
public async Task Handle(ChapterPullRequestedEvent @event)
{
await _novelUpdateService.PullChapterContents(@event.NovelId, @event.ChapterNumber);
}
}

View File

@@ -0,0 +1,23 @@
using FictionArchive.Service.NovelService.Models.IntegrationEvents;
using FictionArchive.Service.Shared.Services.EventBus;
namespace FictionArchive.Service.NovelService.Services.EventHandlers;
public class NovelUpdateRequestedEventHandler : IIntegrationEventHandler<NovelUpdateRequestedEvent>
{
private readonly ILogger<NovelUpdateRequestedEventHandler> _logger;
private readonly IEventBus _eventBus;
private readonly NovelUpdateService _novelUpdateService;
public NovelUpdateRequestedEventHandler(ILogger<NovelUpdateRequestedEventHandler> logger, IEventBus eventBus, NovelUpdateService novelUpdateService)
{
_logger = logger;
_eventBus = eventBus;
_novelUpdateService = novelUpdateService;
}
public async Task Handle(NovelUpdateRequestedEvent @event)
{
await _novelUpdateService.ImportNovel(@event.NovelUrl);
}
}

View File

@@ -0,0 +1,39 @@
using FictionArchive.Service.NovelService.Models.IntegrationEvents;
using FictionArchive.Service.NovelService.Models.Localization;
using FictionArchive.Service.Shared.Services.EventBus;
using Microsoft.EntityFrameworkCore;
namespace FictionArchive.Service.NovelService.Services.EventHandlers;
public class TranslationRequestCompletedEventHandler : IIntegrationEventHandler<TranslationRequestCompletedEvent>
{
private readonly ILogger<TranslationRequestCompletedEventHandler> _logger;
private readonly NovelServiceDbContext _dbContext;
public TranslationRequestCompletedEventHandler(ILogger<TranslationRequestCompletedEventHandler> logger, NovelServiceDbContext dbContext)
{
_logger = logger;
_dbContext = dbContext;
}
public async Task Handle(TranslationRequestCompletedEvent @event)
{
var localizationRequest = await _dbContext.LocalizationRequests.Include(r => r.KeyRequestedForTranslation)
.ThenInclude(lk => lk.Texts)
.FirstOrDefaultAsync(lk => lk.Id == @event.TranslationRequestId);
if (localizationRequest == null)
{
// Not one of our requests, discard it
return;
}
localizationRequest.KeyRequestedForTranslation.Texts.Add(new LocalizationText()
{
Language = localizationRequest.TranslateTo,
Text = @event.TranslatedText,
TranslationEngine = localizationRequest.Engine
});
_dbContext.LocalizationRequests.Remove(localizationRequest);
await _dbContext.SaveChangesAsync();
}
}

View File

@@ -1,3 +1,4 @@
using FictionArchive.Service.NovelService.Models.Localization;
using FictionArchive.Service.NovelService.Models.Novels;
using FictionArchive.Service.Shared.Services.Database;
using Microsoft.EntityFrameworkCore;
@@ -11,4 +12,6 @@ public class NovelServiceDbContext(DbContextOptions options, ILogger<NovelServic
public DbSet<Source> Sources { get; set; }
public DbSet<TranslationEngine> TranslationEngines { get; set; }
public DbSet<NovelTag> Tags { get; set; }
public DbSet<LocalizationKey> LocalizationKeys { get; set; }
public DbSet<LocalizationRequest> LocalizationRequests { get; set; }
}

View File

@@ -0,0 +1,111 @@
using FictionArchive.Service.NovelService.Models.Enums;
using FictionArchive.Service.NovelService.Models.Localization;
using FictionArchive.Service.NovelService.Models.Novels;
using FictionArchive.Service.NovelService.Models.SourceAdapters;
using FictionArchive.Service.NovelService.Services.SourceAdapters;
using Microsoft.EntityFrameworkCore;
namespace FictionArchive.Service.NovelService.Services;
public class NovelUpdateService
{
private readonly NovelServiceDbContext _dbContext;
private readonly ILogger<NovelUpdateService> _logger;
private readonly IEnumerable<ISourceAdapter> _sourceAdapters;
public NovelUpdateService(NovelServiceDbContext dbContext, ILogger<NovelUpdateService> logger, IEnumerable<ISourceAdapter> sourceAdapters)
{
_dbContext = dbContext;
_logger = logger;
_sourceAdapters = sourceAdapters;
}
public async Task<Novel> ImportNovel(string novelUrl)
{
NovelMetadata? metadata = null;
foreach (ISourceAdapter sourceAdapter in _sourceAdapters)
{
if (await sourceAdapter.CanProcessNovel(novelUrl))
{
metadata = await sourceAdapter.GetMetadata(novelUrl);
}
}
if (metadata == null)
{
throw new NotSupportedException("The provided novel url is currently unsupported.");
}
var systemTags = metadata.SystemTags.Select(tag => new NovelTag()
{
Key = tag,
DisplayName = LocalizationKey.CreateFromText(tag, metadata.RawLanguage),
TagType = TagType.System
});
var sourceTags = metadata.SourceTags.Select(tag => new NovelTag()
{
Key = tag,
DisplayName = LocalizationKey.CreateFromText(tag, metadata.RawLanguage),
TagType = TagType.External
});
var addedNovel = _dbContext.Novels.Add(new Novel()
{
Author = new Person()
{
Name = LocalizationKey.CreateFromText(metadata.AuthorName, metadata.RawLanguage),
ExternalUrl = metadata.AuthorUrl,
},
RawLanguage = metadata.RawLanguage,
Url = metadata.Url,
ExternalId = metadata.ExternalId,
Chapters = metadata.Chapters.Select(chapter =>
{
return new Chapter()
{
Order = chapter.Order,
Url = chapter.Url,
Revision = chapter.Revision,
Name = LocalizationKey.CreateFromText(chapter.Name, metadata.RawLanguage),
Body = new LocalizationKey()
{
Texts = new List<LocalizationText>()
}
};
}).ToList(),
Description = LocalizationKey.CreateFromText(metadata.Description, metadata.RawLanguage),
Name = LocalizationKey.CreateFromText(metadata.Name, metadata.RawLanguage),
RawStatus = metadata.RawStatus,
Tags = sourceTags.Concat(systemTags).ToList(),
Source = new Source()
{
Name = metadata.SourceDescriptor.Name,
Url = metadata.SourceDescriptor.Url,
Key = metadata.SourceDescriptor.Key,
}
});
await _dbContext.SaveChangesAsync();
return addedNovel.Entity;
}
public async Task<Chapter> PullChapterContents(uint novelId, uint chapterNumber)
{
var novel = await _dbContext.Novels.Where(novel => novel.Id == novelId)
.Include(novel => novel.Chapters)
.ThenInclude(chapter => chapter.Body)
.ThenInclude(body => body.Texts)
.Include(novel => novel.Source)
.FirstOrDefaultAsync();
var chapter = novel.Chapters.Where(chapter => chapter.Order == chapterNumber).FirstOrDefault();
var adapter = _sourceAdapters.FirstOrDefault(adapter => adapter.SourceDescriptor.Key == novel.Source.Key);
var rawChapter = await adapter.GetRawChapter(chapter.Url);
chapter.Body.Texts.Add(new LocalizationText()
{
Text = rawChapter,
Language = novel.RawLanguage
});
await _dbContext.SaveChangesAsync();
return chapter;
}
}

View File

@@ -12,5 +12,9 @@
"ConnectionStrings": {
"DefaultConnection": "Host=localhost;Database=FictionArchive_NovelService;Username=postgres;password=postgres"
},
"RabbitMQ": {
"ConnectionString": "amqp://localhost",
"ClientIdentifier": "NovelService"
},
"AllowedHosts": "*"
}

View File

@@ -0,0 +1,23 @@
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base
USER $APP_UID
WORKDIR /app
EXPOSE 8080
EXPOSE 8081
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
COPY ["FictionArchive.Service.SchedulerService/FictionArchive.Service.SchedulerService.csproj", "FictionArchive.Service.SchedulerService/"]
RUN dotnet restore "FictionArchive.Service.SchedulerService/FictionArchive.Service.SchedulerService.csproj"
COPY . .
WORKDIR "/src/FictionArchive.Service.SchedulerService"
RUN dotnet build "./FictionArchive.Service.SchedulerService.csproj" -c $BUILD_CONFIGURATION -o /app/build
FROM build AS publish
ARG BUILD_CONFIGURATION=Release
RUN dotnet publish "./FictionArchive.Service.SchedulerService.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "FictionArchive.Service.SchedulerService.dll"]

View File

@@ -0,0 +1,32 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
</PropertyGroup>
<ItemGroup>
<Content Include="..\.dockerignore">
<Link>.dockerignore</Link>
</Content>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\FictionArchive.Service.Shared\FictionArchive.Service.Shared.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="AppAny.Quartz.EntityFrameworkCore.Migrations.PostgreSQL" Version="0.5.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.11">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Quartz" Version="3.15.1" />
<PackageReference Include="Quartz.AspNetCore" Version="3.15.1" />
<PackageReference Include="Quartz.Extensions.DependencyInjection" Version="3.15.1" />
<PackageReference Include="Quartz.Serialization.Json" Version="3.15.1" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,35 @@
using System.Data;
using FictionArchive.Service.SchedulerService.Models;
using FictionArchive.Service.SchedulerService.Services;
using HotChocolate.Types;
using Quartz;
namespace FictionArchive.Service.SchedulerService.GraphQL;
public class Mutation
{
[Error<DuplicateNameException>]
[Error<FormatException>]
public async Task<SchedulerJob> ScheduleEventJob(string key, string description, string eventType, string eventData, string cronSchedule, JobManagerService jobManager)
{
return await jobManager.ScheduleEventJob(key, description, eventType, eventData, cronSchedule);
}
[Error<JobPersistenceException>]
public async Task<bool> RunJob(string jobKey, JobManagerService jobManager)
{
return await jobManager.TriggerJob(jobKey);
}
[Error<KeyNotFoundException>]
public async Task<bool> DeleteJob(string jobKey, JobManagerService jobManager)
{
bool deleted = await jobManager.DeleteJob(jobKey);
if (!deleted)
{
throw new KeyNotFoundException($"Job with key '{jobKey}' was not found");
}
return true;
}
}

View File

@@ -0,0 +1,15 @@
using FictionArchive.Service.SchedulerService.Models;
using FictionArchive.Service.SchedulerService.Services;
using HotChocolate;
using Quartz;
using Quartz.Impl.Matchers;
namespace FictionArchive.Service.SchedulerService.GraphQL;
public class Query
{
public async Task<IEnumerable<SchedulerJob>> GetJobs(JobManagerService jobManager)
{
return await jobManager.GetScheduledJobs();
}
}

View File

@@ -0,0 +1,543 @@
// <auto-generated />
using FictionArchive.Service.SchedulerService.Services;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace FictionArchive.Service.SchedulerService.Migrations
{
[DbContext(typeof(SchedulerServiceDbContext))]
[Migration("20251120151130_Initial")]
partial class Initial
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "9.0.11")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzBlobTrigger", b =>
{
b.Property<string>("SchedulerName")
.HasColumnType("text")
.HasColumnName("sched_name");
b.Property<string>("TriggerName")
.HasColumnType("text")
.HasColumnName("trigger_name");
b.Property<string>("TriggerGroup")
.HasColumnType("text")
.HasColumnName("trigger_group");
b.Property<byte[]>("BlobData")
.HasColumnType("bytea")
.HasColumnName("blob_data");
b.HasKey("SchedulerName", "TriggerName", "TriggerGroup");
b.ToTable("qrtz_blob_triggers", "quartz");
});
modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzCalendar", b =>
{
b.Property<string>("SchedulerName")
.HasColumnType("text")
.HasColumnName("sched_name");
b.Property<string>("CalendarName")
.HasColumnType("text")
.HasColumnName("calendar_name");
b.Property<byte[]>("Calendar")
.IsRequired()
.HasColumnType("bytea")
.HasColumnName("calendar");
b.HasKey("SchedulerName", "CalendarName");
b.ToTable("qrtz_calendars", "quartz");
});
modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzCronTrigger", b =>
{
b.Property<string>("SchedulerName")
.HasColumnType("text")
.HasColumnName("sched_name");
b.Property<string>("TriggerName")
.HasColumnType("text")
.HasColumnName("trigger_name");
b.Property<string>("TriggerGroup")
.HasColumnType("text")
.HasColumnName("trigger_group");
b.Property<string>("CronExpression")
.IsRequired()
.HasColumnType("text")
.HasColumnName("cron_expression");
b.Property<string>("TimeZoneId")
.HasColumnType("text")
.HasColumnName("time_zone_id");
b.HasKey("SchedulerName", "TriggerName", "TriggerGroup");
b.ToTable("qrtz_cron_triggers", "quartz");
});
modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzFiredTrigger", b =>
{
b.Property<string>("SchedulerName")
.HasColumnType("text")
.HasColumnName("sched_name");
b.Property<string>("EntryId")
.HasColumnType("text")
.HasColumnName("entry_id");
b.Property<long>("FiredTime")
.HasColumnType("bigint")
.HasColumnName("fired_time");
b.Property<string>("InstanceName")
.IsRequired()
.HasColumnType("text")
.HasColumnName("instance_name");
b.Property<bool>("IsNonConcurrent")
.HasColumnType("bool")
.HasColumnName("is_nonconcurrent");
b.Property<string>("JobGroup")
.HasColumnType("text")
.HasColumnName("job_group");
b.Property<string>("JobName")
.HasColumnType("text")
.HasColumnName("job_name");
b.Property<int>("Priority")
.HasColumnType("integer")
.HasColumnName("priority");
b.Property<bool?>("RequestsRecovery")
.HasColumnType("bool")
.HasColumnName("requests_recovery");
b.Property<long>("ScheduledTime")
.HasColumnType("bigint")
.HasColumnName("sched_time");
b.Property<string>("State")
.IsRequired()
.HasColumnType("text")
.HasColumnName("state");
b.Property<string>("TriggerGroup")
.IsRequired()
.HasColumnType("text")
.HasColumnName("trigger_group");
b.Property<string>("TriggerName")
.IsRequired()
.HasColumnType("text")
.HasColumnName("trigger_name");
b.HasKey("SchedulerName", "EntryId");
b.HasIndex("InstanceName")
.HasDatabaseName("idx_qrtz_ft_trig_inst_name");
b.HasIndex("JobGroup")
.HasDatabaseName("idx_qrtz_ft_job_group");
b.HasIndex("JobName")
.HasDatabaseName("idx_qrtz_ft_job_name");
b.HasIndex("RequestsRecovery")
.HasDatabaseName("idx_qrtz_ft_job_req_recovery");
b.HasIndex("TriggerGroup")
.HasDatabaseName("idx_qrtz_ft_trig_group");
b.HasIndex("TriggerName")
.HasDatabaseName("idx_qrtz_ft_trig_name");
b.HasIndex("SchedulerName", "TriggerName", "TriggerGroup")
.HasDatabaseName("idx_qrtz_ft_trig_nm_gp");
b.ToTable("qrtz_fired_triggers", "quartz");
});
modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzJobDetail", b =>
{
b.Property<string>("SchedulerName")
.HasColumnType("text")
.HasColumnName("sched_name");
b.Property<string>("JobName")
.HasColumnType("text")
.HasColumnName("job_name");
b.Property<string>("JobGroup")
.HasColumnType("text")
.HasColumnName("job_group");
b.Property<string>("Description")
.HasColumnType("text")
.HasColumnName("description");
b.Property<bool>("IsDurable")
.HasColumnType("bool")
.HasColumnName("is_durable");
b.Property<bool>("IsNonConcurrent")
.HasColumnType("bool")
.HasColumnName("is_nonconcurrent");
b.Property<bool>("IsUpdateData")
.HasColumnType("bool")
.HasColumnName("is_update_data");
b.Property<string>("JobClassName")
.IsRequired()
.HasColumnType("text")
.HasColumnName("job_class_name");
b.Property<byte[]>("JobData")
.HasColumnType("bytea")
.HasColumnName("job_data");
b.Property<bool>("RequestsRecovery")
.HasColumnType("bool")
.HasColumnName("requests_recovery");
b.HasKey("SchedulerName", "JobName", "JobGroup");
b.HasIndex("RequestsRecovery")
.HasDatabaseName("idx_qrtz_j_req_recovery");
b.ToTable("qrtz_job_details", "quartz");
});
modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzLock", b =>
{
b.Property<string>("SchedulerName")
.HasColumnType("text")
.HasColumnName("sched_name");
b.Property<string>("LockName")
.HasColumnType("text")
.HasColumnName("lock_name");
b.HasKey("SchedulerName", "LockName");
b.ToTable("qrtz_locks", "quartz");
});
modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzPausedTriggerGroup", b =>
{
b.Property<string>("SchedulerName")
.HasColumnType("text")
.HasColumnName("sched_name");
b.Property<string>("TriggerGroup")
.HasColumnType("text")
.HasColumnName("trigger_group");
b.HasKey("SchedulerName", "TriggerGroup");
b.ToTable("qrtz_paused_trigger_grps", "quartz");
});
modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzSchedulerState", b =>
{
b.Property<string>("SchedulerName")
.HasColumnType("text")
.HasColumnName("sched_name");
b.Property<string>("InstanceName")
.HasColumnType("text")
.HasColumnName("instance_name");
b.Property<long>("CheckInInterval")
.HasColumnType("bigint")
.HasColumnName("checkin_interval");
b.Property<long>("LastCheckInTime")
.HasColumnType("bigint")
.HasColumnName("last_checkin_time");
b.HasKey("SchedulerName", "InstanceName");
b.ToTable("qrtz_scheduler_state", "quartz");
});
modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzSimplePropertyTrigger", b =>
{
b.Property<string>("SchedulerName")
.HasColumnType("text")
.HasColumnName("sched_name");
b.Property<string>("TriggerName")
.HasColumnType("text")
.HasColumnName("trigger_name");
b.Property<string>("TriggerGroup")
.HasColumnType("text")
.HasColumnName("trigger_group");
b.Property<bool?>("BooleanProperty1")
.HasColumnType("bool")
.HasColumnName("bool_prop_1");
b.Property<bool?>("BooleanProperty2")
.HasColumnType("bool")
.HasColumnName("bool_prop_2");
b.Property<decimal?>("DecimalProperty1")
.HasColumnType("numeric")
.HasColumnName("dec_prop_1");
b.Property<decimal?>("DecimalProperty2")
.HasColumnType("numeric")
.HasColumnName("dec_prop_2");
b.Property<int?>("IntegerProperty1")
.HasColumnType("integer")
.HasColumnName("int_prop_1");
b.Property<int?>("IntegerProperty2")
.HasColumnType("integer")
.HasColumnName("int_prop_2");
b.Property<long?>("LongProperty1")
.HasColumnType("bigint")
.HasColumnName("long_prop_1");
b.Property<long?>("LongProperty2")
.HasColumnType("bigint")
.HasColumnName("long_prop_2");
b.Property<string>("StringProperty1")
.HasColumnType("text")
.HasColumnName("str_prop_1");
b.Property<string>("StringProperty2")
.HasColumnType("text")
.HasColumnName("str_prop_2");
b.Property<string>("StringProperty3")
.HasColumnType("text")
.HasColumnName("str_prop_3");
b.Property<string>("TimeZoneId")
.HasColumnType("text")
.HasColumnName("time_zone_id");
b.HasKey("SchedulerName", "TriggerName", "TriggerGroup");
b.ToTable("qrtz_simprop_triggers", "quartz");
});
modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzSimpleTrigger", b =>
{
b.Property<string>("SchedulerName")
.HasColumnType("text")
.HasColumnName("sched_name");
b.Property<string>("TriggerName")
.HasColumnType("text")
.HasColumnName("trigger_name");
b.Property<string>("TriggerGroup")
.HasColumnType("text")
.HasColumnName("trigger_group");
b.Property<long>("RepeatCount")
.HasColumnType("bigint")
.HasColumnName("repeat_count");
b.Property<long>("RepeatInterval")
.HasColumnType("bigint")
.HasColumnName("repeat_interval");
b.Property<long>("TimesTriggered")
.HasColumnType("bigint")
.HasColumnName("times_triggered");
b.HasKey("SchedulerName", "TriggerName", "TriggerGroup");
b.ToTable("qrtz_simple_triggers", "quartz");
});
modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzTrigger", b =>
{
b.Property<string>("SchedulerName")
.HasColumnType("text")
.HasColumnName("sched_name");
b.Property<string>("TriggerName")
.HasColumnType("text")
.HasColumnName("trigger_name");
b.Property<string>("TriggerGroup")
.HasColumnType("text")
.HasColumnName("trigger_group");
b.Property<string>("CalendarName")
.HasColumnType("text")
.HasColumnName("calendar_name");
b.Property<string>("Description")
.HasColumnType("text")
.HasColumnName("description");
b.Property<long?>("EndTime")
.HasColumnType("bigint")
.HasColumnName("end_time");
b.Property<byte[]>("JobData")
.HasColumnType("bytea")
.HasColumnName("job_data");
b.Property<string>("JobGroup")
.IsRequired()
.HasColumnType("text")
.HasColumnName("job_group");
b.Property<string>("JobName")
.IsRequired()
.HasColumnType("text")
.HasColumnName("job_name");
b.Property<short?>("MisfireInstruction")
.HasColumnType("smallint")
.HasColumnName("misfire_instr");
b.Property<long?>("NextFireTime")
.HasColumnType("bigint")
.HasColumnName("next_fire_time");
b.Property<long?>("PreviousFireTime")
.HasColumnType("bigint")
.HasColumnName("prev_fire_time");
b.Property<int?>("Priority")
.HasColumnType("integer")
.HasColumnName("priority");
b.Property<long>("StartTime")
.HasColumnType("bigint")
.HasColumnName("start_time");
b.Property<string>("TriggerState")
.IsRequired()
.HasColumnType("text")
.HasColumnName("trigger_state");
b.Property<string>("TriggerType")
.IsRequired()
.HasColumnType("text")
.HasColumnName("trigger_type");
b.HasKey("SchedulerName", "TriggerName", "TriggerGroup");
b.HasIndex("NextFireTime")
.HasDatabaseName("idx_qrtz_t_next_fire_time");
b.HasIndex("TriggerState")
.HasDatabaseName("idx_qrtz_t_state");
b.HasIndex("NextFireTime", "TriggerState")
.HasDatabaseName("idx_qrtz_t_nft_st");
b.HasIndex("SchedulerName", "JobName", "JobGroup");
b.ToTable("qrtz_triggers", "quartz");
});
modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzBlobTrigger", b =>
{
b.HasOne("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzTrigger", "Trigger")
.WithMany("BlobTriggers")
.HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Trigger");
});
modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzCronTrigger", b =>
{
b.HasOne("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzTrigger", "Trigger")
.WithMany("CronTriggers")
.HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Trigger");
});
modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzSimplePropertyTrigger", b =>
{
b.HasOne("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzTrigger", "Trigger")
.WithMany("SimplePropertyTriggers")
.HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Trigger");
});
modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzSimpleTrigger", b =>
{
b.HasOne("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzTrigger", "Trigger")
.WithMany("SimpleTriggers")
.HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Trigger");
});
modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzTrigger", b =>
{
b.HasOne("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzJobDetail", "JobDetail")
.WithMany("Triggers")
.HasForeignKey("SchedulerName", "JobName", "JobGroup")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("JobDetail");
});
modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzJobDetail", b =>
{
b.Navigation("Triggers");
});
modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzTrigger", b =>
{
b.Navigation("BlobTriggers");
b.Navigation("CronTriggers");
b.Navigation("SimplePropertyTriggers");
b.Navigation("SimpleTriggers");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,373 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace FictionArchive.Service.SchedulerService.Migrations
{
/// <inheritdoc />
public partial class Initial : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.EnsureSchema(
name: "quartz");
migrationBuilder.CreateTable(
name: "qrtz_calendars",
schema: "quartz",
columns: table => new
{
sched_name = table.Column<string>(type: "text", nullable: false),
calendar_name = table.Column<string>(type: "text", nullable: false),
calendar = table.Column<byte[]>(type: "bytea", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_qrtz_calendars", x => new { x.sched_name, x.calendar_name });
});
migrationBuilder.CreateTable(
name: "qrtz_fired_triggers",
schema: "quartz",
columns: table => new
{
sched_name = table.Column<string>(type: "text", nullable: false),
entry_id = table.Column<string>(type: "text", nullable: false),
trigger_name = table.Column<string>(type: "text", nullable: false),
trigger_group = table.Column<string>(type: "text", nullable: false),
instance_name = table.Column<string>(type: "text", nullable: false),
fired_time = table.Column<long>(type: "bigint", nullable: false),
sched_time = table.Column<long>(type: "bigint", nullable: false),
priority = table.Column<int>(type: "integer", nullable: false),
state = table.Column<string>(type: "text", nullable: false),
job_name = table.Column<string>(type: "text", nullable: true),
job_group = table.Column<string>(type: "text", nullable: true),
is_nonconcurrent = table.Column<bool>(type: "bool", nullable: false),
requests_recovery = table.Column<bool>(type: "bool", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_qrtz_fired_triggers", x => new { x.sched_name, x.entry_id });
});
migrationBuilder.CreateTable(
name: "qrtz_job_details",
schema: "quartz",
columns: table => new
{
sched_name = table.Column<string>(type: "text", nullable: false),
job_name = table.Column<string>(type: "text", nullable: false),
job_group = table.Column<string>(type: "text", nullable: false),
description = table.Column<string>(type: "text", nullable: true),
job_class_name = table.Column<string>(type: "text", nullable: false),
is_durable = table.Column<bool>(type: "bool", nullable: false),
is_nonconcurrent = table.Column<bool>(type: "bool", nullable: false),
is_update_data = table.Column<bool>(type: "bool", nullable: false),
requests_recovery = table.Column<bool>(type: "bool", nullable: false),
job_data = table.Column<byte[]>(type: "bytea", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_qrtz_job_details", x => new { x.sched_name, x.job_name, x.job_group });
});
migrationBuilder.CreateTable(
name: "qrtz_locks",
schema: "quartz",
columns: table => new
{
sched_name = table.Column<string>(type: "text", nullable: false),
lock_name = table.Column<string>(type: "text", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_qrtz_locks", x => new { x.sched_name, x.lock_name });
});
migrationBuilder.CreateTable(
name: "qrtz_paused_trigger_grps",
schema: "quartz",
columns: table => new
{
sched_name = table.Column<string>(type: "text", nullable: false),
trigger_group = table.Column<string>(type: "text", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_qrtz_paused_trigger_grps", x => new { x.sched_name, x.trigger_group });
});
migrationBuilder.CreateTable(
name: "qrtz_scheduler_state",
schema: "quartz",
columns: table => new
{
sched_name = table.Column<string>(type: "text", nullable: false),
instance_name = table.Column<string>(type: "text", nullable: false),
last_checkin_time = table.Column<long>(type: "bigint", nullable: false),
checkin_interval = table.Column<long>(type: "bigint", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_qrtz_scheduler_state", x => new { x.sched_name, x.instance_name });
});
migrationBuilder.CreateTable(
name: "qrtz_triggers",
schema: "quartz",
columns: table => new
{
sched_name = table.Column<string>(type: "text", nullable: false),
trigger_name = table.Column<string>(type: "text", nullable: false),
trigger_group = table.Column<string>(type: "text", nullable: false),
job_name = table.Column<string>(type: "text", nullable: false),
job_group = table.Column<string>(type: "text", nullable: false),
description = table.Column<string>(type: "text", nullable: true),
next_fire_time = table.Column<long>(type: "bigint", nullable: true),
prev_fire_time = table.Column<long>(type: "bigint", nullable: true),
priority = table.Column<int>(type: "integer", nullable: true),
trigger_state = table.Column<string>(type: "text", nullable: false),
trigger_type = table.Column<string>(type: "text", nullable: false),
start_time = table.Column<long>(type: "bigint", nullable: false),
end_time = table.Column<long>(type: "bigint", nullable: true),
calendar_name = table.Column<string>(type: "text", nullable: true),
misfire_instr = table.Column<short>(type: "smallint", nullable: true),
job_data = table.Column<byte[]>(type: "bytea", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_qrtz_triggers", x => new { x.sched_name, x.trigger_name, x.trigger_group });
table.ForeignKey(
name: "FK_qrtz_triggers_qrtz_job_details_sched_name_job_name_job_group",
columns: x => new { x.sched_name, x.job_name, x.job_group },
principalSchema: "quartz",
principalTable: "qrtz_job_details",
principalColumns: new[] { "sched_name", "job_name", "job_group" },
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "qrtz_blob_triggers",
schema: "quartz",
columns: table => new
{
sched_name = table.Column<string>(type: "text", nullable: false),
trigger_name = table.Column<string>(type: "text", nullable: false),
trigger_group = table.Column<string>(type: "text", nullable: false),
blob_data = table.Column<byte[]>(type: "bytea", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_qrtz_blob_triggers", x => new { x.sched_name, x.trigger_name, x.trigger_group });
table.ForeignKey(
name: "FK_qrtz_blob_triggers_qrtz_triggers_sched_name_trigger_name_tr~",
columns: x => new { x.sched_name, x.trigger_name, x.trigger_group },
principalSchema: "quartz",
principalTable: "qrtz_triggers",
principalColumns: new[] { "sched_name", "trigger_name", "trigger_group" },
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "qrtz_cron_triggers",
schema: "quartz",
columns: table => new
{
sched_name = table.Column<string>(type: "text", nullable: false),
trigger_name = table.Column<string>(type: "text", nullable: false),
trigger_group = table.Column<string>(type: "text", nullable: false),
cron_expression = table.Column<string>(type: "text", nullable: false),
time_zone_id = table.Column<string>(type: "text", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_qrtz_cron_triggers", x => new { x.sched_name, x.trigger_name, x.trigger_group });
table.ForeignKey(
name: "FK_qrtz_cron_triggers_qrtz_triggers_sched_name_trigger_name_tr~",
columns: x => new { x.sched_name, x.trigger_name, x.trigger_group },
principalSchema: "quartz",
principalTable: "qrtz_triggers",
principalColumns: new[] { "sched_name", "trigger_name", "trigger_group" },
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "qrtz_simple_triggers",
schema: "quartz",
columns: table => new
{
sched_name = table.Column<string>(type: "text", nullable: false),
trigger_name = table.Column<string>(type: "text", nullable: false),
trigger_group = table.Column<string>(type: "text", nullable: false),
repeat_count = table.Column<long>(type: "bigint", nullable: false),
repeat_interval = table.Column<long>(type: "bigint", nullable: false),
times_triggered = table.Column<long>(type: "bigint", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_qrtz_simple_triggers", x => new { x.sched_name, x.trigger_name, x.trigger_group });
table.ForeignKey(
name: "FK_qrtz_simple_triggers_qrtz_triggers_sched_name_trigger_name_~",
columns: x => new { x.sched_name, x.trigger_name, x.trigger_group },
principalSchema: "quartz",
principalTable: "qrtz_triggers",
principalColumns: new[] { "sched_name", "trigger_name", "trigger_group" },
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "qrtz_simprop_triggers",
schema: "quartz",
columns: table => new
{
sched_name = table.Column<string>(type: "text", nullable: false),
trigger_name = table.Column<string>(type: "text", nullable: false),
trigger_group = table.Column<string>(type: "text", nullable: false),
str_prop_1 = table.Column<string>(type: "text", nullable: true),
str_prop_2 = table.Column<string>(type: "text", nullable: true),
str_prop_3 = table.Column<string>(type: "text", nullable: true),
int_prop_1 = table.Column<int>(type: "integer", nullable: true),
int_prop_2 = table.Column<int>(type: "integer", nullable: true),
long_prop_1 = table.Column<long>(type: "bigint", nullable: true),
long_prop_2 = table.Column<long>(type: "bigint", nullable: true),
dec_prop_1 = table.Column<decimal>(type: "numeric", nullable: true),
dec_prop_2 = table.Column<decimal>(type: "numeric", nullable: true),
bool_prop_1 = table.Column<bool>(type: "bool", nullable: true),
bool_prop_2 = table.Column<bool>(type: "bool", nullable: true),
time_zone_id = table.Column<string>(type: "text", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_qrtz_simprop_triggers", x => new { x.sched_name, x.trigger_name, x.trigger_group });
table.ForeignKey(
name: "FK_qrtz_simprop_triggers_qrtz_triggers_sched_name_trigger_name~",
columns: x => new { x.sched_name, x.trigger_name, x.trigger_group },
principalSchema: "quartz",
principalTable: "qrtz_triggers",
principalColumns: new[] { "sched_name", "trigger_name", "trigger_group" },
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "idx_qrtz_ft_job_group",
schema: "quartz",
table: "qrtz_fired_triggers",
column: "job_group");
migrationBuilder.CreateIndex(
name: "idx_qrtz_ft_job_name",
schema: "quartz",
table: "qrtz_fired_triggers",
column: "job_name");
migrationBuilder.CreateIndex(
name: "idx_qrtz_ft_job_req_recovery",
schema: "quartz",
table: "qrtz_fired_triggers",
column: "requests_recovery");
migrationBuilder.CreateIndex(
name: "idx_qrtz_ft_trig_group",
schema: "quartz",
table: "qrtz_fired_triggers",
column: "trigger_group");
migrationBuilder.CreateIndex(
name: "idx_qrtz_ft_trig_inst_name",
schema: "quartz",
table: "qrtz_fired_triggers",
column: "instance_name");
migrationBuilder.CreateIndex(
name: "idx_qrtz_ft_trig_name",
schema: "quartz",
table: "qrtz_fired_triggers",
column: "trigger_name");
migrationBuilder.CreateIndex(
name: "idx_qrtz_ft_trig_nm_gp",
schema: "quartz",
table: "qrtz_fired_triggers",
columns: new[] { "sched_name", "trigger_name", "trigger_group" });
migrationBuilder.CreateIndex(
name: "idx_qrtz_j_req_recovery",
schema: "quartz",
table: "qrtz_job_details",
column: "requests_recovery");
migrationBuilder.CreateIndex(
name: "idx_qrtz_t_next_fire_time",
schema: "quartz",
table: "qrtz_triggers",
column: "next_fire_time");
migrationBuilder.CreateIndex(
name: "idx_qrtz_t_nft_st",
schema: "quartz",
table: "qrtz_triggers",
columns: new[] { "next_fire_time", "trigger_state" });
migrationBuilder.CreateIndex(
name: "idx_qrtz_t_state",
schema: "quartz",
table: "qrtz_triggers",
column: "trigger_state");
migrationBuilder.CreateIndex(
name: "IX_qrtz_triggers_sched_name_job_name_job_group",
schema: "quartz",
table: "qrtz_triggers",
columns: new[] { "sched_name", "job_name", "job_group" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "qrtz_blob_triggers",
schema: "quartz");
migrationBuilder.DropTable(
name: "qrtz_calendars",
schema: "quartz");
migrationBuilder.DropTable(
name: "qrtz_cron_triggers",
schema: "quartz");
migrationBuilder.DropTable(
name: "qrtz_fired_triggers",
schema: "quartz");
migrationBuilder.DropTable(
name: "qrtz_locks",
schema: "quartz");
migrationBuilder.DropTable(
name: "qrtz_paused_trigger_grps",
schema: "quartz");
migrationBuilder.DropTable(
name: "qrtz_scheduler_state",
schema: "quartz");
migrationBuilder.DropTable(
name: "qrtz_simple_triggers",
schema: "quartz");
migrationBuilder.DropTable(
name: "qrtz_simprop_triggers",
schema: "quartz");
migrationBuilder.DropTable(
name: "qrtz_triggers",
schema: "quartz");
migrationBuilder.DropTable(
name: "qrtz_job_details",
schema: "quartz");
}
}
}

View File

@@ -0,0 +1,540 @@
// <auto-generated />
using FictionArchive.Service.SchedulerService.Services;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace FictionArchive.Service.SchedulerService.Migrations
{
[DbContext(typeof(SchedulerServiceDbContext))]
partial class SchedulerServiceDbContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "9.0.11")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzBlobTrigger", b =>
{
b.Property<string>("SchedulerName")
.HasColumnType("text")
.HasColumnName("sched_name");
b.Property<string>("TriggerName")
.HasColumnType("text")
.HasColumnName("trigger_name");
b.Property<string>("TriggerGroup")
.HasColumnType("text")
.HasColumnName("trigger_group");
b.Property<byte[]>("BlobData")
.HasColumnType("bytea")
.HasColumnName("blob_data");
b.HasKey("SchedulerName", "TriggerName", "TriggerGroup");
b.ToTable("qrtz_blob_triggers", "quartz");
});
modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzCalendar", b =>
{
b.Property<string>("SchedulerName")
.HasColumnType("text")
.HasColumnName("sched_name");
b.Property<string>("CalendarName")
.HasColumnType("text")
.HasColumnName("calendar_name");
b.Property<byte[]>("Calendar")
.IsRequired()
.HasColumnType("bytea")
.HasColumnName("calendar");
b.HasKey("SchedulerName", "CalendarName");
b.ToTable("qrtz_calendars", "quartz");
});
modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzCronTrigger", b =>
{
b.Property<string>("SchedulerName")
.HasColumnType("text")
.HasColumnName("sched_name");
b.Property<string>("TriggerName")
.HasColumnType("text")
.HasColumnName("trigger_name");
b.Property<string>("TriggerGroup")
.HasColumnType("text")
.HasColumnName("trigger_group");
b.Property<string>("CronExpression")
.IsRequired()
.HasColumnType("text")
.HasColumnName("cron_expression");
b.Property<string>("TimeZoneId")
.HasColumnType("text")
.HasColumnName("time_zone_id");
b.HasKey("SchedulerName", "TriggerName", "TriggerGroup");
b.ToTable("qrtz_cron_triggers", "quartz");
});
modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzFiredTrigger", b =>
{
b.Property<string>("SchedulerName")
.HasColumnType("text")
.HasColumnName("sched_name");
b.Property<string>("EntryId")
.HasColumnType("text")
.HasColumnName("entry_id");
b.Property<long>("FiredTime")
.HasColumnType("bigint")
.HasColumnName("fired_time");
b.Property<string>("InstanceName")
.IsRequired()
.HasColumnType("text")
.HasColumnName("instance_name");
b.Property<bool>("IsNonConcurrent")
.HasColumnType("bool")
.HasColumnName("is_nonconcurrent");
b.Property<string>("JobGroup")
.HasColumnType("text")
.HasColumnName("job_group");
b.Property<string>("JobName")
.HasColumnType("text")
.HasColumnName("job_name");
b.Property<int>("Priority")
.HasColumnType("integer")
.HasColumnName("priority");
b.Property<bool?>("RequestsRecovery")
.HasColumnType("bool")
.HasColumnName("requests_recovery");
b.Property<long>("ScheduledTime")
.HasColumnType("bigint")
.HasColumnName("sched_time");
b.Property<string>("State")
.IsRequired()
.HasColumnType("text")
.HasColumnName("state");
b.Property<string>("TriggerGroup")
.IsRequired()
.HasColumnType("text")
.HasColumnName("trigger_group");
b.Property<string>("TriggerName")
.IsRequired()
.HasColumnType("text")
.HasColumnName("trigger_name");
b.HasKey("SchedulerName", "EntryId");
b.HasIndex("InstanceName")
.HasDatabaseName("idx_qrtz_ft_trig_inst_name");
b.HasIndex("JobGroup")
.HasDatabaseName("idx_qrtz_ft_job_group");
b.HasIndex("JobName")
.HasDatabaseName("idx_qrtz_ft_job_name");
b.HasIndex("RequestsRecovery")
.HasDatabaseName("idx_qrtz_ft_job_req_recovery");
b.HasIndex("TriggerGroup")
.HasDatabaseName("idx_qrtz_ft_trig_group");
b.HasIndex("TriggerName")
.HasDatabaseName("idx_qrtz_ft_trig_name");
b.HasIndex("SchedulerName", "TriggerName", "TriggerGroup")
.HasDatabaseName("idx_qrtz_ft_trig_nm_gp");
b.ToTable("qrtz_fired_triggers", "quartz");
});
modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzJobDetail", b =>
{
b.Property<string>("SchedulerName")
.HasColumnType("text")
.HasColumnName("sched_name");
b.Property<string>("JobName")
.HasColumnType("text")
.HasColumnName("job_name");
b.Property<string>("JobGroup")
.HasColumnType("text")
.HasColumnName("job_group");
b.Property<string>("Description")
.HasColumnType("text")
.HasColumnName("description");
b.Property<bool>("IsDurable")
.HasColumnType("bool")
.HasColumnName("is_durable");
b.Property<bool>("IsNonConcurrent")
.HasColumnType("bool")
.HasColumnName("is_nonconcurrent");
b.Property<bool>("IsUpdateData")
.HasColumnType("bool")
.HasColumnName("is_update_data");
b.Property<string>("JobClassName")
.IsRequired()
.HasColumnType("text")
.HasColumnName("job_class_name");
b.Property<byte[]>("JobData")
.HasColumnType("bytea")
.HasColumnName("job_data");
b.Property<bool>("RequestsRecovery")
.HasColumnType("bool")
.HasColumnName("requests_recovery");
b.HasKey("SchedulerName", "JobName", "JobGroup");
b.HasIndex("RequestsRecovery")
.HasDatabaseName("idx_qrtz_j_req_recovery");
b.ToTable("qrtz_job_details", "quartz");
});
modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzLock", b =>
{
b.Property<string>("SchedulerName")
.HasColumnType("text")
.HasColumnName("sched_name");
b.Property<string>("LockName")
.HasColumnType("text")
.HasColumnName("lock_name");
b.HasKey("SchedulerName", "LockName");
b.ToTable("qrtz_locks", "quartz");
});
modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzPausedTriggerGroup", b =>
{
b.Property<string>("SchedulerName")
.HasColumnType("text")
.HasColumnName("sched_name");
b.Property<string>("TriggerGroup")
.HasColumnType("text")
.HasColumnName("trigger_group");
b.HasKey("SchedulerName", "TriggerGroup");
b.ToTable("qrtz_paused_trigger_grps", "quartz");
});
modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzSchedulerState", b =>
{
b.Property<string>("SchedulerName")
.HasColumnType("text")
.HasColumnName("sched_name");
b.Property<string>("InstanceName")
.HasColumnType("text")
.HasColumnName("instance_name");
b.Property<long>("CheckInInterval")
.HasColumnType("bigint")
.HasColumnName("checkin_interval");
b.Property<long>("LastCheckInTime")
.HasColumnType("bigint")
.HasColumnName("last_checkin_time");
b.HasKey("SchedulerName", "InstanceName");
b.ToTable("qrtz_scheduler_state", "quartz");
});
modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzSimplePropertyTrigger", b =>
{
b.Property<string>("SchedulerName")
.HasColumnType("text")
.HasColumnName("sched_name");
b.Property<string>("TriggerName")
.HasColumnType("text")
.HasColumnName("trigger_name");
b.Property<string>("TriggerGroup")
.HasColumnType("text")
.HasColumnName("trigger_group");
b.Property<bool?>("BooleanProperty1")
.HasColumnType("bool")
.HasColumnName("bool_prop_1");
b.Property<bool?>("BooleanProperty2")
.HasColumnType("bool")
.HasColumnName("bool_prop_2");
b.Property<decimal?>("DecimalProperty1")
.HasColumnType("numeric")
.HasColumnName("dec_prop_1");
b.Property<decimal?>("DecimalProperty2")
.HasColumnType("numeric")
.HasColumnName("dec_prop_2");
b.Property<int?>("IntegerProperty1")
.HasColumnType("integer")
.HasColumnName("int_prop_1");
b.Property<int?>("IntegerProperty2")
.HasColumnType("integer")
.HasColumnName("int_prop_2");
b.Property<long?>("LongProperty1")
.HasColumnType("bigint")
.HasColumnName("long_prop_1");
b.Property<long?>("LongProperty2")
.HasColumnType("bigint")
.HasColumnName("long_prop_2");
b.Property<string>("StringProperty1")
.HasColumnType("text")
.HasColumnName("str_prop_1");
b.Property<string>("StringProperty2")
.HasColumnType("text")
.HasColumnName("str_prop_2");
b.Property<string>("StringProperty3")
.HasColumnType("text")
.HasColumnName("str_prop_3");
b.Property<string>("TimeZoneId")
.HasColumnType("text")
.HasColumnName("time_zone_id");
b.HasKey("SchedulerName", "TriggerName", "TriggerGroup");
b.ToTable("qrtz_simprop_triggers", "quartz");
});
modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzSimpleTrigger", b =>
{
b.Property<string>("SchedulerName")
.HasColumnType("text")
.HasColumnName("sched_name");
b.Property<string>("TriggerName")
.HasColumnType("text")
.HasColumnName("trigger_name");
b.Property<string>("TriggerGroup")
.HasColumnType("text")
.HasColumnName("trigger_group");
b.Property<long>("RepeatCount")
.HasColumnType("bigint")
.HasColumnName("repeat_count");
b.Property<long>("RepeatInterval")
.HasColumnType("bigint")
.HasColumnName("repeat_interval");
b.Property<long>("TimesTriggered")
.HasColumnType("bigint")
.HasColumnName("times_triggered");
b.HasKey("SchedulerName", "TriggerName", "TriggerGroup");
b.ToTable("qrtz_simple_triggers", "quartz");
});
modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzTrigger", b =>
{
b.Property<string>("SchedulerName")
.HasColumnType("text")
.HasColumnName("sched_name");
b.Property<string>("TriggerName")
.HasColumnType("text")
.HasColumnName("trigger_name");
b.Property<string>("TriggerGroup")
.HasColumnType("text")
.HasColumnName("trigger_group");
b.Property<string>("CalendarName")
.HasColumnType("text")
.HasColumnName("calendar_name");
b.Property<string>("Description")
.HasColumnType("text")
.HasColumnName("description");
b.Property<long?>("EndTime")
.HasColumnType("bigint")
.HasColumnName("end_time");
b.Property<byte[]>("JobData")
.HasColumnType("bytea")
.HasColumnName("job_data");
b.Property<string>("JobGroup")
.IsRequired()
.HasColumnType("text")
.HasColumnName("job_group");
b.Property<string>("JobName")
.IsRequired()
.HasColumnType("text")
.HasColumnName("job_name");
b.Property<short?>("MisfireInstruction")
.HasColumnType("smallint")
.HasColumnName("misfire_instr");
b.Property<long?>("NextFireTime")
.HasColumnType("bigint")
.HasColumnName("next_fire_time");
b.Property<long?>("PreviousFireTime")
.HasColumnType("bigint")
.HasColumnName("prev_fire_time");
b.Property<int?>("Priority")
.HasColumnType("integer")
.HasColumnName("priority");
b.Property<long>("StartTime")
.HasColumnType("bigint")
.HasColumnName("start_time");
b.Property<string>("TriggerState")
.IsRequired()
.HasColumnType("text")
.HasColumnName("trigger_state");
b.Property<string>("TriggerType")
.IsRequired()
.HasColumnType("text")
.HasColumnName("trigger_type");
b.HasKey("SchedulerName", "TriggerName", "TriggerGroup");
b.HasIndex("NextFireTime")
.HasDatabaseName("idx_qrtz_t_next_fire_time");
b.HasIndex("TriggerState")
.HasDatabaseName("idx_qrtz_t_state");
b.HasIndex("NextFireTime", "TriggerState")
.HasDatabaseName("idx_qrtz_t_nft_st");
b.HasIndex("SchedulerName", "JobName", "JobGroup");
b.ToTable("qrtz_triggers", "quartz");
});
modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzBlobTrigger", b =>
{
b.HasOne("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzTrigger", "Trigger")
.WithMany("BlobTriggers")
.HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Trigger");
});
modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzCronTrigger", b =>
{
b.HasOne("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzTrigger", "Trigger")
.WithMany("CronTriggers")
.HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Trigger");
});
modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzSimplePropertyTrigger", b =>
{
b.HasOne("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzTrigger", "Trigger")
.WithMany("SimplePropertyTriggers")
.HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Trigger");
});
modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzSimpleTrigger", b =>
{
b.HasOne("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzTrigger", "Trigger")
.WithMany("SimpleTriggers")
.HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Trigger");
});
modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzTrigger", b =>
{
b.HasOne("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzJobDetail", "JobDetail")
.WithMany("Triggers")
.HasForeignKey("SchedulerName", "JobName", "JobGroup")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("JobDetail");
});
modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzJobDetail", b =>
{
b.Navigation("Triggers");
});
modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzTrigger", b =>
{
b.Navigation("BlobTriggers");
b.Navigation("CronTriggers");
b.Navigation("SimplePropertyTriggers");
b.Navigation("SimpleTriggers");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,35 @@
using FictionArchive.Service.Shared.Services.EventBus;
using Newtonsoft.Json;
using Quartz;
namespace FictionArchive.Service.SchedulerService.Models.JobTemplates;
public class EventJobTemplate : IJob
{
private readonly IEventBus _eventBus;
private readonly ILogger<EventJobTemplate> _logger;
public const string EventTypeParameter = "RoutingKey";
public const string EventDataParameter = "MessageData";
public EventJobTemplate(IEventBus eventBus, ILogger<EventJobTemplate> logger)
{
_eventBus = eventBus;
_logger = logger;
}
public async Task Execute(IJobExecutionContext context)
{
try
{
var eventData = context.MergedJobDataMap.GetString(EventDataParameter);
var eventType = context.MergedJobDataMap.GetString(EventTypeParameter);
var eventObject = JsonConvert.DeserializeObject(eventData);
await _eventBus.Publish(eventObject, eventType);
}
catch (Exception ex)
{
_logger.LogError(ex, "An error occurred while running an event job.");
}
}
}

View File

@@ -0,0 +1,12 @@
using Quartz;
namespace FictionArchive.Service.SchedulerService.Models;
public class SchedulerJob
{
public JobKey JobKey { get; set; }
public string Description { get; set; }
public string JobTypeName { get; set; }
public List<string> CronSchedule { get; set; }
public Dictionary<string, string> JobData { get; set; }
}

View File

@@ -0,0 +1,74 @@
using FictionArchive.Service.SchedulerService.GraphQL;
using FictionArchive.Service.SchedulerService.Services;
using FictionArchive.Service.Shared.Extensions;
using FictionArchive.Service.Shared.Services.EventBus.Implementations;
using Quartz;
using Quartz.Impl.AdoJobStore;
namespace FictionArchive.Service.SchedulerService;
public class Program
{
public static void Main(string[] args)
{
var builder = WebApplication.CreateBuilder(args);
// Services
builder.Services.AddDefaultGraphQl<Query, Mutation>();
builder.Services.AddHealthChecks();
builder.Services.AddTransient<JobManagerService>();
#region Database
builder.Services.RegisterDbContext<SchedulerServiceDbContext>(builder.Configuration.GetConnectionString("DefaultConnection"));
#endregion
#region Event Bus
builder.Services.AddRabbitMQ(opt =>
{
builder.Configuration.GetSection("RabbitMQ").Bind(opt);
});
#endregion
#region Quartz
builder.Services.AddQuartz(opt =>
{
opt.UsePersistentStore(pso =>
{
pso.UsePostgres(pgsql =>
{
pgsql.ConnectionString = builder.Configuration.GetConnectionString("DefaultConnection");
pgsql.UseDriverDelegate<PostgreSQLDelegate>();
pgsql.TablePrefix = "quartz.qrtz_"; // Needed for Postgres due to the differing schema used
});
pso.UseNewtonsoftJsonSerializer();
});
});
builder.Services.AddQuartzHostedService(opt =>
{
opt.WaitForJobsToComplete = true;
});
#endregion
var app = builder.Build();
using (var scope = app.Services.CreateScope())
{
var dbContext = scope.ServiceProvider.GetRequiredService<SchedulerServiceDbContext>();
dbContext.UpdateDatabase();
}
app.UseHttpsRedirection();
app.MapHealthChecks("/healthz");
app.MapGraphQL();
app.Run();
}
}

View File

@@ -0,0 +1,39 @@
{
"$schema": "http://json.schemastore.org/launchsettings.json",
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:61312",
"sslPort": 44365
}
},
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "http://localhost:5213",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"launchUrl": "graphql",
"applicationUrl": "https://localhost:7145;http://localhost:5213",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"IIS Express": {
"commandName": "IISExpress",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View File

@@ -0,0 +1,99 @@
using System.Data;
using FictionArchive.Service.SchedulerService.Models;
using FictionArchive.Service.SchedulerService.Models.JobTemplates;
using FictionArchive.Service.Shared.Services.EventBus;
using Quartz;
using Quartz.Impl.Matchers;
namespace FictionArchive.Service.SchedulerService.Services;
public class JobManagerService
{
private readonly ILogger<JobManagerService> _logger;
private readonly ISchedulerFactory _schedulerFactory;
public JobManagerService(ILogger<JobManagerService> logger, ISchedulerFactory schedulerFactory)
{
_logger = logger;
_schedulerFactory = schedulerFactory;
}
public async Task<List<SchedulerJob>> GetScheduledJobs()
{
var scheduler = await _schedulerFactory.GetScheduler();
var groups = await scheduler.GetJobGroupNames();
var result = new List<(IJobDetail Job, IReadOnlyCollection<ITrigger> Triggers)>();
foreach (var group in groups)
{
var jobKeys = await scheduler.GetJobKeys(GroupMatcher<JobKey>.GroupEquals(group));
foreach (var jobKey in jobKeys)
{
var jobDetail = await scheduler.GetJobDetail(jobKey);
var triggers = await scheduler.GetTriggersOfJob(jobKey);
result.Add((jobDetail, triggers));
}
}
return result.Select(tuple => new SchedulerJob()
{
JobKey = tuple.Job.Key,
Description = tuple.Job.Description,
JobTypeName = tuple.Job.JobType.FullName,
JobData = tuple.Job.JobDataMap.ToDictionary(kv => kv.Key, kv => kv.Value.ToString()),
CronSchedule = tuple.Triggers.Where(trigger => trigger is ICronTrigger).Select(trigger => (trigger as ICronTrigger).CronExpressionString).ToList()
}).ToList();
}
public async Task<SchedulerJob> ScheduleEventJob(string? jobKey, string? description, string eventType, string eventData, string cronSchedule)
{
var scheduler = await _schedulerFactory.GetScheduler();
if (await scheduler.GetJobDetail(new JobKey(jobKey)) != null)
{
throw new DuplicateNameException("A job with the same key already exists.");
}
jobKey ??= Guid.NewGuid().ToString();
var jobData = new JobDataMap
{
{ EventJobTemplate.EventTypeParameter, eventType },
{ EventJobTemplate.EventDataParameter, eventData }
};
var job = JobBuilder.Create<EventJobTemplate>()
.WithIdentity(jobKey)
.WithDescription(description ?? $"Fires off an event on a set schedule")
.SetJobData(jobData)
.Build();
var trigger = TriggerBuilder.Create()
.WithIdentity(jobKey)
.WithCronSchedule(cronSchedule)
.StartNow()
.Build();
await scheduler.ScheduleJob(job, trigger);
return new SchedulerJob()
{
CronSchedule = new List<string> { cronSchedule },
Description = description,
JobKey = new JobKey(jobKey),
JobTypeName = typeof(EventJobTemplate).FullName,
JobData = jobData.ToDictionary(kv => kv.Key, kv => kv.Value.ToString())
};
}
public async Task<bool> TriggerJob(string jobKey)
{
var scheduler = await _schedulerFactory.GetScheduler();
await scheduler.TriggerJob(new JobKey(jobKey));
return true;
}
public async Task<bool> DeleteJob(string jobKey)
{
var scheduler = await _schedulerFactory.GetScheduler();
return await scheduler.DeleteJob(new JobKey(jobKey));
}
}

View File

@@ -0,0 +1,19 @@
using AppAny.Quartz.EntityFrameworkCore.Migrations;
using AppAny.Quartz.EntityFrameworkCore.Migrations.PostgreSQL;
using FictionArchive.Service.Shared.Services.Database;
using Microsoft.EntityFrameworkCore;
namespace FictionArchive.Service.SchedulerService.Services;
public class SchedulerServiceDbContext : FictionArchiveDbContext
{
public SchedulerServiceDbContext(DbContextOptions options, ILogger<SchedulerServiceDbContext> logger) : base(options, logger)
{
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.AddQuartz(builder => builder.UsePostgreSql());
base.OnModelCreating(modelBuilder);
}
}

View File

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

View File

@@ -0,0 +1,16 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"RabbitMQ": {
"ConnectionString": "amqp://localhost",
"ClientIdentifier": "SchedulerService"
},
"ConnectionStrings": {
"DefaultConnection": "Host=localhost;Database=FictionArchive_SchedulerService;Username=postgres;password=postgres"
},
"AllowedHosts": "*"
}

View File

@@ -13,6 +13,7 @@ public static class GraphQLExtensions
.AddQueryType<TQuery>()
.AddMutationType<TMutation>()
.AddDiagnosticEventListener<ErrorEventListener>()
.AddErrorFilter<LoggingErrorFilter>()
.AddType<UnsignedIntType>()
.AddType<InstantType>()
.AddMutationConventions(applyToAllMutations: true)

View File

@@ -22,6 +22,8 @@
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="NodaTime.Serialization.JsonNet" Version="3.2.0" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime" Version="9.0.4" />
<PackageReference Include="RabbitMQ.Client" Version="7.2.0" />
@@ -30,9 +32,5 @@
<ItemGroup>
<ProjectReference Include="..\FictionArchive.Common\FictionArchive.Common.csproj" />
</ItemGroup>
<ItemGroup>
<Folder Include="Services\Messaging\Interfaces\" />
</ItemGroup>
</Project>

View File

@@ -5,7 +5,7 @@ namespace FictionArchive.Service.Shared.Models;
public abstract class BaseEntity<TKey> : IAuditable
{
public uint Id { get; set; }
public TKey Id { get; set; }
public Instant CreatedTime { get; set; }
public Instant LastUpdatedTime { get; set; }
}

View File

@@ -0,0 +1,25 @@
using Microsoft.Extensions.DependencyInjection;
namespace FictionArchive.Service.Shared.Services.EventBus;
public class EventBusBuilder<TEventBus> where TEventBus : class, IEventBus
{
private readonly IServiceCollection _services;
private readonly SubscriptionManager _subscriptionManager;
public EventBusBuilder(IServiceCollection services)
{
_services = services;
_services.AddSingleton<IEventBus, TEventBus>();
_subscriptionManager = new SubscriptionManager();
_services.AddSingleton<SubscriptionManager>(_subscriptionManager);
}
public EventBusBuilder<TEventBus> Subscribe<TEvent, TEventHandler>() where TEvent : IntegrationEvent where TEventHandler : class, IIntegrationEventHandler<TEvent>
{
_services.AddKeyedTransient<IIntegrationEventHandler, TEventHandler>(typeof(TEvent).Name);
_subscriptionManager.RegisterSubscription<TEvent>();
return this;
}
}

View File

@@ -0,0 +1,12 @@
using Microsoft.Extensions.DependencyInjection;
namespace FictionArchive.Service.Shared.Services.EventBus;
public static class EventBusExtensions
{
public static EventBusBuilder<TEventBus> AddEventBus<TEventBus>(this IServiceCollection services)
where TEventBus : class, IEventBus
{
return new EventBusBuilder<TEventBus>(services);
}
}

View File

@@ -0,0 +1,7 @@
namespace FictionArchive.Service.Shared.Services.EventBus;
public interface IEventBus
{
Task Publish<TEvent>(TEvent integrationEvent) where TEvent : IntegrationEvent;
Task Publish(object integrationEvent, string eventType);
}

View File

@@ -0,0 +1,12 @@
namespace FictionArchive.Service.Shared.Services.EventBus;
public interface IIntegrationEventHandler<in TEvent> : IIntegrationEventHandler where TEvent : IntegrationEvent
{
Task Handle(TEvent @event);
Task IIntegrationEventHandler.Handle(IntegrationEvent @event) => Handle((TEvent)@event);
}
public interface IIntegrationEventHandler
{
Task Handle(IntegrationEvent @event);
}

View File

@@ -0,0 +1,35 @@
using RabbitMQ.Client;
namespace FictionArchive.Service.Shared.Services.EventBus.Implementations;
public class RabbitMQConnectionProvider
{
private readonly IConnectionFactory _connectionFactory;
private IConnection Connection { get; set; }
private IChannel DefaultChannel { get; set; }
public RabbitMQConnectionProvider(IConnectionFactory connectionFactory)
{
_connectionFactory = connectionFactory;
}
public async Task<IConnection> GetConnectionAsync()
{
if (Connection == null)
{
Connection = await _connectionFactory.CreateConnectionAsync();
}
return Connection;
}
public async Task<IChannel> GetDefaultChannelAsync()
{
if (DefaultChannel == null)
{
DefaultChannel = await (await GetConnectionAsync()).CreateChannelAsync();
}
return DefaultChannel;
}
}

View File

@@ -0,0 +1,129 @@
using System.Text;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Newtonsoft.Json;
using NodaTime;
using NodaTime.Serialization.JsonNet;
using RabbitMQ.Client;
using RabbitMQ.Client.Events;
namespace FictionArchive.Service.Shared.Services.EventBus.Implementations;
public class RabbitMQEventBus : IEventBus, IHostedService
{
private readonly IServiceScopeFactory _serviceScopeFactory;
private readonly RabbitMQConnectionProvider _connectionProvider;
private readonly RabbitMQOptions _options;
private readonly SubscriptionManager _subscriptionManager;
private readonly ILogger<RabbitMQEventBus> _logger;
private readonly JsonSerializerSettings _jsonSerializerSettings;
private const string ExchangeName = "fiction-archive-event-bus";
public RabbitMQEventBus(IServiceScopeFactory serviceScopeFactory, RabbitMQConnectionProvider connectionProvider, IOptions<RabbitMQOptions> options, SubscriptionManager subscriptionManager, ILogger<RabbitMQEventBus> logger)
{
_serviceScopeFactory = serviceScopeFactory;
_connectionProvider = connectionProvider;
_subscriptionManager = subscriptionManager;
_logger = logger;
_options = options.Value;
_jsonSerializerSettings = new JsonSerializerSettings().ConfigureForNodaTime(DateTimeZoneProviders.Tzdb);
}
public async Task Publish<TEvent>(TEvent integrationEvent) where TEvent : IntegrationEvent
{
var routingKey = typeof(TEvent).Name;
// Set integration event values
integrationEvent.CreatedAt = Instant.FromDateTimeUtc(DateTime.UtcNow);
integrationEvent.EventId = Guid.NewGuid();
await Publish(integrationEvent, routingKey);
}
public async Task Publish(object integrationEvent, string eventType)
{
var channel = await _connectionProvider.GetDefaultChannelAsync();
var body = Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(integrationEvent));
await channel.BasicPublishAsync(ExchangeName, eventType, true, body);
_logger.LogInformation("Published event {EventName}", eventType);
}
public async Task StartAsync(CancellationToken cancellationToken)
{
_ = Task.Factory.StartNew(async () =>
{
try
{
var channel = await _connectionProvider.GetDefaultChannelAsync();
await channel.ExchangeDeclareAsync(ExchangeName, ExchangeType.Direct,
cancellationToken: cancellationToken);
await channel.QueueDeclareAsync(_options.ClientIdentifier, true, false, false,
cancellationToken: cancellationToken);
var consumer = new AsyncEventingBasicConsumer(channel);
consumer.ReceivedAsync += (sender, @event) =>
{
return OnReceivedEvent(sender, @event, channel);
};
foreach (var subscription in _subscriptionManager.Subscriptions)
{
await channel.QueueBindAsync(_options.ClientIdentifier, ExchangeName, subscription.Key,
cancellationToken: cancellationToken);
_logger.LogInformation("Subscribed to {SubscriptionKey}", subscription.Key);
}
await channel.BasicConsumeAsync(_options.ClientIdentifier, false, consumer, cancellationToken: cancellationToken);
_logger.LogInformation("RabbitMQ EventBus started.");
}
catch (Exception e)
{
_logger.LogError(e, "An error occurred while starting the RabbitMQ EventBus");
}
}, cancellationToken);
}
public Task StopAsync(CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
private async Task OnReceivedEvent(object sender, BasicDeliverEventArgs @event, IChannel channel)
{
var eventName = @event.RoutingKey;
_logger.LogInformation("Received event {EventName}", eventName);
try
{
if (!_subscriptionManager.Subscriptions.ContainsKey(eventName))
{
_logger.LogWarning("Received event without subscription entry.");
return;
}
var eventBody = Encoding.UTF8.GetString(@event.Body.Span);
var eventObject = JsonConvert.DeserializeObject(eventBody, _subscriptionManager.Subscriptions[eventName], _jsonSerializerSettings) as IntegrationEvent;
using var scope = _serviceScopeFactory.CreateScope();
foreach (var service in scope.ServiceProvider.GetKeyedServices<IIntegrationEventHandler>(eventName))
{
await service.Handle(eventObject);
}
_logger.LogInformation("Finished handling event with name {EventName}", eventName);
}
catch (Exception e)
{
_logger.LogError(e, "An error occurred while handling an event.");
}
finally
{
await channel.BasicAckAsync(@event.DeliveryTag, false);
}
}
}

View File

@@ -0,0 +1,24 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using RabbitMQ.Client;
namespace FictionArchive.Service.Shared.Services.EventBus.Implementations;
public static class RabbitMQExtensions
{
public static EventBusBuilder<RabbitMQEventBus> AddRabbitMQ(this IServiceCollection services, Action<RabbitMQOptions> configure)
{
services.Configure(configure);
services.AddSingleton<IConnectionFactory, ConnectionFactory>(provider =>
{
var options = provider.GetService<IOptions<RabbitMQOptions>>();
ConnectionFactory factory = new ConnectionFactory();
factory.Uri = new Uri(options.Value.ConnectionString);
return factory;
});
services.AddSingleton<RabbitMQConnectionProvider>();
services.AddHostedService<RabbitMQEventBus>();
return services.AddEventBus<RabbitMQEventBus>();
}
}

View File

@@ -0,0 +1,7 @@
namespace FictionArchive.Service.Shared.Services.EventBus.Implementations;
public class RabbitMQOptions
{
public string ConnectionString { get; set; }
public string ClientIdentifier { get; set; }
}

View File

@@ -0,0 +1,9 @@
using NodaTime;
namespace FictionArchive.Service.Shared.Services.EventBus;
public abstract class IntegrationEvent
{
public Guid EventId { get; set; }
public Instant CreatedAt { get; set; }
}

View File

@@ -0,0 +1,11 @@
namespace FictionArchive.Service.Shared.Services.EventBus;
public class SubscriptionManager
{
public Dictionary<string, Type> Subscriptions { get; } = new Dictionary<string, Type>();
public void RegisterSubscription<TEvent>()
{
Subscriptions.Add(typeof(TEvent).Name, typeof(TEvent));
}
}

View File

@@ -0,0 +1,23 @@
using Microsoft.Extensions.Logging;
namespace FictionArchive.Service.Shared.Services.GraphQL;
public class LoggingErrorFilter : IErrorFilter
{
private readonly ILogger<LoggingErrorFilter> _logger;
public LoggingErrorFilter(ILogger<LoggingErrorFilter> logger)
{
_logger = logger;
}
public IError OnError(IError error)
{
if (error.Exception != null)
{
_logger.LogError(error.Exception, "Unexpected GraphQL error occurred");
}
return error;
}
}

View File

@@ -1,6 +1,8 @@
using FictionArchive.Common.Enums;
using FictionArchive.Service.TranslationService.Models;
using FictionArchive.Service.TranslationService.Models.Database;
using FictionArchive.Service.TranslationService.Models.Enums;
using FictionArchive.Service.TranslationService.Services;
using FictionArchive.Service.TranslationService.Services.Database;
using FictionArchive.Service.TranslationService.Services.TranslationEngines;
@@ -8,23 +10,10 @@ namespace FictionArchive.Service.TranslationService.GraphQL;
public class Mutation
{
public async Task<string> TranslateText(string text, Language from, Language to, string translationEngineKey, IEnumerable<ITranslationEngine> translationEngines, TranslationServiceDbContext dbContext)
public async Task<TranslationResult> TranslateText(string text, Language from, Language to, string translationEngineKey, TranslationEngineService translationEngineService)
{
var engine = translationEngines.FirstOrDefault(engine => engine.Descriptor.Key == translationEngineKey);
var translation = await engine.GetTranslation(text, from, to);
dbContext.TranslationRequests.Add(new TranslationRequest()
{
OriginalText = text,
BilledCharacterCount = 0, // FILL ME
From = from,
To = to,
Status = translation != null ? TranslationRequestStatus.Success : TranslationRequestStatus.Failed,
TranslatedText = translation,
TranslationEngineKey = translationEngineKey
});
await dbContext.SaveChangesAsync();
var result = await translationEngineService.Translate(from, to, text, translationEngineKey);
return translation;
return result;
}
}

View File

@@ -0,0 +1,18 @@
using FictionArchive.Common.Enums;
using FictionArchive.Service.Shared.Services.EventBus;
using FictionArchive.Service.TranslationService.Models.Enums;
namespace FictionArchive.Service.TranslationService.Models.IntegrationEvents;
public class TranslationRequestCompletedEvent : IntegrationEvent
{
/// <summary>
/// Maps this event back to a triggering request.
/// </summary>
public Guid? TranslationRequestId { get; set; }
/// <summary>
/// The resulting text.
/// </summary>
public string? TranslatedText { get; set; }
}

View File

@@ -0,0 +1,13 @@
using FictionArchive.Common.Enums;
using FictionArchive.Service.Shared.Services.EventBus;
namespace FictionArchive.Service.TranslationService.Models.IntegrationEvents;
public class TranslationRequestCreatedEvent : IntegrationEvent
{
public Guid TranslationRequestId { get; set; }
public Language From { get; set; }
public Language To { get; set; }
public string Body { get; set; }
public string TranslationEngineKey { get; set; }
}

View File

@@ -0,0 +1,15 @@
using FictionArchive.Common.Enums;
using FictionArchive.Service.TranslationService.Models.Enums;
namespace FictionArchive.Service.TranslationService.Models;
public class TranslationResult
{
public required string OriginalText { get; set; }
public string? TranslatedText { get; set; }
public Language From { get; set; }
public Language To { get; set; }
public required string TranslationEngineKey { get; set; }
public TranslationRequestStatus Status { get; set; }
public uint BilledCharacterCount { get; set; }
}

View File

@@ -1,11 +1,16 @@
using DeepL;
using FictionArchive.Common.Extensions;
using FictionArchive.Service.Shared.Extensions;
using FictionArchive.Service.Shared.Services.EventBus.Implementations;
using FictionArchive.Service.Shared.Services.GraphQL;
using FictionArchive.Service.TranslationService.GraphQL;
using FictionArchive.Service.TranslationService.Models.IntegrationEvents;
using FictionArchive.Service.TranslationService.Services;
using FictionArchive.Service.TranslationService.Services.Database;
using FictionArchive.Service.TranslationService.Services.EventHandlers;
using FictionArchive.Service.TranslationService.Services.TranslationEngines;
using FictionArchive.Service.TranslationService.Services.TranslationEngines.DeepLTranslate;
using RabbitMQ.Client;
namespace FictionArchive.Service.TranslationService;
@@ -18,6 +23,17 @@ public class Program
builder.Services.AddHealthChecks();
#region Event Bus
builder.Services.AddRabbitMQ(opt =>
{
builder.Configuration.GetSection("RabbitMQ").Bind(opt);
})
.Subscribe<TranslationRequestCreatedEvent, TranslationRequestCreatedEventHandler>();
#endregion
#region Database
builder.Services.RegisterDbContext<TranslationServiceDbContext>(builder.Configuration.GetConnectionString("DefaultConnection"));
@@ -37,6 +53,8 @@ public class Program
return new DeepLClient(builder.Configuration["DeepL:ApiKey"]);
});
builder.Services.AddTransient<ITranslationEngine, DeepLTranslationEngine>();
builder.Services.AddTransient<TranslationEngineService>();
#endregion

View File

@@ -0,0 +1,31 @@
using FictionArchive.Service.Shared.Services.EventBus;
using FictionArchive.Service.TranslationService.Models.Enums;
using FictionArchive.Service.TranslationService.Models.IntegrationEvents;
namespace FictionArchive.Service.TranslationService.Services.EventHandlers;
public class TranslationRequestCreatedEventHandler : IIntegrationEventHandler<TranslationRequestCreatedEvent>
{
private readonly ILogger<TranslationRequestCreatedEventHandler> _logger;
private readonly TranslationEngineService _translationEngineService;
private readonly IEventBus _eventBus;
public TranslationRequestCreatedEventHandler(ILogger<TranslationRequestCreatedEventHandler> logger, TranslationEngineService translationEngineService)
{
_logger = logger;
_translationEngineService = translationEngineService;
}
public async Task Handle(TranslationRequestCreatedEvent @event)
{
var result = await _translationEngineService.Translate(@event.From, @event.To, @event.Body, @event.TranslationEngineKey);
if (result.Status == TranslationRequestStatus.Success)
{
await _eventBus.Publish(new TranslationRequestCompletedEvent()
{
TranslatedText = result.TranslatedText,
TranslationRequestId = @event.TranslationRequestId,
});
}
}
}

View File

@@ -0,0 +1,47 @@
using System.Text;
using FictionArchive.Common.Enums;
using FictionArchive.Service.Shared.Services.EventBus;
using FictionArchive.Service.Shared.Services.EventBus.Implementations;
using FictionArchive.Service.TranslationService.Models;
using FictionArchive.Service.TranslationService.Models.Database;
using FictionArchive.Service.TranslationService.Models.Enums;
using FictionArchive.Service.TranslationService.Models.IntegrationEvents;
using FictionArchive.Service.TranslationService.Services.Database;
using FictionArchive.Service.TranslationService.Services.TranslationEngines;
using RabbitMQ.Client;
namespace FictionArchive.Service.TranslationService.Services;
public class TranslationEngineService
{
private readonly IEnumerable<ITranslationEngine> _translationEngines;
private readonly IEventBus _eventBus;
private readonly TranslationServiceDbContext _dbContext;
public TranslationEngineService(IEnumerable<ITranslationEngine> translationEngines, TranslationServiceDbContext dbContext, IEventBus eventBus)
{
_translationEngines = translationEngines;
_dbContext = dbContext;
_eventBus = eventBus;
}
public async Task<TranslationResult> Translate(Language from, Language to, string text, string translationEngineKey)
{
var engine = _translationEngines.FirstOrDefault(engine => engine.Descriptor.Key == translationEngineKey);
var translation = await engine.GetTranslation(text, from, to);
_dbContext.TranslationRequests.Add(new TranslationRequest()
{
OriginalText = text,
BilledCharacterCount = translation.BilledCharacterCount, // FILL ME
From = from,
To = to,
Status = translation != null ? TranslationRequestStatus.Success : TranslationRequestStatus.Failed,
TranslatedText = translation.TranslatedText,
TranslationEngineKey = translationEngineKey
});
await _dbContext.SaveChangesAsync();
return translation;
}
}

View File

@@ -1,6 +1,7 @@
using DeepL;
using DeepL.Model;
using FictionArchive.Service.TranslationService.Models;
using FictionArchive.Service.TranslationService.Models.Enums;
using Language = FictionArchive.Common.Enums.Language;
namespace FictionArchive.Service.TranslationService.Services.TranslationEngines.DeepLTranslate;
@@ -31,11 +32,20 @@ public class DeepLTranslationEngine : ITranslationEngine
}
}
public async Task<string?> GetTranslation(string body, Language from, Language to)
public async Task<TranslationResult> GetTranslation(string body, Language from, Language to)
{
TextResult translationResult = await _deepLClient.TranslateTextAsync(body, GetLanguageCode(from), GetLanguageCode(to));
_logger.LogInformation("Translated text. Usage statistics: CHARACTERS BILLED {TranslationResultBilledCharacters}", translationResult.BilledCharacters);
return translationResult.Text;
return new TranslationResult()
{
OriginalText = body,
From = from,
To = to,
TranslationEngineKey = Key,
BilledCharacterCount = (uint)translationResult.BilledCharacters,
Status = TranslationRequestStatus.Success,
TranslatedText = translationResult.Text
};
}
private string GetLanguageCode(Language language)

View File

@@ -6,5 +6,5 @@ namespace FictionArchive.Service.TranslationService.Services.TranslationEngines;
public interface ITranslationEngine
{
public TranslationEngineDescriptor Descriptor { get; }
public Task<string?> GetTranslation(string body, Language from, Language to);
public Task<TranslationResult> GetTranslation(string body, Language from, Language to);
}

View File

@@ -11,5 +11,9 @@
"ConnectionStrings": {
"DefaultConnection": "Host=localhost;Database=FictionArchive_NovelService;Username=postgres;password=postgres"
},
"RabbitMQ": {
"ConnectionString": "amqp://localhost",
"ClientIdentifier": "TranslationService"
},
"AllowedHosts": "*"
}

View File

@@ -10,6 +10,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FictionArchive.Service.Tran
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FictionArchive.Service.Shared", "FictionArchive.Service.Shared\FictionArchive.Service.Shared.csproj", "{82638874-304C-43E6-8EFA-8AD4C41C4435}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FictionArchive.Service.SchedulerService", "FictionArchive.Service.SchedulerService\FictionArchive.Service.SchedulerService.csproj", "{6813A8AD-A071-4F86-B227-BC4A5BCD7F3C}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -36,5 +38,9 @@ Global
{82638874-304C-43E6-8EFA-8AD4C41C4435}.Debug|Any CPU.Build.0 = Debug|Any CPU
{82638874-304C-43E6-8EFA-8AD4C41C4435}.Release|Any CPU.ActiveCfg = Release|Any CPU
{82638874-304C-43E6-8EFA-8AD4C41C4435}.Release|Any CPU.Build.0 = Release|Any CPU
{6813A8AD-A071-4F86-B227-BC4A5BCD7F3C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{6813A8AD-A071-4F86-B227-BC4A5BCD7F3C}.Debug|Any CPU.Build.0 = Debug|Any CPU
{6813A8AD-A071-4F86-B227-BC4A5BCD7F3C}.Release|Any CPU.ActiveCfg = Release|Any CPU
{6813A8AD-A071-4F86-B227-BC4A5BCD7F3C}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal