8 Commits

Author SHA1 Message Date
43c2c22de7 feat(logs): add logs for list handler
All checks were successful
release / docker-image (push) Successful in 59s
release / goreleaser (push) Successful in 10m1s
2026-01-25 13:00:41 +03:00
fbc43946f8 feat(logs): add more logs 2026-01-25 12:58:16 +03:00
daf99c5b66 feat(proxy): add proxy from env variables 2026-01-25 12:55:07 +03:00
4f8118562e chore(agents): update agents.md 2026-01-25 12:50:23 +03:00
1e32de279e chore(build): fix fetch tags for goreleaser
All checks were successful
release / docker-image (push) Successful in 46s
release / goreleaser (push) Successful in 9m59s
2026-01-09 15:02:41 +03:00
b5c8ec45aa chore(build): fix fetch tags for goreleaser
Some checks failed
release / goreleaser (push) Failing after 35s
release / docker-image (push) Successful in 48s
2026-01-09 15:00:51 +03:00
6e51b420d4 chore(build): fetch tags for goreleaser
Some checks failed
release / docker-image (push) Successful in 55s
release / goreleaser (push) Failing after 35s
2026-01-09 14:54:26 +03:00
f9732d9c33 chore(build): fix go build
All checks were successful
release / docker-image (push) Successful in 46s
release / goreleaser (push) Successful in 9m57s
2026-01-09 14:23:25 +03:00
4 changed files with 124 additions and 9 deletions

View File

@@ -12,11 +12,14 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v6 uses: actions/checkout@v6
with:
fetch-depth: 0
fetch-tags: true
- name: Setup Go - name: Setup Go
uses: actions/setup-go@v6 uses: actions/setup-go@v6
with: with:
go-version: 'go.mod' go-version-file: 'go.mod'
- name: Run GoReleaser - name: Run GoReleaser
uses: goreleaser/goreleaser-action@v6 uses: goreleaser/goreleaser-action@v6

107
AGENTS.md
View File

@@ -1,9 +1,104 @@
## Role # AGENTS.md
Write code like a Go Senior Developer. This file provides guidance to LLM agents when working with code in this repository.
Use the best practices, write modern idiomatic code, and handle all errors.
Keep an eye on security.
## Context ## Project Overview
- go 1.25.5 Trackers is a Go application that aggregates torrent tracker links from multiple sources (HTTP, HTTPS, and local files). It:
- Polls configured sources at regular intervals
- Deduplicates and validates tracker links
- Caches results to disk for resilience
- Serves aggregated tracker lists via HTTP API
- Runs a single HTTP server with minimal dependencies
This is an educational project written in Go 1.25.5.
## Development Commands
### Build
```bash
go build -o trackers ./main.go
```
### Run
```bash
go run main.go -config config.toml
```
### Test
The codebase has no tests currently. Tests can be added with:
```bash
go test ./...
```
To run a single test:
```bash
go test -run TestName
```
### Lint
```bash
go fmt ./...
go vet ./...
```
### Release Build
Uses goreleaser for cross-platform builds (Linux amd64/arm64):
```bash
goreleaser build --snapshot --clean
```
## Architecture
**Single File Design**: All code is in `main.go` with clear functional separation:
1. **Config System** (`loadConfig`): Parses TOML configuration with defaults
- `port`: HTTP server port (default: 8080)
- `cache_dir`: Directory for caching tracker lists (default: cache/)
- `poll_interval`: How often to refresh sources (default: 60m)
- `sources`: Array of URLs/file paths to fetch tracker lists from
2. **Aggregator** (type `Aggregator`): Thread-safe in-memory deduplication
- Maintains per-source tracker sets using `sync.RWMutex`
- `Update()`: Stores new tracker list for a source
- `List()`: Returns combined sorted list across all sources
3. **Polling System** (`pollSource`/`runOnce`): Background goroutine per source
- Fetches source on startup and at configured intervals
- Updates aggregator and writes to cache on success
- Graceful shutdown on SIGINT/SIGTERM
4. **HTTP Handler** (`/list` endpoint): Returns deduplicated tracker list as plain text
- Links separated by double newlines
- Read timeouts enforce reasonable request handling
5. **Source Fetching** (`fetchSource`): Pluggable source handlers
- **HTTP/HTTPS**: Makes requests with context support and timeout (15s)
- **File**: Reads local files via `file://` URLs
- Response is parsed line-by-line
6. **Link Validation** (`normalizeLinks`/`isValidTrackerLink`):
- Strips whitespace and empty lines
- Validates URL format and supported schemes: `http`, `https`, `udp`, `ws`, `wss`
- Deduplicates via map-based set
7. **Caching** (`writeCache`/`loadCachedLinks`): SHA1-hashed filenames in `cache_dir/`
- Enables graceful degradation if source becomes unavailable
- Filenames are hex-encoded SHA1(source_url) + ".txt"
## Key Design Decisions
- **No external dependencies except go-toml**: Keeps binary small and build simple
- **Simple HTTP server**: Uses stdlib `net/http` instead of frameworks
- **Per-source goroutines**: Allows independent polling without blocking
- **RWMutex for reads**: Readers don't block each other when listing trackers
- **Context propagation**: Respects shutdown signals in all async operations
- **Line-based parsing**: Flexible input format (handles various tracker list formats)
## Testing Notes
The project follows Go conventions but has no test files. Consider adding tests for:
- Link validation edge cases
- Config parsing with invalid inputs
- Concurrent aggregator updates
- Cache file I/O

