// Package kemono implements the Kemono Party base. // GET {base}/api/v1/creators → creator list // GET {base}/api/v1/{service}/{creator}/posts?o={offset} → paginated posts package kemono import ( "context" "encoding/json" "fmt" "io" "net/http" "strings" "goyomi/internal/httpclient" "goyomi/internal/source" "goyomi/sources/base/util" ) type Config struct { Name string BaseURL string Lang string } type creatorDTO struct { ID string `json:"id"` Name string `json:"name"` Service string `json:"service"` Icon string `json:"icon"` } type postDTO struct { ID string `json:"id"` Title string `json:"title"` User string `json:"user"` Service string `json:"service"` Added string `json:"added"` Attachments []struct { Name string `json:"name"` Path string `json:"path"` } `json:"attachments"` File struct { Name string `json:"name"` Path string `json:"path"` } `json:"file"` } type Source struct { cfg Config client *httpclient.Client id int64 } func New(cfg Config) *Source { 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) getJSON(ctx context.Context, rawURL string, out any) error { req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) if err != nil { return err } resp, err := s.client.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return fmt.Errorf("kemono: HTTP %d for %s", resp.StatusCode, rawURL) } body, err := io.ReadAll(resp.Body) if err != nil { return err } return json.Unmarshal(body, out) } func (s *Source) base() string { return strings.TrimRight(s.cfg.BaseURL, "/") } func (s *Source) creatorToSManga(c creatorDTO) source.SManga { icon := c.Icon if icon != "" && !strings.HasPrefix(icon, "http") { icon = s.base() + "/data" + icon } return source.SManga{ URL: fmt.Sprintf("/%s/user/%s", c.Service, c.ID), Title: c.Name, ThumbnailURL: icon, } } func (s *Source) GetPopularManga(page int) (source.MangasPage, error) { var creators []creatorDTO if err := s.getJSON(context.Background(), s.base()+"/api/v1/creators", &creators); err != nil { return source.MangasPage{}, err } // page 1 returns all; no actual pagination mangas := make([]source.SManga, 0, len(creators)) for _, c := range creators { mangas = append(mangas, s.creatorToSManga(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) { mp, err := s.GetPopularManga(1) if err != nil { return source.MangasPage{}, err } // Client-side filter q := strings.ToLower(query) var matched []source.SManga for _, m := range mp.Mangas { if strings.Contains(strings.ToLower(m.Title), q) { matched = append(matched, m) } } return source.MangasPage{Mangas: matched, HasNextPage: false}, nil } func (s *Source) GetMangaDetails(manga source.SManga) (source.SManga, error) { return manga, nil } func (s *Source) GetChapterList(manga source.SManga) ([]source.SChapter, error) { // URL: /{service}/user/{id} parts := strings.Split(strings.Trim(manga.URL, "/"), "/") if len(parts) < 3 { return nil, fmt.Errorf("kemono: invalid manga URL %s", manga.URL) } service := parts[0] creatorID := parts[2] var all []source.SChapter offset := 0 const limit = 50 for { apiURL := fmt.Sprintf("%s/api/v1/%s/user/%s/posts?o=%d", s.base(), service, creatorID, offset) var posts []postDTO if err := s.getJSON(context.Background(), apiURL, &posts); err != nil { return nil, err } for _, p := range posts { all = append(all, source.SChapter{ URL: fmt.Sprintf("/%s/user/%s/post/%s", service, creatorID, p.ID), Name: p.Title, DateUpload: util.ParseAbsoluteDate(p.Added, "2006-01-02 15:04:05"), }) } if len(posts) < limit { break } offset += limit } return all, nil } func (s *Source) GetPageList(chapter source.SChapter) ([]source.Page, error) { // URL: /{service}/user/{creatorID}/post/{postID} parts := strings.Split(strings.Trim(chapter.URL, "/"), "/") if len(parts) < 5 { return nil, fmt.Errorf("kemono: invalid chapter URL %s", chapter.URL) } service := parts[0] creatorID := parts[2] postID := parts[4] apiURL := fmt.Sprintf("%s/api/v1/%s/user/%s/post/%s", s.base(), service, creatorID, postID) var post postDTO if err := s.getJSON(context.Background(), apiURL, &post); err != nil { return nil, err } var pages []source.Page idx := 0 if post.File.Path != "" { imgURL := post.File.Path if !strings.HasPrefix(imgURL, "http") { imgURL = s.base() + "/data" + imgURL } pages = append(pages, source.Page{Index: idx, ImageURL: imgURL}) idx++ } for _, att := range post.Attachments { imgURL := att.Path if !strings.HasPrefix(imgURL, "http") { imgURL = s.base() + "/data" + imgURL } pages = append(pages, source.Page{Index: idx, ImageURL: imgURL}) idx++ } return pages, nil } func (s *Source) GetImageURL(page source.Page) (string, error) { return page.ImageURL, nil } func (s *Source) GetFilterList() []source.Filter { return nil }