Compare commits

...

10 Commits

102 changed files with 5645 additions and 309 deletions

View File

@@ -0,0 +1,8 @@
namespace MangaReader.Core.Common;
public enum ContributorRole
{
Unknown,
Author,
Artist
}

View File

@@ -0,0 +1,9 @@
namespace MangaReader.Core.Common;
public enum Language
{
Unknown,
Japanese,
Romaji,
English
}

View File

@@ -4,13 +4,13 @@ public class Manga
{ {
public int MangaId { get; set; } public int MangaId { get; set; }
public required string Slug { get; set; } public required string Slug { get; set; }
public required string Title { get; set; } public int? Year { get; set; }
public string? Description { get; set; }
public virtual ICollection<MangaCover> Covers { get; set; } = []; public virtual ICollection<MangaCover> Covers { get; set; } = [];
public virtual ICollection<MangaTitle> Titles { get; set; } = []; public virtual ICollection<MangaTitle> Titles { get; set; } = [];
public virtual ICollection<MangaDescription> Descriptions { get; set; } = [];
public virtual ICollection<MangaSource> Sources { get; set; } = []; public virtual ICollection<MangaSource> Sources { get; set; } = [];
public virtual ICollection<MangaContributor> Contributors { get; set; } = []; public virtual ICollection<MangaContributor> Contributors { get; set; } = [];
public virtual ICollection<MangaGenre> Genres { get; set; } = []; public virtual ICollection<MangaGenre> Genres { get; set; } = [];
public virtual ICollection<MangaChapter> Chapters { get; set; } = []; //public virtual ICollection<MangaChapter> Chapters { get; set; } = [];
} }

View File

@@ -7,15 +7,22 @@ public class MangaContext(DbContextOptions options) : DbContext(options)
public DbSet<Manga> Mangas { get; set; } public DbSet<Manga> Mangas { get; set; }
public DbSet<MangaCover> MangaCovers { get; set; } public DbSet<MangaCover> MangaCovers { get; set; }
public DbSet<MangaTitle> MangaTitles { get; set; } public DbSet<MangaTitle> MangaTitles { get; set; }
public DbSet<MangaDescription> MangaDescriptions { get; set; }
public DbSet<Source> Sources { get; set; } public DbSet<Source> Sources { get; set; }
public DbSet<MangaSource> MangaSources { get; set; } public DbSet<MangaSource> MangaSources { get; set; }
public DbSet<Contributor> Contributors { get; set; } public DbSet<Contributor> Contributors { get; set; }
public DbSet<MangaContributor> MangaContributors { get; set; } public DbSet<MangaContributor> MangaContributors { get; set; }
public DbSet<Genre> Genres { get; set; } public DbSet<Genre> Genres { get; set; }
public DbSet<MangaGenre> MangaGenres { get; set; } public DbSet<MangaGenre> MangaGenres { get; set; }
public DbSet<MangaChapter> MangaChapters { get; set; } //public DbSet<MangaChapter> MangaChapters { get; set; }
public DbSet<ChapterSource> ChapterSources { get; set; } //public DbSet<ChapterSource> ChapterSources { get; set; }
public DbSet<ChapterPage> ChapterPages { get; set; } //public DbSet<ChapterPage> ChapterPages { get; set; }
public DbSet<SourceTitle> SourceTitles { get; set; }
public DbSet<SourceDescription> SourceDescriptions { get; set; }
public DbSet<SourceCover> SourceCovers { get; set; }
public DbSet<SourceChapter> SourceChapters { get; set; }
public DbSet<SourcePage> SourcePages { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder) protected override void OnModelCreating(ModelBuilder modelBuilder)
{ {
@@ -24,15 +31,21 @@ public class MangaContext(DbContextOptions options) : DbContext(options)
ConfigureManga(modelBuilder); ConfigureManga(modelBuilder);
ConfigureMangaCover(modelBuilder); ConfigureMangaCover(modelBuilder);
ConfigureMangaTitle(modelBuilder); ConfigureMangaTitle(modelBuilder);
ConfigureMangaDescription(modelBuilder);
ConfigureSource(modelBuilder); ConfigureSource(modelBuilder);
ConfigureMangaSource(modelBuilder); ConfigureMangaSource(modelBuilder);
ConfigureContributor(modelBuilder); ConfigureContributor(modelBuilder);
ConfigureMangaContributor(modelBuilder); ConfigureMangaContributor(modelBuilder);
ConfigureGenre(modelBuilder); ConfigureGenre(modelBuilder);
ConfigureMangaGenre(modelBuilder); ConfigureMangaGenre(modelBuilder);
ConfigureMangaChapter(modelBuilder); //ConfigureMangaChapter(modelBuilder);
ConfigureChapterSource(modelBuilder); //ConfigureChapterSource(modelBuilder);
ConfigureChapterPage(modelBuilder); //ConfigureChapterPage(modelBuilder);
ConfigureSourceTitle(modelBuilder);
ConfigureSourceDescription(modelBuilder);
ConfigureMangaSourceCover(modelBuilder);
ConfigureMangaSourceChapter(modelBuilder);
ConfigureSourcePage(modelBuilder);
} }
private static void ConfigureManga(ModelBuilder modelBuilder) private static void ConfigureManga(ModelBuilder modelBuilder)
@@ -41,9 +54,9 @@ public class MangaContext(DbContextOptions options) : DbContext(options)
.Entity<Manga>() .Entity<Manga>()
.HasKey(x => x.MangaId); .HasKey(x => x.MangaId);
modelBuilder.Entity<Manga>() //modelBuilder.Entity<Manga>()
.HasIndex(x => x.Title) // .HasIndex(x => x.Title)
.IsUnique(); // .IsUnique();
modelBuilder.Entity<Manga>() modelBuilder.Entity<Manga>()
.HasIndex(x => x.Slug) .HasIndex(x => x.Slug)
@@ -81,12 +94,20 @@ public class MangaContext(DbContextOptions options) : DbContext(options)
.HasKey(mangaTitle => mangaTitle.MangaTitleId); .HasKey(mangaTitle => mangaTitle.MangaTitleId);
modelBuilder.Entity<MangaTitle>() modelBuilder.Entity<MangaTitle>()
.HasIndex(mangaTitle => new { mangaTitle.MangaId, mangaTitle.TitleEntry }) .Property(mt => mt.Name)
.IsRequired();
modelBuilder.Entity<MangaTitle>()
.Property(mt => mt.Language)
.IsRequired();
modelBuilder.Entity<MangaTitle>()
.HasIndex(mangaTitle => new { mangaTitle.MangaId, mangaTitle.Name, mangaTitle.Language })
.IsUnique(); .IsUnique();
modelBuilder modelBuilder
.Entity<MangaTitle>() .Entity<MangaTitle>()
.HasIndex(mangaTitle => mangaTitle.TitleEntry); .HasIndex(mangaTitle => mangaTitle.Name);
modelBuilder modelBuilder
.Entity<MangaTitle>() .Entity<MangaTitle>()
@@ -96,6 +117,36 @@ public class MangaContext(DbContextOptions options) : DbContext(options)
.OnDelete(DeleteBehavior.Cascade); .OnDelete(DeleteBehavior.Cascade);
} }
private static void ConfigureMangaDescription(ModelBuilder modelBuilder)
{
modelBuilder
.Entity<MangaDescription>()
.HasKey(mangaDescription => mangaDescription.MangaDescriptionId);
modelBuilder.Entity<MangaDescription>()
.Property(mangaDescription => mangaDescription.Text)
.IsRequired();
modelBuilder.Entity<MangaDescription>()
.Property(mangaDescription => mangaDescription.Language)
.IsRequired();
modelBuilder.Entity<MangaDescription>()
.HasIndex(mangaDescription => new { mangaDescription.MangaId, mangaDescription.Text, mangaDescription.Language })
.IsUnique();
modelBuilder
.Entity<MangaDescription>()
.HasIndex(mangaDescription => mangaDescription.Text);
modelBuilder
.Entity<MangaDescription>()
.HasOne(x => x.Manga)
.WithMany(x => x.Descriptions)
.HasForeignKey(x => x.MangaId)
.OnDelete(DeleteBehavior.Cascade);
}
private static void ConfigureSource(ModelBuilder modelBuilder) private static void ConfigureSource(ModelBuilder modelBuilder)
{ {
modelBuilder modelBuilder
@@ -112,7 +163,11 @@ public class MangaContext(DbContextOptions options) : DbContext(options)
{ {
modelBuilder modelBuilder
.Entity<MangaSource>() .Entity<MangaSource>()
.HasKey(mangaSource => new { mangaSource.MangaId, mangaSource.SourceId }); .HasKey(mangaSource => mangaSource.MangaSourceId);
//modelBuilder
// .Entity<MangaSource>()
// .HasKey(mangaSource => new { mangaSource.MangaId, mangaSource.SourceId });
modelBuilder.Entity<MangaSource>() modelBuilder.Entity<MangaSource>()
.HasIndex(x => x.Url) .HasIndex(x => x.Url)
@@ -178,50 +233,177 @@ public class MangaContext(DbContextOptions options) : DbContext(options)
.OnDelete(DeleteBehavior.Cascade); .OnDelete(DeleteBehavior.Cascade);
} }
private static void ConfigureMangaChapter(ModelBuilder modelBuilder) //private static void ConfigureMangaChapter(ModelBuilder modelBuilder)
//{
// modelBuilder
// .Entity<MangaChapter>()
// .HasKey(x => x.MangaChapterId);
// modelBuilder
// .Entity<MangaChapter>()
// .HasOne(x => x.Manga)
// .WithMany(x => x.Chapters)
// .HasForeignKey(x => x.MangaId)
// .OnDelete(DeleteBehavior.Cascade);
//}
//private static void ConfigureChapterSource(ModelBuilder modelBuilder)
//{
// modelBuilder
// .Entity<ChapterSource>()
// .HasKey(chapterSource => new { chapterSource.MangaChapterId, chapterSource.SourceId });
// modelBuilder
// .Entity<ChapterSource>()
// .HasOne(x => x.Chapter)
// .WithMany(x => x.Sources)
// .HasForeignKey(x => x.MangaChapterId)
// .OnDelete(DeleteBehavior.Cascade);
//}
//private static void ConfigureChapterPage(ModelBuilder modelBuilder)
//{
// modelBuilder
// .Entity<ChapterPage>()
// .HasKey(chapterPage => chapterPage.ChapterPageId);
// modelBuilder
// .Entity<ChapterPage>()
// .HasIndex(chapterPage => new { chapterPage.MangaChapterId, chapterPage.PageNumber })
// .IsUnique(true);
// modelBuilder
// .Entity<ChapterPage>()
// .HasOne(x => x.MangaChapter)
// .WithMany(x => x.Pages)
// .HasForeignKey(x => x.MangaChapterId)
// .OnDelete(DeleteBehavior.Cascade);
//}
private static void ConfigureSourceTitle(ModelBuilder modelBuilder)
{ {
modelBuilder modelBuilder
.Entity<MangaChapter>() .Entity<SourceTitle>()
.HasKey(x => x.MangaChapterId); .HasKey(x => x.SourceTitleId);
modelBuilder modelBuilder
.Entity<MangaChapter>() .Entity<SourceTitle>()
.HasOne(x => x.Manga) .Property(x => x.Name)
.WithMany(x => x.Chapters) .IsRequired()
.HasForeignKey(x => x.MangaId) .HasMaxLength(512);
// Avoid duplicate rows coming from the same source record
modelBuilder
.Entity<SourceTitle>()
.HasIndex(x => new { x.MangaSourceId, x.Language, x.Name })
.IsUnique();
modelBuilder
.Entity<SourceTitle>()
.HasOne(x => x.MangaSource)
.WithMany(x => x.Titles)
.HasForeignKey(x => x.MangaSourceId)
.OnDelete(DeleteBehavior.Cascade); .OnDelete(DeleteBehavior.Cascade);
} }
private static void ConfigureChapterSource(ModelBuilder modelBuilder) private static void ConfigureSourceDescription(ModelBuilder modelBuilder)
{ {
modelBuilder modelBuilder
.Entity<ChapterSource>() .Entity<SourceDescription>()
.HasKey(chapterSource => new { chapterSource.MangaChapterId, chapterSource.SourceId }); .HasKey(x => x.SourceDescriptionId);
modelBuilder modelBuilder
.Entity<ChapterSource>() .Entity<SourceDescription>()
.HasOne(x => x.Chapter) .Property(x => x.Text)
.WithMany(x => x.Sources) .IsRequired();
.HasForeignKey(x => x.MangaChapterId)
// If sources can emit multiple descriptions per language, keep it non-unique.
// If not, uncomment:
//modelBuilder
// .Entity<SourceDescription>()
// .HasIndex(x => new { x.MangaSourceId, x.Language })
// .IsUnique();
modelBuilder
.Entity<SourceDescription>()
.HasOne(x => x.MangaSource)
.WithMany(x => x.Descriptions)
.HasForeignKey(x => x.MangaSourceId)
.OnDelete(DeleteBehavior.Cascade); .OnDelete(DeleteBehavior.Cascade);
} }
private static void ConfigureChapterPage(ModelBuilder modelBuilder) private static void ConfigureMangaSourceCover(ModelBuilder modelBuilder)
{ {
modelBuilder modelBuilder
.Entity<ChapterPage>() .Entity<SourceCover>()
.HasKey(chapterPage => chapterPage.ChapterPageId); .HasKey(sourceCover => sourceCover.SourceCoverId);
modelBuilder modelBuilder
.Entity<ChapterPage>() .Entity<SourceCover>()
.HasIndex(chapterPage => new { chapterPage.MangaChapterId, chapterPage.PageNumber }) .Property(x => x.Url)
.IsRequired()
.HasMaxLength(2048);
modelBuilder
.Entity<SourceCover>()
.HasIndex(sourceCover => new { sourceCover.MangaSourceId, sourceCover.Url })
.IsUnique(true); .IsUnique(true);
modelBuilder modelBuilder
.Entity<ChapterPage>() .Entity<SourceCover>()
.HasOne(x => x.MangaChapter) .HasOne(x => x.MangaSource)
.WithMany(x => x.Covers)
.HasForeignKey(x => x.MangaSourceId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder
.Entity<SourceCover>()
.HasOne(x => x.MangaCover)
.WithMany() // or .WithMany(c => c.SourceCovers) if you add a collection nav on MangaCover
.HasForeignKey(x => x.MangaCoverId)
.OnDelete(DeleteBehavior.SetNull);
// Helpful for lookups when you dedupe/file-link after download
modelBuilder
.Entity<SourceCover>()
.HasIndex(x => x.MangaCoverId);
}
private static void ConfigureMangaSourceChapter(ModelBuilder modelBuilder)
{
modelBuilder
.Entity<SourceChapter>()
.HasKey(sourceChapter => sourceChapter.SourceChapterId);
modelBuilder
.Entity<SourceChapter>()
.HasIndex(sourceChapter => new { sourceChapter.MangaSourceId, sourceChapter.ChapterNumber, sourceChapter.Url })
.IsUnique(true);
modelBuilder
.Entity<SourceChapter>()
.HasOne(x => x.MangaSource)
.WithMany(x => x.Chapters)
.HasForeignKey(x => x.MangaSourceId)
.OnDelete(DeleteBehavior.Cascade);
}
private static void ConfigureSourcePage(ModelBuilder modelBuilder)
{
modelBuilder
.Entity<SourcePage>()
.HasKey(sourcePage => sourcePage.SourcePageId);
modelBuilder
.Entity<SourcePage>()
.HasIndex(sourcePage => new { sourcePage.SourceChapterId, sourcePage.PageNumber })
.IsUnique(true);
modelBuilder
.Entity<SourcePage>()
.HasOne(x => x.Chapter)
.WithMany(x => x.Pages) .WithMany(x => x.Pages)
.HasForeignKey(x => x.MangaChapterId) .HasForeignKey(x => x.SourceChapterId)
.OnDelete(DeleteBehavior.Cascade); .OnDelete(DeleteBehavior.Cascade);
} }
} }

View File

@@ -1,4 +1,6 @@
namespace MangaReader.Core.Data; using MangaReader.Core.Common;
namespace MangaReader.Core.Data;
public class MangaContributor public class MangaContributor
{ {
@@ -8,5 +10,5 @@ public class MangaContributor
public int ContributorId { get; set; } public int ContributorId { get; set; }
public required Contributor Contributor { get; set; } public required Contributor Contributor { get; set; }
public MangaContributorRole Role { get; set; } public ContributorRole Role { get; set; }
} }

View File

@@ -1,7 +0,0 @@
namespace MangaReader.Core.Data;
public enum MangaContributorRole
{
Author,
Artist
}

View File

@@ -0,0 +1,14 @@
using MangaReader.Core.Common;
namespace MangaReader.Core.Data;
public class MangaDescription
{
public int MangaDescriptionId { get; set; }
public int MangaId { get; set; }
public required Manga Manga { get; set; }
public required string Text { get; set; }
public required Language Language { get; set; }
}

View File

@@ -2,6 +2,8 @@
public class MangaSource public class MangaSource
{ {
public int MangaSourceId { get; set; }
public int MangaId { get; set; } public int MangaId { get; set; }
public required Manga Manga { get; set; } public required Manga Manga { get; set; }
@@ -9,4 +11,9 @@ public class MangaSource
public required Source Source { get; set; } public required Source Source { get; set; }
public required string Url { get; set; } public required string Url { get; set; }
public virtual ICollection<SourceTitle> Titles { get; set; } = [];
public virtual ICollection<SourceDescription> Descriptions { get; set; } = [];
public virtual ICollection<SourceCover> Covers { get; set; } = [];
public virtual ICollection<SourceChapter> Chapters { get; set; } = [];
} }

View File

@@ -1,4 +1,6 @@
namespace MangaReader.Core.Data; using MangaReader.Core.Common;
namespace MangaReader.Core.Data;
public class MangaTitle public class MangaTitle
{ {
@@ -7,6 +9,7 @@ public class MangaTitle
public int MangaId { get; set; } public int MangaId { get; set; }
public required Manga Manga { get; set; } public required Manga Manga { get; set; }
public required string TitleEntry { get; set; } public required string Name { get; set; }
public TitleType TitleType { get; set; } public required Language Language { get; set; }
public bool IsPrimary { get; set; }
} }

View File

@@ -0,0 +1,7 @@
namespace MangaReader.Core.Data;
public class ScanlationGroup
{
public int ScanlationGroupId { get; set; }
public required string Name { get; set; }
}

View File

@@ -0,0 +1,20 @@
namespace MangaReader.Core.Data;
public class SourceChapter
{
public int SourceChapterId { get; set; }
public int MangaSourceId { get; set; }
public required MangaSource MangaSource { get; set; }
public int? ScanlationGroupId { get; set; }
public ScanlationGroup? ScanlationGroup { get; set; }
public required float ChapterNumber { get; set; }
public int? VolumeNumber { get; set; }
public string? Title { get; set; }
public required string Url { get; set; }
public ICollection<SourcePage> Pages { get; set; } = [];
}

View File

@@ -0,0 +1,14 @@
namespace MangaReader.Core.Data;
public class SourceCover
{
public int SourceCoverId { get; set; }
public int MangaSourceId { get; set; }
public required MangaSource MangaSource { get; set; }
public required string Url { get; set; }
public int? MangaCoverId { get; set; }
public MangaCover? MangaCover { get; set; }
}

View File

@@ -0,0 +1,14 @@
using MangaReader.Core.Common;
namespace MangaReader.Core.Data;
public class SourceDescription
{
public int SourceDescriptionId { get; set; }
public int MangaSourceId { get; set; }
public required MangaSource MangaSource { get; set; }
public required string Text { get; set; }
public required Language Language { get; set; }
}

View File

@@ -0,0 +1,12 @@
namespace MangaReader.Core.Data;
public class SourcePage
{
public int SourcePageId { get; set; }
public int SourceChapterId { get; set; }
public required SourceChapter Chapter { get; set; }
public int PageNumber { get; set; }
public required string Url { get; set; }
}

View File

@@ -0,0 +1,15 @@
using MangaReader.Core.Common;
namespace MangaReader.Core.Data;
public class SourceTitle
{
public int SourceTitleId { get; set; }
public int MangaSourceId { get; set; }
public required MangaSource MangaSource { get; set; }
public required string Name { get; set; }
public required Language Language { get; set; }
public bool IsPrimary { get; set; }
}

View File

@@ -1,12 +1,12 @@
namespace MangaReader.Core.Data; namespace MangaReader.Core.Data;
public enum TitleType //public enum TitleType
{ //{
Primary, // Primary,
OfficialTranslation, // OfficialTranslation,
FanTranslation, // FanTranslation,
Synonym, // Synonym,
Abbreviation, // Abbreviation,
Romaji, // Romaji,
Japanese // Japanese
} //}

View File

@@ -1,6 +1,15 @@
using MangaReader.Core.Search; using MangaReader.Core.Data;
using MangaReader.Core.Http;
using MangaReader.Core.Metadata;
using MangaReader.Core.Pipeline;
using MangaReader.Core.Search;
using MangaReader.Core.Sources.MangaDex.Api;
using MangaReader.Core.Sources.MangaDex.Metadata;
using MangaReader.Core.Sources.MangaDex.Search; using MangaReader.Core.Sources.MangaDex.Search;
using MangaReader.Core.Sources.NatoManga.Api;
using MangaReader.Core.Sources.NatoManga.Metadata;
using MangaReader.Core.Sources.NatoManga.Search; using MangaReader.Core.Sources.NatoManga.Search;
using Microsoft.EntityFrameworkCore;
#pragma warning disable IDE0130 // Namespace does not match folder structure #pragma warning disable IDE0130 // Namespace does not match folder structure
namespace Microsoft.Extensions.DependencyInjection; namespace Microsoft.Extensions.DependencyInjection;
@@ -8,11 +17,52 @@ namespace Microsoft.Extensions.DependencyInjection;
public static class ServiceCollectionExtensions public static class ServiceCollectionExtensions
{ {
public static IServiceCollection AddMangaReader(this IServiceCollection services) public static IServiceCollection AddMangaReader(this IServiceCollection services, Action<DbContextOptionsBuilder>? optionsAction = null)
{ {
services.AddScoped<IMangaSearchProvider, NatoMangaSearchProvider>(); // Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:139.0) Gecko/20100101 Firefox/139.0
services.AddHttpClient<IHttpService, HttpService>(client =>
{
client.DefaultRequestHeaders.UserAgent.ParseAdd("Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:139.0) Gecko/20100101 Firefox/139.0");
});
// Http
services.AddScoped<IHttpService, HttpService>();
services.AddScoped<IHtmlLoader, HtmlLoader>();
// NatoManga
//services.AddScoped<INatoMangaClient, NatoMangaClient>();
//services.AddScoped<IMangaSearchProvider, NatoMangaSearchProvider>();
//services.AddScoped<IMangaMetadataProvider, NatoMangaWebCrawler>();
// MangaDex
services.AddScoped<IMangaDexClient, MangaDexClient>();
services.AddScoped<IMangaSearchProvider, MangaDexSearchProvider>(); services.AddScoped<IMangaSearchProvider, MangaDexSearchProvider>();
//services.AddScoped<IMangaMetadataProvider, MangaDexMetadataProvider>();
services.AddKeyedScoped<IMangaMetadataProvider, MangaDexMetadataProvider>("MangaDex");
services.AddScoped<IMangaSearchCoordinator, MangaSearchCoordinator>(); services.AddScoped<IMangaSearchCoordinator, MangaSearchCoordinator>();
services.AddScoped<IMangaMetadataCoordinator, MangaMetadataCoordinator>();
services.AddScoped<IMangaPipeline, MangaPipeline>();
// Database
services.AddDbContext<MangaContext>(options =>
{
if (optionsAction is not null)
{
optionsAction(options);
}
else
{
var dbPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"MangaReader",
"manga.db");
Directory.CreateDirectory(Path.GetDirectoryName(dbPath)!);
options.UseSqlite($"Data Source={dbPath}");
}
});
return services; return services;
} }

View File

@@ -0,0 +1,16 @@
using HtmlAgilityPack;
namespace MangaReader.Core.Http;
public class HtmlLoader(IHttpService httpService) : IHtmlLoader
{
public async Task<HtmlDocument> GetHtmlDocumentAsync(string url, CancellationToken cancellationToken)
{
string html = await httpService.GetStringAsync(url, cancellationToken);
HtmlDocument doc = new();
doc.LoadHtml(html);
return doc;
}
}

View File

@@ -0,0 +1,25 @@
namespace MangaReader.Core.Http;
public class HttpService(HttpClient httpClient) : IHttpService
{
public Task<string> GetStringAsync(string url, CancellationToken cancellationToken)
=> GetStringAsync(url, new Dictionary<string, string>(), cancellationToken);
public async Task<string> GetStringAsync(string url, IDictionary<string, string> headers, CancellationToken cancellationToken)
{
using HttpRequestMessage request = new(HttpMethod.Get, url);
foreach (KeyValuePair<string, string> header in headers)
{
request.Headers.TryAddWithoutValidation(header.Key, header.Value);
}
//httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("MangaReader/1.0");
httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:139.0) Gecko/20100101 Firefox/139.0");
using HttpResponseMessage response = await httpClient.SendAsync(request, cancellationToken);
response.EnsureSuccessStatusCode();
return await response.Content.ReadAsStringAsync(cancellationToken);
}
}

View File

@@ -0,0 +1,8 @@
using HtmlAgilityPack;
namespace MangaReader.Core.Http;
public interface IHtmlLoader
{
Task<HtmlDocument> GetHtmlDocumentAsync(string url, CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,7 @@
namespace MangaReader.Core.Http;
public interface IHttpService
{
Task<string> GetStringAsync(string url, CancellationToken cancellationToken);
Task<string> GetStringAsync(string url, IDictionary<string, string> headers, CancellationToken cancellationToken);
}

View File

@@ -1,7 +0,0 @@
namespace MangaReader.Core.HttpService;
public class HttpService(HttpClient httpClient) : IHttpService
{
public Task<string> GetStringAsync(string url, CancellationToken cancellationToken)
=> httpClient.GetStringAsync(url, cancellationToken);
}

View File

@@ -1,6 +0,0 @@
namespace MangaReader.Core.HttpService;
public interface IHttpService
{
Task<string> GetStringAsync(string url, CancellationToken cancellationToken);
}

View File

@@ -7,8 +7,15 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="HtmlAgilityPack" Version="1.12.1" /> <PackageReference Include="HtmlAgilityPack" Version="1.12.4" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.5" /> <PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.10" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.10">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.10" />
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.10" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.11" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View File

@@ -0,0 +1,6 @@
namespace MangaReader.Core.Metadata;
public interface IMangaMetadataCoordinator
{
IMangaMetadataProvider GetProvider(string sourceName);
}

View File

@@ -0,0 +1,11 @@
using Microsoft.Extensions.DependencyInjection;
namespace MangaReader.Core.Metadata;
public class MangaMetadataCoordinator(IServiceProvider serviceProvider) : IMangaMetadataCoordinator
{
public IMangaMetadataProvider GetProvider(string sourceName)
{
return serviceProvider.GetRequiredKeyedService<IMangaMetadataProvider>(sourceName);
}
}

View File

@@ -1,21 +1,17 @@
using HtmlAgilityPack; namespace MangaReader.Core.Metadata;
namespace MangaReader.Core.Metadata;
public abstract class MangaWebCrawler : IMangaMetadataProvider public abstract class MangaWebCrawler : IMangaMetadataProvider
{ {
public abstract string SourceId { get; } public abstract string SourceId { get; }
public abstract Task<SourceManga?> GetMangaAsync(string url, CancellationToken cancellationToken); public abstract Task<SourceManga?> GetMangaAsync(string url, CancellationToken cancellationToken);
protected virtual async Task<HtmlDocument> GetHtmlDocumentAsync(string url, CancellationToken cancellationToken) //protected virtual async Task<HtmlDocument> GetHtmlDocumentAsync(string url, CancellationToken cancellationToken)
{ //{
HtmlWeb web = new() // HtmlWeb web = new()
{ // {
UsingCacheIfExists = false // UsingCacheIfExists = false
}; // };
//return web.Load(url); // return await web.LoadFromWebAsync(url, cancellationToken);
//}
return await web.LoadFromWebAsync(url, cancellationToken);
}
} }

View File

@@ -2,15 +2,17 @@
public class SourceManga public class SourceManga
{ {
public required string Title { get; set; } public required SourceMangaTitle Title { get; set; }
public string? Description { get; set; } public SourceMangaDescription[] Descriptions { get; set; } = [];
public List<SourceMangaTitle> AlternateTitles { get; set; } = []; public SourceMangaTitle[] AlternateTitles { get; set; } = [];
public SourceMangaContributor[] Contributors { get; set; } = []; public SourceMangaContributor[] Contributors { get; set; } = [];
public MangaStatus Status { get; set; } = MangaStatus.Unknown; public MangaStatus Status { get; set; } = MangaStatus.Unknown;
public List<string> Genres { get; set; } = []; public string[] Genres { get; set; } = [];
public DateTime? UpdateDate { get; set; } public DateTime? UpdateDate { get; set; }
public long? Views { get; set; } public long? Views { get; set; }
public float? RatingPercent { get; set; } public float? RatingPercent { get; set; }
public int? Votes { get; set; } public int? Votes { get; set; }
public List<SourceMangaChapter> Chapters { get; set; } = []; public SourceMangaChapter[] Chapters { get; set; } = [];
public string[] CoverArtUrls { get; set; } = [];
public int? Year { get; set; }
} }

View File

@@ -1,7 +1,9 @@
namespace MangaReader.Core.Metadata; using MangaReader.Core.Common;
namespace MangaReader.Core.Metadata;
public class SourceMangaContributor public class SourceMangaContributor
{ {
public required string Name { get; set; } public required string Name { get; set; }
public SourceMangaContributorRole Role { get; set; } public ContributorRole Role { get; set; }
} }

View File

@@ -1,8 +0,0 @@
namespace MangaReader.Core.Metadata;
public enum SourceMangaContributorRole
{
Unknown,
Author,
Artist
}

View File

@@ -0,0 +1,9 @@
using MangaReader.Core.Common;
namespace MangaReader.Core.Metadata;
public class SourceMangaDescription
{
public required string Name { get; set; }
public Language Language { get; set; }
}

View File

@@ -1,9 +1,9 @@
namespace MangaReader.Core.Metadata; namespace MangaReader.Core.Metadata;
public enum SourceMangaLanguage //public enum SourceMangaLanguage
{ //{
Unknown, // Unknown,
Japanese, // Japanese,
Romanji, // Romanji,
English // English
} //}

View File

@@ -1,7 +1,9 @@
namespace MangaReader.Core.Metadata; using MangaReader.Core.Common;
namespace MangaReader.Core.Metadata;
public class SourceMangaTitle public class SourceMangaTitle
{ {
public required string Title { get; set; } public required string Name { get; set; }
public SourceMangaLanguage Language { get; set; } public Language Language { get; set; }
} }

View File

@@ -0,0 +1,8 @@
using MangaReader.Core.Sources;
namespace MangaReader.Core.Pages;
public interface IMangaPageProvider : IMangaSourceComponent
{
Task<IReadOnlyList<string>> GetPageImageUrlsAsync(string chapterUrl, CancellationToken cancellationToken);
}

View File

@@ -1,8 +1,7 @@
using MangaReader.Core.Metadata; namespace MangaReader.Core.Pipeline;
namespace MangaReader.Core.Pipeline;
public interface IMangaPipeline public interface IMangaPipeline
{ {
Task RunAsync(SourceManga mangaDto); Task RunMetadataAsync(MangaMetadataPipelineRequest request, CancellationToken cancellationToken);
Task RunPagesAsync(MangaPagePipelineRequest request, CancellationToken cancellationToken);
} }

View File

@@ -0,0 +1,11 @@
using MangaReader.Core.Metadata;
namespace MangaReader.Core.Pipeline;
public class MangaMetadataPipelineRequest
{
public int? MangaId { get; init; }
public required string SourceName { get; init; }
public required string SourceUrl { get; init; }
public required SourceManga SourceManga { get; init; }
}

View File

@@ -0,0 +1,7 @@
namespace MangaReader.Core.Pipeline;
public class MangaPagePipelineRequest
{
public required int SourceChapterId { get; init; }
public required IReadOnlyList<string> PageImageUrls { get; init; }
}

View File

@@ -1,19 +1,45 @@
using MangaReader.Core.Data; using MangaReader.Core.Data;
using MangaReader.Core.Metadata; using MangaReader.Core.Metadata;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Formats;
using SixLabors.ImageSharp.PixelFormats;
using System.Security.Cryptography;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
namespace MangaReader.Core.Pipeline; namespace MangaReader.Core.Pipeline;
public partial class MangaPipeline(MangaContext context) : IMangaPipeline public partial class MangaPipeline(MangaContext context) : IMangaPipeline
{ {
public async Task RunAsync(SourceManga sourceManga) enum TitleType
{ {
Manga manga = await GetOrAddMangaAsync(sourceManga); Primary,
Secondary
}
public async Task RunMetadataAsync(MangaMetadataPipelineRequest request, CancellationToken cancellationToken)
{
string sourceName = request.SourceName;
string sourceUrl = request.SourceUrl;
SourceManga sourceManga = request.SourceManga;
Source source = await GetOrAddSourceAsync(sourceName);
Manga manga = await GetOrAddMangaAsync(sourceManga, sourceUrl);
MangaSource mangaSource = await AddMangaSourceAsync(sourceUrl, manga, source);
await AddSourceTitleAsync(mangaSource, sourceManga.Title, TitleType.Primary);
await AddTitleAsync(manga, sourceManga.Title, TitleType.Primary);
foreach (SourceMangaDescription description in sourceManga.Descriptions)
{
await AddSourceDescriptionAsync(mangaSource, description);
await AddDescriptionAsync(manga, description);
}
foreach (SourceMangaTitle alternateTitle in sourceManga.AlternateTitles) foreach (SourceMangaTitle alternateTitle in sourceManga.AlternateTitles)
{ {
await AddTitleAsync(manga, alternateTitle); await AddSourceTitleAsync(mangaSource, alternateTitle, TitleType.Secondary);
await AddTitleAsync(manga, alternateTitle, TitleType.Secondary);
} }
foreach (string genre in sourceManga.Genres) foreach (string genre in sourceManga.Genres)
@@ -21,25 +47,58 @@ public partial class MangaPipeline(MangaContext context) : IMangaPipeline
await LinkGenreAsync(manga, genre); await LinkGenreAsync(manga, genre);
} }
foreach (SourceMangaContributor contributor in sourceManga.Contributors)
{
await LinkMangaContributorAsync(manga, contributor);
}
foreach (SourceMangaChapter chapter in sourceManga.Chapters) foreach (SourceMangaChapter chapter in sourceManga.Chapters)
{ {
await AddChapterAsync(manga, chapter); await AddChapterAsync(mangaSource, chapter);
}
foreach (string coverArtUrl in sourceManga.CoverArtUrls)
{
await AddCoverAsync(mangaSource, coverArtUrl);
} }
context.SaveChanges(); context.SaveChanges();
} }
private async Task<Manga> GetOrAddMangaAsync(SourceManga sourceManga) private async Task<Source> GetOrAddSourceAsync(string sourceName)
{ {
Manga? manga = await context.Mangas.FirstOrDefaultAsync(manga => manga.Title == sourceManga.Title); Source? source = await context.Sources.FirstOrDefaultAsync(s => s.Name == sourceName);
if (source != null)
return source;
source = new()
{
Name = sourceName
};
context.Sources.Add(source);
return source;
}
private async Task<Manga> GetOrAddMangaAsync(SourceManga sourceManga, string sourceUrl)
{
//Manga? manga = await context.Mangas.FirstOrDefaultAsync(manga =>
// manga.Titles.Any(mangaTitle => mangaTitle.Name == sourceManga.Title.Name));
Manga? manga = await context.Mangas.FirstOrDefaultAsync(manga =>
manga.Sources.Any(mangaSource => mangaSource.Url == sourceUrl));
if (manga != null) if (manga != null)
{
manga.Year = sourceManga.Year ?? manga.Year;
return manga; return manga;
}
manga = new() manga = new()
{ {
Title = sourceManga.Title, Slug = GenerateSlug(sourceManga.Title.Name),
Slug = GenerateSlug(sourceManga.Title),
}; };
context.Add(manga); context.Add(manga);
@@ -62,10 +121,73 @@ public partial class MangaPipeline(MangaContext context) : IMangaPipeline
[GeneratedRegex(@"\s+")] [GeneratedRegex(@"\s+")]
private static partial Regex RemoveSpacesWithDashRegex(); private static partial Regex RemoveSpacesWithDashRegex();
private async Task AddTitleAsync(Manga manga, SourceMangaTitle sourceMangaTitle) private async Task<MangaSource> AddMangaSourceAsync(string sourceUrl, Manga manga, Source source)
{
MangaSource? mangaSource = await context.MangaSources.FirstOrDefaultAsync(ms =>
ms.Manga == manga && ms.Source == source && ms.Url == sourceUrl);
if (mangaSource != null)
return mangaSource;
mangaSource = new()
{
Manga = manga,
Source = source,
Url = sourceUrl
};
context.MangaSources.Add(mangaSource);
return mangaSource;
}
private async Task AddSourceTitleAsync(MangaSource mangaSource, SourceMangaTitle sourceMangaTitle, TitleType titleType)
{
SourceTitle? sourceTitle = await context.SourceTitles.FirstOrDefaultAsync(mt =>
mt.MangaSource == mangaSource && mt.Name == sourceMangaTitle.Name);
if (sourceTitle != null)
return;
sourceTitle = new()
{
MangaSource = mangaSource,
Name = sourceMangaTitle.Name,
Language = sourceMangaTitle.Language,
IsPrimary = titleType == TitleType.Primary
};
context.SourceTitles.Add(sourceTitle);
}
private async Task AddSourceDescriptionAsync(MangaSource mangaSource, SourceMangaDescription? sourceMangaDescription)
{
if (sourceMangaDescription == null)
return;
SourceDescription? sourceDescription = await context.SourceDescriptions.FirstOrDefaultAsync(md =>
md.MangaSource == mangaSource && md.Language == sourceMangaDescription.Language);
if (sourceDescription != null)
{
sourceDescription.Text = sourceMangaDescription.Name;
return;
}
sourceDescription = new()
{
MangaSource = mangaSource,
Text = sourceMangaDescription.Name,
Language = sourceMangaDescription.Language
};
context.SourceDescriptions.Add(sourceDescription);
}
private async Task AddTitleAsync(Manga manga, SourceMangaTitle sourceMangaTitle, TitleType titleType)
{ {
MangaTitle? mangaTitle = await context.MangaTitles.FirstOrDefaultAsync(mt => MangaTitle? mangaTitle = await context.MangaTitles.FirstOrDefaultAsync(mt =>
mt.Manga == manga && mt.TitleEntry == sourceMangaTitle.Title); mt.Manga == manga && mt.Name == sourceMangaTitle.Name);
if (mangaTitle != null) if (mangaTitle != null)
return; return;
@@ -73,12 +195,38 @@ public partial class MangaPipeline(MangaContext context) : IMangaPipeline
mangaTitle = new() mangaTitle = new()
{ {
Manga = manga, Manga = manga,
TitleEntry = sourceMangaTitle.Title, Name = sourceMangaTitle.Name,
Language = sourceMangaTitle.Language,
IsPrimary = titleType == TitleType.Primary
}; };
context.MangaTitles.Add(mangaTitle); context.MangaTitles.Add(mangaTitle);
} }
private async Task AddDescriptionAsync(Manga manga, SourceMangaDescription? sourceMangaDescription)
{
if (sourceMangaDescription == null)
return;
MangaDescription? mangaDescription = await context.MangaDescriptions.FirstOrDefaultAsync(md =>
md.Manga == manga && md.Language == sourceMangaDescription.Language);
if (mangaDescription != null)
{
mangaDescription.Text = sourceMangaDescription.Name;
return;
}
mangaDescription = new()
{
Manga = manga,
Text = sourceMangaDescription.Name,
Language = sourceMangaDescription.Language
};
context.MangaDescriptions.Add(mangaDescription);
}
private async Task LinkGenreAsync(Manga manga, string genreName) private async Task LinkGenreAsync(Manga manga, string genreName)
{ {
Genre genre = await GetOrAddGenreAsync(genreName); Genre genre = await GetOrAddGenreAsync(genreName);
@@ -114,32 +262,233 @@ public partial class MangaPipeline(MangaContext context) : IMangaPipeline
return genre; return genre;
} }
private async Task AddChapterAsync(Manga manga, SourceMangaChapter sourceeMangaChapter) private async Task LinkMangaContributorAsync(Manga manga, SourceMangaContributor sourceMangaContributor)
{ {
MangaChapter mangaChapter = await context.MangaChapters.FirstOrDefaultAsync(x => x.ChapterNumber == sourceeMangaChapter.Number) Contributor contributor = await GetOrAddContributorAsync(sourceMangaContributor.Name);
?? AddMangaChapter(manga, sourceeMangaChapter);
if (mangaChapter.VolumeNumber is null && sourceeMangaChapter.Volume is not null) MangaContributor? mangaContributor = await context.MangaContributors.FirstOrDefaultAsync(x =>
{ x.Manga == manga && x.Contributor == contributor && x.Role == sourceMangaContributor.Role);
mangaChapter.VolumeNumber = sourceeMangaChapter.Volume;
}
if (mangaChapter.Title is null && sourceeMangaChapter.Title is not null) if (mangaContributor != null)
{ return;
mangaChapter.Title = sourceeMangaChapter.Title;
}
}
private MangaChapter AddMangaChapter(Manga manga, SourceMangaChapter sourceeMangaChapter) mangaContributor = new()
{
MangaChapter mangaChapter = new()
{ {
Manga = manga, Manga = manga,
ChapterNumber = sourceeMangaChapter.Number Contributor = contributor,
Role = sourceMangaContributor.Role
}; };
context.MangaChapters.Add(mangaChapter); context.MangaContributors.Add(mangaContributor);
}
return mangaChapter; private async Task<Contributor> GetOrAddContributorAsync(string contributorName)
{
Contributor? trackedContributor = context.ChangeTracker
.Entries<Contributor>()
.Select(e => e.Entity)
.FirstOrDefault(c => c.Name == contributorName);
if (trackedContributor is not null)
return trackedContributor;
Contributor? contributor = await context.Contributors.FirstOrDefaultAsync(x => x.Name == contributorName);
if (contributor == null)
{
contributor = new()
{
Name = contributorName,
};
await context.Contributors.AddAsync(contributor);
}
return contributor;
}
private async Task AddChapterAsync(MangaSource mangaSource, SourceMangaChapter sourceMangaChapter)
{
SourceChapter sourceChapter = await GetSourceChapter(mangaSource, sourceMangaChapter)
?? AddSourceChapter(mangaSource, sourceMangaChapter);
if (sourceChapter.VolumeNumber is null && sourceMangaChapter.Volume is not null)
{
sourceChapter.VolumeNumber = sourceMangaChapter.Volume;
}
if (sourceChapter.Title is null && sourceMangaChapter.Title is not null)
{
sourceChapter.Title = sourceMangaChapter.Title;
}
}
private async Task<SourceChapter?> GetSourceChapter(MangaSource mangaSource, SourceMangaChapter sourceMangaChapter)
{
return await context.SourceChapters.FirstOrDefaultAsync(x =>
x.MangaSource == mangaSource && x.ChapterNumber == sourceMangaChapter.Number);
}
private SourceChapter AddSourceChapter(MangaSource mangaSource, SourceMangaChapter sourceMangaChapter)
{
SourceChapter sourceChapter = new()
{
MangaSource = mangaSource,
ChapterNumber = sourceMangaChapter.Number,
Url = sourceMangaChapter.Url
};
context.SourceChapters.Add(sourceChapter);
return sourceChapter;
}
private async Task AddCoverAsync(MangaSource mangaSource, string coverArtUrl)
{
SourceCover? sourceCover = await context.SourceCovers.FirstOrDefaultAsync(x =>
x.MangaSource == mangaSource && x.Url == coverArtUrl);
if (sourceCover == null)
{
sourceCover = new()
{
MangaSource = mangaSource,
Url = coverArtUrl
};
context.SourceCovers.Add(sourceCover);
}
}
//public async Task RunCoversAsync(MangaPagePipelineRequest request, CancellationToken cancellationToken)
//{
// SourceCover[] sourceCovers = await context.SourceCovers
// .Where(x => x.MangaCoverId == null)
// .ToArrayAsync(cancellationToken);
// foreach (SourceCover sourceCover in sourceCovers)
// {
// await AddOrUpdateCoverAsync(sourceCover, cancellationToken);
// }
//}
//private async Task AddOrUpdateCoverAsync(SourceCover sourceCover, CancellationToken cancellationToken)
//{
// HttpClient client = httpClientFactory.CreateClient();
// client.DefaultRequestHeaders.UserAgent.ParseAdd("Mozilla/5.0");
// using HttpResponseMessage responseMessage = await client.GetAsync(sourceCover.Url, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
// responseMessage.EnsureSuccessStatusCode();
// await using Stream networkStream = await responseMessage.Content.ReadAsStreamAsync(cancellationToken);
// Image image = await Image.LoadAsync(networkStream, cancellationToken);
// networkStream.Position = 0;
// IImageFormat imageFormat = await Image.DetectFormatAsync(networkStream, cancellationToken);
// // Normalize output extension (you can keep WEBP if your viewer supports it; WinUI often prefers JPEG/PNG)
// string extension = (imageFormat.Name?.ToLowerInvariant()) switch
// {
// "png" => "png",
// _ => "jpg"
// };
// // Save to memory to hash & maybe convert formats
// await using MemoryStream memoryStream = new();
// if (extension == "png")
// await image.SaveAsPngAsync(memoryStream, cancellationToken);
// else
// await image.SaveAsJpegAsync(memoryStream, cancellationToken);
// memoryStream.Position = 0;
// // Compute hash for dedupe
// string sha256;
// using (var sha = SHA256.Create())
// sha256 = Convert.ToHexString(sha.ComputeHash(memoryStream)).ToLowerInvariant();
// memoryStream.Position = 0;
// // Check if we already have this exact file
// var existing = await context.MangaCovers.FirstOrDefaultAsync(c => c.Sha256 == sha256 && c.MangaId == sc.MangaSource.MangaId, ct);
// var existing2 = await context.MangaCovers.FirstOrDefaultAsync(c => c. == sha256 && c.MangaId == sc.MangaSource.MangaId, ct);
// MangaCover cover;
// if (existing is not null)
// {
// cover = existing;
// }
// else
// {
// // Choose file path
// var folder = _paths.GetMangaCoverFolder(sc.MangaSource.MangaId);
// var guid = Guid.NewGuid();
// var fileName = $"{guid}.{ext}";
// var fullPath = Path.Combine(folder, fileName);
// // Write to disk
// await using (var fs = File.Create(fullPath))
// await ms.CopyToAsync(fs, ct);
// // Create DB record
// cover = new MangaCover
// {
// Manga = sc.MangaSource.Manga,
// Guid = guid,
// FileExtension = ext,
// Sha256 = sha256,
// Width = image.Width,
// Height = image.Height,
// IsPrimary = false
// };
// context.MangaCovers.Add(cover);
// }
// // Link and mark done
// sourceCover.MangaCover = cover;
// //sc.Status = CoverDownloadStatus.Done;
// //sc.Error = null;
// await context.SaveChangesAsync(cancellationToken);
//}
public async Task RunPagesAsync(MangaPagePipelineRequest request, CancellationToken cancellationToken)
{
SourceChapter? sourceChapter = await context.SourceChapters.FirstOrDefaultAsync(x => x.SourceChapterId == request.SourceChapterId, cancellationToken);
if (sourceChapter == null)
return;
int currentPageNumber = 1;
foreach (string pageImageUrl in request.PageImageUrls)
{
await AddOrUpdateSourcePageAsync(sourceChapter, currentPageNumber++, pageImageUrl, cancellationToken);
}
}
private async Task AddOrUpdateSourcePageAsync(SourceChapter sourceChapter, int pageNumber, string pageImageUrl, CancellationToken cancellationToken)
{
SourcePage? sourcePage = await context.SourcePages.FirstOrDefaultAsync(x =>
x.Chapter == sourceChapter && x.PageNumber == pageNumber, cancellationToken);
if (sourcePage == null)
{
sourcePage = new()
{
Chapter = sourceChapter,
PageNumber = pageNumber,
Url = pageImageUrl
};
await context.SourcePages.AddAsync(sourcePage, cancellationToken);
}
else
{
sourcePage.Url = pageImageUrl;
}
} }
} }

View File

@@ -1,4 +1,4 @@
using MangaReader.Core.HttpService; using MangaReader.Core.Http;
using System.Text.Json; using System.Text.Json;
namespace MangaReader.Core.Search; namespace MangaReader.Core.Search;

View File

@@ -2,9 +2,11 @@
public record MangaSearchResult public record MangaSearchResult
{ {
public required string Source { get; init; }
public required string Url { get; init; } public required string Url { get; init; }
public required string Title { get; init; } public required string Title { get; init; }
public string? Thumbnail { get; init; } public string? Thumbnail { get; init; }
public string? Author { get; init; } public string? Author { get; init; }
public string? Description { get; init; } public string? Description { get; init; }
public string[] Genres { get; init; } = [];
} }

View File

@@ -8,4 +8,6 @@ public interface IMangaDexClient
Task<MangaDexResponse?> GetMangaAsync(Guid mangaGuid, CancellationToken cancellationToken); Task<MangaDexResponse?> GetMangaAsync(Guid mangaGuid, CancellationToken cancellationToken);
Task<MangaDexResponse?> GetFeedAsync(Guid mangaGuid, CancellationToken cancellationToken); Task<MangaDexResponse?> GetFeedAsync(Guid mangaGuid, CancellationToken cancellationToken);
Task<MangaDexChapterResponse?> GetChapterAsync(Guid chapterGuid, CancellationToken cancellationToken); Task<MangaDexChapterResponse?> GetChapterAsync(Guid chapterGuid, CancellationToken cancellationToken);
Task<MangaDexResponse?> GetCoverArtAsync(Guid mangaGuid, CancellationToken cancellationToken);
Task<MangaDexResponse?> GetCoverArtAsync(Guid[] mangaGuid, CancellationToken cancellationToken);
} }

View File

@@ -6,4 +6,5 @@ public class MangaAttributes
public List<Dictionary<string, string>> AltTitles { get; set; } = []; public List<Dictionary<string, string>> AltTitles { get; set; } = [];
public Dictionary<string, string> Description { get; set; } = []; public Dictionary<string, string> Description { get; set; } = [];
public List<TagEntity> Tags { get; set; } = []; public List<TagEntity> Tags { get; set; } = [];
public int? Year { get; set; }
} }

