2024-08-15 12:01:00 +02:00
|
|
|
|
package main
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"bytes"
|
2024-08-20 11:22:29 +02:00
|
|
|
|
"context"
|
2024-08-15 12:01:00 +02:00
|
|
|
|
"database/sql"
|
|
|
|
|
"encoding/json"
|
|
|
|
|
"encoding/xml"
|
|
|
|
|
"flag"
|
|
|
|
|
"fmt"
|
|
|
|
|
"html/template"
|
|
|
|
|
"io"
|
|
|
|
|
"log"
|
|
|
|
|
"net/http"
|
2024-08-20 11:22:29 +02:00
|
|
|
|
"os"
|
2024-08-15 12:01:00 +02:00
|
|
|
|
"path/filepath"
|
|
|
|
|
"runtime/debug"
|
|
|
|
|
"strconv"
|
|
|
|
|
"strings"
|
|
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
"github.com/etix/goscrape"
|
|
|
|
|
_ "github.com/mattn/go-sqlite3"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
type Torrent struct {
|
|
|
|
|
URL string `json:"url"`
|
|
|
|
|
TopLevelGroupName string `json:"top_level_group_name"`
|
|
|
|
|
GroupName string `json:"group_name"`
|
|
|
|
|
DisplayName string `json:"display_name"`
|
|
|
|
|
AddedToTorrentsList string `json:"added_to_torrents_list_at"`
|
|
|
|
|
IsMetadata bool `json:"is_metadata"`
|
|
|
|
|
BTIH string `json:"btih"`
|
|
|
|
|
MagnetLink string `json:"magnet_link"`
|
|
|
|
|
TorrentSize int `json:"torrent_size"`
|
|
|
|
|
NumFiles int `json:"num_files"`
|
|
|
|
|
DataSize int64 `json:"data_size"`
|
|
|
|
|
AACurrentlySeeding bool `json:"aa_currently_seeding"`
|
|
|
|
|
Obsolete bool `json:"obsolete"`
|
|
|
|
|
Embargo bool `json:"embargo"`
|
|
|
|
|
Seeders int `json:"seeders"`
|
|
|
|
|
Leechers int `json:"leechers"`
|
|
|
|
|
Completed int `json:"completed"`
|
|
|
|
|
StatsScrapedAt string `json:"stats_scraped_at"`
|
|
|
|
|
PartiallyBroken bool `json:"partially_broken"`
|
|
|
|
|
FormattedSeeders string
|
|
|
|
|
FormattedLeechers string
|
|
|
|
|
SeedersColor string
|
|
|
|
|
LeechersColor string
|
|
|
|
|
FormattedDataSize string
|
|
|
|
|
FormattedAddedDate string
|
|
|
|
|
MetadataLabel string
|
|
|
|
|
StatusLabel template.HTML
|
|
|
|
|
SeederStats []SeederStat `json:"seederStats"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type GroupData struct {
|
|
|
|
|
TopLevelGroupName string
|
|
|
|
|
GroupName string
|
|
|
|
|
Torrents []Torrent
|
|
|
|
|
TotalCount int
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func wrap(handler func(http.ResponseWriter, *http.Request) error) http.HandlerFunc {
|
|
|
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
defer func() {
|
|
|
|
|
if rec := recover(); rec != nil {
|
|
|
|
|
log.Printf("Panic in %s: %v\n%s", r.URL.Path, rec, debug.Stack())
|
|
|
|
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
|
|
|
|
}
|
|
|
|
|
}()
|
|
|
|
|
|
|
|
|
|
err := handler(w, r)
|
|
|
|
|
if err != nil {
|
|
|
|
|
log.Printf("Error in %s: %v", r.URL.Path, err)
|
|
|
|
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2024-08-20 11:22:29 +02:00
|
|
|
|
var readDB, writeDB *sql.DB
|
|
|
|
|
|
|
|
|
|
func initDB(dbPath string) error {
|
|
|
|
|
var err error
|
|
|
|
|
readDB, err = sql.Open("sqlite3", dbPath)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("error opening read database: %v", err)
|
|
|
|
|
}
|
|
|
|
|
writeDB, err = sql.Open("sqlite3", dbPath)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("error opening write database: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Enable WAL mode for both connections
|
|
|
|
|
for _, db := range []*sql.DB{readDB, writeDB} {
|
|
|
|
|
_, err = db.Exec("PRAGMA journal_mode=WAL;")
|
|
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("error setting journal mode: %v", err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func withReadTx(fn func(*sql.Tx) error) error {
|
|
|
|
|
tx, err := readDB.BeginTx(context.Background(), &sql.TxOptions{ReadOnly: true})
|
|
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("error beginning read transaction: %v", err)
|
|
|
|
|
}
|
|
|
|
|
defer tx.Rollback()
|
|
|
|
|
|
|
|
|
|
if err := fn(tx); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return tx.Commit()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var (
|
|
|
|
|
torrentsEnabled bool
|
|
|
|
|
dbPath string
|
|
|
|
|
assetsPath string
|
|
|
|
|
torrentsPath string
|
|
|
|
|
)
|
|
|
|
|
|
2024-08-15 12:01:00 +02:00
|
|
|
|
func main() {
|
|
|
|
|
port := flag.Int("port", 8080, "Port to run the server on")
|
2024-08-20 11:22:29 +02:00
|
|
|
|
dataDir := flag.String("directory", ".", "Directory to store data")
|
|
|
|
|
flag.BoolVar(&torrentsEnabled, "torrents", false, "Download and store torrent files")
|
2024-08-15 12:01:00 +02:00
|
|
|
|
flag.Parse()
|
|
|
|
|
|
2024-08-20 11:22:29 +02:00
|
|
|
|
// Define paths
|
|
|
|
|
dbPath = filepath.Join(*dataDir, "torrents.db")
|
|
|
|
|
assetsPath = filepath.Join(*dataDir, "assets")
|
|
|
|
|
torrentsPath = filepath.Join(assetsPath, "torrents")
|
|
|
|
|
|
|
|
|
|
// Ensure directories exist
|
|
|
|
|
if err := os.MkdirAll(assetsPath, 0755); err != nil {
|
|
|
|
|
log.Fatalf("Error creating assets directory: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if err := os.MkdirAll(torrentsPath, 0755); err != nil {
|
|
|
|
|
log.Fatalf("Error creating torrents directory: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Ensure necessary directories exist
|
|
|
|
|
if err := os.MkdirAll(assetsPath, 0755); err != nil {
|
|
|
|
|
log.Fatalf("Error creating assets directory: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if torrentsEnabled {
|
|
|
|
|
if err := os.MkdirAll(torrentsPath, 0755); err != nil {
|
|
|
|
|
log.Fatalf("Error creating torrents directory: %v", err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
err := initDB(dbPath)
|
2024-08-15 12:01:00 +02:00
|
|
|
|
if err != nil {
|
2024-08-20 11:22:29 +02:00
|
|
|
|
log.Fatalf("Error initializing database: %v", err)
|
2024-08-15 12:01:00 +02:00
|
|
|
|
}
|
2024-08-20 11:22:29 +02:00
|
|
|
|
defer readDB.Close()
|
|
|
|
|
defer writeDB.Close()
|
2024-08-15 12:01:00 +02:00
|
|
|
|
|
|
|
|
|
createTable()
|
|
|
|
|
|
|
|
|
|
go updateTorrents()
|
|
|
|
|
go updateStats()
|
|
|
|
|
go updateDailySeederStats()
|
|
|
|
|
|
|
|
|
|
http.HandleFunc("/", wrap(handleRoot))
|
|
|
|
|
http.HandleFunc("/full/", wrap(handleFullList))
|
|
|
|
|
http.HandleFunc("/stats/", wrap(handleStats))
|
|
|
|
|
http.HandleFunc("/seeder-stats/", wrap(handleSeederStats))
|
|
|
|
|
http.HandleFunc("/json", wrap(handleTorrentStats))
|
|
|
|
|
http.HandleFunc("/generate-torrent-list", wrap(handleGenerateTorrentList))
|
2024-08-20 11:22:29 +02:00
|
|
|
|
http.Handle("/assets/", http.StripPrefix("/assets/", http.FileServer(http.Dir(assetsPath))))
|
2024-08-15 12:01:00 +02:00
|
|
|
|
|
|
|
|
|
log.Printf("Starting server on port %d", *port)
|
|
|
|
|
log.Fatal(http.ListenAndServe(fmt.Sprintf(":%d", *port), nil))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func createTable() {
|
2024-08-20 11:22:29 +02:00
|
|
|
|
_, err := writeDB.Exec(`
|
2024-08-15 12:01:00 +02:00
|
|
|
|
CREATE TABLE IF NOT EXISTS torrents (
|
|
|
|
|
btih TEXT PRIMARY KEY,
|
|
|
|
|
url TEXT,
|
|
|
|
|
top_level_group_name TEXT,
|
|
|
|
|
group_name TEXT,
|
|
|
|
|
display_name TEXT,
|
|
|
|
|
added_to_torrents_list DATETIME,
|
|
|
|
|
is_metadata INTEGER,
|
|
|
|
|
magnet_link TEXT,
|
|
|
|
|
torrent_size INTEGER,
|
|
|
|
|
num_files INTEGER,
|
|
|
|
|
data_size INTEGER,
|
|
|
|
|
aa_currently_seeding INTEGER,
|
|
|
|
|
obsolete INTEGER,
|
|
|
|
|
embargo INTEGER,
|
|
|
|
|
seeders INTEGER,
|
|
|
|
|
leechers INTEGER,
|
|
|
|
|
completed INTEGER,
|
|
|
|
|
stats_scraped_at DATETIME,
|
|
|
|
|
partially_broken INTEGER
|
|
|
|
|
)
|
|
|
|
|
`)
|
|
|
|
|
if err != nil {
|
|
|
|
|
log.Fatalf("Error creating table: %v", err)
|
|
|
|
|
}
|
2024-08-20 11:22:29 +02:00
|
|
|
|
_, err = writeDB.Exec(`
|
2024-08-15 12:01:00 +02:00
|
|
|
|
CREATE TABLE IF NOT EXISTS torrent_updates (
|
|
|
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
|
|
|
btih TEXT,
|
|
|
|
|
seeders INTEGER,
|
|
|
|
|
leechers INTEGER,
|
|
|
|
|
completed INTEGER,
|
|
|
|
|
stats_scraped_at DATETIME,
|
|
|
|
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
|
|
|
)
|
|
|
|
|
`)
|
|
|
|
|
if err != nil {
|
|
|
|
|
log.Fatalf("Error creating torrent_updates table: %v", err)
|
|
|
|
|
}
|
2024-08-20 11:22:29 +02:00
|
|
|
|
_, err = writeDB.Exec(`
|
2024-08-15 12:01:00 +02:00
|
|
|
|
CREATE TABLE IF NOT EXISTS daily_seeder_stats (
|
|
|
|
|
date DATE PRIMARY KEY,
|
|
|
|
|
low_seeders_tb FLOAT, -- Total TB for torrents with <4 seeders
|
|
|
|
|
medium_seeders_tb FLOAT, -- Total TB for torrents with 4-10 seeders
|
|
|
|
|
high_seeders_tb FLOAT -- Total TB for torrents with >10 seeders
|
|
|
|
|
);
|
|
|
|
|
`)
|
|
|
|
|
if err != nil {
|
|
|
|
|
log.Fatalf("Error creating torrent_updates table: %v", err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func updateTorrents() {
|
|
|
|
|
ticker := time.NewTicker(24 * time.Hour)
|
|
|
|
|
defer ticker.Stop()
|
|
|
|
|
|
|
|
|
|
for {
|
|
|
|
|
if err := performTorrentUpdate(); err != nil {
|
|
|
|
|
log.Printf("Error updating torrents: %v", err)
|
|
|
|
|
}
|
|
|
|
|
<-ticker.C
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func performTorrentUpdate() error {
|
|
|
|
|
resp, err := http.Get("https://annas-archive.org/dyn/torrents.json")
|
|
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("error fetching torrents: %w", err)
|
|
|
|
|
}
|
|
|
|
|
defer resp.Body.Close()
|
|
|
|
|
|
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("error reading response body: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
2024-08-20 11:22:29 +02:00
|
|
|
|
var torrentsData []Torrent
|
|
|
|
|
if err := json.Unmarshal(body, &torrentsData); err != nil {
|
2024-08-15 12:01:00 +02:00
|
|
|
|
return fmt.Errorf("error decoding torrents: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
2024-08-20 11:22:29 +02:00
|
|
|
|
// Ensure the assets directory exists
|
|
|
|
|
if err := os.MkdirAll(assetsPath, 0755); err != nil {
|
|
|
|
|
return fmt.Errorf("error creating assets directory: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if torrentsEnabled {
|
|
|
|
|
// Ensure the torrents directory exists
|
|
|
|
|
if err := os.MkdirAll(torrentsPath, 0755); err != nil {
|
|
|
|
|
return fmt.Errorf("error creating torrents directory: %w", err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for _, t := range torrentsData {
|
|
|
|
|
_, err := writeDB.Exec(`
|
2024-08-15 12:01:00 +02:00
|
|
|
|
INSERT OR REPLACE INTO torrents (
|
|
|
|
|
btih, url, top_level_group_name, group_name, display_name, added_to_torrents_list,
|
|
|
|
|
is_metadata, magnet_link, torrent_size, num_files, data_size, aa_currently_seeding,
|
|
|
|
|
obsolete, embargo, seeders, leechers, completed, stats_scraped_at, partially_broken
|
|
|
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
|
|
|
t.BTIH, t.URL, t.TopLevelGroupName, t.GroupName, t.DisplayName, t.AddedToTorrentsList,
|
|
|
|
|
t.IsMetadata, t.MagnetLink, t.TorrentSize, t.NumFiles, t.DataSize, t.AACurrentlySeeding,
|
|
|
|
|
t.Obsolete, t.Embargo, t.Seeders, t.Leechers, t.Completed, t.StatsScrapedAt, t.PartiallyBroken)
|
|
|
|
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
log.Printf("Error inserting/updating torrent %s: %v", t.BTIH, err)
|
|
|
|
|
} else {
|
|
|
|
|
log.Printf("Successfully inserted/updated torrent: %s", t.BTIH)
|
|
|
|
|
}
|
2024-08-20 11:22:29 +02:00
|
|
|
|
|
|
|
|
|
// Download torrent file if --torrents flag is set
|
|
|
|
|
if torrentsEnabled {
|
|
|
|
|
torrentPath := filepath.Join(torrentsPath, t.BTIH+".torrent")
|
|
|
|
|
if _, err := os.Stat(torrentPath); os.IsNotExist(err) {
|
|
|
|
|
if err := downloadTorrentFile(t.URL, torrentPath); err != nil {
|
|
|
|
|
log.Printf("Error downloading torrent file for %s: %v", t.BTIH, err)
|
|
|
|
|
} else {
|
|
|
|
|
log.Printf("Successfully downloaded torrent file: %s", t.BTIH)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2024-08-15 12:01:00 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
log.Println("Torrents updated successfully")
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
2024-08-20 11:22:29 +02:00
|
|
|
|
func downloadTorrentFile(url, filepath string) error {
|
|
|
|
|
resp, err := http.Get(url)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("error downloading torrent file: %w", err)
|
|
|
|
|
}
|
|
|
|
|
defer resp.Body.Close()
|
|
|
|
|
|
|
|
|
|
out, err := os.Create(filepath)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("error creating torrent file: %w", err)
|
|
|
|
|
}
|
|
|
|
|
defer out.Close()
|
|
|
|
|
|
|
|
|
|
_, err = io.Copy(out, resp.Body)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("error writing torrent file: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return nil
|
|
|
|
|
}
|
2024-08-15 12:01:00 +02:00
|
|
|
|
|
2024-08-20 11:22:29 +02:00
|
|
|
|
func updateStats() {
|
2024-08-15 12:01:00 +02:00
|
|
|
|
log.Println("Starting stats scraping process...")
|
|
|
|
|
|
|
|
|
|
ticker := time.NewTicker(30 * time.Minute)
|
|
|
|
|
for {
|
|
|
|
|
<-ticker.C
|
|
|
|
|
|
|
|
|
|
var allInfohashes []string
|
2024-08-20 11:22:29 +02:00
|
|
|
|
err := withReadTx(func(tx *sql.Tx) error {
|
|
|
|
|
// Query to select all btih values
|
|
|
|
|
rows, err := tx.Query("SELECT btih FROM torrents")
|
|
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("error querying torrents: %v", err)
|
2024-08-15 12:01:00 +02:00
|
|
|
|
}
|
2024-08-20 11:22:29 +02:00
|
|
|
|
defer rows.Close()
|
2024-08-15 12:01:00 +02:00
|
|
|
|
|
2024-08-20 11:22:29 +02:00
|
|
|
|
// Slice to hold the infohash values
|
|
|
|
|
for rows.Next() {
|
|
|
|
|
var btih string
|
|
|
|
|
if err := rows.Scan(&btih); err != nil {
|
|
|
|
|
return fmt.Errorf("error scanning row: %v", err)
|
|
|
|
|
}
|
|
|
|
|
allInfohashes = append(allInfohashes, btih)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check for any errors encountered during iteration
|
|
|
|
|
if err := rows.Err(); err != nil {
|
|
|
|
|
return fmt.Errorf("error iterating rows: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return nil
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
log.Printf("Error fetching infohashes: %v", err)
|
|
|
|
|
continue
|
2024-08-15 12:01:00 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Create a new instance of the goscrape library
|
|
|
|
|
s, err := goscrape.New("udp://tracker.opentrackr.org:1337/announce")
|
|
|
|
|
if err != nil {
|
2024-08-20 11:22:29 +02:00
|
|
|
|
log.Printf("Error creating goscrape instance: %v", err)
|
|
|
|
|
continue
|
2024-08-15 12:01:00 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Process infohashes in bundles of 50
|
|
|
|
|
const bundleSize = 50
|
|
|
|
|
for i := 0; i < len(allInfohashes); i += bundleSize {
|
|
|
|
|
end := i + bundleSize
|
|
|
|
|
if end > len(allInfohashes) {
|
|
|
|
|
end = len(allInfohashes)
|
|
|
|
|
}
|
|
|
|
|
bundle := allInfohashes[i:end]
|
|
|
|
|
|
|
|
|
|
// Convert bundle to [][]byte
|
|
|
|
|
infohash := make([][]byte, len(bundle))
|
|
|
|
|
for j, hash := range bundle {
|
|
|
|
|
infohash[j] = []byte(hash)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Scrape the current bundle
|
|
|
|
|
res, err := s.Scrape(infohash...)
|
|
|
|
|
if err != nil {
|
2024-08-20 11:22:29 +02:00
|
|
|
|
log.Printf("Error scraping infohashes: %v", err)
|
|
|
|
|
continue
|
2024-08-15 12:01:00 +02:00
|
|
|
|
}
|
|
|
|
|
|
2024-08-20 11:22:29 +02:00
|
|
|
|
// Loop over the results and update the database
|
2024-08-15 12:01:00 +02:00
|
|
|
|
for _, r := range res {
|
2024-08-20 11:22:29 +02:00
|
|
|
|
fmt.Printf("Infohash: %s, Seeders: %d, Leechers: %d, Completed: %d\n",
|
|
|
|
|
string(r.Infohash), r.Seeders, r.Leechers, r.Completed)
|
|
|
|
|
|
|
|
|
|
// Use a transaction for the updates
|
|
|
|
|
tx, err := writeDB.Begin()
|
2024-08-15 12:01:00 +02:00
|
|
|
|
if err != nil {
|
2024-08-20 11:22:29 +02:00
|
|
|
|
log.Printf("Error beginning transaction: %v", err)
|
|
|
|
|
continue
|
2024-08-15 12:01:00 +02:00
|
|
|
|
}
|
|
|
|
|
|
2024-08-20 11:22:29 +02:00
|
|
|
|
// Update torrents table
|
|
|
|
|
_, err = tx.Exec(`
|
|
|
|
|
UPDATE torrents
|
|
|
|
|
SET seeders = ?, leechers = ?, completed = ?, stats_scraped_at = ?
|
|
|
|
|
WHERE btih = ?
|
|
|
|
|
`, r.Seeders, r.Leechers, r.Completed, time.Now(), string(r.Infohash))
|
2024-08-15 12:01:00 +02:00
|
|
|
|
if err != nil {
|
2024-08-20 11:22:29 +02:00
|
|
|
|
tx.Rollback()
|
2024-08-15 12:01:00 +02:00
|
|
|
|
log.Printf("Error updating torrent: %v", err)
|
2024-08-20 11:22:29 +02:00
|
|
|
|
continue
|
2024-08-15 12:01:00 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Insert update details into torrent_updates table
|
2024-08-20 11:22:29 +02:00
|
|
|
|
_, err = tx.Exec(`
|
|
|
|
|
INSERT INTO torrent_updates (
|
|
|
|
|
btih, seeders, leechers, completed, stats_scraped_at
|
|
|
|
|
) VALUES (?, ?, ?, ?, ?)
|
|
|
|
|
`, string(r.Infohash), r.Seeders, r.Leechers, r.Completed, time.Now())
|
2024-08-15 12:01:00 +02:00
|
|
|
|
if err != nil {
|
2024-08-20 11:22:29 +02:00
|
|
|
|
tx.Rollback()
|
2024-08-15 12:01:00 +02:00
|
|
|
|
log.Printf("Error inserting update into torrent_updates: %v", err)
|
2024-08-20 11:22:29 +02:00
|
|
|
|
continue
|
2024-08-15 12:01:00 +02:00
|
|
|
|
}
|
|
|
|
|
|
2024-08-20 11:22:29 +02:00
|
|
|
|
if err := tx.Commit(); err != nil {
|
|
|
|
|
log.Printf("Error committing transaction: %v", err)
|
|
|
|
|
}
|
2024-08-15 12:01:00 +02:00
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
log.Println("Stats updated successfully")
|
|
|
|
|
}
|
|
|
|
|
}
|
2024-08-20 11:22:29 +02:00
|
|
|
|
|
2024-08-15 12:01:00 +02:00
|
|
|
|
func handleSeederStats(w http.ResponseWriter, r *http.Request) error {
|
|
|
|
|
if r.Method != http.MethodGet {
|
|
|
|
|
return fmt.Errorf("method not allowed: %s", r.Method)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var stats []struct {
|
|
|
|
|
Date string `json:"date"`
|
|
|
|
|
LowSeedersTB float64 `json:"lowSeedersTB"`
|
|
|
|
|
MediumSeedersTB float64 `json:"mediumSeedersTB"`
|
|
|
|
|
HighSeedersTB float64 `json:"highSeedersTB"`
|
|
|
|
|
}
|
|
|
|
|
|
2024-08-20 11:22:29 +02:00
|
|
|
|
err := withReadTx(func(tx *sql.Tx) error {
|
|
|
|
|
rows, err := tx.Query("SELECT date, low_seeders_tb, medium_seeders_tb, high_seeders_tb FROM daily_seeder_stats ORDER BY date")
|
|
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("error querying database: %w", err)
|
2024-08-15 12:01:00 +02:00
|
|
|
|
}
|
2024-08-20 11:22:29 +02:00
|
|
|
|
defer rows.Close()
|
|
|
|
|
|
|
|
|
|
for rows.Next() {
|
|
|
|
|
var stat struct {
|
|
|
|
|
Date time.Time `json:"-"`
|
|
|
|
|
LowSeedersTB float64 `json:"lowSeedersTB"`
|
|
|
|
|
MediumSeedersTB float64 `json:"mediumSeedersTB"`
|
|
|
|
|
HighSeedersTB float64 `json:"highSeedersTB"`
|
|
|
|
|
}
|
|
|
|
|
if err := rows.Scan(&stat.Date, &stat.LowSeedersTB, &stat.MediumSeedersTB, &stat.HighSeedersTB); err != nil {
|
|
|
|
|
return fmt.Errorf("error scanning row: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
formattedStat := struct {
|
|
|
|
|
Date string `json:"date"`
|
|
|
|
|
LowSeedersTB float64 `json:"lowSeedersTB"`
|
|
|
|
|
MediumSeedersTB float64 `json:"mediumSeedersTB"`
|
|
|
|
|
HighSeedersTB float64 `json:"highSeedersTB"`
|
|
|
|
|
}{
|
|
|
|
|
Date: stat.Date.Format("2006-01-02"), // Format date as YYYY-MM-DD
|
|
|
|
|
LowSeedersTB: stat.LowSeedersTB,
|
|
|
|
|
MediumSeedersTB: stat.MediumSeedersTB,
|
|
|
|
|
HighSeedersTB: stat.HighSeedersTB,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
stats = append(stats, formattedStat)
|
2024-08-15 12:01:00 +02:00
|
|
|
|
}
|
|
|
|
|
|
2024-08-20 11:22:29 +02:00
|
|
|
|
if err := rows.Err(); err != nil {
|
|
|
|
|
return fmt.Errorf("error after iterating rows: %w", err)
|
2024-08-15 12:01:00 +02:00
|
|
|
|
}
|
|
|
|
|
|
2024-08-20 11:22:29 +02:00
|
|
|
|
return nil
|
|
|
|
|
})
|
2024-08-15 12:01:00 +02:00
|
|
|
|
|
2024-08-20 11:22:29 +02:00
|
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("error fetching seeder stats: %w", err)
|
2024-08-15 12:01:00 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
|
return json.NewEncoder(w).Encode(stats)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func updateDailySeederStats() {
|
|
|
|
|
log.Println("Starting daily seeder stats update process...")
|
|
|
|
|
|
|
|
|
|
performUpdate := func() error {
|
|
|
|
|
var lowSeedersTB, mediumSeedersTB, highSeedersTB float64
|
2024-08-20 11:22:29 +02:00
|
|
|
|
|
|
|
|
|
// Use readDB for the query
|
|
|
|
|
err := withReadTx(func(tx *sql.Tx) error {
|
|
|
|
|
rows, err := tx.Query(`
|
|
|
|
|
SELECT
|
|
|
|
|
SUM(CASE WHEN seeders < 4 THEN data_size ELSE 0 END) / 1099511627776.0 AS low_seeders_tb,
|
|
|
|
|
SUM(CASE WHEN seeders BETWEEN 4 AND 10 THEN data_size ELSE 0 END) / 1099511627776.0 AS medium_seeders_tb,
|
|
|
|
|
SUM(CASE WHEN seeders > 10 THEN data_size ELSE 0 END) / 1099511627776.0 AS high_seeders_tb
|
|
|
|
|
FROM torrents
|
|
|
|
|
WHERE embargo = 0
|
|
|
|
|
`)
|
2024-08-15 12:01:00 +02:00
|
|
|
|
if err != nil {
|
2024-08-20 11:22:29 +02:00
|
|
|
|
return fmt.Errorf("error querying seeder stats: %w", err)
|
|
|
|
|
}
|
|
|
|
|
defer rows.Close()
|
|
|
|
|
|
|
|
|
|
if rows.Next() {
|
|
|
|
|
err = rows.Scan(&lowSeedersTB, &mediumSeedersTB, &highSeedersTB)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("error scanning row: %w", err)
|
|
|
|
|
}
|
2024-08-15 12:01:00 +02:00
|
|
|
|
}
|
2024-08-20 11:22:29 +02:00
|
|
|
|
|
|
|
|
|
return rows.Err()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("error reading seeder stats: %w", err)
|
2024-08-15 12:01:00 +02:00
|
|
|
|
}
|
|
|
|
|
|
2024-08-20 11:22:29 +02:00
|
|
|
|
// Use writeDB for the update
|
|
|
|
|
tx, err := writeDB.Begin()
|
|
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("error starting write transaction: %w", err)
|
2024-08-15 12:01:00 +02:00
|
|
|
|
}
|
2024-08-20 11:22:29 +02:00
|
|
|
|
defer tx.Rollback()
|
2024-08-15 12:01:00 +02:00
|
|
|
|
|
|
|
|
|
// Insert or update the daily stats
|
|
|
|
|
_, err = tx.Exec(`
|
2024-08-20 11:22:29 +02:00
|
|
|
|
INSERT OR REPLACE INTO daily_seeder_stats (date, low_seeders_tb, medium_seeders_tb, high_seeders_tb)
|
|
|
|
|
VALUES (DATE('now'), ?, ?, ?)`,
|
2024-08-15 12:01:00 +02:00
|
|
|
|
lowSeedersTB, mediumSeedersTB, highSeedersTB)
|
|
|
|
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("error inserting/updating daily seeder stats: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Commit the transaction
|
|
|
|
|
if err := tx.Commit(); err != nil {
|
|
|
|
|
return fmt.Errorf("error committing transaction: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
log.Println("Daily seeder stats updated successfully")
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Perform the first update immediately
|
|
|
|
|
if err := performUpdate(); err != nil {
|
|
|
|
|
log.Printf("Error in initial performUpdate: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Set up a ticker to run once a day
|
|
|
|
|
ticker := time.NewTicker(24 * time.Hour)
|
|
|
|
|
for range ticker.C {
|
|
|
|
|
if err := performUpdate(); err != nil {
|
|
|
|
|
log.Printf("Error in performUpdate: %v", err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func handleTorrentStats(w http.ResponseWriter, r *http.Request) error {
|
|
|
|
|
if r.Method != http.MethodGet {
|
|
|
|
|
return fmt.Errorf("method not allowed: %s", r.Method)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var torrents []Torrent
|
|
|
|
|
|
2024-08-20 11:22:29 +02:00
|
|
|
|
err := withReadTx(func(tx *sql.Tx) error {
|
|
|
|
|
rows, err := tx.Query(`
|
|
|
|
|
SELECT
|
|
|
|
|
url,
|
|
|
|
|
top_level_group_name,
|
|
|
|
|
group_name,
|
|
|
|
|
display_name,
|
|
|
|
|
added_to_torrents_list,
|
|
|
|
|
is_metadata,
|
|
|
|
|
btih,
|
|
|
|
|
magnet_link,
|
|
|
|
|
torrent_size,
|
|
|
|
|
num_files,
|
|
|
|
|
data_size,
|
|
|
|
|
aa_currently_seeding,
|
|
|
|
|
obsolete,
|
|
|
|
|
embargo,
|
|
|
|
|
seeders,
|
|
|
|
|
leechers,
|
|
|
|
|
completed,
|
|
|
|
|
stats_scraped_at,
|
|
|
|
|
partially_broken
|
|
|
|
|
FROM torrents
|
|
|
|
|
`)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("error querying database: %w", err)
|
2024-08-15 12:01:00 +02:00
|
|
|
|
}
|
2024-08-20 11:22:29 +02:00
|
|
|
|
defer rows.Close()
|
2024-08-15 12:01:00 +02:00
|
|
|
|
|
2024-08-20 11:22:29 +02:00
|
|
|
|
for rows.Next() {
|
|
|
|
|
var t Torrent
|
|
|
|
|
if err := rows.Scan(
|
|
|
|
|
&t.URL,
|
|
|
|
|
&t.TopLevelGroupName,
|
|
|
|
|
&t.GroupName,
|
|
|
|
|
&t.DisplayName,
|
|
|
|
|
&t.AddedToTorrentsList,
|
|
|
|
|
&t.IsMetadata,
|
|
|
|
|
&t.BTIH,
|
|
|
|
|
&t.MagnetLink,
|
|
|
|
|
&t.TorrentSize,
|
|
|
|
|
&t.NumFiles,
|
|
|
|
|
&t.DataSize,
|
|
|
|
|
&t.AACurrentlySeeding,
|
|
|
|
|
&t.Obsolete,
|
|
|
|
|
&t.Embargo,
|
|
|
|
|
&t.Seeders,
|
|
|
|
|
&t.Leechers,
|
|
|
|
|
&t.Completed,
|
|
|
|
|
&t.StatsScrapedAt,
|
|
|
|
|
&t.PartiallyBroken,
|
|
|
|
|
); err != nil {
|
|
|
|
|
return fmt.Errorf("error scanning row: %w", err)
|
|
|
|
|
}
|
|
|
|
|
torrents = append(torrents, t)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if err := rows.Err(); err != nil {
|
|
|
|
|
return fmt.Errorf("error after iterating rows: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return nil
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("error fetching torrent stats: %w", err)
|
2024-08-15 12:01:00 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
|
return json.NewEncoder(w).Encode(torrents)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func handleRoot(w http.ResponseWriter, r *http.Request) error {
|
|
|
|
|
torrents, err := fetchTorrents("ORDER BY top_level_group_name, group_name, seeders ASC")
|
|
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("error fetching torrents: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
groups := groupTorrents(torrents)
|
|
|
|
|
return renderTemplate(w, "root", groups)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func handleFullList(w http.ResponseWriter, r *http.Request) error {
|
|
|
|
|
groupName := strings.TrimPrefix(r.URL.Path, "/full/")
|
|
|
|
|
torrents, err := fetchTorrents("WHERE group_name = ? ORDER BY seeders ASC", groupName)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("error fetching torrents: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
data := struct {
|
|
|
|
|
GroupName string
|
|
|
|
|
Torrents []Torrent
|
|
|
|
|
}{
|
|
|
|
|
GroupName: groupName,
|
|
|
|
|
Torrents: torrents,
|
|
|
|
|
}
|
|
|
|
|
return renderTemplate(w, "fullList", data)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func handleStats(w http.ResponseWriter, r *http.Request) error {
|
|
|
|
|
btih := r.URL.Path[len("/stats/"):]
|
|
|
|
|
torrents, err := fetchTorrents("WHERE btih = ?", btih)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("error fetching torrent stats: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if len(torrents) == 0 {
|
|
|
|
|
http.NotFound(w, r)
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Fetch daily average seeder counts
|
|
|
|
|
seederStats, err := fetchDailySeederStats(btih)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("error fetching seeder stats: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Add SeederStats to the Torrent struct
|
|
|
|
|
torrents[0].SeederStats = seederStats
|
|
|
|
|
|
|
|
|
|
return renderTemplate(w, "stats", torrents[0])
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type SeederStat struct {
|
|
|
|
|
Date string `json:"date"`
|
|
|
|
|
Seeders float64 `json:"seeders"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func fetchDailySeederStats(btih string) ([]SeederStat, error) {
|
|
|
|
|
query := `
|
2024-08-20 11:22:29 +02:00
|
|
|
|
SELECT
|
|
|
|
|
DATE(stats_scraped_at) as date,
|
|
|
|
|
AVG(seeders) as avg_seeders
|
|
|
|
|
FROM
|
|
|
|
|
torrent_updates
|
|
|
|
|
WHERE
|
|
|
|
|
btih = ?
|
|
|
|
|
GROUP BY
|
|
|
|
|
DATE(stats_scraped_at)
|
|
|
|
|
ORDER BY
|
|
|
|
|
date ASC
|
|
|
|
|
`
|
2024-08-15 12:01:00 +02:00
|
|
|
|
|
|
|
|
|
var stats []SeederStat
|
2024-08-20 11:22:29 +02:00
|
|
|
|
|
|
|
|
|
err := withReadTx(func(tx *sql.Tx) error {
|
|
|
|
|
rows, err := tx.Query(query, btih)
|
2024-08-15 12:01:00 +02:00
|
|
|
|
if err != nil {
|
2024-08-20 11:22:29 +02:00
|
|
|
|
return fmt.Errorf("error querying database: %w", err)
|
2024-08-15 12:01:00 +02:00
|
|
|
|
}
|
2024-08-20 11:22:29 +02:00
|
|
|
|
defer rows.Close()
|
|
|
|
|
|
|
|
|
|
for rows.Next() {
|
|
|
|
|
var stat SeederStat
|
|
|
|
|
err := rows.Scan(&stat.Date, &stat.Seeders)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("error scanning row: %w", err)
|
|
|
|
|
}
|
|
|
|
|
stats = append(stats, stat)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if err := rows.Err(); err != nil {
|
|
|
|
|
return fmt.Errorf("error after iterating rows: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return nil
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("error fetching daily seeder stats: %w", err)
|
2024-08-15 12:01:00 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return stats, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func handleGenerateTorrentList(w http.ResponseWriter, r *http.Request) error {
|
|
|
|
|
if r.Method != http.MethodPost {
|
|
|
|
|
return fmt.Errorf("method not allowed: %s", r.Method)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
maxTB, err := strconv.ParseFloat(r.FormValue("maxTB"), 64)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("invalid max TB value: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
listType := r.FormValue("listType")
|
|
|
|
|
format := r.FormValue("format")
|
|
|
|
|
|
|
|
|
|
torrents, err := generateTorrentList(maxTB, listType)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("error generating torrent list: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
switch format {
|
|
|
|
|
case "xml":
|
|
|
|
|
w.Header().Set("Content-Type", "application/xml")
|
|
|
|
|
xmlResponse, err := convertToXML(torrents)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("error converting to XML: %w", err)
|
|
|
|
|
}
|
|
|
|
|
_, err = w.Write(xmlResponse)
|
|
|
|
|
return err
|
|
|
|
|
|
|
|
|
|
case "txt":
|
|
|
|
|
w.Header().Set("Content-Type", "text/plain")
|
|
|
|
|
txtResponse := convertToTXT(torrents)
|
|
|
|
|
_, err = w.Write([]byte(txtResponse))
|
|
|
|
|
return err
|
|
|
|
|
|
|
|
|
|
case "json":
|
|
|
|
|
fallthrough
|
|
|
|
|
default:
|
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
|
return json.NewEncoder(w).Encode(torrents)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func fetchTorrents(query string, args ...interface{}) ([]Torrent, error) {
|
|
|
|
|
var torrents []Torrent
|
2024-08-20 11:22:29 +02:00
|
|
|
|
|
|
|
|
|
err := withReadTx(func(tx *sql.Tx) error {
|
|
|
|
|
rows, err := tx.Query("SELECT * FROM torrents "+query, args...)
|
2024-08-15 12:01:00 +02:00
|
|
|
|
if err != nil {
|
2024-08-20 11:22:29 +02:00
|
|
|
|
return fmt.Errorf("error querying torrents: %w", err)
|
2024-08-15 12:01:00 +02:00
|
|
|
|
}
|
2024-08-20 11:22:29 +02:00
|
|
|
|
defer rows.Close()
|
2024-08-15 12:01:00 +02:00
|
|
|
|
|
2024-08-20 11:22:29 +02:00
|
|
|
|
for rows.Next() {
|
|
|
|
|
var t Torrent
|
|
|
|
|
var addedDateStr, statsScrapedAtStr sql.NullString
|
|
|
|
|
err := rows.Scan(
|
|
|
|
|
&t.BTIH, &t.URL, &t.TopLevelGroupName, &t.GroupName, &t.DisplayName,
|
|
|
|
|
&addedDateStr, &t.IsMetadata, &t.MagnetLink, &t.TorrentSize, &t.NumFiles,
|
|
|
|
|
&t.DataSize, &t.AACurrentlySeeding, &t.Obsolete, &t.Embargo, &t.Seeders, &t.Leechers,
|
|
|
|
|
&t.Completed, &statsScrapedAtStr, &t.PartiallyBroken)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("error scanning torrent: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Handle NULL values
|
|
|
|
|
if addedDateStr.Valid {
|
|
|
|
|
t.AddedToTorrentsList = addedDateStr.String
|
|
|
|
|
} else {
|
|
|
|
|
t.AddedToTorrentsList = ""
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if statsScrapedAtStr.Valid {
|
|
|
|
|
t.StatsScrapedAt = statsScrapedAtStr.String
|
|
|
|
|
} else {
|
|
|
|
|
t.StatsScrapedAt = ""
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
formatTorrent(&t)
|
|
|
|
|
torrents = append(torrents, t)
|
2024-08-15 12:01:00 +02:00
|
|
|
|
}
|
|
|
|
|
|
2024-08-20 11:22:29 +02:00
|
|
|
|
if err := rows.Err(); err != nil {
|
|
|
|
|
return fmt.Errorf("error iterating rows: %w", err)
|
2024-08-15 12:01:00 +02:00
|
|
|
|
}
|
|
|
|
|
|
2024-08-20 11:22:29 +02:00
|
|
|
|
return nil
|
|
|
|
|
})
|
2024-08-15 12:01:00 +02:00
|
|
|
|
|
2024-08-20 11:22:29 +02:00
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("error fetching torrents: %w", err)
|
2024-08-15 12:01:00 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return torrents, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func formatTorrent(t *Torrent) {
|
|
|
|
|
t.FormattedSeeders = formatNumber(t.Seeders)
|
|
|
|
|
t.FormattedLeechers = formatNumber(t.Leechers)
|
|
|
|
|
t.FormattedDataSize = formatDataSize(t.DataSize)
|
|
|
|
|
t.FormattedAddedDate = formatDate(t.AddedToTorrentsList)
|
|
|
|
|
t.MetadataLabel = "data"
|
|
|
|
|
if t.IsMetadata {
|
|
|
|
|
t.MetadataLabel = "metadata"
|
|
|
|
|
}
|
|
|
|
|
t.StatusLabel = formatStatus(t.AACurrentlySeeding, t.Seeders, t.Leechers, t.StatsScrapedAt)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func groupTorrents(torrents []Torrent) []GroupData {
|
|
|
|
|
groups := make(map[string]map[string][]Torrent)
|
|
|
|
|
for _, t := range torrents {
|
|
|
|
|
if groups[t.TopLevelGroupName] == nil {
|
|
|
|
|
groups[t.TopLevelGroupName] = make(map[string][]Torrent)
|
|
|
|
|
}
|
|
|
|
|
groups[t.TopLevelGroupName][t.GroupName] = append(groups[t.TopLevelGroupName][t.GroupName], t)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var groupData []GroupData
|
|
|
|
|
for topLevelGroup, groupMap := range groups {
|
|
|
|
|
for group, torrents := range groupMap {
|
|
|
|
|
limitedTorrents := torrents
|
|
|
|
|
if len(torrents) > 20 {
|
|
|
|
|
limitedTorrents = torrents[:20]
|
|
|
|
|
}
|
|
|
|
|
groupData = append(groupData, GroupData{
|
|
|
|
|
TopLevelGroupName: topLevelGroup,
|
|
|
|
|
GroupName: group,
|
|
|
|
|
Torrents: limitedTorrents,
|
|
|
|
|
TotalCount: len(torrents),
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return groupData
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func renderTemplate(w http.ResponseWriter, name string, data interface{}) error {
|
|
|
|
|
funcMap := template.FuncMap{
|
|
|
|
|
"urlsafe": func(s string) template.URL { return template.URL(s) },
|
|
|
|
|
"json": func(v interface{}) template.JS {
|
|
|
|
|
b, err := json.Marshal(v)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return template.JS("null")
|
|
|
|
|
}
|
|
|
|
|
return template.JS(b)
|
|
|
|
|
},
|
2024-08-20 11:22:29 +02:00
|
|
|
|
"torrentsEnabled": func() bool { return torrentsEnabled },
|
2024-08-15 12:01:00 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
tmpl := template.Must(template.New(name).Funcs(funcMap).Parse(getTemplateContent(name)))
|
|
|
|
|
return tmpl.Execute(w, data)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func getTemplateContent(name string) string {
|
|
|
|
|
header := `
|
|
|
|
|
<!DOCTYPE html>
|
|
|
|
|
<html>
|
|
|
|
|
<head>
|
2024-11-20 12:14:40 +01:00
|
|
|
|
<title>hyperreal's Mirror of Anna's Archive</title>
|
2024-08-20 11:22:29 +02:00
|
|
|
|
<link rel="icon" type="image/x-icon" href="/assets/favicon.ico">
|
2024-11-20 12:14:40 +01:00
|
|
|
|
<link rel="stylesheet" href="https://files.hyperreal.coffee/css/aa.css">
|
|
|
|
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
|
|
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
|
|
|
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:ital,wght@0,100..800;1,100..800&display=swap" rel="stylesheet">
|
2024-08-20 11:22:29 +02:00
|
|
|
|
<script src="//cdn.jsdelivr.net/npm/chart.js"></script>
|
2024-08-15 12:01:00 +02:00
|
|
|
|
</head>
|
|
|
|
|
<body>
|
|
|
|
|
<div class="container">
|
|
|
|
|
`
|
|
|
|
|
|
|
|
|
|
footer := `
|
|
|
|
|
</div>
|
|
|
|
|
</body>
|
|
|
|
|
</html>
|
|
|
|
|
`
|
|
|
|
|
|
|
|
|
|
templates := map[string]string{
|
|
|
|
|
"root": header + `
|
|
|
|
|
<section class="header">
|
2024-11-20 12:14:40 +01:00
|
|
|
|
<h1 class="title">hyperreal's Mirror of Anna's Archive</h1>
|
2024-08-20 11:22:29 +02:00
|
|
|
|
</section>
|
|
|
|
|
<p><strong>This is a mirror of the <a href="https://annas-archive.org/torrents">Anna's Archive torrent page</a></strong>. We strive to keep everything as up-to-date as possible, however, we are not affiliated with Anna's Archive and this list is maintained privately.</p>
|
|
|
|
|
<p>This torrent list is the “ultimate unified list” of releases by Anna’s Archive, Library Genesis, Sci-Hub, and others. By seeding these torrents, you help preserve humanity’s knowledge and culture. These torrents represent the vast majority of human knowledge that can be mirrored in bulk.</p>
|
|
|
|
|
<p>These torrents are not meant for downloading individual books. They are meant for long-term preservation. With these torrents you can set up a full mirror of Anna’s Archive, using their <a href="https://software.annas-archive.se/AnnaArchivist/annas-archive">source code</a> and metadata (which can be <a href="https://software.annas-archive.se/AnnaArchivist/annas-archive/-/blob/main/data-imports/README.md">generated</a> or <a href="https://annas-archive.org/torrents#aa_derived_mirror_metadata">downloaded</a> as ElasticSearch and MariaDB databases). We scrape the <a href="https://annas-archive.org/dyn/torrents.json">full torrent list</a> from Anna's Archive every 24 hours. We also have full lists of torrents, as JSON, available <a href="/json">here</a>.</p>
|
|
|
|
|
<canvas id="seederStatsChart" width="800" height="400"></canvas>
|
|
|
|
|
<script>
|
|
|
|
|
async function fetchData() {
|
|
|
|
|
const response = await fetch('/seeder-stats');
|
|
|
|
|
return await response.json();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function createChart() {
|
|
|
|
|
const data = await fetchData();
|
|
|
|
|
const ctx = document.getElementById('seederStatsChart').getContext('2d');
|
|
|
|
|
|
|
|
|
|
new Chart(ctx, {
|
|
|
|
|
type: 'line',
|
|
|
|
|
data: {
|
|
|
|
|
labels: data.map(d => d.date),
|
|
|
|
|
datasets: [
|
|
|
|
|
{
|
|
|
|
|
label: '<4 seeders',
|
|
|
|
|
data: data.map(d => d.lowSeedersTB),
|
|
|
|
|
backgroundColor: 'rgba(255, 99, 132, 0.7)',
|
|
|
|
|
borderColor: 'rgba(255, 99, 132, 1)',
|
|
|
|
|
fill: true,
|
|
|
|
|
order: 3
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
label: '4-10 seeders',
|
|
|
|
|
data: data.map(d => d.mediumSeedersTB),
|
|
|
|
|
backgroundColor: 'rgba(255, 206, 86, 0.7)',
|
|
|
|
|
borderColor: 'rgba(255, 206, 86, 1)',
|
|
|
|
|
fill: true,
|
|
|
|
|
order: 2
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
label: '>10 seeders',
|
|
|
|
|
data: data.map(d => d.highSeedersTB),
|
|
|
|
|
backgroundColor: 'rgba(75, 192, 192, 0.7)',
|
|
|
|
|
borderColor: 'rgba(75, 192, 192, 1)',
|
|
|
|
|
fill: true,
|
|
|
|
|
order: 1
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
},
|
|
|
|
|
options: {
|
|
|
|
|
responsive: true,
|
|
|
|
|
scales: {
|
|
|
|
|
x: {
|
|
|
|
|
type: 'category',
|
|
|
|
|
title: {
|
|
|
|
|
display: true,
|
|
|
|
|
text: 'Date'
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
y: {
|
|
|
|
|
stacked: true,
|
|
|
|
|
title: {
|
|
|
|
|
display: true,
|
|
|
|
|
text: 'Terabytes'
|
|
|
|
|
},
|
|
|
|
|
ticks: {
|
|
|
|
|
callback: function(value) {
|
|
|
|
|
return value + 'TB';
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
plugins: {
|
|
|
|
|
title: {
|
|
|
|
|
display: true,
|
|
|
|
|
text: 'Daily Seeder Stats'
|
|
|
|
|
},
|
|
|
|
|
legend: {
|
|
|
|
|
display: true,
|
|
|
|
|
position: 'top'
|
|
|
|
|
},
|
|
|
|
|
tooltip: {
|
|
|
|
|
mode: 'index',
|
|
|
|
|
intersect: false
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
interaction: {
|
|
|
|
|
mode: 'nearest',
|
|
|
|
|
axis: 'x',
|
|
|
|
|
intersect: false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
createChart();
|
|
|
|
|
</script>
|
|
|
|
|
<form action="/generate-torrent-list" method="POST" target="_blank">
|
|
|
|
|
<div class="row">
|
|
|
|
|
<div class="four columns">
|
|
|
|
|
<label for="maxTB">Max TB:</label>
|
|
|
|
|
<input type="number" id="maxTB" name="maxTB" step="0.1" min="0" required>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="four columns">
|
|
|
|
|
<label for="listType">Type:</label>
|
|
|
|
|
<select id="listType" name="listType" required>
|
|
|
|
|
<option value="magnet">Magnet links</option>
|
|
|
|
|
<option value="torrent">Torrent files</option>
|
|
|
|
|
<option value="both">Both</option>
|
|
|
|
|
</select>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="four columns">
|
|
|
|
|
<label for="format">Format:</label>
|
|
|
|
|
<select id="format" name="format" required>
|
|
|
|
|
<option value="json">JSON</option>
|
|
|
|
|
<option value="xml">XML</option>
|
|
|
|
|
<option value="txt">TXT</option>
|
|
|
|
|
</select>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="row">
|
|
|
|
|
<div class="twelve columns" style="text-align: right;">
|
|
|
|
|
<input class="button-primary" type="submit" value="Generate">
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</form>
|
|
|
|
|
{{range .}}
|
|
|
|
|
<h2>{{.TopLevelGroupName}}</h2>
|
|
|
|
|
<h3>{{.GroupName}}</h3>
|
|
|
|
|
<table>
|
|
|
|
|
<tr>
|
|
|
|
|
<th>Torrent Name</th>
|
|
|
|
|
<th>Date Added</th>
|
|
|
|
|
<th>Data Size</th>
|
|
|
|
|
<th>Num Files</th>
|
|
|
|
|
<th>Type</th>
|
|
|
|
|
<th>Status</th>
|
|
|
|
|
<th>Magnet Link</th>
|
|
|
|
|
<th>Torrent Link</th>
|
|
|
|
|
</tr>
|
|
|
|
|
{{range .Torrents}}
|
|
|
|
|
<tr>
|
|
|
|
|
<td class="{{if .Obsolete}}obsolete{{end}}"><a href="/stats/{{.BTIH}}">{{.DisplayName}}</a></td>
|
|
|
|
|
<td>{{.FormattedAddedDate}}</td>
|
|
|
|
|
<td>{{.FormattedDataSize}}</td>
|
|
|
|
|
<td>{{.NumFiles}}</td>
|
|
|
|
|
<td>{{.MetadataLabel}}</td>
|
|
|
|
|
<td>{{.StatusLabel}}</td>
|
|
|
|
|
<td><a href="{{.MagnetLink | urlsafe}}">Magnet</a></td>
|
|
|
|
|
<td>
|
|
|
|
|
{{if torrentsEnabled}}
|
|
|
|
|
<a href="/assets/torrents/{{.BTIH}}.torrent">Torrent</a>
|
|
|
|
|
{{else}}
|
|
|
|
|
<a href="{{.URL}}">Torrent</a>
|
|
|
|
|
{{end}}
|
|
|
|
|
</td>
|
|
|
|
|
</tr>
|
|
|
|
|
{{end}}
|
|
|
|
|
</table>
|
|
|
|
|
{{if gt .TotalCount 20}}
|
|
|
|
|
<a href="/full/{{.GroupName}}">View full list</a>
|
|
|
|
|
{{end}}
|
|
|
|
|
{{end}}
|
|
|
|
|
` + footer,
|
2024-08-15 12:01:00 +02:00
|
|
|
|
|
|
|
|
|
"fullList": header + `
|
2024-08-20 11:22:29 +02:00
|
|
|
|
<h1>{{.GroupName}} - Full List</h1>
|
|
|
|
|
<table>
|
|
|
|
|
<tr>
|
|
|
|
|
<th>Torrent Name</th>
|
|
|
|
|
<th>Date Added</th>
|
|
|
|
|
<th>Data Size</th>
|
|
|
|
|
<th>Num Files</th>
|
|
|
|
|
<th>Type</th>
|
|
|
|
|
<th>Status</th>
|
|
|
|
|
<th>Magnet Link</th>
|
|
|
|
|
<th>Torrent Link</th>
|
|
|
|
|
</tr>
|
|
|
|
|
{{range .Torrents}}
|
|
|
|
|
<tr>
|
|
|
|
|
<td class="{{if .Obsolete}}obsolete{{end}}"><a href="/stats/{{.BTIH}}">{{.DisplayName}}</a></td>
|
|
|
|
|
<td>{{.FormattedAddedDate}}</td>
|
|
|
|
|
<td>{{.FormattedDataSize}}</td>
|
|
|
|
|
<td>{{.NumFiles}}</td>
|
|
|
|
|
<td>{{.MetadataLabel}}</td>
|
|
|
|
|
<td>{{.StatusLabel}}</td>
|
|
|
|
|
<td><a href="{{.MagnetLink | urlsafe}}">Magnet</a></td>
|
|
|
|
|
<td>
|
2024-08-20 13:28:28 +02:00
|
|
|
|
{{if torrentsEnabled}}
|
2024-08-20 11:22:29 +02:00
|
|
|
|
<a href="/assets/torrents/{{.BTIH}}.torrent">Torrent</a>
|
|
|
|
|
{{else}}
|
|
|
|
|
<a href="{{.URL}}">Torrent</a>
|
|
|
|
|
{{end}}
|
|
|
|
|
</td>
|
|
|
|
|
</tr>
|
|
|
|
|
{{end}}
|
|
|
|
|
</table>
|
|
|
|
|
<a href="/">Back to main page</a>
|
|
|
|
|
` + footer,
|
2024-08-15 12:01:00 +02:00
|
|
|
|
|
|
|
|
|
"stats": header + `
|
2024-08-20 11:22:29 +02:00
|
|
|
|
<h1>Torrent Stats: {{.DisplayName}}</h1>
|
|
|
|
|
<table>
|
|
|
|
|
<tr>
|
|
|
|
|
<th>Property</th>
|
|
|
|
|
<th>Value</th>
|
|
|
|
|
</tr>
|
|
|
|
|
<tr><td>Added To Torrents List</td><td>{{.AddedToTorrentsList}}</td></tr>
|
|
|
|
|
<tr><td>Seeders</td><td>{{.FormattedSeeders}}</td></tr>
|
|
|
|
|
<tr><td>Leechers</td><td>{{.FormattedLeechers}}</td></tr>
|
|
|
|
|
<tr><td>Completed</td><td>{{.Completed}}</td></tr>
|
|
|
|
|
<tr><td>Size</td><td>{{.FormattedDataSize}}</td></tr>
|
|
|
|
|
<tr><td>Magnet Link</td><td><a href="{{.MagnetLink | urlsafe}}">Magnet</a></td></tr>
|
|
|
|
|
<tr><td>Torrent Link</td><td>
|
2024-08-20 13:28:28 +02:00
|
|
|
|
{{if torrentsEnabled}}
|
|
|
|
|
<a href="/assets/torrents/{{.BTIH}}.torrent">Torrent</a>
|
|
|
|
|
{{else}}
|
|
|
|
|
<a href="{{.URL}}">Torrent</a>
|
|
|
|
|
{{end}}
|
2024-08-20 11:22:29 +02:00
|
|
|
|
</td></tr>
|
|
|
|
|
<tr><td>Last Update</td><td>{{.StatsScrapedAt}}</td></tr>
|
|
|
|
|
</table>
|
2024-08-15 12:01:00 +02:00
|
|
|
|
<h2>Seeder History</h2>
|
2024-08-20 11:22:29 +02:00
|
|
|
|
<canvas id="seederChart" width="800" height="400"></canvas>
|
|
|
|
|
|
|
|
|
|
<script>
|
|
|
|
|
var ctx = document.getElementById('seederChart').getContext('2d');
|
|
|
|
|
var seederData = {{.SeederStats | json}};
|
|
|
|
|
|
|
|
|
|
// Find the maximum value in the data
|
|
|
|
|
var maxYValue = Math.max(...seederData.map(d => d.seeders));
|
|
|
|
|
|
|
|
|
|
// Calculate the maximum Y value for the scale, with padding of 10
|
|
|
|
|
var suggestedMax = Math.ceil(maxYValue / 10) * 10 + 10;
|
|
|
|
|
|
|
|
|
|
new Chart(ctx, {
|
|
|
|
|
type: 'line',
|
|
|
|
|
data: {
|
|
|
|
|
labels: seederData.map(d => d.date),
|
|
|
|
|
datasets: [{
|
|
|
|
|
label: 'Average Daily Seeders',
|
|
|
|
|
data: seederData.map(d => d.seeders),
|
|
|
|
|
borderColor: 'rgb(75, 192, 192)',
|
|
|
|
|
tension: 0.1
|
|
|
|
|
}]
|
|
|
|
|
},
|
|
|
|
|
options: {
|
|
|
|
|
responsive: true,
|
|
|
|
|
scales: {
|
|
|
|
|
x: {
|
|
|
|
|
type: 'category',
|
|
|
|
|
title: {
|
|
|
|
|
display: true,
|
|
|
|
|
text: 'Date'
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
y: {
|
|
|
|
|
beginAtZero: true,
|
|
|
|
|
suggestedMin: 0,
|
|
|
|
|
suggestedMax: suggestedMax, // Set the suggested maximum value
|
|
|
|
|
ticks: {
|
|
|
|
|
stepSize: 1, // Ensure whole numbers
|
|
|
|
|
callback: function(value) {
|
|
|
|
|
return Number.isInteger(value) ? value : null;
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
title: {
|
|
|
|
|
display: true,
|
|
|
|
|
text: 'Average Seeders'
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
plugins: {
|
|
|
|
|
title: {
|
|
|
|
|
display: true,
|
|
|
|
|
text: 'Daily Average Seeder Count'
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
</script>
|
|
|
|
|
<a href="/">Back to main page</a>
|
|
|
|
|
` + footer,
|
2024-08-15 12:01:00 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return templates[name]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func formatDate(dateStr string) string {
|
|
|
|
|
if len(dateStr) >= 10 {
|
|
|
|
|
return dateStr[:10]
|
|
|
|
|
}
|
|
|
|
|
return dateStr
|
|
|
|
|
}
|
|
|
|
|
func formatTimeSince(dateStr string) string {
|
|
|
|
|
// First try parsing the extended format with fractional seconds and time zone offset
|
|
|
|
|
layoutWithTimezone := "2006-01-02T15:04:05.999999999Z07:00"
|
|
|
|
|
t, err := time.Parse(layoutWithTimezone, dateStr)
|
|
|
|
|
if err != nil {
|
|
|
|
|
// If it fails, fall back to the standard format without timezone offset
|
|
|
|
|
layoutWithoutTimezone := "2006-01-02T15:04:05Z"
|
|
|
|
|
t, err = time.Parse(layoutWithoutTimezone, dateStr)
|
|
|
|
|
if err != nil {
|
|
|
|
|
// If both parsings fail, return the original string
|
|
|
|
|
return dateStr
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
now := time.Now()
|
|
|
|
|
diff := now.Sub(t)
|
|
|
|
|
|
|
|
|
|
var unit string
|
|
|
|
|
var value int64
|
|
|
|
|
switch {
|
|
|
|
|
case diff < time.Minute:
|
|
|
|
|
unit = "s"
|
|
|
|
|
value = int64(diff.Seconds())
|
|
|
|
|
case diff < time.Hour:
|
|
|
|
|
unit = "m"
|
|
|
|
|
value = int64(diff.Minutes())
|
|
|
|
|
case diff < 24*time.Hour:
|
|
|
|
|
unit = "h"
|
|
|
|
|
value = int64(diff.Hours())
|
|
|
|
|
default:
|
|
|
|
|
unit = "d"
|
|
|
|
|
value = int64(diff.Hours() / 24)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return fmt.Sprintf("%d%s", value, unit)
|
|
|
|
|
}
|
|
|
|
|
func formatDataSize(size int64) string {
|
|
|
|
|
sizeMB := float64(size) / (1024 * 1024)
|
|
|
|
|
if sizeMB > 1024 {
|
|
|
|
|
sizeGB := sizeMB / 1024
|
|
|
|
|
if sizeGB > 1024 {
|
|
|
|
|
return fmt.Sprintf("%.2f TB", sizeGB/1024)
|
|
|
|
|
}
|
|
|
|
|
return fmt.Sprintf("%.2f GB", sizeGB)
|
|
|
|
|
}
|
|
|
|
|
return fmt.Sprintf("%.2f MB", sizeMB)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func formatStatus(currentlySeeding bool, seeders, leechers int, scrapedAtStr string) template.HTML {
|
|
|
|
|
status := "⚫"
|
|
|
|
|
switch {
|
|
|
|
|
case seeders < 2:
|
|
|
|
|
status = "🔴"
|
|
|
|
|
case seeders > 10:
|
|
|
|
|
status = "🟢"
|
|
|
|
|
default:
|
|
|
|
|
status = "🟡"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
archived := ""
|
|
|
|
|
if currentlySeeding {
|
|
|
|
|
archived = "<span class='aaseeded' title=\"Seeded by Anna's Archive\">(📓)</span>"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
timeSince := formatTimeSince(scrapedAtStr)
|
|
|
|
|
return template.HTML(fmt.Sprintf("%s %d seed / %d leech %s <span class='timeSince'>%s</span>", status, seeders, leechers, archived, timeSince))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func formatNumber(n int) string {
|
|
|
|
|
if n < 1000 {
|
|
|
|
|
return fmt.Sprintf("%d", n)
|
|
|
|
|
}
|
|
|
|
|
return fmt.Sprintf("%dK", n/1000)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func generateTorrentList(maxTB float64, listType string) ([]map[string]interface{}, error) {
|
|
|
|
|
maxBytes := int64(maxTB * 1024 * 1024 * 1024 * 1024) // Convert TB to bytes
|
|
|
|
|
var totalBytes int64
|
|
|
|
|
var result []map[string]interface{}
|
|
|
|
|
|
2024-08-20 11:22:29 +02:00
|
|
|
|
err := withReadTx(func(tx *sql.Tx) error {
|
|
|
|
|
rows, err := tx.Query(`
|
|
|
|
|
SELECT btih, magnet_link, url, data_size, seeders
|
|
|
|
|
FROM torrents
|
|
|
|
|
WHERE obsolete = 0 AND embargo = 0 AND seeders > 0
|
|
|
|
|
ORDER BY seeders ASC, data_size DESC
|
|
|
|
|
`)
|
2024-08-15 12:01:00 +02:00
|
|
|
|
if err != nil {
|
2024-08-20 11:22:29 +02:00
|
|
|
|
return fmt.Errorf("error querying database: %w", err)
|
2024-08-15 12:01:00 +02:00
|
|
|
|
}
|
2024-08-20 11:22:29 +02:00
|
|
|
|
defer rows.Close()
|
2024-08-15 12:01:00 +02:00
|
|
|
|
|
2024-08-20 11:22:29 +02:00
|
|
|
|
for rows.Next() {
|
|
|
|
|
var btih, magnetLink, url string
|
|
|
|
|
var dataSize, seeders int64
|
|
|
|
|
err := rows.Scan(&btih, &magnetLink, &url, &dataSize, &seeders)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("error scanning row: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if totalBytes+dataSize > maxBytes {
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
torrent := map[string]interface{}{
|
|
|
|
|
"btih": btih,
|
|
|
|
|
"data_size": dataSize,
|
|
|
|
|
"seeders": seeders,
|
|
|
|
|
}
|
2024-08-15 12:01:00 +02:00
|
|
|
|
|
2024-08-20 11:22:29 +02:00
|
|
|
|
switch listType {
|
|
|
|
|
case "magnet":
|
|
|
|
|
torrent["magnet_link"] = magnetLink
|
|
|
|
|
case "torrent":
|
|
|
|
|
torrent["torrent_url"] = url
|
|
|
|
|
case "both":
|
|
|
|
|
torrent["magnet_link"] = magnetLink
|
|
|
|
|
torrent["torrent_url"] = url
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
result = append(result, torrent)
|
|
|
|
|
totalBytes += dataSize
|
2024-08-15 12:01:00 +02:00
|
|
|
|
}
|
|
|
|
|
|
2024-08-20 11:22:29 +02:00
|
|
|
|
if err := rows.Err(); err != nil {
|
|
|
|
|
return fmt.Errorf("error after iterating rows: %w", err)
|
2024-08-15 12:01:00 +02:00
|
|
|
|
}
|
|
|
|
|
|
2024-08-20 11:22:29 +02:00
|
|
|
|
return nil
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("error generating torrent list: %w", err)
|
2024-08-15 12:01:00 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return result, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// convertToXML converts the torrent list to XML format
|
|
|
|
|
func convertToXML(torrents []map[string]interface{}) ([]byte, error) {
|
|
|
|
|
type Torrent struct {
|
|
|
|
|
BTIH string `xml:"btih"`
|
|
|
|
|
DataSize int64 `xml:"data_size"`
|
|
|
|
|
Seeders int64 `xml:"seeders"`
|
|
|
|
|
MagnetLink string `xml:"magnet_link,omitempty"`
|
|
|
|
|
TorrentURL string `xml:"torrent_url,omitempty"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type Torrents struct {
|
|
|
|
|
XMLName xml.Name `xml:"torrents"`
|
|
|
|
|
List []Torrent `xml:"torrent"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var xmlTorrents []Torrent
|
|
|
|
|
for _, t := range torrents {
|
|
|
|
|
torrent := Torrent{
|
|
|
|
|
BTIH: getStringValue(t, "btih"),
|
|
|
|
|
DataSize: getInt64Value(t, "data_size"),
|
|
|
|
|
Seeders: getInt64Value(t, "seeders"),
|
|
|
|
|
MagnetLink: getStringValue(t, "magnet_link"),
|
|
|
|
|
TorrentURL: getStringValue(t, "torrent_url"),
|
|
|
|
|
}
|
|
|
|
|
xmlTorrents = append(xmlTorrents, torrent)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
output, err := xml.MarshalIndent(Torrents{List: xmlTorrents}, "", " ")
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return append([]byte(xml.Header), output...), nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Helper function to safely get a string value from the map
|
|
|
|
|
func getStringValue(m map[string]interface{}, key string) string {
|
|
|
|
|
if value, ok := m[key].(string); ok {
|
|
|
|
|
return value
|
|
|
|
|
}
|
|
|
|
|
return ""
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Helper function to safely get an int64 value from the map
|
|
|
|
|
func getInt64Value(m map[string]interface{}, key string) int64 {
|
|
|
|
|
if value, ok := m[key].(int64); ok {
|
|
|
|
|
return value
|
|
|
|
|
}
|
|
|
|
|
return 0
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// convertToTXT converts the torrent list to plain text format
|
|
|
|
|
func convertToTXT(torrents []map[string]interface{}) string {
|
|
|
|
|
var buffer bytes.Buffer
|
|
|
|
|
for _, t := range torrents {
|
|
|
|
|
|
|
|
|
|
if magnetLink, ok := t["magnet_link"]; ok {
|
|
|
|
|
buffer.WriteString(fmt.Sprintf("%s\n", magnetLink.(string)))
|
|
|
|
|
}
|
|
|
|
|
if torrentURL, ok := t["torrent_url"]; ok {
|
|
|
|
|
buffer.WriteString(fmt.Sprintf("%s\n", torrentURL.(string)))
|
|
|
|
|
}
|
|
|
|
|
buffer.WriteString("\n")
|
|
|
|
|
}
|
|
|
|
|
return buffer.String()
|
|
|
|
|
}
|