418 lines
10 KiB
Go
418 lines
10 KiB
Go
package main
|
|
|
|
import (
|
|
"embed"
|
|
_ "embed"
|
|
"encoding/json"
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/autobrr/go-qbittorrent"
|
|
"github.com/jmoiron/sqlx"
|
|
"github.com/joho/godotenv"
|
|
"github.com/kylesanderson/go-jackett"
|
|
_ "github.com/mattn/go-sqlite3"
|
|
)
|
|
|
|
//go:embed web/dist/*
|
|
var webOutput embed.FS
|
|
|
|
var (
|
|
hostname = flag.String("H", "", "server http address")
|
|
port = flag.Int("p", 5000, "server http port")
|
|
)
|
|
|
|
func sendJSON(w http.ResponseWriter, data any, status int) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
if err := json.NewEncoder(w).Encode(data); err != nil {
|
|
http.Error(w, err.Error(), 500)
|
|
}
|
|
}
|
|
|
|
type Torrent struct {
|
|
ID int64 `json:"id" db:"id"`
|
|
Title string `json:"title" db:"title"`
|
|
Guid string `json:"guid" db:"guid"`
|
|
Indexer string `json:"indexer" db:"indexer"`
|
|
Pubdate time.Time `json:"pubdate" db:"pubdate"`
|
|
Size int `json:"size" db:"size"`
|
|
DownloadURL string `json:"downloadUrl" db:"download_url"`
|
|
Seeders int `json:"seeders" db:"seeders"`
|
|
Peers int `json:"peers" db:"peers"`
|
|
Category int `json:"category" db:"category"`
|
|
Hash *string `json:"hash" db:"hash"`
|
|
Downloaded bool `json:"downloaded"`
|
|
CreatedAt time.Time `json:"createdAt" db:"created_at"`
|
|
ItemID int `json:"itemId" db:"item_id"`
|
|
}
|
|
|
|
type Item struct {
|
|
ID int64 `json:"id" db:"id"`
|
|
Query string `json:"query" db:"query"`
|
|
Category int `json:"category" db:"category"`
|
|
CreatedAt time.Time `json:"createdAt" db:"created_at"`
|
|
Torrents []Torrent `json:"torrents,omitempty"`
|
|
}
|
|
|
|
func getItems(db *sqlx.DB) ([]*Item, error) {
|
|
rows, err := db.Queryx("SELECT * FROM items")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("couldn't query db: %v", err)
|
|
}
|
|
|
|
items := []*Item{}
|
|
for rows.Next() {
|
|
item := &Item{}
|
|
if err := rows.StructScan(&item); err != nil {
|
|
continue
|
|
}
|
|
items = append(items, item)
|
|
}
|
|
|
|
return items, nil
|
|
}
|
|
|
|
func getItemTorrents(db *sqlx.DB, itemId int64) ([]*Torrent, error) {
|
|
rows, err := db.Queryx("SELECT * FROM torrents WHERE item_id = ?", itemId)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("couldn't query db: %v", err)
|
|
}
|
|
|
|
torrents := []*Torrent{}
|
|
for rows.Next() {
|
|
torrent := &Torrent{}
|
|
if err := rows.StructScan(&torrent); err != nil {
|
|
continue
|
|
}
|
|
torrents = append(torrents, torrent)
|
|
}
|
|
|
|
return torrents, nil
|
|
}
|
|
|
|
func getTorrentById(db *sqlx.DB, torrentId int64) (Torrent, error) {
|
|
var torrent Torrent
|
|
|
|
row := db.QueryRowx("SELECT * FROM torrents WHERE id = ?", torrentId)
|
|
if err := row.StructScan(&torrent); err != nil {
|
|
return torrent, fmt.Errorf("couldn't query torrent: %v", err)
|
|
}
|
|
|
|
return torrent, nil
|
|
}
|
|
|
|
type JackettTorrent struct {
|
|
Seeders string
|
|
Peers string
|
|
}
|
|
|
|
func main() {
|
|
flag.Parse()
|
|
godotenv.Load()
|
|
|
|
jackettHost := os.Getenv("JACKETT_HOST")
|
|
jackettApiKey := os.Getenv("JACKETT_API_KEY")
|
|
if jackettHost == "" || jackettApiKey == "" {
|
|
log.Fatal("no JACKETT_HOST or JACKETT_API_KEY env found")
|
|
}
|
|
|
|
jackettClient := jackett.NewClient(jackett.Config{
|
|
Host: jackettHost,
|
|
APIKey: jackettApiKey,
|
|
})
|
|
|
|
qbittorrentHost := os.Getenv("QBITTORRENT_HOST")
|
|
qbittorrentUsername := os.Getenv("QBITTORRENT_USERNAME")
|
|
qbittorrentPassword := os.Getenv("QBITTORRENT_PASSWORD")
|
|
if qbittorrentHost == "" || qbittorrentUsername == "" || qbittorrentPassword == "" {
|
|
log.Fatal("no QBITTORRENT_HOST, QBITTORRENT_USERNAME or QBITTORRENT_PASSWORD env found")
|
|
}
|
|
|
|
torrentClient := qbittorrent.NewClient(qbittorrent.Config{
|
|
Host: qbittorrentHost,
|
|
Username: qbittorrentUsername,
|
|
Password: qbittorrentPassword,
|
|
})
|
|
|
|
dbPath := os.Getenv("DB_PATH")
|
|
if dbPath == "" {
|
|
dbPath = "./sqlite.db"
|
|
}
|
|
|
|
db, err := sqlx.Connect("sqlite3", dbPath)
|
|
if err != nil {
|
|
log.Fatalf("failed to connect to db: %v\n", err)
|
|
}
|
|
defer db.Close()
|
|
|
|
db.MustExec(`CREATE TABLE IF NOT EXISTS items (
|
|
id integer primary key,
|
|
query varchar not null,
|
|
category integer not null,
|
|
created_at datetime default CURRENT_TIMESTAMP
|
|
)`)
|
|
|
|
db.MustExec(`CREATE TABLE IF NOT EXISTS torrents (
|
|
id integer primary key,
|
|
title varchar not null,
|
|
guid varchar not null unique,
|
|
indexer varchar not null,
|
|
pubdate datetime not null,
|
|
size integer not null,
|
|
download_url varchar,
|
|
seeders integer not null,
|
|
peers integer not null,
|
|
category integer not null,
|
|
hash varchar,
|
|
created_at datetime default CURRENT_TIMESTAMP,
|
|
item_id integer not null,
|
|
FOREIGN KEY (item_id) REFERENCES users(id)
|
|
)`)
|
|
|
|
go func(db *sqlx.DB) {
|
|
for {
|
|
items, _ := getItems(db)
|
|
for _, item := range items {
|
|
results, err := jackettClient.TVSearch(jackett.TVSearchOptions{
|
|
Query: item.Query,
|
|
})
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
for _, torrent := range results.Channel.Item {
|
|
size, _ := strconv.Atoi(torrent.Size)
|
|
category, _ := strconv.Atoi(torrent.Category[0])
|
|
pubDate, _ := time.Parse(time.RFC1123Z, torrent.PubDate)
|
|
|
|
seeders := 0
|
|
peers := 0
|
|
for _, attr := range torrent.Attr {
|
|
if attr.Name == "seeders" {
|
|
seeders, _ = strconv.Atoi(attr.Value)
|
|
}
|
|
if attr.Name == "peers" {
|
|
peers, _ = strconv.Atoi(attr.Value)
|
|
}
|
|
}
|
|
|
|
_, err := db.NamedExec("INSERT INTO torrents (title, guid, indexer, pubdate, size, download_url, seeders, peers, category, item_id) VALUES (:title, :guid, :indexer, :pubdate, :size, :download_url, :seeders, :peers, :category, :item_id)", map[string]any{
|
|
"title": torrent.Title,
|
|
"guid": torrent.Guid,
|
|
"indexer": torrent.Jackettindexer.ID,
|
|
"pubdate": pubDate,
|
|
"size": size,
|
|
"download_url": torrent.Link,
|
|
"seeders": seeders,
|
|
"peers": peers,
|
|
"category": category,
|
|
"item_id": item.ID,
|
|
})
|
|
if err != nil {
|
|
db.NamedExec("UPDATE torrents SET seeders = :seeders, peers = :peers WHERE id = :id", map[string]any{
|
|
"seeders": seeders,
|
|
"peers": peers,
|
|
"id": item.ID,
|
|
})
|
|
continue
|
|
}
|
|
}
|
|
}
|
|
|
|
time.Sleep(10 * time.Second)
|
|
}
|
|
}(db)
|
|
|
|
mux := http.NewServeMux()
|
|
|
|
mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
|
|
path := r.URL.Path
|
|
if path == "/" {
|
|
path = "/index.html"
|
|
}
|
|
|
|
filePath := "web/dist" + path
|
|
|
|
_, err := webOutput.Open(filePath)
|
|
if err != nil {
|
|
filePath = "web/dist/index.html"
|
|
}
|
|
|
|
http.ServeFileFS(w, r, webOutput, filePath)
|
|
fileName := r.URL.Path
|
|
if fileName == "/" {
|
|
fileName = "/web/index.html"
|
|
}
|
|
})
|
|
|
|
mux.HandleFunc("POST /api/items", func(w http.ResponseWriter, r *http.Request) {
|
|
var body struct {
|
|
Query string
|
|
Category int
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
http.Error(w, err.Error(), 500)
|
|
return
|
|
}
|
|
|
|
if body.Query == "" {
|
|
http.Error(w, "invalid request, no query provided", 400)
|
|
return
|
|
}
|
|
|
|
if body.Category == 0 {
|
|
body.Category = 5000
|
|
}
|
|
|
|
res, err := db.NamedExec("INSERT INTO items (query, category) VALUES (:query, :category)", map[string]any{
|
|
"query": body.Query,
|
|
"category": body.Category,
|
|
})
|
|
if err != nil {
|
|
http.Error(w, err.Error(), 500)
|
|
return
|
|
}
|
|
|
|
id, err := res.LastInsertId()
|
|
if err != nil {
|
|
http.Error(w, err.Error(), 500)
|
|
return
|
|
}
|
|
|
|
sendJSON(w, struct {
|
|
Id int64 `json:"id"`
|
|
}{id}, 201)
|
|
|
|
})
|
|
|
|
mux.HandleFunc("GET /api/items", func(w http.ResponseWriter, r *http.Request) {
|
|
items, err := getItems(db)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), 404)
|
|
return
|
|
}
|
|
|
|
sendJSON(w, items, 200)
|
|
})
|
|
|
|
mux.HandleFunc("GET /api/items/{id}/torrents", func(w http.ResponseWriter, r *http.Request) {
|
|
itemId, err := strconv.Atoi(r.PathValue("id"))
|
|
if err != nil {
|
|
http.Error(w, err.Error(), 500)
|
|
return
|
|
}
|
|
|
|
torrents, err := getItemTorrents(db, int64(itemId))
|
|
if err != nil {
|
|
http.Error(w, err.Error(), 500)
|
|
return
|
|
}
|
|
|
|
for _, torrent := range torrents {
|
|
if torrent.Hash != nil {
|
|
properties, err := torrentClient.GetTorrentProperties(*torrent.Hash)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
torrent.Downloaded = properties.CompletionDate > -1
|
|
}
|
|
}
|
|
|
|
sendJSON(w, torrents, 200)
|
|
})
|
|
|
|
mux.HandleFunc("POST /api/torrents/{id}/download", func(w http.ResponseWriter, r *http.Request) {
|
|
torrentId, err := strconv.Atoi(r.PathValue("id"))
|
|
if err != nil {
|
|
http.Error(w, err.Error(), 500)
|
|
return
|
|
}
|
|
|
|
torrent, err := getTorrentById(db, int64(torrentId))
|
|
if err != nil {
|
|
http.Error(w, err.Error(), 404)
|
|
return
|
|
}
|
|
|
|
downloadUrl := torrent.DownloadURL
|
|
if torrent.Indexer == "limetorrents" {
|
|
downloadUrl, err = getLimeTorrentsDownloadUrl(torrent.Guid)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), 500)
|
|
return
|
|
}
|
|
}
|
|
|
|
resp, err := http.Get(downloadUrl)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), 500)
|
|
return
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
data, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), 500)
|
|
return
|
|
}
|
|
|
|
infoHash, err := getTorrentInfoHash(data)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), 500)
|
|
return
|
|
}
|
|
|
|
if _, err := db.NamedExec("UPDATE torrents SET hash = :hash WHERE id = :id", map[string]any{
|
|
"hash": infoHash,
|
|
"id": torrent.ID,
|
|
}); err != nil {
|
|
http.Error(w, err.Error(), 500)
|
|
return
|
|
}
|
|
|
|
if err := torrentClient.AddTorrentFromMemory(data, map[string]string{}); err != nil {
|
|
http.Error(w, err.Error(), 500)
|
|
return
|
|
}
|
|
|
|
sendJSON(w, struct {
|
|
Ok bool `json:"ok"`
|
|
}{true}, 200)
|
|
})
|
|
|
|
mux.HandleFunc("DELETE /api/items/{id}", func(w http.ResponseWriter, r *http.Request) {
|
|
itemId, err := strconv.Atoi(r.PathValue("id"))
|
|
if err != nil {
|
|
http.Error(w, err.Error(), 500)
|
|
return
|
|
}
|
|
|
|
if _, err := db.Exec("DELETE FROM items WHERE id = ?", itemId); err != nil {
|
|
http.Error(w, err.Error(), 404)
|
|
return
|
|
}
|
|
|
|
if _, err := db.Exec("DELETE FROM torrents WHERE item_id = ?", itemId); err != nil {
|
|
http.Error(w, err.Error(), 404)
|
|
return
|
|
}
|
|
|
|
sendJSON(w, struct {
|
|
Ok bool `json:"ok"`
|
|
}{true}, 200)
|
|
})
|
|
|
|
addr := fmt.Sprintf("%s:%d", *hostname, *port)
|
|
fmt.Printf("starting http server on %s\n", addr)
|
|
if err := http.ListenAndServe(addr, mux); err != nil {
|
|
log.Fatalf("failed to start http server: %v\n", err)
|
|
}
|
|
}
|