refactor: rewrite project from Rust to Go

- Replace Axum with Chi router
- Replace sqlx with pgx for PostgreSQL
- Replace tonic/prost with grpc-go
- Replace tracing with zerolog
- Update flake.nix for Go build with protoc generation
- Preserve all existing endpoints and functionality

Stack: Chi, pgx, grpc-go, zerolog, yaml.v3
This commit is contained in:
Alexander
2026-04-29 10:45:05 +02:00
parent f24543f401
commit 41fb033d30
48 changed files with 2306 additions and 6652 deletions
+54
View File
@@ -0,0 +1,54 @@
package indexer
import (
"regexp"
"strings"
)
type MusicSearchCriteria struct {
Artist string
Album *string
Year *uint32
Limit int
Offset int
}
func (c *MusicSearchCriteria) CleanArtist() string {
return cleanSearchTerm(c.Artist)
}
func (c *MusicSearchCriteria) CleanAlbum() *string {
if c.Album == nil {
return nil
}
cleaned := cleanSearchTerm(*c.Album)
return &cleaned
}
var cleanRegex = regexp.MustCompile(`[^\w\s]`)
func cleanSearchTerm(s string) string {
s = cleanRegex.ReplaceAllString(s, " ")
fields := strings.Fields(s)
return strings.Join(fields, " ")
}
type SearchResult struct {
GUID string `json:"guid"`
Title string `json:"title"`
DownloadURL string `json:"download_url"`
InfoURL *string `json:"info_url,omitempty"`
Size uint64 `json:"size"`
PublishDate *string `json:"publish_date,omitempty"`
Artist *string `json:"artist,omitempty"`
Album *string `json:"album,omitempty"`
Year *uint32 `json:"year,omitempty"`
Label *string `json:"label,omitempty"`
Seeders *int `json:"seeders,omitempty"`
Leechers *int `json:"leechers,omitempty"`
Grabs *int `json:"grabs,omitempty"`
Infohash *string `json:"infohash,omitempty"`
MagnetURL *string `json:"magnet_url,omitempty"`
Indexer string `json:"indexer"`
Categories []uint32 `json:"categories"`
}
+289
View File
@@ -0,0 +1,289 @@
package indexer
import (
"context"
"encoding/xml"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
)
var (
ErrAuthFailed = errors.New("authentication failed")
ErrSearchFailed = errors.New("search failed")
ErrRateLimited = errors.New("rate limited")
ErrUnavailable = errors.New("indexer unavailable")
ErrParseError = errors.New("parse error")
)
type TorznabIndexer struct {
name string
baseURL *url.URL
apiKey string
categories []uint32
client *http.Client
}
func NewTorznabIndexer(name, baseURL, apiKey string) (*TorznabIndexer, error) {
u, err := url.Parse(baseURL)
if err != nil {
return nil, fmt.Errorf("invalid URL: %w", err)
}
return &TorznabIndexer{
name: name,
baseURL: u,
apiKey: apiKey,
categories: []uint32{3000, 3010, 3040},
client: &http.Client{},
}, nil
}
func (i *TorznabIndexer) WithCategories(cats []uint32) *TorznabIndexer {
i.categories = cats
return i
}
func (i *TorznabIndexer) Name() string {
return i.name
}
func (i *TorznabIndexer) buildSearchURL(criteria *MusicSearchCriteria) string {
u := *i.baseURL
q := u.Query()
q.Set("t", "music")
q.Set("apikey", i.apiKey)
q.Set("extended", "1")
var cats []string
for _, c := range i.categories {
cats = append(cats, strconv.FormatUint(uint64(c), 10))
}
q.Set("cat", strings.Join(cats, ","))
var qParts []string
qParts = append(qParts, criteria.CleanArtist())
if album := criteria.CleanAlbum(); album != nil {
qParts = append(qParts, *album)
}
if criteria.Year != nil {
qParts = append(qParts, strconv.FormatUint(uint64(*criteria.Year), 10))
}
q.Set("q", strings.Join(qParts, " "))
q.Set("limit", strconv.Itoa(criteria.Limit))
q.Set("offset", strconv.Itoa(criteria.Offset))
u.RawQuery = q.Encode()
return u.String()
}
func (i *TorznabIndexer) Search(ctx context.Context, criteria *MusicSearchCriteria) ([]SearchResult, error) {
searchURL := i.buildSearchURL(criteria)
req, err := http.NewRequestWithContext(ctx, "GET", searchURL, nil)
if err != nil {
return nil, err
}
resp, err := i.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusTooManyRequests {
retryAfter := 60
if ra := resp.Header.Get("Retry-After"); ra != "" {
if v, err := strconv.Atoi(ra); err == nil {
retryAfter = v
}
}
return nil, fmt.Errorf("%w: retry after %d seconds", ErrRateLimited, retryAfter)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("%w: HTTP %d", ErrUnavailable, resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
return i.parseResponse(body)
}
func (i *TorznabIndexer) TestConnection(ctx context.Context) error {
u := *i.baseURL
q := u.Query()
q.Set("t", "caps")
q.Set("apikey", i.apiKey)
u.RawQuery = q.Encode()
req, err := http.NewRequestWithContext(ctx, "GET", u.String(), nil)
if err != nil {
return err
}
resp, err := i.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("%w: HTTP %d", ErrUnavailable, resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
xmlStr := string(body)
if strings.Contains(xmlStr, "<error") && strings.Contains(xmlStr, `code="1`) {
return ErrAuthFailed
}
return nil
}
type rssResponse struct {
Channel struct {
Items []rssItem `xml:"item"`
} `xml:"channel"`
Error *rssError `xml:"error"`
}
type rssError struct {
Code string `xml:"code,attr"`
Description string `xml:"description,attr"`
}
type rssItem struct {
GUID string `xml:"guid"`
Title string `xml:"title"`
Link string `xml:"link"`
Comments string `xml:"comments"`
PubDate string `xml:"pubDate"`
Enclosure enclosure `xml:"enclosure"`
Attrs []attr `xml:"attr"`
}
type enclosure struct {
URL string `xml:"url,attr"`
Length string `xml:"length,attr"`
}
type attr struct {
Name string `xml:"name,attr"`
Value string `xml:"value,attr"`
}
func (i *TorznabIndexer) parseResponse(data []byte) ([]SearchResult, error) {
var rss rssResponse
if err := xml.Unmarshal(data, &rss); err != nil {
return nil, fmt.Errorf("%w: %v", ErrParseError, err)
}
if rss.Error != nil {
if strings.HasPrefix(rss.Error.Code, "1") {
return nil, ErrAuthFailed
}
return nil, fmt.Errorf("%w: %s", ErrSearchFailed, rss.Error.Description)
}
var results []SearchResult
for _, item := range rss.Channel.Items {
result := i.parseItem(item)
results = append(results, result)
}
return results, nil
}
func (i *TorznabIndexer) parseItem(item rssItem) SearchResult {
attrs := make(map[string]string)
var categories []uint32
for _, a := range item.Attrs {
if a.Name == "category" {
if v, err := strconv.ParseUint(a.Value, 10, 32); err == nil {
categories = append(categories, uint32(v))
}
} else {
attrs[a.Name] = a.Value
}
}
size := uint64(0)
if s, ok := attrs["size"]; ok {
if v, err := strconv.ParseUint(s, 10, 64); err == nil {
size = v
}
} else if item.Enclosure.Length != "" {
if v, err := strconv.ParseUint(item.Enclosure.Length, 10, 64); err == nil {
size = v
}
}
result := SearchResult{
GUID: item.GUID,
Title: item.Title,
DownloadURL: item.Link,
Size: size,
Indexer: i.name,
Categories: categories,
}
if item.Comments != "" {
result.InfoURL = &item.Comments
}
if item.PubDate != "" {
result.PublishDate = &item.PubDate
}
if v, ok := attrs["artist"]; ok {
result.Artist = &v
}
if v, ok := attrs["album"]; ok {
result.Album = &v
}
if v, ok := attrs["year"]; ok {
if y, err := strconv.ParseUint(v, 10, 32); err == nil {
y32 := uint32(y)
result.Year = &y32
}
}
if v, ok := attrs["label"]; ok {
result.Label = &v
}
if v, ok := attrs["seeders"]; ok {
if s, err := strconv.Atoi(v); err == nil {
result.Seeders = &s
}
}
if v, ok := attrs["leechers"]; ok {
if l, err := strconv.Atoi(v); err == nil {
result.Leechers = &l
}
}
if v, ok := attrs["grabs"]; ok {
if g, err := strconv.Atoi(v); err == nil {
result.Grabs = &g
}
}
if v, ok := attrs["infohash"]; ok {
result.Infohash = &v
}
if v, ok := attrs["magneturl"]; ok {
result.MagnetURL = &v
}
return result
}