// Package lectormoe implements the LectorMoe manga base. // JSON REST via capibaratraductor.com API; x-organization header required. package lectormoe import ( "context" "encoding/json" "fmt" "io" "net/http" "strings" "goyomi/internal/httpclient/flare" "goyomi/internal/source" "goyomi/sources/base/util" ) const pageLimit = 36 type Config struct { Name string BaseURL string Lang string OrganizationDomain string // defaults to last segment of BaseURL APIBaseURL string // defaults to "https://capibaratraductor.com" } type Source struct { cfg Config client *flare.Client id int64 } func New(cfg Config) *Source { if cfg.APIBaseURL == "" { cfg.APIBaseURL = "https://capibaratraductor.com" } if cfg.OrganizationDomain == "" { cfg.OrganizationDomain = util.SlugFromURL(cfg.BaseURL) } c := flare.NewClient(flare.WithRateLimit(3, 1)) return &Source{cfg: cfg, client: c, id: source.GenerateSourceID(cfg.Name, cfg.Lang)} } func (s *Source) ID() int64 { return s.id } func (s *Source) Name() string { return s.cfg.Name } func (s *Source) Lang() string { return s.cfg.Lang } func (s *Source) SupportsLatest() bool { return true } func (s *Source) api() string { return strings.TrimRight(s.cfg.APIBaseURL, "/") } func (s *Source) getJSON(ctx context.Context, rawURL string, out any) error { resp, err := s.client.Get(ctx, rawURL) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return fmt.Errorf("lectormoe: HTTP %d", resp.StatusCode) } body, _ := io.ReadAll(resp.Body) return json.Unmarshal(body, out) } type listDTO struct { Data []struct { Slug string `json:"slug"` Name string `json:"name"` Cover string `json:"cover"` Synopsis string `json:"synopsis"` Author string `json:"author"` Status string `json:"status"` Genres []struct{ Name string `json:"name"` } `json:"genres"` Chapters []chapterDTO `json:"chapters"` } `json:"data"` Meta struct { Total int `json:"total"` CurrentPage int `json:"current_page"` LastPage int `json:"last_page"` } `json:"meta"` } type chapterDTO struct { Slug string `json:"slug"` SeriesSlug string `json:"series_slug"` Name string `json:"name"` Number float64 `json:"number"` PublishedAt string `json:"published_at"` IsUnreleased bool `json:"is_unreleased"` } func (s *Source) fetchList(ctx context.Context, page int, order, q string) (source.MangasPage, error) { u := fmt.Sprintf("%s/api/manga-custom?page=%d&limit=%d&order=%s", s.api(), page, pageLimit, order) if q != "" { u += "&search=" + q } var result listDTO if err := s.getJSON(ctx, u, &result); err != nil { return source.MangasPage{}, err } mangas := make([]source.SManga, len(result.Data)) for i, m := range result.Data { mangas[i] = source.SManga{ URL: m.Slug, Title: m.Name, ThumbnailURL: m.Cover, } } hasNext := result.Meta.CurrentPage < result.Meta.LastPage return source.MangasPage{Mangas: mangas, HasNextPage: hasNext}, nil } func (s *Source) GetPopularManga(page int) (source.MangasPage, error) { return s.fetchList(context.Background(), page, "popular", "") } func (s *Source) GetLatestUpdates(page int) (source.MangasPage, error) { return s.fetchList(context.Background(), page, "latest", "") } func (s *Source) GetSearchManga(page int, query string, filters []source.Filter) (source.MangasPage, error) { return s.fetchList(context.Background(), page, "popular", query) } func (s *Source) GetMangaDetails(manga source.SManga) (source.SManga, error) { var result struct { Data struct { Slug string `json:"slug"` Name string `json:"name"` Cover string `json:"cover"` Synopsis string `json:"synopsis"` Author string `json:"author"` Status string `json:"status"` Genres []struct{ Name string `json:"name"` } `json:"genres"` } `json:"data"` } if err := s.getJSON(context.Background(), fmt.Sprintf("%s/api/manga-custom/%s", s.api(), manga.URL), &result); err != nil { return manga, err } genres := make([]string, len(result.Data.Genres)) for i, g := range result.Data.Genres { genres[i] = g.Name } return source.SManga{ URL: manga.URL, Title: result.Data.Name, Author: result.Data.Author, Description: result.Data.Synopsis, Genre: strings.Join(genres, ", "), Status: util.StatusFromString(result.Data.Status), ThumbnailURL: result.Data.Cover, }, nil } func (s *Source) GetChapterList(manga source.SManga) ([]source.SChapter, error) { var result struct { Data struct { Slug string `json:"slug"` Chapters []chapterDTO `json:"chapters"` } `json:"data"` } if err := s.getJSON(context.Background(), fmt.Sprintf("%s/api/manga-custom/%s", s.api(), manga.URL), &result); err != nil { return nil, err } var chapters []source.SChapter for _, ch := range result.Data.Chapters { if ch.IsUnreleased { continue } seriesSlug := ch.SeriesSlug if seriesSlug == "" { seriesSlug = manga.URL } name := ch.Name if name == "" { name = fmt.Sprintf("Chapter %.1f", ch.Number) } chapters = append(chapters, source.SChapter{ URL: seriesSlug + "/" + ch.Slug, Name: name, DateUpload: util.ParseAbsoluteDate(ch.PublishedAt, "2006-01-02T15:04:05Z"), }) } return chapters, nil } func (s *Source) GetPageList(chapter source.SChapter) ([]source.Page, error) { seriesSlug := strings.SplitN(chapter.URL, "/", 2)[0] chSlug := "" if parts := strings.SplitN(chapter.URL, "/", 2); len(parts) == 2 { chSlug = parts[1] } var result struct { Data []struct { ImageURL string `json:"image_url"` } `json:"data"` } u := fmt.Sprintf("%s/api/manga-custom/%s/chapter/%s/pages", s.api(), seriesSlug, chSlug) if err := s.getJSON(context.Background(), u, &result); err != nil { return nil, err } pages := make([]source.Page, len(result.Data)) for i, p := range result.Data { pages[i] = source.Page{Index: i, ImageURL: p.ImageURL} } return pages, nil } func (s *Source) GetImageURL(page source.Page) (string, error) { return page.ImageURL, nil } func (s *Source) GetFilterList() []source.Filter { return nil }