package spicytheme import ( "context" "encoding/json" "fmt" "io" "net/http" "net/url" "time" "goyomi/internal/httpclient/flare" "goyomi/internal/source" ) type Config struct { Name string BaseURL string APIBaseURL string Lang string } type Source struct { cfg Config client *flare.Client id int64 } func New(cfg Config) *Source { c := flare.NewClient(flare.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 } type MangaDTO struct { ID string `json:"id"` Slug string `json:"slug"` Name string `json:"name"` Image string `json:"image"` Description string `json:"description"` Status string `json:"status"` Genres []struct { ID string `json:"id"` Name string `json:"name"` } `json:"genders"` } type FilterResponseDTO struct { Data []MangaDTO `json:"data"` Pagination struct { CurrentPage int `json:"currentPage"` LastPage int `json:"lastPage"` Total int `json:"total"` } `json:"pagination"` } type SeriesResponseDTO struct { Series struct { ID string `json:"id"` Slug string `json:"slug"` Name string `json:"name"` Image string `json:"image"` Description string `json:"description"` Status string `json:"status"` Origin string `json:"origin"` Genres []struct { ID string `json:"id"` Name string `json:"name"` } `json:"genders"` Chapters []ChapterDTO `json:"chapters"` } `json:"series"` } type ChapterDTO struct { ID string `json:"id"` Number string `json:"number"` Name string `json:"name"` CreatedAt string `json:"createdAt"` } type PagesResponseDTO struct { Pages struct { RawImages json.RawMessage `json:"rawImages"` } `json:"pages"` } func (s *Source) filterURL(page int, orderBy string) string { u, _ := url.Parse(s.cfg.APIBaseURL + "/filtrar") q := u.Query() q.Set("page", fmt.Sprintf("%d", page)) q.Set("limit", "12") q.Set("orderBy", orderBy) q.Set("sort", "desc") q.Set("gendersId", "") q.Set("origin", "") q.Set("state", "") q.Set("loading", "true") u.RawQuery = q.Encode() return u.String() } func (s *Source) GetPopularManga(page int) (source.MangasPage, error) { return s.fetchList(page, "id_popular") } func (s *Source) GetLatestUpdates(page int) (source.MangasPage, error) { return s.fetchList(page, "id_latest") } func (s *Source) fetchList(page int, orderBy string) (source.MangasPage, error) { url := s.filterURL(page, orderBy) req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil) if err != nil { return source.MangasPage{}, err } s.setHeaders(req) resp, err := s.client.Do(req) if err != nil { return source.MangasPage{}, err } defer resp.Body.Close() var result FilterResponseDTO if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { return source.MangasPage{}, err } mangas := make([]source.SManga, len(result.Data)) for i, d := range result.Data { mangas[i] = s.mangaFromDTO(d) } hasNext := result.Pagination.CurrentPage < result.Pagination.LastPage return source.MangasPage{Mangas: mangas, HasNextPage: hasNext}, nil } func (s *Source) GetSearchManga(page int, query string, filters []source.Filter) (source.MangasPage, error) { if query != "" { if len(query) < 2 { return source.MangasPage{}, fmt.Errorf("escribe al menos 2 caracteres para buscar") } u, _ := url.Parse(s.cfg.APIBaseURL + "/home/buscar") q := u.Query() q.Set("query", query) u.RawQuery = q.Encode() req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, u.String(), nil) if err != nil { return source.MangasPage{}, err } s.setHeaders(req) resp, err := s.client.Do(req) if err != nil { return source.MangasPage{}, err } defer resp.Body.Close() var result []MangaDTO if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { return source.MangasPage{}, err } mangas := make([]source.SManga, len(result)) for i, d := range result { mangas[i] = s.mangaFromDTO(d) } return source.MangasPage{Mangas: mangas, HasNextPage: false}, nil } orderBy := "id_latest" for _, f := range filters { if sel, ok := f.(*source.SelectFilter); ok && len(sel.Values) > sel.Selected { orderBy = sel.Values[sel.Selected] } } return s.fetchList(page, orderBy) } func (s *Source) mangaFromDTO(d MangaDTO) source.SManga { genres := make([]string, len(d.Genres)) for i, g := range d.Genres { genres[i] = g.Name } status := 0 switch d.Status { case "ongoing": status = 1 case "completed": status = 2 case "hiatus": status = 5 case "cancelled": status = 6 } return source.SManga{ URL: d.Slug, Title: d.Name, ThumbnailURL: d.Image, Description: d.Description, Genre: joinStrings(genres, ", "), Status: status, } } func joinStrings(ss []string, sep string) string { if len(ss) == 0 { return "" } result := ss[0] for i := 1; i < len(ss); i++ { result += sep + ss[i] } return result } func (s *Source) GetMangaDetails(manga source.SManga) (source.SManga, error) { url := s.cfg.APIBaseURL + "/serie/" + manga.URL req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil) if err != nil { return manga, err } s.setHeaders(req) resp, err := s.client.Do(req) if err != nil { return manga, err } defer resp.Body.Close() var result SeriesResponseDTO if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { return manga, err } m := s.mangaFromDTO(MangaDTO{ ID: result.Series.ID, Slug: result.Series.Slug, Name: result.Series.Name, Image: result.Series.Image, Description: result.Series.Description, Status: result.Series.Status, Genres: result.Series.Genres, }) m.Initialized = true m.URL = manga.URL return m, nil } func (s *Source) GetChapterList(manga source.SManga) ([]source.SChapter, error) { url := s.cfg.APIBaseURL + "/serie/" + manga.URL req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil) if err != nil { return nil, err } s.setHeaders(req) resp, err := s.client.Do(req) if err != nil { return nil, err } defer resp.Body.Close() var result SeriesResponseDTO if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { return nil, err } chapters := make([]source.SChapter, len(result.Series.Chapters)) dateLayout := "2006-01-02T15:04:05.000Z" for i, ch := range result.Series.Chapters { dateUpload := int64(0) if t, err := time.Parse(dateLayout, ch.CreatedAt); err == nil { dateUpload = t.UnixMilli() } chapters[i] = source.SChapter{ URL: result.Series.Slug + "/" + ch.Number, Name: "Chapter " + ch.Number + " - " + ch.Name, DateUpload: dateUpload, } } return chapters, nil } func (s *Source) GetPageList(chapter source.SChapter) ([]source.Page, error) { url := s.cfg.APIBaseURL + "/serie/" + chapter.URL + "/" req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil) if err != nil { return nil, err } s.setHeaders(req) resp, err := s.client.Do(req) if err != nil { return nil, err } defer resp.Body.Close() var result PagesResponseDTO if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { return nil, err } var images []string if err := json.Unmarshal(result.Pages.RawImages, &images); err != nil { return nil, err } pages := make([]source.Page, len(images)) for i, img := range images { pages[i] = source.Page{Index: i, ImageURL: img} } return pages, nil } func (s *Source) GetImageURL(page source.Page) (string, error) { return page.ImageURL, nil } func (s *Source) GetFilterList() []source.Filter { filters := make([]source.Filter, 0) filters = append(filters, &source.TextFilter{FilterName: "Los filtros no se aplican a la búsqueda por texto"}) filters = append(filters, &source.SelectFilter{ FilterName: "Ordenar por", Values: []string{"id_latest", "id_popular", "views", "release"}, Selected: 0, }) filters = append(filters, &source.SelectFilter{ FilterName: "Origen", Values: []string{"", "jp", "kr", "cn", "other"}, Selected: 0, }) filters = append(filters, &source.SelectFilter{ FilterName: "Género", Values: []string{"", "1", "2", "3", "4", "5", "6", "7", "8"}, Selected: 0, }) filters = append(filters, &source.SelectFilter{ FilterName: "Estado", Values: []string{"", "ongoing", "completed", "hiatus", "cancelled"}, Selected: 0, }) return filters } func (s *Source) setHeaders(req *http.Request) { req.Header.Set("Referer", s.cfg.BaseURL+"/") req.Header.Set("Origin", s.cfg.BaseURL) req.Header.Set("Accept", "application/json") } func (s *Source) fetchJSON(url string, out any) error { req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil) if err != nil { return err } s.setHeaders(req) resp, err := s.client.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return fmt.Errorf("HTTP %d for %s", resp.StatusCode, url) } body, err := io.ReadAll(resp.Body) if err != nil { return err } return json.Unmarshal(body, out) } var _ source.CatalogueSource = (*Source)(nil)