// Package monochrome implements the Monochrome manga base. // JSON REST API at api.{host}; search/browse via /manga endpoint; pages generated from chapter metadata. package monochrome import ( "context" "encoding/json" "fmt" "io" "net/http" "strings" "time" "goyomi/internal/httpclient" "goyomi/internal/source" ) type Config struct { Name string BaseURL string Lang string } type Source struct { cfg Config apiURL string client *httpclient.Client id int64 } func New(cfg Config) *Source { // apiUrl: insert "api." after "://" apiURL := strings.Replace(cfg.BaseURL, "://", "://api.", 1) apiURL = strings.TrimRight(apiURL, "/") c := httpclient.NewClient(httpclient.WithRateLimit(1, 2)) return &Source{ cfg: cfg, apiURL: apiURL, 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 } func (s *Source) doGet(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("Referer", s.cfg.BaseURL+"/") resp, err := s.client.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return fmt.Errorf("monochrome: HTTP %d for %s", resp.StatusCode, rawURL) } body, _ := io.ReadAll(resp.Body) return json.Unmarshal(body, out) } // DTOs type results struct { Offset int `json:"offset"` Limit int `json:"limit"` Results []mangaDTO `json:"results"` Total int `json:"total"` } func (r results) hasNext() bool { return r.Total > len(r.Results)+r.Offset*r.Limit } type mangaDTO struct { Title string `json:"title"` Description string `json:"description"` Author string `json:"author"` Artist string `json:"artist"` Status string `json:"status"` ID string `json:"id"` Version int `json:"version"` } func (m mangaDTO) coverURL(apiURL string) string { return fmt.Sprintf("%s/media/%s/cover.jpg?version=%d", apiURL, m.ID, m.Version) } type chapterDTO struct { Name string `json:"name"` Volume *int `json:"volume"` Number float32 `json:"number"` ScanGroup string `json:"scanGroup"` ID string `json:"id"` Version int `json:"version"` Length int `json:"length"` UploadTime string `json:"uploadTime"` } func (c chapterDTO) title() string { var b strings.Builder if c.Volume != nil { fmt.Fprintf(&b, "Vol %d ", *c.Volume) } fmt.Fprintf(&b, "Chapter %.2g", c.Number) if c.Name != "" { fmt.Fprintf(&b, " - %s", c.Name) } return b.String() } func (c chapterDTO) timestamp() int64 { t, err := time.Parse("2006-01-02T15:04:05.999999", c.UploadTime) if err != nil { t, err = time.Parse("2006-01-02T15:04:05", c.UploadTime) } if err != nil { return 0 } return t.UnixMilli() } // chapterURL: stored as "{mangaUUID}/{chapterID}|{version}|{length}" // (mirrors Kotlin's manga.url + ch.parts where parts = "/{id}|{version}|{length}") func buildChapterURL(mangaUUID, chapterID string, version, length int) string { return fmt.Sprintf("%s/%s|%d|%d", mangaUUID, chapterID, version, length) } func (s *Source) mangaFromDTO(m mangaDTO) source.SManga { sm := source.SManga{ URL: m.ID, Title: m.Title, Description: m.Description, Author: m.Author, Artist: m.Artist, ThumbnailURL: m.coverURL(s.apiURL), } switch strings.ToLower(m.Status) { case "ongoing", "hiatus": sm.Status = source.StatusOngoing case "completed", "cancelled": sm.Status = source.StatusCompleted default: sm.Status = source.StatusUnknown } return sm } func (s *Source) GetPopularManga(page int) (source.MangasPage, error) { return s.GetSearchManga(page, "", nil) } func (s *Source) GetLatestUpdates(page int) (source.MangasPage, error) { return s.GetSearchManga(page, "", nil) } func (s *Source) GetSearchManga(page int, query string, filters []source.Filter) (source.MangasPage, error) { offset := 10 * (page - 1) u := fmt.Sprintf("%s/manga?limit=10&offset=%d&title=%s", s.apiURL, offset, query) var res results if err := s.doGet(context.Background(), u, &res); err != nil { return source.MangasPage{}, err } mangas := make([]source.SManga, len(res.Results)) for i, m := range res.Results { mangas[i] = s.mangaFromDTO(m) } return source.MangasPage{Mangas: mangas, HasNextPage: res.hasNext()}, nil } func (s *Source) GetMangaDetails(manga source.SManga) (source.SManga, error) { var m mangaDTO if err := s.doGet(context.Background(), fmt.Sprintf("%s/manga/%s", s.apiURL, manga.URL), &m); err != nil { return manga, err } result := s.mangaFromDTO(m) result.URL = manga.URL return result, nil } func (s *Source) GetChapterList(manga source.SManga) ([]source.SChapter, error) { var chapters []chapterDTO if err := s.doGet(context.Background(), fmt.Sprintf("%s/manga/%s/chapters", s.apiURL, manga.URL), &chapters); err != nil { return nil, err } result := make([]source.SChapter, len(chapters)) for i, ch := range chapters { result[i] = source.SChapter{ URL: buildChapterURL(manga.URL, ch.ID, ch.Version, ch.Length), Name: ch.title(), DateUpload: ch.timestamp(), } } return result, nil } func (s *Source) GetPageList(chapter source.SChapter) ([]source.Page, error) { // URL format: "{mangaUUID}/{chapterID}|{version}|{length}" pipeIdx := strings.Index(chapter.URL, "|") if pipeIdx < 0 { return nil, fmt.Errorf("monochrome: malformed chapter URL: %s", chapter.URL) } uuidPart := chapter.URL[:pipeIdx] // "mangaUUID/chapterID" rest := chapter.URL[pipeIdx+1:] // "version|length" parts := strings.SplitN(rest, "|", 2) if len(parts) < 2 { return nil, fmt.Errorf("monochrome: malformed chapter URL: %s", chapter.URL) } version := parts[0] var length int fmt.Sscan(parts[1], &length) pages := make([]source.Page, length) for i := range pages { pages[i] = source.Page{ Index: i, ImageURL: fmt.Sprintf("%s/media/%s/%d.jpg?version=%s", s.apiURL, uuidPart, i+1, version), } } return pages, nil } func (s *Source) GetImageURL(page source.Page) (string, error) { return page.ImageURL, nil } func (s *Source) GetFilterList() []source.Filter { return nil }