View File

@@ -1,4 +1,4 @@
using MangaReader.Core.HttpService; using MangaReader.Core.Http;
using System.Text; using System.Text;
using System.Text.Json; using System.Text.Json;
@@ -20,17 +20,28 @@ namespace MangaReader.Core.Sources.MangaDex.Api
} }
private async Task<MangaDexResponse?> GetAsync(string url, CancellationToken cancellationToken) private async Task<MangaDexResponse?> GetAsync(string url, CancellationToken cancellationToken)
{
//string response = await httpService.GetStringAsync(url, cancellationToken);
//return JsonSerializer.Deserialize<MangaDexResponse>(response, _jsonSerializerOptions);
return await GetAsync<MangaDexResponse>(url, cancellationToken);
}
private async Task<T?> GetAsync<T>(string url, CancellationToken cancellationToken)
{ {
string response = await httpService.GetStringAsync(url, cancellationToken); string response = await httpService.GetStringAsync(url, cancellationToken);
return JsonSerializer.Deserialize<MangaDexResponse>(response, _jsonSerializerOptions); return JsonSerializer.Deserialize<T?>(response, _jsonSerializerOptions);
} }
public async Task<MangaDexResponse?> SearchMangaByTitleAsync(string title, CancellationToken cancellationToken) public async Task<MangaDexResponse?> SearchMangaByTitleAsync(string title, CancellationToken cancellationToken)
{ {
string normalizedKeyword = GetNormalizedKeyword(title); string normalizedKeyword = GetNormalizedKeyword(title);
return await GetAsync($"https://api.mangadex.org/manga?title={normalizedKeyword}&limit=5", cancellationToken); //return await GetAsync($"https://api.mangadex.org/manga?title={normalizedKeyword}&limit=5", cancellationToken);
return await GetAsync($"https://api.mangadex.org/manga?title={normalizedKeyword}&limit=10&contentRating[]=safe&contentRating[]=suggestive&contentRating[]=erotica&includes[]=cover_art&order[followedCount]=desc&order[relevance]=desc", cancellationToken);
//
} }
public async Task<MangaDexResponse?> SearchMangaByAuthorAsync(string author, CancellationToken cancellationToken) public async Task<MangaDexResponse?> SearchMangaByAuthorAsync(string author, CancellationToken cancellationToken)
@@ -64,10 +75,24 @@ namespace MangaReader.Core.Sources.MangaDex.Api
public async Task<MangaDexChapterResponse?> GetChapterAsync(Guid chapterGuid, CancellationToken cancellationToken) public async Task<MangaDexChapterResponse?> GetChapterAsync(Guid chapterGuid, CancellationToken cancellationToken)
{ {
string url = $"https://api.mangadex.org/at-home/server/{chapterGuid}?forcePort443=false"; //string url = $"https://api.mangadex.org/at-home/server/{chapterGuid}?forcePort443=false";
string response = await httpService.GetStringAsync(url, cancellationToken); //string response = await httpService.GetStringAsync(url, cancellationToken);
return JsonSerializer.Deserialize<MangaDexChapterResponse>(response, _jsonSerializerOptions); //return JsonSerializer.Deserialize<MangaDexChapterResponse>(response, _jsonSerializerOptions);
return await GetAsync<MangaDexChapterResponse>($"https://api.mangadex.org/at-home/server/{chapterGuid}?forcePort443=false", cancellationToken);
}
public async Task<MangaDexResponse?> GetCoverArtAsync(Guid mangaGuid, CancellationToken cancellationToken)
{
return await GetCoverArtAsync([mangaGuid], cancellationToken);
//return await GetAsync($"https://api.mangadex.org/cover?order[volume]=asc&manga[]={mangaGuid}&limit=100&offset=0", cancellationToken);
}
public async Task<MangaDexResponse?> GetCoverArtAsync(Guid[] mangaGuids, CancellationToken cancellationToken)
{
string mangaGuidQuery = string.Join("&manga[]=", mangaGuids);
return await GetAsync($"https://api.mangadex.org/cover?order[volume]=asc&manga[]={mangaGuidQuery}&limit=100&offset=0", cancellationToken);
} }
} }
} }

