feat: initial Phase 1 implementation — core framework + Docker

- Data types (SManga, SChapter, Page, MangasPage, all Filter variants)
- Source interfaces (Source, CatalogueSource) with MD5-based ID generation matching Tachiyomi/Suwayomi
- HTTP client with per-host rate limiting, cookie jar, and 429 retry
- FlareSolverr v1 client (FLARESOLVERR_URL env)
- Generic GraphQL POST helper
- goquery HTML parser wrappers
- Source registry (panics on duplicate ID)
- Multi-stage Dockerfile (golang:1.26-alpine + distroless) and compose.yml (postgres, flaresolverr, app)
This commit is contained in:
achmad
2026-05-10 21:23:24 +07:00
commit 85d2ea6143
23 changed files with 2864 additions and 0 deletions
+59
View File
@@ -0,0 +1,59 @@
package source
import (
"crypto/md5"
"strings"
)
// Source is the base interface for all sources.
type Source interface {
ID() int64
Name() string
Lang() string
}
// CatalogueSource is the full interface every source must implement.
type CatalogueSource interface {
Source
SupportsLatest() bool
GetPopularManga(page int) (MangasPage, error)
GetLatestUpdates(page int) (MangasPage, error)
GetSearchManga(page int, query string, filters []Filter) (MangasPage, error)
GetMangaDetails(manga SManga) (SManga, error)
GetChapterList(manga SManga) ([]SChapter, error)
GetPageList(chapter SChapter) ([]Page, error)
// GetImageURL resolves the final image URL for a page.
// Sources that embed image URLs directly in pages return page.ImageURL unchanged.
GetImageURL(page Page) (string, error)
GetFilterList() []Filter
}
// GenerateSourceID replicates Tachiyomi/Suwayomi HttpSource.generateId:
//
// key = "${name.lowercase()}/$lang/$versionId"
// MD5(key) → first 8 bytes as big-endian int64, sign bit cleared (& Long.MAX_VALUE)
func GenerateSourceID(name, lang string) int64 {
return GenerateSourceIDv(name, lang, 1)
}
func GenerateSourceIDv(name, lang string, versionID int) int64 {
key := strings.ToLower(name) + "/" + lang + "/" + itoa(versionID)
b := md5.Sum([]byte(key))
var id int64
for i := 0; i < 8; i++ {
id |= int64(b[i]) << (8 * (7 - i))
}
return id & int64(^uint64(0)>>1) // clear sign bit (& Long.MAX_VALUE)
}
func itoa(n int) string {
if n == 0 {
return "0"
}
digits := []byte{}
for n > 0 {
digits = append([]byte{byte('0' + n%10)}, digits...)
n /= 10
}
return string(digits)
}
+28
View File
@@ -0,0 +1,28 @@
package source_test
import (
"testing"
"goyomi/internal/source"
)
func TestGenerateSourceID(t *testing.T) {
// IDs computed from the same MD5 formula as Tachiyomi/Suwayomi HttpSource.generateId:
// key = "${name.lowercase()}/$lang/1", MD5 → first 8 bytes big-endian, sign bit cleared.
cases := []struct {
name string
lang string
want int64
}{
{"MangaDex", "en", 2499283573021220255},
{"MangaDex", "all", 6404943692147160087},
{"HeanCms", "en", 6473152836656709188},
}
for _, tc := range cases {
got := source.GenerateSourceID(tc.name, tc.lang)
if got != tc.want {
t.Errorf("GenerateSourceID(%q, %q) = %d, want %d", tc.name, tc.lang, got, tc.want)
}
}
}
+115
View File
@@ -0,0 +1,115 @@
package source
const (
StatusUnknown = 0
StatusOngoing = 1
StatusCompleted = 2
StatusLicensed = 3
StatusHiatus = 5
StatusCancelled = 6
)
type SManga struct {
URL string
Title string
Artist string
Author string
Description string
Genre string // comma-separated
Status int
ThumbnailURL string
Initialized bool
}
type SChapter struct {
URL string
Name string
DateUpload int64 // unix milliseconds
ChapterNumber float32
Scanlator string
}
type Page struct {
Index int
URL string
ImageURL string
}
type MangasPage struct {
Mangas []SManga
HasNextPage bool
}
// Filter interface
type Filter interface {
Name() string
Value() any
}
// TextFilter — free-text input
type TextFilter struct {
FilterName string
Text string
}
func (f *TextFilter) Name() string { return f.FilterName }
func (f *TextFilter) Value() any { return f.Text }
// CheckboxFilter — boolean
type CheckboxFilter struct {
FilterName string
State bool
}
func (f *CheckboxFilter) Name() string { return f.FilterName }
func (f *CheckboxFilter) Value() any { return f.State }
// TriStateFilter — 0=ignore, 1=include, 2=exclude
type TriStateFilter struct {
FilterName string
State int
}
func (f *TriStateFilter) Name() string { return f.FilterName }
func (f *TriStateFilter) Value() any { return f.State }
// SelectFilter — dropdown
type SelectFilter struct {
FilterName string
Values []string
Selected int
}
func (f *SelectFilter) Name() string { return f.FilterName }
func (f *SelectFilter) Value() any { return f.Selected }
// SortFilter
type SortSelection struct {
Index int
Ascending bool
}
type SortFilter struct {
FilterName string
Values []string
Selection SortSelection
}
func (f *SortFilter) Name() string { return f.FilterName }
func (f *SortFilter) Value() any { return f.Selection }
// GroupFilter — container of sub-filters
type GroupFilter struct {
FilterName string
Filters []Filter
}
func (f *GroupFilter) Name() string { return f.FilterName }
func (f *GroupFilter) Value() any { return f.Filters }