// Package pizzareader implements the PizzaReader manga base. // JSON REST API: GET {api}/comics for popular; GET {api}{chapter.url} for pages. package pizzareader import ( "context" "encoding/json" "fmt" "io" "net/http" "sort" "strings" "goyomi/internal/httpclient" "goyomi/internal/source" "goyomi/sources/base/util" ) type Config struct { Name string BaseURL string Lang string APIPath string // defaults to "/api" } type Source struct { cfg Config client *httpclient.Client id int64 } func New(cfg Config) *Source { if cfg.APIPath == "" { cfg.APIPath = "/api" } c := httpclient.NewClient(httpclient.WithRateLimit(1, 2)) 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) apiBase() string { return strings.TrimRight(s.cfg.BaseURL, "/") + s.cfg.APIPath } func (s *Source) getJSON(ctx context.Context, rawURL string, out any) error { req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) if err != nil { return err } req.Header.Set("Accept", "application/json") req.Header.Set("Referer", s.cfg.BaseURL+"/") resp, err := s.client.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return fmt.Errorf("pizzareader: HTTP %d", resp.StatusCode) } body, _ := io.ReadAll(resp.Body) return json.Unmarshal(body, out) } type pizzaComicDTO struct { Slug string `json:"slug"` Title string `json:"title"` Author string `json:"author"` Artist string `json:"artist"` Description string `json:"description"` Thumbnail string `json:"thumbnail"` Status string `json:"status"` Genres []struct{ Name string `json:"name"` } `json:"genres"` Chapters []pizzaChapterDTO `json:"chapters"` LastChapter *pizzaChapterDTO `json:"last_chapter"` } type pizzaChapterDTO struct { Slug string `json:"slug"` Chapter *int `json:"chapter"` Subchapter *int `json:"subchapter"` Title string `json:"title"` PublishedOn string `json:"published_on"` ComicSlug string `json:"comic_slug"` } type pizzaResultDTO struct { Comic *pizzaComicDTO `json:"comic"` Comics []pizzaComicDTO `json:"comics"` } type pizzaReaderDTO struct { Chapter *struct { Pages []string `json:"pages"` } `json:"chapter"` } func (s *Source) toSManga(c pizzaComicDTO) source.SManga { genres := make([]string, len(c.Genres)) for i, g := range c.Genres { genres[i] = g.Name } return source.SManga{ URL: "/comics/" + c.Slug, Title: c.Title, Author: c.Author, Artist: c.Artist, Description: c.Description, Genre: strings.Join(genres, ", "), Status: util.StatusFromString(c.Status), ThumbnailURL: c.Thumbnail, } } func (s *Source) GetPopularManga(page int) (source.MangasPage, error) { var result pizzaResultDTO if err := s.getJSON(context.Background(), s.apiBase()+"/comics", &result); err != nil { return source.MangasPage{}, err } // sort by last_chapter published_on descending, take all comics := result.Comics sort.Slice(comics, func(i, j int) bool { di := "" dj := "" if comics[i].LastChapter != nil { di = comics[i].LastChapter.PublishedOn } if comics[j].LastChapter != nil { dj = comics[j].LastChapter.PublishedOn } return di > dj }) mangas := make([]source.SManga, len(comics)) for i, c := range comics { mangas[i] = s.toSManga(c) } return source.MangasPage{Mangas: mangas, HasNextPage: false}, nil } func (s *Source) GetLatestUpdates(page int) (source.MangasPage, error) { return s.GetPopularManga(page) } func (s *Source) GetSearchManga(page int, query string, filters []source.Filter) (source.MangasPage, error) { var result pizzaResultDTO u := fmt.Sprintf("%s/search/%s", s.apiBase(), query) if err := s.getJSON(context.Background(), u, &result); err != nil { return source.MangasPage{}, err } mangas := make([]source.SManga, len(result.Comics)) for i, c := range result.Comics { mangas[i] = s.toSManga(c) } return source.MangasPage{Mangas: mangas, HasNextPage: false}, nil } func (s *Source) GetMangaDetails(manga source.SManga) (source.SManga, error) { var result pizzaResultDTO if err := s.getJSON(context.Background(), s.apiBase()+manga.URL, &result); err != nil { return manga, err } if result.Comic == nil { return manga, fmt.Errorf("pizzareader: no comic in response") } return s.toSManga(*result.Comic), nil } func (s *Source) GetChapterList(manga source.SManga) ([]source.SChapter, error) { var result pizzaResultDTO if err := s.getJSON(context.Background(), s.apiBase()+manga.URL, &result); err != nil { return nil, err } if result.Comic == nil { return nil, fmt.Errorf("pizzareader: no comic in response") } chapters := make([]source.SChapter, len(result.Comic.Chapters)) for i, ch := range result.Comic.Chapters { num := -1 if ch.Chapter != nil { num = *ch.Chapter } sub := 0 if ch.Subchapter != nil { sub = *ch.Subchapter } name := fmt.Sprintf("Chapter %d", num) if sub > 0 { name += fmt.Sprintf(".%d", sub) } if ch.Title != "" { name += " - " + ch.Title } comicSlug := ch.ComicSlug if comicSlug == "" { comicSlug = util.SlugFromURL(manga.URL) } chapters[i] = source.SChapter{ URL: fmt.Sprintf("/comics/%s/%s", comicSlug, ch.Slug), Name: name, DateUpload: util.ParseAbsoluteDate(ch.PublishedOn, "2006-01-02T15:04:05.999999"), } } return chapters, nil } func (s *Source) GetPageList(chapter source.SChapter) ([]source.Page, error) { var result pizzaReaderDTO if err := s.getJSON(context.Background(), s.apiBase()+chapter.URL, &result); err != nil { return nil, err } if result.Chapter == nil { return nil, fmt.Errorf("pizzareader: no chapter in response") } pages := make([]source.Page, len(result.Chapter.Pages)) for i, p := range result.Chapter.Pages { pages[i] = source.Page{Index: i, ImageURL: p} } return pages, nil } func (s *Source) GetImageURL(page source.Page) (string, error) { return page.ImageURL, nil } func (s *Source) GetFilterList() []source.Filter { return nil }