View File

@@ -1,4 +1,5 @@
using MangaReader.Core.Metadata; using MangaReader.Core.Common;
using MangaReader.Core.Metadata;
using MangaReader.Core.Sources.MangaDex.Api; using MangaReader.Core.Sources.MangaDex.Api;
namespace MangaReader.Core.Sources.MangaDex.Metadata; namespace MangaReader.Core.Sources.MangaDex.Metadata;
@@ -24,14 +25,17 @@ public class MangaDexMetadataProvider(IMangaDexClient mangaDexClient) : IMangaMe
MangaAttributes mangaAttributes = mangaEntity.Attributes; MangaAttributes mangaAttributes = mangaEntity.Attributes;
List<MangaDexEntity> mangaRelationships = mangaEntity.Relationships; List<MangaDexEntity> mangaRelationships = mangaEntity.Relationships;
MangaDexResponse? mangaDexFeedResponse = await mangaDexClient.GetFeedAsync(mangaGuid, cancellationToken); MangaDexResponse? mangaDexFeedResponse = await mangaDexClient.GetFeedAsync(mangaGuid, cancellationToken);
MangaDexResponse? coverArtResponse = await mangaDexClient.GetCoverArtAsync(mangaGuid, cancellationToken);
return new SourceManga() return new SourceManga()
{ {
Title = GetTitle(mangaAttributes), Title = GetTitle(mangaAttributes),
AlternateTitles = GetAlternateTitles(mangaAttributes), AlternateTitles = GetAlternateTitles(mangaAttributes),
Descriptions = GetDescriptions(mangaAttributes),
Genres = GetGenres(mangaAttributes), Genres = GetGenres(mangaAttributes),
Contributors = GetContributors(mangaRelationships), Contributors = GetContributors(mangaRelationships),
Chapters = GetChapters(mangaDexFeedResponse) Chapters = GetChapters(mangaDexFeedResponse),
CoverArtUrls = GetCoverArtUrls(mangaGuid, coverArtResponse)
}; };
} }
@@ -47,24 +51,38 @@ public class MangaDexMetadataProvider(IMangaDexClient mangaDexClient) : IMangaMe
return mangaGuid; return mangaGuid;
} }
private static string GetTitle(MangaAttributes attributes) private static SourceMangaTitle GetTitle(MangaAttributes attributes)
{ {
if (attributes.Title.TryGetValue("en", out string? title)) (string title, Language langauge) = GetTileAndLanguage(attributes);
return title;
return string.Empty; return new()
{
Name = title,
Language = langauge
};
} }
private static List<SourceMangaTitle> GetAlternateTitles(MangaAttributes attributes) private static (string title, Language langauge) GetTileAndLanguage(MangaAttributes attributes)
{
if (attributes.Title.TryGetValue("en", out string? englishTitle))
return (englishTitle, Language.English);
if (attributes.Title.TryGetValue("ja-ro", out string? japaneseTitle))
return (japaneseTitle, Language.Japanese);
return (string.Empty, Language.Unknown);
}
private static SourceMangaTitle[] GetAlternateTitles(MangaAttributes attributes)
{ {
if (attributes.AltTitles == null || attributes.AltTitles.Count == 0) if (attributes.AltTitles == null || attributes.AltTitles.Count == 0)
return []; return [];
Dictionary<string, SourceMangaLanguage> languageIdMap = new() Dictionary<string, Language> languageIdMap = new()
{ {
{ "en", SourceMangaLanguage.English }, { "en", Language.English },
{ "ja", SourceMangaLanguage.Japanese }, { "ja", Language.Japanese },
{ "ja-ro", SourceMangaLanguage.Romanji }, { "ja-ro", Language.Romaji },
}; };
List<SourceMangaTitle> sourceMangaTitles = []; List<SourceMangaTitle> sourceMangaTitles = [];
@@ -73,12 +91,12 @@ public class MangaDexMetadataProvider(IMangaDexClient mangaDexClient) : IMangaMe
{ {
foreach (string alternateTitleKey in alternateTitle.Keys) foreach (string alternateTitleKey in alternateTitle.Keys)
{ {
if (languageIdMap.TryGetValue(alternateTitleKey, out SourceMangaLanguage language) == false) if (languageIdMap.TryGetValue(alternateTitleKey, out Language language) == false)
continue; continue;
SourceMangaTitle sourceMangaTitle = new() SourceMangaTitle sourceMangaTitle = new()
{ {
Title = alternateTitle[alternateTitleKey], Name = alternateTitle[alternateTitleKey],
Language = language Language = language
}; };
@@ -86,10 +104,41 @@ public class MangaDexMetadataProvider(IMangaDexClient mangaDexClient) : IMangaMe
} }
} }
return sourceMangaTitles; return [.. sourceMangaTitles];
} }
private static List<string> GetGenres(MangaAttributes attributes) private static SourceMangaDescription[] GetDescriptions(MangaAttributes attributes)
{
if (attributes.AltTitles == null || attributes.AltTitles.Count == 0)
return [];
Dictionary<string, Language> languageIdMap = new()
{
{ "en", Language.English },
{ "ja", Language.Japanese },
{ "ja-ro", Language.Romaji },
};
List<SourceMangaDescription> sourceMangaDescriptions = [];
foreach (string key in attributes.Description.Keys)
{
if (languageIdMap.TryGetValue(key, out Language language) == false)
continue;
SourceMangaDescription sourceMangaDescription = new()
{
Name = attributes.Description[key],
Language = language
};
sourceMangaDescriptions.Add(sourceMangaDescription);
}
return [.. sourceMangaDescriptions];
}
private static string[] GetGenres(MangaAttributes attributes)
{ {
if (attributes.Tags == null || attributes.Tags.Count == 0) if (attributes.Tags == null || attributes.Tags.Count == 0)
return []; return [];
@@ -107,7 +156,7 @@ public class MangaDexMetadataProvider(IMangaDexClient mangaDexClient) : IMangaMe
tags.Add(tagEntity.Attributes.Name.FirstOrDefault().Value); tags.Add(tagEntity.Attributes.Name.FirstOrDefault().Value);
} }
return tags; return [.. tags];
} }
private static SourceMangaContributor[] GetContributors(List<MangaDexEntity> relationships) private static SourceMangaContributor[] GetContributors(List<MangaDexEntity> relationships)
@@ -125,7 +174,7 @@ public class MangaDexMetadataProvider(IMangaDexClient mangaDexClient) : IMangaMe
SourceMangaContributor contributor = new() SourceMangaContributor contributor = new()
{ {
Name = authorEntity.Attributes.Name, Name = authorEntity.Attributes.Name,
Role = SourceMangaContributorRole.Author Role = ContributorRole.Author
}; };
contributors.Add(contributor); contributors.Add(contributor);
@@ -139,7 +188,7 @@ public class MangaDexMetadataProvider(IMangaDexClient mangaDexClient) : IMangaMe
SourceMangaContributor contributor = new() SourceMangaContributor contributor = new()
{ {
Name = artistEntity.Attributes.Name, Name = artistEntity.Attributes.Name,
Role = SourceMangaContributorRole.Artist Role = ContributorRole.Artist
}; };
contributors.Add(contributor); contributors.Add(contributor);
@@ -148,7 +197,7 @@ public class MangaDexMetadataProvider(IMangaDexClient mangaDexClient) : IMangaMe
return [.. contributors]; return [.. contributors];
} }
private static List<SourceMangaChapter> GetChapters(MangaDexResponse? mangaDexFeedResponse) private static SourceMangaChapter[] GetChapters(MangaDexResponse? mangaDexFeedResponse)
{ {
if (mangaDexFeedResponse == null || mangaDexFeedResponse is not MangaDexCollectionResponse collectionResponse) if (mangaDexFeedResponse == null || mangaDexFeedResponse is not MangaDexCollectionResponse collectionResponse)
return []; return [];
@@ -179,6 +228,27 @@ public class MangaDexMetadataProvider(IMangaDexClient mangaDexClient) : IMangaMe
chapters.Add(chapter); chapters.Add(chapter);
} }
return chapters; return [.. chapters];
}
private static string[] GetCoverArtUrls(Guid mangaGuid, MangaDexResponse? coverArtResponse)
{
if (coverArtResponse == null || coverArtResponse is not MangaDexCollectionResponse collectionResponse)
return [];
List<string> coverArtUrls = [];
CoverArtEntity[] coverArtEntities = [.. collectionResponse.Data.Where(entity => entity is CoverArtEntity).Cast<CoverArtEntity>()];
foreach (CoverArtEntity coverArtEntity in coverArtEntities)
{
if (coverArtEntity.Attributes == null || string.IsNullOrWhiteSpace(coverArtEntity.Attributes.FileName))
continue;
string url = $"https://mangadex.org/covers/{mangaGuid}/{coverArtEntity.Attributes.FileName}";
coverArtUrls.Add(url);
}
return [.. coverArtUrls];
} }
} }

View File

@@ -21,43 +21,109 @@ public partial class MangaDexSearchProvider(IMangaDexClient mangaDexClient) : IM
if (response == null || (response is not MangaDexCollectionResponse collectionResponse)) if (response == null || (response is not MangaDexCollectionResponse collectionResponse))
return []; return [];
List<MangaSearchResult> mangaSearchResults = []; MangaEntity[] mangaEntities = [.. collectionResponse.Data.Where(entity => entity is MangaEntity).Cast<MangaEntity>()];
foreach (MangaDexEntity entity in collectionResponse.Data) if (mangaEntities.Length == 0)
return [];
Dictionary<Guid, List<CoverArtEntity>> mangaCoverArtMap = await GetCoverArtFileNamesAsync(mangaEntities, cancellationToken);
List<MangaSearchResult> mangaSearchResults = [];
Dictionary<Guid, MangaSearchResult> thing = [];
foreach (MangaEntity mangaEntity in mangaEntities)
{ {
MangaSearchResult? mangaSearchResult = GetMangaSearchResult(entity); CoverArtEntity[] coverArtEntites = [.. mangaCoverArtMap[mangaEntity.Id]];
MangaSearchResult? mangaSearchResult = GetMangaSearchResult(mangaEntity, coverArtEntites);
if (mangaSearchResult == null) if (mangaSearchResult == null)
continue; continue;
mangaSearchResults.Add(mangaSearchResult); mangaSearchResults.Add(mangaSearchResult);
}
if (thing.Count > 0)
{
Guid[] mangaGuids = [.. thing.Select(x => x.Key)];
var reults = await GetCoverArtFileNamesAsync(mangaGuids, cancellationToken);
//var reults = await mangaDexClient.GetCoverArtAsync(mangaGuids, cancellationToken);
} }
return [.. mangaSearchResults]; return [.. mangaSearchResults];
} }
private static MangaSearchResult? GetMangaSearchResult(MangaDexEntity entity) private MangaSearchResult? GetMangaSearchResult(MangaEntity mangaEntity, CoverArtEntity[] coverArtEntites)
{ {
if (entity is not MangaEntity mangaEntity) MangaAttributes? mangaAttributes = mangaEntity.Attributes;
if (mangaAttributes == null)
return null; return null;
if (mangaEntity.Attributes == null) string title = GetTitle(mangaAttributes);
return null;
string title = mangaEntity.Attributes.Title.FirstOrDefault().Value;
string slug = GenerateSlug(title); string slug = GenerateSlug(title);
MangaSearchResult mangaSearchResult = new() MangaSearchResult mangaSearchResult = new()
{ {
Source = SourceId,
Title = title, Title = title,
Description = GetDescription(mangaAttributes),
Genres = GetGenres(mangaAttributes),
Url = $"https://mangadex.org/title/{mangaEntity.Id}/{slug}", Url = $"https://mangadex.org/title/{mangaEntity.Id}/{slug}",
Thumbnail = GetThumbnail(mangaEntity) Thumbnail = GetThumbnail(mangaEntity, coverArtEntites)
}; };
return mangaSearchResult; return mangaSearchResult;
} }
private static string GetTitle(MangaAttributes attributes)
{
var alternateTitle = attributes.AltTitles.Where(x => x.ContainsKey("en")).FirstOrDefault();
if (alternateTitle?.Count > 0)
return alternateTitle["en"];
if (attributes.Title.TryGetValue("en", out string? title))
return title;
if (attributes.Title.Count > 0)
return attributes.Title.ElementAt(0).Value;
return string.Empty;
}
private static string GetDescription(MangaAttributes attributes)
{
if (attributes.Description.TryGetValue("en", out string? description))
return description;
if (attributes.Description.Count > 0)
return attributes.Description.ElementAt(0).Value;
return string.Empty;
}
private static string[] GetGenres(MangaAttributes attributes)
{
if (attributes.Tags == null || attributes.Tags.Count == 0)
return [];
List<string> tags = [];
foreach (TagEntity tagEntity in attributes.Tags)
{
if (tagEntity.Attributes == null)
continue;
if (tagEntity.Attributes.Name == null || tagEntity.Attributes.Name.Count == 0)
continue;
tags.Add(tagEntity.Attributes.Name.FirstOrDefault().Value);
}
return [.. tags];
}
public static string GenerateSlug(string title) public static string GenerateSlug(string title)
{ {
// title.ToLowerInvariant().Normalize(NormalizationForm.FormD); // title.ToLowerInvariant().Normalize(NormalizationForm.FormD);
@@ -70,7 +136,18 @@ public partial class MangaDexSearchProvider(IMangaDexClient mangaDexClient) : IM
return title.Trim('-'); return title.Trim('-');
} }
private static string? GetThumbnail(MangaDexEntity mangaDexEntity) private static string? GetThumbnail(MangaDexEntity mangaDexEntity, CoverArtEntity[] coverArtEntites)
{
string? fileName = GetCoverArtFileNameFromMangaEntity(mangaDexEntity)
?? GetCoverArtFileNameFromCoverArtEntities(coverArtEntites);
if (string.IsNullOrWhiteSpace(fileName))
return null;
return $"https://mangadex.org/covers/{mangaDexEntity.Id}/{fileName}";
}
private static string? GetCoverArtFileNameFromMangaEntity(MangaDexEntity mangaDexEntity)
{ {
CoverArtEntity? coverArtEntity = (CoverArtEntity?)mangaDexEntity.Relationships.FirstOrDefault(entity => CoverArtEntity? coverArtEntity = (CoverArtEntity?)mangaDexEntity.Relationships.FirstOrDefault(entity =>
entity is CoverArtEntity); entity is CoverArtEntity);
@@ -78,11 +155,59 @@ public partial class MangaDexSearchProvider(IMangaDexClient mangaDexClient) : IM
if (coverArtEntity == null || string.IsNullOrWhiteSpace(coverArtEntity.Attributes?.FileName)) if (coverArtEntity == null || string.IsNullOrWhiteSpace(coverArtEntity.Attributes?.FileName))
return null; return null;
string? fileName = coverArtEntity.Attributes?.FileName; return coverArtEntity.Attributes?.FileName;
}
if (string.IsNullOrWhiteSpace(coverArtEntity.Attributes?.FileName)) private static string? GetCoverArtFileNameFromCoverArtEntities(CoverArtEntity[] coverArtEntites)
return null; {
return coverArtEntites.Where(coverArtEntity =>
string.IsNullOrWhiteSpace(coverArtEntity.Attributes?.FileName) == false).FirstOrDefault()?.Attributes!.FileName;
}
return $"https://mangadex.org/covers/{mangaDexEntity.Id}/{fileName}"; private async Task<Dictionary<Guid, List<CoverArtEntity>>> GetCoverArtFileNamesAsync(MangaEntity[] mangaEntities, CancellationToken cancellationToken)
{
Guid[] mangaGuids = [.. mangaEntities.Select(entity => entity.Id)];
return await GetCoverArtFileNamesAsync(mangaGuids, cancellationToken);
}
private async Task<Dictionary<Guid, List<CoverArtEntity>>> GetCoverArtFileNamesAsync(Guid[] mangaGuids, CancellationToken cancellationToken)
{
Dictionary<Guid, List<CoverArtEntity>> result = [];
foreach (Guid mangaGuid in mangaGuids)
{
result.Add(mangaGuid, []);
}
MangaDexResponse? response = await mangaDexClient.GetCoverArtAsync(mangaGuids, cancellationToken);
if (response == null || (response is not MangaDexCollectionResponse collectionResponse))
return result;
CoverArtEntity[] coverArtEntities = [.. collectionResponse.Data.Where(entity => entity is CoverArtEntity).Cast<CoverArtEntity>()];
if (coverArtEntities.Length == 0)
return result;
CoverArtEntity[] orderedCoverArtEntities = [.. coverArtEntities.OrderBy(x => x.Attributes?.Volume)];
foreach (var coverArtEntity in orderedCoverArtEntities)
{
if (coverArtEntity.Attributes == null)
continue;
MangaEntity? mangaEntity = (MangaEntity?)coverArtEntity.Relationships.FirstOrDefault(relationship => relationship is MangaEntity);
if (mangaEntity == null)
continue;
if (result.ContainsKey(mangaEntity.Id) == false)
continue;
result[mangaEntity.Id].Add(coverArtEntity);
}
return result;
} }
} }

View File

