// Package guya implements the Guya/Manga4Life reader base. // GET {base}/api/get_all_series/ returns all manga as a JSON map. package guya import ( "context" "encoding/json" "fmt" "io" "net/http" "strings" "goyomi/internal/httpclient" "goyomi/internal/source" ) type Config struct { Name string BaseURL string Lang string } // seriesListEntry is the per-entry format in GET /api/get_all_series/ // The outer JSON keys are the manga titles. type seriesListEntry struct { Author string `json:"author"` Artist string `json:"artist"` Description string `json:"description"` Slug string `json:"slug"` Cover string `json:"cover"` Groups map[string]string `json:"groups"` LastUpdated int64 `json:"last_updated"` } // seriesDetail is the format returned by GET /api/series/{slug}/ type seriesDetail struct { Title string `json:"title"` Author string `json:"author"` Artist string `json:"artist"` Description string `json:"description"` Slug string `json:"slug"` Cover string `json:"cover"` Groups map[string]string `json:"groups"` Chapters map[string]chapterEntry `json:"chapters"` LastUpdated int64 `json:"last_updated"` } type chapterEntry struct { Volume string `json:"volume"` Title string `json:"title"` Folder string `json:"folder"` Groups map[string][]string `json:"groups"` ReleaseDate map[string]int64 `json:"release_date"` } 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) base() string { return strings.TrimRight(s.cfg.BaseURL, "/") } func (s *Source) getAllSeries(ctx context.Context) (map[string]seriesListEntry, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, s.base()+"/api/get_all_series/", nil) if err != nil { return nil, err } resp, err := s.client.Do(req) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("guya: HTTP %d", resp.StatusCode) } body, err := io.ReadAll(resp.Body) if err != nil { return nil, err } var result map[string]seriesListEntry if err := json.Unmarshal(body, &result); err != nil { return nil, err } return result, nil } func (s *Source) toSManga(title string, entry seriesListEntry) source.SManga { thumb := "" if entry.Cover != "" { thumb = entry.Cover if !strings.HasPrefix(thumb, "http") { thumb = s.base() + "/" + thumb } } return source.SManga{ URL: fmt.Sprintf("/reader/series/%s/", entry.Slug), Title: title, Artist: entry.Artist, Author: entry.Author, Description: entry.Description, ThumbnailURL: thumb, } } func (s *Source) GetPopularManga(page int) (source.MangasPage, error) { if page > 1 { return source.MangasPage{}, nil } series, err := s.getAllSeries(context.Background()) if err != nil { return source.MangasPage{}, err } var mangas []source.SManga for title, entry := range series { mangas = append(mangas, s.toSManga(title, entry)) } 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 } 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) { slug := strings.Trim(strings.TrimPrefix(manga.URL, "/reader/series/"), "/") apiURL := fmt.Sprintf("%s/api/series/%s/", s.base(), slug) req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, apiURL, nil) if err != nil { return manga, err } resp, err := s.client.Do(req) if err != nil { return manga, err } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) var detail seriesDetail if err := json.Unmarshal(body, &detail); err != nil { return manga, err } result := source.SManga{ URL: manga.URL, Title: detail.Title, Artist: detail.Artist, Author: detail.Author, Description: detail.Description, ThumbnailURL: detail.Cover, } if result.ThumbnailURL != "" && !strings.HasPrefix(result.ThumbnailURL, "http") { result.ThumbnailURL = s.base() + "/" + result.ThumbnailURL } return result, nil } func (s *Source) GetChapterList(manga source.SManga) ([]source.SChapter, error) { slug := strings.Trim(strings.TrimPrefix(manga.URL, "/reader/series/"), "/") apiURL := fmt.Sprintf("%s/api/series/%s/", s.base(), slug) req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, apiURL, nil) resp, err := s.client.Do(req) if err != nil { return nil, err } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) var detail seriesDetail if err := json.Unmarshal(body, &detail); err != nil { return nil, err } var chapters []source.SChapter for chNum, ch := range detail.Chapters { name := "Chapter " + chNum if ch.Title != "" { name += " - " + ch.Title } dateUpload := int64(0) for _, ts := range ch.ReleaseDate { if ts > dateUpload { dateUpload = ts } } chapters = append(chapters, source.SChapter{ URL: fmt.Sprintf("%s/reader/series/%s/%s/", s.base(), slug, chNum), Name: name, DateUpload: dateUpload * 1000, }) } return chapters, nil } func (s *Source) GetPageList(chapter source.SChapter) ([]source.Page, error) { return nil, nil } func (s *Source) GetImageURL(page source.Page) (string, error) { return page.ImageURL, nil } func (s *Source) GetFilterList() []source.Filter { return nil }