diff --git a/.circleci/config.yml b/.circleci/config.yml
index c336075..37e8a6b 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -6,7 +6,7 @@
jobs:
build:
docker:
- - image: mcr.microsoft.com/dotnet/sdk:8.0
+ - image: mcr.microsoft.com/dotnet/sdk:9.0
steps:
- checkout
- run:
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index 9f0f92d..bb342d7 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -11,9 +11,9 @@ updates:
interval: "weekly"
target-branch: "main"
reviewers:
- - "aikoofujimotoo"
+ - "fumiichan"
labels:
- "dependencies"
- "enhancement"
assignees:
- - "aikoofujimotoo"
+ - "fumiichan"
diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml
index 81dc74d..09269eb 100644
--- a/.github/workflows/dotnet.yml
+++ b/.github/workflows/dotnet.yml
@@ -12,11 +12,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v2
+ - uses: actions/checkout@v4
- name: Setup .NET
- uses: actions/setup-dotnet@v1
+ uses: actions/setup-dotnet@v4
with:
- dotnet-version: 8.0.x
+ dotnet-version: 9.0.x
- name: Restore dependencies
run: dotnet restore
- name: Build
diff --git a/.gitignore b/.gitignore
index b004b30..4b1b853 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,365 +1,369 @@
-## Ignore Visual Studio temporary files, build results, and
-## files generated by popular Visual Studio add-ons.
-##
-## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore
-
-# Mac
-.DS_Store
-
-# User-specific files
-*.rsuser
-*.suo
-*.user
-*.userosscache
-*.sln.docstates
-
-# User-specific files (MonoDevelop/Xamarin Studio)
-*.userprefs
-
-# Mono auto generated files
-mono_crash.*
-
-# Build results
-[Dd]ebug/
-[Dd]ebugPublic/
-[Rr]elease/
-[Rr]eleases/
-x64/
-x86/
-[Ww][Ii][Nn]32/
-[Aa][Rr][Mm]/
-[Aa][Rr][Mm]64/
-bld/
-[Bb]in/
-[Oo]bj/
-[Ll]og/
-[Ll]ogs/
-
-# Visual Studio 2015/2017 cache/options directory
-.vs/
-# Uncomment if you have tasks that create the project's static files in wwwroot
-#wwwroot/
-
-# Visual Studio 2017 auto generated files
-Generated\ Files/
-
-# MSTest test Results
-[Tt]est[Rr]esult*/
-[Bb]uild[Ll]og.*
-
-# NUnit
-*.VisualState.xml
-TestResult.xml
-nunit-*.xml
-
-# Build Results of an ATL Project
-[Dd]ebugPS/
-[Rr]eleasePS/
-dlldata.c
-
-# Benchmark Results
-BenchmarkDotNet.Artifacts/
-
-# .NET Core
-project.lock.json
-project.fragment.lock.json
-artifacts/
-
-# ASP.NET Scaffolding
-ScaffoldingReadMe.txt
-
-# StyleCop
-StyleCopReport.xml
-
-# Files built by Visual Studio
-*_i.c
-*_p.c
-*_h.h
-*.ilk
-*.meta
-*.obj
-*.iobj
-*.pch
-*.pdb
-*.ipdb
-*.pgc
-*.pgd
-*.rsp
-*.sbr
-*.tlb
-*.tli
-*.tlh
-*.tmp
-*.tmp_proj
-*_wpftmp.csproj
-*.log
-*.vspscc
-*.vssscc
-.builds
-*.pidb
-*.svclog
-*.scc
-
-# Chutzpah Test files
-_Chutzpah*
-
-# Visual C++ cache files
-ipch/
-*.aps
-*.ncb
-*.opendb
-*.opensdf
-*.sdf
-*.cachefile
-*.VC.db
-*.VC.VC.opendb
-
-# Visual Studio profiler
-*.psess
-*.vsp
-*.vspx
-*.sap
-
-# Visual Studio Trace Files
-*.e2e
-
-# TFS 2012 Local Workspace
-$tf/
-
-# Guidance Automation Toolkit
-*.gpState
-
-# ReSharper is a .NET coding add-in
-_ReSharper*/
-*.[Rr]e[Ss]harper
-*.DotSettings.user
-
-# TeamCity is a build add-in
-_TeamCity*
-
-# DotCover is a Code Coverage Tool
-*.dotCover
-
-# AxoCover is a Code Coverage Tool
-.axoCover/*
-!.axoCover/settings.json
-
-# Coverlet is a free, cross platform Code Coverage Tool
-coverage*.json
-coverage*.xml
-coverage*.info
-
-# Visual Studio code coverage results
-*.coverage
-*.coveragexml
-
-# NCrunch
-_NCrunch_*
-.*crunch*.local.xml
-nCrunchTemp_*
-
-# MightyMoose
-*.mm.*
-AutoTest.Net/
-
-# Web workbench (sass)
-.sass-cache/
-
-# Installshield output folder
-[Ee]xpress/
-
-# DocProject is a documentation generator add-in
-DocProject/buildhelp/
-DocProject/Help/*.HxT
-DocProject/Help/*.HxC
-DocProject/Help/*.hhc
-DocProject/Help/*.hhk
-DocProject/Help/*.hhp
-DocProject/Help/Html2
-DocProject/Help/html
-
-# Click-Once directory
-publish/
-
-# Publish Web Output
-*.[Pp]ublish.xml
-*.azurePubxml
-# Note: Comment the next line if you want to checkin your web deploy settings,
-# but database connection strings (with potential passwords) will be unencrypted
-*.pubxml
-*.publishproj
-
-# Microsoft Azure Web App publish settings. Comment the next line if you want to
-# checkin your Azure Web App publish settings, but sensitive information contained
-# in these scripts will be unencrypted
-PublishScripts/
-
-# NuGet Packages
-*.nupkg
-# NuGet Symbol Packages
-*.snupkg
-# The packages folder can be ignored because of Package Restore
-**/[Pp]ackages/*
-# except build/, which is used as an MSBuild target.
-!**/[Pp]ackages/build/
-# Uncomment if necessary however generally it will be regenerated when needed
-#!**/[Pp]ackages/repositories.config
-# NuGet v3's project.json files produces more ignorable files
-*.nuget.props
-*.nuget.targets
-
-# Microsoft Azure Build Output
-csx/
-*.build.csdef
-
-# Microsoft Azure Emulator
-ecf/
-rcf/
-
-# Windows Store app package directories and files
-AppPackages/
-BundleArtifacts/
-Package.StoreAssociation.xml
-_pkginfo.txt
-*.appx
-*.appxbundle
-*.appxupload
-
-# Visual Studio cache files
-# files ending in .cache can be ignored
-*.[Cc]ache
-# but keep track of directories ending in .cache
-!?*.[Cc]ache/
-
-# Others
-ClientBin/
-~$*
-*~
-*.dbmdl
-*.dbproj.schemaview
-*.jfm
-*.pfx
-*.publishsettings
-orleans.codegen.cs
-
-# Including strong name files can present a security risk
-# (https://github.com/github/gitignore/pull/2483#issue-259490424)
-#*.snk
-
-# Since there are multiple workflows, uncomment next line to ignore bower_components
-# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
-#bower_components/
-
-# RIA/Silverlight projects
-Generated_Code/
-
-# Backup & report files from converting an old project file
-# to a newer Visual Studio version. Backup files are not needed,
-# because we have git ;-)
-_UpgradeReport_Files/
-Backup*/
-UpgradeLog*.XML
-UpgradeLog*.htm
-ServiceFabricBackup/
-*.rptproj.bak
-
-# SQL Server files
-*.mdf
-*.ldf
-*.ndf
-
-# Business Intelligence projects
-*.rdl.data
-*.bim.layout
-*.bim_*.settings
-*.rptproj.rsuser
-*- [Bb]ackup.rdl
-*- [Bb]ackup ([0-9]).rdl
-*- [Bb]ackup ([0-9][0-9]).rdl
-
-# Microsoft Fakes
-FakesAssemblies/
-
-# GhostDoc plugin setting file
-*.GhostDoc.xml
-
-# Node.js Tools for Visual Studio
-.ntvs_analysis.dat
-node_modules/
-
-# Visual Studio 6 build log
-*.plg
-
-# Visual Studio 6 workspace options file
-*.opt
-
-# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
-*.vbw
-
-# Visual Studio LightSwitch build output
-**/*.HTMLClient/GeneratedArtifacts
-**/*.DesktopClient/GeneratedArtifacts
-**/*.DesktopClient/ModelManifest.xml
-**/*.Server/GeneratedArtifacts
-**/*.Server/ModelManifest.xml
-_Pvt_Extensions
-
-# Paket dependency manager
-.paket/paket.exe
-paket-files/
-
-# FAKE - F# Make
-.fake/
-
-# CodeRush personal settings
-.cr/personal
-
-# Python Tools for Visual Studio (PTVS)
-__pycache__/
-*.pyc
-
-# Cake - Uncomment if you are using it
-# tools/**
-# !tools/packages.config
-
-# Tabs Studio
-*.tss
-
-# Telerik's JustMock configuration file
-*.jmconfig
-
-# BizTalk build output
-*.btp.cs
-*.btm.cs
-*.odx.cs
-*.xsd.cs
-
-# OpenCover UI analysis results
-OpenCover/
-
-# Azure Stream Analytics local run output
-ASALocalRun/
-
-# MSBuild Binary and Structured Log
-*.binlog
-
-# NVidia Nsight GPU debugger configuration file
-*.nvuser
-
-# MFractors (Xamarin productivity tool) working folder
-.mfractor/
-
-# Local History for Visual Studio
-.localhistory/
-
-# BeatPulse healthcheck temp database
-healthchecksdb
-
-# Backup folder for Package Reference Convert tool in Visual Studio 2017
-MigrationBackup/
-
-# Ionide (cross platform F# VS Code tools) working folder
-.ionide/
-
-# Fody - auto-generated XML schema
-FodyWeavers.xsd
+## Ignore Visual Studio temporary files, build results, and
+## files generated by popular Visual Studio add-ons.
+##
+## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore
+
+# Mac
+.DS_Store
+
+# Editor stuff
+.idea
+
+# User-specific files
+*.rsuser
+*.suo
+*.user
+*.userosscache
+*.sln.docstates
+
+# User-specific files (MonoDevelop/Xamarin Studio)
+*.userprefs
+
+# Mono auto generated files
+mono_crash.*
+
+# Build results
+[Dd]ebug/
+[Dd]ebugPublic/
+[Rr]elease/
+[Rr]eleases/
+x64/
+x86/
+[Ww][Ii][Nn]32/
+[Aa][Rr][Mm]/
+[Aa][Rr][Mm]64/
+bld/
+[Bb]in/
+[Oo]bj/
+[Ll]og/
+[Ll]ogs/
+[Dd]ist/
+
+# Visual Studio 2015/2017 cache/options directory
+.vs/
+# Uncomment if you have tasks that create the project's static files in wwwroot
+#wwwroot/
+
+# Visual Studio 2017 auto generated files
+Generated\ Files/
+
+# MSTest test Results
+[Tt]est[Rr]esult*/
+[Bb]uild[Ll]og.*
+
+# NUnit
+*.VisualState.xml
+TestResult.xml
+nunit-*.xml
+
+# Build Results of an ATL Project
+[Dd]ebugPS/
+[Rr]eleasePS/
+dlldata.c
+
+# Benchmark Results
+BenchmarkDotNet.Artifacts/
+
+# .NET Core
+project.lock.json
+project.fragment.lock.json
+artifacts/
+
+# ASP.NET Scaffolding
+ScaffoldingReadMe.txt
+
+# StyleCop
+StyleCopReport.xml
+
+# Files built by Visual Studio
+*_i.c
+*_p.c
+*_h.h
+*.ilk
+*.meta
+*.obj
+*.iobj
+*.pch
+*.pdb
+*.ipdb
+*.pgc
+*.pgd
+*.rsp
+*.sbr
+*.tlb
+*.tli
+*.tlh
+*.tmp
+*.tmp_proj
+*_wpftmp.csproj
+*.log
+*.vspscc
+*.vssscc
+.builds
+*.pidb
+*.svclog
+*.scc
+
+# Chutzpah Test files
+_Chutzpah*
+
+# Visual C++ cache files
+ipch/
+*.aps
+*.ncb
+*.opendb
+*.opensdf
+*.sdf
+*.cachefile
+*.VC.db
+*.VC.VC.opendb
+
+# Visual Studio profiler
+*.psess
+*.vsp
+*.vspx
+*.sap
+
+# Visual Studio Trace Files
+*.e2e
+
+# TFS 2012 Local Workspace
+$tf/
+
+# Guidance Automation Toolkit
+*.gpState
+
+# ReSharper is a .NET coding add-in
+_ReSharper*/
+*.[Rr]e[Ss]harper
+*.DotSettings.user
+
+# TeamCity is a build add-in
+_TeamCity*
+
+# DotCover is a Code Coverage Tool
+*.dotCover
+
+# AxoCover is a Code Coverage Tool
+.axoCover/*
+!.axoCover/settings.json
+
+# Coverlet is a free, cross platform Code Coverage Tool
+coverage*.json
+coverage*.xml
+coverage*.info
+
+# Visual Studio code coverage results
+*.coverage
+*.coveragexml
+
+# NCrunch
+_NCrunch_*
+.*crunch*.local.xml
+nCrunchTemp_*
+
+# MightyMoose
+*.mm.*
+AutoTest.Net/
+
+# Web workbench (sass)
+.sass-cache/
+
+# Installshield output folder
+[Ee]xpress/
+
+# DocProject is a documentation generator add-in
+DocProject/buildhelp/
+DocProject/Help/*.HxT
+DocProject/Help/*.HxC
+DocProject/Help/*.hhc
+DocProject/Help/*.hhk
+DocProject/Help/*.hhp
+DocProject/Help/Html2
+DocProject/Help/html
+
+# Click-Once directory
+publish/
+
+# Publish Web Output
+*.[Pp]ublish.xml
+*.azurePubxml
+# Note: Comment the next line if you want to checkin your web deploy settings,
+# but database connection strings (with potential passwords) will be unencrypted
+*.pubxml
+*.publishproj
+
+# Microsoft Azure Web App publish settings. Comment the next line if you want to
+# checkin your Azure Web App publish settings, but sensitive information contained
+# in these scripts will be unencrypted
+PublishScripts/
+
+# NuGet Packages
+*.nupkg
+# NuGet Symbol Packages
+*.snupkg
+# The packages folder can be ignored because of Package Restore
+**/[Pp]ackages/*
+# except build/, which is used as an MSBuild target.
+!**/[Pp]ackages/build/
+# Uncomment if necessary however generally it will be regenerated when needed
+#!**/[Pp]ackages/repositories.config
+# NuGet v3's project.json files produces more ignorable files
+*.nuget.props
+*.nuget.targets
+
+# Microsoft Azure Build Output
+csx/
+*.build.csdef
+
+# Microsoft Azure Emulator
+ecf/
+rcf/
+
+# Windows Store app package directories and files
+AppPackages/
+BundleArtifacts/
+Package.StoreAssociation.xml
+_pkginfo.txt
+*.appx
+*.appxbundle
+*.appxupload
+
+# Visual Studio cache files
+# files ending in .cache can be ignored
+*.[Cc]ache
+# but keep track of directories ending in .cache
+!?*.[Cc]ache/
+
+# Others
+ClientBin/
+~$*
+*~
+*.dbmdl
+*.dbproj.schemaview
+*.jfm
+*.pfx
+*.publishsettings
+orleans.codegen.cs
+
+# Including strong name files can present a security risk
+# (https://github.com/github/gitignore/pull/2483#issue-259490424)
+#*.snk
+
+# Since there are multiple workflows, uncomment next line to ignore bower_components
+# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
+#bower_components/
+
+# RIA/Silverlight projects
+Generated_Code/
+
+# Backup & report files from converting an old project file
+# to a newer Visual Studio version. Backup files are not needed,
+# because we have git ;-)
+_UpgradeReport_Files/
+Backup*/
+UpgradeLog*.XML
+UpgradeLog*.htm
+ServiceFabricBackup/
+*.rptproj.bak
+
+# SQL Server files
+*.mdf
+*.ldf
+*.ndf
+
+# Business Intelligence projects
+*.rdl.data
+*.bim.layout
+*.bim_*.settings
+*.rptproj.rsuser
+*- [Bb]ackup.rdl
+*- [Bb]ackup ([0-9]).rdl
+*- [Bb]ackup ([0-9][0-9]).rdl
+
+# Microsoft Fakes
+FakesAssemblies/
+
+# GhostDoc plugin setting file
+*.GhostDoc.xml
+
+# Node.js Tools for Visual Studio
+.ntvs_analysis.dat
+node_modules/
+
+# Visual Studio 6 build log
+*.plg
+
+# Visual Studio 6 workspace options file
+*.opt
+
+# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
+*.vbw
+
+# Visual Studio LightSwitch build output
+**/*.HTMLClient/GeneratedArtifacts
+**/*.DesktopClient/GeneratedArtifacts
+**/*.DesktopClient/ModelManifest.xml
+**/*.Server/GeneratedArtifacts
+**/*.Server/ModelManifest.xml
+_Pvt_Extensions
+
+# Paket dependency manager
+.paket/paket.exe
+paket-files/
+
+# FAKE - F# Make
+.fake/
+
+# CodeRush personal settings
+.cr/personal
+
+# Python Tools for Visual Studio (PTVS)
+__pycache__/
+*.pyc
+
+# Cake - Uncomment if you are using it
+# tools/**
+# !tools/packages.config
+
+# Tabs Studio
+*.tss
+
+# Telerik's JustMock configuration file
+*.jmconfig
+
+# BizTalk build output
+*.btp.cs
+*.btm.cs
+*.odx.cs
+*.xsd.cs
+
+# OpenCover UI analysis results
+OpenCover/
+
+# Azure Stream Analytics local run output
+ASALocalRun/
+
+# MSBuild Binary and Structured Log
+*.binlog
+
+# NVidia Nsight GPU debugger configuration file
+*.nvuser
+
+# MFractors (Xamarin productivity tool) working folder
+.mfractor/
+
+# Local History for Visual Studio
+.localhistory/
+
+# BeatPulse healthcheck temp database
+healthchecksdb
+
+# Backup folder for Package Reference Convert tool in Visual Studio 2017
+MigrationBackup/
+
+# Ionide (cross platform F# VS Code tools) working folder
+.ionide/
+
+# Fody - auto-generated XML schema
+FodyWeavers.xsd
diff --git a/.idea/.idea.asuka/.idea/.gitignore b/.idea/.idea.asuka/.idea/.gitignore
deleted file mode 100644
index 0ae17ed..0000000
--- a/.idea/.idea.asuka/.idea/.gitignore
+++ /dev/null
@@ -1,11 +0,0 @@
-# Default ignored files
-/shelf/
-/workspace.xml
-# Rider ignored files
-/contentModel.xml
-/modules.xml
-/projectSettingsUpdater.xml
-/.idea.asuka.iml
-# Datasource local storage ignored files
-/../../../../../../../../:\User Directories\Code\Aiko's Project Repository\asuka\.idea\.idea.asuka\.idea/dataSources/
-/dataSources.local.xml
diff --git a/.idea/.idea.asuka/.idea/discord.xml b/.idea/.idea.asuka/.idea/discord.xml
deleted file mode 100644
index d8e9561..0000000
--- a/.idea/.idea.asuka/.idea/discord.xml
+++ /dev/null
@@ -1,7 +0,0 @@
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/.idea.asuka/.idea/encodings.xml b/.idea/.idea.asuka/.idea/encodings.xml
deleted file mode 100644
index df87cf9..0000000
--- a/.idea/.idea.asuka/.idea/encodings.xml
+++ /dev/null
@@ -1,4 +0,0 @@
-
-
-
-
\ No newline at end of file
diff --git a/.idea/.idea.asuka/.idea/indexLayout.xml b/.idea/.idea.asuka/.idea/indexLayout.xml
deleted file mode 100644
index f5a863a..0000000
--- a/.idea/.idea.asuka/.idea/indexLayout.xml
+++ /dev/null
@@ -1,8 +0,0 @@
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/.idea.asuka/.idea/vcs.xml b/.idea/.idea.asuka/.idea/vcs.xml
deleted file mode 100644
index 9661ac7..0000000
--- a/.idea/.idea.asuka/.idea/vcs.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
diff --git a/Api/IGalleryApi.cs b/Api/IGalleryApi.cs
deleted file mode 100644
index 9e7ca15..0000000
--- a/Api/IGalleryApi.cs
+++ /dev/null
@@ -1,21 +0,0 @@
-using System.Threading.Tasks;
-using asuka.Api.Queries;
-using asuka.Api.Responses;
-using Refit;
-
-namespace asuka.Api;
-
-public interface IGalleryApi
-{
- [Get("/api/gallery/{code}")]
- Task FetchSingle(string code);
-
- [Get("/api/gallery/{code}/related")]
- Task FetchRecommended(string code);
-
- [Get("/api/galleries/all?page=1")]
- Task FetchAll();
-
- [Get("/api/galleries/search")]
- Task SearchGallery(SearchQuery queries);
-}
diff --git a/Api/IGalleryImage.cs b/Api/IGalleryImage.cs
deleted file mode 100644
index abdbb84..0000000
--- a/Api/IGalleryImage.cs
+++ /dev/null
@@ -1,11 +0,0 @@
-using System.Net.Http;
-using System.Threading.Tasks;
-using Refit;
-
-namespace asuka.Api;
-
-public interface IGalleryImage
-{
- [Get("/galleries/{mediaId}/{filename}")]
- Task GetImage(string mediaId, string filename);
-}
diff --git a/Api/Responses/GalleryImageObjectResponse.cs b/Api/Responses/GalleryImageObjectResponse.cs
deleted file mode 100644
index 8abda65..0000000
--- a/Api/Responses/GalleryImageObjectResponse.cs
+++ /dev/null
@@ -1,11 +0,0 @@
-using System.Collections.Generic;
-using System.Text.Json.Serialization;
-
-namespace asuka.Api.Responses;
-
-#nullable disable
-public record GalleryImageObjectResponse
-{
- [JsonPropertyName("pages")]
- public IReadOnlyList Images { get; set; }
-}
diff --git a/Api/Responses/GalleryImageResponse.cs b/Api/Responses/GalleryImageResponse.cs
deleted file mode 100644
index b26652e..0000000
--- a/Api/Responses/GalleryImageResponse.cs
+++ /dev/null
@@ -1,16 +0,0 @@
-using System.Text.Json.Serialization;
-
-namespace asuka.Api.Responses;
-
-#nullable disable
-public record GalleryImageResponse
-{
- [JsonPropertyName("t")]
- public string Type { get; set; }
-
- [JsonPropertyName("h")]
- public int Height { get; set; }
-
- [JsonPropertyName("w")]
- public int Width { get; set; }
-}
diff --git a/Api/Responses/GalleryListResponse.cs b/Api/Responses/GalleryListResponse.cs
deleted file mode 100644
index f6b1480..0000000
--- a/Api/Responses/GalleryListResponse.cs
+++ /dev/null
@@ -1,11 +0,0 @@
-using System.Collections.Generic;
-using System.Text.Json.Serialization;
-
-namespace asuka.Api.Responses;
-
-#nullable disable
-public record GalleryListResponse
-{
- [JsonPropertyName("result")]
- public IReadOnlyList Result { get; set; }
-}
diff --git a/Api/Responses/GalleryResponse.cs b/Api/Responses/GalleryResponse.cs
deleted file mode 100644
index 811dc90..0000000
--- a/Api/Responses/GalleryResponse.cs
+++ /dev/null
@@ -1,31 +0,0 @@
-using System.Collections.Generic;
-using System.Text.Json.Serialization;
-
-namespace asuka.Api.Responses;
-
-#nullable disable
-public record GalleryResponse
-{
- [JsonPropertyName("id")]
- [JsonNumberHandling(JsonNumberHandling.AllowReadingFromString)]
- public int Id { get; set; }
-
- [JsonPropertyName("media_id")]
- [JsonNumberHandling(JsonNumberHandling.AllowReadingFromString)]
- public int MediaId { get; set; }
-
- [JsonPropertyName("title")]
- public required GalleryTitleResponse Title { get; set; }
-
- [JsonPropertyName("images")]
- public required GalleryImageObjectResponse Images { get; set; }
-
- [JsonPropertyName("tags")]
- public IReadOnlyList Tags { get; set; }
-
- [JsonPropertyName("num_pages")]
- public int TotalPages { get; set; }
-
- [JsonPropertyName("num_favorites")]
- public int TotalFavorites { get; set; }
-}
diff --git a/Api/Responses/GallerySearchResponse.cs b/Api/Responses/GallerySearchResponse.cs
deleted file mode 100644
index 8677ed7..0000000
--- a/Api/Responses/GallerySearchResponse.cs
+++ /dev/null
@@ -1,12 +0,0 @@
-using System.Text.Json.Serialization;
-
-namespace asuka.Api.Responses;
-
-public record GallerySearchResponse : GalleryListResponse
-{
- [JsonPropertyName("num_pages")]
- public int TotalPages { get; set; }
-
- [JsonPropertyName("per_page")]
- public int TotalItemsPerPage { get; set; }
-}
diff --git a/Api/Responses/GalleryTagResponse.cs b/Api/Responses/GalleryTagResponse.cs
deleted file mode 100644
index 62e3393..0000000
--- a/Api/Responses/GalleryTagResponse.cs
+++ /dev/null
@@ -1,22 +0,0 @@
-using System.Text.Json.Serialization;
-
-namespace asuka.Api.Responses;
-
-#nullable disable
-public record GalleryTagResponse
-{
- [JsonPropertyName("id")]
- public int Id { get; init; }
-
- [JsonPropertyName("type")]
- public string Type { get; set; }
-
- [JsonPropertyName("name")]
- public string Name { get; set; }
-
- [JsonPropertyName("url")]
- public string Url { get; set; }
-
- [JsonPropertyName("count")]
- public int Count { get; set; }
-}
diff --git a/Api/Responses/GalleryTitleResponse.cs b/Api/Responses/GalleryTitleResponse.cs
deleted file mode 100644
index ba3f145..0000000
--- a/Api/Responses/GalleryTitleResponse.cs
+++ /dev/null
@@ -1,16 +0,0 @@
-using System.Text.Json.Serialization;
-
-namespace asuka.Api.Responses;
-
-#nullable disable
-public record GalleryTitleResponse
-{
- [JsonPropertyName("japanese")]
- public string Japanese { get; set; }
-
- [JsonPropertyName("english")]
- public string English { get; set; }
-
- [JsonPropertyName("pretty")]
- public string Pretty { get; set; }
-}
diff --git a/Commandline/ICommandLineParser.cs b/Commandline/ICommandLineParser.cs
deleted file mode 100644
index 0c3d388..0000000
--- a/Commandline/ICommandLineParser.cs
+++ /dev/null
@@ -1,8 +0,0 @@
-using System.Threading.Tasks;
-
-namespace asuka.Commandline;
-
-public interface ICommandLineParser
-{
- public Task RunAsync(object options);
-}
diff --git a/Commandline/Options/ConfigureOptions.cs b/Commandline/Options/ConfigureOptions.cs
deleted file mode 100644
index 0663d5c..0000000
--- a/Commandline/Options/ConfigureOptions.cs
+++ /dev/null
@@ -1,26 +0,0 @@
-using CommandLine;
-
-namespace asuka.Commandline.Options;
-
-#nullable disable
-[Verb("config", HelpText = "Configure the client")]
-public record ConfigureOptions
-{
- [Option('s', "set", HelpText = "Set value")]
- public bool SetConfigMode { get; init; }
-
- [Option('r', "read", HelpText = "Read configuration")]
- public bool ReadConfigMode { get; init; }
-
- [Option('l', "list", HelpText = "List all configuration values")]
- public bool ListConfigMode { get; init; }
-
- [Option('k', "key", HelpText = "Configuration to set/read")]
- public string Key { get; init; }
-
- [Option('v', "value", HelpText = "New value")]
- public string Value { get; init; }
-
- [Option("reset", HelpText = "Reset configuration values")]
- public bool ResetConfig { get; init; }
-}
diff --git a/Commandline/Options/CookieConfigureOptions.cs b/Commandline/Options/CookieConfigureOptions.cs
deleted file mode 100644
index 4072c65..0000000
--- a/Commandline/Options/CookieConfigureOptions.cs
+++ /dev/null
@@ -1,14 +0,0 @@
-using CommandLine;
-
-namespace asuka.Commandline.Options;
-
-#nullable disable
-[Verb("cookie", HelpText = "Configure cookies and User Agent")]
-public record CookieConfigureOptions
-{
- [Option('c', "cookie", HelpText = "Read cookies from a text file dump", Required = true)]
- public string CookieFile { get; init; }
-
- [Option('u', "userAgent", HelpText = "Set user agent", Required = true)]
- public string UserAgent { get; init; }
-}
diff --git a/Commandline/Options/FileCommandOptions.cs b/Commandline/Options/FileCommandOptions.cs
deleted file mode 100644
index d5305e5..0000000
--- a/Commandline/Options/FileCommandOptions.cs
+++ /dev/null
@@ -1,16 +0,0 @@
-using CommandLine;
-
-namespace asuka.Commandline.Options;
-
-#nullable disable
-[Verb("file", HelpText = "Download galleries from text file")]
-public record FileCommandOptions : ICommonOptions
-{
- [Option('f', "file",
- Required = true,
- HelpText = "Path to text file to read.")]
- public string FilePath { get; init; }
-
- public bool Pack { get; init; }
- public string Output { get; init; }
-}
diff --git a/Commandline/Options/GetOptions.cs b/Commandline/Options/GetOptions.cs
deleted file mode 100644
index e1968a6..0000000
--- a/Commandline/Options/GetOptions.cs
+++ /dev/null
@@ -1,23 +0,0 @@
-using System.Collections.Generic;
-using CommandLine;
-
-namespace asuka.Commandline.Options;
-
-#nullable disable
-[Verb("get", HelpText = "Download a Single Gallery from URL.")]
-public record GetOptions : ICommonOptions
-{
- [Option('i', "input",
- Required = true,
- HelpText = "Input Numeric Code(s)")]
- public IEnumerable Input { get; init; }
-
- [Option('r', "readonly",
- Default = false,
- Required = false,
- HelpText = "View the information only")]
- public bool ReadOnly { get; init; }
-
- public bool Pack { get; init; }
- public string Output { get; init; }
-}
diff --git a/Commandline/Options/ICommonOptions.cs b/Commandline/Options/ICommonOptions.cs
deleted file mode 100644
index 75d423c..0000000
--- a/Commandline/Options/ICommonOptions.cs
+++ /dev/null
@@ -1,17 +0,0 @@
-using CommandLine;
-
-namespace asuka.Commandline.Options;
-
-public interface ICommonOptions
-{
- [Option('p', "pack",
- Default = false,
- Required = false,
- HelpText = "Pack downloaded gallery into single CBZ archive")]
- bool Pack { get; init; }
-
- [Option('o', "output",
- Required = false,
- HelpText = "Destination path for gallery download")]
- string Output { get; init; }
-}
diff --git a/Commandline/Options/RandomOptions.cs b/Commandline/Options/RandomOptions.cs
deleted file mode 100644
index f03d15a..0000000
--- a/Commandline/Options/RandomOptions.cs
+++ /dev/null
@@ -1,11 +0,0 @@
-using CommandLine;
-
-namespace asuka.Commandline.Options;
-
-#nullable disable
-[Verb("random", HelpText = "Randomly pick a gallery.")]
-public record RandomOptions : ICommonOptions
-{
- public bool Pack { get; init; }
- public string Output { get; init; }
-}
diff --git a/Commandline/Options/RecommendOptions.cs b/Commandline/Options/RecommendOptions.cs
deleted file mode 100644
index a848a3e..0000000
--- a/Commandline/Options/RecommendOptions.cs
+++ /dev/null
@@ -1,15 +0,0 @@
-using CommandLine;
-
-namespace asuka.Commandline.Options;
-
-#nullable disable
-[Verb("recommend", HelpText = "Download recommendation from the gallery URL.")]
-public record RecommendOptions : ICommonOptions
-{
- [Option('i', "input",
- Required = true,
- HelpText = "Input Numeric Code")]
- public int Input { get; init; }
- public bool Pack { get; init; }
- public string Output { get; init; }
-}
diff --git a/Commandline/Options/SearchOptions.cs b/Commandline/Options/SearchOptions.cs
deleted file mode 100644
index 2105147..0000000
--- a/Commandline/Options/SearchOptions.cs
+++ /dev/null
@@ -1,44 +0,0 @@
-using System.Collections.Generic;
-using CommandLine;
-
-namespace asuka.Commandline.Options;
-
-#nullable disable
-[Verb("search", HelpText = "Search something in the gallery")]
-public record SearchOptions : ICommonOptions
-{
- [Option('q', "queries",
- Required = false,
- HelpText = "Search queries.")]
- public IEnumerable Queries { get; init; }
-
- [Option('e', "exclude",
- Required = false,
- HelpText = "Exclude something on search.")]
- public IEnumerable Exclude { get; init; }
-
- [Option("page",
- Required = true,
- Default = 1,
- HelpText = "Page to view its contents")]
- public int Page { get; init; }
-
- [Option('d', "dateRange",
- Required = false,
- HelpText = "Specify uploaded date to search")]
- public IEnumerable DateRange { get; init; }
-
- [Option("pageRange",
- Required = false,
- HelpText = "Specify page range of the gallery")]
- public IEnumerable PageRange { get; init; }
-
- [Option("sort",
- Required = false,
- Default = "date",
- HelpText = "Sort options")]
- public string Sort { get; init; }
-
- public bool Pack { get; init; }
- public string Output { get; init; }
-}
diff --git a/Commandline/Options/SeriesCreatorCommandOptions.cs b/Commandline/Options/SeriesCreatorCommandOptions.cs
deleted file mode 100644
index 313bba6..0000000
--- a/Commandline/Options/SeriesCreatorCommandOptions.cs
+++ /dev/null
@@ -1,15 +0,0 @@
-using System.Collections.Generic;
-using CommandLine;
-
-namespace asuka.Commandline.Options;
-
-#nullable disable
-[Verb("series", HelpText = "Construct series")]
-public record SeriesCreatorCommandOptions: ICommonOptions
-{
- [Option('a', "array", HelpText = "Create a seres from series of ids", Required = true)]
- public IEnumerable FromList { get; set; }
-
- public bool Pack { get; init; }
- public string Output { get; init; }
-}
diff --git a/Commandline/Parsers/ConfigureCommand.cs b/Commandline/Parsers/ConfigureCommand.cs
deleted file mode 100644
index ed912cc..0000000
--- a/Commandline/Parsers/ConfigureCommand.cs
+++ /dev/null
@@ -1,64 +0,0 @@
-using System;
-using System.Threading.Tasks;
-using asuka.Commandline.Options;
-using asuka.Configuration;
-using asuka.Output;
-using FluentValidation;
-
-namespace asuka.Commandline.Parsers;
-
-public class ConfigureCommand : ICommandLineParser
-{
- private readonly IAppConfigManager _appConfigManager;
- private readonly IValidator _validator;
-
- public ConfigureCommand(IValidator validator, IAppConfigManager appConfigManager)
- {
- _appConfigManager = appConfigManager;
- _validator = validator;
- }
-
- public async Task RunAsync(object options)
- {
- var opts = (ConfigureOptions)options;
- var validation = await _validator.ValidateAsync(opts);
- if (!validation.IsValid)
- {
- validation.Errors.PrintValidationExceptions();
- return;
- }
-
- if (opts.SetConfigMode)
- {
- _appConfigManager.SetValue(opts.Key, opts.Value);
- await _appConfigManager.Flush();
-
- return;
- }
-
- if (opts.ReadConfigMode)
- {
- var configValue = _appConfigManager.GetValue(opts.Key);
- Console.WriteLine($"{opts.Key} = {configValue}");
-
- return;
- }
-
- if (opts.ListConfigMode)
- {
- var keyValuePairs = _appConfigManager.GetAllValues();
-
- foreach (var (key, value) in keyValuePairs)
- {
- Console.WriteLine($"{key} = {value}");
- }
-
- return;
- }
-
- if (opts.ResetConfig)
- {
- await _appConfigManager.Reset();
- }
- }
-}
diff --git a/Commandline/Parsers/CookieConfigureService.cs b/Commandline/Parsers/CookieConfigureService.cs
deleted file mode 100644
index 97fc5fb..0000000
--- a/Commandline/Parsers/CookieConfigureService.cs
+++ /dev/null
@@ -1,61 +0,0 @@
-using System;
-using System.IO;
-using System.Linq;
-using System.Text.Json;
-using System.Threading.Tasks;
-using asuka.Commandline.Options;
-using asuka.Configuration;
-using asuka.Output;
-using FluentValidation;
-
-namespace asuka.Commandline.Parsers;
-
-public class CookieConfigureService : ICommandLineParser
-{
- private readonly IRequestConfigurator _requestConfigurator;
- private readonly IValidator _validator;
-
- public CookieConfigureService(
- IRequestConfigurator requestConfigurator,
- IValidator validator)
- {
- _requestConfigurator = requestConfigurator;
- _validator = validator;
- }
-
- public async Task RunAsync(object options)
- {
- var opts = (CookieConfigureOptions)options;
-
- var validationResult = await _validator.ValidateAsync(opts);
- if (!validationResult.IsValid)
- {
- validationResult.Errors.PrintValidationExceptions();
- return;
- }
-
- // Read cookies
- var file = await File.ReadAllTextAsync(opts.CookieFile);
- var cookieData = JsonSerializer.Deserialize(file);
-
- if (cookieData != null)
- {
- var cloudflare = cookieData.FirstOrDefault(x => x.Name == "cf_clearance");
- var csrf = cookieData.FirstOrDefault(x => x.Name == "csrftoken");
-
- if (cloudflare != null && csrf != null)
- {
- await _requestConfigurator.ApplyCookies(cloudflare, csrf);
- }
-
- if (!string.IsNullOrEmpty(opts.UserAgent))
- {
- await _requestConfigurator.ApplyUserAgent(opts.UserAgent);
- }
-
- return;
- }
-
- Console.WriteLine("An error occured at reading the cookie you provided.");
- }
-}
diff --git a/Commandline/Parsers/FileCommandService.cs b/Commandline/Parsers/FileCommandService.cs
deleted file mode 100644
index 4b1ac8e..0000000
--- a/Commandline/Parsers/FileCommandService.cs
+++ /dev/null
@@ -1,105 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.IO;
-using System.Linq;
-using System.Text;
-using System.Text.RegularExpressions;
-using System.Threading.Tasks;
-using asuka.Api;
-using asuka.Commandline.Options;
-using asuka.Core.Compression;
-using asuka.Core.Downloader;
-using asuka.Core.Extensions;
-using asuka.Core.Requests;
-using asuka.Core.Utilities;
-using asuka.Output.Progress;
-
-namespace asuka.Commandline.Parsers;
-
-public class FileCommandService : ICommandLineParser
-{
- private readonly IGalleryRequestService _api;
- private readonly IGalleryImage _apiImage;
- private readonly IProgressFactory _progress;
-
- public FileCommandService(
- IGalleryRequestService api,
- IGalleryImage apiImage,
- IProgressFactory progress)
- {
- _api = api;
- _apiImage = apiImage;
- _progress = progress;
- }
-
- public async Task RunAsync(object options)
- {
- var opts = (FileCommandOptions)options;
- if (!File.Exists(opts.FilePath))
- {
- Console.WriteLine("File doesn't exist.");
- return;
- }
-
- if (IsFileExceedingToFileSizeLimit(opts.FilePath))
- {
- Console.WriteLine("The file size is exceeding 5MB file size limit.");
- return;
- }
-
- var textFile = await File.ReadAllLinesAsync(opts.FilePath, Encoding.UTF8)
- .ConfigureAwait(false);
- var validUrls = FilterValidUrls(textFile);
-
- if (validUrls.Count == 0)
- {
- Console.WriteLine("No valid URLs found.");
- return;
- }
-
- var mainProgress = _progress.Create(validUrls.Count,
- $"Downloading from text file...");
-
- foreach (var url in validUrls)
- {
- var code = new Regex("\\d+").Match(url).Value;
- var response = await _api.FetchSingleAsync(code);
-
- var childProgress = mainProgress.Spawn(response.TotalPages,
- $"Downloading: {response.Title.GetTitle()}")!;
-
- var output = PathUtils.Join(opts.Output, response.Title.GetTitle());
- var downloader = new DownloadBuilder(response, 1)
- {
- Request = _apiImage,
- Output = output,
- OnEachComplete = _ =>
- {
- childProgress.Tick();
- },
- OnComplete = async gallery =>
- {
- await gallery.WriteMetadata(Path.Combine(output, "details.json"));
- if (opts.Pack)
- {
- await Compress.ToCbz(output, childProgress);
- }
- }
- };
-
- await downloader.Start();
- mainProgress.Tick();
- }
- }
-
- private static IReadOnlyList FilterValidUrls(IEnumerable urls)
- {
- return urls.Where(url => new Regex("^http(s)?:\\/\\/(nhentai\\.net)\\b([//g]*)\\b([\\d]{1,6})\\/?$").IsMatch(url)).ToList();
- }
-
- private static bool IsFileExceedingToFileSizeLimit(string inputFile)
- {
- var fileSize = new FileInfo(inputFile).Length;
- return fileSize > 5242880;
- }
-}
diff --git a/Commandline/Parsers/GetCommandService.cs b/Commandline/Parsers/GetCommandService.cs
deleted file mode 100644
index a16957a..0000000
--- a/Commandline/Parsers/GetCommandService.cs
+++ /dev/null
@@ -1,87 +0,0 @@
-using System;
-using System.IO;
-using System.Threading.Tasks;
-using asuka.Api;
-using asuka.Commandline.Options;
-using asuka.Core.Compression;
-using asuka.Core.Downloader;
-using asuka.Core.Extensions;
-using asuka.Core.Requests;
-using asuka.Core.Utilities;
-using asuka.Output;
-using asuka.Output.Progress;
-using FluentValidation;
-
-namespace asuka.Commandline.Parsers;
-
-public class GetCommandService : ICommandLineParser
-{
- private readonly IGalleryImage _apiImage;
- private readonly IGalleryRequestService _api;
- private readonly IValidator _validator;
- private readonly IProgressFactory _progress;
-
- public GetCommandService(
- IGalleryImage apiImage,
- IGalleryRequestService api,
- IValidator validator,
- IProgressFactory progress)
- {
- _apiImage = apiImage;
- _api = api;
- _validator = validator;
- _progress = progress;
- }
-
- private async Task DownloadTask(int input, bool pack, bool readOnly, string outputPath)
- {
- var response = await _api.FetchSingleAsync(input.ToString());
- Console.WriteLine(response.ToFormattedText());
-
- // Don't download.
- if (readOnly)
- {
- return;
- }
-
- var mainProgress = _progress.Create(response.TotalPages,
- $"Downloading Manga: {response.Title.GetTitle()}");
-
- var output = PathUtils.Join(outputPath, response.Title.GetTitle());
- var downloader = new DownloadBuilder(response, 1)
- {
- Request = _apiImage,
- Output = output,
- OnEachComplete = _ =>
- {
- mainProgress.Tick();
- },
- OnComplete = async gallery =>
- {
- await gallery.WriteMetadata(Path.Combine(output, "details.json"));
- if (pack)
- {
- await Compress.ToCbz(output, mainProgress);
- }
- }
- };
-
- await downloader.Start();
- }
-
- public async Task RunAsync(object options)
- {
- var opts = (GetOptions)options;
- var validationResult = await _validator.ValidateAsync(opts);
- if (!validationResult.IsValid)
- {
- validationResult.Errors.PrintValidationExceptions();
- return;
- }
-
- foreach (var code in opts.Input)
- {
- await DownloadTask(code, opts.Pack, opts.ReadOnly, opts.Output);
- }
- }
-}
diff --git a/Commandline/Parsers/RandomCommandService.cs b/Commandline/Parsers/RandomCommandService.cs
deleted file mode 100644
index ff0d2b9..0000000
--- a/Commandline/Parsers/RandomCommandService.cs
+++ /dev/null
@@ -1,77 +0,0 @@
-using System;
-using System.IO;
-using System.Threading.Tasks;
-using asuka.Api;
-using asuka.Commandline.Options;
-using asuka.Core.Compression;
-using asuka.Core.Downloader;
-using asuka.Core.Extensions;
-using asuka.Core.Requests;
-using asuka.Core.Utilities;
-using asuka.Output.Progress;
-using Sharprompt;
-
-namespace asuka.Commandline.Parsers;
-
-public class RandomCommandService : ICommandLineParser
-{
- private readonly IGalleryRequestService _api;
- private readonly IGalleryImage _apiImage;
- private readonly IProgressFactory _progress;
-
- public RandomCommandService(
- IGalleryRequestService api,
- IGalleryImage apiImage,
- IProgressFactory progress)
- {
- _api = api;
- _apiImage = apiImage;
- _progress = progress;
- }
-
- public async Task RunAsync(object options)
- {
- var opts = (RandomOptions)options;
- var totalNumbers = await _api.GetTotalGalleryCountAsync();
-
- while (true)
- {
- var randomCode = new Random().Next(1, totalNumbers);
- var response = await _api.FetchSingleAsync(randomCode.ToString());
-
- Console.WriteLine(response.ToFormattedText());
-
- var prompt = Prompt.Confirm("Are you sure to download this one?", true);
- if (!prompt)
- {
- await Task.Delay(1000).ConfigureAwait(false);
- continue;
- }
-
- var mainProgress = _progress.Create(response.TotalPages,
- $"Downloading Manga: {response.Title.GetTitle()}");
-
- var output = PathUtils.Join(opts.Output, response.Title.GetTitle());
- var downloader = new DownloadBuilder(response, 1)
- {
- Output = output,
- Request = _apiImage,
- OnEachComplete = _ =>
- {
- mainProgress.Tick();
- },
- OnComplete = async data =>
- {
- await data.WriteMetadata(Path.Combine(output, "details.json"));
- if (opts.Pack)
- {
- await Compress.ToCbz(output, mainProgress);
- }
- }
- };
-
- await downloader.Start();
- break;
- }
- }
-}
diff --git a/Commandline/Parsers/RecommendCommandService.cs b/Commandline/Parsers/RecommendCommandService.cs
deleted file mode 100644
index 2c48921..0000000
--- a/Commandline/Parsers/RecommendCommandService.cs
+++ /dev/null
@@ -1,79 +0,0 @@
-using System.IO;
-using System.Threading.Tasks;
-using asuka.Api;
-using asuka.Commandline.Options;
-using asuka.Core.Compression;
-using asuka.Core.Downloader;
-using asuka.Core.Extensions;
-using asuka.Core.Mappings;
-using asuka.Core.Requests;
-using asuka.Core.Utilities;
-using asuka.Output;
-using asuka.Output.Progress;
-using FluentValidation;
-
-namespace asuka.Commandline.Parsers;
-
-public class RecommendCommandService : ICommandLineParser
-{
- private readonly IValidator _validator;
- private readonly IGalleryRequestService _api;
- private readonly IGalleryImage _apiImage;
- private readonly IProgressFactory _progress;
-
- public RecommendCommandService(
- IValidator validator,
- IGalleryRequestService api,
- IGalleryImage apiImage,
- IProgressFactory progress)
- {
- _validator = validator;
- _api = api;
- _apiImage = apiImage;
- _progress = progress;
- }
-
- public async Task RunAsync(object options)
- {
- var opts = (RecommendOptions)options;
- var validator = await _validator.ValidateAsync(opts);
- if (!validator.IsValid)
- {
- validator.Errors.PrintValidationExceptions();
- return;
- }
-
- var responses = await _api.FetchRecommendedAsync(opts.Input.ToString());
- var selection = responses.FilterByUserSelected();
-
- // Initialise the Progress bar.
- var mainProgress = _progress.Create(selection.Count, $"Downloading {opts.Input} recommendations...");
-
- foreach (var response in selection)
- {
- var childProgress = mainProgress
- .Spawn(response.TotalPages, $"Downloading {response.Title.GetTitle()}")!;
- var output = PathUtils.Join(opts.Output, response.Title.GetTitle());
- var downloader = new DownloadBuilder(response, 1)
- {
- Request = _apiImage,
- Output = output,
- OnEachComplete = _ =>
- {
- childProgress.Tick();
- },
- OnComplete = async data =>
- {
- await data.WriteMetadata(Path.Combine(output, "details.json"));
- if (opts.Pack)
- {
- await Compress.ToCbz(output, childProgress);
- }
- }
- };
-
- await downloader.Start();
- mainProgress.Tick();
- }
- }
-}
diff --git a/Commandline/Parsers/SearchCommandService.cs b/Commandline/Parsers/SearchCommandService.cs
deleted file mode 100644
index c3eb956..0000000
--- a/Commandline/Parsers/SearchCommandService.cs
+++ /dev/null
@@ -1,103 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.IO;
-using System.Linq;
-using System.Threading.Tasks;
-using asuka.Api;
-using asuka.Commandline.Options;
-using asuka.Api.Queries;
-using asuka.Core.Compression;
-using asuka.Core.Downloader;
-using asuka.Core.Extensions;
-using asuka.Core.Mappings;
-using asuka.Core.Requests;
-using asuka.Core.Utilities;
-using asuka.Output;
-using asuka.Output.Progress;
-using FluentValidation;
-
-namespace asuka.Commandline.Parsers;
-
-public class SearchCommandService : ICommandLineParser
-{
- private readonly IGalleryRequestService _api;
- private readonly IGalleryImage _apiImage;
- private readonly IValidator _validator;
- private readonly IProgressFactory _progress;
-
- public SearchCommandService(
- IGalleryRequestService api,
- IGalleryImage apiImage,
- IValidator validator,
- IProgressFactory progress)
- {
- _api = api;
- _apiImage = apiImage;
- _validator = validator;
- _progress = progress;
- }
-
- public async Task RunAsync(object options)
- {
- var opts = (SearchOptions)options;
- var validationResult = await _validator.ValidateAsync(opts);
- if (!validationResult.IsValid)
- {
- validationResult.Errors.PrintValidationExceptions();
- return;
- }
-
- // Construct search query
- var searchQueries = new List();
- searchQueries.AddRange(opts.Queries);
- searchQueries.AddRange(opts.Exclude.Select(q => $"-{q}"));
- searchQueries.AddRange(opts.DateRange.Select(d => $"uploaded:{d}"));
- searchQueries.AddRange(opts.PageRange.Select(p => $"pages:{p}"));
-
- var query = new SearchQuery
- {
- Queries = string.Join(" ", searchQueries),
- PageNumber = opts.Page,
- Sort = opts.Sort
- };
-
- var responses = await _api.SearchAsync(query);
- if (responses.Count < 1)
- {
- Console.WriteLine("No results found.");
- return;
- }
-
- var selection = responses.FilterByUserSelected();
-
- // Initialise the Progress bar.
- var mainProgress = _progress.Create(selection.Count, "Downloading found results...");
-
- foreach (var response in selection)
- {
- var childProgress = mainProgress
- .Spawn(response.TotalPages, $"Downloading {response.Title.GetTitle()}")!;
- var output = PathUtils.Join(opts.Output, response.Title.GetTitle());
- var downloader = new DownloadBuilder(response, 1)
- {
- Output = output,
- Request = _apiImage,
- OnEachComplete = _ =>
- {
- childProgress.Tick();
- },
- OnComplete = async data =>
- {
- await data.WriteMetadata(Path.Combine(output, "details.json"));
- if (opts.Pack)
- {
- await Compress.ToCbz(output, childProgress);
- }
- }
- };
-
- await downloader.Start();
- mainProgress.Tick();
- }
- }
-}
diff --git a/Commandline/Parsers/SeriesCreatorCommandService.cs b/Commandline/Parsers/SeriesCreatorCommandService.cs
deleted file mode 100644
index 47178fa..0000000
--- a/Commandline/Parsers/SeriesCreatorCommandService.cs
+++ /dev/null
@@ -1,75 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Threading.Tasks;
-using asuka.Api;
-using asuka.Commandline.Options;
-using asuka.Core.Requests;
-using asuka.Downloader;
-using asuka.Output;
-using asuka.Output.Progress;
-using FluentValidation;
-
-namespace asuka.Commandline.Parsers;
-
-public class SeriesCreatorCommandService : ICommandLineParser
-{
- private readonly IGalleryRequestService _api;
- private readonly IGalleryImage _apiImage;
- private readonly IValidator _validator;
- private readonly IProgressFactory _progress;
-
- public SeriesCreatorCommandService(
- IGalleryRequestService api,
- IGalleryImage apiImage,
- IValidator validator,
- IProgressFactory progress)
- {
- _api = api;
- _apiImage = apiImage;
- _validator = validator;
- _progress = progress;
- }
-
- private async Task HandleArrayTask(IList codes, string output, bool pack)
- {
- var mainProgressBar = _progress.Create(codes.Count, $"Downloading series...");
- var downloader = new SeriesDownloaderBuilder()
- {
- GalleryImage = _apiImage,
- Output = output,
- Progress = mainProgressBar,
- Pack = pack,
- };
-
- foreach (var code in codes)
- {
- try
- {
- var galleryResponse = await _api.FetchSingleAsync(code);
- downloader.AddChapter(galleryResponse);
- }
- catch
- {
- Console.WriteLine($"Skipping: {code} because of an error.");
- }
- }
-
- await downloader.Start();
- }
-
- public async Task RunAsync(object options)
- {
- var opts = (SeriesCreatorCommandOptions)options;
-
- var validationResult = await _validator.ValidateAsync(opts);
- if (!validationResult.IsValid)
- {
- validationResult.Errors.PrintValidationExceptions();
- return;
- }
-
- var list = opts.FromList.ToList();
- await HandleArrayTask(list, opts.Output, opts.Pack);
- }
-}
diff --git a/Configuration/AppConfigManager.cs b/Configuration/AppConfigManager.cs
deleted file mode 100644
index 3797aa4..0000000
--- a/Configuration/AppConfigManager.cs
+++ /dev/null
@@ -1,108 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.IO;
-using System.Linq;
-using System.Text;
-using System.Text.RegularExpressions;
-using System.Threading.Tasks;
-
-namespace asuka.Configuration;
-
-public class AppConfigManager : IAppConfigManager
-{
- private Dictionary _config;
-
- public AppConfigManager()
- {
- var configRoot = Path.Join(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), ".asuka");
- var configPath = Path.Join(configRoot, "config.conf");
-
- if (!File.Exists(configPath))
- {
- Directory.CreateDirectory(configRoot);
- _config = GetDefaults();
-
- return;
- }
-
- var data = File.ReadAllText(configPath, Encoding.UTF8);
- _config = ReadConfiguration(data);
- }
-
- private Dictionary GetDefaults()
- {
- return new Dictionary
- {
- {
- "colors.theme", "dark"
- },
- {
- "tui.progress", "progress"
- }
- };
- }
-
- private Dictionary ReadConfiguration(string fileData)
- {
- var config = fileData.Split("\n");
-
- var dict = new Dictionary();
- foreach (var value in config)
- {
- var regex = new Regex("^([a-z1-9.]+)=([a-z1-9])+$");
- if (!regex.IsMatch(value))
- {
- continue;
- }
-
- var configValue = value.Split('=');
- dict.Add(configValue[0], configValue[1]);
- }
-
- // Ensure we populate all configuraiton options
- foreach (var value in GetDefaults())
- {
- if (!dict.ContainsKey(value.Key))
- {
- dict.Add(value.Key, value.Value);
- }
- }
-
- return dict;
- }
-
- public void SetValue(string key, string value)
- {
- _config[key] = value;
- }
-
- public string GetValue(string key)
- {
- return _config.GetValueOrDefault(key) ?? "";
- }
-
- public IReadOnlyList<(string, string)> GetAllValues()
- {
- return _config.Select(x => (x.Key, x.Value)).ToList();
- }
-
- public async Task Reset()
- {
- _config = GetDefaults();
- await Flush();
- }
-
- public async Task Flush()
- {
- var configPath = Path.Join(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
- ".asuka/config.conf");
-
- var stringBuilder = new StringBuilder();
- foreach (var (key, value) in _config)
- {
- stringBuilder.Append($"{key}={value}\n");
- }
-
- await File.WriteAllTextAsync(configPath, stringBuilder.ToString());
- }
-}
diff --git a/Configuration/ApplicationSettingsModel.cs b/Configuration/ApplicationSettingsModel.cs
deleted file mode 100644
index ac5d51f..0000000
--- a/Configuration/ApplicationSettingsModel.cs
+++ /dev/null
@@ -1,34 +0,0 @@
-namespace asuka.Configuration;
-
-public record CookieMetadata
-{
- public string Name { get; set; } = "";
- public string Value { get; set; } = "";
- public string Domain { get; set; } = "";
- public bool HttpOnly { get; set; }
- public bool Secure { get; set; }
-}
-
-public record CookieStore
-{
- public CookieMetadata CloudflareClearance { get; set; } = new();
- public CookieMetadata CsrfToken { get; set; } = new();
-}
-
-public record RequestOptions
-{
- public CookieStore Cookies { get; set; } = new();
- public string UserAgent { get; set; } = "";
-}
-
-public record Addresses
-{
- public string ApiBaseAddress { get; set; } = "";
- public string ImageBaseAddress { get; set; } = "";
-}
-
-public record ApplicationSettingsModel
-{
- public Addresses BaseAddresses { get; set; } = new();
- public RequestOptions RequestOptions { get; init; } = new();
-}
diff --git a/Configuration/CookieDump.cs b/Configuration/CookieDump.cs
deleted file mode 100644
index 3a68e9f..0000000
--- a/Configuration/CookieDump.cs
+++ /dev/null
@@ -1,21 +0,0 @@
-using System.Text.Json.Serialization;
-
-namespace asuka.Configuration;
-
-public record CookieDump
-{
- [JsonPropertyName("domain")]
- public string Domain { get; set; } = "";
-
- [JsonPropertyName("httpOnly")]
- public bool HttpOnly { get; set; }
-
- [JsonPropertyName("secure")]
- public bool Secure { get; set; }
-
- [JsonPropertyName("name")]
- public string Name { get; set; } = "";
-
- [JsonPropertyName("value")]
- public string Value { get; set; } = "";
-}
diff --git a/Configuration/IAppConfigManager.cs b/Configuration/IAppConfigManager.cs
deleted file mode 100644
index 9caf3d7..0000000
--- a/Configuration/IAppConfigManager.cs
+++ /dev/null
@@ -1,13 +0,0 @@
-using System.Collections.Generic;
-using System.Threading.Tasks;
-
-namespace asuka.Configuration;
-
-public interface IAppConfigManager
-{
- void SetValue(string key, string value);
- string GetValue(string key);
- IReadOnlyList<(string, string)> GetAllValues();
- Task Reset();
- Task Flush();
-}
diff --git a/Configuration/IRequestConfigurator.cs b/Configuration/IRequestConfigurator.cs
deleted file mode 100644
index 15645ec..0000000
--- a/Configuration/IRequestConfigurator.cs
+++ /dev/null
@@ -1,10 +0,0 @@
-using System.Threading.Tasks;
-
-namespace asuka.Configuration;
-
-public interface IRequestConfigurator
-{
- Task ApplyCookies(CookieDump clearance, CookieDump csrf);
- Task ApplyUserAgent(string userAgent);
- Task ChangeBaseAddresses(string apiEndpoint, string imageEndpoint);
-}
diff --git a/Configuration/RequestConfigurator.cs b/Configuration/RequestConfigurator.cs
deleted file mode 100644
index 932b308..0000000
--- a/Configuration/RequestConfigurator.cs
+++ /dev/null
@@ -1,81 +0,0 @@
-using System.IO;
-using System.Reflection;
-using System.Text.Json;
-using System.Threading.Tasks;
-
-namespace asuka.Configuration;
-
-public class RequestConfigurator : IRequestConfigurator
-{
- private readonly string _appSettingsPath;
-
- public RequestConfigurator()
- {
- var assemblyDir = Path.GetDirectoryName(Assembly.GetEntryAssembly()!.Location)!;
- _appSettingsPath = Path.Combine(assemblyDir, "appsettings.json");
- }
-
- private async Task ReadSettings()
- {
- var file = await File.ReadAllTextAsync(_appSettingsPath);
- var config = JsonSerializer.Deserialize(file);
-
- return config ?? new ApplicationSettingsModel();
- }
-
- private async Task WriteSettings(ApplicationSettingsModel settings)
- {
- var jsonConfig = JsonSerializer.Serialize(settings, new JsonSerializerOptions
- {
- WriteIndented = true
- });
- await File.WriteAllTextAsync(_appSettingsPath, jsonConfig);
- }
-
- public async Task ApplyCookies(CookieDump clearance, CookieDump csrf)
- {
- var config = await ReadSettings();
-
- config.RequestOptions.Cookies.CloudflareClearance = new CookieMetadata
- {
- Name = clearance.Name,
- Domain = clearance.Domain,
- HttpOnly = clearance.HttpOnly,
- Secure = clearance.Secure,
- Value = clearance.Value
- };
-
- config.RequestOptions.Cookies.CsrfToken = new CookieMetadata
- {
- Name = csrf.Name,
- Domain = csrf.Domain,
- HttpOnly = csrf.HttpOnly,
- Secure = csrf.Secure,
- Value = csrf.Value
- };
-
- await WriteSettings(config);
- }
-
- public async Task ApplyUserAgent(string userAgent)
- {
- var config = await ReadSettings();
-
- config.RequestOptions.UserAgent = userAgent;
-
- await WriteSettings(config);
- }
-
- public async Task ChangeBaseAddresses(string apiEndpoint, string imageEndpoint)
- {
- var config = await ReadSettings();
-
- config.BaseAddresses = new Addresses
- {
- ApiBaseAddress = apiEndpoint,
- ImageBaseAddress = imageEndpoint
- };
-
- await WriteSettings(config);
- }
-}
diff --git a/Core/Downloader/DownloadBuilder.cs b/Core/Downloader/DownloadBuilder.cs
deleted file mode 100644
index 4bdc2f8..0000000
--- a/Core/Downloader/DownloadBuilder.cs
+++ /dev/null
@@ -1,89 +0,0 @@
-using System;
-using System.IO;
-using System.Linq;
-using System.Threading;
-using System.Threading.Tasks;
-using asuka.Api;
-using asuka.Core.Models;
-
-namespace asuka.Core.Downloader;
-
-public class DownloadStatus
-{
- public bool Success { get; set; }
- public required string FileName { get; set; }
-}
-
-public class DownloadBuilder
-{
- private readonly GalleryResult _chapter;
- private readonly int _chapterId;
-
- public required IGalleryImage Request { get; init; }
- public required string Output { get; init; }
- public Action? OnEachComplete { get; init; }
- public Func? OnComplete { get; init; }
-
- public DownloadBuilder(GalleryResult chapter, int chapterId)
- {
- _chapter = chapter;
- _chapterId = chapterId;
- }
-
- public async Task Start()
- {
- var chapterPath = Path.Combine(Output, $"ch{_chapterId}");
- if (!Directory.Exists(chapterPath))
- {
- Directory.CreateDirectory(chapterPath);
- }
-
- var semaphore = new SemaphoreSlim(2);
- var tasks = _chapter.Images
- .Select(x => Task.Run(async () =>
- {
- await semaphore.WaitAsync().ConfigureAwait(false);
-
- var filePath = Path.Combine(chapterPath, x.Filename);
- await DownloadImage(filePath, _chapter.MediaId.ToString(), x.ServerFilename);
-
- semaphore.Release();
- }));
-
- await Task.WhenAll(tasks)
- .ConfigureAwait(false);
-
- OnComplete?.Invoke(_chapter);
- }
-
- private async Task DownloadImage(string outputPath, string mediaId, string serverFileName)
- {
- var status = new DownloadStatus
- {
- Success = true,
- FileName = Path.GetFileName(outputPath)
- };
-
- try
- {
- if (File.Exists(outputPath))
- {
- OnEachComplete?.Invoke(status);
- return;
- }
-
- var image = await Request.GetImage(mediaId, serverFileName);
- var data = await image.ReadAsByteArrayAsync();
-
- await File.WriteAllBytesAsync(outputPath, data)
- .ConfigureAwait(false);
-
- OnEachComplete?.Invoke(status);
- }
- catch
- {
- status.Success = false;
- OnEachComplete?.Invoke(status);
- }
- }
-}
diff --git a/Core/Extensions/GalleryResultExtensions.cs b/Core/Extensions/GalleryResultExtensions.cs
deleted file mode 100644
index 131d260..0000000
--- a/Core/Extensions/GalleryResultExtensions.cs
+++ /dev/null
@@ -1,63 +0,0 @@
-using System.Collections.Generic;
-using System.IO;
-using System.Text;
-using System.Text.Json;
-using System.Threading.Tasks;
-using asuka.Core.Models;
-using asuka.Output;
-
-namespace asuka.Core.Extensions;
-
-public static class GalleryResultExtensions
-{
- public static async Task WriteMetadata(this GalleryResult result, string destination)
- {
- var serializerOptions = new JsonSerializerOptions { WriteIndented = true };
- var metadata = JsonSerializer
- .Serialize(CreateTachiyomiDetails(result), serializerOptions);
-
- await File.WriteAllTextAsync(destination, metadata).ConfigureAwait(false);
- }
-
- public static string ToFormattedText(this GalleryResult result)
- {
- var builder = new StringBuilder();
-
- builder.AppendLine("Title =========================================");
- builder.AppendLine($"Japanese: {result.Title.Japanese}");
- builder.AppendLine($"English: {result.Title.English}");
- builder.AppendLine($"Pretty: {result.Title.Pretty}");
-
- builder.AppendLine("Tags ==========================================");
- builder.AppendLine($"Artists: {SafeJoin(result.Artists)}");
- builder.AppendLine($"Parodies: {SafeJoin(result.Parodies)}");
- builder.AppendLine($"Characters: {SafeJoin(result.Characters)}");
- builder.AppendLine($"Categories: {SafeJoin(result.Categories)}");
- builder.AppendLine($"Groups: {SafeJoin(result.Groups)}");
- builder.AppendLine($"Tags: {SafeJoin(result.Tags)}");
- builder.AppendLine($"Language: {SafeJoin(result.Languages)}");
-
- builder.AppendLine("===============================================");
- builder.AppendLine($"Total Pages: {result.TotalPages}");
- builder.AppendLine($"URL: https://nhentai.net/g/{result.Id}\n");
-
- return builder.ToString();
- }
-
- private static TachiyomiDetails CreateTachiyomiDetails(GalleryResult result)
- {
- return new TachiyomiDetails
- {
- Title = result.Title.GetTitle(),
- Author = SafeJoin(result.Artists),
- Artist = SafeJoin(result.Artists),
- Description = $"Source: https://nhentai.net/g/{result.Id}",
- Genres = result.Tags
- };
- }
-
- private static string SafeJoin(IEnumerable? strings)
- {
- return strings == null ? string.Empty : string.Join(", ", strings);
- }
-}
\ No newline at end of file
diff --git a/Core/Extensions/GalleryTitleResultExtension.cs b/Core/Extensions/GalleryTitleResultExtension.cs
deleted file mode 100644
index 181d954..0000000
--- a/Core/Extensions/GalleryTitleResultExtension.cs
+++ /dev/null
@@ -1,38 +0,0 @@
-using asuka.Core.Models;
-
-namespace asuka.Core.Extensions;
-
-public static class GalleryTitleResultExtension
-{
- public static string GetTitle(this GalleryTitleResult title)
- {
- if (!string.IsNullOrEmpty(title.Japanese))
- {
- return title.Japanese;
- }
- if (!string.IsNullOrEmpty(title.English))
- {
- return title.English;
- }
- return !string.IsNullOrEmpty(title.Pretty) ? title.Pretty : "Unknown title";
- }
-
- public static string? GetTitleByLanguage(this GalleryTitleResult title, TitleLanguages language)
- {
- if (!string.IsNullOrEmpty(title.Japanese) && language == TitleLanguages.Japanese)
- {
- return title.Japanese;
- }
- if (!string.IsNullOrEmpty(title.English) && language == TitleLanguages.English)
- {
- return title.English;
- }
-
- if (!string.IsNullOrEmpty(title.Pretty) && language == TitleLanguages.Pretty)
- {
- return title.Pretty;
- }
-
- return null;
- }
-}
\ No newline at end of file
diff --git a/Core/Extensions/TitleLanguages.cs b/Core/Extensions/TitleLanguages.cs
deleted file mode 100644
index 92f394b..0000000
--- a/Core/Extensions/TitleLanguages.cs
+++ /dev/null
@@ -1,19 +0,0 @@
-namespace asuka.Core.Extensions;
-
-public enum TitleLanguages
-{
- ///
- /// Official title written in native language. This can be either in Japanese or
- /// others
- ///
- Japanese,
- ///
- /// Translated title
- ///
- English,
- ///
- /// Either english or japanese or official title written in native language without
- /// other tags
- ///
- Pretty
-}
\ No newline at end of file
diff --git a/Core/Mappings/ContractToGalleryImageResultModelMapping.cs b/Core/Mappings/ContractToGalleryImageResultModelMapping.cs
deleted file mode 100644
index ecf42ca..0000000
--- a/Core/Mappings/ContractToGalleryImageResultModelMapping.cs
+++ /dev/null
@@ -1,34 +0,0 @@
-using System.Collections.Generic;
-using System.Linq;
-using asuka.Api.Responses;
-using asuka.Core.Models;
-
-namespace asuka.Core.Mappings;
-
-public static class ContractToGalleryImageResultModelMapping
-{
- public static IReadOnlyList ToGalleryImageResult(
- this IReadOnlyList response)
- {
- return response.Select((value, index) =>
- {
- var extension = value.Type switch
- {
- "j" => ".jpg",
- "p" => ".png",
- "g" => ".gif",
- _ => ""
- };
-
- var pageNumber = index + 1;
- var pageNumberFormatted = pageNumber.ToString($"D{response.Count.ToString().Length}");
- var filename = $"{pageNumberFormatted}{extension}";
-
- return new GalleryImageResult
- {
- ServerFilename = $"{pageNumber}{extension}",
- Filename = filename
- };
- }).ToList();
- }
-}
diff --git a/Core/Mappings/ContractToGalleryResultModelMapping.cs b/Core/Mappings/ContractToGalleryResultModelMapping.cs
deleted file mode 100644
index 3cb6990..0000000
--- a/Core/Mappings/ContractToGalleryResultModelMapping.cs
+++ /dev/null
@@ -1,32 +0,0 @@
-using asuka.Api.Responses;
-using asuka.Core.Models;
-
-namespace asuka.Core.Mappings;
-
-public static class ContractToGalleryResultModelMapping
-{
- public static GalleryResult ToGalleryResult(this GalleryResponse response)
- {
- return new GalleryResult
- {
- Id = response.Id,
- MediaId = response.MediaId,
- Title = new GalleryTitleResult
- {
- Japanese = response.Title.Japanese,
- English = response.Title.English,
- Pretty = response.Title.Pretty
- },
- Images = response.Images.Images.ToGalleryImageResult(),
- Artists = response.Tags.GetTagByGroup("artist"),
- Parodies = response.Tags.GetTagByGroup("parody"),
- Characters = response.Tags.GetTagByGroup("character"),
- Tags = response.Tags.GetTagByGroup("tag"),
- Categories = response.Tags.GetTagByGroup("category"),
- // Not sure about this one.
- Groups = response.Tags.GetTagByGroup("group"),
- Languages = response.Tags.GetTagByGroup("language"),
- TotalPages = response.TotalPages
- };
- }
-}
diff --git a/Core/Mappings/ContractToGalleryTagResultModelMapping.cs b/Core/Mappings/ContractToGalleryTagResultModelMapping.cs
deleted file mode 100644
index 2508c6e..0000000
--- a/Core/Mappings/ContractToGalleryTagResultModelMapping.cs
+++ /dev/null
@@ -1,18 +0,0 @@
-using System.Collections.Generic;
-using System.Linq;
-using asuka.Api.Responses;
-
-namespace asuka.Core.Mappings;
-
-public static class ContractToGalleryTagResultModelMapping
-{
- public static IReadOnlyList GetTagByGroup(
- this IEnumerable response,
- string filter)
- {
- return response
- .Where(x => x.Type == filter)
- .Select(x => x.Name)
- .ToList();
- }
-}
diff --git a/Core/Mappings/ContractToUserSelectedModelMapping.cs b/Core/Mappings/ContractToUserSelectedModelMapping.cs
deleted file mode 100644
index e09af52..0000000
--- a/Core/Mappings/ContractToUserSelectedModelMapping.cs
+++ /dev/null
@@ -1,19 +0,0 @@
-using System.Collections.Generic;
-using System.Linq;
-using asuka.Core.Extensions;
-using asuka.Core.Models;
-using Sharprompt;
-
-namespace asuka.Core.Mappings;
-
-public static class ContractToUserSelectedModelMapping
-{
- public static IReadOnlyList FilterByUserSelected(
- this IReadOnlyList response)
- {
- var selection = Prompt.MultiSelect("Select to download", response, response.Count,
- textSelector: result => result.Title.GetTitle());
-
- return selection.ToList();
- }
-}
diff --git a/Core/Models/GalleryImageResult.cs b/Core/Models/GalleryImageResult.cs
deleted file mode 100644
index 56bdef7..0000000
--- a/Core/Models/GalleryImageResult.cs
+++ /dev/null
@@ -1,7 +0,0 @@
-namespace asuka.Core.Models;
-
-public record GalleryImageResult
-{
- public required string ServerFilename { get; init; }
- public required string Filename { get; init; }
-}
diff --git a/Core/Models/GalleryResult.cs b/Core/Models/GalleryResult.cs
deleted file mode 100644
index 5b5b274..0000000
--- a/Core/Models/GalleryResult.cs
+++ /dev/null
@@ -1,19 +0,0 @@
-using System.Collections.Generic;
-
-namespace asuka.Core.Models;
-
-public record GalleryResult
-{
- public int Id { get; init; }
- public int MediaId { get; init; }
- public required GalleryTitleResult Title { get; init; }
- public required IReadOnlyList Images { get; init; }
- public required IReadOnlyList Artists { get; init; }
- public required IReadOnlyList Parodies { get; init; }
- public required IReadOnlyList Characters { get; init; }
- public required IReadOnlyList Tags { get; init; }
- public required IReadOnlyList Categories { get; init; }
- public required IReadOnlyList Languages { get; init; }
- public required IReadOnlyList Groups { get; init; }
- public int TotalPages { get; init; }
-}
diff --git a/Core/Models/GalleryTitleResult.cs b/Core/Models/GalleryTitleResult.cs
deleted file mode 100644
index 77f28e2..0000000
--- a/Core/Models/GalleryTitleResult.cs
+++ /dev/null
@@ -1,8 +0,0 @@
-namespace asuka.Core.Models;
-
-public record GalleryTitleResult
-{
- public string? Japanese { get; init; }
- public string? English { get; init; }
- public string? Pretty { get; init; }
-}
diff --git a/Core/Models/TachiyomiDetails.cs b/Core/Models/TachiyomiDetails.cs
deleted file mode 100644
index 23d66bd..0000000
--- a/Core/Models/TachiyomiDetails.cs
+++ /dev/null
@@ -1,25 +0,0 @@
-using System.Collections.Generic;
-using System.Text.Json.Serialization;
-
-namespace asuka.Core.Models;
-
-public record TachiyomiDetails
-{
- [JsonPropertyName("title")]
- public required string Title { get; init; }
-
- [JsonPropertyName("author")]
- public required string Author { get; init; }
-
- [JsonPropertyName("artist")]
- public required string Artist { get; init; }
-
- [JsonPropertyName("description")]
- public required string Description { get; init; }
-
- [JsonPropertyName("genre")]
- public required IReadOnlyList Genres { get; init; }
-
- [JsonPropertyName("status")]
- public string Status { get; init; } = "2";
-}
diff --git a/Core/Output/Progress/IProgressProvider.cs b/Core/Output/Progress/IProgressProvider.cs
deleted file mode 100644
index f26e7f7..0000000
--- a/Core/Output/Progress/IProgressProvider.cs
+++ /dev/null
@@ -1,47 +0,0 @@
-namespace asuka.Core.Output.Progress;
-
-public interface IProgressProvider
-{
- ///
- /// Increments progress by 1
- ///
- void Tick();
-
- ///
- /// Increments progress and changes the maximum progress to a new one.
- ///
- ///
- void Tick(int newMaxTicks);
-
- ///
- /// Increments progress and changes the text on the progress.
- ///
- ///
- void Tick(string message);
-
- ///
- /// Increments progress and changes the maximum progress and the text.
- ///
- ///
- ///
- void Tick(int newMaxTicks, string message);
-
- ///
- /// Stops the progress bar.
- ///
- void Stop();
-
- ///
- /// Stops the progress with a message
- ///
- ///
- void Stop(string message);
-
- ///
- /// Spawns child progressbar.
- ///
- ///
- ///
- /// Child progressbar
- IProgressProvider? Spawn(int maxTicks, string message);
-}
\ No newline at end of file
diff --git a/Core/Output/Progress/StealthProgressBar.cs b/Core/Output/Progress/StealthProgressBar.cs
deleted file mode 100644
index 4418ae0..0000000
--- a/Core/Output/Progress/StealthProgressBar.cs
+++ /dev/null
@@ -1,39 +0,0 @@
-namespace asuka.Core.Output.Progress;
-
-public class StealthProgressBar : IProgressProvider
-{
- public void Tick()
- {
- // Progress is left empty to print nothing.
- }
-
- public void Tick(int newMaxTicks)
- {
- // Progress is left empty to print nothing.
- }
-
- public void Tick(string message)
- {
- // Progress is left empty to print nothing.
- }
-
- public void Tick(int newMaxTicks, string message)
- {
- // Progress is left empty to print nothing.
- }
-
- public void Stop()
- {
- // Progress is left empty to print nothing.
- }
-
- public void Stop(string message)
- {
- // Progress is left empty to print nothing.
- }
-
- public IProgressProvider Spawn(int maxTicks, string message)
- {
- return new StealthProgressBar();
- }
-}
\ No newline at end of file
diff --git a/Core/Output/Progress/TextProgressBar.cs b/Core/Output/Progress/TextProgressBar.cs
deleted file mode 100644
index b18523f..0000000
--- a/Core/Output/Progress/TextProgressBar.cs
+++ /dev/null
@@ -1,82 +0,0 @@
-using System;
-
-namespace asuka.Core.Output.Progress;
-
-public class TextProgressBar : IProgressProvider
-{
- private readonly int _spacing;
- private int _progress;
- private int _maxTicks;
- private string _title;
- private bool _stopped;
-
- public TextProgressBar(int maxTicks, string title)
- {
- _maxTicks = maxTicks;
- _title = title;
- }
-
- private TextProgressBar(int spacing, int maxTicks, string title)
- {
- _spacing = spacing;
- _maxTicks = maxTicks;
- _title = title;
- }
-
- public void Tick()
- {
- if (_stopped)
- {
- return;
- }
-
- _progress++;
- var print = $"{_title} : {_progress} out of {_maxTicks}";
- Console.WriteLine(print.PadLeft(print.Length + _spacing), ' ');
- }
-
- public void Tick(int newMaxTicks)
- {
- _maxTicks = newMaxTicks;
- Tick();
- }
-
- public void Tick(string message)
- {
- _title = message;
- Tick();
- }
-
- public void Tick(int newMaxTicks, string message)
- {
- _maxTicks = newMaxTicks;
- _title = message;
- Tick();
- }
-
- public void Stop()
- {
- _stopped = true;
- var print = $"{_title} : {_progress} out of {_maxTicks}";
- Console.WriteLine(print.PadLeft(print.Length + _spacing), ' ');
- }
-
- public void Stop(string message)
- {
- _stopped = true;
- _title = message;
-
- var print = $"{_title} : {_progress} out of {_maxTicks}";
- Console.WriteLine(print.PadLeft(print.Length + _spacing), ' ');
- }
-
- public IProgressProvider? Spawn(int maxTicks, string message)
- {
- if (_stopped)
- {
- return null;
- }
-
- return new TextProgressBar(_spacing + 2, maxTicks, message);
- }
-}
diff --git a/Core/Requests/GalleryRequestService.cs b/Core/Requests/GalleryRequestService.cs
deleted file mode 100644
index ad0c949..0000000
--- a/Core/Requests/GalleryRequestService.cs
+++ /dev/null
@@ -1,49 +0,0 @@
-using System.Collections.Generic;
-using System.Linq;
-using System.Threading.Tasks;
-using asuka.Api;
-using asuka.Api.Queries;
-using asuka.Core.Mappings;
-using asuka.Core.Models;
-
-namespace asuka.Core.Requests;
-
-public class GalleryRequestService : IGalleryRequestService
-{
- private readonly IGalleryApi _api;
-
- public GalleryRequestService(IGalleryApi api)
- {
- _api = api;
- }
-
- public async Task FetchSingleAsync(string code)
- {
- var result = await _api.FetchSingle(code)
- .ConfigureAwait(false);
- return result.ToGalleryResult();
- }
-
- public async Task> FetchRecommendedAsync(string code)
- {
- var result = await _api.FetchRecommended(code)
- .ConfigureAwait(false);
- return result.Result.Select(x => x.ToGalleryResult()).ToList();
- }
-
- public async Task> SearchAsync(SearchQuery query)
- {
- var result = await _api.SearchGallery(query)
- .ConfigureAwait(false);
- return result.Result.Select(x => x.ToGalleryResult()).ToList();
- }
-
- public async Task GetTotalGalleryCountAsync()
- {
- var result = await _api.FetchAll()
- .ConfigureAwait(false);
- var id = result.Result[0].Id;
-
- return id;
- }
-}
diff --git a/Core/Requests/IGalleryRequestService.cs b/Core/Requests/IGalleryRequestService.cs
deleted file mode 100644
index a912a89..0000000
--- a/Core/Requests/IGalleryRequestService.cs
+++ /dev/null
@@ -1,14 +0,0 @@
-using System.Collections.Generic;
-using System.Threading.Tasks;
-using asuka.Api.Queries;
-using asuka.Core.Models;
-
-namespace asuka.Core.Requests;
-
-public interface IGalleryRequestService
-{
- Task FetchSingleAsync(string url);
- Task> FetchRecommendedAsync(string url);
- Task> SearchAsync(SearchQuery query);
- Task GetTotalGalleryCountAsync();
-}
diff --git a/Downloader/SeriesDownloaderBuilder.cs b/Downloader/SeriesDownloaderBuilder.cs
deleted file mode 100644
index cf7ba58..0000000
--- a/Downloader/SeriesDownloaderBuilder.cs
+++ /dev/null
@@ -1,69 +0,0 @@
-using System.Collections.Generic;
-using System.IO;
-using System.Threading.Tasks;
-using asuka.Api;
-using asuka.Core.Compression;
-using asuka.Core.Downloader;
-using asuka.Core.Extensions;
-using asuka.Core.Models;
-using asuka.Core.Output.Progress;
-using asuka.Core.Utilities;
-
-namespace asuka.Downloader;
-
-public class SeriesDownloaderBuilder
-{
- private readonly List _chapters;
-
- public required string Output { get; init; }
- public required IGalleryImage GalleryImage { get; init; }
- public required IProgressProvider Progress { get; init; }
- public int StartingChapter { get; init; } = 1;
- public bool Pack { get; init; }
-
- public SeriesDownloaderBuilder()
- {
- _chapters = new List();
- }
-
- public void AddChapter(GalleryResult chapter)
- {
- _chapters.Add(chapter);
- }
-
- public async Task Start()
- {
- if (_chapters.Count <= 0)
- {
- return;
- }
-
- var output = PathUtils.Join(Output, _chapters[0].Title.GetTitle());
-
- for (var i = 0; i < _chapters.Count; i++)
- {
- var childProgress = Progress
- .Spawn(_chapters[i].TotalPages, $"Downloading chapter {StartingChapter + i}")!;
- var downloader = new DownloadBuilder(_chapters[i], StartingChapter + i)
- {
- Output = output,
- Request = GalleryImage,
- OnEachComplete = _ =>
- {
- childProgress.Tick();
- }
- };
-
- await downloader.Start();
- Progress.Tick();
- }
-
- // Write tachiyomi metadata
- await _chapters[0].WriteMetadata(Path.Combine(output, "details.json"));
-
- if (Pack)
- {
- await Compress.ToCbz(output, Progress);
- }
- }
-}
diff --git a/Installers/IInstaller.cs b/Installers/IInstaller.cs
deleted file mode 100644
index b5c5121..0000000
--- a/Installers/IInstaller.cs
+++ /dev/null
@@ -1,9 +0,0 @@
-using Microsoft.Extensions.Configuration;
-using Microsoft.Extensions.DependencyInjection;
-
-namespace asuka.Installers;
-
-public interface IInstaller
-{
- void ConfigureService(IServiceCollection services, IConfiguration configuration);
-}
diff --git a/Installers/InstallServices.cs b/Installers/InstallServices.cs
deleted file mode 100644
index c0fb835..0000000
--- a/Installers/InstallServices.cs
+++ /dev/null
@@ -1,32 +0,0 @@
-using asuka.Commandline;
-using asuka.Commandline.Parsers;
-using asuka.Configuration;
-using asuka.Core.Requests;
-using asuka.Output.Progress;
-using FluentValidation;
-using Microsoft.Extensions.Configuration;
-using Microsoft.Extensions.DependencyInjection;
-
-namespace asuka.Installers;
-
-public class InstallServices : IInstaller
-{
- public void ConfigureService(IServiceCollection services, IConfiguration configuration)
- {
- services.AddSingleton();
- services.AddSingleton();
- services.AddSingleton();
- services.AddSingleton();
-
- // Command parsers
- services.AddKeyedScoped("Cmd_Get");
- services.AddKeyedScoped("Cmd_Recommend");
- services.AddKeyedScoped("Cmd_Search");
- services.AddKeyedScoped("Cmd_Random");
- services.AddKeyedScoped("Cmd_File");
- services.AddKeyedScoped("Cmd_Configure");
- services.AddKeyedScoped("Cmd_Series");
- services.AddKeyedScoped("Cmd_Cookie");
- services.AddValidatorsFromAssemblyContaining();
- }
-}
diff --git a/Installers/InstallerExtension.cs b/Installers/InstallerExtension.cs
deleted file mode 100644
index c7172b8..0000000
--- a/Installers/InstallerExtension.cs
+++ /dev/null
@@ -1,21 +0,0 @@
-using System;
-using System.Linq;
-using Microsoft.Extensions.Configuration;
-using Microsoft.Extensions.DependencyInjection;
-
-namespace asuka.Installers;
-
-public static class InstallerExtension
-{
- public static void InstallServicesInAssembly(this IServiceCollection serviceCollection,
- IConfiguration configuration)
- {
- var installers = typeof(Program).Assembly.ExportedTypes
- .Where(x => typeof(IInstaller).IsAssignableFrom(x) && !x.IsInterface && !x.IsInterface)
- .Select(Activator.CreateInstance)
- .Cast()
- .ToList();
-
- installers.ForEach(installer => installer.ConfigureService(serviceCollection, configuration));
- }
-}
diff --git a/Installers/Refit/ConfigureRefitService.cs b/Installers/Refit/ConfigureRefitService.cs
deleted file mode 100644
index ea5a7fc..0000000
--- a/Installers/Refit/ConfigureRefitService.cs
+++ /dev/null
@@ -1,118 +0,0 @@
-using System;
-using System.Net;
-using System.Net.Http;
-using System.Text.Json;
-using asuka.Api;
-using Microsoft.Extensions.Configuration;
-using Microsoft.Extensions.DependencyInjection;
-using Polly;
-using Polly.Contrib.WaitAndRetry;
-using Refit;
-
-namespace asuka.Installers.Refit;
-
-public class ConfigureRefitService : IInstaller
-{
- public void ConfigureService(IServiceCollection services, IConfiguration configuration)
- {
- var contentSerializerSettings = new JsonSerializerOptions
- {
- WriteIndented = true,
- PropertyNamingPolicy = JsonNamingPolicy.CamelCase
- };
-
- var cloudflare = CreateCookieFromConfig(configuration, "CloudflareClearance");
- var csrf = CreateCookieFromConfig(configuration, "CsrfToken");
-
- // Warn about cookies.
- if (cloudflare == null || csrf == null)
- {
- Console.WriteLine("Cookies might be unset! Run \"asuka cookie\" command to set your cookies!");
- }
-
- var configureRefit = new RefitSettings
- {
- ContentSerializer = new SystemTextJsonContentSerializer(contentSerializerSettings),
- HttpMessageHandlerFactory = () =>
- {
- var handler = new HttpClientHandler();
-
- if (cloudflare != null)
- {
- handler.CookieContainer.Add(cloudflare);
- }
-
- if (csrf != null)
- {
- handler.CookieContainer.Add(csrf);
- }
-
- return handler;
- }
- };
-
- var apiBaseAddress = configuration.GetSection("BaseAddresses")
- .GetValue("ApiBaseAddress") ?? "https://nhentai.net";
- var imageBaseAddress = configuration.GetSection("BaseAddresses")
- .GetValue("ImageBaseAddress") ?? "https://i.nhentai.net";
- var userAgent = configuration.GetSection("RequestOptions")
- .GetValue("UserAgent");
-
- services.AddRefitClient(configureRefit)
- .AddTransientHttpErrorPolicy(ConfigureErrorPolicyBuilder)
- .ConfigureHttpClient(httpClient =>
- {
- httpClient.BaseAddress = new Uri(apiBaseAddress);
- httpClient.DefaultRequestHeaders.UserAgent.TryParseAdd(userAgent);
- });
- services.AddRefitClient()
- .AddTransientHttpErrorPolicy(ConfigureErrorPolicyBuilder)
- .ConfigureHttpClient(httpClient =>
- {
- httpClient.BaseAddress = new Uri(imageBaseAddress);
- httpClient.DefaultRequestHeaders.UserAgent.TryParseAdd(userAgent);
- });
- }
-
- private static IAsyncPolicy ConfigureErrorPolicyBuilder(
- PolicyBuilder builder)
- {
- var delay = Backoff.DecorrelatedJitterBackoffV2(
- TimeSpan.FromSeconds(1), 5);
- return builder.WaitAndRetryAsync(delay, (_, span) =>
- {
- Console.WriteLine($"Retrying in {span.Seconds}");
- });
- }
-
- private static Cookie? CreateCookieFromConfig(IConfiguration configuration, string name)
- {
- var option = configuration
- .GetSection("RequestOptions")
- .GetSection("Cookies")
- .GetSection(name);
-
- if (string.IsNullOrEmpty(option.GetValue("Name")))
- {
- return null;
- }
-
- var cookieName = option.GetValue("Name");
- var cookieValue = option.GetValue("Value");
- var cookieDomain = option.GetValue("Domain");
- var cookieHttpOnlyFlag = option.GetValue("HttpOnly");
- var cookieSecureFlag = option.GetValue("Secure");
-
- if (string.IsNullOrEmpty(cookieName) || string.IsNullOrEmpty(cookieValue) || string.IsNullOrEmpty(cookieDomain))
- {
- return null;
- }
-
- return new Cookie(cookieName, cookieValue)
- {
- Domain = cookieDomain,
- HttpOnly = cookieHttpOnlyFlag,
- Secure = cookieSecureFlag
- };
- }
-}
diff --git a/LICENSE b/LICENSE
index 55ac0bb..8081ba7 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,7 +1,7 @@
-Copyright 2023 Aiko Fujimoto
-
-Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+Copyright 2023 Aiko Fujimoto
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..ff531d3
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,17 @@
+# This restores the dependencies in entire solution
+restore:
+ dotnet restore
+
+# Builds the project
+.PHONY: build
+build:
+ # Restore first the dependencies within the solution
+ dotnet restore
+
+ # Build main application first
+ dotnet publish -p:PublishSingleFile=true --self-contained true -o ./dist/ asuka.Application/asuka.csproj
+
+ # Build the plugins
+ dotnet build -c Release -o ./dist/providers/nhentai asuka.Provider.Nhentai/asuka.Provider.Nhentai.csproj
+ dotnet build -c Release -o ./dist/providers/koharu asuka.Provider.Koharu/asuka.Provider.Koharu.csproj
+ dotnet build -c Release -o ./dist/providers/hitomi asuka.Provider.Hitomi/asuka.Provider.Hitomi.csproj
diff --git a/Output/Progress/IProgressFactory.cs b/Output/Progress/IProgressFactory.cs
deleted file mode 100644
index 1931472..0000000
--- a/Output/Progress/IProgressFactory.cs
+++ /dev/null
@@ -1,9 +0,0 @@
-using asuka.Core.Output.Progress;
-
-namespace asuka.Output.Progress;
-
-public interface IProgressFactory
-{
- IProgressProvider Create(int maxTicks, string message);
- IProgressProvider Create(ProgressTypes type, int maxTicks, string message);
-}
diff --git a/Output/Progress/ProgressFactory.cs b/Output/Progress/ProgressFactory.cs
deleted file mode 100644
index bb3d238..0000000
--- a/Output/Progress/ProgressFactory.cs
+++ /dev/null
@@ -1,39 +0,0 @@
-using asuka.Configuration;
-using asuka.Core.Output.Progress;
-
-namespace asuka.Output.Progress;
-
-public class ProgressFactory : IProgressFactory
-{
- private readonly IAppConfigManager _config;
-
- public ProgressFactory(IAppConfigManager config)
- {
- _config = config;
- }
-
- public IProgressProvider Create(int maxTicks, string message)
- {
- return _config.GetValue("tui.progress") switch
- {
- "stealth" => Create(ProgressTypes.Stealth, maxTicks, message),
- "text" => Create(ProgressTypes.Text, maxTicks, message),
- _ => Create(ProgressTypes.Progress, maxTicks, message)
- };
- }
-
- public IProgressProvider Create(ProgressTypes type, int maxTicks, string message)
- {
- if (type == ProgressTypes.Stealth)
- {
- return new StealthProgressBar();
- }
-
- if (type == ProgressTypes.Text)
- {
- return new TextProgressBar(maxTicks, message);
- }
-
- return new ShellProgressBarWrapper(maxTicks, message);
- }
-}
\ No newline at end of file
diff --git a/Output/Progress/ProgressTypes.cs b/Output/Progress/ProgressTypes.cs
deleted file mode 100644
index ff68b1e..0000000
--- a/Output/Progress/ProgressTypes.cs
+++ /dev/null
@@ -1,8 +0,0 @@
-namespace asuka.Output.Progress;
-
-public enum ProgressTypes
-{
- Progress,
- Text,
- Stealth
-}
\ No newline at end of file
diff --git a/Output/Progress/ShellProgressBarOptions.cs b/Output/Progress/ShellProgressBarOptions.cs
deleted file mode 100644
index 5946f94..0000000
--- a/Output/Progress/ShellProgressBarOptions.cs
+++ /dev/null
@@ -1,14 +0,0 @@
-using System;
-using ShellProgressBar;
-
-namespace asuka.Output.Progress;
-
-public class ShellProgressBarOptions
-{
- public static ProgressBarOptions Options => new ProgressBarOptions
- {
- ForegroundColor = ConsoleColor.Yellow,
- ForegroundColorDone = ConsoleColor.Green,
- ProgressCharacter = '-'
- };
-}
diff --git a/Output/Progress/ShellProgressBarWrapper.cs b/Output/Progress/ShellProgressBarWrapper.cs
deleted file mode 100644
index 190e809..0000000
--- a/Output/Progress/ShellProgressBarWrapper.cs
+++ /dev/null
@@ -1,56 +0,0 @@
-using asuka.Core.Output.Progress;
-using ShellProgressBar;
-
-namespace asuka.Output.Progress;
-
-public class ShellProgressBarWrapper : IProgressProvider
-{
- private readonly IProgressBar _progress;
-
- public ShellProgressBarWrapper(int maxTicks, string message)
- {
- _progress = new ProgressBar(maxTicks, message, ShellProgressBarOptions.Options);
- }
-
- private ShellProgressBarWrapper(IProgressBar progress, int maxTicks, string message)
- {
- _progress = progress.Spawn(maxTicks, message, ShellProgressBarOptions.Options);
- }
-
- public void Tick()
- {
- _progress.Tick();
- }
-
- public void Tick(int newMaxTicks)
- {
- _progress.Tick(newMaxTicks);
- }
-
- public void Tick(string message)
- {
- _progress.Tick(message);
- }
-
- public void Tick(int newMaxTicks, string message)
- {
- _progress.Tick(newMaxTicks, message);
- }
-
- public void Stop()
- {
- var progress = _progress.AsProgress();
- progress.Report(1);
- }
-
- public void Stop(string message)
- {
- _progress.Message = message;
- Stop();
- }
-
- public IProgressProvider Spawn(int maxTicks, string message)
- {
- return new ShellProgressBarWrapper(_progress, maxTicks, message);
- }
-}
diff --git a/Output/ValidationErrorExtensions.cs b/Output/ValidationErrorExtensions.cs
deleted file mode 100644
index 9739687..0000000
--- a/Output/ValidationErrorExtensions.cs
+++ /dev/null
@@ -1,16 +0,0 @@
-using System;
-using System.Collections.Generic;
-using FluentValidation.Results;
-
-namespace asuka.Output;
-
-public static class ValidationErrorExtensions
-{
- public static void PrintValidationExceptions(this List failures)
- {
- foreach (var failure in failures)
- {
- Console.WriteLine(failure.ErrorMessage);
- }
- }
-}
diff --git a/Program.cs b/Program.cs
deleted file mode 100644
index 27e8648..0000000
--- a/Program.cs
+++ /dev/null
@@ -1,60 +0,0 @@
-using System;
-using System.Threading.Tasks;
-using asuka.Commandline;
-using asuka.Commandline.Options;
-using Microsoft.Extensions.Configuration;
-using Microsoft.Extensions.DependencyInjection;
-using asuka.Installers;
-using CommandLine;
-
-var configuration = new ConfigurationBuilder()
- .AddJsonFile("appsettings.json")
- .Build();
-
-var services = new ServiceCollection();
-services.InstallServicesInAssembly(configuration);
-var serviceProvider = services.BuildServiceProvider();
-
-async Task RunCommand(object options, string serviceKey)
-{
- var service = serviceProvider.GetKeyedService($"Cmd_{serviceKey}");
- if (service == null)
- {
- Console.WriteLine($"Unknown command");
- return;
- }
-
- await service.RunAsync(options);
-}
-
-var parser = Parser.Default
- .ParseArguments(args);
-
-try
-{
- await parser.MapResult(
- async (GetOptions opts) => { await RunCommand(opts, "Get"); },
- async (RecommendOptions opts) => { await RunCommand(opts, "Recommend"); },
- async (SearchOptions opts) => { await RunCommand(opts, "Search"); },
- async (RandomOptions opts) => { await RunCommand(opts, "Random"); },
- async (FileCommandOptions opts) => { await RunCommand(opts, "File"); },
- async (ConfigureOptions opts) => { await RunCommand(opts, "Configure"); },
- async (SeriesCreatorCommandOptions opts) => { await RunCommand(opts, "Series"); },
- async (CookieConfigureOptions opts) => { await RunCommand(opts, "Cookie"); },
- errors =>
- {
- foreach (var error in errors)
- {
- Console.WriteLine($"An error occured. Type: {error.Tag}");
- }
-
- return Task.FromResult(1);
- });
-
- return 0;
-}
-catch (Exception e)
-{
- Console.WriteLine($"An error occured. Message: {e.Message}");
- return 1;
-}
diff --git a/README.md b/README.md
index 826d510..9997bda 100644
--- a/README.md
+++ b/README.md
@@ -1,41 +1,36 @@
-
-
-[](https://github.com/aikoofujimotoo/asuka/graphs/commit-activity)
-[](https://www.codacy.com/gh/aikoofujimotoo/asuka/dashboard?utm_source=github.com&utm_medium=referral&utm_content=aikoofujimotoo/asuka&utm_campaign=Badge_Grade)
-[](https://en.wikipedia.org/wiki/Age_of_majority)
-[](LICENSE)
-
-Cross-platform nhentai downloader on Console.
-
-## Requirements
-
-- [.NET 7.0 Runtime](https://dotnet.microsoft.com/download/dotnet/7.0)
-
-- For supported platforms check [here](https://github.com/dotnet/core/blob/main/release-notes/6.0/supported-os.md)*.
- - *Releases supports x64 Operating Systems only. You cannot use this on x86 or ARM. Check Compiling from Source section for compiling builds for these platforms.*
-
-## Usage
-
-[Getting Started](https://github.com/fumiichan/asuka/wiki/Getting-Started)
-
-## Compiling from Source
-
-### What do I need
-
-- [.NET 7.0 SDK](https://dotnet.microsoft.com/download/dotnet/7.0)
-
-### Compiling
-
-To compile, simply use your Terminal of your choice and navigate towards the asuka's source root.
-
-1. `dotnet restore` to restore the packages.
-
-2. `dotnet build` to build.
-
- You can use `--configuration` to specify the configuration to use. Available configurations are `Debug` and `Release`. For more information, check the [`dotnet build` documentation](https://docs.microsoft.com/en-us/dotnet/core/tools/dotnet-build).
-
-You'll see the built packages on `bin\Debug\net6.0` or `bin\Release\net6.0` depending on the build configuration you used.
-
-### License
-
-This project is licensed under [MIT license](LICENSE). For more information about the license, read the [LICENSE](LICENSE) file. It's short I promise.
+
+
+[](https://github.com/aikoofujimotoo/asuka/graphs/commit-activity)
+[](https://www.codacy.com/gh/aikoofujimotoo/asuka/dashboard?utm_source=github.com&utm_medium=referral&utm_content=aikoofujimotoo/asuka&utm_campaign=Badge_Grade)
+[](https://en.wikipedia.org/wiki/Age_of_majority)
+[](LICENSE)
+
+Cross-platform ~~nhentai~~ downloader on Console.
+
+## Requirements
+
+- [.NET 9.0 Runtime](https://dotnet.microsoft.com/download/dotnet/9.0)
+
+- For supported platforms check [here](https://github.com/dotnet/core/blob/main/release-notes/9.0/supported-os.md)*.
+ - *Releases supports x64 Operating Systems only. You cannot use this on x86 or ARM. Check Compiling from Source section for compiling builds for these platforms.*
+
+## Usage
+
+[Getting Started](https://github.com/fumiichan/asuka/wiki/Getting-Started)
+
+## Compiling from Source
+
+```sh
+# Clone the repository
+git clone https://github.com/fumiichan/asuka.git
+cd asuka
+
+# Build the application
+# If you are planning to build this on Windows, ensure you run it under
+# MinGW-w64 or Git Bash
+make build
+```
+
+### License
+
+This project is licensed under [MIT license](LICENSE).
diff --git a/Validators/ConfigurationValidator.cs b/Validators/ConfigurationValidator.cs
deleted file mode 100644
index 1a4b333..0000000
--- a/Validators/ConfigurationValidator.cs
+++ /dev/null
@@ -1,29 +0,0 @@
-using System.Text.RegularExpressions;
-using asuka.Commandline.Options;
-using FluentValidation;
-
-namespace asuka.Validators;
-
-public class ConfigurationValidator : AbstractValidator
-{
- public ConfigurationValidator()
- {
- When(opts => opts.ReadConfigMode, () =>
- {
- RuleFor(opts => opts.Key)
- .Must(x => !string.IsNullOrEmpty(x))
- .WithMessage("Invalid key");
- });
-
- When(opts => opts.SetConfigMode, () =>
- {
- RuleFor(opts => opts.Key)
- .Must(x => !string.IsNullOrEmpty(x))
- .WithMessage("Invalid key.");
-
- RuleFor(opts => opts.Value)
- .Must(x => !string.IsNullOrEmpty(x))
- .WithMessage("Invalid value");
- });
- }
-}
diff --git a/Validators/CookieConfiguratorValidator.cs b/Validators/CookieConfiguratorValidator.cs
deleted file mode 100644
index 0ea13b0..0000000
--- a/Validators/CookieConfiguratorValidator.cs
+++ /dev/null
@@ -1,18 +0,0 @@
-using System.IO;
-using asuka.Commandline.Options;
-using FluentValidation;
-
-namespace asuka.Validators;
-
-public class CookieConfiguratorValidator : AbstractValidator
-{
- public CookieConfiguratorValidator()
- {
- When(opts => !string.IsNullOrEmpty(opts.CookieFile), () =>
- {
- RuleFor(opts => opts.CookieFile)
- .Must(File.Exists)
- .WithMessage("Your cookie file cannot be found!");
- });
- }
-}
diff --git a/Validators/GetOptionValidator.cs b/Validators/GetOptionValidator.cs
deleted file mode 100644
index 25eaa5e..0000000
--- a/Validators/GetOptionValidator.cs
+++ /dev/null
@@ -1,15 +0,0 @@
-using System.Runtime.InteropServices.JavaScript;
-using asuka.Commandline.Options;
-using FluentValidation;
-
-namespace asuka.Validators;
-
-public class GetValidator : AbstractValidator
-{
- public GetValidator()
- {
- RuleForEach(opts => opts.Input)
- .GreaterThan(0)
- .WithMessage("IDs must not be lower than 0.");
- }
-}
diff --git a/Validators/RecommendedOptionValidator.cs b/Validators/RecommendedOptionValidator.cs
deleted file mode 100644
index 1959755..0000000
--- a/Validators/RecommendedOptionValidator.cs
+++ /dev/null
@@ -1,14 +0,0 @@
-using asuka.Commandline.Options;
-using FluentValidation;
-
-namespace asuka.Validators;
-
-public class RecommendedOptionValidator : AbstractValidator
-{
- public RecommendedOptionValidator()
- {
- RuleFor(opts => opts.Input)
- .GreaterThan(0)
- .WithMessage("Enter a valid gallery code.");
- }
-}
diff --git a/Validators/SearchValidator.cs b/Validators/SearchValidator.cs
deleted file mode 100644
index 016f62d..0000000
--- a/Validators/SearchValidator.cs
+++ /dev/null
@@ -1,29 +0,0 @@
-using asuka.Commandline.Options;
-using FluentValidation;
-
-namespace asuka.Validators;
-
-public class SearchValidator : AbstractValidator
-{
- public SearchValidator()
- {
- RuleForEach(opts => opts.Queries)
- .Must(query => !query.StartsWith("-"))
- .WithMessage("Queries should not start with a dash. Use --exclude option instead.");
-
- RuleFor(opts => opts.Page)
- .GreaterThan(0);
-
- RuleForEach(opts => opts.DateRange)
- .Matches(@"(>|<)?(=)?(\d+)(d|m|w|y)")
- .WithMessage("One or more arguments on your date range is wrong.");
-
- RuleForEach(opts => opts.PageRange)
- .Matches(@"(>|<)?(=)?(\d+)")
- .WithMessage("One or more arguments on your page range is wrong.");
-
- RuleFor(opts => opts.Sort)
- .Matches(@"popular-?(week|today)?|date")
- .WithMessage("Invalid Sort option.");
- }
-}
diff --git a/Validators/SeriesCreatorValidator.cs b/Validators/SeriesCreatorValidator.cs
deleted file mode 100644
index a9c878b..0000000
--- a/Validators/SeriesCreatorValidator.cs
+++ /dev/null
@@ -1,18 +0,0 @@
-using System.Linq;
-using asuka.Commandline.Options;
-using FluentValidation;
-
-namespace asuka.Validators;
-
-public class SeriesCreatorValidator : AbstractValidator
-{
- public SeriesCreatorValidator()
- {
- When(opts => opts.FromList.Any(), () =>
- {
- RuleForEach(opts => opts.FromList)
- .Matches(@"^\d{1,6}$")
- .WithMessage("One or more elements on this list contains invalid Ids.");
- });
- }
-}
diff --git a/appsettings.json b/appsettings.json
deleted file mode 100644
index 958aae5..0000000
--- a/appsettings.json
+++ /dev/null
@@ -1,25 +0,0 @@
-{
- "BaseAddresses": {
- "ApiBaseAddress": "https://nhentai.net",
- "ImageBaseAddress": "https://i.nhentai.net"
- },
- "RequestOptions": {
- "Cookies": {
- "CloudflareClearance": {
- "Name": "",
- "Value": "",
- "Domain": "",
- "HttpOnly": false,
- "Secure": false
- },
- "CsrfToken": {
- "Name": "",
- "Value": "",
- "Domain": "",
- "HttpOnly": false,
- "Secure": false
- }
- },
- "UserAgent": ""
- }
-}
diff --git a/asuka.Application/Commands/FileCommand.cs b/asuka.Application/Commands/FileCommand.cs
new file mode 100644
index 0000000..80c6f52
--- /dev/null
+++ b/asuka.Application/Commands/FileCommand.cs
@@ -0,0 +1,103 @@
+using System;
+using System.IO;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+using asuka.Application.Services.Downloader;
+using asuka.Application.Services.ProviderManager;
+using asuka.Application.Validators;
+using Cocona;
+using Microsoft.Extensions.Logging;
+using Spectre.Console;
+
+namespace asuka.Application.Commands;
+
+internal sealed class FileCommand : CoconaConsoleAppBase
+{
+ private readonly IProviderManager _provider;
+ private readonly IDownloaderBuilder _builder;
+ private readonly ILogger _logger;
+
+ public FileCommand(IProviderManager provider, IDownloaderBuilder builder, ILogger logger)
+ {
+ _provider = provider;
+ _builder = builder;
+ _logger = logger;
+ }
+
+ [Command("file", Aliases = ["f"], Description = "Download galleries from text file")]
+ public async Task RunAsync(
+ [Argument(Description = "Path to the text file to read")]
+ [PathExists]
+ [FileWithinSizeLimits]
+ string file,
+
+ [Option("provider", Description = "Specify a provider to use")]
+ string? provider,
+
+ [Option("pack", ['p'], Description = "Compress downloads to a CBZ archive")]
+ bool pack,
+
+ [Option("output", ['o'], Description = "Specify destination path for downloads")]
+ string? output)
+ {
+ await AnsiConsole.Status()
+ .StartAsync("Running...", async ctx =>
+ {
+ ctx.Status("Reading text file...");
+ var lines = await File.ReadAllLinesAsync(file, Encoding.UTF8, Context.CancellationToken);
+
+ // Avoid lines that is not a full URL.
+ var queue = lines.Where(x =>
+ {
+ return Uri.TryCreate(x, UriKind.Absolute, out var uri)
+ && (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps);
+ }).ToList();
+ AnsiConsole.MarkupLine("Found total of {0} URLs.", queue.Count);
+
+ ctx.Status("Downloading list...");
+ foreach (var url in queue)
+ {
+ if (Context.CancellationToken.IsCancellationRequested)
+ {
+ AnsiConsole.MarkupLine("[orange1]Cancelled.[/]");
+ break;
+ }
+
+ var client = string.IsNullOrEmpty(provider)
+ ? _provider.GetProviderForGalleryId(url)
+ : _provider.GetProviderByAlias(provider);
+
+ if (client == null)
+ {
+ AnsiConsole.MarkupLine("[orange1]Unsupported: {0}[/]", Markup.Escape(url));
+ continue;
+ }
+
+ try
+ {
+ var response = await client.GetSeries(url, Context.CancellationToken);
+ var instance = _builder.CreateDownloaderInstance(client, response);
+ instance.Configure(c =>
+ {
+ c.OutputPath = output;
+ c.Pack = pack;
+ });
+ instance.OnProgress = m => ctx.Status(Markup.Escape(m));
+ await instance.Start();
+ }
+ catch (OperationCanceledException)
+ {
+ break;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError("Fetching failed due to an exception: Series = {series}, Exception = {ex}", url, ex);
+ AnsiConsole.MarkupLine("[red3_1]Failed to download due to an exception: {0}[/]", Markup.Escape(url));
+ }
+ }
+
+ AnsiConsole.MarkupLine("[chartreuse1]All jobs finished.[/]");
+ });
+ }
+}
diff --git a/asuka.Application/Commands/GetCommand.cs b/asuka.Application/Commands/GetCommand.cs
new file mode 100644
index 0000000..9307d58
--- /dev/null
+++ b/asuka.Application/Commands/GetCommand.cs
@@ -0,0 +1,112 @@
+using System;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using asuka.Application.Services.Downloader;
+using asuka.Application.Services.ProviderManager;
+using asuka.Provider.Sdk;
+using Cocona;
+using Microsoft.Extensions.Logging;
+using Spectre.Console;
+
+namespace asuka.Application.Commands;
+
+internal sealed class GetCommand : CoconaConsoleAppBase
+{
+ private readonly IProviderManager _provider;
+ private readonly IDownloaderBuilder _builder;
+ private readonly ILogger _logger;
+
+ public GetCommand(IProviderManager provider, IDownloaderBuilder builder, ILogger logger)
+ {
+ _provider = provider;
+ _builder = builder;
+ _logger = logger;
+ }
+
+ [Command("get", Aliases = ["g"], Description = "Download galleries")]
+ public async Task RunAsync(
+ [Argument(Description = "List of galleries to download")]
+ string[] galleryIds,
+
+ [Option("provider", Description = "Specify a provider to use")]
+ string? provider,
+
+ [Option("pack", ['p'], Description = "Compress downloads to a CBZ archive")]
+ bool pack,
+
+ [Option("output", ['o'], Description = "Specify destination path for downloads")]
+ string? output)
+ {
+ await AnsiConsole.Status()
+ .StartAsync("Running...", async ctx =>
+ {
+ ctx.Status("Retrieving gallery information...");
+
+ var queue = new List<(MetaInfo, Series)>();
+ foreach (var code in galleryIds)
+ {
+ if (Context.CancellationToken.IsCancellationRequested)
+ {
+ AnsiConsole.MarkupLine("[orange1]Cancelled.[/]");
+ break;
+ }
+
+ // Find appropriate provider
+ var client = string.IsNullOrEmpty(provider)
+ ? _provider.GetProviderForGalleryId(code)
+ : _provider.GetProviderByAlias(provider);
+
+ if (client == null)
+ {
+ AnsiConsole.MarkupLine("[orange1]No such provider or unsupported: {0}[/]", code);
+ continue;
+ }
+
+ try
+ {
+ var response = await client.GetSeries(code, Context.CancellationToken);
+ queue.Add((client, response));
+
+ AnsiConsole.MarkupLine("[chartreuse1]Retrieved: {0}[/]", Markup.Escape(response.Title));
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError("Fetching failed due to an exception: Input = {code}, Exception = {ex}", code, ex);
+ AnsiConsole.MarkupLine("[red3_1]Failed to fetch: {0}. See logs for more information.[/]", code);
+ }
+ }
+
+ ctx.Status("Starting download...");
+ foreach (var (client, series) in queue)
+ {
+ if (Context.CancellationToken.IsCancellationRequested)
+ {
+ break;
+ }
+
+ try
+ {
+ var instance = _builder.CreateDownloaderInstance(client, series);
+ instance.Configure(c =>
+ {
+ c.OutputPath = output;
+ c.Pack = pack;
+ });
+ instance.OnProgress = m => ctx.Status(Markup.Escape(m));
+ await instance.Start();
+ }
+ catch (OperationCanceledException)
+ {
+ break;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError("Fetching failed due to an exception: Series = {series}, Exception = {ex}", series, ex);
+ AnsiConsole.MarkupLine("[red3_1]Failed to download: {0}. See logs for more information.[/]", Markup.Escape(series.Title));
+ }
+ }
+
+ AnsiConsole.MarkupLine("[chartreuse1]All jobs finished.[/]");
+ });
+ }
+}
diff --git a/asuka.Application/Commands/InfoCommand.cs b/asuka.Application/Commands/InfoCommand.cs
new file mode 100644
index 0000000..c2413b4
--- /dev/null
+++ b/asuka.Application/Commands/InfoCommand.cs
@@ -0,0 +1,58 @@
+using System;
+using System.Threading.Tasks;
+using asuka.Application.Services.ProviderManager;
+using Cocona;
+using Microsoft.Extensions.Logging;
+
+namespace asuka.Application.Commands;
+
+internal sealed class InfoCommand : CoconaConsoleAppBase
+{
+ private readonly IProviderManager _provider;
+ private readonly ILogger _logger;
+
+ public InfoCommand(IProviderManager provider, ILogger logger)
+ {
+ _provider = provider;
+ _logger = logger;
+ }
+
+ [Command("info", Aliases = ["i"], Description = "View gallery information")]
+ public async Task RunAsync(
+ [Argument(Description = "List of galleries to download")]
+ string galleryId,
+
+ [Option("provider", Description = "Specify a provider to use")]
+ string? provider)
+ {
+ var client = string.IsNullOrEmpty(provider)
+ ? _provider.GetProviderForGalleryId(galleryId)
+ : _provider.GetProviderByAlias(provider);
+
+ if (client == null)
+ {
+ Console.WriteLine($"'{galleryId}' has no providers that supports this ID.");
+ return;
+ }
+
+ try
+ {
+ var result = await client.GetSeries(galleryId, Context.CancellationToken);
+
+ Console.WriteLine($"Title: {result.Title}");
+ Console.WriteLine($"Artist: {string.Join(", ", result.Artists)}");
+ Console.WriteLine($"Genres/Tags: {string.Join(", ", result.Genres)}");
+ Console.WriteLine($"Total Chapters: {result.Chapters.Count}");
+ }
+ catch (OperationCanceledException)
+ {
+ _logger.LogError("Operation canceled by user.");
+ Console.WriteLine("Operation canceled by user.");
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError("An exception occured. Exception: {ex}", ex);
+ Console.WriteLine($"An exception occured. Error: {ex.Message}. See logs for more details");
+ }
+ }
+}
diff --git a/asuka.Application/Commands/ProvidersCommand.cs b/asuka.Application/Commands/ProvidersCommand.cs
new file mode 100644
index 0000000..4273223
--- /dev/null
+++ b/asuka.Application/Commands/ProvidersCommand.cs
@@ -0,0 +1,35 @@
+using System;
+using asuka.Application.Services.ProviderManager;
+using Cocona;
+using Spectre.Console;
+
+namespace asuka.Application.Commands;
+
+internal sealed class ProvidersCommand
+{
+ private readonly IProviderManager _provider;
+
+ public ProvidersCommand(IProviderManager provider)
+ {
+ _provider = provider;
+ }
+
+ [Command("provider", Description = "Lists/Manages the providers currently installed")]
+ public void Run()
+ {
+ var table = new Table();
+ table.AddColumn("Provider ID");
+ table.AddColumn("Version");
+ table.AddColumn("Aliases");
+
+ foreach (var provider in _provider.GetAllRegisteredProviders())
+ {
+ table.AddRow(
+ Markup.Escape(provider.Id),
+ Markup.Escape(provider.Version.ToString()),
+ Markup.Escape(string.Join(", ", provider.Aliases)));
+ }
+
+ AnsiConsole.Write(table);
+ }
+}
diff --git a/asuka.Application/Commands/RandomCommand.cs b/asuka.Application/Commands/RandomCommand.cs
new file mode 100644
index 0000000..97051fa
--- /dev/null
+++ b/asuka.Application/Commands/RandomCommand.cs
@@ -0,0 +1,105 @@
+using System;
+using System.Threading.Tasks;
+using asuka.Application.Services.Downloader;
+using asuka.Application.Services.ProviderManager;
+using Cocona;
+using Microsoft.Extensions.Logging;
+using Spectre.Console;
+
+namespace asuka.Application.Commands;
+
+internal sealed class RandomCommand : CoconaConsoleAppBase
+{
+ private readonly IProviderManager _provider;
+ private readonly IDownloaderBuilder _builder;
+ private readonly ILogger _logger;
+
+ public RandomCommand(IProviderManager provider, IDownloaderBuilder builder, ILogger logger)
+ {
+ _provider = provider;
+ _builder = builder;
+ _logger = logger;
+ }
+
+ [Command("random", Aliases = ["r"], Description = "Randomly pick a gallery")]
+ public async Task RunAsync(
+ [Option("provider", Description = "Specify a provider to use")]
+ string provider,
+
+ [Option("pack", ['p'], Description = "Compress downloads to a CBZ archive")]
+ bool pack,
+
+ [Option("output", ['o'], Description = "Specify destination path for downloads")]
+ string? output)
+ {
+ var client = _provider.GetProviderByAlias(provider);
+ if (client == null)
+ {
+ AnsiConsole.MarkupLine("[red3_1]No provider with ID or alias of '{0}' found[/]", Markup.Escape(provider));
+ return;
+ }
+
+ while (true)
+ {
+ if (Context.CancellationToken.IsCancellationRequested)
+ {
+ _logger.LogWarning("Operation cancelled by the user.");
+ break;
+ }
+
+ try
+ {
+ var random = await client.GetRandom(Context.CancellationToken);
+ Console.WriteLine($"Title: {random.Title}");
+ Console.WriteLine($"Artist: {string.Join(", ", random.Artists)}");
+ Console.WriteLine($"Genres/Tags: {string.Join(", ", random.Genres)}");
+ Console.WriteLine($"Total Chapters: {random.Chapters.Count}");
+
+ var confirmation = AnsiConsole.Prompt(
+ new TextPrompt("Are you sure to download this one?")
+ .AddChoice(true)
+ .AddChoice(false)
+ .DefaultValue(true)
+ .WithConverter(c => c ? "y" : "n"));
+ if (!confirmation)
+ {
+ _logger.LogInformation("Picked no. Title: {code}", random.Title);
+ await Task.Delay(1000).ConfigureAwait(false);
+ continue;
+ }
+
+ _logger.LogInformation("Picked yes to download: {title}", random.Title);
+
+ await AnsiConsole.Status()
+ .StartAsync("Running...", async ctx =>
+ {
+ var downloader = _builder.CreateDownloaderInstance(client, random);
+ downloader.Configure(c =>
+ {
+ c.OutputPath = output;
+ c.Pack = pack;
+ });
+ downloader.OnProgress = m => ctx.Status(Markup.Escape(m));
+ await downloader.Start(Context.CancellationToken);
+ });
+
+ AnsiConsole.MarkupLine("[chartreuse1]All jobs finished.[/]");
+ break;
+ }
+ catch (NotSupportedException)
+ {
+ AnsiConsole.MarkupLine("[red3_1]Unsupported by the provider.[/]");
+ break;
+ }
+ catch (OperationCanceledException)
+ {
+ break;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError("Fetching failed due to an exception: {ex}", ex);
+ AnsiConsole.MarkupLine("[red3_1]Failed. See logs for more information.[/]");
+ }
+ }
+ }
+}
diff --git a/asuka.Application/Commands/RecommendCommand.cs b/asuka.Application/Commands/RecommendCommand.cs
new file mode 100644
index 0000000..ec02ec4
--- /dev/null
+++ b/asuka.Application/Commands/RecommendCommand.cs
@@ -0,0 +1,98 @@
+using System;
+using System.Threading.Tasks;
+using asuka.Application.Services.Downloader;
+using asuka.Application.Services.ProviderManager;
+using asuka.Provider.Sdk;
+using Cocona;
+using Microsoft.Extensions.Logging;
+using Spectre.Console;
+
+namespace asuka.Application.Commands;
+
+internal sealed class RecommendCommand : CoconaConsoleAppBase
+{
+ private readonly IProviderManager _provider;
+ private readonly IDownloaderBuilder _builder;
+ private readonly ILogger _logger;
+
+ public RecommendCommand(IProviderManager provider, IDownloaderBuilder builder, ILogger logger)
+ {
+ _provider = provider;
+ _builder = builder;
+ _logger = logger;
+ }
+
+ [Command("recommend", Aliases = ["rc"], Description = "Download recommendation from specified gallery.")]
+ public async Task RunAsync(
+ [Argument]
+ string gallery,
+
+ [Option("provider", Description = "Specify a provider to use")]
+ string provider,
+
+ [Option("pack", ['p'], Description = "Compress downloads to a CBZ archive")]
+ bool pack,
+
+ [Option("output", ['o'], Description = "Specify destination path for downloads")]
+ string? output)
+ {
+ var client = string.IsNullOrEmpty(provider)
+ ? _provider.GetProviderForGalleryId(gallery)
+ : _provider.GetProviderByAlias(provider);
+
+ if (client == null)
+ {
+ AnsiConsole.MarkupLine("[red3_1]No provider with ID or alias of '{0}' found[/]", Markup.Escape(provider));
+ return;
+ }
+
+ try
+ {
+ var result = await client.GetRecommendations(gallery);
+
+ // Select
+ var selection = AnsiConsole.Prompt(
+ new MultiSelectionPrompt()
+ .Title("Select to download")
+ .Required()
+ .InstructionsText(
+ "[grey](Press [blue][/] to pick, and [green][/] to start downloading)[/]")
+ .AddChoices(result)
+ .UseConverter(x => Markup.Escape(x.Title)));
+
+ _logger.LogInformation("Selection: {selection}", selection);
+ AnsiConsole.MarkupLine("Selected total of {} of galleries to download.", selection.Count);
+
+ await AnsiConsole.Status()
+ .StartAsync("Running...", async ctx =>
+ {
+ foreach (var item in selection)
+ {
+ var instance = _builder.CreateDownloaderInstance(client, item);
+ instance.Configure(c =>
+ {
+ c.OutputPath = output;
+ c.Pack = pack;
+ });
+ instance.OnProgress = m => ctx.Status(Markup.Escape(m));
+ await instance.Start();
+ }
+ });
+ }
+ catch (NotSupportedException)
+ {
+ AnsiConsole.MarkupLine("[red3_1]Unsupported by the provider.[/]");
+ }
+ catch (OperationCanceledException)
+ {
+ AnsiConsole.MarkupLine("[yellow]Cancelled.[/]");
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError("Operation failed due to an exception: {ex}", ex);
+ AnsiConsole.MarkupLine("[red3_1]An exception occurred. See logs for more information.[/]");
+ }
+
+ AnsiConsole.MarkupLine("[chartreuse1]All jobs finished.[/]");
+ }
+}
diff --git a/asuka.Application/Commands/SearchCommand.cs b/asuka.Application/Commands/SearchCommand.cs
new file mode 100644
index 0000000..f0389d4
--- /dev/null
+++ b/asuka.Application/Commands/SearchCommand.cs
@@ -0,0 +1,124 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using asuka.Application.Services.Downloader;
+using asuka.Application.Services.ProviderManager;
+using asuka.Provider.Sdk;
+using Cocona;
+using Microsoft.Extensions.Logging;
+using Spectre.Console;
+
+namespace asuka.Application.Commands;
+
+internal sealed class SearchCommand : CoconaConsoleAppBase
+{
+ private readonly IProviderManager _provider;
+ private readonly IDownloaderBuilder _builder;
+ private readonly ILogger _logger;
+
+ public SearchCommand(IProviderManager provider, IDownloaderBuilder builder, ILogger logger)
+ {
+ _provider = provider;
+ _builder = builder;
+ _logger = logger;
+ }
+
+ [Command("search", Aliases = ["s"], Description = "Search something in the gallery")]
+ public async Task RunAsync(
+ [Option("provider", Description = "Specify a provider to use")]
+ string provider,
+
+ [Option("pack", ['p'], Description = "Compress downloads to a CBZ archive")]
+ bool pack,
+
+ [Option("output", ['o'], Description = "Specify destination path for downloads")]
+ string? output,
+
+ [Option("queries", ['q'], Description = "Search query")]
+ string[]? queries,
+
+ [Option("exclude", ['e'], Description = "Excluded tags/queries")]
+ string[]? excluded,
+
+ [Option("sort", ['s'], Description = "Sort options. Values vary across providers")]
+ string? sort,
+
+ [Option("page", Description = "Specify search page number")]
+ int pageNumber = 1)
+ {
+ var client = _provider.GetProviderByAlias(provider);
+ if (client == null)
+ {
+ AnsiConsole.MarkupLine("[red3_1]No provider with ID or alias of '{0}' found[/]", Markup.Escape(provider));
+ return;
+ }
+
+ var searchArguments = new List();
+ if (queries != null)
+ {
+ searchArguments.AddRange(queries);
+ }
+
+ if (excluded != null)
+ {
+ searchArguments.AddRange(excluded.Select(exclude => $"-{exclude}"));
+ }
+
+ _logger.LogInformation("Search initiated with following arguments: {queries} on page {page}", searchArguments, pageNumber);
+ var query = new SearchQuery
+ {
+ SearchQueries = searchArguments,
+ PageNumber = pageNumber,
+ Sort = sort ?? "popularity"
+ };
+
+ try
+ {
+ var responses = await client.Search(query, Context.CancellationToken);
+ if (responses.Count < 1)
+ {
+ AnsiConsole.MarkupLine("[orange1]No results found.[/]");
+ return;
+ }
+
+ // Select
+ var selection = AnsiConsole.Prompt(
+ new MultiSelectionPrompt()
+ .Title("Select to download")
+ .Required()
+ .InstructionsText(
+ "[grey](Press [blue][/] to pick, and [green][/] to start downloading)[/]")
+ .AddChoices(responses)
+ .UseConverter(x => Markup.Escape(x.Title)));
+
+ _logger.LogInformation("Selected galleries: {selected}", selection);
+ await AnsiConsole.Status()
+ .StartAsync("Running...", async ctx =>
+ {
+ foreach (var series in responses)
+ {
+ ctx.Status($"Starting: {series.Title}...");
+
+ var instance = _builder.CreateDownloaderInstance(client, series);
+ instance.Configure(c =>
+ {
+ c.OutputPath = output;
+ c.Pack = pack;
+ });
+ instance.OnProgress = m => ctx.Status(Markup.Escape(m));
+ await instance.Start();
+ }
+ });
+ }
+ catch (OperationCanceledException)
+ {
+ _logger.LogError("Operation cancelled by the user.");
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError("An exception occured: {ex}", ex);
+ Console.WriteLine($"An exception occured. Error: {ex.Message}. See logs for more information");
+ }
+ }
+}
diff --git a/asuka.Application/Commands/SeriesCommand.cs b/asuka.Application/Commands/SeriesCommand.cs
new file mode 100644
index 0000000..b882e3f
--- /dev/null
+++ b/asuka.Application/Commands/SeriesCommand.cs
@@ -0,0 +1,109 @@
+using System;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using asuka.Application.Services.Downloader;
+using asuka.Application.Services.ProviderManager;
+using asuka.Provider.Sdk;
+using Cocona;
+using Microsoft.Extensions.Logging;
+using Spectre.Console;
+
+namespace asuka.Application.Commands;
+
+internal sealed class SeriesCommand : CoconaConsoleAppBase
+{
+ private readonly IProviderManager _provider;
+ private readonly IDownloaderBuilder _downloader;
+ private readonly ILogger _logger;
+
+ public SeriesCommand(IProviderManager provider, IDownloaderBuilder downloader, ILogger logger)
+ {
+ _provider = provider;
+ _downloader = downloader;
+ _logger = logger;
+ }
+
+ [Command("series", Aliases = ["sc"], Description = "Merge galleries together to form a single series (Single provider only)")]
+ public async Task RunAsync(
+ [Argument(Description = "Galleries to merge (in order)")]
+ string[] galleryIds,
+
+ [Option("provider", Description = "Specify a provider to use")]
+ string provider,
+
+ [Option("pack", ['p'], Description = "Compress downloads to a CBZ archive")]
+ bool pack,
+
+ [Option("output", ['o'], Description = "Specify destination path for downloads")]
+ string? output)
+ {
+ await AnsiConsole.Status()
+ .StartAsync("Running...", async ctx =>
+ {
+ var client = _provider.GetProviderByAlias(provider);
+ if (client == null)
+ {
+ AnsiConsole.MarkupLine("[red3_1]No provider with ID or alias of '{0}' found[/]", Markup.Escape(provider));
+ return;
+ }
+
+ try
+ {
+ ctx.Status("Retrieving gallery information...");
+
+ var queue = new List();
+ foreach (var gallery in galleryIds)
+ {
+ ctx.Status($"Retrieving gallery: {Markup.Escape(gallery)}");
+
+ var response = await client.GetSeries(gallery, Context.CancellationToken);
+ queue.Add(response);
+
+ AnsiConsole.MarkupLine("[chartreuse1]Retrieved: {0}[/]", Markup.Escape(response.Title));
+ }
+
+ ctx.Status("Building series information...");
+ var series = new Series
+ {
+ Title = queue[0].Title,
+ Artists = queue[0].Artists,
+ Authors = queue[0].Authors,
+ Genres = queue[0].Genres,
+ Chapters = [],
+ Status = queue[0].Status
+ };
+
+ var counter = 1;
+ foreach (var item in queue)
+ {
+ foreach (var chapter in item.Chapters)
+ {
+ series.Chapters.Add(new Chapter
+ {
+ Id = counter,
+ Pages = chapter.Pages,
+ });
+ counter++;
+ }
+ }
+
+ ctx.Status("Starting download...");
+ var instance = _downloader.CreateDownloaderInstance(client, series);
+ instance.Configure(c =>
+ {
+ c.OutputPath = output;
+ c.Pack = pack;
+ });
+ instance.OnProgress = m => ctx.Status(Markup.Escape(m));
+ await instance.Start();
+ }
+ catch (OperationCanceledException)
+ { }
+ catch (Exception ex)
+ {
+ _logger.LogError("Failed to fetch gallery with exception: {ex}", ex);
+ AnsiConsole.MarkupLine("[red3_1]Failed to fetch gallery. See logs for more details.[/]");
+ }
+ });
+ }
+}
diff --git a/asuka.Application/Extensions/SeriesExtensions.cs b/asuka.Application/Extensions/SeriesExtensions.cs
new file mode 100644
index 0000000..a463b49
--- /dev/null
+++ b/asuka.Application/Extensions/SeriesExtensions.cs
@@ -0,0 +1,58 @@
+using System.Collections.Generic;
+using System.IO;
+using System.Text;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using System.Threading.Tasks;
+using asuka.Provider.Sdk;
+
+namespace asuka.Application.Extensions;
+
+internal static class SeriesExtensions
+{
+// ReSharper disable UnusedAutoPropertyAccessor.Local
+#pragma warning disable CS8618
+ private class TachiyomiDetails
+ {
+ [JsonPropertyName("title")]
+ public string Title { get; init; }
+
+ [JsonPropertyName("author")]
+ public string Author { get; init; }
+
+ [JsonPropertyName("artist")]
+ public string Artist { get; init; }
+
+ [JsonPropertyName("description")]
+ public string Description { get; init; }
+
+ [JsonPropertyName("genre")]
+ public IEnumerable Genres { get; init; }
+
+ [JsonPropertyName("status")]
+ public string Status { get; init; }
+ }
+#pragma warning restore CS8618
+// ReSharper restore UnusedAutoPropertyAccessor.Local
+
+ ///
+ /// Save metadata
+ ///
+ ///
+ ///
+ public static async Task WriteMetadata(this Series series, string writePath)
+ {
+ var metadata = new TachiyomiDetails
+ {
+ Title = series.Title,
+ Artist = string.Join(", ", series.Artists),
+ Author = string.Join(", ", series.Authors),
+ Description = "",
+ Genres = series.Genres,
+ Status = series.Status == SeriesStatus.Completed ? "2" : "1"
+ };
+
+ var serialized = JsonSerializer.Serialize(metadata);
+ await File.WriteAllTextAsync(writePath, serialized, Encoding.UTF8);
+ }
+}
diff --git a/asuka.Application/Program.cs b/asuka.Application/Program.cs
new file mode 100644
index 0000000..31b05d0
--- /dev/null
+++ b/asuka.Application/Program.cs
@@ -0,0 +1,50 @@
+using System;
+using System.IO;
+using asuka.Application.Commands;
+using asuka.Application.Services.Downloader;
+using asuka.Application.Services.ProviderManager;
+using Cocona;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using Serilog;
+
+var builder = CoconaApp.CreateBuilder();
+
+// Logging stuff for diagnostics
+var logRoot = Path.Combine(AppContext.BaseDirectory, "logs");
+if (!Directory.Exists(logRoot))
+{
+ Directory.CreateDirectory(logRoot);
+}
+
+Log.Logger = new LoggerConfiguration()
+ .WriteTo
+ .File(
+ Path.Combine(logRoot, "log.txt"),
+ rollingInterval: RollingInterval.Day,
+ outputTemplate: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz}[{Level}][{SourceContext}] {Message}{NewLine}{Exception}")
+ .CreateLogger();
+
+builder.Services.AddLogging(loggingBuilder =>
+{
+ loggingBuilder.ClearProviders();
+ loggingBuilder.AddSerilog(dispose: true);
+});
+
+// Services
+builder.Services.AddSingleton();
+builder.Services.AddSingleton();
+
+var app = builder.Build();
+
+// Commands
+app.AddCommands();
+app.AddCommands();
+app.AddCommands();
+app.AddCommands();
+app.AddCommands();
+app.AddCommands();
+app.AddCommands();
+app.AddCommands();
+
+await app.RunAsync();
diff --git a/Resources/AppIcon.png b/asuka.Application/Resources/AppIcon.png
similarity index 100%
rename from Resources/AppIcon.png
rename to asuka.Application/Resources/AppIcon.png
diff --git a/Core/Compression/Compress.cs b/asuka.Application/Services/Downloader/Compression/Compress.cs
similarity index 52%
rename from Core/Compression/Compress.cs
rename to asuka.Application/Services/Downloader/Compression/Compress.cs
index 9c1cc38..47f6686 100644
--- a/Core/Compression/Compress.cs
+++ b/asuka.Application/Services/Downloader/Compression/Compress.cs
@@ -2,11 +2,11 @@
using System.IO.Compression;
using System.Linq;
using System.Threading.Tasks;
-using asuka.Core.Output.Progress;
+using Spectre.Console;
-namespace asuka.Core.Compression;
+namespace asuka.Application.Services.Downloader.Compression;
-public static class Compress
+internal static class Compress
{
private class FilePath
{
@@ -14,7 +14,7 @@ private class FilePath
public required string Full { get; init; }
}
- public static async Task ToCbz(string folder, IProgressProvider progress)
+ public static async Task ToCbz(string folder)
{
var directory = new DirectoryInfo(folder);
var parent = Directory.GetParent(directory.FullName)
@@ -34,28 +34,20 @@ public static async Task ToCbz(string folder, IProgressProvider progress)
{
File.Delete(outputFile);
}
+
+ await using var stream = new FileStream(outputFile, FileMode.Create);
+ using var archive = new ZipArchive(stream, ZipArchiveMode.Create);
- var bar = progress.Spawn(files.Count, $"Compressing: {directory.Name}.cbz");
-
- try
+ foreach (var file in files)
{
- await using var stream = new FileStream(outputFile, FileMode.Create);
- using var archive = new ZipArchive(stream, ZipArchiveMode.Create);
+ var entry = archive.CreateEntry(file.Relative);
- foreach (var file in files)
- {
- var entry = archive.CreateEntry(file.Relative);
+ await using var writer = new BinaryWriter(entry.Open());
+ var data = await File.ReadAllBytesAsync(file.Full);
- await using var writer = new BinaryWriter(entry.Open());
- var data = await File.ReadAllBytesAsync(file.Full);
-
- writer.Write(data);
- bar!.Tick();
- }
- }
- catch
- {
- bar!.Stop("Failed to compress due to an exception.");
+ writer.Write(data);
}
+
+ AnsiConsole.MarkupLine($"[chartreuse1]Compression done: {Path.GetDirectoryName(folder)}[/]");
}
}
diff --git a/asuka.Application/Services/Downloader/DownloadBuilder.cs b/asuka.Application/Services/Downloader/DownloadBuilder.cs
new file mode 100644
index 0000000..9be4f30
--- /dev/null
+++ b/asuka.Application/Services/Downloader/DownloadBuilder.cs
@@ -0,0 +1,128 @@
+using System;
+using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
+using asuka.Application.Extensions;
+using asuka.Application.Services.Downloader.Compression;
+using asuka.Application.Utilities;
+using asuka.Provider.Sdk;
+using Microsoft.Extensions.Logging;
+using Spectre.Console;
+
+namespace asuka.Application.Services.Downloader;
+
+internal sealed class DownloadBuilder : IDownloaderBuilder
+{
+ private readonly ILogger _logger;
+
+ public DownloadBuilder(ILogger logger)
+ {
+ _logger = logger;
+ }
+
+ public Downloader CreateDownloaderInstance(MetaInfo client, Series series)
+ {
+ return new Downloader(_logger, client, series);
+ }
+}
+
+internal sealed class Downloader
+{
+ private readonly DownloaderConfiguration _config = new()
+ {
+ OutputPath = Environment.CurrentDirectory,
+ SaveMetadata = true,
+ Pack = false
+ };
+
+ private readonly ILogger _logger;
+
+ // Events
+ public Action OnProgress = (_) => { };
+
+ private readonly MetaInfo _client;
+ private readonly Series _series;
+
+ internal Downloader(ILogger logger, MetaInfo client, Series series)
+ {
+ _logger = logger;
+ _client = client;
+ _series = series;
+ }
+
+ public void Configure(Action configDelegate)
+ {
+ configDelegate(_config);
+ }
+
+ public async Task Start(CancellationToken cancellationToken = default)
+ {
+ _logger.LogInformation("Downloading series: {@series}", _series);
+
+ var seriesPath = PathUtils.Join(_config.OutputPath, _series.Title);
+ foreach (var chapter in _series.Chapters)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ OnProgress.Invoke($"Downloading {_series.Title} (Chapter {chapter.Id} of {_series.Chapters.Count}) (0/{chapter.Pages.Count})...");
+
+ await ProcessChapter(chapter, seriesPath, cancellationToken);
+ AnsiConsole.MarkupLine("[chartreuse1]Finished: {0} (Chapter {1} of {2})[/]", Markup.Escape(_series.Title), chapter.Id, _series.Chapters.Count);
+ }
+
+ if (_config.SaveMetadata)
+ {
+ var metaPath = Path.Combine(seriesPath, "details.json");
+ await _series.WriteMetadata(metaPath);
+ }
+
+ if (_config.Pack)
+ {
+ OnProgress.Invoke($"Compressing: {_series.Title}...");
+ await Compress.ToCbz(seriesPath);
+ }
+ }
+
+ private async Task ProcessChapter(Chapter chapter, string outputPath, CancellationToken cancellationToken = default)
+ {
+ _logger.LogInformation("Downloading chapter id: {chapter}", chapter.Id);
+
+ var chapterPath = PathUtils.Join(outputPath, $"ch{chapter.Id}");
+ if (!Directory.Exists(chapterPath)) Directory.CreateDirectory(chapterPath);
+
+ var tick = 0;
+ foreach (var page in chapter.Pages)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ try
+ {
+ var filePath = Path.Combine(chapterPath, page.Filename);
+ if (File.Exists(filePath))
+ {
+ _logger.LogInformation("Download skipped due to file exists: {path}", filePath);
+
+ tick++;
+ OnProgress.Invoke($"Downloading {_series.Title} (Chapter {chapter.Id} of {_series.Chapters.Count}) ({tick}/{chapter.Pages.Count})...");
+ continue;
+ }
+
+ var data = await _client.GetImage(page.ImageRemotePath, cancellationToken);
+ await File.WriteAllBytesAsync(filePath, data, CancellationToken.None);
+
+ _logger.LogInformation("File downloaded: {file} with {length} bytes", filePath,
+ data.Length);
+
+ tick++;
+ OnProgress.Invoke($"Downloading {_series.Title} (Chapter {chapter.Id} of {_series.Chapters.Count}) ({tick}/{chapter.Pages.Count})...");
+ }
+ catch (Exception ex)
+ {
+ AnsiConsole.MarkupLine("[red3_1]Failed to download image: {0}[/]", page.ImageRemotePath);
+ _logger.LogError("Download failed due to an exception: {ex}", ex);
+
+ break;
+ }
+ }
+ _logger.LogInformation("Download of chapter completed: {chapter}", chapter.Id);
+ }
+}
diff --git a/asuka.Application/Services/Downloader/DownloaderConfiguration.cs b/asuka.Application/Services/Downloader/DownloaderConfiguration.cs
new file mode 100644
index 0000000..98c4456
--- /dev/null
+++ b/asuka.Application/Services/Downloader/DownloaderConfiguration.cs
@@ -0,0 +1,11 @@
+using System;
+
+namespace asuka.Application.Services.Downloader;
+
+internal sealed class DownloaderConfiguration
+{
+ public required string? OutputPath { get; set; }
+
+ public bool Pack { get; set; }
+ public bool SaveMetadata { get; set; }
+}
diff --git a/asuka.Application/Services/Downloader/IDownloaderBuilder.cs b/asuka.Application/Services/Downloader/IDownloaderBuilder.cs
new file mode 100644
index 0000000..3a38a91
--- /dev/null
+++ b/asuka.Application/Services/Downloader/IDownloaderBuilder.cs
@@ -0,0 +1,8 @@
+using asuka.Provider.Sdk;
+
+namespace asuka.Application.Services.Downloader;
+
+internal interface IDownloaderBuilder
+{
+ Downloader CreateDownloaderInstance(MetaInfo client, Series series);
+}
diff --git a/asuka.Application/Services/ProviderManager/IProviderManager.cs b/asuka.Application/Services/ProviderManager/IProviderManager.cs
new file mode 100644
index 0000000..fb5d5d3
--- /dev/null
+++ b/asuka.Application/Services/ProviderManager/IProviderManager.cs
@@ -0,0 +1,36 @@
+using System;
+using System.Collections.Generic;
+using asuka.Provider.Sdk;
+
+namespace asuka.Application.Services.ProviderManager;
+
+internal interface IProviderManager
+{
+ ///
+ /// Gets the provider based on the gallery ID/URL provided.
+ ///
+ ///
+ /// Returns the that supports it. Returns null if found none.
+ MetaInfo? GetProviderForGalleryId(string galleryId);
+
+ ///
+ /// Gets the provider based on the alias.
+ ///
+ /// The alias or the ID of the provider
+ /// Returns the that has that alias. Returns null if found none.
+ MetaInfo? GetProviderByAlias(string alias);
+
+ ///
+ /// Gets the list of providers currently detected
+ ///
+ ///
+ List GetAllRegisteredProviders();
+}
+
+internal sealed class RegisteredProvider
+{
+ public required string Id { get; init; }
+ public required Version Version { get; init; }
+ public required List Aliases { get; init; }
+}
+
diff --git a/asuka.Application/Services/ProviderManager/ProviderManager.cs b/asuka.Application/Services/ProviderManager/ProviderManager.cs
new file mode 100644
index 0000000..b48b782
--- /dev/null
+++ b/asuka.Application/Services/ProviderManager/ProviderManager.cs
@@ -0,0 +1,109 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Reflection;
+using asuka.Provider.Sdk;
+using Microsoft.Extensions.Logging;
+
+namespace asuka.Application.Services.ProviderManager;
+
+internal sealed class ProviderManager : IProviderManager
+{
+ private readonly Dictionary _providers;
+ private readonly ILogger _logger;
+
+ public ProviderManager(ILogger logger)
+ {
+ _logger = logger;
+ _providers = new Dictionary();
+
+ // Look for existing providers in the Providers folder
+ var providerRoot = Path.Combine(AppContext.BaseDirectory, "providers");
+ if (!Directory.Exists(providerRoot))
+ {
+ Directory.CreateDirectory(providerRoot);
+ }
+
+ var providers = Directory.GetFiles(providerRoot, "asuka.Provider.*.dll", SearchOption.AllDirectories);
+ foreach (var provider in providers)
+ {
+ var activatedInstance = TryLoadAssembly(provider);
+ if (activatedInstance == null)
+ {
+ continue;
+ }
+
+ if (_providers.ContainsKey(activatedInstance.GetId()))
+ {
+ continue;
+ }
+
+ _providers.TryAdd(activatedInstance.GetId(), activatedInstance);
+ }
+ }
+
+ private MetaInfo? TryLoadAssembly(string providerPath)
+ {
+ try
+ {
+ var types = Assembly.LoadFrom(providerPath).GetExportedTypes();
+ var metaInfo = types
+ .Single(t => t is { IsClass: true, IsAbstract: false } && t.IsSubclassOf(typeof(MetaInfo)));
+
+ return (MetaInfo)Activator.CreateInstance(metaInfo)!;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError("Loading of assembly failed due to an exception: {ex}", ex);
+ }
+
+ return null;
+ }
+
+ public MetaInfo? GetProviderForGalleryId(string galleryId)
+ {
+ foreach (var (_, provider) in _providers)
+ {
+ if (!provider.IsGallerySupported(galleryId))
+ {
+ continue;
+ }
+
+ _logger.LogInformation("Provider found for id: {galleryId} -> {providerId}", galleryId, provider.GetId());
+ return provider;
+ }
+
+ _logger.LogInformation("No provider found for id: {galleryId}", galleryId);
+ return null;
+ }
+
+ public MetaInfo? GetProviderByAlias(string alias)
+ {
+ foreach (var (key, provider) in _providers)
+ {
+ if (alias == key || provider.GetAliases().Contains(alias))
+ {
+ _logger.LogInformation("Provider alias matched: {alias} : {providerId}", alias, provider.GetId());
+ return provider;
+ }
+ }
+
+ _logger.LogInformation("No provider found with alias of {alias}", alias);
+ return null;
+ }
+
+ public List GetAllRegisteredProviders()
+ {
+ return _providers.Values
+ .Select(instance =>
+ {
+ return new RegisteredProvider
+ {
+ Id = instance.GetId(),
+ Aliases = instance.GetAliases(),
+ Version = instance.GetVersion()
+ };
+ }).ToList();
+ }
+}
diff --git a/Core/Utilities/PathUtils.cs b/asuka.Application/Utilities/PathUtils.cs
similarity index 98%
rename from Core/Utilities/PathUtils.cs
rename to asuka.Application/Utilities/PathUtils.cs
index 5a6ea51..ea9b502 100644
--- a/Core/Utilities/PathUtils.cs
+++ b/asuka.Application/Utilities/PathUtils.cs
@@ -3,7 +3,7 @@
using System.IO;
using System.Linq;
-namespace asuka.Core.Utilities;
+namespace asuka.Application.Utilities;
public static class PathUtils
{
@@ -88,7 +88,7 @@ public static class PathUtils
///
///
///
- public static string Join(string outputPath, string folderName)
+ public static string Join(string? outputPath, string folderName)
{
var destinationPath = string.IsNullOrEmpty(outputPath)
? Environment.CurrentDirectory
diff --git a/asuka.Application/Validators/FileWithinSizeLimitsAttribute.cs b/asuka.Application/Validators/FileWithinSizeLimitsAttribute.cs
new file mode 100644
index 0000000..f094685
--- /dev/null
+++ b/asuka.Application/Validators/FileWithinSizeLimitsAttribute.cs
@@ -0,0 +1,27 @@
+using System.ComponentModel.DataAnnotations;
+using System.IO;
+
+namespace asuka.Application.Validators;
+
+public class FileWithinSizeLimitsAttribute : ValidationAttribute
+{
+ private readonly long _maximumSize;
+
+ public FileWithinSizeLimitsAttribute(long maximumSize = 5242880)
+ {
+ _maximumSize = maximumSize;
+ }
+
+ protected override ValidationResult? IsValid(object? value, ValidationContext validationContext)
+ {
+ if (value is string path && !string.IsNullOrEmpty(path) && File.Exists(path))
+ {
+ var size = new FileInfo(path).Length;
+ return size > _maximumSize
+ ? new ValidationResult($"The file '{path}' is greater than {_maximumSize} bytes.")
+ : ValidationResult.Success;
+ }
+
+ return new ValidationResult($"The path '{value}' cannot be found.");
+ }
+}
diff --git a/asuka.Application/Validators/PathExistsAttribute.cs b/asuka.Application/Validators/PathExistsAttribute.cs
new file mode 100644
index 0000000..55d43cb
--- /dev/null
+++ b/asuka.Application/Validators/PathExistsAttribute.cs
@@ -0,0 +1,17 @@
+using System.ComponentModel.DataAnnotations;
+using System.IO;
+
+namespace asuka.Application.Validators;
+
+public class PathExistsAttribute : ValidationAttribute
+{
+ protected override ValidationResult? IsValid(object? value, ValidationContext validationContext)
+ {
+ if (value is string path && !string.IsNullOrEmpty(path) && File.Exists(path))
+ {
+ return ValidationResult.Success;
+ }
+
+ return new ValidationResult($"The path '{value}' is not found.");
+ }
+}
diff --git a/appicon.ico b/asuka.Application/appicon.ico
similarity index 100%
rename from appicon.ico
rename to asuka.Application/appicon.ico
diff --git a/asuka.Application/asuka.csproj b/asuka.Application/asuka.csproj
new file mode 100644
index 0000000..8cce68e
--- /dev/null
+++ b/asuka.Application/asuka.csproj
@@ -0,0 +1,48 @@
+
+
+
+ Exe
+ net9.0
+ 1.3.0.0
+ 1.3.0.0
+ appicon.ico
+ Release;Debug
+ x64
+ 1.3.0
+ Aiko Fujimoto
+ AppIcon.png
+
+ latestmajor
+ enable
+ asuka.Application
+ false
+
+
+
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Always
+
+
+ True
+
+
+
+
+
+
+
+
diff --git a/asuka.Provider.Hitomi/Contracts/Responses/Character.cs b/asuka.Provider.Hitomi/Contracts/Responses/Character.cs
new file mode 100644
index 0000000..796f341
--- /dev/null
+++ b/asuka.Provider.Hitomi/Contracts/Responses/Character.cs
@@ -0,0 +1,12 @@
+using System.Text.Json.Serialization;
+
+namespace asuka.Provider.Hitomi.Contracts.Responses;
+
+internal sealed class Character
+{
+ [JsonPropertyName("character")]
+ public required string Name { get; set; }
+
+ [JsonPropertyName("url")]
+ public required string Url { get; set; }
+}
diff --git a/asuka.Provider.Hitomi/Contracts/Responses/FileEntry.cs b/asuka.Provider.Hitomi/Contracts/Responses/FileEntry.cs
new file mode 100644
index 0000000..fd37b6c
--- /dev/null
+++ b/asuka.Provider.Hitomi/Contracts/Responses/FileEntry.cs
@@ -0,0 +1,27 @@
+using System.Text.Json.Serialization;
+
+namespace asuka.Provider.Hitomi.Contracts.Responses;
+
+internal sealed class FileEntry
+{
+ [JsonPropertyName("hasjxl")]
+ public int HasJxl { get; set; }
+
+ [JsonPropertyName("haswebp")]
+ public int HasWebp { get; set; }
+
+ [JsonPropertyName("hasavif")]
+ public int HasAvif { get; set; }
+
+ [JsonPropertyName("width")]
+ public int Width { get; set; }
+
+ [JsonPropertyName("height")]
+ public int Height { get; set; }
+
+ [JsonPropertyName("hash")]
+ public required string Hash { get; set; }
+
+ [JsonPropertyName("name")]
+ public required string Name { get; set; }
+}
diff --git a/asuka.Provider.Hitomi/Contracts/Responses/GalleryInformation.cs b/asuka.Provider.Hitomi/Contracts/Responses/GalleryInformation.cs
new file mode 100644
index 0000000..5065a30
--- /dev/null
+++ b/asuka.Provider.Hitomi/Contracts/Responses/GalleryInformation.cs
@@ -0,0 +1,36 @@
+using System.Text.Json.Serialization;
+
+namespace asuka.Provider.Hitomi.Contracts.Responses;
+
+internal sealed class GalleryInformation
+{
+ [JsonPropertyName("characters")]
+ public IEnumerable? Characters { get; init; } = [];
+
+ [JsonPropertyName("tags")]
+ public IEnumerable? Tags { get; init; } = [];
+
+ [JsonPropertyName("type")]
+ public string? Type { get; init; }
+
+ [JsonPropertyName("files")]
+ public IEnumerable Files { get; init; } = [];
+
+ [JsonPropertyName("groups")]
+ public IEnumerable? Groups { get; init; } = [];
+
+ [JsonPropertyName("parodys")]
+ public IEnumerable? Parodies { get; init; } = [];
+
+ [JsonPropertyName("related")]
+ public IEnumerable? Related { get; init; } = [];
+
+ [JsonPropertyName("date")]
+ public required string Date { get; init; }
+
+ [JsonPropertyName("title")]
+ public required string Title { get; init; }
+
+ [JsonPropertyName("japanese_title")]
+ public string? JapaneseTitle { get; init; }
+}
diff --git a/asuka.Provider.Hitomi/Contracts/Responses/Group.cs b/asuka.Provider.Hitomi/Contracts/Responses/Group.cs
new file mode 100644
index 0000000..1c3e86b
--- /dev/null
+++ b/asuka.Provider.Hitomi/Contracts/Responses/Group.cs
@@ -0,0 +1,12 @@
+using System.Text.Json.Serialization;
+
+namespace asuka.Provider.Hitomi.Contracts.Responses;
+
+internal sealed class Group
+{
+ [JsonPropertyName("group")]
+ public required string Name { get; set; }
+
+ [JsonPropertyName("url")]
+ public required string Url { get; set; }
+}
diff --git a/asuka.Provider.Hitomi/Contracts/Responses/Parody.cs b/asuka.Provider.Hitomi/Contracts/Responses/Parody.cs
new file mode 100644
index 0000000..402642a
--- /dev/null
+++ b/asuka.Provider.Hitomi/Contracts/Responses/Parody.cs
@@ -0,0 +1,12 @@
+using System.Text.Json.Serialization;
+
+namespace asuka.Provider.Hitomi.Contracts.Responses;
+
+internal sealed class Parody
+{
+ [JsonPropertyName("parody")]
+ public required string Name { get; set; }
+
+ [JsonPropertyName("url")]
+ public required string Url { get; set; }
+}
diff --git a/asuka.Provider.Hitomi/Contracts/Responses/Tag.cs b/asuka.Provider.Hitomi/Contracts/Responses/Tag.cs
new file mode 100644
index 0000000..481af80
--- /dev/null
+++ b/asuka.Provider.Hitomi/Contracts/Responses/Tag.cs
@@ -0,0 +1,12 @@
+using System.Text.Json.Serialization;
+
+namespace asuka.Provider.Hitomi.Contracts.Responses;
+
+internal sealed class Tag
+{
+ [JsonPropertyName("tag")]
+ public required string Name { get; init; }
+
+ [JsonPropertyName("url")]
+ public required string Url { get; init; }
+}
diff --git a/asuka.Provider.Hitomi/HitomiHelper.cs b/asuka.Provider.Hitomi/HitomiHelper.cs
new file mode 100644
index 0000000..4cebe6b
--- /dev/null
+++ b/asuka.Provider.Hitomi/HitomiHelper.cs
@@ -0,0 +1,87 @@
+using System.Globalization;
+using System.Text.RegularExpressions;
+using asuka.Provider.Hitomi.Contracts.Responses;
+
+namespace asuka.Provider.Hitomi;
+
+internal static partial class HitomiHelper
+{
+ public static async Task GetGgCode(HttpClient client, CancellationToken cancellationToken = default)
+ {
+ using var request = await client.GetAsync("gg.js", cancellationToken);
+ request.EnsureSuccessStatusCode();
+
+ var response = await request.Content.ReadAsStringAsync(cancellationToken);
+
+ // the m stuff
+ var mDict = new Dictionary();
+ var mFilter = TheMStuffRegex().Matches(response);
+
+ var k = new List();
+ foreach (Match m in mFilter)
+ {
+ var ky = m.Groups[1].Value;
+ var vl = m.Groups[2].Value;
+
+ if (int.TryParse(ky, out var kk))
+ {
+ k.Add(kk);
+ if (int.TryParse(vl, out var v))
+ {
+ foreach (var n in k)
+ {
+ mDict[n] = v;
+ }
+ k.Clear();
+ }
+ }
+ }
+
+ // the b code something
+ var bFilter = GgCodeRegex().Match(response);
+
+ // the d code something
+ var dFilter = TheDStuffRegex().Match(response).Groups[1].Value;
+
+ return new GgResult
+ {
+ M = mDict,
+ D = !string.IsNullOrEmpty(dFilter) ? int.Parse(dFilter) : 0,
+ B = int.Parse(bFilter.Groups[1].Value.TrimEnd('/'))
+ };
+ }
+
+ public static int GetHiddenCodeFromHash(string hash)
+ {
+ var m = TheHiddenCodeFromHashRegex().Match(hash);
+ return int.Parse(m.Groups[2].Value + m.Groups[1].Value, NumberStyles.HexNumber);
+ }
+
+ public static List MergeTags(this GalleryInformation info)
+ {
+ var tags = new List();
+ tags.AddRange(info.Tags?.Select(x => x.Name) ?? []);
+ tags.AddRange(info.Parodies?.Select(x => x.Name) ?? []);
+
+ return tags;
+ }
+
+ [GeneratedRegex(@"(..)(.)$")]
+ private static partial Regex TheHiddenCodeFromHashRegex();
+
+ [GeneratedRegex(@"(?:var\s|default:)\s*o\s*=\s*(\d+)")]
+ private static partial Regex TheDStuffRegex();
+
+ [GeneratedRegex(@"case\s+(\d+):(?:\s*o\s*=\s*(\d+))?")]
+ private static partial Regex TheMStuffRegex();
+
+ [GeneratedRegex(@"b:\s*[\""'](.+)[\""']")]
+ private static partial Regex GgCodeRegex();
+}
+
+internal sealed class GgResult
+{
+ public required Dictionary M { get; init; }
+ public required int B { get; init; }
+ public required int D { get; init; }
+}
diff --git a/asuka.Provider.Hitomi/Metadata.cs b/asuka.Provider.Hitomi/Metadata.cs
new file mode 100644
index 0000000..050b61d
--- /dev/null
+++ b/asuka.Provider.Hitomi/Metadata.cs
@@ -0,0 +1,174 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using System.Text.RegularExpressions;
+using asuka.Provider.Hitomi.Contracts.Responses;
+using asuka.Provider.Sdk;
+using asuka.Provider.Sdk.Utilities;
+
+namespace asuka.Provider.Hitomi;
+
+public sealed partial class Metadata : MetaInfo
+{
+ public Metadata()
+ {
+ Id = "asuka.provider.hitomi";
+ Version = new Version(0, 0, 1, 0);
+ ProviderAliases =
+ [
+ "hitomi"
+ ];
+ }
+
+ public override bool IsGallerySupported(string galleryId)
+ {
+ return !string.IsNullOrEmpty(galleryId) && (FullUrlRegex().IsMatch(galleryId) || GalleryIdRegex().IsMatch(galleryId));
+ }
+
+ public override async Task GetSeries(string galleryId, CancellationToken cancellationToken = default)
+ {
+ var client = HttpClientFactory.CreateClientFromProvider("https://ltn.hitomi.la",
+ new Dictionary
+ {
+ { "Referer", galleryId },
+ });
+
+ // Find ID from the URL.
+ var id = GalleryIdRegex().Match(galleryId).Groups[1].Value;
+
+ return await GetInfo(client, id, cancellationToken);
+ }
+
+ public override async Task> GetRecommendations(string galleryId, CancellationToken cancellationToken = default)
+ {
+ var id = GalleryIdRegex().Match(galleryId).Groups[1].Value;
+
+ var client = HttpClientFactory.CreateClientFromProvider("https://ltn.hitomi.la",
+ new Dictionary
+ {
+ { "Referer", galleryId },
+ });
+
+ var ids = await GetRelativeIdsFromGalleryId(client, id, cancellationToken);
+
+ var result = new List();
+ foreach (var related in ids)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ var info = await GetInfo(client, related.ToString(), cancellationToken);
+ result.Add(info);
+ }
+
+ return result;
+ }
+
+ public override async Task GetImage(string remotePath, CancellationToken cancellationToken = default)
+ {
+ var @params = remotePath.Split('|');
+
+ var uri = new Uri(@params[0]);
+ var referer = @params[1];
+
+ var client = HttpClientFactory.CreateClientFromProvider($"https://{uri.Authority}",
+ new Dictionary
+ {
+ { "Referer", referer },
+ });
+ var request = await client.GetAsync(uri.AbsolutePath, cancellationToken);
+ request.EnsureSuccessStatusCode();
+
+ return await request.Content.ReadAsByteArrayAsync(cancellationToken);
+ }
+
+ #region Unsupported Methods
+ public override Task> Search(SearchQuery query, CancellationToken cancellationToken = default)
+ {
+ throw new NotSupportedException();
+ }
+
+ public override Task GetRandom(CancellationToken cancellationToken = default)
+ {
+ throw new NotSupportedException();
+ }
+ #endregion
+
+ #region Helper Methods
+
+ private async Task> GetRelativeIdsFromGalleryId(HttpClient client, string id,
+ CancellationToken cancellationToken = default)
+ {
+ // Retrieve the Javascript file we need that contains metadata of the images.
+ using var request = await client.GetAsync($"galleries/{id}.js", cancellationToken);
+ request.EnsureSuccessStatusCode();
+
+ // Ensuring that we get a proper json, we need to remove some stuff
+ var response = await request.Content.ReadAsStringAsync(cancellationToken);
+ var jsonData = response[response.IndexOf('{')..];
+
+ var data = JsonSerializer.Deserialize(jsonData);
+ if (data is null)
+ {
+ throw new Exception("Parsing data failed.");
+ }
+
+ return data.Related ?? [];
+ }
+
+ private async Task GetInfo(HttpClient client, string id, CancellationToken cancellationToken = default)
+ {
+ // Retrieve the Javascript file we need that contains metadata of the images.
+ using var request = await client.GetAsync($"galleries/{id}.js", cancellationToken);
+ request.EnsureSuccessStatusCode();
+
+ // Ensuring that we get a proper json, we need to remove some stuff
+ var response = await request.Content.ReadAsStringAsync(cancellationToken);
+ var jsonData = response[response.IndexOf('{')..];
+
+ var data = JsonSerializer.Deserialize(jsonData, new JsonSerializerOptions
+ {
+ NumberHandling = JsonNumberHandling.AllowReadingFromString
+ });
+ if (data is null)
+ {
+ throw new Exception("Parsing data failed.");
+ }
+
+ // Get the image parameters somewhere
+ var ggCode = await HitomiHelper.GetGgCode(client, cancellationToken);
+ var referrer = $"https://hitomi.la/reader/{id}.html";
+
+ var chapter = new Chapter
+ {
+ Id = 1,
+ Pages = data.Files.Select(x =>
+ {
+ var a = HitomiHelper.GetHiddenCodeFromHash(x.Hash);
+ var b = (char)(97 + (ggCode.M.TryGetValue(a, out var c) ? c : ggCode.D));
+
+ var url = $"https://{b}a.hitomi.la/webp/{ggCode.B}/{a}/{x.Hash}.webp";
+ var filename = Path.GetFileName(x.Name).Replace(Path.GetExtension(x.Name), ".webp");
+ return new Chapter.ChapterImages
+ {
+ Filename = filename,
+ ImageRemotePath = url + "|" + referrer,
+ };
+ }).ToList()
+ };
+
+ return new Series
+ {
+ Title = string.IsNullOrEmpty(data.JapaneseTitle) ? data.Title : data.JapaneseTitle,
+ Artists = data.Groups?.Select(x => x.Name).ToList() ?? [],
+ Authors = [],
+ Genres = data.MergeTags(),
+ Chapters = [chapter]
+ };
+ }
+ #endregion
+
+ [GeneratedRegex(@"^https:\/{2}(hitomi.la)\/(doujinshi|imageset)\/(.+)-\d{1,9}\.html$")]
+ private static partial Regex FullUrlRegex();
+
+ [GeneratedRegex(@"([0-9]+)(?:\.html)?$")]
+ private static partial Regex GalleryIdRegex();
+}
diff --git a/asuka.Provider.Hitomi/asuka.Provider.Hitomi.csproj b/asuka.Provider.Hitomi/asuka.Provider.Hitomi.csproj
new file mode 100644
index 0000000..4987c44
--- /dev/null
+++ b/asuka.Provider.Hitomi/asuka.Provider.Hitomi.csproj
@@ -0,0 +1,21 @@
+
+
+
+ net9.0
+ enable
+ enable
+
+
+
+ bin\Debug
+
+
+
+ bin\Release
+
+
+
+
+
+
+
diff --git a/asuka.Provider.Koharu/Api/IKoharuApi.cs b/asuka.Provider.Koharu/Api/IKoharuApi.cs
new file mode 100644
index 0000000..de2bad2
--- /dev/null
+++ b/asuka.Provider.Koharu/Api/IKoharuApi.cs
@@ -0,0 +1,25 @@
+using asuka.Provider.Koharu.Contracts.Queries;
+using asuka.Provider.Koharu.Contracts.Responses;
+using Refit;
+
+namespace asuka.Provider.Koharu.Api;
+
+internal interface IKoharuApi
+{
+ [Get("/books/detail/{id}/{publicKey}")]
+ Task FetchSingle(int id, string publicKey, CancellationToken cancellationToken = default);
+
+ [Get("/books/random")]
+ Task FetchRandom(CancellationToken cancellationToken = default);
+
+ [Get("/books/data/{id}/{publicKey}/{anotherId}/{anotherPublicKey}")]
+ Task FetchContents(
+ int id,
+ string publicKey,
+ int anotherId,
+ string anotherPublicKey,
+ ImageListQuery query,
+ CancellationToken cancellationToken = default);
+
+ Task Search(SearchParams @params, CancellationToken cancellationToken = default);
+}
diff --git a/asuka.Provider.Koharu/Api/IKoharuImageApi.cs b/asuka.Provider.Koharu/Api/IKoharuImageApi.cs
new file mode 100644
index 0000000..45fda41
--- /dev/null
+++ b/asuka.Provider.Koharu/Api/IKoharuImageApi.cs
@@ -0,0 +1,33 @@
+using Refit;
+
+namespace asuka.Provider.Koharu.Api;
+
+internal interface IKoharuImageApi
+{
+ ///
+ /// Retrieves the image
+ ///
+ /// ID of the gallery
+ /// Public Key of the gallery
+ /// Whatever this is (1)
+ /// Whatever this is (2)
+ ///
+ /// Query string parameters
+ ///
+ ///
+ [Get("/data/{id}/{publicKey}/{hash1}/{hash2}/{file}")]
+ public Task GetImage(
+ string id,
+ string publicKey,
+ string hash1,
+ string hash2,
+ string file,
+ ImageQuery query,
+ CancellationToken cancellationToken = default);
+}
+
+internal class ImageQuery
+{
+ [AliasAs("w")]
+ public int Width { get; init; }
+}
diff --git a/asuka.Provider.Koharu/Contracts/Queries/ImageListQuery.cs b/asuka.Provider.Koharu/Contracts/Queries/ImageListQuery.cs
new file mode 100644
index 0000000..30147bf
--- /dev/null
+++ b/asuka.Provider.Koharu/Contracts/Queries/ImageListQuery.cs
@@ -0,0 +1,12 @@
+using Refit;
+
+namespace asuka.Provider.Koharu.Contracts.Queries;
+
+internal sealed class ImageListQuery
+{
+ [AliasAs("v")]
+ public long Version { get; set; }
+
+ [AliasAs("w")]
+ public int Width { get; set; } = 1280;
+}
diff --git a/asuka.Provider.Koharu/Contracts/Queries/SearchParams.cs b/asuka.Provider.Koharu/Contracts/Queries/SearchParams.cs
new file mode 100644
index 0000000..bd53248
--- /dev/null
+++ b/asuka.Provider.Koharu/Contracts/Queries/SearchParams.cs
@@ -0,0 +1,9 @@
+using Refit;
+
+namespace asuka.Provider.Koharu.Contracts.Queries;
+
+internal sealed class SearchParams
+{
+ [AliasAs("s")]
+ public required string Query { get; init; }
+}
diff --git a/asuka.Provider.Koharu/Contracts/Responses/GalleryContentsResponse.cs b/asuka.Provider.Koharu/Contracts/Responses/GalleryContentsResponse.cs
new file mode 100644
index 0000000..31ec11d
--- /dev/null
+++ b/asuka.Provider.Koharu/Contracts/Responses/GalleryContentsResponse.cs
@@ -0,0 +1,21 @@
+using System.Text.Json.Serialization;
+
+namespace asuka.Provider.Koharu.Contracts.Responses;
+
+internal sealed class GalleryContentsResponse
+{
+ [JsonPropertyName("base")]
+ public string Base { get; set; } = string.Empty;
+
+ [JsonPropertyName("entries")]
+ public List Entries { get; set; } = [];
+
+ internal sealed class Entry
+ {
+ [JsonPropertyName("dimensions")]
+ public int[] Dimensions { get; init; } = [];
+
+ [JsonPropertyName("path")]
+ public string Path { get; init; } = string.Empty;
+ }
+}
diff --git a/asuka.Provider.Koharu/Contracts/Responses/GalleryInfoResponse.cs b/asuka.Provider.Koharu/Contracts/Responses/GalleryInfoResponse.cs
new file mode 100644
index 0000000..763ecdc
--- /dev/null
+++ b/asuka.Provider.Koharu/Contracts/Responses/GalleryInfoResponse.cs
@@ -0,0 +1,74 @@
+// ReSharper disable ClassNeverInstantiated.Global
+
+using System.Text.Json.Serialization;
+
+namespace asuka.Provider.Koharu.Contracts.Responses;
+
+internal sealed class GalleryInfoResponse
+{
+ [JsonPropertyName("created_at")]
+ public long CreatedAt { get; init; }
+
+ [JsonPropertyName("data")]
+ public Dictionary Data { get; init; } = new();
+
+ [JsonPropertyName("id")]
+ public int Id { get; init; }
+
+ [JsonPropertyName("public_key")]
+ public string PublicKey { get; init; } = string.Empty;
+
+ [JsonPropertyName("rels")]
+ public List RelevantGalleries { get; init; } = [];
+
+ [JsonPropertyName("tags")]
+ public List Tags { get; init; } = [];
+
+ [JsonPropertyName("title")]
+ public string Title { get; init; } = "Unknown Title";
+
+ [JsonPropertyName("updated_at")]
+ public long UpdatedAt { get; init; }
+
+ internal sealed class GalleryImageDetails
+ {
+ [JsonPropertyName("id")]
+ public int Id { get; init; }
+
+ [JsonPropertyName("public_key")]
+ public string PublicKey { get; init; } = string.Empty;
+
+ [JsonPropertyName("size")]
+ public long Size { get; init; }
+ }
+
+ internal sealed class RelevantGallery
+ {
+ [JsonPropertyName("created_at")]
+ public long CreatedAt { get; init; }
+
+ [JsonPropertyName("id")]
+ public int Id { get; init; }
+
+ [JsonPropertyName("language")]
+ public string Language { get; init; } = string.Empty;
+
+ [JsonPropertyName("pages")]
+ public int Pages { get; init; }
+
+ [JsonPropertyName("public_key")]
+ public string PublicKey { get; init; } = string.Empty;
+
+ [JsonPropertyName("tags")]
+ public List Tags { get; init; } = [];
+ }
+
+ internal sealed class TagDetails
+ {
+ [JsonPropertyName("namespace")]
+ public GalleryTag Namespace { get; init; } = GalleryTag.NoNamespace;
+
+ [JsonPropertyName("name")]
+ public string Name { get; init; } = string.Empty;
+ }
+}
diff --git a/asuka.Provider.Koharu/Contracts/Responses/GalleryRandomResponse.cs b/asuka.Provider.Koharu/Contracts/Responses/GalleryRandomResponse.cs
new file mode 100644
index 0000000..6c01fd6
--- /dev/null
+++ b/asuka.Provider.Koharu/Contracts/Responses/GalleryRandomResponse.cs
@@ -0,0 +1,12 @@
+using System.Text.Json.Serialization;
+
+namespace asuka.Provider.Koharu.Contracts.Responses;
+
+internal sealed class GalleryRandomResponse
+{
+ [JsonPropertyName("id")]
+ public int Id { get; init; }
+
+ [JsonPropertyName("public_key")]
+ public string PublicKey { get; init; } = string.Empty;
+}
diff --git a/asuka.Provider.Koharu/Contracts/Responses/GallerySearchResult.cs b/asuka.Provider.Koharu/Contracts/Responses/GallerySearchResult.cs
new file mode 100644
index 0000000..833d9a5
--- /dev/null
+++ b/asuka.Provider.Koharu/Contracts/Responses/GallerySearchResult.cs
@@ -0,0 +1,18 @@
+using System.Text.Json.Serialization;
+
+namespace asuka.Provider.Koharu.Contracts.Responses;
+
+internal sealed class GallerySearchResult
+{
+ [JsonPropertyName("entries")]
+ public List Entries { get; set; } = [];
+
+ [JsonPropertyName("limit")]
+ public int Limit { get; set; }
+
+ [JsonPropertyName("page")]
+ public int Page { get; set; }
+
+ [JsonPropertyName("total")]
+ public int Total { get; set; }
+}
\ No newline at end of file
diff --git a/asuka.Provider.Koharu/Contracts/Responses/GalleryTag.cs b/asuka.Provider.Koharu/Contracts/Responses/GalleryTag.cs
new file mode 100644
index 0000000..2fca7c8
--- /dev/null
+++ b/asuka.Provider.Koharu/Contracts/Responses/GalleryTag.cs
@@ -0,0 +1,12 @@
+namespace asuka.Provider.Koharu.Contracts.Responses;
+
+internal enum GalleryTag
+{
+ Artist = 1,
+ Male = 8,
+ Female = 9,
+ Magazine = 4,
+ Language = 11,
+ Other = 12,
+ NoNamespace = 9999
+}
diff --git a/asuka.Provider.Koharu/Extensions/KeyCollectionExtensions.cs b/asuka.Provider.Koharu/Extensions/KeyCollectionExtensions.cs
new file mode 100644
index 0000000..f43d0cb
--- /dev/null
+++ b/asuka.Provider.Koharu/Extensions/KeyCollectionExtensions.cs
@@ -0,0 +1,26 @@
+using asuka.Provider.Koharu.Contracts.Responses;
+
+namespace asuka.Provider.Koharu.Extensions;
+
+internal static class KeyCollectionExtensions
+{
+ public static string FindHighestKey(
+ this Dictionary.KeyCollection keys)
+ {
+ var highest = 0;
+ foreach (var key in keys)
+ {
+ if (!int.TryParse(key, out var value))
+ {
+ continue;
+ }
+
+ if (value > highest)
+ {
+ highest = value;
+ }
+ }
+
+ return highest.ToString();
+ }
+}
diff --git a/asuka.Provider.Koharu/Mappers/GalleryInfoToSeries.cs b/asuka.Provider.Koharu/Mappers/GalleryInfoToSeries.cs
new file mode 100644
index 0000000..d49c7ef
--- /dev/null
+++ b/asuka.Provider.Koharu/Mappers/GalleryInfoToSeries.cs
@@ -0,0 +1,56 @@
+using asuka.Provider.Koharu.Contracts.Responses;
+using asuka.Provider.Sdk;
+
+namespace asuka.Provider.Koharu.Mappers;
+
+internal static class GalleryInfoToSeries
+{
+ public static Series ToSeries(this GalleryInfoResponse response, GalleryContentsResponse contents, int width)
+ {
+ var data = new Series
+ {
+ Title = response.Title,
+ Artists = response.Tags
+ .Where(tag => tag.Namespace == GalleryTag.Artist)
+ .Select(tag => tag.Name)
+ .ToList(),
+ Authors = [],
+ Genres = response.Tags
+ .Where(tag => tag.Namespace != GalleryTag.Artist)
+ .Select(tag => tag.Name)
+ .ToList(),
+ Chapters =
+ [
+ new Chapter
+ {
+ Id = 1,
+ Pages = contents.ToChapterImages(width)
+ }
+ ]
+ };
+
+ return data;
+ }
+
+ private static List ToChapterImages(this GalleryContentsResponse contents, int width)
+ {
+ var chapterImages = new List();
+
+ var pages = contents.Entries.Count;
+ for (var i = 0; i < pages; i++)
+ {
+ var path = contents.Entries[i].Path;
+
+ var filenameFormat = (i + 1).ToString($"D{pages.ToString().Length}");
+ var extension = Path.GetExtension(path);
+
+ chapterImages.Add(new Chapter.ChapterImages
+ {
+ Filename = $"{filenameFormat}{extension}",
+ ImageRemotePath = $"{contents.Base}{path}?w={width}"
+ });
+ }
+
+ return chapterImages;
+ }
+}
diff --git a/asuka.Provider.Koharu/Provider.cs b/asuka.Provider.Koharu/Provider.cs
new file mode 100644
index 0000000..1e5b27b
--- /dev/null
+++ b/asuka.Provider.Koharu/Provider.cs
@@ -0,0 +1,185 @@
+using System.Text.Json;
+using System.Text.RegularExpressions;
+using asuka.Provider.Koharu.Api;
+using asuka.Provider.Koharu.Contracts.Queries;
+using asuka.Provider.Koharu.Extensions;
+using asuka.Provider.Koharu.Mappers;
+using asuka.Provider.Sdk;
+using asuka.Provider.Sdk.Utilities;
+using Refit;
+
+namespace asuka.Provider.Koharu;
+
+public sealed partial class Provider : MetaInfo
+{
+ private readonly IKoharuApi _api;
+
+ private string _activeHost = string.Empty;
+ private IKoharuImageApi? _imageApi;
+
+ public Provider()
+ {
+ Id = "asuka.Provider.Koharu";
+ Version = new Version(1, 0, 0, 0);
+ ProviderAliases =
+ [
+ "koharu",
+ "niyaniya",
+ "shcale",
+ "gehenna",
+ "shupogaki",
+ "hoshino",
+ "seia"
+ ];
+
+ var apiClient = HttpClientFactory.CreateClientFromProvider("https://api.niyaniya.moe/",
+ new Dictionary
+ {
+ { "Referer", "https://niyaniya.moe" },
+ { "Origin", "https://niyaniya.moe" }
+ });
+ _api = RestService.For(apiClient, new RefitSettings
+ {
+ ContentSerializer = new SystemTextJsonContentSerializer(new JsonSerializerOptions
+ {
+ WriteIndented = true,
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase
+ })
+ });
+ }
+
+ public override bool IsGallerySupported(string galleryId)
+ {
+ var regex = UrlRegex();
+ return regex.IsMatch(galleryId);
+ }
+
+ public override async Task GetSeries(string galleryId, CancellationToken cancellationToken = default)
+ {
+ if (!IsGallerySupported(galleryId))
+ {
+ throw new NotSupportedException($"The supplied value'{galleryId}' is not supported by asuka.Providers.Koharu");
+ }
+
+ // Retrieve the required info from the URL
+ var publicKey = PublicKeyRegex().Match(galleryId).Value;
+ if (!int.TryParse(IdRegex().Match(galleryId).Value, out var id))
+ {
+ throw new NotSupportedException($"The supplied value'{galleryId}' is not supported by asuka.Providers.Koharu");
+ }
+
+ // Retrieve the gallery data
+ var data = await _api.FetchSingle(id, publicKey, cancellationToken);
+
+ var highestWidth = data.Data.Keys.FindHighestKey();
+ var imageQueries = new ImageListQuery
+ {
+ Version = data.UpdatedAt,
+ Width = int.Parse(highestWidth),
+ };
+
+ var images = await _api.FetchContents(
+ id,
+ publicKey,
+
+ data.Data[highestWidth].Id,
+ data.Data[highestWidth].PublicKey,
+
+ imageQueries,
+ cancellationToken);
+
+ return data.ToSeries(images, imageQueries.Width);
+ }
+
+ public override Task> Search(SearchQuery query, CancellationToken cancellationToken = default)
+ {
+ throw new NotSupportedException();
+ }
+
+ public override async Task GetRandom(CancellationToken cancellationToken = default)
+ {
+ var random = await _api.FetchRandom(cancellationToken);
+
+ var gallery = await _api.FetchSingle(random.Id, random.PublicKey, cancellationToken);
+ var highestWidth = gallery.Data.Keys.FindHighestKey();
+ var imageQueries = new ImageListQuery
+ {
+ Version = gallery.UpdatedAt,
+ Width = int.Parse(highestWidth),
+ };
+
+ var images = await _api.FetchContents(
+ random.Id,
+ random.PublicKey,
+
+ gallery.Data[highestWidth].Id,
+ gallery.Data[highestWidth].PublicKey,
+
+ imageQueries,
+ cancellationToken);
+
+ return gallery.ToSeries(images, imageQueries.Width);
+ }
+
+ public override Task> GetRecommendations(string galleryId, CancellationToken cancellationToken = default)
+ {
+ throw new NotSupportedException();
+ }
+
+ public override async Task GetImage(string remotePath, CancellationToken cancellationToken = default)
+ {
+ var uri = new Uri(remotePath);
+ if (uri.Authority != _activeHost || _imageApi == null)
+ {
+ _activeHost = uri.Authority;
+
+ var client = HttpClientFactory.CreateClientFromProvider($"https://{uri.Authority}/",
+ new Dictionary
+ {
+ { "Referer", "https://niyaniya.moe" },
+ { "Origin", "https://niyaniya.moe" }
+ });
+
+ _imageApi = RestService.For(client);
+ }
+
+ // It must need to be 7 in length, just to be safe.
+ var parameters = uri.AbsolutePath.Split('/');
+ if (parameters.Length != 7)
+ {
+ throw new ArgumentException($"The remote path may not be usable: {remotePath}", nameof(remotePath));
+ }
+
+ var widthFromQuery = ResolutionRegex().Match(uri.Query).Value;
+
+ // Parameters
+ var id = parameters[2];
+ var publicKey = parameters[3];
+ var hash1 = parameters[4];
+ var hash2 = parameters[5];
+ var file = parameters[6];
+
+ var data = await _imageApi.GetImage(
+ id,
+ publicKey,
+ hash1,
+ hash2,
+ file,
+ new ImageQuery { Width = int.Parse(widthFromQuery) },
+ cancellationToken);
+
+ return await data.ReadAsByteArrayAsync(cancellationToken);
+ }
+
+ [GeneratedRegex(@"^https:\/\/niyaniya\.moe\/g\/\d{1,9}\/[a-fA-F0-9]+$")]
+ private static partial Regex UrlRegex();
+
+ [GeneratedRegex(@"([a-fA-F0-9])+$")]
+ private static partial Regex PublicKeyRegex();
+
+ [GeneratedRegex(@"\d{1,9}")]
+ private static partial Regex IdRegex();
+
+ [GeneratedRegex(@"(\d{3,4})$")]
+ private static partial Regex ResolutionRegex();
+}
diff --git a/asuka.Provider.Koharu/asuka.Provider.Koharu.csproj b/asuka.Provider.Koharu/asuka.Provider.Koharu.csproj
new file mode 100644
index 0000000..17fa394
--- /dev/null
+++ b/asuka.Provider.Koharu/asuka.Provider.Koharu.csproj
@@ -0,0 +1,18 @@
+
+
+
+ net9.0
+ enable
+ enable
+ true
+
+
+
+
+
+
+
+
+
+
+
diff --git a/asuka.Provider.Nhentai/Api/IGalleryApi.cs b/asuka.Provider.Nhentai/Api/IGalleryApi.cs
new file mode 100644
index 0000000..1b1d8b4
--- /dev/null
+++ b/asuka.Provider.Nhentai/Api/IGalleryApi.cs
@@ -0,0 +1,17 @@
+using asuka.Provider.Nhentai.Api.Requests;
+using asuka.Provider.Nhentai.Contracts;
+using Refit;
+
+namespace asuka.Provider.Nhentai.Api;
+
+internal interface IGalleryApi
+{
+ [Get("/api/gallery/{code}")]
+ Task FetchSingle(string code, CancellationToken cancellationToken = default);
+
+ [Get("/api/gallery/{code}/related")]
+ Task FetchRecommended(string code, CancellationToken cancellationToken = default);
+
+ [Get("/api/galleries/search")]
+ Task SearchGallery(GallerySearchQuery queries, CancellationToken cancellationToken = default);
+}
diff --git a/asuka.Provider.Nhentai/Api/IGalleryImage.cs b/asuka.Provider.Nhentai/Api/IGalleryImage.cs
new file mode 100644
index 0000000..585a87f
--- /dev/null
+++ b/asuka.Provider.Nhentai/Api/IGalleryImage.cs
@@ -0,0 +1,9 @@
+using Refit;
+
+namespace asuka.Provider.Nhentai.Api;
+
+internal interface IGalleryImage
+{
+ [Get("/galleries/{mediaId}/{filename}")]
+ Task GetImage(string mediaId, string filename, CancellationToken cancellationToken = default);
+}
diff --git a/Api/Queries/SearchQuery.cs b/asuka.Provider.Nhentai/Api/Requests/GallerySearchQuery.cs
similarity index 71%
rename from Api/Queries/SearchQuery.cs
rename to asuka.Provider.Nhentai/Api/Requests/GallerySearchQuery.cs
index 91f6bd5..f876beb 100644
--- a/Api/Queries/SearchQuery.cs
+++ b/asuka.Provider.Nhentai/Api/Requests/GallerySearchQuery.cs
@@ -1,8 +1,8 @@
using Refit;
-namespace asuka.Api.Queries;
+namespace asuka.Provider.Nhentai.Api.Requests;
-public record SearchQuery
+internal sealed class GallerySearchQuery
{
[AliasAs("query")]
public required string Queries { get; init; }
diff --git a/asuka.Provider.Nhentai/Contracts/GalleryListResponse.cs b/asuka.Provider.Nhentai/Contracts/GalleryListResponse.cs
new file mode 100644
index 0000000..0b0303b
--- /dev/null
+++ b/asuka.Provider.Nhentai/Contracts/GalleryListResponse.cs
@@ -0,0 +1,9 @@
+using System.Text.Json.Serialization;
+
+namespace asuka.Provider.Nhentai.Contracts;
+
+internal sealed class GalleryListResponse
+{
+ [JsonPropertyName("result")]
+ public IEnumerable Result { get; init; }
+}
\ No newline at end of file
diff --git a/asuka.Provider.Nhentai/Contracts/GalleryResponse.cs b/asuka.Provider.Nhentai/Contracts/GalleryResponse.cs
new file mode 100644
index 0000000..095a5e3
--- /dev/null
+++ b/asuka.Provider.Nhentai/Contracts/GalleryResponse.cs
@@ -0,0 +1,71 @@
+#pragma warning disable CS8618
+// ReSharper disable ClassNeverInstantiated.Global
+
+using System.Text.Json.Serialization;
+
+namespace asuka.Provider.Nhentai.Contracts;
+
+internal sealed class GalleryResponse
+{
+ internal sealed class Titles
+ {
+ [JsonPropertyName("japanese")]
+ public string Japanese { get; init; }
+
+ [JsonPropertyName("english")]
+ public string English { get; init; }
+
+ [JsonPropertyName("pretty")]
+ public string Pretty { get; init; }
+ }
+
+ internal sealed class GalleryImages
+ {
+ [JsonPropertyName("pages")]
+ public IEnumerable Pages { get; init; }
+ }
+
+ internal sealed class Page
+ {
+ [JsonPropertyName("t")]
+ public string Format { get; init; }
+
+ [JsonPropertyName("h")]
+ public int Height { get; init; }
+
+ [JsonPropertyName("w")]
+ public int Width { get; init; }
+ }
+
+ internal sealed class Tag
+ {
+ [JsonPropertyName("id")]
+ public int Id { get; init; }
+
+ [JsonPropertyName("type")]
+ public string Type { get; init; }
+
+ [JsonPropertyName("name")]
+ public string Name { get; init; }
+ }
+
+ [JsonPropertyName("id")]
+ [JsonNumberHandling(JsonNumberHandling.AllowReadingFromString)]
+ public int Id { get; init; }
+
+ [JsonPropertyName("media_id")]
+ [JsonNumberHandling(JsonNumberHandling.AllowReadingFromString)]
+ public int MediaId { get; init; }
+
+ [JsonPropertyName("title")]
+ public Titles Title { get; init; }
+
+ [JsonPropertyName("images")]
+ public GalleryImages Images { get; init; }
+
+ [JsonPropertyName("tags")]
+ public IEnumerable Tags { get; init; }
+
+ [JsonPropertyName("num_pages")]
+ public int TotalPages { get; init; }
+}
diff --git a/asuka.Provider.Nhentai/Contracts/GallerySearchResponse.cs b/asuka.Provider.Nhentai/Contracts/GallerySearchResponse.cs
new file mode 100644
index 0000000..6263ac5
--- /dev/null
+++ b/asuka.Provider.Nhentai/Contracts/GallerySearchResponse.cs
@@ -0,0 +1,15 @@
+using System.Text.Json.Serialization;
+
+namespace asuka.Provider.Nhentai.Contracts;
+
+internal sealed class GallerySearchResponse
+{
+ [JsonPropertyName("result")]
+ public IEnumerable Result { get; init; }
+
+ [JsonPropertyName("num_pages")]
+ public int TotalPages { get; init; }
+
+ [JsonPropertyName("per_page")]
+ public int TotalItemsPerPage { get; init; }
+}
diff --git a/asuka.Provider.Nhentai/Extensions/GalleryResponseExtensions.cs b/asuka.Provider.Nhentai/Extensions/GalleryResponseExtensions.cs
new file mode 100644
index 0000000..5abf31b
--- /dev/null
+++ b/asuka.Provider.Nhentai/Extensions/GalleryResponseExtensions.cs
@@ -0,0 +1,23 @@
+using asuka.Provider.Nhentai.Contracts;
+
+namespace asuka.Provider.Nhentai.Extensions;
+
+internal static class GalleryResponseExtensions
+{
+ public static string GetTitle(this GalleryResponse gallery)
+ {
+ if (!string.IsNullOrEmpty(gallery.Title.Japanese))
+ {
+ return gallery.Title.Japanese;
+ }
+
+ if (!string.IsNullOrEmpty(gallery.Title.English))
+ {
+ return gallery.Title.English;
+ }
+
+ return string.IsNullOrEmpty(gallery.Title.Pretty)
+ ? $"{gallery.Id} - Unknown title"
+ : gallery.Title.Pretty;
+ }
+}
diff --git a/asuka.Provider.Nhentai/Mappers/GalleryResponseToSeriesMapper.cs b/asuka.Provider.Nhentai/Mappers/GalleryResponseToSeriesMapper.cs
new file mode 100644
index 0000000..9172cb2
--- /dev/null
+++ b/asuka.Provider.Nhentai/Mappers/GalleryResponseToSeriesMapper.cs
@@ -0,0 +1,59 @@
+using asuka.Provider.Nhentai.Contracts;
+using asuka.Provider.Nhentai.Extensions;
+using asuka.Provider.Sdk;
+
+namespace asuka.Provider.Nhentai.Mappers;
+
+internal static class GalleryResponseToSeriesMapper
+{
+ public static Series ToSeries(this GalleryResponse response)
+ {
+ var artists = response.Tags
+ .Where(x => x.Type == "artist")
+ .Select(x => x.Name)
+ .ToList();
+ var tags = response.Tags
+ .Where(x => x.Type == "tag")
+ .Select(x => x.Name)
+ .ToList();
+
+ return new Series
+ {
+ Title = response.GetTitle(),
+ Artists = artists,
+ Authors = artists,
+ Genres = tags,
+ Chapters =
+ [
+ new()
+ {
+ Id = 1,
+ Pages = response.Images.Pages
+ .Select((x, i) =>
+ {
+ var pageNumber = i + 1;
+ var extension = x.Format switch
+ {
+ "j" => ".jpg",
+ "p" => ".png",
+ "g" => ".gif",
+ "w" => ".webp",
+ _ => ""
+ };
+
+ var pageNumberFormatted = pageNumber.ToString($"D{response.TotalPages.ToString().Length}");
+ var filename = $"{pageNumberFormatted}{extension}";
+
+ return new Chapter.ChapterImages
+ {
+ ImageRemotePath = $"/galleries/{response.MediaId}/{pageNumber}{extension}",
+ Filename = filename
+ };
+ })
+ .ToList()
+ }
+ ],
+ Status = SeriesStatus.Completed
+ };
+ }
+}
diff --git a/asuka.Provider.Nhentai/Provider.cs b/asuka.Provider.Nhentai/Provider.cs
new file mode 100644
index 0000000..d5a94c3
--- /dev/null
+++ b/asuka.Provider.Nhentai/Provider.cs
@@ -0,0 +1,163 @@
+using System.Net;
+using System.Security.Cryptography;
+using System.Text.Json;
+using System.Text.RegularExpressions;
+using asuka.Provider.Nhentai.Api;
+using asuka.Provider.Nhentai.Api.Requests;
+using asuka.Provider.Nhentai.Mappers;
+using asuka.Provider.Sdk;
+using asuka.Provider.Sdk.Utilities;
+using Refit;
+
+namespace asuka.Provider.Nhentai;
+
+public sealed partial class Provider : MetaInfo
+{
+ private readonly IGalleryApi _gallery;
+
+ private int _activeHostnameIndex = 0;
+ private IGalleryImage? _galleryImage;
+ private readonly List _knownHostnames = [
+ "https://i.nhentai.net",
+ "https://i1.nhentai.net",
+ "https://i2.nhentai.net",
+ "https://i3.nhentai.net",
+ "https://i4.nhentai.net",
+ "https://i5.nhentai.net",
+ "https://i6.nhentai.net",
+ ];
+
+ public Provider()
+ {
+ Id = "asuka.provider.nhentai";
+ Version = new Version(1, 1, 0, 2);
+ ProviderAliases =
+ [
+ "nh",
+ "nhentai"
+ ];
+
+ // Configure request
+ var galleryClient = HttpClientFactory.CreateClientFromProvider("https://nhentai.net/");
+ _gallery = RestService.For(galleryClient, new RefitSettings
+ {
+ ContentSerializer = new SystemTextJsonContentSerializer(new JsonSerializerOptions
+ {
+ WriteIndented = true,
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase
+ })
+ });
+ }
+
+ public override bool IsGallerySupported(string galleryId)
+ {
+ var allowedInput1 = FullUrlRegex();
+ var allowedInput2 = NumericOnlyRegex();
+
+ return allowedInput1.IsMatch(galleryId) || allowedInput2.IsMatch(galleryId);
+ }
+
+ public override async Task GetSeries(string galleryId, CancellationToken cancellationToken = default)
+ {
+ // Sanity check
+ if (!IsGallerySupported(galleryId))
+ {
+ throw new NotSupportedException($"The gallery ID supplied '{galleryId}' is not supported by asuka.Providers.Nhentai");
+ }
+
+ // Retrieve the code
+ var codeRegex = CodeOnlyRegex();
+ var code = codeRegex.Match(galleryId).Value;
+
+ // Request
+ var request = await _gallery.FetchSingle(code, cancellationToken);
+ return request.ToSeries();
+ }
+
+ public override async Task> Search(SearchQuery query, CancellationToken cancellationToken = default)
+ {
+ var request = await _gallery.SearchGallery(new GallerySearchQuery
+ {
+ Queries = string.Join(" ", query.SearchQueries),
+ PageNumber = query.PageNumber,
+ Sort = query.Sort ?? "popularity"
+ }, cancellationToken);
+
+ return request.Result
+ .Select(x => x.ToSeries())
+ .ToList();
+ }
+
+ public override async Task GetRandom(CancellationToken cancellationToken = default)
+ {
+ var id = RandomNumberGenerator.GetInt32(1, 500_000);
+ return await GetSeries(id.ToString(), cancellationToken);
+ }
+
+ public override async Task> GetRecommendations(string galleryId, CancellationToken cancellationToken = default)
+ {
+ // Sanity check
+ if (!IsGallerySupported(galleryId))
+ {
+ throw new NotSupportedException($"The gallery ID supplied '{galleryId}' is not supported by asuka.Providers.Nhentai");
+ }
+
+ // Retrieve the code
+ var codeRegex = CodeOnlyRegex();
+ var code = codeRegex.Match(galleryId).Value;
+
+ var request = await _gallery.FetchRecommended(code, cancellationToken);
+ return request.Result
+ .Select(x => x.ToSeries())
+ .ToList();
+ }
+
+ public override async Task GetImage(string remotePath, CancellationToken cancellationToken = default)
+ {
+ return await TryGetImage(remotePath, cancellationToken: cancellationToken);
+ }
+
+ private async Task TryGetImage(string remotePath, int maxCalls = 0, CancellationToken cancellationToken = default)
+ {
+ // Check if the instance is null
+ if (_galleryImage == null)
+ {
+ var client = HttpClientFactory.CreateClientFromProvider(_knownHostnames[_activeHostnameIndex]);
+ _galleryImage = RestService.For(client);
+ }
+
+ var pathArguments = remotePath.Split("/");
+ var mediaId = pathArguments[2];
+ var filename = pathArguments[3];
+
+ try
+ {
+ var response = await _galleryImage.GetImage(mediaId, filename, cancellationToken);
+ return await response.ReadAsByteArrayAsync(cancellationToken);
+ }
+ catch (ApiException ex)
+ {
+ if (ex.StatusCode == HttpStatusCode.NotFound && maxCalls < _knownHostnames.Count)
+ {
+ _activeHostnameIndex = (_activeHostnameIndex + 1) >= _knownHostnames.Count ? 0 : _activeHostnameIndex + 1;
+
+ // Override the gallery instance
+ var client = HttpClientFactory.CreateClientFromProvider(_knownHostnames[_activeHostnameIndex]);
+ _galleryImage = RestService.For(client);
+
+ return await TryGetImage(remotePath, maxCalls + 1, cancellationToken);
+ }
+
+ throw;
+ }
+ }
+
+ [GeneratedRegex(@"^http(s)?:\/\/(nhentai\.net)\b([//g]*)\b([\d]{1,6})\/?$")]
+ private static partial Regex FullUrlRegex();
+
+ [GeneratedRegex(@"^#?\d{1,6}$")]
+ private static partial Regex NumericOnlyRegex();
+
+ [GeneratedRegex(@"\d{1,6}")]
+ private static partial Regex CodeOnlyRegex();
+}
diff --git a/asuka.Provider.Nhentai/asuka.Provider.Nhentai.csproj b/asuka.Provider.Nhentai/asuka.Provider.Nhentai.csproj
new file mode 100644
index 0000000..21f3979
--- /dev/null
+++ b/asuka.Provider.Nhentai/asuka.Provider.Nhentai.csproj
@@ -0,0 +1,22 @@
+
+
+
+ enable
+ enable
+ net9.0
+ latestmajor
+ true
+ false
+ 1.1.0.0
+ 1.1.0.0
+
+
+
+
+
+
+
+
+
+
+
diff --git a/asuka.Provider.Sdk/Chapter.cs b/asuka.Provider.Sdk/Chapter.cs
new file mode 100644
index 0000000..3664e51
--- /dev/null
+++ b/asuka.Provider.Sdk/Chapter.cs
@@ -0,0 +1,13 @@
+namespace asuka.Provider.Sdk;
+
+public sealed class Chapter
+{
+ public sealed class ChapterImages
+ {
+ public required string ImageRemotePath { get; init; }
+ public required string Filename { get; init; }
+ }
+
+ public required int Id { get; init; }
+ public List Pages { get; init; }
+}
diff --git a/asuka.Provider.Sdk/MetaInfo.cs b/asuka.Provider.Sdk/MetaInfo.cs
new file mode 100644
index 0000000..3bb1424
--- /dev/null
+++ b/asuka.Provider.Sdk/MetaInfo.cs
@@ -0,0 +1,96 @@
+namespace asuka.Provider.Sdk;
+
+public abstract class MetaInfo
+{
+ ///
+ /// Unique Identifier of the Provider
+ ///
+ ///
+ /// Conflicting IDs will be ignored if one is registered first. Whichever is loaded first (Note that loading or
+ /// provider plugins are not predictable) will be loaded while the rest with the same IDs will be ignored.
+ ///
+ protected string Id { get; init; } = "";
+
+ ///
+ /// Version of the Provider
+ ///
+ protected Version Version = new(1, 0, 0, 0);
+
+ ///
+ /// Supported aliases that can be used to specify a provider.
+ ///
+ ///
+ /// Ensure that the aliases are unique. If aliases are the same, the first one will be resolved. The order of which
+ /// the provider are loaded are not guaranteed.
+ ///
+ protected List ProviderAliases { get; init; } = [];
+
+ public string GetId() => Id;
+ public Version GetVersion() => Version;
+ public List GetAliases() => ProviderAliases;
+
+ ///
+ /// Checks if the gallery is supported to use this provider
+ ///
+ ///
+ ///
+ public abstract bool IsGallerySupported(string galleryId);
+
+ ///
+ /// Retrieves the series/gallery information
+ ///
+ /// The full URL or ID of the gallery
+ ///
+ ///
+ /// The Provided Gallery ID is not supported by the provider
+ ///
+ public abstract Task GetSeries(string galleryId, CancellationToken cancellationToken = default);
+
+ ///
+ /// Search something in the provider
+ ///
+ ///
+ /// Note that some queries may not support exclusions and other queries such as page ranges or
+ /// date ranges. It may vary across providers.
+ ///
+ ///
+ ///
+ /// List of galleries matches the query
+ ///
+ public abstract Task> Search(SearchQuery query, CancellationToken cancellationToken = default);
+
+ ///
+ /// Retrieves a random gallery
+ ///
+ ///
+ /// Some providers don't support a random. It is possible to develop if the gallery IDs are just
+ /// incrementing integers but for cases with GUIDs or strings as gallery IDs are not possible unless
+ /// if the provider has API to randomly pick a gallery for you. Providers that doesn't support random
+ /// can throw if not supported.
+ ///
+ ///
+ ///
+ /// Unsupported by the provider
+ public abstract Task GetRandom(CancellationToken cancellationToken = default);
+
+ ///
+ /// Retrieves a list of galleries/series recommendation from gallery ID.
+ ///
+ ///
+ /// Some providers don't support recommendations and its impossible/very difficult to implement it
+ /// on their own. Providers will throw if not supported.
+ ///
+ ///
+ ///
+ /// Returns a list of galleries.
+ /// Unsupported by the provider
+ public abstract Task> GetRecommendations(string galleryId, CancellationToken cancellationToken = default);
+
+ ///
+ /// Downloads the image from the provider
+ ///
+ /// Path to the resource.
+ ///
+ /// Returns entire file in form of byte array
+ public abstract Task GetImage(string remotePath, CancellationToken cancellationToken = default);
+}
diff --git a/asuka.Provider.Sdk/SearchQuery.cs b/asuka.Provider.Sdk/SearchQuery.cs
new file mode 100644
index 0000000..2475564
--- /dev/null
+++ b/asuka.Provider.Sdk/SearchQuery.cs
@@ -0,0 +1,8 @@
+namespace asuka.Provider.Sdk;
+
+public sealed class SearchQuery
+{
+ public required List SearchQueries { get; init; }
+ public int PageNumber { get; init; }
+ public string? Sort { get; init; }
+}
diff --git a/asuka.Provider.Sdk/Series.cs b/asuka.Provider.Sdk/Series.cs
new file mode 100644
index 0000000..3f782ed
--- /dev/null
+++ b/asuka.Provider.Sdk/Series.cs
@@ -0,0 +1,34 @@
+namespace asuka.Provider.Sdk;
+
+public sealed class Series
+{
+ ///
+ /// Title of the Gallery
+ ///
+ public required string Title { get; init; }
+
+ ///
+ /// Authors of the gallery
+ ///
+ public required List Authors { get; init; }
+
+ ///
+ /// Artists (Can be synonymous to Authors)
+ ///
+ public required List Artists { get; init; }
+
+ ///
+ /// Genres/Tags of the gallery
+ ///
+ public required List Genres { get; init; }
+
+ ///
+ /// Chapters of the gallery
+ ///
+ public required List Chapters { get; init; }
+
+ ///
+ /// Status of the series
+ ///
+ public SeriesStatus Status { get; init; }
+}
\ No newline at end of file
diff --git a/asuka.Provider.Sdk/SeriesStatus.cs b/asuka.Provider.Sdk/SeriesStatus.cs
new file mode 100644
index 0000000..eeea1c4
--- /dev/null
+++ b/asuka.Provider.Sdk/SeriesStatus.cs
@@ -0,0 +1,7 @@
+namespace asuka.Provider.Sdk;
+
+public enum SeriesStatus
+{
+ Ongoing,
+ Completed
+}
diff --git a/asuka.Provider.Sdk/Utilities/CookieParsers.cs b/asuka.Provider.Sdk/Utilities/CookieParsers.cs
new file mode 100644
index 0000000..8b8704a
--- /dev/null
+++ b/asuka.Provider.Sdk/Utilities/CookieParsers.cs
@@ -0,0 +1,129 @@
+using System.Net;
+using System.Reflection;
+using System.Text;
+using System.Text.Json;
+
+namespace asuka.Provider.Sdk.Utilities;
+
+internal static class CookieParsers
+{
+ ///
+ /// Gets the cookie dump from file relative to the assembly path
+ ///
+ /// The assembly to be used as reference
+ /// Custom file name of the dump
+ ///
+ public static List GetFromFileRelativeToType(Type type, string fileName = "cookies.txt")
+ {
+ var assemblyRoot = Path.GetDirectoryName(Assembly.GetAssembly(type)?.Location);
+ if (string.IsNullOrEmpty(assemblyRoot))
+ {
+ return [];
+ }
+
+ var path = Path.Combine(assemblyRoot, Path.GetFileName(fileName));
+ if (!File.Exists(path))
+ {
+ return [];
+ }
+
+ if (TryParseJsonExport(path, out var jsonCookies))
+ {
+ return jsonCookies;
+ }
+
+ return TryParseNetscapeNavigatorExport(path, out var navigatorCookies)
+ ? navigatorCookies
+ : [];
+ }
+
+ private static bool TryParseJsonExport(string filePath, out List cookies)
+ {
+ if (!File.Exists(filePath))
+ {
+ cookies = [];
+ return false;
+ }
+
+ try
+ {
+ var file = File.ReadAllText(filePath, Encoding.UTF8);
+ var data = JsonSerializer.Deserialize(file);
+
+ if (data == null)
+ {
+ cookies = [];
+ return false;
+ }
+
+ var exportedCookies = new List();
+ foreach (var cookie in data)
+ {
+ exportedCookies.Add(new Cookie
+ {
+ Name = cookie.Name,
+ Value = cookie.Value,
+ Domain = cookie.Domain,
+ HttpOnly = cookie.HttpOnly,
+ Secure = cookie.Secure,
+ Path = cookie.Path
+ });
+ }
+
+ cookies = exportedCookies;
+ return true;
+ }
+ catch
+ {
+ cookies = [];
+ return false;
+ }
+ }
+
+ private static bool TryParseNetscapeNavigatorExport(string filePath, out List cookies)
+ {
+ if (!File.Exists(filePath))
+ {
+ cookies = [];
+ return false;
+ }
+
+ var exportedCookies = new List();
+ foreach (var line in File.ReadAllLines(filePath))
+ {
+ // Skip line starts with #. These are comments.
+ if (line.StartsWith('#'))
+ {
+ continue;
+ }
+
+ var fields = line.Split('\t').ToList();
+
+ // If the field count isn't 7, ignore the line. Just to be safe.
+ if (fields.Count < 7)
+ {
+ continue;
+ }
+
+ var host = fields[0];
+ var path = fields[2];
+ var isSecure = bool.Parse(fields[3]);
+ var name = fields[5];
+ var value = fields[6];
+
+ var cookie = new Cookie
+ {
+ Name = name,
+ Value = value,
+ Domain = host,
+ Secure = isSecure,
+ Path = path
+ };
+
+ exportedCookies.Add(cookie);
+ }
+
+ cookies = exportedCookies;
+ return true;
+ }
+}
diff --git a/asuka.Provider.Sdk/Utilities/HttpClientFactory.cs b/asuka.Provider.Sdk/Utilities/HttpClientFactory.cs
new file mode 100644
index 0000000..8f62121
--- /dev/null
+++ b/asuka.Provider.Sdk/Utilities/HttpClientFactory.cs
@@ -0,0 +1,66 @@
+using System.Reflection;
+
+namespace asuka.Provider.Sdk.Utilities;
+
+public static class HttpClientFactory
+{
+ ///
+ /// Creates an HTTP Client
+ ///
+ ///
+ ///
+ ///
+ public static HttpClient CreateClientFromProvider(
+ string hostname,
+ Dictionary? customHeaders = null)
+ {
+ var userAgent = GetUserAgentFromFile(typeof(T)) is null
+ ? "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36"
+ : GetUserAgentFromFile(typeof(T));
+ var cookies = CookieParsers.GetFromFileRelativeToType(typeof(T));
+
+ var handler = new HttpClientHandler();
+
+ // Read cookies from file and load them into RestClientOptions
+ foreach (var cookie in cookies)
+ {
+ handler.CookieContainer.Add(cookie);
+ }
+
+ var httpClient = new HttpClient(handler)
+ {
+ BaseAddress = new Uri(hostname)
+ };
+
+ httpClient.DefaultRequestHeaders.UserAgent.TryParseAdd(userAgent);
+
+ // Load custom header values
+ if (customHeaders != null)
+ {
+ foreach (var header in customHeaders)
+ {
+ httpClient.DefaultRequestHeaders.Add(header.Key, header.Value);
+ }
+ }
+
+ return httpClient;
+ }
+
+ private static string? GetUserAgentFromFile(Type type)
+ {
+ var assemblyRoot = Path.GetDirectoryName(Assembly.GetAssembly(type)?.Location);
+ if (string.IsNullOrEmpty(assemblyRoot))
+ {
+ return null;
+ }
+
+ var path = Path.Combine(assemblyRoot, "UA.txt");
+ if (!File.Exists(path))
+ {
+ return null;
+ }
+
+ var file = File.ReadAllLines(path);
+ return file.Length == 0 ? null : file[0];
+ }
+}
diff --git a/asuka.Provider.Sdk/Utilities/JsonCookie.cs b/asuka.Provider.Sdk/Utilities/JsonCookie.cs
new file mode 100644
index 0000000..d5e6795
--- /dev/null
+++ b/asuka.Provider.Sdk/Utilities/JsonCookie.cs
@@ -0,0 +1,34 @@
+// ReSharper disable AutoPropertyCanBeMadeGetOnly.Global
+// ReSharper disable ClassNeverInstantiated.Global
+// ReSharper disable UnusedAutoPropertyAccessor.Global
+
+using System.Text.Json.Serialization;
+
+namespace asuka.Provider.Sdk.Utilities;
+
+internal sealed class JsonCookie
+{
+ [JsonPropertyName("domain")]
+ public string Domain { get; init; } = string.Empty;
+
+ [JsonPropertyName("hostOnly")]
+ public bool HostOnly { get; init; }
+
+ [JsonPropertyName("httpOnly")]
+ public bool HttpOnly { get; init; }
+
+ [JsonPropertyName("name")]
+ public string Name { get; init; } = string.Empty;
+
+ [JsonPropertyName("path")]
+ public string Path { get; init; } = string.Empty;
+
+ [JsonPropertyName("sameSite")]
+ public string SameSite { get; init; } = string.Empty;
+
+ [JsonPropertyName("secure")]
+ public bool Secure { get; init; }
+
+ [JsonPropertyName("value")]
+ public string Value { get; init; } = string.Empty;
+}
diff --git a/asuka.Provider.Sdk/asuka.Provider.Sdk.csproj b/asuka.Provider.Sdk/asuka.Provider.Sdk.csproj
new file mode 100644
index 0000000..3086865
--- /dev/null
+++ b/asuka.Provider.Sdk/asuka.Provider.Sdk.csproj
@@ -0,0 +1,11 @@
+
+
+
+ net8.0
+ enable
+ enable
+ asuka.Provider.Sdk
+ asuka.Provider.Sdk
+
+
+
diff --git a/asuka.csproj b/asuka.csproj
deleted file mode 100644
index 45315b3..0000000
--- a/asuka.csproj
+++ /dev/null
@@ -1,60 +0,0 @@
-
-
-
- Exe
- net8.0
- 1.3.0.0
- 1.3.0.0
- appicon.ico
- Release;Debug
- x64
- 1.3.0
- Aiko Fujimoto
- Cross Platform nhentai downloader
- Copyright 2023 Aiko Fujimoto
- Aiko Fujimoto
- https://github.com/aikoofujimotoo/asuka
- AppIcon.png
-
- D:\Code\Projects\asuka\LICENSE
- latestmajor
- enable
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Always
-
-
-
- Always
-
-
-
- Always
-
-
- Always
-
-
- True
-
-
-
-
diff --git a/asuka.sln b/asuka.sln
index 1764bb3..33da0a6 100644
--- a/asuka.sln
+++ b/asuka.sln
@@ -1,6 +1,29 @@
Microsoft Visual Studio Solution File, Format Version 12.00
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "asuka", "asuka.csproj", "{F2D45627-3ECD-41C4-A10C-B3AA475EEF14}"
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "asuka", "asuka.Application\asuka.csproj", "{310A657A-4E69-469C-836B-67E16384F23F}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Providers", "Providers", "{2B203105-1EC9-48D2-AAC1-9BD716EF13C1}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "asuka.Provider.Sdk", "asuka.Provider.Sdk\asuka.Provider.Sdk.csproj", "{2BD4729C-1B0B-46F8-9602-F689639DBBAB}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "asuka.Provider.Nhentai", "asuka.Provider.Nhentai\asuka.Provider.Nhentai.csproj", "{F5313F69-A9E9-4672-A23D-2FE123F14A2C}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Files", "Solution Files", "{2860B11D-2FF9-445D-AC1E-6C03AE9762DF}"
+ ProjectSection(SolutionItems) = preProject
+ LICENSE = LICENSE
+ README.md = README.md
+ Makefile = Makefile
+ .gitignore = .gitignore
+ EndProjectSection
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "asuka.Provider.Koharu", "asuka.Provider.Koharu\asuka.Provider.Koharu.csproj", "{78D1F38F-0BBA-49FF-A40E-02866C2445F5}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Documentation", "Documentation", "{F7A21844-1EE1-4C7C-872C-32F6593B6649}"
+ ProjectSection(SolutionItems) = preProject
+ docs\SDK = docs\SDK
+ EndProjectSection
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "asuka.Provider.Hitomi", "asuka.Provider.Hitomi\asuka.Provider.Hitomi.csproj", "{18855F77-0449-4C70-BBEE-212D619CFAF1}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
@@ -8,9 +31,32 @@ Global
Debug|x64 = Debug|x64
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
- {F2D45627-3ECD-41C4-A10C-B3AA475EEF14}.Release|x64.ActiveCfg = Release|x64
- {F2D45627-3ECD-41C4-A10C-B3AA475EEF14}.Release|x64.Build.0 = Release|x64
- {F2D45627-3ECD-41C4-A10C-B3AA475EEF14}.Debug|x64.ActiveCfg = Debug|x64
- {F2D45627-3ECD-41C4-A10C-B3AA475EEF14}.Debug|x64.Build.0 = Debug|x64
+ {310A657A-4E69-469C-836B-67E16384F23F}.Release|x64.ActiveCfg = Release|x64
+ {310A657A-4E69-469C-836B-67E16384F23F}.Release|x64.Build.0 = Release|x64
+ {310A657A-4E69-469C-836B-67E16384F23F}.Debug|x64.ActiveCfg = Debug|x64
+ {310A657A-4E69-469C-836B-67E16384F23F}.Debug|x64.Build.0 = Debug|x64
+ {2BD4729C-1B0B-46F8-9602-F689639DBBAB}.Release|x64.ActiveCfg = Release|Any CPU
+ {2BD4729C-1B0B-46F8-9602-F689639DBBAB}.Release|x64.Build.0 = Release|Any CPU
+ {2BD4729C-1B0B-46F8-9602-F689639DBBAB}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {2BD4729C-1B0B-46F8-9602-F689639DBBAB}.Debug|x64.Build.0 = Debug|Any CPU
+ {F5313F69-A9E9-4672-A23D-2FE123F14A2C}.Release|x64.ActiveCfg = Release|Any CPU
+ {F5313F69-A9E9-4672-A23D-2FE123F14A2C}.Release|x64.Build.0 = Release|Any CPU
+ {F5313F69-A9E9-4672-A23D-2FE123F14A2C}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {F5313F69-A9E9-4672-A23D-2FE123F14A2C}.Debug|x64.Build.0 = Debug|Any CPU
+ {78D1F38F-0BBA-49FF-A40E-02866C2445F5}.Release|x64.ActiveCfg = Release|Any CPU
+ {78D1F38F-0BBA-49FF-A40E-02866C2445F5}.Release|x64.Build.0 = Release|Any CPU
+ {78D1F38F-0BBA-49FF-A40E-02866C2445F5}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {78D1F38F-0BBA-49FF-A40E-02866C2445F5}.Debug|x64.Build.0 = Debug|Any CPU
+ {18855F77-0449-4C70-BBEE-212D619CFAF1}.Release|x64.ActiveCfg = Release|Any CPU
+ {18855F77-0449-4C70-BBEE-212D619CFAF1}.Release|x64.Build.0 = Release|Any CPU
+ {18855F77-0449-4C70-BBEE-212D619CFAF1}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {18855F77-0449-4C70-BBEE-212D619CFAF1}.Debug|x64.Build.0 = Debug|Any CPU
+ EndGlobalSection
+ GlobalSection(NestedProjects) = preSolution
+ {2BD4729C-1B0B-46F8-9602-F689639DBBAB} = {2B203105-1EC9-48D2-AAC1-9BD716EF13C1}
+ {F5313F69-A9E9-4672-A23D-2FE123F14A2C} = {2B203105-1EC9-48D2-AAC1-9BD716EF13C1}
+ {78D1F38F-0BBA-49FF-A40E-02866C2445F5} = {2B203105-1EC9-48D2-AAC1-9BD716EF13C1}
+ {F7A21844-1EE1-4C7C-872C-32F6593B6649} = {2860B11D-2FF9-445D-AC1E-6C03AE9762DF}
+ {18855F77-0449-4C70-BBEE-212D619CFAF1} = {2B203105-1EC9-48D2-AAC1-9BD716EF13C1}
EndGlobalSection
EndGlobal