@@ -1,22 +1,28 @@
using HtmlAgilityPack; using HtmlAgilityPack;
using MangaReader.Core.Common;
using MangaReader.Core.Http;
using MangaReader.Core.Metadata; using MangaReader.Core.Metadata;
using System.Text; using System.Text;
using System.Web; using System.Web;
namespace MangaReader.Core.Sources.MangaNato.Metadata; namespace MangaReader.Core.Sources.MangaNato.Metadata;
public class MangaNatoWebCrawler : MangaWebCrawler public class MangaNatoWebCrawler(IHtmlLoader htmlLoader) : MangaWebCrawler
{ {
public override string SourceId => "MangaNato"; public override string SourceId => "MangaNato";
public override async Task<SourceManga?> GetMangaAsync(string url, CancellationToken cancellationToken) public override async Task<SourceManga?> GetMangaAsync(string url, CancellationToken cancellationToken)
{ {
HtmlDocument document = await GetHtmlDocumentAsync(url, cancellationToken); HtmlDocument document = await htmlLoader.GetHtmlDocumentAsync(url, cancellationToken);
MangaNatoMangaDocument node = new(document); MangaNatoMangaDocument node = new(document);
SourceManga manga = new() SourceManga manga = new()
{ {
Title = node.TitleNode?.InnerText ?? string.Empty, Title = new()
{
Name = node.TitleNode?.InnerText ?? string.Empty,
Language = Language.Unknown
},
AlternateTitles = GetAlternateTitles(node.AlternateTitlesNode), AlternateTitles = GetAlternateTitles(node.AlternateTitlesNode),
Contributors = GetContributors(node.AuthorsNode), Contributors = GetContributors(node.AuthorsNode),
Status = GetStatus(node.StatusNode), Status = GetStatus(node.StatusNode),
@@ -25,14 +31,21 @@ public class MangaNatoWebCrawler : MangaWebCrawler
RatingPercent = GetRatingPercent(node.AverageRatingNode, node.BestRatingNode), RatingPercent = GetRatingPercent(node.AverageRatingNode, node.BestRatingNode),
Votes = node.VotesNode != null ? int.Parse(node.VotesNode.InnerText) : 0, Votes = node.VotesNode != null ? int.Parse(node.VotesNode.InnerText) : 0,
Views = GetViews(node.ViewsNode), Views = GetViews(node.ViewsNode),
Description = GetTextFromNodes(node.StoryDescriptionTextNodes), Descriptions =
[
new()
{
Name = GetTextFromNodes(node.StoryDescriptionTextNodes),
Language = Language.Unknown
}
],
Chapters = GetChapters(node.ChapterNodes) Chapters = GetChapters(node.ChapterNodes)
}; };
return manga; return manga;
} }
private static List<SourceMangaTitle> GetAlternateTitles(HtmlNode? node) private static SourceMangaTitle[] GetAlternateTitles(HtmlNode? node)
{ {
if (node == null) if (node == null)
return []; return [];
@@ -45,8 +58,8 @@ public class MangaNatoWebCrawler : MangaWebCrawler
{ {
SourceMangaTitle sourceMangaTitle = new() SourceMangaTitle sourceMangaTitle = new()
{ {
Title = title, Name = title,
Language = SourceMangaLanguage.Unknown Language = Language.Unknown
}; };
sourceMangaTitles.Add(sourceMangaTitle); sourceMangaTitles.Add(sourceMangaTitle);
@@ -69,7 +82,7 @@ public class MangaNatoWebCrawler : MangaWebCrawler
SourceMangaContributor contributor = new() SourceMangaContributor contributor = new()
{ {
Name = name, Name = name,
Role = SourceMangaContributorRole.Author Role = ContributorRole.Author
}; };
contributors.Add(contributor); contributors.Add(contributor);
@@ -88,7 +101,7 @@ public class MangaNatoWebCrawler : MangaWebCrawler
}; };
} }
private static List<string> GetGenres(HtmlNode? node) private static string[] GetGenres(HtmlNode? node)
{ {
if (node == null) if (node == null)
return []; return [];
@@ -152,12 +165,12 @@ public class MangaNatoWebCrawler : MangaWebCrawler
return (int)Math.Round(average / best * 100); return (int)Math.Round(average / best * 100);
} }
private static List<SourceMangaChapter> GetChapters(HtmlNodeCollection? chapterNodes) private static SourceMangaChapter[] GetChapters(HtmlNodeCollection? chapterNodes)
{ {
List<SourceMangaChapter> chapters = [];
if (chapterNodes == null) if (chapterNodes == null)
return chapters; return [];
List<SourceMangaChapter> chapters = [];
foreach (var node in chapterNodes) foreach (var node in chapterNodes)
{ {
@@ -177,7 +190,7 @@ public class MangaNatoWebCrawler : MangaWebCrawler
chapters.Add(chapter); chapters.Add(chapter);
} }
return chapters; return [.. chapters];
} }
private static float GetChapterNumber(HtmlNode? chapterNameNode) private static float GetChapterNumber(HtmlNode? chapterNameNode)

View File

@@ -1,4 +1,4 @@
using MangaReader.Core.HttpService; using MangaReader.Core.Http;
using System.Globalization; using System.Globalization;
using System.Text; using System.Text;
using System.Text.Json; using System.Text.Json;
@@ -23,12 +23,15 @@ public partial class NatoMangaClient(IHttpService httpService) : INatoMangaClien
{ {
string url = GetSearchUrl(searchWord); string url = GetSearchUrl(searchWord);
string response = await httpService.GetStringAsync(url, cancellationToken); Dictionary<string,string> requestHeader = [];
requestHeader.Add("Referer", "https://www.natomanga.com/");
string response = await httpService.GetStringAsync(url, requestHeader, cancellationToken);
return JsonSerializer.Deserialize<NatoMangaSearchResult[]>(response, _jsonSerializerOptions) ?? []; return JsonSerializer.Deserialize<NatoMangaSearchResult[]>(response, _jsonSerializerOptions) ?? [];
} }
protected string GetSearchUrl(string searchWord) protected static string GetSearchUrl(string searchWord)
{ {
string formattedSeachWord = GetFormattedSearchWord(searchWord); string formattedSeachWord = GetFormattedSearchWord(searchWord);

View File

@@ -1,20 +1,25 @@
using HtmlAgilityPack; using HtmlAgilityPack;
using MangaReader.Core.Http;
using MangaReader.Core.Metadata; using MangaReader.Core.Metadata;
namespace MangaReader.Core.Sources.NatoManga.Metadata; namespace MangaReader.Core.Sources.NatoManga.Metadata;
public class NatoMangaWebCrawler : MangaWebCrawler public class NatoMangaWebCrawler(IHtmlLoader htmlLoader) : MangaWebCrawler
{ {
public override string SourceId => "NatoManga"; public override string SourceId => "NatoManga";
public override async Task<SourceManga?> GetMangaAsync(string url, CancellationToken cancellationToken) public override async Task<SourceManga?> GetMangaAsync(string url, CancellationToken cancellationToken)
{ {
HtmlDocument document = await GetHtmlDocumentAsync(url, cancellationToken); HtmlDocument document = await htmlLoader.GetHtmlDocumentAsync(url, cancellationToken);
NatoMangaHtmlDocument node = new(document); NatoMangaHtmlDocument node = new(document);
SourceManga manga = new() SourceManga manga = new()
{ {
Title = node.TitleNode?.InnerText ?? string.Empty, Title = new()
{
Name = node.TitleNode?.InnerText ?? string.Empty,
Language = Common.Language.Unknown
},
Genres = GetGenres(node.GenresNode), Genres = GetGenres(node.GenresNode),
Chapters = GetChapters(node.ChapterNodes) Chapters = GetChapters(node.ChapterNodes)
}; };
@@ -22,7 +27,7 @@ public class NatoMangaWebCrawler : MangaWebCrawler
return manga; return manga;
} }
private static List<string> GetGenres(HtmlNode? node) private static string[] GetGenres(HtmlNode? node)
{ {
if (node == null) if (node == null)
return []; return [];
@@ -72,12 +77,12 @@ public class NatoMangaWebCrawler : MangaWebCrawler
}; };
} }
private static List<SourceMangaChapter> GetChapters(HtmlNodeCollection? chapterNodes) private static SourceMangaChapter[] GetChapters(HtmlNodeCollection? chapterNodes)
{ {
List<SourceMangaChapter> chapters = [];
if (chapterNodes == null) if (chapterNodes == null)
return chapters; return [];
List<SourceMangaChapter> chapters = [];
foreach (var node in chapterNodes) foreach (var node in chapterNodes)
{ {
@@ -105,7 +110,7 @@ public class NatoMangaWebCrawler : MangaWebCrawler
chapters.Add(chapter); chapters.Add(chapter);
} }
return chapters; return [.. chapters];
} }
private static float GetChapterNumber(HtmlNode chapterNameNode) private static float GetChapterNumber(HtmlNode chapterNameNode)

View File

@@ -0,0 +1,43 @@
using HtmlAgilityPack;
using MangaReader.Core.Http;
using MangaReader.Core.Pages;
namespace MangaReader.Core.Sources.NatoManga.Pages;
public class NatoMangaPageProvider(IHtmlLoader htmlLoader) : IMangaPageProvider
{
public string SourceId => "NatoManga";
public async Task<IReadOnlyList<string>> GetPageImageUrlsAsync(string chapterUrl, CancellationToken cancellationToken)
{
List<string> imageUrlCollection = [];
HtmlDocument document = await htmlLoader.GetHtmlDocumentAsync(chapterUrl, cancellationToken);
HtmlNodeCollection? htmlNodeCollection = GetImageNodeCollection(document);
if (htmlNodeCollection == null)
return imageUrlCollection;
foreach (var htmlNode in htmlNodeCollection)
{
string imageSourceUrl = htmlNode.GetAttributeValue<string>("src", string.Empty);
if (string.IsNullOrWhiteSpace(imageSourceUrl))
continue;
imageUrlCollection.Add(imageSourceUrl);
}
return imageUrlCollection;
}
private static HtmlNodeCollection? GetImageNodeCollection(HtmlDocument document)
{
HtmlNode? chapterReaderNode = document.DocumentNode.SelectSingleNode(".//div[@class='container-chapter-reader']");
if (chapterReaderNode == null)
return null;
return chapterReaderNode.SelectNodes(".//img");
}
}

View File

@@ -17,6 +17,7 @@ public partial class NatoMangaSearchProvider(INatoMangaClient natoMangaClient) :
{ {
MangaSearchResult mangaSearchResult = new() MangaSearchResult mangaSearchResult = new()
{ {
Source = SourceId,
Title = searchResult.Name, Title = searchResult.Name,
Thumbnail = searchResult.Thumb, Thumbnail = searchResult.Thumb,
Url = searchResult.Url Url = searchResult.Url

View File

@@ -11,21 +11,27 @@
<ItemGroup> <ItemGroup>
<None Remove="Sources\MangaDex\Api\Manga-Chapter-Response.json" /> <None Remove="Sources\MangaDex\Api\Manga-Chapter-Response.json" />
<None Remove="Sources\MangaDex\Api\Manga-Cover-Art-Response.json" />
<None Remove="Sources\MangaDex\Api\Manga-Search-Response-2.json" />
<None Remove="Sources\NatoManga\Api\Manga-Chapter-Response.html" />
<None Remove="WebCrawlers\Samples\MangaNato - Please Go Home, Akutsu-San!.htm" /> <None Remove="WebCrawlers\Samples\MangaNato - Please Go Home, Akutsu-San!.htm" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<Content Include="WebCrawlers\NatoManga\SampleMangaPage.html"> <EmbeddedResource Include="Sources\NatoManga\Metadata\Manga-Response.html">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content> </EmbeddedResource>
<Content Include="WebCrawlers\Samples\MangaNato - Please Go Home, Akutsu-San!.htm"> <EmbeddedResource Include="Sources\MangaNato\Metadata\Manga-Response.html">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content> </EmbeddedResource>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<EmbeddedResource Include="Sources\MangaDex\Api\Manga-Chapter-Response.json" /> <EmbeddedResource Include="Sources\MangaDex\Api\Manga-Chapter-Response.json" />
<EmbeddedResource Include="Sources\MangaDex\Api\Manga-Cover-Art-Response.json" />
<EmbeddedResource Include="Sources\MangaDex\Api\Manga-Search-Response-2.json" />
<EmbeddedResource Include="Sources\MangaDex\Api\Manga-Search-Response.json" /> <EmbeddedResource Include="Sources\MangaDex\Api\Manga-Search-Response.json" />
<EmbeddedResource Include="Sources\NatoManga\Pages\Manga-Chapter-Response.html" />
<EmbeddedResource Include="Sources\NatoManga\Api\Manga-Search-Response.json" /> <EmbeddedResource Include="Sources\NatoManga\Api\Manga-Search-Response.json" />
<EmbeddedResource Include="Sources\MangaDex\Api\Manga-Feed-Response.json" /> <EmbeddedResource Include="Sources\MangaDex\Api\Manga-Feed-Response.json" />
<EmbeddedResource Include="Sources\MangaDex\Api\Manga-Response.json" /> <EmbeddedResource Include="Sources\MangaDex\Api\Manga-Response.json" />
@@ -36,11 +42,12 @@
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" /> <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.10" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.0" />
<PackageReference Include="NSubstitute" Version="5.3.0" /> <PackageReference Include="NSubstitute" Version="5.3.0" />
<PackageReference Include="Shouldly" Version="4.3.0" /> <PackageReference Include="Shouldly" Version="4.3.0" />
<PackageReference Include="xunit" Version="2.9.3" /> <PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.0"> <PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
@@ -54,4 +61,9 @@
<Using Include="Xunit" /> <Using Include="Xunit" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<Folder Include="WebCrawlers\NatoManga\" />
<Folder Include="WebCrawlers\Samples\" />
</ItemGroup>
</Project> </Project>

View File

@@ -0,0 +1,90 @@
using MangaReader.Core.Common;
using MangaReader.Core.Data;
using MangaReader.Core.Metadata;
using MangaReader.Core.Pipeline;
using MangaReader.Tests.Utilities;
using Shouldly;
namespace MangaReader.Tests.Pipeline;
public class MangaPipelineTests(TestDbContextFactory factory) : IClassFixture<TestDbContextFactory>
{
[Fact]
public async Task RunAsync_SavesMangaTitlesChaptersGenres()
{
using MangaContext context = factory.CreateContext();
var pipeline = new MangaPipeline(context);
var sourceManga = new SourceManga
{
Title = new()
{
Name = "Fullmetal Alchemist",
Language = Language.English
},
AlternateTitles =
[
new()
{
Name = "Hagane no Renkinjutsushi",
Language = Language.Romaji
}
],
Genres = ["Action", "Adventure"],
Contributors =
[
new()
{
Name = "Manga Author",
Role = ContributorRole.Author
},
new()
{
Name = "Manga Author",
Role = ContributorRole.Artist
},
new()
{
Name = "Helper Artist",
Role = ContributorRole.Artist
}
],
Chapters =
[
new()
{
Number = 1,
Title = "The Two Alchemists",
Volume = 1,
Url = string.Empty
}
]
};
MangaMetadataPipelineRequest request = new()
{
SourceName = "MySource",
SourceUrl = "https://wwww.mymangasource.org/my-manga",
SourceManga = sourceManga
};
await pipeline.RunMetadataAsync(request, CancellationToken.None);
context.Mangas.ShouldHaveSingleItem();
context.MangaTitles.Count().ShouldBe(2);
context.MangaTitles.Where(mt => mt.IsPrimary).ShouldHaveSingleItem();
context.MangaTitles.Where(mt => mt.IsPrimary).First().Name.ShouldBe("Fullmetal Alchemist");
context.MangaTitles.Where(mt => mt.IsPrimary).First().Language.ShouldBe(Language.English);
context.Genres.Count().ShouldBe(2);
context.MangaContributors.Count().ShouldBe(3);
context.MangaContributors.ElementAt(0).Contributor.Name.ShouldBe("Manga Author");
context.MangaContributors.ElementAt(0).Role.ShouldBe(ContributorRole.Author);
context.MangaContributors.ElementAt(1).Contributor.Name.ShouldBe("Manga Author");
context.MangaContributors.ElementAt(1).Role.ShouldBe(ContributorRole.Artist);
context.MangaContributors.ElementAt(2).Contributor.Name.ShouldBe("Helper Artist");
context.MangaContributors.ElementAt(2).Role.ShouldBe(ContributorRole.Artist);
context.SourceChapters.ShouldHaveSingleItem();
}
}

View File

@@ -16,14 +16,16 @@ public class MangaSearchCoordinatorTests
[ [
new() new()
{ {
Title = "Test Manga 1", Source = "Manga Source 1",
Url = "https://mangasource1.com/manga/1", Url = "https://mangasource1.com/manga/1",
Title = "Test Manga 1",
Thumbnail = "https://mangasource1.com/manga/cover/1.png" Thumbnail = "https://mangasource1.com/manga/cover/1.png"
}, },
new() new()
{ {
Source = "Manga Source 1",
Url = "https://mangasource1.com/manga/2",
Title = "Test Manga 2", Title = "Test Manga 2",
Url = "https://mangasource2.com/manga/2",
Thumbnail = "https://mangasource2.com/manga/cover/2.png" Thumbnail = "https://mangasource2.com/manga/cover/2.png"
} }
]); ]);
@@ -35,8 +37,9 @@ public class MangaSearchCoordinatorTests
[ [
new() new()
{ {
Source = "Manga Source 2",
Url = "https://mangasource2.com/manga/3",
Title = "Test Manga 3", Title = "Test Manga 3",
Url = "https://mangasource3.com/manga/3",
Thumbnail = "https://mangasource3.com/manga/cover/3.png" Thumbnail = "https://mangasource3.com/manga/cover/3.png"
}, },
]); ]);
@@ -57,14 +60,16 @@ public class MangaSearchCoordinatorTests
[ [
new() new()
{ {
Title = "Test Manga 1", Source = "Manga Source 1",
Url = "https://mangasource1.com/manga/1", Url = "https://mangasource1.com/manga/1",
Title = "Test Manga 1",
Thumbnail = "https://mangasource1.com/manga/cover/1.png" Thumbnail = "https://mangasource1.com/manga/cover/1.png"
}, },
new() new()
{ {
Source = "Manga Source 1",
Url = "https://mangasource1.com/manga/2",
Title = "Test Manga 2", Title = "Test Manga 2",
Url = "https://mangasource2.com/manga/2",
Thumbnail = "https://mangasource2.com/manga/cover/2.png" Thumbnail = "https://mangasource2.com/manga/cover/2.png"
} }
]); ]);
@@ -73,8 +78,9 @@ public class MangaSearchCoordinatorTests
[ [
new() new()
{ {
Source = "Manga Source 2",
Url = "https://mangasource2.com/manga/3",
Title = "Test Manga 3", Title = "Test Manga 3",
Url = "https://mangasource3.com/manga/3",
Thumbnail = "https://mangasource3.com/manga/cover/3.png" Thumbnail = "https://mangasource3.com/manga/cover/3.png"
} }
]); ]);

View File

@@ -0,0 +1,216 @@
{
"result": "ok",
"response": "collection",
"data": [
{
"id": "0045f243-5625-4f0f-9066-a6c3a95d84d3",
"type": "cover_art",
"attributes": {
"description": "",
"volume": "1",
"fileName": "2569ffd8-4ba1-4030-8d08-b7a21333a7a6.jpg",
"locale": "ja",
"createdAt": "2024-06-18T14:42:11+00:00",
"updatedAt": "2024-06-18T14:42:11+00:00",
"version": 1
},
"relationships": [
{
"id": "ee96e2b7-9af2-4864-9656-649f4d3b6fec",
"type": "manga"
},
{
"id": "f73d34f6-f678-4f8b-9dff-5c759d5fc767",
"type": "user"
}
]
},
{
"id": "a81ad8d3-ba2c-4003-9126-fbd9d28e3732",
"type": "cover_art",
"attributes": {
"description": "",
"volume": "2",
"fileName": "d2314e9b-4287-4e65-8045-b713d97c0b28.jpg",
"locale": "ja",
"createdAt": "2024-06-18T14:42:15+00:00",
"updatedAt": "2024-06-18T14:42:15+00:00",
"version": 1
},
"relationships": [
{
"id": "ee96e2b7-9af2-4864-9656-649f4d3b6fec",
"type": "manga"
},
{
"id": "f73d34f6-f678-4f8b-9dff-5c759d5fc767",
"type": "user"
}
]
},
{
"id": "0ca43181-8ae1-4e5f-934f-4ea407e05913",
"type": "cover_art",
"attributes": {
"description": "",
"volume": "3",
"fileName": "ed4715de-fc1b-4f50-9e12-9d2ba99e044f.jpg",
"locale": "ja",
"createdAt": "2024-06-18T14:42:15+00:00",
"updatedAt": "2024-06-18T14:42:15+00:00",
"version": 1
},
"relationships": [
{
"id": "ee96e2b7-9af2-4864-9656-649f4d3b6fec",
"type": "manga"
},
{
"id": "f73d34f6-f678-4f8b-9dff-5c759d5fc767",
"type": "user"
}
]
},
{
"id": "1dce1a43-86fb-4db6-86ca-fbc4b6c5cfab",
"type": "cover_art",
"attributes": {
"description": "",
"volume": "4",
"fileName": "b4c335cc-0e4d-4407-86ff-61e41f817e83.jpg",
"locale": "ja",
"createdAt": "2024-06-18T14:42:14+00:00",
"updatedAt": "2024-06-18T14:42:14+00:00",
"version": 1
},
"relationships": [
{
"id": "ee96e2b7-9af2-4864-9656-649f4d3b6fec",
"type": "manga"
},
{
"id": "f73d34f6-f678-4f8b-9dff-5c759d5fc767",
"type": "user"
}
]
},
{
"id": "c8151575-1aac-4464-a99f-6cafa1f962c5",
"type": "cover_art",
"attributes": {
"description": "",
"volume": "5",
"fileName": "66323609-ba33-4ade-8c64-3b08c346e6da.jpg",
"locale": "ja",
"createdAt": "2024-06-18T14:42:11+00:00",
"updatedAt": "2024-06-18T14:42:11+00:00",
"version": 1
},
"relationships": [
{
"id": "ee96e2b7-9af2-4864-9656-649f4d3b6fec",
"type": "manga"
},
{
"id": "f73d34f6-f678-4f8b-9dff-5c759d5fc767",
"type": "user"
}
]
},
{
"id": "dbac6a58-4d97-4ec9-85e3-fb4a4d904590",
"type": "cover_art",
"attributes": {
"description": "",
"volume": "6",
"fileName": "fc9d5eb5-3179-4543-ae6b-88d3231fca5b.jpg",
"locale": "ja",
"createdAt": "2024-06-18T14:42:15+00:00",
"updatedAt": "2024-06-18T14:42:15+00:00",
"version": 1
},
"relationships": [
{
"id": "ee96e2b7-9af2-4864-9656-649f4d3b6fec",
"type": "manga"
},
{
"id": "f73d34f6-f678-4f8b-9dff-5c759d5fc767",
"type": "user"
}
]
},
{
"id": "d7158641-029d-4763-b621-fbdaa83ed3c4",
"type": "cover_art",
"attributes": {
"description": "",
"volume": "7",
"fileName": "61f990f0-103a-4967-ac64-01dc9938cb5c.jpg",
"locale": "ja",
"createdAt": "2024-06-18T14:42:12+00:00",
"updatedAt": "2024-06-18T14:42:12+00:00",
"version": 1
},
"relationships": [
{
"id": "ee96e2b7-9af2-4864-9656-649f4d3b6fec",
"type": "manga"
},
{
"id": "f73d34f6-f678-4f8b-9dff-5c759d5fc767",
"type": "user"
}
]
},
{
"id": "9cbbbcfc-e82d-4c83-9d82-de692f52faf1",
"type": "cover_art",
"attributes": {
"description": "",
"volume": "8",
"fileName": "4b87e456-0243-4dfe-abda-d0f41c91141a.jpg",
"locale": "ja",
"createdAt": "2024-10-16T10:40:16+00:00",
"updatedAt": "2024-10-16T10:40:16+00:00",
"version": 1
},
"relationships": [
{
"id": "ee96e2b7-9af2-4864-9656-649f4d3b6fec",
"type": "manga"
},
{
"id": "f73d34f6-f678-4f8b-9dff-5c759d5fc767",
"type": "user"
}
]
},
{
"id": "a06943fd-6309-49a8-a66a-8df0f6dc41eb",
"type": "cover_art",
"attributes": {
"description": "Volume 9 Cover from BookLive",
"volume": "9",
"fileName": "6b3073de-bb65-4723-8113-6068bf8c6eb4.jpg",
"locale": "ja",
"createdAt": "2025-02-20T11:59:45+00:00",
"updatedAt": "2025-02-20T11:59:45+00:00",
"version": 1
},
"relationships": [
{
"id": "ee96e2b7-9af2-4864-9656-649f4d3b6fec",
"type": "manga"
},
{
"id": "27bde0e8-71b0-4bf2-8e25-2902a7b2dd4b",
"type": "user"
}
]
}
],
"limit": 100,
"offset": 0,
"total": 9
}

View File

