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