3 Commits

Author SHA1 Message Date
7c9e0eddf9 Metadata-Site Search (Interactive linking) 2025-06-29 21:13:05 +02:00
ae0c6c8240 Change PrimaryKey of MetadataEntry to Fetcher + Identifier 2025-06-29 20:43:21 +02:00
2eb0d941b2 MetadataFetching:
- Jikan (MAL) linking, fetching/updating
2025-06-28 22:59:35 +02:00
31 changed files with 2330 additions and 433 deletions

View File

@ -12,6 +12,7 @@
<ItemGroup>
<PackageReference Include="Asp.Versioning.Mvc.ApiExplorer" Version="8.1.0" />
<PackageReference Include="HtmlAgilityPack" Version="1.12.0" />
<PackageReference Include="JikanDotNet" Version="2.9.1" />
<PackageReference Include="log4net" Version="3.0.4" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="9.0.3" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.3" />

View File

@ -0,0 +1,166 @@
using API.Schema;
using API.Schema.Contexts;
using API.Schema.MetadataFetchers;
using Asp.Versioning;
using log4net;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using static Microsoft.AspNetCore.Http.StatusCodes;
// ReSharper disable InconsistentNaming
namespace API.Controllers;
[ApiVersion(2)]
[ApiController]
[Route("v{v:apiVersion}/[controller]")]
public class MetadataFetcherController(PgsqlContext context, ILog Log) : Controller
{
/// <summary>
/// Get all available Connectors (Metadata-Sites)
/// </summary>
/// <response code="200">Names of Metadata-Fetchers</response>
[HttpGet]
[ProducesResponseType<string[]>(Status200OK, "application/json")]
public IActionResult GetConnectors()
{
string[] connectors = Tranga.MetadataFetchers.Select(f => f.MetadataFetcherName).ToArray();
return Ok(connectors);
}
/// <summary>
/// Returns all Mangas which have a linked Metadata-Provider
/// </summary>
/// <response code="200"></response>
[HttpGet("Links")]
[ProducesResponseType<MetadataEntry>(Status200OK, "application/json")]
public IActionResult GetLinkedEntries()
{
return Ok(context.MetadataEntries.ToArray());
}
/// <summary>
/// Searches Metadata-Provider for Manga-Metadata
/// </summary>
/// <param name="searchTerm">Instead of using the Manga for search, use a specific term</param>
/// <response code="200"></response>
/// <response code="400">Metadata-fetcher with Name does not exist</response>
/// <response code="404">Manga with ID not found</response>
[HttpPost("{MetadataFetcherName}/SearchManga/{MangaId}")]
[ProducesResponseType<MetadataSearchResult[]>(Status200OK, "application/json")]
[ProducesResponseType(Status400BadRequest)]
[ProducesResponseType(Status404NotFound)]
public IActionResult SearchMangaMetadata(string MangaId, string MetadataFetcherName, [FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Allow)]string? searchTerm = null)
{
if(context.Mangas.Find(MangaId) is not { } manga)
return NotFound();
if(Tranga.MetadataFetchers.FirstOrDefault(f => f.MetadataFetcherName == MetadataFetcherName) is not { } fetcher)
return BadRequest();
MetadataSearchResult[] searchResults = searchTerm is null ? fetcher.SearchMetadataEntry(manga) : fetcher.SearchMetadataEntry(searchTerm);
return Ok(searchResults);
}
/// <summary>
/// Links Metadata-Provider using Provider-Specific Identifier to Manga
/// </summary>
/// <response code="200"></response>
/// <response code="400">Metadata-fetcher with Name does not exist</response>
/// <response code="404">Manga with ID not found</response>
/// <response code="500">Error during Database Operation</response>
[HttpPost("{MetadataFetcherName}/Link/{MangaId}")]
[ProducesResponseType(Status200OK)]
[ProducesResponseType(Status400BadRequest)]
[ProducesResponseType(Status404NotFound)]
[ProducesResponseType<string>(Status500InternalServerError, "text/plain")]
public IActionResult LinkMangaMetadata(string MangaId, string MetadataFetcherName, [FromBody]string Identifier)
{
if(context.Mangas.Find(MangaId) is not { } manga)
return NotFound();
if(Tranga.MetadataFetchers.FirstOrDefault(f => f.MetadataFetcherName == MetadataFetcherName) is not { } fetcher)
return BadRequest();
MetadataEntry entry = fetcher.CreateMetadataEntry(manga, Identifier);
try
{
context.MetadataEntries.Add(entry);
context.SaveChanges();
}
catch (Exception e)
{
Log.Error(e);
return StatusCode(500, e.Message);
}
return Ok();
}
/// <summary>
/// Un-Links Metadata-Provider using Provider-Specific Identifier to Manga
/// </summary>
/// <response code="200"></response>
/// <response code="400">Metadata-fetcher with Name does not exist</response>
/// <response code="404">Manga with ID not found</response>
/// <response code="412">No Entry linking Manga and Metadata-Provider found</response>
/// <response code="500">Error during Database Operation</response>
[HttpPost("{MetadataFetcherName}/Unlink/{MangaId}")]
[ProducesResponseType(Status200OK)]
[ProducesResponseType(Status400BadRequest)]
[ProducesResponseType(Status404NotFound)]
[ProducesResponseType<string>(Status412PreconditionFailed, "text/plain")]
[ProducesResponseType<string>(Status500InternalServerError, "text/plain")]
public IActionResult UnlinkMangaMetadata(string MangaId, string MetadataFetcherName)
{
if(context.Mangas.Find(MangaId) is not { } manga)
return NotFound();
if(Tranga.MetadataFetchers.FirstOrDefault(f => f.MetadataFetcherName == MetadataFetcherName) is not { } fetcher)
return BadRequest();
MetadataEntry? entry = context.MetadataEntries.FirstOrDefault(e => e.MangaId == MangaId && e.MetadataFetcherName == MetadataFetcherName);
if (entry is null)
return StatusCode(Status412PreconditionFailed, "No entry found");
try
{
context.MetadataEntries.Remove(entry);
context.SaveChanges();
}
catch (Exception e)
{
Log.Error(e);
return StatusCode(500, e.Message);
}
return Ok();
}
/// <summary>
/// Tries linking a Manga to a Metadata-Provider-Site
/// </summary>
/// <response code="200"></response>
/// <response code="400">MetadataFetcher Name is invalid</response>
/// <response code="404">Manga has no linked entry with MetadataFetcher</response>
/// <response code="500">Error during Database Operation</response>
[HttpPost("{MetadataFetcherName}/{MangaId}/UpdateMetadata")]
[ProducesResponseType(Status200OK)]
[ProducesResponseType(Status400BadRequest)]
[ProducesResponseType(Status404NotFound)]
[ProducesResponseType<string>(Status500InternalServerError, "text/plain")]
public IActionResult UpdateMetadata(string MangaId, string MetadataFetcherName)
{
if(Tranga.MetadataFetchers
.FirstOrDefault(f =>
f.MetadataFetcherName.Equals(MetadataFetcherName, StringComparison.InvariantCultureIgnoreCase)) is not { } fetcher)
return BadRequest();
MetadataEntry? entry = context.MetadataEntries
.FirstOrDefault(e =>
e.MangaId == MangaId && e.MetadataFetcherName.Equals(MetadataFetcherName, StringComparison.InvariantCultureIgnoreCase));
if (entry is null)
return NotFound();
try
{
fetcher.UpdateMetadata(entry, context);
}
catch (Exception e)
{
Log.Error(e);
return StatusCode(500, e.Message);
}
return Ok();
}
}

View File

@ -1,47 +0,0 @@
using API.Schema.Jobs;
namespace API;
internal static class JobQueueSorter
{
public static readonly Dictionary<JobType, byte> JobTypePriority = new()
{
{ JobType.DownloadSingleChapterJob, 50 },
{ JobType.DownloadAvailableChaptersJob, 51 },
{ JobType.MoveFileOrFolderJob, 102 },
{ JobType.DownloadMangaCoverJob, 10 },
{ JobType.RetrieveChaptersJob, 52 },
{ JobType.UpdateChaptersDownloadedJob, 90 },
{ JobType.MoveMangaLibraryJob, 101 },
{ JobType.UpdateCoverJob, 11 },
};
public static byte GetPriority(Job job)
{
return JobTypePriority[job.JobType];
}
public static byte GetPriority(JobType jobType)
{
return JobTypePriority[jobType];
}
public static IEnumerable<Job> Sort(this IEnumerable<Job> jobQueueSortables)
{
return jobQueueSortables.Order();
}
public static IEnumerable<Job> GetStartableJobs(this IEnumerable<Job> jobQueueSortables)
{
Job[] sorted = jobQueueSortables.Order().ToArray();
// Job has to be due, no missing dependenices
// Index - 1, Index is first job that does not match requirements
IEnumerable<(int Index, Job Item)> index = sorted.Index();
(int i, Job? item) = index.FirstOrDefault(job =>
job.Item.NextExecution > DateTime.UtcNow || job.Item.GetDependencies().Any(j => !j.IsCompleted));
if (item is null)
return sorted;
index.
}
}

View File

