// Package libgroup implements the LibGroup manga base (RusManga, MangaLib, etc.). // Bearer token auth; GET {apiDomain}/api/manga; FlareSolverr required for initial token. package libgroup import ( "context" "encoding/json" "fmt" "io" "net/http" "strings" "goyomi/internal/httpclient/flare" "goyomi/internal/source" "goyomi/sources/base/util" ) const defaultAPIDomain = "https://api.lib.social" type Config struct { Name string BaseURL string Lang string SiteID int APIDomain string // defaults to "https://api.lib.social" BearerToken string // optional; set after WebView acquisition } type Source struct { cfg Config client *flare.Client id int64 } func New(cfg Config) *Source { if cfg.APIDomain == "" { cfg.APIDomain = defaultAPIDomain } opts := []flare.Option{flare.WithRateLimit(1, 1)} c := flare.NewClient(opts...) 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.APIDomain, "/") } 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+"/") req.Header.Set("Site-Id", fmt.Sprintf("%d", s.cfg.SiteID)) if s.cfg.BearerToken != "" { req.Header.Set("Authorization", s.cfg.BearerToken) } resp, err := s.client.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return fmt.Errorf("libgroup: HTTP %d", resp.StatusCode) } body, _ := io.ReadAll(resp.Body) return json.Unmarshal(body, out) } type mangaDTO struct { SlugURL string `json:"slug_url"` Name string `json:"name"` EngName string `json:"eng_name"` Cover struct { Default string `json:"default"` Thumbnail string `json:"thumbnail"` } `json:"cover"` Summary string `json:"summary"` Authors []struct{ Name string `json:"name"` } `json:"authors"` Status struct{ Label string `json:"label"` } `json:"status"` Genres []struct{ Name string `json:"name"` } `json:"genres"` } type metaDTO struct { HasNextPage bool `json:"has_next_page"` } type mangaListDTO struct { Data []mangaDTO `json:"data"` Meta metaDTO `json:"meta"` } type dataDTO[T any] struct { Data T `json:"data"` } func (s *Source) toSManga(m mangaDTO, useEng bool) source.SManga { title := m.Name if useEng && m.EngName != "" { title = m.EngName } thumb := m.Cover.Default if thumb == "" { thumb = m.Cover.Thumbnail } var authors []string for _, a := range m.Authors { authors = append(authors, a.Name) } var genres []string for _, g := range m.Genres { genres = append(genres, g.Name) } slug := m.SlugURL if !strings.HasPrefix(slug, "/") { slug = "/" + slug } return source.SManga{ URL: slug, Title: title, Author: strings.Join(authors, ", "), Description: m.Summary, Genre: strings.Join(genres, ", "), Status: util.StatusFromString(m.Status.Label), ThumbnailURL: thumb, } } func (s *Source) isEng() bool { return s.cfg.Lang == "en" } func (s *Source) GetPopularManga(page int) (source.MangasPage, error) { u := fmt.Sprintf("%s/api/manga?site_id[]=%d&page=%d", s.api(), s.cfg.SiteID, page) var result mangaListDTO if err := s.getJSON(context.Background(), u, &result); err != nil { return source.MangasPage{}, err } mangas := make([]source.SManga, len(result.Data)) for i, m := range result.Data { mangas[i] = s.toSManga(m, s.isEng()) } return source.MangasPage{Mangas: mangas, HasNextPage: result.Meta.HasNextPage}, nil } func (s *Source) GetLatestUpdates(page int) (source.MangasPage, error) { u := fmt.Sprintf("%s/api/latest-updates?site_id[]=%d&page=%d", s.api(), s.cfg.SiteID, page) var result mangaListDTO if err := s.getJSON(context.Background(), u, &result); err != nil { return source.MangasPage{}, err } mangas := make([]source.SManga, len(result.Data)) for i, m := range result.Data { mangas[i] = s.toSManga(m, s.isEng()) } return source.MangasPage{Mangas: mangas, HasNextPage: result.Meta.HasNextPage}, nil } func (s *Source) GetSearchManga(page int, query string, filters []source.Filter) (source.MangasPage, error) { u := fmt.Sprintf("%s/api/manga?site_id[]=%d&page=%d&q=%s", s.api(), s.cfg.SiteID, page, query) var result mangaListDTO if err := s.getJSON(context.Background(), u, &result); err != nil { return source.MangasPage{}, err } mangas := make([]source.SManga, len(result.Data)) for i, m := range result.Data { mangas[i] = s.toSManga(m, s.isEng()) } return source.MangasPage{Mangas: mangas, HasNextPage: result.Meta.HasNextPage}, nil } func (s *Source) GetMangaDetails(manga source.SManga) (source.SManga, error) { u := fmt.Sprintf("%s/api/manga%s?fields[]=eng_name", s.api(), manga.URL) var result dataDTO[mangaDTO] if err := s.getJSON(context.Background(), u, &result); err != nil { return manga, err } out := s.toSManga(result.Data, s.isEng()) out.URL = manga.URL return out, nil } func (s *Source) GetChapterList(manga source.SManga) ([]source.SChapter, error) { u := fmt.Sprintf("%s/api/manga%s/chapters", s.api(), manga.URL) var result dataDTO[[]struct { ID int `json:"id"` Volume string `json:"volume"` Number float64 `json:"number"` Name string `json:"name"` Date string `json:"created_at"` BranchID int `json:"branch_id"` }] if err := s.getJSON(context.Background(), u, &result); err != nil { return nil, err } slugURL := strings.TrimPrefix(manga.URL, "/") chapters := make([]source.SChapter, len(result.Data)) for i, ch := range result.Data { name := fmt.Sprintf("%.1f", ch.Number) if ch.Name != "" { name += " - " + ch.Name } chURL := fmt.Sprintf("/%s/%d?volume=%s&number=%.1f", slugURL, ch.ID, ch.Volume, ch.Number) if ch.BranchID > 0 { chURL += fmt.Sprintf("&branch_id=%d", ch.BranchID) } chapters[i] = source.SChapter{ URL: chURL, Name: "Chapter " + name, DateUpload: util.ParseAbsoluteDate(ch.Date, "2006-01-02T15:04:05.000000Z"), } } return chapters, nil } func (s *Source) GetPageList(chapter source.SChapter) ([]source.Page, error) { u := fmt.Sprintf("%s/api/manga%s", s.api(), chapter.URL) var result dataDTO[struct { Pages []struct { ID int `json:"id"` URL string `json:"url"` } `json:"pages"` }] if err := s.getJSON(context.Background(), u, &result); err != nil { return nil, err } pages := make([]source.Page, len(result.Data.Pages)) for i, p := range result.Data.Pages { pages[i] = source.Page{Index: i, ImageURL: util.AbsURL(s.cfg.BaseURL, p.URL)} } return pages, nil } func (s *Source) GetImageURL(page source.Page) (string, error) { return page.ImageURL, nil } func (s *Source) GetFilterList() []source.Filter { return nil }