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
+43
View File
@@ -0,0 +1,43 @@
package registry
import (
"fmt"
"sort"
"sync"
"goyomi/internal/source"
)
var (
mu sync.RWMutex
sources = map[int64]source.CatalogueSource{}
)
// Register adds a source. Panics on duplicate ID — caught at startup.
func Register(s source.CatalogueSource) {
mu.Lock()
defer mu.Unlock()
if _, exists := sources[s.ID()]; exists {
panic(fmt.Sprintf("registry: duplicate source ID %d (%s/%s)", s.ID(), s.Name(), s.Lang()))
}
sources[s.ID()] = s
}
func Get(id int64) (source.CatalogueSource, bool) {
mu.RLock()
defer mu.RUnlock()
s, ok := sources[id]
return s, ok
}
// All returns all registered sources sorted by ID.
func All() []source.CatalogueSource {
mu.RLock()
defer mu.RUnlock()
out := make([]source.CatalogueSource, 0, len(sources))
for _, s := range sources {
out = append(out, s)
}
sort.Slice(out, func(i, j int) bool { return out[i].ID() < out[j].ID() })
return out
}
+39
View File
@@ -0,0 +1,39 @@
package registry_test
import (
"testing"
"goyomi/internal/registry"
"goyomi/internal/source"
)
type mockSource struct {
id int64
name string
lang string
}
func (m *mockSource) ID() int64 { return m.id }
func (m *mockSource) Name() string { return m.name }
func (m *mockSource) Lang() string { return m.lang }
func (m *mockSource) SupportsLatest() bool { return false }
func (m *mockSource) GetPopularManga(page int) (source.MangasPage, error) { return source.MangasPage{}, nil }
func (m *mockSource) GetLatestUpdates(page int) (source.MangasPage, error) { return source.MangasPage{}, nil }
func (m *mockSource) GetSearchManga(page int, query string, filters []source.Filter) (source.MangasPage, error) {
return source.MangasPage{}, nil
}
func (m *mockSource) GetMangaDetails(manga source.SManga) (source.SManga, error) { return manga, nil }
func (m *mockSource) GetChapterList(manga source.SManga) ([]source.SChapter, error) { return nil, nil }
func (m *mockSource) GetPageList(chapter source.SChapter) ([]source.Page, error) { return nil, nil }
func (m *mockSource) GetImageURL(page source.Page) (string, error) { return page.ImageURL, nil }
func (m *mockSource) GetFilterList() []source.Filter { return nil }
func TestDuplicateIDPanics(t *testing.T) {
defer func() {
if r := recover(); r == nil {
t.Error("expected panic on duplicate source ID, got none")
}
}()
registry.Register(&mockSource{id: 9999, name: "A", lang: "en"})
registry.Register(&mockSource{id: 9999, name: "B", lang: "en"})
}