@ -0,0 +1,788 @@
// <auto-generated />
using System;
using API.Schema.Contexts;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace API.Migrations.pgsql
{
[DbContext(typeof(PgsqlContext))]
[Migration("20250628204956_AddMAL")]
partial class AddMAL
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "9.0.5")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("API.Schema.Author", b =>
{
b.Property<string>("AuthorId")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("AuthorName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.HasKey("AuthorId");
b.ToTable("Authors");
});
modelBuilder.Entity("API.Schema.Chapter", b =>
{
b.Property<string>("ChapterId")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("ChapterNumber")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("character varying(10)");
b.Property<bool>("Downloaded")
.HasColumnType("boolean");
b.Property<string>("FileName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("IdOnConnectorSite")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("ParentMangaId")
.IsRequired()
.HasColumnType("character varying(64)");
b.Property<string>("Title")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("Url")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<int?>("VolumeNumber")
.HasColumnType("integer");
b.HasKey("ChapterId");
b.HasIndex("ParentMangaId");
b.ToTable("Chapters");
});
modelBuilder.Entity("API.Schema.Jobs.Job", b =>
{
b.Property<string>("JobId")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<bool>("Enabled")
.HasColumnType("boolean");
b.Property<byte>("JobType")
.HasColumnType("smallint");
b.Property<DateTime>("LastExecution")
.HasColumnType("timestamp with time zone");
b.Property<string>("ParentJobId")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<decimal>("RecurrenceMs")
.HasColumnType("numeric(20,0)");
b.Property<byte>("state")
.HasColumnType("smallint");
b.HasKey("JobId");
b.HasIndex("ParentJobId");
b.ToTable("Jobs");
b.HasDiscriminator<byte>("JobType");
b.UseTphMappingStrategy();
});
modelBuilder.Entity("API.Schema.LocalLibrary", b =>
{
b.Property<string>("LocalLibraryId")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("BasePath")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("LibraryName")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.HasKey("LocalLibraryId");
b.ToTable("LocalLibraries");
});
modelBuilder.Entity("API.Schema.Manga", b =>
{
b.Property<string>("MangaId")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("CoverFileNameInCache")
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<string>("CoverUrl")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<string>("Description")
.IsRequired()
.HasColumnType("text");
b.Property<string>("DirectoryName")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)");
b.Property<string>("IdOnConnectorSite")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<float>("IgnoreChaptersBefore")
.HasColumnType("real");
b.Property<string>("LibraryId")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("MangaConnectorName")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<string>("OriginalLanguage")
.HasMaxLength(8)
.HasColumnType("character varying(8)");
b.Property<byte>("ReleaseStatus")
.HasColumnType("smallint");
b.Property<string>("WebsiteUrl")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<long?>("Year")
.HasColumnType("bigint");
b.HasKey("MangaId");
b.HasIndex("LibraryId");
b.HasIndex("MangaConnectorName");
b.ToTable("Mangas");
});
modelBuilder.Entity("API.Schema.MangaConnectors.MangaConnector", b =>
{
b.Property<string>("Name")
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.PrimitiveCollection<string[]>("BaseUris")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("text[]");
b.Property<bool>("Enabled")
.HasColumnType("boolean");
b.Property<string>("IconUrl")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.PrimitiveCollection<string[]>("SupportedLanguages")
.IsRequired()
.HasMaxLength(8)
.HasColumnType("text[]");
b.HasKey("Name");
b.ToTable("MangaConnectors");
b.HasDiscriminator<string>("Name").HasValue("MangaConnector");
b.UseTphMappingStrategy();
});
modelBuilder.Entity("API.Schema.MangaTag", b =>
{
b.Property<string>("Tag")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.HasKey("Tag");
b.ToTable("Tags");
});
modelBuilder.Entity("API.Schema.MetadataFetchers.MetadataEntry", b =>
{
b.Property<string>("MangaId")
.HasColumnType("character varying(64)");
b.Property<string>("MetadataFetcherName")
.HasColumnType("text");
b.Property<string>("Identifier")
.IsRequired()
.HasColumnType("text");
b.HasKey("MangaId", "MetadataFetcherName");
b.HasIndex("MetadataFetcherName");
b.ToTable("MetadataEntries");
});
modelBuilder.Entity("API.Schema.MetadataFetchers.MetadataFetcher", b =>
{
b.Property<string>("MetadataFetcherName")
.HasColumnType("text");
b.Property<string>("MetadataEntry")
.IsRequired()
.HasMaxLength(21)
.HasColumnType("character varying(21)");
b.HasKey("MetadataFetcherName");
b.ToTable("MetadataFetcher");
b.HasDiscriminator<string>("MetadataEntry").HasValue("MetadataFetcher");
b.UseTphMappingStrategy();
});
modelBuilder.Entity("AuthorToManga", b =>
{
b.Property<string>("AuthorIds")
.HasColumnType("character varying(64)");
b.Property<string>("MangaIds")
.HasColumnType("character varying(64)");
b.HasKey("AuthorIds", "MangaIds");
b.HasIndex("MangaIds");
b.ToTable("AuthorToManga");
});
modelBuilder.Entity("JobJob", b =>
{
b.Property<string>("DependsOnJobsJobId")
.HasColumnType("character varying(64)");
b.Property<string>("JobId")
.HasColumnType("character varying(64)");
b.HasKey("DependsOnJobsJobId", "JobId");
b.HasIndex("JobId");
b.ToTable("JobJob");
});
modelBuilder.Entity("MangaTagToManga", b =>
{
b.Property<string>("MangaTagIds")
.HasColumnType("character varying(64)");
b.Property<string>("MangaIds")
.HasColumnType("character varying(64)");
b.HasKey("MangaTagIds", "MangaIds");
b.HasIndex("MangaIds");
b.ToTable("MangaTagToManga");
});
modelBuilder.Entity("API.Schema.Jobs.DownloadAvailableChaptersJob", b =>
{
b.HasBaseType("API.Schema.Jobs.Job");
b.Property<string>("MangaId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.HasIndex("MangaId");
b.ToTable("Jobs", t =>
{
t.Property("MangaId")
.HasColumnName("DownloadAvailableChaptersJob_MangaId");
});
b.HasDiscriminator().HasValue((byte)1);
});
modelBuilder.Entity("API.Schema.Jobs.DownloadMangaCoverJob", b =>
{
b.HasBaseType("API.Schema.Jobs.Job");
b.Property<string>("MangaId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.HasIndex("MangaId");
b.HasDiscriminator().HasValue((byte)4);
});
modelBuilder.Entity("API.Schema.Jobs.DownloadSingleChapterJob", b =>
{
b.HasBaseType("API.Schema.Jobs.Job");
b.Property<string>("ChapterId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.HasIndex("ChapterId");
b.HasDiscriminator().HasValue((byte)0);
});
modelBuilder.Entity("API.Schema.Jobs.MoveFileOrFolderJob", b =>
{
b.HasBaseType("API.Schema.Jobs.Job");
b.Property<string>("FromLocation")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("ToLocation")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasDiscriminator().HasValue((byte)3);
});
modelBuilder.Entity("API.Schema.Jobs.MoveMangaLibraryJob", b =>
{
b.HasBaseType("API.Schema.Jobs.Job");
b.Property<string>("MangaId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("ToLibraryId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.HasIndex("MangaId");
b.HasIndex("ToLibraryId");
b.ToTable("Jobs", t =>
{
t.Property("MangaId")
.HasColumnName("MoveMangaLibraryJob_MangaId");
});
b.HasDiscriminator().HasValue((byte)7);
});
modelBuilder.Entity("API.Schema.Jobs.RetrieveChaptersJob", b =>
{
b.HasBaseType("API.Schema.Jobs.Job");
b.Property<string>("Language")
.IsRequired()
.HasMaxLength(8)
.HasColumnType("character varying(8)");
b.Property<string>("MangaId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.HasIndex("MangaId");
b.ToTable("Jobs", t =>
{
t.Property("MangaId")
.HasColumnName("RetrieveChaptersJob_MangaId");
});
b.HasDiscriminator().HasValue((byte)5);
});
modelBuilder.Entity("API.Schema.Jobs.UpdateChaptersDownloadedJob", b =>
{
b.HasBaseType("API.Schema.Jobs.Job");
b.Property<string>("MangaId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.HasIndex("MangaId");
b.ToTable("Jobs", t =>
{
t.Property("MangaId")
.HasColumnName("UpdateChaptersDownloadedJob_MangaId");
});
b.HasDiscriminator().HasValue((byte)6);
});
modelBuilder.Entity("API.Schema.Jobs.UpdateCoverJob", b =>
{
b.HasBaseType("API.Schema.Jobs.Job");
b.Property<string>("MangaId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.HasIndex("MangaId");
b.ToTable("Jobs", t =>
{
t.Property("MangaId")
.HasColumnName("UpdateCoverJob_MangaId");
});
b.HasDiscriminator().HasValue((byte)9);
});
modelBuilder.Entity("API.Schema.MangaConnectors.ComickIo", b =>
{
b.HasBaseType("API.Schema.MangaConnectors.MangaConnector");
b.HasDiscriminator().HasValue("ComickIo");
});
modelBuilder.Entity("API.Schema.MangaConnectors.Global", b =>
{
b.HasBaseType("API.Schema.MangaConnectors.MangaConnector");
b.HasDiscriminator().HasValue("Global");
});
modelBuilder.Entity("API.Schema.MangaConnectors.MangaDex", b =>
{
b.HasBaseType("API.Schema.MangaConnectors.MangaConnector");
b.HasDiscriminator().HasValue("MangaDex");
});
modelBuilder.Entity("API.Schema.MetadataFetchers.MyAnimeList", b =>
{
b.HasBaseType("API.Schema.MetadataFetchers.MetadataFetcher");
b.HasDiscriminator().HasValue("MyAnimeList");
});
modelBuilder.Entity("API.Schema.Chapter", b =>
{
b.HasOne("API.Schema.Manga", "ParentManga")
.WithMany("Chapters")
.HasForeignKey("ParentMangaId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("ParentManga");
});
modelBuilder.Entity("API.Schema.Jobs.Job", b =>
{
b.HasOne("API.Schema.Jobs.Job", "ParentJob")
.WithMany()
.HasForeignKey("ParentJobId")
.OnDelete(DeleteBehavior.Cascade);
b.Navigation("ParentJob");
});
modelBuilder.Entity("API.Schema.Manga", b =>
{
b.HasOne("API.Schema.LocalLibrary", "Library")
.WithMany()
.HasForeignKey("LibraryId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("API.Schema.MangaConnectors.MangaConnector", "MangaConnector")
.WithMany()
.HasForeignKey("MangaConnectorName")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.OwnsMany("API.Schema.Link", "Links", b1 =>
{
b1.Property<string>("LinkId")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b1.Property<string>("LinkProvider")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b1.Property<string>("LinkUrl")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b1.Property<string>("MangaId")
.IsRequired()
.HasColumnType("character varying(64)");
b1.HasKey("LinkId");
b1.HasIndex("MangaId");
b1.ToTable("Link");
b1.WithOwner()
.HasForeignKey("MangaId");
});
b.OwnsMany("API.Schema.MangaAltTitle", "AltTitles", b1 =>
{
b1.Property<string>("AltTitleId")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b1.Property<string>("Language")
.IsRequired()
.HasMaxLength(8)
.HasColumnType("character varying(8)");
b1.Property<string>("MangaId")
.IsRequired()
.HasColumnType("character varying(64)");
b1.Property<string>("Title")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b1.HasKey("AltTitleId");
b1.HasIndex("MangaId");
b1.ToTable("MangaAltTitle");
b1.WithOwner()
.HasForeignKey("MangaId");
});
b.Navigation("AltTitles");
b.Navigation("Library");
b.Navigation("Links");
b.Navigation("MangaConnector");
});
modelBuilder.Entity("API.Schema.MetadataFetchers.MetadataEntry", b =>
{
b.HasOne("API.Schema.Manga", "Manga")
.WithMany()
.HasForeignKey("MangaId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("API.Schema.MetadataFetchers.MetadataFetcher", "MetadataFetcher")
.WithMany()
.HasForeignKey("MetadataFetcherName")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Manga");
b.Navigation("MetadataFetcher");
});
modelBuilder.Entity("AuthorToManga", b =>
{
b.HasOne("API.Schema.Author", null)
.WithMany()
.HasForeignKey("AuthorIds")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("API.Schema.Manga", null)
.WithMany()
.HasForeignKey("MangaIds")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("JobJob", b =>
{
b.HasOne("API.Schema.Jobs.Job", null)
.WithMany()
.HasForeignKey("DependsOnJobsJobId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("API.Schema.Jobs.Job", null)
.WithMany()
.HasForeignKey("JobId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("MangaTagToManga", b =>
{
b.HasOne("API.Schema.Manga", null)
.WithMany()
.HasForeignKey("MangaIds")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("API.Schema.MangaTag", null)
.WithMany()
.HasForeignKey("MangaTagIds")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("API.Schema.Jobs.DownloadAvailableChaptersJob", b =>
{
b.HasOne("API.Schema.Manga", "Manga")
.WithMany()
.HasForeignKey("MangaId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Manga");
});
modelBuilder.Entity("API.Schema.Jobs.DownloadMangaCoverJob", b =>
{
b.HasOne("API.Schema.Manga", "Manga")
.WithMany()
.HasForeignKey("MangaId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Manga");
});
modelBuilder.Entity("API.Schema.Jobs.DownloadSingleChapterJob", b =>
{
b.HasOne("API.Schema.Chapter", "Chapter")
.WithMany()
.HasForeignKey("ChapterId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Chapter");
});
modelBuilder.Entity("API.Schema.Jobs.MoveMangaLibraryJob", b =>
{
b.HasOne("API.Schema.Manga", "Manga")
.WithMany()
.HasForeignKey("MangaId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("API.Schema.LocalLibrary", "ToLibrary")
.WithMany()
.HasForeignKey("ToLibraryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Manga");
b.Navigation("ToLibrary");
});
modelBuilder.Entity("API.Schema.Jobs.RetrieveChaptersJob", b =>
{
b.HasOne("API.Schema.Manga", "Manga")
.WithMany()
.HasForeignKey("MangaId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Manga");
});
modelBuilder.Entity("API.Schema.Jobs.UpdateChaptersDownloadedJob", b =>
{
b.HasOne("API.Schema.Manga", "Manga")
.WithMany()
.HasForeignKey("MangaId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Manga");
});
modelBuilder.Entity("API.Schema.Jobs.UpdateCoverJob", b =>
{
b.HasOne("API.Schema.Manga", "Manga")
.WithMany()
.HasForeignKey("MangaId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Manga");
});
modelBuilder.Entity("API.Schema.Manga", b =>
{
b.Navigation("Chapters");
});
#pragma warning restore 612, 618
}
}
}

View File

@ -0,0 +1,66 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace API.Migrations.pgsql
{
/// <inheritdoc />
public partial class AddMAL : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "MetadataFetcher",
columns: table => new
{
MetadataFetcherName = table.Column<string>(type: "text", nullable: false),
MetadataEntry = table.Column<string>(type: "character varying(21)", maxLength: 21, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_MetadataFetcher", x => x.MetadataFetcherName);
});
migrationBuilder.CreateTable(
name: "MetadataEntries",
columns: table => new
{
MangaId = table.Column<string>(type: "character varying(64)", nullable: false),
MetadataFetcherName = table.Column<string>(type: "text", nullable: false),
Identifier = table.Column<string>(type: "text", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_MetadataEntries", x => new { x.MangaId, x.MetadataFetcherName });
table.ForeignKey(
name: "FK_MetadataEntries_Mangas_MangaId",
column: x => x.MangaId,
principalTable: "Mangas",
principalColumn: "MangaId",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_MetadataEntries_MetadataFetcher_MetadataFetcherName",
column: x => x.MetadataFetcherName,
principalTable: "MetadataFetcher",
principalColumn: "MetadataFetcherName",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_MetadataEntries_MetadataFetcherName",
table: "MetadataEntries",
column: "MetadataFetcherName");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "MetadataEntries");
migrationBuilder.DropTable(
name: "MetadataFetcher");
}
}
}

View File

@ -0,0 +1,788 @@
// <auto-generated />
using System;
using API.Schema.Contexts;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace API.Migrations.pgsql
{
[DbContext(typeof(PgsqlContext))]
[Migration("20250629184056_MetadataEntry-PrimaryKeyChange")]
partial class MetadataEntryPrimaryKeyChange
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "9.0.5")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("API.Schema.Author", b =>
{
b.Property<string>("AuthorId")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("AuthorName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.HasKey("AuthorId");
b.ToTable("Authors");
});
modelBuilder.Entity("API.Schema.Chapter", b =>
{
b.Property<string>("ChapterId")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("ChapterNumber")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("character varying(10)");
b.Property<bool>("Downloaded")
.HasColumnType("boolean");
b.Property<string>("FileName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("IdOnConnectorSite")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("ParentMangaId")
.IsRequired()
.HasColumnType("character varying(64)");
b.Property<string>("Title")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("Url")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<int?>("VolumeNumber")
.HasColumnType("integer");
b.HasKey("ChapterId");
b.HasIndex("ParentMangaId");
b.ToTable("Chapters");
});
modelBuilder.Entity("API.Schema.Jobs.Job", b =>
{
b.Property<string>("JobId")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<bool>("Enabled")
.HasColumnType("boolean");
b.Property<byte>("JobType")
.HasColumnType("smallint");
b.Property<DateTime>("LastExecution")
.HasColumnType("timestamp with time zone");
b.Property<string>("ParentJobId")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<decimal>("RecurrenceMs")
.HasColumnType("numeric(20,0)");
b.Property<byte>("state")
.HasColumnType("smallint");
b.HasKey("JobId");
b.HasIndex("ParentJobId");
b.ToTable("Jobs");
b.HasDiscriminator<byte>("JobType");
b.UseTphMappingStrategy();
});
modelBuilder.Entity("API.Schema.LocalLibrary", b =>
{
b.Property<string>("LocalLibraryId")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("BasePath")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("LibraryName")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.HasKey("LocalLibraryId");
b.ToTable("LocalLibraries");
});
modelBuilder.Entity("API.Schema.Manga", b =>
{
b.Property<string>("MangaId")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("CoverFileNameInCache")
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<string>("CoverUrl")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<string>("Description")
.IsRequired()
.HasColumnType("text");
b.Property<string>("DirectoryName")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)");
b.Property<string>("IdOnConnectorSite")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<float>("IgnoreChaptersBefore")
.HasColumnType("real");
b.Property<string>("LibraryId")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("MangaConnectorName")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<string>("OriginalLanguage")
.HasMaxLength(8)
.HasColumnType("character varying(8)");
b.Property<byte>("ReleaseStatus")
.HasColumnType("smallint");
b.Property<string>("WebsiteUrl")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<long?>("Year")
.HasColumnType("bigint");
b.HasKey("MangaId");
b.HasIndex("LibraryId");
b.HasIndex("MangaConnectorName");
b.ToTable("Mangas");
});
modelBuilder.Entity("API.Schema.MangaConnectors.MangaConnector", b =>
{
b.Property<string>("Name")
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.PrimitiveCollection<string[]>("BaseUris")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("text[]");
b.Property<bool>("Enabled")
.HasColumnType("boolean");
b.Property<string>("IconUrl")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.PrimitiveCollection<string[]>("SupportedLanguages")
.IsRequired()
.HasMaxLength(8)
.HasColumnType("text[]");
b.HasKey("Name");
b.ToTable("MangaConnectors");
b.HasDiscriminator<string>("Name").HasValue("MangaConnector");
b.UseTphMappingStrategy();
});
modelBuilder.Entity("API.Schema.MangaTag", b =>
{
b.Property<string>("Tag")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.HasKey("Tag");
b.ToTable("Tags");
});
modelBuilder.Entity("API.Schema.MetadataFetchers.MetadataEntry", b =>
{
b.Property<string>("MetadataFetcherName")
.HasColumnType("text");
b.Property<string>("Identifier")
.HasColumnType("text");
b.Property<string>("MangaId")
.IsRequired()
.HasColumnType("character varying(64)");
b.HasKey("MetadataFetcherName", "Identifier");
b.HasIndex("MangaId");
b.ToTable("MetadataEntries");
});
modelBuilder.Entity("API.Schema.MetadataFetchers.MetadataFetcher", b =>
{
b.Property<string>("MetadataFetcherName")
.HasColumnType("text");
b.Property<string>("MetadataEntry")
.IsRequired()
.HasMaxLength(21)
.HasColumnType("character varying(21)");
b.HasKey("MetadataFetcherName");
b.ToTable("MetadataFetcher");
b.HasDiscriminator<string>("MetadataEntry").HasValue("MetadataFetcher");
b.UseTphMappingStrategy();
});
modelBuilder.Entity("AuthorToManga", b =>
{
b.Property<string>("AuthorIds")
.HasColumnType("character varying(64)");
b.Property<string>("MangaIds")
.HasColumnType("character varying(64)");
b.HasKey("AuthorIds", "MangaIds");
b.HasIndex("MangaIds");
b.ToTable("AuthorToManga");
});
modelBuilder.Entity("JobJob", b =>
{
b.Property<string>("DependsOnJobsJobId")
.HasColumnType("character varying(64)");
b.Property<string>("JobId")
.HasColumnType("character varying(64)");
b.HasKey("DependsOnJobsJobId", "JobId");
b.HasIndex("JobId");
b.ToTable("JobJob");
});
modelBuilder.Entity("MangaTagToManga", b =>
{
b.Property<string>("MangaTagIds")
.HasColumnType("character varying(64)");
b.Property<string>("MangaIds")
.HasColumnType("character varying(64)");
b.HasKey("MangaTagIds", "MangaIds");
b.HasIndex("MangaIds");
b.ToTable("MangaTagToManga");
});
modelBuilder.Entity("API.Schema.Jobs.DownloadAvailableChaptersJob", b =>
{
b.HasBaseType("API.Schema.Jobs.Job");
b.Property<string>("MangaId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.HasIndex("MangaId");
b.ToTable("Jobs", t =>
{
t.Property("MangaId")
.HasColumnName("DownloadAvailableChaptersJob_MangaId");
});
b.HasDiscriminator().HasValue((byte)1);
});
modelBuilder.Entity("API.Schema.Jobs.DownloadMangaCoverJob", b =>
{
b.HasBaseType("API.Schema.Jobs.Job");
b.Property<string>("MangaId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.HasIndex("MangaId");
b.HasDiscriminator().HasValue((byte)4);
});
modelBuilder.Entity("API.Schema.Jobs.DownloadSingleChapterJob", b =>
{
b.HasBaseType("API.Schema.Jobs.Job");
b.Property<string>("ChapterId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.HasIndex("ChapterId");
b.HasDiscriminator().HasValue((byte)0);
});
modelBuilder.Entity("API.Schema.Jobs.MoveFileOrFolderJob", b =>
{
b.HasBaseType("API.Schema.Jobs.Job");
b.Property<string>("FromLocation")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("ToLocation")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasDiscriminator().HasValue((byte)3);
});
modelBuilder.Entity("API.Schema.Jobs.MoveMangaLibraryJob", b =>
{
b.HasBaseType("API.Schema.Jobs.Job");
b.Property<string>("MangaId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("ToLibraryId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.HasIndex("MangaId");
b.HasIndex("ToLibraryId");
b.ToTable("Jobs", t =>
{
t.Property("MangaId")
.HasColumnName("MoveMangaLibraryJob_MangaId");
});
b.HasDiscriminator().HasValue((byte)7);
});
modelBuilder.Entity("API.Schema.Jobs.RetrieveChaptersJob", b =>
{
b.HasBaseType("API.Schema.Jobs.Job");
b.Property<string>("Language")
.IsRequired()
.HasMaxLength(8)
.HasColumnType("character varying(8)");
b.Property<string>("MangaId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.HasIndex("MangaId");
b.ToTable("Jobs", t =>
{
t.Property("MangaId")
.HasColumnName("RetrieveChaptersJob_MangaId");
});
b.HasDiscriminator().HasValue((byte)5);
});
modelBuilder.Entity("API.Schema.Jobs.UpdateChaptersDownloadedJob", b =>
{
b.HasBaseType("API.Schema.Jobs.Job");
b.Property<string>("MangaId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.HasIndex("MangaId");
b.ToTable("Jobs", t =>
{
t.Property("MangaId")
.HasColumnName("UpdateChaptersDownloadedJob_MangaId");
});
b.HasDiscriminator().HasValue((byte)6);
});
modelBuilder.Entity("API.Schema.Jobs.UpdateCoverJob", b =>
{
b.HasBaseType("API.Schema.Jobs.Job");
b.Property<string>("MangaId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.HasIndex("MangaId");
b.ToTable("Jobs", t =>
{
t.Property("MangaId")
.HasColumnName("UpdateCoverJob_MangaId");
});
b.HasDiscriminator().HasValue((byte)9);
});
modelBuilder.Entity("API.Schema.MangaConnectors.ComickIo", b =>
{
b.HasBaseType("API.Schema.MangaConnectors.MangaConnector");
b.HasDiscriminator().HasValue("ComickIo");
});
modelBuilder.Entity("API.Schema.MangaConnectors.Global", b =>
{
b.HasBaseType("API.Schema.MangaConnectors.MangaConnector");
b.HasDiscriminator().HasValue("Global");
});
modelBuilder.Entity("API.Schema.MangaConnectors.MangaDex", b =>
{
b.HasBaseType("API.Schema.MangaConnectors.MangaConnector");
b.HasDiscriminator().HasValue("MangaDex");
});
modelBuilder.Entity("API.Schema.MetadataFetchers.MyAnimeList", b =>
{
b.HasBaseType("API.Schema.MetadataFetchers.MetadataFetcher");
b.HasDiscriminator().HasValue("MyAnimeList");
});
modelBuilder.Entity("API.Schema.Chapter", b =>
{
b.HasOne("API.Schema.Manga", "ParentManga")
.WithMany("Chapters")
.HasForeignKey("ParentMangaId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("ParentManga");
});
modelBuilder.Entity("API.Schema.Jobs.Job", b =>
{
b.HasOne("API.Schema.Jobs.Job", "ParentJob")
.WithMany()
.HasForeignKey("ParentJobId")
.OnDelete(DeleteBehavior.Cascade);
b.Navigation("ParentJob");
});
modelBuilder.Entity("API.Schema.Manga", b =>
{
b.HasOne("API.Schema.LocalLibrary", "Library")
.WithMany()
.HasForeignKey("LibraryId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("API.Schema.MangaConnectors.MangaConnector", "MangaConnector")
.WithMany()
.HasForeignKey("MangaConnectorName")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.OwnsMany("API.Schema.Link", "Links", b1 =>
{
b1.Property<string>("LinkId")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b1.Property<string>("LinkProvider")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b1.Property<string>("LinkUrl")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b1.Property<string>("MangaId")
.IsRequired()
.HasColumnType("character varying(64)");
b1.HasKey("LinkId");
b1.HasIndex("MangaId");
b1.ToTable("Link");
b1.WithOwner()
.HasForeignKey("MangaId");
});
b.OwnsMany("API.Schema.MangaAltTitle", "AltTitles", b1 =>
{
b1.Property<string>("AltTitleId")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b1.Property<string>("Language")
.IsRequired()
.HasMaxLength(8)
.HasColumnType("character varying(8)");
b1.Property<string>("MangaId")
.IsRequired()
.HasColumnType("character varying(64)");
b1.Property<string>("Title")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b1.HasKey("AltTitleId");
b1.HasIndex("MangaId");
b1.ToTable("MangaAltTitle");
b1.WithOwner()
.HasForeignKey("MangaId");
});
b.Navigation("AltTitles");
b.Navigation("Library");
b.Navigation("Links");
b.Navigation("MangaConnector");
});
modelBuilder.Entity("API.Schema.MetadataFetchers.MetadataEntry", b =>
{
b.HasOne("API.Schema.Manga", "Manga")
.WithMany()
.HasForeignKey("MangaId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("API.Schema.MetadataFetchers.MetadataFetcher", "MetadataFetcher")
.WithMany()
.HasForeignKey("MetadataFetcherName")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Manga");
b.Navigation("MetadataFetcher");
});
modelBuilder.Entity("AuthorToManga", b =>
{
b.HasOne("API.Schema.Author", null)
.WithMany()
.HasForeignKey("AuthorIds")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("API.Schema.Manga", null)
.WithMany()
.HasForeignKey("MangaIds")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("JobJob", b =>
{
b.HasOne("API.Schema.Jobs.Job", null)
.WithMany()
.HasForeignKey("DependsOnJobsJobId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("API.Schema.Jobs.Job", null)
.WithMany()
.HasForeignKey("JobId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("MangaTagToManga", b =>
{
b.HasOne("API.Schema.Manga", null)
.WithMany()
.HasForeignKey("MangaIds")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("API.Schema.MangaTag", null)
.WithMany()
.HasForeignKey("MangaTagIds")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("API.Schema.Jobs.DownloadAvailableChaptersJob", b =>
{
b.HasOne("API.Schema.Manga", "Manga")
.WithMany()
.HasForeignKey("MangaId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Manga");
});
modelBuilder.Entity("API.Schema.Jobs.DownloadMangaCoverJob", b =>
{
b.HasOne("API.Schema.Manga", "Manga")
.WithMany()
.HasForeignKey("MangaId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Manga");
});
modelBuilder.Entity("API.Schema.Jobs.DownloadSingleChapterJob", b =>
{
b.HasOne("API.Schema.Chapter", "Chapter")
.WithMany()
.HasForeignKey("ChapterId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Chapter");
});
modelBuilder.Entity("API.Schema.Jobs.MoveMangaLibraryJob", b =>
{
b.HasOne("API.Schema.Manga", "Manga")
.WithMany()
.HasForeignKey("MangaId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("API.Schema.LocalLibrary", "ToLibrary")
.WithMany()
.HasForeignKey("ToLibraryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Manga");
b.Navigation("ToLibrary");
});
modelBuilder.Entity("API.Schema.Jobs.RetrieveChaptersJob", b =>
{
b.HasOne("API.Schema.Manga", "Manga")
.WithMany()
.HasForeignKey("MangaId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Manga");
});
modelBuilder.Entity("API.Schema.Jobs.UpdateChaptersDownloadedJob", b =>
{
b.HasOne("API.Schema.Manga", "Manga")
.WithMany()
.HasForeignKey("MangaId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Manga");
});
modelBuilder.Entity("API.Schema.Jobs.UpdateCoverJob", b =>
{
b.HasOne("API.Schema.Manga", "Manga")
.WithMany()
.HasForeignKey("MangaId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Manga");
});
modelBuilder.Entity("API.Schema.Manga", b =>
{
b.Navigation("Chapters");
});
#pragma warning restore 612, 618
}
}
}

View File

@ -0,0 +1,54 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace API.Migrations.pgsql
{
/// <inheritdoc />
public partial class MetadataEntryPrimaryKeyChange : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropPrimaryKey(
name: "PK_MetadataEntries",
table: "MetadataEntries");
migrationBuilder.DropIndex(
name: "IX_MetadataEntries_MetadataFetcherName",
table: "MetadataEntries");
migrationBuilder.AddPrimaryKey(
name: "PK_MetadataEntries",
table: "MetadataEntries",
columns: new[] { "MetadataFetcherName", "Identifier" });
migrationBuilder.CreateIndex(
name: "IX_MetadataEntries_MangaId",
table: "MetadataEntries",
column: "MangaId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropPrimaryKey(
name: "PK_MetadataEntries",
table: "MetadataEntries");
migrationBuilder.DropIndex(
name: "IX_MetadataEntries_MangaId",
table: "MetadataEntries");
migrationBuilder.AddPrimaryKey(
name: "PK_MetadataEntries",
table: "MetadataEntries",
columns: new[] { "MangaId", "MetadataFetcherName" });
migrationBuilder.CreateIndex(
name: "IX_MetadataEntries_MetadataFetcherName",
table: "MetadataEntries",
column: "MetadataFetcherName");
}
}
}

View File

@ -255,6 +255,44 @@ namespace API.Migrations.pgsql
b.ToTable("Tags");
});
modelBuilder.Entity("API.Schema.MetadataFetchers.MetadataEntry", b =>
{
b.Property<string>("MetadataFetcherName")
.HasColumnType("text");
b.Property<string>("Identifier")
.HasColumnType("text");
b.Property<string>("MangaId")
.IsRequired()
.HasColumnType("character varying(64)");
b.HasKey("MetadataFetcherName", "Identifier");
b.HasIndex("MangaId");
b.ToTable("MetadataEntries");
});
modelBuilder.Entity("API.Schema.MetadataFetchers.MetadataFetcher", b =>
{
b.Property<string>("MetadataFetcherName")
.HasColumnType("text");
b.Property<string>("MetadataEntry")
.IsRequired()
.HasMaxLength(21)
.HasColumnType("character varying(21)");
b.HasKey("MetadataFetcherName");
b.ToTable("MetadataFetcher");
b.HasDiscriminator<string>("MetadataEntry").HasValue("MetadataFetcher");
b.UseTphMappingStrategy();
});
modelBuilder.Entity("AuthorToManga", b =>
{
b.Property<string>("AuthorIds")
@ -478,6 +516,13 @@ namespace API.Migrations.pgsql
b.HasDiscriminator().HasValue("MangaDex");
});
modelBuilder.Entity("API.Schema.MetadataFetchers.MyAnimeList", b =>
{
b.HasBaseType("API.Schema.MetadataFetchers.MetadataFetcher");
b.HasDiscriminator().HasValue("MyAnimeList");
});
modelBuilder.Entity("API.Schema.Chapter", b =>
{
b.HasOne("API.Schema.Manga", "ParentManga")
@ -581,6 +626,25 @@ namespace API.Migrations.pgsql
b.Navigation("MangaConnector");
});
modelBuilder.Entity("API.Schema.MetadataFetchers.MetadataEntry", b =>
{
b.HasOne("API.Schema.Manga", "Manga")
.WithMany()
.HasForeignKey("MangaId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("API.Schema.MetadataFetchers.MetadataFetcher", "MetadataFetcher")
.WithMany()
.HasForeignKey("MetadataFetcherName")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Manga");
b.Navigation("MetadataFetcher");
});
modelBuilder.Entity("AuthorToManga", b =>
{
b.HasOne("API.Schema.Author", null)

View File

@ -4,7 +4,6 @@ using System.Text;
using System.Text.RegularExpressions;
using System.Xml.Linq;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Newtonsoft.Json;
namespace API.Schema;
@ -15,28 +14,8 @@ public class Chapter : IComparable<Chapter>
[StringLength(64)] [Required] public string ChapterId { get; init; }
[StringLength(256)]public string? IdOnConnectorSite { get; init; }
[StringLength(64)] [Required] public string ParentMangaId { get; init; } = null!;
private Manga? _parentManga = null!;
[JsonIgnore]
public Manga ParentManga
{
get => _lazyLoader.Load(this, ref _parentManga) ?? throw new InvalidOperationException();
init
{
ParentMangaId = value.MangaId;
_parentManga = value;
}
}
private MangaConnectorMangaEntry? _mangaConnectorMangaEntry = null!;
[JsonIgnore]
public MangaConnectorMangaEntry MangaConnectorMangaEntry
{
get => _lazyLoader.Load(this, ref _mangaConnectorMangaEntry) ?? throw new InvalidOperationException();
init => _mangaConnectorMangaEntry = value;
}
public string ParentMangaId { get; init; }
[JsonIgnore] public Manga ParentManga { get; init; } = null!;
public int? VolumeNumber { get; private set; }
[StringLength(10)] [Required] public string ChapterNumber { get; private set; }
@ -48,15 +27,14 @@ public class Chapter : IComparable<Chapter>
[StringLength(256)] [Required] public string FileName { get; private set; }
[Required] public bool Downloaded { get; internal set; }
[NotMapped] public string FullArchiveFilePath => Path.Join(MangaConnectorMangaEntry.Manga.FullDirectoryPath, FileName);
[NotMapped] public string FullArchiveFilePath => Path.Join(ParentManga.FullDirectoryPath, FileName);
private readonly ILazyLoader _lazyLoader = null!;
public Chapter(MangaConnectorMangaEntry mangaConnectorMangaEntry, string url, string chapterNumber, int? volumeNumber = null, string? idOnConnectorSite = null, string? title = null)
public Chapter(Manga parentManga, string url, string chapterNumber, int? volumeNumber = null, string? idOnConnectorSite = null, string? title = null)
{
this.ChapterId = TokenGen.CreateToken(typeof(Chapter), mangaConnectorMangaEntry.MangaId, chapterNumber);
this.MangaConnectorMangaEntry = mangaConnectorMangaEntry;
this.ChapterId = TokenGen.CreateToken(typeof(Chapter), parentManga.MangaId, chapterNumber);
this.IdOnConnectorSite = idOnConnectorSite;
this.ParentMangaId = parentManga.MangaId;
this.ParentManga = parentManga;
this.VolumeNumber = volumeNumber;
this.ChapterNumber = chapterNumber;
this.Url = url;
@ -68,11 +46,11 @@ public class Chapter : IComparable<Chapter>
/// <summary>
/// EF ONLY!!!
/// </summary>
internal Chapter(ILazyLoader lazyLoader, string chapterId, int? volumeNumber, string chapterNumber, string url, string? idOnConnectorSite, string? title, string fileName, bool downloaded)
internal Chapter(string chapterId, string parentMangaId, int? volumeNumber, string chapterNumber, string url, string? idOnConnectorSite, string? title, string fileName, bool downloaded)
{
this._lazyLoader = lazyLoader;
this.ChapterId = chapterId;
this.IdOnConnectorSite = idOnConnectorSite;
this.ParentMangaId = parentMangaId;
this.VolumeNumber = volumeNumber;
this.ChapterNumber = chapterNumber;
this.Url = url;
@ -125,14 +103,14 @@ public class Chapter : IComparable<Chapter>
char placeholder = nullable.Groups[1].Value[0];
bool isNull = placeholder switch
{
'M' => MangaConnectorMangaEntry.Manga?.Name is null,
'M' => ParentManga?.Name is null,
'V' => VolumeNumber is null,
'C' => ChapterNumber is null,
'T' => Title is null,
'A' => MangaConnectorMangaEntry.Manga?.Authors?.FirstOrDefault()?.AuthorName is null,
'A' => ParentManga?.Authors?.FirstOrDefault()?.AuthorName is null,
'I' => ChapterId is null,
'i' => MangaConnectorMangaEntry.Manga?.MangaId is null,
'Y' => MangaConnectorMangaEntry.Manga?.Year is null,
'i' => ParentManga?.MangaId is null,
'Y' => ParentManga?.Year is null,
_ => true
};
if(!isNull)
@ -153,14 +131,14 @@ public class Chapter : IComparable<Chapter>
char placeholder = replace.Groups[1].Value[0];
string? value = placeholder switch
{
'M' => MangaConnectorMangaEntry.Manga?.Name,
'M' => ParentManga?.Name,
'V' => VolumeNumber?.ToString(),
'C' => ChapterNumber,
'T' => Title,
'A' => MangaConnectorMangaEntry.Manga?.Authors?.FirstOrDefault()?.AuthorName,
'A' => ParentManga?.Authors?.FirstOrDefault()?.AuthorName,
'I' => ChapterId,
'i' => MangaConnectorMangaEntry.Manga?.MangaId,
'Y' => MangaConnectorMangaEntry.Manga?.Year.ToString(),
'i' => ParentManga?.MangaId,
'Y' => ParentManga?.Year.ToString(),
_ => null
};
stringBuilder.Append(value);
@ -201,16 +179,16 @@ public class Chapter : IComparable<Chapter>
);
if(Title is not null)
comicInfo.Add(new XElement("Title", Title));
if(MangaConnectorMangaEntry.Manga.MangaTags.Count > 0)
comicInfo.Add(new XElement("Tags", string.Join(',', MangaConnectorMangaEntry.Manga.MangaTags.Select(tag => tag.Tag))));
if(ParentManga.MangaTags.Count > 0)
comicInfo.Add(new XElement("Tags", string.Join(',', ParentManga.MangaTags.Select(tag => tag.Tag))));
if(VolumeNumber is not null)
comicInfo.Add(new XElement("Volume", VolumeNumber));
if(MangaConnectorMangaEntry.Manga.Authors.Count > 0)
comicInfo.Add(new XElement("Writer", string.Join(',', MangaConnectorMangaEntry.Manga.Authors.Select(author => author.AuthorName))));
if(MangaConnectorMangaEntry.Manga.OriginalLanguage is not null)
comicInfo.Add(new XElement("LanguageISO", MangaConnectorMangaEntry.Manga.OriginalLanguage));
if(MangaConnectorMangaEntry.Manga.Description != string.Empty)
comicInfo.Add(new XElement("Summary", MangaConnectorMangaEntry.Manga.Description));
if(ParentManga.Authors.Count > 0)
comicInfo.Add(new XElement("Writer", string.Join(',', ParentManga.Authors.Select(author => author.AuthorName))));
if(ParentManga.OriginalLanguage is not null)
comicInfo.Add(new XElement("LanguageISO", ParentManga.OriginalLanguage));
if(ParentManga.Description != string.Empty)
comicInfo.Add(new XElement("Summary", ParentManga.Description));
return comicInfo.ToString();
}

View File

@ -1,5 +1,6 @@
using API.Schema.Jobs;
using API.Schema.MangaConnectors;
using API.Schema.MetadataFetchers;
using log4net;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
@ -15,6 +16,7 @@ public class PgsqlContext(DbContextOptions<PgsqlContext> options) : DbContext(op
public DbSet<Chapter> Chapters { get; set; }
public DbSet<Author> Authors { get; set; }
public DbSet<MangaTag> Tags { get; set; }
public DbSet<MetadataEntry> MetadataEntries { get; set; }
private ILog Log => LogManager.GetLogger(GetType());
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
@ -40,40 +42,31 @@ public class PgsqlContext(DbContextOptions<PgsqlContext> options) : DbContext(op
.HasValue<RetrieveChaptersJob>(JobType.RetrieveChaptersJob)
.HasValue<UpdateCoverJob>(JobType.UpdateCoverJob)
.HasValue<UpdateChaptersDownloadedJob>(JobType.UpdateChaptersDownloadedJob);
modelBuilder.Entity<Job>()
.HasDiscriminator(j => j.GetType().IsSubclassOf(typeof(JobWithDownloading)))
.HasValue<JobWithDownloading>(true);
//Job specification
modelBuilder.Entity<JobWithDownloading>()
.HasOne<MangaConnector>(j => j.MangaConnector)
.WithMany()
.HasForeignKey(j => j.MangaConnectorName)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<JobWithDownloading>()
.Navigation(j => j.MangaConnector)
.EnableLazyLoading();
modelBuilder.Entity<DownloadAvailableChaptersJob>()
.HasOne<MangaConnectorMangaEntry>(j => j.MangaConnectorMangaEntry)
.HasOne<Manga>(j => j.Manga)
.WithMany()
.HasForeignKey(j => j.MangaId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<DownloadAvailableChaptersJob>()
.Navigation(j => j.MangaConnectorMangaEntry)
.Navigation(j => j.Manga)
.EnableLazyLoading();
modelBuilder.Entity<DownloadMangaCoverJob>()
.HasOne<MangaConnectorMangaEntry>(j => j.MangaConnectorMangaEntry)
.HasOne<Manga>(j => j.Manga)
.WithMany()
.HasForeignKey(j => j.MangaId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<DownloadMangaCoverJob>()
.Navigation(j => j.MangaConnectorMangaEntry)
.Navigation(j => j.Manga)
.EnableLazyLoading();
modelBuilder.Entity<DownloadSingleChapterJob>()
.HasOne<MangaConnectorMangaEntry>(j => j.MangaConnectorMangaEntry)
.HasOne<Chapter>(j => j.Chapter)
.WithMany()
.HasForeignKey(j => j.ChapterId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<DownloadSingleChapterJob>()
.Navigation(j => j.MangaConnectorMangaEntry)
.Navigation(j => j.Chapter)
.EnableLazyLoading();
modelBuilder.Entity<MoveMangaLibraryJob>()
.HasOne<Manga>(j => j.Manga)
@ -92,11 +85,12 @@ public class PgsqlContext(DbContextOptions<PgsqlContext> options) : DbContext(op
.Navigation(j => j.ToLibrary)
.EnableLazyLoading();
modelBuilder.Entity<RetrieveChaptersJob>()
.HasOne<MangaConnectorMangaEntry>(j => j.MangaConnectorMangaEntry)
.HasOne<Manga>(j => j.Manga)
.WithMany()
.HasForeignKey(j => j.MangaId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<RetrieveChaptersJob>()
.Navigation(j => j.MangaConnectorMangaEntry)
.Navigation(j => j.Manga)
.EnableLazyLoading();
modelBuilder.Entity<UpdateChaptersDownloadedJob>()
.HasOne<Manga>(j => j.Manga)
@ -128,6 +122,15 @@ public class PgsqlContext(DbContextOptions<PgsqlContext> options) : DbContext(op
.HasValue<Global>("Global")
.HasValue<MangaDex>("MangaDex")
.HasValue<ComickIo>("ComickIo");
//MangaConnector is responsible for many Manga
modelBuilder.Entity<MangaConnector>()
.HasMany<Manga>()
.WithOne(m => m.MangaConnector)
.HasForeignKey(m => m.MangaConnectorName)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<Manga>()
.Navigation(m => m.MangaConnector)
.AutoInclude();
//Manga has many Chapters
modelBuilder.Entity<Manga>()
@ -135,6 +138,9 @@ public class PgsqlContext(DbContextOptions<PgsqlContext> options) : DbContext(op
.WithOne(c => c.ParentManga)
.HasForeignKey(c => c.ParentMangaId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<Chapter>()
.Navigation(c => c.ParentManga)
.AutoInclude();
modelBuilder.Entity<Manga>()
.Navigation(m => m.Chapters)
.AutoInclude(false)
@ -177,17 +183,6 @@ public class PgsqlContext(DbContextOptions<PgsqlContext> options) : DbContext(op
modelBuilder.Entity<Manga>()
.Navigation(m => m.Authors)
.AutoInclude();
//Manga has many MangaConnectorMangaEntries with one MangaConnector
modelBuilder.Entity<Manga>()
.HasMany<MangaConnectorMangaEntry>(m => m.MangaConnectorLinkedToManga)
.WithOne(e => e.Manga)
.HasForeignKey(e => e.MangaId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<MangaConnectorMangaEntry>()
.HasOne<MangaConnector>(e => e.MangaConnector)
.WithMany()
.HasForeignKey(e => e.MangaConnectorName)
.OnDelete(DeleteBehavior.Cascade);
//LocalLibrary has many Mangas
modelBuilder.Entity<LocalLibrary>()
@ -198,5 +193,18 @@ public class PgsqlContext(DbContextOptions<PgsqlContext> options) : DbContext(op
modelBuilder.Entity<Manga>()
.Navigation(m => m.Library)
.AutoInclude();
modelBuilder.Entity<MetadataFetcher>()
.HasDiscriminator<string>(nameof(MetadataEntry))
.HasValue<MyAnimeList>(nameof(MyAnimeList));
//MetadataEntry
modelBuilder.Entity<MetadataEntry>()
.HasOne<Manga>(entry => entry.Manga)
.WithMany()
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<MetadataEntry>()
.HasOne<MetadataFetcher>(entry => entry.MetadataFetcher)
.WithMany()
.OnDelete(DeleteBehavior.Cascade);
}
}

View File

@ -1,36 +1,42 @@
using API.Schema.Contexts;
using System.ComponentModel.DataAnnotations;
using API.Schema.Contexts;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Newtonsoft.Json;
namespace API.Schema.Jobs;
public class DownloadAvailableChaptersJob : JobWithDownloading
public class DownloadAvailableChaptersJob : Job
{
private MangaConnectorMangaEntry? _mangaConnectorMangaEntry = null!;
[StringLength(64)] [Required] public string MangaId { get; init; }
private Manga _manga = null!;
[JsonIgnore]
public MangaConnectorMangaEntry MangaConnectorMangaEntry
public Manga Manga
{
get => LazyLoader.Load(this, ref _mangaConnectorMangaEntry) ?? throw new InvalidOperationException();
init => _mangaConnectorMangaEntry = value;
get => LazyLoader.Load(this, ref _manga);
init => _manga = value;
}
public DownloadAvailableChaptersJob(MangaConnectorMangaEntry mangaConnectorMangaEntry, ulong recurrenceMs, Job? parentJob = null, ICollection<Job>? dependsOnJobs = null)
: base(TokenGen.CreateToken(typeof(DownloadAvailableChaptersJob)), JobType.DownloadAvailableChaptersJob, recurrenceMs, mangaConnectorMangaEntry.MangaConnector, parentJob, dependsOnJobs)
public DownloadAvailableChaptersJob(Manga manga, ulong recurrenceMs, Job? parentJob = null, ICollection<Job>? dependsOnJobs = null)
: base(TokenGen.CreateToken(typeof(DownloadAvailableChaptersJob)), JobType.DownloadAvailableChaptersJob, recurrenceMs, parentJob, dependsOnJobs)
{
this.MangaConnectorMangaEntry = mangaConnectorMangaEntry;
this.MangaId = manga.MangaId;
this.Manga = manga;
}
/// <summary>
/// EF ONLY!!!
/// </summary>
internal DownloadAvailableChaptersJob(ILazyLoader lazyLoader, string jobId, ulong recurrenceMs, string mangaConnectorName, string? parentJobId)
: base(lazyLoader, jobId, JobType.DownloadAvailableChaptersJob, recurrenceMs, mangaConnectorName, parentJobId)
internal DownloadAvailableChaptersJob(ILazyLoader lazyLoader, string jobId, ulong recurrenceMs, string mangaId, string? parentJobId)
: base(lazyLoader, jobId, JobType.DownloadAvailableChaptersJob, recurrenceMs, parentJobId)
{
this.MangaId = mangaId;
}
protected override IEnumerable<Job> RunInternal(PgsqlContext context)
{
return MangaConnectorMangaEntry.Manga.Chapters.Where(c => c.Downloaded == false).Select(chapter => new DownloadSingleChapterJob(chapter, this.MangaConnectorMangaEntry));
context.Entry(Manga).Reference<LocalLibrary>(m => m.Library).Load();
return Manga.Chapters.Where(c => c.Downloaded == false).Select(chapter => new DownloadSingleChapterJob(chapter, this));
}
}

View File

@ -6,36 +6,40 @@ using Newtonsoft.Json;
namespace API.Schema.Jobs;
public class DownloadMangaCoverJob : JobWithDownloading
public class DownloadMangaCoverJob : Job
{
private MangaConnectorMangaEntry? _mangaConnectorMangaEntry = null!;
[StringLength(64)] [Required] public string MangaId { get; init; }
private Manga _manga = null!;
[JsonIgnore]
public MangaConnectorMangaEntry MangaConnectorMangaEntry
public Manga Manga
{
get => LazyLoader.Load(this, ref _mangaConnectorMangaEntry) ?? throw new InvalidOperationException();
init => _mangaConnectorMangaEntry = value;
get => LazyLoader.Load(this, ref _manga);
init => _manga = value;
}
public DownloadMangaCoverJob(MangaConnectorMangaEntry mangaConnectorEntry, Job? parentJob = null, ICollection<Job>? dependsOnJobs = null)
: base(TokenGen.CreateToken(typeof(DownloadMangaCoverJob)), JobType.DownloadMangaCoverJob, 0, mangaConnectorEntry.MangaConnector, parentJob, dependsOnJobs)
public DownloadMangaCoverJob(Manga manga, Job? parentJob = null, ICollection<Job>? dependsOnJobs = null)
: base(TokenGen.CreateToken(typeof(DownloadMangaCoverJob)), JobType.DownloadMangaCoverJob, 0, parentJob, dependsOnJobs)
{
this.MangaConnectorMangaEntry = mangaConnectorEntry;
this.MangaId = manga.MangaId;
this.Manga = manga;
}
/// <summary>
/// EF ONLY!!!
/// </summary>
internal DownloadMangaCoverJob(ILazyLoader lazyLoader, string jobId, ulong recurrenceMs, string mangaConnectorName, string? parentJobId)
: base(lazyLoader, jobId, JobType.DownloadMangaCoverJob, recurrenceMs, mangaConnectorName, parentJobId)
internal DownloadMangaCoverJob(ILazyLoader lazyLoader, string jobId, ulong recurrenceMs, string mangaId, string? parentJobId)
: base(lazyLoader, jobId, JobType.DownloadMangaCoverJob, recurrenceMs, parentJobId)
{
this.MangaId = mangaId;
}
protected override IEnumerable<Job> RunInternal(PgsqlContext context)
{
try
{
MangaConnectorMangaEntry.Manga.CoverFileNameInCache = MangaConnectorMangaEntry.MangaConnector.SaveCoverImageToCache(MangaConnectorMangaEntry.Manga);
Manga.CoverFileNameInCache = Manga.MangaConnector.SaveCoverImageToCache(Manga);
context.SaveChanges();
}
catch (DbUpdateException e)

View File

@ -3,6 +3,7 @@ using System.IO.Compression;
using System.Runtime.InteropServices;
using API.MangaDownloadClients;
using API.Schema.Contexts;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Newtonsoft.Json;
using SixLabors.ImageSharp;
@ -13,42 +14,31 @@ using static System.IO.UnixFileMode;
namespace API.Schema.Jobs;
public class DownloadSingleChapterJob : JobWithDownloading
public class DownloadSingleChapterJob : Job
{
[StringLength(64)] [Required] public string ChapterId { get; init; } = null!;
private Chapter? _chapter = null!;
[StringLength(64)] [Required] public string ChapterId { get; init; }
private Chapter _chapter = null!;
[JsonIgnore]
public Chapter Chapter
{
get => LazyLoader.Load(this, ref _chapter) ?? throw new InvalidOperationException();
init
{
ChapterId = value.ChapterId;
_chapter = value;
}
get => LazyLoader.Load(this, ref _chapter);
init => _chapter = value;
}
private MangaConnectorMangaEntry? _mangaConnectorMangaEntry = null!;
[JsonIgnore]
public MangaConnectorMangaEntry MangaConnectorMangaEntry
{
get => LazyLoader.Load(this, ref _mangaConnectorMangaEntry) ?? throw new InvalidOperationException();
init => _mangaConnectorMangaEntry = value;
}
public DownloadSingleChapterJob(Chapter chapter, MangaConnectorMangaEntry mangaConnectorMangaEntry, Job? parentJob = null, ICollection<Job>? dependsOnJobs = null)
: base(TokenGen.CreateToken(typeof(DownloadSingleChapterJob)), JobType.DownloadSingleChapterJob, 0, mangaConnectorMangaEntry.MangaConnector, parentJob, dependsOnJobs)
public DownloadSingleChapterJob(Chapter chapter, Job? parentJob = null, ICollection<Job>? dependsOnJobs = null)
: base(TokenGen.CreateToken(typeof(DownloadSingleChapterJob)), JobType.DownloadSingleChapterJob, 0, parentJob, dependsOnJobs)
{
this.ChapterId = chapter.ChapterId;
this.Chapter = chapter;
this.MangaConnectorMangaEntry = mangaConnectorMangaEntry;
}
/// <summary>
/// EF ONLY!!!
/// </summary>
internal DownloadSingleChapterJob(ILazyLoader lazyLoader, string jobId, ulong recurrenceMs, string mangaConnectorName, string chapterId, string? parentJobId)
: base(lazyLoader, jobId, JobType.DownloadSingleChapterJob, recurrenceMs, mangaConnectorName, parentJobId)
internal DownloadSingleChapterJob(ILazyLoader lazyLoader, string jobId, ulong recurrenceMs, string chapterId, string? parentJobId)
: base(lazyLoader, jobId, JobType.DownloadSingleChapterJob, recurrenceMs, parentJobId)
{
this.ChapterId = chapterId;
}
@ -60,13 +50,13 @@ public class DownloadSingleChapterJob : JobWithDownloading
Log.Info("Chapter was already downloaded.");
return [];
}
string[] imageUrls = MangaConnectorMangaEntry.MangaConnector.GetChapterImageUrls(Chapter);
string[] imageUrls = Chapter.ParentManga.MangaConnector.GetChapterImageUrls(Chapter);
if (imageUrls.Length < 1)
{
Log.Info($"No imageUrls for chapter {ChapterId}");
return [];
}
context.Entry(Chapter.MangaConnectorMangaEntry.Manga).Reference<LocalLibrary>(m => m.Library).Load(); //Need to explicitly load, because we are not accessing navigation directly...
context.Entry(Chapter.ParentManga).Reference<LocalLibrary>(m => m.Library).Load(); //Need to explicitly load, because we are not accessing navigation directly...
string saveArchiveFilePath = Chapter.FullArchiveFilePath;
Log.Debug($"Chapter path: {saveArchiveFilePath}");
@ -113,7 +103,7 @@ public class DownloadSingleChapterJob : JobWithDownloading
}
}
CopyCoverFromCacheToDownloadLocation(Chapter.MangaConnectorMangaEntry.Manga);
CopyCoverFromCacheToDownloadLocation(Chapter.ParentManga);
Log.Debug($"Creating ComicInfo.xml {ChapterId}");
File.WriteAllText(Path.Join(tempFolder, "ComicInfo.xml"), Chapter.GetComicInfoXmlString());
@ -133,11 +123,11 @@ public class DownloadSingleChapterJob : JobWithDownloading
if (j.JobType != JobType.UpdateChaptersDownloadedJob)
return false;
UpdateChaptersDownloadedJob job = (UpdateChaptersDownloadedJob)j;
return job.MangaId == this.Chapter.MangaConnectorMangaEntry.MangaId;
return job.MangaId == this.Chapter.ParentMangaId;
}))
return [];
return [new UpdateChaptersDownloadedJob(Chapter.MangaConnectorMangaEntry.Manga, 0, this.ParentJob)];
return [new UpdateChaptersDownloadedJob(Chapter.ParentManga, 0, this.ParentJob)];
}
private void ProcessImage(string imagePath)
@ -190,7 +180,7 @@ public class DownloadSingleChapterJob : JobWithDownloading
}
Log.Info($"Copying cover to {publicationFolder}");
string? fileInCache = manga.CoverFileNameInCache ?? MangaConnectorMangaEntry.MangaConnector.SaveCoverImageToCache(manga);
string? fileInCache = manga.CoverFileNameInCache ?? manga.MangaConnector.SaveCoverImageToCache(manga);
if (fileInCache is null)
{
Log.Error($"File {fileInCache} does not exist");

View File

@ -9,7 +9,7 @@ using Newtonsoft.Json;
namespace API.Schema.Jobs;
[PrimaryKey("JobId")]
public abstract class Job : IComparable<Job>
public abstract class Job
{
[StringLength(64)]
[Required]
@ -119,39 +119,16 @@ public abstract class Job : IComparable<Job>
protected abstract IEnumerable<Job> RunInternal(PgsqlContext context);
public List<Job> GetDependenciesAndSelf()
{
List<Job> ret = GetDependencies();
ret.Add(this);
return ret;
}
public List<Job> GetDependencies()
{
List<Job> ret = new ();
foreach (Job job in DependsOnJobs)
{
ret.AddRange(job.GetDependenciesAndSelf());
}
ret.Add(this);
return ret;
}
public int CompareTo(Job? other)
{
if (other is null)
return -1;
// Sort by missing dependencies
if (this.GetDependencies().Count(job => !job.IsCompleted) <
other.GetDependencies().Count(job => !job.IsCompleted))
return -1;
// Sort by NextExecution-time
if (this.NextExecution < other.NextExecution)
return -1;
// Sort by JobPriority
if (JobQueueSorter.GetPriority(this) > JobQueueSorter.GetPriority(other))
return -1;
return 1;
}
public override string ToString()
{
return $"{JobId}";

View File

@ -1,37 +0,0 @@
using System.ComponentModel.DataAnnotations;
using API.Schema.MangaConnectors;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Newtonsoft.Json;
namespace API.Schema.Jobs;
public abstract class JobWithDownloading : Job
{
[StringLength(32)] [Required] public string MangaConnectorName { get; private set; } = null!;
[JsonIgnore] private MangaConnector? _mangaConnector;
[JsonIgnore]
public MangaConnector MangaConnector
{
get => LazyLoader.Load(this, ref _mangaConnector) ?? throw new InvalidOperationException();
init
{
MangaConnectorName = value.Name;
_mangaConnector = value;
}
}
protected JobWithDownloading(string jobId, JobType jobType, ulong recurrenceMs, MangaConnector mangaConnector, Job? parentJob = null, ICollection<Job>? dependsOnJobs = null)
: base(jobId, jobType, recurrenceMs, parentJob, dependsOnJobs)
{
this.MangaConnector = mangaConnector;
}
/// <summary>
/// EF CORE ONLY!!!
/// </summary>
internal JobWithDownloading(ILazyLoader lazyLoader, string jobId, JobType jobType, ulong recurrenceMs, string mangaConnectorName, string? parentJobId)
: base(lazyLoader, jobId, jobType, recurrenceMs, parentJobId)
{
this.MangaConnectorName = mangaConnectorName;
}
}

View File

@ -10,33 +10,23 @@ public class MoveMangaLibraryJob : Job
{
[StringLength(64)] [Required] public string MangaId { get; init; }
private Manga? _manga = null!;
private Manga _manga = null!;
[JsonIgnore]
public Manga Manga
{
get => LazyLoader.Load(this, ref _manga) ?? throw new InvalidOperationException();
get => LazyLoader.Load(this, ref _manga);
init => _manga = value;
}
[StringLength(64)] [Required] public string ToLibraryId { get; private set; } = null!;
private LocalLibrary? _toLibrary = null!;
[JsonIgnore]
public LocalLibrary ToLibrary
{
get => LazyLoader.Load(this, ref _toLibrary) ?? throw new InvalidOperationException();
init
{
ToLibraryId = value.LocalLibraryId;
_toLibrary = value;
}
}
[StringLength(64)] [Required] public string ToLibraryId { get; init; }
public LocalLibrary ToLibrary { get; init; } = null!;
public MoveMangaLibraryJob(Manga manga, LocalLibrary toLibrary, Job? parentJob = null, ICollection<Job>? dependsOnJobs = null)
: base(TokenGen.CreateToken(typeof(MoveMangaLibraryJob)), JobType.MoveMangaLibraryJob, 0, parentJob, dependsOnJobs)
{
this.MangaId = manga.MangaId;
this.Manga = manga;
this.ToLibraryId = toLibrary.LocalLibraryId;
this.ToLibrary = toLibrary;
}

View File

@ -6,45 +6,49 @@ using Newtonsoft.Json;
namespace API.Schema.Jobs;
public class RetrieveChaptersJob : JobWithDownloading
public class RetrieveChaptersJob : Job
{
private MangaConnectorMangaEntry? _mangaConnectorMangaEntry = null!;
[JsonIgnore]
public MangaConnectorMangaEntry MangaConnectorMangaEntry
{
get => LazyLoader.Load(this, ref _mangaConnectorMangaEntry) ?? throw new InvalidOperationException();
init => _mangaConnectorMangaEntry = value;
}
[StringLength(64)] [Required] public string MangaId { get; init; }
private Manga _manga = null!;
[JsonIgnore]
public Manga Manga
{
get => LazyLoader.Load(this, ref _manga);
init => _manga = value;
}
[StringLength(8)] [Required] public string Language { get; private set; }
public RetrieveChaptersJob(MangaConnectorMangaEntry mangaConnectorMangaEntry, string language, ulong recurrenceMs, Job? parentJob = null, ICollection<Job>? dependsOnJobs = null)
: base(TokenGen.CreateToken(typeof(RetrieveChaptersJob)), JobType.RetrieveChaptersJob, recurrenceMs, mangaConnectorMangaEntry.MangaConnector, parentJob, dependsOnJobs)
public RetrieveChaptersJob(Manga manga, string language, ulong recurrenceMs, Job? parentJob = null, ICollection<Job>? dependsOnJobs = null)
: base(TokenGen.CreateToken(typeof(RetrieveChaptersJob)), JobType.RetrieveChaptersJob, recurrenceMs, parentJob, dependsOnJobs)
{
this.MangaConnectorMangaEntry = mangaConnectorMangaEntry;
this.MangaId = manga.MangaId;
this.Manga = manga;
this.Language = language;
}
/// <summary>
/// EF ONLY!!!
/// </summary>
internal RetrieveChaptersJob(ILazyLoader lazyLoader, string jobId, ulong recurrenceMs, string mangaConnectorName, string language, string? parentJobId)
: base(lazyLoader, jobId, JobType.RetrieveChaptersJob, recurrenceMs, mangaConnectorName, parentJobId)
internal RetrieveChaptersJob(ILazyLoader lazyLoader, string jobId, ulong recurrenceMs, string mangaId, string language, string? parentJobId)
: base(lazyLoader, jobId, JobType.RetrieveChaptersJob, recurrenceMs, parentJobId)
{
this.MangaId = mangaId;
this.Language = language;
}
protected override IEnumerable<Job> RunInternal(PgsqlContext context)
{
// This gets all chapters that are not downloaded
Chapter[] allChapters = MangaConnectorMangaEntry.MangaConnector.GetChapters(MangaConnectorMangaEntry, Language).DistinctBy(c => c.ChapterId).ToArray();
Chapter[] newChapters = allChapters.Where(chapter => MangaConnectorMangaEntry.Manga.Chapters.Select(c => c.ChapterId).Contains(chapter.ChapterId) == false).ToArray();
Log.Info($"{MangaConnectorMangaEntry.Manga.Chapters.Count} existing + {newChapters.Length} new chapters.");
Chapter[] allChapters = Manga.MangaConnector.GetChapters(Manga, Language).DistinctBy(c => c.ChapterId).ToArray();
Chapter[] newChapters = allChapters.Where(chapter => Manga.Chapters.Select(c => c.ChapterId).Contains(chapter.ChapterId) == false).ToArray();
Log.Info($"{Manga.Chapters.Count} existing + {newChapters.Length} new chapters.");
try
{
foreach (Chapter newChapter in newChapters)
MangaConnectorMangaEntry.Manga.Chapters.Add(newChapter);
Manga.Chapters.Add(newChapter);
context.SaveChanges();
}
catch (DbUpdateException e)

View File

@ -8,41 +8,46 @@ namespace API.Schema.Jobs;
public class UpdateCoverJob : Job
{
private MangaConnectorMangaEntry? _mangaConnectorMangaEntry = null!;
[StringLength(64)] [Required] public string MangaId { get; init; }
private Manga _manga = null!;
[JsonIgnore]
public MangaConnectorMangaEntry MangaConnectorMangaEntry
public Manga Manga
{
get => LazyLoader.Load(this, ref _mangaConnectorMangaEntry) ?? throw new InvalidOperationException();
init => _mangaConnectorMangaEntry = value;
get => LazyLoader.Load(this, ref _manga);
init => _manga = value;
}
public UpdateCoverJob(MangaConnectorMangaEntry mangaConnectorMangaEntry, ulong recurrenceMs, Job? parentJob = null, ICollection<Job>? dependsOnJobs = null)
public UpdateCoverJob(Manga manga, ulong recurrenceMs, Job? parentJob = null, ICollection<Job>? dependsOnJobs = null)
: base(TokenGen.CreateToken(typeof(UpdateCoverJob)), JobType.UpdateCoverJob, recurrenceMs, parentJob, dependsOnJobs)
{
this.MangaConnectorMangaEntry = mangaConnectorMangaEntry;
this.MangaId = manga.MangaId;
this.Manga = manga;
}
/// <summary>
/// EF ONLY!!!
/// </summary>
internal UpdateCoverJob(ILazyLoader lazyLoader, string jobId, ulong recurrenceMs, string? parentJobId)
internal UpdateCoverJob(ILazyLoader lazyLoader, string jobId, ulong recurrenceMs, string mangaId, string? parentJobId)
: base(lazyLoader, jobId, JobType.UpdateCoverJob, recurrenceMs, parentJobId)
{
this.MangaId = mangaId;
}
protected override IEnumerable<Job> RunInternal(PgsqlContext context)
{
bool keepCover = context.Jobs
.Any(job => job.JobType == JobType.DownloadAvailableChaptersJob
&& ((DownloadAvailableChaptersJob)job).MangaConnectorMangaEntry.MangaId == MangaConnectorMangaEntry.MangaId);
&& ((DownloadAvailableChaptersJob)job).MangaId == MangaId);
if (!keepCover)
{
if(File.Exists(MangaConnectorMangaEntry.Manga.CoverFileNameInCache))
File.Delete(MangaConnectorMangaEntry.Manga.CoverFileNameInCache);
if(File.Exists(Manga.CoverFileNameInCache))
File.Delete(Manga.CoverFileNameInCache);
try
{
MangaConnectorMangaEntry.Manga.CoverFileNameInCache = null;
Manga.CoverFileNameInCache = null;
context.Jobs.Remove(this);
context.SaveChanges();
}
@ -53,7 +58,7 @@ public class UpdateCoverJob : Job
}
else
{
return [new DownloadMangaCoverJob(MangaConnectorMangaEntry, this)];
return [new DownloadMangaCoverJob(Manga, this)];
}
return [];
}

View File

@ -2,6 +2,7 @@ using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Runtime.InteropServices;
using System.Text;
using API.Schema.MangaConnectors;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Newtonsoft.Json;
@ -12,23 +13,24 @@ namespace API.Schema;
[PrimaryKey("MangaId")]
public class Manga
{
[StringLength(64)] [Required] public string MangaId { get; init; }
[StringLength(64)]
[Required]
public string MangaId { get; init; }
[StringLength(256)] [Required] public string IdOnConnectorSite { get; init; }
[StringLength(512)] [Required] public string Name { get; internal set; }
[Required] public string Description { get; internal set; }
[Url] [StringLength(512)] [Required] public string WebsiteUrl { get; internal init; }
[JsonIgnore] [Url] [StringLength(512)] public string CoverUrl { get; internal set; }
[Required] public MangaReleaseStatus ReleaseStatus { get; internal set; }
[StringLength(64)] public string? LibraryId { get; private set; }
private LocalLibrary? _library = null!;
[JsonIgnore]
public LocalLibrary? Library
{
get => _lazyLoader.Load(this, ref _library);
set
{
LibraryId = value?.LocalLibraryId;
_library = value;
}
}
[StringLength(64)]
public string? LibraryId { get; init; }
[JsonIgnore] public LocalLibrary? Library { get; internal set; }
[StringLength(32)]
[Required]
public string MangaConnectorName { get; init; }
[JsonIgnore] public MangaConnector MangaConnector { get; init; } = null!;
public ICollection<Author> Authors { get; internal set; }= null!;
public ICollection<MangaTag> MangaTags { get; internal set; }= null!;
@ -36,6 +38,7 @@ public class Manga
public ICollection<MangaAltTitle> AltTitles { get; internal set; } = null!;
[Required] public float IgnoreChaptersBefore { get; internal set; }
[StringLength(1024)] [Required] public string DirectoryName { get; private set; }
[JsonIgnore] [StringLength(512)] public string? CoverFileNameInCache { get; internal set; } = null;
public uint? Year { get; internal init; }
[StringLength(8)] public string? OriginalLanguage { get; internal init; }
@ -45,37 +48,30 @@ public class Manga
public string? FullDirectoryPath => Library is not null ? Path.Join(Library.BasePath, DirectoryName) : null;
[NotMapped] public ICollection<string> ChapterIds => Chapters.Select(c => c.ChapterId).ToList();
private ICollection<Chapter>? _chapters = null!;
private readonly ILazyLoader _lazyLoader = null!;
private ICollection<Chapter> _chapters = null!;
[JsonIgnore]
public ICollection<Chapter> Chapters
{
get => _lazyLoader.Load(this, ref _chapters) ?? throw new InvalidOperationException();
get => _lazyLoader.Load(this, ref _chapters);
init => _chapters = value;
}
[NotMapped]
public ICollection<string> LinkedMangaConnectors =>
MangaConnectorLinkedToManga.Select(l => l.MangaConnectorName).ToList();
private ICollection<MangaConnectorMangaEntry>? _mangaConnectorLinkedToManga = null!;
[JsonIgnore]
public ICollection<MangaConnectorMangaEntry> MangaConnectorLinkedToManga
{
get => _lazyLoader.Load(this, ref _mangaConnectorLinkedToManga) ?? throw new InvalidOperationException();
init => _mangaConnectorLinkedToManga = value;
}
private readonly ILazyLoader _lazyLoader = null!;
public Manga(string name, string description, string coverUrl, MangaReleaseStatus releaseStatus,
ICollection<Author> authors, ICollection<MangaTag> mangaTags, ICollection<Link> links, ICollection<MangaAltTitle> altTitles,
public Manga(string idOnConnector, string name, string description, string websiteUrl, string coverUrl, MangaReleaseStatus releaseStatus,
MangaConnector mangaConnector, ICollection<Author> authors, ICollection<MangaTag> mangaTags, ICollection<Link> links, ICollection<MangaAltTitle> altTitles,
LocalLibrary? library = null, float ignoreChaptersBefore = 0f, uint? year = null, string? originalLanguage = null)
{
this.MangaId = TokenGen.CreateToken(typeof(Manga), name);
this.MangaId = TokenGen.CreateToken(typeof(Manga), mangaConnector.Name, idOnConnector);
this.IdOnConnectorSite = idOnConnector;
this.Name = name;
this.Description = description;
this.WebsiteUrl = websiteUrl;
this.CoverUrl = coverUrl;
this.ReleaseStatus = releaseStatus;
this.LibraryId = library?.LocalLibraryId;
this.Library = library;
this.MangaConnectorName = mangaConnector.Name;
this.MangaConnector = mangaConnector;
this.Authors = authors;
this.MangaTags = mangaTags;
this.Links = links;
@ -90,15 +86,18 @@ public class Manga
/// <summary>
/// EF ONLY!!!
/// </summary>
public Manga(ILazyLoader lazyLoader, string mangaId, string name, string description, string coverUrl, MangaReleaseStatus releaseStatus,
string directoryName, float ignoreChaptersBefore, string? libraryId, uint? year, string? originalLanguage)
public Manga(ILazyLoader lazyLoader, string mangaId, string idOnConnectorSite, string name, string description, string websiteUrl, string coverUrl, MangaReleaseStatus releaseStatus,
string mangaConnectorName, string directoryName, float ignoreChaptersBefore, string? libraryId, uint? year, string? originalLanguage)
{
this._lazyLoader = lazyLoader;
this.MangaId = mangaId;
this.IdOnConnectorSite = idOnConnectorSite;
this.Name = name;
this.Description = description;
this.WebsiteUrl = websiteUrl;
this.CoverUrl = coverUrl;
this.ReleaseStatus = releaseStatus;
this.MangaConnectorName = mangaConnectorName;
this.DirectoryName = directoryName;
this.LibraryId = libraryId;
this.IgnoreChaptersBefore = ignoreChaptersBefore;

View File

@ -1,59 +0,0 @@
using System.ComponentModel.DataAnnotations;
using API.Schema.MangaConnectors;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Newtonsoft.Json;
namespace API.Schema;
[PrimaryKey("MangaId", "MangaConnectorName")]
public class MangaConnectorMangaEntry
{
[StringLength(64)] [Required] public string MangaId { get; private set; } = null!;
[JsonIgnore] private Manga? _manga = null!;
[JsonIgnore]
public Manga Manga
{
get => _lazyLoader.Load(this, ref _manga) ?? throw new InvalidOperationException();
init => _manga = value;
}
[StringLength(32)] [Required] public string MangaConnectorName { get; private set; } = null!;
[JsonIgnore] private MangaConnector? _mangaConnector = null!;
[JsonIgnore]
public MangaConnector MangaConnector
{
get => _lazyLoader.Load(this, ref _mangaConnector) ?? throw new InvalidOperationException();
init
{
MangaConnectorName = value.Name;
_mangaConnector = value;
}
}
[StringLength(256)] [Required] public string IdOnConnectorSite { get; init; }
[Url] [StringLength(512)] [Required] public string WebsiteUrl { get; internal init; }
private readonly ILazyLoader _lazyLoader = null!;
public MangaConnectorMangaEntry(Manga manga, MangaConnector mangaConnector, string idOnConnectorSite, string websiteUrl)
{
this.Manga = manga;
this.MangaConnector = mangaConnector;
this.IdOnConnectorSite = idOnConnectorSite;
this.WebsiteUrl = websiteUrl;
}
/// <summary>
/// EF CORE ONLY!!!
/// </summary>
public MangaConnectorMangaEntry(ILazyLoader lazyLoader, string mangaId, string mangaConnectorName, string idOnConnectorSite, string websiteUrl)
{
this._lazyLoader = lazyLoader;
this.MangaId = mangaId;
this.MangaConnectorName = mangaConnectorName;
this.IdOnConnectorSite = idOnConnectorSite;
this.WebsiteUrl = websiteUrl;
}
}

View File

@ -17,7 +17,7 @@ public class ComickIo : MangaConnector
this.downloadClient = new HttpDownloadClient();
}
public override MangaConnectorMangaEntry[] SearchManga(string mangaSearchName)
public override Manga[] SearchManga(string mangaSearchName)
{
Log.Info($"Searching Manga: {mangaSearchName}");
@ -46,20 +46,20 @@ public class ComickIo : MangaConnector
}
Log.Debug($"Search {mangaSearchName} yielded {slugs.Count} slugs. Requesting mangas now...");
List<MangaConnectorMangaEntry> mangas = slugs.Select(GetMangaFromId).ToList()!;
List<Manga> mangas = slugs.Select(GetMangaFromId).ToList()!;
Log.Info($"Search {mangaSearchName} yielded {mangas.Count} results.");
return mangas.ToArray();
}
private readonly Regex _getSlugFromTitleRex = new(@"https?:\/\/comick\.io\/comic\/(.+)(?:\/.*)*");
public override MangaConnectorMangaEntry? GetMangaFromUrl(string url)
public override Manga? GetMangaFromUrl(string url)
{
Match m = _getSlugFromTitleRex.Match(url);
return m.Groups[1].Success ? GetMangaFromId(m.Groups[1].Value) : null;
}
public override MangaConnectorMangaEntry? GetMangaFromId(string mangaIdOnSite)
public override Manga? GetMangaFromId(string mangaIdOnSite)
{
string requestUrl = $"https://api.comick.fun/comic/{mangaIdOnSite}";
@ -75,14 +75,14 @@ public class ComickIo : MangaConnector
return ParseMangaFromJToken(data);
}
public override Chapter[] GetChapters(MangaConnectorMangaEntry mangaConnectorMangaEntry, string? language = null)
public override Chapter[] GetChapters(Manga manga, string? language = null)
{
Log.Info($"Getting Chapters: {mangaConnectorMangaEntry.IdOnConnectorSite}");
Log.Info($"Getting Chapters: {manga.IdOnConnectorSite}");
List<Chapter> chapters = new();
int page = 1;
while(page < 50)
{
string requestUrl = $"https://api.comick.fun/comic/{mangaConnectorMangaEntry.IdOnConnectorSite}/chapters?limit=100&page={page}&lang={language}";
string requestUrl = $"https://api.comick.fun/comic/{manga.IdOnConnectorSite}/chapters?limit=100&page={page}&lang={language}";
RequestResult result = downloadClient.MakeRequest(requestUrl, RequestType.MangaInfo);
if ((int)result.statusCode < 200 || (int)result.statusCode >= 300)
@ -98,7 +98,7 @@ public class ComickIo : MangaConnector
if (chaptersArray is null || chaptersArray.Count < 1)
break;
chapters.AddRange(ParseChapters(mangaConnectorMangaEntry, chaptersArray));
chapters.AddRange(ParseChapters(manga, chaptersArray));
page++;
}
@ -133,7 +133,7 @@ public class ComickIo : MangaConnector
}).ToArray();
}
private MangaConnectorMangaEntry ParseMangaFromJToken(JToken json)
private Manga ParseMangaFromJToken(JToken json)
{
string? hid = json["comic"]?.Value<string>("hid");
string? slug = json["comic"]?.Value<string>("slug");
@ -211,12 +211,12 @@ public class ComickIo : MangaConnector
if(name is null)
throw new Exception("name is null");
Manga manga = new (name, description??"", coverUrl, status, authors, tags, links, altTitles,
return new Manga(hid, name, description??"", url, coverUrl, status, this,
authors, tags, links, altTitles,
year: year, originalLanguage: originalLanguage);
return new MangaConnectorMangaEntry(manga, this, hid, url);
}
private List<Chapter> ParseChapters(MangaConnectorMangaEntry mangaConnectorMangaEntry, JArray chaptersArray)
private List<Chapter> ParseChapters(Manga parentManga, JArray chaptersArray)
{
List<Chapter> chapters = new ();
foreach (JToken chapter in chaptersArray)
@ -226,12 +226,12 @@ public class ComickIo : MangaConnector
int? volumeNum = volumeNumStr is null ? null : int.Parse(volumeNumStr);
string? title = chapter.Value<string>("title");
string? hid = chapter.Value<string>("hid");
string url = $"https://comick.io/comic/{mangaConnectorMangaEntry.IdOnConnectorSite}/{hid}";
string url = $"https://comick.io/comic/{parentManga.IdOnConnectorSite}/{hid}";
if(chapterNum is null || hid is null)
continue;
chapters.Add(new (mangaConnectorMangaEntry, url, chapterNum, volumeNum, hid, title));
chapters.Add(new (parentManga, url, chapterNum, volumeNum, hid, title));
}
return chapters;
}

View File

@ -10,14 +10,14 @@ public class Global : MangaConnector
this.context = context;
}
public override MangaConnectorMangaEntry[] SearchManga(string mangaSearchName)
public override Manga[] SearchManga(string mangaSearchName)
{
//Get all enabled Connectors
MangaConnector[] enabledConnectors = context.MangaConnectors.Where(c => c.Enabled && c.Name != "Global").ToArray();
//Create Task for each MangaConnector to search simulatneously
Task<MangaConnectorMangaEntry[]>[] tasks =
enabledConnectors.Select(c => new Task<MangaConnectorMangaEntry[]>(() => c.SearchManga(mangaSearchName))).ToArray();
Task<Manga[]>[] tasks =
enabledConnectors.Select(c => new Task<Manga[]>(() => c.SearchManga(mangaSearchName))).ToArray();
foreach (var task in tasks)
task.Start();
@ -28,28 +28,28 @@ public class Global : MangaConnector
}while(tasks.Any(t => t.Status < TaskStatus.RanToCompletion));
//Concatenate all results into one
MangaConnectorMangaEntry[] ret = tasks.Select(t => t.IsCompletedSuccessfully ? t.Result : []).ToArray().SelectMany(i => i).ToArray();
Manga[] ret = tasks.Select(t => t.IsCompletedSuccessfully ? t.Result : []).ToArray().SelectMany(i => i).ToArray();
return ret;
}
public override MangaConnectorMangaEntry? GetMangaFromUrl(string url)
public override Manga? GetMangaFromUrl(string url)
{
MangaConnector? mc = context.MangaConnectors.ToArray().FirstOrDefault(c => c.UrlMatchesConnector(url));
return mc?.GetMangaFromUrl(url) ?? null;
}
public override MangaConnectorMangaEntry? GetMangaFromId(string mangaIdOnSite)
public override Manga? GetMangaFromId(string mangaIdOnSite)
{
return null;
}
public override Chapter[] GetChapters(MangaConnectorMangaEntry mangaConnectorMangaEntry, string? language = null)
public override Chapter[] GetChapters(Manga manga, string? language = null)
{
return mangaConnectorMangaEntry.MangaConnector.GetChapters(mangaConnectorMangaEntry, language);
return manga.MangaConnector.GetChapters(manga, language);
}
internal override string[] GetChapterImageUrls(Chapter chapter)
{
return chapter.MangaConnectorMangaEntry.MangaConnector.GetChapterImageUrls(chapter);
return chapter.ParentManga.MangaConnector.GetChapterImageUrls(chapter);
}
}

View File

@ -34,13 +34,13 @@ public abstract class MangaConnector(string name, string[] supportedLanguages, s
[Required]
public bool Enabled { get; internal set; } = true;
public abstract MangaConnectorMangaEntry[] SearchManga(string mangaSearchName);
public abstract Manga[] SearchManga(string mangaSearchName);
public abstract MangaConnectorMangaEntry? GetMangaFromUrl(string url);
public abstract Manga? GetMangaFromUrl(string url);
public abstract MangaConnectorMangaEntry? GetMangaFromId(string mangaIdOnSite);
public abstract Manga? GetMangaFromId(string mangaIdOnSite);
public abstract Chapter[] GetChapters(MangaConnectorMangaEntry mangaConnectorMangaEntry, string? language = null);
public abstract Chapter[] GetChapters(Manga manga, string? language = null);
internal abstract string[] GetChapterImageUrls(Chapter chapter);

View File

@ -18,10 +18,10 @@ public class MangaDex : MangaConnector
}
private const int Limit = 100;
public override MangaConnectorMangaEntry[] SearchManga(string mangaSearchName)
public override Manga[] SearchManga(string mangaSearchName)
{
Log.Info($"Searching Manga: {mangaSearchName}");
List<MangaConnectorMangaEntry> mangas = new ();
List<Manga> mangas = new ();
int offset = 0;
int total = int.MaxValue;
@ -67,7 +67,7 @@ public class MangaDex : MangaConnector
}
private static readonly Regex GetMangaIdFromUrl = new(@"https?:\/\/mangadex\.org\/title\/([a-z0-9-]+)\/?.*");
public override MangaConnectorMangaEntry? GetMangaFromUrl(string url)
public override Manga? GetMangaFromUrl(string url)
{
Log.Info($"Getting Manga: {url}");
if (!UrlMatchesConnector(url))
@ -87,7 +87,7 @@ public class MangaDex : MangaConnector
return GetMangaFromId(id);
}
public override MangaConnectorMangaEntry? GetMangaFromId(string mangaIdOnSite)
public override Manga? GetMangaFromId(string mangaIdOnSite)
{
Log.Info($"Getting Manga: {mangaIdOnSite}");
string requestUrl =
@ -118,12 +118,13 @@ public class MangaDex : MangaConnector
return null;
}
return ParseMangaFromJToken(data);
Manga manga = ParseMangaFromJToken(data);
return manga;
}
public override Chapter[] GetChapters(MangaConnectorMangaEntry mangaConnectorMangaEntry, string? language = null)
public override Chapter[] GetChapters(Manga manga, string? language = null)
{
Log.Info($"Getting Chapters: {mangaConnectorMangaEntry.IdOnConnectorSite}");
Log.Info($"Getting Chapters: {manga.IdOnConnectorSite}");
List<Chapter> chapters = new ();
int offset = 0;
@ -131,7 +132,7 @@ public class MangaDex : MangaConnector
while(offset < total)
{
string requestUrl =
$"https://api.mangadex.org/manga/{mangaConnectorMangaEntry.IdOnConnectorSite}/feed?limit={Limit}&offset={offset}&" +
$"https://api.mangadex.org/manga/{manga.IdOnConnectorSite}/feed?limit={Limit}&offset={offset}&" +
$"translatedLanguage%5B%5D={language}&" +
$"contentRating%5B%5D=safe&contentRating%5B%5D=suggestive&contentRating%5B%5D=erotica&includeFutureUpdates=0&includes%5B%5D=";
offset += Limit;
@ -162,10 +163,10 @@ public class MangaDex : MangaConnector
return [];
}
chapters.AddRange(data.Select(d => ParseChapterFromJToken(mangaConnectorMangaEntry, d)));
chapters.AddRange(data.Select(d => ParseChapterFromJToken(manga, d)));
}
Log.Info($"Request for chapters for {mangaConnectorMangaEntry.Manga.Name} yielded {chapters.Count} results.");
Log.Info($"Request for chapters for {manga.Name} yielded {chapters.Count} results.");
return chapters.ToArray();
}
@ -222,7 +223,7 @@ public class MangaDex : MangaConnector
return urls.ToArray();
}
private MangaConnectorMangaEntry ParseMangaFromJToken(JToken jToken)
private Manga ParseMangaFromJToken(JToken jToken)
{
string? id = jToken.Value<string>("id");
if(id is null)
@ -312,12 +313,12 @@ public class MangaDex : MangaConnector
string websiteUrl = $"https://mangadex.org/title/{id}";
string coverUrl = $"https://uploads.mangadex.org/covers/{id}/{coverFileName}";
Manga manga = new Manga(name, description, coverUrl, releaseStatus, authors, tags, links,altTitles,
return new Manga(id, name, description, websiteUrl, coverUrl, releaseStatus, this,
authors, tags, links,altTitles,
null, 0f, year, originalLanguage);
return new MangaConnectorMangaEntry(manga, this, id, websiteUrl);
}
private Chapter ParseChapterFromJToken(MangaConnectorMangaEntry mangaConnectorMangaEntry, JToken jToken)
private Chapter ParseChapterFromJToken(Manga parentManga, JToken jToken)
{
string? id = jToken.Value<string>("id");
JToken? attributes = jToken["attributes"];
@ -332,6 +333,6 @@ public class MangaDex : MangaConnector
volume = int.Parse(volumeStr);
string url = $"https://mangadex.org/chapter/{id}";
return new Chapter(mangaConnectorMangaEntry, url, chapter, volume, id, title);
return new Chapter(parentManga, url, chapter, volume, id, title);
}
}

View File

@ -0,0 +1,35 @@
using Microsoft.EntityFrameworkCore;
using Newtonsoft.Json;
namespace API.Schema.MetadataFetchers;
[PrimaryKey("MetadataFetcherName", "Identifier")]
public class MetadataEntry
{
[JsonIgnore]
public Manga Manga { get; init; } = null!;
public string MangaId { get; init; }
[JsonIgnore]
public MetadataFetcher MetadataFetcher { get; init; } = null!;
public string MetadataFetcherName { get; init; }
public string Identifier { get; init; }
public MetadataEntry(MetadataFetcher fetcher, Manga manga, string identifier)
{
this.Manga = manga;
this.MangaId = manga.MangaId;
this.MetadataFetcher = fetcher;
this.MetadataFetcherName = fetcher.MetadataFetcherName;
this.Identifier = identifier;
}
/// <summary>
/// EFCORE only!!!!
/// </summary>
internal MetadataEntry(string mangaId, string identifier, string metadataFetcherName)
{
this.MangaId = mangaId;
this.Identifier = identifier;
this.MetadataFetcherName = metadataFetcherName;
}
}

View File

@ -0,0 +1,37 @@
using System.Diagnostics.CodeAnalysis;
using API.Schema.Contexts;
using Microsoft.EntityFrameworkCore;
namespace API.Schema.MetadataFetchers;
[PrimaryKey("MetadataFetcherName")]
public abstract class MetadataFetcher
{
// ReSharper disable once EntityFramework.ModelValidation.UnlimitedStringLength
public string MetadataFetcherName { get; init; }
protected MetadataFetcher()
{
this.MetadataFetcherName = this.GetType().Name;
}
/// <summary>
/// EFCORE ONLY!!!
/// </summary>
internal MetadataFetcher(string metadataFetcherName)
{
this.MetadataFetcherName = metadataFetcherName;
}
internal MetadataEntry CreateMetadataEntry(Manga manga, string identifier) =>
new (this, manga, identifier);
public abstract MetadataSearchResult[] SearchMetadataEntry(Manga manga);
public abstract MetadataSearchResult[] SearchMetadataEntry(string searchTerm);
/// <summary>
/// Updates the Manga linked in the MetadataEntry
/// </summary>
public abstract void UpdateMetadata(MetadataEntry metadataEntry, PgsqlContext dbContext);
}

View File

@ -0,0 +1,3 @@
namespace API.Schema.MetadataFetchers;
public record MetadataSearchResult(string Identifier, string Name, string Url, string? Description = null, string? CoverUrl = null);

View File

@ -0,0 +1,79 @@
using System.Text.RegularExpressions;
using API.Schema.Contexts;
using JikanDotNet;
using Microsoft.EntityFrameworkCore;
namespace API.Schema.MetadataFetchers;
public class MyAnimeList : MetadataFetcher
{
private static readonly Jikan Jikan = new ();
private static readonly Regex GetIdFromUrl = new(@"https?:\/\/myanimelist\.net\/manga\/([0-9]+)\/?.*");
public override MetadataSearchResult[] SearchMetadataEntry(Manga manga)
{
if (manga.Links.Any(link => link.LinkProvider.Equals("MyAnimeList", StringComparison.InvariantCultureIgnoreCase)))
{
string url = manga.Links.First(link => link.LinkProvider.Equals("MyAnimeList", StringComparison.InvariantCultureIgnoreCase)).LinkUrl;
Match m = GetIdFromUrl.Match(url);
if (m.Success && m.Groups[1].Success)
{
long id = long.Parse(m.Groups[1].Value);
JikanDotNet.Manga data = Jikan.GetMangaAsync(id).Result.Data;
return [new MetadataSearchResult(id.ToString(), data.Titles.First().Title, data.Url, data.Synopsis)];
}
}
return SearchMetadataEntry(manga.Name);
}
public override MetadataSearchResult[] SearchMetadataEntry(string searchTerm)
{
ICollection<JikanDotNet.Manga> resultData = Jikan.SearchMangaAsync(searchTerm).Result.Data;
if (resultData.Count < 1)
return [];
return resultData.Select(data =>
new MetadataSearchResult(data.MalId.ToString(), data.Titles.First().Title, data.Url, data.Synopsis))
.ToArray();
}
/// <summary>
/// Updates the Manga linked in the MetadataEntry
/// </summary>
/// <param name="metadataEntry"></param>
/// <param name="dbContext"></param>
/// <exception cref="FormatException"></exception>
/// <exception cref="DbUpdateException"></exception>
public override void UpdateMetadata(MetadataEntry metadataEntry, PgsqlContext dbContext)
{
Manga dbManga = dbContext.Mangas.Find(metadataEntry.MangaId)!;
MangaFull resultData;
try
{
long id = long.Parse(metadataEntry.Identifier);
resultData = Jikan.GetMangaFullDataAsync(id).Result.Data;
}
catch (Exception)
{
throw new FormatException("ID was not in correct format");
}
try
{
dbManga.Name = resultData.Titles.First().Title;
dbManga.Description = resultData.Synopsis;
dbManga.AltTitles.Clear();
dbManga.AltTitles = resultData.Titles.Select(t => new MangaAltTitle(t.Type, t.Title)).ToList();
dbManga.Authors.Clear();
dbManga.Authors = resultData.Authors.Select(a => new Author(a.Name)).ToList();
dbContext.SaveChanges();
}
catch (DbUpdateException e)
{
throw;
}
}
}

View File

@ -2,6 +2,7 @@
using API.Schema.Contexts;
using API.Schema.Jobs;
using API.Schema.MangaConnectors;
using API.Schema.MetadataFetchers;
using API.Schema.NotificationConnectors;
using log4net;
using log4net.Config;
@ -24,6 +25,7 @@ public static class Tranga
public static Thread NotificationSenderThread { get; } = new (NotificationSender);
public static Thread JobStarterThread { get; } = new (JobStarter);
private static readonly ILog Log = LogManager.GetLogger(typeof(Tranga));
internal static MetadataFetcher[] MetadataFetchers = [new MyAnimeList()];
internal static void StartLogger()
{
@ -276,17 +278,13 @@ public static class Tranga
private static List<Job> MatchJobsRunningAndWaiting(Dictionary<string, Dictionary<JobType, List<Job>>> running,
Dictionary<string, Dictionary<JobType, List<Job>>> waiting)
{
Log.Debug($"Matching {running.Count} running Jobs to {waiting.Count} waiting Jobs. Busy Connectors: {string.Join(", ", running.Select(r => r.Key))}");
DateTime start = DateTime.UtcNow;
List<Job> ret = new();
//Foreach MangaConnector
foreach ((string connector, Dictionary<JobType, List<Job>> jobTypeJobsWaiting) in waiting)
{
//Check if MangaConnector has a Job running
if (running.TryGetValue(connector, out Dictionary<JobType, List<Job>>? jobTypeJobsRunning))
{
//MangaConnector has running Jobs
//Match per JobType (MangaConnector can have 1 Job per Type running at the same time)
{ //MangaConnector has running Jobs
//Match per JobType
foreach ((JobType jobType, List<Job> jobsWaiting) in jobTypeJobsWaiting)
{
if(jobTypeJobsRunning.ContainsKey(jobType))
@ -301,13 +299,9 @@ public static class Tranga
}
}
else
{
//MangaConnector has no running Jobs
{ //MangaConnector has no running Jobs
foreach ((JobType jobType, List<Job> jobsWaiting) in jobTypeJobsWaiting)
{
if(ret.Any(j => j.JobType == jobType))
//Already a job of type to be started
continue;
if (jobType is not JobType.DownloadSingleChapterJob)
//If it is not a DownloadSingleChapterJob, just add the first
ret.Add(jobsWaiting.First());

View File

@ -94,6 +94,8 @@ Endpoints are documented in Swagger. Just spin up an instance, and go to `http:/
- [PuppeteerSharp](https://github.com/hardkoded/puppeteer-sharp/blob/master/LICENSE)
- [Html Agility Pack (HAP)](https://github.com/zzzprojects/html-agility-pack/blob/master/LICENSE)
- [Soenneker.Utils.String.NeedlemanWunsch](https://github.com/soenneker/soenneker.utils.string.needlemanwunsch/blob/main/LICENSE)
- [Jikan](https://jikan.moe/)
- [Jikan.Net](https://github.com/Ervie/jikan.net)
- 💙 Blåhaj 🦈
<p align="right">(<a href="#readme-top">back to top</a>)</p>

View File

@ -2,6 +2,7 @@
<s:Boolean x:Key="/Default/UserDictionary/Words/=altnames/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=authorsartists/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=Gotify/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=jikan/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=jjob/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=Komga/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=lunasea/@EntryIndexedValue">True</s:Boolean>