@@ -0,0 +1,890 @@
{
"result": "ok",
"response": "collection",
"data": [
{
"id": "e78a489b-6632-4d61-b00b-5206f5b8b22b",
"type": "manga",
"attributes": {
"title": { "en": "Tensei Shitara Slime Datta Ken" },
"altTitles": [
{ "en": "That Time I Got Reincarnated as a Slime" },
{ "fr": "Moi, quand je me r\u00e9incarne en Slime" },
{ "pl": "Odrodzony jako galareta" },
{ "en": "Regarding Reincarnated to Slime" },
{ "ja-ro": "Tensei Slime" },
{ "ja-ro": "TenSli" },
{ "ja-ro": "TenSura" },
{ "en": "In Regards to My Reincarnation as a Slime" },
{ "it": "Vita da Slime" },
{ "ru": "\u041e \u043c\u043e\u0451\u043c \u043f\u0435\u0440\u0435\u0440\u043e\u0436\u0434\u0435\u043d\u0438\u0438 \u0432 \u0441\u043b\u0438\u0437\u044c" },
{ "th": "\u0e40\u0e01\u0e34\u0e14\u0e43\u0e2b\u0e21\u0e48\u0e17\u0e31\u0e49\u0e07\u0e17\u0e35\u0e01\u0e47\u0e40\u0e1b\u0e47\u0e19\u0e2a\u0e44\u0e25\u0e21\u0e4c\u0e44\u0e1b\u0e0b\u0e30\u0e41\u0e25\u0e49\u0e27" },
{ "zh-hk": "\u5173\u4e8e\u6211\u8f6c\u751f\u540e\u6210\u4e3a\u53f2\u83b1\u59c6\u7684\u90a3\u4ef6\u4e8b" },
{ "ja": "\u8ee2\u751f\u3057\u305f\u3089\u30b9\u30e9\u30a4\u30e0\u3060\u3063\u305f\u4ef6" },
{ "ko": "\uc804\uc0dd\ud588\ub354\ub2c8 \uc2ac\ub77c\uc784\uc774\uc5c8\ub358 \uac74\uc5d0 \ub300\ud558\uc5ec" },
{ "es-la": "Aquella vez que me convert\u00ed en Slime" },
{ "ar": "\u0630\u0644\u0643 \u0627\u0644\u0648\u0642\u062a \u0627\u0644\u0630\u064a \u062a\u062c\u0633\u062f\u062a \u0641\u064a\u0647 \u0643\u0633\u0644\u0627\u064a\u0645" },
{ "fi": "Kun j\u00e4lleensynnyin hirvi\u00f6n\u00e4" },
{ "tr": "O zaman bir bal\u00e7\u0131k olarak reenkarne oldum" },
{ "tr": "O zaman bir slime olarak reenkarne oldum" },
{ "de": "Meine Wiedergeburt als Schleim in einer anderen Welt" }
],
"description": {
"en": "The ordinary Mikami Satoru found himself dying after being stabbed by a slasher. It should have been the end of his meager 37 years, but he found himself deaf and blind after hearing a mysterious voice. \nHe had been reincarnated into a slime! \n \nWhile complaining about becoming the weak but famous slime and enjoying the life of a slime at the same time, Mikami Satoru met with the Catastrophe-level monster \u201cStorm Dragon Veldora\u201d, and his fate began to move.\n\n---\n**Links:**\n- Alternative Official English - [K MANGA](https:\/\/kmanga.kodansha.com\/title\/10044\/episode\/317350) (U.S. Only), [INKR](https:\/\/comics.inkr.com\/title\/233-that-time-i-got-reincarnated-as-a-slime), [Azuki](https:\/\/www.azuki.co\/series\/that-time-i-got-reincarnated-as-a-slime), [Coolmic](https:\/\/coolmic.me\/titles\/587), [Manga Planet](https:\/\/mangaplanet.com\/comic\/618e32db10673)",
"ru": "37-\u043b\u0435\u0442\u043d\u0438\u0439 \u044f\u043f\u043e\u043d\u0435\u0446-\u0445\u043e\u043b\u043e\u0441\u0442\u044f\u043a \u0431\u044b\u043b \u0437\u0430\u0440\u0435\u0437\u0430\u043d \u043d\u0430 \u0443\u043b\u0438\u0446\u0435 \u043a\u0430\u043a\u0438\u043c-\u0442\u043e \u043c\u0435\u0440\u0437\u0430\u0432\u0446\u0435\u043c-\u0433\u0440\u0430\u0431\u0438\u0442\u0435\u043b\u0435\u043c. \u0422\u0443\u0442 \u0431\u044b \u0438 \u0438\u0441\u0442\u043e\u0440\u0438\u0438 \u043a\u043e\u043d\u0435\u0446, \u0434\u0430 \u0432\u0441\u0451 \u043e\u0431\u0435\u0440\u043d\u0443\u043b\u043e\u0441\u044c \u0438\u043d\u0430\u0447\u0435, \u043d\u0435\u043e\u0436\u0438\u0434\u0430\u043d\u043d\u043e \u043e\u043d \u043f\u0435\u0440\u0435\u0440\u043e\u0434\u0438\u043b\u0441\u044f \u0441\u043b\u0438\u0437\u044c\u044e \u0432 \u0444\u044d\u043d\u0442\u0435\u0437\u0438\u0439\u043d\u043e\u043c \u043c\u0438\u0440\u0435. \u041d\u043e \u0447\u0442\u043e \u043c\u043e\u0436\u0435\u0442 \u0441\u0434\u0435\u043b\u0430\u0442\u044c, \u043f\u0443\u0441\u043a\u0430\u0439 \u0438 \u0440\u0430\u0437\u0443\u043c\u043d\u0430\u044f, \u043d\u043e \u0441\u043b\u0438\u0437\u044c? \r\n\r\n\r\n---\r\n\r\n**Links:** \r\n- [Anime Season 1 on ANN](https:\/\/www.animenewsnetwork.com\/encyclopedia\/anime.php?id=20736)",
"es-la": "Un hombre, que al tratar de salvar a su compa\u00f1ero de trabajo y su novia, fue apu\u00f1alado por un ladr\u00f3n que escapaba. Mientras mor\u00eda desangrado escuch\u00f3 una voz extra\u00f1a. Esta voz escuch\u00f3 su lamento de haber muerto virgen y a causa de eso le dio la Habilidad \u00danica \"Gran Sabio\" \u00bfFue esto una burla? Ahora \u00e9l ha reencarnado como un Slime en otro mundo, \u00bfSer\u00e1 este el inicio de una emocionante aventura?",
"pt-br": "Depois de ser morto por um ladr\u00e3o que fugia, um rapaz normal de 37 anos de idade se encontra reencarnado em um outro mundo como um slime cego com habilidades \u00fanicas. Com um novo nome \"Rimuru Tempest\" ele chegou depois de conhecer seu novo amigo, o \"n\u00edvel cat\u00e1strofe\", Drag\u00e3o da Tempestade Verudora, ele come\u00e7a sua vida de slime em outro mundo com seu crescente n\u00famero de seguidores."
},
"isLocked": true,
"links": {
"al": "86399",
"ap": "that-time-i-got-reincarnated-as-a-slime",
"bw": "series\/56105",
"kt": "35483",
"mu": "119910",
"nu": "tensei-shitara-slime-datta-ken",
"amz": "https:\/\/www.amazon.co.jp\/gp\/product\/B074CFC3N4",
"cdj": "http:\/\/www.cdjapan.co.jp\/product\/NEOBK-1858955",
"ebj": "https:\/\/ebookjapan.yahoo.co.jp\/books\/334900",
"mal": "87609",
"raw": "https:\/\/pocket.shonenmagazine.com\/episode\/10834108156631339284",
"engtl": "https:\/\/kodansha.us\/series\/that-time-i-got-reincarnated-as-a-slime"
},
"originalLanguage": "ja",
"lastVolume": "",
"lastChapter": "",
"publicationDemographic": "shounen",
"status": "ongoing",
"year": 2015,
"contentRating": "safe",
"tags": [
{
"id": "0bc90acb-ccc1-44ca-a34a-b9f3a73259d0",
"type": "tag",
"attributes": {
"name": { "en": "Reincarnation" },
"description": {},
"group": "theme",
"version": 1
},
"relationships": []
},
{
"id": "36fd93ea-e8b8-445e-b836-358f02b3d33d",
"type": "tag",
"attributes": {
"name": { "en": "Monsters" },
"description": {},
"group": "theme",
"version": 1
},
"relationships": []
},
{
"id": "391b0423-d847-456f-aff0-8b0cfc03066b",
"type": "tag",
"attributes": {
"name": { "en": "Action" },
"description": {},
"group": "genre",
"version": 1
},
"relationships": []
},
{
"id": "39730448-9a5f-48a2-85b0-a70db87b1233",
"type": "tag",
"attributes": {
"name": { "en": "Demons" },
"description": {},
"group": "theme",
"version": 1
},
"relationships": []
},
{
"id": "4d32cc48-9f00-4cca-9b5a-a839f0764984",
"type": "tag",
"attributes": {
"name": { "en": "Comedy" },
"description": {},
"group": "genre",
"version": 1
},
"relationships": []
},
{
"id": "81183756-1453-4c81-aa9e-f6e1b63be016",
"type": "tag",
"attributes": {
"name": { "en": "Samurai" },
"description": {},
"group": "theme",
"version": 1
},
"relationships": []
},
{
"id": "ace04997-f6bd-436e-b261-779182193d3d",
"type": "tag",
"attributes": {
"name": { "en": "Isekai" },
"description": {},
"group": "genre",
"version": 1
},
"relationships": []
},
{
"id": "cdc58593-87dd-415e-bbc0-2ec27bf404cc",
"type": "tag",
"attributes": {
"name": { "en": "Fantasy" },
"description": {},
"group": "genre",
"version": 1
},
"relationships": []
},
{
"id": "f4122d1c-3b44-44d0-9936-ff7502c39ad3",
"type": "tag",
"attributes": {
"name": { "en": "Adaptation" },
"description": {},
"group": "format",
"version": 1
},
"relationships": []
}
],
"state": "published",
"chapterNumbersResetOnNewVolume": false,
"createdAt": "2018-11-05T01:59:35+00:00",
"updatedAt": "2025-01-23T19:15:00+00:00",
"version": 82,
"availableTranslatedLanguages": [ "fr", "ar", "es-la", "id", "en", "pt-br" ],
"latestUploadedChapter": "3872f6f3-f327-410c-b61b-0b955fc42609"
},
"relationships": [
{
"id": "dbf8af05-7173-49f3-bf60-f4ea3f586486",
"type": "author"
},
{
"id": "560748c6-fbe7-49f5-8258-7b3292942101",
"type": "artist"
},
{
"id": "1575a7ba-6f3e-477e-9491-74506a21b268",
"type": "cover_art",
"attributes": {
"description": "Volume 28 Cover from Booklive",
"volume": "28",
"fileName": "67de8b2f-c080-4006-91dd-a3b87abdb7fd.jpg",
"locale": "ja",
"createdAt": "2025-01-23T19:13:27+00:00",
"updatedAt": "2025-01-23T19:13:27+00:00",
"version": 1
}
},
{
"id": "0d20230b-60de-4f02-b898-b477748ee667",
"type": "manga",
"related": "colored"
},
{
"id": "0e620699-0033-4b54-beb6-1bd82e6ee02e",
"type": "manga",
"related": "side_story"
},
{
"id": "1180743d-8e38-4c00-b767-c53169fadc6a",
"type": "manga",
"related": "spin_off"
},
{
"id": "1f284c6f-73f2-48db-a43b-2c35c40b1021",
"type": "manga",
"related": "spin_off"
},
{
"id": "40633ae0-794a-4dc7-b318-30774ef9908d",
"type": "manga",
"related": "doujinshi"
},
{
"id": "4c0f5ac2-37e9-421e-934f-b1351f9ee6b3",
"type": "manga",
"related": "doujinshi"
},
{
"id": "4fd9e91c-696f-468a-bf0c-a4d26468851c",
"type": "manga",
"related": "side_story"
},
{
"id": "58703998-d847-42a2-9ff4-9c671d36772f",
"type": "manga",
"related": "doujinshi"
},
{
"id": "5ede3032-6278-439f-a06b-c3f6d1493554",
"type": "manga",
"related": "spin_off"
},
{
"id": "615a8f24-4289-437d-b0b7-c32e5b9d09b0",
"type": "manga",
"related": "doujinshi"
},
{
"id": "61d81be6-2759-4cc4-9815-7952a3449149",
"type": "manga",
"related": "spin_off"
},
{
"id": "7afb9330-261e-4717-8042-8d41b2b3deba",
"type": "manga",
"related": "doujinshi"
},
{
"id": "7b650718-55d6-4094-afe6-95f59e8d0c4c",
"type": "manga",
"related": "doujinshi"
},
{
"id": "7d580248-cf9c-4fb6-925e-343ffb3dcc7e",
"type": "manga",
"related": "doujinshi"
},
{
"id": "a1343483-8779-4b6f-b919-9025a89d98c3",
"type": "manga",
"related": "spin_off"
},
{
"id": "b956fd7d-f50a-4e2b-94d7-84bd9aa125e1",
"type": "manga",
"related": "doujinshi"
},
{
"id": "bd76862b-640c-4448-b721-5a22b6691774",
"type": "manga",
"related": "doujinshi"
},
{
"id": "c2972668-1107-4c2f-a06b-aaa2252906fb",
"type": "manga",
"related": "spin_off"
},
{
"id": "c8e83aab-43e8-425c-bd55-e7fa7fd666f7",
"type": "manga",
"related": "spin_off"
},
{
"id": "cab847c6-2748-4259-b9f4-c62bffd51311",
"type": "manga",
"related": "spin_off"
},
{
"id": "e2d738e5-340f-4b24-aef3-e624623154a0",
"type": "manga",
"related": "doujinshi"
},
{
"id": "f0e05005-4ac8-4f5b-aca9-8762ede16daa",
"type": "manga",
"related": "doujinshi"
},
{
"id": "f1c79d23-d306-40e5-8b47-17cab7408f1a",
"type": "manga",
"related": "spin_off"
}
]
},
{
"id": "5e3a710f-0b0d-482b-9e84-d9c91960c625",
"type": "manga",
"attributes": {
"title": { "en": "Yancha Gal no Anjou-san" },
"altTitles": [
{ "ja": "\u3084\u3093\u3061\u3083\u30ae\u30e3\u30eb\u306e\u5b89\u57ce\u3055\u3093" },
{ "ja-ro": "Yancha Gyaru no Anjou-san" },
{ "en": "Anjo the Mischievous Gal" },
{ "en": "The Mischievous Gal Anjou-san" },
{ "ru": "\u041e\u0437\u043e\u0440\u043d\u0430\u044f \u0413\u044f\u0440\u0443 \u0410\u043d\u0434\u0437\u0451-\u0441\u0430\u043d" },
{ "th": "\u0e04\u0e38\u0e13\u0e2d\u0e31\u0e19\u0e42\u0e08 \u0e2b\u0e22\u0e2d\u0e01\u0e19\u0e31\u0e01\u0e40\u0e1e\u0e23\u0e32\u0e30\u0e23\u0e31\u0e01\u0e19\u0e30" },
{ "zh": "\u64c5\u957f\u6311\u9017\u7684\u5b89\u57ce\u540c\u5b66" },
{ "zh-ro": "Sh\u00e0nch\u00e1ng ti\u01ceod\u00f2u de \u0101nch\u00e9ng t\u00f3ngxu\u00e9" },
{ "zh": "\u6dd8\u6c14\u8fa3\u59b9\u5b89\u57ce" },
{ "zh": "\u987d\u76ae\u8fa3\u59b9\u5b89\u57ce\u540c\u5b66" },
{ "ko": "\uc7a5\ub09c\uce58\ub294\uac38\ub8e8 \uc548\uc8e0 \uc591" },
{ "ko-ro": "Jangnanchineungyalu Anjyo Yang" }
],
"description": {
"de": "Seto ist ein total normaler und irgendwie langweiliger Sch\u00fcler. Aus einem unerkl\u00e4rlichen Grund jedoch, l\u00e4sst seine Mitsch\u00fclerin Anjou ihn nicht in Ruhe! Der ernste Seto und die energische Anjou bilden ein kontrastreiches Duo, doch Anjou macht das nichts aus, da sie sehr viel Spa\u00df hat, Seto zu necken. Andererseits hat Seto eine schwere Zeit mit Anjous Eskapaden. Wenn er doch nur w\u00fcsste, dass seine Reaktionen der Grund sind, dass Anjou weitermacht. \n \nDoch \u2026 ist ihr flirten wirklich nur gespielt \u2026?",
"en": "Seto is a completely ordinary and somewhat boring high school student. Yet, for whatever reason, his errant gyaru classmate Anjou just won't leave him alone! The serious Seto and energetic Anjou make a contrasting duo, but Anjou doesn't seem to mind, as she has too much fun teasing him. On the other hand, Seto has a hard time dealing with all of her endless antics; little does he realize that his humorous reactions are precisely the reason Anjou enjoys his company.\n\nBut\u2026 just how much of her flirting is merely an act?\n\n**Official English:** [emaqi - USA & Canada only](https:\/\/emaqi.com\/manga\/anjo-the-mischievous-gal)",
"fr": "Seto est un lyc\u00e9en ne souhaitant qu'une chose : passer sa scolarit\u00e9 sans \u00eatre remarqu\u00e9 et \u00eatre en paix. Malheureusement pour lui, il attire l'attention de la pire personne possible dans sa classe, Anjou la gal. A partir de ce jour, le quotidien de Seto va drastiquement changer gr\u00e2ce (ou \u00e0 cause) de sa camarade, cherchant \u00e0 s'amuser au d\u00e9pend de notre h\u00e9ros.",
"ja": "\u771f\u9762\u76ee\u3067\u30af\u30e9\u30b9\u306e\u4e2d\u3067\u3082\u76ee\u7acb\u305f\u306a\u3044\u702c\u6238\u304f\u3093\u306b\u306f\u3001\u306a\u305c\u304b\u3044\u3064\u3082\u30a4\u30b1\u3066\u308b\u30ae\u30e3\u30eb\u306e\u5b89\u57ce\u3055\u3093\u304c\u3044\u3061\u3044\u3061\u30a8\u30ed\u304f\u7d61\u3093\u3067\u304f\u308b\u3002\u3044\u3064\u3082\u30ae\u30ea\u30ae\u30ea\u3067\u30c9\u30ad\u30c9\u30ad\u3059\u308b\u601d\u6625\u671f\u3074\u3061\u3074\u3061\u30e9\u30d6\u30b3\u30e1\u30c7\u30a3\uff01",
"ru": "\u0421\u0435\u0442\u043e - \u0441\u0435\u0440\u044c\u0451\u0437\u043d\u044b\u0439 \u0441\u0442\u0430\u0440\u0448\u0435\u043a\u043b\u0430\u0441\u0441\u043d\u0438\u043a, \u043a\u043e\u0442\u043e\u0440\u044b\u0439 \u0432\u0435\u043b \u0442\u0438\u0445\u0443\u044e \u0448\u043a\u043e\u043b\u044c\u043d\u0443\u044e \u0436\u0438\u0437\u043d\u044c, \u043f\u043e\u043a\u0430 \u043d\u0435 \u0441\u0442\u0430\u043b \u0436\u0435\u0440\u0442\u0432\u043e\u0439 \u0434\u0440\u0430\u0437\u043d\u0438\u043b\u043e\u043a \u0433\u044f\u0440\u0443 \u0410\u043d\u0434\u0437\u0451-\u0441\u0430\u043d.",
"pt-br": "Seto \u00e9 um estudante completamente comum e um tanto chato. Mesmo assim, por alguma raz\u00e3o, sua nada correta colega de classe Gal Anjou simplesmente n\u00e3o o deixa em paz!\n\nO Seto s\u00e9rio e a en\u00e9rgica Anjou formam uma dupla um tanto interessante, mas Anjou n\u00e3o parece se importar, pois se diverte muito provocando-o. Por outro lado, Seto tem dificuldade em lidar com todas as suas travessuras intermin\u00e1veis; mal ele percebe que suas rea\u00e7\u00f5es humor\u00edsticas s\u00e3o precisamente o motivo pelo qual Anjou gosta de sua companhia.\n\nMas\u2026 quanto de seu flerte \u00e9 meramente uma atua\u00e7\u00e3o?"
},
"isLocked": true,
"links": {
"al": "101315",
"ap": "yancha-gal-no-anjou-san",
"bw": "series\/154016",
"kt": "40927",
"mu": "145904",
"amz": "https:\/\/www.amazon.co.jp\/dp\/B07J2W5N37",
"cdj": "https:\/\/www.cdjapan.co.jp\/product\/NEOBK-2193867",
"ebj": "https:\/\/ebookjapan.yahoo.co.jp\/books\/447131\/",
"mal": "111357",
"raw": "https:\/\/piccoma.com\/web\/product\/30277?etype=episode",
"engtl": "https:\/\/x.com\/emaqi_official\/status\/1838216760945770879"
},
"originalLanguage": "ja",
"lastVolume": "",
"lastChapter": "",
"publicationDemographic": "seinen",
"status": "ongoing",
"year": 2017,
"contentRating": "suggestive",
"tags": [
{
"id": "423e2eae-a7a2-4a8b-ac03-a8351462d71d",
"type": "tag",
"attributes": {
"name": { "en": "Romance" },
"description": {},
"group": "genre",
"version": 1
},
"relationships": []
},
{
"id": "4d32cc48-9f00-4cca-9b5a-a839f0764984",
"type": "tag",
"attributes": {
"name": { "en": "Comedy" },
"description": {},
"group": "genre",
"version": 1
},
"relationships": []
},
{
"id": "caaa44eb-cd40-4177-b930-79d3ef2afe87",
"type": "tag",
"attributes": {
"name": { "en": "School Life" },
"description": {},
"group": "theme",
"version": 1
},
"relationships": []
},
{
"id": "e5301a23-ebd9-49dd-a0cb-2add944c7fe9",
"type": "tag",
"attributes": {
"name": { "en": "Slice of Life" },
"description": {},
"group": "genre",
"version": 1
},
"relationships": []
},
{
"id": "fad12b5e-68ba-460e-b933-9ae8318f5b65",
"type": "tag",
"attributes": {
"name": { "en": "Gyaru" },
"description": {},
"group": "theme",
"version": 1
},
"relationships": []
}
],
"state": "published",
"chapterNumbersResetOnNewVolume": false,
"createdAt": "2019-05-21T04:40:11+00:00",
"updatedAt": "2025-01-02T18:55:29+00:00",
"version": 64,
"availableTranslatedLanguages": [ "en", "fr", "pt-br", "ru", "es-la", "pl", "id", "vi", "it", "hu" ],
"latestUploadedChapter": "8bfd2743-73f1-42b5-8a69-c41ac42f4117"
},
"relationships": [
{
"id": "02812ab1-327c-443c-ac52-0e602e0cafe4",
"type": "author"
},
{
"id": "02812ab1-327c-443c-ac52-0e602e0cafe4",
"type": "artist"
},
{
"id": "205787ba-a981-4966-b02e-e9cd82baacce",
"type": "cover_art",
"attributes": {
"description": "Volume 15 Cover from BookLive",
"volume": "15",
"fileName": "c5111530-7823-4451-bd42-c439a2aaeece.jpg",
"locale": "ja",
"createdAt": "2025-02-18T21:22:27+00:00",
"updatedAt": "2025-02-18T21:22:27+00:00",
"version": 1
}
},
{
"id": "1d2e06de-cc7a-480b-a3b9-0d06971bd165",
"type": "manga",
"related": "spin_off"
},
{
"id": "2fa18b85-7ec3-4c15-a87d-32cc6aed6ca8",
"type": "manga",
"related": "spin_off"
},
{
"id": "7a2ecc5c-b215-47ab-a7f0-fcdeff941e9f",
"type": "manga",
"related": "side_story"
},
{
"id": "c400ee54-26cd-48bf-ac88-5cf7cc3ab77c",
"type": "manga",
"related": "colored"
},
{
"id": "ce068526-df38-45b9-899f-a2a672b4442a",
"type": "manga",
"related": "preserialization"
}
]
},
{
"id": "d8323b7b-9a7a-462b-90f0-2759fed52511",
"type": "manga",
"attributes": {
"title": { "en": "Dosanko Gal wa Namaramenkoi" },
"altTitles": [
{ "ja": "\u9053\u7523\u5b50\u30ae\u30e3\u30eb\u306f\u306a\u307e\u3089\u3081\u3093\u3053\u3044" },
{ "ja-ro": "Dosanko Gyura wa Namaramenkoi" },
{ "en": "Hokkaido Gals are Super Adorable!" },
{ "ko": "\ub3c4\uc0b0\ucf54 \uac38\ub8e8\ub294 \ucc38\ub9d0\ub85c \uadc0\uc5ec\uc6cc" },
{ "pt-br": "Gyarus de Hokkaido s\u00e3o ador\u00e1veis!" },
{ "ru": "\u0414\u043e\u0441\u0430\u043d\u043a\u043e-\u0433\u044f\u0440\u0443 \u0447\u0443\u0434\u043e \u043a\u0430\u043a \u043c\u0438\u043b\u044b" },
{ "es": "Esa gal de Hokkaido es demasiado linda" },
{ "tr": "Hokkaido'nun Gyaru K\u0131zlar\u0131 Acayip G\u00fczel!" },
{ "uk": "\u0414\u043e\u0441\u0430\u043d\u043a\u043e-\u0433\u044f\u0440\u0443 \u0441\u0442\u0440\u0430\u0445 \u044f\u043a\u0456 \u0433\u0430\u0440\u043d\u0435\u043d\u044c\u043a\u0456" }
],
"description": {
"en": "Shiki Tsubasa has just moved from Tokyo to Hokkaido in the middle of winter. Not quite appreciating how far apart towns are in the country, he gets off the taxi at the next town over from his destination so he can see the sights around his home, but he is shocked when he learns the \"next town\" is a 3-hour walk away. However, he also meets a cute Dosanko (born and raised in Hokkaido) gyaru named Fuyuki Minami who is braving 8 degrees celcius below 0 weather in the standard gyaru outfit of short skirts and bare legs!",
"es": "Tsubasa es un chico que se va vivir desde Tokyo a Hokkaido en pleno invierno porque han trasladado a su padre. En su antiguo instituto no era nada popular y se met\u00edan con \u00e9l siempre que pod\u00edan. Dispuesto a empezar una nueva vida, llega en taxi entre un paisaje nevado y se baja a las primeras de cambio pensando que ya ha llegado.\n\nPero pronto se da cuenta de que en Hokkaido las cosas son diferentes que en la gran ciudad y aqu\u00ed las distancias son mucho mayores, as\u00ed que el sitio al que iba est\u00e1 a tres horas andando: eso significa que se ha quedado tirado en medio de la nieve. El impacto es mayor cuando se encuentra con Minami Fuyuki, una chica vestida como la t\u00edpica gal pese al fr\u00edo que hace.\n\nMinami no s\u00f3lo es guapa, sino que es una chica supermaja y con las maneras de una gal, un crush instant\u00e1neo para Tsubasa, aunque el combo de gal m\u00e1s chica de pueblo lo deja bastante perplejo y m\u00e1s cuando se entera de que van al mismo instituto. Gracias a Minami podr\u00e1 integrarse bastante r\u00e1pido y conocer a otras gals, aunque el choque cultural a varios niveles est\u00e1 servido. \u00a1Sus d\u00edas de gals, fr\u00edo y diversi\u00f3n acaban de empezar!",
"fr": "Natsukawa Tsubasa vient de d\u00e9m\u00e9nager de Tokyo \u00e0 Hokkaido, en plein hiver. Ne se rendant pas compte de la r\u00e9alit\u00e9 des distances \u00e0 la campagne, il se retrouve perdu \u00e0 3 heures de marche de sa destination. Mais il fait \u00e9galement la rencontre d\u2019une Dosanko (\u00ab n\u00e9e et \u00e9lev\u00e9e \u00e0 Hokkaido \u00bb) gyaru, en minijupe par \u2013 8\u00b0C ! \n\n\n---\n\n**Links:** \n- [Author's Twitter](https:\/\/twitter.com\/ikada_kai) | [Author's YouTube channel](https:\/\/www.youtube.com\/channel\/UC-U4OJu-cEF2VlnM61B77bQ) | [Author's Pixiv](https:\/\/www.pixiv.net\/en\/users\/21326958)",
"ja": "\u5317\u6d77\u9053\u5317\u898b\u5e02\u306b\u8ee2\u6821\u3057\u3066\u304d\u305f\u56db\u5b63 \u7ffc\u306f\u3001\u771f\u3063\u767d\u306a\u9280\u4e16\u754c\u30671\u4eba\u306e\u201c\u30ae\u30e3\u30eb\u201d\u3068\u51fa\u4f1a\u3046\u2015\u2015\u3002\u6c37\u70b9\u4e0b\u3067\u3082\u751f\u8db3\u3067\u3001\u8ddd\u96e2\u304c\u8fd1\u304f\u3066\u3001\u65b9\u8a00\u30d0\u30ea\u30d0\u30ea\uff01",
"ru": "\u041d\u0430\u0446\u0443\u043a\u0430\u0432\u0430 \u0426\u0443\u0431\u0430\u0441\u0430 \u0442\u043e\u043b\u044c\u043a\u043e \u0447\u0442\u043e \u043f\u0435\u0440\u0435\u0435\u0445\u0430\u043b \u0438\u0437 \u0422\u043e\u043a\u0438\u043e \u043d\u0430 \u0425\u043e\u043a\u043a\u0430\u0439\u0434\u043e. \u00a0\u041d\u0435 \u0438\u043c\u0435\u044f \u043f\u0440\u0435\u0434\u0441\u0442\u0430\u0432\u043b\u0435\u043d\u0438\u044f, \u043e \u0442\u043e\u043c \u043a\u0430\u043a\u043e\u0435 \u0431\u043e\u043b\u044c\u0448\u043e\u0435 \u0440\u0430\u0441\u0441\u0442\u043e\u044f\u043d\u0438\u0435 \u043c\u0435\u0436\u0434\u0443 \u043d\u0430\u0441\u0435\u043b\u0451\u043d\u043d\u044b\u043c\u0438 \u043f\u0443\u043d\u043a\u0442\u0430\u043c\u0438 \u0432 \u0437\u0430\u0445\u043e\u043b\u0443\u0441\u0442\u044c\u0435 \u043e\u043d \u0440\u0435\u0448\u0438\u043b \u0432\u044b\u0439\u0442\u0438 \u0438\u0437 \u0442\u0430\u043a\u0441\u0438 \u0432 \u0441\u043e\u0441\u0435\u0434\u043d\u0435\u043c \u0433\u043e\u0440\u043e\u0434\u0435, \u043f\u043e\u0441\u0440\u0435\u0434\u0438 \u0437\u0438\u043c\u044b. \u041e\u0434\u043d\u0430\u043a\u043e \u043e\u043d \u0431\u044b\u043b \u0448\u043e\u043a\u0438\u0440\u043e\u0432\u0430\u043d \u0443\u0437\u043d\u0430\u0432, \u0447\u0442\u043e \u0434\u043e \u0441\u043e\u0441\u0435\u0434\u043d\u0435\u0433\u043e \u0433\u043e\u0440\u043e\u0434\u0430 \u0438\u0434\u0442\u0438 \u0446\u0435\u043b\u044b\u0445 3 \u0447\u0430\u0441\u0430. \u0422\u0443\u0442 \u0436\u0435 \u043e\u043d \u0432\u0441\u0442\u0440\u0435\u0447\u0430\u0435\u0442 \u0414\u043e\u0441\u0430\u043d\u043a\u043e-\u0433\u044f\u0440\u0443 (\u0440\u043e\u0434\u043e\u043c \u0438\u0437 \u0425\u043e\u043a\u043a\u0430\u0439\u0434\u043e) \u043f\u043e \u0438\u043c\u0435\u043d\u0438 \u0424\u0443\u044e\u043a\u0438 \u041c\u0438\u043d\u0430\u043c\u0438, \u043a\u043e\u0442\u043e\u0440\u0430\u044f \u043f\u0440\u0438 \u043c\u0438\u043d\u0443\u0441 8 \u0433\u0440\u0430\u0434\u0443\u0441\u0430\u0445 \u0446\u0435\u043b\u044c\u0441\u0438\u044f \u0445\u043e\u0434\u0438\u0442 \u0432 \u0441\u0442\u0430\u043d\u0434\u0430\u0440\u0442\u043d\u043e\u0439 \u043e\u0434\u0435\u0436\u0434\u0435 \u0433\u044f\u0440\u0443: \u043a\u043e\u0440\u043e\u0442\u043a\u043e\u0439 \u044e\u0431\u043a\u0435 \u0438 \u0441 \u0433\u043e\u043b\u044b\u043c\u0438 \u043d\u043e\u0433\u0430\u043c\u0438. \n\n\n---",
"tr": "Liseli Tsubasa, Hokkaido\u2019daki Kitami \u015fehrine ta\u015f\u0131n\u0131r ve oradaki bir otob\u00fcs dura\u011f\u0131nda bir \"gyaru\" ile tan\u0131\u015f\u0131r. Dondurucu so\u011fu\u011fa ra\u011fmen \u00e7\u0131plak bacaklar\u0131yla beyaz kar manzaras\u0131n\u0131n kar\u015f\u0131s\u0131nda tek ba\u015f\u0131na durdu\u011funu g\u00f6rmek kalbini cezbeder.",
"pt-br": "Shiki Tsubasa acaba de se mudar de T\u00f3quio para Hokkaido no meio do inverno. Sem perceber a dist\u00e2ncia entre as cidades do pa\u00eds, ele desce do t\u00e1xi na cidade mais pr\u00f3xima de seu destino para poder ver os pontos tur\u00edsticos ao redor de sua casa, mas fica chocado quando descobre que a \"pr\u00f3xima cidade\" \u00e9 uma a tr\u00eas horas a p\u00e9. No entanto, ele tamb\u00e9m conhece uma linda Dosanko (nascido e criado em Hokkaido) gyaru chamado Fuyuki Minami, que est\u00e1 enfrentando um clima de 8 graus Celsius abaixo de 0 com a roupa gyaru padr\u00e3o de saias curtas e pernas nuas!"
},
"isLocked": false,
"links": {
"al": "111403",
"ap": "dosanko-gyaru-wa-namaramenkoi",
"bw": "series\/225993\/list",
"kt": "55322",
"mu": "152497",
"amz": "https:\/\/www.amazon.co.jp\/dp\/B084NT3Q8X",
"cdj": "https:\/\/www.cdjapan.co.jp\/product\/NEOBK-2635186",
"ebj": "https:\/\/ebookjapan.yahoo.co.jp\/books\/568854",
"mal": "121597",
"raw": "https:\/\/shonenjumpplus.com\/episode\/10834108156684177150",
"engtl": "https:\/\/mangaplus.shueisha.co.jp\/titles\/100116"
},
"originalLanguage": "ja",
"lastVolume": "14",
"lastChapter": "119",
"publicationDemographic": "shounen",
"status": "completed",
"year": 2019,
"contentRating": "suggestive",
"tags": [
{
"id": "423e2eae-a7a2-4a8b-ac03-a8351462d71d",
"type": "tag",
"attributes": {
"name": { "en": "Romance" },
"description": {},
"group": "genre",
"version": 1
},
"relationships": []
},
{
"id": "4d32cc48-9f00-4cca-9b5a-a839f0764984",
"type": "tag",
"attributes": {
"name": { "en": "Comedy" },
"description": {},
"group": "genre",
"version": 1
},
"relationships": []
},
{
"id": "aafb99c1-7f60-43fa-b75f-fc9502ce29c7",
"type": "tag",
"attributes": {
"name": { "en": "Harem" },
"description": {},
"group": "theme",
"version": 1
},
"relationships": []
},
{
"id": "b9af3a63-f058-46de-a9a0-e0c13906197a",
"type": "tag",
"attributes": {
"name": { "en": "Drama" },
"description": {},
"group": "genre",
"version": 1
},
"relationships": []
},
{
"id": "caaa44eb-cd40-4177-b930-79d3ef2afe87",
"type": "tag",
"attributes": {
"name": { "en": "School Life" },
"description": {},
"group": "theme",
"version": 1
},
"relationships": []
},
{
"id": "e5301a23-ebd9-49dd-a0cb-2add944c7fe9",
"type": "tag",
"attributes": {
"name": { "en": "Slice of Life" },
"description": {},
"group": "genre",
"version": 1
},
"relationships": []
},
{
"id": "fad12b5e-68ba-460e-b933-9ae8318f5b65",
"type": "tag",
"attributes": {
"name": { "en": "Gyaru" },
"description": {},
"group": "theme",
"version": 1
},
"relationships": []
}
],
"state": "published",
"chapterNumbersResetOnNewVolume": false,
"createdAt": "2019-10-12T20:49:23+00:00",
"updatedAt": "2025-02-01T22:40:38+00:00",
"version": 50,
"availableTranslatedLanguages": [ "es", "ru", "en" ],
"latestUploadedChapter": "b0950d10-7e2d-4278-98bd-52f3d92cc494"
},
"relationships": [
{
"id": "b77fe548-6f64-4380-8cca-faee8891a7d3",
"type": "author"
},
{
"id": "b77fe548-6f64-4380-8cca-faee8891a7d3",
"type": "artist"
},
{
"id": "a78a2332-99cf-42b3-8285-0eed22c41251",
"type": "cover_art",
"attributes": {
"description": "",
"volume": "14",
"fileName": "2f11791d-e3ff-4347-b4a8-b39aafc3b121.jpg",
"locale": "ja",
"createdAt": "2024-10-31T17:30:22+00:00",
"updatedAt": "2024-10-31T17:30:22+00:00",
"version": 1
}
}
]
},
{
"id": "a920060c-7e39-4ac1-980c-f0e605a40ae4",
"type": "manga",
"attributes": {
"title": { "en": "Gal Yome no Himitsu" },
"altTitles": [
{ "ja": "\u30ae\u30e3\u30eb\u5ac1\u306e\u79d8\u5bc6" },
{ "ja-ro": "Gyaru Yome no Himitsu" },
{ "en": "Secrets of the Gal Wife" },
{ "id": "Rahasia Istri Gal" },
{ "en": "My Gyaru Wife's Secret" }
],
"description": {
"en": "Fuyuki is a beautiful and cool gal! But there's a secret side of her that she only shows in front of her husband...?",
"ja": "\u51ac\u96ea\uff08\u3075\u3086\u304d\uff09\u306f\u7f8e\u4eba\u3067\u30af\u30fc\u30eb\u306a\u30ae\u30e3\u30eb\uff01\u3067\u3082\u300c\u65e6\u90a3\u300d\u306e\u524d\u3060\u3051\u898b\u305b\u308b\u79d8\u5bc6\u306e\u59ff\u304c\u3042\u3063\u3066\u2026\u2026\uff1fSNS\u7d2f\u8a0837\u4e07\u3044\u3044\u306d\uff01\u306e\u53ef\u611b\u3044\u300c\u30ae\u30e3\u30eb\u5ac1\u300d\u3068\u306e\u30e9\u30d6\u30b3\u30e1\u30c7\u30a3\uff01",
"es-la": "\u00a1Fuyuki es una hermosa y atractiva gal! \u00bfPero hay una parte de ella que solo le muestra a su esposo y mantiene en secreto del resto? Una linda comedia rom\u00e1ntica con una esposa que es una gal.",
"pt-br": "Fuyuki \u00e9 uma linda gyaru! Mas tem um lado secreto que ela s\u00f3 mostra para o seu marido? Uma com\u00e9dia rom\u00e2ntica fofa com uma esposa gyaru!"
},
"isLocked": false,
"links": {
"al": "169734",
"ap": "gyaru-yome-no-himitsu",
"bw": "series\/495151\/list",
"kt": "69730",
"mu": "sjl40n9",
"amz": "https:\/\/www.amazon.co.jp\/dp\/B0DMT6X4NC",
"cdj": "https:\/\/www.cdjapan.co.jp\/product\/NEOBK-3016982",
"ebj": "https:\/\/ebookjapan.yahoo.co.jp\/books\/857988",
"mal": "163267",
"raw": "https:\/\/ganma.jp\/galyome"
},
"originalLanguage": "ja",
"lastVolume": "",
"lastChapter": "",
"publicationDemographic": "seinen",
"status": "ongoing",
"year": 2023,
"contentRating": "suggestive",
"tags": [
{
"id": "423e2eae-a7a2-4a8b-ac03-a8351462d71d",
"type": "tag",
"attributes": {
"name": { "en": "Romance" },
"description": {},
"group": "genre",
"version": 1
},
"relationships": []
},
{
"id": "4d32cc48-9f00-4cca-9b5a-a839f0764984",
"type": "tag",
"attributes": {
"name": { "en": "Comedy" },
"description": {},
"group": "genre",
"version": 1
},
"relationships": []
},
{
"id": "92d6d951-ca5e-429c-ac78-451071cbf064",
"type": "tag",
"attributes": {
"name": { "en": "Office Workers" },
"description": {},
"group": "theme",
"version": 1
},
"relationships": []
},
{
"id": "e5301a23-ebd9-49dd-a0cb-2add944c7fe9",
"type": "tag",
"attributes": {
"name": { "en": "Slice of Life" },
"description": {},
"group": "genre",
"version": 1
},
"relationships": []
},
{
"id": "fad12b5e-68ba-460e-b933-9ae8318f5b65",
"type": "tag",
"attributes": {
"name": { "en": "Gyaru" },
"description": {},
"group": "theme",
"version": 1
},
"relationships": []
}
],
"state": "published",
"chapterNumbersResetOnNewVolume": false,
"createdAt": "2023-09-30T01:44:37+00:00",
"updatedAt": "2025-01-27T20:50:36+00:00",
"version": 24,
"availableTranslatedLanguages": [ "en", "pt-br", "th", "es-la", "id", "it", "vi", "ru" ],
"latestUploadedChapter": "8f5be93c-b5fa-43ee-8e29-41599a5d9bb5"
},
"relationships": [
{
"id": "5c66247f-85bb-4fac-8c08-c207f5ec53ce",
"type": "author"
},
{
"id": "5c66247f-85bb-4fac-8c08-c207f5ec53ce",
"type": "artist"
},
{
"id": "e8a74e9c-bd63-4424-a1d2-02a9d7286ab5",
"type": "cover_art",
"attributes": {
"description": "",
"volume": "2",
"fileName": "07d02b26-cbd0-4323-8774-9d83579863d5.jpg",
"locale": "ja",
"createdAt": "2025-02-18T09:27:22+00:00",
"updatedAt": "2025-02-18T09:27:22+00:00",
"version": 1
}
},
{
"id": "45220025-46fb-44c5-a975-a4754fe512a2",
"type": "creator"
}
]
},
{
"id": "cf7b7869-3d9a-4c4d-bd06-249eba113558",
"type": "manga",
"attributes": {
"title": { "en": "Boku to Gal ga Fufu ni Narumade" },
"altTitles": [
{ "ja": "\u50d5\u3068\u541b\u304c\u592b\u5a66\u306b\u306a\u308b\u307e\u3067" },
{ "ja-ro": "Boku to Gal ga Fuufu ni Naru made" },
{ "en": "Until the Gal and I Become a Married Couple" },
{ "ru": "\u041f\u043e\u043a\u0430 \u043c\u044b \u0441 \u0434\u0435\u0432\u0443\u0448\u043a\u043e\u0439 \u043d\u0435 \u0441\u0442\u0430\u043d\u0435\u043c \u0441\u0443\u043f\u0440\u0443\u0436\u0435\u0441\u043a\u043e\u0439 \u043f\u0430\u0440\u043e\u0439" }
],
"description": {
"en": "Saku Kanakura was a boy born into a poor household. He studied hard and managed to pass the exam to an excellent high school but, it soon turned out that his parents were in debt! When it was time to pay the debt off, a mysterious gal appeared and offered to pay off their debt. A big burden was almost off Kanakura's head, but the gal made a condition in return which stated that\u2014",
"id": "Saku Kanakura adalah anak laki-laki yang lahir dari keluarga miskin. Dia belajar dengan giat dan berhasil lulus ujian ke sekolah menengah yang bagus, tetapi ternyata orang tuanya terlilit hutang! Ketika tiba waktunya untuk melunasi hutang, seorang gadis misterius muncul dan menawarkan untuk melunasi hutang mereka. Sebuah beban besar hampir terlepas dari kepala Kanakura, tetapi gadis itu membuat syarat sebagai balasannya yang menyatakan bahwa\u2014",
"ja": "\u8ca7\u4e4f\u306a\u5bb6\u5ead\u306b\u80b2\u3063\u305f\u5c11\u5e74\u30fb\u795e\u9577\u5009\u98af\u7a7a\u3002\u731b\u52c9\u5f37\u306e\u7532\u6590\u3042\u3063\u3066\u898b\u4e8b\u5e0c\u671b\u3059\u308b\u9ad8\u6821\u3078\u5408\u683c\u3057\u305f\u5f7c\u3060\u3063\u305f\u304c\u3001\u4e21\u89aa\u306e\u501f\u91d1\u304c\u5224\u660e\uff01 \u501f\u91d1\u3092\u53d6\u308a\u7acb\u3066\u3089\u308c\u3066\u3044\u305f\u3068\u3053\u308d\u3001\u8b0e\u306e\u30ae\u30e3\u30eb\u304c\u73fe\u308c\u3066\u305d\u308c\u3089\u3092\u8fd4\u6e08\u3057\u3066\u304f\u308c\u308b\u3053\u3068\u306b\uff01 \u7aae\u5730\u3092\u8131\u3057\u305f\u795e\u9577\u5009\u3060\u3063\u305f\u304c\u3001\u30ae\u30e3\u30eb\u304c\u501f\u91d1\u3092\u80a9\u4ee3\u308f\u308a\u3059\u308b\u969b\u306b\u5f7c\u306b\u51fa\u3057\u305f\u6761\u4ef6\u306f\u2015\u2015\u3002"
},
"isLocked": false,
"links": {
"al": "159308",
"ap": "boku-to-gal-ga-fuufu-ni-naru-made",
"bw": "series\/431780\/list",
"kt": "boku-to-gal-ga-fuufu-ni-naru-made",
"mu": "tobfmjc",
"amz": "https:\/\/www.amazon.co.jp\/dp\/B0CHMJMTNY",
"ebj": "https:\/\/ebookjapan.yahoo.co.jp\/books\/781303\/",
"mal": "154486",
"raw": "https:\/\/comic-walker.com\/detail\/KC_001788_S"
},
"originalLanguage": "ja",
"lastVolume": "",
"lastChapter": "",
"publicationDemographic": "shounen",
"status": "ongoing",
"year": 2022,
"contentRating": "suggestive",
"tags": [
{
"id": "423e2eae-a7a2-4a8b-ac03-a8351462d71d",
"type": "tag",
"attributes": {
"name": { "en": "Romance" },
"description": {},
"group": "genre",
"version": 1
},
"relationships": []
},
{
"id": "4d32cc48-9f00-4cca-9b5a-a839f0764984",
"type": "tag",
"attributes": {
"name": { "en": "Comedy" },
"description": {},
"group": "genre",
"version": 1
},
"relationships": []
},
{
"id": "caaa44eb-cd40-4177-b930-79d3ef2afe87",
"type": "tag",
"attributes": {
"name": { "en": "School Life" },
"description": {},
"group": "theme",
"version": 1
},
"relationships": []
},
{
"id": "e5301a23-ebd9-49dd-a0cb-2add944c7fe9",
"type": "tag",
"attributes": {
"name": { "en": "Slice of Life" },
"description": {},
"group": "genre",
"version": 1
},
"relationships": []
},
{
"id": "fad12b5e-68ba-460e-b933-9ae8318f5b65",
"type": "tag",
"attributes": {
"name": { "en": "Gyaru" },
"description": {},
"group": "theme",
"version": 1
},
"relationships": []
}
],
"state": "published",
"chapterNumbersResetOnNewVolume": false,
"createdAt": "2022-12-25T17:05:32+00:00",
"updatedAt": "2024-11-02T18:34:30+00:00",
"version": 28,
"availableTranslatedLanguages": [ "ru", "en", "es-la", "pt-br", "id", "tr", "vi" ],
"latestUploadedChapter": "37170d3e-108c-4c5e-b287-ef2394d7a3e8"
},
"relationships": [
{
"id": "08ee3ef5-2878-46d4-9040-2aef23fabf74",
"type": "author"
},
{
"id": "08ee3ef5-2878-46d4-9040-2aef23fabf74",
"type": "artist"
},
{
"id": "4243e338-6306-4d6a-a6cc-b1fdcb30c7cb",
"type": "cover_art",
"attributes": {
"description": "",
"volume": "2",
"fileName": "5b978b74-b18f-4039-a972-fa51d15e5d12.jpg",
"locale": "ja",
"createdAt": "2024-09-21T10:34:15+00:00",
"updatedAt": "2024-09-21T10:34:15+00:00",
"version": 1
}
},
{
"id": "e7dac780-4b88-4e0d-ac14-aa3b3a7f08a6",
"type": "creator"
}
]
}
],
"limit": 5,
"offset": 0,
"total": 363
}

