package scanr import ( "context" "encoding/json" "fmt" "io" "net/http" "strings" "goyomi/internal/httpclient" "goyomi/internal/source" ) type Config struct { Name string BaseURL string Lang string UseHighLowQualityCover bool SlugSeparator string } type Source struct { cfg Config client *httpclient.Client id int64 } func New(cfg Config) *Source { if cfg.SlugSeparator == "" { cfg.SlugSeparator = "-" } 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 false } type ConfigResponse struct { LocalSeriesFiles []string `json:"localSeriesFiles"` } type SeriesData struct { Title string `json:"title"` Slug string `json:"slug"` Description string `json:"description"` Thumbnail string `json:"thumbnail"` Author string `json:"author"` Status string `json:"status"` Chapters map[string]ChapterData `json:"chapters"` ChapterGroups map[string]map[string]any `json:"chapterGroups"` } type ChapterData struct { Title string `json:"title"` LastUpdated int64 `json:"lastUpdated"` Volume string `json:"volume"` Licenced bool `json:"licenced"` } type ReaderData struct { Series struct { Chapters map[string]struct { Groups map[string][]string `json:"groups"` } `json:"chapters"` } `json:"series"` } type PageData struct { Link string `json:"link"` } func (s *Source) toSManga(data SeriesData) source.SManga { status := 0 lowerStatus := strings.ToLower(data.Status) if strings.Contains(lowerStatus, "ongoing") || strings.Contains(lowerStatus, "releasing") { status = 1 } else if strings.Contains(lowerStatus, "completed") { status = 2 } return source.SManga{ URL: "/" + toSlug(data.Title, s.cfg.SlugSeparator), Title: data.Title, ThumbnailURL: data.Thumbnail, Description: data.Description, Author: data.Author, Status: status, } } func (s *Source) toDetailedSManga(data SeriesData) source.SManga { m := s.toSManga(data) m.Initialized = true return m } func toSlug(title, sep string) string { lower := strings.ToLower(title) slug := strings.ReplaceAll(lower, " ", sep) return slug } func (s *Source) fetchSeriesData(fileName string) (SeriesData, error) { url := fmt.Sprintf("%s/data/series/%s", s.cfg.BaseURL, fileName) req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil) if err != nil { return SeriesData{}, err } resp, err := s.client.Do(req) if err != nil { return SeriesData{}, err } defer resp.Body.Close() var data SeriesData if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { return SeriesData{}, err } return data, nil } func (s *Source) GetPopularManga(page int) (source.MangasPage, error) { return s.GetSearchManga(page, "", nil) } func (s *Source) GetLatestUpdates(page int) (source.MangasPage, error) { return source.MangasPage{}, fmt.Errorf("not supported") } func (s *Source) GetSearchManga(page int, query string, filters []source.Filter) (source.MangasPage, error) { configURL := s.cfg.BaseURL + "/data/config.json" if query != "" { configURL += "#" + query } req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, configURL, nil) if err != nil { return source.MangasPage{}, err } resp, err := s.client.Do(req) if err != nil { return source.MangasPage{}, err } defer resp.Body.Close() var config ConfigResponse if err := json.NewDecoder(resp.Body).Decode(&config); err != nil { return source.MangasPage{}, err } searchQuery := "" if idx := strings.Index(configURL, "#"); idx != -1 { searchQuery = configURL[idx+1:] } mangas := make([]source.SManga, 0) for _, fileName := range config.LocalSeriesFiles { data, err := s.fetchSeriesData(fileName) if err != nil { continue } if searchQuery == "" || strings.Contains(strings.ToLower(data.Title), strings.ToLower(searchQuery)) { mangas = append(mangas, s.toSManga(data)) } } return source.MangasPage{Mangas: mangas, HasNextPage: false}, nil } func (s *Source) GetMangaDetails(manga source.SManga) (source.SManga, error) { fileName := strings.TrimPrefix(manga.URL, "/") + ".json" data, err := s.fetchSeriesData(fileName) if err != nil { return manga, err } return s.toDetailedSManga(data), nil } func (s *Source) GetChapterList(manga source.SManga) ([]source.SChapter, error) { fileName := strings.TrimPrefix(manga.URL, "/") + ".json" data, err := s.fetchSeriesData(fileName) if err != nil { return nil, err } if data.Chapters == nil { return nil, nil } chapters := make([]source.SChapter, 0, len(data.Chapters)) multipleChapters := len(data.Chapters) > 1 for chapterNumber, chapterData := range data.Chapters { if chapterData.Licenced { continue } title := chapterData.Title volumeNumber := chapterData.Volume var name string if multipleChapters { name = "Ch. " + chapterNumber if volumeNumber != "" { name = "Vol. " + volumeNumber + " " + name } if title != "" { name += " - " + title } } else { if title != "" { name = "One Shot - " + title } else { name = "One Shot" } } chapters = append(chapters, source.SChapter{ URL: "/" + toSlug(data.Title, s.cfg.SlugSeparator) + "/" + chapterNumber, Name: name, DateUpload: chapterData.LastUpdated * 1000, ChapterNumber: parseChapterNumber(chapterNumber), }) } return chapters, nil } func parseChapterNumber(s string) float32 { var f float64 _, err := fmt.Sscanf(s, "%f", &f) if err != nil { return -1 } return float32(f) } func (s *Source) GetPageList(chapter source.SChapter) ([]source.Page, error) { chapterURL := s.cfg.BaseURL + chapter.URL req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, chapterURL, nil) if err != nil { return nil, err } resp, err := s.client.Do(req) if err != nil { return nil, err } defer resp.Body.Close() html, err := io.ReadAll(resp.Body) if err != nil { return nil, err } readerData, err := s.extractReaderData(string(html)) if err != nil { return nil, err } chapterNumber := getChapterNumberFromURL(chapter.URL) chapterInfo, ok := readerData.Series.Chapters[chapterNumber] if !ok { return nil, fmt.Errorf("chapter data not found for %s", chapterNumber) } var chapterID string for _, groups := range chapterInfo.Groups { if len(groups) > 0 { chapterID = groups[0] break } } if chapterID == "" { return nil, fmt.Errorf("no chapter ID found") } chapterID = strings.TrimPrefix(chapterID, "/") chapterID = strings.TrimSuffix(chapterID, "/") chapterID = getLastPathSegment(chapterID) pagesURL := fmt.Sprintf("%s/api/imgchchest-chapter-pages?id=%s", s.cfg.BaseURL, chapterID) req, err = http.NewRequestWithContext(context.Background(), http.MethodGet, pagesURL, nil) if err != nil { return nil, err } resp, err = s.client.Do(req) if err != nil { return nil, err } defer resp.Body.Close() var pages []PageData if err := json.NewDecoder(resp.Body).Decode(&pages); err != nil { return nil, err } result := make([]source.Page, len(pages)) for i, p := range pages { result[i] = source.Page{Index: i, ImageURL: p.Link} } return result, nil } func (s *Source) extractReaderData(html string) (ReaderData, error) { start := strings.Index(html, ``) if end == -1 || end < start { return ReaderData{}, fmt.Errorf("invalid reader data format") } jsonStr := html[start:end] var data ReaderData if err := json.Unmarshal([]byte(jsonStr), &data); err != nil { return ReaderData{}, err } return data, nil } func getChapterNumberFromURL(url string) string { parts := strings.Split(url, "/") if len(parts) > 0 { return parts[len(parts)-1] } return "" } func getLastPathSegment(path string) string { parts := strings.Split(path, "/") for i := len(parts) - 1; i >= 0; i-- { if parts[i] != "" { return parts[i] } } return "" } func (s *Source) GetImageURL(page source.Page) (string, error) { return page.ImageURL, nil } func (s *Source) GetFilterList() []source.Filter { return nil }