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:
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
Reference in New Issue
Block a user