View File

@@ -1,4 +1,4 @@
using MangaReader.Core.HttpService; using MangaReader.Core.Http;
using MangaReader.Core.Sources.MangaDex.Api; using MangaReader.Core.Sources.MangaDex.Api;
using MangaReader.Tests.Utilities; using MangaReader.Tests.Utilities;
using NSubstitute; using NSubstitute;
@@ -22,6 +22,116 @@ public class MangaDexClientTests
MangaDexResponse? mangaDexResponse = await mangaDexClient.SearchMangaByTitleAsync("Some random text", CancellationToken.None); MangaDexResponse? mangaDexResponse = await mangaDexClient.SearchMangaByTitleAsync("Some random text", CancellationToken.None);
// Testing here // Testing here
mangaDexResponse.ShouldNotBeNull();
mangaDexResponse.Response.ShouldBe("collection");
mangaDexResponse.ShouldBeOfType<MangaDexCollectionResponse>();
MangaDexCollectionResponse mangaDexCollectionResponse = (mangaDexResponse as MangaDexCollectionResponse)!;
mangaDexCollectionResponse.Data.Count.ShouldBe(3);
mangaDexCollectionResponse.Data[0].ShouldBeOfType<MangaEntity>();
MangaEntity mangaEntity = (mangaDexCollectionResponse.Data[0] as MangaEntity)!;
mangaEntity.Attributes.ShouldNotBeNull();
mangaEntity.Attributes.Title.ShouldContainKey("en");
mangaEntity.Attributes.Title["en"].ShouldBe("Gals Cant Be Kind to Otaku!?");
mangaEntity.Attributes.Description.ShouldContainKey("en");
mangaEntity.Attributes.Description["en"].ShouldBe("Takuya Seo is an otaku who likes \"anime for girls\" and can't say he likes it out loud. One day, he hooks up with two gals from his class, Amane and Ijichi, but it seems that Amane is also an otaku...");
mangaEntity.Attributes.Tags.Count.ShouldBe(5);
mangaEntity.Attributes.Tags[0].Attributes.ShouldNotBeNull();
mangaEntity.Attributes.Tags[0].Attributes!.Name.ShouldContainKey("en");
mangaEntity.Attributes.Tags[0].Attributes!.Name["en"].ShouldBe("Romance");
mangaEntity.Attributes.Tags[1].Attributes.ShouldNotBeNull();
mangaEntity.Attributes.Tags[1].Attributes!.Name.ShouldContainKey("en");
mangaEntity.Attributes.Tags[1].Attributes!.Name["en"].ShouldBe("Comedy");
mangaEntity.Attributes.Tags[2].Attributes.ShouldNotBeNull();
mangaEntity.Attributes.Tags[2].Attributes!.Name.ShouldContainKey("en");
mangaEntity.Attributes.Tags[2].Attributes!.Name["en"].ShouldBe("School Life");
mangaEntity.Attributes.Tags[3].Attributes.ShouldNotBeNull();
mangaEntity.Attributes.Tags[3].Attributes!.Name.ShouldContainKey("en");
mangaEntity.Attributes.Tags[3].Attributes!.Name["en"].ShouldBe("Slice of Life");
mangaEntity.Attributes.Tags[4].Attributes.ShouldNotBeNull();
mangaEntity.Attributes.Tags[4].Attributes!.Name.ShouldContainKey("en");
mangaEntity.Attributes.Tags[4].Attributes!.Name["en"].ShouldBe("Gyaru");
mangaEntity.Relationships.Count.ShouldBe(4);
mangaEntity.Relationships[2].ShouldBeOfType<CoverArtEntity>();
CoverArtEntity coverArtEntity = (mangaEntity.Relationships[2] as CoverArtEntity)!;
coverArtEntity.Attributes.ShouldNotBeNull();
coverArtEntity.Attributes.FileName.ShouldBe("6b3073de-bb65-4723-8113-6068bf8c6eb4.jpg");
mangaEntity.Attributes.Year.ShouldBe(2021);
}
[Fact]
public async Task Search_Manga_2()
{
string searchResultJson = await ReadJsonResourceAsync("Manga-Search-Response-2.json");
IHttpService httpService = Substitute.For<IHttpService>();
httpService.GetStringAsync(Arg.Any<string>(), CancellationToken.None)
.Returns(Task.FromResult(searchResultJson));
MangaDexClient mangaDexClient = new(httpService);
MangaDexResponse? mangaDexResponse = await mangaDexClient.SearchMangaByTitleAsync("Some random text", CancellationToken.None);
// Testing here
mangaDexResponse.ShouldNotBeNull();
mangaDexResponse.Response.ShouldBe("collection");
mangaDexResponse.ShouldBeOfType<MangaDexCollectionResponse>();
MangaDexCollectionResponse mangaDexCollectionResponse = (mangaDexResponse as MangaDexCollectionResponse)!;
mangaDexCollectionResponse.Data.Count.ShouldBe(5);
mangaDexCollectionResponse.Data[3].ShouldBeOfType<MangaEntity>();
MangaEntity mangaEntity = (mangaDexCollectionResponse.Data[3] as MangaEntity)!;
mangaEntity.Attributes.ShouldNotBeNull();
mangaEntity.Attributes.Title.ShouldContainKey("en");
mangaEntity.Attributes.Title["en"].ShouldBe("Gal Yome no Himitsu");
mangaEntity.Attributes.Description.ShouldContainKey("en");
mangaEntity.Attributes.Description["en"].ShouldBe("Fuyuki is a beautiful and cool gal! But there's a secret side of her that she only shows in front of her husband...?");
mangaEntity.Attributes.Tags.Count.ShouldBe(5);
//mangaEntity.Attributes.Tags[0].Attributes.ShouldNotBeNull();
//mangaEntity.Attributes.Tags[0].Attributes!.Name.ShouldContainKey("en");
//mangaEntity.Attributes.Tags[0].Attributes!.Name["en"].ShouldBe("Romance");
//mangaEntity.Attributes.Tags[1].Attributes.ShouldNotBeNull();
//mangaEntity.Attributes.Tags[1].Attributes!.Name.ShouldContainKey("en");
//mangaEntity.Attributes.Tags[1].Attributes!.Name["en"].ShouldBe("Comedy");
//mangaEntity.Attributes.Tags[2].Attributes.ShouldNotBeNull();
//mangaEntity.Attributes.Tags[2].Attributes!.Name.ShouldContainKey("en");
//mangaEntity.Attributes.Tags[2].Attributes!.Name["en"].ShouldBe("School Life");
//mangaEntity.Attributes.Tags[3].Attributes.ShouldNotBeNull();
//mangaEntity.Attributes.Tags[3].Attributes!.Name.ShouldContainKey("en");
//mangaEntity.Attributes.Tags[3].Attributes!.Name["en"].ShouldBe("Slice of Life");
//mangaEntity.Attributes.Tags[4].Attributes.ShouldNotBeNull();
//mangaEntity.Attributes.Tags[4].Attributes!.Name.ShouldContainKey("en");
//mangaEntity.Attributes.Tags[4].Attributes!.Name["en"].ShouldBe("Gyaru");
mangaEntity.Relationships.Count.ShouldBe(4);
mangaEntity.Relationships[2].ShouldBeOfType<CoverArtEntity>();
CoverArtEntity coverArtEntity = (mangaEntity.Relationships[2] as CoverArtEntity)!;
coverArtEntity.Attributes.ShouldNotBeNull();
coverArtEntity.Attributes.FileName.ShouldBe("07d02b26-cbd0-4323-8774-9d83579863d5.jpg");
} }
[Fact] [Fact]
@@ -154,6 +264,35 @@ public class MangaDexClientTests
mangaDexChapterResponse.Chapter.DataSaver[12].ShouldBe("13-b886b4ed986a473478e3db7bb18fe2faea567a1ad5e520408967410dcf8838d1.jpg"); mangaDexChapterResponse.Chapter.DataSaver[12].ShouldBe("13-b886b4ed986a473478e3db7bb18fe2faea567a1ad5e520408967410dcf8838d1.jpg");
} }
[Fact]
public async Task Get_Cover_Art()
{
string searchResultJson = await ReadJsonResourceAsync("Manga-Cover-Art-Response.json");
IHttpService httpService = Substitute.For<IHttpService>();
httpService.GetStringAsync(Arg.Any<string>(), CancellationToken.None)
.Returns(Task.FromResult(searchResultJson));
MangaDexClient mangaDexClient = new(httpService);
MangaDexResponse? mangaDexResponse = await mangaDexClient.GetCoverArtAsync(Guid.NewGuid(), CancellationToken.None);
mangaDexResponse.ShouldNotBeNull();
mangaDexResponse.Response.ShouldBe("collection");
mangaDexResponse.ShouldBeOfType<MangaDexCollectionResponse>();
MangaDexCollectionResponse mangaDexEntityResponse = (mangaDexResponse as MangaDexCollectionResponse)!;
List<CoverArtEntity> coverArtEntities = [.. mangaDexEntityResponse.Data.Where(entity => entity is CoverArtEntity).Cast<CoverArtEntity>()];
coverArtEntities.Count.ShouldBe(9);
coverArtEntities[0].Attributes.ShouldNotBeNull();
coverArtEntities[0].Attributes!.FileName.ShouldBe("2569ffd8-4ba1-4030-8d08-b7a21333a7a6.jpg");
coverArtEntities[8].Attributes.ShouldNotBeNull();
coverArtEntities[8].Attributes!.FileName.ShouldBe("6b3073de-bb65-4723-8113-6068bf8c6eb4.jpg");
}
private static async Task<string> ReadJsonResourceAsync(string resourceName) private static async Task<string> ReadJsonResourceAsync(string resourceName)
{ {
return await ResourceHelper.ReadJsonResourceAsync($"MangaReader.Tests.Sources.MangaDex.Api.{resourceName}"); return await ResourceHelper.ReadJsonResourceAsync($"MangaReader.Tests.Sources.MangaDex.Api.{resourceName}");

View File

@@ -1,4 +1,5 @@
using MangaReader.Core.Metadata; using MangaReader.Core.Common;
using MangaReader.Core.Metadata;
using MangaReader.Core.Sources.MangaDex.Api; using MangaReader.Core.Sources.MangaDex.Api;
using MangaReader.Core.Sources.MangaDex.Metadata; using MangaReader.Core.Sources.MangaDex.Metadata;
using NSubstitute; using NSubstitute;
@@ -11,7 +12,7 @@ public class MangaDexMetadataTests
[Fact] [Fact]
public async Task Get_Manga() public async Task Get_Manga()
{ {
MangaDexEntityResponse entityResponse = new() MangaDexEntityResponse mangaEntityResponse = new()
{ {
Result = "ok", Result = "ok",
Response = "entity", Response = "entity",
@@ -141,7 +142,7 @@ public class MangaDexMetadataTests
} }
}; };
MangaDexCollectionResponse collectionResponse = new() MangaDexCollectionResponse feedCollectionResponse = new()
{ {
Result = "ok", Result = "ok",
Response = "collection", Response = "collection",
@@ -186,38 +187,68 @@ public class MangaDexMetadataTests
] ]
}; };
MangaDexCollectionResponse coverArtCollectionResponse = new()
{
Result = "ok",
Response = "collection",
Data =
[
new CoverArtEntity()
{
Id = new Guid("0045f243-5625-4f0f-9066-a6c3a95d84d3"),
Type = "cover_art",
Attributes = new()
{
FileName = "2569ffd8-4ba1-4030-8d08-b7a21333a7a6.jpg"
}
},
new CoverArtEntity()
{
Id = new Guid("a81ad8d3-ba2c-4003-9126-fbd9d28e3732"),
Type = "cover_art",
Attributes = new()
{
FileName = "d2314e9b-4287-4e65-8045-b713d97c0b28.jpg"
}
}
]
};
IMangaDexClient mangaDexClient = Substitute.For<IMangaDexClient>(); IMangaDexClient mangaDexClient = Substitute.For<IMangaDexClient>();
mangaDexClient.GetMangaAsync(Arg.Any<Guid>(), CancellationToken.None) mangaDexClient.GetMangaAsync(Arg.Any<Guid>(), CancellationToken.None)
.Returns(entityResponse); .Returns(mangaEntityResponse);
mangaDexClient.GetFeedAsync(Arg.Any<Guid>(), CancellationToken.None) mangaDexClient.GetFeedAsync(Arg.Any<Guid>(), CancellationToken.None)
.Returns(collectionResponse); .Returns(feedCollectionResponse);
mangaDexClient.GetCoverArtAsync(Arg.Any<Guid>(), CancellationToken.None)
.Returns(coverArtCollectionResponse);
MangaDexMetadataProvider metadataProvider = new(mangaDexClient); MangaDexMetadataProvider metadataProvider = new(mangaDexClient);
SourceManga? sourceManga = await metadataProvider.GetMangaAsync("https://mangadex.org/title/ee96e2b7-9af2-4864-9656-649f4d3b6fec/gals-can-t-be-kind-to-otaku", CancellationToken.None); SourceManga? sourceManga = await metadataProvider.GetMangaAsync("https://mangadex.org/title/ee96e2b7-9af2-4864-9656-649f4d3b6fec/gals-can-t-be-kind-to-otaku", CancellationToken.None);
sourceManga.ShouldNotBeNull(); sourceManga.ShouldNotBeNull();
sourceManga.Title.ShouldBe("Gals Cant Be Kind to Otaku!?"); sourceManga.Title.Name.ShouldBe("Gals Cant Be Kind to Otaku!?");
sourceManga.AlternateTitles.Count.ShouldBe(5); sourceManga.AlternateTitles.Length.ShouldBe(5);
sourceManga.AlternateTitles[0].Title.ShouldBe("オタクに優しいギャルはいない!?"); sourceManga.AlternateTitles[0].Name.ShouldBe("オタクに優しいギャルはいない!?");
sourceManga.AlternateTitles[0].Language.ShouldBe(SourceMangaLanguage.Japanese); sourceManga.AlternateTitles[0].Language.ShouldBe(Language.Japanese);
sourceManga.AlternateTitles[1].Title.ShouldBe("Otaku ni Yasashii Gal wa Inai!?"); sourceManga.AlternateTitles[1].Name.ShouldBe("Otaku ni Yasashii Gal wa Inai!?");
sourceManga.AlternateTitles[1].Language.ShouldBe(SourceMangaLanguage.Romanji); sourceManga.AlternateTitles[1].Language.ShouldBe(Language.Romaji);
sourceManga.AlternateTitles[2].Title.ShouldBe("Otaku ni Yasashii Gyaru ha Inai!?"); sourceManga.AlternateTitles[2].Name.ShouldBe("Otaku ni Yasashii Gyaru ha Inai!?");
sourceManga.AlternateTitles[2].Language.ShouldBe(SourceMangaLanguage.Romanji); sourceManga.AlternateTitles[2].Language.ShouldBe(Language.Romaji);
sourceManga.AlternateTitles[3].Title.ShouldBe("Gal Can't Be Kind to Otaku!?"); sourceManga.AlternateTitles[3].Name.ShouldBe("Gal Can't Be Kind to Otaku!?");
sourceManga.AlternateTitles[3].Language.ShouldBe(SourceMangaLanguage.English); sourceManga.AlternateTitles[3].Language.ShouldBe(Language.English);
sourceManga.AlternateTitles[4].Title.ShouldBe("Gals Can't Be Kind To A Geek!?"); sourceManga.AlternateTitles[4].Name.ShouldBe("Gals Can't Be Kind To A Geek!?");
sourceManga.AlternateTitles[4].Language.ShouldBe(SourceMangaLanguage.English); sourceManga.AlternateTitles[4].Language.ShouldBe(Language.English);
sourceManga.Genres.Count.ShouldBe(5); sourceManga.Genres.Length.ShouldBe(5);
sourceManga.Genres[0].ShouldBe("Romance"); sourceManga.Genres[0].ShouldBe("Romance");
sourceManga.Genres[1].ShouldBe("Comedy"); sourceManga.Genres[1].ShouldBe("Comedy");
sourceManga.Genres[2].ShouldBe("School Life"); sourceManga.Genres[2].ShouldBe("School Life");
@@ -227,12 +258,12 @@ public class MangaDexMetadataTests
sourceManga.Contributors.Length.ShouldBe(2); sourceManga.Contributors.Length.ShouldBe(2);
sourceManga.Contributors[0].Name.ShouldBe("Norishiro-chan"); sourceManga.Contributors[0].Name.ShouldBe("Norishiro-chan");
sourceManga.Contributors[0].Role.ShouldBe(SourceMangaContributorRole.Author); sourceManga.Contributors[0].Role.ShouldBe(ContributorRole.Author);
sourceManga.Contributors[1].Name.ShouldBe("Sakana Uozimi"); sourceManga.Contributors[1].Name.ShouldBe("Sakana Uozimi");
sourceManga.Contributors[1].Role.ShouldBe(SourceMangaContributorRole.Artist); sourceManga.Contributors[1].Role.ShouldBe(ContributorRole.Artist);
sourceManga.Chapters.Count.ShouldBe(3); sourceManga.Chapters.Length.ShouldBe(3);
sourceManga.Chapters[0].Volume.ShouldBeNull(); sourceManga.Chapters[0].Volume.ShouldBeNull();
sourceManga.Chapters[0].Number.ShouldBe(69); sourceManga.Chapters[0].Number.ShouldBe(69);
@@ -248,5 +279,9 @@ public class MangaDexMetadataTests
sourceManga.Chapters[2].Number.ShouldBe(8.1f); sourceManga.Chapters[2].Number.ShouldBe(8.1f);
sourceManga.Chapters[2].Title.ShouldBe("Otaku & Gyaru & Protegee"); sourceManga.Chapters[2].Title.ShouldBe("Otaku & Gyaru & Protegee");
sourceManga.Chapters[2].Url.ShouldBe("https://mangadex.org/chapter/b5206e9b-6e3e-4ef0-aa62-381fd0ff75a5"); sourceManga.Chapters[2].Url.ShouldBe("https://mangadex.org/chapter/b5206e9b-6e3e-4ef0-aa62-381fd0ff75a5");
sourceManga.CoverArtUrls.Length.ShouldBe(2);
sourceManga.CoverArtUrls[0].ShouldBe("https://mangadex.org/covers/ee96e2b7-9af2-4864-9656-649f4d3b6fec/2569ffd8-4ba1-4030-8d08-b7a21333a7a6.jpg");
sourceManga.CoverArtUrls[1].ShouldBe("https://mangadex.org/covers/ee96e2b7-9af2-4864-9656-649f4d3b6fec/d2314e9b-4287-4e65-8045-b713d97c0b28.jpg");
} }
} }