5
CLAUDE.md Normal file
View File

@@ -0,0 +1,5 @@
# CLAUDE.md
@AGENTS.md

16
main.go
View File

@@ -122,7 +122,13 @@ func main() {
agg := NewAggregator() agg := NewAggregator()
client := &http.Client{Timeout: 15 * time.Second} transport := &http.Transport{
Proxy: http.ProxyFromEnvironment,
}
client := &http.Client{
Timeout: 15 * time.Second,
Transport: transport,
}
for _, source := range cfg.Sources { for _, source := range cfg.Sources {
cached, err := loadCachedLinks(cfg.CacheDir, source) cached, err := loadCachedLinks(cfg.CacheDir, source)
@@ -143,6 +149,7 @@ func main() {
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("/list", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/list", func(w http.ResponseWriter, r *http.Request) {
log.Printf("request /list from %s [%s %s]", r.RemoteAddr, r.Method, r.UserAgent())
links := agg.List() links := agg.List()
w.Header().Set("Content-Type", "text/plain; charset=utf-8") w.Header().Set("Content-Type", "text/plain; charset=utf-8")
for i, link := range links { for i, link := range links {
@@ -151,6 +158,7 @@ func main() {
} }
_, _ = w.Write([]byte(link)) _, _ = w.Write([]byte(link))
} }
log.Printf("response /list to %s: %d links", r.RemoteAddr, len(links))
}) })
server := &http.Server{ server := &http.Server{
@@ -197,10 +205,11 @@ func pollSource(ctx context.Context, source string, interval time.Duration, cach
func runOnce(ctx context.Context, source string, cacheDir string, agg *Aggregator, client *http.Client) { func runOnce(ctx context.Context, source string, cacheDir string, agg *Aggregator, client *http.Client) {
links, err := fetchSource(ctx, source, client) links, err := fetchSource(ctx, source, client)
if err != nil { if err != nil {
log.Printf("poll %s: %v", source, err) log.Printf("poll %s: failed - %v", source, err)
return return
} }
if len(links) == 0 { if len(links) == 0 {
log.Printf("poll %s: success - 0 links", source)
agg.Update(source, nil) agg.Update(source, nil)
if err := writeCache(cacheDir, source, nil); err != nil { if err := writeCache(cacheDir, source, nil); err != nil {
log.Printf("write cache %s: %v", source, err) log.Printf("write cache %s: %v", source, err)
@@ -208,6 +217,7 @@ func runOnce(ctx context.Context, source string, cacheDir string, agg *Aggregato
return return
} }
log.Printf("poll %s: success - %d links", source, len(links))
agg.Update(source, links) agg.Update(source, links)
if err := writeCache(cacheDir, source, links); err != nil { if err := writeCache(cacheDir, source, links); err != nil {
log.Printf("write cache %s: %v", source, err) log.Printf("write cache %s: %v", source, err)
@@ -234,6 +244,7 @@ func fetchSource(ctx context.Context, source string, client *http.Client) ([]str
if resp.StatusCode < 200 || resp.StatusCode >= 300 { if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("unexpected status: %s", resp.Status) return nil, fmt.Errorf("unexpected status: %s", resp.Status)
} }
log.Printf("fetch %s: HTTP %d", source, resp.StatusCode)
body, err := io.ReadAll(resp.Body) body, err := io.ReadAll(resp.Body)
if err != nil { if err != nil {
return nil, fmt.Errorf("read body: %w", err) return nil, fmt.Errorf("read body: %w", err)
@@ -248,6 +259,7 @@ func fetchSource(ctx context.Context, source string, client *http.Client) ([]str
if err != nil { if err != nil {
return nil, fmt.Errorf("read file: %w", err) return nil, fmt.Errorf("read file: %w", err)
} }
log.Printf("fetch %s: file read %d bytes", source, len(data))
return normalizeLinks(string(data)), nil return normalizeLinks(string(data)), nil
default: default:
return nil, fmt.Errorf("unsupported source scheme: %s", u.Scheme) return nil, fmt.Errorf("unsupported source scheme: %s", u.Scheme)