node build fixed
This commit is contained in:
66
seanime-2.9.10/internal/manga/providers/_local_pdf_test.go
Normal file
66
seanime-2.9.10/internal/manga/providers/_local_pdf_test.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package manga_providers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"image/jpeg"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestConvertPDFToImages(t *testing.T) {
|
||||
start := time.Now()
|
||||
|
||||
doc, err := fitz.New("")
|
||||
require.NoError(t, err)
|
||||
defer doc.Close()
|
||||
|
||||
images := make(map[int][]byte, doc.NumPage())
|
||||
|
||||
// Load images into memory
|
||||
for n := 0; n < doc.NumPage(); n++ {
|
||||
img, err := doc.Image(n)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
err = jpeg.Encode(&buf, img, &jpeg.Options{Quality: jpeg.DefaultQuality})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
images[n] = buf.Bytes()
|
||||
}
|
||||
|
||||
end := time.Now()
|
||||
|
||||
t.Logf("Converted %d pages in %f seconds", len(images), end.Sub(start).Seconds())
|
||||
|
||||
for n, imgData := range images {
|
||||
t.Logf("Page %d: %d bytes", n, len(imgData))
|
||||
}
|
||||
|
||||
//tmpDir, err := os.MkdirTemp(os.TempDir(), "manga_test_")
|
||||
//require.NoError(t, err)
|
||||
//if len(images) > 0 {
|
||||
// // Write the first image to a file for verification
|
||||
// firstImagePath := tmpDir + "/page_0.jpg"
|
||||
// err = os.WriteFile(firstImagePath, images[0], 0644)
|
||||
// require.NoError(t, err)
|
||||
// t.Logf("First image written to: %s", firstImagePath)
|
||||
//}
|
||||
//
|
||||
//time.Sleep(1 * time.Minute)
|
||||
//
|
||||
//t.Cleanup(func() {
|
||||
// // Clean up the temporary directory
|
||||
// err := os.RemoveAll(tmpDir)
|
||||
// if err != nil {
|
||||
// t.Logf("Failed to remove temp directory: %v", err)
|
||||
// } else {
|
||||
// t.Logf("Temporary directory removed: %s", tmpDir)
|
||||
// }
|
||||
//})
|
||||
}
|
||||
82
seanime-2.9.10/internal/manga/providers/_template.go
Normal file
82
seanime-2.9.10/internal/manga/providers/_template.go
Normal file
@@ -0,0 +1,82 @@
|
||||
package manga_providers
|
||||
|
||||
import (
|
||||
"github.com/rs/zerolog"
|
||||
"net/http"
|
||||
"seanime/internal/util"
|
||||
"time"
|
||||
)
|
||||
|
||||
type (
|
||||
Template struct {
|
||||
Url string
|
||||
Client *http.Client
|
||||
UserAgent string
|
||||
logger *zerolog.Logger
|
||||
}
|
||||
)
|
||||
|
||||
func NewTemplate(logger *zerolog.Logger) *Template {
|
||||
c := &http.Client{
|
||||
Timeout: 60 * time.Second,
|
||||
}
|
||||
c.Transport = util.AddCloudFlareByPass(c.Transport)
|
||||
return &Template{
|
||||
Url: "https://XXXXXX.com",
|
||||
Client: c,
|
||||
UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.3",
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
func (mp *Template) Search(opts SearchOptions) ([]*SearchResult, error) {
|
||||
results := make([]*SearchResult, 0)
|
||||
|
||||
mp.logger.Debug().Str("query", opts.Query).Msg("XXXXXX: Searching manga")
|
||||
|
||||
// code
|
||||
|
||||
if len(results) == 0 {
|
||||
mp.logger.Error().Str("query", opts.Query).Msg("XXXXXX: No results found")
|
||||
return nil, ErrNoResults
|
||||
}
|
||||
|
||||
mp.logger.Info().Int("count", len(results)).Msg("XXXXXX: Found results")
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func (mp *Template) FindChapters(id string) ([]*ChapterDetails, error) {
|
||||
ret := make([]*ChapterDetails, 0)
|
||||
|
||||
mp.logger.Debug().Str("mangaId", id).Msg("XXXXXX: Finding chapters")
|
||||
|
||||
// code
|
||||
|
||||
if len(ret) == 0 {
|
||||
mp.logger.Error().Str("mangaId", id).Msg("XXXXXX: No chapters found")
|
||||
return nil, ErrNoChapters
|
||||
}
|
||||
|
||||
mp.logger.Info().Int("count", len(ret)).Msg("XXXXXX: Found chapters")
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func (mp *Template) FindChapterPages(id string) ([]*ChapterPage, error) {
|
||||
ret := make([]*ChapterPage, 0)
|
||||
|
||||
mp.logger.Debug().Str("chapterId", id).Msg("XXXXXX: Finding chapter pages")
|
||||
|
||||
// code
|
||||
|
||||
if len(ret) == 0 {
|
||||
mp.logger.Error().Str("chapterId", id).Msg("XXXXXX: No pages found")
|
||||
return nil, ErrNoPages
|
||||
}
|
||||
|
||||
mp.logger.Info().Int("count", len(ret)).Msg("XXXXXX: Found pages")
|
||||
|
||||
return ret, nil
|
||||
|
||||
}
|
||||
127
seanime-2.9.10/internal/manga/providers/_template_test.go
Normal file
127
seanime-2.9.10/internal/manga/providers/_template_test.go
Normal file
@@ -0,0 +1,127 @@
|
||||
package manga_providers
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"seanime/internal/util"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestXXXXXX_Search(t *testing.T) {
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
query string
|
||||
}{
|
||||
{
|
||||
name: "Boku no Kokoro no Yabai Yatsu",
|
||||
query: "Boku no Kokoro no Yabai Yatsu",
|
||||
},
|
||||
}
|
||||
|
||||
provider := NewXXXXXX(util.NewLogger())
|
||||
|
||||
for _, tt := range tests {
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
|
||||
searchRes, err := provider.Search(SearchOptions{
|
||||
Query: tt.query,
|
||||
})
|
||||
if assert.NoError(t, err, "provider.Search() error") {
|
||||
assert.NotEmpty(t, searchRes, "search result is empty")
|
||||
|
||||
for _, res := range searchRes {
|
||||
t.Logf("Title: %s", res.Title)
|
||||
t.Logf("\tID: %s", res.ID)
|
||||
t.Logf("\tYear: %d", res.Year)
|
||||
t.Logf("\tImage: %s", res.Image)
|
||||
t.Logf("\tProvider: %s", res.Provider)
|
||||
t.Logf("\tSearchRating: %f", res.SearchRating)
|
||||
t.Logf("\tSynonyms: %v", res.Synonyms)
|
||||
t.Log("--------------------------------------------------")
|
||||
}
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func TestXXXXXX_FindChapters(t *testing.T) {
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
id string
|
||||
atLeast int
|
||||
}{
|
||||
{
|
||||
name: "The Dangers in My Heart",
|
||||
id: "",
|
||||
atLeast: 141,
|
||||
},
|
||||
}
|
||||
|
||||
provider := NewXXXXXX(util.NewLogger())
|
||||
|
||||
for _, tt := range tests {
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
|
||||
chapters, err := provider.FindChapters(tt.id)
|
||||
if assert.NoError(t, err, "provider.FindChapters() error") {
|
||||
|
||||
assert.NotEmpty(t, chapters, "chapters is empty")
|
||||
|
||||
assert.GreaterOrEqual(t, len(chapters), tt.atLeast, "chapters length is less than expected")
|
||||
|
||||
for _, chapter := range chapters {
|
||||
t.Logf("Title: %s", chapter.Title)
|
||||
t.Logf("\tSlug: %s", chapter.ID)
|
||||
t.Logf("\tURL: %s", chapter.URL)
|
||||
t.Logf("\tIndex: %d", chapter.Index)
|
||||
t.Logf("\tUpdatedAt: %s", chapter.UpdatedAt)
|
||||
t.Log("--------------------------------------------------")
|
||||
}
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func TestXXXXXX_FindChapterPages(t *testing.T) {
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
chapterId string
|
||||
}{
|
||||
{
|
||||
name: "The Dangers in My Heart",
|
||||
chapterId: "", // Chapter 1
|
||||
},
|
||||
}
|
||||
|
||||
provider := NewXXXXXX(util.NewLogger())
|
||||
|
||||
for _, tt := range tests {
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
|
||||
pages, err := provider.FindChapterPages(tt.chapterId)
|
||||
if assert.NoError(t, err, "provider.FindChapterPages() error") {
|
||||
assert.NotEmpty(t, pages, "pages is empty")
|
||||
|
||||
for _, page := range pages {
|
||||
t.Logf("Index: %d", page.Index)
|
||||
t.Logf("\tURL: %s", page.URL)
|
||||
t.Log("--------------------------------------------------")
|
||||
}
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
376
seanime-2.9.10/internal/manga/providers/comick.go
Normal file
376
seanime-2.9.10/internal/manga/providers/comick.go
Normal file
@@ -0,0 +1,376 @@
|
||||
package manga_providers
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"fmt"
|
||||
"net/url"
|
||||
hibikemanga "seanime/internal/extension/hibike/manga"
|
||||
"seanime/internal/util"
|
||||
"seanime/internal/util/comparison"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/imroc/req/v3"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
type (
|
||||
ComicK struct {
|
||||
Url string
|
||||
Client *req.Client
|
||||
logger *zerolog.Logger
|
||||
}
|
||||
|
||||
ComicKResultItem struct {
|
||||
ID int `json:"id"`
|
||||
HID string `json:"hid"`
|
||||
Slug string `json:"slug"`
|
||||
Title string `json:"title"`
|
||||
Country string `json:"country"`
|
||||
Rating string `json:"rating"`
|
||||
BayesianRating string `json:"bayesian_rating"`
|
||||
RatingCount int `json:"rating_count"`
|
||||
FollowCount int `json:"follow_count"`
|
||||
Description string `json:"desc"`
|
||||
Status int `json:"status"`
|
||||
LastChapter float64 `json:"last_chapter"`
|
||||
TranslationCompleted bool `json:"translation_completed"`
|
||||
ViewCount int `json:"view_count"`
|
||||
ContentRating string `json:"content_rating"`
|
||||
Demographic int `json:"demographic"`
|
||||
UploadedAt string `json:"uploaded_at"`
|
||||
Genres []int `json:"genres"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UserFollowCount int `json:"user_follow_count"`
|
||||
Year int `json:"year"`
|
||||
MuComics struct {
|
||||
Year int `json:"year"`
|
||||
} `json:"mu_comics"`
|
||||
MdTitles []struct {
|
||||
Title string `json:"title"`
|
||||
} `json:"md_titles"`
|
||||
MdCovers []struct {
|
||||
W int `json:"w"`
|
||||
H int `json:"h"`
|
||||
B2Key string `json:"b2key"`
|
||||
} `json:"md_covers"`
|
||||
Highlight string `json:"highlight"`
|
||||
}
|
||||
)
|
||||
|
||||
func NewComicK(logger *zerolog.Logger) *ComicK {
|
||||
client := req.C().
|
||||
SetUserAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36").
|
||||
SetTimeout(60 * time.Second).
|
||||
EnableInsecureSkipVerify().
|
||||
ImpersonateSafari()
|
||||
|
||||
return &ComicK{
|
||||
Url: "https://api.comick.fun",
|
||||
Client: client,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// DEVNOTE: Each chapter ID is a unique string provided by ComicK
|
||||
|
||||
func (c *ComicK) GetSettings() hibikemanga.Settings {
|
||||
return hibikemanga.Settings{
|
||||
SupportsMultiScanlator: false,
|
||||
SupportsMultiLanguage: false,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *ComicK) Search(opts hibikemanga.SearchOptions) ([]*hibikemanga.SearchResult, error) {
|
||||
searchUrl := fmt.Sprintf("%s/v1.0/search?q=%s&limit=25&page=1", c.Url, url.QueryEscape(opts.Query))
|
||||
if opts.Year != 0 {
|
||||
searchUrl += fmt.Sprintf("&from=%d&to=%d", opts.Year, opts.Year)
|
||||
}
|
||||
|
||||
c.logger.Debug().Str("searchUrl", searchUrl).Msg("comick: Searching manga")
|
||||
|
||||
var data []*ComicKResultItem
|
||||
resp, err := c.Client.R().
|
||||
SetSuccessResult(&data).
|
||||
Get(searchUrl)
|
||||
|
||||
if err != nil {
|
||||
c.logger.Error().Err(err).Msg("comick: Failed to send request")
|
||||
return nil, fmt.Errorf("failed to send request: %w", err)
|
||||
}
|
||||
|
||||
if !resp.IsSuccessState() {
|
||||
c.logger.Error().Str("status", resp.Status).Msg("comick: Request failed")
|
||||
return nil, fmt.Errorf("failed to reach API: status %s", resp.Status)
|
||||
}
|
||||
|
||||
results := make([]*hibikemanga.SearchResult, 0)
|
||||
for _, result := range data {
|
||||
|
||||
// Skip fan-colored manga
|
||||
if strings.Contains(result.Slug, "fan-colored") {
|
||||
continue
|
||||
}
|
||||
|
||||
var coverURL string
|
||||
if len(result.MdCovers) > 0 && result.MdCovers[0].B2Key != "" {
|
||||
coverURL = "https://meo.comick.pictures/" + result.MdCovers[0].B2Key
|
||||
}
|
||||
|
||||
altTitles := make([]string, len(result.MdTitles))
|
||||
for j, title := range result.MdTitles {
|
||||
altTitles[j] = title.Title
|
||||
}
|
||||
|
||||
// DEVNOTE: We don't compare to alt titles because ComicK's synonyms aren't good
|
||||
compRes, _ := comparison.FindBestMatchWithSorensenDice(&opts.Query, []*string{&result.Title})
|
||||
|
||||
results = append(results, &hibikemanga.SearchResult{
|
||||
ID: result.HID,
|
||||
Title: cmp.Or(result.Title, result.Slug),
|
||||
Synonyms: altTitles,
|
||||
Image: coverURL,
|
||||
Year: result.Year,
|
||||
SearchRating: compRes.Rating,
|
||||
Provider: ComickProvider,
|
||||
})
|
||||
}
|
||||
|
||||
if len(results) == 0 {
|
||||
c.logger.Warn().Msg("comick: No results found")
|
||||
return nil, ErrNoChapters
|
||||
}
|
||||
|
||||
c.logger.Info().Int("count", len(results)).Msg("comick: Found results")
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func (c *ComicK) FindChapters(id string) ([]*hibikemanga.ChapterDetails, error) {
|
||||
ret := make([]*hibikemanga.ChapterDetails, 0)
|
||||
|
||||
c.logger.Debug().Str("mangaId", id).Msg("comick: Fetching chapters")
|
||||
|
||||
uri := fmt.Sprintf("%s/comic/%s/chapters?lang=en&page=0&limit=1000000&chap-order=1", c.Url, id)
|
||||
|
||||
var data struct {
|
||||
Chapters []*ComicChapter `json:"chapters"`
|
||||
}
|
||||
|
||||
resp, err := c.Client.R().
|
||||
SetSuccessResult(&data).
|
||||
Get(uri)
|
||||
|
||||
if err != nil {
|
||||
c.logger.Error().Err(err).Msg("comick: Failed to send request")
|
||||
return nil, fmt.Errorf("failed to send request: %w", err)
|
||||
}
|
||||
|
||||
if !resp.IsSuccessState() {
|
||||
c.logger.Error().Str("status", resp.Status).Msg("comick: Request failed")
|
||||
return nil, fmt.Errorf("failed to decode response: status %s", resp.Status)
|
||||
}
|
||||
|
||||
chapters := make([]*hibikemanga.ChapterDetails, 0)
|
||||
chaptersMap := make(map[string]*hibikemanga.ChapterDetails)
|
||||
count := 0
|
||||
for _, chapter := range data.Chapters {
|
||||
if chapter.Chap == "" || chapter.Lang != "en" {
|
||||
continue
|
||||
}
|
||||
title := "Chapter " + chapter.Chap + " "
|
||||
|
||||
if title == "" {
|
||||
if chapter.Title == "" {
|
||||
title = "Oneshot"
|
||||
} else {
|
||||
title = chapter.Title
|
||||
}
|
||||
}
|
||||
title = strings.TrimSpace(title)
|
||||
|
||||
prev, ok := chaptersMap[chapter.Chap]
|
||||
rating := chapter.UpCount - chapter.DownCount
|
||||
|
||||
if !ok || rating > prev.Rating {
|
||||
if !ok {
|
||||
count++
|
||||
}
|
||||
chaptersMap[chapter.Chap] = &hibikemanga.ChapterDetails{
|
||||
Provider: ComickProvider,
|
||||
ID: chapter.HID,
|
||||
Title: title,
|
||||
Index: uint(count),
|
||||
URL: fmt.Sprintf("%s/chapter/%s", c.Url, chapter.HID),
|
||||
Chapter: chapter.Chap,
|
||||
Rating: rating,
|
||||
UpdatedAt: chapter.UpdatedAt,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, chapter := range chaptersMap {
|
||||
chapters = append(chapters, chapter)
|
||||
}
|
||||
|
||||
// Sort chapters by index
|
||||
slices.SortStableFunc(chapters, func(i, j *hibikemanga.ChapterDetails) int {
|
||||
return cmp.Compare(i.Index, j.Index)
|
||||
})
|
||||
|
||||
ret = append(ret, chapters...)
|
||||
|
||||
if len(ret) == 0 {
|
||||
c.logger.Warn().Msg("comick: No chapters found")
|
||||
return nil, ErrNoChapters
|
||||
}
|
||||
|
||||
c.logger.Info().Int("count", len(ret)).Msg("comick: Found chapters")
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func (c *ComicK) FindChapterPages(id string) ([]*hibikemanga.ChapterPage, error) {
|
||||
ret := make([]*hibikemanga.ChapterPage, 0)
|
||||
|
||||
c.logger.Debug().Str("chapterId", id).Msg("comick: Finding chapter pages")
|
||||
|
||||
uri := fmt.Sprintf("%s/chapter/%s", c.Url, id)
|
||||
|
||||
var data struct {
|
||||
Chapter *ComicChapter `json:"chapter"`
|
||||
}
|
||||
|
||||
resp, err := c.Client.R().
|
||||
SetHeader("User-Agent", util.GetRandomUserAgent()).
|
||||
SetSuccessResult(&data).
|
||||
Get(uri)
|
||||
|
||||
if err != nil {
|
||||
c.logger.Error().Err(err).Msg("comick: Failed to send request")
|
||||
return nil, fmt.Errorf("failed to send request: %w", err)
|
||||
}
|
||||
|
||||
if !resp.IsSuccessState() {
|
||||
c.logger.Error().Str("status", resp.Status).Msg("comick: Request failed")
|
||||
return nil, fmt.Errorf("failed to decode response: status %s", resp.Status)
|
||||
}
|
||||
|
||||
if data.Chapter == nil {
|
||||
c.logger.Error().Msg("comick: Chapter not found")
|
||||
return nil, fmt.Errorf("chapter not found")
|
||||
}
|
||||
|
||||
for index, image := range data.Chapter.MdImages {
|
||||
ret = append(ret, &hibikemanga.ChapterPage{
|
||||
Provider: ComickProvider,
|
||||
URL: fmt.Sprintf("https://meo.comick.pictures/%s", image.B2Key),
|
||||
Index: index,
|
||||
Headers: make(map[string]string),
|
||||
})
|
||||
}
|
||||
|
||||
if len(ret) == 0 {
|
||||
c.logger.Warn().Msg("comick: No pages found")
|
||||
return nil, ErrNoPages
|
||||
}
|
||||
|
||||
c.logger.Info().Int("count", len(ret)).Msg("comick: Found pages")
|
||||
|
||||
return ret, nil
|
||||
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
type Comic struct {
|
||||
ID int `json:"id"`
|
||||
HID string `json:"hid"`
|
||||
Title string `json:"title"`
|
||||
Country string `json:"country"`
|
||||
Status int `json:"status"`
|
||||
Links struct {
|
||||
AL string `json:"al"`
|
||||
AP string `json:"ap"`
|
||||
BW string `json:"bw"`
|
||||
KT string `json:"kt"`
|
||||
MU string `json:"mu"`
|
||||
AMZ string `json:"amz"`
|
||||
CDJ string `json:"cdj"`
|
||||
EBJ string `json:"ebj"`
|
||||
MAL string `json:"mal"`
|
||||
RAW string `json:"raw"`
|
||||
} `json:"links"`
|
||||
LastChapter interface{} `json:"last_chapter"`
|
||||
ChapterCount int `json:"chapter_count"`
|
||||
Demographic int `json:"demographic"`
|
||||
Hentai bool `json:"hentai"`
|
||||
UserFollowCount int `json:"user_follow_count"`
|
||||
FollowRank int `json:"follow_rank"`
|
||||
CommentCount int `json:"comment_count"`
|
||||
FollowCount int `json:"follow_count"`
|
||||
Description string `json:"desc"`
|
||||
Parsed string `json:"parsed"`
|
||||
Slug string `json:"slug"`
|
||||
Mismatch interface{} `json:"mismatch"`
|
||||
Year int `json:"year"`
|
||||
BayesianRating interface{} `json:"bayesian_rating"`
|
||||
RatingCount int `json:"rating_count"`
|
||||
ContentRating string `json:"content_rating"`
|
||||
TranslationCompleted bool `json:"translation_completed"`
|
||||
RelateFrom []interface{} `json:"relate_from"`
|
||||
Mies interface{} `json:"mies"`
|
||||
MdTitles []struct {
|
||||
Title string `json:"title"`
|
||||
} `json:"md_titles"`
|
||||
MdComicMdGenres []struct {
|
||||
MdGenres struct {
|
||||
Name string `json:"name"`
|
||||
Type interface{} `json:"type"`
|
||||
Slug string `json:"slug"`
|
||||
Group string `json:"group"`
|
||||
} `json:"md_genres"`
|
||||
} `json:"md_comic_md_genres"`
|
||||
MuComics struct {
|
||||
LicensedInEnglish interface{} `json:"licensed_in_english"`
|
||||
MuComicCategories []struct {
|
||||
MuCategories struct {
|
||||
Title string `json:"title"`
|
||||
Slug string `json:"slug"`
|
||||
} `json:"mu_categories"`
|
||||
PositiveVote int `json:"positive_vote"`
|
||||
NegativeVote int `json:"negative_vote"`
|
||||
} `json:"mu_comic_categories"`
|
||||
} `json:"mu_comics"`
|
||||
MdCovers []struct {
|
||||
Vol interface{} `json:"vol"`
|
||||
W int `json:"w"`
|
||||
H int `json:"h"`
|
||||
B2Key string `json:"b2key"`
|
||||
} `json:"md_covers"`
|
||||
Iso6391 string `json:"iso639_1"`
|
||||
LangName string `json:"lang_name"`
|
||||
LangNative string `json:"lang_native"`
|
||||
}
|
||||
|
||||
type ComicChapter struct {
|
||||
ID int `json:"id"`
|
||||
Chap string `json:"chap"`
|
||||
Title string `json:"title"`
|
||||
Vol string `json:"vol,omitempty"`
|
||||
Lang string `json:"lang"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
UpCount int `json:"up_count"`
|
||||
DownCount int `json:"down_count"`
|
||||
GroupName []string `json:"group_name"`
|
||||
HID string `json:"hid"`
|
||||
MdImages []struct {
|
||||
Name string `json:"name"`
|
||||
W int `json:"w"`
|
||||
H int `json:"h"`
|
||||
S int `json:"s"`
|
||||
B2Key string `json:"b2key"`
|
||||
} `json:"md_images"`
|
||||
}
|
||||
249
seanime-2.9.10/internal/manga/providers/comick_multi.go
Normal file
249
seanime-2.9.10/internal/manga/providers/comick_multi.go
Normal file
@@ -0,0 +1,249 @@
|
||||
package manga_providers
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"fmt"
|
||||
"net/url"
|
||||
hibikemanga "seanime/internal/extension/hibike/manga"
|
||||
"seanime/internal/util"
|
||||
"seanime/internal/util/comparison"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/imroc/req/v3"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
type (
|
||||
ComicKMulti struct {
|
||||
Url string
|
||||
Client *req.Client
|
||||
logger *zerolog.Logger
|
||||
}
|
||||
)
|
||||
|
||||
func NewComicKMulti(logger *zerolog.Logger) *ComicKMulti {
|
||||
client := req.C().
|
||||
SetUserAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36").
|
||||
SetTimeout(60 * time.Second).
|
||||
EnableInsecureSkipVerify().
|
||||
ImpersonateSafari()
|
||||
|
||||
return &ComicKMulti{
|
||||
Url: "https://api.comick.fun",
|
||||
Client: client,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// DEVNOTE: Each chapter ID is a unique string provided by ComicK
|
||||
|
||||
func (c *ComicKMulti) GetSettings() hibikemanga.Settings {
|
||||
return hibikemanga.Settings{
|
||||
SupportsMultiScanlator: true,
|
||||
SupportsMultiLanguage: true,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *ComicKMulti) Search(opts hibikemanga.SearchOptions) ([]*hibikemanga.SearchResult, error) {
|
||||
|
||||
c.logger.Debug().Str("query", opts.Query).Msg("comick: Searching manga")
|
||||
|
||||
searchUrl := fmt.Sprintf("%s/v1.0/search?q=%s&limit=25&page=1", c.Url, url.QueryEscape(opts.Query))
|
||||
if opts.Year != 0 {
|
||||
searchUrl += fmt.Sprintf("&from=%d&to=%d", opts.Year, opts.Year)
|
||||
}
|
||||
|
||||
var data []*ComicKResultItem
|
||||
resp, err := c.Client.R().
|
||||
SetSuccessResult(&data).
|
||||
Get(searchUrl)
|
||||
|
||||
if err != nil {
|
||||
c.logger.Error().Err(err).Msg("comick: Failed to send request")
|
||||
return nil, fmt.Errorf("failed to send request: %w", err)
|
||||
}
|
||||
|
||||
if !resp.IsSuccessState() {
|
||||
c.logger.Error().Str("status", resp.Status).Msg("comick: Request failed")
|
||||
return nil, fmt.Errorf("failed to reach API: status %s", resp.Status)
|
||||
}
|
||||
|
||||
results := make([]*hibikemanga.SearchResult, 0)
|
||||
for _, result := range data {
|
||||
|
||||
// Skip fan-colored manga
|
||||
if strings.Contains(result.Slug, "fan-colored") {
|
||||
continue
|
||||
}
|
||||
|
||||
var coverURL string
|
||||
if len(result.MdCovers) > 0 && result.MdCovers[0].B2Key != "" {
|
||||
coverURL = "https://meo.comick.pictures/" + result.MdCovers[0].B2Key
|
||||
}
|
||||
|
||||
altTitles := make([]string, len(result.MdTitles))
|
||||
for j, title := range result.MdTitles {
|
||||
altTitles[j] = title.Title
|
||||
}
|
||||
|
||||
// DEVNOTE: We don't compare to alt titles because ComicK's synonyms aren't good
|
||||
compRes, _ := comparison.FindBestMatchWithSorensenDice(&opts.Query, []*string{&result.Title})
|
||||
|
||||
results = append(results, &hibikemanga.SearchResult{
|
||||
ID: result.HID,
|
||||
Title: cmp.Or(result.Title, result.Slug),
|
||||
Synonyms: altTitles,
|
||||
Image: coverURL,
|
||||
Year: result.Year,
|
||||
SearchRating: compRes.Rating,
|
||||
Provider: ComickProvider,
|
||||
})
|
||||
}
|
||||
|
||||
if len(results) == 0 {
|
||||
c.logger.Warn().Msg("comick: No results found")
|
||||
return nil, ErrNoChapters
|
||||
}
|
||||
|
||||
c.logger.Info().Int("count", len(results)).Msg("comick: Found results")
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func (c *ComicKMulti) FindChapters(id string) ([]*hibikemanga.ChapterDetails, error) {
|
||||
ret := make([]*hibikemanga.ChapterDetails, 0)
|
||||
|
||||
// c.logger.Debug().Str("mangaId", id).Msg("comick: Fetching chapters")
|
||||
|
||||
uri := fmt.Sprintf("%s/comic/%s/chapters?page=0&limit=1000000&chap-order=1", c.Url, id)
|
||||
c.logger.Debug().Str("mangaId", id).Str("uri", uri).Msg("comick: Fetching chapters")
|
||||
|
||||
var data struct {
|
||||
Chapters []*ComicChapter `json:"chapters"`
|
||||
}
|
||||
|
||||
resp, err := c.Client.R().
|
||||
SetSuccessResult(&data).
|
||||
Get(uri)
|
||||
|
||||
if err != nil {
|
||||
c.logger.Error().Err(err).Msg("comick: Failed to send request")
|
||||
return nil, fmt.Errorf("failed to send request: %w", err)
|
||||
}
|
||||
|
||||
if !resp.IsSuccessState() {
|
||||
c.logger.Error().Str("status", resp.Status).Msg("comick: Request failed")
|
||||
return nil, fmt.Errorf("failed to decode response: status %s", resp.Status)
|
||||
}
|
||||
|
||||
chapters := make([]*hibikemanga.ChapterDetails, 0)
|
||||
chaptersCountMap := make(map[string]int)
|
||||
for _, chapter := range data.Chapters {
|
||||
if chapter.Chap == "" {
|
||||
continue
|
||||
}
|
||||
title := "Chapter " + chapter.Chap + " "
|
||||
|
||||
if title == "" {
|
||||
if chapter.Title == "" {
|
||||
title = "Oneshot"
|
||||
} else {
|
||||
title = chapter.Title
|
||||
}
|
||||
}
|
||||
title = strings.TrimSpace(title)
|
||||
|
||||
groupName := ""
|
||||
if len(chapter.GroupName) > 0 {
|
||||
groupName = chapter.GroupName[0]
|
||||
}
|
||||
|
||||
count, ok := chaptersCountMap[groupName]
|
||||
if !ok {
|
||||
chaptersCountMap[groupName] = 0
|
||||
count = 0
|
||||
}
|
||||
chapters = append(chapters, &hibikemanga.ChapterDetails{
|
||||
Provider: ComickProvider,
|
||||
ID: chapter.HID,
|
||||
Title: title,
|
||||
Language: chapter.Lang,
|
||||
Index: uint(count),
|
||||
URL: fmt.Sprintf("%s/chapter/%s", c.Url, chapter.HID),
|
||||
Chapter: chapter.Chap,
|
||||
Scanlator: groupName,
|
||||
Rating: 0,
|
||||
UpdatedAt: chapter.UpdatedAt,
|
||||
})
|
||||
chaptersCountMap[groupName]++
|
||||
}
|
||||
|
||||
// Sort chapters by index
|
||||
slices.SortStableFunc(chapters, func(i, j *hibikemanga.ChapterDetails) int {
|
||||
return cmp.Compare(i.Index, j.Index)
|
||||
})
|
||||
|
||||
ret = append(ret, chapters...)
|
||||
|
||||
if len(ret) == 0 {
|
||||
c.logger.Warn().Msg("comick: No chapters found")
|
||||
return nil, ErrNoChapters
|
||||
}
|
||||
|
||||
c.logger.Info().Int("count", len(ret)).Msg("comick: Found chapters")
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func (c *ComicKMulti) FindChapterPages(id string) ([]*hibikemanga.ChapterPage, error) {
|
||||
ret := make([]*hibikemanga.ChapterPage, 0)
|
||||
|
||||
c.logger.Debug().Str("chapterId", id).Msg("comick: Finding chapter pages")
|
||||
|
||||
uri := fmt.Sprintf("%s/chapter/%s", c.Url, id)
|
||||
|
||||
var data struct {
|
||||
Chapter *ComicChapter `json:"chapter"`
|
||||
}
|
||||
|
||||
resp, err := c.Client.R().
|
||||
SetHeader("User-Agent", util.GetRandomUserAgent()).
|
||||
SetSuccessResult(&data).
|
||||
Get(uri)
|
||||
|
||||
if err != nil {
|
||||
c.logger.Error().Err(err).Msg("comick: Failed to send request")
|
||||
return nil, fmt.Errorf("failed to send request: %w", err)
|
||||
}
|
||||
|
||||
if !resp.IsSuccessState() {
|
||||
c.logger.Error().Str("status", resp.Status).Msg("comick: Request failed")
|
||||
return nil, fmt.Errorf("failed to decode response: status %s", resp.Status)
|
||||
}
|
||||
|
||||
if data.Chapter == nil {
|
||||
c.logger.Error().Msg("comick: Chapter not found")
|
||||
return nil, fmt.Errorf("chapter not found")
|
||||
}
|
||||
|
||||
for index, image := range data.Chapter.MdImages {
|
||||
ret = append(ret, &hibikemanga.ChapterPage{
|
||||
Provider: ComickProvider,
|
||||
URL: fmt.Sprintf("https://meo.comick.pictures/%s", image.B2Key),
|
||||
Index: index,
|
||||
Headers: make(map[string]string),
|
||||
})
|
||||
}
|
||||
|
||||
if len(ret) == 0 {
|
||||
c.logger.Warn().Msg("comick: No pages found")
|
||||
return nil, ErrNoPages
|
||||
}
|
||||
|
||||
c.logger.Info().Int("count", len(ret)).Msg("comick: Found pages")
|
||||
|
||||
return ret, nil
|
||||
|
||||
}
|
||||
224
seanime-2.9.10/internal/manga/providers/comick_test.go
Normal file
224
seanime-2.9.10/internal/manga/providers/comick_test.go
Normal file
@@ -0,0 +1,224 @@
|
||||
package manga_providers
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
hibikemanga "seanime/internal/extension/hibike/manga"
|
||||
"seanime/internal/util"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestComicK_Search(t *testing.T) {
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
query string
|
||||
}{
|
||||
{
|
||||
name: "One Piece",
|
||||
query: "One Piece",
|
||||
},
|
||||
{
|
||||
name: "Jujutsu Kaisen",
|
||||
query: "Jujutsu Kaisen",
|
||||
},
|
||||
{
|
||||
name: "Komi-san wa, Komyushou desu",
|
||||
query: "Komi-san wa, Komyushou desu",
|
||||
},
|
||||
{
|
||||
name: "Boku no Kokoro no Yabai Yatsu",
|
||||
query: "Boku no Kokoro no Yabai Yatsu",
|
||||
},
|
||||
}
|
||||
|
||||
comick := NewComicK(util.NewLogger())
|
||||
|
||||
for _, tt := range tests {
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
|
||||
searchRes, err := comick.Search(hibikemanga.SearchOptions{
|
||||
Query: tt.query,
|
||||
})
|
||||
if assert.NoError(t, err, "comick.Search() error") {
|
||||
assert.NotEmpty(t, searchRes, "search result is empty")
|
||||
|
||||
for _, res := range searchRes {
|
||||
t.Logf("Title: %s", res.Title)
|
||||
t.Logf("\tID: %s", res.ID)
|
||||
t.Logf("\tYear: %d", res.Year)
|
||||
t.Logf("\tImage: %s", res.Image)
|
||||
t.Logf("\tProvider: %s", res.Provider)
|
||||
t.Logf("\tSearchRating: %f", res.SearchRating)
|
||||
t.Logf("\tSynonyms: %v", res.Synonyms)
|
||||
t.Log("--------------------------------------------------")
|
||||
}
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
func TestComicK_FindChapters(t *testing.T) {
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
id string
|
||||
atLeast int
|
||||
}{
|
||||
{
|
||||
name: "Jujutsu Kaisen",
|
||||
id: "TA22I5O7",
|
||||
atLeast: 250,
|
||||
},
|
||||
{
|
||||
name: "Komi-san wa, Komyushou desu",
|
||||
id: "K_Dn8VW7",
|
||||
atLeast: 250,
|
||||
},
|
||||
{
|
||||
name: "Boku no Kokoro no Yabai Yatsu",
|
||||
id: "pYN47sZm",
|
||||
atLeast: 141,
|
||||
},
|
||||
}
|
||||
|
||||
comick := NewComicK(util.NewLogger())
|
||||
|
||||
for _, tt := range tests {
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
|
||||
chapters, err := comick.FindChapters(tt.id)
|
||||
if assert.NoError(t, err, "comick.FindChapters() error") {
|
||||
|
||||
assert.NotEmpty(t, chapters, "chapters is empty")
|
||||
|
||||
assert.GreaterOrEqual(t, len(chapters), tt.atLeast, "chapters length is less than expected")
|
||||
|
||||
for _, chapter := range chapters {
|
||||
t.Logf("Title: %s", chapter.Title)
|
||||
t.Logf("\tSlug: %s", chapter.ID)
|
||||
t.Logf("\tURL: %s", chapter.URL)
|
||||
t.Logf("\tIndex: %d", chapter.Index)
|
||||
t.Logf("\tChapter: %s", chapter.Chapter)
|
||||
t.Logf("\tUpdatedAt: %s", chapter.UpdatedAt)
|
||||
t.Log("--------------------------------------------------")
|
||||
}
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func TestComicKMulti_FindChapters(t *testing.T) {
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
id string
|
||||
atLeast int
|
||||
}{
|
||||
{
|
||||
name: "Jujutsu Kaisen",
|
||||
id: "TA22I5O7",
|
||||
atLeast: 250,
|
||||
},
|
||||
{
|
||||
name: "Komi-san wa, Komyushou desu",
|
||||
id: "K_Dn8VW7",
|
||||
atLeast: 250,
|
||||
},
|
||||
{
|
||||
name: "Boku no Kokoro no Yabai Yatsu",
|
||||
id: "pYN47sZm",
|
||||
atLeast: 141,
|
||||
},
|
||||
}
|
||||
|
||||
comick := NewComicKMulti(util.NewLogger())
|
||||
|
||||
for _, tt := range tests {
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
|
||||
chapters, err := comick.FindChapters(tt.id)
|
||||
if assert.NoError(t, err, "comick.FindChapters() error") {
|
||||
|
||||
assert.NotEmpty(t, chapters, "chapters is empty")
|
||||
|
||||
assert.GreaterOrEqual(t, len(chapters), tt.atLeast, "chapters length is less than expected")
|
||||
|
||||
for _, chapter := range chapters {
|
||||
t.Logf("Title: %s", chapter.Title)
|
||||
t.Logf("\tLanguage: %s", chapter.Language)
|
||||
t.Logf("\tScanlator: %s", chapter.Scanlator)
|
||||
t.Logf("\tSlug: %s", chapter.ID)
|
||||
t.Logf("\tURL: %s", chapter.URL)
|
||||
t.Logf("\tIndex: %d", chapter.Index)
|
||||
t.Logf("\tChapter: %s", chapter.Chapter)
|
||||
t.Logf("\tUpdatedAt: %s", chapter.UpdatedAt)
|
||||
t.Log("--------------------------------------------------")
|
||||
}
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func TestComicK_FindChapterPages(t *testing.T) {
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
id string
|
||||
index uint
|
||||
}{
|
||||
{
|
||||
name: "Jujutsu Kaisen",
|
||||
id: "TA22I5O7",
|
||||
index: 258,
|
||||
},
|
||||
}
|
||||
|
||||
comick := NewComicK(util.NewLogger())
|
||||
|
||||
for _, tt := range tests {
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
|
||||
chapters, err := comick.FindChapters(tt.id)
|
||||
if assert.NoError(t, err, "comick.FindChapters() error") {
|
||||
|
||||
assert.NotEmpty(t, chapters, "chapters is empty")
|
||||
|
||||
var chapterInfo *hibikemanga.ChapterDetails
|
||||
for _, chapter := range chapters {
|
||||
if chapter.Index == tt.index {
|
||||
chapterInfo = chapter
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if assert.NotNil(t, chapterInfo, "chapter not found") {
|
||||
pages, err := comick.FindChapterPages(chapterInfo.ID)
|
||||
if assert.NoError(t, err, "comick.FindChapterPages() error") {
|
||||
assert.NotEmpty(t, pages, "pages is empty")
|
||||
|
||||
for _, page := range pages {
|
||||
t.Logf("Index: %d", page.Index)
|
||||
t.Logf("\tURL: %s", page.URL)
|
||||
t.Log("--------------------------------------------------")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
14
seanime-2.9.10/internal/manga/providers/helpers.go
Normal file
14
seanime-2.9.10/internal/manga/providers/helpers.go
Normal file
@@ -0,0 +1,14 @@
|
||||
package manga_providers
|
||||
|
||||
import "strings"
|
||||
|
||||
// GetNormalizedChapter returns a normalized chapter string.
|
||||
// e.g. "0001" -> "1"
|
||||
func GetNormalizedChapter(chapter string) string {
|
||||
// Trim padding zeros
|
||||
unpaddedChStr := strings.TrimLeft(chapter, "0")
|
||||
if unpaddedChStr == "" {
|
||||
unpaddedChStr = "0"
|
||||
}
|
||||
return unpaddedChStr
|
||||
}
|
||||
556
seanime-2.9.10/internal/manga/providers/local.go
Normal file
556
seanime-2.9.10/internal/manga/providers/local.go
Normal file
@@ -0,0 +1,556 @@
|
||||
package manga_providers
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"fmt"
|
||||
// "image/jpeg"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
hibikemanga "seanime/internal/extension/hibike/manga"
|
||||
"seanime/internal/util/comparison"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
// "github.com/gen2brain/go-fitz"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
|
||||
const (
|
||||
LocalServePath = "{{manga-local-assets}}"
|
||||
)
|
||||
|
||||
type Local struct {
|
||||
dir string // Directory to scan for manga
|
||||
logger *zerolog.Logger
|
||||
|
||||
mu sync.Mutex
|
||||
currentChapterPath string
|
||||
currentZipCloser io.Closer
|
||||
currentPages map[string]*loadedPage
|
||||
}
|
||||
|
||||
type loadedPage struct {
|
||||
buf []byte
|
||||
page *hibikemanga.ChapterPage
|
||||
}
|
||||
|
||||
// chapterEntry represents a potential chapter file or directory found during scanning
|
||||
type chapterEntry struct {
|
||||
RelativePath string // Path relative to manga root (e.g., "mangaID/chapter1.cbz" or "mangaID/vol1/ch1.cbz")
|
||||
IsDir bool // Whether this entry is a directory
|
||||
}
|
||||
|
||||
func NewLocal(dir string, logger *zerolog.Logger) hibikemanga.Provider {
|
||||
_ = os.MkdirAll(dir, 0755)
|
||||
|
||||
return &Local{
|
||||
dir: dir,
|
||||
logger: logger,
|
||||
currentPages: make(map[string]*loadedPage),
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Local) GetSettings() hibikemanga.Settings {
|
||||
return hibikemanga.Settings{
|
||||
SupportsMultiScanlator: false,
|
||||
SupportsMultiLanguage: false,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Local) SetSourceDirectory(dir string) {
|
||||
if dir != "" {
|
||||
p.dir = dir
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Local) getAllManga() (res []*hibikemanga.SearchResult, err error) {
|
||||
if p.dir == "" {
|
||||
return make([]*hibikemanga.SearchResult, 0), nil
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(p.dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
res = make([]*hibikemanga.SearchResult, 0)
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
res = append(res, &hibikemanga.SearchResult{
|
||||
ID: entry.Name(),
|
||||
Title: entry.Name(),
|
||||
Provider: LocalProvider,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (p *Local) Search(opts hibikemanga.SearchOptions) (res []*hibikemanga.SearchResult, err error) {
|
||||
res = make([]*hibikemanga.SearchResult, 0)
|
||||
all, err := p.getAllManga()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if opts.Query == "" {
|
||||
return all, nil
|
||||
}
|
||||
|
||||
allTitles := make([]*string, len(all))
|
||||
for i, manga := range all {
|
||||
allTitles[i] = &manga.Title
|
||||
}
|
||||
compRes := comparison.CompareWithLevenshteinCleanFunc(&opts.Query, allTitles, cleanMangaTitle)
|
||||
|
||||
var bestMatch *comparison.LevenshteinResult
|
||||
for _, res := range compRes {
|
||||
if bestMatch == nil || res.Distance < bestMatch.Distance {
|
||||
bestMatch = res
|
||||
}
|
||||
}
|
||||
|
||||
if bestMatch == nil {
|
||||
return res, nil
|
||||
}
|
||||
|
||||
if bestMatch.Distance > 3 {
|
||||
// If the best match is too far away, return no results
|
||||
return res, nil
|
||||
}
|
||||
|
||||
manga, ok := lo.Find(all, func(manga *hibikemanga.SearchResult) bool {
|
||||
return manga.Title == *bestMatch.Value
|
||||
})
|
||||
|
||||
if !ok {
|
||||
return res, nil
|
||||
}
|
||||
|
||||
res = append(res, manga)
|
||||
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func cleanMangaTitle(title string) string {
|
||||
title = strings.TrimSpace(title)
|
||||
|
||||
// Remove some characters to make comparison easier
|
||||
title = strings.Map(func(r rune) rune {
|
||||
if r == '/' || r == '\\' || r == ':' || r == '*' || r == '?' || r == '!' || r == '"' || r == '<' || r == '>' || r == '|' || r == ',' {
|
||||
return rune(0)
|
||||
}
|
||||
return r
|
||||
}, title)
|
||||
|
||||
return title
|
||||
}
|
||||
|
||||
// FindChapters scans the manga series directory and returns the chapters.
|
||||
// Supports nested folder structures up to 2 levels deep.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// Series title/
|
||||
// ├── Chapter 1/
|
||||
// │ ├── image_1.ext
|
||||
// │ └── image_n.ext
|
||||
// ├── Chapter 2.pdf
|
||||
// └── Ch 1-10/
|
||||
// ├── Ch 1/
|
||||
// └── Ch 2/
|
||||
func (p *Local) FindChapters(mangaID string) (res []*hibikemanga.ChapterDetails, err error) {
|
||||
if p.dir == "" {
|
||||
return make([]*hibikemanga.ChapterDetails, 0), nil
|
||||
}
|
||||
|
||||
mangaPath := filepath.Join(p.dir, mangaID)
|
||||
|
||||
p.logger.Trace().Str("mangaPath", mangaPath).Msg("manga: Finding local chapters")
|
||||
|
||||
// Collect all potential chapter entries up to 2 levels deep
|
||||
chapterEntries, err := p.collectChapterEntries(mangaPath, mangaID, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
res = make([]*hibikemanga.ChapterDetails, 0)
|
||||
// Go through all collected entries.
|
||||
for _, entry := range chapterEntries {
|
||||
scannedEntry, ok := scanChapterFilename(filepath.Base(entry.RelativePath))
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
if len(scannedEntry.Chapter) != 1 {
|
||||
// Handle one-shots (no chapter number and only one entry)
|
||||
if len(scannedEntry.Chapter) == 0 && len(chapterEntries) == 1 {
|
||||
chapterTitle := "Chapter 1"
|
||||
if scannedEntry.ChapterTitle != "" {
|
||||
chapterTitle += " - " + scannedEntry.ChapterTitle
|
||||
}
|
||||
res = append(res, &hibikemanga.ChapterDetails{
|
||||
Provider: LocalProvider,
|
||||
ID: filepath.ToSlash(entry.RelativePath), // ID is the relative filepath, e.g. "/series/chapter_1.cbz" or "/series/vol1/ch1.cbz"
|
||||
URL: "",
|
||||
Title: chapterTitle,
|
||||
Chapter: "1",
|
||||
Index: 0, // placeholder, will be set later
|
||||
LocalIsPDF: scannedEntry.IsPDF,
|
||||
})
|
||||
} else if len(scannedEntry.Chapter) == 2 {
|
||||
// Handle combined chapters (e.g. "Chapter 1-2")
|
||||
chapterTitle := "Chapter " + cleanChapter(scannedEntry.Chapter[0]) + "-" + cleanChapter(scannedEntry.Chapter[1])
|
||||
if scannedEntry.ChapterTitle != "" {
|
||||
chapterTitle += " - " + scannedEntry.ChapterTitle
|
||||
}
|
||||
res = append(res, &hibikemanga.ChapterDetails{
|
||||
Provider: LocalProvider,
|
||||
ID: filepath.ToSlash(entry.RelativePath), // ID is the relative filepath, e.g. "/series/chapter_1.cbz" or "/series/vol1/ch1.cbz"
|
||||
URL: "",
|
||||
Title: chapterTitle,
|
||||
// Use the last chapter number as the chapter for progress tracking
|
||||
Chapter: cleanChapter(scannedEntry.Chapter[1]),
|
||||
Index: 0, // placeholder, will be set later
|
||||
LocalIsPDF: scannedEntry.IsPDF,
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
ch := cleanChapter(scannedEntry.Chapter[0])
|
||||
chapterTitle := "Chapter " + ch
|
||||
if scannedEntry.ChapterTitle != "" {
|
||||
chapterTitle += " - " + scannedEntry.ChapterTitle
|
||||
}
|
||||
|
||||
res = append(res, &hibikemanga.ChapterDetails{
|
||||
Provider: LocalProvider,
|
||||
ID: filepath.ToSlash(entry.RelativePath), // ID is the relative filepath, e.g. "/series/chapter_1.cbz" or "/series/vol1/ch1.cbz"
|
||||
URL: "",
|
||||
Title: chapterTitle,
|
||||
Chapter: ch,
|
||||
Index: 0, // placeholder, will be set later
|
||||
LocalIsPDF: scannedEntry.IsPDF,
|
||||
})
|
||||
}
|
||||
|
||||
// sort by chapter number (ascending)
|
||||
slices.SortFunc(res, func(a, b *hibikemanga.ChapterDetails) int {
|
||||
chA, _ := strconv.ParseFloat(a.Chapter, 64)
|
||||
chB, _ := strconv.ParseFloat(b.Chapter, 64)
|
||||
return int(chA - chB)
|
||||
})
|
||||
|
||||
// set the indexes
|
||||
for i, chapter := range res {
|
||||
chapter.Index = uint(i)
|
||||
}
|
||||
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// collectChapterEntries walks the directory tree up to maxDepth levels deep and collects
|
||||
// all potential chapter files and directories.
|
||||
func (p *Local) collectChapterEntries(currentPath, mangaID string, currentDepth int) (entries []*chapterEntry, err error) {
|
||||
const maxDepth = 2
|
||||
|
||||
if currentDepth > maxDepth {
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
dirEntries, err := os.ReadDir(currentPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
entries = make([]*chapterEntry, 0)
|
||||
|
||||
for _, entry := range dirEntries {
|
||||
entryPath := filepath.Join(currentPath, entry.Name())
|
||||
|
||||
// Calculate relative path from manga root
|
||||
var relativePath string
|
||||
if currentDepth == 0 {
|
||||
// At manga root level
|
||||
relativePath = filepath.Join(mangaID, entry.Name())
|
||||
} else {
|
||||
// Get the relative part from current path
|
||||
relativeFromManga, err := filepath.Rel(filepath.Join(p.dir, mangaID), entryPath)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
relativePath = filepath.Join(mangaID, relativeFromManga)
|
||||
}
|
||||
|
||||
if entry.IsDir() {
|
||||
// Check if this directory contains only images (making it a chapter directory)
|
||||
isImageDirectory, _ := p.isImageOnlyDirectory(entryPath)
|
||||
|
||||
if isImageDirectory {
|
||||
// Directory contains only images, treat it as a chapter
|
||||
entries = append(entries, &chapterEntry{
|
||||
RelativePath: relativePath,
|
||||
IsDir: true,
|
||||
})
|
||||
} else if currentDepth < maxDepth {
|
||||
// Directory doesn't contain only images, recursively scan subdirectories
|
||||
subEntries, err := p.collectChapterEntries(entryPath, mangaID, currentDepth+1)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// If subdirectory contains chapters, add them
|
||||
if len(subEntries) > 0 {
|
||||
entries = append(entries, subEntries...)
|
||||
} else {
|
||||
// If no sub-chapters found, treat directory itself as potential chapter
|
||||
entries = append(entries, &chapterEntry{
|
||||
RelativePath: relativePath,
|
||||
IsDir: true,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// At max depth, treat directory as potential chapter
|
||||
entries = append(entries, &chapterEntry{
|
||||
RelativePath: relativePath,
|
||||
IsDir: true,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// File entry - check if it's a potential chapter file
|
||||
ext := strings.ToLower(filepath.Ext(entry.Name()))
|
||||
if ext == ".cbz" || ext == ".cbr" || ext == ".pdf" || ext == ".zip" {
|
||||
entries = append(entries, &chapterEntry{
|
||||
RelativePath: relativePath,
|
||||
IsDir: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
// isImageOnlyDirectory checks if a directory contains only image files (no subdirectories or other files)
|
||||
func (p *Local) isImageOnlyDirectory(dirPath string) (bool, error) {
|
||||
entries, err := os.ReadDir(dirPath)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
if len(entries) == 0 {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
hasImages := false
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
if isFileImage(entry.Name()) {
|
||||
hasImages = true
|
||||
} else {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
|
||||
return hasImages, nil
|
||||
}
|
||||
|
||||
// "0001" -> "1", "0" -> "0"
|
||||
func cleanChapter(ch string) string {
|
||||
if ch == "" {
|
||||
return ""
|
||||
}
|
||||
if ch == "0" {
|
||||
return "0"
|
||||
}
|
||||
if strings.HasPrefix(ch, "0") {
|
||||
return strings.TrimLeft(ch, "0")
|
||||
}
|
||||
return ch
|
||||
}
|
||||
|
||||
// FindChapterPages will extract the images
|
||||
func (p *Local) FindChapterPages(id string) (ret []*hibikemanga.ChapterPage, err error) {
|
||||
if p.dir == "" {
|
||||
return make([]*hibikemanga.ChapterPage, 0), nil
|
||||
}
|
||||
|
||||
// id = filepath
|
||||
// e.g. "series/chapter_1.cbz"
|
||||
fullpath := filepath.Join(p.dir, id) // e.g. "/collection/series/chapter_1.cbz"
|
||||
|
||||
// Prefix with {{manga-local-assets}} to signal the client that this is a local file
|
||||
// e.g. "{{manga-local-assets}}/series/chapter_1.cbz/image_1.jpg"
|
||||
formatUrl := func(fileName string) string {
|
||||
return filepath.ToSlash(filepath.Join(LocalServePath, id, fileName))
|
||||
}
|
||||
|
||||
ext := filepath.Ext(fullpath)
|
||||
|
||||
// Close the current pages
|
||||
if p.currentZipCloser != nil {
|
||||
_ = p.currentZipCloser.Close()
|
||||
}
|
||||
for _, loadedPage := range p.currentPages {
|
||||
loadedPage.buf = nil
|
||||
}
|
||||
p.currentPages = make(map[string]*loadedPage)
|
||||
p.currentZipCloser = nil
|
||||
p.currentChapterPath = fullpath
|
||||
|
||||
switch ext {
|
||||
case ".zip", ".cbz":
|
||||
r, err := zip.OpenReader(fullpath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
for _, f := range r.File {
|
||||
if !isFileImage(f.Name) {
|
||||
continue
|
||||
}
|
||||
|
||||
page, err := f.Open()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open page: %w", err)
|
||||
}
|
||||
buf, err := io.ReadAll(page)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read page: %w", err)
|
||||
}
|
||||
p.currentPages[strings.ToLower(f.Name)] = &loadedPage{
|
||||
buf: buf,
|
||||
page: &hibikemanga.ChapterPage{
|
||||
Provider: LocalProvider,
|
||||
URL: formatUrl(f.Name),
|
||||
Index: 0, // placeholder, will be set later
|
||||
Buf: buf,
|
||||
},
|
||||
}
|
||||
}
|
||||
case ".pdf":
|
||||
// doc, err := fitz.New(fullpath)
|
||||
// if err != nil {
|
||||
// return nil, fmt.Errorf("failed to open PDF file: %w", err)
|
||||
// }
|
||||
// defer doc.Close()
|
||||
|
||||
// // Load images into memory
|
||||
// for n := 0; n < doc.NumPage(); n++ {
|
||||
// img, err := doc.Image(n)
|
||||
// if err != nil {
|
||||
// panic(err)
|
||||
// }
|
||||
|
||||
// var buf bytes.Buffer
|
||||
// err = jpeg.Encode(&buf, img, &jpeg.Options{Quality: jpeg.DefaultQuality})
|
||||
// if err != nil {
|
||||
// panic(err)
|
||||
// }
|
||||
|
||||
// p.currentPages[fmt.Sprintf("page_%d.jpg", n)] = &loadedPage{
|
||||
// buf: buf.Bytes(),
|
||||
// page: &hibikemanga.ChapterPage{
|
||||
// Provider: LocalProvider,
|
||||
// URL: formatUrl(fmt.Sprintf("page_%d.jpg", n)),
|
||||
// Index: n,
|
||||
// },
|
||||
// }
|
||||
// }
|
||||
default:
|
||||
// If it's a directory of images
|
||||
stat, err := os.Stat(fullpath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to stat file: %w", err)
|
||||
}
|
||||
if !stat.IsDir() {
|
||||
return nil, fmt.Errorf("file is not a directory: %s", fullpath)
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(fullpath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read directory: %w", err)
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
if !isFileImage(entry.Name()) {
|
||||
continue
|
||||
}
|
||||
|
||||
page, err := os.Open(filepath.Join(fullpath, entry.Name()))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open page: %w", err)
|
||||
}
|
||||
buf, err := io.ReadAll(page)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read page: %w", err)
|
||||
}
|
||||
p.currentPages[strings.ToLower(entry.Name())] = &loadedPage{
|
||||
buf: buf,
|
||||
page: &hibikemanga.ChapterPage{
|
||||
Provider: LocalProvider,
|
||||
URL: formatUrl(entry.Name()),
|
||||
Index: 0, // placeholder, will be set later
|
||||
Buf: buf,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type pageStruct struct {
|
||||
Number float64
|
||||
LoadedPage *loadedPage
|
||||
}
|
||||
|
||||
pages := make([]*pageStruct, 0)
|
||||
|
||||
// Parse and order the pages
|
||||
for _, loadedPage := range p.currentPages {
|
||||
scannedPage, ok := parsePageFilename(filepath.Base(loadedPage.page.URL))
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
pages = append(pages, &pageStruct{
|
||||
Number: scannedPage.Number,
|
||||
LoadedPage: loadedPage,
|
||||
})
|
||||
}
|
||||
|
||||
// Sort pages
|
||||
slices.SortFunc(pages, func(a, b *pageStruct) int {
|
||||
return strings.Compare(filepath.Base(a.LoadedPage.page.URL), filepath.Base(b.LoadedPage.page.URL))
|
||||
})
|
||||
|
||||
ret = make([]*hibikemanga.ChapterPage, 0)
|
||||
for idx, pageStruct := range pages {
|
||||
pageStruct.LoadedPage.page.Index = idx
|
||||
ret = append(ret, pageStruct.LoadedPage.page)
|
||||
}
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func (p *Local) ReadPage(path string) (ret io.ReadCloser, err error) {
|
||||
// e.g. path = "/series/chapter_1.cbz/image_1.jpg"
|
||||
|
||||
// If the pages are already in memory, return them
|
||||
if len(p.currentPages) > 0 {
|
||||
page, ok := p.currentPages[strings.ToLower(filepath.Base(path))]
|
||||
if ok {
|
||||
return io.NopCloser(bytes.NewReader(page.buf)), nil // Return the page
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("page not found: %s", path)
|
||||
}
|
||||
823
seanime-2.9.10/internal/manga/providers/local_parser.go
Normal file
823
seanime-2.9.10/internal/manga/providers/local_parser.go
Normal file
@@ -0,0 +1,823 @@
|
||||
package manga_providers
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
type ScannedChapterFile struct {
|
||||
Chapter []string // can be a single chapter or a range of chapters
|
||||
MangaTitle string // typically comes before the chapter number
|
||||
ChapterTitle string // typically comes after the chapter number
|
||||
Volume []string // typically comes after the chapter number
|
||||
IsPDF bool
|
||||
}
|
||||
|
||||
type TokenType int
|
||||
|
||||
const (
|
||||
TokenUnknown TokenType = iota
|
||||
TokenText
|
||||
TokenNumber
|
||||
TokenKeyword
|
||||
TokenSeparator
|
||||
TokenEnclosed
|
||||
TokenFileExtension
|
||||
)
|
||||
|
||||
// Token represents a parsed token from the filename
|
||||
type Token struct {
|
||||
Type TokenType
|
||||
Value string
|
||||
Position int
|
||||
IsChapter bool
|
||||
IsVolume bool
|
||||
}
|
||||
|
||||
// Lexer handles the tokenization of the filename
|
||||
type Lexer struct {
|
||||
input string
|
||||
position int
|
||||
tokens []Token
|
||||
currentToken int
|
||||
}
|
||||
|
||||
var ChapterKeywords = []string{
|
||||
"ch", "chp", "chapter", "chap", "c",
|
||||
}
|
||||
|
||||
var VolumeKeywords = []string{
|
||||
"v", "vol", "volume",
|
||||
}
|
||||
|
||||
var SeparatorChars = []rune{
|
||||
' ', '-', '_', '.', '[', ']', '(', ')', '{', '}', '~',
|
||||
}
|
||||
|
||||
var ImageExtensions = map[string]struct{}{
|
||||
".png": {},
|
||||
".jpg": {},
|
||||
".jpeg": {},
|
||||
".gif": {},
|
||||
".webp": {},
|
||||
".bmp": {},
|
||||
".tiff": {},
|
||||
".tif": {},
|
||||
}
|
||||
|
||||
// NewLexer creates a new lexer instance
|
||||
func NewLexer(input string) *Lexer {
|
||||
return &Lexer{
|
||||
input: strings.TrimSpace(input),
|
||||
tokens: make([]Token, 0),
|
||||
currentToken: 0,
|
||||
}
|
||||
}
|
||||
|
||||
// Tokenize breaks down the input into tokens
|
||||
func (l *Lexer) Tokenize() []Token {
|
||||
l.position = 0
|
||||
l.tokens = make([]Token, 0)
|
||||
|
||||
for l.position < len(l.input) {
|
||||
if l.isWhitespace(l.current()) {
|
||||
l.skipWhitespace()
|
||||
continue
|
||||
}
|
||||
|
||||
if l.isEnclosedStart(l.current()) {
|
||||
l.readEnclosed()
|
||||
continue
|
||||
}
|
||||
|
||||
if l.isSeparator(l.current()) {
|
||||
l.readSeparator()
|
||||
continue
|
||||
}
|
||||
|
||||
if l.isDigit(l.current()) {
|
||||
l.readNumber()
|
||||
continue
|
||||
}
|
||||
|
||||
if l.isLetter(l.current()) {
|
||||
l.readText()
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip unknown characters
|
||||
l.position++
|
||||
}
|
||||
|
||||
l.classifyTokens()
|
||||
return l.tokens
|
||||
}
|
||||
|
||||
// current returns the current character
|
||||
func (l *Lexer) current() rune {
|
||||
if l.position >= len(l.input) {
|
||||
return 0
|
||||
}
|
||||
return rune(l.input[l.position])
|
||||
}
|
||||
|
||||
// peek returns the next character without advancing
|
||||
func (l *Lexer) peek() rune {
|
||||
if l.position+1 >= len(l.input) {
|
||||
return 0
|
||||
}
|
||||
return rune(l.input[l.position+1])
|
||||
}
|
||||
|
||||
// advance moves to the next character
|
||||
func (l *Lexer) advance() {
|
||||
l.position++
|
||||
}
|
||||
|
||||
// isWhitespace checks if character is whitespace
|
||||
func (l *Lexer) isWhitespace(r rune) bool {
|
||||
return r == ' ' || r == '\t' || r == '\n' || r == '\r'
|
||||
}
|
||||
|
||||
// isSeparator checks if character is a separator
|
||||
func (l *Lexer) isSeparator(r rune) bool {
|
||||
for _, sep := range SeparatorChars {
|
||||
if r == sep {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// isEnclosedStart checks if character starts an enclosed section
|
||||
func (l *Lexer) isEnclosedStart(r rune) bool {
|
||||
return r == '[' || r == '(' || r == '{'
|
||||
}
|
||||
|
||||
// isDigit checks if character is a digit
|
||||
func (l *Lexer) isDigit(r rune) bool {
|
||||
return r >= '0' && r <= '9'
|
||||
}
|
||||
|
||||
// isLetter checks if character is a letter
|
||||
func (l *Lexer) isLetter(r rune) bool {
|
||||
return (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z')
|
||||
}
|
||||
|
||||
// skipWhitespace skips all whitespace characters
|
||||
func (l *Lexer) skipWhitespace() {
|
||||
for l.position < len(l.input) && l.isWhitespace(l.current()) {
|
||||
l.advance()
|
||||
}
|
||||
}
|
||||
|
||||
// readEnclosed reads content within brackets/parentheses
|
||||
func (l *Lexer) readEnclosed() {
|
||||
start := l.position
|
||||
openChar := l.current()
|
||||
var closeChar rune
|
||||
|
||||
switch openChar {
|
||||
case '[':
|
||||
closeChar = ']'
|
||||
case '(':
|
||||
closeChar = ')'
|
||||
case '{':
|
||||
closeChar = '}'
|
||||
default:
|
||||
l.advance()
|
||||
return
|
||||
}
|
||||
|
||||
l.advance() // Skip opening character
|
||||
startContent := l.position
|
||||
|
||||
for l.position < len(l.input) && l.current() != closeChar {
|
||||
l.advance()
|
||||
}
|
||||
|
||||
if l.position < len(l.input) {
|
||||
content := l.input[startContent:l.position]
|
||||
l.advance() // Skip closing character
|
||||
|
||||
// Only add if content is meaningful
|
||||
if len(strings.TrimSpace(content)) > 0 {
|
||||
l.addToken(TokenEnclosed, content, start)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// readSeparator reads separator characters
|
||||
func (l *Lexer) readSeparator() {
|
||||
start := l.position
|
||||
value := string(l.current())
|
||||
l.advance()
|
||||
l.addToken(TokenSeparator, value, start)
|
||||
}
|
||||
|
||||
// readNumber reads numeric values (including decimals)
|
||||
func (l *Lexer) readNumber() {
|
||||
start := l.position
|
||||
|
||||
for l.position < len(l.input) && (l.isDigit(l.current()) || l.current() == '.') {
|
||||
// Stop if we hit a file extension
|
||||
if l.current() == '.' && l.position+1 < len(l.input) {
|
||||
// Check if this is followed by common file extensions
|
||||
remaining := l.input[l.position+1:]
|
||||
if strings.HasPrefix(remaining, "cbz") || strings.HasPrefix(remaining, "cbr") ||
|
||||
strings.HasPrefix(remaining, "pdf") || strings.HasPrefix(remaining, "epub") {
|
||||
break
|
||||
}
|
||||
}
|
||||
l.advance()
|
||||
}
|
||||
|
||||
value := l.input[start:l.position]
|
||||
l.addToken(TokenNumber, value, start)
|
||||
}
|
||||
|
||||
// readText reads alphabetic text
|
||||
func (l *Lexer) readText() {
|
||||
start := l.position
|
||||
|
||||
for l.position < len(l.input) && (l.isLetter(l.current()) || l.isDigit(l.current())) {
|
||||
l.advance()
|
||||
}
|
||||
|
||||
value := l.input[start:l.position]
|
||||
lowerValue := strings.ToLower(value) // Use lowercase for keyword checking
|
||||
|
||||
// Check if this might be a concatenated keyword that continues with a decimal
|
||||
if l.startsWithKeyword(lowerValue) && l.position < len(l.input) && l.current() == '.' {
|
||||
// Look ahead to see if there are more digits after the decimal
|
||||
tempPos := l.position + 1
|
||||
if tempPos < len(l.input) && l.isDigit(rune(l.input[tempPos])) {
|
||||
// Read the decimal part
|
||||
l.advance() // consume the '.'
|
||||
for l.position < len(l.input) && l.isDigit(l.current()) {
|
||||
l.advance()
|
||||
}
|
||||
// Update value to include decimal part
|
||||
value = l.input[start:l.position]
|
||||
lowerValue = strings.ToLower(value)
|
||||
}
|
||||
}
|
||||
|
||||
// Check for concatenated keywords like "ch001", "c001", "chp001", "c12.5"
|
||||
if l.containsKeywordPrefix(lowerValue) {
|
||||
l.splitKeywordAndNumber(lowerValue, value, start) // Pass both versions
|
||||
} else {
|
||||
l.addToken(TokenText, value, start) // Use original case
|
||||
}
|
||||
}
|
||||
|
||||
// startsWithKeyword checks if text starts with any known keyword
|
||||
func (l *Lexer) startsWithKeyword(text string) bool {
|
||||
for _, keyword := range ChapterKeywords {
|
||||
if strings.HasPrefix(text, keyword) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, keyword := range VolumeKeywords {
|
||||
if strings.HasPrefix(text, keyword) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// containsKeywordPrefix checks if text starts with a known keyword
|
||||
func (l *Lexer) containsKeywordPrefix(text string) bool {
|
||||
chKeywords := ChapterKeywords
|
||||
// Sort by length descending to match longer keywords first
|
||||
slices.SortFunc(chKeywords, func(a, b string) int {
|
||||
return len(b) - len(a) // Sort by length descending
|
||||
})
|
||||
for _, keyword := range ChapterKeywords {
|
||||
if strings.HasPrefix(text, keyword) && len(text) > len(keyword) {
|
||||
remaining := text[len(keyword):]
|
||||
// Check if remaining part is numeric (including decimals)
|
||||
if len(remaining) == 0 {
|
||||
return false
|
||||
}
|
||||
return l.isValidNumberPart(remaining)
|
||||
}
|
||||
}
|
||||
for _, keyword := range VolumeKeywords {
|
||||
if strings.HasPrefix(text, keyword) && len(text) > len(keyword) {
|
||||
remaining := text[len(keyword):]
|
||||
// Check if remaining part is numeric (including decimals)
|
||||
if len(remaining) == 0 {
|
||||
return false
|
||||
}
|
||||
return l.isValidNumberPart(remaining)
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// isValidNumberPart checks if string is valid number (including decimals)
|
||||
func (l *Lexer) isValidNumberPart(s string) bool {
|
||||
if len(s) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
// Don't allow starting with decimal
|
||||
if s[0] == '.' {
|
||||
return false
|
||||
}
|
||||
|
||||
hasDecimal := false
|
||||
for _, r := range s {
|
||||
if r == '.' {
|
||||
if hasDecimal {
|
||||
return false // Multiple decimals not allowed
|
||||
}
|
||||
hasDecimal = true
|
||||
} else if !l.isDigit(r) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// splitKeywordAndNumber splits concatenated keyword and number tokens
|
||||
func (l *Lexer) splitKeywordAndNumber(lowerText, originalText string, position int) {
|
||||
for _, keyword := range ChapterKeywords {
|
||||
if strings.HasPrefix(lowerText, keyword) && len(lowerText) > len(keyword) {
|
||||
// Use original case for the keyword part
|
||||
originalKeyword := originalText[:len(keyword)]
|
||||
l.addKeywordToken(originalKeyword, position, true, false)
|
||||
|
||||
// Extract number part (keeping original case/formatting)
|
||||
numberPart := originalText[len(keyword):]
|
||||
l.addToken(TokenNumber, numberPart, position+len(keyword))
|
||||
return
|
||||
}
|
||||
}
|
||||
for _, keyword := range VolumeKeywords {
|
||||
if strings.HasPrefix(lowerText, keyword) && len(lowerText) > len(keyword) {
|
||||
// Use original case for the keyword part
|
||||
originalKeyword := originalText[:len(keyword)]
|
||||
l.addKeywordToken(originalKeyword, position, false, true)
|
||||
|
||||
// Extract number part (keeping original case/formatting)
|
||||
numberPart := originalText[len(keyword):]
|
||||
l.addToken(TokenNumber, numberPart, position+len(keyword))
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// addKeywordToken adds a keyword token with flags
|
||||
func (l *Lexer) addKeywordToken(value string, position int, isChapter, isVolume bool) {
|
||||
l.tokens = append(l.tokens, Token{
|
||||
Type: TokenKeyword,
|
||||
Value: value,
|
||||
Position: position,
|
||||
IsChapter: isChapter,
|
||||
IsVolume: isVolume,
|
||||
})
|
||||
}
|
||||
|
||||
// addToken adds a token to the list
|
||||
func (l *Lexer) addToken(tokenType TokenType, value string, position int) {
|
||||
l.tokens = append(l.tokens, Token{
|
||||
Type: tokenType,
|
||||
Value: value,
|
||||
Position: position,
|
||||
})
|
||||
}
|
||||
|
||||
// classifyTokens identifies chapter and volume keywords
|
||||
func (l *Lexer) classifyTokens() {
|
||||
for i := range l.tokens {
|
||||
token := &l.tokens[i]
|
||||
|
||||
// Check for chapter keywords (case insensitive)
|
||||
lowerValue := strings.ToLower(token.Value)
|
||||
for _, keyword := range ChapterKeywords {
|
||||
if lowerValue == keyword {
|
||||
token.Type = TokenKeyword
|
||||
token.IsChapter = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Check for volume keywords (case insensitive)
|
||||
for _, keyword := range VolumeKeywords {
|
||||
if lowerValue == keyword {
|
||||
token.Type = TokenKeyword
|
||||
token.IsVolume = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Check for file extensions
|
||||
if strings.Contains(lowerValue, "pdf") || strings.Contains(lowerValue, "cbz") ||
|
||||
strings.Contains(lowerValue, "cbr") || strings.Contains(lowerValue, "epub") {
|
||||
token.Type = TokenFileExtension
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parser handles the semantic analysis of tokens
|
||||
type Parser struct {
|
||||
tokens []Token
|
||||
result *ScannedChapterFile
|
||||
}
|
||||
|
||||
// NewParser creates a new parser instance
|
||||
func NewParser(tokens []Token) *Parser {
|
||||
return &Parser{
|
||||
tokens: tokens,
|
||||
result: &ScannedChapterFile{
|
||||
Chapter: make([]string, 0),
|
||||
Volume: make([]string, 0),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Parse performs semantic analysis on the tokens
|
||||
func (p *Parser) Parse() *ScannedChapterFile {
|
||||
p.extractChapters()
|
||||
p.extractVolumes()
|
||||
p.extractTitles()
|
||||
p.checkPDF()
|
||||
|
||||
return p.result
|
||||
}
|
||||
|
||||
// extractChapters finds and extracts chapter numbers
|
||||
func (p *Parser) extractChapters() {
|
||||
for i, token := range p.tokens {
|
||||
if token.IsChapter {
|
||||
// Look for numbers after chapter keyword
|
||||
for j := i + 1; j < len(p.tokens) && j < i+3; j++ {
|
||||
nextToken := p.tokens[j]
|
||||
if nextToken.Type == TokenNumber {
|
||||
p.addChapterNumber(nextToken.Value)
|
||||
break
|
||||
} else if nextToken.Type == TokenSeparator {
|
||||
continue
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
} else if token.Type == TokenNumber && !token.IsVolume {
|
||||
// Standalone number might be a chapter
|
||||
if p.isLikelyChapterNumber(token, i) {
|
||||
p.addChapterNumber(token.Value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle ranges by looking for dash-separated numbers
|
||||
p.handleChapterRanges()
|
||||
}
|
||||
|
||||
// handleChapterRanges processes chapter ranges like "1-2" or "001-002"
|
||||
func (p *Parser) handleChapterRanges() {
|
||||
for i := 0; i < len(p.tokens)-2; i++ {
|
||||
if p.tokens[i].Type == TokenNumber &&
|
||||
p.tokens[i+1].Type == TokenSeparator && p.tokens[i+1].Value == "-" &&
|
||||
p.tokens[i+2].Type == TokenNumber {
|
||||
|
||||
// Check if first number is already a chapter
|
||||
firstIsChapter := false
|
||||
for _, ch := range p.result.Chapter {
|
||||
if ch == p.tokens[i].Value {
|
||||
firstIsChapter = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if firstIsChapter {
|
||||
// Add the second number as a chapter too
|
||||
p.result.Chapter = append(p.result.Chapter, p.tokens[i+2].Value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// extractVolumes finds and extracts volume numbers
|
||||
func (p *Parser) extractVolumes() {
|
||||
for i, token := range p.tokens {
|
||||
if token.IsVolume {
|
||||
// Look for numbers after volume keyword
|
||||
for j := i + 1; j < len(p.tokens) && j < i+3; j++ {
|
||||
nextToken := p.tokens[j]
|
||||
if nextToken.Type == TokenNumber {
|
||||
p.result.Volume = append(p.result.Volume, nextToken.Value)
|
||||
break
|
||||
} else if nextToken.Type == TokenSeparator {
|
||||
continue
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// extractTitles finds manga title and chapter title
|
||||
func (p *Parser) extractTitles() {
|
||||
// Find first chapter keyword or number position
|
||||
chapterPos := -1
|
||||
for i, token := range p.tokens {
|
||||
if token.IsChapter || (token.Type == TokenNumber && p.isLikelyChapterNumber(token, i)) {
|
||||
chapterPos = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if chapterPos > 0 {
|
||||
// Everything before chapter is likely manga title
|
||||
titleParts := make([]string, 0)
|
||||
for i := 0; i < chapterPos; i++ {
|
||||
token := p.tokens[i]
|
||||
if token.Type == TokenText && !token.IsVolume && !p.isIgnoredToken(token) {
|
||||
titleParts = append(titleParts, token.Value)
|
||||
} else if token.Type == TokenNumber && p.isNumberInTitle(token, i, chapterPos) {
|
||||
// Include numbers that are part of the title (but not volume indicators)
|
||||
titleParts = append(titleParts, token.Value)
|
||||
}
|
||||
}
|
||||
if len(titleParts) > 0 {
|
||||
p.result.MangaTitle = strings.Join(titleParts, " ")
|
||||
}
|
||||
|
||||
// Look for chapter title after chapter number
|
||||
p.extractChapterTitle(chapterPos)
|
||||
} else {
|
||||
// No clear chapter indicator, check if this is a "number - title" pattern
|
||||
if len(p.result.Chapter) > 0 && p.hasChapterTitlePattern() {
|
||||
p.extractChapterTitleFromPattern()
|
||||
} else {
|
||||
// Treat most text as manga title
|
||||
p.extractFallbackTitle()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// hasChapterTitlePattern checks for "number - title" pattern
|
||||
func (p *Parser) hasChapterTitlePattern() bool {
|
||||
for i := 0; i < len(p.tokens)-2; i++ {
|
||||
if p.tokens[i].Type == TokenNumber &&
|
||||
p.tokens[i+1].Type == TokenSeparator && p.tokens[i+1].Value == "-" &&
|
||||
i+2 < len(p.tokens) && p.tokens[i+2].Type == TokenText {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// extractChapterTitleFromPattern extracts title from "number - title" pattern
|
||||
func (p *Parser) extractChapterTitleFromPattern() {
|
||||
for i := 0; i < len(p.tokens)-2; i++ {
|
||||
if p.tokens[i].Type == TokenNumber &&
|
||||
p.tokens[i+1].Type == TokenSeparator && p.tokens[i+1].Value == "-" {
|
||||
|
||||
// Collect text after the dash
|
||||
titleParts := make([]string, 0)
|
||||
for j := i + 2; j < len(p.tokens); j++ {
|
||||
token := p.tokens[j]
|
||||
if token.Type == TokenText && !p.isIgnoredToken(token) {
|
||||
titleParts = append(titleParts, token.Value)
|
||||
} else if token.Type == TokenFileExtension {
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(titleParts) > 0 {
|
||||
p.result.ChapterTitle = strings.Join(titleParts, " ")
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// extractFallbackTitle extracts title when no clear chapter indicators
|
||||
func (p *Parser) extractFallbackTitle() {
|
||||
titleParts := make([]string, 0)
|
||||
for _, token := range p.tokens {
|
||||
if token.Type == TokenText && !p.isIgnoredToken(token) {
|
||||
titleParts = append(titleParts, token.Value)
|
||||
}
|
||||
}
|
||||
if len(titleParts) > 0 {
|
||||
p.result.MangaTitle = strings.Join(titleParts, " ")
|
||||
}
|
||||
}
|
||||
|
||||
// addChapterNumber adds a chapter number, handling ranges
|
||||
func (p *Parser) addChapterNumber(value string) {
|
||||
// Check for range indicators in the surrounding tokens
|
||||
if strings.Contains(value, "-") {
|
||||
parts := strings.Split(value, "-")
|
||||
for _, part := range parts {
|
||||
if part != "" {
|
||||
p.result.Chapter = append(p.result.Chapter, strings.TrimSpace(part))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
p.result.Chapter = append(p.result.Chapter, value)
|
||||
}
|
||||
}
|
||||
|
||||
// isLikelyChapterNumber determines if a number token is likely a chapter
|
||||
func (p *Parser) isLikelyChapterNumber(token Token, position int) bool {
|
||||
// If we already have chapters from keywords, be more strict
|
||||
if len(p.result.Chapter) > 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check context - numbers at the start of filename are likely chapters
|
||||
if position < 3 {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check if preceded by common patterns
|
||||
if position > 0 {
|
||||
prevToken := p.tokens[position-1]
|
||||
if prevToken.Type == TokenSeparator && (prevToken.Value == "-" || prevToken.Value == " ") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// isNumberInTitle determines if a number token should be part of the title
|
||||
func (p *Parser) isNumberInTitle(token Token, position int, chapterPos int) bool {
|
||||
// Don't include numbers that are right before the chapter position
|
||||
if position == chapterPos-1 {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if this number looks like it's associated with volume
|
||||
if position > 0 {
|
||||
prevToken := p.tokens[position-1]
|
||||
if prevToken.IsVolume {
|
||||
return false // This number belongs to volume
|
||||
}
|
||||
}
|
||||
|
||||
// Small numbers (like 05, 2) that appear early in the title are likely part of title
|
||||
if position < 5 {
|
||||
if val := token.Value; len(val) <= 2 {
|
||||
// Check if this number looks like part of a title (e.g., "Title 05")
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// isIgnoredToken checks if token should be ignored in titles
|
||||
func (p *Parser) isIgnoredToken(token Token) bool {
|
||||
ignoredWords := []string{"digital", "group", "scan", "scans", "team", "raw", "raws"}
|
||||
for _, word := range ignoredWords {
|
||||
if token.Value == word {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Check for version indicators that shouldn't be in volume
|
||||
if strings.HasPrefix(token.Value, "v") && len(token.Value) > 1 {
|
||||
remaining := token.Value[1:]
|
||||
// If it's just "v" + digit, it might be version, not volume
|
||||
if len(remaining) > 0 && remaining[0] >= '0' && remaining[0] <= '9' {
|
||||
// Check context - if preceded by a number, it's likely a version
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// checkPDF sets the PDF flag if file is a PDF
|
||||
func (p *Parser) checkPDF() {
|
||||
for _, token := range p.tokens {
|
||||
if token.Type == TokenFileExtension && strings.Contains(token.Value, "pdf") {
|
||||
p.result.IsPDF = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// scanChapterFilename scans the filename and returns a chapter entry if it is a chapter.
|
||||
func scanChapterFilename(filename string) (res *ScannedChapterFile, ok bool) {
|
||||
// Create lexer and tokenize
|
||||
lexer := NewLexer(filename)
|
||||
tokens := lexer.Tokenize()
|
||||
|
||||
// Create parser and parse
|
||||
parser := NewParser(tokens)
|
||||
res = parser.Parse()
|
||||
|
||||
return res, true
|
||||
}
|
||||
|
||||
func isFileImage(filename string) bool {
|
||||
ext := strings.ToLower(filepath.Ext(filename))
|
||||
_, ok := ImageExtensions[ext]
|
||||
return ok
|
||||
}
|
||||
|
||||
// extractChapterTitle finds chapter title after chapter number
|
||||
func (p *Parser) extractChapterTitle(startPos int) {
|
||||
// Skip to after chapter number
|
||||
numberPos := -1
|
||||
for i := startPos; i < len(p.tokens); i++ {
|
||||
if p.tokens[i].Type == TokenNumber {
|
||||
numberPos = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if numberPos == -1 {
|
||||
return
|
||||
}
|
||||
|
||||
// Look for dash separator followed by text
|
||||
for i := numberPos + 1; i < len(p.tokens); i++ {
|
||||
token := p.tokens[i]
|
||||
if token.Type == TokenSeparator && token.Value == "-" {
|
||||
// Found dash, collect text after it
|
||||
titleParts := make([]string, 0)
|
||||
for j := i + 1; j < len(p.tokens); j++ {
|
||||
nextToken := p.tokens[j]
|
||||
if nextToken.Type == TokenText && !p.isIgnoredToken(nextToken) {
|
||||
titleParts = append(titleParts, nextToken.Value)
|
||||
} else if nextToken.Type == TokenFileExtension {
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(titleParts) > 0 {
|
||||
p.result.ChapterTitle = strings.Join(titleParts, " ")
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
type ScannedPageFile struct {
|
||||
Number float64
|
||||
Filename string
|
||||
Ext string
|
||||
}
|
||||
|
||||
func parsePageFilename(filename string) (res *ScannedPageFile, ok bool) {
|
||||
res = &ScannedPageFile{
|
||||
Filename: filename,
|
||||
}
|
||||
|
||||
filename = strings.ToLower(filename)
|
||||
res.Ext = filepath.Ext(filename)
|
||||
filename = strings.TrimSuffix(filename, res.Ext)
|
||||
|
||||
if len(filename) == 0 {
|
||||
return res, false
|
||||
}
|
||||
|
||||
// Find number at the start
|
||||
// check if first rune is a digit
|
||||
numStr := ""
|
||||
if !unicode.IsDigit(rune(filename[0])) {
|
||||
// walk until non-digit
|
||||
for i := 0; i < len(filename); i++ {
|
||||
if !unicode.IsDigit(rune(filename[i])) && rune(filename[i]) != '.' {
|
||||
break
|
||||
}
|
||||
numStr += string(filename[i])
|
||||
}
|
||||
if len(numStr) > 0 {
|
||||
res.Number, _ = strconv.ParseFloat(numStr, 64)
|
||||
return res, true
|
||||
}
|
||||
}
|
||||
|
||||
// walk until first digit
|
||||
numStr = ""
|
||||
firstDigitIdx := strings.IndexFunc(filename, unicode.IsDigit)
|
||||
if firstDigitIdx != -1 {
|
||||
numStr += string(filename[firstDigitIdx])
|
||||
// walk until first non-digit or end
|
||||
for i := firstDigitIdx + 1; i < len(filename); i++ {
|
||||
if !unicode.IsDigit(rune(filename[i])) && rune(filename[i]) != '.' {
|
||||
break
|
||||
}
|
||||
numStr += string(filename[i])
|
||||
}
|
||||
if len(numStr) > 0 {
|
||||
res.Number, _ = strconv.ParseFloat(numStr, 64)
|
||||
return res, true
|
||||
}
|
||||
}
|
||||
|
||||
return res, false
|
||||
}
|
||||
483
seanime-2.9.10/internal/manga/providers/local_test.go
Normal file
483
seanime-2.9.10/internal/manga/providers/local_test.go
Normal file
@@ -0,0 +1,483 @@
|
||||
package manga_providers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestScanChapterFilename(t *testing.T) {
|
||||
tests := []struct {
|
||||
filename string
|
||||
expectedChapter []string
|
||||
expectedMangaTitle string
|
||||
expectedChapterTitle string
|
||||
expectedVolume []string
|
||||
}{
|
||||
{
|
||||
filename: "1.cbz",
|
||||
expectedChapter: []string{"1"},
|
||||
expectedMangaTitle: "",
|
||||
expectedChapterTitle: "",
|
||||
expectedVolume: []string{},
|
||||
},
|
||||
{
|
||||
filename: "2.5.pdf",
|
||||
expectedChapter: []string{"2.5"},
|
||||
expectedMangaTitle: "",
|
||||
expectedChapterTitle: "",
|
||||
expectedVolume: []string{},
|
||||
},
|
||||
{
|
||||
filename: "Chapter 5.5.pdf",
|
||||
expectedChapter: []string{"5.5"},
|
||||
expectedMangaTitle: "",
|
||||
expectedChapterTitle: "",
|
||||
expectedVolume: []string{},
|
||||
},
|
||||
{
|
||||
filename: "ch 1.cbz",
|
||||
expectedChapter: []string{"1"},
|
||||
expectedMangaTitle: "",
|
||||
expectedChapterTitle: "",
|
||||
expectedVolume: []string{},
|
||||
},
|
||||
{
|
||||
filename: "ch 1.5-2.cbz",
|
||||
expectedChapter: []string{"1.5", "2"},
|
||||
expectedMangaTitle: "",
|
||||
expectedChapterTitle: "",
|
||||
expectedVolume: []string{},
|
||||
},
|
||||
{
|
||||
filename: "Some title Chapter 1.cbz",
|
||||
expectedChapter: []string{"1"},
|
||||
expectedMangaTitle: "Some title",
|
||||
expectedChapterTitle: "",
|
||||
expectedVolume: []string{},
|
||||
},
|
||||
{
|
||||
filename: "Chapter 23 The Fanatics.pdf",
|
||||
expectedChapter: []string{"23"},
|
||||
expectedMangaTitle: "The Fanatics",
|
||||
expectedChapterTitle: "",
|
||||
expectedVolume: []string{},
|
||||
},
|
||||
{
|
||||
filename: "chapter_1.cbz",
|
||||
expectedChapter: []string{"1"},
|
||||
expectedMangaTitle: "",
|
||||
expectedChapterTitle: "",
|
||||
expectedVolume: []string{},
|
||||
},
|
||||
{
|
||||
filename: "1 - Some title.cbz",
|
||||
expectedChapter: []string{"1"},
|
||||
expectedMangaTitle: "",
|
||||
expectedChapterTitle: "Some title",
|
||||
expectedVolume: []string{},
|
||||
},
|
||||
{
|
||||
filename: "30 - Some title.cbz",
|
||||
expectedChapter: []string{"30"},
|
||||
expectedMangaTitle: "",
|
||||
expectedChapterTitle: "Some title",
|
||||
expectedVolume: []string{},
|
||||
},
|
||||
{
|
||||
filename: "[Group] Manga Title - c001 [123456].cbz",
|
||||
expectedChapter: []string{"001"},
|
||||
expectedMangaTitle: "Manga Title",
|
||||
expectedChapterTitle: "",
|
||||
expectedVolume: []string{},
|
||||
},
|
||||
{
|
||||
filename: "[Group] Manga Title - c12.5 [654321].cbz",
|
||||
expectedChapter: []string{"12.5"},
|
||||
expectedMangaTitle: "Manga Title",
|
||||
expectedChapterTitle: "",
|
||||
expectedVolume: []string{},
|
||||
},
|
||||
{
|
||||
filename: "[Group] Manga Title 05 - ch10.cbz",
|
||||
expectedChapter: []string{"10"},
|
||||
expectedMangaTitle: "Manga Title 05",
|
||||
expectedChapterTitle: "",
|
||||
expectedVolume: []string{},
|
||||
},
|
||||
{
|
||||
filename: "[Group] Manga Title - ch10.cbz",
|
||||
expectedChapter: []string{"10"},
|
||||
expectedMangaTitle: "Manga Title",
|
||||
expectedChapterTitle: "",
|
||||
expectedVolume: []string{},
|
||||
},
|
||||
{
|
||||
filename: "[Group] Manga Title - ch_11.cbz",
|
||||
expectedChapter: []string{"11"},
|
||||
expectedMangaTitle: "Manga Title",
|
||||
expectedChapterTitle: "",
|
||||
expectedVolume: []string{},
|
||||
},
|
||||
{
|
||||
filename: "[Group] Manga Title - ch-12.cbz",
|
||||
expectedChapter: []string{"12"},
|
||||
expectedMangaTitle: "Manga Title",
|
||||
expectedChapterTitle: "",
|
||||
expectedVolume: []string{},
|
||||
},
|
||||
{
|
||||
filename: "Manga Title v01 c001.cbz",
|
||||
expectedChapter: []string{"001"},
|
||||
expectedMangaTitle: "Manga Title",
|
||||
expectedChapterTitle: "",
|
||||
expectedVolume: []string{"01"},
|
||||
},
|
||||
{
|
||||
filename: "Manga Title v01 c001.5.cbz",
|
||||
expectedChapter: []string{"001.5"},
|
||||
expectedMangaTitle: "Manga Title",
|
||||
expectedChapterTitle: "",
|
||||
expectedVolume: []string{"01"},
|
||||
},
|
||||
{
|
||||
filename: "Manga Title - 003.cbz",
|
||||
expectedChapter: []string{"003"},
|
||||
expectedMangaTitle: "Manga Title",
|
||||
expectedChapterTitle: "",
|
||||
expectedVolume: []string{},
|
||||
},
|
||||
{
|
||||
filename: "Manga Title - 003.5.cbz",
|
||||
expectedChapter: []string{"003.5"},
|
||||
expectedMangaTitle: "Manga Title",
|
||||
expectedChapterTitle: "",
|
||||
expectedVolume: []string{},
|
||||
},
|
||||
{
|
||||
filename: "Manga Title - 3.5 (Digital).cbz",
|
||||
expectedChapter: []string{"3.5"},
|
||||
expectedMangaTitle: "Manga Title",
|
||||
expectedChapterTitle: "",
|
||||
expectedVolume: []string{},
|
||||
},
|
||||
{
|
||||
filename: "Manga Title - 10 (Digital) [Group].cbz",
|
||||
expectedChapter: []string{"10"},
|
||||
expectedMangaTitle: "Manga Title",
|
||||
expectedChapterTitle: "",
|
||||
expectedVolume: []string{},
|
||||
},
|
||||
{
|
||||
filename: "Manga Title - chp_15.cbz",
|
||||
expectedChapter: []string{"15"},
|
||||
expectedMangaTitle: "Manga Title",
|
||||
expectedChapterTitle: "",
|
||||
expectedVolume: []string{},
|
||||
},
|
||||
{
|
||||
filename: "Manga Title - chp-16.cbz",
|
||||
expectedChapter: []string{"16"},
|
||||
expectedMangaTitle: "Manga Title",
|
||||
expectedChapterTitle: "",
|
||||
expectedVolume: []string{},
|
||||
},
|
||||
{
|
||||
filename: "Manga Title - chp17.cbz",
|
||||
expectedChapter: []string{"17"},
|
||||
expectedMangaTitle: "Manga Title",
|
||||
expectedChapterTitle: "",
|
||||
expectedVolume: []string{},
|
||||
},
|
||||
{
|
||||
filename: "Manga Title - chp 18.cbz",
|
||||
expectedChapter: []string{"18"},
|
||||
expectedMangaTitle: "Manga Title",
|
||||
expectedChapterTitle: "",
|
||||
expectedVolume: []string{},
|
||||
},
|
||||
{
|
||||
filename: "Manga Title - 001 (v2).cbz",
|
||||
expectedChapter: []string{"001"},
|
||||
expectedMangaTitle: "Manga Title",
|
||||
expectedChapterTitle: "",
|
||||
expectedVolume: []string{},
|
||||
},
|
||||
{
|
||||
filename: "Manga Title - 001v2.cbz",
|
||||
expectedChapter: []string{"001"},
|
||||
expectedMangaTitle: "Manga Title",
|
||||
expectedChapterTitle: "",
|
||||
expectedVolume: []string{"2"},
|
||||
},
|
||||
{
|
||||
filename: "Manga Title - 001 [v2].cbz",
|
||||
expectedChapter: []string{"001"},
|
||||
expectedMangaTitle: "Manga Title",
|
||||
expectedChapterTitle: "",
|
||||
expectedVolume: []string{},
|
||||
},
|
||||
{
|
||||
filename: "Manga Title - 001 [Digital] [v2].cbz",
|
||||
expectedChapter: []string{"001"},
|
||||
expectedMangaTitle: "Manga Title",
|
||||
expectedChapterTitle: "",
|
||||
expectedVolume: []string{},
|
||||
},
|
||||
{
|
||||
filename: "Manga Title - 001-002.cbz",
|
||||
expectedChapter: []string{"001", "002"},
|
||||
expectedMangaTitle: "Manga Title",
|
||||
expectedChapterTitle: "",
|
||||
expectedVolume: []string{},
|
||||
},
|
||||
{
|
||||
filename: "Manga Title - 001-001.5.cbz",
|
||||
expectedChapter: []string{"001", "001.5"},
|
||||
expectedMangaTitle: "Manga Title",
|
||||
expectedChapterTitle: "",
|
||||
expectedVolume: []string{},
|
||||
},
|
||||
{
|
||||
filename: "Manga Title - 1-2.cbz",
|
||||
expectedChapter: []string{"1", "2"},
|
||||
expectedMangaTitle: "Manga Title",
|
||||
expectedChapterTitle: "",
|
||||
expectedVolume: []string{},
|
||||
},
|
||||
{
|
||||
filename: "Manga Title - 1.5-2.cbz",
|
||||
expectedChapter: []string{"1.5", "2"},
|
||||
expectedMangaTitle: "Manga Title",
|
||||
expectedChapterTitle: "",
|
||||
expectedVolume: []string{},
|
||||
},
|
||||
{
|
||||
filename: "Manga Title - 1 (Sample).cbz",
|
||||
expectedChapter: []string{"1"},
|
||||
expectedMangaTitle: "Manga Title",
|
||||
expectedChapterTitle: "",
|
||||
expectedVolume: []string{},
|
||||
},
|
||||
{
|
||||
filename: "Manga Title - 1 (Preview).cbz",
|
||||
expectedChapter: []string{"1"},
|
||||
expectedMangaTitle: "Manga Title",
|
||||
expectedChapterTitle: "",
|
||||
expectedVolume: []string{},
|
||||
},
|
||||
{
|
||||
filename: "Manga Title - 1 (Special Edition).cbz",
|
||||
expectedChapter: []string{"1"},
|
||||
expectedMangaTitle: "Manga Title",
|
||||
expectedChapterTitle: "",
|
||||
expectedVolume: []string{},
|
||||
},
|
||||
{
|
||||
filename: "Manga Title - 1 (Digital) (Official).cbz",
|
||||
expectedChapter: []string{"1"},
|
||||
expectedMangaTitle: "Manga Title",
|
||||
expectedChapterTitle: "",
|
||||
expectedVolume: []string{},
|
||||
},
|
||||
{
|
||||
filename: "Manga Title - 1.cbz",
|
||||
expectedChapter: []string{"1"},
|
||||
expectedMangaTitle: "Manga Title",
|
||||
expectedChapterTitle: "",
|
||||
expectedVolume: []string{},
|
||||
},
|
||||
{
|
||||
filename: "Manga Title - 1.0.cbz",
|
||||
expectedChapter: []string{"1.0"},
|
||||
expectedMangaTitle: "Manga Title",
|
||||
expectedChapterTitle: "",
|
||||
expectedVolume: []string{},
|
||||
},
|
||||
{
|
||||
filename: "Manga Title - 01.cbz",
|
||||
expectedChapter: []string{"01"},
|
||||
expectedMangaTitle: "Manga Title",
|
||||
expectedChapterTitle: "",
|
||||
expectedVolume: []string{},
|
||||
},
|
||||
{
|
||||
filename: "Manga Title - 01.5.cbz",
|
||||
expectedChapter: []string{"01.5"},
|
||||
expectedMangaTitle: "Manga Title",
|
||||
expectedChapterTitle: "",
|
||||
expectedVolume: []string{},
|
||||
},
|
||||
{
|
||||
filename: "Manga Title - 001.cbz",
|
||||
expectedChapter: []string{"001"},
|
||||
expectedMangaTitle: "Manga Title",
|
||||
expectedChapterTitle: "",
|
||||
expectedVolume: []string{},
|
||||
},
|
||||
{
|
||||
filename: "Manga Title - 001.5.cbz",
|
||||
expectedChapter: []string{"001.5"},
|
||||
expectedMangaTitle: "Manga Title",
|
||||
expectedChapterTitle: "",
|
||||
expectedVolume: []string{},
|
||||
},
|
||||
{
|
||||
filename: "Manga Title - ch001.cbz",
|
||||
expectedChapter: []string{"001"},
|
||||
expectedMangaTitle: "Manga Title",
|
||||
expectedChapterTitle: "",
|
||||
expectedVolume: []string{},
|
||||
},
|
||||
{
|
||||
filename: "Manga Title - ch001.5.cbz",
|
||||
expectedChapter: []string{"001.5"},
|
||||
expectedMangaTitle: "Manga Title",
|
||||
expectedChapterTitle: "",
|
||||
expectedVolume: []string{},
|
||||
},
|
||||
{
|
||||
filename: "Manga Title - ch_001.cbz",
|
||||
expectedChapter: []string{"001"},
|
||||
expectedMangaTitle: "Manga Title",
|
||||
expectedChapterTitle: "",
|
||||
expectedVolume: []string{},
|
||||
},
|
||||
{
|
||||
filename: "Manga Title - ch_001.5.cbz",
|
||||
expectedChapter: []string{"001.5"},
|
||||
expectedMangaTitle: "Manga Title",
|
||||
expectedChapterTitle: "",
|
||||
expectedVolume: []string{},
|
||||
},
|
||||
{
|
||||
filename: "Manga Title - ch-001.cbz",
|
||||
expectedChapter: []string{"001"},
|
||||
expectedMangaTitle: "Manga Title",
|
||||
expectedChapterTitle: "",
|
||||
expectedVolume: []string{},
|
||||
},
|
||||
{
|
||||
filename: "Manga Title - ch-001.5.cbz",
|
||||
expectedChapter: []string{"001.5"},
|
||||
expectedMangaTitle: "Manga Title",
|
||||
expectedChapterTitle: "",
|
||||
expectedVolume: []string{},
|
||||
},
|
||||
{
|
||||
filename: "Manga Title - chp001.cbz",
|
||||
expectedChapter: []string{"001"},
|
||||
expectedMangaTitle: "Manga Title",
|
||||
expectedChapterTitle: "",
|
||||
expectedVolume: []string{},
|
||||
},
|
||||
{
|
||||
filename: "Manga Title - chp_001.cbz",
|
||||
expectedChapter: []string{"001"},
|
||||
expectedMangaTitle: "Manga Title",
|
||||
expectedChapterTitle: "",
|
||||
expectedVolume: []string{},
|
||||
},
|
||||
{
|
||||
filename: "Manga Title - chp_001.5.cbz",
|
||||
expectedChapter: []string{"001.5"},
|
||||
expectedMangaTitle: "Manga Title",
|
||||
expectedChapterTitle: "",
|
||||
expectedVolume: []string{},
|
||||
},
|
||||
{
|
||||
filename: "Manga Title - chp-001.cbz",
|
||||
expectedChapter: []string{"001"},
|
||||
expectedMangaTitle: "Manga Title",
|
||||
expectedChapterTitle: "",
|
||||
expectedVolume: []string{},
|
||||
},
|
||||
{
|
||||
filename: "Manga Title - chp-001.5.cbz",
|
||||
expectedChapter: []string{"001.5"},
|
||||
expectedMangaTitle: "Manga Title",
|
||||
expectedChapterTitle: "",
|
||||
expectedVolume: []string{},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.filename, func(t *testing.T) {
|
||||
res, ok := scanChapterFilename(tt.filename)
|
||||
if !ok {
|
||||
t.Errorf("Failed to scan chapter filename: %s", tt.filename)
|
||||
}
|
||||
require.Equalf(t, tt.expectedChapter, res.Chapter, "Expected chapter '%v' for '%s' but got '%v'", tt.expectedChapter, tt.filename, res.Chapter)
|
||||
require.Equalf(t, tt.expectedMangaTitle, res.MangaTitle, "Expected manga title '%v' for '%s' but got '%v'", tt.expectedMangaTitle, tt.filename, res.MangaTitle)
|
||||
require.Equalf(t, tt.expectedChapterTitle, res.ChapterTitle, "Expected chapter title '%v' for '%s' but got '%v'", tt.expectedChapterTitle, tt.filename, res.ChapterTitle)
|
||||
require.Equalf(t, tt.expectedVolume, res.Volume, "Expected volume '%v' for '%s' but got '%v'", tt.expectedVolume, tt.filename, res.Volume)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPageSorting(t *testing.T) {
|
||||
tests := []struct {
|
||||
expectedOrder []string
|
||||
}{
|
||||
{
|
||||
expectedOrder: []string{"1149-000.jpg", "1149-001.jpg", "1149-002.jpg", "1149-019.jpg", "1149-019b.jpg", "1149-020.jpg"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(fmt.Sprintf("%v", tt.expectedOrder), func(t *testing.T) {
|
||||
newSlice := tt.expectedOrder
|
||||
slices.SortFunc(newSlice, func(a, b string) int {
|
||||
return strings.Compare(a, b)
|
||||
})
|
||||
for i, filename := range tt.expectedOrder {
|
||||
require.Equalf(t, filename, newSlice[i], "Expected order '%v' for '%s' but got '%v'", tt.expectedOrder, tt.expectedOrder[i], filename)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePageFilename(t *testing.T) {
|
||||
tests := []struct {
|
||||
filename string
|
||||
expected float64
|
||||
}{
|
||||
{
|
||||
filename: "1.jpg",
|
||||
expected: 1,
|
||||
},
|
||||
{
|
||||
filename: "1.5.jpg",
|
||||
expected: 1.5,
|
||||
},
|
||||
{
|
||||
filename: "Page 001.jpg",
|
||||
expected: 1,
|
||||
},
|
||||
{
|
||||
filename: "1.55.jpg",
|
||||
expected: 1.55,
|
||||
},
|
||||
{
|
||||
filename: "2.5 -.jpg",
|
||||
expected: 2.5,
|
||||
},
|
||||
{
|
||||
filename: "page_27.jpg",
|
||||
expected: 27,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.filename, func(t *testing.T) {
|
||||
res, ok := parsePageFilename(tt.filename)
|
||||
if !ok {
|
||||
t.Errorf("Failed to parse page filename: %s", tt.filename)
|
||||
}
|
||||
require.Equalf(t, tt.expected, res.Number, "Expected number '%v' for '%s' but got '%v'", tt.expected, tt.filename, res.Number)
|
||||
})
|
||||
}
|
||||
}
|
||||
388
seanime-2.9.10/internal/manga/providers/mangadex.go
Normal file
388
seanime-2.9.10/internal/manga/providers/mangadex.go
Normal file
@@ -0,0 +1,388 @@
|
||||
package manga_providers
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"fmt"
|
||||
"net/url"
|
||||
hibikemanga "seanime/internal/extension/hibike/manga"
|
||||
"seanime/internal/util"
|
||||
"seanime/internal/util/comparison"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/imroc/req/v3"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
type (
|
||||
Mangadex struct {
|
||||
Url string
|
||||
BaseUrl string
|
||||
UserAgent string
|
||||
Client *req.Client
|
||||
logger *zerolog.Logger
|
||||
}
|
||||
|
||||
MangadexManga struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Attributes MangadexMangeAttributes
|
||||
Relationships []MangadexMangaRelationship `json:"relationships"`
|
||||
}
|
||||
|
||||
MangadexMangeAttributes struct {
|
||||
AltTitles []map[string]string `json:"altTitles"`
|
||||
Title map[string]string `json:"title"`
|
||||
Year int `json:"year"`
|
||||
}
|
||||
|
||||
MangadexMangaRelationship struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Related string `json:"related"`
|
||||
Attributes map[string]interface{} `json:"attributes"`
|
||||
}
|
||||
|
||||
MangadexErrorResponse struct {
|
||||
ID string `json:"id"`
|
||||
Status string `json:"status"`
|
||||
Code string `json:"code"`
|
||||
Title string `json:"title"`
|
||||
Detail string `json:"detail"`
|
||||
}
|
||||
|
||||
MangadexChapterData struct {
|
||||
ID string `json:"id"`
|
||||
Attributes MangadexChapterAttributes `json:"attributes"`
|
||||
}
|
||||
|
||||
MangadexChapterAttributes struct {
|
||||
Title string `json:"title"`
|
||||
Volume string `json:"volume"`
|
||||
Chapter string `json:"chapter"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
}
|
||||
)
|
||||
|
||||
// DEVNOTE: Each chapter ID is a unique string provided by Mangadex
|
||||
|
||||
func NewMangadex(logger *zerolog.Logger) *Mangadex {
|
||||
client := req.C().
|
||||
SetUserAgent(util.GetRandomUserAgent()).
|
||||
SetTimeout(60 * time.Second).
|
||||
EnableInsecureSkipVerify().
|
||||
ImpersonateChrome()
|
||||
|
||||
return &Mangadex{
|
||||
Url: "https://api.mangadex.org",
|
||||
BaseUrl: "https://mangadex.org",
|
||||
Client: client,
|
||||
UserAgent: util.GetRandomUserAgent(),
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
func (md *Mangadex) GetSettings() hibikemanga.Settings {
|
||||
return hibikemanga.Settings{
|
||||
SupportsMultiScanlator: false,
|
||||
SupportsMultiLanguage: false,
|
||||
}
|
||||
}
|
||||
|
||||
func (md *Mangadex) Search(opts hibikemanga.SearchOptions) ([]*hibikemanga.SearchResult, error) {
|
||||
ret := make([]*hibikemanga.SearchResult, 0)
|
||||
|
||||
retManga := make([]*MangadexManga, 0)
|
||||
|
||||
for i := range 1 {
|
||||
uri := fmt.Sprintf("%s/manga?title=%s&limit=25&offset=%d&order[relevance]=desc&contentRating[]=safe&contentRating[]=suggestive&includes[]=cover_art", md.Url, url.QueryEscape(opts.Query), 25*i)
|
||||
|
||||
var data struct {
|
||||
Data []*MangadexManga `json:"data"`
|
||||
}
|
||||
|
||||
resp, err := md.Client.R().
|
||||
SetHeader("Referer", "https://google.com").
|
||||
SetSuccessResult(&data).
|
||||
Get(uri)
|
||||
|
||||
if err != nil {
|
||||
md.logger.Error().Err(err).Msg("mangadex: Failed to send request")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !resp.IsSuccessState() {
|
||||
md.logger.Error().Str("status", resp.Status).Msg("mangadex: Request failed")
|
||||
return nil, fmt.Errorf("failed to decode response: status %s", resp.Status)
|
||||
}
|
||||
|
||||
retManga = append(retManga, data.Data...)
|
||||
}
|
||||
|
||||
for _, manga := range retManga {
|
||||
var altTitles []string
|
||||
for _, title := range manga.Attributes.AltTitles {
|
||||
altTitle, ok := title["en"]
|
||||
if ok {
|
||||
altTitles = append(altTitles, altTitle)
|
||||
}
|
||||
altTitle, ok = title["jp"]
|
||||
if ok {
|
||||
altTitles = append(altTitles, altTitle)
|
||||
}
|
||||
altTitle, ok = title["ja"]
|
||||
if ok {
|
||||
altTitles = append(altTitles, altTitle)
|
||||
}
|
||||
}
|
||||
t := getTitle(manga.Attributes)
|
||||
|
||||
var img string
|
||||
for _, relation := range manga.Relationships {
|
||||
if relation.Type == "cover_art" {
|
||||
fn, ok := relation.Attributes["fileName"].(string)
|
||||
if ok {
|
||||
img = fmt.Sprintf("%s/covers/%s/%s.512.jpg", md.BaseUrl, manga.ID, fn)
|
||||
} else {
|
||||
img = fmt.Sprintf("%s/covers/%s/%s.jpg.512.jpg", md.BaseUrl, manga.ID, relation.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
format := strings.ToUpper(manga.Type)
|
||||
if format == "ADAPTATION" {
|
||||
format = "MANGA"
|
||||
}
|
||||
|
||||
compRes, _ := comparison.FindBestMatchWithSorensenDice(&opts.Query, []*string{&t})
|
||||
|
||||
result := &hibikemanga.SearchResult{
|
||||
ID: manga.ID,
|
||||
Title: t,
|
||||
Synonyms: altTitles,
|
||||
Image: img,
|
||||
Year: manga.Attributes.Year,
|
||||
SearchRating: compRes.Rating,
|
||||
Provider: string(MangadexProvider),
|
||||
}
|
||||
|
||||
ret = append(ret, result)
|
||||
}
|
||||
|
||||
if len(ret) == 0 {
|
||||
md.logger.Error().Msg("mangadex: No results found")
|
||||
return nil, ErrNoResults
|
||||
}
|
||||
|
||||
md.logger.Info().Int("count", len(ret)).Msg("mangadex: Found results")
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func (md *Mangadex) FindChapters(id string) ([]*hibikemanga.ChapterDetails, error) {
|
||||
ret := make([]*hibikemanga.ChapterDetails, 0)
|
||||
|
||||
md.logger.Debug().Str("mangaId", id).Msg("mangadex: Finding chapters")
|
||||
|
||||
for page := 0; page <= 1; page++ {
|
||||
uri := fmt.Sprintf("%s/manga/%s/feed?limit=500&translatedLanguage%%5B%%5D=en&includes[]=scanlation_group&includes[]=user&order[volume]=desc&order[chapter]=desc&offset=%d&contentRating[]=safe&contentRating[]=suggestive&contentRating[]=erotica&contentRating[]=pornographic", md.Url, id, 500*page)
|
||||
|
||||
var data struct {
|
||||
Result string `json:"result"`
|
||||
Errors []MangadexErrorResponse `json:"errors"`
|
||||
Data []MangadexChapterData `json:"data"`
|
||||
}
|
||||
|
||||
resp, err := md.Client.R().
|
||||
SetSuccessResult(&data).
|
||||
Get(uri)
|
||||
|
||||
if err != nil {
|
||||
md.logger.Error().Err(err).Msg("mangadex: Failed to send request")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !resp.IsSuccessState() {
|
||||
md.logger.Error().Str("status", resp.Status).Msg("mangadex: Request failed")
|
||||
return nil, fmt.Errorf("failed to decode response: status %s", resp.Status)
|
||||
}
|
||||
|
||||
if data.Result == "error" {
|
||||
md.logger.Error().Str("error", data.Errors[0].Title).Str("detail", data.Errors[0].Detail).Msg("mangadex: Could not find chapters")
|
||||
return nil, fmt.Errorf("could not find chapters: %s", data.Errors[0].Detail)
|
||||
}
|
||||
|
||||
slices.Reverse(data.Data)
|
||||
|
||||
chapterMap := make(map[string]*hibikemanga.ChapterDetails)
|
||||
idx := uint(len(ret))
|
||||
for _, chapter := range data.Data {
|
||||
|
||||
if chapter.Attributes.Chapter == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
title := "Chapter " + fmt.Sprintf("%s", chapter.Attributes.Chapter) + " "
|
||||
|
||||
if _, ok := chapterMap[chapter.Attributes.Chapter]; ok {
|
||||
continue
|
||||
}
|
||||
|
||||
chapterMap[chapter.Attributes.Chapter] = &hibikemanga.ChapterDetails{
|
||||
ID: chapter.ID,
|
||||
Title: title,
|
||||
Index: idx,
|
||||
Chapter: chapter.Attributes.Chapter,
|
||||
UpdatedAt: chapter.Attributes.UpdatedAt,
|
||||
Provider: string(MangadexProvider),
|
||||
}
|
||||
idx++
|
||||
}
|
||||
|
||||
chapters := make([]*hibikemanga.ChapterDetails, 0, len(chapterMap))
|
||||
for _, chapter := range chapterMap {
|
||||
chapters = append(chapters, chapter)
|
||||
}
|
||||
|
||||
slices.SortStableFunc(chapters, func(i, j *hibikemanga.ChapterDetails) int {
|
||||
return cmp.Compare(i.Index, j.Index)
|
||||
})
|
||||
|
||||
if len(chapters) > 0 {
|
||||
ret = append(ret, chapters...)
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if len(ret) == 0 {
|
||||
md.logger.Error().Msg("mangadex: No chapters found")
|
||||
return nil, ErrNoChapters
|
||||
}
|
||||
|
||||
md.logger.Info().Int("count", len(ret)).Msg("mangadex: Found chapters")
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func (md *Mangadex) FindChapterPages(id string) ([]*hibikemanga.ChapterPage, error) {
|
||||
ret := make([]*hibikemanga.ChapterPage, 0)
|
||||
|
||||
md.logger.Debug().Str("chapterId", id).Msg("mangadex: Finding chapter pages")
|
||||
|
||||
uri := fmt.Sprintf("%s/at-home/server/%s", md.Url, id)
|
||||
|
||||
var data struct {
|
||||
BaseUrl string `json:"baseUrl"`
|
||||
Chapter struct {
|
||||
Hash string `json:"hash"`
|
||||
Data []string `json:"data"`
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := md.Client.R().
|
||||
SetHeader("User-Agent", util.GetRandomUserAgent()).
|
||||
SetSuccessResult(&data).
|
||||
Get(uri)
|
||||
|
||||
if err != nil {
|
||||
md.logger.Error().Err(err).Msg("mangadex: Failed to get chapter pages")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !resp.IsSuccessState() {
|
||||
md.logger.Error().Str("status", resp.Status).Msg("mangadex: Request failed")
|
||||
return nil, fmt.Errorf("failed to decode response: status %s", resp.Status)
|
||||
}
|
||||
|
||||
for i, page := range data.Chapter.Data {
|
||||
ret = append(ret, &hibikemanga.ChapterPage{
|
||||
Provider: string(MangadexProvider),
|
||||
URL: fmt.Sprintf("%s/data/%s/%s", data.BaseUrl, data.Chapter.Hash, page),
|
||||
Index: i,
|
||||
Headers: map[string]string{
|
||||
"Referer": "https://mangadex.org",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if len(ret) == 0 {
|
||||
md.logger.Error().Msg("mangadex: No pages found")
|
||||
return nil, ErrNoPages
|
||||
}
|
||||
|
||||
md.logger.Info().Int("count", len(ret)).Msg("mangadex: Found pages")
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
func getTitle(attributes MangadexMangeAttributes) string {
|
||||
altTitles := attributes.AltTitles
|
||||
title := attributes.Title
|
||||
|
||||
enTitle := title["en"]
|
||||
if enTitle != "" {
|
||||
return enTitle
|
||||
}
|
||||
|
||||
var enAltTitle string
|
||||
for _, altTitle := range altTitles {
|
||||
if value, ok := altTitle["en"]; ok {
|
||||
enAltTitle = value
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if enAltTitle != "" && util.IsMostlyLatinString(enAltTitle) {
|
||||
return enAltTitle
|
||||
}
|
||||
|
||||
// Check for other language titles
|
||||
if jaRoTitle, ok := title["ja-ro"]; ok {
|
||||
return jaRoTitle
|
||||
}
|
||||
if jpRoTitle, ok := title["jp-ro"]; ok {
|
||||
return jpRoTitle
|
||||
}
|
||||
if jpTitle, ok := title["jp"]; ok {
|
||||
return jpTitle
|
||||
}
|
||||
if jaTitle, ok := title["ja"]; ok {
|
||||
return jaTitle
|
||||
}
|
||||
if koTitle, ok := title["ko"]; ok {
|
||||
return koTitle
|
||||
}
|
||||
|
||||
// Check alt titles for other languages
|
||||
for _, altTitle := range altTitles {
|
||||
if value, ok := altTitle["ja-ro"]; ok {
|
||||
return value
|
||||
}
|
||||
}
|
||||
for _, altTitle := range altTitles {
|
||||
if value, ok := altTitle["jp-ro"]; ok {
|
||||
return value
|
||||
}
|
||||
}
|
||||
for _, altTitle := range altTitles {
|
||||
if value, ok := altTitle["jp"]; ok {
|
||||
return value
|
||||
}
|
||||
}
|
||||
for _, altTitle := range altTitles {
|
||||
if value, ok := altTitle["ja"]; ok {
|
||||
return value
|
||||
}
|
||||
}
|
||||
for _, altTitle := range altTitles {
|
||||
if value, ok := altTitle["ko"]; ok {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
153
seanime-2.9.10/internal/manga/providers/mangadex_test.go
Normal file
153
seanime-2.9.10/internal/manga/providers/mangadex_test.go
Normal file
@@ -0,0 +1,153 @@
|
||||
package manga_providers
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
hibikemanga "seanime/internal/extension/hibike/manga"
|
||||
"seanime/internal/util"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMangadex_Search(t *testing.T) {
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
query string
|
||||
}{
|
||||
{
|
||||
name: "One Piece",
|
||||
query: "One Piece",
|
||||
},
|
||||
{
|
||||
name: "Jujutsu Kaisen",
|
||||
query: "Jujutsu Kaisen",
|
||||
},
|
||||
{
|
||||
name: "Boku no Kokoro no Yabai Yatsu",
|
||||
query: "Boku no Kokoro no Yabai Yatsu",
|
||||
},
|
||||
}
|
||||
|
||||
mangadex := NewMangadex(util.NewLogger())
|
||||
|
||||
for _, tt := range tests {
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
|
||||
searchRes, err := mangadex.Search(hibikemanga.SearchOptions{
|
||||
Query: tt.query,
|
||||
})
|
||||
if assert.NoError(t, err, "mangadex.Search() error") {
|
||||
assert.NotEmpty(t, searchRes, "search result is empty")
|
||||
|
||||
for _, res := range searchRes {
|
||||
t.Logf("Title: %s", res.Title)
|
||||
t.Logf("\tID: %s", res.ID)
|
||||
t.Logf("\tYear: %d", res.Year)
|
||||
t.Logf("\tImage: %s", res.Image)
|
||||
t.Logf("\tProvider: %s", res.Provider)
|
||||
t.Logf("\tSearchRating: %f", res.SearchRating)
|
||||
t.Logf("\tSynonyms: %v", res.Synonyms)
|
||||
t.Log("--------------------------------------------------")
|
||||
}
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func TestMangadex_FindChapters(t *testing.T) {
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
id string
|
||||
atLeast int
|
||||
}{
|
||||
//{
|
||||
// name: "One Piece",
|
||||
// id: "One-Piece",
|
||||
// atLeast: 1100,
|
||||
//},
|
||||
{
|
||||
name: "Jujutsu Kaisen",
|
||||
id: "c52b2ce3-7f95-469c-96b0-479524fb7a1a",
|
||||
atLeast: 250,
|
||||
},
|
||||
{
|
||||
name: "The Dangers in My Heart",
|
||||
id: "3df1a9a3-a1be-47a3-9e90-9b3e55b1d0ac",
|
||||
atLeast: 141,
|
||||
},
|
||||
}
|
||||
|
||||
mangadex := NewMangadex(util.NewLogger())
|
||||
|
||||
for _, tt := range tests {
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
|
||||
chapters, err := mangadex.FindChapters(tt.id)
|
||||
if assert.NoError(t, err, "mangadex.FindChapters() error") {
|
||||
|
||||
assert.NotEmpty(t, chapters, "chapters is empty")
|
||||
|
||||
assert.GreaterOrEqual(t, len(chapters), tt.atLeast, "chapters length is less than expected")
|
||||
|
||||
for _, chapter := range chapters {
|
||||
t.Logf("Title: %s", chapter.Title)
|
||||
t.Logf("\tSlug: %s", chapter.ID)
|
||||
t.Logf("\tURL: %s", chapter.URL)
|
||||
t.Logf("\tIndex: %d", chapter.Index)
|
||||
t.Logf("\tUpdatedAt: %s", chapter.UpdatedAt)
|
||||
t.Log("--------------------------------------------------")
|
||||
}
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func TestMangadex_FindChapterPages(t *testing.T) {
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
id string
|
||||
chapterId string
|
||||
}{
|
||||
{
|
||||
name: "The Dangers in My Heart",
|
||||
id: "3df1a9a3-a1be-47a3-9e90-9b3e55b1d0ac",
|
||||
chapterId: "5145ea39-be4b-4bf9-81e7-4f90961db857", // Chapter 1
|
||||
},
|
||||
{
|
||||
name: "Kagurabachi",
|
||||
id: "",
|
||||
chapterId: "9c9652fc-10d2-40b3-9382-16fb072d3068", // Chapter 1
|
||||
},
|
||||
}
|
||||
|
||||
mangadex := NewMangadex(util.NewLogger())
|
||||
|
||||
for _, tt := range tests {
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
|
||||
pages, err := mangadex.FindChapterPages(tt.chapterId)
|
||||
if assert.NoError(t, err, "mangadex.FindChapterPages() error") {
|
||||
assert.NotEmpty(t, pages, "pages is empty")
|
||||
|
||||
for _, page := range pages {
|
||||
t.Logf("Index: %d", page.Index)
|
||||
t.Logf("\tURL: %s", page.URL)
|
||||
t.Log("--------------------------------------------------")
|
||||
}
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
220
seanime-2.9.10/internal/manga/providers/mangafire.go
Normal file
220
seanime-2.9.10/internal/manga/providers/mangafire.go
Normal file
@@ -0,0 +1,220 @@
|
||||
package manga_providers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
hibikemanga "seanime/internal/extension/hibike/manga"
|
||||
"seanime/internal/util"
|
||||
"seanime/internal/util/comparison"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gocolly/colly"
|
||||
"github.com/imroc/req/v3"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
// DEVNOTE: Shelved due to WAF captcha
|
||||
|
||||
type (
|
||||
Mangafire struct {
|
||||
Url string
|
||||
Client *req.Client
|
||||
UserAgent string
|
||||
logger *zerolog.Logger
|
||||
}
|
||||
)
|
||||
|
||||
func NewMangafire(logger *zerolog.Logger) *Mangafire {
|
||||
client := req.C().
|
||||
SetUserAgent(util.GetRandomUserAgent()).
|
||||
SetTimeout(60 * time.Second).
|
||||
EnableInsecureSkipVerify().
|
||||
ImpersonateChrome()
|
||||
|
||||
return &Mangafire{
|
||||
Url: "https://mangafire.to",
|
||||
Client: client,
|
||||
UserAgent: util.GetRandomUserAgent(),
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
func (mf *Mangafire) GetSettings() hibikemanga.Settings {
|
||||
return hibikemanga.Settings{
|
||||
SupportsMultiScanlator: false,
|
||||
SupportsMultiLanguage: false,
|
||||
}
|
||||
}
|
||||
|
||||
func (mf *Mangafire) Search(opts hibikemanga.SearchOptions) ([]*hibikemanga.SearchResult, error) {
|
||||
results := make([]*hibikemanga.SearchResult, 0)
|
||||
|
||||
mf.logger.Debug().Str("query", opts.Query).Msg("mangafire: Searching manga")
|
||||
|
||||
yearStr := ""
|
||||
if opts.Year > 0 {
|
||||
yearStr = fmt.Sprintf("&year=%%5B%%5D=%d", opts.Year)
|
||||
}
|
||||
uri := fmt.Sprintf("%s/filter?keyword=%s%s&sort=recently_updated", mf.Url, url.QueryEscape(opts.Query), yearStr)
|
||||
|
||||
c := colly.NewCollector(
|
||||
colly.UserAgent(util.GetRandomUserAgent()),
|
||||
)
|
||||
|
||||
c.WithTransport(mf.Client.Transport)
|
||||
|
||||
type ToVisit struct {
|
||||
ID string
|
||||
Title string
|
||||
Image string
|
||||
}
|
||||
toVisit := make([]ToVisit, 0)
|
||||
|
||||
c.OnHTML("main div.container div.original div.unit", func(e *colly.HTMLElement) {
|
||||
id := e.ChildAttr("a", "href")
|
||||
if len(toVisit) >= 15 || id == "" {
|
||||
return
|
||||
}
|
||||
title := ""
|
||||
e.ForEachWithBreak("div.info a", func(i int, e *colly.HTMLElement) bool {
|
||||
if i == 0 && e.Text != "" {
|
||||
title = strings.TrimSpace(e.Text)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
obj := ToVisit{
|
||||
ID: id,
|
||||
Title: title,
|
||||
Image: e.ChildAttr("img", "src"),
|
||||
}
|
||||
if obj.Title != "" && obj.ID != "" {
|
||||
toVisit = append(toVisit, obj)
|
||||
}
|
||||
})
|
||||
|
||||
err := c.Visit(uri)
|
||||
if err != nil {
|
||||
mf.logger.Error().Err(err).Msg("mangafire: Failed to visit")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
wg := sync.WaitGroup{}
|
||||
wg.Add(len(toVisit))
|
||||
|
||||
for _, v := range toVisit {
|
||||
go func(tv ToVisit) {
|
||||
defer wg.Done()
|
||||
|
||||
c2 := colly.NewCollector(
|
||||
colly.UserAgent(mf.UserAgent),
|
||||
)
|
||||
|
||||
c2.WithTransport(mf.Client.Transport)
|
||||
|
||||
result := &hibikemanga.SearchResult{
|
||||
Provider: MangafireProvider,
|
||||
}
|
||||
|
||||
// Synonyms
|
||||
c2.OnHTML("main div#manga-page div.info h6", func(e *colly.HTMLElement) {
|
||||
parts := strings.Split(e.Text, "; ")
|
||||
for i, v := range parts {
|
||||
parts[i] = strings.TrimSpace(v)
|
||||
}
|
||||
syn := strings.Join(parts, "")
|
||||
if syn != "" {
|
||||
result.Synonyms = append(result.Synonyms, syn)
|
||||
}
|
||||
})
|
||||
|
||||
// Year
|
||||
c2.OnHTML("main div#manga-page div.meta", func(e *colly.HTMLElement) {
|
||||
if result.Year != 0 || e.Text == "" {
|
||||
return
|
||||
}
|
||||
parts := strings.Split(e.Text, "Published: ")
|
||||
if len(parts) < 2 {
|
||||
return
|
||||
}
|
||||
parts2 := strings.Split(parts[1], " to")
|
||||
if len(parts2) < 2 {
|
||||
return
|
||||
}
|
||||
result.Year = util.StringToIntMust(strings.TrimSpace(parts2[0]))
|
||||
})
|
||||
|
||||
result.ID = tv.ID
|
||||
result.Title = tv.Title
|
||||
result.Image = tv.Image
|
||||
|
||||
err := c2.Visit(fmt.Sprintf("%s/%s", mf.Url, tv.ID))
|
||||
if err != nil {
|
||||
mf.logger.Error().Err(err).Str("id", tv.ID).Msg("mangafire: Failed to visit manga page")
|
||||
return
|
||||
}
|
||||
|
||||
// Comparison
|
||||
compTitles := []*string{&result.Title}
|
||||
for _, syn := range result.Synonyms {
|
||||
if !util.IsMostlyLatinString(syn) {
|
||||
continue
|
||||
}
|
||||
compTitles = append(compTitles, &syn)
|
||||
}
|
||||
compRes, _ := comparison.FindBestMatchWithSorensenDice(&opts.Query, compTitles)
|
||||
|
||||
result.SearchRating = compRes.Rating
|
||||
|
||||
results = append(results, result)
|
||||
}(v)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
if len(results) == 0 {
|
||||
mf.logger.Error().Str("query", opts.Query).Msg("mangafire: No results found")
|
||||
return nil, ErrNoResults
|
||||
}
|
||||
|
||||
mf.logger.Info().Int("count", len(results)).Msg("mangafire: Found results")
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func (mf *Mangafire) FindChapters(id string) ([]*hibikemanga.ChapterDetails, error) {
|
||||
ret := make([]*hibikemanga.ChapterDetails, 0)
|
||||
|
||||
mf.logger.Debug().Str("mangaId", id).Msg("mangafire: Finding chapters")
|
||||
|
||||
// code
|
||||
|
||||
if len(ret) == 0 {
|
||||
mf.logger.Error().Str("mangaId", id).Msg("mangafire: No chapters found")
|
||||
return nil, ErrNoChapters
|
||||
}
|
||||
|
||||
mf.logger.Info().Int("count", len(ret)).Msg("mangafire: Found chapters")
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func (mf *Mangafire) FindChapterPages(id string) ([]*hibikemanga.ChapterPage, error) {
|
||||
ret := make([]*hibikemanga.ChapterPage, 0)
|
||||
|
||||
mf.logger.Debug().Str("chapterId", id).Msg("mangafire: Finding chapter pages")
|
||||
|
||||
// code
|
||||
|
||||
if len(ret) == 0 {
|
||||
mf.logger.Error().Str("chapterId", id).Msg("mangafire: No pages found")
|
||||
return nil, ErrNoPages
|
||||
}
|
||||
|
||||
mf.logger.Info().Int("count", len(ret)).Msg("mangafire: Found pages")
|
||||
|
||||
return ret, nil
|
||||
|
||||
}
|
||||
132
seanime-2.9.10/internal/manga/providers/mangafire_test.go
Normal file
132
seanime-2.9.10/internal/manga/providers/mangafire_test.go
Normal file
@@ -0,0 +1,132 @@
|
||||
package manga_providers
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
hibikemanga "seanime/internal/extension/hibike/manga"
|
||||
"seanime/internal/util"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMangafire_Search(t *testing.T) {
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
query string
|
||||
}{
|
||||
{
|
||||
name: "Boku no Kokoro no Yabai Yatsu",
|
||||
query: "Boku no Kokoro no Yabai Yatsu",
|
||||
},
|
||||
{
|
||||
name: "Dangers in My Heart",
|
||||
query: "Dangers in My Heart",
|
||||
},
|
||||
}
|
||||
|
||||
provider := NewMangafire(util.NewLogger())
|
||||
|
||||
for _, tt := range tests {
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
|
||||
searchRes, err := provider.Search(hibikemanga.SearchOptions{
|
||||
Query: tt.query,
|
||||
})
|
||||
if assert.NoError(t, err, "provider.Search() error") {
|
||||
assert.NotEmpty(t, searchRes, "search result is empty")
|
||||
|
||||
for _, res := range searchRes {
|
||||
t.Logf("Title: %s", res.Title)
|
||||
t.Logf("\tID: %s", res.ID)
|
||||
t.Logf("\tYear: %d", res.Year)
|
||||
t.Logf("\tImage: %s", res.Image)
|
||||
t.Logf("\tProvider: %s", res.Provider)
|
||||
t.Logf("\tSearchRating: %f", res.SearchRating)
|
||||
t.Logf("\tSynonyms: %v", res.Synonyms)
|
||||
t.Log("--------------------------------------------------")
|
||||
}
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func TestMangafire_FindChapters(t *testing.T) {
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
id string
|
||||
atLeast int
|
||||
}{
|
||||
{
|
||||
name: "The Dangers in My Heart",
|
||||
id: "/manga/boku-no-kokoro-no-yabai-yatsu.vv882",
|
||||
atLeast: 141,
|
||||
},
|
||||
}
|
||||
|
||||
provider := NewMangafire(util.NewLogger())
|
||||
|
||||
for _, tt := range tests {
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
|
||||
chapters, err := provider.FindChapters(tt.id)
|
||||
if assert.NoError(t, err, "provider.FindChapters() error") {
|
||||
|
||||
assert.NotEmpty(t, chapters, "chapters is empty")
|
||||
|
||||
assert.GreaterOrEqual(t, len(chapters), tt.atLeast, "chapters length is less than expected")
|
||||
|
||||
for _, chapter := range chapters {
|
||||
t.Logf("Title: %s", chapter.Title)
|
||||
t.Logf("\tSlug: %s", chapter.ID)
|
||||
t.Logf("\tURL: %s", chapter.URL)
|
||||
t.Logf("\tIndex: %d", chapter.Index)
|
||||
t.Logf("\tUpdatedAt: %s", chapter.UpdatedAt)
|
||||
t.Log("--------------------------------------------------")
|
||||
}
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
//func TestMangafire_FindChapterPages(t *testing.T) {
|
||||
//
|
||||
// tests := []struct {
|
||||
// name string
|
||||
// chapterId string
|
||||
// }{
|
||||
// {
|
||||
// name: "The Dangers in My Heart",
|
||||
// chapterId: "", // Chapter 1
|
||||
// },
|
||||
// }
|
||||
//
|
||||
// provider := NewMangafire(util.NewLogger())
|
||||
//
|
||||
// for _, tt := range tests {
|
||||
//
|
||||
// t.Run(tt.name, func(t *testing.T) {
|
||||
//
|
||||
// pages, err := provider.FindChapterPages(tt.chapterId)
|
||||
// if assert.NoError(t, err, "provider.FindChapterPages() error") {
|
||||
// assert.NotEmpty(t, pages, "pages is empty")
|
||||
//
|
||||
// for _, page := range pages {
|
||||
// t.Logf("Index: %d", page.Index)
|
||||
// t.Logf("\tURL: %s", page.URL)
|
||||
// t.Log("--------------------------------------------------")
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// })
|
||||
//
|
||||
// }
|
||||
//
|
||||
//}
|
||||
302
seanime-2.9.10/internal/manga/providers/manganato.go
Normal file
302
seanime-2.9.10/internal/manga/providers/manganato.go
Normal file
@@ -0,0 +1,302 @@
|
||||
package manga_providers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"net/url"
|
||||
hibikemanga "seanime/internal/extension/hibike/manga"
|
||||
"seanime/internal/util"
|
||||
"seanime/internal/util/comparison"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
"github.com/imroc/req/v3"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
type (
|
||||
Manganato struct {
|
||||
Url string
|
||||
Client *req.Client
|
||||
logger *zerolog.Logger
|
||||
}
|
||||
|
||||
ManganatoSearchResult struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
NameUnsigned string `json:"nameunsigned"`
|
||||
LastChapter string `json:"lastchapter"`
|
||||
Image string `json:"image"`
|
||||
Author string `json:"author"`
|
||||
StoryLink string `json:"story_link"`
|
||||
}
|
||||
)
|
||||
|
||||
func NewManganato(logger *zerolog.Logger) *Manganato {
|
||||
client := req.C().
|
||||
SetUserAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36").
|
||||
SetTimeout(60 * time.Second).
|
||||
EnableInsecureSkipVerify().
|
||||
ImpersonateSafari()
|
||||
|
||||
return &Manganato{
|
||||
Url: "https://natomanga.com",
|
||||
Client: client,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
func (mp *Manganato) GetSettings() hibikemanga.Settings {
|
||||
return hibikemanga.Settings{
|
||||
SupportsMultiScanlator: false,
|
||||
SupportsMultiLanguage: false,
|
||||
}
|
||||
}
|
||||
|
||||
func (mp *Manganato) Search(opts hibikemanga.SearchOptions) (ret []*hibikemanga.SearchResult, err error) {
|
||||
ret = make([]*hibikemanga.SearchResult, 0)
|
||||
|
||||
mp.logger.Debug().Str("query", opts.Query).Msg("manganato: Searching manga")
|
||||
|
||||
q := opts.Query
|
||||
q = strings.ReplaceAll(q, " ", "_")
|
||||
q = strings.ToLower(q)
|
||||
q = strings.TrimSpace(q)
|
||||
q = url.QueryEscape(q)
|
||||
uri := fmt.Sprintf("https://natomanga.com/search/story/%s", q)
|
||||
|
||||
resp, err := mp.Client.R().
|
||||
SetHeader("User-Agent", util.GetRandomUserAgent()).
|
||||
Get(uri)
|
||||
|
||||
if err != nil {
|
||||
mp.logger.Error().Err(err).Str("uri", uri).Msg("manganato: Failed to send request")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !resp.IsSuccessState() {
|
||||
mp.logger.Error().Str("status", resp.Status).Str("uri", uri).Msg("manganato: Request failed")
|
||||
return nil, fmt.Errorf("failed to fetch search results: status %s", resp.Status)
|
||||
}
|
||||
|
||||
bodyBytes := resp.Bytes()
|
||||
|
||||
//mp.logger.Debug().Str("body", string(bodyBytes)).Msg("manganato: Response body")
|
||||
|
||||
doc, err := goquery.NewDocumentFromReader(bytes.NewReader(bodyBytes))
|
||||
if err != nil {
|
||||
mp.logger.Error().Err(err).Msg("manganato: Failed to parse HTML")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
doc.Find("div.story_item").Each(func(i int, s *goquery.Selection) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
}
|
||||
}()
|
||||
|
||||
result := &hibikemanga.SearchResult{
|
||||
Provider: string(ManganatoProvider),
|
||||
}
|
||||
|
||||
href, exists := s.Find("a").Attr("href")
|
||||
if !exists {
|
||||
return
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(href, "https://natomanga.com/") &&
|
||||
!strings.HasPrefix(href, "https://www.natomanga.com/") &&
|
||||
!strings.HasPrefix(href, "https://www.chapmanganato.com/") &&
|
||||
!strings.HasPrefix(href, "https://chapmanganato.com/") {
|
||||
return
|
||||
}
|
||||
|
||||
result.ID = href
|
||||
splitHref := strings.Split(result.ID, "/")
|
||||
|
||||
if strings.Contains(href, "chapmanganato") {
|
||||
result.ID = "chapmanganato$"
|
||||
} else {
|
||||
result.ID = "manganato$"
|
||||
}
|
||||
|
||||
if len(splitHref) > 4 {
|
||||
result.ID += splitHref[4]
|
||||
}
|
||||
|
||||
result.Title = s.Find("h3.story_name").Text()
|
||||
result.Title = strings.TrimSpace(result.Title)
|
||||
result.Image, _ = s.Find("img").Attr("src")
|
||||
|
||||
compRes, _ := comparison.FindBestMatchWithSorensenDice(&opts.Query, []*string{&result.Title})
|
||||
result.SearchRating = compRes.Rating
|
||||
ret = append(ret, result)
|
||||
})
|
||||
|
||||
if len(ret) == 0 {
|
||||
mp.logger.Error().Str("query", opts.Query).Msg("manganato: No results found")
|
||||
return nil, ErrNoResults
|
||||
}
|
||||
|
||||
mp.logger.Info().Int("count", len(ret)).Msg("manganato: Found results")
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func (mp *Manganato) FindChapters(id string) (ret []*hibikemanga.ChapterDetails, err error) {
|
||||
ret = make([]*hibikemanga.ChapterDetails, 0)
|
||||
|
||||
mp.logger.Debug().Str("mangaId", id).Msg("manganato: Finding chapters")
|
||||
|
||||
splitId := strings.Split(id, "$")
|
||||
if len(splitId) != 2 {
|
||||
mp.logger.Error().Str("mangaId", id).Msg("manganato: Invalid manga ID")
|
||||
return nil, ErrNoChapters
|
||||
}
|
||||
|
||||
uri := ""
|
||||
if splitId[0] == "manganato" {
|
||||
uri = fmt.Sprintf("https://natomanga.com/manga/%s", splitId[1])
|
||||
} else if splitId[0] == "chapmanganato" {
|
||||
uri = fmt.Sprintf("https://chapmanganato.com/manga/%s", splitId[1])
|
||||
}
|
||||
|
||||
resp, err := mp.Client.R().
|
||||
SetHeader("User-Agent", util.GetRandomUserAgent()).
|
||||
Get(uri)
|
||||
|
||||
if err != nil {
|
||||
mp.logger.Error().Err(err).Str("uri", uri).Msg("manganato: Failed to send request")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !resp.IsSuccessState() {
|
||||
mp.logger.Error().Str("status", resp.Status).Str("uri", uri).Msg("manganato: Request failed")
|
||||
return nil, fmt.Errorf("failed to fetch chapters: status %s", resp.Status)
|
||||
}
|
||||
|
||||
doc, err := goquery.NewDocumentFromReader(resp.Body)
|
||||
if err != nil {
|
||||
mp.logger.Error().Err(err).Msg("manganato: Failed to parse HTML")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
doc.Find(".chapter-list .row").Each(func(i int, s *goquery.Selection) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
}
|
||||
}()
|
||||
|
||||
name := s.Find("a").Text()
|
||||
if strings.HasPrefix(name, "Vol.") {
|
||||
split := strings.Split(name, " ")
|
||||
name = strings.Join(split[1:], " ")
|
||||
}
|
||||
|
||||
chStr := strings.TrimSpace(strings.Split(name, " ")[1])
|
||||
chStr = strings.TrimSuffix(chStr, ":")
|
||||
|
||||
href, exists := s.Find("a").Attr("href")
|
||||
if !exists {
|
||||
return
|
||||
}
|
||||
|
||||
hrefParts := strings.Split(href, "/")
|
||||
if len(hrefParts) < 6 {
|
||||
return
|
||||
}
|
||||
|
||||
chapterId := hrefParts[5]
|
||||
chapter := &hibikemanga.ChapterDetails{
|
||||
Provider: string(ManganatoProvider),
|
||||
ID: splitId[1] + "$" + chapterId,
|
||||
URL: href,
|
||||
Title: strings.TrimSpace(name),
|
||||
Chapter: chStr,
|
||||
}
|
||||
ret = append(ret, chapter)
|
||||
})
|
||||
|
||||
slices.Reverse(ret)
|
||||
for i, chapter := range ret {
|
||||
chapter.Index = uint(i)
|
||||
}
|
||||
|
||||
if len(ret) == 0 {
|
||||
mp.logger.Error().Str("mangaId", id).Msg("manganato: No chapters found")
|
||||
return nil, ErrNoChapters
|
||||
}
|
||||
|
||||
mp.logger.Info().Int("count", len(ret)).Msg("manganato: Found chapters")
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func (mp *Manganato) FindChapterPages(id string) (ret []*hibikemanga.ChapterPage, err error) {
|
||||
ret = make([]*hibikemanga.ChapterPage, 0)
|
||||
|
||||
mp.logger.Debug().Str("chapterId", id).Msg("manganato: Finding chapter pages")
|
||||
|
||||
splitId := strings.Split(id, "$")
|
||||
if len(splitId) != 2 {
|
||||
mp.logger.Error().Str("chapterId", id).Msg("manganato: Invalid chapter ID")
|
||||
return nil, ErrNoPages
|
||||
}
|
||||
|
||||
uri := fmt.Sprintf("https://natomanga.com/manga/%s/%s", splitId[0], splitId[1])
|
||||
|
||||
resp, err := mp.Client.R().
|
||||
SetHeader("User-Agent", util.GetRandomUserAgent()).
|
||||
SetHeader("Referer", "https://natomanga.com/").
|
||||
Get(uri)
|
||||
|
||||
if err != nil {
|
||||
mp.logger.Error().Err(err).Str("uri", uri).Msg("manganato: Failed to send request")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !resp.IsSuccessState() {
|
||||
mp.logger.Error().Str("status", resp.Status).Str("uri", uri).Msg("manganato: Request failed")
|
||||
return nil, fmt.Errorf("failed to fetch chapter pages: status %s", resp.Status)
|
||||
}
|
||||
|
||||
doc, err := goquery.NewDocumentFromReader(resp.Body)
|
||||
if err != nil {
|
||||
mp.logger.Error().Err(err).Msg("manganato: Failed to parse HTML")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
doc.Find(".container-chapter-reader img").Each(func(i int, s *goquery.Selection) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
}
|
||||
}()
|
||||
|
||||
src, exists := s.Attr("src")
|
||||
if !exists || src == "" {
|
||||
return
|
||||
}
|
||||
|
||||
page := &hibikemanga.ChapterPage{
|
||||
Provider: string(ManganatoProvider),
|
||||
URL: src,
|
||||
Index: len(ret),
|
||||
Headers: map[string]string{
|
||||
"Referer": "https://natomanga.com/",
|
||||
},
|
||||
}
|
||||
ret = append(ret, page)
|
||||
})
|
||||
|
||||
if len(ret) == 0 {
|
||||
mp.logger.Error().Str("chapterId", id).Msg("manganato: No pages found")
|
||||
return nil, ErrNoPages
|
||||
}
|
||||
|
||||
mp.logger.Info().Int("count", len(ret)).Msg("manganato: Found pages")
|
||||
|
||||
return ret, nil
|
||||
|
||||
}
|
||||
130
seanime-2.9.10/internal/manga/providers/manganato_test.go
Normal file
130
seanime-2.9.10/internal/manga/providers/manganato_test.go
Normal file
@@ -0,0 +1,130 @@
|
||||
package manga_providers
|
||||
|
||||
import (
|
||||
hibikemanga "seanime/internal/extension/hibike/manga"
|
||||
"seanime/internal/util"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestManganato_Search(t *testing.T) {
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
query string
|
||||
}{
|
||||
{
|
||||
name: "Boku no Kokoro no Yabai Yatsu",
|
||||
query: "Boku no Kokoro no Yabai Yatsu",
|
||||
},
|
||||
}
|
||||
|
||||
provider := NewManganato(util.NewLogger())
|
||||
|
||||
for _, tt := range tests {
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
|
||||
searchRes, err := provider.Search(hibikemanga.SearchOptions{
|
||||
Query: tt.query,
|
||||
})
|
||||
if assert.NoError(t, err, "provider.Search() error") {
|
||||
assert.NotEmpty(t, searchRes, "search result is empty")
|
||||
|
||||
for _, res := range searchRes {
|
||||
t.Logf("Title: %s", res.Title)
|
||||
t.Logf("\tID: %s", res.ID)
|
||||
t.Logf("\tYear: %d", res.Year)
|
||||
t.Logf("\tImage: %s", res.Image)
|
||||
t.Logf("\tProvider: %s", res.Provider)
|
||||
t.Logf("\tSearchRating: %f", res.SearchRating)
|
||||
t.Logf("\tSynonyms: %v", res.Synonyms)
|
||||
t.Log("--------------------------------------------------")
|
||||
}
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func TestManganato_FindChapters(t *testing.T) {
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
id string
|
||||
atLeast int
|
||||
}{
|
||||
{
|
||||
name: "The Dangers in My Heart",
|
||||
id: "manganato$boku-no-kokoro-no-yabai-yatsu",
|
||||
atLeast: 141,
|
||||
},
|
||||
}
|
||||
|
||||
provider := NewManganato(util.NewLogger())
|
||||
|
||||
for _, tt := range tests {
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
|
||||
chapters, err := provider.FindChapters(tt.id)
|
||||
if assert.NoError(t, err, "provider.FindChapters() error") {
|
||||
|
||||
assert.NotEmpty(t, chapters, "chapters is empty")
|
||||
|
||||
assert.GreaterOrEqual(t, len(chapters), tt.atLeast, "chapters length is less than expected")
|
||||
|
||||
for _, chapter := range chapters {
|
||||
t.Logf("Title: %s", chapter.Title)
|
||||
t.Logf("\tID: %s", chapter.ID)
|
||||
t.Logf("\tChapter: %s", chapter.Chapter)
|
||||
t.Logf("\tURL: %s", chapter.URL)
|
||||
t.Logf("\tIndex: %d", chapter.Index)
|
||||
t.Logf("\tUpdatedAt: %s", chapter.UpdatedAt)
|
||||
t.Log("--------------------------------------------------")
|
||||
}
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func TestManganato_FindChapterPages(t *testing.T) {
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
chapterId string
|
||||
}{
|
||||
{
|
||||
name: "The Dangers in My Heart",
|
||||
chapterId: "boku-no-kokoro-no-yabai-yatsu$chapter-20", // Chapter 20
|
||||
},
|
||||
}
|
||||
|
||||
provider := NewManganato(util.NewLogger())
|
||||
|
||||
for _, tt := range tests {
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
|
||||
pages, err := provider.FindChapterPages(tt.chapterId)
|
||||
if assert.NoError(t, err, "provider.FindChapterPages() error") {
|
||||
assert.NotEmpty(t, pages, "pages is empty")
|
||||
|
||||
for _, page := range pages {
|
||||
t.Logf("Index: %d", page.Index)
|
||||
t.Logf("\tURL: %s", page.URL)
|
||||
t.Log("--------------------------------------------------")
|
||||
}
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
235
seanime-2.9.10/internal/manga/providers/mangapill.go
Normal file
235
seanime-2.9.10/internal/manga/providers/mangapill.go
Normal file
@@ -0,0 +1,235 @@
|
||||
package manga_providers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
hibikemanga "seanime/internal/extension/hibike/manga"
|
||||
"seanime/internal/util"
|
||||
"seanime/internal/util/comparison"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gocolly/colly"
|
||||
"github.com/imroc/req/v3"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
type (
|
||||
Mangapill struct {
|
||||
Url string
|
||||
Client *req.Client
|
||||
UserAgent string
|
||||
logger *zerolog.Logger
|
||||
}
|
||||
)
|
||||
|
||||
func NewMangapill(logger *zerolog.Logger) *Mangapill {
|
||||
client := req.C().
|
||||
SetUserAgent(util.GetRandomUserAgent()).
|
||||
SetTimeout(60 * time.Second).
|
||||
EnableInsecureSkipVerify().
|
||||
ImpersonateChrome()
|
||||
|
||||
return &Mangapill{
|
||||
Url: "https://mangapill.com",
|
||||
Client: client,
|
||||
UserAgent: util.GetRandomUserAgent(),
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// DEVNOTE: Unique ID
|
||||
// Each chapter ID has this format: {number}${slug} -- e.g. 6502-10004000$gokurakugai-chapter-4
|
||||
// The chapter ID is split by the $ character to reconstruct the chapter URL for subsequent requests
|
||||
|
||||
func (mp *Mangapill) GetSettings() hibikemanga.Settings {
|
||||
return hibikemanga.Settings{
|
||||
SupportsMultiScanlator: false,
|
||||
SupportsMultiLanguage: false,
|
||||
}
|
||||
}
|
||||
|
||||
func (mp *Mangapill) Search(opts hibikemanga.SearchOptions) (ret []*hibikemanga.SearchResult, err error) {
|
||||
ret = make([]*hibikemanga.SearchResult, 0)
|
||||
|
||||
mp.logger.Debug().Str("query", opts.Query).Msg("mangapill: Searching manga")
|
||||
|
||||
uri := fmt.Sprintf("%s/search?q=%s", mp.Url, url.QueryEscape(opts.Query))
|
||||
|
||||
c := colly.NewCollector(
|
||||
colly.UserAgent(mp.UserAgent),
|
||||
)
|
||||
|
||||
c.WithTransport(mp.Client.Transport)
|
||||
|
||||
c.OnHTML("div.container div.my-3.justify-end > div", func(e *colly.HTMLElement) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
}
|
||||
}()
|
||||
result := &hibikemanga.SearchResult{
|
||||
Provider: string(MangapillProvider),
|
||||
}
|
||||
|
||||
result.ID = strings.Split(e.ChildAttr("a", "href"), "/manga/")[1]
|
||||
result.ID = strings.Replace(result.ID, "/", "$", -1)
|
||||
|
||||
title := e.DOM.Find("div > a > div.mt-3").Text()
|
||||
result.Title = strings.TrimSpace(title)
|
||||
|
||||
altTitles := e.DOM.Find("div > a > div.text-xs.text-secondary").Text()
|
||||
if altTitles != "" {
|
||||
result.Synonyms = []string{strings.TrimSpace(altTitles)}
|
||||
}
|
||||
|
||||
compTitles := []*string{&result.Title}
|
||||
if len(result.Synonyms) > 0 {
|
||||
compTitles = append(compTitles, &result.Synonyms[0])
|
||||
}
|
||||
compRes, _ := comparison.FindBestMatchWithSorensenDice(&opts.Query, compTitles)
|
||||
result.SearchRating = compRes.Rating
|
||||
|
||||
result.Image = e.ChildAttr("a img", "data-src")
|
||||
|
||||
yearStr := e.DOM.Find("div > div.flex > div").Eq(1).Text()
|
||||
year, err := strconv.Atoi(strings.TrimSpace(yearStr))
|
||||
if err != nil {
|
||||
result.Year = 0
|
||||
} else {
|
||||
result.Year = year
|
||||
}
|
||||
|
||||
ret = append(ret, result)
|
||||
})
|
||||
|
||||
err = c.Visit(uri)
|
||||
if err != nil {
|
||||
mp.logger.Error().Err(err).Msg("mangapill: Failed to visit")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// code
|
||||
|
||||
if len(ret) == 0 {
|
||||
mp.logger.Error().Str("query", opts.Query).Msg("mangapill: No results found")
|
||||
return nil, ErrNoResults
|
||||
}
|
||||
|
||||
mp.logger.Info().Int("count", len(ret)).Msg("mangapill: Found results")
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func (mp *Mangapill) FindChapters(id string) (ret []*hibikemanga.ChapterDetails, err error) {
|
||||
ret = make([]*hibikemanga.ChapterDetails, 0)
|
||||
|
||||
mp.logger.Debug().Str("mangaId", id).Msg("mangapill: Finding chapters")
|
||||
|
||||
uriId := strings.Replace(id, "$", "/", -1)
|
||||
uri := fmt.Sprintf("%s/manga/%s", mp.Url, uriId)
|
||||
|
||||
c := colly.NewCollector(
|
||||
colly.UserAgent(mp.UserAgent),
|
||||
)
|
||||
|
||||
c.WithTransport(mp.Client.Transport)
|
||||
|
||||
c.OnHTML("div.container div.border-border div#chapters div.grid-cols-1 a", func(e *colly.HTMLElement) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
}
|
||||
}()
|
||||
chapter := &hibikemanga.ChapterDetails{
|
||||
Provider: MangapillProvider,
|
||||
}
|
||||
|
||||
chapter.ID = strings.Split(e.Attr("href"), "/chapters/")[1]
|
||||
chapter.ID = strings.Replace(chapter.ID, "/", "$", -1)
|
||||
|
||||
chapter.Title = strings.TrimSpace(e.Text)
|
||||
|
||||
splitTitle := strings.Split(chapter.Title, "Chapter ")
|
||||
if len(splitTitle) < 2 {
|
||||
return
|
||||
}
|
||||
chapter.Chapter = splitTitle[1]
|
||||
|
||||
ret = append(ret, chapter)
|
||||
})
|
||||
|
||||
err = c.Visit(uri)
|
||||
if err != nil {
|
||||
mp.logger.Error().Err(err).Msg("mangapill: Failed to visit")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(ret) == 0 {
|
||||
mp.logger.Error().Str("mangaId", id).Msg("mangapill: No chapters found")
|
||||
return nil, ErrNoChapters
|
||||
}
|
||||
|
||||
slices.Reverse(ret)
|
||||
|
||||
for i, chapter := range ret {
|
||||
chapter.Index = uint(i)
|
||||
}
|
||||
|
||||
mp.logger.Info().Int("count", len(ret)).Msg("mangapill: Found chapters")
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func (mp *Mangapill) FindChapterPages(id string) (ret []*hibikemanga.ChapterPage, err error) {
|
||||
ret = make([]*hibikemanga.ChapterPage, 0)
|
||||
|
||||
mp.logger.Debug().Str("chapterId", id).Msg("mangapill: Finding chapter pages")
|
||||
|
||||
uriId := strings.Replace(id, "$", "/", -1)
|
||||
uri := fmt.Sprintf("%s/chapters/%s", mp.Url, uriId)
|
||||
|
||||
c := colly.NewCollector(
|
||||
colly.UserAgent(mp.UserAgent),
|
||||
)
|
||||
|
||||
c.WithTransport(mp.Client.Transport)
|
||||
|
||||
c.OnHTML("chapter-page", func(e *colly.HTMLElement) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
}
|
||||
}()
|
||||
page := &hibikemanga.ChapterPage{}
|
||||
|
||||
page.URL = e.DOM.Find("div picture img").AttrOr("data-src", "")
|
||||
if page.URL == "" {
|
||||
return
|
||||
}
|
||||
indexStr := e.DOM.Find("div[data-summary] > div").Text()
|
||||
index, _ := strconv.Atoi(strings.Split(strings.Split(indexStr, "page ")[1], "/")[0])
|
||||
page.Index = index - 1
|
||||
|
||||
page.Headers = map[string]string{
|
||||
"Referer": "https://mangapill.com/",
|
||||
}
|
||||
|
||||
ret = append(ret, page)
|
||||
})
|
||||
|
||||
err = c.Visit(uri)
|
||||
if err != nil {
|
||||
mp.logger.Error().Err(err).Msg("mangapill: Failed to visit")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(ret) == 0 {
|
||||
mp.logger.Error().Str("chapterId", id).Msg("mangapill: No pages found")
|
||||
return nil, ErrNoPages
|
||||
}
|
||||
|
||||
mp.logger.Info().Int("count", len(ret)).Msg("mangapill: Found pages")
|
||||
|
||||
return ret, nil
|
||||
|
||||
}
|
||||
128
seanime-2.9.10/internal/manga/providers/mangapill_test.go
Normal file
128
seanime-2.9.10/internal/manga/providers/mangapill_test.go
Normal file
@@ -0,0 +1,128 @@
|
||||
package manga_providers
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
hibikemanga "seanime/internal/extension/hibike/manga"
|
||||
"seanime/internal/util"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMangapill_Search(t *testing.T) {
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
query string
|
||||
}{
|
||||
{
|
||||
name: "Boku no Kokoro no Yabai Yatsu",
|
||||
query: "Boku no Kokoro no Yabai Yatsu",
|
||||
},
|
||||
}
|
||||
|
||||
provider := NewMangapill(util.NewLogger())
|
||||
|
||||
for _, tt := range tests {
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
|
||||
searchRes, err := provider.Search(hibikemanga.SearchOptions{
|
||||
Query: tt.query,
|
||||
})
|
||||
if assert.NoError(t, err, "provider.Search() error") {
|
||||
assert.NotEmpty(t, searchRes, "search result is empty")
|
||||
|
||||
for _, res := range searchRes {
|
||||
t.Logf("Title: %s", res.Title)
|
||||
t.Logf("\tID: %s", res.ID)
|
||||
t.Logf("\tYear: %d", res.Year)
|
||||
t.Logf("\tImage: %s", res.Image)
|
||||
t.Logf("\tProvider: %s", res.Provider)
|
||||
t.Logf("\tSearchRating: %f", res.SearchRating)
|
||||
t.Logf("\tSynonyms: %v", res.Synonyms)
|
||||
t.Log("--------------------------------------------------")
|
||||
}
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func TestMangapill_FindChapters(t *testing.T) {
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
id string
|
||||
atLeast int
|
||||
}{
|
||||
{
|
||||
name: "The Dangers in My Heart",
|
||||
id: "5232$boku-no-kokoro-no-yabai-yatsu",
|
||||
atLeast: 141,
|
||||
},
|
||||
}
|
||||
|
||||
provider := NewMangapill(util.NewLogger())
|
||||
|
||||
for _, tt := range tests {
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
|
||||
chapters, err := provider.FindChapters(tt.id)
|
||||
if assert.NoError(t, err, "provider.FindChapters() error") {
|
||||
|
||||
assert.NotEmpty(t, chapters, "chapters is empty")
|
||||
|
||||
assert.GreaterOrEqual(t, len(chapters), tt.atLeast, "chapters length is less than expected")
|
||||
|
||||
for _, chapter := range chapters {
|
||||
t.Logf("Title: %s", chapter.Title)
|
||||
t.Logf("\tSlug: %s", chapter.ID)
|
||||
t.Logf("\tURL: %s", chapter.URL)
|
||||
t.Logf("\tIndex: %d", chapter.Index)
|
||||
t.Logf("\tUpdatedAt: %s", chapter.UpdatedAt)
|
||||
t.Log("--------------------------------------------------")
|
||||
}
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func TestMangapill_FindChapterPages(t *testing.T) {
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
chapterId string
|
||||
}{
|
||||
{
|
||||
name: "The Dangers in My Heart",
|
||||
chapterId: "5232-10001000$boku-no-kokoro-no-yabai-yatsu-chapter-1", // Chapter 1
|
||||
},
|
||||
}
|
||||
|
||||
provider := NewMangapill(util.NewLogger())
|
||||
|
||||
for _, tt := range tests {
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
|
||||
pages, err := provider.FindChapterPages(tt.chapterId)
|
||||
if assert.NoError(t, err, "provider.FindChapterPages() error") {
|
||||
assert.NotEmpty(t, pages, "pages is empty")
|
||||
|
||||
for _, page := range pages {
|
||||
t.Logf("Index: %d", page.Index)
|
||||
t.Logf("\tURL: %s", page.URL)
|
||||
t.Log("--------------------------------------------------")
|
||||
}
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
19
seanime-2.9.10/internal/manga/providers/providers.go
Normal file
19
seanime-2.9.10/internal/manga/providers/providers.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package manga_providers
|
||||
|
||||
import "errors"
|
||||
|
||||
const (
|
||||
WeebCentralProvider = "weebcentral"
|
||||
MangadexProvider string = "mangadex"
|
||||
ComickProvider string = "comick"
|
||||
MangapillProvider string = "mangapill"
|
||||
ManganatoProvider string = "manganato"
|
||||
MangafireProvider string = "mangafire"
|
||||
LocalProvider string = "local-manga"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNoResults = errors.New("no results found")
|
||||
ErrNoChapters = errors.New("no chapters found")
|
||||
ErrNoPages = errors.New("no pages found")
|
||||
)
|
||||
8
seanime-2.9.10/internal/manga/providers/proxy_images.go
Normal file
8
seanime-2.9.10/internal/manga/providers/proxy_images.go
Normal file
@@ -0,0 +1,8 @@
|
||||
package manga_providers
|
||||
|
||||
import util "seanime/internal/util/proxies"
|
||||
|
||||
func GetImageByProxy(url string, headers map[string]string) ([]byte, error) {
|
||||
ip := &util.ImageProxy{}
|
||||
return ip.GetImage(url, headers)
|
||||
}
|
||||
309
seanime-2.9.10/internal/manga/providers/weebcentral.go
Normal file
309
seanime-2.9.10/internal/manga/providers/weebcentral.go
Normal file
@@ -0,0 +1,309 @@
|
||||
package manga_providers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"regexp"
|
||||
hibikemanga "seanime/internal/extension/hibike/manga"
|
||||
"seanime/internal/util"
|
||||
"seanime/internal/util/comparison"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
"github.com/imroc/req/v3"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
// WeebCentral implements the manga provider for WeebCentral
|
||||
// It uses goquery to scrape search results, chapter lists, and chapter pages.
|
||||
|
||||
type WeebCentral struct {
|
||||
Url string
|
||||
UserAgent string
|
||||
Client *req.Client
|
||||
logger *zerolog.Logger
|
||||
}
|
||||
|
||||
// NewWeebCentral initializes and returns a new WeebCentral provider instance.
|
||||
func NewWeebCentral(logger *zerolog.Logger) *WeebCentral {
|
||||
client := req.C().
|
||||
SetUserAgent(util.GetRandomUserAgent()).
|
||||
SetTimeout(60 * time.Second).
|
||||
EnableInsecureSkipVerify().
|
||||
ImpersonateChrome()
|
||||
|
||||
return &WeebCentral{
|
||||
Url: "https://weebcentral.com",
|
||||
UserAgent: util.GetRandomUserAgent(),
|
||||
Client: client,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
func (w *WeebCentral) GetSettings() hibikemanga.Settings {
|
||||
return hibikemanga.Settings{
|
||||
SupportsMultiScanlator: false,
|
||||
SupportsMultiLanguage: false,
|
||||
}
|
||||
}
|
||||
|
||||
func (w *WeebCentral) Search(opts hibikemanga.SearchOptions) ([]*hibikemanga.SearchResult, error) {
|
||||
w.logger.Debug().Str("query", opts.Query).Msg("weebcentral: Searching manga")
|
||||
|
||||
searchUrl := fmt.Sprintf("%s/search/simple?location=main", w.Url)
|
||||
form := url.Values{}
|
||||
form.Set("text", opts.Query)
|
||||
|
||||
resp, err := w.Client.R().
|
||||
SetContentType("application/x-www-form-urlencoded").
|
||||
SetHeader("HX-Request", "true").
|
||||
SetHeader("HX-Trigger", "quick-search-input").
|
||||
SetHeader("HX-Trigger-Name", "text").
|
||||
SetHeader("HX-Target", "quick-search-result").
|
||||
SetHeader("HX-Current-URL", w.Url+"/").
|
||||
SetBody(form.Encode()).
|
||||
Post(searchUrl)
|
||||
|
||||
if err != nil {
|
||||
w.logger.Error().Err(err).Msg("weebcentral: Failed to send search request")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !resp.IsSuccessState() {
|
||||
w.logger.Error().Str("status", resp.Status).Msg("weebcentral: Search request failed")
|
||||
return nil, fmt.Errorf("search request failed: status %s", resp.Status)
|
||||
}
|
||||
|
||||
body := resp.String()
|
||||
|
||||
doc, err := goquery.NewDocumentFromReader(strings.NewReader(body))
|
||||
if err != nil {
|
||||
w.logger.Error().Err(err).Msg("weebcentral: Failed to parse search HTML")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var searchResults []*hibikemanga.SearchResult
|
||||
doc.Find("#quick-search-result > div > a").Each(func(i int, s *goquery.Selection) {
|
||||
link, exists := s.Attr("href")
|
||||
if !exists {
|
||||
return
|
||||
}
|
||||
title := strings.TrimSpace(s.Find(".flex-1").Text())
|
||||
|
||||
var image string
|
||||
if s.Find("source").Length() > 0 {
|
||||
image, _ = s.Find("source").Attr("srcset")
|
||||
} else if s.Find("img").Length() > 0 {
|
||||
image, _ = s.Find("img").Attr("src")
|
||||
}
|
||||
|
||||
// Extract manga id from link assuming the format contains '/series/{id}/'
|
||||
idPart := ""
|
||||
parts := strings.Split(link, "/series/")
|
||||
if len(parts) > 1 {
|
||||
subparts := strings.Split(parts[1], "/")
|
||||
idPart = subparts[0]
|
||||
}
|
||||
if idPart == "" {
|
||||
return
|
||||
}
|
||||
|
||||
titleCopy := title
|
||||
titles := []*string{&titleCopy}
|
||||
compRes, ok := comparison.FindBestMatchWithSorensenDice(&opts.Query, titles)
|
||||
if !ok || compRes.Rating < 0.6 {
|
||||
return
|
||||
}
|
||||
|
||||
searchResults = append(searchResults, &hibikemanga.SearchResult{
|
||||
ID: idPart,
|
||||
Title: title,
|
||||
Synonyms: []string{},
|
||||
Year: 0,
|
||||
Image: image,
|
||||
Provider: WeebCentralProvider,
|
||||
SearchRating: compRes.Rating,
|
||||
})
|
||||
})
|
||||
|
||||
if len(searchResults) == 0 {
|
||||
w.logger.Error().Msg("weebcentral: No search results found")
|
||||
return nil, errors.New("no results found")
|
||||
}
|
||||
|
||||
w.logger.Info().Int("count", len(searchResults)).Msg("weebcentral: Found search results")
|
||||
return searchResults, nil
|
||||
}
|
||||
|
||||
func (w *WeebCentral) FindChapters(mangaId string) ([]*hibikemanga.ChapterDetails, error) {
|
||||
w.logger.Debug().Str("mangaId", mangaId).Msg("weebcentral: Fetching chapters")
|
||||
|
||||
chapterUrl := fmt.Sprintf("%s/series/%s/full-chapter-list", w.Url, mangaId)
|
||||
|
||||
resp, err := w.Client.R().
|
||||
SetHeader("HX-Request", "true").
|
||||
SetHeader("HX-Target", "chapter-list").
|
||||
SetHeader("HX-Current-URL", fmt.Sprintf("%s/series/%s", w.Url, mangaId)).
|
||||
SetHeader("Referer", fmt.Sprintf("%s/series/%s", w.Url, mangaId)).
|
||||
Get(chapterUrl)
|
||||
|
||||
if err != nil {
|
||||
w.logger.Error().Err(err).Msg("weebcentral: Failed to fetch chapter list")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !resp.IsSuccessState() {
|
||||
w.logger.Error().Str("status", resp.Status).Msg("weebcentral: Chapter list request failed")
|
||||
return nil, fmt.Errorf("chapter list request failed: status %s", resp.Status)
|
||||
}
|
||||
|
||||
body := resp.String()
|
||||
doc, err := goquery.NewDocumentFromReader(strings.NewReader(body))
|
||||
if err != nil {
|
||||
w.logger.Error().Err(err).Msg("weebcentral: Failed to parse chapter list HTML")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var chapters []*hibikemanga.ChapterDetails
|
||||
volumeCounter := 1
|
||||
lastChapterNumber := 9999.0
|
||||
|
||||
chapterRegex := regexp.MustCompile("(\\d+(?:\\.\\d+)?)")
|
||||
|
||||
doc.Find("div.flex.items-center").Each(func(i int, s *goquery.Selection) {
|
||||
a := s.Find("a")
|
||||
chapterUrl, exists := a.Attr("href")
|
||||
if !exists {
|
||||
return
|
||||
}
|
||||
chapterTitle := strings.TrimSpace(a.Find("span.grow > span").First().Text())
|
||||
|
||||
var chapterNumber string
|
||||
var parsedChapterNumber float64
|
||||
|
||||
match := chapterRegex.FindStringSubmatch(chapterTitle)
|
||||
if len(match) > 1 {
|
||||
chapterNumber = w.cleanChapterNumber(match[1])
|
||||
if num, err := strconv.ParseFloat(chapterNumber, 64); err == nil {
|
||||
parsedChapterNumber = num
|
||||
}
|
||||
} else {
|
||||
chapterNumber = ""
|
||||
}
|
||||
|
||||
if parsedChapterNumber > lastChapterNumber {
|
||||
volumeCounter++
|
||||
}
|
||||
if parsedChapterNumber != 0 {
|
||||
lastChapterNumber = parsedChapterNumber
|
||||
}
|
||||
|
||||
// Extract chapter id from the URL assuming format contains '/chapters/{id}'
|
||||
chapterId := ""
|
||||
parts := strings.Split(chapterUrl, "/chapters/")
|
||||
if len(parts) > 1 {
|
||||
chapterId = parts[1]
|
||||
}
|
||||
|
||||
chapters = append(chapters, &hibikemanga.ChapterDetails{
|
||||
ID: chapterId,
|
||||
URL: chapterUrl,
|
||||
Title: chapterTitle,
|
||||
Chapter: chapterNumber,
|
||||
Index: uint(i),
|
||||
Provider: WeebCentralProvider,
|
||||
})
|
||||
})
|
||||
|
||||
if len(chapters) == 0 {
|
||||
w.logger.Error().Msg("weebcentral: No chapters found")
|
||||
return nil, errors.New("no chapters found")
|
||||
}
|
||||
|
||||
slices.Reverse(chapters)
|
||||
|
||||
for i := range chapters {
|
||||
chapters[i].Index = uint(i)
|
||||
}
|
||||
|
||||
w.logger.Info().Int("count", len(chapters)).Msg("weebcentral: Found chapters")
|
||||
return chapters, nil
|
||||
}
|
||||
|
||||
func (w *WeebCentral) FindChapterPages(chapterId string) ([]*hibikemanga.ChapterPage, error) {
|
||||
url := fmt.Sprintf("%s/chapters/%s/images?is_prev=False&reading_style=long_strip", w.Url, chapterId)
|
||||
|
||||
resp, err := w.Client.R().
|
||||
SetHeader("HX-Request", "true").
|
||||
SetHeader("HX-Current-URL", fmt.Sprintf("%s/chapters/%s", w.Url, chapterId)).
|
||||
SetHeader("Referer", fmt.Sprintf("%s/chapters/%s", w.Url, chapterId)).
|
||||
Get(url)
|
||||
|
||||
if err != nil {
|
||||
w.logger.Error().Err(err).Msg("weebcentral: Failed to fetch chapter pages")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !resp.IsSuccessState() {
|
||||
w.logger.Error().Str("status", resp.Status).Msg("weebcentral: Chapter pages request failed")
|
||||
return nil, fmt.Errorf("chapter pages request failed: status %s", resp.Status)
|
||||
}
|
||||
|
||||
body := resp.String()
|
||||
doc, err := goquery.NewDocumentFromReader(strings.NewReader(body))
|
||||
if err != nil {
|
||||
w.logger.Error().Err(err).Msg("weebcentral: Failed to parse chapter pages HTML")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var pages []*hibikemanga.ChapterPage
|
||||
totalImgs := doc.Find("img").Length()
|
||||
|
||||
doc.Find("section.flex-1 img").Each(func(i int, s *goquery.Selection) {
|
||||
imageUrl, exists := s.Attr("src")
|
||||
if !exists || imageUrl == "" {
|
||||
return
|
||||
}
|
||||
pages = append(pages, &hibikemanga.ChapterPage{
|
||||
URL: imageUrl,
|
||||
Index: i,
|
||||
Headers: map[string]string{"Referer": w.Url},
|
||||
Provider: WeebCentralProvider,
|
||||
})
|
||||
})
|
||||
|
||||
if len(pages) == 0 && totalImgs > 0 {
|
||||
doc.Find("img").Each(func(i int, s *goquery.Selection) {
|
||||
imageUrl, exists := s.Attr("src")
|
||||
if !exists || imageUrl == "" {
|
||||
return
|
||||
}
|
||||
pages = append(pages, &hibikemanga.ChapterPage{
|
||||
URL: imageUrl,
|
||||
Index: i,
|
||||
Headers: map[string]string{"Referer": w.Url},
|
||||
Provider: WeebCentralProvider,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
if len(pages) == 0 {
|
||||
w.logger.Error().Msg("weebcentral: No pages found")
|
||||
return nil, errors.New("no pages found")
|
||||
}
|
||||
|
||||
w.logger.Info().Int("count", len(pages)).Msg("weebcentral: Found chapter pages")
|
||||
return pages, nil
|
||||
}
|
||||
|
||||
func (w *WeebCentral) cleanChapterNumber(chapterStr string) string {
|
||||
cleaned := strings.TrimLeft(chapterStr, "0")
|
||||
if cleaned == "" {
|
||||
return "0"
|
||||
}
|
||||
return cleaned
|
||||
}
|
||||
162
seanime-2.9.10/internal/manga/providers/weebcentral_test.go
Normal file
162
seanime-2.9.10/internal/manga/providers/weebcentral_test.go
Normal file
@@ -0,0 +1,162 @@
|
||||
package manga_providers
|
||||
|
||||
import (
|
||||
"seanime/internal/util"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
hibikemanga "seanime/internal/extension/hibike/manga"
|
||||
)
|
||||
|
||||
func TestWeebCentral_Search(t *testing.T) {
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
query string
|
||||
}{
|
||||
{
|
||||
name: "One Piece",
|
||||
query: "One Piece",
|
||||
},
|
||||
{
|
||||
name: "Jujutsu Kaisen",
|
||||
query: "Jujutsu Kaisen",
|
||||
},
|
||||
}
|
||||
|
||||
weebcentral := NewWeebCentral(util.NewLogger())
|
||||
|
||||
for _, tt := range tests {
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
|
||||
searchRes, err := weebcentral.Search(hibikemanga.SearchOptions{
|
||||
Query: tt.query,
|
||||
})
|
||||
if assert.NoError(t, err, "weebcentral.Search() error") {
|
||||
assert.NotEmpty(t, searchRes, "search result is empty")
|
||||
|
||||
for _, res := range searchRes {
|
||||
t.Logf("Title: %s", res.Title)
|
||||
t.Logf("\tID: %s", res.ID)
|
||||
t.Logf("\tYear: %d", res.Year)
|
||||
t.Logf("\tImage: %s", res.Image)
|
||||
t.Logf("\tProvider: %s", res.Provider)
|
||||
t.Logf("\tSearchRating: %f", res.SearchRating)
|
||||
t.Logf("\tSynonyms: %v", res.Synonyms)
|
||||
t.Log("--------------------------------------------------")
|
||||
}
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func TestWeebCentral_FindChapters(t *testing.T) {
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
id string
|
||||
atLeast int
|
||||
}{
|
||||
{
|
||||
name: "One Piece",
|
||||
id: "01J76XY7E9FNDZ1DBBM6PBJPFK",
|
||||
atLeast: 1100,
|
||||
},
|
||||
{
|
||||
name: "Jujutsu Kaisen",
|
||||
id: "01J76XYCERXE60T7FKXVCCAQ0H",
|
||||
atLeast: 250,
|
||||
},
|
||||
}
|
||||
|
||||
weebcentral := NewWeebCentral(util.NewLogger())
|
||||
|
||||
for _, tt := range tests {
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
|
||||
chapters, err := weebcentral.FindChapters(tt.id)
|
||||
if assert.NoError(t, err, "weebcentral.FindChapters() error") {
|
||||
|
||||
assert.NotEmpty(t, chapters, "chapters is empty")
|
||||
|
||||
assert.GreaterOrEqual(t, len(chapters), tt.atLeast, "chapters length is less than expected")
|
||||
|
||||
for _, chapter := range chapters {
|
||||
t.Logf("Title: %s", chapter.Title)
|
||||
t.Logf("\tSlug: %s", chapter.ID)
|
||||
t.Logf("\tURL: %s", chapter.URL)
|
||||
t.Logf("\tIndex: %d", chapter.Index)
|
||||
t.Logf("\tChapter: %s", chapter.Chapter)
|
||||
t.Logf("\tUpdatedAt: %s", chapter.UpdatedAt)
|
||||
t.Log("--------------------------------------------------")
|
||||
}
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func TestWeebCentral_FindChapterPages(t *testing.T) {
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
id string
|
||||
index uint
|
||||
}{
|
||||
{
|
||||
name: "One Piece",
|
||||
id: "01J76XY7E9FNDZ1DBBM6PBJPFK",
|
||||
index: 1110,
|
||||
},
|
||||
{
|
||||
name: "Jujutsu Kaisen",
|
||||
id: "01J76XYCERXE60T7FKXVCCAQ0H",
|
||||
index: 0,
|
||||
},
|
||||
}
|
||||
|
||||
weebcentral := NewWeebCentral(util.NewLogger())
|
||||
|
||||
for _, tt := range tests {
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
|
||||
chapters, err := weebcentral.FindChapters(tt.id)
|
||||
if assert.NoError(t, err, "weebcentral.FindChapters() error") {
|
||||
|
||||
assert.NotEmpty(t, chapters, "chapters is empty")
|
||||
|
||||
var chapterInfo *hibikemanga.ChapterDetails
|
||||
for _, chapter := range chapters {
|
||||
if chapter.Index == tt.index {
|
||||
chapterInfo = chapter
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if assert.NotNil(t, chapterInfo, "chapter not found") {
|
||||
pages, err := weebcentral.FindChapterPages(chapterInfo.ID)
|
||||
if assert.NoError(t, err, "weebcentral.FindChapterPages() error") {
|
||||
assert.NotEmpty(t, pages, "pages is empty")
|
||||
|
||||
for _, page := range pages {
|
||||
t.Logf("Index: %d", page.Index)
|
||||
t.Logf("\tURL: %s", page.URL)
|
||||
t.Log("--------------------------------------------------")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user