View File

@@ -1,47 +1,36 @@
using HtmlAgilityPack; using MangaReader.Core.Common;
using MangaReader.Core.Http;
using MangaReader.Core.Metadata; using MangaReader.Core.Metadata;
using MangaReader.Core.Sources.MangaNato.Metadata; using MangaReader.Core.Sources.MangaNato.Metadata;
using MangaReader.Tests.Utilities;
using NSubstitute;
using Shouldly; using Shouldly;
using System.Data; using System.Data;
using System.Xml.Linq;
namespace MangaReader.Tests.WebCrawlers; namespace MangaReader.Tests.Sources.MangaNato.Metadata;
public class UnitTest1 public class MangaNatoMetadataTests
{ {
class TestMangaNatoWebCrawler : MangaNatoWebCrawler
{
protected override Task<HtmlDocument> GetHtmlDocumentAsync(string url, CancellationToken cancellationToken)
{
HtmlWeb web = new()
{
UsingCacheIfExists = false
};
return Task.FromResult(web.Load(url));
}
}
private readonly string samplesPath;
private readonly string mangaNatoSampleFilePath;
public UnitTest1()
{
samplesPath = Path.Combine(AppContext.BaseDirectory, "WebCrawlers", "Samples");
mangaNatoSampleFilePath = Path.Combine(samplesPath, "MangaNato - Please Go Home, Akutsu-San!.htm");
}
[Fact] [Fact]
public async Task Get_Manga() public async Task Get_Manga()
{ {
var webCrawler = new TestMangaNatoWebCrawler(); string mangaHtml = await ReadJsonResourceAsync("Manga-Response.html");
var manga = await webCrawler.GetMangaAsync(mangaNatoSampleFilePath, CancellationToken.None);
IHttpService httpService = Substitute.For<IHttpService>();
httpService.GetStringAsync(Arg.Any<string>(), CancellationToken.None)
.Returns(Task.FromResult(mangaHtml));
HtmlLoader htmlLoader = new(httpService);
MangaNatoWebCrawler webCrawler = new(htmlLoader);
SourceManga? manga = await webCrawler.GetMangaAsync("/test-url", CancellationToken.None);
manga.ShouldNotBeNull(); manga.ShouldNotBeNull();
manga.Title.ShouldBe("Please Go Home, Akutsu-San!"); manga.Title.Name.ShouldBe("Please Go Home, Akutsu-San!");
manga.AlternateTitles.Select(x => x.Title).ShouldBe([ manga.AlternateTitles.Select(x => x.Name).ShouldBe([
"Kaette kudasai! Akutsu-san", "Kaette kudasai! Akutsu-san",
"Yankee Musume", "Yankee Musume",
"ヤンキー娘", "ヤンキー娘",
@@ -49,7 +38,7 @@ public class UnitTest1
SourceMangaContributor[] expectedContributors = SourceMangaContributor[] expectedContributors =
[ [
new() { Name = "Nagaoka Taichi", Role = SourceMangaContributorRole.Author } new() { Name = "Nagaoka Taichi", Role = ContributorRole.Author }
]; ];
manga.Contributors.ShouldBeEquivalentTo(expectedContributors); manga.Contributors.ShouldBeEquivalentTo(expectedContributors);
@@ -61,11 +50,12 @@ public class UnitTest1
manga.RatingPercent.ShouldBe(97); manga.RatingPercent.ShouldBe(97);
manga.Votes.ShouldBe(15979); manga.Votes.ShouldBe(15979);
manga.Descriptions.Length.ShouldBe(1);
//manga.Description.ShouldStartWith("Ooyama-kun normally doesnt get involved with Akutsu-san, a delinquent girl in his class"); //manga.Description.ShouldStartWith("Ooyama-kun normally doesnt get involved with Akutsu-san, a delinquent girl in his class");
manga.Description.ShouldStartWith("Ooyama-kun normally doesnt get involved with Akutsu-san, a delinquent girl in his class"); manga.Descriptions[0].Name.ShouldStartWith("Ooyama-kun normally doesnt get involved with Akutsu-san, a delinquent girl in his class");
manga.Description.ShouldEndWith("Artist's Pixiv: https://www.pixiv.net/member.php?id=133935"); manga.Descriptions[0].Name.ShouldEndWith("Artist's Pixiv: https://www.pixiv.net/member.php?id=133935");
manga.Chapters.Count.ShouldBe(236); manga.Chapters.Length.ShouldBe(236);
manga.Chapters[0].Url.ShouldBe("https://chapmanganato.to/manga-hf984788/chapter-186"); manga.Chapters[0].Url.ShouldBe("https://chapmanganato.to/manga-hf984788/chapter-186");
manga.Chapters[0].Number.ShouldBe(186); manga.Chapters[0].Number.ShouldBe(186);
@@ -79,4 +69,9 @@ public class UnitTest1
manga.Chapters[235].Views.ShouldBe(232_200); manga.Chapters[235].Views.ShouldBe(232_200);
manga.Chapters[235].UploadDate.ShouldBe(new DateTime(2021, 8, 24, 1, 8, 0)); manga.Chapters[235].UploadDate.ShouldBe(new DateTime(2021, 8, 24, 1, 8, 0));
} }
private static async Task<string> ReadJsonResourceAsync(string resourceName)
{
return await ResourceHelper.ReadJsonResourceAsync($"MangaReader.Tests.Sources.MangaNato.Metadata.{resourceName}");
}
} }

View File

@@ -1,4 +1,4 @@
using MangaReader.Core.HttpService; using MangaReader.Core.Http;
using MangaReader.Core.Sources.NatoManga.Api; using MangaReader.Core.Sources.NatoManga.Api;
using MangaReader.Tests.Utilities; using MangaReader.Tests.Utilities;
using NSubstitute; using NSubstitute;
@@ -34,6 +34,9 @@ public class NatoMangaClientTests
httpService.GetStringAsync(Arg.Any<string>(), CancellationToken.None) httpService.GetStringAsync(Arg.Any<string>(), CancellationToken.None)
.Returns(Task.FromResult(searchResultJson)); .Returns(Task.FromResult(searchResultJson));
httpService.GetStringAsync(Arg.Any<string>(), Arg.Any<IDictionary<string,string>>(), CancellationToken.None)
.Returns(Task.FromResult(searchResultJson));
NatoMangaClient natoMangaClient = new(httpService); NatoMangaClient natoMangaClient = new(httpService);
NatoMangaSearchResult[] searchResults = await natoMangaClient.SearchAsync("Gal Can't Be Kind", CancellationToken.None); NatoMangaSearchResult[] searchResults = await natoMangaClient.SearchAsync("Gal Can't Be Kind", CancellationToken.None);

View File

@@ -1,35 +1,32 @@
using HtmlAgilityPack; using MangaReader.Core.Http;
using MangaReader.Core.Metadata;
using MangaReader.Core.Sources.NatoManga.Metadata; using MangaReader.Core.Sources.NatoManga.Metadata;
using MangaReader.Tests.Utilities;
using NSubstitute;
using Shouldly; using Shouldly;
namespace MangaReader.Tests.WebCrawlers.NatoManga; namespace MangaReader.Tests.Sources.NatoManga.Metadata;
public class NatoMangaWebCrawlerTests public class NatoMangaWebCrawlerTests
{ {
class TestNatoMangaWebCrawler : NatoMangaWebCrawler
{
protected override Task<HtmlDocument> GetHtmlDocumentAsync(string url, CancellationToken cancellationToken)
{
HtmlWeb web = new()
{
UsingCacheIfExists = false
};
return Task.FromResult(web.Load(url));
}
}
[Fact] [Fact]
public async Task Get_Manga() public async Task Get_Manga()
{ {
string sampleFilePath = Path.Combine(AppContext.BaseDirectory, "WebCrawlers", "NatoManga", "SampleMangaPage.html"); string mangaHtml = await ReadJsonResourceAsync("Manga-Response.html");
var webCrawler = new TestNatoMangaWebCrawler(); IHttpService httpService = Substitute.For<IHttpService>();
var manga = await webCrawler.GetMangaAsync(sampleFilePath, CancellationToken.None);
httpService.GetStringAsync(Arg.Any<string>(), CancellationToken.None)
.Returns(Task.FromResult(mangaHtml));
HtmlLoader htmlLoader = new(httpService);
NatoMangaWebCrawler webCrawler = new(htmlLoader);
SourceManga? manga = await webCrawler.GetMangaAsync("/test-url", CancellationToken.None);
manga.ShouldNotBeNull(); manga.ShouldNotBeNull();
manga.Title.ShouldBe("Gal Cant Be Kind to Otaku!?"); manga.Title.Name.ShouldBe("Gal Cant Be Kind to Otaku!?");
//manga.AlternateTitles.ShouldBe([ //manga.AlternateTitles.ShouldBe([
// "Kaette kudasai! Akutsu-san", // "Kaette kudasai! Akutsu-san",
@@ -49,7 +46,7 @@ public class NatoMangaWebCrawlerTests
//manga.Description.ShouldStartWith("Ooyama-kun normally doesnt get involved with Akutsu-san, a delinquent girl in his class"); //manga.Description.ShouldStartWith("Ooyama-kun normally doesnt get involved with Akutsu-san, a delinquent girl in his class");
//manga.Description.ShouldEndWith("Artist's Pixiv: https://www.pixiv.net/member.php?id=133935"); //manga.Description.ShouldEndWith("Artist's Pixiv: https://www.pixiv.net/member.php?id=133935");
manga.Chapters.Count.ShouldBe(83); manga.Chapters.Length.ShouldBe(83);
manga.Chapters[0].Url.ShouldBe("https://www.natomanga.com/manga/gal-cant-be-kind-to-otaku/chapter-69"); manga.Chapters[0].Url.ShouldBe("https://www.natomanga.com/manga/gal-cant-be-kind-to-otaku/chapter-69");
manga.Chapters[0].Number.ShouldBe(69); manga.Chapters[0].Number.ShouldBe(69);
@@ -63,4 +60,9 @@ public class NatoMangaWebCrawlerTests
//manga.Chapters[235].Views.ShouldBe(232_200); //manga.Chapters[235].Views.ShouldBe(232_200);
//manga.Chapters[235].UploadDate.ShouldBe(new DateTime(2021, 8, 24, 1, 8, 0)); //manga.Chapters[235].UploadDate.ShouldBe(new DateTime(2021, 8, 24, 1, 8, 0));
} }
private static async Task<string> ReadJsonResourceAsync(string resourceName)
{
return await ResourceHelper.ReadJsonResourceAsync($"MangaReader.Tests.Sources.NatoManga.Metadata.{resourceName}");
}
} }

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,35 @@
using MangaReader.Core.Http;
using MangaReader.Core.Sources.NatoManga.Pages;
using MangaReader.Tests.Utilities;
using NSubstitute;
using Shouldly;
namespace MangaReader.Tests.Sources.NatoManga.Pages;
public class NatoMangaPageTests
{
[Fact]
public async Task Get_Pages()
{
string mangaHtml = await ReadJsonResourceAsync("Manga-Chapter-Response.html");
IHttpService httpService = Substitute.For<IHttpService>();
httpService.GetStringAsync(Arg.Any<string>(), CancellationToken.None)
.Returns(Task.FromResult(mangaHtml));
HtmlLoader htmlLoader = new(httpService);
NatoMangaPageProvider pageProvider = new(htmlLoader);
IReadOnlyList<string> pageImageUrls = await pageProvider.GetPageImageUrlsAsync("/test-url", CancellationToken.None);
pageImageUrls.Count.ShouldBe(13);
pageImageUrls[0].ShouldBe("https://img-r1.2xstorage.com/gal-cant-be-kind-to-otaku/69/0.webp");
pageImageUrls[12].ShouldBe("https://img-r1.2xstorage.com/gal-cant-be-kind-to-otaku/69/12.webp");
}
private static async Task<string> ReadJsonResourceAsync(string resourceName)
{
return await ResourceHelper.ReadJsonResourceAsync($"MangaReader.Tests.Sources.NatoManga.Pages.{resourceName}");
}
}

View File

@@ -0,0 +1,38 @@
using MangaReader.Core.Data;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
namespace MangaReader.Tests.Utilities;
public class TestDbContextFactory : IDisposable
{
private readonly SqliteConnection _connection;
private readonly DbContextOptions<MangaContext> _options;
public TestDbContextFactory()
{
_connection = new SqliteConnection("DataSource=:memory:");
_connection.Open();
_options = new DbContextOptionsBuilder<MangaContext>()
.UseSqlite(_connection)
.EnableSensitiveDataLogging() // Optional: helps with debugging
.Options;
using MangaContext context = new(_options);
context.Database.EnsureCreated();
}
public MangaContext CreateContext()
{
return new MangaContext(_options);
}
public void Dispose()
{
_connection.Close();
_connection.Dispose();
GC.SuppressFinalize(this);
}
}

View File

@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<Application
x:Class="MangaReader.WinUI.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="using:MangaReader.WinUI">
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<XamlControlsResources xmlns="using:Microsoft.UI.Xaml.Controls" />
<!-- Other merged dictionaries here -->
<ResourceDictionary Source="/Resources/Converters.xaml"/>
<ResourceDictionary Source="/Resources/Fonts.xaml"/>
<ResourceDictionary Source="/Resources/Styles.xaml"/>
<ResourceDictionary Source="/Resources/ViewModels.xaml"/>
</ResourceDictionary.MergedDictionaries>
<!-- Other app resources here -->
</ResourceDictionary>
</Application.Resources>
</Application>

View File

@@ -0,0 +1,49 @@
using MangaReader.Core.Data;
using MangaReader.WinUI.ViewModels;
using MangaReader.WinUI.Views;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.UI.Xaml;
using System;
using System.IO;
namespace MangaReader.WinUI;
public partial class App : Application
{
private Window? _window;
public static IServiceProvider ServiceProvider { get; private set; }
static App()
{
ServiceCollection services = new();
services.AddSingleton<MainWindow>();
services.AddSingleton<MainViewModel>();
services.AddSingleton<SearchViewModel>();
services.AddSingleton<LibraryViewModel>();
services.AddMangaReader();
ServiceProvider = services.BuildServiceProvider();
// Ensure the database is created
using var scope = ServiceProvider.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<MangaContext>();
dbContext.Database.EnsureCreated();
dbContext.Database.Migrate();
}
public App()
{
InitializeComponent();
}
protected override void OnLaunched(LaunchActivatedEventArgs args)
{
_window = ServiceProvider.GetRequiredService<MainWindow>();
_window.Activate();
}
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 432 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 637 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 283 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 456 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@@ -0,0 +1,20 @@
using Microsoft.UI.Xaml.Data;
using System;
namespace MangaReader.WinUI.Converters;
public partial class UppercaseConverter : IValueConverter
{
public object? Convert(object value, Type targetType, object parameter, string language)
{
if (value == null)
return null;
return value?.ToString()?.ToUpperInvariant();
}
public object ConvertBack(object value, Type targetType, object parameter, string language)
{
throw new NotImplementedException();
}
}

View File

@@ -0,0 +1,34 @@
<?xml version="1.0" encoding="utf-8"?>
<Window
x:Class="MangaReader.WinUI.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="using:MangaReader.WinUI"
xmlns:views="using:MangaReader.WinUI.Views"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d"
Title="MangaReader.WinUI">
<Window.SystemBackdrop>
<MicaBackdrop />
</Window.SystemBackdrop>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"></RowDefinition>
<RowDefinition Height="*"></RowDefinition>
</Grid.RowDefinitions>
<!-- Title Bar -->
<Grid x:Name="AppTitleBar" Grid.Row="0" VerticalAlignment="Center" Padding="10">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"></ColumnDefinition>
<ColumnDefinition Width="*"></ColumnDefinition>
</Grid.ColumnDefinitions>
<Image Grid.Column="0" x:Name="TitleBarIcon" Source="ms-appx:///Assets/Images/MangaReader.png" Width="20" Height="20" Margin="0 0 10 0" />
<TextBlock Grid.Column="1" x:Name="AppTitle" Text="{x:Bind Title, Mode=OneWay}" Style="{StaticResource CaptionTextBlockStyle}" VerticalAlignment="Center" />
</Grid>
<!-- Main View -->
<views:MainView Grid.Row="1"></views:MainView>
</Grid>
</Window>

View File

@@ -0,0 +1,17 @@
using Microsoft.UI.Xaml;
namespace MangaReader.WinUI;
public sealed partial class MainWindow : Window
{
private const string ApplicationTitle = "Manga Reader";
public MainWindow()
{
InitializeComponent();
Title = ApplicationTitle;
ExtendsContentIntoTitleBar = true; // enable custom titlebar
SetTitleBar(AppTitleBar); // set user ui element as titlebar
}
}

View File

@@ -0,0 +1,103 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net9.0-windows10.0.19041.0</TargetFramework>
<TargetPlatformMinVersion>10.0.17763.0</TargetPlatformMinVersion>
<RootNamespace>MangaReader.WinUI</RootNamespace>
<ApplicationManifest>app.manifest</ApplicationManifest>
<Platforms>x86;x64;ARM64</Platforms>
<RuntimeIdentifiers>win-x86;win-x64;win-arm64</RuntimeIdentifiers>
<PublishProfile>win-$(Platform).pubxml</PublishProfile>
<UseWinUI>true</UseWinUI>
<EnableMsixTooling>true</EnableMsixTooling>
<Nullable>enable</Nullable>
<WindowsPackageType>None</WindowsPackageType>
<WindowsAppSDKSelfContained>true</WindowsAppSDKSelfContained>
</PropertyGroup>
<ItemGroup>
<None Remove="Assets\Fonts\Poppins-Medium.otf" />
<None Remove="Assets\Fonts\Poppins-Regular.otf" />
<None Remove="Assets\Fonts\Poppins-SemiBold.otf" />
<None Remove="Assets\Images\MangaReader.png" />
<None Remove="Resources\ViewModels.xaml" />
</ItemGroup>
<ItemGroup>
<Content Include="Assets\SplashScreen.scale-200.png" />
<Content Include="Assets\LockScreenLogo.scale-200.png" />
<Content Include="Assets\Square150x150Logo.scale-200.png" />
<Content Include="Assets\Square44x44Logo.scale-200.png" />
<Content Include="Assets\Square44x44Logo.targetsize-24_altform-unplated.png" />
<Content Include="Assets\StoreLogo.png" />
<Content Include="Assets\Wide310x150Logo.scale-200.png" />
<Content Include="MangaReader.ico" />
</ItemGroup>
<ItemGroup>
<Manifest Include="$(ApplicationManifest)" />
</ItemGroup>
<!--
Defining the "Msix" ProjectCapability here allows the Single-project MSIX Packaging
Tools extension to be activated for this project even if the Windows App SDK Nuget
package has not yet been restored.
-->
<ItemGroup Condition="'$(DisableMsixProjectCapabilityAddedByProject)'!='true' and '$(EnableMsixTooling)'=='true'">
<ProjectCapability Include="Msix" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.0" />
<PackageReference Include="CommunityToolkit.WinUI.Controls.Primitives" Version="8.2.250402" />
<PackageReference Include="CommunityToolkit.WinUI.Media" Version="8.2.250402" />
<PackageReference Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.26100.6584" />
<PackageReference Include="Microsoft.WindowsAppSDK" Version="1.8.251003001" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.11" />
<PackageReference Include="SkiaSharp" Version="3.119.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\MangaReader.Core\MangaReader.Core.csproj" />
</ItemGroup>
<ItemGroup>
<Page Update="Resources\Converters.xaml">
<SubType>Designer</SubType>
</Page>
<Page Update="Resources\Styles.xaml">
<SubType>Designer</SubType>
</Page>
<Page Update="Resources\Fonts.xaml">
<SubType>Designer</SubType>
</Page>
<Page Update="Views\MainView.xaml">
<SubType>Designer</SubType>
</Page>
<Page Update="Views\LibraryView.xaml">
<SubType>Designer</SubType>
</Page>
<Page Update="Views\SearchView.xaml">
<Generator>MSBuild:Compile</Generator>
</Page>
</ItemGroup>
<ItemGroup>
<Page Update="Resources\ViewModels.xaml">
<Generator>MSBuild:Compile</Generator>
</Page>
</ItemGroup>
<!--
Defining the "HasPackageAndPublishMenuAddedByProject" property here allows the Solution
Explorer "Package and Publish" context menu entry to be enabled for this project even if
the Windows App SDK Nuget package has not yet been restored.
-->
<PropertyGroup Condition="'$(DisableHasPackageAndPublishMenuAddedByProject)'!='true' and '$(EnableMsixTooling)'=='true'">
<HasPackageAndPublishMenu>true</HasPackageAndPublishMenu>
</PropertyGroup>
<!-- Publish Properties -->
<PropertyGroup>
<PublishReadyToRun Condition="'$(Configuration)' == 'Debug'">False</PublishReadyToRun>
<PublishReadyToRun Condition="'$(Configuration)' != 'Debug'">True</PublishReadyToRun>
<PublishTrimmed Condition="'$(Configuration)' == 'Debug'">False</PublishTrimmed>
<PublishTrimmed Condition="'$(Configuration)' != 'Debug'">True</PublishTrimmed>
<ApplicationIcon>MangaReader.ico</ApplicationIcon>
</PropertyGroup>
</Project>

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

View File

@@ -0,0 +1,51 @@
<?xml version="1.0" encoding="utf-8"?>
<Package
xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10"
xmlns:mp="http://schemas.microsoft.com/appx/2014/phone/manifest"
xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10"
xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities"
IgnorableNamespaces="uap rescap">
<Identity
Name="cdba7326-1a2b-45f0-a233-463a21c91c91"
Publisher="CN=Brian"
Version="1.0.0.0" />
<mp:PhoneIdentity PhoneProductId="cdba7326-1a2b-45f0-a233-463a21c91c91" PhonePublisherId="00000000-0000-0000-0000-000000000000"/>
<Properties>
<DisplayName>MangaReader.WinUI</DisplayName>
<PublisherDisplayName>Brian</PublisherDisplayName>
<Logo>Assets\StoreLogo.png</Logo>
</Properties>
<Dependencies>
<TargetDeviceFamily Name="Windows.Universal" MinVersion="10.0.17763.0" MaxVersionTested="10.0.19041.0" />
<TargetDeviceFamily Name="Windows.Desktop" MinVersion="10.0.17763.0" MaxVersionTested="10.0.19041.0" />
</Dependencies>
<Resources>
<Resource Language="x-generate"/>
</Resources>
<Applications>
<Application Id="App"
Executable="$targetnametoken$.exe"
EntryPoint="$targetentrypoint$">
<uap:VisualElements
DisplayName="MangaReader.WinUI"
Description="MangaReader.WinUI"
BackgroundColor="transparent"
Square150x150Logo="Assets\Square150x150Logo.png"
Square44x44Logo="Assets\Square44x44Logo.png">
<uap:DefaultTile Wide310x150Logo="Assets\Wide310x150Logo.png" />
<uap:SplashScreen Image="Assets\SplashScreen.png" />
</uap:VisualElements>
</Application>
</Applications>
<Capabilities>
<rescap:Capability Name="runFullTrust" />
</Capabilities>
</Package>

View File

@@ -0,0 +1,10 @@
{
"profiles": {
"MangaReader.WinUI (Package)": {
"commandName": "MsixPackage"
},
"MangaReader.WinUI (Unpackaged)": {
"commandName": "Project"
}
}
}

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<ResourceDictionary
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:converters="using:MangaReader.WinUI.Converters">
<converters:UppercaseConverter x:Key="UppercaseConverter" />
</ResourceDictionary>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<ResourceDictionary
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<FontFamily x:Key="PoppinsRegular">ms-appx:///Assets/Fonts/Poppins-Regular.otf#Poppins Regular</FontFamily>
<FontFamily x:Key="PoppinsMedium">ms-appx:///Assets/Fonts/Poppins-Medium.otf#Poppins Medium</FontFamily>
<FontFamily x:Key="PoppinsSemiBold">ms-appx:///Assets/Fonts/Poppins-SemiBold.otf#Poppins SemiBold</FontFamily>
</ResourceDictionary>

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<ResourceDictionary
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:Media="using:CommunityToolkit.WinUI.Media">
<Style TargetType="TextBlock">
<Setter Property="FontFamily" Value="{StaticResource PoppinsRegular}" />
</Style>
<Media:AttachedCardShadow x:Key="CommonShadow" Offset="5" BlurRadius="10" Opacity=".4" />
</ResourceDictionary>

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<ResourceDictionary
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:MangaReader.WinUI.ViewModels">
<vm:ViewModelLocator x:Key="Locator" />
</ResourceDictionary>

View File

@@ -0,0 +1,121 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using MangaReader.Core.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.UI.Dispatching;
using Microsoft.UI.Xaml.Media.Imaging;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using System.Windows.Input;
namespace MangaReader.WinUI.ViewModels;
public partial class LibraryViewModel(MangaContext context) : ViewModelBase
{
private readonly DispatcherQueue _dispatcherQueue = DispatcherQueue.GetForCurrentThread();
private CancellationTokenSource? _cancellationTokenSource;
private string? _keyword;
public string? Keyword
{
get
{
return _keyword;
}
set
{
SetProperty(ref _keyword, value);
}
}
private ObservableCollection<ObservableMangaItem> _mangaItems = [];
public ObservableCollection<ObservableMangaItem> MangaItems
{
get
{
return _mangaItems;
}
set
{
SetProperty(ref _mangaItems, value);
}
}
public ICommand SearchCommand => new AsyncRelayCommand(SearchAsync);
//public ICommand ImportCommand => new AsyncRelayCommand(ImportAsync);
public async Task SearchAsync()
{
//if (string.IsNullOrWhiteSpace(Keyword))
// return;
_cancellationTokenSource?.Cancel();
_cancellationTokenSource = new();
ObservableMangaItem[] mangaItems = await GetMangaItemsAsync(_cancellationTokenSource.Token);
_dispatcherQueue.TryEnqueue(DispatcherQueuePriority.Normal, () =>
{
MangaItems = new(mangaItems);
});
//MangaItems = new(mangaItems);
}
public async Task<ObservableMangaItem[]> GetMangaItemsAsync(CancellationToken cancellationToken)
{
Manga[] mangas = await context.Mangas
.Include(x => x.Titles)
.Include(x => x.Descriptions)
.Include(x => x.Genres).ThenInclude(x => x.Genre)
//.Include(x => x.Sources)
//.ThenInclude(x => x.Chapters)
.ToArrayAsync(cancellationToken);
List<ObservableMangaItem> mangaItems = [];
foreach (Manga manga in mangas)
{
ObservableMangaItem mangaItem = new()
{
MangaId = manga.MangaId,
Title = manga.Titles.OrderByDescending(title => title.IsPrimary).FirstOrDefault()?.Name,
Description = manga.Descriptions.FirstOrDefault()?.Text,
Genres = [.. manga.Genres.Select(x => x.Genre.Name)]
};
mangaItems.Add(mangaItem);
}
return [.. mangaItems];
}
}
public partial class ObservableMangaItem : ObservableObject
{
public int MangaId { get; init; }
public string? Title { get; init; }
public string? Description { get; init; }
public string? Thumbnail { get; init; }
public string[] Genres { get; init; } = [];
private BitmapImage? _thumbnailBitmap;
public BitmapImage? ThumbnailBitmap
{
get
{
return _thumbnailBitmap;
}
set
{
SetProperty(ref _thumbnailBitmap, value);
}
}
}

View File

@@ -0,0 +1,39 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using MangaReader.Core.Metadata;
using MangaReader.Core.Pipeline;
using MangaReader.Core.Search;
using MangaReader.WinUI.Views;
using Microsoft.UI.Dispatching;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Media.Animation;
using Microsoft.UI.Xaml.Media.Imaging;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.IO;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using System.Windows.Input;
namespace MangaReader.WinUI.ViewModels;
public partial class MainViewModel : ViewModelBase
{
private string? _keyword;
public string? Keyword
{
get
{
return _keyword;
}
set
{
SetProperty(ref _keyword, value);
}
}
}

View File

@@ -0,0 +1,218 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using MangaReader.Core.Metadata;
using MangaReader.Core.Pipeline;
using MangaReader.Core.Search;
using Microsoft.UI.Dispatching;
using Microsoft.UI.Xaml.Media.Imaging;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.IO;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using System.Windows.Input;
namespace MangaReader.WinUI.ViewModels;
public partial class SearchViewModel(
IMangaSearchCoordinator searchCoordinator,
IMangaMetadataCoordinator metadataCoordinator,
IMangaPipeline pipeline) : ViewModelBase
{
private readonly DispatcherQueue _dispatcherQueue = DispatcherQueue.GetForCurrentThread();
private CancellationTokenSource? _cancellationTokenSource;
private string? _keyword;
public string? Keyword
{
get
{
return _keyword;
}
set
{
SetProperty(ref _keyword, value);
}
}
private ObservableCollection<MangaSearchResult> _searchResults = [];
public ObservableCollection<MangaSearchResult> SearchResults
{
get
{
return _searchResults;
}
set
{
SetProperty(ref _searchResults, value);
}
}
private ObservableCollection<ObservableMangaSearchResult> _searchResults2 = [];
public ObservableCollection<ObservableMangaSearchResult> SearchResults2
{
get
{
return _searchResults2;
}
set
{
SetProperty(ref _searchResults2, value);
}
}
public ICommand SearchCommand => new AsyncRelayCommand(SearchAsync);
//public ICommand ImportCommand => new AsyncRelayCommand(ImportAsync);
public async Task SearchAsync()
{
if (string.IsNullOrWhiteSpace(Keyword))
return;
_cancellationTokenSource?.Cancel();
_cancellationTokenSource = new();
Dictionary<string, MangaSearchResult[]> result = await searchCoordinator.SearchAsync(Keyword, _cancellationTokenSource.Token);
List<MangaSearchResult> searchResults = [];
List<ObservableMangaSearchResult> mangaSearchResults = [];
foreach (var item in result)
{
foreach (MangaSearchResult searchResult in item.Value)
{
//searchResults.Add(searchResult);
ObservableMangaSearchResult mangaSearchResult = new()
{
Source = searchResult.Source,
Url = searchResult.Url,
Title = searchResult.Title,
Thumbnail = searchResult.Thumbnail,
Description = searchResult.Description,
Genres = searchResult.Genres
};
Task.Run(() => mangaSearchResult.LoadThumbnailAsync(_dispatcherQueue)); // or defer this if you want lazy loading
searchResults.Add(searchResult);
mangaSearchResults.Add(mangaSearchResult);
}
}
SearchResults = new(searchResults);
SearchResults2 = new(mangaSearchResults);
}
public static async Task<BitmapImage?> LoadWebpAsBitmapImageAsync(string? url)
{
if (string.IsNullOrWhiteSpace(url))
return null;
using var httpClient = new HttpClient();
httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:139.0) Gecko/20100101 Firefox/139.0");
using var webpStream = await httpClient.GetStreamAsync(url);
using var image = await Image.LoadAsync(webpStream); // from SixLabors.ImageSharp
using var ms = new MemoryStream();
//await image.SaveAsPngAsync(ms); // Convert to PNG in memory
await image.SaveAsJpegAsync(ms);
ms.Position = 0;
var bitmap = new BitmapImage();
await bitmap.SetSourceAsync(ms.AsRandomAccessStream());
return bitmap;
}
public async Task ImportAsync(ObservableMangaSearchResult searchResult, CancellationToken cancellationToken)
{
IMangaMetadataProvider metadataProvider = metadataCoordinator.GetProvider(searchResult.Source);
SourceManga? sourceManga = await metadataProvider.GetMangaAsync(searchResult.Url, cancellationToken);
if (sourceManga == null)
return;
MangaMetadataPipelineRequest request = new()
{
SourceName = searchResult.Source,
SourceUrl = searchResult.Url,
SourceManga = sourceManga,
};
await pipeline.RunMetadataAsync(request, cancellationToken);
}
}
public partial class ObservableMangaSearchResult : ObservableObject
{
public required string Source { get; init; }
public required string Url { get; init; }
public string? Title { get; init; }
public string? Description { get; init; }
public string? Thumbnail { get; init; }
public string[] Genres { get; init; } = [];
private BitmapImage? _thumbnailBitmap;
public BitmapImage? ThumbnailBitmap
{
get
{
return _thumbnailBitmap;
}
set
{
SetProperty(ref _thumbnailBitmap, value);
}
}
public async Task LoadThumbnailAsync(DispatcherQueue dispatchQueue)
{
if (string.IsNullOrWhiteSpace(Thumbnail))
return;
try
{
using var httpClient = new HttpClient();
httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("Mozilla/5.0");
using var stream = await httpClient.GetStreamAsync(Thumbnail);
using var image = await Image.LoadAsync(stream); // Important: use a pixel type
using var ms = new MemoryStream();
await image.SaveAsJpegAsync(ms); // or SaveAsPngAsync
ms.Position = 0;
TaskCompletionSource taskCompletionSource = new();
dispatchQueue.TryEnqueue(async () => {
var bitmap = new BitmapImage();
await bitmap.SetSourceAsync(ms.AsRandomAccessStream());
ThumbnailBitmap = bitmap;
taskCompletionSource.SetResult();
});
taskCompletionSource.Task.GetAwaiter().GetResult();
//var bitmap = new BitmapImage();
//await bitmap.SetSourceAsync(ms.AsRandomAccessStream());
//ThumbnailBitmap = bitmap;
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"[Thumbnail Load Failed] {ex.Message}");
}
}
}

View File

@@ -0,0 +1,8 @@
using CommunityToolkit.Mvvm.ComponentModel;
namespace MangaReader.WinUI.ViewModels;
public partial class ViewModelBase : ObservableObject
{
}

View File

@@ -0,0 +1,15 @@
using Microsoft.Extensions.DependencyInjection;
namespace MangaReader.WinUI.ViewModels;
public class ViewModelLocator
{
public static MainViewModel MainViewModel
=> App.ServiceProvider.GetRequiredService<MainViewModel>();
public static SearchViewModel SearchViewModel
=> App.ServiceProvider.GetRequiredService<SearchViewModel>();
public static LibraryViewModel LibraryViewModel
=> App.ServiceProvider.GetRequiredService<LibraryViewModel>();
}

View File

@@ -0,0 +1,97 @@
<?xml version="1.0" encoding="utf-8"?>
<Page
x:Class="MangaReader.WinUI.Views.LibraryView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="using:MangaReader.WinUI.Views"
xmlns:vm="using:MangaReader.WinUI.ViewModels"
xmlns:search="using:MangaReader.Core.Search"
xmlns:UI="using:CommunityToolkit.WinUI"
xmlns:Controls="using:CommunityToolkit.WinUI.Controls"
xmlns:Media="using:CommunityToolkit.WinUI.Media"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
DataContext="{Binding Source={StaticResource Locator}, Path=LibraryViewModel}"
d:DataContext="{d:DesignInstance Type=vm:LibraryViewModel, IsDesignTimeCreatable=True}"
Loaded="Page_Loaded"
mc:Ignorable="d">
<Page.Resources>
<Media:AttachedCardShadow x:Key="CommonShadow" Offset="5" BlurRadius="10" Opacity=".4" />
<DataTemplate x:Key="MangaItemTemplate" x:DataType="vm:ObservableMangaItem">
<Grid Padding="20" ColumnSpacing="20" Height="400" VerticalAlignment="Stretch" Background="{StaticResource CardBackgroundFillColorDefault}" CornerRadius="8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"></ColumnDefinition>
<ColumnDefinition Width="*"></ColumnDefinition>
</Grid.ColumnDefinitions>
<Border Grid.Column="0" MaxWidth="300" UI:Effects.Shadow="{StaticResource CommonShadow}" VerticalAlignment="Top" HorizontalAlignment="Left">
<Grid VerticalAlignment="Top" HorizontalAlignment="Left" CornerRadius="8">
<!--<Image Source="{x:Bind ThumbnailBitmap, Mode=OneWay}" MaxWidth="300"></Image>-->
<Canvas Background="#19000000"></Canvas>
</Grid>
</Border>
<Grid Grid.Column="1" RowSpacing="20">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"></RowDefinition>
<RowDefinition Height="Auto"></RowDefinition>
<RowDefinition Height="*"></RowDefinition>
<RowDefinition Height="Auto"></RowDefinition>
</Grid.RowDefinitions>
<Border>
<TextBlock Grid.Row="0" Text="{x:Bind Title, Mode=OneTime}" FontSize="24" FontFamily="{StaticResource PoppinsSemiBold}" TextWrapping="Wrap"></TextBlock>
</Border>
<ItemsControl Grid.Row="1" ItemsSource="{x:Bind Genres, Mode=OneTime}" ItemTemplate="{StaticResource GenreTemplate}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<Controls:WrapPanel Orientation="Horizontal" HorizontalSpacing="10" VerticalSpacing="10" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
</ItemsControl>
<ScrollViewer Grid.Row="2">
<TextBlock Text="{x:Bind Description}" Foreground="{StaticResource TextFillColorSecondaryBrush}" FontSize="16" TextWrapping="Wrap" LineStackingStrategy="BlockLineHeight" LineHeight="22"></TextBlock>
</ScrollViewer>
<StackPanel Grid.Row="3" Orientation="Horizontal" HorizontalAlignment="Right">
<!--<Button
Content="Import"
MinWidth="100"
Tag="{x:Bind}"
Click="Button_Click">
</Button>-->
</StackPanel>
</Grid>
</Grid>
</DataTemplate>
<DataTemplate x:Key="GenreTemplate" x:DataType="x:String">
<Border>
<TextBlock FontSize="12" Foreground="{StaticResource TextFillColorTertiary}" Text="{x:Bind Mode=OneTime, Converter={StaticResource UppercaseConverter}}" FontFamily="{StaticResource PoppinsSemiBold}"></TextBlock>
</Border>
</DataTemplate>
</Page.Resources>
<Grid Padding="0" RowSpacing="20">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"></RowDefinition>
<RowDefinition Height="*"></RowDefinition>
</Grid.RowDefinitions>
<Border HorizontalAlignment="Stretch">
<StackPanel Orientation="Horizontal" Spacing="10" HorizontalAlignment="Center" Padding="10">
<TextBox Text="{Binding Keyword, Mode=TwoWay}" Width="300"></TextBox>
<Button Content="Search" Command="{Binding SearchCommand}"></Button>
</StackPanel>
</Border>
<ScrollViewer Grid.Row="1" RenderTransformOrigin=".5,.5" Padding="50">
<ScrollViewer.RenderTransform>
<ScaleTransform ScaleX="1" ScaleY="1" />
</ScrollViewer.RenderTransform>
<ItemsRepeater ItemsSource="{Binding MangaItems, Mode=OneWay}" ItemTemplate="{StaticResource MangaItemTemplate}">
<ItemsRepeater.Layout>
<UniformGridLayout MinRowSpacing="50" MinColumnSpacing="50" ItemsStretch="Fill" MinItemWidth="800"></UniformGridLayout>
</ItemsRepeater.Layout>
</ItemsRepeater>
</ScrollViewer>
</Grid>
</Page>

View File

@@ -0,0 +1,23 @@
using MangaReader.WinUI.ViewModels;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using System.Threading.Tasks;
namespace MangaReader.WinUI.Views;
public sealed partial class LibraryView : Page
{
private readonly LibraryViewModel viewModel;
public LibraryView()
{
InitializeComponent();
viewModel = (LibraryViewModel)DataContext;
}
private void Page_Loaded(object sender, RoutedEventArgs e)
{
Task.Run(viewModel.SearchAsync);
}
}

View File

@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<UserControl
x:Class="MangaReader.WinUI.Views.MainView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="using:MangaReader.WinUI.Views"
xmlns:vm="using:MangaReader.WinUI.ViewModels"
xmlns:search="using:MangaReader.Core.Search"
xmlns:UI="using:CommunityToolkit.WinUI"
xmlns:Controls="using:CommunityToolkit.WinUI.Controls"
xmlns:Media="using:CommunityToolkit.WinUI.Media"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
DataContext="{Binding Source={StaticResource Locator}, Path=MainViewModel}"
d:DataContext="{d:DesignInstance Type=vm:MainViewModel, IsDesignTimeCreatable=True}"
mc:Ignorable="d">
<NavigationView IsBackEnabled="False" IsBackButtonVisible="Collapsed" SelectionChanged="nvSample_SelectionChanged">
<NavigationView.MenuItems>
<NavigationViewItem Icon="Library" Content="Library" Tag="Library" />
<NavigationViewItem Icon="Find" Content="Search" Tag="Search" />
</NavigationView.MenuItems>
<Frame x:Name="ContentFrame"/>
</NavigationView>
</UserControl>

View File

@@ -0,0 +1,50 @@
using MangaReader.WinUI.ViewModels;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Media.Animation;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
namespace MangaReader.WinUI.Views;
public sealed partial class MainView : UserControl
{
private readonly MainViewModel viewModel;
private readonly List<(string Tag, Type ViewType)> _tagViewMap =
[
("Search", typeof(SearchView)),
("Library", typeof(LibraryView))
];
public MainView()
{
InitializeComponent();
viewModel = (MainViewModel)DataContext;
}
private void nvSample_SelectionChanged(NavigationView sender, NavigationViewSelectionChangedEventArgs args)
{
if (args.SelectedItem is not NavigationViewItem navigationViewItem)
return;
if (navigationViewItem.Tag is not string tagName)
return;
UpdateContent(tagName, null);
}
private void UpdateContent(string tag, NavigationTransitionInfo? transitionInfo)
{
Type? viewType = _tagViewMap.FirstOrDefault(x => x.Tag == tag).ViewType;
if (viewType == null)
return;
ContentFrame.Navigate(viewType);
}
}

View File

@@ -0,0 +1,95 @@
<?xml version="1.0" encoding="utf-8"?>
<Page
x:Class="MangaReader.WinUI.Views.SearchView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="using:MangaReader.WinUI.Views"
xmlns:vm="using:MangaReader.WinUI.ViewModels"
xmlns:search="using:MangaReader.Core.Search"
xmlns:UI="using:CommunityToolkit.WinUI"
xmlns:Controls="using:CommunityToolkit.WinUI.Controls"
xmlns:Media="using:CommunityToolkit.WinUI.Media"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
DataContext="{Binding Source={StaticResource Locator}, Path=SearchViewModel}"
d:DataContext="{d:DesignInstance Type=vm:SearchViewModel, IsDesignTimeCreatable=True}"
mc:Ignorable="d">
<Page.Resources>
<Media:AttachedCardShadow x:Key="CommonShadow" Offset="5" BlurRadius="10" Opacity=".4" />
<DataTemplate x:Key="MangaSearchResultTemplate" x:DataType="vm:ObservableMangaSearchResult">
<Grid Padding="20" ColumnSpacing="20" Height="400" VerticalAlignment="Stretch" Background="{StaticResource CardBackgroundFillColorDefault}" CornerRadius="8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"></ColumnDefinition>
<ColumnDefinition Width="*"></ColumnDefinition>
</Grid.ColumnDefinitions>
<Border Grid.Column="0" MaxWidth="300" UI:Effects.Shadow="{StaticResource CommonShadow}" VerticalAlignment="Top" HorizontalAlignment="Left">
<Grid VerticalAlignment="Top" HorizontalAlignment="Left" CornerRadius="8">
<Image Source="{x:Bind ThumbnailBitmap, Mode=OneWay}" MaxWidth="300"></Image>
<Canvas Background="#19000000"></Canvas>
</Grid>
</Border>
<Grid Grid.Column="1" RowSpacing="20">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"></RowDefinition>
<RowDefinition Height="Auto"></RowDefinition>
<RowDefinition Height="*"></RowDefinition>
<RowDefinition Height="Auto"></RowDefinition>
</Grid.RowDefinitions>
<Border>
<TextBlock Grid.Row="0" Text="{x:Bind Title, Mode=OneTime}" FontSize="24" FontFamily="{StaticResource PoppinsSemiBold}" TextWrapping="Wrap"></TextBlock>
</Border>
<ItemsControl Grid.Row="1" ItemsSource="{x:Bind Genres, Mode=OneTime}" ItemTemplate="{StaticResource GenreTemplate}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<Controls:WrapPanel Orientation="Horizontal" HorizontalSpacing="10" VerticalSpacing="10" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
</ItemsControl>
<ScrollViewer Grid.Row="2">
<TextBlock Text="{x:Bind Description}" Foreground="{StaticResource TextFillColorSecondaryBrush}" FontSize="16" TextWrapping="Wrap" LineStackingStrategy="BlockLineHeight" LineHeight="22"></TextBlock>
</ScrollViewer>
<StackPanel Grid.Row="3" Orientation="Horizontal" HorizontalAlignment="Right">
<Button
Content="Import"
MinWidth="100"
Tag="{x:Bind}"
Click="Button_Click">
</Button>
</StackPanel>
</Grid>
</Grid>
</DataTemplate>
<DataTemplate x:Key="GenreTemplate" x:DataType="x:String">
<Border>
<TextBlock FontSize="12" Foreground="{StaticResource TextFillColorTertiary}" Text="{x:Bind Mode=OneTime, Converter={StaticResource UppercaseConverter}}" FontFamily="{StaticResource PoppinsSemiBold}"></TextBlock>
</Border>
</DataTemplate>
</Page.Resources>
<Grid Padding="0" RowSpacing="20">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"></RowDefinition>
<RowDefinition Height="*"></RowDefinition>
</Grid.RowDefinitions>
<Border HorizontalAlignment="Stretch">
<StackPanel Orientation="Horizontal" Spacing="10" HorizontalAlignment="Center" Padding="10">
<TextBox Text="{Binding Keyword, Mode=TwoWay}" Width="300"></TextBox>
<Button Content="Search" Command="{Binding SearchCommand}"></Button>
</StackPanel>
</Border>
<ScrollViewer Grid.Row="1" RenderTransformOrigin=".5,.5" Padding="50">
<ScrollViewer.RenderTransform>
<ScaleTransform ScaleX="1" ScaleY="1" />
</ScrollViewer.RenderTransform>
<ItemsRepeater ItemsSource="{Binding SearchResults2, Mode=OneWay}" ItemTemplate="{StaticResource MangaSearchResultTemplate}">
<ItemsRepeater.Layout>
<UniformGridLayout MinRowSpacing="50" MinColumnSpacing="50" ItemsStretch="Fill" MinItemWidth="800"></UniformGridLayout>
</ItemsRepeater.Layout>
</ItemsRepeater>
</ScrollViewer>
</Grid>
</Page>

View File

@@ -0,0 +1,30 @@
using MangaReader.WinUI.ViewModels;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using System.Threading;
using System.Threading.Tasks;
namespace MangaReader.WinUI.Views;
public sealed partial class SearchView : Page
{
private readonly SearchViewModel viewModel;
public SearchView()
{
InitializeComponent();
viewModel = (SearchViewModel)DataContext;
}
private async void Button_Click(object sender, RoutedEventArgs e)
{
if (sender is not Button button)
return;
if (button.Tag is not ObservableMangaSearchResult searchResult)
return;
await viewModel.ImportAsync(searchResult, CancellationToken.None);
}
}

Some files were not shown because too many files have changed in this diff Show More